Skip to content

CL-0021: Credentials embedded in connection-string 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 password sits in the userinfo segment of a connection string in plaintext
  • Impact: Single container — the credential authenticates to the service the connection string addresses
  • 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. See CL-0020 — the two rules differ in matcher, not in risk

References: - OWASP Docker Security Cheat Sheet — Rule #12: Utilize Docker Secrets for Sensitive Data Management - Compose secrets: top-level element - RFC 3986 §3.2.1 — userinfo deprecation for passwords

What it detects

Environment variable values that contain a literal scheme://user:password@host userinfo segment, regardless of the key name.

services:
  app:
    environment:
      DATABASE_URL: postgresql://postgres:hunter2@db:5432/app  # fires
      MONGO_URL: mongodb://root:example@mongo:27017/           # fires
      AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:airflow@postgres/airflow  # fires

Detection matches any URI scheme ([a-zA-Z][a-zA-Z0-9+.\-]*) followed by ://, then splits the user:password@ that follows at the first : and @ that sit outside a ${...} substitution — a plain split lands inside one, so postgresql://${DB_USER:?error}:${DB_PASSWORD:?error}@db/x would be read as a password of ?error}:${DB_PASSWORD:?error} and reported as a literal even though the file ships no credential. A match requires the password half to ship a non-empty literal — that is, it is not made up solely of ${VAR}/$VAR references Compose resolves to nothing (Compose's escaped literal dollar, $$, is data, not a substitution — pa$$w0rd fires). The username half may be empty: RFC 3986 §3.2.1 permits it, and redis://:password@host — the standard Redis URL form — still leaks the password, so it fires.

Skipped:

  • Password half is made only of ${VAR}/$VAR references with no default — Compose ships nothing there, so the credential is parameterized. (A $-valued username with a literal password still fires: the password leaks regardless. A password whose only dollars are $$ escapes also fires — that is a literal $ in the credential, not a substitution. A defaulted password such as ${DB_PASSWORD:-hunter2} fires too: the default is what the file deploys.)
  • Both halves are references with no default (postgresql://${DB_USER:?error}:${DB_PASSWORD:?error}@db/x) — the same rule as above, once the split respects the substitution.
  • Userinfo password is empty (scheme://user:@host) — no credential to leak.
  • No userinfo at all (scheme://host/path) — nothing to flag.
  • Empty or non-string value.

This rule is key-name-agnostic and value-shaped, the inverse of CL-0020. They are independent and may both fire on the same service when both surfaces apply (e.g., a separate DB_PASSWORD: hunter2 and a DATABASE_URL: postgres://app:hunter2@db/app).

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

The exposure surface is identical to CL-0020 — env vars propagate through docker inspect, /proc/<pid>/environ, docker compose config, process listings, and CI logs. Embedding the credential in a URL doesn't add or remove any leak path, it just hides the credential from key-pattern scanning.

This is the largest detection gap that key-pattern rules miss in real-world Compose files. In a corpus of 1554 real docker-compose.yml files, 151 contained a literal user:password@ segment in an environment: value — none of which would have been caught by credential-shaped key matching alone.

RFC 3986 §3.2.1 deprecates passing passwords in URI userinfo regardless of Docker context: "Use of the format 'user:password' in the userinfo field is deprecated." This rule covers the Compose-specific exposure on top of the general URI hygiene concern.

Fix

Three remediation patterns, in order of preference:

1. Move the credential to secrets: and reassemble the URL at startup (best)

services:
  app:
    image: myapp:1.0
    secrets:
      - db_password
    # Entrypoint reads /run/secrets/db_password and exports
    # DATABASE_URL=postgresql://app:$(cat /run/secrets/db_password)@db/app
    # before exec'ing the workload.

secrets:
  db_password:
    file: ./secrets/db_password.txt   # add to .gitignore

2. Use the image's *_FILE convention for the password component (when supported)

Some clients (e.g., Postgres, libpq via PGPASSFILE) read the password from a file referenced by env, leaving the rest of the connection string in plain env vars.

3. Substitute the password from process env (interim improvement)

services:
  app:
    environment:
      DATABASE_URL: postgresql://app:${DB_PASSWORD}@db/app

This pulls the credential out of the document. Compose resolves ${DB_PASSWORD} from the sibling .env or the operator's shell, and compose-lint deliberately does not resolve a .env value into environment: (ADR-026 §2) — so following this advice does not trip the rule that gave it.

One condition: the file supplying DB_PASSWORD must not also be named in an env_file: for this service. env_file: .env merges every key in that file into the container's process environment, so the credential lands back where it started and CL-0020 reports it — correctly (ADR-027). Keep .env for interpolation or name it in env_file: for container config; doing both undoes this fix.

Less secure than option 1 in any case — the credential still ends up in container env — but a meaningful improvement over a literal value, and a one-line change.

Options 1 and 2 apply unchanged when the connection string was written in an env_file: rather than in environment:; see CL-0020's migration notes for the two traps on that path.

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-0020 — credential-shaped env keys with literal values (key-side detection of the same exposure surface).
  • CL-0010 — host PID/IPC namespace sharing (cross-container /proc/<pid>/environ exposure).