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
-
terraform applyerrors on an RDS password rotate. - An engineer runs
terraform show -jsonor copiesterraform.tfstate. - The blob is pasted into an agent because “the plan is too noisy.”
- The model echoes
outputs.db_password.valuewhile explaining the graph. - 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.*.valuewhensensitiveis true -
random_password/tls_private_keyresultorprivate_key_pem - provider env:
AWS_SECRET_ACCESS_KEY,GOOGLE_CREDENTIALS,KUBE_CONFIG_RAW -
client-key-data,client-certificate-data,tokeninside 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:
- Gate the file on the workstation or in CI.
- Only then ask a model to explain a redacted plan.
- 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_resourcetriggers, andexternaldata sources will hide strings the regex never sees. -
sensitive_attributesrecords paths; it does not eraseattributes. - 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 pullis 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.
Top comments (0)