Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 8 min read

Reproduce a Terraform State Password Leak With a Minimal JSON Fixture

The apply failed. Staging RDS would not rotate. Someone dropped terraform show -json into a chat model and asked why the password in state still matched the old output. The model quoted db_master_password back in the fi

The apply failed. Staging RDS would not rotate. Someone dropped terraform show -json into a chat model and asked why the password in state still matched the old output.

The model quoted db_master_password back in the first paragraph. That is not a debugging win. That is a trust-boundary violation: a secret that belonged in the state backend just crossed into a model prompt log, and you cannot unsend it.

You needed a diff. Why did the whole state go with it?

This article is a lab walkthrough, not a production incident report. I am not claiming I found a live leak. I am claiming the JSON shape below is enough to fail a gate before any model, local or remote, is allowed to see infrastructure state.

The actual failure sequence

  1. terraform apply errors on an RDS password rotate.
  2. An engineer runs terraform show -json or copies terraform.tfstate.
  3. The blob is pasted into an agent because β€œthe plan is too noisy.”
  4. The model echoes outputs.db_password.value while explaining the graph.
  5. The prompt, the completion, and often the tool trace now hold a live credential.

Gitignoring *.tfstate does not help. This is not a commit problem. This is a paste problem.

.gitignore stops git. It does not stop a model.

Trust boundaries, not vibes

Treat the model as another principal. It is not your backend. It is not Vault. It is not terraform console.

Zone What it is allowed to hold What must never cross out
Terraform backend (S3, GCS, Terraform Cloud) Full state, including sensitive values that Terraform still stores Unauthenticated read, world-readable buckets
Engineer workstation A local pull of state for apply/plan Raw JSON into clipboard, chat, tickets, agents
Redaction gate (CI or pre-tool hook) Paths, resource addresses, redacted hashes Cleartext outputs.*.value, provider env, private keys
Model context Resource addresses, error codes, sanitized diffs Passwords, tokens, kubeconfig client-key-data, TLS PEMs
Model provider logs / traces Whatever you already sent Your ability to delete it on demand

Terraform’s sensitive = true is a UI flag. It hides values in terminal output. It does not remove them from state. If you did not know that, you are the audience for this fixture.

Ask the ugly question: if the model provider is subpoenaed tomorrow, is that RDS password in the export?

Lab fixture (synthetic, labeled)

Pin the shape, not a vendor myth. Modern Terraform state is format version 4. The file below is a lab object. Do not load it into a real backend.

{
  "version": 4,
  "terraform_version": "1.9.8",
  "serial": 7,
  "lineage": "lab-not-a-real-lineage",
  "outputs": {
    "db_password": {
      "value": "tfstate-canary-P@ssw0rd-9f3c",
      "type": "string",
      "sensitive": true
    },
    "endpoint": {
      "value": "staging-db.internal.example",
      "type": "string",
      "sensitive": false
    }
  },
  "resources": [
    {
      "mode": "managed",
      "type": "random_password",
      "name": "db",
      "provider": "provider[\"registry.terraform.io/hashicorp/random\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "id": "none",
            "result": "tfstate-canary-P@ssw0rd-9f3c",
            "length": 24,
            "special": true
          },
          "sensitive_attributes": [
            ["result"]
          ]
        }
      ]
    }
  ]
}

Save it as fixtures/positive.tfstate.json.

The canary is the point. If a model completion, a tool trace, or a chat export later contains tfstate-canary-P@ssw0rd-9f3c, you have proof the boundary failed. No guessing. No β€œthe model might have hallucinated a password.”

Negative fixture, fixtures/negative.tfstate.json: same graph, values replaced with ***REDACTED***, and sensitive_attributes left in place so the structure still looks like Terraform.

Expected evidence:

  • Positive file must fail the gate (exit 2).
  • Negative file must pass (exit 0).
  • A completion that repeats the canary is a failed control, not a clever explanation.

Gate the JSON, not the vibes

Here is a stdlib scanner you can drop in CI or in a pre-tool wrapper. Unexecuted against your backend until you run it. That is the honest label.

#!/usr/bin/env python3
"""Fail if Terraform state still carries cleartext secrets. stdlib only."""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

CANARY = "tfstate-canary-P@ssw0rd-9f3c"
SECRETISH = re.compile(
    r"(password|secret|token|private_key|access_key|client-key-data)",
    re.I,
)
PEM = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")
HIGH_ENTROPY = re.compile(r"^[A-Za-z0-9/+=_\-]{20,}$")


def walk(node, path: str, hits: list[str]) -> None:
    if isinstance(node, dict):
        for k, v in node.items():
            walk(v, f"{path}.{k}" if path else k, hits)
    elif isinstance(node, list):
        for i, v in enumerate(node):
            walk(v, f"{path}[{i}]", hits)
    elif isinstance(node, str):
        if node == CANARY or PEM.search(node):
            hits.append(path)
            return
        if SECRETISH.search(path) and node and node != "***REDACTED***":
            hits.append(path)
        elif SECRETISH.search(path) and HIGH_ENTROPY.match(node):
            hits.append(path)


