Docker & ContainersXII · Supply ChainCVE scanning
CVE scanning — Trivy, Grype, Docker Scout
What you'll learn
- Explain the mechanism a scanner uses, and derive from it what the scanner is blind to
- Run Trivy, Grype and Docker Scout with flags that exist and mean what you think
- Explain why two scanners report different severities for the same package
- Gate CI on findings that are actionable rather than on a number
Prerequisites
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12
CVE scanning compares the components in your images against a database of known vulnerabilities. The output is a list of the form “this version of openssl is affected by CVE-2024-1234”.
Before any of the tooling, one sentence that reframes everything else in this lesson:
A scanner reports the packages it can see.
Not the code in the image. Not the behaviour of the image. The packages it can identify. Everything that follows — the false comfort, the disagreements between tools, the clean report on a suspicious image — comes from that.
Trivy
The de facto standard: free, fast, and it reads more package formats than anything else.
IMG=registry.example.com/myorg/myapp@sha256:REPLACE_ME
trivy image "$IMG"The flags that matter, all current:
| Flag | Effect |
|---|---|
--severity | Filter to UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL |
--exit-code | Exit code when issues are found — this is the CI gate |
--ignore-unfixed | Show only vulnerabilities that have a fix available |
--scanners | vuln, misconfig, secret, license; defaults to vuln,secret |
--pkg-types | os, library, or both (the default) |
--format | table, json, sarif, cyclonedx, spdx-json, cosign-vuln, others |
--ignorefile | Path to a .trivyignore or .trivyignore.yaml |
--show-suppressed | Display findings that a suppression rule removed |
Grype
Anchore’s scanner, and a useful second opinion precisely because it matches differently.
IMG=registry.example.com/myorg/myapp@sha256:REPLACE_ME
grype "$IMG" --fail-on high --scope all-layers --only-fixed--fail-on accepts negligible, low, medium, high and
critical, and sets the return code to 2 when a vulnerability at or
above that level is found. --only-fixed is Grype’s equivalent of
Trivy’s --ignore-unfixed.
Docker Scout
Cloud-backed, integrated with Docker Hub and Docker Desktop.
IMG=registry.example.com/myorg/myapp@sha256:REPLACE_ME
docker scout cves "$IMG" \
--only-severity critical,high \
--only-fixed \
--ignore-base \
--exit-code--exit-code returns 2 when vulnerabilities are found. --epss adds
Exploit Prediction Scoring System scores and --only-cisa-kev filters
to CVEs in the CISA Known Exploited Vulnerabilities catalogue — which
is a far better prioritisation signal than CVSS alone, because it is a
statement about observed exploitation rather than about theoretical
impact.
Why two scanners disagree
Run Trivy and Grype on the same image and you will get different counts, different severities, and each will report at least one thing the other does not. This is normal and it is not a bug in either.
$ trivy image --severity HIGH,CRITICAL myapp:1.0.0 | tail -3; grype myapp:1.0.0 --fail-on high | tail -3Total: 4 (HIGH: 3, CRITICAL: 1)
[0004] CVE-2024-0000 libssl3 HIGH
5 vulnerabilities foundIllustrative output
The causes, in rough order of how often they explain a discrepancy:
Severity source. A distribution’s security team assesses a CVE in the context of how they built the package — compiler hardening, a backported patch, a feature not compiled in. Debian may rate as minor something NVD rates 9.8. A scanner that prefers the distro rating and one that prefers NVD will disagree loudly on exactly the same package. Neither is lying.
Backported fixes. Enterprise distributions fix a vulnerability
without changing the upstream version number. 1.1.1k-1+deb11u5
contains the fix that upstream shipped in 1.1.1w. A matcher keyed on
upstream versions flags it; a matcher that understands the distro’s
versioning does not. This is the classic false positive and it is why
“just use the version number” does not work.
Catalogue differences. One tool parses a package format the other does not, so it sees components the other never enumerates.
Database freshness. Both tools ship a local database updated on a schedule. A finding that appears in one and not the other may simply be six hours of lag.
The practical conclusion: pick one scanner as the gate and use the other as a periodic cross-check. Gating on two tools means gating on the union of their false positives, and a pipeline that fails for reasons nobody can act on gets bypassed within a month.
Gating CI on something actionable
set -euo pipefail
IMG=registry.example.com/myorg/myapp@sha256:REPLACE_ME
trivy image --format json --output trivy-full.json "$IMG"
trivy image \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 1 \
"$IMG"myapp (debian 12.5)
Total: 0 (HIGH: 0, CRITICAL: 0)Illustrative output
Suppression that expires
A blanket ignore file is how a scanner becomes decoration. Trivy’s
.trivyignore.yaml format takes an expiry date and a written
justification, which turns a suppression into a decision with a review
date.
vulnerabilities:
- id: CVE-2024-0000
statement: >-
Backported fix present in the Debian package; the upstream version
string is misleading. Confirmed with the distro security tracker.
expired_at: 2026-11-01
- id: CVE-2024-1111
purls:
- "pkg:golang/example.com/vendored/lib"
statement: >-
Vulnerable code path is the TLS server, which this binary does not
start. Re-review if the service ever listens.
expired_at: 2026-10-01The statement field is the point. A suppression with no written
reason is indistinguishable from a suppression added at 17:55 on a
Friday to make a build go green, and six months later nobody can tell
them apart. Add --show-suppressed to a periodic report so the
suppression list is visible rather than invisible.
For a more rigorous version of the same idea, Trivy consumes VEX
documents via --vex. VEX lets the producer of a component state
“this CVE does not affect this artefact, and here is why”, in a
machine-readable form that travels with the artefact instead of living
in each consumer’s ignore file.
Scanning what is actually running
The common production loop scans docker ps --format '{{.Image}}',
which reports tags. If a tag has moved, you are scanning something
other than what is running.
set -uo pipefail
docker ps -q \
| xargs -r -n1 docker inspect --format '{{index .Config.Image}}@{{.Image}}' \
| sort -u \
| while IFS= read -r entry; do
echo "=== ${entry} ==="
trivy image --severity HIGH,CRITICAL --ignore-unfixed "${entry##*@}" || true
done=== registry.example.com/myorg/myapp:1.4.0@sha256:4f2a...c19d ===
Total: 1 (HIGH: 1, CRITICAL: 0)Illustrative output
Scanning a stored SBOM instead of the image is faster and does not require pulling anything — that pattern is covered in the SBOM lesson, and it is the right shape for continuous monitoring of a large fleet.
Triage
Severity is an input to prioritisation, not the whole of it. The questions that actually order the work:
- Is there a fix? No fix means no action beyond tracking, regardless of CVSS.
- Is it in the CISA KEV catalogue, or does it have a high EPSS score? Observed exploitation beats theoretical severity every time.
- Is the component reachable? A vulnerable parser in a code path the service never invokes is a different risk from one on the request path. No scanner determines this; you do, from what the service does.
- Is the container exposed? Internet-facing changes the calculus versus a batch job on an internal network.
- Is it in the base image or in your code? Base-image findings are fixed by a base bump and affect every image you ship; application findings are fixed by a dependency change.
Question three is the one that requires judgement and the one people try to automate. Reachability analysis tools exist and are improving, but the decision remains yours, and “the scanner said HIGH” is not a risk assessment.
Knowledge check
Knowledge check · 5 questions
Q1. A distroless image containing one statically linked Go binary scans clean. What does that tell you?
Q2. Trivy reports a package as vulnerable; the distribution security tracker says the package is fixed. The version strings match. What is the most likely explanation?
Q3. Which are true of a CI gate built on `--severity HIGH,CRITICAL` with no other filter? Select all that apply.
Q4. By default, most scanners examine the squashed final filesystem, so a package installed and then removed in a later layer is not reported.
Q5. Running two scanners and failing the build when either reports a HIGH is a stronger control than running one.
Passing score: 75%. Answers are checked in this browser.