CL-0020: Credential-shaped environment keys with literal values
September 9, 2026 · View on GitHub
Severity: HIGH
Derivation (see severity model):
- Baseline: B — the attacker can read the compose file, or influence what the image reference resolves to; no code execution anywhere yet
- Precondition: Direct — reading the file is the attack. The credential is present in plaintext; no technique and no second defect stand between the reader and it
- Impact: Single container — the credential authenticates to the service it belongs to. A credential that also unlocks a neighbour is that neighbour's finding
- Qualifier/modifier: none
- Derived: Direct × Single container = HIGH
- Shipped: HIGH
- Evidence: Not runtime-observable, and it does not need to be: the compose file is the artifact, and the finding is the literal value it contains. Every finding names the key and the line where the value appears
References:
- OWASP Docker Security Cheat Sheet — Rule #12: Utilize Docker Secrets for Sensitive Data Management
- Compose
secrets:top-level element
What it detects
Environment variable entries (both list form KEY=value and map form KEY: value) where:
- The key name matches a credential convention (case-insensitive substring or suffix match), AND
- The value ships a non-empty literal string — not a
${VAR}reference Compose resolves to nothing, and not a boolean/numeric flag.
Matched key patterns:
| Match type | Pattern | Notes |
|---|---|---|
| substring | PASSWORD, PASSPHRASE, TOKEN, SECRET, API_KEY, APIKEY, PRIVATE_KEY, ACCESS_KEY, SECRET_KEY, ENCRYPTION_KEY, CREDENTIAL | case-insensitive |
| suffix | _PASS, _PWD, PASSWD, _SALT, _DSN | suffix-anchored to avoid noisy substring hits (raw PASS would match Passport.js naming) |
Exemptions (key matches a credential pattern but the rule does not fire):
-
Keys ending in
_FILE— this is the secrets-mount convention (POSTGRES_PASSWORD_FILE: /run/secrets/db_password), the fix, not the bug. -
Keys containing
ALLOW_EMPTY_orRANDOM_— image-startup boolean toggles (MYSQL_ALLOW_EMPTY_PASSWORD: "yes",MYSQL_RANDOM_ROOT_PASSWORD: "yes"). -
Values that are exactly
yes,no,true,false,on,off,0, or1(case-insensitive) — boolean flags. -
Keys that name a quantity about the credential — a lifetime, size, limit or policy knob (lifetimes
TTL,TIMEOUT,EXPIRE,EXPIRY,EXPIRATION,VALIDITY,LIFETIME,MAX_AGE/MAXAGE,ROTATION,INTERVAL,RETENTION,DURATION; time units_SECONDS/_SECS,_MINUTES/_MINS,_HOURS,_DAYS,_MS; sizes and policy knobsMIN_LENGTH/MINLENGTH,MAX_LENGTH/MAXLENGTH,MIN_CHAR/MINCHAR,_LENGTH,_SIZE,_LIMIT,POLICY,REQUIREMENTS,_BONUS,_STRENGTH,_HISTORY,_ATTEMPTS,_RETRIES, pluralTOKENS; work factorsROUNDS,ITERATIONS,_COST; and_PORT) — and whose value is a bare quantity (30,1.5,900s,30m).JWT_ACCESS_TOKEN_EXPIRE_MINUTES: 30is a duration, not a token.Both halves are required. On the key alone,
AUTH_TOKENS: your_token_herewould be skipped; on the value alone,DB_PASSWORD: 12345678would be — and a weak numeric password is exactly the finding. SoPASSWORD_RESET_TOKEN_TTL: 900is exempt whileSECRET_KEY: 12345fires. -
Empty values (
PASSWORD: ""— env unset, not a credential). -
Values Compose ships as empty — made only of references with no default (
${DB_PASSWORD}, or"${DB_PASSWORD}"in a list-form entry, where the quotes are literal characters). The credential is parameterized and sourced from process env, the documented secure-ish pattern.Two shapes that look similar but are not skipped, because the file ships a literal either way: a value that merely contains a reference (
hunter2$Xshipshunter2— one appended character used to silence this rule), and a defaulted reference (${DB_PASSWORD:-hunter2}shipshunter2, which is a hardcoded credential in every fresh clone). Compose's escaped literal dollar ($$) does not count as a reference either:DB_PASSWORD: "pa$$w0rd"is a literal credential containing$, so it fires.
This rule is a naming-convention check, not a content scanner. It does not inspect the value for entropy, length, or provider-specific formats. A value of changeme fires, a 40-character production credential under BILLING_ENDPOINT does not. For value-side detection of credentials in URL-shaped env vars, see CL-0021.
Where it looks
Both environment: and every env_file: a service names. Compose merges a named
env file into the container's process environment, so a credential written there
reaches the same surfaces this rule describes while never appearing in the
document — the reason env_file: is graded at all
(ADR-027).
A key set in both is reported once, against environment:, because that is the
spelling Compose ships. A finding from an env file names the key and the file it
was written in; the value is never printed, and the text formatter does not
open a file that is not a Compose document. A target resolving outside the
project directory is refused rather than read. When a target contributes nothing,
the run says which one and why on stderr, without changing the exit code.
Why it matters
Compose's environment: block places credentials directly into the container's process environment, which propagates to several surfaces:
docker inspect <container>(and the underlying daemon API) returns the full env block to anyone with daemon access./proc/<pid>/environis readable by any process running as the same UID inside the container, and by privileged processes outside it.docker compose configprints the rendered env to stdout — frequently captured in CI logs andset -xtraces.- Process listings via
ps ewwexpose env to any user on the host that can see the process. - Container-runtime metadata is often shipped to log aggregators and APM tools by default.
Any service with daemon access can read every other container's env. Compose's secrets: primitive solves this by materializing credentials as files at /run/secrets/<name>, scoped to the container's filesystem and absent from env, daemon metadata, and process listings.
Placeholder values like changeme, admin, or postgres are intentionally flagged. Real-world incidents repeatedly involve placeholders shipped to production unchanged — the leak path and the fix are identical regardless of whether the value is a real credential.
Fix
Move the credential to Compose's secrets: primitive. For images that support the *_FILE convention (Postgres, MySQL, MariaDB, MinIO, Bitnami images, many others), this is a one-line change:
# Before
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: hunter2
# After
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt # add to .gitignore
# or external: true if managed via `docker secret create`
Coming from an env_file:
The same fix applies, because the destination is the same: an env_file: value
lands in the container's process environment exactly as an environment: value
does. Three things are worth knowing on the way.
It is worth doing even when the file is already gitignored. The gain is not
that the value leaves git — it may have left already — but that it leaves the
process environment. A secret declared under secrets: is mounted at
/run/secrets/<name> as a file, so it appears in none of the four surfaces
listed above. An env_file: value appears in all four.
Do not point secrets: file: at the env file itself. A file-sourced secret
is mounted byte-for-byte, so an env-file-style source gives the workload the
whole line — POSTGRES_PASSWORD=hunter2 and its trailing newline — as the
password, rather than hunter2 (verified against a live container). The source
file holds the value alone, nothing else:
# secrets/db_password.txt -- the whole file, no key, no quotes
hunter2
If the file is your .env, split its two jobs. A .env supplies ${VAR}
interpolation to the document; naming it in env_file: additionally pushes
every key in it into the container. Those are different jobs and only the second
one is graded here. Keep .env for interpolation, put container configuration in
its own env_file:, and put credentials in secrets:.
A placeholder value is not exempt, and should not be: changeme in a committed
env file is what every fresh clone deploys until someone remembers to change it.
The fix is the same.
For images that don't support *_FILE env vars, have the entrypoint read /run/secrets/<name> at startup and export the value into the workload's environment before launching the main process. The credential is still in env at the workload level, but it is not in the Compose file or the daemon's view of the container's static config.
ATT&CK coverage
Remediating this finding contributes to mitigating the following MITRE ATT&CK techniques (pinned to ATT&CK v18). compose-lint is a static analyser, so this is mitigation coverage — it detects nothing at runtime.
| Technique | Tactic |
|---|---|
| T1552.001 Unsecured Credentials: Credentials In Files | Credential Access |