CL-0020: Credential-shaped environment keys with literal values¶
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. - 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.
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`
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 |