Skip to main content
RunBook Academy

← All break/fix scenarios in Secrets, PKI & Certificates

intermediatesecret-leak~40 min

The production database password is readable in remote state

Reported symptoms

  • At 14:20 UTC an engineer preparing for an audit lists who can read the remote state object and finds 41 identities with read access to the production state bucket
  • Pulling that state and searching it returns the live production database password in plain text, exactly as an application would use it
  • The variable carrying that password has been declared sensitive since the module was written 14 months ago, and every plan since has displayed the value as redacted
  • Nobody can name a breach: the database authentication log shows no logins from outside the expected address ranges, which the channel initially treats as evidence that nothing happened
  • Bucket versioning is enabled and 96 previous versions of the state object exist, each of which contains the same password
  • A deployment pipeline step prints a connection string built from a Terraform output, and those job logs are retained for 400 days

Evidence

  • · A state pull followed by a search for the password returns a match inside the database resource attributes, in clear text, with no encoding or redaction of any kind
  • · The variable block declares the sensitive argument as true, and the plan output for the same resource displays the attribute as a sensitive value, so both observations are correct at the same time
  • · Listing object versions on the state key returns 96 entries spanning 14 months, and a pull of the oldest one contains the same password as the newest
  • · The bucket policy and the identity policies together grant read on the state prefix to a group whose membership is 41 principals, including contractors and read-only auditors
  • · The backend configuration does not set the encrypt option, so the state object relies entirely on whatever default the bucket applies
  • · Server access logging for the bucket was never enabled, so the only available record of who read the object is an identity log with a 90 day retention that shows 12 distinct principals
  • · The Terraform version in use supports both ephemeral values and write-only resource arguments, so the correct mechanism has been available for the entire life of this module
  • · The pipeline job log for the most recent deployment contains the password inside a printed connection string, and that log is inside its 400 day retention window
Diagnosis and resolutionclick to reveal

Root cause

The sensitive argument controls what Terraform prints. It does not control what Terraform stores. The documentation is unambiguous on this point: Terraform still records sensitive values in state, and anyone who can read the state data can read those values. Marking the variable sensitive redacted it from plan and apply output, which is exactly what the team observed for 14 months and reasonably read as protection. The password was supplied as an ordinary input variable to a managed resource, so it was written into state and into every plan file produced along the way, and object versioning preserved a copy of each. That is the first defect, and on its own it is a design flaw rather than an incident. The second defect is what made it reportable. Remote state was classified as infrastructure metadata rather than as a credential store, so read access was granted to a broad group on the reasoning that visibility into infrastructure is useful and harmless. Backend encryption was never enabled and object access logging was never turned on, so the population who could read the credential is large and the population who did read it cannot be determined beyond 90 days. A credential readable by two automation identities is a risk with a bounded blast radius. The same credential readable by 41 people, in 96 historical versions, plus a pipeline log retained for over a year, is a disclosure. The pipeline log matters independently: the output command prints sensitive values in clear regardless of how the variable is marked.

Remediation

Rotate first. The credential must be treated as compromised from the moment the state became broadly readable, not from the moment somebody noticed, and not only if evidence of misuse appears. Rotation is the only action that makes every existing copy harmless at once, and there are copies in 96 state versions, in plan files, in a pipeline log and possibly in 41 laptops. Before changing any access policy, capture the evidence: export the current membership of the group with read access, the identity log covering the retention window, and the list of object versions. Tightening the policy first destroys your ability to enumerate who had access, which is the question the review will ask. Then rotate the database password out of band, through the database and the secret manager rather than through Terraform, and confirm the old password no longer authenticates. Only then narrow read access on the state prefix to the automation identity plus a named break-glass group, enable backend encryption, and enable object access logging. Now change the configuration so the replacement never enters state at all: declare the password as an ephemeral value and pass it through a write-only argument with its companion version counter, which Terraform sends to the provider during the operation and never persists. Do not settle for adding the sensitive argument, because that is what was already in place. Do not hand-edit the state file to remove the string: the value survives in earlier versions and in plan files, and a botched edit costs you the resource. Finally, remove the output command from every logged pipeline step.

Verification

Verify from the systems that enforce the credential, not from the tool that manages them. Attempt to authenticate to the database with the old password from an allowed host and confirm the attempt is refused, which is the only proof that rotation actually took effect rather than being queued behind a pending apply. Read the database authentication log and confirm the application is connecting with the new credential and that the connection count has returned to its normal band, so the rotation did not silently strand a consumer. Then have a colleague with read access pull the current state and search it for the new password, and confirm they find nothing; asking someone else to look is deliberate, because the person who made the change is the person most likely to search for the wrong string. Confirm the write-only version counter incremented and that a fresh plan reports the credential attribute as changed without displaying a value. Check the newest pipeline job log and confirm it contains no connection string. Confirm the access policy now resolves to the small number of principals you intended, by enumerating effective access rather than by reading the policy document. Finally, confirm the bucket is now writing access log records, because a control that is configured but not producing evidence will be discovered the same way this one was.

