900 MB of someone else's vulnerabilities
ShopLite's first Dockerfile used node:latest: a full Debian with compilers, curl, git, and hundreds of CVEs — none of which the app ever uses.
- Weakness
- CWE-1104 · Use of unmaintained third-party components
- Target
- ShopLite image base layers
trivy image for OS packagesslim vs alpine vs distrolessmulti-stage buildspinning by digest + automated rebuildsRun these techniques only against the lab you own. Using them on systems you don't have permission to test is illegal.
The threat
01Every package is attack surface
A full OS base ships shells, package managers, compilers, and network tools. The app uses none of them, but after any code-execution bug, an attacker can use all of them. Each package also carries its own vulnerabilities, which you inherit and must track. A smaller image is a smaller target and much less scanner noise, so real findings stand out.
What's at risk
- A long list of inherited CVEs, and ready-made tools for anyone who gets code execution inside the container.
Detect
01Compare bases with the same scanner
terminal$ for img in node:22 node:22-slim node:22-alpine gcr.io/distroless/nodejs22-debian12; doprintf "%-42s " "$img"; docker run --rm aquasec/trivy:0.63.0 image -q --scanners vuln --format json "$img" \| jq '[.Results[]?.Vulnerabilities[]?] | "\(length) vulns, \(map(select(.Severity=="CRITICAL" or .Severity=="HIGH")) | length) high+critical"'done── output ──node:22 "1204 vulns, 147 high+critical"node:22-slim "96 vulns, 11 high+critical"node:22-alpine "9 vulns, 1 high+critical"gcr.io/distroless/nodejs22-debian12 "14 vulns, 0 high+critical"Exact numbers change daily as advisories are published and images rebuilt; the order of magnitude is the lesson.
Defend
01Multi-stage: build with tools, ship without them
The build stage has npm and everything needed to install dependencies. The final stage copies only the app and its production
node_modulesonto a distroless base, which has no shell and no package manager, just the Node runtime and the libraries it needs. It runs as non-root by default (:nonroottag).Vulnerable
Dockerfilewhole filedocker FROM node:latest WORKDIR /app COPY . . RUN npm install CMD ["node", "server.js"]Hardened
Dockerfilewhole filedocker # syntax=docker/dockerfile:1 FROM node:22-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev --ignore-scripts COPY server.js ./ FROM gcr.io/distroless/nodejs22-debian12:nonroot WORKDIR /app COPY --from=build /app /app CMD ["server.js"]02Pin by digest, rebuild on a schedule
Pin the base image by digest for reproducibility (
FROM image@sha256:...), and let Renovate or Dependabot open PRs when the upstream digest changes. Rebuild and redeploy at least weekly even without code changes, because base images receive security patches constantly.
Verify
01Re-scan the final image
terminal$ docker build -t shoplite:distroless . && docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.63.0 image -q --severity HIGH,CRITICAL shoplite:distrolessdocker images shoplite --format '{{.Tag}} {{.Size}}'── output ──(no HIGH or CRITICAL findings)distroless 168MBbad 1.12GB
The concepts
Choosing a base
slim Debian variants remove docs and build tools while keeping glibc compatibility. alpine is tiny (musl libc; occasionally incompatible with native modules). DISTROLESS and hardened minimal images (Google distroless, Chainguard/Wolfi, Docker Hardened Images) contain only the runtime and are rebuilt frequently. The trade-off: no shell for debugging. Use ephemeral debug containers (kubectl debug) instead of baking tools in.
Your turn
With no shell in the distroless image, how do you debug a running container?
Interview questions
How do you minimise vulnerabilities in container images?