Contents

Trivy and Container Scanning: Finding Vulnerabilities Before They Find You

Scanning images, filesystems and IaC without selling a kidney

Contents

Every Docker image you pull is a tarball of someone else’s decisions. That base image you chose two years ago because the tutorial used it? It’s carrying an OpenSSL with a known hole, a libc with a CVE, and three system packages you’ve never heard of, one of which has a remote code execution bug filed against it. You didn’t write any of that. You’re still running it.

Container scanning is the unglamorous practice of finding out what’s actually inside your images before an attacker does. And the tool I reach for first, every time, is Trivy — partly because it’s genuinely good, and partly because it’s free, fast, and doesn’t try to drag me into a sales call.

What Trivy actually does

Advertisement

Trivy reads through an image layer by layer, builds an inventory of every OS package and application dependency it can find, and cross-references that inventory against vulnerability databases. The output is a list of CVEs, each with a severity, the installed version, and — the bit that makes it useful rather than just frightening — the version that fixes it.

The single-command experience is the whole pitch:

1
trivy image nginx:1.25.2

That pulls the image (or reads it locally), enumerates the packages, downloads the latest vuln database, and prints a table. The first thing you learn is humbling: a “clean” official image off Docker Hub will routinely show dozens of vulnerabilities, most of them inherited from the base. Scanning isn’t about reaching zero. It’s about knowing your numbers and not shipping the ones that matter.

It’s not just images

The trap people fall into is thinking Trivy is only a container thing. It scans far more than that, and this is where it earns a permanent spot in the toolbox:

  • Filesystems and Git repos — point it at a directory or a repo URL and it’ll find vulnerable dependencies in your package-lock.json, go.sum, requirements.txt and friends.
  • Infrastructure-as-code — it lints Terraform, Kubernetes manifests, Dockerfiles and Helm charts for misconfigurations, like a container running as root or a security group open to the world.
  • Secrets — it’ll flag an AWS key or private key that someone helpfully committed.
1
2
# scan your source tree for vulnerable deps AND leaked secrets
trivy fs --scanners vuln,secret,misconfig .

One tool, the same database plumbing, covering the image, the code that built it, and the manifests that deploy it. That breadth is why I stopped juggling three separate scanners.

Scanning tells you what’s inside; it doesn’t tell you it’s yours

Advertisement

There’s a gap Trivy doesn’t fill, and it’s worth being clear about. Scanning tells you what vulnerabilities an image contains. It says nothing about whether the image is the one you think it is. An attacker who compromises your registry or your build pipeline can hand you a perfectly “clean” image that happens to contain their backdoor — Trivy will scan it and shrug, because the backdoor isn’t a known CVE. Scanning is a content check, not a provenance check.

That’s why serious supply-chain security pairs scanning with signing. Once you’ve scanned an image and decided it’s fit to ship, you sign it, and your cluster refuses to run anything unsigned. I’ve written separately about verifying container images with Sigstore and Cosign, which is the other half of this story: Trivy answers “is this image full of holes?”, Cosign answers “is this image actually the one we built and approved?”. You want both. Neither substitutes for the other.

Building an SBOM while you’re at it

A useful side effect of Trivy’s package inventory is that it can emit a Software Bill of Materials — a machine-readable list of everything in the image — in standard formats like CycloneDX or SPDX:

1
trivy image --format cyclonedx --output sbom.json myorg/myapp:latest

That SBOM is worth keeping. When the next Log4Shell-scale vulnerability drops, the question everyone scrambles to answer is “are we affected?” — and if you’ve archived an SBOM per release, you can answer it with a grep instead of a frantic afternoon of rebuilding and rescanning everything you’ve ever deployed. Generating it costs nothing on top of a scan you’re already running.

Wiring it into CI so humans don’t have to remember

A scanner you run manually is a scanner you forget. The win comes from making the pipeline fail when something genuinely dangerous turns up. The flag that makes this practical is --exit-code, paired with a severity floor so you’re not blocking every build over a low-severity issue in a package you’ll never call:

1
2
3
4
5
6
7
8
# .github/workflows/scan.yml (excerpt)
- name: Scan image
  run: |
    trivy image \
      --severity HIGH,CRITICAL \
      --ignore-unfixed \
      --exit-code 1 \
      myorg/myapp:${{ github.sha }}

Two flags there are doing the heavy lifting. --severity HIGH,CRITICAL keeps the signal high. --ignore-unfixed is the pragmatic one: there’s no point failing a build over a CVE that has no patch available yet — you can’t fix it, so blocking on it just trains people to slap continue-on-error everywhere and ignore the lot. Fail on what’s actionable.

