mirror of
https://github.com/anchore/grype.git
synced 2026-10-28 16:34:40 +08:00
feat(apk/matcher): apk matcher does alias aware aggregation (#3634)
* feat(apk/matcher): apk matcher does alias aware aggregation Signed-off-by: Zackary Crosley <zackary.crosley@chainguard.dev> * chore(apk/matcher): use result set throughout Signed-off-by: Zackary Crosley <zackary.crosley@chainguard.dev> * fix linter Signed-off-by: Zackary Crosley <zackary.crosley@chainguard.dev> * add test for alias suppression Signed-off-by: Zackary Crosley <zackary.crosley@chainguard.dev> * comments and fixes Signed-off-by: Zackary Crosley <zackary.crosley@chainguard.dev> * fix: merge matches based only on package + vuln identity Signed-off-by: Keith Zantow <kzantow@gmail.com> * chore: fix the VEX detail selection logic in match_by_image_test Signed-off-by: Keith Zantow <kzantow@gmail.com> * chore: move match and detail deduplication to match package Signed-off-by: Keith Zantow <kzantow@gmail.com> * chore: revert logic correction for match-type ignore rules Signed-off-by: Keith Zantow <kzantow@gmail.com> * chore: missed unnecessary refactor Signed-off-by: Keith Zantow <kzantow@gmail.com> * show edge case for stripping nvd fixes Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: clear inferred NVD fix data on both cpeDisclosures paths apk discards the fixes grype-db infers from NVD end-of-range data (#2162): an inferred fix is an upstream release number rather than an apk version, and only the distro feed knows when an apk actually ships one. that only happened on the "feed has no entry for this vulnerability" path. the other path, where the feed still considers the package vulnerable, returned NVD's fix as-is. that was unreachable for OSV feeds while identity was exact-ID, since a CGA-keyed row could never match a CVE-keyed NVD record, so every NVD record took the stripping path. alias-aware identity resolves the two, and records now arrive on the other one carrying a fix inferred from `versionEndExcluding`. stripping at the return makes it a property of the function rather than of one branch, which is what #2162's argument actually says: where a record entered from has no bearing on whether NVD's fix data applies to an apk. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: make merged record selection independent of arrival order compareRecordAuthority picks which record supplies the fields Merge cannot combine -- severity metadata, package name, status. it was order-independent for two records but not for three or more, so those fields could change between runs. two reasons, both now closed: - the tiebreak compared detail sets, and merging unions detail sets. a third record was therefore compared against a set that had never been an input. it now compares only the fields Merge carries over from the winner untouched, so they read the same on a merged record as on an input. everything else the winner supplies is fixed by the fingerprint (id, namespace, and the metadata cached against them) or already spent by match time (package qualifiers), so an exact tie means either record produces the same merged match. - mergeFix resolved state by favouring the base, which made it sensitive to merge direction. it now ranks states by how much they say and takes the higher. one cell of the state table moves: wont-fix merged with not-fixed reports not-fixed, matching what the other direction already did. not-fixed outranks wont-fix deliberately -- both say no fix is available, and merging should not make a finding easier to suppress than either record was alone, since `fix-state: wont-fix` is a common ignore rule. rank needed no change: merging unions detail sets, so a merged record ranks as the better of the two it came from. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: keep a record's own CPEs in its match detail `found.cpes` on a CPE match detail lists the vulnerability's CPEs that are relevant at the version searched. it compared them against the raw package version, so a CPE the search had already matched could be filtered back out by a version its own ecosystem spells differently: an apk 1.2.3-r4 dropped a "1.2.3" CPE, and a JVM 1.8.0_351 dropped a "1.8.0" CPE with update "update351". both were listed before. before this it ran inside FindResultsByCPEs, which had the normalized search version in scope. building details moved into the shared result provider, which does not, so it reached for the package version instead. it now takes the CPE the search was made with -- the criteria already carry it, and extractSearchParameters already reads it the same way -- and normalizes both sides of the comparison per format. the two normalizations move to matcher/internal/cpeversion so the result package can reach them without importing matcher/internal, which would be a cycle. they belong together anyway: both exist because a CPE spells a version the way NVD writes it rather than the way the package's ecosystem does. normalizing inside the comparison also covers a package whose own CPE has no version. that falls back to the raw package version, and the fallback happens after the alpine normalization in cpe.go, so relying on the search version alone would still have dropped the CPE. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: reconcile NVD against the same distro feed that produced the matches cpeDisclosures reconciled CPE records against a feed it built itself, with a query that quietly differed from the one FindResultsByDistro uses: no PackageSearchNames fanout, no OnlyQualifiedPackages, no ForUnaffected pass. Reconciling against a different feed than the one that produced the matches went wrong three ways: - a row for another architecture was still visible, so an aliased NVD record could be dropped on the strength of a row that did not apply to the package. a finding disappears with nothing having said it was fixed. - distro NAKs and unaffected rows were absent, so a CPE record the vendor had explicitly answered still became a match. the matcher emitted an ignore meant to cover it, but an IgnoreRelatedPackage only fires for packages that overlap the match's package by file, never for that package itself, so nothing suppressed it. - rootio name variants were absent, so reconciliation ran against a strict subset of the feed. the feed is no longer re-derived. FindResultsByDistro already partitions its results into vulnerable and everything else, both from the same qualified, name-fanned search, and both halves now come back to Match. that also collapses the three-way rule in cpeDisclosures to one: keep the CPE records the feed has no opinion on. a record it considers vulnerable is already reported as the authoritative distro match, and one it has settled needs no second opinion. matcher.go loses about 160 lines on net. Match now reads as its three sources -- distro, NVD, NAKs -- and the one rule that reconciles them. three rootio cases change: an NVD CPE match covered by a rootio NAK is no longer reported at all, rather than reported alongside an ignore that could never fire. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- Signed-off-by: Zackary Crosley <zackary.crosley@chainguard.dev> Signed-off-by: Keith Zantow <kzantow@gmail.com> Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> Co-authored-by: Keith Zantow <kzantow@gmail.com> Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
36 files changed
+2793
-1078
No files matched your search
@@ -716,8 +716,24 @@ func filterUnaffectedCPEVersions(constraintMatcher search.VersionConstraintMatch
|
||||
// filterAffectedPackageRanges returns true if all ranges removed
|
||||
func filterAffectedPackageRanges(matcher search.VersionConstraintMatcher, b *PackageBlob) (bool, []string) {
|
||||
if len(b.Ranges) == 0 {
|
||||
// no ranges means that we're implicitly vulnerable to all versions
|
||||
return false, nil
|
||||
// no ranges means the record is implicitly vulnerable to all versions. Represent that as
|
||||
// an always-satisfied "none" constraint and let the matcher decide, rather than blindly
|
||||
// keeping the record: a version-membership matcher (e.g. ByVersion) still keeps it because
|
||||
// the empty constraint is always satisfied, but a predicate matcher looking for a specific
|
||||
// constraint — e.g. the apk "< 0" NAK sentinel — correctly rejects a record that carries no
|
||||
// such constraint.
|
||||
constraint, err := version.GetConstraint("", version.UnknownFormat)
|
||||
if err != nil {
|
||||
// preserve the prior "implicitly vulnerable to all versions" behavior on the
|
||||
// unexpected case that the empty constraint cannot be constructed.
|
||||
return false, nil
|
||||
}
|
||||
matches, err := matcher.MatchesConstraint(constraint)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Debug("match constraint error on ranges-less package")
|
||||
}
|
||||
// filter if this doesn't match
|
||||
return !matches, nil
|
||||
}
|
||||
var unmatchedConstraints []string
|
||||
for _, r := range b.Ranges {
|
||||
|
||||
@@ -478,9 +478,27 @@ func Test_filterAffectedPackageRanges(t *testing.T) {
|
||||
expectedUnmatchedStrings []string
|
||||
}{
|
||||
{
|
||||
name: "no ranges",
|
||||
ranges: nil,
|
||||
expectedAllRangesRemoved: false, // important! we assume that a vulnerability with no ranges is always vulnerable
|
||||
// a ranges-less record is implicitly vulnerable to all versions, so a matcher that
|
||||
// accepts the synthesized always-satisfied "none" constraint (e.g. a version-membership
|
||||
// matcher) keeps it.
|
||||
name: "no ranges, matcher accepts the implicit all-versions constraint",
|
||||
ranges: nil,
|
||||
matchesConstraint: func(constraint version.Constraint) (bool, error) {
|
||||
return true, nil
|
||||
},
|
||||
expectedAllRangesRemoved: false,
|
||||
expectedUnmatchedStrings: nil,
|
||||
},
|
||||
{
|
||||
// a predicate matcher (e.g. the apk "< 0" NAK sentinel) rejects the synthesized
|
||||
// all-versions constraint, so the ranges-less record must be dropped rather than
|
||||
// spuriously matched.
|
||||
name: "no ranges, predicate matcher rejects the implicit all-versions constraint",
|
||||
ranges: nil,
|
||||
matchesConstraint: func(constraint version.Constraint) (bool, error) {
|
||||
return false, nil
|
||||
},
|
||||
expectedAllRangesRemoved: true,
|
||||
expectedUnmatchedStrings: nil,
|
||||
},
|
||||
{
|
||||
|
||||
+167
-18
@@ -1,7 +1,10 @@
|
||||
package match
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
@@ -42,6 +45,7 @@ func (m Details) Types() (tys []Type) {
|
||||
return tys
|
||||
}
|
||||
|
||||
// ID is a content hash over every field of the detail, empty when the detail cannot be hashed.
|
||||
func (m Detail) ID() string {
|
||||
f, err := hashstructure.Hash(&m, &hashstructure.HashOptions{
|
||||
ZeroNil: true,
|
||||
@@ -58,33 +62,178 @@ func (m Details) Len() int {
|
||||
return len(m)
|
||||
}
|
||||
|
||||
// Less orders details strongest-first so that Details satisfies sort.Interface.
|
||||
func (m Details) Less(i, j int) bool {
|
||||
a := m[i]
|
||||
b := m[j]
|
||||
return compareDetails(m[i], m[j]) < 0
|
||||
}
|
||||
|
||||
if a.Type != b.Type {
|
||||
// exact-direct-match < exact-indirect-match < cpe-match
|
||||
|
||||
at := typeOrder[a.Type]
|
||||
bt := typeOrder[b.Type]
|
||||
if at == 0 {
|
||||
return false
|
||||
} else if bt == 0 {
|
||||
return true
|
||||
}
|
||||
return at < bt
|
||||
// compareDetails orders two details strongest-first over every field, so that details comparing equal
|
||||
// are the same evidence.
|
||||
func compareDetails(a, b Detail) int {
|
||||
if c := compareTypes(a.Type, b.Type); c != 0 {
|
||||
return c
|
||||
}
|
||||
|
||||
// sort by confidence
|
||||
if a.Confidence != b.Confidence {
|
||||
// flipped comparison since we want higher confidence to be first
|
||||
return a.Confidence > b.Confidence
|
||||
// higher confidence first
|
||||
return cmp.Compare(b.Confidence, a.Confidence)
|
||||
}
|
||||
|
||||
// if the types are the same, then sort by the ID (costly, but deterministic)
|
||||
return strings.Compare(a.ID(), b.ID()) < 0
|
||||
if c := strings.Compare(string(a.Matcher), string(b.Matcher)); c != 0 {
|
||||
return c
|
||||
}
|
||||
|
||||
if c := comparePayloads(a.SearchedBy, b.SearchedBy); c != 0 {
|
||||
return c
|
||||
}
|
||||
|
||||
return comparePayloads(a.Found, b.Found)
|
||||
}
|
||||
|
||||
// compareTypes orders two match types strongest-first per typeOrder, with unrecognized types last.
|
||||
func compareTypes(a, b Type) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
|
||||
at, aok := typeOrder[a]
|
||||
bt, bok := typeOrder[b]
|
||||
switch {
|
||||
case aok && bok:
|
||||
return cmp.Compare(at, bt)
|
||||
case aok:
|
||||
return -1
|
||||
case bok:
|
||||
return 1
|
||||
default:
|
||||
return strings.Compare(string(a), string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// comparePayloads orders two Detail payloads, comparing the payload types known to this package field
|
||||
// by field and anything else by type name and then formatted value.
|
||||
func comparePayloads(a, b any) int {
|
||||
switch av := a.(type) {
|
||||
case CPEParameters:
|
||||
if bv, ok := b.(CPEParameters); ok {
|
||||
return av.compare(bv)
|
||||
}
|
||||
case CPEResult:
|
||||
if bv, ok := b.(CPEResult); ok {
|
||||
return av.compare(bv)
|
||||
}
|
||||
case DistroParameters:
|
||||
if bv, ok := b.(DistroParameters); ok {
|
||||
return av.compare(bv)
|
||||
}
|
||||
case DistroResult:
|
||||
if bv, ok := b.(DistroResult); ok {
|
||||
return av.compare(bv)
|
||||
}
|
||||
case EcosystemParameters:
|
||||
if bv, ok := b.(EcosystemParameters); ok {
|
||||
return av.compare(bv)
|
||||
}
|
||||
case EcosystemResult:
|
||||
if bv, ok := b.(EcosystemResult); ok {
|
||||
return av.compare(bv)
|
||||
}
|
||||
}
|
||||
|
||||
// %T is package-qualified and unique per type, so this separates payloads of different types
|
||||
if c := strings.Compare(fmt.Sprintf("%T", a), fmt.Sprintf("%T", b)); c != 0 {
|
||||
return c
|
||||
}
|
||||
|
||||
return strings.Compare(fmt.Sprintf("%v", a), fmt.Sprintf("%v", b))
|
||||
}
|
||||
|
||||
func (m Details) Swap(i, j int) {
|
||||
m[i], m[j] = m[j], m[i]
|
||||
}
|
||||
|
||||
// rank returns the typeOrder position of the best detail in a set, counting from 1, with sets holding
|
||||
// no recognized type ranking last.
|
||||
func (m Details) rank() int {
|
||||
best := math.MaxInt
|
||||
for _, d := range m {
|
||||
r, ok := typeOrder[d.Type]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if r < best {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// mergeDetails returns the union of the given detail sets, deduped and sorted strongest-first, in a
|
||||
// slice freshly allocated for the caller.
|
||||
func mergeDetails(sets ...Details) Details {
|
||||
var out Details
|
||||
for _, set := range sets {
|
||||
for _, d := range set {
|
||||
out = appendDetail(out, d)
|
||||
}
|
||||
}
|
||||
return sortDetails(out)
|
||||
}
|
||||
|
||||
// appendDetail adds d to out unless out already holds an identical detail, or a CPE detail that d can
|
||||
// be folded into.
|
||||
func appendDetail(out Details, d Detail) Details {
|
||||
incomingFound, foundIsCPE := d.Found.(CPEResult)
|
||||
incomingSearchedBy, searchedByIsCPE := d.SearchedBy.(CPEParameters)
|
||||
foldable := foundIsCPE && searchedByIsCPE
|
||||
|
||||
for i := range out {
|
||||
if foldable {
|
||||
if foldCPEDetail(&out[i], d, incomingSearchedBy, incomingFound) {
|
||||
return out
|
||||
}
|
||||
continue
|
||||
}
|
||||
if compareDetails(out[i], d) == 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
return append(out, d)
|
||||
}
|
||||
|
||||
// foldCPEDetail folds an incoming CPE detail into an existing one that found the same record, unioning
|
||||
// the package CPEs the two were searched by, and reports whether it did.
|
||||
func foldCPEDetail(existing *Detail, d Detail, incomingSearchedBy CPEParameters, incomingFound CPEResult) bool {
|
||||
if existing.Type != d.Type || existing.Matcher != d.Matcher || existing.Confidence != d.Confidence {
|
||||
return false
|
||||
}
|
||||
|
||||
found, ok := existing.Found.(CPEResult)
|
||||
if !ok || !found.Equals(incomingFound) {
|
||||
return false
|
||||
}
|
||||
|
||||
searchedBy, ok := existing.SearchedBy.(CPEParameters)
|
||||
if !ok || searchedBy.Package != incomingSearchedBy.Package {
|
||||
return false
|
||||
}
|
||||
|
||||
// Merge replaces the CPE list rather than appending to it, so this cannot disturb the given detail
|
||||
if err := searchedBy.Merge(incomingSearchedBy); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
existing.SearchedBy = searchedBy
|
||||
return true
|
||||
}
|
||||
|
||||
// sortDetails orders details strongest-first, in place, and returns the slice it was given.
|
||||
func sortDetails(details Details) Details {
|
||||
if len(details) < 2 {
|
||||
return details
|
||||
}
|
||||
|
||||
slices.SortStableFunc(details, compareDetails)
|
||||
return details
|
||||
}
|
||||
+354
-32
@@ -1,6 +1,9 @@
|
||||
package match
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
@@ -104,55 +107,374 @@ func TestDetails_Sorting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasExclusivelyAnyMatchTypes(t *testing.T) {
|
||||
// TestDetails_rank covers the ranking Match.Merge uses to decide which of two records for the
|
||||
// same finding describes it -- the successor to the direct-supersedes-indirect rules that used to
|
||||
// live in Matches.Add.
|
||||
func TestDetails_rank(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
details Details
|
||||
types []Type
|
||||
expected bool
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "all types allowed",
|
||||
details: Details{{Type: "A"}, {Type: "B"}},
|
||||
types: []Type{"A", "B"},
|
||||
expected: true,
|
||||
name: "direct is the strongest",
|
||||
details: Details{{Type: ExactDirectMatch}},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "mixed types with disallowed",
|
||||
details: Details{{Type: "A"}, {Type: "B"}, {Type: "C"}},
|
||||
types: []Type{"A", "B"},
|
||||
expected: false,
|
||||
name: "indirect ranks below direct",
|
||||
details: Details{{Type: ExactIndirectMatch}},
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
name: "single allowed type",
|
||||
details: Details{{Type: "A"}},
|
||||
types: []Type{"A"},
|
||||
expected: true,
|
||||
name: "CPE ranks below indirect",
|
||||
details: Details{{Type: CPEMatch}},
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
name: "empty details",
|
||||
name: "the strongest detail in the set wins",
|
||||
details: Details{{Type: CPEMatch}, {Type: ExactDirectMatch}, {Type: ExactIndirectMatch}},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "unrecognized types do not contribute",
|
||||
details: Details{{Type: "made-up"}, {Type: CPEMatch}},
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
name: "no recognized type ranks last",
|
||||
details: Details{{Type: "made-up"}},
|
||||
expected: math.MaxInt,
|
||||
},
|
||||
{
|
||||
name: "no details rank last",
|
||||
details: Details{},
|
||||
types: []Type{"A"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty types list",
|
||||
details: Details{{Type: "A"}},
|
||||
types: []Type{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no match with disallowed type",
|
||||
details: Details{{Type: "C"}},
|
||||
types: []Type{"A", "B"},
|
||||
expected: false,
|
||||
expected: math.MaxInt,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := hasExclusivelyAnyMatchTypes(tt.details, tt.types...)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
assert.Equal(t, tt.expected, tt.details.rank())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeDetails covers the guarantee that a match never carries the same detail twice: exact
|
||||
// duplicates are dropped both across the sets being merged and within each one.
|
||||
func TestMergeDetails(t *testing.T) {
|
||||
direct := Detail{Type: ExactDirectMatch, SearchedBy: "attr1", Found: "value1", Matcher: "matcher1"}
|
||||
cpe := Detail{Type: CPEMatch, SearchedBy: "attr2", Found: "value2", Matcher: "matcher2"}
|
||||
otherCPE := Detail{Type: CPEMatch, SearchedBy: "attr3", Found: "value2", Matcher: "matcher2"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sets []Details
|
||||
expected Details
|
||||
}{
|
||||
{
|
||||
name: "duplicates within a single set",
|
||||
sets: []Details{{cpe, cpe}},
|
||||
expected: Details{cpe},
|
||||
},
|
||||
{
|
||||
name: "duplicates across sets",
|
||||
sets: []Details{{cpe}, {cpe}},
|
||||
expected: Details{cpe},
|
||||
},
|
||||
{
|
||||
name: "duplicates on both sides",
|
||||
sets: []Details{{cpe, cpe}, {cpe, cpe}},
|
||||
expected: Details{cpe},
|
||||
},
|
||||
{
|
||||
name: "details that differ are all kept, sorted strongest first",
|
||||
sets: []Details{{cpe, otherCPE}, {direct, cpe}},
|
||||
// direct sorts ahead of the CPE details
|
||||
expected: Details{direct, cpe, otherCPE},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mergeDetails(tt.sets...)
|
||||
assert.Equal(t, tt.expected[0], got[0], "strongest detail should sort first")
|
||||
assert.ElementsMatch(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeDetails_foldsCPEDetailsByRecord covers folding the details of several of a package's CPEs
|
||||
// that found one database record into a single detail listing every CPE that found it.
|
||||
func TestMergeDetails_foldsCPEDetailsByRecord(t *testing.T) {
|
||||
pkgParam := PackageParameter{Name: "widget", Version: "1.5.0"}
|
||||
|
||||
searchedByFirst := CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{"cpe:2.3:a:acme:widget:1.5.0:*:*:*:*:*:*:*"},
|
||||
Package: pkgParam,
|
||||
}
|
||||
searchedBySecond := CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{"cpe:2.3:a:acme:widget:1.5.0:*:*:*:*:rails:*:*"},
|
||||
Package: pkgParam,
|
||||
}
|
||||
searchedByBoth := CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
// sorted, as CPEParameters.Merge leaves them
|
||||
"cpe:2.3:a:acme:widget:1.5.0:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:a:acme:widget:1.5.0:*:*:*:*:rails:*:*",
|
||||
},
|
||||
Package: pkgParam,
|
||||
}
|
||||
|
||||
record := CPEResult{
|
||||
VulnerabilityID: "CVE-2026-1",
|
||||
VersionConstraint: ">= 1.0.0, < 2.0.0 (unknown)",
|
||||
CPEs: []string{"cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*"},
|
||||
}
|
||||
// the same CVE as a second DB record, with its own constraint
|
||||
otherRecord := CPEResult{
|
||||
VulnerabilityID: "CVE-2026-1",
|
||||
VersionConstraint: "<= 1.5.0 (unknown)",
|
||||
CPEs: []string{"cpe:2.3:a:acme:widget:*:*:*:*:*:embedded-mod:*:*"},
|
||||
}
|
||||
|
||||
detail := func(searchedBy CPEParameters, found CPEResult) Detail {
|
||||
return Detail{
|
||||
Type: CPEMatch,
|
||||
Matcher: "java-matcher",
|
||||
Confidence: 0.9,
|
||||
SearchedBy: searchedBy,
|
||||
Found: found,
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sets []Details
|
||||
expected Details
|
||||
}{
|
||||
{
|
||||
name: "same record via two package CPEs folds into one detail",
|
||||
sets: []Details{{detail(searchedByFirst, record)}, {detail(searchedBySecond, record)}},
|
||||
expected: Details{detail(searchedByBoth, record)},
|
||||
},
|
||||
{
|
||||
name: "folding does not depend on merge order",
|
||||
sets: []Details{{detail(searchedBySecond, record)}, {detail(searchedByFirst, record)}},
|
||||
expected: Details{detail(searchedByBoth, record)},
|
||||
},
|
||||
{
|
||||
name: "two CPEs of one package finding it in a single set fold too",
|
||||
sets: []Details{{detail(searchedByFirst, record), detail(searchedBySecond, record)}},
|
||||
expected: Details{detail(searchedByBoth, record)},
|
||||
},
|
||||
{
|
||||
name: "details that found different records are kept apart",
|
||||
sets: []Details{{detail(searchedByFirst, record)}, {detail(searchedBySecond, otherRecord)}},
|
||||
expected: Details{
|
||||
detail(searchedByFirst, record),
|
||||
detail(searchedBySecond, otherRecord),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a namespace mismatch is kept apart",
|
||||
sets: func() []Details {
|
||||
otherNamespace := searchedBySecond
|
||||
otherNamespace.Namespace = "other:cpe"
|
||||
return []Details{{detail(searchedByFirst, record)}, {detail(otherNamespace, record)}}
|
||||
}(),
|
||||
expected: Details{
|
||||
detail(searchedByFirst, record),
|
||||
detail(func() CPEParameters { c := searchedBySecond; c.Namespace = "other:cpe"; return c }(), record),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "the same record searched for a different package is kept apart",
|
||||
sets: func() []Details {
|
||||
upstream := searchedBySecond
|
||||
upstream.Package = PackageParameter{Name: "widget-upstream", Version: "1.5.0"}
|
||||
return []Details{{detail(searchedByFirst, record)}, {detail(upstream, record)}}
|
||||
}(),
|
||||
expected: Details{
|
||||
detail(searchedByFirst, record),
|
||||
detail(func() CPEParameters {
|
||||
c := searchedBySecond
|
||||
c.Package = PackageParameter{Name: "widget-upstream", Version: "1.5.0"}
|
||||
return c
|
||||
}(), record),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "details from different matchers are kept apart",
|
||||
sets: func() []Details {
|
||||
d := detail(searchedBySecond, record)
|
||||
d.Matcher = "stock-matcher"
|
||||
return []Details{{detail(searchedByFirst, record)}, {d}}
|
||||
}(),
|
||||
expected: Details{
|
||||
detail(searchedByFirst, record),
|
||||
func() Detail { d := detail(searchedBySecond, record); d.Matcher = "stock-matcher"; return d }(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "details that are not CPE details are kept, never dropped",
|
||||
sets: []Details{
|
||||
{detail(searchedByFirst, record)},
|
||||
{{Type: ExactDirectMatch, Matcher: "java-matcher", SearchedBy: "attr", Found: "value"}},
|
||||
},
|
||||
expected: Details{
|
||||
{Type: ExactDirectMatch, Matcher: "java-matcher", SearchedBy: "attr", Found: "value"},
|
||||
detail(searchedByFirst, record),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mergeDetails(tt.sets...)
|
||||
assert.Len(t, got, len(tt.expected))
|
||||
assert.ElementsMatch(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeDetails_keepsDetailsThatCannotBeHashed pins that identity is the field-by-field comparison
|
||||
// and not a hash, so details that cannot be hashed are not all treated as the same detail.
|
||||
func TestMergeDetails_keepsDetailsThatCannotBeHashed(t *testing.T) {
|
||||
// a func field is enough to make hashstructure fail
|
||||
unhashable := Detail{Type: CPEMatch, Matcher: "java-matcher", SearchedBy: "first", Found: func() {}}
|
||||
otherUnhashable := Detail{Type: CPEMatch, Matcher: "java-matcher", SearchedBy: "second", Found: func() {}}
|
||||
require.Empty(t, unhashable.ID(), "expected this detail to be unhashable")
|
||||
require.Empty(t, otherUnhashable.ID(), "expected this detail to be unhashable")
|
||||
|
||||
got := mergeDetails(Details{unhashable}, Details{otherUnhashable})
|
||||
assert.Len(t, got, 2, "details that cannot be hashed must not collapse into one")
|
||||
|
||||
// the same detail merged twice is still one piece of evidence, hashable or not
|
||||
got = mergeDetails(Details{unhashable}, Details{unhashable})
|
||||
assert.Len(t, got, 1, "the same detail merged twice is one detail")
|
||||
|
||||
// a hashable detail dedups as always
|
||||
plain := Detail{Type: CPEMatch, Matcher: "java-matcher", SearchedBy: "attr", Found: "value"}
|
||||
assert.Len(t, mergeDetails(Details{plain}, Details{plain}), 1)
|
||||
}
|
||||
|
||||
// TestMergeDetails_orderMatchesDetailsLess guards mergeDetails and Details.Less against ordering
|
||||
// details differently.
|
||||
func TestMergeDetails_orderMatchesDetailsLess(t *testing.T) {
|
||||
details := Details{
|
||||
{Type: CPEMatch, Matcher: "java-matcher", SearchedBy: "a", Found: "1", Confidence: 0.9},
|
||||
{Type: ExactDirectMatch, Matcher: "java-matcher", SearchedBy: "b", Found: "2", Confidence: 1},
|
||||
{Type: CPEMatch, Matcher: "stock-matcher", SearchedBy: "c", Found: "3", Confidence: 0.9},
|
||||
{Type: ExactIndirectMatch, Matcher: "dpkg-matcher", SearchedBy: "d", Found: "4", Confidence: 1},
|
||||
{Type: CPEMatch, Matcher: "java-matcher", SearchedBy: "e", Found: "5", Confidence: 0.5},
|
||||
}
|
||||
|
||||
viaLess := append(Details(nil), details...)
|
||||
sort.Sort(viaLess)
|
||||
|
||||
assert.Equal(t, viaLess, mergeDetails(details))
|
||||
|
||||
// and independent of the order they arrive in
|
||||
reversed := append(Details(nil), details...)
|
||||
slices.Reverse(reversed)
|
||||
assert.Equal(t, viaLess, mergeDetails(reversed))
|
||||
}
|
||||
|
||||
// TestCompareDetails_isATotalOrder pins that the comparison is antisymmetric and separates any two
|
||||
// details that are not the same evidence.
|
||||
func TestCompareDetails_isATotalOrder(t *testing.T) {
|
||||
details := Details{
|
||||
{Type: ExactDirectMatch, Matcher: ApkMatcher, Confidence: 1,
|
||||
SearchedBy: DistroParameters{Namespace: "alpine:distro:alpine:3.20", Distro: DistroIdentification{Type: "alpine", Version: "3.20"}, Package: PackageParameter{Name: "openssl", Version: "1.0"}},
|
||||
Found: DistroResult{VulnerabilityID: "CVE-1", VersionConstraint: "< 1.2.3"}},
|
||||
// same as above but a different distro version -- must not collapse into it
|
||||
{Type: ExactDirectMatch, Matcher: ApkMatcher, Confidence: 1,
|
||||
SearchedBy: DistroParameters{Namespace: "alpine:distro:alpine:3.20", Distro: DistroIdentification{Type: "alpine", Version: "3.21"}, Package: PackageParameter{Name: "openssl", Version: "1.0"}},
|
||||
Found: DistroResult{VulnerabilityID: "CVE-1", VersionConstraint: "< 1.2.3"}},
|
||||
{Type: ExactIndirectMatch, Matcher: ApkMatcher, Confidence: 1,
|
||||
SearchedBy: DistroParameters{Namespace: "alpine:distro:alpine:3.20", Distro: DistroIdentification{Type: "alpine", Version: "3.20"}, Package: PackageParameter{Name: "openssl", Version: "1.0"}},
|
||||
Found: DistroResult{VulnerabilityID: "CVE-1", VersionConstraint: "< 1.2.3"}},
|
||||
{Type: CPEMatch, Matcher: JavaMatcher, Confidence: 0.9,
|
||||
SearchedBy: CPEParameters{Namespace: "nvd:cpe", CPEs: []string{"cpe:2.3:a:o:openssl:1.0:*:*:*:*:*:*:*"}, Package: PackageParameter{Name: "openssl", Version: "1.0"}},
|
||||
Found: CPEResult{VulnerabilityID: "CVE-1", VersionConstraint: "< 2.0", CPEs: []string{"cpe:2.3:a:o:openssl:*:*:*:*:*:*:*:*"}}},
|
||||
// differs from the CPE detail above only in the constraint of the record found
|
||||
{Type: CPEMatch, Matcher: JavaMatcher, Confidence: 0.9,
|
||||
SearchedBy: CPEParameters{Namespace: "nvd:cpe", CPEs: []string{"cpe:2.3:a:o:openssl:1.0:*:*:*:*:*:*:*"}, Package: PackageParameter{Name: "openssl", Version: "1.0"}},
|
||||
Found: CPEResult{VulnerabilityID: "CVE-1", VersionConstraint: "< 3.0", CPEs: []string{"cpe:2.3:a:o:openssl:*:*:*:*:*:*:*:*"}}},
|
||||
// differs from the first distro detail only in the vulnerability found
|
||||
{Type: ExactDirectMatch, Matcher: ApkMatcher, Confidence: 1,
|
||||
SearchedBy: DistroParameters{Namespace: "alpine:distro:alpine:3.20", Distro: DistroIdentification{Type: "alpine", Version: "3.20"}, Package: PackageParameter{Name: "openssl", Version: "1.0"}},
|
||||
Found: DistroResult{VulnerabilityID: "CVE-2", VersionConstraint: "< 1.2.3"}},
|
||||
{Type: ExactDirectMatch, Matcher: GoModuleMatcher, Confidence: 1,
|
||||
SearchedBy: EcosystemParameters{Language: "go", Namespace: "github:language:go", Package: PackageParameter{Name: "golang.org/x/net", Version: "0.1.0"}},
|
||||
Found: EcosystemResult{VulnerabilityID: "GHSA-1", VersionConstraint: "< 0.2.0", MatchedSymbols: []string{"html.Parse"}}},
|
||||
// differs from the ecosystem detail above only in the symbols matched
|
||||
{Type: ExactDirectMatch, Matcher: GoModuleMatcher, Confidence: 1,
|
||||
SearchedBy: EcosystemParameters{Language: "go", Namespace: "github:language:go", Package: PackageParameter{Name: "golang.org/x/net", Version: "0.1.0"}},
|
||||
Found: EcosystemResult{VulnerabilityID: "GHSA-1", VersionConstraint: "< 0.2.0", MatchedSymbols: []string{"html.Render"}}},
|
||||
// an unrecognized match type and a payload type this package knows nothing about
|
||||
{Type: "some-future-type", Matcher: "some-future-matcher", SearchedBy: "a", Found: 1},
|
||||
{Type: "some-future-type", Matcher: "some-future-matcher", SearchedBy: "b", Found: 1},
|
||||
}
|
||||
|
||||
for i := range details {
|
||||
for j := range details {
|
||||
a, b := details[i], details[j]
|
||||
forward, reverse := compareDetails(a, b), compareDetails(b, a)
|
||||
|
||||
require.Equal(t, -reverse, forward, "comparison is not antisymmetric for details %d and %d", i, j)
|
||||
|
||||
if i == j {
|
||||
require.Zero(t, forward, "a detail must compare equal to itself (index %d)", i)
|
||||
continue
|
||||
}
|
||||
require.NotZero(t, forward, "distinct details %d and %d compare equal, so one would be dropped as a duplicate", i, j)
|
||||
}
|
||||
}
|
||||
|
||||
// nothing in the set duplicates or folds into anything else, so merging keeps all of it
|
||||
assert.Len(t, mergeDetails(details), len(details))
|
||||
}
|
||||
|
||||
// benchmarkCPEDetail builds a distinct CPE detail per index: one package CPE found one database
|
||||
// record, with a constraint of its own so that nothing folds and the merged set keeps every detail.
|
||||
func benchmarkCPEDetail(i int) Detail {
|
||||
return Detail{
|
||||
Type: CPEMatch,
|
||||
Matcher: JavaMatcher,
|
||||
Confidence: 0.9,
|
||||
SearchedBy: CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{fmt.Sprintf("cpe:2.3:a:acme:widget%d:1.5.0:*:*:*:*:*:*:*", i)},
|
||||
Package: PackageParameter{Name: "widget", Version: "1.5.0"},
|
||||
},
|
||||
Found: CPEResult{
|
||||
VulnerabilityID: "CVE-2026-1",
|
||||
VersionConstraint: fmt.Sprintf(">= 1.0.0, < %d.0.0 (unknown)", i+2),
|
||||
CPEs: []string{
|
||||
"cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:a:acme:widget-core:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkMergeDetails tracks the cost of normalizing a finding's details, which Matches.Add pays
|
||||
// once per record contributing to the finding.
|
||||
func BenchmarkMergeDetails(b *testing.B) {
|
||||
for _, n := range []int{2, 10, 50} {
|
||||
set := make(Details, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
set = append(set, benchmarkCPEDetail(i))
|
||||
}
|
||||
b.Run(fmt.Sprintf("details=%d", n), func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = mergeDetails(set)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,19 +8,17 @@ import (
|
||||
"github.com/anchore/grype/grype/pkg"
|
||||
)
|
||||
|
||||
// Fingerprint is the identity of a finding: one vulnerability, from one source, on one package.
|
||||
// Matches that share a fingerprint are the same finding and are reported as one match, however many
|
||||
// database records produced them -- see Match.Merge for how records that disagree are reconciled.
|
||||
type Fingerprint struct {
|
||||
coreFingerprint
|
||||
vulnerabilityFixes string
|
||||
}
|
||||
|
||||
type coreFingerprint struct {
|
||||
vulnerabilityID string
|
||||
vulnerabilityNamespace string
|
||||
packageID pkg.ID // note: this encodes package name, version, type, location
|
||||
}
|
||||
|
||||
func (m Fingerprint) String() string {
|
||||
return fmt.Sprintf("Fingerprint(vuln=%q namespace=%q fixes=%q package=%q)", m.vulnerabilityID, m.vulnerabilityNamespace, m.vulnerabilityFixes, m.packageID)
|
||||
return fmt.Sprintf("Fingerprint(vuln=%q namespace=%q package=%q)", m.vulnerabilityID, m.vulnerabilityNamespace, m.packageID)
|
||||
}
|
||||
|
||||
func (m Fingerprint) ID() string {
|
||||
|
||||
+173
-47
@@ -1,13 +1,13 @@
|
||||
package match
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"sort"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/scylladb/go-set/strset"
|
||||
|
||||
"github.com/anchore/grype/grype/pkg"
|
||||
"github.com/anchore/grype/grype/version"
|
||||
"github.com/anchore/grype/grype/vulnerability"
|
||||
"github.com/anchore/syft/syft/cpe"
|
||||
)
|
||||
@@ -28,66 +28,192 @@ func (m Match) String() string {
|
||||
|
||||
func (m Match) Fingerprint() Fingerprint {
|
||||
return Fingerprint{
|
||||
coreFingerprint: coreFingerprint{
|
||||
vulnerabilityID: m.Vulnerability.ID,
|
||||
vulnerabilityNamespace: m.Vulnerability.Namespace,
|
||||
packageID: m.Package.ID,
|
||||
},
|
||||
vulnerabilityFixes: strings.Join(m.Vulnerability.Fix.Versions, ","),
|
||||
vulnerabilityID: m.Vulnerability.ID,
|
||||
vulnerabilityNamespace: m.Vulnerability.Namespace,
|
||||
packageID: m.Package.ID,
|
||||
}
|
||||
}
|
||||
|
||||
// Merge folds another match for the same finding -- the same vulnerability, from the same source, on
|
||||
// the same package -- into this one.
|
||||
//
|
||||
// Several records can describe one finding and disagree about the particulars: an NVD CVE listing
|
||||
// two vulnerable CPEs that the package matches, or an advisory found both directly and through the
|
||||
// package's upstream. Each is a separate record with its own constraint and fixed-in version, but to
|
||||
// a user they are one finding, so they are reported as one match.
|
||||
//
|
||||
// The record that produced the strongest match supplies the fields that cannot be combined --
|
||||
// severity metadata, package name, fix state -- and the remainder are unioned: fixed-in versions,
|
||||
// constraints, advisories, aliases, CPEs and match details. Every record contributes its own detail,
|
||||
// so how each one matched is preserved even though only one describes the finding as a whole.
|
||||
func (m *Match) Merge(other Match) error {
|
||||
if other.Fingerprint() != m.Fingerprint() {
|
||||
return ErrCannotMerge
|
||||
}
|
||||
|
||||
// the record that matched most strongly describes the finding; the other only feeds the unions below
|
||||
base, secondary := m.Vulnerability, other.Vulnerability
|
||||
if compareRecordAuthority(other, *m) < 0 {
|
||||
base, secondary = other.Vulnerability, m.Vulnerability
|
||||
}
|
||||
|
||||
base.Fix = mergeFix(base.Fix, secondary.Fix)
|
||||
|
||||
// note: the combined constraint is informational -- whether each record applies was decided
|
||||
// before the match was created, and every record's own constraint is preserved on the detail it
|
||||
// contributed.
|
||||
base.Constraint = version.CombineConstraints(base.Constraint, secondary.Constraint)
|
||||
|
||||
// there are cases related vulnerabilities are synthetic, for example when
|
||||
// orienting results by CVE. we need to keep track of these
|
||||
related := strset.New()
|
||||
for _, r := range m.Vulnerability.RelatedVulnerabilities {
|
||||
related.Add(referenceID(r))
|
||||
}
|
||||
for _, r := range other.Vulnerability.RelatedVulnerabilities {
|
||||
if related.Has(referenceID(r)) {
|
||||
continue
|
||||
}
|
||||
m.Vulnerability.RelatedVulnerabilities = append(m.Vulnerability.RelatedVulnerabilities, r)
|
||||
}
|
||||
|
||||
// for stable output
|
||||
sort.Slice(m.Vulnerability.RelatedVulnerabilities, func(i, j int) bool {
|
||||
a := m.Vulnerability.RelatedVulnerabilities[i]
|
||||
b := m.Vulnerability.RelatedVulnerabilities[j]
|
||||
return strings.Compare(referenceID(a), referenceID(b)) < 0
|
||||
})
|
||||
|
||||
// also keep details from the other match that are unique
|
||||
detailIDs := strset.New()
|
||||
for _, d := range m.Details {
|
||||
detailIDs.Add(d.ID())
|
||||
}
|
||||
for _, d := range other.Details {
|
||||
if detailIDs.Has(d.ID()) {
|
||||
continue
|
||||
}
|
||||
m.Details = append(m.Details, d)
|
||||
}
|
||||
|
||||
// for stable output
|
||||
sort.Sort(m.Details)
|
||||
base.RelatedVulnerabilities = mergeReferences(base.RelatedVulnerabilities, secondary.RelatedVulnerabilities)
|
||||
base.Advisories = mergeAdvisories(base.Advisories, secondary.Advisories)
|
||||
|
||||
// retain all unique CPEs for consistent output
|
||||
m.Vulnerability.CPEs = cpe.Merge(m.Vulnerability.CPEs, other.Vulnerability.CPEs)
|
||||
if m.Vulnerability.CPEs == nil {
|
||||
base.CPEs = cpe.Merge(base.CPEs, secondary.CPEs)
|
||||
if base.CPEs == nil {
|
||||
// ensure we always have a non-nil slice
|
||||
m.Vulnerability.CPEs = []cpe.CPE{}
|
||||
base.CPEs = []cpe.CPE{}
|
||||
}
|
||||
|
||||
m.Vulnerability = base
|
||||
m.Details = mergeDetails(m.Details, other.Details)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// referenceID returns an "ID" string for a vulnerability.Reference
|
||||
func referenceID(r vulnerability.Reference) string {
|
||||
return fmt.Sprintf("%s:%s", r.Namespace, r.ID)
|
||||
// compareRecordAuthority orders two matches for the same finding by how well each describes it, best
|
||||
// first, so that the winner does not depend on the order the matches were added in. That holds for any
|
||||
// number of records, which needs both halves to survive a merge unchanged:
|
||||
//
|
||||
// - rank is the strongest detail type in a set, and merging unions detail sets, so a merged record
|
||||
// ranks as the better of the two it came from. The best-ranked record wins whatever the order.
|
||||
// - the tiebreak compares only fields the winner keeps intact, so they read the same on a merged
|
||||
// record as on an input. Detail sets cannot serve here: merging unions them, so a third record
|
||||
// would be compared against a set that was never an input, and the winner would depend on order.
|
||||
func compareRecordAuthority(a, b Match) int {
|
||||
if c := cmp.Compare(a.Details.rank(), b.Details.rank()); c != 0 {
|
||||
return c
|
||||
}
|
||||
return compareUnmergedFields(a.Vulnerability, b.Vulnerability)
|
||||
}
|
||||
|
||||
// compareUnmergedFields orders two records for one finding by the fields Merge takes from the winner
|
||||
// rather than unioning. Everything else the winner supplies is either fixed by the fingerprint -- the
|
||||
// ID, the namespace, and the metadata cached against them -- or already spent by the time a match
|
||||
// exists, as package qualifiers are. So when these compare equal, either record can win and the merge
|
||||
// produces the same match.
|
||||
func compareUnmergedFields(a, b vulnerability.Vulnerability) int {
|
||||
if c := strings.Compare(a.PackageName, b.PackageName); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := strings.Compare(a.Status, b.Status); c != 0 {
|
||||
return c
|
||||
}
|
||||
if a.Unaffected != b.Unaffected {
|
||||
// a record calling the package unaffected describes the finding least well
|
||||
if a.Unaffected {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// mergeFix unions the fix information of two records describing the same finding, with a fix reported
|
||||
// by either record clearing it (see TestMergeFix_StateResolution for the whole state table).
|
||||
func mergeFix(base, other vulnerability.Fix) vulnerability.Fix {
|
||||
out := vulnerability.Fix{
|
||||
State: base.State,
|
||||
}
|
||||
|
||||
if versions := slices.Concat(base.Versions, other.Versions); len(versions) > 0 {
|
||||
slices.Sort(versions)
|
||||
out.Versions = slices.Compact(versions)
|
||||
}
|
||||
|
||||
seen := make(map[vulnerability.FixAvailable]struct{}, len(base.Available)+len(other.Available))
|
||||
for _, a := range slices.Concat(base.Available, other.Available) {
|
||||
if _, exists := seen[a]; exists {
|
||||
continue
|
||||
}
|
||||
seen[a] = struct{}{}
|
||||
out.Available = append(out.Available, a)
|
||||
}
|
||||
|
||||
out.State = mergeFixState(base.State, other.State)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// fixStatePrecedence ranks fix states from least to most informative, so that mergeFixState can take
|
||||
// the higher of two rather than favouring whichever record happened to be the base. Without it the
|
||||
// merged state depends on merge direction, and for three or more records that means it depends on the
|
||||
// order they arrived in.
|
||||
//
|
||||
// not-fixed outranking wont-fix is the one judgement call here. Both say no fix is available, and
|
||||
// merging should not make a finding easier to suppress than either record was on its own -- users
|
||||
// routinely ignore `fix-state: wont-fix`, so promoting to it would hide a finding one of the records
|
||||
// never claimed was closed.
|
||||
var fixStatePrecedence = map[vulnerability.FixState]int{
|
||||
"": 0, // a record that states nothing at all
|
||||
vulnerability.FixStateUnknown: 1,
|
||||
vulnerability.FixStateWontFix: 2,
|
||||
vulnerability.FixStateNotFixed: 3,
|
||||
vulnerability.FixStateFixed: 4,
|
||||
}
|
||||
|
||||
// mergeFixState resolves the fix states of two records describing one finding, most informative first.
|
||||
func mergeFixState(a, b vulnerability.FixState) vulnerability.FixState {
|
||||
if fixStatePrecedence[b] > fixStatePrecedence[a] {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// mergeReferences unions two sets of vulnerability references (aliases), sorted for stable output.
|
||||
func mergeReferences(base, other []vulnerability.Reference) []vulnerability.Reference {
|
||||
seen := make(map[vulnerability.Reference]struct{}, len(base)+len(other))
|
||||
var out []vulnerability.Reference
|
||||
for _, r := range slices.Concat(base, other) {
|
||||
// comparable without Internal reference
|
||||
key := vulnerability.Reference{ID: r.ID, Namespace: r.Namespace}
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, r)
|
||||
}
|
||||
|
||||
// for stable output
|
||||
slices.SortFunc(out, func(a, b vulnerability.Reference) int {
|
||||
if c := strings.Compare(a.Namespace, b.Namespace); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.ID, b.ID)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeAdvisories unions two sets of advisories, sorted for stable output.
|
||||
func mergeAdvisories(base, other []vulnerability.Advisory) []vulnerability.Advisory {
|
||||
// vulnerability.Advisory is comparable, so it keys the set on its own
|
||||
seen := make(map[vulnerability.Advisory]struct{}, len(base)+len(other))
|
||||
var out []vulnerability.Advisory
|
||||
for _, a := range slices.Concat(base, other) {
|
||||
if _, exists := seen[a]; exists {
|
||||
continue
|
||||
}
|
||||
seen[a] = struct{}{}
|
||||
out = append(out, a)
|
||||
}
|
||||
|
||||
// for stable output
|
||||
slices.SortFunc(out, func(a, b vulnerability.Advisory) int {
|
||||
if c := strings.Compare(a.ID, b.ID); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.Link, b.Link)
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package match
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/grype/grype/pkg"
|
||||
@@ -247,3 +250,232 @@ func TestMatch_Merge(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeReferences_UncomparableInternal guards the reason mergeReferences projects onto
|
||||
// referenceKey rather than keying on vulnerability.Reference itself: Reference.Internal is an `any`,
|
||||
// and providers do put uncomparable values in it. Using Reference as a map key would compile fine
|
||||
// and panic at runtime ("hash of unhashable type") the first time one showed up.
|
||||
func TestMergeReferences_UncomparableInternal(t *testing.T) {
|
||||
withSlice := vulnerability.Reference{
|
||||
ID: "CVE-2023-0001",
|
||||
Namespace: "nvd:cpe",
|
||||
Internal: []string{"uncomparable"},
|
||||
}
|
||||
withMap := vulnerability.Reference{
|
||||
ID: "CVE-2023-0002",
|
||||
Namespace: "nvd:cpe",
|
||||
Internal: map[string]string{"also": "uncomparable"},
|
||||
}
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
got := mergeReferences([]vulnerability.Reference{withSlice}, []vulnerability.Reference{withMap, withSlice})
|
||||
|
||||
// deduped on (id, namespace), ignoring Internal, and sorted
|
||||
require.Len(t, got, 2)
|
||||
assert.Equal(t, "CVE-2023-0001", got[0].ID)
|
||||
assert.Equal(t, "CVE-2023-0002", got[1].ID)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMergeAdvisories covers dedup and ordering for the set keyed directly on the (comparable)
|
||||
// vulnerability.Advisory value.
|
||||
func TestMergeAdvisories(t *testing.T) {
|
||||
a := vulnerability.Advisory{ID: "RHSA-2024:9371", Link: "https://example.com/a"}
|
||||
b := vulnerability.Advisory{ID: "RHSA-2024:6163", Link: "https://example.com/b"}
|
||||
// same ID, different link -- a distinct advisory, so both are kept
|
||||
aPrime := vulnerability.Advisory{ID: "RHSA-2024:9371", Link: "https://example.com/other"}
|
||||
|
||||
got := mergeAdvisories([]vulnerability.Advisory{a, b}, []vulnerability.Advisory{a, aPrime})
|
||||
|
||||
// sorted by ID, then Link
|
||||
assert.Equal(t, []vulnerability.Advisory{b, a, aPrime}, got)
|
||||
}
|
||||
|
||||
// TestMergeFix_AvailableDedup covers the set keyed directly on the (comparable)
|
||||
// vulnerability.FixAvailable value, including its time.Time field.
|
||||
func TestMergeFix_AvailableDedup(t *testing.T) {
|
||||
when := time.Date(2024, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
later := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
base := vulnerability.Fix{
|
||||
Versions: []string{"2.0.0", "1.0.0"},
|
||||
State: vulnerability.FixStateFixed,
|
||||
Available: []vulnerability.FixAvailable{{Version: "1.0.0", Kind: "rpm", Date: when}},
|
||||
}
|
||||
other := vulnerability.Fix{
|
||||
Versions: []string{"1.0.0", "3.0.0"},
|
||||
Available: []vulnerability.FixAvailable{
|
||||
{Version: "1.0.0", Kind: "rpm", Date: when}, // exact duplicate, dropped
|
||||
{Version: "1.0.0", Kind: "rpm", Date: later}, // same version+kind, different date: kept
|
||||
},
|
||||
}
|
||||
|
||||
got := mergeFix(base, other)
|
||||
|
||||
assert.Equal(t, []string{"1.0.0", "2.0.0", "3.0.0"}, got.Versions, "versions should be sorted and deduped")
|
||||
assert.Equal(t, vulnerability.FixStateFixed, got.State)
|
||||
assert.Equal(t, []vulnerability.FixAvailable{
|
||||
{Version: "1.0.0", Kind: "rpm", Date: when},
|
||||
{Version: "1.0.0", Kind: "rpm", Date: later},
|
||||
}, got.Available)
|
||||
}
|
||||
|
||||
// TestMergeFix_StateResolution pins the whole fix-state table of mergeFix.
|
||||
func TestMergeFix_StateResolution(t *testing.T) {
|
||||
var (
|
||||
none vulnerability.FixState // a record that states nothing at all
|
||||
unknown = vulnerability.FixStateUnknown
|
||||
notFixed = vulnerability.FixStateNotFixed
|
||||
wontFix = vulnerability.FixStateWontFix
|
||||
fixed = vulnerability.FixStateFixed
|
||||
)
|
||||
|
||||
// rows are the base record's state, cells the merged state for each of the other record's states
|
||||
others := []vulnerability.FixState{none, unknown, notFixed, wontFix, fixed}
|
||||
expected := map[vulnerability.FixState][]vulnerability.FixState{
|
||||
// other: none unknown not-fixed wont-fix fixed
|
||||
none: {none, unknown, notFixed, wontFix, fixed},
|
||||
unknown: {unknown, unknown, notFixed, wontFix, fixed},
|
||||
notFixed: {notFixed, notFixed, notFixed, notFixed, fixed},
|
||||
wontFix: {wontFix, wontFix, notFixed, wontFix, fixed},
|
||||
fixed: {fixed, fixed, fixed, fixed, fixed},
|
||||
}
|
||||
|
||||
for _, base := range []vulnerability.FixState{none, unknown, notFixed, wontFix, fixed} {
|
||||
for i, other := range others {
|
||||
name := fmt.Sprintf("base=%q other=%q", base, other)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := mergeFix(vulnerability.Fix{State: base}, vulnerability.Fix{State: other})
|
||||
assert.Equal(t, expected[base][i], got.State)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// the table must be symmetric: compareRecordAuthority can leave either record as the base when
|
||||
// they are equally authoritative, and for three or more records a direction-sensitive cell would
|
||||
// make the merged state depend on arrival order
|
||||
t.Run("is commutative", func(t *testing.T) {
|
||||
for _, a := range others {
|
||||
for _, b := range others {
|
||||
forward := mergeFix(vulnerability.Fix{State: a}, vulnerability.Fix{State: b}).State
|
||||
reverse := mergeFix(vulnerability.Fix{State: b}, vulnerability.Fix{State: a}).State
|
||||
assert.Equal(t, forward, reverse, "mergeFix(%q, %q) must not depend on direction", a, b)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// the pair the policy exists for, stated on its own so a change to it cannot pass unnoticed
|
||||
t.Run("wont-fix merged with fixed reports fixed, either direction", func(t *testing.T) {
|
||||
wontFixRecord := vulnerability.Fix{State: wontFix}
|
||||
fixedRecord := vulnerability.Fix{State: fixed, Versions: []string{"2.0.0"}}
|
||||
|
||||
forward := mergeFix(wontFixRecord, fixedRecord)
|
||||
assert.Equal(t, fixed, forward.State)
|
||||
assert.Equal(t, []string{"2.0.0"}, forward.Versions)
|
||||
|
||||
reverse := mergeFix(fixedRecord, wontFixRecord)
|
||||
assert.Equal(t, fixed, reverse.State)
|
||||
assert.Equal(t, []string{"2.0.0"}, reverse.Versions)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMatch_Merge_isOrderIndependent pins that two records for one finding reconcile to the same match
|
||||
// whichever one was stored first.
|
||||
func TestMatch_Merge_isOrderIndependent(t *testing.T) {
|
||||
p := pkg.Package{ID: "pkg-1", Name: "openssl", Version: "1.0"}
|
||||
record := func(packageName string, state vulnerability.FixState, constraint, searchedCPE string) Match {
|
||||
return Match{
|
||||
Package: p,
|
||||
Vulnerability: vulnerability.Vulnerability{
|
||||
Reference: vulnerability.Reference{ID: "CVE-1", Namespace: "nvd:cpe"},
|
||||
PackageName: packageName,
|
||||
Fix: vulnerability.Fix{State: state},
|
||||
},
|
||||
Details: Details{{
|
||||
Type: CPEMatch, Matcher: JavaMatcher, Confidence: 0.9,
|
||||
SearchedBy: CPEParameters{Namespace: "nvd:cpe", CPEs: []string{searchedCPE}, Package: PackageParameter{Name: "openssl", Version: "1.0"}},
|
||||
Found: CPEResult{VulnerabilityID: "CVE-1", VersionConstraint: constraint, CPEs: []string{"cpe:2.3:a:o:openssl:*:*:*:*:*:*:*:*"}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// two NVD records for one CVE: same rank, disagreeing about the fix state
|
||||
a := record("openssl-a", vulnerability.FixStateWontFix, "< 2.0", "cpe:2.3:a:o:openssl:1.0:*:*:*:*:*:*:*")
|
||||
b := record("openssl-b", vulnerability.FixStateNotFixed, "< 3.0", "cpe:2.3:a:p:openssl:1.0:*:*:*:*:*:*:*")
|
||||
|
||||
forward := a
|
||||
require.NoError(t, forward.Merge(b))
|
||||
|
||||
reverse := b
|
||||
require.NoError(t, reverse.Merge(a))
|
||||
|
||||
assert.Equal(t, forward.Vulnerability.PackageName, reverse.Vulnerability.PackageName, "the surviving record must not depend on merge direction")
|
||||
assert.Equal(t, forward.Vulnerability.Fix.State, reverse.Vulnerability.Fix.State, "the surviving fix state must not depend on merge direction")
|
||||
assert.Equal(t, forward.Details, reverse.Details, "the merged details must not depend on merge direction")
|
||||
|
||||
// and the same through Matches.Add, which is how this is reached in practice
|
||||
first := NewMatches(a, b)
|
||||
second := NewMatches(b, a)
|
||||
assert.Equal(t, first.Sorted(), second.Sorted(), "Matches.Add must not depend on the order matches arrive in")
|
||||
}
|
||||
|
||||
// TestMatch_Merge_isOrderIndependentForThreeRecords extends the two-record case above
|
||||
// to three, which is where compareRecordAuthority stops holding its promise.
|
||||
//
|
||||
// compareDetailSets is slices.CompareFunc, so a shorter set that is a prefix of a longer
|
||||
// one sorts first: a record carrying only the shared detail outranks a record carrying
|
||||
// the shared detail plus one of its own. Merging then replaces the winner's details with
|
||||
// the union of both, so the third comparison runs against a set that never existed as an
|
||||
// input, and the relation is not associative.
|
||||
//
|
||||
// The surviving record supplies Metadata (severity/CVSS), PackageName, Status and the
|
||||
// mergeFix base state, so which one wins is user-visible. Reached in practice whenever
|
||||
// one fingerprint collects three or more records, e.g. two package CPEs against two NVD
|
||||
// records on the apk upstream path.
|
||||
func TestMatch_Merge_isOrderIndependentForThreeRecords(t *testing.T) {
|
||||
p := pkg.Package{ID: "pkg-1", Name: "openssl", Version: "1.0"}
|
||||
detail := func(searchedCPE string) Detail {
|
||||
return Detail{
|
||||
Type: CPEMatch, Matcher: JavaMatcher, Confidence: 0.9,
|
||||
SearchedBy: CPEParameters{Namespace: "nvd:cpe", CPEs: []string{searchedCPE}, Package: PackageParameter{Name: "openssl", Version: "1.0"}},
|
||||
Found: CPEResult{VulnerabilityID: "CVE-1", VersionConstraint: "< 1.0.0"},
|
||||
}
|
||||
}
|
||||
record := func(packageName string, state vulnerability.FixState, details ...Detail) Match {
|
||||
return Match{
|
||||
Package: p,
|
||||
Vulnerability: vulnerability.Vulnerability{
|
||||
Reference: vulnerability.Reference{ID: "CVE-1", Namespace: "nvd:cpe"},
|
||||
PackageName: packageName,
|
||||
Fix: vulnerability.Fix{State: state},
|
||||
},
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
// every record was found through the same shared CPE; b and c were each also found
|
||||
// through a second one, so their detail sets are supersets of a's
|
||||
shared := detail("cpe:2.3:a:o:openssl:*:*:*:*:*:*:*:*")
|
||||
a := record("openssl-a", vulnerability.FixStateNotFixed, shared)
|
||||
b := record("openssl-b", vulnerability.FixStateWontFix, shared, detail("cpe:2.3:a:o:openssl:1:*:*:*:*:*:*:*"))
|
||||
c := record("openssl-c", vulnerability.FixStateNotFixed, shared, detail("cpe:2.3:a:o:openssl:2:*:*:*:*:*:*:*"))
|
||||
|
||||
permutations := [][]Match{
|
||||
{a, b, c}, {a, c, b}, {b, a, c},
|
||||
{b, c, a}, {c, a, b}, {c, b, a},
|
||||
}
|
||||
|
||||
var want Match
|
||||
for i, order := range permutations {
|
||||
collection := NewMatches(order...)
|
||||
got := collection.Sorted()
|
||||
require.Len(t, got, 1, "all three records share a fingerprint, so they must collapse to one match")
|
||||
|
||||
if i == 0 {
|
||||
want = got[0]
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, want.Vulnerability.PackageName, got[0].Vulnerability.PackageName, "the surviving record must not depend on the order matches arrive in")
|
||||
assert.Equal(t, want.Vulnerability.Fix.State, got[0].Vulnerability.Fix.State, "the surviving fix state must not depend on the order matches arrive in")
|
||||
}
|
||||
}
|
||||
+23
-122
@@ -3,16 +3,13 @@ package match
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/scylladb/go-set/strset"
|
||||
|
||||
"github.com/anchore/grype/grype/pkg"
|
||||
"github.com/anchore/grype/internal/log"
|
||||
)
|
||||
|
||||
type Matches struct {
|
||||
byFingerprint map[Fingerprint]Match
|
||||
byCoreFingerprint map[coreFingerprint]map[Fingerprint]struct{}
|
||||
byPackage map[pkg.ID]map[Fingerprint]struct{}
|
||||
byFingerprint map[Fingerprint]Match
|
||||
byPackage map[pkg.ID]map[Fingerprint]struct{}
|
||||
}
|
||||
|
||||
func NewMatches(matches ...Match) Matches {
|
||||
@@ -23,9 +20,8 @@ func NewMatches(matches ...Match) Matches {
|
||||
|
||||
func newMatches() Matches {
|
||||
return Matches{
|
||||
byFingerprint: make(map[Fingerprint]Match),
|
||||
byCoreFingerprint: make(map[coreFingerprint]map[Fingerprint]struct{}),
|
||||
byPackage: make(map[pkg.ID]map[Fingerprint]struct{}),
|
||||
byFingerprint: make(map[Fingerprint]Match),
|
||||
byPackage: make(map[pkg.ID]map[Fingerprint]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,107 +62,37 @@ func (r *Matches) Diff(other Matches) *Matches {
|
||||
return &diff
|
||||
}
|
||||
|
||||
// Add stores the given matches, folding each into any match already present for the same finding.
|
||||
// A fingerprint is one vulnerability, from one source, on one package -- so records that describe
|
||||
// that finding differently, such as two vulnerable CPEs of one CVE that the package matches, or an
|
||||
// advisory found both directly and via the package's upstream, become a single match carrying all
|
||||
// of their details rather than duplicate findings. See Match.Merge for how they are reconciled.
|
||||
func (r *Matches) Add(matches ...Match) {
|
||||
for _, newMatch := range matches {
|
||||
newFp := newMatch.Fingerprint()
|
||||
fingerprint := newMatch.Fingerprint()
|
||||
|
||||
// add or merge the new match with an existing match
|
||||
r.addOrMerge(newMatch, newFp)
|
||||
// normalize first: every detail set this collection holds is deduped and ordered strongest-first
|
||||
newMatch.Details = mergeDetails(newMatch.Details)
|
||||
|
||||
// track common elements (core fingerprint + package index)
|
||||
|
||||
if _, exists := r.byCoreFingerprint[newFp.coreFingerprint]; !exists {
|
||||
r.byCoreFingerprint[newFp.coreFingerprint] = make(map[Fingerprint]struct{})
|
||||
if existingMatch, exists := r.byFingerprint[fingerprint]; exists {
|
||||
if err := existingMatch.Merge(newMatch); err != nil {
|
||||
// unreachable: an equal fingerprint is the only thing Merge rejects on
|
||||
log.WithFields("original", existingMatch.String(), "new", newMatch.String(), "error", err).Warn("unable to merge matches")
|
||||
// at least capture the additional details
|
||||
existingMatch.Details = mergeDetails(existingMatch.Details, newMatch.Details)
|
||||
}
|
||||
r.byFingerprint[fingerprint] = existingMatch
|
||||
} else {
|
||||
r.byFingerprint[fingerprint] = newMatch
|
||||
}
|
||||
|
||||
r.byCoreFingerprint[newFp.coreFingerprint][newFp] = struct{}{}
|
||||
|
||||
if _, exists := r.byPackage[newMatch.Package.ID]; !exists {
|
||||
r.byPackage[newMatch.Package.ID] = make(map[Fingerprint]struct{})
|
||||
}
|
||||
r.byPackage[newMatch.Package.ID][newFp] = struct{}{}
|
||||
r.byPackage[newMatch.Package.ID][fingerprint] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Matches) addOrMerge(newMatch Match, newFp Fingerprint) {
|
||||
// a) if there is an exact fingerprint match, then merge with that
|
||||
// b) otherwise, look for core fingerprint matches (looser rules)
|
||||
// we prefer direct matches to indirect matches:
|
||||
// 1. if the new match is a direct match and there is an indirect match, replace the indirect match with the direct match
|
||||
// 2. if the new match is an indirect match and there is a direct match, merge with the existing direct match
|
||||
// c) this is a new match
|
||||
|
||||
if existingMatch, exists := r.byFingerprint[newFp]; exists {
|
||||
// case A
|
||||
if err := existingMatch.Merge(newMatch); err != nil {
|
||||
log.WithFields("original", existingMatch.String(), "new", newMatch.String(), "error", err).Warn("unable to merge matches")
|
||||
// at least capture the additional details
|
||||
existingMatch.Details = append(existingMatch.Details, newMatch.Details...)
|
||||
}
|
||||
|
||||
r.byFingerprint[newFp] = existingMatch
|
||||
} else if existingFingerprints, exists := r.byCoreFingerprint[newFp.coreFingerprint]; exists {
|
||||
// case B
|
||||
if !r.mergeCoreMatches(newMatch, newFp, existingFingerprints) {
|
||||
// case C (we should not drop this match if we were unable to merge it)
|
||||
r.byFingerprint[newFp] = newMatch
|
||||
}
|
||||
} else {
|
||||
// case C
|
||||
r.byFingerprint[newFp] = newMatch
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Matches) mergeCoreMatches(newMatch Match, newFp Fingerprint, existingFingerprints map[Fingerprint]struct{}) bool {
|
||||
for existingFp := range existingFingerprints {
|
||||
existingMatch := r.byFingerprint[existingFp]
|
||||
|
||||
shouldSupersede := hasMatchType(newMatch.Details, ExactDirectMatch) && hasExclusivelyAnyMatchTypes(existingMatch.Details, ExactIndirectMatch)
|
||||
if shouldSupersede {
|
||||
// case B1
|
||||
if replaced := r.replace(newMatch, existingFp, newFp, existingMatch.Details...); !replaced {
|
||||
log.WithFields("original", existingMatch.String(), "new", newMatch.String()).Trace("unable to replace match")
|
||||
// at least capture the new details
|
||||
existingMatch.Details = append(existingMatch.Details, newMatch.Details...)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// case B2
|
||||
if err := existingMatch.Merge(newMatch); err != nil {
|
||||
log.WithFields("original", existingMatch.String(), "new", newMatch.String(), "error", err).Trace("unable to merge matches")
|
||||
// at least capture the new details
|
||||
existingMatch.Details = append(existingMatch.Details, newMatch.Details...)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Matches) replace(m Match, ogFp, newFp Fingerprint, extraDetails ...Detail) bool {
|
||||
if ogFp.coreFingerprint != newFp.coreFingerprint {
|
||||
return false
|
||||
}
|
||||
|
||||
// update indexes
|
||||
for pkgID, fingerprints := range r.byPackage {
|
||||
if _, exists := fingerprints[ogFp]; exists {
|
||||
delete(fingerprints, ogFp)
|
||||
fingerprints[newFp] = struct{}{}
|
||||
r.byPackage[pkgID] = fingerprints
|
||||
}
|
||||
}
|
||||
|
||||
// update the match
|
||||
delete(r.byFingerprint, ogFp)
|
||||
m.Details = append(m.Details, extraDetails...)
|
||||
sort.Sort(m.Details)
|
||||
r.byFingerprint[newFp] = m
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Matches) Enumerate() <-chan Match {
|
||||
channel := make(chan Match)
|
||||
go func() {
|
||||
@@ -193,28 +119,3 @@ func (r *Matches) Sorted() []Match {
|
||||
func (r *Matches) Count() int {
|
||||
return len(r.byFingerprint)
|
||||
}
|
||||
|
||||
func hasMatchType(details Details, ty Type) bool {
|
||||
for _, d := range details {
|
||||
if d.Type == ty {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasExclusivelyAnyMatchTypes(details Details, tys ...Type) bool {
|
||||
allowed := strset.New()
|
||||
for _, ty := range tys {
|
||||
allowed.Add(string(ty))
|
||||
}
|
||||
var found bool
|
||||
for _, d := range details {
|
||||
if allowed.Has(string(d.Type)) {
|
||||
found = true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
+142
-19
@@ -1,6 +1,7 @@
|
||||
package match
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -496,6 +497,13 @@ func TestMatches_Add_Merge(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// several cases below pair the direct record above with a weaker record reporting a different
|
||||
// fixed-in version. The stronger record describes the merged finding, but neither fix is lost.
|
||||
commonVulnerabilityWithBothFixes := commonVulnerability
|
||||
commonVulnerabilityWithBothFixes.Fix = vulnerability.Fix{
|
||||
Versions: []string{"1.0.0", "3.2.12"},
|
||||
}
|
||||
|
||||
matchPkg2Indirect := Match{
|
||||
Vulnerability: commonVulnerability,
|
||||
Package: pkg.Package{
|
||||
@@ -596,7 +604,9 @@ func TestMatches_Add_Merge(t *testing.T) {
|
||||
expectedMatches: map[string][]Match{
|
||||
"pkg1": {
|
||||
{
|
||||
Vulnerability: commonVulnerability,
|
||||
// the direct record describes the finding, but neither record's fixed-in
|
||||
// version is dropped
|
||||
Vulnerability: commonVulnerabilityWithBothFixes,
|
||||
Package: matchPkg1Direct.Package,
|
||||
Details: Details{
|
||||
commonDirectDetail, // sorts to first (direct should be prioritized over indirect)
|
||||
@@ -612,7 +622,10 @@ func TestMatches_Add_Merge(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "does not merge matches with different fingerprints but semantically the same when matched by CPE",
|
||||
// a CVE from one source reached both by CPE and by a direct match on the same package is
|
||||
// one finding, not two -- this is the NVD shape where one CVE lists several vulnerable
|
||||
// CPEs, each its own record with its own fixed-in version
|
||||
name: "merges a CPE match into a direct match for the same finding",
|
||||
matches: []Match{
|
||||
{
|
||||
Vulnerability: vulnerability.Vulnerability{
|
||||
@@ -644,31 +657,19 @@ func TestMatches_Add_Merge(t *testing.T) {
|
||||
expectedMatches: map[string][]Match{
|
||||
"pkg1": {
|
||||
{
|
||||
Vulnerability: vulnerability.Vulnerability{
|
||||
Reference: vulnerability.Reference{
|
||||
ID: commonVuln,
|
||||
Namespace: commonNamespace,
|
||||
},
|
||||
Constraint: func() version.Constraint { // different!
|
||||
c, err := version.GetConstraint("< 3.2.12", version.SemanticFormat)
|
||||
require.NoError(t, err)
|
||||
return c
|
||||
}(),
|
||||
Fix: vulnerability.Fix{
|
||||
Versions: []string{"3.2.12"}, // different!
|
||||
},
|
||||
},
|
||||
Package: matchPkg1Direct.Package,
|
||||
// the direct match is the stronger record, so it describes the finding
|
||||
Vulnerability: commonVulnerabilityWithBothFixes,
|
||||
Package: matchPkg1Direct.Package,
|
||||
Details: Details{
|
||||
commonDirectDetail, // sorts to first (direct should be prioritized over CPE)
|
||||
{
|
||||
Type: CPEMatch, // different!
|
||||
Type: CPEMatch,
|
||||
SearchedBy: "attr1",
|
||||
Found: "value1",
|
||||
Matcher: "matcher1",
|
||||
},
|
||||
},
|
||||
},
|
||||
matchPkg1Direct,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -702,3 +703,125 @@ func TestMatches_Add_Merge(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatches_Add_CollapsesRecordsIntoOneFinding pins the shape of the fingerprint: one
|
||||
// vulnerability, from one source, on one package is one finding, however many database records
|
||||
// produced it and however much they disagree about the fixed-in version.
|
||||
//
|
||||
// This also covers a pair of bugs in the two-tier index this replaced: the "indirect merges into an
|
||||
// existing direct match" rule was dead code (it routed through Match.Merge, which rejected the very
|
||||
// records it was handed), so the finding count depended on which order the matcher happened to emit
|
||||
// its records -- indirect-then-direct collapsed to one match, direct-then-indirect did not.
|
||||
func TestMatches_Add_CollapsesRecordsIntoOneFinding(t *testing.T) {
|
||||
mk := func(fix string, ty Type) Match {
|
||||
return Match{
|
||||
Vulnerability: vulnerability.Vulnerability{
|
||||
Reference: vulnerability.Reference{ID: "CVE-2023-0001", Namespace: "namespace1"},
|
||||
Fix: vulnerability.Fix{Versions: []string{fix}},
|
||||
},
|
||||
Package: pkg.Package{ID: "pkg1"},
|
||||
// searched-by varies per record so each contributes a distinct detail
|
||||
Details: Details{{Type: ty, SearchedBy: string(ty), Found: "value", Matcher: "matcher"}},
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// the strongest record's match type, which describes the merged finding
|
||||
matches []Match
|
||||
}{
|
||||
{
|
||||
name: "indirect then direct",
|
||||
matches: []Match{mk("3.2.12", ExactIndirectMatch), mk("1.0.0", ExactDirectMatch)},
|
||||
},
|
||||
{
|
||||
// the mirror of the above: used to yield two findings, because case B2 never fired
|
||||
name: "direct then indirect",
|
||||
matches: []Match{mk("1.0.0", ExactDirectMatch), mk("3.2.12", ExactIndirectMatch)},
|
||||
},
|
||||
{
|
||||
name: "CPE then direct",
|
||||
matches: []Match{mk("3.2.12", CPEMatch), mk("1.0.0", ExactDirectMatch)},
|
||||
},
|
||||
{
|
||||
name: "direct then CPE",
|
||||
matches: []Match{mk("1.0.0", ExactDirectMatch), mk("3.2.12", CPEMatch)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := NewMatches(tt.matches...)
|
||||
|
||||
matches := m.Sorted()
|
||||
require.Len(t, matches, 1, "expected the records to collapse into a single finding")
|
||||
require.Equal(t, 1, m.Count())
|
||||
got := matches[0]
|
||||
|
||||
// neither record's fix is lost
|
||||
assert.Equal(t, []string{"1.0.0", "3.2.12"}, got.Vulnerability.Fix.Versions)
|
||||
|
||||
// every record still says how it matched, and the strongest sorts first regardless of
|
||||
// arrival order
|
||||
assert.Equal(t, ExactDirectMatch, got.Details.Types()[0])
|
||||
assert.ElementsMatch(t,
|
||||
[]Type{tt.matches[0].Details[0].Type, tt.matches[1].Details[0].Type},
|
||||
got.Details.Types())
|
||||
|
||||
// the package index resolves to the live match
|
||||
for fp := range m.byPackage["pkg1"] {
|
||||
require.Contains(t, m.byFingerprint, fp,
|
||||
"byPackage[pkg1] tracks a fingerprint with no match: %s", fp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatches_Add_KeepsSeparateSources guards the other side of the fingerprint: records from
|
||||
// different sources are not merged, so a distro advisory's fixed-in version can never end up in the
|
||||
// same list as an upstream one (they use different version schemes).
|
||||
func TestMatches_Add_KeepsSeparateSources(t *testing.T) {
|
||||
mk := func(namespace, fix string, ty Type) Match {
|
||||
return Match{
|
||||
Vulnerability: vulnerability.Vulnerability{
|
||||
Reference: vulnerability.Reference{ID: "CVE-2018-0735", Namespace: namespace},
|
||||
Fix: vulnerability.Fix{Versions: []string{fix}},
|
||||
},
|
||||
Package: pkg.Package{ID: "pkg1"},
|
||||
Details: Details{{Type: ty, SearchedBy: namespace, Found: "value", Matcher: "matcher"}},
|
||||
}
|
||||
}
|
||||
|
||||
m := NewMatches(
|
||||
mk("redhat:distro:redhat:7", "1:1.0.2k-16.el7_6.1", ExactDirectMatch),
|
||||
mk("nvd:cpe", "1.1.0i", CPEMatch),
|
||||
)
|
||||
|
||||
matches := m.Sorted()
|
||||
require.Len(t, matches, 2, "records from different sources should stay separate findings")
|
||||
for _, got := range matches {
|
||||
require.Len(t, got.Vulnerability.Fix.Versions, 1,
|
||||
"fixed-in versions from different sources must not be unioned: %v", got.Vulnerability.Fix.Versions)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkMatchesAddOneFinding tracks the cost of building a single finding out of N database records.
|
||||
func BenchmarkMatchesAddOneFinding(b *testing.B) {
|
||||
for _, n := range []int{2, 10, 50} {
|
||||
b.Run(fmt.Sprintf("records=%d", n), func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
matches := NewMatches()
|
||||
for j := 0; j < n; j++ {
|
||||
matches.Add(Match{
|
||||
Vulnerability: vulnerability.Vulnerability{
|
||||
Reference: vulnerability.Reference{ID: "CVE-2026-1", Namespace: "nvd:cpe"},
|
||||
Fix: vulnerability.Fix{Versions: []string{fmt.Sprintf("%d.0.0", j)}},
|
||||
},
|
||||
Package: pkg.Package{ID: "pkg1", Name: "widget", Version: "1.5.0"},
|
||||
Details: Details{benchmarkCPEDetail(j)},
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+72
-5
@@ -2,7 +2,9 @@ package match
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/scylladb/go-set/strset"
|
||||
)
|
||||
@@ -18,6 +20,13 @@ type PackageParameter struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func (p PackageParameter) compare(other PackageParameter) int {
|
||||
if c := strings.Compare(p.Name, other.Name); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(p.Version, other.Version)
|
||||
}
|
||||
|
||||
func (i *CPEParameters) Merge(other CPEParameters) error {
|
||||
if i.Namespace != other.Namespace {
|
||||
return fmt.Errorf("namespaces do not match")
|
||||
@@ -31,6 +40,17 @@ func (i *CPEParameters) Merge(other CPEParameters) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// compare orders two CPE search parameters, most significant field first.
|
||||
func (i CPEParameters) compare(other CPEParameters) int {
|
||||
if c := strings.Compare(i.Namespace, other.Namespace); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := i.Package.compare(other.Package); c != 0 {
|
||||
return c
|
||||
}
|
||||
return slices.Compare(i.CPEs, other.CPEs)
|
||||
}
|
||||
|
||||
type CPEResult struct {
|
||||
VulnerabilityID string `json:"vulnerabilityID"`
|
||||
VersionConstraint string `json:"versionConstraint"`
|
||||
@@ -55,6 +75,16 @@ func (h CPEResult) Equals(other CPEResult) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (h CPEResult) compare(other CPEResult) int {
|
||||
if c := strings.Compare(h.VulnerabilityID, other.VulnerabilityID); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := strings.Compare(h.VersionConstraint, other.VersionConstraint); c != 0 {
|
||||
return c
|
||||
}
|
||||
return slices.Compare(h.CPEs, other.CPEs)
|
||||
}
|
||||
|
||||
type DistroParameters struct {
|
||||
Distro DistroIdentification `json:"distro"`
|
||||
Package PackageParameter `json:"package"`
|
||||
@@ -85,6 +115,19 @@ func (d *DistroParameters) Merge(other DistroParameters) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DistroParameters) compare(other DistroParameters) int {
|
||||
if c := strings.Compare(d.Namespace, other.Namespace); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := strings.Compare(d.Distro.Type, other.Distro.Type); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := strings.Compare(d.Distro.Version, other.Distro.Version); c != 0 {
|
||||
return c
|
||||
}
|
||||
return d.Package.compare(other.Package)
|
||||
}
|
||||
|
||||
type DistroResult struct {
|
||||
VulnerabilityID string `json:"vulnerabilityID"`
|
||||
VersionConstraint string `json:"versionConstraint"`
|
||||
@@ -95,6 +138,13 @@ func (d DistroResult) Equals(other DistroResult) bool {
|
||||
d.VersionConstraint == other.VersionConstraint
|
||||
}
|
||||
|
||||
func (d DistroResult) compare(other DistroResult) int {
|
||||
if c := strings.Compare(d.VulnerabilityID, other.VulnerabilityID); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(d.VersionConstraint, other.VersionConstraint)
|
||||
}
|
||||
|
||||
type EcosystemParameters struct {
|
||||
Language string `json:"language"`
|
||||
Namespace string `json:"namespace"`
|
||||
@@ -117,13 +167,30 @@ func (e *EcosystemParameters) Merge(other EcosystemParameters) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e EcosystemParameters) compare(other EcosystemParameters) int {
|
||||
if c := strings.Compare(e.Namespace, other.Namespace); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := strings.Compare(e.Language, other.Language); c != 0 {
|
||||
return c
|
||||
}
|
||||
return e.Package.compare(other.Package)
|
||||
}
|
||||
|
||||
type EcosystemResult struct {
|
||||
VulnerabilityID string `json:"vulnerabilityID"`
|
||||
VersionConstraint string `json:"versionConstraint"`
|
||||
// MatchedSymbols is the sorted, de-duplicated set of vulnerable Go symbols the package was found
|
||||
// to use (empty unless the match was scoped by symbol evidence). A slice, not a scalar: detail
|
||||
// identity is computed via hashstructure (SlicesAsSets), and EcosystemResult is only ever carried
|
||||
// in Detail.Found (never compared or used as a map key), so no comparability constraint applies —
|
||||
// the sibling CPEResult.CPEs is likewise a slice.
|
||||
// MatchedSymbols is the sorted, de-duplicated set of vulnerable Go symbols the package was found to
|
||||
// use, empty unless the match was scoped by symbol evidence.
|
||||
MatchedSymbols []string `json:"matchedSymbols,omitempty"`
|
||||
}
|
||||
|
||||
func (e EcosystemResult) compare(other EcosystemResult) int {
|
||||
if c := strings.Compare(e.VulnerabilityID, other.VulnerabilityID); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := strings.Compare(e.VersionConstraint, other.VersionConstraint); c != 0 {
|
||||
return c
|
||||
}
|
||||
return slices.Compare(e.MatchedSymbols, other.MatchedSymbols)
|
||||
}
|
||||
+148
-182
@@ -3,21 +3,26 @@ package apk
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/anchore/grype/grype/match"
|
||||
"github.com/anchore/grype/grype/matcher/internal"
|
||||
"github.com/anchore/grype/grype/matcher/internal/result"
|
||||
"github.com/anchore/grype/grype/pkg"
|
||||
"github.com/anchore/grype/grype/search"
|
||||
"github.com/anchore/grype/grype/version"
|
||||
"github.com/anchore/grype/grype/vulnerability"
|
||||
"github.com/anchore/grype/internal/log"
|
||||
syftPkg "github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
const (
|
||||
ignoreReasonDistroFixed = "DistroPackageFixed"
|
||||
ignoreReasonExplicitNAK = "Explicit APK NAK"
|
||||
)
|
||||
|
||||
var (
|
||||
nakVersionString = version.MustGetConstraint("< 0", version.ApkFormat).String()
|
||||
|
||||
// nakConstraint checks the exact version string for being an APK version with "< 0"
|
||||
nakConstraint = search.ByConstraintFunc(func(c version.Constraint) (bool, error) {
|
||||
return c.String() == nakVersionString, nil
|
||||
})
|
||||
@@ -33,208 +38,86 @@ func (m *Matcher) Type() match.MatcherType {
|
||||
return match.ApkMatcher
|
||||
}
|
||||
|
||||
func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
|
||||
var matches []match.Match
|
||||
var ignoreFilters []match.IgnoreFilter
|
||||
|
||||
// direct matches with package itself (+ distro-fixed ignore rules when metadata implements FileOwner)
|
||||
directMatches, directIgnores, err := m.findMatchesForPackage(store, p, nil)
|
||||
// Match collects vulnerability matches and ignore filters for an APK package from three sources: the
|
||||
// authoritative distro security feed, explicit NAK entries, and upstream NVD (CPE) data reconciled
|
||||
// against the feed. Each source covers the package and its upstream/origin packages.
|
||||
//
|
||||
// Everything is reconciled in result.Set space by vulnerability identity -- ID plus aliases -- so
|
||||
// records naming the same vulnerability under different IDs (a CGA and its CVE, say) are deduplicated
|
||||
// correctly.
|
||||
//
|
||||
// A nil distro is not an error: the distro-feed and NAK paths simply contribute nothing, and the
|
||||
// package is still matched against NVD.
|
||||
func (m *Matcher) Match(vp vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
|
||||
// the distro feed is the authority for an apk. What it considers this version vulnerable to
|
||||
// becomes matches; the rest -- already fixed at this version, explicitly unaffected, or NAK'd --
|
||||
// is what the other sources reconcile against, and becomes ignores for packages overlapping this
|
||||
// one by file.
|
||||
vulnerable, allFixed, err := m.distroResults(vp, p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, fmt.Errorf("failed to find distro matches for apk pkg=%q: %w", p.Name, err)
|
||||
}
|
||||
matches = append(matches, directMatches...)
|
||||
ignoreFilters = append(ignoreFilters, directIgnores...)
|
||||
|
||||
// indirect matches, via package's origin package
|
||||
indirectMatches, indirectIgnores, err := m.findMatchesForOriginPackage(store, p)
|
||||
// NVD counts only where the feed is silent: a record the feed considers vulnerable is already
|
||||
// reported as the authoritative distro match, and one the feed has settled needs no second opinion.
|
||||
cpeVulnerable, cpeIgnores, err := m.cpeResults(vp, p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, fmt.Errorf("failed to find nvd matches for apk pkg=%q: %w", p.Name, err)
|
||||
}
|
||||
matches = append(matches, indirectMatches...)
|
||||
ignoreFilters = append(ignoreFilters, indirectIgnores...)
|
||||
vulnerable = vulnerable.Merge(cpeVulnerable.Remove(vulnerable).Remove(allFixed))
|
||||
|
||||
// APK sources are also able to NAK vulnerabilities, so we want to return these as explicit ignores in order
|
||||
// to allow rules later to use these to ignore "the same" vulnerability found in "the same" locations
|
||||
naks, err := m.findNaksForPackage(store, p)
|
||||
// NAK entries never produce matches of their own, only ignores
|
||||
nakIgnores, err := m.nakIgnores(vp, p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, fmt.Errorf("failed to find naks for apk pkg=%q: %w", p.Name, err)
|
||||
}
|
||||
ignoreFilters = append(ignoreFilters, naks...)
|
||||
|
||||
return matches, ignoreFilters, nil
|
||||
ignores := slices.Concat(
|
||||
cpeIgnores,
|
||||
nakIgnores,
|
||||
internal.OwnershipIgnores(p, ignoreReasonDistroFixed, allFixed.Vulnerabilities()...),
|
||||
)
|
||||
|
||||
return vulnerable.ToMatches(), ignores, nil
|
||||
}
|
||||
|
||||
//nolint:funlen,gocognit
|
||||
func (m *Matcher) cpeMatchesWithoutSecDBFixes(provider vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
|
||||
// find CPE-indexed vulnerability matches specific to the given package name and version
|
||||
cpeMatches, ignored, err := internal.MatchPackageByCPEs(provider, p, m.Type())
|
||||
if err != nil {
|
||||
log.WithFields("package", p.Name, "error", err).Debug("failed to find CPE matches for package")
|
||||
}
|
||||
if p.Distro == nil {
|
||||
return cpeMatches, ignored, nil
|
||||
}
|
||||
|
||||
cpeMatchesByID := matchesByID(cpeMatches)
|
||||
|
||||
// remove cpe matches where there is an entry in the secDB for the particular package-vulnerability pairing, and the
|
||||
// installed package version is >= the fixed in version for the secDB record.
|
||||
secDBVulnerabilities, err := provider.FindVulnerabilities(
|
||||
search.ByPackageName(p.Name),
|
||||
search.ByDistro(*p.Distro))
|
||||
// distroResults searches the authoritative distro feed for the package and each of its upstream/origin
|
||||
// packages, and partitions what it finds: records this version is vulnerable to, and the rest.
|
||||
//
|
||||
// allFixed is broader than its name: alongside records already fixed at this version it holds the ones
|
||||
// the feed calls unaffected, and apk "< 0" NAKs, which are vulnerable at no version and so land here
|
||||
// too. That is what makes it the right thing to reconcile other sources against.
|
||||
func (m *Matcher) distroResults(vp vulnerability.Provider, p pkg.Package) (vulnerable, allFixed result.Set, err error) {
|
||||
// APK doesn't use epochs, so pass a nil comparison config.
|
||||
vulnerable, allFixed, err = internal.FindResultsByDistro(vp, p, nil, m.Type(), nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, upstreamPkg := range pkg.UpstreamPackages(p) {
|
||||
secDBVulnerabilitiesForUpstream, err := provider.FindVulnerabilities(
|
||||
search.ByPackageName(upstreamPkg.Name),
|
||||
search.ByDistro(*upstreamPkg.Distro))
|
||||
upstreamVulnerable, upstreamFixed, err := internal.FindResultsByDistro(vp, upstreamPkg, &p, m.Type(), nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
secDBVulnerabilities = append(secDBVulnerabilities, secDBVulnerabilitiesForUpstream...)
|
||||
|
||||
vulnerable = vulnerable.Merge(markIndirect(upstreamVulnerable, p))
|
||||
allFixed = allFixed.Merge(upstreamFixed)
|
||||
}
|
||||
|
||||
secDBVulnerabilitiesByID := vulnerabilitiesByID(secDBVulnerabilities)
|
||||
|
||||
verObj := version.New(p.Version, pkg.VersionFormat(p))
|
||||
|
||||
var finalCpeMatches []match.Match
|
||||
|
||||
cveLoop:
|
||||
for id, cpeMatchesForID := range cpeMatchesByID {
|
||||
// check to see if there is a secdb entry for this ID (CVE)
|
||||
secDBVulnerabilitiesForID, exists := secDBVulnerabilitiesByID[id]
|
||||
if !exists {
|
||||
// does not exist in secdb, so the CPE record(s) should be added to the final results
|
||||
|
||||
// remove fixed-in versions, since NVD doesn't know when Alpine will fix things
|
||||
for _, nvdOnlyMatch := range cpeMatchesForID {
|
||||
if len(nvdOnlyMatch.Vulnerability.Fix.Versions) > 0 {
|
||||
nvdOnlyMatch.Vulnerability.Fix = vulnerability.Fix{
|
||||
State: vulnerability.FixStateUnknown,
|
||||
}
|
||||
}
|
||||
finalCpeMatches = append(finalCpeMatches, nvdOnlyMatch)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// there is a secdb entry...
|
||||
for _, vuln := range secDBVulnerabilitiesForID {
|
||||
// ...is there a fixed in entry? (should always be yes)
|
||||
if len(vuln.Fix.Versions) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// ...is the current package vulnerable?
|
||||
vulnerable, err := vuln.Constraint.Satisfied(verObj)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if vulnerable {
|
||||
// if there is at least one vulnerable entry, then all CPE record(s) should be added to the final results
|
||||
finalCpeMatches = append(finalCpeMatches, cpeMatchesForID...)
|
||||
continue cveLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
return finalCpeMatches, ignored, nil
|
||||
return vulnerable, allFixed, nil
|
||||
}
|
||||
|
||||
func deduplicateMatches(secDBMatches, cpeMatches []match.Match) (matches []match.Match) {
|
||||
// add additional unique matches from CPE source that is unique from the SecDB matches
|
||||
secDBMatchesByID := matchesByID(secDBMatches)
|
||||
cpeMatchesByID := matchesByID(cpeMatches)
|
||||
for id, cpeMatchesForID := range cpeMatchesByID {
|
||||
// by this point all matches have been verified to be vulnerable within the given package version relative to the vulnerability source.
|
||||
// now we will add unique CPE candidates that were not found in secdb.
|
||||
if _, exists := secDBMatchesByID[id]; !exists {
|
||||
// add the new CPE-based record (e.g. NVD) since it was not found in secDB
|
||||
matches = append(matches, cpeMatchesForID...)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func matchesByID(matches []match.Match) map[string][]match.Match {
|
||||
var results = make(map[string][]match.Match)
|
||||
for _, secDBMatch := range matches {
|
||||
results[secDBMatch.Vulnerability.ID] = append(results[secDBMatch.Vulnerability.ID], secDBMatch)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func vulnerabilitiesByID(vulns []vulnerability.Vulnerability) map[string][]vulnerability.Vulnerability {
|
||||
var results = make(map[string][]vulnerability.Vulnerability)
|
||||
for _, vuln := range vulns {
|
||||
results[vuln.ID] = append(results[vuln.ID], vuln)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (m *Matcher) findMatchesForPackage(store vulnerability.Provider, p pkg.Package, catalogPkg *pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
|
||||
// find SecDB matches for the given package name and version
|
||||
// APK doesn't use epochs, so pass nil for the config
|
||||
secDBMatches, secDBIgnores, err := internal.MatchPackageByDistro(store, p, catalogPkg, m.Type(), nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// TODO: are there other errors that we should handle here that causes this to short circuit
|
||||
cpeMatches, cpeIgnores, err := m.cpeMatchesWithoutSecDBFixes(store, p)
|
||||
if err != nil && !errors.Is(err, internal.ErrEmptyCPEMatch) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var matches []match.Match
|
||||
|
||||
// keep all secdb matches, as this is an authoritative source
|
||||
matches = append(matches, secDBMatches...)
|
||||
|
||||
// keep only unique CPE matches
|
||||
matches = append(matches, deduplicateMatches(secDBMatches, cpeMatches)...)
|
||||
|
||||
return matches, append(secDBIgnores, cpeIgnores...), nil
|
||||
}
|
||||
|
||||
func (m *Matcher) findMatchesForOriginPackage(store vulnerability.Provider, catalogPkg pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
|
||||
var matches []match.Match
|
||||
var ignores []match.IgnoreFilter
|
||||
|
||||
for _, indirectPackage := range pkg.UpstreamPackages(catalogPkg) {
|
||||
indirectMatches, indirectIgnores, err := m.findMatchesForPackage(store, indirectPackage, &catalogPkg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to find vulnerabilities for apk upstream source package: %w", err)
|
||||
}
|
||||
matches = append(matches, indirectMatches...)
|
||||
ignores = append(ignores, indirectIgnores...)
|
||||
}
|
||||
|
||||
// we want to make certain that we are tracking the match based on the package from the SBOM (not the indirect package)
|
||||
// however, we also want to keep the indirect package around for future reference
|
||||
match.ConvertToIndirectMatches(matches, catalogPkg)
|
||||
|
||||
return matches, ignores, nil
|
||||
}
|
||||
|
||||
// NAK entries are those reported as explicitly not vulnerable by the upstream provider,
|
||||
// for example this entry is present in the v5 database:
|
||||
// 312891,CVE-2020-7224,openvpn,alpine:distro:alpine:3.10,,< 0,apk,,"[{""id"":""CVE-2020-7224"",""namespace"":""nvd:cpe""}]","[""0""]",fixed,
|
||||
// which indicates, for the alpine:3.10 distro, package openvpn is not vulnerable to CVE-2020-7224
|
||||
// we want to report these NAK entries as match.IgnoredMatch, to allow for later processing to create ignore rules
|
||||
// based on packages which overlap by location, such as a python binary found in addition to the python APK entry --
|
||||
// we want to NAK this vulnerability for BOTH packages
|
||||
func (m *Matcher) findNaksForPackage(provider vulnerability.Provider, p pkg.Package) ([]match.IgnoreFilter, error) {
|
||||
// nakIgnores collects explicit NAK ("< 0") entries for the package and its upstreams and returns them
|
||||
// as ignore filters. NAK entries never produce matches; they only allow later rules to suppress the
|
||||
// same vulnerability on packages that overlap this one by location.
|
||||
func (m *Matcher) nakIgnores(vp vulnerability.Provider, p pkg.Package) ([]match.IgnoreFilter, error) {
|
||||
if p.Distro == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// get all the direct naks
|
||||
naks, err := provider.FindVulnerabilities(
|
||||
provider := result.NewProvider(vp, p, m.Type())
|
||||
|
||||
naks, err := provider.FindResults(
|
||||
search.ByDistro(*p.Distro),
|
||||
search.ByPackageName(p.Name),
|
||||
nakConstraint,
|
||||
@@ -243,9 +126,8 @@ func (m *Matcher) findNaksForPackage(provider vulnerability.Provider, p pkg.Pack
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// append all the upstream naks
|
||||
for _, upstreamPkg := range pkg.UpstreamPackages(p) {
|
||||
upstreamNaks, err := provider.FindVulnerabilities(
|
||||
upstreamNaks, err := provider.FindResults(
|
||||
search.ByDistro(*upstreamPkg.Distro),
|
||||
search.ByPackageName(upstreamPkg.Name),
|
||||
nakConstraint,
|
||||
@@ -253,9 +135,93 @@ func (m *Matcher) findNaksForPackage(provider vulnerability.Provider, p pkg.Pack
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
naks = append(naks, upstreamNaks...)
|
||||
naks = naks.Merge(upstreamNaks)
|
||||
}
|
||||
|
||||
return internal.OwnershipIgnores(p, "Explicit APK NAK", naks...), nil
|
||||
return internal.OwnershipIgnores(p, ignoreReasonExplicitNAK, naks.Vulnerabilities()...), nil
|
||||
}
|
||||
|
||||
// cpeResults finds NVD (CPE-indexed) results for the package itself and for each of its
|
||||
// upstream/origin packages, the latter recorded against the SBOM package. Searching the origin is what
|
||||
// surfaces, for example, an openssl CVE for a libssl3 APK whose origin is openssl.
|
||||
func (m *Matcher) cpeResults(provider vulnerability.Provider, p pkg.Package) (result.Set, []match.IgnoreFilter, error) {
|
||||
disclosures, ignores, err := m.cpeDisclosures(provider, p, p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, upstreamPkg := range pkg.UpstreamPackages(p) {
|
||||
upstreamDisclosures, upstreamIgnores, err := m.cpeDisclosures(provider, upstreamPkg, p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
disclosures = disclosures.Merge(upstreamDisclosures)
|
||||
ignores = append(ignores, upstreamIgnores...)
|
||||
}
|
||||
|
||||
return disclosures, ignores, nil
|
||||
}
|
||||
|
||||
func (m *Matcher) cpeDisclosures(provider vulnerability.Provider, searchPkg, catalogPkg pkg.Package) (result.Set, []match.IgnoreFilter, error) {
|
||||
cpeSet, ignores, err := internal.FindResultsByCPEs(provider, searchPkg, m.Type())
|
||||
if err != nil {
|
||||
if !errors.Is(err, internal.ErrEmptyCPEMatch) {
|
||||
return nil, nil, err
|
||||
}
|
||||
return result.Set{}, ignores, nil
|
||||
}
|
||||
|
||||
if len(cpeSet) == 0 {
|
||||
return cpeSet, ignores, nil
|
||||
}
|
||||
|
||||
if searchPkg.Name != catalogPkg.Name {
|
||||
cpeSet = markIndirect(cpeSet, catalogPkg)
|
||||
}
|
||||
|
||||
if searchPkg.Distro == nil {
|
||||
// no distro feed, so no authority to defer to: NVD's fix is the only information there is
|
||||
return cpeSet, ignores, nil
|
||||
}
|
||||
|
||||
// NVD cannot know when the distro will ship a fix, and an inferred NVD fix is an upstream release
|
||||
// number rather than an apk version, so no record leaves here carrying one (see #2162)
|
||||
return stripFixState(cpeSet), ignores, nil
|
||||
}
|
||||
|
||||
// markIndirect records results against the SBOM (catalog) package rather than the upstream package
|
||||
// they were searched with, and marks their evidence indirect -- the result.Set equivalent of
|
||||
// match.ConvertToIndirectMatches.
|
||||
//
|
||||
// Both halves are needed. The match type is otherwise derived by comparing the searched package name
|
||||
// against the cataloged one, which cannot tell the two apart when a package is its own origin, so the
|
||||
// upstream pass says so explicitly.
|
||||
func markIndirect(s result.Set, catalogPkg pkg.Package) result.Set {
|
||||
return s.Map(func(r *result.Result) {
|
||||
r.Package = &catalogPkg
|
||||
|
||||
// replace the slice rather than mutate in place: Map shallow-copies results, so the Details
|
||||
// backing array is shared with the source set
|
||||
details := make([]match.Detail, len(r.Details))
|
||||
for i, d := range r.Details {
|
||||
if d.Type == match.ExactDirectMatch {
|
||||
d.Type = match.ExactIndirectMatch
|
||||
}
|
||||
details[i] = d
|
||||
}
|
||||
r.Details = details
|
||||
})
|
||||
}
|
||||
|
||||
func stripFixState(s result.Set) result.Set {
|
||||
return s.Map(func(r *result.Result) {
|
||||
vulns := make([]vulnerability.Vulnerability, len(r.Vulnerabilities))
|
||||
for i, v := range r.Vulnerabilities {
|
||||
if len(v.Fix.Versions) > 0 {
|
||||
v.Fix = vulnerability.Fix{State: vulnerability.FixStateUnknown}
|
||||
}
|
||||
vulns[i] = v
|
||||
}
|
||||
r.Vulnerabilities = vulns
|
||||
})
|
||||
}
|
||||
@@ -470,6 +470,168 @@ func TestMatcherApk_NvdCanceledByUpstreamSecdbNak(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// === upstream (NVD) suppression by alias ===
|
||||
//
|
||||
// The chainguard-rolling fixture pairs a distro advisory with its upstream NVD
|
||||
// record so the alias-aware reconciliation can be exercised end to end:
|
||||
// - distro: CGA-22hv-wp9q-4779 (Chainguard langfuse-3-worker, arch x86_64,
|
||||
// fixed 3.153.0-r0) carries upstream aliases CVE-2026-24398 and
|
||||
// GHSA-r354-f388-2fhh.
|
||||
// - NVD: CVE-2026-24398 flags cpe:2.3:a:langfuse:langfuse in [3.0.0, 4.0.0).
|
||||
//
|
||||
// Because the distro feed is authoritative and the two records share identity
|
||||
// via the CGA's alias set, the NVD CVE-2026-24398 CPE finding must never surface
|
||||
// on its own - it is either dropped (distro considers it fixed) or deduped into
|
||||
// the authoritative distro match (distro considers it vulnerable).
|
||||
|
||||
// TestMatcherApk_UpstreamCveSuppressedByFixedAlias langfuse-3-worker is
|
||||
// past the CGA fix (3.153.1-r0 > 3.153.0-r0), so the distro feed reports
|
||||
// the vulnerability fixed. NVD still flags CVE-2026-24398 for the langfuse
|
||||
// CPE at this version, since CVE-2026-24398 is an alias of the fixed CGA,
|
||||
// it is neither "not in feed" nor "still vulnerable" and gets dropped.
|
||||
func TestMatcherApk_UpstreamCveSuppressedByFixedAlias(t *testing.T) {
|
||||
dbtest.DBs(t, "chainguard-rolling").Run(func(t *testing.T, db *dbtest.DB) {
|
||||
matcher := Matcher{}
|
||||
pkgID := pkg.ID("langfuse-past-fix-with-cpe")
|
||||
p := dbtest.NewPackage("langfuse-3-worker", "3.153.1-r0", syftPkg.ApkPkg).
|
||||
WithID(pkgID).
|
||||
WithDistro(dbtest.ChainguardRolling).
|
||||
WithArchitecture("x86_64").
|
||||
WithCPE("cpe:2.3:a:langfuse:langfuse:3.153.1-r0:*:*:*:*:*:*:*").
|
||||
Build()
|
||||
|
||||
findings := db.Match(t, &matcher, p)
|
||||
|
||||
// the NVD CVE alias is suppressed by the fixed CGA distro record - it
|
||||
// must not surface as its own match.
|
||||
findings.DoesNotHaveAnyVulnerabilities("CVE-2026-24398")
|
||||
|
||||
// the distro fixed path still emits one DistroPackageFixed ignore per
|
||||
// identifier (the CGA id plus its CVE/GHSA aliases).
|
||||
findings.Ignores().
|
||||
SelectRelatedPackageIgnores(reasonDistroPackageFixed,
|
||||
"CGA-22hv-wp9q-4779",
|
||||
"CVE-2026-24398",
|
||||
"GHSA-r354-f388-2fhh").
|
||||
ForPackage(pkgID).
|
||||
WithRelationshipType(artifact.OwnershipByFileOverlapRelationship)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMatcherApk_UpstreamCveDedupedByVulnerableAlias langfuse-3-worker is below the CGA fix
|
||||
// (3.152.0-r0 < 3.153.0-r0), so both the distro CGA record and the NVD CVE-2026-24398 CPE
|
||||
// record consider it vulnerable. The two share identity via the CGA's alias set, so the
|
||||
// upstream NVD finding is deduped into the authoritative distro match rather than surfacing
|
||||
// separately - only the CGA distro match is returned.
|
||||
func TestMatcherApk_UpstreamCveDedupedByVulnerableAlias(t *testing.T) {
|
||||
dbtest.DBs(t, "chainguard-rolling").Run(func(t *testing.T, db *dbtest.DB) {
|
||||
matcher := Matcher{}
|
||||
p := dbtest.NewPackage("langfuse-3-worker", "3.152.0-r0", syftPkg.ApkPkg).
|
||||
WithDistro(dbtest.ChainguardRolling).
|
||||
WithArchitecture("x86_64").
|
||||
WithCPE("cpe:2.3:a:langfuse:langfuse:3.152.0-r0:*:*:*:*:*:*:*").
|
||||
Build()
|
||||
|
||||
findings := db.Match(t, &matcher, p)
|
||||
|
||||
// only the authoritative distro record surfaces...
|
||||
findings.SelectMatch("CGA-22hv-wp9q-4779").
|
||||
SelectDetailByType(match.ExactDirectMatch).
|
||||
AsDistroSearch()
|
||||
|
||||
// ...the aliased upstream NVD CVE is deduped away.
|
||||
findings.DoesNotHaveAnyVulnerabilities("CVE-2026-24398")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMatcherApk_UpstreamCveDedupedByIntroducedZeroAlias covers an unfixed distro advisory:
|
||||
// CGA-n0f1-x000-0000 lists grafana with a single "introduced: 0" event and no fixed event, so the
|
||||
// distro feed considers grafana vulnerable at every version (a package handle with no ranges is
|
||||
// implicitly vulnerable to all versions). NVD flags the aliased CVE-2026-30000 for the grafana CPE
|
||||
// at this version, but the two share identity via the CGA's alias set, so the upstream NVD finding
|
||||
// is deduped into the authoritative distro match - only the CGA distro match is returned.
|
||||
func TestMatcherApk_UpstreamCveDedupedByIntroducedZeroAlias(t *testing.T) {
|
||||
dbtest.DBs(t, "chainguard-rolling").Run(func(t *testing.T, db *dbtest.DB) {
|
||||
matcher := Matcher{}
|
||||
pkgID := pkg.ID("grafana-introduced-zero")
|
||||
p := dbtest.NewPackage("grafana", "9.0.0-r0", syftPkg.ApkPkg).
|
||||
WithID(pkgID).
|
||||
WithDistro(dbtest.ChainguardRolling).
|
||||
WithArchitecture("x86_64").
|
||||
WithCPE("cpe:2.3:a:grafana:grafana:9.0.0-r0:*:*:*:*:*:*:*").
|
||||
Build()
|
||||
|
||||
findings := db.Match(t, &matcher, p)
|
||||
|
||||
// the unfixed distro advisory surfaces...
|
||||
findings.SelectMatch("CGA-n0f1-x000-0000").
|
||||
SelectDetailByType(match.ExactDirectMatch).
|
||||
AsDistroSearch()
|
||||
|
||||
// ...the aliased upstream NVD CVE is deduped away.
|
||||
findings.DoesNotHaveAnyVulnerabilities("CVE-2026-30000")
|
||||
|
||||
// the ranges-less advisory must NOT be treated as an apk NAK: it is affected at every
|
||||
// version, not "not affected". The "< 0" NAK filter no longer matches a record that
|
||||
// carries no "< 0" constraint, so no Explicit APK NAK ignores are emitted.
|
||||
findings.Ignores().IsEmpty()
|
||||
})
|
||||
}
|
||||
|
||||
// TestMatcherApk_SelfUpstreamIsRecordedAsIndirect covers an apk whose origin package is itself, which
|
||||
// is common (an apk that is its own origin still lists one). The distro feed is searched twice for
|
||||
// it, once directly and once as its own upstream, and the upstream pass must record what it finds as
|
||||
// an indirect match.
|
||||
//
|
||||
// The match-type is otherwise derived by comparing the searched name against the cataloged one, and
|
||||
// here they are equal, so nothing downstream can tell the two passes apart. The upstream pass has to
|
||||
// say so explicitly, which is why the package override and the detail-type rewrite belong together.
|
||||
func TestMatcherApk_SelfUpstreamIsRecordedAsIndirect(t *testing.T) {
|
||||
dbtest.DBs(t, "alpine318").SelectOnly("3.18/CVE-2024-0727").Run(func(t *testing.T, db *dbtest.DB) {
|
||||
matcher := Matcher{}
|
||||
p := dbtest.NewPackage("openssl", "3.1.4-r0", syftPkg.ApkPkg).
|
||||
WithDistro(dbtest.Alpine318).
|
||||
WithUpstream("openssl", "3.1.4-r0").
|
||||
Build()
|
||||
|
||||
finding := db.Match(t, &matcher, p).SelectMatch("CVE-2024-0727")
|
||||
|
||||
// the direct search finds it...
|
||||
finding.SelectDetailByType(match.ExactDirectMatch).AsDistroSearch()
|
||||
// ...and so does the pass over its own upstream, which must not look direct as well
|
||||
finding.SelectDetailByType(match.ExactIndirectMatch).AsDistroSearch()
|
||||
})
|
||||
}
|
||||
|
||||
// TestMatcherApk_NoDistroKeepsNvdFix covers an apk package with no distro, which happens for
|
||||
// SBOM-only input. apk clears NVD-inferred fix data because the distro feed is the authority on when
|
||||
// an apk ships (#2162) -- but with no distro there is no feed and no authority to defer to, so NVD's
|
||||
// fix is the only information available and dropping it just loses data.
|
||||
func TestMatcherApk_NoDistroKeepsNvdFix(t *testing.T) {
|
||||
dbtest.DBs(t, "alpine318").Run(func(t *testing.T, db *dbtest.DB) {
|
||||
matcher := Matcher{}
|
||||
pkgID := pkg.ID("openssl-no-distro")
|
||||
p := dbtest.NewPackage("openssl", "1.0.1f-r0", syftPkg.ApkPkg).
|
||||
WithID(pkgID).
|
||||
// no distro, deliberately
|
||||
WithCPE("cpe:2.3:a:openssl:openssl:1.0.1f-r0:*:*:*:*:*:*:*").
|
||||
Build()
|
||||
|
||||
findings := db.Match(t, &matcher, p)
|
||||
|
||||
finding := findings.SelectMatch("CVE-2014-0224")
|
||||
finding.SelectDetailByType(match.CPEMatch).AsCPESearch()
|
||||
finding.HasFix(vulnerability.FixStateFixed, "0.9.8za", "1.0.0m", "1.0.1h")
|
||||
|
||||
// this version is outside NVD's range for CVE-2024-0727, so the CPE search reports it as an
|
||||
// ignore rather than a match
|
||||
findings.Ignores().
|
||||
SelectRelatedPackageIgnore(reasonCPENotVulnerable, "CVE-2024-0727").
|
||||
ForPackage(pkgID).
|
||||
WithRelationshipType(artifact.OwnershipByFileOverlapRelationship)
|
||||
})
|
||||
}
|
||||
|
||||
// === architecture qualifier filtering (CG OSV records) ===
|
||||
//
|
||||
// The chainguard-rolling fixture has CGA-22hv-wp9q-4779, which emits
|
||||
@@ -553,6 +715,81 @@ func TestMatcherApk_ArchFilter_NoMatchWhenArchDisagrees(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestMatcherApk_NvdOnlyMatchGetsFixStripped pins the rule from #2162: grype-db infers
|
||||
// fixes from NVD end-of-range data and apk discards them, because an NVD "fix" is an
|
||||
// upstream release number and only the distro feed knows when an apk ships. Here NVD's
|
||||
// [3.0.0, 4.0.0) range for CVE-2026-24398 becomes fix 4.0.0. main (ffbca561) reports
|
||||
// this same match with fix=[] state=unknown.
|
||||
//
|
||||
// The port of that rule is faithful. main's cpeMatchesWithoutSecDBFixes had the same two
|
||||
// exits cpeDisclosures does: "no feed entry for this ID" strips, "feed says vulnerable at
|
||||
// this version" does not. What changed is identity. main keyed that lookup on the exact
|
||||
// vulnerability ID, so a CGA-keyed row could never match a CVE-keyed NVD record and every
|
||||
// NVD CVE on an OSV feed took the stripping exit. Alias-aware identity resolves the CGA to
|
||||
// this CVE, reaching the non-stripping exit for the first time.
|
||||
//
|
||||
// So the question this raises is not "what did the port break" but what that second exit
|
||||
// should mean now that "the feed knows this vulnerability" can be true by alias: #2162's
|
||||
// argument does not care which exit a record arrived on.
|
||||
func TestMatcherApk_NvdOnlyMatchGetsFixStripped(t *testing.T) {
|
||||
dbtest.DBs(t, "chainguard-rolling").Run(func(t *testing.T, db *dbtest.DB) {
|
||||
matcher := Matcher{}
|
||||
p := dbtest.NewPackage("langfuse-3-worker", "3.152.0-r0", syftPkg.ApkPkg).
|
||||
WithDistro(dbtest.ChainguardRolling).
|
||||
WithArchitecture("aarch64"). // mismatch: the Chainguard row is x86_64... we just want to test NVD-only
|
||||
WithCPE("cpe:2.3:a:langfuse:langfuse:3.152.0-r0:*:*:*:*:*:*:*").
|
||||
Build()
|
||||
|
||||
findings := db.Match(t, &matcher, p)
|
||||
|
||||
// no distro match: the x86_64 row does not apply to this package
|
||||
findings.DoesNotHaveAnyVulnerabilities("CGA-22hv-wp9q-4779")
|
||||
|
||||
// so the NVD record is the only match, and it is the [3.0.0, 4.0.0) one
|
||||
finding := findings.SelectMatch("CVE-2026-24398").InNamespace("nvd:cpe")
|
||||
finding.SelectDetailByCPE(
|
||||
"cpe:2.3:a:langfuse:langfuse:3.152.0:*:*:*:*:*:*:*",
|
||||
">= 3.0.0, < 4.0.0 (unknown)",
|
||||
).HasMatchType(match.CPEMatch)
|
||||
|
||||
// being NVD-only, its inferred fix must be stripped
|
||||
finding.HasNoFixVersions()
|
||||
finding.HasFix(vulnerability.FixStateUnknown)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMatcherApk_ArchFilter_WrongArchRowMustNotSuppressNvdMatch is the arch mismatch again, but
|
||||
// with the package PAST the CGA's fix version rather than below it.
|
||||
//
|
||||
// The only Chainguard row for this name is x86_64, so on aarch64 nothing in the distro feed applies
|
||||
// and the aliased NVD CVE-2026-24398 (langfuse [3.0.0, 4.0.0), and 3.153.1 is inside it) is the only
|
||||
// thing that has an opinion. It should be reported.
|
||||
//
|
||||
// It is not. feedSet queries the feed without OnlyQualifiedPackages, so it sees the x86_64 row that
|
||||
// the distro path correctly filtered out. That row aliases this CVE, which removes it from notInFeed,
|
||||
// and the row is fixed at this version, which keeps it out of feedVulnerable and so out of
|
||||
// stillVulnerable too. The record falls out of both exits and is dropped: a finding is lost because
|
||||
// of a row for another architecture.
|
||||
func TestMatcherApk_ArchFilter_WrongArchRowMustNotSuppressNvdMatch(t *testing.T) {
|
||||
dbtest.DBs(t, "chainguard-rolling").Run(func(t *testing.T, db *dbtest.DB) {
|
||||
matcher := Matcher{}
|
||||
p := dbtest.NewPackage("langfuse-3-worker", "3.153.1-r0", syftPkg.ApkPkg).
|
||||
WithDistro(dbtest.ChainguardRolling).
|
||||
WithArchitecture("aarch64"). // the Chainguard row is x86_64, so it says nothing here
|
||||
WithCPE("cpe:2.3:a:langfuse:langfuse:3.153.1-r0:*:*:*:*:*:*:*").
|
||||
Build()
|
||||
|
||||
findings := db.Match(t, &matcher, p)
|
||||
|
||||
// the x86_64 row does not apply, so it must not suppress anything either
|
||||
findings.DoesNotHaveAnyVulnerabilities("CGA-22hv-wp9q-4779")
|
||||
findings.SelectMatch("CVE-2026-24398").
|
||||
InNamespace("nvd:cpe").
|
||||
SelectDetailByType(match.CPEMatch).
|
||||
AsCPESearch()
|
||||
})
|
||||
}
|
||||
|
||||
// TestMatcherApk_ArchFilter_InertWhenPackageHasNoArch documents the
|
||||
// passthrough branch of Satisfied(p): if p.Arch == "" the qualifier is
|
||||
// treated as inert, preserving pre-change behavior for input paths that
|
||||
|
||||
@@ -3,6 +3,9 @@ package apk
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/grype/grype/match"
|
||||
"github.com/anchore/grype/grype/pkg"
|
||||
"github.com/anchore/grype/internal/dbtest"
|
||||
@@ -21,17 +24,19 @@ import (
|
||||
// CVEs. Real-world rootio scans therefore rely on NVD CPE matching for
|
||||
// most disclosures. The apk matcher surfaces:
|
||||
//
|
||||
// - the NVD CPE match in its `matches` output (downstream pipeline
|
||||
// then drops it when the matcher also emits a covering IgnoreFilter);
|
||||
// - the NVD CPE match, but only where the distro feed has no opinion.
|
||||
// A rootio NAK covering the same vulnerability by alias means the
|
||||
// vendor has answered it, so no match is reported at all;
|
||||
// - the rootio NAK as DistroPackageFixed IgnoreFilters that
|
||||
// alias-unwind into the rootio record ID + the upstream CVE.
|
||||
// alias-unwind into the rootio record ID + the upstream CVE. Those
|
||||
// are for packages that overlap this one by file, which is the only
|
||||
// thing an IgnoreRelatedPackage can suppress — never the package it
|
||||
// is keyed to.
|
||||
//
|
||||
// The tests assert both signals: the raw match coming out of the
|
||||
// matcher, and the alias-unwound ignore filters that downstream code
|
||||
// uses to suppress it. Cases without a disclosure to suppress (socat,
|
||||
// which has no NVD CPE entry) still emit the NAK ignore — the matcher
|
||||
// reports NAKs from the unaffected set whether or not a disclosure
|
||||
// exists in the same scan.
|
||||
// The tests assert both signals. Cases without a disclosure to suppress
|
||||
// (socat, which has no NVD CPE entry) still emit the NAK ignore — the
|
||||
// matcher reports NAKs from the unaffected set whether or not a
|
||||
// disclosure exists in the same scan.
|
||||
//
|
||||
// CVE coverage:
|
||||
//
|
||||
@@ -40,6 +45,43 @@ import (
|
||||
// CVE-2024-10524 / wget: rootio + nvd (rootio fix 1.21.4-r00071, nvd vEnd 1.25.0)
|
||||
// CVE-2024-54661 / socat: rootio only (rootio fix 1.7.4.4-r10071)
|
||||
// CVE-2024-0727 / openssl: nvd + alpine (alpine fix 3.1.4-r5)
|
||||
//
|
||||
// TestMatcherApk_RootIO_NakIgnoreMustSuppressItsOwnMatch pins what the comment above claims: that
|
||||
// the NVD CPE match on a rootio-NAK'd package is fine to emit because "downstream pipeline then drops
|
||||
// it when the matcher also emits a covering IgnoreFilter".
|
||||
//
|
||||
// It does not drop it. The ignore is an IgnoreRelatedPackage keyed to the very package the match is
|
||||
// on, and IgnoreRelatedPackage.IgnoreMatch only fires when the ignore's package ID appears in the
|
||||
// match package's RelatedPackages -- which never contains the package itself (pkg.Package.go skips
|
||||
// `ownerPackage == p` when building the overlap map). So the ignore covers every package that
|
||||
// overlaps rootio-libuv by file, and never rootio-libuv.
|
||||
//
|
||||
// feedSet is why the matcher has to lean on that ignore at all: unlike FindResultsByDistro it runs no
|
||||
// ForUnaffected pass, so the rootio NAK is not in the feed, cpeSet.Remove(feed) does not drop the
|
||||
// aliased CVE, and the match is emitted for something the vendor explicitly said is not affected.
|
||||
func TestMatcherApk_RootIO_NakIgnoreMustSuppressItsOwnMatch(t *testing.T) {
|
||||
dbtest.DBs(t, "rootio-alpine-318").Run(func(t *testing.T, db *dbtest.DB) {
|
||||
matcher := Matcher{}
|
||||
p := dbtest.NewPackage("rootio-libuv", "1.44.2-r20071", syftPkg.ApkPkg).
|
||||
WithID(pkg.ID("rootio-libuv-at-fix")).
|
||||
WithDistro(dbtest.Alpine318).
|
||||
WithCPE("cpe:2.3:a:libuv:libuv:1.44.2:*:*:*:*:*:*:*").
|
||||
Build()
|
||||
|
||||
matches, ignores, err := matcher.Match(db, p)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, ignores, "expected the rootio NAK to produce ignore filters")
|
||||
|
||||
// mirror what the pipeline does with a matcher's ignore filters
|
||||
remaining, _ := match.ApplyIgnoreFilters(matches, ignores...)
|
||||
|
||||
for _, m := range remaining {
|
||||
assert.NotEqual(t, "CVE-2024-24806", m.Vulnerability.ID,
|
||||
"rootio explicitly NAK'd this package for this CVE, but it survives its own ignore")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatcherApk_RootIO(t *testing.T) {
|
||||
dbtest.DBs(t, "rootio-alpine-318").Run(func(t *testing.T, db *dbtest.DB) {
|
||||
matcher := Matcher{}
|
||||
@@ -48,7 +90,7 @@ func TestMatcherApk_RootIO(t *testing.T) {
|
||||
// 1.48.0) — match surfaces. The rootio NAK matches alias
|
||||
// CVE-2024-24806 and appears as the DistroPackageFixed ignore
|
||||
// pair that downstream code uses to drop the match.
|
||||
t.Run("rootio-libuv at rootio fix: CPE match + NAK ignore", func(t *testing.T) {
|
||||
t.Run("rootio-libuv at rootio fix: no match, NAK ignore only", func(t *testing.T) {
|
||||
pkgID := pkg.ID("rootio-libuv-at-fix")
|
||||
p := dbtest.NewPackage("rootio-libuv", "1.44.2-r20071", syftPkg.ApkPkg).
|
||||
WithID(pkgID).
|
||||
@@ -57,9 +99,9 @@ func TestMatcherApk_RootIO(t *testing.T) {
|
||||
Build()
|
||||
|
||||
findings := db.Match(t, &matcher, p)
|
||||
findings.SelectMatch("CVE-2024-24806").
|
||||
SelectDetailByType(match.CPEMatch).
|
||||
AsCPESearch()
|
||||
// rootio NAK'd this vulnerability for this package, and the NAK aliases the CVE, so the
|
||||
// NVD CPE record is answered and never becomes a match
|
||||
findings.DoesNotHaveAnyVulnerabilities("CVE-2024-24806")
|
||||
findings.Ignores().
|
||||
SelectRelatedPackageIgnores("DistroPackageFixed",
|
||||
"ROOT-OS-ALPINE-318-CVE-2024-24806",
|
||||
@@ -85,7 +127,7 @@ func TestMatcherApk_RootIO(t *testing.T) {
|
||||
|
||||
// rootio-nghttp2 at rootio fix: same shape as libuv, different
|
||||
// package and CVE.
|
||||
t.Run("rootio-nghttp2 at rootio fix: CPE match + NAK ignore", func(t *testing.T) {
|
||||
t.Run("rootio-nghttp2 at rootio fix: no match, NAK ignore only", func(t *testing.T) {
|
||||
pkgID := pkg.ID("rootio-nghttp2-at-fix")
|
||||
p := dbtest.NewPackage("rootio-nghttp2", "1.57.0-r00072", syftPkg.ApkPkg).
|
||||
WithID(pkgID).
|
||||
@@ -94,9 +136,9 @@ func TestMatcherApk_RootIO(t *testing.T) {
|
||||
Build()
|
||||
|
||||
findings := db.Match(t, &matcher, p)
|
||||
findings.SelectMatch("CVE-2024-28182").
|
||||
SelectDetailByType(match.CPEMatch).
|
||||
AsCPESearch()
|
||||
// rootio NAK'd this vulnerability for this package, and the NAK aliases the CVE, so the
|
||||
// NVD CPE record is answered and never becomes a match
|
||||
findings.DoesNotHaveAnyVulnerabilities("CVE-2024-28182")
|
||||
findings.Ignores().
|
||||
SelectRelatedPackageIgnores("DistroPackageFixed",
|
||||
"ROOT-OS-ALPINE-318-CVE-2024-28182",
|
||||
@@ -119,7 +161,7 @@ func TestMatcherApk_RootIO(t *testing.T) {
|
||||
})
|
||||
|
||||
// rootio-wget at rootio fix.
|
||||
t.Run("rootio-wget at rootio fix: CPE match + NAK ignore", func(t *testing.T) {
|
||||
t.Run("rootio-wget at rootio fix: no match, NAK ignore only", func(t *testing.T) {
|
||||
pkgID := pkg.ID("rootio-wget-at-fix")
|
||||
p := dbtest.NewPackage("rootio-wget", "1.21.4-r00071", syftPkg.ApkPkg).
|
||||
WithID(pkgID).
|
||||
@@ -128,9 +170,9 @@ func TestMatcherApk_RootIO(t *testing.T) {
|
||||
Build()
|
||||
|
||||
findings := db.Match(t, &matcher, p)
|
||||
findings.SelectMatch("CVE-2024-10524").
|
||||
SelectDetailByType(match.CPEMatch).
|
||||
AsCPESearch()
|
||||
// rootio NAK'd this vulnerability for this package, and the NAK aliases the CVE, so the
|
||||
// NVD CPE record is answered and never becomes a match
|
||||
findings.DoesNotHaveAnyVulnerabilities("CVE-2024-10524")
|
||||
findings.Ignores().
|
||||
SelectRelatedPackageIgnores("DistroPackageFixed",
|
||||
"ROOT-OS-ALPINE-318-CVE-2024-10524",
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"identifier": "CGA-n0f1-x000-0000",
|
||||
"schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/osv/schema-1.6.0.json",
|
||||
"item": {
|
||||
"schema_version": "1.7.0",
|
||||
"id": "CGA-n0f1-x000-0000",
|
||||
"published": "2026-02-24T13:07:08Z",
|
||||
"modified": "2026-02-24T13:07:08Z",
|
||||
"summary": "Grafana: unfixed information disclosure (affected at all versions)",
|
||||
"severity": [
|
||||
{
|
||||
"type": "CVSS_V3",
|
||||
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N"
|
||||
}
|
||||
],
|
||||
"upstream": [
|
||||
"CVE-2026-30000",
|
||||
"GHSA-n0f1-n0f1-n0f1"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"type": "ADVISORY",
|
||||
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-30000"
|
||||
}
|
||||
],
|
||||
"affected": [
|
||||
{
|
||||
"package": {
|
||||
"ecosystem": "Chainguard",
|
||||
"name": "grafana",
|
||||
"purl": "pkg:apk/chainguard/grafana?arch=x86_64"
|
||||
},
|
||||
"ranges": [
|
||||
{
|
||||
"type": "ECOSYSTEM",
|
||||
"events": [
|
||||
{
|
||||
"introduced": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"provider": "nvd",
|
||||
"version": 2,
|
||||
"processor": "vunnel@manual",
|
||||
"schema": {
|
||||
"version": "1.0.3",
|
||||
"url": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/provider-workspace-state/schema-1.0.3.json"
|
||||
},
|
||||
"urls": [
|
||||
"https://services.nvd.nist.gov/rest/json/cves/2.0"
|
||||
],
|
||||
"timestamp": "2026-06-05T00:00:00Z",
|
||||
"listing": {
|
||||
"path": "results/listing.xxh64",
|
||||
"digest": "0000000000000000",
|
||||
"algorithm": "xxh64"
|
||||
},
|
||||
"store": "flat-file"
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"identifier": "2026/cve-2026-24398",
|
||||
"item": {
|
||||
"cve": {
|
||||
"configurations": [
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"cpeMatch": [
|
||||
{
|
||||
"criteria": "cpe:2.3:a:langfuse:langfuse:*:*:*:*:*:*:*:*",
|
||||
"matchCriteriaId": "11111111-1111-1111-1111-111111111111",
|
||||
"versionEndExcluding": "4.0.0",
|
||||
"versionStartIncluding": "3.0.0",
|
||||
"vulnerable": true
|
||||
}
|
||||
],
|
||||
"negate": false,
|
||||
"operator": "OR"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cveTags": [],
|
||||
"descriptions": [
|
||||
{
|
||||
"lang": "en",
|
||||
"value": "Langfuse discloses session information via insecure session handling. This is the upstream NVD record aliased by CGA-22hv-wp9q-4779."
|
||||
}
|
||||
],
|
||||
"id": "CVE-2026-24398",
|
||||
"lastModified": "2026-03-01T00:00:00.000",
|
||||
"metrics": {
|
||||
"cvssMetricV31": [
|
||||
{
|
||||
"cvssData": {
|
||||
"attackComplexity": "LOW",
|
||||
"attackVector": "NETWORK",
|
||||
"availabilityImpact": "NONE",
|
||||
"baseScore": 6.5,
|
||||
"baseSeverity": "MEDIUM",
|
||||
"confidentialityImpact": "HIGH",
|
||||
"integrityImpact": "NONE",
|
||||
"privilegesRequired": "LOW",
|
||||
"scope": "UNCHANGED",
|
||||
"userInteraction": "NONE",
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
|
||||
"version": "3.1"
|
||||
},
|
||||
"exploitabilityScore": 2.8,
|
||||
"impactScore": 3.6,
|
||||
"source": "nvd@nist.gov",
|
||||
"type": "Primary"
|
||||
}
|
||||
]
|
||||
},
|
||||
"published": "2026-02-24T13:07:08.000",
|
||||
"references": [
|
||||
{
|
||||
"source": "nvd@nist.gov",
|
||||
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-24398"
|
||||
}
|
||||
],
|
||||
"sourceIdentifier": "nvd@nist.gov",
|
||||
"vulnStatus": "Analyzed",
|
||||
"weaknesses": [
|
||||
{
|
||||
"description": [
|
||||
{
|
||||
"lang": "en",
|
||||
"value": "CWE-200"
|
||||
}
|
||||
],
|
||||
"source": "nvd@nist.gov",
|
||||
"type": "Primary"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/nvd/schema-1.0.1.json"
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"identifier": "2026/cve-2026-30000",
|
||||
"item": {
|
||||
"cve": {
|
||||
"configurations": [
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"cpeMatch": [
|
||||
{
|
||||
"criteria": "cpe:2.3:a:grafana:grafana:*:*:*:*:*:*:*:*",
|
||||
"matchCriteriaId": "22222222-2222-2222-2222-222222222222",
|
||||
"versionEndExcluding": "100.0.0",
|
||||
"versionStartIncluding": "1.0.0",
|
||||
"vulnerable": true
|
||||
}
|
||||
],
|
||||
"negate": false,
|
||||
"operator": "OR"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cveTags": [],
|
||||
"descriptions": [
|
||||
{
|
||||
"lang": "en",
|
||||
"value": "Grafana discloses session information. This is the upstream NVD record aliased by CGA-n0f1-x000-0000, whose distro record carries no fixed version."
|
||||
}
|
||||
],
|
||||
"id": "CVE-2026-30000",
|
||||
"lastModified": "2026-03-01T00:00:00.000",
|
||||
"metrics": {
|
||||
"cvssMetricV31": [
|
||||
{
|
||||
"cvssData": {
|
||||
"attackComplexity": "LOW",
|
||||
"attackVector": "NETWORK",
|
||||
"availabilityImpact": "NONE",
|
||||
"baseScore": 6.5,
|
||||
"baseSeverity": "MEDIUM",
|
||||
"confidentialityImpact": "HIGH",
|
||||
"integrityImpact": "NONE",
|
||||
"privilegesRequired": "LOW",
|
||||
"scope": "UNCHANGED",
|
||||
"userInteraction": "NONE",
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
|
||||
"version": "3.1"
|
||||
},
|
||||
"exploitabilityScore": 2.8,
|
||||
"impactScore": 3.6,
|
||||
"source": "nvd@nist.gov",
|
||||
"type": "Primary"
|
||||
}
|
||||
]
|
||||
},
|
||||
"published": "2026-02-24T13:07:08.000",
|
||||
"references": [
|
||||
{
|
||||
"source": "nvd@nist.gov",
|
||||
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-30000"
|
||||
}
|
||||
],
|
||||
"sourceIdentifier": "nvd@nist.gov",
|
||||
"vulnStatus": "Analyzed",
|
||||
"weaknesses": [
|
||||
{
|
||||
"description": [
|
||||
{
|
||||
"lang": "en",
|
||||
"value": "CWE-200"
|
||||
}
|
||||
],
|
||||
"source": "nvd@nist.gov",
|
||||
"type": "Primary"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/nvd/schema-1.0.1.json"
|
||||
}
|
||||
@@ -242,7 +242,6 @@ func finalizeMatchDetails(processedResult *result.Result, originalDetails match.
|
||||
|
||||
// keep details around only if we have vulnerabilities they describe
|
||||
processedResult.Details = append(processedResult.Details, originalDetails...)
|
||||
processedResult.Details = result.NewMatchDetailsSet(processedResult.Details...).ToSlice()
|
||||
|
||||
// patch the version in the details if it is missing
|
||||
for idx := range processedResult.Details {
|
||||
|
||||
@@ -75,7 +75,8 @@ func TestResolveESMDisclosures(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}},
|
||||
// the disclosure and the advisory each contribute a detail, collapsed by match.Matches.Add
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}, {Type: match.ExactDirectMatch}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -200,7 +201,7 @@ func TestResolveESMDisclosures(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}, {Type: match.ExactDirectMatch}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -355,7 +356,7 @@ func TestResolveESMDisclosures(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}, {Type: match.ExactDirectMatch}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -542,7 +543,7 @@ func TestResolveESMDisclosures(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}, {Type: match.ExactDirectMatch}},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+24
-179
@@ -3,62 +3,55 @@ package internal
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/facebookincubator/nvdtools/wfn"
|
||||
|
||||
"github.com/anchore/grype/grype/match"
|
||||
"github.com/anchore/grype/grype/matcher/internal/cpeversion"
|
||||
"github.com/anchore/grype/grype/matcher/internal/result"
|
||||
"github.com/anchore/grype/grype/pkg"
|
||||
"github.com/anchore/grype/grype/search"
|
||||
"github.com/anchore/grype/grype/version"
|
||||
"github.com/anchore/grype/grype/vulnerability"
|
||||
"github.com/anchore/grype/internal/log"
|
||||
"github.com/anchore/syft/syft/cpe"
|
||||
syftPkg "github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
func alpineCPEComparableVersion(version string) string {
|
||||
// clean the alpine package version so that it compares correctly with the CPE version comparison logic
|
||||
// alpine versions are suffixed with -r{buildindex}; however, if left intact CPE comparison logic will
|
||||
// incorrectly treat these as a pre-release. In actuality, we just want to treat 1.2.3-r21 as equivalent to
|
||||
// 1.2.3 for purposes of CPE-based matching since the alpine fix should filter out any cases where a later
|
||||
// build fixes something that was vulnerable in 1.2.3
|
||||
components := strings.Split(version, "-r")
|
||||
cpeComparableVersion := version
|
||||
|
||||
if len(components) == 2 {
|
||||
cpeComparableVersion = components[0]
|
||||
}
|
||||
|
||||
return cpeComparableVersion
|
||||
}
|
||||
|
||||
var ErrEmptyCPEMatch = errors.New("attempted CPE match against package with no CPEs")
|
||||
|
||||
// MatchPackageByCPEs retrieves all vulnerabilities that match any of the provided package's CPEs
|
||||
// MatchPackageByCPEs retrieves all vulnerabilities that match any of the provided package's CPEs,
|
||||
// returned as a flat slice of matches. It is a thin wrapper over FindResultsByCPEs for callers that
|
||||
// work in []match.Match rather than result.Set.
|
||||
func MatchPackageByCPEs(vulnProvider vulnerability.Provider, p pkg.Package, upstreamMatcher match.MatcherType) ([]match.Match, []match.IgnoreFilter, error) {
|
||||
results, ignore, err := FindResultsByCPEs(vulnProvider, p, upstreamMatcher)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return results.ToMatches(), ignore, nil
|
||||
}
|
||||
|
||||
// FindResultsByCPEs retrieves all vulnerabilities that match any of the provided package's CPEs as a
|
||||
// result.Set, so callers can reconcile them against other sources by vulnerability identity. Records
|
||||
// the CPE search finds but whose version is not affected are returned as "CPE not vulnerable" ignores.
|
||||
//
|
||||
//nolint:funlen
|
||||
func MatchPackageByCPEs(vulnProvider vulnerability.Provider, p pkg.Package, upstreamMatcher match.MatcherType) ([]match.Match, []match.IgnoreFilter, error) {
|
||||
func FindResultsByCPEs(vulnProvider vulnerability.Provider, p pkg.Package, upstreamMatcher match.MatcherType) (result.Set, []match.IgnoreFilter, error) {
|
||||
provider := result.NewProvider(vulnProvider, p, upstreamMatcher)
|
||||
|
||||
affected := result.Set{}
|
||||
var ignores []match.IgnoreFilter
|
||||
// we attempt to merge match details within the same matcher when searching by CPEs, in this way there are fewer duplicated match
|
||||
// objects (and fewer duplicated match details).
|
||||
|
||||
// Warn the user if they are matching by CPE, but there are no CPEs available.
|
||||
if len(p.CPEs) == 0 {
|
||||
return nil, nil, ErrEmptyCPEMatch
|
||||
}
|
||||
|
||||
matchesByFingerprint := make(map[match.Fingerprint]match.Match)
|
||||
for _, c := range p.CPEs {
|
||||
// prefer the CPE version, but if npt specified use the package version
|
||||
searchVersion := c.Attributes.Version
|
||||
|
||||
if p.Type == syftPkg.ApkPkg {
|
||||
searchVersion = alpineCPEComparableVersion(searchVersion)
|
||||
searchVersion = cpeversion.Alpine(searchVersion)
|
||||
}
|
||||
|
||||
if searchVersion == wfn.NA || searchVersion == wfn.Any || isUnknownVersion(searchVersion) {
|
||||
@@ -77,7 +70,7 @@ func MatchPackageByCPEs(vulnProvider vulnerability.Provider, p pkg.Package, upst
|
||||
format := pkg.VersionFormat(p)
|
||||
|
||||
if format == version.JVMFormat {
|
||||
searchVersion = transformJvmVersion(searchVersion, c.Attributes.Update)
|
||||
searchVersion = cpeversion.JVM(searchVersion, c.Attributes.Update)
|
||||
}
|
||||
|
||||
var verObj *version.Version
|
||||
@@ -110,160 +103,12 @@ func MatchPackageByCPEs(vulnProvider vulnerability.Provider, p pkg.Package, upst
|
||||
return nil, nil, fmt.Errorf("matcher failed to fetch unaffected CPE records for pkg=%q: %w", p.Name, err)
|
||||
}
|
||||
|
||||
// add affected vulns to the top level set; nothing is reconciled until the set becomes matches
|
||||
affected = affected.Merge(vulns)
|
||||
// mark unaffected as ignores
|
||||
unaffected = all.Remove(vulns).Merge(unaffected)
|
||||
ignores = append(ignores, OwnershipIgnores(p, "CPE not vulnerable", unaffected.Vulnerabilities()...)...)
|
||||
|
||||
// for each vulnerability record found, check the version constraint. If the constraint is satisfied
|
||||
// relative to the current version information from the CPE (or the package) then the given package
|
||||
// is vulnerable.
|
||||
for _, vuln := range vulns.Vulnerabilities() {
|
||||
addNewMatch(matchesByFingerprint, vuln, p, verObj, upstreamMatcher, c)
|
||||
}
|
||||
}
|
||||
|
||||
return toMatches(matchesByFingerprint), ignores, nil
|
||||
}
|
||||
|
||||
func transformJvmVersion(searchVersion, updateCpeField string) string {
|
||||
// we should take into consideration the CPE update field for JVM packages
|
||||
if strings.HasPrefix(searchVersion, "1.") && !strings.Contains(searchVersion, "_") && updateCpeField != wfn.NA && updateCpeField != wfn.Any {
|
||||
searchVersion = fmt.Sprintf("%s_%s", searchVersion, strings.TrimPrefix(updateCpeField, "update"))
|
||||
}
|
||||
return searchVersion
|
||||
}
|
||||
|
||||
func addNewMatch(matchesByFingerprint map[match.Fingerprint]match.Match, vuln vulnerability.Vulnerability, p pkg.Package, searchVersion *version.Version, upstreamMatcher match.MatcherType, searchedByCPE cpe.CPE) {
|
||||
candidateMatch := match.Match{
|
||||
|
||||
Vulnerability: vuln,
|
||||
Package: p,
|
||||
}
|
||||
|
||||
if existingMatch, exists := matchesByFingerprint[candidateMatch.Fingerprint()]; exists {
|
||||
candidateMatch = existingMatch
|
||||
}
|
||||
|
||||
candidateMatch.Details = addMatchDetails(candidateMatch.Details,
|
||||
CPEMatchDetails(upstreamMatcher, vuln, searchedByCPE, p, searchVersion),
|
||||
)
|
||||
|
||||
matchesByFingerprint[candidateMatch.Fingerprint()] = candidateMatch
|
||||
}
|
||||
|
||||
func CPEMatchDetails(matcherType match.MatcherType, vuln vulnerability.Vulnerability, searchedByCPE cpe.CPE, p pkg.Package, searchVersion *version.Version) match.Detail {
|
||||
return match.Detail{
|
||||
Type: match.CPEMatch,
|
||||
Confidence: 0.9, // TODO: this is hard coded for now
|
||||
Matcher: matcherType,
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: vuln.Namespace,
|
||||
CPEs: []string{
|
||||
// use .String() for proper escaping
|
||||
searchedByCPE.Attributes.String(),
|
||||
},
|
||||
Package: match.PackageParameter{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VulnerabilityID: vuln.ID,
|
||||
VersionConstraint: vuln.Constraint.String(),
|
||||
CPEs: cpesToString(filterCPEsByVersion(searchVersion, vuln.CPEs)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func addMatchDetails(existingDetails []match.Detail, newDetails match.Detail) []match.Detail {
|
||||
newFound, ok := newDetails.Found.(match.CPEResult)
|
||||
if !ok {
|
||||
return existingDetails
|
||||
}
|
||||
|
||||
newSearchedBy, ok := newDetails.SearchedBy.(match.CPEParameters)
|
||||
if !ok {
|
||||
return existingDetails
|
||||
}
|
||||
for idx, detail := range existingDetails {
|
||||
found, ok := detail.Found.(match.CPEResult)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
searchedBy, ok := detail.SearchedBy.(match.CPEParameters)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if !found.Equals(newFound) {
|
||||
continue
|
||||
}
|
||||
|
||||
err := searchedBy.Merge(newSearchedBy)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
existingDetails[idx].SearchedBy = searchedBy
|
||||
return existingDetails
|
||||
}
|
||||
|
||||
// could not merge with another entry, append to the end
|
||||
existingDetails = append(existingDetails, newDetails)
|
||||
return existingDetails
|
||||
}
|
||||
|
||||
func filterCPEsByVersion(pkgVersion *version.Version, allCPEs []cpe.CPE) (matchedCPEs []cpe.CPE) {
|
||||
if pkgVersion == nil {
|
||||
// all CPEs are valid in the case when a version is not specified
|
||||
return allCPEs
|
||||
}
|
||||
for _, c := range allCPEs {
|
||||
if c.Attributes.Version == wfn.Any || c.Attributes.Version == wfn.NA {
|
||||
matchedCPEs = append(matchedCPEs, c)
|
||||
continue
|
||||
}
|
||||
|
||||
ver := c.Attributes.Version
|
||||
|
||||
if pkgVersion.Format == version.JVMFormat {
|
||||
if c.Attributes.Update != wfn.Any && c.Attributes.Update != wfn.NA {
|
||||
ver = transformJvmVersion(ver, c.Attributes.Update)
|
||||
}
|
||||
}
|
||||
|
||||
constraint, err := version.GetConstraint(ver, pkgVersion.Format)
|
||||
if err != nil {
|
||||
// if we can't get a version constraint, don't filter out the CPE
|
||||
matchedCPEs = append(matchedCPEs, c)
|
||||
continue
|
||||
}
|
||||
|
||||
satisfied, err := constraint.Satisfied(pkgVersion)
|
||||
if err != nil || satisfied {
|
||||
// if we can't check for version satisfaction, don't filter out the CPE
|
||||
matchedCPEs = append(matchedCPEs, c)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return matchedCPEs
|
||||
}
|
||||
|
||||
func toMatches(matchesByFingerprint map[match.Fingerprint]match.Match) (matches []match.Match) {
|
||||
for _, m := range matchesByFingerprint {
|
||||
matches = append(matches, m)
|
||||
}
|
||||
sort.Sort(match.ByElements(matches))
|
||||
return matches
|
||||
}
|
||||
|
||||
// cpesToString receives one or more CPEs and stringifies them
|
||||
func cpesToString(cpes []cpe.CPE) []string {
|
||||
var strs = make([]string, len(cpes))
|
||||
for idx, c := range cpes {
|
||||
// use .String() for proper escaping
|
||||
strs[idx] = c.Attributes.String()
|
||||
}
|
||||
sort.Strings(strs)
|
||||
return strs
|
||||
return affected, ignores, nil
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/anchore/grype/grype/version"
|
||||
"github.com/anchore/grype/grype/vulnerability"
|
||||
"github.com/anchore/grype/grype/vulnerability/mock"
|
||||
"github.com/anchore/grype/internal/dbtest"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/cpe"
|
||||
syftPkg "github.com/anchore/syft/syft/pkg"
|
||||
@@ -1019,348 +1020,82 @@ func TestFindMatchesByPackageCPE(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterCPEsByVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
vulnerabilityCPEs []string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "filter out by simple version",
|
||||
version: "1.0",
|
||||
vulnerabilityCPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:2.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
expected: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "do not filter on empty version",
|
||||
version: "", // important!
|
||||
vulnerabilityCPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:2.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
expected: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:2.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
}
|
||||
// TestMatchPackageByCPEs_multipleRecordsPerCVE covers folding the several database records NVD stores
|
||||
// for one CVE -- one per vulnerable CPE -- into a single finding.
|
||||
//
|
||||
// This exercises the CPE path every CPE-enabled matcher shares (java, javascript, python, ruby,
|
||||
// dotnet, rust, hex, golang and stock all reach it), so it is tested here rather than through any one
|
||||
// of them. The nvd-cpe-details fixture holds synthetic NVD records:
|
||||
//
|
||||
// - CVE-2026-40001 lists two distinct vulnerable CPEs, acme:widget (>= 1.0.0 < 2.0.0, fixed in
|
||||
// 2.0.0) and acme:widget-core (>= 1.0.0 < 3.0.0, fixed in 3.0.0).
|
||||
//
|
||||
// - CVE-2026-40003 lists acme:gizmo twice: unqualified (>= 1.0.0 < 2.0.0, fixed in 2.0.0) and for
|
||||
// target software "embedded-mod" (<= 1.5.0, no fix). Modeled on CVE-2017-9229, which lists
|
||||
// cpe:2.3:a:php:php both unqualified and for "oniguruma-mod", and which grype once reported twice
|
||||
// for one php package. To see that shape against a real DB:
|
||||
//
|
||||
// grype 'pkg:generic/php@7.1.5' --add-cpes-if-none -o json
|
||||
func TestMatchPackageByCPEs_multipleRecordsPerCVE(t *testing.T) {
|
||||
dbtest.DBs(t, "nvd-cpe-details").Run(func(t *testing.T, db *dbtest.DB) {
|
||||
// One CVE reached through two of the package's CPEs, where each CPE resolves to a separate DB
|
||||
// record with its own constraint and fix.
|
||||
//
|
||||
// One CVE on one package is one finding, however many records produced it: a match.Fingerprint
|
||||
// is exactly (vulnerability, package), so Set.ToMatches groups the records under one
|
||||
// fingerprint and folds them together with match.Match.Merge -- fix versions unioned, both
|
||||
// records' details kept. Keying the fingerprint on the fix version as well (as it once did)
|
||||
// would report this CVE twice for one package.
|
||||
t.Run("one CVE via two CPEs with differing fixes", func(t *testing.T) {
|
||||
p := dbtest.NewPackage("widget-core", "1.5.0", syftPkg.BinaryPkg).
|
||||
WithCPE("cpe:2.3:a:acme:widget:1.5.0:*:*:*:*:*:*:*").
|
||||
WithCPE("cpe:2.3:a:acme:widget-core:1.5.0:*:*:*:*:*:*:*").
|
||||
Build()
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
// format strings to CPE objects...
|
||||
vulnerabilityCPEs := make([]cpe.CPE, len(test.vulnerabilityCPEs))
|
||||
for idx, c := range test.vulnerabilityCPEs {
|
||||
vulnerabilityCPEs[idx] = cpe.Must(c, "")
|
||||
matches, ignores, err := MatchPackageByCPEs(db, p, match.StockMatcher)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var versionObj *version.Version
|
||||
if test.version != "" {
|
||||
versionObj = version.New(test.version, version.UnknownFormat)
|
||||
}
|
||||
// a single finding, fixed by either record's version...
|
||||
finding := dbtest.AssertFindingsAndIgnores(t, matches, ignores, p).
|
||||
SelectMatch("CVE-2026-40001")
|
||||
finding.HasFix(vulnerability.FixStateFixed, "2.0.0", "3.0.0").
|
||||
HasDetailCount(2)
|
||||
|
||||
// run the test subject...
|
||||
actual := filterCPEsByVersion(versionObj, vulnerabilityCPEs)
|
||||
|
||||
// format CPE objects to string...
|
||||
actualStrs := make([]string, len(actual))
|
||||
for idx, a := range actual {
|
||||
// use .String() for proper escaping
|
||||
actualStrs[idx] = a.Attributes.String()
|
||||
}
|
||||
|
||||
assert.ElementsMatch(t, test.expected, actualStrs)
|
||||
// ...carrying a detail per record, each describing its own record
|
||||
finding.SelectDetailByCPE("cpe:2.3:a:acme:widget:1.5.0:*:*:*:*:*:*:*", ">= 1.0.0, < 2.0.0 (unknown)").
|
||||
FoundCPEs("cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*")
|
||||
finding.SelectDetailByCPE("cpe:2.3:a:acme:widget-core:1.5.0:*:*:*:*:*:*:*", ">= 1.0.0, < 3.0.0 (unknown)").
|
||||
FoundCPEs("cpe:2.3:a:acme:widget-core:*:*:*:*:*:*:*:*")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMatchDetails(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
existing []match.Detail
|
||||
new match.Detail
|
||||
expected []match.Detail
|
||||
}{
|
||||
{
|
||||
name: "append new entry -- found not equal",
|
||||
existing: []match.Detail{
|
||||
{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
new: match.Detail{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"totally-different-search",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"totally-different-match",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []match.Detail{
|
||||
{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"totally-different-search",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"totally-different-match",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "append new entry -- searchedBy merge fails",
|
||||
existing: []match.Detail{
|
||||
{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
new: match.Detail{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "totally-different",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []match.Detail{
|
||||
{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "totally-different",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merge with exiting entry",
|
||||
existing: []match.Detail{
|
||||
{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
new: match.Detail{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"totally-different-search",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []match.Detail{
|
||||
{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
"totally-different-search",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no addition - bad new searchedBy type",
|
||||
existing: []match.Detail{
|
||||
{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
new: match.Detail{
|
||||
SearchedBy: "something else!",
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []match.Detail{
|
||||
{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no addition - bad new found type",
|
||||
existing: []match.Detail{
|
||||
{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
new: match.Detail{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
Found: "something-else!",
|
||||
},
|
||||
expected: []match.Detail{
|
||||
{
|
||||
SearchedBy: match.CPEParameters{
|
||||
Namespace: "nvd:cpe",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
Found: match.CPEResult{
|
||||
VersionConstraint: "< 2.0 (unknown)",
|
||||
CPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
// The same shape reached through a single package CPE, with one of the two records unfixed:
|
||||
// the fix state has to survive the merge, so a record reporting no fix must not mask the fix
|
||||
// the other record does report.
|
||||
t.Run("one CPE matching a fixed and an unfixed record", func(t *testing.T) {
|
||||
p := dbtest.NewPackage("gizmo", "1.5.0", syftPkg.BinaryPkg).
|
||||
WithCPE("cpe:2.3:a:acme:gizmo:1.5.0:*:*:*:*:*:*:*").
|
||||
Build()
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, addMatchDetails(test.existing, test.new))
|
||||
matches, ignores, err := MatchPackageByCPEs(db, p, match.StockMatcher)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// one finding, still reporting the fix the unqualified record provides
|
||||
finding := dbtest.AssertFindingsAndIgnores(t, matches, ignores, p).
|
||||
SelectMatch("CVE-2026-40003")
|
||||
finding.HasFix(vulnerability.FixStateFixed, "2.0.0").
|
||||
HasDetailCount(2)
|
||||
|
||||
// both records searched by the same package CPE, each detail describing its own record
|
||||
finding.SelectDetailByCPE("cpe:2.3:a:acme:gizmo:1.5.0:*:*:*:*:*:*:*", ">= 1.0.0, < 2.0.0 (unknown)").
|
||||
FoundCPEs("cpe:2.3:a:acme:gizmo:*:*:*:*:*:*:*:*")
|
||||
finding.SelectDetailByCPE("cpe:2.3:a:acme:gizmo:1.5.0:*:*:*:*:*:*:*", "<= 1.5.0 (unknown)").
|
||||
FoundCPEs("cpe:2.3:a:acme:gizmo:*:*:*:*:*:embedded-mod:*:*")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCPESearchHit_Equals(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package cpeversion renders package and CPE versions in terms that can be compared with each other.
|
||||
//
|
||||
// A CPE spells a version the way NVD writes it, which is not always the way the package's own
|
||||
// ecosystem does. Both the CPE search and the match detail it produces have to reconcile the two, and
|
||||
// they live in different packages, so the rules are collected here rather than in either of them.
|
||||
package cpeversion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cpeAny and cpeNA are the CPE "any" and "not applicable" markers. Spelled out rather than taken from
|
||||
// nvdtools so this package stays free of that dependency.
|
||||
const (
|
||||
cpeAny = "*"
|
||||
cpeNA = "-"
|
||||
)
|
||||
|
||||
// Alpine drops the -rN build suffix from an apk version. CPE comparison treats the suffix as a
|
||||
// pre-release, so 1.2.3-r21 would sort before 1.2.3 and fail a constraint its own package satisfies.
|
||||
// For CPE purposes every build of 1.2.3 is 1.2.3; the alpine feed is what decides whether a later
|
||||
// build fixed it.
|
||||
func Alpine(version string) string {
|
||||
components := strings.Split(version, "-r")
|
||||
if len(components) == 2 {
|
||||
return components[0]
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// JVM folds a CPE's update field into its version. Pre-JEP-223 releases split a version across both
|
||||
// fields: 1.8.0_351 appears as version "1.8.0" with update "update351", and comparing the version
|
||||
// alone would place it before every update of the same release. Anything else is returned unchanged.
|
||||
func JVM(version, cpeUpdate string) string {
|
||||
switch cpeUpdate {
|
||||
case "", cpeAny, cpeNA: // the update field says nothing
|
||||
return version
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(version, "1.") || strings.Contains(version, "_") {
|
||||
// not pre-JEP-223, or the version already carries its update
|
||||
return version
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s", version, strings.TrimPrefix(cpeUpdate, "update"))
|
||||
}
|
||||
@@ -13,23 +13,25 @@ import (
|
||||
"github.com/anchore/grype/internal/log"
|
||||
)
|
||||
|
||||
// MatchPackageByDistro searches the distro namespace for every name the
|
||||
// FindResultsByDistro searches the distro namespace for every name the
|
||||
// provider claims for searchPkg, then partitions the unioned results in
|
||||
// memory into vulnerable matches and fixes the matcher should ignore on
|
||||
// overlapping packages (e.g. an APK that owns NPM).
|
||||
// memory into vulnerable disclosures and fixed records (folding the distro
|
||||
// "unaffected"/NAK records into fixed). It returns result.Sets so callers can
|
||||
// reconcile them against other sources (e.g. NVD/CPE) by identity, or convert
|
||||
// to matches + ignores via MatchPackageByDistro.
|
||||
//
|
||||
// The fanout over PackageSearchNames is what makes the rootio NAK pattern
|
||||
// work: a scan against `rootio-libssl3` also searches for the bare
|
||||
// `libssl3` upstream disclosure, and any rootio NAK in the unaffected set
|
||||
// suppresses the match via ID + alias identity in result.Set.Remove.
|
||||
func MatchPackageByDistro(provider vulnerability.Provider, searchPkg pkg.Package, catalogPkg *pkg.Package, upstreamMatcher match.MatcherType, cfg *version.ComparisonConfig) ([]match.Match, []match.IgnoreFilter, error) {
|
||||
func FindResultsByDistro(provider vulnerability.Provider, searchPkg pkg.Package, catalogPkg *pkg.Package, upstreamMatcher match.MatcherType, cfg *version.ComparisonConfig) (vulnerable result.Set, fixed result.Set, err error) {
|
||||
if searchPkg.Distro == nil {
|
||||
return nil, nil, nil
|
||||
return result.Set{}, result.Set{}, nil
|
||||
}
|
||||
|
||||
if isUnknownVersion(searchPkg.Version) {
|
||||
log.WithFields("package", searchPkg.Name).Trace("skipping package with unknown version")
|
||||
return nil, nil, nil
|
||||
return result.Set{}, result.Set{}, nil
|
||||
}
|
||||
|
||||
var pkgVersion *version.Version
|
||||
@@ -60,8 +62,8 @@ func MatchPackageByDistro(provider vulnerability.Provider, searchPkg pkg.Package
|
||||
allVulns = allVulns.Merge(v)
|
||||
}
|
||||
|
||||
vulnerable := allVulns.Filter(versionCriteria)
|
||||
fixed := allVulns.Remove(vulnerable)
|
||||
vulnerable = allVulns.Filter(versionCriteria)
|
||||
fixed = allVulns.Remove(vulnerable)
|
||||
|
||||
unaffected := result.Set{}
|
||||
for _, name := range searchNames {
|
||||
@@ -80,6 +82,20 @@ func MatchPackageByDistro(provider vulnerability.Provider, searchPkg pkg.Package
|
||||
vulnerable = vulnerable.Remove(unaffected)
|
||||
fixed = fixed.Merge(unaffected)
|
||||
|
||||
return vulnerable, fixed, nil
|
||||
}
|
||||
|
||||
// MatchPackageByDistro searches the distro namespace for every name the
|
||||
// provider claims for searchPkg, then partitions the unioned results in
|
||||
// memory into vulnerable matches and fixes the matcher should ignore on
|
||||
// overlapping packages (e.g. an APK that owns NPM). It is a thin wrapper over
|
||||
// FindResultsByDistro for callers that work in []match.Match.
|
||||
func MatchPackageByDistro(provider vulnerability.Provider, searchPkg pkg.Package, catalogPkg *pkg.Package, upstreamMatcher match.MatcherType, cfg *version.ComparisonConfig) ([]match.Match, []match.IgnoreFilter, error) {
|
||||
vulnerable, fixed, err := FindResultsByDistro(provider, searchPkg, catalogPkg, upstreamMatcher, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Use the SBOM package (not the synthetic upstream) for file ownership — the upstream package doesn't have file metadata.
|
||||
ignores := OwnershipIgnores(matchPackage(searchPkg, catalogPkg), "DistroPackageFixed", fixed.Vulnerabilities()...)
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package result
|
||||
|
||||
import "github.com/anchore/grype/grype/match"
|
||||
|
||||
type MatchDetailsSet struct {
|
||||
order []match.Detail
|
||||
// keyed by Detail.ID() (a hashstructure hash) rather than the Detail value itself: Found/SearchedBy
|
||||
// are `any` and may hold slice-bearing types (e.g. EcosystemResult.MatchedSymbols, CPEResult.CPEs)
|
||||
// that are not valid Go map keys and would panic on direct keying.
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
func NewMatchDetailsSet(ds ...match.Detail) MatchDetailsSet {
|
||||
s := MatchDetailsSet{
|
||||
order: []match.Detail{},
|
||||
seen: make(map[string]struct{}),
|
||||
}
|
||||
for _, detail := range ds {
|
||||
s.Add(detail)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (ds *MatchDetailsSet) Add(detail match.Detail) {
|
||||
id := detail.ID()
|
||||
if _, exists := ds.seen[id]; !exists {
|
||||
ds.order = append(ds.order, detail)
|
||||
ds.seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (ds MatchDetailsSet) ToSlice() []match.Detail {
|
||||
return ds.order
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
package result
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/facebookincubator/nvdtools/wfn"
|
||||
|
||||
"github.com/anchore/grype/grype/match"
|
||||
"github.com/anchore/grype/grype/matcher/internal/cpeversion"
|
||||
"github.com/anchore/grype/grype/pkg"
|
||||
"github.com/anchore/grype/grype/pkg/qualifier/gosymbols"
|
||||
"github.com/anchore/grype/grype/search"
|
||||
"github.com/anchore/grype/grype/version"
|
||||
"github.com/anchore/grype/grype/vulnerability"
|
||||
"github.com/anchore/syft/syft/cpe"
|
||||
)
|
||||
|
||||
var _ Provider = (*provider)(nil)
|
||||
@@ -56,19 +63,21 @@ func (p provider) FindResults(criteria ...vulnerability.Criteria) (Set, error) {
|
||||
}
|
||||
|
||||
func detailProvider(matcher match.MatcherType, catalogedPkg pkg.Package, criteriaSet []vulnerability.Criteria, vuln vulnerability.Vulnerability) match.Details {
|
||||
cpeParams, distroParams, ecosystemParams, pkgParams := extractSearchParameters(criteriaSet, vuln)
|
||||
cpeParams, distroParams, ecosystemParams, pkgParams := extractSearchParameters(criteriaSet, vuln, catalogedPkg)
|
||||
distroMatchType := determineMatchType(catalogedPkg, pkgParams)
|
||||
applyPackageParamsToSearchParams(pkgParams, &cpeParams, &distroParams, &ecosystemParams)
|
||||
constraintStr := getConstraintString(vuln)
|
||||
// the vulnerable Go symbols the package was found to use; empty for every non-Go match and for
|
||||
// module-granularity Go matches where no specific symbol intersection decided the match.
|
||||
matchedSymbols := gosymbols.MatchedSymbols(vuln.PackageQualifiers, catalogedPkg)
|
||||
// the vulnerability's CPEs relevant at the searched version, surfaced on the CPE match detail
|
||||
foundCPEs := matchedCPEsForSearch(catalogedPkg, searchedCPE(criteriaSet), vuln)
|
||||
|
||||
return buildMatchDetails(matcher, distroMatchType, constraintStr, vuln, cpeParams, distroParams, ecosystemParams, matchedSymbols)
|
||||
return buildMatchDetails(matcher, distroMatchType, constraintStr, vuln, cpeParams, distroParams, ecosystemParams, matchedSymbols, foundCPEs)
|
||||
}
|
||||
|
||||
// extractSearchParameters processes criteria set and extracts search parameters for different match types
|
||||
func extractSearchParameters(criteriaSet []vulnerability.Criteria, vuln vulnerability.Vulnerability) ([]match.CPEParameters, []match.DistroParameters, []match.EcosystemParameters, *match.PackageParameter) {
|
||||
func extractSearchParameters(criteriaSet []vulnerability.Criteria, vuln vulnerability.Vulnerability, catalogedPkg pkg.Package) ([]match.CPEParameters, []match.DistroParameters, []match.EcosystemParameters, *match.PackageParameter) {
|
||||
var cpeParams []match.CPEParameters
|
||||
var distroParams []match.DistroParameters
|
||||
var ecosystemParams []match.EcosystemParameters
|
||||
@@ -98,7 +107,12 @@ func extractSearchParameters(criteriaSet []vulnerability.Criteria, vuln vulnerab
|
||||
cpeParams = append(cpeParams, match.CPEParameters{
|
||||
Namespace: vuln.Namespace, // TODO: this is a holdover and will be removed in the future
|
||||
CPEs: []string{
|
||||
c.CPE.Attributes.BindToFmtString(),
|
||||
c.CPE.Attributes.String(),
|
||||
},
|
||||
// CPE searches carry no package-name criterion, so record package identity from the cataloged package
|
||||
Package: match.PackageParameter{
|
||||
Name: catalogedPkg.Name,
|
||||
Version: catalogedPkg.Version,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -157,9 +171,20 @@ func getConstraintString(vuln vulnerability.Vulnerability) string {
|
||||
}
|
||||
|
||||
// buildMatchDetails creates the final match details from all parameters
|
||||
func buildMatchDetails(matcher match.MatcherType, distroMatchType match.Type, constraintStr string, vuln vulnerability.Vulnerability, cpeParams []match.CPEParameters, distroParams []match.DistroParameters, ecosystemParams []match.EcosystemParameters, matchedSymbols []string) match.Details {
|
||||
func buildMatchDetails(
|
||||
matcher match.MatcherType, distroMatchType match.Type, constraintStr string, vuln vulnerability.Vulnerability,
|
||||
cpeParams []match.CPEParameters, distroParams []match.DistroParameters, ecosystemParams []match.EcosystemParameters,
|
||||
matchedSymbols []string, foundCPEs []cpe.CPE,
|
||||
) match.Details {
|
||||
var details match.Details
|
||||
|
||||
// stringify (with proper escaping) and sort the found CPEs for deterministic detail output
|
||||
foundCPEStrings := make([]string, 0, len(foundCPEs))
|
||||
for _, c := range foundCPEs {
|
||||
foundCPEStrings = append(foundCPEStrings, c.Attributes.String())
|
||||
}
|
||||
sort.Strings(foundCPEStrings)
|
||||
|
||||
// add CPE match details
|
||||
for _, cpeParam := range cpeParams {
|
||||
details = append(details, match.Detail{
|
||||
@@ -169,6 +194,7 @@ func buildMatchDetails(matcher match.MatcherType, distroMatchType match.Type, co
|
||||
Found: match.CPEResult{
|
||||
VulnerabilityID: vuln.ID,
|
||||
VersionConstraint: constraintStr,
|
||||
CPEs: foundCPEStrings,
|
||||
},
|
||||
Confidence: 0.9, // TODO: this is hard coded for now
|
||||
})
|
||||
@@ -205,3 +231,83 @@ func buildMatchDetails(matcher match.MatcherType, distroMatchType match.Type, co
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
// searchedCPE returns the CPE a search was made with, or nil when the criteria hold no CPE search.
|
||||
func searchedCPE(criteriaSet []vulnerability.Criteria) *cpe.CPE {
|
||||
for i := range criteriaSet {
|
||||
if c, ok := criteriaSet[i].(*search.CPECriteria); ok {
|
||||
return &c.CPE
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchedCPEsForSearch returns the vulnerability's CPEs that are relevant at the version the search
|
||||
// was made with. It is empty when the vulnerability carries no CPEs.
|
||||
//
|
||||
// The comparison uses searchedBy's version rather than the package's own. By the time a CPE search
|
||||
// runs, that version has been put in terms a CPE can be compared against -- an apk's -rN build suffix
|
||||
// dropped, for one -- and a CPE the search already matched must not then be filtered back out by a
|
||||
// version its ecosystem happens to spell differently. Without a CPE search there is no searched
|
||||
// version, and the result is unused anyway: only CPE details carry found CPEs.
|
||||
func matchedCPEsForSearch(catalogedPkg pkg.Package, searchedBy *cpe.CPE, vuln vulnerability.Vulnerability) []cpe.CPE {
|
||||
if len(vuln.CPEs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
format := pkg.VersionFormat(catalogedPkg)
|
||||
|
||||
searchVersion := catalogedPkg.Version
|
||||
var searchUpdate string
|
||||
if searchedBy != nil {
|
||||
if v := searchedBy.Attributes.Version; v != "" && v != wfn.Any && v != wfn.NA {
|
||||
searchVersion = v
|
||||
}
|
||||
searchUpdate = searchedBy.Attributes.Update
|
||||
}
|
||||
|
||||
if searchVersion == "" {
|
||||
// no filtering available by version
|
||||
return vuln.CPEs
|
||||
}
|
||||
|
||||
pkgVersion := version.New(comparableCPEVersion(searchVersion, searchUpdate, format), format)
|
||||
matchedCPEs := make([]cpe.CPE, 0, len(vuln.CPEs))
|
||||
for _, c := range vuln.CPEs {
|
||||
if c.Attributes.Version == wfn.Any || c.Attributes.Version == wfn.NA {
|
||||
matchedCPEs = append(matchedCPEs, c)
|
||||
continue
|
||||
}
|
||||
|
||||
constraint, err := version.GetConstraint(comparableCPEVersion(c.Attributes.Version, c.Attributes.Update, format), format)
|
||||
if err != nil {
|
||||
// if we can't get a version constraint, don't filter out the CPE
|
||||
matchedCPEs = append(matchedCPEs, c)
|
||||
continue
|
||||
}
|
||||
|
||||
satisfied, err := constraint.Satisfied(pkgVersion)
|
||||
if err != nil || satisfied {
|
||||
// if we can't check for version satisfaction, don't filter out the CPE
|
||||
matchedCPEs = append(matchedCPEs, c)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return matchedCPEs
|
||||
}
|
||||
|
||||
// comparableCPEVersion renders a CPE's version and update fields as one version string the given
|
||||
// format can be compared against.
|
||||
func comparableCPEVersion(cpeVersion, cpeUpdate string, format version.Format) string {
|
||||
switch format {
|
||||
case version.ApkFormat:
|
||||
// the searched version is normalized before a CPE search runs, but only when the package's own
|
||||
// CPE carried a version -- the fallback to the raw package version is not -- so normalize here
|
||||
// rather than relying on where the version came from
|
||||
return cpeversion.Alpine(cpeVersion)
|
||||
case version.JVMFormat:
|
||||
return cpeversion.JVM(cpeVersion, cpeUpdate)
|
||||
}
|
||||
return cpeVersion
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package result
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/grype/grype/match"
|
||||
"github.com/anchore/grype/grype/pkg"
|
||||
"github.com/anchore/grype/grype/search"
|
||||
"github.com/anchore/grype/grype/vulnerability"
|
||||
"github.com/anchore/syft/syft/cpe"
|
||||
syftPkg "github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
//nolint:funlen
|
||||
func TestMatchedCPEsForSearch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
pkgType syftPkg.Type
|
||||
pkgMetadata any
|
||||
searchedCPE string // the CPE the search was made with; its version is already ecosystem-normalized
|
||||
vulnerabilityCPEs []string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "filter out by simple version",
|
||||
version: "1.0",
|
||||
vulnerabilityCPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:2.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
expected: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "do not filter on empty version",
|
||||
version: "", // important!
|
||||
vulnerabilityCPEs: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:2.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
expected: []string{
|
||||
"cpe:2.3:*:multiple:multiple:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:1.0:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:*:multiple:multiple:2.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
{
|
||||
// an apk version carries a -rN build suffix that the CPE version never does, so comparing
|
||||
// them raw makes 1.2.3-r4 fail a "1.2.3" constraint and the record's own CPE drops out of
|
||||
// the detail. the deleted filterCPEsByVersion compared against the alpine-normalized
|
||||
// search version instead, which is what alpineCPEComparableVersion exists for.
|
||||
name: "apk build suffix does not exclude the record's CPE",
|
||||
version: "1.2.3-r4",
|
||||
pkgType: syftPkg.ApkPkg,
|
||||
searchedCPE: "cpe:2.3:a:acme:widget:1.2.3:*:*:*:*:*:*:*",
|
||||
vulnerabilityCPEs: []string{
|
||||
"cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:a:acme:widget:1.2.3:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:a:acme:widget:2.0.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
expected: []string{
|
||||
"cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:a:acme:widget:1.2.3:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
{
|
||||
// the package's own CPE carries no version, so FindResultsByCPEs falls back to the raw
|
||||
// package version -- and that fallback happens after the alpine normalization, so the
|
||||
// searched CPE carries the -rN suffix. the comparison has to normalize it here too.
|
||||
name: "apk build suffix does not exclude the record's CPE, with no version to search by",
|
||||
version: "1.2.3-r4",
|
||||
pkgType: syftPkg.ApkPkg,
|
||||
searchedCPE: "cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
|
||||
vulnerabilityCPEs: []string{
|
||||
"cpe:2.3:a:acme:widget:1.2.3:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:a:acme:widget:2.0.0:*:*:*:*:*:*:*",
|
||||
},
|
||||
expected: []string{
|
||||
"cpe:2.3:a:acme:widget:1.2.3:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
{
|
||||
// a JVM CPE splits the version across the version and update fields, so "1.8.0" plus
|
||||
// update "update351" describes the same release as 1.8.0_351. the deleted
|
||||
// filterCPEsByVersion folded the update field in via transformJvmVersion before comparing;
|
||||
// without that the CPE fails its own package's constraint.
|
||||
name: "jvm update field does not exclude the record's CPE",
|
||||
version: "1.8.0_351",
|
||||
pkgType: syftPkg.BinaryPkg,
|
||||
pkgMetadata: pkg.JavaVMInstallationMetadata{},
|
||||
searchedCPE: "cpe:2.3:a:oracle:jre:1.8.0:update351:*:*:*:*:*:*",
|
||||
vulnerabilityCPEs: []string{
|
||||
"cpe:2.3:a:oracle:jre:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:a:oracle:jre:1.8.0:update351:*:*:*:*:*:*",
|
||||
"cpe:2.3:a:oracle:jre:1.8.0:update400:*:*:*:*:*:*",
|
||||
},
|
||||
expected: []string{
|
||||
"cpe:2.3:a:oracle:jre:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:a:oracle:jre:1.8.0:update351:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
// format strings to CPE objects...
|
||||
vulnerabilityCPEs := make([]cpe.CPE, len(test.vulnerabilityCPEs))
|
||||
for idx, c := range test.vulnerabilityCPEs {
|
||||
vulnerabilityCPEs[idx] = cpe.Must(c, "")
|
||||
}
|
||||
|
||||
catalogedPkg := pkg.Package{Version: test.version, Type: test.pkgType, Metadata: test.pkgMetadata}
|
||||
vuln := vulnerability.Vulnerability{CPEs: vulnerabilityCPEs}
|
||||
|
||||
var searchedBy *cpe.CPE
|
||||
if test.searchedCPE != "" {
|
||||
c := cpe.Must(test.searchedCPE, "")
|
||||
searchedBy = &c
|
||||
}
|
||||
|
||||
// run the test subject...
|
||||
actual := matchedCPEsForSearch(catalogedPkg, searchedBy, vuln)
|
||||
|
||||
// format CPE objects to string...
|
||||
actualStrs := make([]string, len(actual))
|
||||
for idx, a := range actual {
|
||||
// use .String() for proper escaping
|
||||
actualStrs[idx] = a.Attributes.String()
|
||||
}
|
||||
|
||||
assert.ElementsMatch(t, test.expected, actualStrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailProvider_CPEDetail pins how a CPE match detail is rendered: the searched-by CPE is the
|
||||
// one that was searched with, WFN-escaped, and the found CPEs are the record's own.
|
||||
func TestDetailProvider_CPEDetail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pkgVersion string
|
||||
searchedCPE string
|
||||
vulnCPEs []string
|
||||
expectedSearchedBy string
|
||||
expectedFound []string
|
||||
}{
|
||||
{
|
||||
// the searched-by CPE must be WFN-escaped, i.e. rendered with cpe.Attributes.String()
|
||||
// rather than BindToFmtString(), which leaves the "+" unquoted. That is the form the rest
|
||||
// of grype emits, including the found CPEs on this very detail.
|
||||
name: "searched-by CPE is escaped when the version has WFN punctuation",
|
||||
pkgVersion: `1.5.0+build.7`,
|
||||
searchedCPE: `cpe:2.3:a:acme:gadget:1.5.0\+build.7:*:*:*:*:*:*:*`,
|
||||
vulnCPEs: []string{"cpe:2.3:a:acme:gadget:*:*:*:*:*:*:*:*"},
|
||||
expectedSearchedBy: `cpe:2.3:a:acme:gadget:1.5.0\+build.7:*:*:*:*:*:*:*`,
|
||||
expectedFound: []string{"cpe:2.3:a:acme:gadget:*:*:*:*:*:*:*:*"},
|
||||
},
|
||||
{
|
||||
// v6 always stores a vulnerability's CPEs with version=ANY, so the version filtering in
|
||||
// matchedCPEsForSearch is a no-op on real records however specific the searched-by CPE is
|
||||
name: "found CPEs are the record's CPEs, version-agnostic",
|
||||
pkgVersion: "1.9.9",
|
||||
searchedCPE: "cpe:2.3:a:acme:gadget:1.9.9:*:*:*:*:*:*:*",
|
||||
vulnCPEs: []string{"cpe:2.3:a:acme:gadget:*:*:*:*:*:*:*:*"},
|
||||
expectedSearchedBy: "cpe:2.3:a:acme:gadget:1.9.9:*:*:*:*:*:*:*",
|
||||
expectedFound: []string{"cpe:2.3:a:acme:gadget:*:*:*:*:*:*:*:*"},
|
||||
},
|
||||
{
|
||||
name: "found CPEs are sorted",
|
||||
pkgVersion: "1.5.0",
|
||||
searchedCPE: "cpe:2.3:a:acme:widget:1.5.0:*:*:*:*:*:*:*",
|
||||
vulnCPEs: []string{
|
||||
"cpe:2.3:a:acme:widget-core:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
expectedSearchedBy: "cpe:2.3:a:acme:widget:1.5.0:*:*:*:*:*:*:*",
|
||||
expectedFound: []string{
|
||||
"cpe:2.3:a:acme:widget-core:*:*:*:*:*:*:*:*",
|
||||
"cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
vulnCPEs := make([]cpe.CPE, len(tt.vulnCPEs))
|
||||
for i, c := range tt.vulnCPEs {
|
||||
vulnCPEs[i] = cpe.Must(c, cpe.DeclaredSource)
|
||||
}
|
||||
|
||||
catalogedPkg := pkg.Package{Name: "gadget", Version: tt.pkgVersion}
|
||||
vuln := vulnerability.Vulnerability{
|
||||
Reference: vulnerability.Reference{ID: "CVE-2026-40002"},
|
||||
CPEs: vulnCPEs,
|
||||
}
|
||||
criteria := []vulnerability.Criteria{search.ByCPE(cpe.Must(tt.searchedCPE, cpe.DeclaredSource))}
|
||||
|
||||
details := detailProvider(match.StockMatcher, catalogedPkg, criteria, vuln)
|
||||
|
||||
require.Len(t, details, 1)
|
||||
assert.Equal(t, match.CPEMatch, details[0].Type)
|
||||
|
||||
searchedBy, ok := details[0].SearchedBy.(match.CPEParameters)
|
||||
require.True(t, ok, "expected CPE search parameters, got %T", details[0].SearchedBy)
|
||||
assert.Equal(t, []string{tt.expectedSearchedBy}, searchedBy.CPEs)
|
||||
|
||||
found, ok := details[0].Found.(match.CPEResult)
|
||||
require.True(t, ok, "expected a CPE result, got %T", details[0].Found)
|
||||
assert.Equal(t, tt.expectedFound, found.CPEs)
|
||||
assert.Equal(t, "CVE-2026-40002", found.VulnerabilityID)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,45 +30,32 @@ type Result struct {
|
||||
|
||||
type Set map[string][]Result
|
||||
|
||||
func unionIntoResult(existing []Result) Result {
|
||||
var merged Result
|
||||
for _, r := range existing {
|
||||
if merged.ID == "" {
|
||||
merged.ID = r.ID
|
||||
merged.Package = r.Package
|
||||
}
|
||||
merged.Vulnerabilities = append(merged.Vulnerabilities, r.Vulnerabilities...)
|
||||
merged.Details = append(merged.Details, r.Details...)
|
||||
}
|
||||
merged.Details = NewMatchDetailsSet(merged.Details...).ToSlice()
|
||||
return merged
|
||||
}
|
||||
|
||||
// ToMatches converts the set into matches, one per finding.
|
||||
//
|
||||
// Each record contributes a match carrying only the details that describe *that* record. Records
|
||||
// that turn out to be the same finding -- same vulnerability, same source, same package -- are then
|
||||
// folded together by match.Matches, which unions their fixed-in versions and details. Attaching the
|
||||
// whole set's details to every match instead would make each duplicate claim the others' evidence.
|
||||
func (s Set) ToMatches() []match.Match {
|
||||
var out []match.Match
|
||||
out := match.NewMatches()
|
||||
|
||||
for _, results := range s {
|
||||
merged := unionIntoResult(results)
|
||||
|
||||
if len(merged.Vulnerabilities) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if merged.Package == nil {
|
||||
continue // skip results without a package
|
||||
}
|
||||
|
||||
for _, vv := range merged.Vulnerabilities {
|
||||
out = append(out,
|
||||
match.Match{
|
||||
Vulnerability: vv,
|
||||
Package: *merged.Package,
|
||||
Details: merged.Details,
|
||||
},
|
||||
)
|
||||
for _, r := range results {
|
||||
if r.Package == nil {
|
||||
continue // skip results without a package
|
||||
}
|
||||
for _, v := range r.Vulnerabilities {
|
||||
out.Add(match.Match{
|
||||
Vulnerability: v,
|
||||
Package: *r.Package,
|
||||
Details: r.Details,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
// deterministic output order (map iteration above is not ordered)
|
||||
return out.Sorted()
|
||||
}
|
||||
|
||||
// Vulnerabilities returns a flattened slice of all vulnerabilities in the set
|
||||
@@ -268,6 +255,22 @@ func (s Set) Update(incoming Set, shouldUpdate func(existing Result, incoming Re
|
||||
return out
|
||||
}
|
||||
|
||||
// Map returns a new Set with fn applied to a copy of every result. Because the copy is shallow, fn
|
||||
// must not mutate the elements of a slice field (e.g. Vulnerabilities, Details) in place — replace the
|
||||
// whole slice instead — otherwise it will corrupt the source set's shared backing arrays.
|
||||
func (s Set) Map(fn func(r *Result)) Set {
|
||||
out := make(Set, len(s))
|
||||
for id, results := range s {
|
||||
updated := make([]Result, len(results))
|
||||
for i, r := range results {
|
||||
fn(&r)
|
||||
updated[i] = r
|
||||
}
|
||||
out[id] = updated
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s Set) Filter(criteria ...vulnerability.Criteria) Set {
|
||||
out := Set{}
|
||||
for id, results := range s {
|
||||
|
||||
@@ -604,6 +604,32 @@ func TestSet_ToMatches(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// results may carry the same detail more than once; match.Matches.Add collapses them
|
||||
name: "duplicate details collapse into one",
|
||||
receiver: Set{
|
||||
"vuln-1": []Result{
|
||||
{
|
||||
ID: "vuln-1",
|
||||
Vulnerabilities: []vulnerability.Vulnerability{
|
||||
{Reference: vulnerability.Reference{ID: "CVE-2021-1"}},
|
||||
},
|
||||
Details: match.Details{
|
||||
{Type: match.ExactDirectMatch, SearchedBy: "attr", Found: "value", Matcher: "matcher"},
|
||||
{Type: match.ExactDirectMatch, SearchedBy: "attr", Found: "value", Matcher: "matcher"},
|
||||
},
|
||||
Package: &testPkg,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []match.Match{
|
||||
{
|
||||
Vulnerability: vulnerability.Vulnerability{Reference: vulnerability.Reference{ID: "CVE-2021-1"}},
|
||||
Package: testPkg,
|
||||
Details: match.Details{{Type: match.ExactDirectMatch, SearchedBy: "attr", Found: "value", Matcher: "matcher"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty set returns no matches",
|
||||
receiver: Set{},
|
||||
@@ -1127,3 +1153,77 @@ func TestSet_Update(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSet_Map(t *testing.T) {
|
||||
// fn replaces the Vulnerabilities slice (the safe pattern for the shallow copy) setting Status.
|
||||
setStatus := func(r *Result) {
|
||||
vulns := make([]vulnerability.Vulnerability, len(r.Vulnerabilities))
|
||||
for i, v := range r.Vulnerabilities {
|
||||
v.Status = "mapped"
|
||||
vulns[i] = v
|
||||
}
|
||||
r.Vulnerabilities = vulns
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in Set
|
||||
want Set
|
||||
}{
|
||||
{
|
||||
name: "applies fn to every result under every id",
|
||||
in: Set{
|
||||
"CVE-1": []Result{
|
||||
{ID: "CVE-1", Vulnerabilities: []vulnerability.Vulnerability{{Reference: vulnerability.Reference{ID: "CVE-1"}, Status: "orig"}}},
|
||||
},
|
||||
"CVE-2": []Result{
|
||||
{ID: "CVE-2", Vulnerabilities: []vulnerability.Vulnerability{{Reference: vulnerability.Reference{ID: "CVE-2"}, Status: "orig"}}},
|
||||
{ID: "CVE-2", Vulnerabilities: []vulnerability.Vulnerability{{Reference: vulnerability.Reference{ID: "CVE-2"}, Status: "orig"}}},
|
||||
},
|
||||
},
|
||||
want: Set{
|
||||
"CVE-1": []Result{
|
||||
{ID: "CVE-1", Vulnerabilities: []vulnerability.Vulnerability{{Reference: vulnerability.Reference{ID: "CVE-1"}, Status: "mapped"}}},
|
||||
},
|
||||
"CVE-2": []Result{
|
||||
{ID: "CVE-2", Vulnerabilities: []vulnerability.Vulnerability{{Reference: vulnerability.Reference{ID: "CVE-2"}, Status: "mapped"}}},
|
||||
{ID: "CVE-2", Vulnerabilities: []vulnerability.Vulnerability{{Reference: vulnerability.Reference{ID: "CVE-2"}, Status: "mapped"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty set",
|
||||
in: Set{},
|
||||
want: Set{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.in.Map(setStatus)
|
||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
t.Errorf("Set.Map() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSet_Map_doesNotMutateSource(t *testing.T) {
|
||||
src := Set{
|
||||
"CVE-1": []Result{
|
||||
{ID: "CVE-1", Vulnerabilities: []vulnerability.Vulnerability{{Reference: vulnerability.Reference{ID: "CVE-1"}, Status: "orig"}}},
|
||||
},
|
||||
}
|
||||
|
||||
// fn replaces the slice (as callers must); the source must be untouched.
|
||||
_ = src.Map(func(r *Result) {
|
||||
vulns := make([]vulnerability.Vulnerability, len(r.Vulnerabilities))
|
||||
for i, v := range r.Vulnerabilities {
|
||||
v.Status = "mapped"
|
||||
vulns[i] = v
|
||||
}
|
||||
r.Vulnerabilities = vulns
|
||||
})
|
||||
|
||||
require.Equal(t, "orig", string(src["CVE-1"][0].Vulnerabilities[0].Status))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"provider": "nvd",
|
||||
"version": 2,
|
||||
"processor": "vunnel@manual",
|
||||
"schema": {
|
||||
"version": "1.0.3",
|
||||
"url": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/provider-workspace-state/schema-1.0.3.json"
|
||||
},
|
||||
"urls": [
|
||||
"https://services.nvd.nist.gov/rest/json/cves/2.0"
|
||||
],
|
||||
"timestamp": "2026-06-05T00:00:00Z",
|
||||
"listing": {
|
||||
"path": "results/listing.xxh64",
|
||||
"digest": "0000000000000000",
|
||||
"algorithm": "xxh64"
|
||||
},
|
||||
"store": "flat-file"
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"identifier": "2026/cve-2026-40001",
|
||||
"item": {
|
||||
"cve": {
|
||||
"configurations": [
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"cpeMatch": [
|
||||
{
|
||||
"criteria": "cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
|
||||
"matchCriteriaId": "22222222-2222-2222-2222-222222222222",
|
||||
"versionEndExcluding": "2.0.0",
|
||||
"versionStartIncluding": "1.0.0",
|
||||
"vulnerable": true
|
||||
}
|
||||
],
|
||||
"negate": false,
|
||||
"operator": "OR"
|
||||
},
|
||||
{
|
||||
"cpeMatch": [
|
||||
{
|
||||
"criteria": "cpe:2.3:a:acme:widget-core:*:*:*:*:*:*:*:*",
|
||||
"matchCriteriaId": "33333333-3333-3333-3333-333333333333",
|
||||
"versionEndExcluding": "3.0.0",
|
||||
"versionStartIncluding": "1.0.0",
|
||||
"vulnerable": true
|
||||
}
|
||||
],
|
||||
"negate": false,
|
||||
"operator": "OR"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cveTags": [],
|
||||
"descriptions": [
|
||||
{
|
||||
"lang": "en",
|
||||
"value": "Synthetic record: the same CVE is described by two distinct vulnerable CPEs (acme:widget and acme:widget-core) with different fixed versions, so the DB holds two affected-CPE records for one CVE ID."
|
||||
}
|
||||
],
|
||||
"id": "CVE-2026-40001",
|
||||
"lastModified": "2026-03-01T00:00:00.000",
|
||||
"metrics": {
|
||||
"cvssMetricV31": [
|
||||
{
|
||||
"cvssData": {
|
||||
"attackComplexity": "LOW",
|
||||
"attackVector": "NETWORK",
|
||||
"availabilityImpact": "NONE",
|
||||
"baseScore": 7.5,
|
||||
"baseSeverity": "HIGH",
|
||||
"confidentialityImpact": "HIGH",
|
||||
"integrityImpact": "NONE",
|
||||
"privilegesRequired": "NONE",
|
||||
"scope": "UNCHANGED",
|
||||
"userInteraction": "NONE",
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
|
||||
"version": "3.1"
|
||||
},
|
||||
"exploitabilityScore": 3.9,
|
||||
"impactScore": 3.6,
|
||||
"source": "nvd@nist.gov",
|
||||
"type": "Primary"
|
||||
}
|
||||
]
|
||||
},
|
||||
"published": "2026-02-24T13:07:08.000",
|
||||
"references": [
|
||||
{
|
||||
"source": "nvd@nist.gov",
|
||||
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-40001"
|
||||
}
|
||||
],
|
||||
"sourceIdentifier": "nvd@nist.gov",
|
||||
"vulnStatus": "Analyzed",
|
||||
"weaknesses": [
|
||||
{
|
||||
"description": [
|
||||
{
|
||||
"lang": "en",
|
||||
"value": "CWE-200"
|
||||
}
|
||||
],
|
||||
"source": "nvd@nist.gov",
|
||||
"type": "Primary"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/nvd/schema-1.0.1.json"
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"identifier": "2026/cve-2026-40003",
|
||||
"item": {
|
||||
"cve": {
|
||||
"configurations": [
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"cpeMatch": [
|
||||
{
|
||||
"criteria": "cpe:2.3:a:acme:gizmo:*:*:*:*:*:*:*:*",
|
||||
"matchCriteriaId": "55555555-5555-5555-5555-555555555555",
|
||||
"versionEndExcluding": "2.0.0",
|
||||
"versionStartIncluding": "1.0.0",
|
||||
"vulnerable": true
|
||||
}
|
||||
],
|
||||
"negate": false,
|
||||
"operator": "OR"
|
||||
},
|
||||
{
|
||||
"cpeMatch": [
|
||||
{
|
||||
"criteria": "cpe:2.3:a:acme:gizmo:*:*:*:*:*:embedded-mod:*:*",
|
||||
"matchCriteriaId": "66666666-6666-6666-6666-666666666666",
|
||||
"versionEndIncluding": "1.5.0",
|
||||
"vulnerable": true
|
||||
}
|
||||
],
|
||||
"negate": false,
|
||||
"operator": "OR"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cveTags": [],
|
||||
"descriptions": [
|
||||
{
|
||||
"lang": "en",
|
||||
"value": "Synthetic record modeled on CVE-2017-9229 (php:php): the same vendor:product is listed twice, once with an unqualified target software and a fixed range, and once for a specific target software with no fix. A single package CPE matches both records."
|
||||
}
|
||||
],
|
||||
"id": "CVE-2026-40003",
|
||||
"lastModified": "2026-03-01T00:00:00.000",
|
||||
"metrics": {
|
||||
"cvssMetricV31": [
|
||||
{
|
||||
"cvssData": {
|
||||
"attackComplexity": "LOW",
|
||||
"attackVector": "NETWORK",
|
||||
"availabilityImpact": "HIGH",
|
||||
"baseScore": 7.5,
|
||||
"baseSeverity": "HIGH",
|
||||
"confidentialityImpact": "NONE",
|
||||
"integrityImpact": "NONE",
|
||||
"privilegesRequired": "NONE",
|
||||
"scope": "UNCHANGED",
|
||||
"userInteraction": "NONE",
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
|
||||
"version": "3.1"
|
||||
},
|
||||
"exploitabilityScore": 3.9,
|
||||
"impactScore": 3.6,
|
||||
"source": "nvd@nist.gov",
|
||||
"type": "Primary"
|
||||
}
|
||||
]
|
||||
},
|
||||
"published": "2026-02-24T13:07:08.000",
|
||||
"references": [
|
||||
{
|
||||
"source": "nvd@nist.gov",
|
||||
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-40003"
|
||||
}
|
||||
],
|
||||
"sourceIdentifier": "nvd@nist.gov",
|
||||
"vulnStatus": "Analyzed",
|
||||
"weaknesses": [
|
||||
{
|
||||
"description": [
|
||||
{
|
||||
"lang": "en",
|
||||
"value": "CWE-400"
|
||||
}
|
||||
],
|
||||
"source": "nvd@nist.gov",
|
||||
"type": "Primary"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/nvd/schema-1.0.1.json"
|
||||
}
|
||||
@@ -360,7 +360,6 @@ func finalizeMatchDetails(processedResult *result.Result, originalDetails match.
|
||||
|
||||
// keep details around only if we have vulnerabilities they describe
|
||||
processedResult.Details = append(processedResult.Details, originalDetails...)
|
||||
processedResult.Details = result.NewMatchDetailsSet(processedResult.Details...).ToSlice()
|
||||
|
||||
// patch the version in the details if it is missing
|
||||
for idx := range processedResult.Details {
|
||||
|
||||
@@ -274,7 +274,8 @@ func TestResolveEUSDisclosures(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}},
|
||||
// the disclosure and the advisory each contribute a detail, collapsed by match.Matches.Add
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}, {Type: match.ExactDirectMatch}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -399,7 +400,7 @@ func TestResolveEUSDisclosures(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}, {Type: match.ExactDirectMatch}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -554,7 +555,7 @@ func TestResolveEUSDisclosures(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}, {Type: match.ExactDirectMatch}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -741,7 +742,7 @@ func TestResolveEUSDisclosures(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}},
|
||||
Details: []match.Detail{{Type: match.ExactDirectMatch}, {Type: match.ExactDirectMatch}},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -153,7 +153,7 @@ func (m *VulnerabilityMatcher) findDBMatches(ctx context.Context, pkgs []pkg.Pac
|
||||
|
||||
if m.NormalizeByCVE {
|
||||
normalizedMatches := match.NewMatches()
|
||||
for originalMatch := range matches.Enumerate() {
|
||||
for _, originalMatch := range matches.Sorted() {
|
||||
normalizedMatches.Add(m.normalizeByCVE(originalMatch))
|
||||
}
|
||||
|
||||
|
||||
@@ -728,6 +728,17 @@ func (s *SingleFindingAssertion) HasFix(state vulnerability.FixState, versions .
|
||||
return s
|
||||
}
|
||||
|
||||
// HasNoFixVersions asserts the match's vulnerability carries no fix versions at
|
||||
// all. HasFix skips its version check when passed none, so this is the only way
|
||||
// to pin the empty case - which matters wherever a fix version from one source
|
||||
// must not be reported against a package from another (e.g. an NVD upstream
|
||||
// release leaking onto a distro package).
|
||||
func (s *SingleFindingAssertion) HasNoFixVersions() *SingleFindingAssertion {
|
||||
s.t.Helper()
|
||||
assert.Empty(s.t, s.match.Vulnerability.Fix.Versions, "expected no fix versions")
|
||||
return s
|
||||
}
|
||||
|
||||
// HasAdvisories asserts the match's vulnerability has exactly the given
|
||||
// advisory IDs (order doesn't matter, but the count must match).
|
||||
func (s *SingleFindingAssertion) HasAdvisories(ids ...string) *SingleFindingAssertion {
|
||||
@@ -895,9 +906,19 @@ func (s *SingleFindingAssertion) SelectDetailByDistro(distroType, distroVersion
|
||||
// and validates the found vulnerability.
|
||||
// Fails if not exactly one detail matches.
|
||||
// Takes an optional version constraint to validate (0 = no assertion, 1 = assert, 2+ = error).
|
||||
//
|
||||
// When a constraint is given it also disambiguates: one package CPE can match several records of the
|
||||
// same vulnerability (NVD stores one record per vulnerable CPE, e.g. an unqualified entry plus one
|
||||
// scoped to a target software), which merge into a single finding carrying a detail per record. Those
|
||||
// details share a searched-by CPE and differ only by the record they found.
|
||||
func (s *SingleFindingAssertion) SelectDetailByCPE(cpe string, constraint ...string) *CPEDetailAssertion {
|
||||
s.t.Helper()
|
||||
|
||||
if len(constraint) > 1 {
|
||||
s.t.Fatalf("SelectDetailByCPE accepts at most one constraint argument, got %d", len(constraint))
|
||||
return nil
|
||||
}
|
||||
|
||||
var matchedIdx = -1
|
||||
var matched *match.Detail
|
||||
var searchedBy match.CPEParameters
|
||||
@@ -917,25 +938,33 @@ func (s *SingleFindingAssertion) SelectDetailByCPE(cpe string, constraint ...str
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasCPE {
|
||||
if matched != nil {
|
||||
s.t.Fatalf("SelectDetailByCPE expected exactly one detail with CPE %q, but found multiple", cpe)
|
||||
return nil
|
||||
}
|
||||
matchedIdx = i
|
||||
matched = d
|
||||
searchedBy = sb
|
||||
f, ok := d.Found.(match.CPEResult)
|
||||
if !ok {
|
||||
s.t.Fatalf("expected Found to be CPEResult, got %T", d.Found)
|
||||
return nil
|
||||
}
|
||||
found = f
|
||||
if !hasCPE {
|
||||
continue
|
||||
}
|
||||
|
||||
f, ok := d.Found.(match.CPEResult)
|
||||
if !ok {
|
||||
s.t.Fatalf("expected Found to be CPEResult, got %T", d.Found)
|
||||
return nil
|
||||
}
|
||||
|
||||
// when given, the constraint identifies which of the CPE's records this assertion is about
|
||||
if len(constraint) == 1 && f.VersionConstraint != constraint[0] {
|
||||
continue
|
||||
}
|
||||
|
||||
if matched != nil {
|
||||
s.t.Fatalf("SelectDetailByCPE expected exactly one detail with CPE %q (constraint %v), but found multiple", cpe, constraint)
|
||||
return nil
|
||||
}
|
||||
matchedIdx = i
|
||||
matched = d
|
||||
searchedBy = sb
|
||||
found = f
|
||||
}
|
||||
|
||||
if matched == nil {
|
||||
s.t.Fatalf("SelectDetailByCPE found no detail with CPE %q", cpe)
|
||||
s.t.Fatalf("SelectDetailByCPE found no detail with CPE %q (constraint %v)", cpe, constraint)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -927,7 +927,24 @@ func TestMatchByImage(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
result.Details = result.Details[:len(result.Details)-1]
|
||||
// the VEX processor contributes exactly one detail to the match it un-ignores and leaves
|
||||
// the rest of the match alone. Matches.Add normalizes the details of every match it
|
||||
// stores -- deduped and sorted strongest-first -- so the VEX detail is not necessarily
|
||||
// the last one; select it by its matcher and compare what remains to the original match.
|
||||
var vexDetails, originalDetails match.Details
|
||||
for _, detail := range result.Details {
|
||||
switch detail.Matcher {
|
||||
case match.OpenVexMatcher, match.CsafVexMatcher:
|
||||
vexDetails = append(vexDetails, detail)
|
||||
default:
|
||||
originalDetails = append(originalDetails, detail)
|
||||
}
|
||||
}
|
||||
if len(vexDetails) != 1 {
|
||||
t.Errorf("expected exactly one VEX detail on the VEXed result, got %d", len(vexDetails))
|
||||
}
|
||||
|
||||
result.Details = originalDetails
|
||||
actualResults := match.NewMatches()
|
||||
actualResults.Add(result)
|
||||
|
||||
|
||||
Reference in new issue
Block a user