When more than one advisory record exists for the same CVE and package, --by-cve collapses them into a single match, but which record's metadata survives changes between identical runs. The finding set is stable; the content attached to each finding is not.
Reproduction
Built from main at 970b4d10. Database v6.1.9 (built 2026-08-05), verified identical across every run by comparing descriptor.db.status in the output.
repro.cdx.json — one component reachable through both a purl and a CPE:
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"metadata": { "component": { "type": "application", "name": "repro", "version": "1.0.0" } },
"components": [
{
"type": "library",
"name": "stdlib",
"version": "go1.21.0",
"purl": "pkg:golang/stdlib@1.21.0",
"cpe": "cpe:2.3:a:golang:go:1.21.0:*:*:*:*:*:*:*"
}
]
}
export GRYPE_MATCH_GOLANG_ALWAYS_USE_CPE_FOR_STDLIB=true
for i in $(seq 1 20); do
grype sbom:repro.cdx.json --by-cve -o json -q > run$i.json
done
match.golang.always-use-cpe-for-stdlib is only needed to give this particular component two sources; it defaults to false on main and defaulted to true in v0.112.0. Any package matched through both an ecosystem advisory and a CPE reaches the same code path.
Observed, comparing the 20 runs on vulnerability.{namespace,dataSource,description,fix,urls,severity,relatedVulnerabilities} keyed by (id, package, version):
- 20 distinct outputs from 20 runs; 0 of 190 run pairs are identical
- 65 of 83 findings vary in
namespace, dataSource, fix and urls; 24 also vary in description
- between 21 and 43 findings differ per run pair
CVE-2023-45288 / stdlib go1.21.0 alternates between:
|
run A |
run B |
dataSource |
https://go.dev/issue/65051 |
https://nvd.nist.gov/vuln/detail/CVE-2023-45288 |
namespace |
govulndb:language:go |
nvd:cpe |
fix.available[].kind |
advisory (dated 2024-04-03) |
first-observed (dated 2025-09-04) |
Control: the same command without --by-cve, 20 runs, is byte-identical in content across all 190 pairs. It is also stable when only one source matches the package, so the two competing records are what triggers it.
Expected: identical input and identical database produce identical output.
Cause
Without --by-cve these are two separate findings — GO-2024-2687 in govulndb:language:go found by exact-direct-match, and CVE-2023-45288 in nvd:cpe found by cpe-match — and both report the same fix versions, 1.21.9, 1.22.2.
-
Matches.Enumerate ranges over the byFingerprint map, so it yields matches in Go's randomized map order (grype/match/matches.go:170-179).
-
findDBMatches feeds that enumeration straight into the normalization loop, so which match becomes the merge receiver is decided by map order (grype/vulnerability_matcher.go:154-158).
-
normalizeByCVE rewrites Vulnerability.ID and Vulnerability.Namespace to the aliased CVE record's, and nothing else (grype/vulnerability_matcher.go:362-364). Reference.Internal, Vulnerability.Metadata and Vulnerability.Fix still describe the record the match was found in.
-
Fingerprint is {vulnerabilityID, vulnerabilityNamespace, packageID} plus the joined Fix.Versions (grype/match/match.go:29-38). Because both records report the same fix versions, the rewritten record now collides with the NVD record on the full fingerprint, so addOrMerge takes case A and calls existingMatch.Merge(newMatch) (grype/match/matches.go:99-107).
-
Match.Merge unions RelatedVulnerabilities, Details and CPEs, and leaves every other field of the receiver's Vulnerability alone (grype/match/match.go:40-88). So the record enumerated first keeps its Metadata and Fix; the other contributes only references and details.
The JSON presenter emits m.Vulnerability.Metadata directly when it is populated (grype/presenter/models/match.go:51), and namespace/dataSource/description/urls/severity come from that blob (grype/presenter/models/vulnerability_metadata.go:62-73), which is why the emitted namespace can read govulndb:language:go even though Vulnerability.Namespace was rewritten to nvd:cpe.
Sorted() sorts at the end, which stabilizes the order of the output but not the content of each merged match — which is why the finding set looks stable.
This is the same class of problem as #1046, which fixed related vulnerabilities and CPEs being lost during the merge and added the // for stable output sorts now in Match.Merge; the remaining fields were not covered.
Impact
Anyone diffing grype output across runs — drift detection, cached comparisons, or a report expected to be reproducible from the same inputs — sees changes that did not come from a data or version change. The losing record is not preserved anywhere a consumer could recover it from, so the choice cannot be normalized downstream. When the NVD record wins, the curated fix metadata is what is lost: in the control run above, all 140 fix.available entries on the ecosystem records carry kind: advisory with real advisory dates, while all 171 on the NVD records carry kind: first-observed.
Question
Two directions seem possible, and I do not want to assume which fits better:
- have the normalization loop (or
Enumerate itself) iterate a sorted fingerprint list, which is narrow, though the survivor then becomes whichever fingerprint sorts first — for CVE-vs-GHSA/GO that is consistently the NVD record, so it would be stable but would settle on the record with the less specific fix data;
- pick the survivor in the case-A merge by an explicit rule, preferring the record matched directly against an ecosystem advisory over one reached only by CPE, which is stable and keeps the curated fix data, but changes merge semantics slightly outside
--by-cve.
Would either be acceptable, or is there a preferred direction? Happy to open a PR.
When more than one advisory record exists for the same CVE and package,
--by-cvecollapses them into a single match, but which record's metadata survives changes between identical runs. The finding set is stable; the content attached to each finding is not.Reproduction
Built from
mainat970b4d10. Database v6.1.9 (built 2026-08-05), verified identical across every run by comparingdescriptor.db.statusin the output.repro.cdx.json— one component reachable through both a purl and a CPE:{ "bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1, "metadata": { "component": { "type": "application", "name": "repro", "version": "1.0.0" } }, "components": [ { "type": "library", "name": "stdlib", "version": "go1.21.0", "purl": "pkg:golang/stdlib@1.21.0", "cpe": "cpe:2.3:a:golang:go:1.21.0:*:*:*:*:*:*:*" } ] }match.golang.always-use-cpe-for-stdlibis only needed to give this particular component two sources; it defaults tofalseon main and defaulted totruein v0.112.0. Any package matched through both an ecosystem advisory and a CPE reaches the same code path.Observed, comparing the 20 runs on
vulnerability.{namespace,dataSource,description,fix,urls,severity,relatedVulnerabilities}keyed by (id, package, version):namespace,dataSource,fixandurls; 24 also vary indescriptionCVE-2023-45288/stdlibgo1.21.0alternates between:dataSourcehttps://go.dev/issue/65051https://nvd.nist.gov/vuln/detail/CVE-2023-45288namespacegovulndb:language:gonvd:cpefix.available[].kindadvisory(dated 2024-04-03)first-observed(dated 2025-09-04)Control: the same command without
--by-cve, 20 runs, is byte-identical in content across all 190 pairs. It is also stable when only one source matches the package, so the two competing records are what triggers it.Expected: identical input and identical database produce identical output.
Cause
Without
--by-cvethese are two separate findings —GO-2024-2687ingovulndb:language:gofound byexact-direct-match, andCVE-2023-45288innvd:cpefound bycpe-match— and both report the same fix versions,1.21.9, 1.22.2.Matches.Enumerateranges over thebyFingerprintmap, so it yields matches in Go's randomized map order (grype/match/matches.go:170-179).findDBMatchesfeeds that enumeration straight into the normalization loop, so which match becomes the merge receiver is decided by map order (grype/vulnerability_matcher.go:154-158).normalizeByCVErewritesVulnerability.IDandVulnerability.Namespaceto the aliased CVE record's, and nothing else (grype/vulnerability_matcher.go:362-364).Reference.Internal,Vulnerability.MetadataandVulnerability.Fixstill describe the record the match was found in.Fingerprintis{vulnerabilityID, vulnerabilityNamespace, packageID}plus the joinedFix.Versions(grype/match/match.go:29-38). Because both records report the same fix versions, the rewritten record now collides with the NVD record on the full fingerprint, soaddOrMergetakes case A and callsexistingMatch.Merge(newMatch)(grype/match/matches.go:99-107).Match.MergeunionsRelatedVulnerabilities,DetailsandCPEs, and leaves every other field of the receiver'sVulnerabilityalone (grype/match/match.go:40-88). So the record enumerated first keeps itsMetadataandFix; the other contributes only references and details.The JSON presenter emits
m.Vulnerability.Metadatadirectly when it is populated (grype/presenter/models/match.go:51), andnamespace/dataSource/description/urls/severitycome from that blob (grype/presenter/models/vulnerability_metadata.go:62-73), which is why the emittednamespacecan readgovulndb:language:goeven thoughVulnerability.Namespacewas rewritten tonvd:cpe.Sorted()sorts at the end, which stabilizes the order of the output but not the content of each merged match — which is why the finding set looks stable.This is the same class of problem as #1046, which fixed related vulnerabilities and CPEs being lost during the merge and added the
// for stable outputsorts now inMatch.Merge; the remaining fields were not covered.Impact
Anyone diffing grype output across runs — drift detection, cached comparisons, or a report expected to be reproducible from the same inputs — sees changes that did not come from a data or version change. The losing record is not preserved anywhere a consumer could recover it from, so the choice cannot be normalized downstream. When the NVD record wins, the curated fix metadata is what is lost: in the control run above, all 140
fix.availableentries on the ecosystem records carrykind: advisorywith real advisory dates, while all 171 on the NVD records carrykind: first-observed.Question
Two directions seem possible, and I do not want to assume which fits better:
Enumerateitself) iterate a sorted fingerprint list, which is narrow, though the survivor then becomes whichever fingerprint sorts first — for CVE-vs-GHSA/GO that is consistently the NVD record, so it would be stable but would settle on the record with the less specific fix data;--by-cve.Would either be acceptable, or is there a preferred direction? Happy to open a PR.