Trivy

OSS (Apache-2.0) · Aqua Security · aquasecurity/trivy · Docs · Companion check set: aquasecurity/trivy-checks

Trivy is the polyscanner alternative to Grype: the same OS-package and language-ecosystem SCA coverage plus secrets, IaC, SBOM, license, and Dockerfile / Kubernetes misconfiguration analysis under a single binary and a single feed pipeline. One invocation against an image returns OS-package CVEs, lockfile CVEs, embedded secrets, and misconfig findings together — the trait that makes Trivy the right pick when a team wants one tool to cover several finding classes from one report.

The other feature worth calling out up front is native consumption of both CycloneDX VEX and OpenVEX via --vex (Grype consumes OpenVEX only). For a triage workflow that already maintains a CycloneDX VEX next to the SBOM, this halves the format-juggling.

Modes overview

Trivy is one binary with many subcommands; the triage workflow you choose depends on which mode you ran:

ModeCommandWhat it scansFindings produced
Imagetrivy image <ref>OCI image layersOS pkgs + lang deps + secrets + misconfig + licenses
Filesystemtrivy fs <path>Local source treeManifests + secrets + IaC + licenses
Repotrivy repo <url>Remote git repoSame as fs (cloned first)
SBOMtrivy sbom <file>Existing CycloneDX or SPDX SBOMVuln matches against the SBOM components
Configtrivy config <path>Terraform / CloudFormation / Dockerfile / k8s YAML / HelmMisconfig findings only
Kubernetestrivy k8s <cluster>Live k8s clusterAll finding classes across every resource
VMtrivy vm <ref>VM images (qcow2 / vmdk / AMI)OS pkgs + secrets
AWStrivy awsLive AWS accountCloud-config misconfig
Plugintrivy plugin run …Third-party pluginPlugin-specific

The two most common triage entry points are image (CI / pre-deploy gate on a built container) and fs (developer-local pre-commit). The sbom mode is the recommended path for VEX-aware triage — same reasoning as Grype’s sbom: mode: you re-scan the artefact you already trust, no re-resolution.

What Trivy finds in JSON

# Image scan, JSON output
trivy image -f json -o trivy.json ghcr.io/yourorg/myapp:2.3.0

# Filesystem scan
trivy fs -f json -o trivy.json .

# SBOM re-scan (the recommended path for VEX-aware triage)
trivy sbom -f json -o trivy.json ./.vulnetix/sbom.cdx.json

# Include every scanner family in one image run
trivy image --scanners vuln,secret,misconfig,license -f json -o trivy.json <ref>

Top-level shape:

{
  "SchemaVersion": 2,
  "ArtifactName": "ghcr.io/yourorg/myapp:2.3.0",
  "ArtifactType": "container_image",
  "Results": [
    {
      "Target": "ghcr.io/yourorg/myapp (debian 12.5)",
      "Class": "os-pkgs",
      "Type": "debian",
      "Vulnerabilities": [ /* one per OS-pkg finding */ ]
    },
    {
      "Target": "app/package-lock.json",
      "Class": "lang-pkgs",
      "Type": "npm",
      "Vulnerabilities": [ /* one per lockfile finding */ ]
    },
    {
      "Target": "Dockerfile",
      "Class": "config",
      "Type": "dockerfile",
      "Misconfigurations": [ /* one per misconfig */ ]
    },
    {
      "Target": "src/.env",
      "Class": "secret",
      "Secrets": [ /* one per secret hit */ ]
    }
  ]
}

Per-vulnerability fields:

FieldPurpose
Results[].Vulnerabilities[].VulnerabilityIDCVE / GHSA / OS-vendor advisory ID
Results[].Vulnerabilities[].PkgName + .InstalledVersionMatched component
Results[].Vulnerabilities[].PkgIdentifier.PURLPURL — direct input to a CycloneDX VEX entry
Results[].Vulnerabilities[].FixedVersionVersions that include the fix (empty when no fix)
Results[].Vulnerabilities[].Statusfixed / affected / under_investigation / will_not_fix / fix_deferred / end_of_life
Results[].Vulnerabilities[].SeverityCRITICAL / HIGH / MEDIUM / LOW / UNKNOWN
Results[].Vulnerabilities[].CVSSMap keyed by vendor (nvd, redhat, ghsa) with V3Vector, V3Score, V40Vector, V40Score
Results[].Vulnerabilities[].PrimaryURLCanonical advisory URL
Results[].Vulnerabilities[].DataSourceWhich feed supplied the record (NVD / GHSA / Debian / Alpine / RedHat / GitLab)
Results[].Classos-pkgs / lang-pkgs / config / secret / license
Results[].TypeWithin a class: e.g. debian, alpine, rpm, npm, pip, gomod, maven, dockerfile, kubernetes, terraform

