Trivy vs Grype: choosing a container image scanner
Both scanners read the same alpine:3.18.0 tar on the same morning. They agreed on 49 findings, split on 14 more and on seven severities, and exit differently; what that means for the gate.
Most Trivy-versus-Grype pages list features. The question a pipeline owner has is narrower: for the image we build, will the two tools report the same thing, and when they differ, whose number goes in the ticket? The only honest way to answer it is to hand both scanners the same bytes on the same morning and read the JSON, which is what this article does: alpine:3.18.0 pulled by digest, saved once with docker save, scanned by Trivy 0.74.0 through --input and by Grype 0.118.0 through docker-archive:, with both vulnerability databases refreshed in the same run and then frozen. The image is deliberately old (May 2023, end of life since May 2025), so it carries fixed CVEs and shows what each scanner does when the distribution feed has stopped.
Both tools read the same file and report the same image ID (the image config digest). Databases are downloaded first, then every scan runs with updates disabled, so neither scanner sees newer data than the other.
trivy --version | tr "\n" " "Version: 0.74.0 Vulnerability DB: Version: 2 UpdatedAt: 2026-09-13 07:13:14.295197098 +0000 UTC NextUpdate: 2026-09-14 07:13:14.295196898 +0000 UTC DownloadedAt: 2026-09-13 08:37:45.253362457 +0000 UTCgrype version | grep -E "^(Version|Syft Version|Supported DB Schema)"; grype db status | grep -E "^(Schema|Built|Status)"Version: 0.118.0; Syft Version: v1.51.1; Supported DB Schema: 6;Schema: v6.1.9; Built: 2026-09-13T06:31:42Z; Status: valid;trivy image --input image.tar --scanners vuln --format json --output trivy.json # stderrINFO Detected OS family="alpine" version="3.18.0"INFO [alpine] Detecting vulnerabilities... os_version="3.18" repository="3.18" pkg_num=15WARN Using severities from other vendors for some vulnerabilities. Read https://trivy.dev/docs/v0.74/guide/scanner/vulnerability#severity-selection for details.WARN This OS version is no longer supported by the distribution family="alpine" version="3.18.0"trivy image --input image.tar --scanners vuln -q | grep ^TotalTotal: 49 (UNKNOWN: 0, LOW: 14, MEDIUM: 26, HIGH: 6, CRITICAL: 3)grype docker-archive:image.tar -o json --file grype.json # stderr15 packages from EOL distro "alpine 3.18.0" - vulnerability data may be incomplete or outdated; consider upgrading to a supported versionjq -r '"matches: \(.matches|length) …"' grype.jsonmatches: 63 (Critical: 5, High: 10, Low: 6, Medium: 42); unique ids: 28; packages from an EOL distro: 15both scans exit 0: without --exit-code (Trivy) or --fail-on (Grype), findings never change the exit codeWhat each scanner found in the same tar
Forty-nine rows are common to both: the same (package, CVE) pairs, with the same fixed versions, across busybox, busybox-binsh, ssl_client, libcrypto3, libssl3, musl and musl-utils. Every one of Trivy's rows names alpine as its data source, and every one of Grype's forty-nine has the namespace alpine:distro:alpine:3.18. Both scanners built that list from the Alpine security database, and for this image they built the same list. Grype reports fourteen more rows that Trivy does not: NVD records, matched by CPE, for busybox 1.36.0, musl 1.2.4 and zlib 1.2.13, none with a fixed version. Grype's apk matcher, in its own words, treats the distro feed as the authority and lets NVD count only where the feed is silent, and it strips the fix state from those NVD rows because an upstream release number is not an apk version. The Alpine feed for 3.18 has been silent since the branch reached end of life in May 2025, and none of these six CVEs (two published in April 2025, four in 2026) has an entry in it, so they reach Grype's table through NVD and never reach Trivy's, whose documented source for Alpine packages is the security database alone.
Recorded counts for the same image (databases of 2026-09-13; they move with every update)
| Trivy 0.74.0 | Grype 0.118.0 | |
|---|---|---|
| rows reported | 49 | 63 (49 with a fix, 14 from NVD without one) |
| unique CVE ids | 22 | 28 |
| Critical / High / Medium / Low | 3 / 6 / 26 / 14 | 5 / 10 / 42 / 6 |
| data source of the rows | alpine for all 49 | alpine:distro:alpine:3.18 for 49, nvd:cpe for 14 |
| shared (package, CVE) pairs | 49 | 49 |
| end-of-life signal | WARN This OS version is no longer supported; Metadata.OS.EOSL: true in the JSON; --exit-on-eol available | 15 packages from EOL distro on stderr; alertsByPackage in the JSON |
jq -r '.Results[].Vulnerabilities[] | "\(.PkgName)\t\(.VulnerabilityID)"' trivy.json | sort -u > t.pairsjq -r '.matches[] | select(.vulnerability.fix.state=="fixed") | "\(.artifact.name)\t\(.vulnerability.id)"' grype.json | sort -u > g.fixed.pairstrivy rows: 49 grype rows with a fix: 49 grype rows without a fix: 14shared (package, CVE) pairs: 49 only Trivy: 0 only Grype (with fix): 0grype rows without a fix version, by source namespace: nvd:cpe 14 busybox CVE-2024-58251 busybox CVE-2025-46394 busybox CVE-2025-60876 musl CVE-2026-40200 musl CVE-2026-6042 zlib CVE-2026-27171 unknown(busybox-binsh and ssl_client repeat the busybox rows, musl-utils the musl rows: 14 in all)trivy data source of every row: alpinegrype docker-archive:image.tar --only-fixed -o json | jq '{matches: (.matches|length), ignored: (.ignoredMatches|length)}'matches: 49 (Critical: 5, High: 8, Medium: 36) ignoredMatches: 14 (nvd:cpe: 14)Whether those fourteen rows are findings or noise depends on what you want the scanner to say about an image whose distribution has stopped tracking it. They are not wrong: busybox 1.36.0 is inside every range NVD lists. They are also not actionable inside Alpine 3.18, because no package upgrade fixes them, which is why --only-fixed moves all fourteen into ignoredMatches and leaves Grype with the same forty-nine rows Trivy prints. Trivy answers the question with a warning and, if you ask for it, an exit code. Grype answers it with rows. On a supported base image the two behaviours converge, because the distro feed is rarely silent for long; on an image past its end of life they do not, and a comparison that only ever scans alpine:latest never sees it.
Seven CVEs, two severities
Of the twenty-two CVEs both scanners agree exist, fifteen carry the same severity and seven do not. The seven are all OpenSSL, all in libcrypto3 and libssl3, and all rated LOW by Trivy while Grype says Medium, High or, for CVE-2024-5535, Critical. The JSON explains the split without guessing. Trivy's VendorSeverity map for each of the seven has no nvd entry and SeveritySource is empty: NVD has never produced its own analysis for them (their NVD status is Deferred), and Trivy's documented rule, when NVD is silent, is to take the next vendor in the trivy-db source order, which is Red Hat. Red Hat rates all seven Low, and Low is what Trivy printed. Grype's record for each carries exactly one CVSS 3.1 vector, marked Secondary, from source 134c704f-9b21-4f2e-91b3-4a467353bcc0, which NVD's source registry names CISA-ADP; the severity Grype prints is the band of that score (9.1 is Critical, 7.5 High, 4.1 to 5.9 Medium). Two defensible rules, two answers, from the same NVD page.
The seven disagreements (recorded 2026-09-13; all in libcrypto3 and libssl3 3.1.0-r4)
| CVE | fixed in | Trivy | Grype | what the record says |
|---|---|---|---|---|
| CVE-2024-5535 | 3.1.6-r0 | LOW | Critical | NVD Deferred; CISA-ADP CVSS 3.1 = 9.1; Red Hat Low |
| CVE-2024-4741 | 3.1.6-r0 | LOW | High | NVD Deferred; CISA-ADP 7.5; Red Hat Low |
| CVE-2024-2511 | 3.1.4-r6 | LOW | Medium | NVD Deferred; CISA-ADP 5.9; Red Hat Low; EPSS 54 % (Grype risk 29.4, third row of its table) |
| CVE-2023-6237 | 3.1.4-r4 | LOW | Medium | NVD Deferred; CISA-ADP 5.9; Red Hat Low |
| CVE-2024-4603 | 3.1.5-r0 | LOW | Medium | NVD Deferred; CISA-ADP 5.3; Red Hat Low |
| CVE-2024-9143 | 3.1.7-r1 | LOW | Medium | NVD Deferred; CISA-ADP 4.3; Red Hat Low |
| CVE-2024-13176 | 3.1.8-r0 | LOW | Medium | NVD Deferred; CISA-ADP 4.1; Red Hat Low |
Exit codes: what the pipeline actually sees
The gating flags are not symmetric, and the asymmetry decides how a CI job tells an outage from a finding. Trivy exits 0 on findings unless --exit-code N is set, and the caller chooses N; its own errors exit 1. Run the popular --exit-code 1 and a missing database, a bad archive and a CRITICAL all look identical to the job. Grype's threshold exit is fixed at 2 and its errors are 1, so a wrapper can react differently to each without parsing output. Trivy has a second gate Grype does not: --exit-on-eol N returns N for an end-of-life OS, and in this run it took precedence over --exit-code when both applied. Grype has a guard that Trivy's database options do not describe: it refuses to scan with a database older than five days (db.max-allowed-built-age), exit 1, unless db.validate-age is switched off.
Exit codes observed on the same tar
| Case | Trivy 0.74.0 | Grype 0.118.0 |
|---|---|---|
| findings, no gating flag | 0 | 0 |
| findings at or above the threshold | --exit-code 1 gives 1; --exit-code 2 gives 2 (caller's choice) | --fail-on critical gives 2 (fixed) |
| tool error (input file missing) | 1, whatever --exit-code says | 1 |
| end-of-life OS | --exit-on-eol 3 gives 3, also when --exit-code 1 --severity CRITICAL would give 1 | warning and alertsByPackage only |
| database too old for policy | no maximum-age setting in the database documentation; --skip-db-update with no cached database exits 1 | 1, "the vulnerability database was built … ago (max allowed age is …)"; GRYPE_DB_VALIDATE_AGE=false scans anyway |
trivy image --input image.tar --scanners vuln --severity CRITICAL --exit-code 1 -q; echo "exit $?"Total: 3 (CRITICAL: 3)│ busybox │ CVE-2022-48174 │ CRITICAL │ fixed │ 1.36.0-r9 │ 1.36.1-r1 │ busybox: stack overflow vulnerability in ash.c leads to │exit 1trivy image --input missing.tar --scanners vuln --exit-code 2; echo "exit $?"FATAL Fatal error run error: image scan error: scan error: unable to initialize a scan service: unable to initialize artifact: unable to initialize archive image: 2 errors occurred:exit 1trivy image --input image.tar --scanners vuln --exit-on-eol 3 --format json --output /dev/null; echo "exit $?"ERROR Detected EOL OS family="alpine" version="3.18.0"exit 3grype docker-archive:image.tar --fail-on critical -o table --sort-by severity; echo "exit $?"NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISKlibcrypto3 3.1.0-r4 3.1.6-r0 apk CVE-2024-5535 Critical 5.6% (92nd) 5.1busybox 1.36.0-r9 1.36.1-r1 apk CVE-2022-48174 Critical 3.2% (87th) 3.0exit 2grype docker-archive:missing.tar; echo "exit $?"ERROR failed to catalog: no source providers were able to resolve the input "missing.tar"exit 1GRYPE_DB_MAX_ALLOWED_BUILT_AGE=1s grype docker-archive:image.tar; echo "exit $?"ERROR failed to load vulnerability db: the vulnerability database was built 2 hours ago (max allowed age is 1 second)exit 1GRYPE_DB_MAX_ALLOWED_BUILT_AGE=1s GRYPE_DB_VALIDATE_AGE=false grype docker-archive:image.tar -o json | jq …; echo "exit $?"matches: 63 with the age check offexit 0Exceptions: the same CVE in two files
A gate needs a way to accept a finding for a while, and the two mechanisms differ in ways that matter for review. Trivy's .trivyignore.yaml (still marked experimental) takes an id, an optional statement and an expired_at date, and it is applied only when --ignorefile names the file. Grype's .grype.yaml ignore rules take a vulnerability, a package name, version, type or location, a fix state and VEX fields; the documented field list has no expiry and no reason, so the ticket goes in a YAML comment and the lapse is a calendar entry. Grype reads .grype.yaml from the working directory without being asked, which is convenient in a repository and surprising in a shared runner. Both accept VEX documents (--vex) for the more formal version of the same statement.
cat exc/.trivyignore.yamlvulnerabilities: - id: CVE-2022-48174 statement: accepted until the base image bump, INFRA-442 expired_at: 2027-01-01trivy image --input image.tar --scanners vuln --severity CRITICAL --exit-code 1 --ignorefile exc/.trivyignore.yaml; echo "exit $?"INFO Some vulnerabilities have been ignored/suppressed. Use the "--show-suppressed" flag to display them.│ image.tar (alpine 3.18.0) │ alpine │ 0 │exit 0cat exc/.grype.yamlignore: - vulnerability: CVE-2022-48174grype docker-archive:image.tar -c exc/.grype.yaml --fail-on critical --only-fixed -o json | jq …; echo "exit $?"still failing on: CVE-2024-5535 ignoredMatches: 17, of them by the rule: 3exit 2cp exc/.trivyignore.yaml exc/.grype.yaml . && trivy image --input image.tar --scanners vuln --severity CRITICAL -q | grep ^Total; grype docker-archive:image.tar --only-fixed -o json | jq …trivy, no --ignorefile: Total: 3 (CRITICAL: 3)grype, no -c: matches: 46, ignoredMatches: 17, of them by an ignore rule: 3The same one-line exception turns Trivy's CRITICAL gate green and leaves Grype's red, because Grype also rates CVE-2024-5535 Critical and Trivy files it under LOW. Nothing about that split says which scanner is right, and it is not a reason to add a second ignore rule. It is a reason to open the record: an OpenSSL bug with no NVD analysis, a 9.1 from CISA's enrichment and a Low from Red Hat, fixed in 3.1.6-r0, on a base image that should have been rebuilt long before the question came up. The exception you write should name that reasoning, and in Trivy's case it can also name the date it stops applying.
SBOMs: each scanner reads its own
Both tools scan an SBOM instead of an image (trivy sbom, grype sbom:), so the SBOM-first workflow that used to be described as Grype's distinguishing feature is available on either side. What the run shows is that the SBOM has to come from the same family as the scanner that will read it. Trivy re-reading its own CycloneDX found all forty-nine rows; Grype re-reading its own found all sixty-three. Crossed over, Grype found nothing in Trivy's SBOM, and Trivy found six rows in Grype's. The reason is visible in one component. Trivy records the origin package of libssl3 as a property, aquasecurity:trivy:SrcName=openssl, and writes the distro qualifier as distro=3.18.0; Syft, which Grype embeds, writes upstream=openssl and distro=alpine-3.18.0 into the purl. Each reader ignores the other's field, so the Alpine feed, which is keyed by origin package, only matches the packages whose name is already the origin: busybox and musl. Pointing Grype at the distro by hand (--distro alpine:3.18.0) recovers exactly those six and nothing else.
trivy image --input image.tar --format cyclonedx --output trivy.cdx.json; trivy sbom trivy.cdx.json --format json | jq "[.Results[].Vulnerabilities[]] | length"trivy cyclonedx 1.7, 16 components, 0 vulnerabilities -> trivy sbom: 49 rowsgrype docker-archive:image.tar -o cyclonedx-json --file grype.cdx.json; grype sbom:grype.cdx.json -o json | jq ".matches | length"grype cyclonedx 1.7, 95 components, 63 vulnerabilities -> grype sbom: 63 matchesgrype sbom:trivy.cdx.json -o json | jq ".matches | length"; grype sbom:trivy.cdx.json --distro alpine:3.18.0 -o json | jq ".matches | length"grype on trivy.cdx.json: 0 matches; with --distro alpine:3.18.0: 6 matches (busybox 5, musl 1)trivy sbom grype.cdx.json --format json | jq "[.Results[].Vulnerabilities[]] | length"trivy on grype.cdx.json: 6 rows (busybox 5, musl 1)trivy purl pkg:apk/alpine/libssl3@3.1.0-r4?arch=aarch64&distro=3.18.0 properties aquasecurity:trivy:PkgType=alpine aquasecurity:trivy:SrcName=opensslgrype purl pkg:apk/alpine/libssl3@3.1.0-r4?arch=aarch64&distro=alpine-3.18.0&upstream=openssltrivy image --input image.tar --scanners vuln --format sarif --output trivy.sarif; grype docker-archive:image.tar -o sarif --file grype.sariftrivy sarif: 1 run, 49 results, 22 rules (one per CVE)grype sarif: 1 run, 63 results, 63 rules (one per match)For a registry of stored SBOMs this is the operational rule: generate with Syft if Grype will re-scan, with Trivy if Trivy will, and keep the image digest in the document so a scan of the image itself can settle any doubt. The SARIF shapes differ in the same spirit; a code-scanning dashboard receives one rule per CVE from Trivy and one per match from Grype, so the same forty-nine rows count differently on the two sides of a migration.
Choosing, and what this run does not settle
What one image on one day cannot tell you is which scanner is more complete, more accurate or faster in general. Both used the same Alpine feed and agreed on it; the fourteen extra rows and the seven severity splits are policy differences, not a defect on either side, and they will look different on an image whose vendor publishes its own ratings, which Trivy prefers over NVD (the Red Hat example in its documentation). Speed was not measured here, because cache state, database downloads and the host dominate a single run. Running both and reviewing the union is a legitimate choice for a strict environment, as long as the team agrees in advance whose severity decides the gate.
When the scanner is the problem: recovery in order of preference
| Situation | Do | Do not |
|---|---|---|
| the database download fails and every job is red | Trivy: TRIVY_SKIP_DB_UPDATE=true with the cached database (--skip-db-update on an empty cache exits 1). Grype: GRYPE_DB_AUTO_UPDATE=false with the cache, and remember the five-day age check; db.validate-age: false is the explicit override, with a date to remove it | --exit-code 0 or a dropped --fail-on to get green: the gate is gone and nothing says so |
| an overnight database update adds a CRITICAL to a base image nobody can rebuild today | Trivy: a .trivyignore.yaml entry with statement and expired_at, passed with --ignorefile, in the same merge request. Grype: an ignore rule with the ticket in a comment and a reminder to remove it | a rule that names the package rather than the CVE: it also hides the next one |
| the two scanners disagree on a severity | read the record: SeveritySource and VendorSeverity in Trivy's JSON, cvss[].source and relatedVulnerabilities in Grype's; decide per CVE which rating your policy follows | suppress it in the tool that rates it higher because the other one rates it lower |
| a match looks wrong (a CPE match, a package that is not what the name suggests) | Grype: check matchDetails[].type (cpe-match is the low-confidence kind) and --only-fixed; both: a VEX statement with a justification, which both tools apply with --vex | a blanket --ignore-unfixed or --only-fixed in the gate without saying why: it also hides real unfixed findings on supported images |
| a scanner upgrade changes the numbers | pin the image tag (aquasec/trivy:0.74.0, anchore/grype:v0.118.0), keep the JSON of the last good run, and diff (package, CVE) pairs before raising the gate | a floating latest on the tool that enforces policy |
The pipeline side of either choice is the same shape: a report pass that never blocks and a gate pass that blocks on a short list, which the Trivy in GitLab CI note builds step by step. If Syft SBOMs are going to be the artifact your registry keeps, the SBOM with Syft and Grype note covers the formats and the day a CVE lands.
Go deeper in a courseSoftware supply chain securitySBOMs, scanning, signing and admission as one pipeline, with the trade-offs stated.View course