Hardening 6 min read

How to fix CVEs in runtime Docker images — or prove they don't apply

baseimage Analytics Team

You scanned a runtime image — python, node, rust, openjdk — and got 100+ findings. This page tells you which of them are real, how to remove most of the rest in five steps, and how to document what's left so it passes a security review.

Short version
  • • Most findings sit in OS packages your application never runs. Real, actionable issues are usually a handful.
  • • The count drops fastest by upgrading the tag and shrinking the base — not by patching findings one by one.
  • • What you don't fix, you prove: a VEX statement with evidence, not a silent ignore.

Why runtime images ship with 100+ CVEs

A runtime image is an OS layer (Debian/Ubuntu in default tags) + the language runtime + system libraries (OpenSSL, zlib, libxml2). The default tag installs hundreds of general-purpose OS packages — perl, git, gnutls, libsystemd — because the image is built for everyone, not for your app.

A scanner flags every known CVE in every installed package, whether or not your code ever loads it. So the number you see is an inventory of what's installed — not a measure of your risk.

How many findings are real

SignalFigureSource
Critical CVSS that stays critical with runtime context18%Datadog, State of DevSecOps 2025
Critical count once filtered to running + exploitable + exposed−92%Datadog, State of DevSecOps 2025
All published CVEs ever exploited in the wild~1%CISA KEV: ~1,500 of 312,000+
Open-source dependency vulns reachable at function level<9.5%Endor Labs
Dependency code actually used, on average12%Endor Labs

Function-level reachability cuts remediation work by roughly 90%. In practice a 150-finding report typically contains a few issues worth fixing today. The rest you remove structurally or document as not applicable.

Fix these first

A finding is real and urgent if any of these hold:

  1. It's in CISA/VulnCheck KEV — exploited in the wild right now.
  2. EPSS is elevated (above roughly 1–5%, versus the sub-0.5% typical for image findings).
  3. The package is one your app actually loads, on an internet-exposed path.
  4. A fixed version exists and upgrading is cheap.

Patch these directly: bump the affected package or move to the tag where it's fixed. Everything else — next section.

Five steps that cut the count

1. Upgrade the tag

Old tags sit on old OS bases and eventually stop receiving updates entirely — Python 3.9 reached end of life in October 2025, so its images no longer get security patches at all. Moving to a current version on the latest patch release rebases the whole OS layer onto patched packages. Typically the single biggest drop, for zero code changes.

2. Switch to a smaller variant

Fewer packages, fewer findings:

VariantTypical finding count
Full Ubuntu-based runtime image100–200+
-slima few dozen
distroless / Wolfi-basednear zero
Trade-offs
distroless has no shell or package manager (requires a multi-stage build); alpine uses musl instead of glibc and can break native dependencies and Python wheels.

3. Multi-stage build

Compile and install in a builder stage, copy only artifacts into the runtime stage. Compilers, headers, and package managers — with all their CVEs — never ship.

Dockerfile
FROM python:3.13-slim AS builder
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.13-slim
COPY --from=builder /install /usr/local
COPY . /app
USER 1000:1000
CMD ["python", "/app/main.py"]

4. Remove what you don't use

--no-install-recommends, clean caches, drop dev headers and docs.

5. Rebuild and re-scan on a schedule

New CVEs are published against packages that didn't change in your image. A stale minimal image is worse than a fresh slim one. Pin the base by digest, rebuild when it updates, re-scan daily.

Prove the rest doesn't apply

For findings you choose not to fix, record evidence — don't silently ignore:

  • Build-only — the package exists in the builder stage and isn't present in the shipped image.
  • Not reachable — the vulnerable function is never called on any code path your app executes.
  • Not exposed — no network or input path reaches the vulnerable code.
  • Already patched by the distro — Debian/Ubuntu backport security fixes without bumping the version string, and mark low-impact issues “no-dsa”; version-matching scanners still flag both. Check the distro security tracker before treating these as open.

Record each as a VEX statement: status not_affected plus a standard justification (for example vulnerable_code_not_in_execute_path). VEX is machine-readable — your scanner suppresses the finding, your auditor accepts the evidence, and the question doesn't come back next quarter.

The manual cost — and the alternative

Done by hand, this is roughly a day per image: upgrade, rebase, restructure the build, check distro trackers for backports, write VEX for the remainder — then repeat every time the base updates or a new CVE lands.

BaseImage ships hardened runtime images with this already done: noise removed, remainder documented, re-scanned daily so the one CVE that starts being exploited reaches you first.

Hardened images

Skip the day of manual work

Noise removed, the remainder documented with VEX, re-scanned daily.

Harden my image

We rebuild your runtime image with far fewer CVEs — and prove the rest can't be exploited.