Skip to content
Published on

How to Read Container Image Vulnerability Scan Results — The Real Way to Turn Hundreds of Criticals Into Zero

Share
Authors

Introduction — Standing in Front of a Report With Nine Criticals and 1,247 Findings

Attach an image scanner to your pipeline for the first time and this is usually the output you see.

trivy image --scanners vuln registry.acme.com/payments:1.4.2
registry.acme.com/payments:1.4.2 (debian 12.6)
Total: 1247 (UNKNOWN: 0, LOW: 812, MEDIUM: 289, HIGH: 137, CRITICAL: 9)

node-pkg (node-pkg)
Total: 6 (UNKNOWN: 0, LOW: 0, MEDIUM: 4, HIGH: 2, CRITICAL: 0)

Paste this report into the team channel as is and one of two things follows. Everyone ignores it, or the release is blocked with nobody able to touch it. In neither case does security improve.

The problem is not the number but the interpretation. Of the 1,247 findings, the ones that actually need action are usually a single digit, and the way to find that single digit is not a severity filter. And the way to remove the remaining 1,200 or so is not patching them one at a time.

This post covers what a scanner sees and what it cannot see, which indicators you should filter by, and what action in practice actually drops this number to double digits or below.

What a Scanner Really Does — Diffing a List Against a Database

An image scanner neither runs the image nor analyzes the code. What it does happens in two steps.

First, it unpacks the image layers and builds a list of installed packages. For Debian derivatives it reads /var/lib/dpkg/status, for RPM derivatives the RPM database, and for language packages a lockfile or a metadata directory.

trivy image --list-all-pkgs --format json node:22 \
  | jq '[.Results[].Packages // [] | length] | add'
432

Second, it diffs the names and versions in that list against a vulnerability database. That is all.

trivy image --format json node:22 \
  | jq -r '.Results[].Vulnerabilities[]? | [.PkgName, .InstalledVersion, .VulnerabilityID, .Severity] | @tsv' \
  | head -5
libssl3   3.0.14-1~deb12u2   CVE-2024-5535   CRITICAL
libc6     2.36-9+deb12u8     CVE-2025-0395   MEDIUM
perl-base 5.36.0-7+deb12u1   CVE-2023-31486  HIGH
zlib1g    1:1.2.13.dfsg-1    CVE-2023-45853  CRITICAL
tar       1.34+dfsg-1.2      CVE-2005-2541   HIGH

Understand this structure and every property of a scanner follows from it.

If there is no package metadata, it is invisible. A library you built from source and dropped in, a binary pulled with curl and copied, a statically linked dependency: none of these appear in the list, so no vulnerability is reported for them. That is the first reason a clean scan result does not mean safe.

Conversely, if it is on the list it gets reported regardless of whether it runs. If tar is installed in the image, it shows up in the vulnerability list even when the application never calls it once. The last line of the output above, a CVE registered back in 2005 still appearing, has the same cause.

The Structure of False Positives — Vendor Backport Patches

The most frequent kind of false positive is the backport. For stability, a distribution ports only the security fix into the existing version without bumping the version number.

docker run --rm debian:12-slim bash -lc 'dpkg -l | grep -E "^ii\s+openssl"'
ii  openssl  3.0.14-1~deb12u2  amd64  Secure Sockets Layer toolkit

By upstream standards, 3.0.14 is vulnerable to a certain CVE. But Debian ported the fix into the deb12u2 revision. You can confirm it.

docker run --rm debian:12-slim bash -lc \
  'apt-get -qq update && apt-get -qq changelog openssl 2>/dev/null | head -12'
openssl (3.0.14-1~deb12u2) bookworm-security; urgency=medium

  * Fix CVE-2024-5535: SSL_select_next_proto buffer overread
  * Fix CVE-2024-4741: use-after-free in SSL_free_buffers

 -- Debian Security Team  Mon, 08 Jul 2026 21:14:02 +0000

A tool that only looks at the version string and diffs it against the ranges in an upstream database reports this as vulnerable. So one criterion for choosing a scanner emerges. You have to use a scanner that consumes the distribution security advisories as a data source. A tool that references only the version ranges of the upstream database produces false positives in bulk on Debian, Ubuntu and RHEL images.

When the data source is wired up properly, a status field appears.

trivy image --format json debian:12-slim \
  | jq -r '.Results[].Vulnerabilities[]? | .Status' | sort | uniq -c | sort -rn
     58 will_not_fix
     23 affected
      6 fixed
      2 end_of_life

will_not_fix means the distribution security team reviewed the item and decided not to fix it in this release. In most cases there is no real impact, or the exploitation preconditions do not hold. affected means it is acknowledged but there is no fix yet.

This is where the most practically useful option comes in.

trivy image --ignore-unfixed --severity CRITICAL,HIGH registry.acme.com/payments:1.4.2
Total: 4 (HIGH: 3, CRITICAL: 1)

