Available for day contractsFrom 21st September I have availability for day and half day contracts. Please contact for more information.

Contact →
mikepreston.org

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.

Scanners (--scanners)Targets (subcommands)opt-inopt-inopt-inopt-inimage — registry,engine, or tarfilesystem — a localdirectoryrepository — localor remote gitrootfs — anextracted rootfilesystemsbom — CycloneDX orSPDX inputconfig — IaC filesonlykubernetes — a liveclustervm — a VM image orsnapshotvuln — CVEs in OSand languagepackagesmisconfig — IaC andDockerfile checkssecret — hard-codedcredentialslicense — packagelicenceclassificationReport — table,JSON, SARIF, SBOM,templateScanners (--scanners)Targets (subcommands)opt-inopt-inopt-inopt-inimage — registry,engine, or tarfilesystem — a localdirectoryrepository — localor remote gitrootfs — anextracted rootfilesystemsbom — CycloneDX orSPDX inputconfig — IaC filesonlykubernetes — a liveclustervm — a VM image orsnapshotvuln — CVEs in OSand languagepackagesmisconfig — IaC andDockerfile checkssecret — hard-codedcredentialslicense — packagelicenceclassificationReport — 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 .tpl from 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 podman works.

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 k8s is the only target with everything on by default, and rbac exists 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 license alongside --severity HIGH --exit-code 1 fails your build on every GPL package in the base image. Gate licences in a separate step, or set license.ignored in 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 1 you 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-dir at a memory-backed /tmp fails partway through with write .../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-scan does 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-*-update flags, and --offline-scan alongside 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-dir does 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 cyclonedx and --format spdx-json switch the vulnerability scanner off, whatever --scanners defaults to. Trivy says so at INFO, 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 its vulnerabilities array is simply empty. --format github and --format cosign-vuln are 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.yaml is not auto-detected. --ignorefile defaults to .trivyignore and nothing else. A .trivyignore.yaml sitting 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 set ignorefile: in trivy.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-58251 for busybox and left it reported against busybox-binsh and ssl_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-suppressed rather 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-config is a global flag but needs a subcommand — bare trivy --generate-default-config just 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 config has no vulnerability DB, so DB flags are rejected. Passing --skip-db-update — copied from an image invocation — makes it print usage and do nothing. Under --quiet the 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-namespaces is 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 AKIAIOSFODNN7EXAMPLE and wJalrXUtnFEMI/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, and trivy k8s reports fewer findings without making the reduced coverage obvious. Use --disable-node-collector deliberately 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:4954 to accept remote clients.
  • --token plus --token-header (default Trivy-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: write is required for upload-sarif. Without it the step fails with a 403 that reads like an authentication problem.
  • Pin the action to a release tag. @master is common in examples and gives you an unreviewed action on every run.
  • Pin version:. The action carries its own Trivy default (v0.70.0 as of trivy-action v0.36.0), so it silently lags upstream. If you rely on current behaviour, say so.

Footgun. severity does not apply to SARIF output — the action deliberately reports every severity to code scanning regardless, so GitHub holds the full picture. Set limit-severities-for-sarif: true if you want the filter honoured. The action also caches the DB itself (cache: true by default); adding your own actions/cache step 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:

  1. Grype and Syft - SBOM-first scanning, where matching is a separate re-runnable step, and a detailed comparison against Trivy
  2. Clair - Registry-integrated continuous scanning, and when a persistent service beats a CLI
  3. Container Security - Runtime hardening, rootless isolation, capabilities, and build-time secret handling that scanning cannot see
  4. Container Image Optimisation - Multi-stage builds and base-image selection; the cheapest way to cut a scan's findings is to ship less
  5. GitHub Actions - Workflow syntax, permissions, OIDC, and caching, for the pipelines that invoke these scans
  6. Policy as Code (OPA) - Rego in depth, for writing and testing the custom checks Trivy's misconfiguration scanner evaluates