# CL-0020: Credential-Shaped Env Key With Literal Value

**Severity:** HIGH

**References:**
- [OWASP Docker Security Cheat Sheet — Rule #12: Utilize Docker Secrets for Sensitive Data Management](https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html#rule-12-utilize-docker-secrets-for-sensitive-data-management)
- [Compose `secrets:` top-level element](https://docs.docker.com/reference/compose-file/secrets/)

## 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** is a non-empty literal string (not a `${VAR}` substitution, 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).
- Pure or partial `${VAR}` substitutions — the credential is parameterized and sourced from process env, which is the documented secure-ish pattern.

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](CL-0021.md).

## 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:

```yaml
# 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.

## See also

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