# Docker base image SHA pinning

Owner: platform team Last reviewed: 2026-05-28 Audit reference: pass 3 #145 (B4)

## Why pin

The audit pass 3 sweep found 100+ Dockerfiles in the repo that ship
`FROM <image>:<tag>` without a content-addressable digest. The implications:

1. **Non-reproducible builds.** `node:22-alpine` today and `node:22-alpine`
   tomorrow are different bytes. A successful build today may fail the same
   build pipeline tomorrow when the upstream image rebuilds.
2. **Supply-chain attack surface.** If the upstream registry is compromised, or
   a tag is silently re-pushed to point at a malicious layer, every downstream
   build pulls the new bytes — no audit trail, no warning.
3. **No drift detection.** Without digests, the lockfile tells you nothing about
   what was actually built; the image hash is the only ground truth.

The fix is to rewrite `FROM image:tag` into `FROM image:tag@sha256:<digest>`.
Renovate then handles bumping the digest on a schedule.

## How we pin

This repo's [`renovate.json`](../../renovate.json) extends `docker:pinDigests`,
which:

- On first run, opens one PR per Dockerfile that rewrites every `FROM` line to
  include the current registry digest for the named tag.
- On every weekly run, opens a single grouped PR ("docker base image digests")
  that bumps any digest whose upstream image has been republished.

The first-pass PRs need a human reviewer to merge in batches. We do NOT
auto-merge because:

- A digest bump for an alpine base sometimes ships a soname ABI break that
  breaks our native-binding builds.
- We want the security team to glance at any digest change to a base image that
  runs untrusted user code.

## What the audit's grep would find

Run locally to verify which Dockerfiles still need pinning:

```bash
grep -L "^FROM .*@sha256:" $(find . -name "Dockerfile*" -not -path "*/node_modules/*")
```

Anything listed is missing a pinned digest. Expected steady state after Renovate
has run a full cycle: empty.

## Per-image policy

| Base image family            | Pinning required? | Why                                           |
| ---------------------------- | ----------------- | --------------------------------------------- |
| node:\* (slim, alpine, base) | Yes               | Used in 70+ services                          |
| python:\* (slim, alpine)     | Yes               | Used by psyche, lilith ml workers             |
| nvidia/cuda:\*               | Yes               | Bigger ABI surface; harder to debug a regress |
| runpod/\* base images        | Yes               | Stand-in for our own bases; pin to be safe    |
| scratch                      | No                | No content to pin                             |

## Recovery

If a pinned digest goes missing from the registry (rare — Docker Hub does GC
dangling tags after 6 months), `docker pull` will fail. Renovate's next weekly
run will surface the missing digest in a PR with an updated valid digest from
the next-newest publish. Until the PR merges, builds that depend on the missing
digest fail loudly — which is the correct behaviour versus silently pulling new
bytes.

## Manual one-off pin

If a developer wants to pin a single Dockerfile ahead of Renovate's schedule,
the workflow is:

```bash
DIGEST=$(docker buildx imagetools inspect node:22-alpine | grep -E '^Digest:' | awk '{print $2}')
# Edit the Dockerfile to read:
#   FROM node:22-alpine@${DIGEST}
```
