CL-0019: Image tags without digest pins (@sha256)¶
Severity: MEDIUM
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: Second flaw — a tag pin is only defeated by something outside this file: a registry operator, a compromised publishing pipeline, or an account takeover overwriting the tag
- Impact: Single container — the substituted image runs as this service
- Qualifier/modifier: none
- Derived: Second flaw × Single container = MEDIUM
- Shipped: MEDIUM
- Evidence: Not runtime-observable — compose-lint does not read the registry (ADR-020). The premise is the registry API contract: a tag is a mutable pointer, and a digest is content-addressed, so a digest-pinned pull cannot silently resolve to different bytes
References: - Docker docs — pull an image by digest (immutable identifier) - CIS Docker Benchmark 5.28 — Ensure that Docker commands always make use of the latest version of their image (its remediation is a pinning mandate) - OWASP Docker Security Rule #13 — general supply-chain framing; note it contains no digest-specific guidance
What it detects¶
Services using a version tag (e.g., nginx:1.25.3) without a digest pin (@sha256:...). This rule does not overlap with CL-0004:
| Image reference | CL-0004 | CL-0019 |
|---|---|---|
nginx |
fires | — |
nginx:latest |
fires | — |
nginx:1.25.3 |
— | fires |
nginx:1.25.3@sha256:... |
— | — |
Build-only services (no image: key) are skipped.
Why it matters¶
Docker tags are mutable pointers. A registry operator, compromised CI pipeline, or supply chain attack can overwrite nginx:1.25.3 with a different image. Without a digest pin, docker compose pull will silently fetch the replacement. You have no cryptographic guarantee that the image you pull today is the same one you tested last week.
Fix¶
Add a digest pin. Get the current digest without pulling the full image:
# Preferred: doesn't require a full pull, returns the OCI index digest
crane digest nginx:1.25.3
docker buildx imagetools inspect nginx:1.25.3 --format '{{ .Manifest.Digest }}'
# Or after pulling
docker pull nginx:1.25.3 && \
docker inspect --format='{{index .RepoDigests 0}}' nginx:1.25.3
Apply it:
# Before
services:
app:
image: nginx:1.25.3
# After
services:
app:
image: nginx:1.25.3@sha256:6a5af...
Pin the index digest, not a per-arch digest¶
For multi-arch images (most official images on Docker Hub), there are two kinds of digests:
- OCI index / manifest list digest — points to a manifest that lists per-architecture manifests. This is what
docker pullresolves through. - Per-arch manifest digest — points to a single architecture (e.g.
linux/amd64).
Pin the index digest. Pinning a per-arch digest will break ARM64 (or AMD64) users who pull on the other architecture. crane digest and docker buildx imagetools inspect return the index digest by default; docker inspect after a pull returns whichever architecture you happened to pull. Verify with docker manifest inspect <image>@<digest> — the result should be a manifest.list.v2+json (index), not a single manifest.v2+json.
Use Dependabot or Renovate to automatically update digest pins when new images are published under the same tag.
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 |
|---|---|
| T1204.003 User Execution: Malicious Image | Execution |
| T1525 Implant Internal Image | Persistence |
See also¶
- CL-0004 — image not pinned to a tag (the weaker prerequisite to digest pinning)