Skip to content

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:

  1. The key name matches a credential convention (case-insensitive substring or suffix match), AND
  2. 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_ or RANDOM_ — 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, or 1 (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$X ships hunter2 — one appended character used to silence this rule), and a defaulted reference (${DB_PASSWORD:-hunter2} ships hunter2, 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>/environ is readable by any process running as the same UID inside the container, and by privileged processes outside it.
  • docker compose config prints the rendered env to stdout — frequently captured in CI logs and set -x traces.
  • Process listings via ps eww expose 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

See also

  • CL-0021 — credentials embedded in connection-string env values (different detection mode, same exposure surface, same fix).
  • CL-0010 — host PID/IPC namespace sharing (cross-container /proc/<pid>/environ exposure).