CVE Scanning with Trivy: Finding Vulnerabilities in Container Images
A practical guide to scanning container images for known vulnerabilities with Trivy — from a one-line local scan to CI gates, ignore policies, and air-gapped setups.
Published on • October 14, 2026
AI Assistant

Building a small image and layering it well, as covered in the previous posts in this series, cuts your attack surface. But smaller is not the same as safe. Somewhere inside that slim image is a copied libssl from six months ago with a CVE against it — and you will not find out until someone else does.
Container image scanning closes that gap. Trivy has become the default tool for it: one binary, no server, a single command that tells you which packages in your image have known vulnerabilities and how bad they are.
This post is a practical tour — what Trivy scans, how to read its output, and how to wire it into CI without turning every build into a security review meeting.
What Trivy Actually Checks
Trivy (from Aqua Security) is a universal vulnerability scanner. For containers, its primary job is filesystem-level: it walks the packages installed in your image’s filesystem, identifies them by name and version, and matches them against vulnerability databases — NVD CVEs, plus distribution-specific feeds like Debian Security Tracker, Ubuntu CVE, Alpine SecDB, and Red Hat advisories.
That last point matters. A CVE entry is often vague (“affects libxml2 before 2.9.1”), while the distro feed says precisely which package build is patched in which release. Trivy prefers the distro feed when it can, so results are far less noisy than raw NVD matching.
Beyond packages, Trivy can also scan for:
- Misconfigurations — secrets left in the image, IAM policy mistakes in IaC files, Dockerfile best-practice violations
- Secrets — API keys, private keys, tokens accidentally committed into a layer
- License information — for compliance checks
- SBOMs — generate or consume a software bill of materials
It is a single static binary with an embedded Go runtime. There is no daemon to run, no database to host, no account to create. That low friction is exactly why it took over this space.
Your First Scan
Install it (macOS, Linux, Windows binaries, or your package manager), then point it at an image:
trivy image nginx:1.25
Trivy pulls the image if it is not local, walks its layers, and prints a report grouped by package:
nginx:1.25 (debian 12.4)
=======================================
Total: 14 (UNKNOWN: 0, LOW: 3, MEDIUM: 6, HIGH: 4, CRITICAL: 1)
┌──────────────┬────────────────┬──────────┐
│ Library │ Vulnerability │ Severity │
├──────────────┼────────────────┼──────────┤
│ zlib1g │ CVE-2023-XXXXX │ HIGH │
│ │ Fixed: 1:1.2.13│ │
├──────────────┼────────────────┼──────────┤
│ libssl3 │ CVE-2024-XXXXX │ CRITICAL │
│ │ Fixed: 3.0.14 │ │
└──────────────┴────────────────┴──────────┘
Three things to notice:
- The OS line at the top —
debian 12.4tells you which distro feed was used. If you seeUNKNOWNhere, Trivy fell back to generic NVD matching and the results are less reliable. - Fixed versions are listed. The vulnerability is not just reported; the remedy is right there.
- Severity is per-vendor-CVSS, so CRITICAL does not always mean “exploitable in your case” — more on that below.
Two flags you will use constantly:
# Only what matters for a hard gate
trivy image --severity CRITICAL,HIGH --exit-code 1 my-app:1.0
# Machine-readable output
trivy image --format json -o report.json my-app:1.0
--exit-code 1 is what makes Trivy a gate rather than a report: the process exits non-zero when findings match the severity filter, so CI can fail the build.
Severity Is a Starting Point, Not a Verdict
The most common misuse of image scanning is treating “no CRITICAL CVEs” as “secure”. CVSS scores measure the worst case of a vulnerability in isolation — they do not know whether the affected code path is even reachable in your container.
Trivy gives you levers to narrow this down:
--vuln-type os,library— separate OS packages (apt/apk) from language dependencies (npm, pip, Go modules baked into the image). Often you only care about one.--ignore-unfixed— hide CVEs with no patch available. If the vendor has not shipped a fix, flagging it in every build just trains people to ignore the report.--scanners vuln— Trivy actually runs several scanners by default (vuln,secret,misconfig). For a pure CVE gate, restrict it.
A mature setup uses different scans at different points: a full scan for visibility, a filtered --severity CRITICAL --ignore-unfixed --exit-code 1 scan as a hard merge gate. Two numbers, two policies, no argument about whether a MEDIUM finding blocks a Friday deploy.
Wire It Into CI
Trivy’s zero-config nature makes CI integration almost trivial. For GitHub Actions:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: 'my-app:${{ github.sha }}'
format: 'table'
exit-code: '1'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
GitLab gets an equivalent job — Trivy is just a binary, so any runner with a shell can run it. The pattern is the same everywhere:
- Build and push the image (or save it as a tarball)
- Scan the exact artifact you would ship, not the Dockerfile
- Fail on threshold
That last point deserves emphasis: scan the image, not the source tree. Scanning requirements.txt misses base-image packages, OS tooling, and transitive files copied in during the build. The image is the unit of deployment; it should be the unit of scanning.
For a softer landing while a team adopts scanning, generate an artifact instead of gating:
trivy image --format cyclonedx -o sbom.json my-app:1.0
SBOM output in CycloneDX format slots into any downstream compliance tooling — and is increasingly a regulatory requirement in its own right, not just a nice-to-have.
Triage and Ignore Policy: .trivyignore
You will eventually hit a CVE that does not apply to you — a vulnerable function your binary never calls, a package installed but never loaded — or a CVE with no fix where the risk is accepted. Do not just re-run with lower severity to make it green. Record the decision:
# .trivyignore
# CVE-2024-1234: affects optional compression module we do not enable;
# reviewed 2026-10-14, accepted until upstream release 2.1
CVE-2024-1234
The file is plain text, lives in your repo, and travels with the image’s source. Every suppression carries a reason and a date. This converts “why is this build green?” from archaeology into a git blame.
Trivy also supports expiry — CVE-2024-1234#2026-11-01 suppresses the finding only until November 1, forcing a re-review. That one line prevents the classic slow rot where a two-year-old ignore entry is hiding something genuinely dangerous.
Air-Gapped and DB Management
Trivy downloads a vulnerability database on first use and refreshes it periodically (by default every 24 hours). Two situations complicate this:
CI runners with no egress. Pre-pull the DB as a build step, or bundle it into your runner image. Trivy stores it in a cache directory you can bake in.
Air-gapped environments. Trivy supports --skip-db-update to run entirely offline, with the DB transported separately (Aqua documents this in their docs for disconnected environments). The scan itself never needs network access — only the DB refresh does.
trivy image --skip-db-update --offline-scan my-app:1.0
--offline-scan goes further, skipping even version checks for Trivy’s own plugins. In a controlled network, both flags together give you a fully deterministic, reproducible scan that does not depend on external availability.
Scanner vs. Fixer: Closing the Loop
Finding CVEs is half the job. The other half is actually rebuilding with patched packages — which is where this series’ earlier posts come back:
docker-build-cache-invalidation— when you rebuild to pick up security patches, make sure your cache does not hand you the old packages. A--no-cacherebuild for security updates is a legitimate exception to normal cache discipline.docker-base-images-alpine-slim-distroless-scratch— fewer packages means fewer CVEs, full stop. A distroless image with only your runtime has an order of magnitude smaller attack surface than a full OS image.
A useful cadence: run the full scan daily against your published tags (new CVEs appear constantly — a “clean” image from last month may be dirty today), and the gate scan on every build. Trivy’s speed makes this cheap; a typical image scan completes in seconds once the DB is cached.
Takeaways
- Scan the image artifact, not the source tree — the image is what ships.
- Use distro-specific feeds (they are automatic) to avoid raw-NVD noise; check the OS line at the top of the report.
- Gate with
--severity CRITICAL,HIGH --exit-code 1 --ignore-unfixed— a hard policy on fixable, severe issues. - Suppress with a reason in
.trivyignore— every ignore entry carries a comment and ideally an expiry date. - Generate SBOMs (
--format cyclonedx) as a compliance artifact, separate from the gate. - New CVEs invalidate old “clean” results — schedule periodic rescans, not just build-time scans.
Scanning does not make a vulnerable image safe. It makes the vulnerability visible — early enough, and with enough context, to do something about it.
Sources: