· via dev.to (home feed)
Mistyped Terraform plan filename bypassed gitignore and leaked every secret
A dev.to post recounts how terraform plan -out=out slipped past gitignore and secret scanners, exposing full Terraform state and forcing secret rotation across four environments and six providers.

A first-person account published on dev.to describes how one mistyped Terraform command produced a plan file named out, which was committed to a private repository and sat on the remote for nine days — carrying every secret in the workspace with it.
The author intended to run terraform plan -out=tfplan but typed terraform plan -out=out. Terraform accepted the filename without complaint, and a subsequent git add . swept the file into the next commit. According to the post, every safeguard was keyed on the filename: .gitignore listed tfplan and out/, and neither pattern matches a file called out. The author also notes that GitHub's own Terraform gitignore template contains fourteen patterns covering state, tfvars and override files, with none for plan files — *tfplan* appears there only in a comment.
The secret scanner missed it too. Scanners read text, and file identified the artifact as a Zip archive, so a password inside it was never a plain string in a file. Gitleaks can inspect archives as of version 8.27.0, but only when --max-archive-depth is set, and the post says that option defaults to off. Reviewers, meanwhile, saw GitHub's "Binary file not shown" placeholder and read the file as a leftover build artifact. The repo being private was small comfort: every engineer, contractor and CI runner with a clone had the file.
What the plan file actually contained
A Terraform plan file is a zip archive holding five things, per the post: tfplan (the planned changes plus every input variable value), tfstate (the full prior state after refresh, in plain JSON), tfstate-prev (state before refresh, also plain JSON), a copy of every .tf file from root and modules, and the provider lock file. The upshot is that committing a plan file amounts to committing the state. The diff involved roughly twenty resources, but the state held a few hundred, and all of them were inside the archive.
Two details stand out. Targeting does not help: a plan built with -target against a single random_pet resource still contained the full state, every generated password in the workspace, and every input variable. And the "(sensitive value)" placeholder printed by terraform show is a display choice, not encryption — terraform show -json prints the values in clear text, which the post points out is documented behavior. Reading the file requires only unzip, not any Terraform binary.
Building the inventory from the file, not from memory
Instead of listing known secrets, the author exported terraform show -json output and used jq queries to enumerate input variables and every resource attribute Terraform marks sensitive, in both the prior state and the planned changes. The sensitive flag turned out to understate the problem, because providers do not mark everything: connection strings, bearer tokens in monitor HTTP headers, and private keys inside Kubernetes secret data maps all appeared unmarked, so the jq pass was followed by a plain grep over the unzipped state for terms like password, secret, token, key and BEGIN.
The findings sorted into five groups: passwords set directly in config; generated secrets such as random_password results; secrets read through data sources like Secrets Manager and Key Vault, which copy values into state even though reading them feels read-only; input variables such as TF_VAR_datadog_api_key passed in by CI and saved by -out; and credentials that providers hand back, including IAM access key secrets and TLS private keys. Because the Datadog key and Cloudflare token were the same variable in every environment's pipeline, "it was only the prod plan" offered no protection.
Why rotation was not one afternoon
Rotation spanned four environments and six providers, and each secret type rotates differently. Database master passwords change in place and break every consumer holding the old value until redeployed. IAM access keys cannot change value, so a second key must be created, consumers switched over, and the first deleted. Storage account keys regenerate outside Terraform. random_password rotation means bumping keepers and updating every dependent resource. Data-source secrets rotate at the source while state retains the old copy until the next refresh. Provider tokens must be updated in every pipeline that holds them, and replacing a CA private key means reissuing everything it signed.
The structural gap, according to the author: Terraform knows the resource, not its readers. State says a database has a password but says nothing about the services, cron jobs or analyst notebooks holding a copy, so the question of who else has each value had to be answered by hand.
Why it matters
Plan files are treated as disposable local artifacts, but they are state files in a zip. Filename-based defenses fail on typos, text-based scanners fail on archives, and the sensitive flag is only a display hint. The practical lessons from the post: gitignore and pre-commit hooks should block plan files broadly rather than by exact name, archive scanning should be enabled in secret scanners, any committed plan should be treated as a full state leak — and teams should maintain a living map of which consumers hold which secret, because no file in the repository will answer that when rotation day comes.
- #terraform
- #devops
- #security
- #secrets-management
- #infrastructure-as-code