Skip to content

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 pull resolves 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)