Last Tuesday a worker refused to talk to Postgres. I did what every on-call engineer does without thinking.
docker inspect api-worker --format '{{json .Config.Env}}'
The JSON came back with DATABASE_URL=postgres://app:CANARY_PASSWORD@db.lab.internal:5432/app sitting next to JWT_SECRET=lab-only-not-a-real-key. I needed a second pair of eyes. My cursor was already in a chat box.
That is the trust-boundary violation. Not a CVE. Not a clever jailbreak. Just a runtime dump about to leave the host.
Would you paste that blob into a remote model? I almost did. The rest of this article is the gate I now run first.
The failing sequence
Label this a lab reconstruction, not a production incident. I did not find a live credential in the wild. I built a fixture that fails on purpose.
- A container starts with connection strings in
Config.Env. -
docker inspectserializes those values as plain JSON strings. - An engineer pastes the dump into a coding model because the error message is useless.
- The model now holds a password, a JWT signing secret, and the internal hostname.
- Logs, chat history, and any provider-side retention sit outside the cluster's trust boundary.
The inspect API is not the bug. The missing invariant is: runtime Env never crosses a model boundary in the clear.
Trust boundaries, not vibes
Draw the lines before you argue about tools.
flowchart LR
subgraph host [Container host]
Dump["docker inspect JSON"]
Gate[Env classifier]
end
subgraph remote [Remote free-model access]
Redacted[Redacted question only]
end
subgraph local [Self-hosted coding server]
Raw[Raw dump stays here]
end
Dump --> Gate
Gate -->|no residual secrets| Redacted
Gate -->|secret still present| Raw
Three boundaries matter:
-
Host runtime.
docker inspectis privileged-adjacent. Anyone who can inspect can oftenexec. - Prompt channel. A remote model is a third party, even when the quota is free.
- Workspace disk. A self-hosted coding server still writes prompts, diffs, and tool output to disk. Disk is not "safe." It is a different failure domain.
If you cannot name which layer enforces the invariant, you do not have an invariant. You have a habit.
What not to send
I treat Config.Env as secret-by-default. Names lie. NODE_ENV=production is boring. APP_KEY is not. DEBUG=1 is boring until it is DEBUG_TOKEN.
Do not send these to a remote model, even "just to parse JSON":
- URLs with userinfo (
postgres://,mysql://,redis://,amqp://,mongodb://) -
*_SECRET,*_TOKEN,*_PASSWORD,*_PRIVATE_KEY,*_API_KEY - PEM blocks and
BEGINmarkers - JWT-shaped strings (
eyJprefix, two dots) - Cloud-shaped access keys and session tokens
- Cookie headers,
Authorizationvalues, webhook URLs with path secrets
Topology leaks still hurt after redaction. db.prod.internal:5432 plus the app name is often enough to aim the next phish. Redact values. Then ask whether the keys and hostnames still belong off-box.
Lab fixture (unexecuted template)
Pinned assumptions: Docker inspect JSON with .Config.Env as a list of KEY=VALUE strings. Python 3.12. Patterns below are lab defaults, not a detection product.
Positive fixture — must fail the gate:
{
"Config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NODE_ENV=production",
"DATABASE_URL=postgres://app:CANARY_PASSWORD@db.lab.internal:5432/app",
"JWT_SECRET=lab-only-not-a-real-key"
]
}
}
Negative fixture — must pass after allowlisting non-secret keys:
{
"Config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NODE_ENV=production",
"LANG=C.UTF-8"
]
}
}
Expected failure evidence: exit code 2, stderr containing DATABASE_URL and JWT_SECRET, no raw password on stdout.
#!/usr/bin/env python3
"""Lab gate: refuse docker inspect Env that still holds secrets."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
SECRET_NAME = re.compile(
r"(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL)$",
re.I,
)
SECRET_VALUE = re.compile(
r"(://[^\s:]+:[^\s@]+@)|(\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.)|(BEGIN [A-Z ]+PRIVATE KEY)",
re.I,
)
ALLOW_NAME = {"PATH", "LANG", "HOME", "TERM", "NODE_ENV", "PYTHONUNBUFFERED"}
def parse_env(pairs: list[str]) -> dict[str, str]:
out: dict[str, str] = {}
for item in pairs:
if "=" not in item:
continue
key, value = item.split("=", 1)
out[key] = value
return out
def findings(env: dict[str, str]) -> list[str]:
hits: list[str] = []
for key, value in env.items():
if key in ALLOW_NAME:
continue
if SECRET_NAME.search(key) or SECRET_VALUE.search(value):
hits.append(key)
return hits
def main() -> int:
payload = json.loads(Path(sys.argv[1]).read_text())
env = parse_env(payload["Config"]["Env"])
hits = findings(env)
if hits:
print("FAIL: secret-bearing Env keys:", ", ".join(hits), file=sys.stderr)
return 2
redacted = {k: ("<redacted>" if k not in ALLOW_NAME else v) for k, v in env.items()}
json.dump({"ok": True, "env": redacted}, sys.stdout, indent=2)
print()
return 0
if __name__ == "__main__":
raise SystemExit(main())
Wrapper I keep next to the script:
#!/usr/bin/env bash
set -euo pipefail
# Lab only. Replace api-worker with a canary container you own.
docker inspect api-worker > /tmp/inspect.json
python3 inspect_env_gate.py /tmp/inspect.json
If you want a one-liner before the Python gate exists, this jq probe is the smoke test, not the policy:
docker inspect api-worker | jq -r '.[0].Config.Env[]' \
| awk -F= '$1 ~ /(SECRET|TOKEN|PASSWORD|API_KEY)$/ { print $1; found=1 } END { exit found+0 }'
A zero from awk here is not a pass. It is a hint. Names without those suffixes still leak URLs with embedded passwords. That is why the Python fixture looks at values too.
Local free server versus remote free model
Once the dump exists, you still need a place to reason about it. I split the work by boundary, not by brand.
MonkeyCode is an open-source, self-hosted AI development platform with free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
I do not need a model catalog to use that split. Remote free-model access is for questions that already passed the gate. A free server you operate is for the raw inspect JSON, the compose file, and the crash loop that still contains the DSN.
| Artifact | Remote free-model channel | Self-hosted server | Why |
|---|---|---|---|
PATH, NODE_ENV, LANG
|
Allowed | Allowed | No secret material |
| Redacted schema: keys only | Allowed after gate | Allowed | Structure without values |
DATABASE_URL in the clear |
Denied | Disk encrypted, access logged | Credential + topology |
JWT_SECRET |
Denied | Denied in chat logs too | Signing key outlives the incident |
Full docker inspect
|
Denied | Allowed in a restricted workspace | Image IDs and mounts leak too |
Convenience is not a control. Free remote tokens do not change the data-flow. They change the invoice.
Prevent / detect / recover
| Layer | Prevent | Detect | Recover |
|---|---|---|---|
| Image build | Runtime secrets from a manager, not ENV/ARG
|
docker history and inspect in CI |
Rebuild without the value; rotate |
| Host | Drop docker inspect from shared jump boxes |
Gate on inspect JSON before paste | Treat paste as a credential event |
| Prompt channel | Block secret-bearing Env at the workspace sync | Fail closed on exit 2
|
Rotate DB password and JWT key |
| Self-hosted disk | Workspace allowlist, no homedir mounts | Alert on BEGIN PRIVATE KEY in prompt logs |
Wipe workspace volume, rotate again |
Rotation is the recovery. Redaction is not. If a remote model already saw CANARY_PASSWORD, you rotate. You do not "ask the provider to forget."
Threat model notes
Attacker is not a nation-state here. Attacker is you, tired, plus whoever later reads the chat transcript.
- Spoofing. A paste looks like "debug context." It is a credential export.
- Information disclosure. Env dumps include topology, image tags, and sometimes file paths.
- Tampering. A model that saw a JWT secret can suggest "fixes" that keep the same secret.
- Denial of recovery. If the only copy of the DSN lived in that dump and you rotate blindly, you still need a secrets manager as source of truth.
I also assume the self-hosted server can be compromised. That is why JWT material does not belong in its chat logs either. Local is a smaller blast radius. It is not a shredder.
Limitations — who should not use this
This gate will miss low-entropy passwords in names like APP_SETTING_12. It will false-positive on GPG_TTY. It will not see secrets in overlay files, Swarm secrets, or Kubernetes envFrom once they are already in the process. docker inspect is one serialization. proc/<pid>/environ is another.
Do not use this approach if:
- You need the model to debug the literal password. That request is the incident.
- Your policy forbids any runtime metadata leaving the VPC, redacted or not.
- You cannot rotate the credential you might leak. A gate without rotation is theater.
- You ship this regex pack as a compliance control. It is a regression fixture.
Unexecuted means unexecuted. Run it against a canary container you own. Do not point it at a production inspect dump and then paste the failure into a ticket that syncs to a SaaS tracker. That just moves the leak.
What I actually enforce
I want one invariant in CI: inspect JSON with secret-bearing Env never attaches to a model job.
The host enforces collection. The classifier enforces the prompt channel. The secrets manager enforces recovery. Which of those three belongs in CI for your platform, and which layer should fail closed when the worker is down and someone is in a hurry?
Top comments (0)