1,247 findings became 4. Nothing got safer, but only the items you can act on right now remain. Keeping items with no available fix in the queue is what makes the queue meaningless.

What a Scanner Cannot See

A passing scan does not make an image safe. There are things it structurally cannot see.

Application code. Injection, missing authorization and unsafe deserialization in the code we wrote ourselves are not what an image scanner looks at. In real breaches, this side is actually the larger share of entry points.

Configuration. A container running as root, excessive privileges, host path mounts and a writable root file system are not CVEs, so they are not in the vulnerability list. They need a separate scanner.

trivy image --scanners vuln,secret,misconfig registry.acme.com/payments:1.4.2
payments:1.4.2 (secrets)
HIGH: AWS Access Key ID
 Dockerfile:22
   ENV AWS_ACCESS_KEY_ID=AKIA****************

payments:1.4.2 (dockerfile)
HIGH: DS002 Image user should not be root
   Specify at least 1 USER command with non-root user

Files that came in without metadata are also invisible. The following is a vulnerable binary that a scan will never catch.

RUN curl -fsSL https://example.com/tools/legacy-cli-1.2.0.tar.gz \
  | tar xz -C /usr/local/bin

Things that arrive by this path are in neither the SBOM nor the scan results. When you put something into an image, going through a package manager pays off in observability.

Finally, a scanner does not know whether the library is on an execution path. If it is installed, it gets reported. The following command gives you a rough sense of what is actually used.

# The shared libraries the process actually opens
docker run --rm --entrypoint sh registry.acme.com/payments:1.4.2 -c \
  'ldd /usr/local/bin/node | awk "{print \$1}" | sort'
libc.so.6
libcrypto.so.3
libdl.so.2
libgcc_s.so.1
libm.so.6
libpthread.so.0
libssl.so.3
libstdc++.so.6

Eight of them. Out of the 432 packages installed in the image, this is how many the application actually links against. Most of the rest came along with the base image, and this observation leads to the conclusion covered further down.

Why CVSS Alone Is Not Enough — EPSS and KEV

Setting priorities by CVSS score alone fails. CVSS is a technical assessment of the impact and difficulty if a vulnerability were exploited, not the probability that it actually will be. And the share of registered vulnerabilities for which exploitation is actually observed is on the order of a few percent.

Two indicators have to be read alongside it. EPSS is an estimate of the probability that exploitation will be observed within the next 30 days, and KEV is the list of vulnerabilities where exploitation has been confirmed.

curl -s 'https://api.first.org/data/v1/epss?cve=CVE-2021-44228,CVE-2023-45853,CVE-2024-5535' \
  | jq -r '.data[] | [.cve, .epss, .percentile] | @tsv'
CVE-2021-44228  0.944400  0.99970
CVE-2023-45853  0.004120  0.74331
CVE-2024-5535   0.001960  0.58207

The first has a 94 percent exploitation probability; the other two are under 1 percent. All three are rated Critical by CVSS. By severity alone the three look identical, but the urgency of the response is completely different.

Checking against KEV is even simpler.

curl -s https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \
  | jq -r '.vulnerabilities[].cveID' | sort -u > /tmp/kev.txt

trivy image --format json registry.acme.com/payments:1.4.2 \
  | jq -r '.Results[].Vulnerabilities[]?.VulnerabilityID' | sort -u > /tmp/found.txt

comm -12 /tmp/found.txt /tmp/kev.txt
CVE-2023-38545

Of the 1,247 findings, exactly one has confirmed exploitation. That one is the item to handle today; the rest can be pushed to a regular cycle.

To sum up, a priority judgment is the product of three axes. Exploitability (KEV, EPSS), exposure (is this a service reachable from the internet), and reachability (is that code on an execution path). CVSS answers none of the three.

Removing 1,247 Findings Is Not Patching but Replacing the Base Image

This is the practical core of the post. The most effective action for reducing the vulnerability count is not upgrading individual packages but making sure those packages are not in the image in the first place.

Put the same application on several bases and scan them, and the difference is obvious.

Base imageImage sizePackages detectedFixable CRITICALFixable HIGHShell and package manager
node:221.12 GB4329137included
node:22-slim231 MB118224included
node:22-alpine148 MB4103included
gcr.io/distroless/nodejs22-debian12187 MB1901none
static binary plus scratch24 MB000none

The absolute numbers vary with the scan date, but the trend is always the same. The fewer files in the image, the fewer vulnerabilities. It sounds obvious, but in practice teams usually spend dozens of hours on individual CVEs before arriving at this conclusion.

The transition is done with a multi-stage build.

# syntax=docker/dockerfile:1

FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build && npm prune --omit=dev

FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/dist ./dist
USER nonroot
EXPOSE 8080
CMD ["dist/server.js"]

The build stage can keep compilers and headers, because they do not go into the final image. Compare the results.

