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}/$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.) - 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 |