Two mature, widely used, free image scanners. One image. Run within minutes of each other.
Trivy 7696 (CRITICAL: 229, HIGH: 1622, MEDIUM: 4253, LOW: 1536, UNKNOWN: 56)
Grype 3995 (Critical: 267, High: 1167, Medium: 1379, Low: 237,
Negligible: 915, Unknown: 30)Trivy reports 93% more findings. Grype reports 17% more criticals.
If you have ever been handed two scan reports that disagree and been asked which is right, this lesson is that situation.
Verified against the Range image — Trivy 0.72.0 and Grype 0.117.0, same image ID, same afternoon. Output copied from a real terminal.
Step 1 — Reproduce it
git clone https://github.com/jaybilgaye/aiopsone-range
cd aiopsone-range
docker build -f docker/Dockerfile -t range-app:vulnerable .
brew install grype
trivy image --scanners vuln range-app:vulnerable
grype range-app:vulnerableStep 2 — Why the totals differ
Three causes, and none of them is one tool being broken.
Different severity vocabularies. Grype has a Negligible band — 915 findings here — that Trivy does not. Those 915 are not missing from Trivy; they are distributed into LOW and UNKNOWN, or dropped. Comparing bucket to bucket across the two tools is meaningless before you reconcile the vocabularies.
Different data sources and different severity assignment. Both consume the NVD, distribution advisories and language ecosystem feeds, but they weight them differently. When Debian rates a CVE lower than NVD does — common, because Debian assesses it against the version they ship — the tools can land on different severities for the identical package.
Different matching strictness. Trivy counts more total findings; Grype promotes more of what it does find to Critical. That is a defensible design difference: report everything and let the user filter, versus report less and be more confident in each.
The practical consequence. "We have 229 critical vulnerabilities" is not a fact about your image. It is a fact about your image and your scanner and the day you ran it. Any metric built on raw counts across tools — or across time, as databases update — will mislead whoever reads it.
Step 3 — Compare something that means something
The totals are not the comparison worth making. This is:
# same image, both tools, JSON out
trivy image --scanners vuln -f json -o trivy.json range-app:vulnerable
grype range-app:vulnerable -o json --file grype.json
# what does each find that the other does not?
python3 - <<'PY'
import json
t = {v['VulnerabilityID'] for r in json.load(open('trivy.json')).get('Results', [])
for v in (r.get('Vulnerabilities') or [])}
g = {m['vulnerability']['id'] for m in json.load(open('grype.json'))['matches']}
print(f"both: {len(t & g)}")
print(f"trivy only: {len(t - g)}")
print(f"grype only: {len(g - t)}")
PYRun that on your own images. The interesting number is grype only — the findings you would never have seen if you had standardised on Trivy alone, and vice versa. That set is what a second scanner actually buys you.
Step 4 — Choose, and gate on one
Running both in a blocking gate means two databases to keep current, two ignore files, and two sets of arguments about which is right. For most teams that is not worth it.
Gate on one. Audit with the other.
# blocking: one scanner, one threshold
- name: Trivy (gate)
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: app:${{ github.sha }}
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: 1
# non-blocking: second opinion, weekly, report only
- name: Grype (audit)
if: github.event_name == 'schedule'
run: grype app:${{ github.sha }} --fail-on high || trueThe scheduled job answers "is our primary scanner missing a class of thing?" without adding a second way for a pull request to fail at 5pm on a Friday.
Step 5 — Pick a threshold you can defend
Whichever you gate on, --ignore-unfixed (Trivy) and --only-fixed (Grype) matter more than the tool choice. A vulnerability with no available patch is not a task; blocking on it teaches people to bypass the gate.
grype range-app:vulnerable --only-fixed --fail-on criticalThat is a gate someone can pass by doing work, which is the only kind that survives.
Step 6 — The thing neither tool tells you
Both report on packages present in the image. Neither knows whether your application calls the vulnerable function.
A CVE in a code path you never execute is a lower risk than one in your request handler, and no image scanner can tell them apart. Reachability analysis is a different capability — it is what the commercial tiers of Snyk and Semgrep sell, and it is the honest answer to "why would I pay for this when Trivy is free". Until you have it, severity is a proxy for risk, not a measure of it.
What you learned
- 7,696 versus 3,995 on an identical image. Different severity vocabularies, different data sources, different matching strictness.
- Grype's
Negligibleband has no Trivy equivalent — bucket-to-bucket comparison is meaningless without reconciling them first. - A raw CVE count is a fact about your scanner and the date, not about your image. Do not build a metric on it.
- Compare the set difference, not the totals.
grype onlyis what a second scanner buys you. - Gate on one, audit with the other on a schedule. Two blocking gates is two arguments.
- Neither tool knows whether the vulnerable code is reachable. That is what reachability analysis sells, and why severity is a proxy for risk rather than a measure of it.