How to Scan Container Images with Trivy and Grype (2026)

Oct 20, 2026 · 12 min · Ajay Kumar

This guide is for engineers who want a container image scanner in CI that blocks the findings that matter and stays quiet about the rest. By the end you will have Trivy and Grype installed from verified binaries, scans of images, filesystems and SBOMs, a GitHub Actions gate that fails only on fixable critical and high vulnerabilities, suppression via ignore files and VEX, and a way to compare the two scanners' results. Commands were checked against the official docs for Trivy v0.76.0 (released 14 October 2026), Grype v0.121.0 (19 October 2026), Syft v1.55.0, aquasecurity/trivy-action v0.36.0 and anchore/scan-action v7.4.2. You need Docker or another local image source, jq, and optionally cosign v3 for signature checks. If you want the background on why "fail on every critical" stopped working, read the companion post on vulnerability management in 2026.

Step 1: Understand the March 2026 Trivy compromise before you install

Trivy was itself the target of a supply-chain attack this year, and it changes how you should install any scanner. Per Aqua's incident page and the GitHub advisory for CVE-2026-33634:

  • On 19 March 2026 a malicious Trivy v0.69.4 binary was published, and 76 of 77 tags of aquasecurity/trivy-action plus the tags of aquasecurity/setup-trivy were force-pushed to credential-stealing code.
  • On 22 March malicious Docker Hub images tagged 0.69.5 and 0.69.6 appeared.
  • Only trivy-action v0.35.0 was untouched. CISA added the CVE to its KEV catalog on 26 March.

If any workflow ran an affected tag between 19 and 20 March, rotate every secret that runner could read; upgrading is not enough. Going forward, the rules are simple: pin actions to full commit SHAs, pin binary and image versions explicitly, and verify signatures. Everything below follows them. The same discipline applies to all your CI actions, which the GitHub Actions security post covers more broadly.

Step 2: Install Trivy and verify the signature

Since v0.68.1 every Trivy release asset ships with a Sigstore bundle (.sigstore.json), according to the signature verification docs. Download a pinned version, verify, then install:

# Linux x86_64. Change the asset name for arm64 (Linux-ARM64) or macOS.
TRIVY_VERSION=0.76.0
BASE=https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}
ASSET=trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz

curl -sSfLO "${BASE}/${ASSET}"
curl -sSfLO "${BASE}/${ASSET}.sigstore.json"

# Keyless verification (cosign v3; with cosign v2.4+ add --new-bundle-format)
cosign verify-blob "${ASSET}" \
  --bundle "${ASSET}.sigstore.json" \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  --certificate-identity "https://github.com/aquasecurity/trivy/.github/workflows/reusable-release.yaml@refs/tags/v${TRIVY_VERSION}"
# Expected: Verified OK

tar -xzf "${ASSET}" trivy && sudo install -m 0755 trivy /usr/local/bin/trivy
trivy --version

If you prefer the container image, pin the tag (aquasec/trivy:0.76.0, never latest) and verify it the same way with cosign verify and the identity regexp https://github\.com/aquasecurity/trivy/\.github/workflows/.+. On a Mac, brew install trivy works for laptops; keep the verified path for CI.

Step 3: Install Grype (and Syft) the same way

Grype publishes a signed checksums file rather than one bundle per asset, so you verify the checksums file, then check the tarball against it:

GRYPE_VERSION=0.121.0
BASE=https://github.com/anchore/grype/releases/download/v${GRYPE_VERSION}
ASSET=grype_${GRYPE_VERSION}_linux_amd64.tar.gz
SUMS=grype_${GRYPE_VERSION}_checksums.txt

curl -sSfLO "${BASE}/${ASSET}"
curl -sSfLO "${BASE}/${SUMS}"
curl -sSfLO "${BASE}/${SUMS}.sigstore.json"

cosign verify-blob "${SUMS}" \
  --bundle "${SUMS}.sigstore.json" \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  --certificate-identity-regexp '^https://github\.com/anchore/grype/'