Prevention

Scan state after every apply. A pipeline step that pulls the state and fails the build on high-entropy strings or on known credential attribute names costs an afternoon and would have caught this on the first apply, 14 months ago. Require write-only arguments for every credential attribute on every resource that supports them, and enforce it with a policy check that rejects a plan where a credential attribute is fed from anything other than an ephemeral source. Treat remote state as a credential store in the access model: limit read to at most three principals, review the list quarterly, enable backend encryption explicitly rather than relying on a bucket default, and enable object access logging with a retention at least as long as your credential rotation period. Rotate database credentials on a 90 day cycle with a page at 100 days, so that any exposure nobody detects has a bounded life; this incident is only alarming because the same password sat there for 14 months. Ban the output command in its raw and JSON forms from every logged step, because both print sensitive values in clear regardless of how the variable is marked. Best of all, stop having Terraform mint the credential: let the secret manager generate and own it, and have Terraform reference an identity rather than a password. A value the tool never sees cannot appear in its state.

Reported symptoms

The platform team manages the production database with Terraform. The module is 14 months old, has been applied 96 times, and has never caused an incident. Remote state lives in an object store bucket with versioning enabled.

At 14:20 UTC on a Thursday, an engineer building an evidence pack for an audit listed who could read the state prefix. The answer was 41 identities: the platform team, two contractor accounts, the read-only auditor role, and a group called platform-readers that had accreted members for a year.

Curious rather than alarmed, they pulled the state and searched it.

  • The production database password is in there, in clear text, exactly as an application would use it.
  • The variable that carries it has been marked sensitive since the module was written. Every plan for 14 months has printed the attribute as redacted, and screenshots of those plans are in three change tickets.
  • Versioning is on. There are 96 versions of the state object, and the oldest one contains the same password as the newest, because it has never been rotated.
  • The deploy pipeline prints a connection string built from a Terraform output. Those job logs are retained for 400 days.
  • The database authentication log shows no logins from outside the expected address ranges. Within ten minutes somebody has used that to argue this is a paperwork issue rather than an incident.

That last argument is the one to kill first, and the rest of this scenario is mostly about why.

Evidence provided

Read-only / Saferun by a member of platform-readers, with no elevated access
$ terraform state pull | jq -r '.resources[].instances[].attributes | keys[]' | sort -u

The declaration everyone had been relying on:

variable "db_master_password" {
  description = "Master password for the production database"
  type        = string
  sensitive   = true
}

And the resource it feeds:

resource "aws_db_instance" "prod" {
  identifier = "prod-appdb"
  username   = "appadmin"
  password   = var.db_master_password
}
Read-only / Safethe contrasting capture: what the team saw for 14 months
$ terraform plan -no-color | grep -i password
Read-only / Safethe number that turns one exposure into 96
$ aws s3api list-object-versions --bucket example-tfstate-prod --prefix rds/terraform.tfstate --query 'length(Versions)'
Read-only / Safethe negative result that decides how the incident must be classified
$ aws s3api get-bucket-logging --bucket example-tfstate-prod

The backend, which is the other half of the same oversight:

terraform {
  backend "s3" {
    bucket = "example-tfstate-prod"
    key    = "rds/terraform.tfstate"
    region = "eu-west-1"
  }
}
Read-only / Safethe copy nobody was looking for
$ grep -c 'postgres://' /var/log/ci/deploy-prod-4821.log

Work the evidence before reading on

Two observations are both true and appear to contradict each other, and the whole scenario turns on holding them at the same time.

  1. The plan redacts the value and the state contains it in clear. What exactly does the sensitive argument promise, and where would you have gone to find out before assuming?
  2. Versioning is on and the credential has never been rotated. How many distinct secrets are actually exposed here, and does deleting the current state version change that number?
  3. There is no evidence of misuse and no object access log. Which of those two facts is load-bearing when you classify this, and what does the absence of a log let you conclude?
  4. The pipeline log contains the same password by an entirely different route. Name that route, and say whether fixing the state fixes it.

Before continuing: decide whether this is an incident or a finding, and be able to defend the answer from the evidence rather than from the absence of a breach.

Root cause

The sensitive argument governs display, not storage

The documentation says it in one sentence: Terraform still records sensitive values in state, so anyone who can access the state data can access those values. The argument prevents Terraform from showing the value in CLI output and marks derived expressions as sensitive too. That is genuinely useful, and it is not confidentiality.

The team’s experience was consistent with their belief for 14 months because the only place they ever looked was the plan, and the plan is precisely the surface the argument covers.

A credential was passed as an ordinary input to a managed resource