Per-misconfig and per-secret records carry different shapes:

FieldPurpose
Results[].Misconfigurations[].IDBuilt-in rule ID (DS001..DS031 for Dockerfile, KSV001.. for k8s)
Results[].Misconfigurations[].AVDIDAqua Vulnerability DB ID
Results[].Misconfigurations[].Title / .Description / .MessageHuman-readable explanation
Results[].Misconfigurations[].ResolutionOne-line fix recipe
Results[].Misconfigurations[].References[]External advisory URLs
Results[].Misconfigurations[].CauseMetadata.StartLine / .EndLineFile coordinates
Results[].Secrets[].RuleIDProvider rule (aws-access-key-id, github-pat, …)
Results[].Secrets[].MatchRedacted match snippet
Results[].Secrets[].StartLine / .EndLineFile coordinates

Querying with jq

# Every vulnerability flattened across all Results
jq '[.Results[]
     | select(.Vulnerabilities)
     | .Class as $class | .Target as $target
     | .Vulnerabilities[]
     | {id: .VulnerabilityID, pkg: .PkgName, version: .InstalledVersion,
        fix: .FixedVersion, severity: .Severity, purl: .PkgIdentifier.PURL,
        class: $class, target: $target}]' trivy.json

# Critical + High gating queue
jq '.Results[].Vulnerabilities[]?
    | select(.Severity == "CRITICAL" or .Severity == "HIGH")
    | {id: .VulnerabilityID, purl: .PkgIdentifier.PURL, fix: .FixedVersion}' \
   trivy.json

# OS-package findings only (Grype-style Class A — base-image triage)
jq '.Results[] | select(.Class == "os-pkgs") | .Vulnerabilities[]
    | {id: .VulnerabilityID, pkg: .PkgName, fix: .FixedVersion,
       distro: .DataSource.Name}' trivy.json

# Language-ecosystem findings only (Grype-style Class B — SCA triage)
jq '.Results[] | select(.Class == "lang-pkgs") | .Vulnerabilities[]
    | {id: .VulnerabilityID, pkg: .PkgName, manifest: ($target // "n/a"),
       fix: .FixedVersion}' trivy.json

# Misconfig findings with the Resolution recipe (read before VEX-ing)
jq '.Results[] | select(.Misconfigurations) | .Misconfigurations[]
    | {id: .ID, avd: .AVDID, severity: .Severity, where: .CauseMetadata,
       fix: .Resolution}' trivy.json

# Secrets findings
jq '.Results[] | select(.Class == "secret") | {target: .Target,
    hits: [.Secrets[] | {rule: .RuleID, line: .StartLine, match: .Match}]}' \
   trivy.json

# Findings with no fix available (will_not_fix / fix_deferred / end_of_life)
jq '.Results[].Vulnerabilities[]?
    | select(.FixedVersion == null or .FixedVersion == "")
    | {id: .VulnerabilityID, pkg: .PkgName, status: .Status,
       severity: .Severity}' trivy.json

From finding to root cause

The pivot depends on Results[].Class. Read it first; everything else flows from there.

Class os-pkgs (Grype Class A — OS-package finding)

Trivy emitted a CVE against an OS package in the image (deb / apk / rpm). Triage is identical to Grype’s Class A:

  • Option 1: bump the FROM tag.
  • Option 2: upgrade the specific package during build (apt-get install foo=…, apk add 'foo>=…').
  • Option 3: migrate to a maintained hardened base — see Grype § Class A — fix mechanics for the UBI / Chainguard / distroless / Wolfi catalogue and rationale.

Pull Engineer Triage inputs from Vulnetix’s VDB before deciding:

ID=$(jq -r '[.Results[] | select(.Class=="os-pkgs") | .Vulnerabilities[]]
            | .[0].VulnerabilityID' trivy.json)
vulnetix vdb vuln "$ID" --output json \
  | jq '.[0].containers.adp[0] | {coordinator: .x_ssvc.decision,
        exploitation: .x_exploitationMaturity.level,
        kev: .x_kev.knownRansomwareCampaignUse,
        routines: .x_affectedRoutines}'