def main(argv: list[str]) -> int:
    if len(argv) != 2:
        print("usage: tfstate_secret_gate.py <state.json>", file=sys.stderr)
        return 2
    raw = json.loads(Path(argv[1]).read_text())
    hits: list[str] = []
    walk(raw.get("outputs", {}), "outputs", hits)
    walk(raw.get("resources", []), "resources", hits)
    if hits:
        print("REFUSE: cleartext secret paths in terraform state:")
        for h in hits:
            print(f"  - {h}")
        return 2
    print("PASS: no canary / secret-shaped values in outputs or resources")
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))

Commands, as a reader-run recipe:

python3 tfstate_secret_gate.py fixtures/positive.tfstate.json
# expected: exit 2, path list includes outputs.db_password.value

python3 tfstate_secret_gate.py fixtures/negative.tfstate.json
# expected: exit 0

# What people actually paste β€” still in scope:
terraform show -json > /tmp/show.json
python3 tfstate_secret_gate.py /tmp/show.json

A one-liner if you do not want Python yet:

jq -e '
  .outputs | to_entries[]
  | select(.value.sensitive == true and (.value.value | type == "string")
           and .value.value != "***REDACTED***")
  | .key
' fixtures/positive.tfstate.json && echo "REFUSE" || true

If that prints db_password, the file is not model-safe. Stop.

What not to send to a model

Refuse the whole object if any of these are present in cleartext:

  • outputs.*.value when sensitive is true
  • random_password / tls_private_key result or private_key_pem
  • provider env: AWS_SECRET_ACCESS_KEY, GOOGLE_CREDENTIALS, KUBE_CONFIG_RAW
  • client-key-data, client-certificate-data, token inside any kubeconfig blob that landed in state
  • database URLs with postgres://user:password@
  • the canary string, always

Allow, after the gate passes:

  • resource addresses (random_password.db)
  • error codes from the apply log with secrets stripped
  • a redacted plan: actions, counts, resource types
  • hashes of values (sha256:…) if you need to prove β€œit changed” without proving β€œhere it is”

Sanitize before the model, not after the completion. After is forensics. Before is control.

Prevent / detect / recover

Layer Prevent Detect Recover
CI Fail the job if a prompt pack, gist, or β€œdebug bundle” contains version: 4 plus resources plus secret-shaped strings Gate exit 2 on terraform show -json artifacts Drop the artifact from the bundle; do not β€œjust redact in the ticket”
Agent harness Tool wrapper: run the gate on any file whose name matches *.tfstate* or whose JSON has terraform_version Canary match in stdout/stderr/tool traces Kill the session; mark the trace as secret-bearing
Human paste Refuse to paste terraform show -json into any chat Clipboard check on tfstate-canary- in drills Rotate the DB password and any key that appeared in outputs
Backend Bucket policy, encryption, no public ACLs CloudTrail / access logs on GetObject for state Rotate credentials stored in state, then terraform apply to rewrite

Recovery is not β€œask the model to forget.” Recovery is rotate, rewrite state, and treat the provider log as compromised for that secret’s lifetime.

Where a self-hosted model still fits

A local model does not make raw state safe. Same JSON, same leak, smaller blast radius. Smaller is not zero.

The useful split is mechanical:

  1. Gate the file on the workstation or in CI.
  2. Only then ask a model to explain a redacted plan.
  3. Keep even that step inside a network you control if the redaction is imperfect β€” and it will be imperfect.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. I use MonkeyCode here only as one self-hosted option: its free model access and free server option are relevant when you want the redacted review to stay on your side of the boundary. The gate above does not depend on it. If you strip the product name out, the invariant is unchanged: no cleartext outputs.*.value in the prompt.

Do not send the positive fixture to any hosted model to β€œsee what happens.” You already know what happens. The canary comes back.

Limitations, and who should not do this

This scanner is a tripwire. It is not a secret detector for all of Terraform.

  • Nested objects, null_resource triggers, and external data sources will hide strings the regex never sees.
  • sensitive_attributes records paths; it does not erase attributes.
  • Partial plans (terraform show -json tfplan) have a different schema (resource_changes[].change.after). Extend the walker or you will false-pass.
  • Remote state pulled with terraform state pull is still a local secret once it hits disk.
  • High-entropy matching will false-positive on IDs. Tune paths, not vibes.

Who should not use this approach:

  • Anyone hoping a model will manage production secrets. Wrong principal.
  • Teams with no Terraform state in the debugging path β€” use a different fixture.
  • People who will run the positive fixture against a public endpoint β€œas a demo.” That is how canaries die.
  • Air-gapped incident work that does not need a model at all. Then skip the model. Keep the gate.

If your process is β€œpaste first, redact if someone complains,” you do not have a process.

Close the loop in CI, not in the chat

Put the canary in a scheduled drill. Put the gate on any job that builds an β€œAI debug bundle.” Put a harness check in front of file-read tools. Three layers, one invariant: Terraform state is not model input.

Which invariant belongs in CI, and which layer should enforce it? CI should refuse to ship a bundle that still contains format-version-4 state with cleartext outputs. The agent harness should refuse to read that file even if CI was skipped. The model should never be the layer that notices.

If the model is the first control that sees the password, the control already failed.

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.