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

The value-side regex matches any URI scheme ([a-zA-Z][a-zA-Z0-9+.\-]*) followed by ://user:password@, where the password half ships 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.)
  • 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).

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 from the operator's environment instead of committing it to the file. Less secure than option 1 (the credential still ends up in container env), but a meaningful improvement over a literal value and a one-line change.

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).