There’s a sequencing decision here too. You can scan an image after you’ve built and pushed it, or you can scan the Dockerfile and its dependencies before the build even starts. The earlier you catch it, the cheaper the fix, so I run a fast trivy fs scan of the source at the start of the pipeline and the full trivy image scan on the built artefact at the end. The first catches “you added a dependency with a known hole”; the second catches “your base image aged into a vulnerability since last build.” Same principle as running checks in pre-commit hooks: the further left you shift the check, the less it costs when it fires. A vulnerability caught on the developer’s laptop is a two-line dependency bump; the same vulnerability caught in production is an incident review.

Living with the false-positive tax

Here’s the honest bit. Scanners are noisy. You will get findings for a vulnerable library that your code never actually calls, or a CVE that’s only exploitable in a configuration you don’t run. Treating every CRITICAL as a five-alarm fire is how teams burn out and start ignoring the scanner entirely — which is worse than never having run it.

The answer is a triage file. Trivy supports a .trivyignore listing the CVE IDs you’ve assessed and consciously accepted, ideally with a comment and an expiry date so the decision gets revisited:

1
2
# CVE-2023-XXXXX  vulnerable lib present but code path unreachable; review 2024-09
CVE-2023-XXXXX

The discipline isn’t “scan and panic.” It’s “scan, triage, fix what’s fixable, document what you’re accepting, and rebuild on a fresh base regularly so the inherited cruft ages out.” Most of your real wins come not from chasing individual CVEs but from moving to a slimmer base image — a -slim or distroless variant — which removes whole categories of vulnerable packages you were never using anyway. A distroless image has no shell, no package manager, and no dozens of system libraries, so a scan of it is often near-empty not because you fixed anything but because the vulnerable stuff was never there. That’s the highest-leverage move in the whole discipline: reduce the surface, and the CVE count drops on its own.

The database is the whole game — keep it fresh

Trivy is only as good as its vulnerability database, and that database is updated constantly as new CVEs land. By default Trivy downloads the latest DB before a scan, which is what you want interactively but a problem in CI, where a hundred parallel jobs all pulling the DB will eventually get you rate-limited from the registry hosting it. The fix is to cache the database and refresh it on a schedule rather than per-scan:

1
2
3
# refresh the DB once (e.g. a nightly job), then scan offline all day
trivy image --download-db-only
trivy image --skip-db-update --offline-scan myorg/myapp:latest

The corollary is that a scan result has a shelf life. “Zero criticals” on Monday means nothing on Friday if a critical dropped in between — the image didn’t change, but the world’s knowledge of it did. This is why scanning has to be continuous, not a one-time gate: rescan images already in production, not just ones on their way in. An image you scanned clean six months ago and forgot about is exactly where tomorrow’s incident lives.

Troubleshooting: when the scanner itself is the problem

Every scan reports zero vulnerabilities — suspiciously so. If a scan of a real image comes back empty, the database probably didn’t load. Check for a DB-download failure in the output; a rate limit or a network block during --download-db-only leaves you scanning against nothing. An empty result should always make you more suspicious, not less.

Scans are painfully slow in CI. The usual culprit is re-downloading the DB every run and re-pulling the image every run. Cache the Trivy DB (~/.cache/trivy) between jobs, and where possible scan the image from your local build output rather than pulling it back from a remote registry. Both cut minutes off a pipeline.

A CVE you fixed keeps reappearing. Two common causes. First, you patched the package in your layer but the base image still ships the vulnerable version and Trivy sees both — rebuild on an updated base. Second, your .trivyignore entry expired or the CVE ID changed (they occasionally get merged or superseded). Read the finding’s detail rather than assuming the fix didn’t take.

Trivy flags a language dependency you’re sure you removed. Lockfiles lie. A package-lock.json or go.sum can still list a transitive dependency that nothing imports any more. Regenerate the lockfile from a clean install and rescan; often the finding evaporates because the stale entry is gone.

The IaC scanner is drowning you in low-severity noise. The misconfiguration scanner is opinionated and will flag things you’ve consciously accepted. Use a config file to disable specific checks by ID rather than lowering the severity floor globally, so you keep the signal on the checks you do care about.

The verdict

If you ship containers and you aren’t scanning them, Trivy is the lowest-effort, highest-return change you can make this week. It’s a single binary, it runs offline once the database is cached, it covers images and code and IaC, and it costs nothing. Wire it into CI with a sensible severity floor and --ignore-unfixed, keep a triage file, and rebuild on slim bases.

Who is this for? Anyone running other people’s images in production — which is everyone. It won’t make you bulletproof; nothing does. But “we knew about it, assessed the risk, and chose to ship anyway” is a defensible position you can stand behind in a post-incident review. “We had no idea what was in the image” is not, and never will be.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.