What this check reads
For each running container it prints the whole of .Config.Env through docker inspect and matches variable names against (.*_)?(PASSWORD|PASSWD|SECRET|API_?KEY|ACCESS_?KEY|TOKEN|PRIVATE_?KEY)=, then throws away any match whose value is empty or literally null, changeme or example. The value is stripped off before anything is recorded and at most three names per container are kept, so a finding reads as container[VAR1,VAR2] and never carries a secret. One surviving match on any container is the WARN; no matches anywhere is the PASS.
When it applies
Needs a reachable daemon and at least one running container; otherwise SKIP. It reads .Config.Env - the environment baked into the container at creation - and matches on variable names, not values, against PASSWORD|PASSWD|SECRET|API_KEY|ACCESS_KEY|TOKEN|PRIVATE_KEY (with an optional prefix). Values that are empty or literally null, changeme or example are ignored as placeholders. Only the first 3 matching names per container are listed, and values are never printed - the finding is safe to paste into a ticket. Consequences: a secret in a variable named something else (DB_DSN, CONNECTION_STRING) is missed, and a variable that merely looks like a secret name is reported regardless of its content.
What each result means
| Result | When | What it means |
|---|---|---|
| SKIP | No running containers | No container environment was read, so nothing was verified. This wording also covers a daemon the script could not reach. |
| WARN | Secret-like variable names found | At least one running container carries a variable whose name matches the secret pattern. Only names are printed, so confirm by hand which of them really hold a credential. |
| PASS | None | No running container has a variable name matching the pattern. A credential hidden in a name the pattern does not cover, such as DATABASE_URL, would have gone unseen. |
Why it matters
Environment variables are visible to docker inspect (anyone in the docker group), to every child process, in crash dumps, in /proc/<pid>/environ, and frequently in logs and error reporters. OWASP Docker Cheat Sheet Rule #12 "Utilize Docker Secrets for Sensitive Data Management"; OWASP Secrets Management Cheat Sheet; CIS Docker 4.10; Docker docs "Manage sensitive data with Docker secrets".
Why it fails, and when it is wrong
POSTGRES_PASSWORD,MYSQL_ROOT_PASSWORD,REDIS_PASSWORDetc. are the documented way to configure official images, so nearly every Compose stack WARNs. The_FILEconvention (POSTGRES_PASSWORD_FILE=/run/secrets/db) is supported by many official images and is not matched by the regex (the name ends in_FILE), so it is the intended fix.- The keyword has to be the last part of the name, so
TOKEN_TTLandAPI_KEY_HEADERare not matched, while a flag that only ends in one is:SHOW_PASSWORD=falseorMAX_TOKEN=4096is reported as a secret. - The placeholder filter is case-sensitive, so
nullandexampleare skipped butNULLandEXAMPLEare reported. - Only names are reported; values are never printed.
How to fix it
Rotate what was exposed
Anyone who could run docker inspect, read /proc/<pid>/environ or see a crash report may have it already. Removing the variable does not undo that.
Mount the secret as a file
File-based Compose secrets work without Swarm.
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
secrets:
db_password:
file: ./secrets/db_password.txtKeep ./secrets/ out of version control. With Swarm use docker secret create; Vault and similar managers are the other route.
Point the application at the file
Many official images read a *_FILE variable for exactly this (POSTGRES_PASSWORD_FILE, MYSQL_ROOT_PASSWORD_FILE), and the check does not match names ending in _FILE. For your own services, read the value from /run/secrets/<name> at start-up.
Recreate and confirm
Config.Env is fixed when a container is created.
docker compose up -d --force-recreate
docker exec <name> ls -l /run/secrets/
docker inspect <name> --format '{{.Config.Env}}' | grep -ci password # expected: 0Verify the fix
# Names only, mirroring the check (safe to share):
docker inspect -f '{{.Name}}{{range .Config.Env}} {{.}}{{end}}' $(docker ps -q) \
| tr ' ' '\n' | grep -iE '(PASSWORD|SECRET|API_?KEY|TOKEN|PRIVATE_?KEY)=' | sed 's/=.*//'
# expected: no output
# Confirm the replacement works - the secret should arrive as a file, not an env var:
docker exec <name> sh -c 'ls -l /run/secrets/ && env | grep -ci password'
# expected: the secret file exists; the env grep returns 0
# Remember env vars are visible to anyone who can reach the daemon:
docker inspect <name> --format '{{.Config.Env}}'Debugging
This one message covers two different cases: the audit could not reach the Docker daemon, or it reached it and nothing was running. To tell them apart, check systemctl is-active docker, then docker ps as the audit user and sudo docker ps, and run the audit as root or with sudo if only the second works. If both work and list nothing, there is genuinely nothing to evaluate. The Docker Inventory check reports the unreachable case under its own message and walks through it in more detail.
The match is on the name alone, and the name only has to end in one of the keywords, so a setting like SHOW_PASSWORD=false or MAX_TOKEN=4096 is reported. Confirm the value by hand and record it; renaming the variable also clears it.
The name list is fixed. A connection string in DATABASE_URL, or a credential in DB_DSN, is invisible to this check. Review the full environment yourself with the first command above minus the grep.
Environment variables are readable by anyone who can run docker inspect, are inherited by every child process, appear in /proc/<pid>/environ, and are frequently dumped into crash reports and logs. That is the reason for the finding even when only your own team can reach the host.
Docker secrets (Swarm) or, for plain Compose, mount the value as a file and point the application at the path; many official images support a *_FILE convention (POSTGRES_PASSWORD_FILE, MYSQL_ROOT_PASSWORD_FILE) precisely for this. Recreate the container afterwards, since Config.Env is fixed at creation.
If a secret has been in Config.Env on a shared host, treat it as disclosed and rotate it; removing the variable does not undo the exposure.
Sources
- Docker Compose: Use secrets in Compose
- Docker Swarm: Manage sensitive data with Docker secrets
- Docker Hub postgres image:
*_FILEvariables - Docker Hub mysql image:
*_FILEvariables - OWASP Docker Security Cheat Sheet (Rule #12)
- OWASP Secrets Management Cheat Sheet
- HashiCorp Vault Agent injection
How the script reads this
Next
Re-run the Network & Supply Chain audit after applying the fix and confirm this check moves to PASS. CtrlOps runs all 5 checks over your existing SSH connection and scores the result, so the change is visible without reading another config file.
All 5 Network & Supply Chain fixes