trivy image --ignore-unfixed --severity CRITICAL,HIGH registry.acme.com/payments:1.4.2
trivy image --ignore-unfixed --severity CRITICAL,HIGH registry.acme.com/payments:1.5.0
payments:1.4.2 (debian 12.6)
Total: 4 (HIGH: 3, CRITICAL: 1)

payments:1.5.0 (debian 12.6)
Total: 0 (HIGH: 0, CRITICAL: 0)

payments:1.5.0 (node-pkg)
Total: 2 (HIGH: 2, CRITICAL: 0)

OS package vulnerabilities went to 0, and the two remaining findings are npm dependencies of our own application. The target for action is now unambiguous.

It helps to know in advance the three places where moving to distroless snags in practice.

There is no shell, so you cannot get in with kubectl exec. Debugging changes into attaching an ephemeral container.

kubectl debug -it pod/payments-7d9f -c debugger --image=busybox --target=app

Writing CMD in shell form does not work. It has to be exec form only. And any configuration that used curl or wget for health checks has to be changed. Implement it in the language runtime or move to a gRPC health check.

If you need CA certificates, you have to check whether they are included in the image. The nonroot tag of the distroless base has them; scratch does not.

Putting It in CI — Gate Criteria and Exceptions With Expiry Dates

The moment you put a scan into CI, a policy decision is required: under what condition do you fail the build.

Fail on every Critical and development stops. Do not fail at all and nobody looks. The criterion that stays stable in practice is the following combination.

trivy image \
  --scanners vuln \
  --severity CRITICAL,HIGH \
  --ignore-unfixed \
  --ignorefile .trivyignore.yaml \
  --exit-code 1 \
  registry.acme.com/payments:1.5.0

Only Criticals and Highs that have a fix available are subject to the gate. In other words, you fail only on "something you could fix right now and did not." Add on top of that a rule that treats any item on the KEV list as an immediate failure regardless of severity, and you will not miss the real risk.

if comm -12 /tmp/found.txt /tmp/kev.txt | grep -q .; then
  echo "A vulnerability on the KEV list is present. Halting the deployment."
  comm -12 /tmp/found.txt /tmp/kev.txt
  exit 1
fi

There is one rule that matters most in exception handling: forbid indefinite suppression. An exception file must always carry a reason and an expiry date.

# .trivyignore.yaml
vulnerabilities:
  - id: CVE-2024-45491
    paths:
      - usr/lib/x86_64-linux-gnu/libexpat.so.1.8.10
    statement: This service does not parse untrusted XML. To be resolved by the August base image swap.
    expired_at: 2026-09-30

  - id: CVE-2023-45853
    statement: zlib is not statically linked and there is no call path. Debian verdict is will_not_fix.
    expired_at: 2026-10-31

Once the expiry date passes, the scanner fails again. It is the only way to keep an exception from quietly becoming permanent. Put the exception file under code review and report items nearing expiry periodically, and it stays manageable.

python3 - <<'PY'
import datetime, yaml
data = yaml.safe_load(open(".trivyignore.yaml"))
today = datetime.date.today()
for v in data.get("vulnerabilities", []):
    due = v["expired_at"]
    left = (due - today).days
    if left < 30:
        print(f"{v['id']}  expires in {left} days  {v['statement'][:40]}")
PY

The last step is turning scan results into enforcement. Sign the images that passed CI, and make the cluster accept only signed images.

cosign sign --yes registry.acme.com/payments:1.5.0
cosign attest --yes --type cyclonedx --predicate sbom.cdx.json registry.acme.com/payments:1.5.0
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signature
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - registry.acme.com/*
          attestors:
            - entries:
                - keyless:
                    subject: https://github.com/acme/payments/.github/workflows/release.yaml@refs/heads/main
                    issuer: https://token.actions.githubusercontent.com

With this policy in place, an image pushed by hand around the pipeline cannot get into the cluster. This is the point at which the scan gate is actually enforced. Before that it is closer to an advisory you can bypass.

Wrapping Up — Look at the Actionable Items, Not the Number in the Report

Reading scan results comes down to three steps.

First, add --ignore-unfixed. For items with no fix there is nothing you can do today, and leaving them in the list hides everything else. This one option usually drops the number to double digits or below.

Next, set priorities with KEV and EPSS. If one of nine CVSS Criticals has confirmed exploitation, today's work is that one. A severity rating carries far too little information to be used as a sort key.

And before patching what remains one by one, look at the base image. If the application actually links against eight shared libraries while the image contains 432 packages, the problem is not the vulnerabilities but the image composition. One day of moving to distroless or a minimal image often replaces weeks of individual CVE work.

Finally, always put an expiry date on exceptions. Once items suppressed permanently without a reason pile up, the exception file itself becomes a new blind spot. The value of scan results is measured not by the number of findings opened but by the number closed.