Lesson 7 of 9 · 22m
Container Scanning: Images Are Frozen Filesystems
Every package in the image ships to prod and stays that vulnerable until you rebuild — base-image hygiene, digest pinning, and Trivy flags that keep the gate honest.
A container image is a frozen filesystem. Every package in it — the Debian userland, the Python wheels, the debugging tool someone apt installed six months ago — ships to production and stays exactly that vulnerable until you rebuild. The scan itself is easy; the interesting part is realizing your node:18 base pulled last March is carrying a year of unpatched OpenSSL.
What the scanner sees
Trivy, Grype, and friends unpack image layers and inventory two things: OS packages (dpkg/apk/rpm databases) and language packages (node_modules, site-packages, gems), then match that inventory against CVE feeds. Two consequences:
- It reports what's installed, not what's exploited — same reachability caveats as dependency scanning.
- It can't flag what isn't there. An image
FROM scratchwith a static binary has almost nothing to report — that's the point of distroless.
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 registry.internal/app@sha256:4b2f...--ignore-unfixed is doing heavy lifting: it drops findings with no released patch, which on a Debian base is a firehose of "yes, we know, there's no fix." Gating on unfixable CVEs is how you teach a team to ignore the gate.
Most findings are base-image debt
Before fixing a single finding, check what fraction lives in the OS layer — usually it's most of them. The leverage moves:
# fat: full OS — shell, package manager, hundreds of packages
FROM node:20
# lean: distroless — no shell, no package manager, near-zero OS surface
FROM gcr.io/distroless/nodejs20-debian12Pin by digest, not tag: node:20 today is not node:20 in six weeks. image@sha256:... makes builds reproducible and makes the scan report actually describe what you ran. Then rebuild on a schedule even when code doesn't change — images rot sitting still.
The Dockerfile is a security boundary
USER— run as non-root (USER nodeor a created UID). A container escape from root is a much worse afternoon.COPY . .drags.git,.env, and test fixtures into the image unless.dockerignoresays otherwise.--mount=type=secretfor build-time secrets;ARGvalues persist in image history forever.hadolint Dockerfilelints all of this mechanically — run it next to the image scan.
Where to scan
Twice: in CI before push (gate on it) and in the registry on a schedule (monitor it). An image clean at build time has new CVEs by Friday; the registry rescan is how you notice.
Field notes
- Multi-stage builds aren't only about size — the build stage carries compiler and toolchain CVEs; the final stage shouldn't.
- Alpine isn't automatically better: musl vs glibc breaks things subtly. Distroless or
slimvariants are usually the quieter win. - Scan the image you actually deploy — by digest — not the
:latestyou happened to test.