Class lang-pkgs (Grype Class B — language ecosystem)

Trivy reports the manifest path in .Target (e.g. app/package-lock.json). Fix in the source repo’s manifest using the appropriate workflow in the package managers appendix, then rebuild the image. False-pivot trap: don’t try to upgrade the OS to fix an npm CVE.

Class config (misconfig)

Results[].Misconfigurations[].Resolution is a one-line fix recipe — read it before reaching for a VEX. Trivy’s IaC findings have direct remediation guidance that most SCA findings don’t. For Dockerfile findings, the rule IDs are DS001..DS031; for Kubernetes manifests, KSV001..KSV*. Cross-reference Aqua’s trivy-checks catalogue for the rule’s intent. If the misconfig is intentional (a CI bastion host that legitimately runs as root), record it as an OpenVEX not_affected with justification: "inline_mitigations_already_exist".

Class secret

Rotate the credential first; then triage the leak. Trivy’s bundled scanner finds secrets in the current tree only — it does not walk git history. For history coverage, pair with GitHub Secret Scanning (GitHub-hosted repos) or gitleaks. Once rotated, suppress with an OpenVEX statement using a non-PURL subject (the file path + line + rule ID) — there’s no PURL for a secret.

Class license

Run with --scanners license to surface license findings. Pivot to the license-check workflow if your policy needs an SBOM-style report.

VEX loop

Trivy’s --vex consumes both CycloneDX VEX and OpenVEX (Trivy auto-detects which one):

# Scan with both forms in scope
trivy image --vex ./.vulnetix/vex.cdx.json --vex ./.vulnetix/vex.openvex.json \
            -f json -o trivy.json <ref>

Findings whose subject + ID matches a VEX statement disappear from Results[].Vulnerabilities[] on the next run. The legacy alternative is .trivyignore — a flat file of CVE IDs to suppress unconditionally. Prefer VEX over .trivyignore for new work: VEX entries are auditable, scoped to a specific PURL, carry a justification, and travel with the SBOM to downstream consumers; .trivyignore is repo-local and opaque.

Decision tree

Decision tree
Trivy emits a PURL for every package finding and a path-based subject for misconfig / secret findings.

For Vulnerabilities[] (PURL-backed):
  → CycloneDX VEX entry referencing .PkgIdentifier.PURL
    (compact when you already maintain a CycloneDX SBOM next to the image)

For Misconfigurations[] (no PURL — file path + rule ID is the subject):
  → OpenVEX statement, subject is ":" or the AVD ID

For Secrets[] (no PURL):
  → OpenVEX statement, subject is "::"
  → rotate the credential first; VEX records the triage decision, not the fix

Trivy consumes either format via --vex, so format choice is driven by what
downstream consumers expect, not by Trivy itself.

Need a WAF / IPS / SIEM mitigation while a fix is in flight?
  → vulnetix vdb traffic-filters  supplies the rule;
    record the workaround in the VEX action_statement

Worked example: CVE-2023-50387 (KeyTrap DNSSEC) on libbind9-9 in a Debian-based image

The same CVE that drives the Grype worked example, so the two outputs can be read side-by-side.

trivy image -f json --scanners vuln ghcr.io/library/postgres:16.2 > trivy.json

The match record:

{
  "Results": [{
    "Target": "ghcr.io/library/postgres:16.2 (debian 12.5)",
    "Class": "os-pkgs",
    "Type": "debian",
    "Vulnerabilities": [{
      "VulnerabilityID": "CVE-2023-50387",
      "PkgName": "libbind9-9",
      "PkgIdentifier": {
        "PURL": "pkg:deb/debian/libbind9-9@1:9.18.19-1~deb12u1?distro=debian-12"
      },
      "InstalledVersion": "1:9.18.19-1~deb12u1",
      "FixedVersion": "1:9.18.28-1~deb12u2",
      "Status": "fixed",
      "Severity": "HIGH",
      "CVSS": {
        "nvd": { "V3Vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
                 "V3Score": 7.5 }
      },
      "DataSource": { "Name": "Debian Security Tracker", "URL": "…" }
    }]
  }]
}

Class: os-pkgs + Type: debian says this is a base-image dpkg match. The reachability check is the same as Grype’s — does any binary in the image actually link against libbind9?

