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}/$VARreferences 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 |