Trivy
Scanning images, filesystems, repositories, Kubernetes, and IaC with Trivy: scanners, severity gating, SBOM, VEX, and CI integration.
Trivy
A single-binary scanner that finds vulnerabilities, misconfigurations, secrets, and licences across container images, filesystems, repositories, SBOMs, and live Kubernetes clusters.
Overview
Trivy is Aqua Security's general-purpose security scanner. Its distinguishing property is that one binary covers targets that would otherwise need four tools: a vulnerability scanner, an IaC linter, a secret scanner, and an SBOM generator. There is no daemon, no database server, and no runtime dependency beyond a writable cache directory.
Two axes define every invocation: what you point it at (the target subcommand) and what it looks for (the scanners). Getting the second axis wrong is the single most common Trivy mistake, because the defaults differ per target and are not what most people assume.
flowchart LR
subgraph Targets["Targets (subcommands)"]
IMG["image — registry, engine, or tar"]
FS["filesystem — a local directory"]
REPO["repository — local or remote git"]
ROOT["rootfs — an extracted root filesystem"]
SBOM["sbom — CycloneDX or SPDX input"]
CFG["config — IaC files only"]
K8S["kubernetes — a live cluster"]
VM["vm — a VM image or snapshot"]
end
subgraph Scanners["Scanners (--scanners)"]
V["vuln — CVEs in OS and language packages"]
M["misconfig — IaC and Dockerfile checks"]
S["secret — hard-coded credentials"]
L["license — package licence classification"]
end
IMG --> V
IMG --> S
FS --> V
FS --> S
REPO --> V
REPO --> S
ROOT --> V
ROOT --> S
VM --> V
VM --> S
SBOM --> V
CFG --> M
K8S --> V
K8S --> M
K8S --> S
IMG -.opt-in.-> M
IMG -.opt-in.-> L
FS -.opt-in.-> M
FS -.opt-in.-> L
Scanners --> OUT["Report — table, JSON, SARIF, SBOM, template"]
Solid arrows are defaults; dotted arrows must be requested explicitly.
Everything below was exercised against Trivy 0.74.0 (Linux amd64) with vulnerability DB schema 2, unless noted as documentation-only.
Installation
Trivy ships as a static binary with no dependencies. Prefer the release tarball with a checksum check over a shell-piped installer.
VERSION=0.74.0
BASE="https://github.com/aquasecurity/trivy/releases/download/v${VERSION}"
curl -sSLO "${BASE}/trivy_${VERSION}_Linux-64bit.tar.gz"
curl -sSLO "${BASE}/trivy_${VERSION}_checksums.txt"
sha256sum --check --ignore-missing "trivy_${VERSION}_checksums.txt"
# trivy_0.74.0_Linux-64bit.tar.gz: OK
tar xzf "trivy_${VERSION}_Linux-64bit.tar.gz" trivy && ./trivy --version
# Version: 0.74.0
Releases also carry .sigstore.json bundles for cosign verify-blob if you need signed provenance.
The container image is the usual CI form, and is the only distribution that carries the Go report templates — binary at /usr/local/bin/trivy, templates at /contrib/ (asff.tpl, gitlab.tpl, gitlab-codequality.tpl, html.tpl, junit.tpl):
podman run --rm docker.io/aquasec/trivy:0.74.0 image alpine:3.22
Footgun. A binary install has no templates at all.
--template "@/usr/local/share/trivy/templates/html.tpl"is a path that has never existed in a tarball install — fetch the.tplfrom the repository, or use the container image, where the path is@/contrib/html.tpl.
Scan Targets
| Subcommand | Alias | What it takes | Use it for |
|---|---|---|---|
image |
— | Registry ref, local engine image, or --input tar |
Built artefacts, base-image drift, release gating |
filesystem |
fs |
A local directory | Source trees, unpacked artefacts, a running host's / |
repository |
repo |
Local path or remote git URL | Dependency manifests without a clone; pre-clone triage |
rootfs |
— | An extracted root filesystem | Debug/distroless layers, chroots, unpacked OCI layers |
sbom |
— | CycloneDX or SPDX file | Re-scanning an SBOM as new CVEs land, without the image |
config |
conf |
A directory of IaC | Terraform, Kubernetes YAML, Dockerfiles, Helm, CloudFormation |
kubernetes |
k8s |
A kubeconfig context | Live cluster posture across workloads and RBAC |
vm |
— | VM image, EBS snapshot, or VMDK | Cloud instances and golden images |
kubernetes and vm are still marked [EXPERIMENTAL] in 0.74.
The distinction that trips people up is fs versus repository versus rootfs. fs walks a directory as-is. repository additionally understands git — it will clone a remote URL, and on a local repo it can scan history. rootfs treats the directory as a distribution root, so it detects the OS family from /etc/os-release and applies OS package advisories; fs on the same directory finds only language lockfiles.
# Scan a remote repository without cloning it yourself
trivy repository https://github.com/aquasecurity/trivy-ci-test
# │ Target │ Type │ Vulnerabilities │ Secrets │
# │ Cargo.lock │ cargo │ 13 │ - │
# │ Pipfile.lock │ pipenv │ 94 │ - │
Where trivy image looks for an image
The default resolution order is docker, containerd, podman, remote — tried in sequence, falling through to a registry pull. When every source fails, the error names all four:
trivy image localonly:v1
# FATAL unable to find the specified image "localonly:v1" in ["docker" "containerd" "podman" "remote"]:
# * docker error: permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
# * containerd error: containerd socket not found: /run/containerd/containerd.sock
# * podman error: no podman socket found: stat /run/user/1004/podman/podman.sock: no such file or directory
# * remote error: GET https://index.docker.io/v2/library/localonly/manifests/v1:
# UNAUTHORIZED: authentication required
Footgun. The last line reads like a credentials problem, but it is the fallback failing. Trivy never found your local image and went to Docker Hub looking for
library/localonly. Rootless Podman needs its socket running (systemctl --user start podman.socket) before--image-src podmanworks.
Constrain the search with --image-src, or sidestep the engine entirely:
trivy image --image-src podman localonly:v1 # only try Podman
trivy image --image-src docker,remote app:v1 # daemon then registry
# Engine-agnostic and always reliable: scan a saved archive
podman save --format docker-archive -o app.tar localonly:v1
trivy image --input app.tar
Scanners and Their Defaults
--scanners selects what to look for. The defaults vary by target, and misconfiguration and licence scanning are off almost everywhere.
| Target | Default --scanners |
Allowed values |
|---|---|---|
image |
vuln,secret |
vuln,misconfig,secret,license |
filesystem |
vuln,secret |
vuln,misconfig,secret,license |
repository |
vuln,secret |
vuln,misconfig,secret,license |
rootfs |
vuln,secret |
vuln,misconfig,secret,license |
vm |
vuln,secret |
vuln,misconfig,secret,license |
sbom |
vuln |
vuln,license |
kubernetes |
vuln,misconfig,secret,rbac |
vuln,misconfig,secret,rbac |
config |
n/a — the command is the misconfig scanner | n/a |
Two consequences worth internalising:
trivy fs .on a repository containing a Dockerfile, Terraform, and Kubernetes manifests reports none of their misconfigurations. It found no problems because it never looked.trivy k8sis the only target with everything on by default, andrbacexists only there.
# Verified: fs with defaults over a tree containing Dockerfile + tf + k8s manifests
trivy fs proj/
# │ Target │ Type │ Vulnerabilities │ Secrets │
# │ requirements.txt │ pip │ 11 │ - │
# The Dockerfile, tf/main.tf and k8s/pod.yaml are not even listed.
# The same tree with everything enabled
trivy fs --scanners vuln,misconfig,secret,license proj/
# │ Target │ Type │ Vulnerabilities │ Misconfigurations │ Secrets │ Licenses │
# │ requirements.txt │ pip │ 11 │ - │ - │ - │
# │ Dockerfile │ dockerfile │ - │ 3 │ - │ - │
# │ k8s/pod.yaml │ kubernetes │ - │ 18 │ - │ - │
# │ tf/main.tf │ terraform │ - │ 11 │ - │ - │
In the summary table - means not scanned and 0 means scanned and clean. That distinction is the fastest way to catch a scanner you forgot to enable.
On versions before 0.38 this flag was spelled --security-checks, and config was --security-checks config rather than --scanners misconfig. Both old spellings are gone; use --scanners.
Licence classification
Licence findings are not vulnerabilities but are graded on the same severity scale, by category:
trivy image --scanners license alpine:3.19
# ('notice', 'LOW', 'MIT' / 'Apache-2.0' / 'BSD-3-Clause' / 'Zlib')
# ('reciprocal', 'MEDIUM', 'MPL-2.0')
# ('restricted', 'HIGH', 'GPL-2.0-only')
Footgun. Turning on
licensealongside--severity HIGH --exit-code 1fails your build on every GPL package in the base image. Gate licences in a separate step, or setlicense.ignoredin the config file.
Severity, Fix Status, and Exit Codes
This is where CI pipelines quietly stop working. Three flags interact, and the defaults are permissive.
--exit-code defaults to 0
Trivy exits 0 when it finds vulnerabilities unless you tell it otherwise. A pipeline that runs trivy image myapp and trusts the exit status is not gating anything.
# All verified against alpine:3.19 (10 findings: 3 LOW, 5 MEDIUM, 2 HIGH, 0 CRITICAL)
trivy image alpine:3.19 # exit 0 <-- findings ignored
trivy image --exit-code 1 alpine:3.19 # exit 1
trivy image --exit-code 1 --severity CRITICAL alpine:3.19 # exit 0 (none at CRITICAL)
trivy image --exit-code 1 --severity HIGH alpine:3.19 # exit 1
trivy image --exit-code 5 --severity HIGH alpine:3.19 # exit 5
--severity filters the report before the exit-code decision, so the gate only ever considers what survived the filter. That is usually what you want, but it means --severity CRITICAL silently makes a HIGH-only image "pass".
Operational failures also exit 1
trivy image --exit-code 1 doesnotexist.invalid/nope:1 # exit 1 (scan failed)
trivy image doesnotexist.invalid/nope:1 # exit 1 (scan failed, no --exit-code)
Footgun. With
--exit-code 1you cannot distinguish "found vulnerabilities" from "the scan never ran". A typo'd image name, an expired registry token, or a DB download failure looks exactly like a policy violation — and worse, a green build after you "fix" the typo may just mean the scan is now silently succeeding on the wrong thing.
Use a distinct code so the two are separable:
trivy image --exit-code 5 --severity HIGH,CRITICAL myapp:v1
case $? in
0) echo "clean" ;;
5) echo "policy violation: HIGH/CRITICAL findings"; exit 1 ;;
*) echo "scan failed to run"; exit 2 ;;
esac
--ignore-unfixed and --ignore-status
--ignore-unfixed shows only vulnerabilities that have a fix available in the target distribution — the pragmatic default for gating, since you cannot act on the rest.
trivy image --exit-code 1 --severity HIGH --ignore-unfixed alpine:3.19 # exit 1 (both HIGHs are fixed)
It is shorthand over --ignore-status, which takes explicit states:
unknown, not_affected, affected, fixed, under_investigation,
will_not_fix, fix_deferred, end_of_life
# Red Hat-style triage: drop what the vendor will never fix, keep the rest
trivy image --ignore-status will_not_fix,fix_deferred,end_of_life ubi9:latest
--severity does not mean what you assume
Trivy takes severity from the distribution vendor, not NVD, because vendor ratings account for how the package is actually built and shipped. A CVE that NVD rates CRITICAL is routinely MEDIUM on Debian. Trivy says so out loud:
trivy image alpine:3.22
# WARN Using severities from other vendors for some vulnerabilities.
# Read https://trivy.dev/docs/v0.74/guide/scanner/vulnerability#severity-selection for details.
Override the source order when you must reconcile with an NVD-based tracker:
trivy image --vuln-severity-source nvd myapp:v1 # NVD only
trivy image --vuln-severity-source auto,nvd myapp:v1 # vendor first, NVD as fallback
# allowed: auto, nvd, and 31 per-vendor sources (redhat, redhat-oval, debian,
# ubuntu, alpine, amazon, suse-cvrf, wolfi, …). --help lists only 13 of
# the 33 and omits "auto"; an invalid value prints the full set.
Where no configured source has data, severity becomes UNKNOWN — which --severity HIGH,CRITICAL then filters out entirely.
End-of-life base images
An unsupported distribution gets no security updates, so a clean scan is meaningless. Gate on it separately:
trivy image --exit-on-eol 1 alpine:3.16
# WARN This OS version is no longer supported by the distribution family="alpine" version="3.16.9"
# WARN The vulnerability detection may be insufficient because security updates are not provided
# ERROR Detected EOL OS family="alpine" version="3.16.9"
# exit 1
trivy image --exit-on-eol 1 alpine:3.22 # exit 0
The Vulnerability Database
Trivy has no vulnerability service. It downloads a compiled BoltDB file and queries it locally.
Distribution is via OCI registries
The DB is an OCI artefact, and the default is a list of repositories tried in priority order:
db:
repository:
- mirror.gcr.io/aquasec/trivy-db:2 # primary (Google-hosted mirror)
- ghcr.io/aquasecurity/trivy-db:2 # fallback
java-repository:
- mirror.gcr.io/aquasec/trivy-java-db:1
- ghcr.io/aquasecurity/trivy-java-db:1
Historically the default was GHCR alone, and before that a GitHub Releases asset. The GCR mirror is now primary specifically because anonymous pulls were hitting registry rate limits in CI.
trivy image --download-db-only
# INFO [vulndb] Need to update DB
# INFO [vulndb] Downloading vulnerability DB...
# INFO [vulndb] Downloading artifact... repo="mirror.gcr.io/aquasec/trivy-db:2"
# 110.98 MiB / 110.98 MiB [====================================] 100.00%
cat "$TRIVY_CACHE_DIR/db/metadata.json"
# {"Version":2,"NextUpdate":"2026-09-06T13:02:41Z",
# "UpdatedAt":"2026-09-05T13:02:41Z","DownloadedAt":"2026-09-05T15:42:23Z"}
The artefact is ~111 MiB compressed and ~1.3 GB on disk once unpacked. Upstream rebuilds it every six hours, but that is not your refresh rate: NextUpdate is what drives a re-download, and it is set 24 hours after UpdatedAt. A client can therefore be up to a day behind the published database while believing it is current. trivy clean --all forces a fetch if you need the newest set. (There is no --reset on 0.74.0; it was removed in favour of trivy clean.)
Footgun. A 1.3 GB cache will not fit in a small
tmpfs. Pointing--cache-dirat a memory-backed/tmpfails partway through withwrite .../trivy.db: no space left on device, after a successful-looking download. Put the cache on real disk.
Rate limits and mirroring
Even with the GCR mirror, shared CI egress IPs get throttled. The durable fixes, in order of preference:
# 1. Authenticate to GHCR so pulls count against your account, not the anonymous pool
export TRIVY_USERNAME=<user> TRIVY_PASSWORD=<github-token>
# 2. Mirror the DB into your own registry and point everything at it
oras copy mirror.gcr.io/aquasec/trivy-db:2 registry.internal/trivy-db:2
export TRIVY_DB_REPOSITORY=registry.internal/trivy-db:2
export TRIVY_JAVA_DB_REPOSITORY=registry.internal/trivy-java-db:1
# 3. Share one DB across runners with client/server mode (see below)
--db-repository accepts a comma-separated list, so you can keep upstream as a fallback behind your mirror.
Air-gapped operation
Move the cache, not the network. Populate on a connected host, transfer, then scan with updates disabled:
# Connected host
trivy image --cache-dir ./trivy-cache --download-db-only
trivy image --cache-dir ./trivy-cache --download-java-db-only # only if scanning JARs
tar czf trivy-cache.tar.gz trivy-cache/
# Air-gapped host
tar xzf trivy-cache.tar.gz
trivy image --cache-dir ./trivy-cache \
--skip-db-update --skip-java-db-update --skip-check-update \
--offline-scan myapp:v1
Each flag disables exactly one fetch:
| Flag | Disables |
|---|---|
--skip-db-update |
Vulnerability DB refresh |
--skip-java-db-update |
Java index DB refresh |
--skip-check-update |
Rego checks bundle refresh |
--offline-scan |
Outbound API calls made during analysis (Maven Central lookups) |
Footgun.
--offline-scandoes not make Trivy offline. Its help text is precise: "do not issue API requests to identify dependencies". It suppresses dependency-resolution calls only; the DB download still happens. Air-gapped operation needs the--skip-*-updateflags, and--offline-scanalongside them if you scan Java.
--skip-db-update on an empty cache is a hard error rather than a silent degradation, which is the correct behaviour:
trivy image --cache-dir ./empty --skip-db-update alpine:3.19
# FATAL DB error: database error: --skip-db-update cannot be specified on the first run
The checks bundle (Rego misconfiguration rules, default mirror.gcr.io/aquasec/trivy-checks:2) is embedded in the binary at build time and used as a fallback, so trivy config degrades gracefully offline — the rules are simply as old as your binary.
The Java DB
Java is a special case: a JAR's coordinates are frequently not recoverable from the archive, so Trivy ships a separate index (trivy-java-db) mapping JAR checksums to GAV coordinates. It is downloaded lazily, only when a scan encounters a JAR, which is why the first Java scan in a pipeline is unexpectedly slow. Pre-fetch it with --download-java-db-only in the same step that warms the main DB.
Cache management
trivy clean --all # everything
trivy clean --vuln-db # just the vulnerability DB
trivy clean --java-db # just the Java index
trivy clean --scan-cache # analysis results only; keeps the DBs
trivy clean --checks-bundle # Rego checks
trivy clean --vex-repo # downloaded VEX repositories
Footgun.
--cache-dirdoes not cover everything. Plugins and VEX repository config live under$HOME/.trivy/, separate from$HOME/.cache/trivy/. In a container you must persist or set both.
Output Formats
trivy image --format json -o out.json myapp:v1
trivy image --format sarif -o out.sarif myapp:v1 # SARIF 2.1.0, for code scanning
trivy image --format cyclonedx -o sbom.json myapp:v1 # CycloneDX 1.7
trivy image --format spdx-json -o sbom.spdx myapp:v1 # SPDX-2.3
trivy image --format github -o dep.json myapp:v1 # GitHub dependency snapshot
trivy image --format cosign-vuln -o att.json myapp:v1 # cosign attestation predicate
Verified shapes for alpine:3.19: the table showed 10 rows, but those are 10 package-CVE pairs covering only 4 distinct CVE IDs — busybox, busybox-binsh, and ssl_client each carry the same two BusyBox CVEs. SARIF collapses to 4 rules with 10 results; CycloneDX reports 16 components and, with --scanners vuln, 4 vulnerabilities.
Footgun. "Trivy found 10 vulnerabilities" and "Trivy found 4 vulnerabilities" can both be true for one scan. Do not compare raw counts across formats, or against another scanner, without agreeing on the unit.
Footgun — verified.
--format cyclonedxand--format spdx-jsonswitch the vulnerability scanner off, whatever--scannersdefaults to. Trivy says so atINFO, which scrolls past in CI:"--format cyclonedx" disables security scanning. Specify "--scanners vuln" explicitly if you want to include vulnerabilities in the "cyclonedx" report.The document is otherwise well formed and itsvulnerabilitiesarray is simply empty.--format githuband--format cosign-vulnare unaffected.
Reducing table noise
trivy image --table-mode summary myapp:v1 # summary table only, no per-finding detail
That is the right default for CI logs — the detail belongs in an uploaded JSON or SARIF artefact.
--report is not a general output switch
For image, fs, and friends, --report summary|all (default summary) applies only to compliance reports. For trivy k8s it controls the main output and defaults to all. Same flag, different meaning per subcommand.
Go templates
# Fetch the template — it does not ship with the binary
curl -sSLO https://raw.githubusercontent.com/aquasecurity/trivy/v0.74.0/contrib/html.tpl
trivy image --format template --template "@html.tpl" -o report.html myapp:v1
# JUnit, for test-report ingestion
trivy image --format template --template "@junit.tpl" -o junit.xml myapp:v1
# <testsuites name="trivy">
# <testsuite tests="10" failures="10" name="alpine:3.19 (alpine 3.19.9)" errors="0" skipped="0" time="">
# Inline templates work too
trivy image --format template \
--template '{{ range . }}{{ .Target }}: {{ len .Vulnerabilities }}{{ println }}{{ end }}' myapp:v1
# alpine:3.19 (alpine 3.19.9): 10
Available templates: asff.tpl, gitlab.tpl, gitlab-codequality.tpl, html.tpl, junit.tpl. There is no SARIF template — SARIF is a native format.
Scan once, convert many
trivy image --format json -o result.json myapp:v1
trivy convert --format sarif -o result.sarif result.json
trivy convert --format cyclonedx -o result.cdx result.json
trivy convert --format table result.json
This is the correct CI pattern: one scan, one DB read, many artefacts. Note --list-all-pkgs defaults to true, so the JSON already carries the full package inventory that conversion needs.
Ignoring and Triaging Findings
Three mechanisms, in increasing order of rigour.
.trivyignore
A plain list of IDs, auto-detected in the working directory:
# .trivyignore
CVE-2024-58251
CVE-2025-46394
AVD-AWS-0086
trivy image alpine:3.19 # 10 findings
# with the .trivyignore above: 4 findings
No expiry, no rationale, no scope. It rots, and nobody remembers why an entry is there.
.trivyignore.yaml
Structured, with per-entry statements and expiry:
# .trivyignore.yaml
vulnerabilities:
- id: CVE-2024-58251
statement: netstat is not reachable in this image
expired_at: 2026-12-01
- id: CVE-2025-46394
paths:
- "usr/bin/busybox"
statement: no untrusted tar input
misconfigurations:
- id: DS-0002
statement: runs under a read-only root with a non-root PSA policy
secrets:
- id: aws-access-key-id
paths:
- "testdata/**"
Expiry behaves as advertised — verified by moving the date either side of today:
trivy image --ignorefile exp-future.yaml alpine:3.19 # 4 findings (expired_at 2027-01-01)
trivy image --ignorefile exp-past.yaml alpine:3.19 # 10 findings (expired_at 2025-01-01)
Footgun — verified.
.trivyignore.yamlis not auto-detected.--ignorefiledefaults to.trivyignoreand nothing else. A.trivyignore.yamlsitting in the repository root is silently ignored, so every finding you thought you had triaged is still failing the build — or, worse, you never noticed because nothing was gating. Pass--ignorefile .trivyignore.yaml, or setignorefile:intrivy.yaml.
Check IDs are zero-padded in current Trivy — DS-0002, KSV-0017, AWS-0107. Older releases emitted DS002 / KSV017 / AVD-AWS-0086. Ignore entries written against the old spelling stop matching silently.
VEX
VEX (Vulnerability Exploitability eXchange) is the defensible answer to "we have triaged this". Rather than deleting a finding, it records a machine-readable assertion — scoped to a package URL, with a justification — that survives audit.
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://example.com/vex/alpine-demo",
"author": "Platform Security",
"timestamp": "2026-09-05T00:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": { "name": "CVE-2024-58251" },
"products": [
{ "@id": "pkg:apk/alpine/busybox@1.36.1-r20?arch=x86_64&distro=3.19.9" }
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
]
}
trivy image --vex vex.openvex.json --show-suppressed alpine:3.19
# Total: 9 (was 10)
#
# Suppressed Vulnerabilities (Total: 1)
# │ Library │ Vulnerability │ Severity │ Status │ Statement │ Source │
# │ busybox │ CVE-2024-58251 │ MEDIUM │ not_affected │ vulnerable_code_not_in_execute_path │ vex.openvex.json │
Footgun — verified. VEX matching is purl-exact. The statement above suppressed
CVE-2024-58251forbusyboxand left it reported againstbusybox-binshandssl_client, which are separate packages carrying the same CVE. A triage decision that is logically about "the BusyBox CVE" needs a product entry per affected purl. Always confirm with--show-suppressedrather than assuming a statement landed.
--vex accepts a file path, oci (read a VEX attestation attached to the image), or repo (a curated VEX repository):
trivy vex repo init
# INFO [vex] The default repository config has been created
# file_path="$HOME/.trivy/vex/repository.yaml"
cat "$HOME/.trivy/vex/repository.yaml"
# repositories:
# - name: default
# url: https://github.com/aquasecurity/vexhub
# enabled: true
trivy image --vex repo myapp:v1 # apply upstream-published VEX
VEX repositories let upstream maintainers publish "not affected" once and have every consumer pick it up, instead of each team re-triaging the same CVE. --vex and trivy vex are still marked [EXPERIMENTAL].
Choosing between them: .trivyignore for throwaway noise, .trivyignore.yaml for team-owned suppressions that must expire, VEX for anything a customer or auditor will ask about.
Configuration File and Environment Variables
trivy.yaml in the working directory is loaded automatically (--config / -c overrides the path). Generate the full annotated schema rather than guessing key names:
trivy image --generate-default-config
# INFO Writing the default config to trivy-default.yaml...
Footgun.
--generate-default-configis a global flag but needs a subcommand — baretrivy --generate-default-configjust prints help and writes nothing. It also refuses to overwrite an existing file (Config File "trivy-default.yaml" Already Exists).
The schema nests under the flag group, not the flag name:
# trivy.yaml
severity: [HIGH, CRITICAL]
exit-code: 5
ignorefile: .trivyignore.yaml # the only way to auto-load the YAML form
scan:
scanners: [vuln, secret, misconfig]
skip-dirs: [vendor, node_modules]
detection-priority: precise # or "comprehensive" — more findings, more false positives
disable-telemetry: true
db:
repository: # a list, tried in order
- registry.internal/trivy-db:2
- mirror.gcr.io/aquasec/trivy-db:2
vulnerability:
ignore-unfixed: true
ignore-status: [will_not_fix, end_of_life]
severity-source: [auto]
misconfiguration:
terraform:
exclude-downloaded-modules: true
license:
ignored: [GPL-2.0-only]
secret:
config: trivy-secret.yaml
Note scan.disable-telemetry — Trivy sends anonymous usage data to Aqua by default. Set it, or TRIVY_DISABLE_TELEMETRY=true, if that matters to you.
Precedence
Every flag has a TRIVY_-prefixed environment variable (--severity → TRIVY_SEVERITY, --cache-dir → TRIVY_CACHE_DIR, --skip-db-update → TRIVY_SKIP_DB_UPDATE). Verified by setting the same option three ways at once:
# trivy.yaml sets severity: [LOW]
trivy image --format json alpine:3.19 # -> ['LOW']
TRIVY_SEVERITY=HIGH trivy image --format json alpine:3.19 # -> ['HIGH']
TRIVY_SEVERITY=HIGH trivy image --severity MEDIUM ... alpine:3.19 # -> ['MEDIUM']
Command-line flag > environment variable > config file > built-in default.
Misconfiguration and IaC Scanning
trivy config DIR scans IaC. The engine is Rego, and the rules ship as the trivy-checks bundle.
trivy config proj/
# │ Target │ Type │ Misconfigurations │
# │ Dockerfile │ dockerfile │ 3 │
# │ k8s/pod.yaml │ kubernetes │ 18 │
# │ tf/main.tf │ terraform │ 11 │
#
# Dockerfile (dockerfile)
# Tests: 27 (SUCCESSES: 24, FAILURES: 3)
# Failures: 3 (UNKNOWN: 0, LOW: 1, MEDIUM: 1, HIGH: 1, CRITICAL: 0)
#
# DS-0002 (HIGH): Specify at least 1 USER command in Dockerfile with non-root user as argument
# DS-0004 (MEDIUM): Port 22 should not be exposed in Dockerfile
# DS-0026 (LOW): No HEALTHCHECK defined
Footgun.
trivy confighas no vulnerability DB, so DB flags are rejected. Passing--skip-db-update— copied from animageinvocation — makes it print usage and do nothing. Under--quietthe error is suppressed and you get an empty-looking success.
Check ID prefixes tell you the ruleset: DS- Dockerfile, KSV- Kubernetes, AWS-/AZU-/GCP- cloud providers via Terraform or CloudFormation.
Selecting file types
--misconfig-scanners azure-arm,cloudformation,dockerfile,helm,kubernetes,\
terraform,terraformplan-json,terraformplan-snapshot,ansible
All nine are enabled by default. Narrowing them is a real speed win on a large monorepo:
trivy config --misconfig-scanners terraform,kubernetes infra/
Terraform, Helm, and plans
# Feed variables so conditional resources evaluate correctly
trivy config --tf-vars prod.tfvars infra/
# Skip modules pulled into .terraform/modules — you do not own those findings
trivy config --tf-exclude-downloaded-modules infra/
# Scanning the plan catches what static HCL cannot resolve
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
trivy config tfplan.json
# Helm charts are templated before scanning
trivy config --helm-values values-prod.yaml --helm-kube-version 1.31.0 charts/app/
Custom Rego checks
# checks/no_latest_tag.rego
# METADATA
# title: Image must not use the latest tag
# description: Pin images to an immutable digest or explicit version.
# custom:
# id: ORG-0001
# severity: HIGH
# input:
# selector:
# - type: kubernetes
package custom.kubernetes.ORG0001
import rego.v1
deny contains res if {
some container in input.spec.containers
endswith(container.image, ":latest")
res := sprintf("container '%s' uses the ':latest' tag", [container.name])
}
trivy config --config-check ./checks --check-namespaces custom proj/
Footgun. Custom checks in a namespace Trivy does not know about are loaded and never evaluated.
--check-namespacesis what registers yours; without it the run succeeds and reports nothing from your rules. Confirm with--include-non-failures, which lists passing checks so you can see yours ran at all.
Secret Scanning
Enabled by default on image, fs, repository, rootfs, and vm. Findings are always CRITICAL and values are masked in output.
trivy fs --scanners secret sec/
# real.env (secrets)
# Total: 3 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 3)
#
# CRITICAL: AWS (aws-access-key-id)
# real.env:1 (offset: 18 bytes)
# 1 [ AWS_ACCESS_KEY_ID=********************
Footgun — verified. Trivy's built-in rules allow-list well-known example credentials. A file containing
AKIAIOSFODNN7EXAMPLEandwJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY— the canonical AWS documentation keys — produces zero findings. Testing your secret-scanning setup with the example key from the docs will convince you the scanner is broken or disabled. Use a realistic random key to smoke-test.
Custom rules go in trivy-secret.yaml (auto-detected):
# trivy-secret.yaml
rules:
- id: internal-api-token
category: general
title: Internal API token
severity: CRITICAL
regex: 'ACME_(?i)(token|key)[[:space:]]*[=:][[:space:]]*[A-Za-z0-9]{32}'
keywords:
- ACME_
# Suppress by rule, path, or literal value
allow-rules:
- id: test-fixtures
description: fixture credentials
path: .*/testdata/.*
disable-rules:
- hugging-face-access-token
The keywords field is a prefilter: a rule whose keywords are absent from a file never has its regex evaluated. Omitting it makes large scans much slower.
trivy fs --scanners secret --secret-config ./trivy-secret.yaml .
Trivy scans the filesystem it is given, so on an image it sees only what survived into the final layers. A secret added and deleted in an earlier layer is still in the image but not in the scanned rootfs. Use trivy repository with git history, or a dedicated history scanner, for that class. See security/container-security.md for build-time secret handling.
Kubernetes Scanning
trivy k8s # current context, whole cluster
trivy k8s --include-namespaces production
trivy k8s --exclude-namespaces kube-system,kube-public
trivy k8s kind-kind --report summary # a named context
trivy k8s --kubeconfig ~/.kube/staging.yaml
All four scanners run by default (vuln,misconfig,secret,rbac), --report defaults to all, and --format is limited to table, json, and cyclonedx.
Practical constraints:
# Skip pulling and scanning every image — much faster posture-only run
trivy k8s --skip-images --report summary
# Ignore Pods owned by a Deployment/DaemonSet; report the controller instead
trivy k8s --exclude-owned
Node-level checks are gathered by a Job that Trivy schedules into the cluster:
trivy k8s --disable-node-collector # no in-cluster Job; loses node misconfig findings
trivy k8s --node-collector-namespace trivy-temp
trivy k8s --exclude-nodes kubernetes.io/arch:arm64
Footgun. The node collector needs to create a Job (default namespace
trivy-temp) and mount host paths. Under a restrictive PodSecurity policy it fails, andtrivy k8sreports fewer findings without making the reduced coverage obvious. Use--disable-node-collectordeliberately rather than discovering it silently.
Compliance reports map findings onto published benchmarks:
trivy k8s --compliance k8s-cis-1.23 --report summary
# Built-in: k8s-nsa-1.0, k8s-cis-1.23, eks-cis-1.4, rke2-cis-1.24,
# k8s-pss-baseline-0.1, k8s-pss-restricted-0.1
trivy image --compliance docker-cis-1.6.0 alpine:3.19
# Summary Report for compliance: CIS Docker Community Edition Benchmark v1.6.0
# │ ID │ Severity │ Control Name │ Status │ Issues │
# │ 4.1 │ HIGH │ Ensure a user has been created │ FAIL │ 1 │
# │ 4.4 │ CRITICAL │ Ensure images are scanned/rebuilt │ PASS │ 0 │
# │ 4.6 │ LOW │ Ensure HEALTHCHECK added │ FAIL │ 1 │
# Controls marked '-' are Manual and cannot be assessed automatically.
Where the Trivy Operator fits
trivy k8s is a point-in-time scan you run. The Trivy Operator (currently v0.34.0) is a controller that watches workloads and writes findings back as CRDs — VulnerabilityReport, ConfigAuditReport, ExposedSecretReport, RbacAssessmentReport — re-scanning on every image change and exposing Prometheus metrics.
Use the CLI for CI gating and ad-hoc audits; use the Operator for continuous posture, because a CLI scan is stale the moment a Deployment rolls. They share a rule engine, so findings are comparable. The Operator is a separate deployment with its own Helm chart and RBAC footprint — see aquasecurity/trivy-operator rather than treating it as a flag on the CLI.
Client/Server Mode
The DB is the expensive part of a scan. In server mode one process holds it and clients stay thin — useful when a dozen CI runners would otherwise each pull 111 MiB.
# Server: holds the DB, does the vulnerability matching
trivy server --listen 0.0.0.0:4954 --token "$TRIVY_TOKEN"
# INFO Listening 0.0.0.0:4954...
# Client: analyses the artefact locally, sends the package list for matching
trivy image --server http://trivy.internal:4954 --token "$TRIVY_TOKEN" myapp:v1
Verified: a client run against a local server produced identical results, and the client's --cache-dir was never created — it holds no database at all.
Details that matter:
- Default listen address is
localhost:4954; you must set--listen 0.0.0.0:4954to accept remote clients. --tokenplus--token-header(defaultTrivy-Token) is the only auth. There is no TLS in the server itself — terminate in front of it.- The client still pulls and analyses the image, so it needs registry credentials and bandwidth. Only vulnerability matching is remote.
- Misconfiguration, secret, and licence scanning all happen client-side; server mode only offloads
vuln.
CI Integration
Cross-reference cicd/github-actions.md for workflow syntax, permissions, and OIDC.
GitHub Actions
name: security-scan
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # required to upload SARIF
steps:
- uses: actions/checkout@v6
# Report everything to code scanning — never gate on this step
- name: Scan image (SARIF)
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: image
image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
format: sarif
output: trivy.sarif
version: v0.74.0 # the action's own default lags the release
- uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy.sarif
# Gate separately, with a distinct exit code
- name: Gate on HIGH/CRITICAL
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: image
image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
format: table
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: "1"
version: v0.74.0
skip-setup-trivy: true # reuse the binary installed above
Four things this example encodes:
- Split reporting from gating. With one combined step a non-zero exit skips the SARIF upload and you lose the evidence for exactly the runs you care about.
security-events: writeis required forupload-sarif. Without it the step fails with a 403 that reads like an authentication problem.- Pin the action to a release tag.
@masteris common in examples and gives you an unreviewed action on every run. - Pin
version:. The action carries its own Trivy default (v0.70.0as of trivy-action v0.36.0), so it silently lags upstream. If you rely on current behaviour, say so.
Footgun.
severitydoes not apply to SARIF output — the action deliberately reports every severity to code scanning regardless, so GitHub holds the full picture. Setlimit-severities-for-sarif: trueif you want the filter honoured. The action also caches the DB itself (cache: trueby default); adding your ownactions/cachestep over the same directory fights it rather than helping.
GitLab CI
variables:
TRIVY_NO_PROGRESS: "true"
TRIVY_CACHE_DIR: ".trivycache/"
TRIVY_EXIT_CODE: "0"
.trivy:
image:
name: docker.io/aquasec/trivy:0.74.0
entrypoint: [""]
cache:
key: trivy-db
paths:
- .trivycache/
before_script:
- trivy --version
- trivy image --download-db-only
container_scanning:
extends: .trivy
stage: test
script:
# Report: never fails the job
- trivy image --format template --template "@/contrib/gitlab.tpl"
-o gl-container-scanning-report.json "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
# Gate: distinct exit code so a scan failure is not a policy failure
- trivy image --severity HIGH,CRITICAL --ignore-unfixed
--exit-code 5 "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
artifacts:
when: always
reports:
container_scanning: gl-container-scanning-report.json
iac_scanning:
extends: .trivy
stage: test
script:
- trivy config --exit-code 5 --severity HIGH,CRITICAL .
GitLab's own Security/Container-Scanning.gitlab-ci.yml template is Trivy-based; running Trivy directly is worth it when you want scanners or gating the template does not expose. The gitlab.tpl path is /contrib/gitlab.tpl inside the official image.
Plugins
trivy plugin update # refresh the index
trivy plugin search
# aqua Send results to Aqua Security | kubectl Scan kubectl resources
# count Count vulnerabilities | referrer Put referrers to OCI registry
# mcp Run an MCP server that drives Trivy | scan2html Output results to a single HTML file
# sonarqube Convert JSON report to SonarQube | vexllm Silence negligible CVEs using an LLM
# zarf Scan Zarf packages | aws Trivy AWS Cloud scanning plugin
trivy plugin install count
trivy count --help # installed plugins become subcommands
trivy plugin run sonarqube ... # or run without installing
trivy plugin uninstall count
Plugins install to $HOME/.trivy/plugins/, which --cache-dir does not relocate. They are arbitrary binaries fetched from third-party repositories — treat installing one as you would any other supply-chain decision.
Trivy Compared
Brief, because the detailed comparison lives in security/grype-syft.md.
| Trivy | Grype + Syft | Clair | |
|---|---|---|---|
| Model | Single binary, local DB | Two binaries: Syft builds SBOM, Grype matches | Server plus PostgreSQL |
| Scope | Vulns, IaC, secrets, licences, k8s | Vulnerabilities and SBOM generation | Vulnerabilities only |
| Deployment | Nothing to run | Nothing to run | Persistent service |
| Best at | Breadth in one tool; CI gating | SBOM fidelity; clean separation of build and match | Registry-integrated continuous scanning at scale |
Trivy wins on breadth and on getting a useful result in one command. Grype and Syft win when you want the SBOM to be the durable artefact and matching to be a separate, re-runnable step — the right shape when SBOMs are a deliverable. Clair earns its operational cost when it sits behind a registry (Quay, Harbor) and scans everything pushed, continuously, without a pipeline invoking it.
They disagree on counts. Different databases, different severity sources, and different ideas of what a "finding" is (see the 10-versus-4 note above) mean cross-tool comparison needs a shared unit before it means anything.
Quick Reference
| Task | Command |
|---|---|
| Scan an image | trivy image myapp:v1 |
| Scan a local tar | trivy image --input app.tar |
| Scan a directory, everything on | trivy fs --scanners vuln,misconfig,secret,license . |
| Scan IaC only | trivy config infra/ |
| Scan a remote repo | trivy repository https://github.com/org/repo |
| Scan a cluster | trivy k8s --report summary |
| Gate a build | trivy image --exit-code 5 --severity HIGH,CRITICAL --ignore-unfixed myapp:v1 |
| Gate on EOL base image | trivy image --exit-on-eol 1 myapp:v1 |
| Generate CycloneDX SBOM | trivy image --format cyclonedx -o sbom.json myapp:v1 |
| Generate SPDX SBOM | trivy image --format spdx-json -o sbom.spdx myapp:v1 |
| Rescan an SBOM | trivy sbom sbom.json |
| SARIF for code scanning | trivy image --format sarif -o out.sarif myapp:v1 |
| Convert an existing report | trivy convert --format sarif -o out.sarif result.json |
| Quiet CI output | trivy image --table-mode summary --no-progress myapp:v1 |
| Pre-download the DB | trivy image --download-db-only |
| Air-gapped scan | trivy image --skip-db-update --skip-java-db-update --skip-check-update --offline-scan myapp:v1 |
| Apply VEX | trivy image --vex vex.json --show-suppressed myapp:v1 |
| Use a YAML ignore file | trivy image --ignorefile .trivyignore.yaml myapp:v1 |
| Start a shared server | trivy server --listen 0.0.0.0:4954 --token "$T" |
| Scan via server | trivy image --server http://host:4954 --token "$T" myapp:v1 |
| Dump the config schema | trivy image --generate-default-config |
| Clear caches | trivy clean --all |
Environment variables worth knowing
| Variable | Effect |
|---|---|
TRIVY_CACHE_DIR |
Cache location (not plugins or VEX repos) |
TRIVY_DB_REPOSITORY |
Override the vulnerability DB OCI source |
TRIVY_JAVA_DB_REPOSITORY |
Override the Java index DB source |
TRIVY_USERNAME / TRIVY_PASSWORD |
Registry credentials, including for DB pulls |
TRIVY_SEVERITY |
Severity filter |
TRIVY_EXIT_CODE |
Exit code on findings |
TRIVY_IGNOREFILE |
Ignore file path |
TRIVY_NO_PROGRESS |
Suppress progress bars in CI |
TRIVY_DISABLE_TELEMETRY |
Stop anonymous usage reporting |
TRIVY_SKIP_DB_UPDATE |
Skip DB refresh |
Common Issues and Solutions
| Issue | Cause | Fix |
|---|---|---|
| CRITICAL findings, build still green | --exit-code defaults to 0 |
Set --exit-code explicitly; it is opt-in |
| Build fails identically on a typo'd image name and a real finding | Operational errors also exit 1 |
Use a distinct code (--exit-code 5) and branch on it |
TOOMANYREQUESTS pulling the DB |
Anonymous registry rate limits on shared CI egress | Set TRIVY_USERNAME/TRIVY_PASSWORD, or mirror to TRIVY_DB_REPOSITORY |
write .../trivy.db: no space left on device |
The unpacked DB is ~1.3 GB; --cache-dir points at a small tmpfs |
Put the cache on real disk |
--skip-db-update cannot be specified on the first run |
No DB in the cache yet | Run --download-db-only once, or ship a populated cache |
--offline-scan still tries to download |
It only suppresses dependency-resolution API calls | Use --skip-db-update --skip-java-db-update --skip-check-update |
.trivyignore.yaml has no effect |
Only .trivyignore is auto-detected |
Pass --ignorefile .trivyignore.yaml or set ignorefile: in trivy.yaml |
| Ignore entries stopped matching after an upgrade | Check IDs are now zero-padded (DS-0002, not DS002) |
Re-derive IDs from a current JSON report |
| VEX suppressed one package but not its siblings | VEX matching is purl-exact | Add a product entry per affected purl; verify with --show-suppressed |
| Secret scanner finds nothing on a test file | Built-in allow-rules skip canonical ...EXAMPLE credentials |
Smoke-test with a realistic random key |
trivy fs reports no Dockerfile or Terraform issues |
misconfig is not a default scanner |
--scanners vuln,misconfig,secret or use trivy config |
trivy config prints usage and exits |
DB flags such as --skip-db-update are invalid there |
Drop them; config has no vulnerability DB |
| Custom Rego checks never fire | Their namespace is not registered | Add --check-namespaces <ns>; confirm with --include-non-failures |
unable to find the specified image ... UNAUTHORIZED |
Local image not found; fell through to a Docker Hub pull | Check the engine socket, use --image-src, or scan a saved tar with --input |
| Rootless Podman images invisible to Trivy | Podman socket not running | systemctl --user start podman.socket |
--template "@/usr/local/share/trivy/templates/..." not found |
Templates ship only in the container image | Fetch the .tpl from the repo, or use @/contrib/… in the image |
| Results look stale after a base-image rebuild | Analysis results are cached separately from the DB | trivy clean --scan-cache (keeps the DBs) |
--severity CRITICAL hides a CVE that NVD rates CRITICAL |
Severity comes from the distro vendor, not NVD | --vuln-severity-source auto,nvd, or widen the filter |
Findings vanish after adding --severity |
UNKNOWN-severity findings are filtered out |
Include UNKNOWN in the list when gating strictly |
| First Java scan is very slow | The Java index DB downloads lazily on first JAR | Pre-fetch with --download-java-db-only |
| SARIF upload fails with 403 | Missing security-events: write |
Add the permission to the job |
Code scanning shows LOW findings despite severity: HIGH,CRITICAL |
trivy-action deliberately exempts SARIF from the severity filter |
Set limit-severities-for-sarif: true |
| CI runs an older Trivy than expected | trivy-action pins its own Trivy default, which lags upstream |
Set the action's version: input explicitly |
| Licence scanning fails the build on GPL | Licence findings share the severity scale; restricted is HIGH |
Gate licences separately or set license.ignored |
trivy k8s misses node findings |
The node-collector Job could not run | Check RBAC and PodSecurity in trivy-temp, or set --disable-node-collector knowingly |
| Plugins disappear when the cache is cleared | Plugins live in $HOME/.trivy/, not --cache-dir |
Persist both paths in containerised CI |
Related Topics
The following topics complement this cheatsheet and would be valuable additions:
- Grype and Syft - SBOM-first scanning, where matching is a separate re-runnable step, and a detailed comparison against Trivy
- Clair - Registry-integrated continuous scanning, and when a persistent service beats a CLI
- Container Security - Runtime hardening, rootless isolation, capabilities, and build-time secret handling that scanning cannot see
- Container Image Optimisation - Multi-stage builds and base-image selection; the cheapest way to cut a scan's findings is to ship less
- GitHub Actions - Workflow syntax, permissions, OIDC, and caching, for the pipelines that invoke these scans
- Policy as Code (OPA) - Rego in depth, for writing and testing the custom checks Trivy's misconfiguration scanner evaluates