Skip to content

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 scratch with 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-debian12

Pin 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 node or a created UID). A container escape from root is a much worse afternoon.
  • COPY . . drags .git, .env, and test fixtures into the image unless .dockerignore says otherwise.
  • --mount=type=secret for build-time secrets; ARG values persist in image history forever.
  • hadolint Dockerfile lints 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 slim variants are usually the quieter win.
  • Scan the image you actually deploy — by digest — not the :latest you happened to test.