LIB=$(jq -r '[.Results[].Vulnerabilities[]?
              | select(.VulnerabilityID=="CVE-2023-50387")][0].PkgName' \
       trivy.json)

docker run --rm --entrypoint sh ghcr.io/library/postgres:16.2 \
  -c "find / -type f -executable 2>/dev/null \
      | xargs -I{} sh -c 'ldd {} 2>/dev/null | grep -l \"$LIB\" && echo {}'" \
  | sort -u

No binary in the runtime image links against libbind9 — PostgreSQL doesn’t use BIND’s resolver; the package was pulled in as a dpkg dependency of a tool that doesn’t exercise the vulnerable code path. Engineer Triage: Reachability: VERIFIED_UNREACHABLE, Remediation: PATCHABLE_DEPLOYMENT (next base-image bump picks up the fix), Priority: HIGH — outcome NIGHTLY_AUTO_PATCH.

Because Trivy emits a PURL for every package finding, CycloneDX VEX is the right format here (contrast with Grype’s worked example, which uses OpenVEX because Grype’s --vex is OpenVEX-only). Trivy consumes either:

CycloneDX VEX outcome
{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "vulnerabilities": [{
    "id": "CVE-2023-50387",
    "source": { "name": "NVD" },
    "affects": [{
      "ref": "pkg:deb/debian/libbind9-9@1:9.18.19-1~deb12u1?distro=debian-12",
      "versions": [{ "version": "1:9.18.19-1~deb12u1", "status": "affected" }]
    }],
    "analysis": {
      "state": "not_affected",
      "justification": "code_not_reachable",
      "detail": "Engineer Triage: BACKLOG (escalated to NIGHTLY_AUTO_PATCH at next base-image bump). libbind9-9 is in the postgres:16.2 base image as a transitive dpkg dependency but no binary in the image links against it — verified by walking executables under / with ldd, no result matched libbind9. Will pick up the fix automatically when the base image is bumped past 1:9.18.28."
    }
  }]
}

Verification on the next scan:

trivy image --vex ./.vulnetix/vex.cdx.json -f json ghcr.io/library/postgres:16.2 \
  | jq '[.Results[].Vulnerabilities[]?
         | select(.VulnerabilityID == "CVE-2023-50387")] | length'
# → 0 (the finding is suppressed)

If you also need an OpenVEX form (for tools in your pipeline that consume that format):

OpenVEX outcome
{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://github.com/yourorg/yourrepo/vex/2026-05-14-trivy-001.json",
  "author": "developer@example.com",
  "timestamp": "2026-05-14T10:00:00Z",
  "version": 1,
  "statements": [{
    "vulnerability": { "name": "CVE-2023-50387" },
    "products": [{
      "@id": "pkg:deb/debian/libbind9-9@1:9.18.19-1~deb12u1?distro=debian-12"
    }],
    "status": "not_affected",
    "justification": "vulnerable_code_not_in_execute_path",
    "action_statement": "Engineer Triage: BACKLOG (NIGHTLY_AUTO_PATCH). Mirrors the CycloneDX VEX statement at vex.cdx.json — Trivy consumes either form."
  }]
}

All-modes triage notes

  • image / fs — same finding classes as Grype (A/B/C/D in Grype’s taxonomy). Results[].Class does the discrimination for you. Always scan the runtime stage of a multi-stage build, not the build stage: docker build --target=runtime -t myapp:runtime && trivy image myapp:runtime.
  • config — every misconfig finding has a Resolution field; read it before VEX-ing. Dockerfile rule IDs are DS001..DS031, k8s are KSV001..KSV*. Override or extend with custom Rego policies — see the next section.
  • k8s — collates findings cluster-wide. Expect heavy noise from kube-system and other operator-owned namespaces; filter with --include-namespaces / --exclude-namespaces early.
  • secret — current-tree only; not a git-history scanner. Pair with GitHub Secret Scanning (GitHub-hosted) or gitleaks for history. False-positive rate is non-trivial on test fixtures and example files — Trivy honours an inline # trivy:ignore comment for genuine fixtures.
  • sbom — the VEX-aware path. Re-scan the SBOM you already trust without re-resolving the dependency tree from the lockfile.
  • vm / aws — out of scope for the typical app-team triage workflow; mentioned for completeness. The aws mode requires an authenticated session (env vars / ~/.aws/credentials) and reads live cloud config — expect long scans on large accounts.

