Scanning images, filesystems and IaC with Trivy
One scanner for container images, dependencies, Terraform and secrets: install it, read its output, and fail a build on the right things.
Trivy is a single binary from Aqua Security that does four separate jobs. Its --scanners flag
takes vuln,misconfig,secret,license: known vulnerabilities in packages, risky settings in
infrastructure files, credentials committed by accident, and package licences.
Only vuln and secret are on by default. That is the first thing that catches people out.
Everything below runs against Trivy v0.74.0. The commands are the quick part. Which findings may fail a build is the part worth arguing about.
Install it
On macOS, Homebrew is the only install path the docs support:
brew install trivyOn Debian and Ubuntu you add the repository by hand:
sudo apt-get install wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee -a /etc/apt/sources.list.d/trivy.list
sudo apt-get update
sudo apt-get install trivyThree details there are worth reading rather than pasting. Trivy's installation doc lists
wget and gnupg as the only prerequisites. apt-transport-https, which older snippets add,
has been built into apt since Debian 10 and Ubuntu 18.04.
gpg --dearmor converts the project's text-format public key into the binary form apt wants.
signed-by= then scopes that key to this repository alone, so no other third-party repo can
vouch for a package claiming to be Trivy.
generic is the repository's only distribution name, not a release codename. Do not substitute
$(lsb_release -sc) for it.
trivy --versionThat prints the binary's version and, under it, the database's build and next-update timestamps.
The binary and the database are two separate things on two separate update schedules, and that difference matters when you pin a version in CI.
Your first scan
trivy image python:3.11-slimCVE, or Common Vulnerabilities and Exposures, is the public catalogue that gives every known
flaw an identifier like CVE-2024-12345, so Trivy and the package maintainer name the same bug
the same way.
The report lists the ones Trivy found in the image's packages.
The first run is slow and needs network access: Trivy pulls a vulnerability database before it scans anything. That is a cold cache, not a broken install.
Note what Trivy scanned. An image is the packaged filesystem you built and pushed. A container is one running instance of that image, and Trivy did not look at one.
To know what to look up, Trivy first works out every package and version inside the image. That is the same inventory an SBOM, or Software Bill of Materials, records.
What such a list proves and does not is a separate question, and Syft writes one out from a container image if you want it as a file rather than a side effect.
Cut the report down to what you can act on
The first report against a real base image is long, and most of it is not work anyone can do.
trivy image --severity HIGH,CRITICAL --ignore-unfixed python:3.11-slim--ignore-unfixed is the important half. It is shorthand for --ignore-status affected,will_not_fix,fix_deferred,end_of_life, and leaves only vulnerabilities with a fixed
version available.
Vendor-unfixed findings are part of what it removes: a CVE the distribution maintainer has looked at and decided not to patch, usually because the way they ship the package makes it unexploitable.
No upgrade clears one of those. Leaving them in the output is how you train people to skim it.
Terraform, Dockerfiles and Kubernetes YAML
Infrastructure as code, IaC, means running your servers, networks and clusters from files you commit rather than a console you click through.
An IaC misconfiguration is a risky setting in one of those files, such as a storage bucket declared public, found by reading the file rather than testing the running system.
One subcommand covers all of it:
trivy config .That walks the directory and auto-detects Dockerfiles, Kubernetes manifests, Terraform, CloudFormation, Azure ARM templates and Helm charts in one pass.
Trivy normally fetches its misconfiguration checks as a bundle from the network, but the binary carries the same checks compiled in as a fallback, so this is the one Trivy scan that runs with no network at all.
trivy fs reaches the same checks. That is where the default scanner set bites:
trivy fs --scanners misconfig,secret .Run trivy fs . with no --scanners and you get vuln and secret, the same default as
trivy image. Point that at a directory of Terraform and it reports nothing, so you conclude
your Terraform is clean. It never looked.
Note the spelling: the scanner is misconfig. The old name config still works, but it prints
a deprecation warning, and on a first scan that warning looks like you mistyped something.
The secret scanner finds credentials already sitting in the files. Keeping them out is a
different job: a scan that finds a key has already lost, and
stopping the commit before it lands is the control that
helps.
What should fail the build
CI, continuous integration, is the automation that runs on every push to build and test what you committed. Trivy exits 0 by default, even when it finds vulnerabilities.
The docs say so outright, and --exit-code exists to change it. A CI job that runs trivy image and checks nothing else passes forever, whatever is in the image.
The gate, in GitLab CI
In GitLab CI, assuming an earlier job built and pushed $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA, this
is the default I would set:
container_scan:
stage: test
image:
name: docker.io/aquasec/trivy:0.74.0
entrypoint: [""]
variables:
TRIVY_NO_PROGRESS: "true"
TRIVY_CACHE_DIR: ".trivycache/"
TRIVY_USERNAME: "$CI_REGISTRY_USER"
TRIVY_PASSWORD: "$CI_REGISTRY_PASSWORD"
TRIVY_AUTH_URL: "$CI_REGISTRY"
FULL_IMAGE_NAME: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
cache:
paths:
- .trivycache/
script:
- trivy --version
- trivy image --exit-code 0 "$FULL_IMAGE_NAME"
- trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed "$FULL_IMAGE_NAME"The job scans twice. The first pass runs with --exit-code 0 and prints the whole report, so
whoever broke the build can see everything.
The second is the gate, and splitting the two lets it be narrow while the output stays wide.
The gate fails only on fixable CRITICAL findings, and --ignore-unfixed is what makes that
defensible.
The person whose commit failed cannot fix a vendor-unfixed CVE in the base image; they will get the gate bypassed, and a gate with one standing bypass is decoration.
Start at CRITICAL and widen
CRITICAL alone rather than HIGH,CRITICAL is a starting position. Widen the gate to HIGH once
the critical count has held at zero long enough that people trust it.
Widening a gate people trust takes an afternoon. Narrowing one they have learned to route around takes a quarter.
Misconfiguration findings stay out of the gate on day one. Run trivy config . unblocking, fix
or accept what it says about the repository you already have, and only then wire it to
--exit-code 1.
A check switched on as a gate against an existing codebase fails the first build, and that is how a check gets deleted.
The job pins the image tag to 0.74.0 rather than latest, so a Trivy release cannot change the
meaning of your gate overnight, though the database underneath it still refreshes every six
hours.
Two lines that are GitLab, not Trivy
Two of those lines come from GitLab rather than Trivy.
entrypoint: [""] blanks the image's own entrypoint so the runner can start a shell inside it,
and TRIVY_CACHE_DIR has to sit inside the project directory for GitLab's cache: to see it.
The two trivy image calls are the same anywhere.
Accept a finding with an end date
When you do accept a finding, accept it with an end date:
# Vendored dependency, upstream fix tracked in JIRA-1234.
# Revisit by 2026-12-01 or this line stops suppressing the finding.
CVE-2024-12345 exp:2026-12-01Substitute a real CVE ID and date.
Trivy picks up .trivyignore automatically from the directory you scan, and the exp: suffix
is the part to insist on: the suppression stops applying on that date and the finding comes
back.
An ignore file without expiry dates turns into a list nobody has re-read. There is also an
experimental .trivyignore.yaml with a richer set of fields.
Trivy does not load that one automatically, so you have to pass --ignorefile ./.trivyignore.yaml. Start with the plain file.
Where the database comes from, and what goes wrong
The vulnerability database is not a GitHub release. It is an OCI artifact: registries such as Docker Hub and GitHub's ghcr.io store container images in a format standardised by the Open Container Initiative, and an OCI artifact is any other data pushed into a registry in that same format.
The database is one. Trivy pulls from mirror.gcr.io/aquasec first and falls back to
ghcr.io/aquasecurity on a transient error. The trivy-db project rebuilds it every six hours.
The cache directory decides everything: a current cache means no network at all, a stale one sends Trivy to the two registries in the order shown.
A 429 is the response a registry sends when it is rate-limiting you, and setting GITHUB_TOKEN
to cure one does nothing: the troubleshooting docs say it does not help for the vulnerability
database, which is not a GitHub release download.
Cache the database between runs, or copy it into a registry you control.
Do not name a default cache path in CI config. ~/.cache/trivy is the Linux default only: on
macOS the root is $HOME/Library/Caches, and inside the official container image it is
/root/.cache.
Set TRIVY_CACHE_DIR explicitly, as the job above does, and cache that path. A cache: stanza
pointing at a home directory silently caches nothing.
For an air-gapped host, carry the cache in:
# On a machine with internet access
trivy image --cache-dir ./trivy-cache --download-db-only
# Copy ./trivy-cache to the air-gapped host, then scan without contacting a registry
trivy image --cache-dir /path/to/trivy-cache --skip-db-update ghcr.io/OWNER/REPO:TAGSubstitute your own image for ghcr.io/OWNER/REPO:TAG. --download-db-only populates a cache
and skips the scan; it does not build a mirror. Run it on the isolated host, which cannot reach
a registry, and it fails. That is how people conclude Trivy has no offline story.
To host the data instead of shipping it, copy ghcr.io/aquasecurity/trivy-db:2 into your own
registry and pass --db-repository your.registry/path/trivy-db:2.
Scanning a cluster, and what that is not
# Uses your current kubeconfig context
trivy k8s --report summary
# Or name a specific context
trivy k8s --report summary my-context-nameThat positional argument is a kubeconfig context: a named entry in ~/.kube/config pairing a
cluster address with your credentials, listed by kubectl config get-contexts.
There is no literal cluster argument, whatever the copies of trivy k8s --report summary cluster in circulation say. --report takes all, the default, or summary.
Trivy's CLI reference flags Kubernetes scanning as experimental. The scan inspects cluster resources and the images they reference. That is a resource inventory scan, not runtime monitoring, so do not promise anyone behavioural detection on the strength of it. It also needs real permissions.
The account you scan with must have list on all resources in the core, apps, batch,
networking.k8s.io and rbac.authorization.k8s.io API groups. The node collector, the part
that reads configuration from the cluster's own machines, needs more again. It is on by default,
and --disable-node-collector turns it off.
The gate a year from now
The CI gate is the one setting in this post you have to choose for yourself, so it is the line to revisit.
If it still sits at fixable CRITICAL twelve months on, it has stopped measuring your risk and started measuring how good the team has got at critical CVEs.