grep " ${ASSET}\$" "${SUMS}" | sha256sum -c -
# Expected: grype_0.121.0_linux_amd64.tar.gz: OK

tar -xzf "${ASSET}" grype && sudo install -m 0755 grype /usr/local/bin/grype
grype version

Repeat the pattern for Syft v1.55.0 if you want Anchore's SBOM generator. Anchore also offers curl -sSfL https://get.anchore.io/grype | sudo sh -s -- -b /usr/local/bin, documented in the Grype README; after this year I would not pipe a remote script into a root shell in CI.

A note on version churn: Grype v0.121.0 was a day old when I wrote this. Pin to a version you have run in your own pipeline and let Dependabot or Renovate propose bumps.

Step 4: Run your first image, filesystem and SBOM scans

Start with a plain image scan, restricted to vulnerabilities (Trivy's image default also includes secret scanning):

# Trivy: vulnerabilities only, high and critical, hide findings with no fix
trivy image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed python:3.12-slim

# Grype: same image, only show findings with a fix, sorted by risk (the default)
grype python:3.12-slim --only-fixed

# Grype can sort by EPSS or KEV status as well
grype python:3.12-slim --only-fixed --sort-by epss

Trivy's table groups results by target (OS packages, then each language ecosystem) with columns for library, vulnerability ID, severity, status, installed and fixed version. Grype prints one row per match with name, installed, fixed-in, type, vulnerability, severity, EPSS and risk columns.

Scan a repository or build context before an image exists:

trivy fs --scanners vuln,secret .
grype dir:.

The best pattern for CI is to generate an SBOM once and scan it with both tools. The SBOM becomes an artefact you can rescan later without pulling the image:

# With Syft
syft python:3.12-slim -o cyclonedx-json=sbom.cdx.json
# Or with Trivy
trivy image --format cyclonedx --output sbom.cdx.json python:3.12-slim

trivy sbom sbom.cdx.json
grype sbom:./sbom.cdx.json

Step 5: Gate CI on fixable critical and high findings

Both scanners exit 0 by default even when they find things. You have to ask for a failing exit code. In Trivy, --exit-code 1 combined with --severity and --ignore-unfixed means "fail only on fixable highs and criticals". In Grype, --fail-on high returns a non-zero code (2, per the CLI reference) when a match at or above that severity remains after filtering.

Here is a GitHub Actions job with both actions pinned to the commit SHAs behind their current release tags:

# .github/workflows/image-scan.yml
name: image-scan
on: [pull_request]

permissions:
  contents: read
  security-events: write   # for SARIF upload only

jobs:
  scan:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v5
        with:
          persist-credentials: false

      - name: Build image
        run: docker build -t app:${{ github.sha }} .

      - name: Trivy gate
        # aquasecurity/trivy-action v0.36.0
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
        with:
          version: v0.76.0            # the action's default is v0.70.0
          image-ref: app:${{ github.sha }}
          scanners: vuln
          severity: HIGH,CRITICAL
          ignore-unfixed: true
          exit-code: '1'
          trivyignores: .trivyignore.yaml

      - name: Grype report
        # anchore/scan-action v7.4.2
        uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439
        id: grype
        with:
          image: app:${{ github.sha }}
          grype-version: v0.121.0
          only-fixed: true
          severity-cutoff: high
          fail-build: false          # Trivy gates; Grype reports
          output-format: sarif

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v4
        with:
          sarif_file: ${{ steps.grype.outputs.sarif }}

Two notes. The tag-to-SHA mapping above came from gh api repos/aquasecurity/trivy-action/git/ref/tags/v0.36.0 and then dereferencing the annotated tag; do the same yourself rather than trusting a blog post. And I left actions/checkout and upload-sarif on major tags for readability; in a real repository pin those to SHAs too.

A container scanning pipeline that fails only on what matters 1. Pin + verify SHA-pinned actions cosign bundles 2. Build + SBOM docker build Syft or Trivy SBOM 3. Scan Trivy and Grype cached vuln DB 4. Filter VEX documents ignores with expiry 5. Gate fixable HIGH+CRIT? exit code decides yes: fail the build fix, upgrade or VEX it no: pass upload SARIF Trivy DB: mirror.gcr.io/aquasec/trivy-db:2, then ghcr.io/aquasecurity/trivy-db:2 (fallback on 429 or 5xx). Grype DB: grype.anchore.io/databases, refused when older than 120h unless you change max-allowed-built-age.
The pipeline this guide builds. Database locations and the 120-hour age limit come from the Trivy DB configuration docs and the Grype configuration reference.

Step 6: Suppress findings with .trivyignore, Grype ignore rules and VEX

Every suppression should say why and when it expires. Trivy's plain .trivyignore supports an exp: suffix, and the YAML form adds paths, PURLs and a statement. Per the filtering docs, the YAML file is only read when you pass --ignorefile (or trivyignores in the action):

# .trivyignore.yaml
vulnerabilities:
  - id: CVE-2024-6345
    purls:
      - "pkg:pypi/setuptools@69.5.1"
    statement: "Build-time only, not present in runtime stage. Owner: platform"
    expired_at: 2026-12-31
    # paths: ["usr/local/lib/python3.12/site-packages/..."] narrows by file path instead
trivy image --ignorefile .trivyignore.yaml --show-suppressed app:dev

--show-suppressed (experimental) lists what was hidden so reviewers can see it. Grype's equivalent lives in .grype.yaml; per the configuration reference a match must meet every field in a rule to be ignored:

# .grype.yaml
only-fixed: true
fail-on-severity: high
ignore:
  - vulnerability: CVE-2024-6345
    reason: "Build-time only, not in runtime stage"
    package:
      name: setuptools
      type: python
  - fix-state: wont-fix

Ignore files are your decisions. VEX is better when the decision belongs to a supplier, or when you want one statement both scanners understand. Trivy reads CycloneDX VEX (for SBOM targets only), OpenVEX and CSAF; OpenVEX works on every Trivy target and is the format Grype supports, so use it. Trivy requires a PURL as the product identifier:

{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://example.com/vex/app-2026-10-20",
  "author": "Platform team",
  "timestamp": "2026-10-20T09:00:00Z",
  "version": 1,
  "statements": [
    {
      "vulnerability": { "name": "CVE-2024-6345" },
      "products": [ { "@id": "pkg:pypi/setuptools@69.5.1" } ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path"
    }
  ]
}
trivy image --vex app.openvex.json app:dev
grype app:dev --vex app.openvex.json

Trivy's --vex repo option also pulls supplier VEX from public VEX repositories; the feature is marked experimental in the VEX docs, so test it before relying on it in a gate.

Step 7: Compare Trivy and Grype results

The two tools use different databases and matchers, so they will disagree. That is useful: a finding both report deserves attention first, and a finding only one reports is worth a look at the package metadata. Normalise to CVE IDs (Grype's --by-cve turns GHSA-keyed matches into CVEs where possible) and diff:

IMG=python:3.12-slim

trivy image --scanners vuln --format json --quiet "$IMG" \
  | jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | .[]' | sort > trivy.txt

grype "$IMG" --by-cve -o json \
  | jq -r '[.matches[].vulnerability.id] | unique | .[]' | sort > grype.txt

echo "both:       $(comm -12 trivy.txt grype.txt | wc -l)"
echo "trivy only: $(comm -23 trivy.txt grype.txt | wc -l)"
echo "grype only: $(comm -13 trivy.txt grype.txt | wc -l)"

Common reasons for differences: one tool treats a distro's "will not fix" status differently, a language package is detected by one tool but not the other, or one database had a refresh the other has not had yet.

Step 8: Cache the vulnerability databases in CI

Both tools download a database on each run unless you cache it. Trivy's DB docs list the defaults as mirror.gcr.io/aquasec/trivy-db:2 with fallback to ghcr.io/aquasecurity/trivy-db:2, retried in order on HTTP 429 or 5xx. For many jobs on one runner, download once and skip updates in each scan:

export TRIVY_CACHE_DIR="$PWD/.cache/trivy"
trivy image --download-db-only
trivy image --skip-db-update --scanners vuln app:dev

# Grype: refresh explicitly, then disable auto-update for the scans
grype db update && grype db status
GRYPE_DB_AUTO_UPDATE=false grype app:dev

For air-gapped environments, point --db-repository at an internal OCI registry that mirrors the database. Setting the flag replaces the defaults, so list the public locations too if you still want the fallback.

Troubleshooting

Symptom Likely cause Fix
failed to download vulnerability DB with TOOMANYREQUESTS Registry rate limit on shared CI egress Cache the DB (Step 8), keep the default mirror order, or mirror trivy-db to your own registry with --db-repository
DB download times out behind a corporate proxy Proxy not passed to the scanner Set HTTPS_PROXY and NO_PROXY in the job environment; for Docker runs pass them with -e
Grype refuses to scan, database too old DB built more than 120h ago and validate-age is on Run grype db update; in offline setups raise GRYPE_DB_MAX_ALLOWED_BUILT_AGE deliberately
CI passes with criticals in the report No failing exit code set Trivy: --exit-code 1 (or exit-code: '1' in the action). Grype: --fail-on high or fail-build: true
.trivyignore.yaml entries ignored YAML ignore file not loaded by default Pass --ignorefile .trivyignore.yaml or set trivyignores
VEX statement has no effect Product PURL does not match the scanned package Copy the PURL from Trivy's JSON output; check with --show-suppressed
Java JAR scans slow or fail offline Trivy fetching the separate Java DB Pre-download with --download-java-db-only, then --skip-java-db-update
Corrupt or half-written DB after a cancelled job Interrupted download in a shared cache trivy clean --vuln-db, or delete Grype's ~/.cache/grype/db, then re-download
Trivy and Grype report different counts Different databases, matchers and fix-state handling Compare by CVE ID (Step 7) and investigate one-sided findings
Scan ran a compromised version Mutable tags (@master, @v0, latest) Pin SHAs and versions; if a March 2026 tag ran, rotate all runner secrets

FAQ

Is Trivy safe to use after the 2026 supply-chain attack?

Yes, if you install it carefully. The malicious artefacts were v0.69.4 and the 0.69.5 and 0.69.6 Docker images, plus hijacked action tags; current releases are signed and verifiable. Pin versions and action SHAs, verify the Sigstore bundle, and rotate secrets if an affected tag ever ran.

Trivy or Grype: which should I use?

Either works for gating. Trivy also scans IaC, secrets and Kubernetes, and supports three VEX formats; Grype pairs naturally with Syft SBOMs and sorts by EPSS, KEV and risk out of the box. Running both and acting first on findings they agree on is cheap and reduces false negatives.

How do I make Trivy fail the build only on fixable vulnerabilities?

Combine --exit-code 1, --severity HIGH,CRITICAL and --ignore-unfixed. The severity and unfixed filters decide which findings remain, and the exit code applies only to those. In the GitHub Action the inputs are exit-code, severity and ignore-unfixed.

Should I scan the image or the SBOM?

Generate the SBOM at build time and scan it, and keep it as an artefact. That lets you rescan old releases when new CVEs land without pulling or rebuilding images. Scanning the image directly is fine for quick local checks.

In production the first thing I check is not the findings but the scanner itself: which version ran, whether its action was pinned to a SHA, and how old its database was. After that, I look only at fixable highs and criticals that are in KEV or have meaningful EPSS, which is where the hardened base image and smaller multi-stage builds pay off by shrinking the list before anyone reads it. Official docs: Trivy documentation, Trivy releases, Grype CLI reference, Grype configuration and OpenVEX specification.

Related: Vulnerability Management in 2026: 48,185 CVEs, a Triaged NVD, EPSS v5, The Worms Learned to Use Your AI Agent: A Year of npm Supply-Chain Attacks and CRA Reporting Went Live on 11 September: 24 Hours to ENISA, 3.08 Billion Rekor Entries, 17% of PyPI Attested, 92% SBOM False Positives.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related