Container & supply-chain security
A container image is not just your code — it's your code plus an entire operating system's worth of packages, plus every dependency those packages pulled in, plus whatever the base image maintainer decided to include. This page covers how to shrink that surface at the base-image layer, catch known vulnerabilities before an image ships, prove where an image actually came from with signing, SBOMs, and the SLSA framework, and reason about a concrete supply-chain attack — dependency confusion — that shows why "it built successfully" is not the same as "it's safe."
Think about the difference between shipping someone a fully furnished apartment versus a single sealed box with exactly the tool they asked for. The furnished apartment has a kitchen, a garage, spare keys in a drawer, a router with a default password — dozens of things a burglar could use even if they only meant to steal one item. The sealed box has one tool and nothing else: nowhere to hide, nothing extra to exploit. A minimal container image is the sealed box. And an SBOM is the packing slip taped to that box, listing exactly what's inside and which supplier made each part — so when a supplier issues a recall, you check the slip instead of tearing the box open to look.
Base image hygiene: minimal and distroless images
Every package in a container image is something you didn't write, might not need at runtime, and are nonetheless responsible for patching. A typical ubuntu:22.04 or debian:bookworm base image ships a shell, a package manager, coreutils, and hundreds of libraries meant for interactive administration — none of which a compiled Go binary or bundled Node app actually needs to run. Every one of those packages is a line item a vulnerability scanner will eventually flag, and every shell binary sitting in the image is a tool an attacker can use to pivot after gaining a foothold, whether that foothold came from your application code or a dependency.
The "distroless" pattern, popularized by Google's gcr.io/distroless image family, strips the base down to just the language runtime and its direct OS-level dependencies — no shell, no package manager, no coreutils. Alpine-based images take a lighter version of the same idea (a real but minimal userland, roughly 5 MB versus Ubuntu's ~80 MB), while scratch goes furthest of all: a genuinely empty base image, viable only for a fully static binary with no runtime dependencies. The trade-off is operational — no shell means no shelling into production to debug, which pushes teams toward ephemeral debug containers (kubectl debug with a sidecar image) instead.
# Multi-stage build: compiler and source tree never reach the shipped image.
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app ./cmd/server
# GOOD — distroless runtime, no shell, no package manager, non-root user.
# (A typical "FROM ubuntu:22.04" runtime stage here would ship a full
# userland and default to running the process as root.)
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]Two things do real work in that second stage. The multi-stage build means the compiler and source tree never cross into the shipped image — only the compiled binary does. And the nonroot distroless variant plus an explicit USER nonroot:nonroot means the process has no path to root even if an attacker achieves code execution inside it; container escapes that rely on a root-owned process to reach the host kernel simply don't have a foothold to start from. Running as root is a common finding in image scans precisely because the default Dockerfile in most tutorials never sets a USER line at all.
Scanning images for known vulnerabilities
Base image hygiene reduces what's in the image; scanning tells you what's actually wrong with what's left. Image scanners — Trivy, Grype, Snyk Container, and the scanning built into registries like Amazon ECR and Google Artifact Registry — unpack an image layer by layer, build a list of every OS package and language-level dependency present, and cross-reference each version against vulnerability databases (the NVD, distro security advisories, the GitHub Advisory Database) to surface known CVEs with a CVSS severity score.
The scan has to run at more than one point to be useful. A scan in CI, gating the merge or build, implements shift-left thinking for containers the same way SAST and SCA do for source code — it stops a known-vulnerable image from ever reaching a registry. But a build-time scan only reflects the CVE database as of that moment; a package clean today can have a CVE disclosed next week without a single line of the image changing. That's why registries also rescan images already stored, and why scanning images actually running matters as a second layer — a pipeline that passed its CI gate six months ago is not necessarily still clean.
Scan policy needs a severity threshold, not an all-or-nothing gate: blocking every build on any CVE at any severity produces enough noise that teams start ignoring the scanner. A workable policy fails the build on Critical and High findings with an available fix, tracks Medium and Low in a backlog, and allows an explicit, time-boxed exception — not a silent suppression — when a fix genuinely isn't available yet.
Image signing and provenance
A scan tells you what's inside an image. Signing tells you the image actually came from who it claims to, and hasn't been altered since. Without it, nothing stops a compromised registry, a man-in-the-middle on a pull, or a malicious insider from swapping a legitimate image for a tampered one under the same tag — the classic incident being a poisoned latest tag silently redirecting a future deploy. The two controls answer different questions and neither substitutes for the other: a signed image can still be full of vulnerable dependencies, and a clean scan means nothing if the image was swapped for a different one after the scan ran.
Image signing applies public-key cryptography to an image the way it's long been applied to software releases and TLS certificates: the party that builds the image signs its digest with a private key, and anyone pulling the image can verify that signature against the corresponding public key first. Sigstore's cosign is the tool most of the industry has converged on for this — it can sign against a locally held key pair, or use "keyless" signing, where identity ties to an OIDC login (a GitHub Actions workflow identity, for example) and the signature is recorded in a public transparency log (Rekor) instead of requiring anyone to manage a long-lived private key. Kubernetes admission controllers like Kyverno or the Sigstore Policy Controller can then enforce signature verification as a deploy-time gate, refusing to schedule any pod whose image isn't signed by an expected identity.
SBOMs and the SLSA framework
A Software Bill of Materials is a structured, machine-readable inventory of every component in a built artifact — every OS package and language dependency, down to specific versions — typically expressed as SPDX (an ISO/IEC standard, ISO/IEC 5962:2021) or CycloneDX (from the OWASP community). Tools like Syft, Trivy, and language-native tooling generate an SBOM directly from a built image as part of CI, and it's stored alongside the artifact. The payoff shows up after the build: when a new CVE is disclosed — Log4Shell in Log4j, December 2021, is the standard example — the operational question is "which of our services actually use this library, and at which version?" Without SBOMs that's a manual, days-long audit; with SBOMs generated at build time and indexed centrally, it's a query against stored inventories that returns a precise list of affected artifacts in minutes.
SLSA (Supply-chain Levels for Software Artifacts, pronounced "salsa") is a framework, originated at Google and now under the OpenSSF, for describing how much you can trust that an artifact was built the way its source claims — not a scanner, a set of increasing levels of process and provenance guarantee you audit a build pipeline against. Roughly: Build L1 requires the build to be scripted and automated, producing basic provenance metadata; Build L2 requires that provenance to come from a managed build service and be signed, so a consumer can tell a real CI-produced artifact from a fabricated one; Build L3 requires the build platform to isolate workflows from each other so one compromised job can't forge convincing provenance for another artifact. Very few organizations run every build at the top level — the framework's real value is a shared vocabulary for stating precisely how much supply-chain trust a pipeline provides, instead of everyone meaning something different by "secure pipeline." It complements the hardening covered in Security in CI/CD: SLSA describes what to prove about a build, and hardened CI configuration is largely how you get there.
Dependency confusion: a concrete supply-chain attack
Dependency confusion is a real, demonstrated attack class. It exploits how package managers (npm, pip, RubyGems, and equivalents) resolve a package name when both a private, internal registry and a public registry could plausibly hold a package with that name. If an organization has an internal package named acme-auth-utils on a private registry, and an attacker publishes a package with the exact same name to the public registry — often with a deliberately higher version number — a misconfigured build can resolve to the public, attacker-controlled package instead, because many package managers' default resolution logic checks the public registry and treats a higher version number there as authoritative regardless of source.
This isn't hypothetical: researcher Alex Birsan demonstrated it at scale in 2021, publishing counterfeit packages under internal names he'd found leaked in package.json files and internal tooling references, and got code execution inside networks at Apple, Microsoft, Tesla, PayPal, and dozens of other companies — each of them pulled the researcher's public package believing it was their own internal one. The fix is configuration, not code: explicit scoping for internal package names (npm's @org-scope/ prefixing), pinning package managers to a private registry as the sole source for internal names rather than falling through to the public one, and reserving placeholder packages on the public registry for internal names so an attacker can't claim them first.
Dependency confusion isn't stopped by image scanning, SBOM generation, or signing — all three of those controls run after the wrong package has already been resolved and pulled into the build. It has to be prevented at dependency-resolution time, through registry scoping and pinning, which is a good reminder that supply-chain security needs controls at every stage — source, build, and artifact — not one scanner covering everything downstream.
1. Why does a distroless or scratch base image reduce risk beyond just having a smaller download size? 2. What does image signing prove that vulnerability scanning does not, and vice versa? 3. Why is an SBOM generated at build time more useful during a new CVE disclosure than auditing the running system from scratch? 4. Why doesn't a mandatory image scan stop a dependency confusion attack?
Check your answers
- Fewer installed packages means fewer components a CVE can ever apply to, and no shell or package manager means an attacker who gains code execution inside the container has far fewer tools available to escalate or pivot — the benefit is a smaller attack surface, not just a smaller image file.
- Signing proves an image came from a specific, verifiable source and wasn't altered afterward (provenance and integrity); it says nothing about whether that image contains known-vulnerable code. Scanning proves the opposite: it finds known vulnerabilities in the contents but can't tell you whether the artifact you're scanning is the one your trusted pipeline actually built.
- Because the SBOM already lists every component and version for every stored artifact, so answering "are we affected" is a fast lookup against existing data instead of a fresh manual audit of every deployed system, which is slow and easy to get wrong.
- Because the attack happens at dependency-resolution time, before the wrong (public, attacker-controlled) package is even pulled into the build — by the time a scanner runs, the malicious package is already part of the build input, so the fix has to be registry scoping and resolution-order configuration, not a downstream scan.