Anything supplied to a resource attribute becomes part of that resource’s recorded state, and Terraform writes it to the state file and to plan files as a matter of design. That is not a leak in Terraform; it is how a tool that computes differences has to work, because it cannot diff a value it does not keep.

The mechanism that solves this properly arrived in two steps and has been available for the entire life of this module. Ephemeral values are available during the run and are omitted from state and plan files. Write-only arguments let a resource receive a temporary value that is never persisted, paired with a version counter so the operator can tell the provider that the value changed.

variable "db_master_password" {
  type      = string
  ephemeral = true
}

resource "aws_db_instance" "prod" {
  identifier          = "prod-appdb"
  username            = "appadmin"
  password_wo         = var.db_master_password
  password_wo_version = 2
}

Remote state was classified as metadata, and access followed that classification

Nobody granted 41 people access to a database password. They granted 41 people read access to infrastructure metadata, which sounds harmless and is a reasonable thing to want. The classification was wrong, and every downstream control inherited the mistake: no backend encryption, no access logging, no review of the group membership, and no scan of the object contents.

Resolution

  1. Classify it as an incident and start the clock at the moment the state became broadly readable, not at 14:20 when somebody noticed. The absence of evidence of misuse is not evidence of absence, particularly when the object access log that would have shown misuse was never enabled.
  2. Capture the evidence before changing any policy: the current membership of every group with read access, the identity log covering its full retention window, and the object version list. Tightening access first destroys the ability to answer who could have read this, which is the first question the review will ask.
  3. Rotate the database password out of band, through the database and the secret manager rather than through Terraform, and coordinate the cutover with the application owners. Rotation is the only action that makes all 96 copies harmless at once.
  4. Confirm the old password is refused before doing anything else. A rotation that is queued behind a pending apply, or applied to a replica rather than the primary, looks identical to a completed one until you test it.
  5. Now narrow read access on the state prefix to the automation identity plus a named break-glass group, enable backend encryption explicitly with the encrypt option rather than relying on a bucket default, and enable object access logging.
  6. Change the module so the replacement never enters state: declare the password as an ephemeral value and pass it through the write-only argument with its companion version counter. Adding sensitive = true is not a fix, because that is what was already there.
  7. Do not hand-edit or delete state versions. The value survives in earlier versions and in plan files, deletion risks the resource mapping, and rotation has already made the question moot.
  8. Remove the output command from every logged pipeline step and rebuild the connection string inside the application from the secret manager instead. Both the raw and the JSON forms print sensitive values in clear regardless of how the variable is marked.

Verification

  1. Attempt to authenticate to the database with the old password from an allowed host and confirm it is refused. This is the only proof that rotation took effect on the primary rather than being queued or applied elsewhere.
  2. Read the database authentication log and confirm the application is connecting with the new credential and the connection count has returned to its usual band. A rotation that strands one consumer is a second incident starting quietly.
  3. Ask a colleague with read access to pull the current state and search it for the new password, and confirm they find nothing. Have somebody other than the person who made the change do it, because that person will search for the string they expect.
  4. Confirm the write-only version counter incremented and that a fresh plan reports the credential attribute as changed without displaying a value. A plan that shows no change means the provider was never told the value moved.
  5. Check the newest pipeline job log end to end and confirm it contains no connection string and no credential-shaped output block.
  6. Enumerate effective access to the state prefix rather than reading the policy document, and confirm it resolves to the small set of principals you intended. Policy documents and effective access disagree more often than anyone expects.
  7. Confirm the bucket is now producing access log records. A control that is configured but emitting nothing will be found the same way this one was, by somebody preparing for an audit.

Prevention

  • Scan state on every apply. A pipeline step that pulls the state and fails the build on high-entropy strings or on known credential attribute names would have caught this on the first apply, 14 months ago, when the fix was a five-line change and not an incident.
  • Require write-only arguments for credential attributes. Enforce it with a policy check that rejects a plan where a credential attribute is fed from anything other than an ephemeral source, so the correct pattern is the default rather than the informed choice.
  • Classify remote state as a credential store. Limit read to at most three principals, review the membership quarterly with a named owner, set backend encryption explicitly, and enable object access logging with a retention at least as long as your rotation period.
  • Rotate on a 90 day cycle with a page at 100 days. The reason this exposure is serious is not that state was readable, it is that the same password was readable for 14 months. A rotation cycle puts a ceiling on the damage from every leak you have not found yet.
  • Ban the output command from logged steps. Both the raw and the JSON forms print sensitive values in clear regardless of how a variable is marked, which makes the pipeline log an independent copy of the credential with its own retention policy.
  • Stop having the tool mint the credential. Let the secret manager generate and own the password and have Terraform reference an identity instead. A value the tool never receives cannot appear in its state, its plan files, or its logs, and that is the only version of this problem that stays solved.