Vulnetix opa-aquasecurity-trivy rule set

vulnetix/opa-aquasecurity-trivy (Apache-2.0) is a Vulnetix-compatible OPA/Rego bundle that re-implements the intent of Aqua Security’s trivy-checks inside the Vulnetix pipeline. It is not a plugin loaded into Trivy itself — it is consumed by vulnetix scan:

# Run alongside Vulnetix's default rules
vulnetix scan --rule Vulnetix/opa-aquasecurity-trivy

# Or as the sole rule set (replaces Vulnetix's defaults)
vulnetix scan --rule Vulnetix/opa-aquasecurity-trivy --disable-default-rules

What it ships:

  • 28 Dockerfile rules under the vulnetix.rules.trivy_docker_* namespace, IDs TRIVY-DS-001..028 — covering ADD vs COPY, root-user containers, package pinning, embedded secrets in RUN lines, and the rest of Trivy’s Dockerfile catalogue.
  • 79 Kubernetes-manifest rules under vulnetix.rules.trivy_k8s_*, IDs TRIVY-KSV-001..079 — pod / container security, dropped capabilities, RBAC scope, Pod Security Standards controls.

Why use it: a team that has standardised on Vulnetix as the merge-gate scanner but wants Trivy’s Dockerfile / k8s rule coverage without running two scanners gets the rule intent inside the Vulnetix workflow — same .vulnetix/memory.yaml triage history, same VEX emit path, same CWSS scoring as every other Vulnetix finding. Conversely, a team running Trivy as their primary container scanner can still use Aqua’s upstream trivy-checks (the canonical Rego bundle) directly via trivy config --policy … — the two are complementary, not competing.

Producing a VEX

For PURL-backed findings — Results[].Vulnerabilities[] regardless of class — CycloneDX VEX is the natural fit because every record carries a PkgIdentifier.PURL. For non-PURL findings (Misconfigurations[], Secrets[]), OpenVEX with a file-path-based subject is the right choice. Trivy consumes either via --vex, so the format choice is driven by what your downstream pipeline already speaks (Vulnetix consumes both; Grype is OpenVEX-only; CycloneDX-only consumers exist too — maintain both forms when the audience is mixed).

Capability snapshot

See the capability matrix for the full comparison. Trivy’s row in summary:

  • Coverage: SCA (lockfile + container OS pkgs), IaC, Dockerfile, k8s, secrets, license — broader than Grype, narrower than Vulnetix on enrichment.
  • Database quality: NVD + GHSA + GitLab Advisory DB + Debian Security Tracker + Ubuntu USN + Alpine secdb + RedHat OVAL + Amazon ALAS + Wolfi + Chainguard + OSV. Comparable to osv-scanner’s feed breadth; broader than Grype on ecosystem aggregation.
  • Reachability: Tier 1 (package-level only). The ldd | grep <lib> recipe is a Tier-1.5 manual technique. For Tier-2/3 evidence, cross-reference Vulnetix or run a SAST tool (CodeQL, Snyk SAST) against the application code.
  • Exploit maturity: severity label only; no EPSS / KEV / sightings / weaponisation indicators. Cross-reference Vulnetix VDB for risk-signal depth.
  • EOL: not native; inferred when no fix is available in the feed. For runtime / package / base-image EOL, cross-reference endoflife.date or Vulnetix.
  • Supply-chain threats: reactive only (via OSV MAL- records). No proactive typosquat or maintainer-health.
  • Outputs: JSON (rich, native), SARIF (flat), CycloneDX 1.4 / 1.5 / 1.6, SPDX 2.2 / 2.3, HTML / Markdown / JUnit XML via templates. VEX consumption via --vexboth CycloneDX VEX and OpenVEX. No native VEX emission.

See also

  • Capability matrix — Trivy’s column in context.
  • Grype — closest peer; the finding-class taxonomy (A / B / C / D) on Grype’s page applies identically to Trivy’s os-pkgs / lang-pkgs output.
  • vulnetix/opa-aquasecurity-trivy — Vulnetix-compatible Rego bundle re-implementing the intent of Trivy’s Dockerfile and k8s checks.
  • Reachability deep-dive — what Tier-1 evidence supports, and when you need Tier 2/3.
  • EOL appendix — for the “should I bump or migrate this base image?” decision.
  • Supply-chain threats — for MAL- records Trivy’s OSV feed surfaces.
  • Glossary — definitions for the terms used above.