Safeguard
Container Security

Docker image vulnerability scanning: best practices for CI/CD

Log4Shell hid in countless container images for years before scanning caught it. Here's how to scan base layers and gate builds before that happens again.

Safeguard Research Team
Research
Updated 7 min read

A Docker image is not one artifact to scan — it's a stack of them. Pull python:3.11 today and you inherit a Debian base layer with its own package manager, a Python runtime, and whatever pip install pulled into site-packages, each with a separate vulnerability surface and a separate database to check it against. Docker image vulnerability scanning exists to walk that whole stack — every layer, every package inventory — and match it against known CVEs before the image ships. That layering is exactly why CVE-2021-44228, Log4Shell, spread as far as it did: Apache disclosed the Log4j2 remote-code-execution flaw on December 9, 2021, and because Log4j2 was a transitive dependency baked silently into Java base images and application layers across the industry, teams that had never run an image scan had no way to even ask "am I affected?" until they started scanning. Four years later, the tooling to answer that question is mature and free — Trivy, Grype, Syft, Docker Scout, and Clair all match installed packages against NVD, GHSA, OSV, and distro trackers via CPE or purl identifiers, and any of them can run as an exit-code-driven step in GitHub Actions, GitLab CI, or Jenkins. This post covers what actually gets scanned in an image, how to shrink what's there to scan, and how to turn a scan result into a real CI/CD gate instead of a report nobody reads.

What actually lives inside a container image that needs scanning?

A container image needs scanning at three distinct layers, each requiring different detection logic. The base OS layer carries packages installed via a distro's package manager — apt on Debian/Ubuntu images, apk on Alpine, yum/dnf on RHEL-based images — and a scanner has to query that distro's own package database to know installed versions. The language runtime and its package manager form a second layer: a node:20 image's OS packages are one inventory, and everything npm install pulled into node_modules is another, tracked through lockfiles like package-lock.json, poetry.lock, or go.sum. The third layer is your own application code plus anything copied in via COPY or built via multi-stage steps. Scanners like Trivy and Grype handle all three by combining an OS package parser with language-ecosystem lockfile parsers, then matching every resolved name-and-version pair against vulnerability feeds. Miss any one layer — scan only the base image and skip the lockfile, for instance — and you have a partial answer that looks complete.

Why do minimal and distroless base images actually reduce risk?

Minimal and distroless base images reduce risk by shrinking the number of installed packages a scanner has to check and an attacker has to exploit, not by hiding vulnerabilities. A ubuntu:latest image ships a general-purpose package set — shells, package managers, editors, libc utilities — most of which an application container never uses at runtime but which each carry their own patch cadence and CVE history. Google's gcr.io/distroless images strip that down to a language runtime and its direct system dependencies, with no shell or package manager at all. Multi-stage Docker builds reinforce this: you compile or pip install in a full-featured builder stage, then COPY --from=builder only the compiled artifact or resolved dependencies into a slim final stage, so build-time tooling never ships to production. The result isn't zero CVEs — a distroless image still has a libc, a runtime, and your application's own dependencies — but a materially smaller inventory means a materially smaller set of things a scan can even find wrong, and fewer surprise packages nobody remembers adding.

Why does pinning by digest instead of tag matter for scanning?

Pinning by digest instead of tag matters because a mutable tag like :latest or even :3.11 can point to a different image tomorrow than it did when you scanned it. FROM python:3.11 resolves to whatever the maintainers most recently pushed under that tag — if they rebuild it with a newer patch version, your next docker build silently pulls different bits than your last scan approved. FROM python:3.11@sha256:<digest> pins to one immutable content hash: the image you scanned in CI is byte-for-byte the image that ships. This is standard supply-chain integrity practice, not container-security folklore — it's the same principle behind lockfiles for language dependencies, applied one layer down. Teams that scan a tag but deploy whatever that tag resolves to at deploy time have effectively skipped the scan; digest pinning is what makes "we scanned this image" and "we deployed this image" refer to the same artifact.

How should a CI/CD pipeline actually gate on scan results?

A CI/CD pipeline should gate on scan results by failing the build when findings cross a defined threshold, not by merely publishing a report alongside a green checkmark. The common pattern: run Trivy, Grype, or an equivalent scanner as a pipeline step immediately after docker build, have it exit non-zero when it finds vulnerabilities at or above a chosen severity or CVSS score, and let that exit code fail the job. Safeguard implements this as Policies and Gates: a policy defines thresholds — no critical CVEs, nothing above CVSS 7.0, no actively-exploited (high-EPSS) findings — and a gate enforces it in the pipeline via an action step (safeguard-sh/gate-action@v1, configured with fail-on: critical) that blocks, warns, or requires manual approval depending on how the gate is configured. The choice of threshold matters: teams that gate on every medium-severity finding from day one typically get bypassed with --force merges within weeks; starting at critical/high and tightening over time keeps the gate credible.

What happens to an image after it's already been scanned and shipped?

A scan result has a shelf life, because the vulnerability databases it was checked against keep growing after the image ships. An image scanned clean the day it was built can become non-compliant a week later purely because a new CVE was disclosed against a package already sitting in one of its layers — nothing in the image changed, but the risk picture did. This is the freshness gap that pure build-time gating misses: it catches known-bad images at build time but says nothing about images already running in production when a new CVE, KEV entry, or EPSS score change lands. Continuous scanning closes that gap by rescoring previously-scanned images against live vulnerability feeds on an ongoing basis, so a Log4Shell-style disclosure surfaces against every affected image already deployed, not just the next one built. Combined with an SBOM generated at build time — a CycloneDX or SPDX manifest of every package in every layer — a team can answer "which of our running images are affected" in minutes by querying inventory instead of re-pulling and re-scanning every image in the registry. Treated this way, Docker image vulnerability scanning becomes a lifecycle practice rather than a one-time build step.

How Safeguard helps

Safeguard connects directly to Amazon ECR, Docker Hub, GCP Cloud Source, and OCI registries, both public and private, and scans base image layers, package-manager inventories (apt, yum, apk, npm, pip, and more), application dependencies, and embedded binaries in one pass rather than stitching together separate OS and language scanners. It generates an SBOM on every scan and re-evaluates it continuously as new CVE, KEV, and EPSS data lands, so an image that passed at build time gets flagged the moment a disclosure like Log4Shell affects a package already sitting in one of its layers. Policies define the thresholds that matter for a given environment — severity, CVSS, EPSS, image age — and Gates enforce them directly in CI/CD through an action step that can block a build, warn, or require approval, so "scan the image" and "stop the bad image from shipping" are the same control instead of two separate steps a team has to remember to wire together.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.