What this check reads
The check takes the first 5 tagged local images from docker images --format '{{.Repository}}:{{.Tag}}' with <none> entries excluded, and counts that truncated list, never more than 5, as the total it reports against. If trivy is installed it runs trivy image --quiet --severity CRITICAL,HIGH --format json --timeout 120s <img> on each of those 5 and counts occurrences of "Severity":"CRITICAL" and "Severity":"HIGH" in the JSON. One or more criticals is the FAIL, highs with no criticals is the WARN, and zero of both across the images that returned output is the PASS, while every scan coming back empty is a SKIP. If only grype or docker scout is available, or no scanner at all, the check WARNs and prints the command to run by hand instead of parsing anything.
That is the intended logic, and it does not work today. Trivy pretty-prints its JSON with a space after the colon ("Severity": "CRITICAL"), so both patterns come back empty and both counts stay at zero. With trivy installed the check can currently only return the PASS or the no-output SKIP, whatever the images contain.
When it applies
Needs the docker binary and daemon access (directly, or via root/sudo); otherwise SKIP. It scans a sample, not the whole host: the first 5 tagged images from docker images, with <none> excluded. Only trivy is parsed automatically - grype and docker scout are detected and reported as "run it yourself" WARNs. Each scan has a 120-second timeout, and images that time out or error are silently dropped from the count, which is why the message distinguishes "scanned" from "total". It reports CRITICAL and HIGH only; MEDIUM and LOW findings never appear. Images used only by stopped containers, or pulled fresh at deploy time and not present locally, are outside its view.
What each result means
| Result | When | What it means |
|---|---|---|
| SKIP | Docker missing, daemon unreachable, no images | Nothing was scanned. No image list was ever produced, so the check makes no claim about the CVE state of this host. |
| SKIP | Trivy produced no output for any image | Trivy ran but returned nothing for every image, usually a database download failure or a timeout. Nothing was verified. |
| FAIL | ≥1 CRITICAL across scanned images | At least one critical CVE was counted in the sampled images. This branch cannot be reached today, because the count never matches the JSON trivy actually prints. |
| WARN | HIGH only | High-severity CVEs were counted but no criticals. Also unreachable today, for the same reason. |
| PASS | No CRITICAL/HIGH in the scanned sample | With trivy installed this is the only verdict the check can reach today, so it means trivy produced output, not that the images are clean. Run trivy yourself before trusting it. |
| WARN | grype/scout present (manual run) or no scanner installed | No scan happened. Only trivy output is parsed, so another scanner or none at all leaves the CVE state unknown. |
Why it matters
Images bundle an OS layer and language packages; each ages into known CVEs. Scanning images (and rebuilding on a schedule) is CIS Docker 4.4 "Ensure images are scanned and rebuilt to include security patches", OWASP Docker Cheat Sheet Rule #9 "Integrate container scanning tools into your CI/CD pipeline", NIST SP 800-190 4.1.1 (image vulnerabilities), OWASP Top 10 A06:2021 Vulnerable and Outdated Components.
Why it fails, and when it is wrong
- The counts never match (known issue). The script greps for
"Severity":"CRITICAL"and"Severity":"HIGH"with no space, and trivy writes"Severity": "CRITICAL". Every count is zero, so an image full of critical CVEs returns PASS. Read trivy's own output, not this verdict. - First run downloads the vulnerability DB (tens of MB) and can exceed the 120 s timeout → empty output → SKIP "produced no output". Run
trivy image --download-db-onlyonce, or pre-warm the cache, or run the audit twice. - Air-gapped hosts need the DB mirrored (
TRIVY_DB_REPOSITORY). - Even with the pattern corrected, the count is a grep over JSON and counts every matching package entry, including duplicates across layers and including vulnerabilities with no fix available. Use
--ignore-unfixedfor a more actionable view. - Only 5 images are sampled; a host with 30 images may have unscanned criticals.
- Distroless/scratch images report few CVEs because there is no OS package manager to inventory; this is expected.
- Scout requires a Docker Hub login for some features.
How to fix it
Scan every image, not the first five
for i in $(docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>'); do
echo "== $i"; trivy image --quiet --severity CRITICAL,HIGH --ignore-unfixed "$i"
done--ignore-unfixed hides findings that have no patch yet, which leaves the list you can act on. Go by this output, not by the check's verdict.
Rebuild on a current base image
Most CVEs sit in the base layer, not in your code. Move the FROM line to a current tag such as node:22-bookworm-slim, and rebuild so the new base and its distro patches are actually fetched. Minimal bases (distroless, Alpine, Chainguard, Docker Hardened Images) carry fewer packages and so fewer CVEs.
docker build --pull --no-cache -t app:1.4.3 .Gate CI on critical findings
trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed <image>A non-zero exit fails the pipeline before an image with a fixable critical CVE is deployed.
Record what has no fix
For a finding with no fix available, add the CVE ID to .trivyignore with a comment giving the date and the reason. Review the file on a schedule, so an accepted risk does not turn into a forgotten one.
Verify the fix
# Reproduce the check's sample:
docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>' | head -5
# Scan properly - every image, all severities, and fail the shell on findings:
for i in $(docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>'); do
echo "== $i"; trivy image --quiet --severity CRITICAL,HIGH --exit-code 1 "$i" || true
done
# expected: no CRITICAL or HIGH rows
# Confirm a rebuilt image actually picked up the fixes:
docker pull <image> && trivy image --quiet --severity CRITICAL "$_"Debugging
The audit account could not reach the daemon. Check systemctl is-active docker, then docker ps as that user and sudo docker ps, and run the audit as root, with sudo, or as a docker group member. The Docker Inventory check covers this case in more detail.
Every image is untagged (<none>) or the host pulls images at deploy time and keeps none. Check with docker images -a. It is also what you get when the audit has root but the daemon is down, because an empty answer and an empty image list look the same to the script.
Trivy ran but returned nothing for every image. Usually the vulnerability database could not be downloaded (no outbound network, or a rate-limited registry) or every scan hit the 120-second timeout. Run trivy image <name> by hand to see the real error, and trivy image --download-db-only to test database access.
Do not trust it at all at the moment: the known issue above means the count is always zero, so this PASS only says trivy returned output. Even with that fixed, the "of N total" in the message is capped at 5, so it is the sample size and not the number of images on the host, and a scanned count below N means some scans timed out. Use the loop above for real coverage.
Nothing was actually assessed. This is a "cannot tell" result, not a mild finding. Install trivy.
Many CVEs are in the base image, not your code. Rebuild on a current base (FROM node:22-alpine rather than a year-old tag), rebuild with --no-cache to pick up distro patches, and use trivy image --ignore-unfixed to see how many have a fix available at all.
Sources
- Trivy: Container image scanning
- Trivy: Vulnerability scanner and DB
- Trivy: Air-gapped / DB download options
- Grype
- Docker Scout CLI:
docker scout cves - OWASP Docker Security Cheat Sheet (Rule #9)
- NIST SP 800-190
- OWASP Top 10 A06:2021 Vulnerable and Outdated Components
How the script reads this
Next
Re-run the Image Vulnerabilities audit after applying the fix and confirm this check moves to PASS. CtrlOps runs all 8 checks over your existing SSH connection and scores the result, so the change is visible without reading another config file.
All 8 Image Vulnerabilities fixes