Skip to main content
RunBook Academy

Docker & ContainersXII · Supply ChainCVE scanning

CVE scanning — Trivy, Grype, Docker Scout

Intermediate⏱ ~26 mindocker

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

Not yet marked complete on this device.

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.

Read-only / Safebasic scan
IMG=registry.example.com/myorg/myapp@sha256:REPLACE_ME

trivy image "$IMG"

The flags that matter, all current:

FlagEffect
--severityFilter to UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL
--exit-codeExit code when issues are found — this is the CI gate
--ignore-unfixedShow only vulnerabilities that have a fix available
--scannersvuln, misconfig, secret, license; defaults to vuln,secret
--pkg-typesos, library, or both (the default)
--formattable, json, sarif, cyclonedx, spdx-json, cosign-vuln, others
--ignorefilePath to a .trivyignore or .trivyignore.yaml
--show-suppressedDisplay findings that a suppression rule removed

Grype

Anchore’s scanner, and a useful second opinion precisely because it matches differently.

Read-only / Safegrype with a CI gate and full layer coverage
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.

Read-only / Safedocker scout
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.

Read-only / Safethe same image, two tools
$ trivy image --severity HIGH,CRITICAL myapp:1.0.0 | tail -3; grype myapp:1.0.0 --fail-on high | tail -3
Total: 4 (HIGH: 3, CRITICAL: 1)

[0004]  CVE-2024-0000  libssl3  HIGH
5 vulnerabilities found

Illustrative 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

Read-only / Safethe CI gate
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.

Configuration change.trivyignore.yaml
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-01

The 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.

Read-only / Safescan the running fleet by digest
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:

  1. Is there a fix? No fix means no action beyond tracking, regardless of CVSS.
  2. Is it in the CISA KEV catalogue, or does it have a high EPSS score? Observed exploitation beats theoretical severity every time.
  3. 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.
  4. Is the container exposed? Internet-facing changes the calculus versus a batch job on an internal network.
  5. 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

  1. Q1. A distroless image containing one statically linked Go binary scans clean. What does that tell you?

  2. 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?

  3. Q3. Which are true of a CI gate built on `--severity HIGH,CRITICAL` with no other filter? Select all that apply.

  4. Q4. By default, most scanners examine the squashed final filesystem, so a package installed and then removed in a later layer is not reported.

  5. 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.