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

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — Standing in Front of a Report With Nine Criticals and 1,247 Findings
- What a Scanner Really Does — Diffing a List Against a Database
- The Structure of False Positives — Vendor Backport Patches
- What a Scanner Cannot See
- Why CVSS Alone Is Not Enough — EPSS and KEV
- Removing 1,247 Findings Is Not Patching but Replacing the Base Image
- Putting It in CI — Gate Criteria and Exceptions With Expiry Dates
- Wrapping Up — Look at the Actionable Items, Not the Number in the Report
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 image | Image size | Packages detected | Fixable CRITICAL | Fixable HIGH | Shell and package manager |
|---|---|---|---|---|---|
| node:22 | 1.12 GB | 432 | 9 | 137 | included |
| node:22-slim | 231 MB | 118 | 2 | 24 | included |
| node:22-alpine | 148 MB | 41 | 0 | 3 | included |
| gcr.io/distroless/nodejs22-debian12 | 187 MB | 19 | 0 | 1 | none |
| static binary plus scratch | 24 MB | 0 | 0 | 0 | none |
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 /app/node_modules ./node_modules
COPY /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.