diff --git a/grype/match/details.go b/grype/match/details.go index 1b6527658d3..aeeabad98b2 100644 --- a/grype/match/details.go +++ b/grype/match/details.go @@ -2,6 +2,7 @@ package match import ( "fmt" + "strings" "github.com/mitchellh/hashstructure/v2" ) @@ -52,3 +53,38 @@ func (m Detail) ID() string { return fmt.Sprintf("%x", f) } + +func (m Details) Len() int { + return len(m) +} + +func (m Details) Less(i, j int) bool { + a := m[i] + b := m[j] + + 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 + } + + // sort by confidence + if a.Confidence != b.Confidence { + // flipped comparison since we want higher confidence to be first + return a.Confidence > b.Confidence + } + + // if the types are the same, then sort by the ID (costly, but deterministic) + return strings.Compare(a.ID(), b.ID()) < 0 +} + +func (m Details) Swap(i, j int) { + m[i], m[j] = m[j], m[i] +} diff --git a/grype/match/details_test.go b/grype/match/details_test.go new file mode 100644 index 00000000000..a7ba5f473e3 --- /dev/null +++ b/grype/match/details_test.go @@ -0,0 +1,158 @@ +package match + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetails_Sorting(t *testing.T) { + + detailExactDirectHigh := Detail{ + Type: ExactDirectMatch, + Confidence: 0.9, + SearchedBy: "attribute1", + Found: "value1", + Matcher: "matcher1", + } + detailExactDirectLow := Detail{ + Type: ExactDirectMatch, + Confidence: 0.5, + SearchedBy: "attribute1", + Found: "value1", + Matcher: "matcher1", + } + detailExactIndirect := Detail{ + Type: ExactIndirectMatch, + Confidence: 0.7, + SearchedBy: "attribute2", + Found: "value2", + Matcher: "matcher2", + } + detailCPEMatch := Detail{ + Type: CPEMatch, + Confidence: 0.8, + SearchedBy: "attribute3", + Found: "value3", + Matcher: "matcher3", + } + + tests := []struct { + name string + details Details + expected Details + }{ + { + name: "sorts by type first, then by confidence", + details: Details{ + detailCPEMatch, + detailExactDirectHigh, + detailExactIndirect, + detailExactDirectLow, + }, + expected: Details{ + detailExactDirectHigh, + detailExactDirectLow, + detailExactIndirect, + detailCPEMatch, + }, + }, + { + name: "sorts by confidence within the same type", + details: Details{ + detailExactDirectLow, + detailExactDirectHigh, + }, + expected: Details{ + detailExactDirectHigh, + detailExactDirectLow, + }, + }, + { + name: "sorts by ID when type and confidence are the same", + details: Details{ + // clone of detailExactDirectLow with slight difference to enforce ID sorting + { + Type: ExactDirectMatch, + Confidence: 0.5, + SearchedBy: "attribute2", + Found: "value2", + Matcher: "matcher2", + }, + detailExactDirectLow, + }, + expected: Details{ + detailExactDirectLow, + { + Type: ExactDirectMatch, + Confidence: 0.5, + SearchedBy: "attribute2", + Found: "value2", + Matcher: "matcher2", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sort.Sort(tt.details) + require.Equal(t, tt.expected, tt.details) + }) + } +} + +func TestHasExclusivelyAnyMatchTypes(t *testing.T) { + tests := []struct { + name string + details Details + types []Type + expected bool + }{ + { + name: "all types allowed", + details: Details{{Type: "A"}, {Type: "B"}}, + types: []Type{"A", "B"}, + expected: true, + }, + { + name: "mixed types with disallowed", + details: Details{{Type: "A"}, {Type: "B"}, {Type: "C"}}, + types: []Type{"A", "B"}, + expected: false, + }, + { + name: "single allowed type", + details: Details{{Type: "A"}}, + types: []Type{"A"}, + expected: true, + }, + { + name: "empty details", + 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, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasExclusivelyAnyMatchTypes(tt.details, tt.types...) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/grype/match/fingerprint.go b/grype/match/fingerprint.go index 21434e2d21b..d4950ee65c3 100644 --- a/grype/match/fingerprint.go +++ b/grype/match/fingerprint.go @@ -9,9 +9,13 @@ import ( ) type Fingerprint struct { + coreFingerprint + vulnerabilityFixes string +} + +type coreFingerprint struct { vulnerabilityID string vulnerabilityNamespace string - vulnerabilityFixes string packageID pkg.ID // note: this encodes package name, version, type, location } diff --git a/grype/match/match.go b/grype/match/match.go index d7982335ef6..f9c5c469bed 100644 --- a/grype/match/match.go +++ b/grype/match/match.go @@ -18,7 +18,7 @@ var ErrCannotMerge = fmt.Errorf("unable to merge vulnerability matches") type Match struct { Vulnerability vulnerability.Vulnerability // The vulnerability details of the match. Package pkg.Package // The package used to search for a match. - Details Details // all ways in which how this particular match was made. + Details Details // all the ways this particular match was made. } // String is the string representation of select match fields. @@ -28,10 +28,12 @@ func (m Match) String() string { func (m Match) Fingerprint() Fingerprint { return Fingerprint{ - vulnerabilityID: m.Vulnerability.ID, - vulnerabilityNamespace: m.Vulnerability.Namespace, - vulnerabilityFixes: strings.Join(m.Vulnerability.Fix.Versions, ","), - packageID: m.Package.ID, + coreFingerprint: coreFingerprint{ + vulnerabilityID: m.Vulnerability.ID, + vulnerabilityNamespace: m.Vulnerability.Namespace, + packageID: m.Package.ID, + }, + vulnerabilityFixes: strings.Join(m.Vulnerability.Fix.Versions, ","), } } @@ -73,11 +75,7 @@ func (m *Match) Merge(other Match) error { } // for stable output - sort.Slice(m.Details, func(i, j int) bool { - a := m.Details[i] - b := m.Details[j] - return strings.Compare(a.ID(), b.ID()) < 0 - }) + sort.Sort(m.Details) // retain all unique CPEs for consistent output m.Vulnerability.CPEs = cpe.Merge(m.Vulnerability.CPEs, other.Vulnerability.CPEs) diff --git a/grype/match/match_test.go b/grype/match/match_test.go new file mode 100644 index 00000000000..bb9f93867c4 --- /dev/null +++ b/grype/match/match_test.go @@ -0,0 +1,233 @@ +package match + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/vulnerability" + "github.com/anchore/syft/syft/cpe" +) + +func TestMatch_Merge(t *testing.T) { + tests := []struct { + name string + m1 Match + m2 Match + expectedErr error + expected Match + }{ + { + name: "error on fingerprint mismatch", + m1: Match{ + Vulnerability: vulnerability.Vulnerability{ + ID: "CVE-2023-0001", + Namespace: "namespace1", + }, + Package: pkg.Package{ + ID: "pkg1", + }, + }, + m2: Match{ + Vulnerability: vulnerability.Vulnerability{ + ID: "CVE-2023-0002", + Namespace: "namespace2", + }, + Package: pkg.Package{ + ID: "pkg2", + }, + }, + expectedErr: ErrCannotMerge, + }, + { + name: "merge with unique values", + m1: Match{ + Vulnerability: vulnerability.Vulnerability{ + ID: "CVE-2023-0001", + Namespace: "namespace", + RelatedVulnerabilities: []vulnerability.Reference{ + { + Namespace: "ns1", + ID: "ID1", + }, + }, + CPEs: []cpe.CPE{ + cpe.Must("cpe:2.3:a:example:example:1.0:*:*:*:*:*:*:*", cpe.DeclaredSource), + }, + }, + Package: pkg.Package{ + ID: "pkg1", + }, + Details: Details{ + { + Type: ExactDirectMatch, + SearchedBy: "attr1", + Found: "value1", + Matcher: "matcher1", + }, + }, + }, + m2: Match{ + Vulnerability: vulnerability.Vulnerability{ + ID: "CVE-2023-0001", + Namespace: "namespace", + RelatedVulnerabilities: []vulnerability.Reference{ + { + Namespace: "ns2", + ID: "ID2", + }, + }, + CPEs: []cpe.CPE{ + cpe.Must("cpe:2.3:a:example:example:1.1:*:*:*:*:*:*:*", cpe.DeclaredSource), + }, + }, + Package: pkg.Package{ + ID: "pkg1", + }, + Details: Details{ + { + Type: ExactIndirectMatch, + SearchedBy: "attr2", + Found: "value2", + Matcher: "matcher2", + }, + }, + }, + expectedErr: nil, + expected: Match{ + Vulnerability: vulnerability.Vulnerability{ + ID: "CVE-2023-0001", + Namespace: "namespace", + RelatedVulnerabilities: []vulnerability.Reference{ + { + Namespace: "ns1", + ID: "ID1", + }, + { + Namespace: "ns2", + ID: "ID2", + }, + }, + CPEs: []cpe.CPE{ + cpe.Must("cpe:2.3:a:example:example:1.0:*:*:*:*:*:*:*", cpe.DeclaredSource), + cpe.Must("cpe:2.3:a:example:example:1.1:*:*:*:*:*:*:*", cpe.DeclaredSource), + }, + }, + Package: pkg.Package{ + ID: "pkg1", + }, + Details: Details{ + { + Type: ExactDirectMatch, + SearchedBy: "attr1", + Found: "value1", + Matcher: "matcher1", + }, + { + Type: ExactIndirectMatch, + SearchedBy: "attr2", + Found: "value2", + Matcher: "matcher2", + }, + }, + }, + }, + { + name: "merges with duplicate values", + m1: Match{ + Vulnerability: vulnerability.Vulnerability{ + ID: "CVE-2023-0001", + Namespace: "namespace", + RelatedVulnerabilities: []vulnerability.Reference{ + { + Namespace: "ns1", + ID: "ID1", + }, + }, + CPEs: []cpe.CPE{ + cpe.Must("cpe:2.3:a:example:example:1.0:*:*:*:*:*:*:*", cpe.DeclaredSource), + }, + }, + Package: pkg.Package{ + ID: "pkg1", + }, + Details: Details{ + { + Type: ExactDirectMatch, + SearchedBy: "attr1", + Found: "value1", + Matcher: "matcher1", + }, + }, + }, + m2: Match{ + Vulnerability: vulnerability.Vulnerability{ + ID: "CVE-2023-0001", + Namespace: "namespace", + RelatedVulnerabilities: []vulnerability.Reference{ + { + Namespace: "ns1", + ID: "ID1", + }, + }, + CPEs: []cpe.CPE{ + cpe.Must("cpe:2.3:a:example:example:1.0:*:*:*:*:*:*:*", cpe.DeclaredSource), + }, + }, + Package: pkg.Package{ + ID: "pkg1", + }, + Details: Details{ + { + Type: ExactDirectMatch, + SearchedBy: "attr1", + Found: "value1", + Matcher: "matcher1", + }, + }, + }, + expectedErr: nil, + expected: Match{ + Vulnerability: vulnerability.Vulnerability{ + ID: "CVE-2023-0001", + Namespace: "namespace", + RelatedVulnerabilities: []vulnerability.Reference{ + { + Namespace: "ns1", + ID: "ID1", + }, + }, + CPEs: []cpe.CPE{ + cpe.Must("cpe:2.3:a:example:example:1.0:*:*:*:*:*:*:*", cpe.DeclaredSource), + }, + }, + Package: pkg.Package{ + ID: "pkg1", + }, + Details: Details{ + { + Type: ExactDirectMatch, + SearchedBy: "attr1", + Found: "value1", + Matcher: "matcher1", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.m1.Merge(tt.m2) + if tt.expectedErr != nil { + require.ErrorIs(t, err, tt.expectedErr) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected.Vulnerability.RelatedVulnerabilities, tt.m1.Vulnerability.RelatedVulnerabilities) + require.Equal(t, tt.expected.Details, tt.m1.Details) + require.Equal(t, tt.expected.Vulnerability.CPEs, tt.m1.Vulnerability.CPEs) + } + }) + } +} diff --git a/grype/match/matches.go b/grype/match/matches.go index e469c6dd3a1..264703920e8 100644 --- a/grype/match/matches.go +++ b/grype/match/matches.go @@ -3,13 +3,16 @@ 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 - byPackage map[pkg.ID][]Fingerprint + byFingerprint map[Fingerprint]Match + byCoreFingerprint map[coreFingerprint]map[Fingerprint]struct{} + byPackage map[pkg.ID]map[Fingerprint]struct{} } func NewMatches(matches ...Match) Matches { @@ -20,14 +23,15 @@ func NewMatches(matches ...Match) Matches { func newMatches() Matches { return Matches{ - byFingerprint: make(map[Fingerprint]Match), - byPackage: make(map[pkg.ID][]Fingerprint), + byFingerprint: make(map[Fingerprint]Match), + byCoreFingerprint: make(map[coreFingerprint]map[Fingerprint]struct{}), + byPackage: make(map[pkg.ID]map[Fingerprint]struct{}), } } // GetByPkgID returns a slice of potential matches from an ID func (r *Matches) GetByPkgID(id pkg.ID) (matches []Match) { - for _, fingerprint := range r.byPackage[id] { + for fingerprint := range r.byPackage[id] { matches = append(matches, r.byFingerprint[fingerprint]) } return matches @@ -37,7 +41,7 @@ func (r *Matches) GetByPkgID(id pkg.ID) (matches []Match) { func (r *Matches) AllByPkgID() map[pkg.ID][]Match { matches := make(map[pkg.ID][]Match) for id, fingerprints := range r.byPackage { - for _, fingerprint := range fingerprints { + for fingerprint := range fingerprints { matches[id] = append(matches[id], r.byFingerprint[fingerprint]) } } @@ -46,7 +50,7 @@ func (r *Matches) AllByPkgID() map[pkg.ID][]Match { func (r *Matches) Merge(other Matches) { for _, fingerprints := range other.byPackage { - for _, fingerprint := range fingerprints { + for fingerprint := range fingerprints { r.Add(other.byFingerprint[fingerprint]) } } @@ -63,26 +67,99 @@ func (r *Matches) Diff(other Matches) *Matches { } func (r *Matches) Add(matches ...Match) { - if len(matches) == 0 { - return - } for _, newMatch := range matches { - fingerprint := newMatch.Fingerprint() + newFp := newMatch.Fingerprint() // add or merge the new match with an existing match - if existingMatch, exists := r.byFingerprint[fingerprint]; exists { - if err := existingMatch.Merge(newMatch); err != nil { - log.Warnf("unable to merge matches: original=%q new=%q : %w", existingMatch.String(), newMatch.String(), err) - // TODO: dropped match in this case, we should figure a way to handle this + r.addOrMerge(newMatch, newFp) + + // track common elements (core fingerprint + package index) + + if _, exists := r.byCoreFingerprint[newFp.coreFingerprint]; !exists { + r.byCoreFingerprint[newFp.coreFingerprint] = make(map[Fingerprint]struct{}) + } + + 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{}{} + } +} + +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") + // TODO: dropped match in this case, we should figure a way to handle this + } + + 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") + } else { + return true } - r.byFingerprint[fingerprint] = existingMatch + } + + // case B2 + if err := existingMatch.Merge(newMatch); err != nil { + log.WithFields("original", existingMatch.String(), "new", newMatch.String(), "error", err).Warn("unable to merge matches") } else { - r.byFingerprint[fingerprint] = newMatch + return true } + } + return false +} - // keep track of which matches correspond to which packages - r.byPackage[newMatch.Package.ID] = append(r.byPackage[newMatch.Package.ID], fingerprint) +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 { @@ -111,3 +188,28 @@ 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 +} diff --git a/grype/match/matches_test.go b/grype/match/matches_test.go index 8ea1b9f504e..b0b869ff989 100644 --- a/grype/match/matches_test.go +++ b/grype/match/matches_test.go @@ -3,11 +3,14 @@ package match import ( "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/version" "github.com/anchore/grype/grype/vulnerability" "github.com/anchore/syft/syft/file" syftPkg "github.com/anchore/syft/syft/pkg" @@ -413,3 +416,237 @@ func TestMatches_Diff(t *testing.T) { }) } } + +func TestMatches_Add_Merge(t *testing.T) { + commonVuln := "CVE-2023-0001" + commonNamespace := "namespace1" + commonVulnerability := vulnerability.Vulnerability{ + ID: commonVuln, + Namespace: commonNamespace, + Constraint: func() version.Constraint { + c, err := version.GetConstraint("< 1.0.0", version.SemanticFormat) + require.NoError(t, err) + return c + }(), + Fix: vulnerability.Fix{ + Versions: []string{"1.0.0"}, + }, + } + + commonDirectDetail := Detail{ + Type: ExactDirectMatch, + SearchedBy: "attr1", + Found: "value1", + Matcher: "matcher1", + } + + matchPkg1Direct := Match{ + Vulnerability: commonVulnerability, + Package: pkg.Package{ + ID: "pkg1", + }, + Details: Details{ + commonDirectDetail, + }, + } + + matchPkg2Indirect := Match{ + Vulnerability: commonVulnerability, + Package: pkg.Package{ + ID: "pkg2", + }, + Details: Details{ + { + Type: ExactIndirectMatch, + SearchedBy: "attr2", + Found: "value2", + Matcher: "matcher2", + }, + }, + } + + tests := []struct { + name string + matches []Match + expectedMatches map[string][]Match + }{ + { + name: "adds new match without merging", + matches: []Match{matchPkg1Direct, matchPkg2Indirect}, + expectedMatches: map[string][]Match{ + "pkg1": { + matchPkg1Direct, + }, + "pkg2": { + matchPkg2Indirect, + }, + }, + }, + { + name: "merges matches with identical fingerprints", + matches: []Match{ + matchPkg1Direct, + { + Vulnerability: matchPkg1Direct.Vulnerability, + Package: matchPkg1Direct.Package, + Details: Details{ + { + Type: ExactIndirectMatch, // different! + SearchedBy: "attr2", // different! + Found: "value2", // different! + Matcher: "matcher2", // different! + }, + }, + }, + }, + expectedMatches: map[string][]Match{ + "pkg1": { + { + Vulnerability: commonVulnerability, + Package: matchPkg1Direct.Package, + Details: Details{ + commonDirectDetail, + { + Type: ExactIndirectMatch, + SearchedBy: "attr2", + Found: "value2", + Matcher: "matcher2", + }, + }, + }, + }, + }, + }, + { + name: "merges matches with different fingerprints but semantically the same", + matches: []Match{ + { + Vulnerability: vulnerability.Vulnerability{ + 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, + Details: Details{ + { + Type: ExactIndirectMatch, // different! + SearchedBy: "attr1", + Found: "value1", + Matcher: "matcher1", + }, + }, + }, + matchPkg1Direct, + }, + expectedMatches: map[string][]Match{ + "pkg1": { + { + Vulnerability: commonVulnerability, + Package: matchPkg1Direct.Package, + Details: Details{ + commonDirectDetail, // sorts to first (direct should be prioritized over indirect) + { + Type: ExactIndirectMatch, // different! + SearchedBy: "attr1", + Found: "value1", + Matcher: "matcher1", + }, + }, + }, + }, + }, + }, + { + name: "does not merge matches with different fingerprints but semantically the same when matched by CPE", + matches: []Match{ + { + Vulnerability: vulnerability.Vulnerability{ + 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, + Details: Details{ + { + Type: CPEMatch, // different! + SearchedBy: "attr1", + Found: "value1", + Matcher: "matcher1", + }, + }, + }, + matchPkg1Direct, + }, + expectedMatches: map[string][]Match{ + "pkg1": { + { + Vulnerability: vulnerability.Vulnerability{ + 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, + Details: Details{ + { + Type: CPEMatch, // different! + SearchedBy: "attr1", + Found: "value1", + Matcher: "matcher1", + }, + }, + }, + matchPkg1Direct, + }, + }, + }, + } + + cmpOpts := []cmp.Option{ + cmpopts.IgnoreUnexported(vulnerability.Vulnerability{}, pkg.Package{}, file.Location{}, file.LocationSet{}), + cmpopts.IgnoreFields(vulnerability.Vulnerability{}, "Constraint"), + cmpopts.EquateEmpty(), + cmpopts.SortSlices(func(a, b Match) bool { + return ByElements([]Match{a, b}).Less(0, 1) + }), + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := NewMatches(tt.matches...) + + require.NotEmpty(t, tt.expectedMatches) + + for pkgId, expected := range tt.expectedMatches { + storedMatches := actual.GetByPkgID(pkg.ID(pkgId)) + + if d := cmp.Diff(expected, storedMatches, cmpOpts...); d != "" { + t.Errorf("unexpected matches for %q (-want, +got): %s", pkgId, d) + } + } + + assert.Len(t, actual.byPackage, len(tt.expectedMatches)) + + }) + } +} diff --git a/grype/match/type.go b/grype/match/type.go index 7f4573667a3..fa54ec1a45b 100644 --- a/grype/match/type.go +++ b/grype/match/type.go @@ -10,6 +10,12 @@ const ( CPEMatch Type = "cpe-match" ) +var typeOrder = map[Type]int{ + ExactDirectMatch: 1, + ExactIndirectMatch: 2, + CPEMatch: 3, +} + type Type string func ConvertToIndirectMatches(matches []Match, p pkg.Package) { diff --git a/grype/vulnerability/vulnerability.go b/grype/vulnerability/vulnerability.go index 1acccb1f575..04216c8c756 100644 --- a/grype/vulnerability/vulnerability.go +++ b/grype/vulnerability/vulnerability.go @@ -72,7 +72,11 @@ func NewVulnerability(vuln grypeDB.Vulnerability) (*Vulnerability, error) { } func (v Vulnerability) String() string { - return fmt.Sprintf("Vuln(id=%s constraint=%q qualifiers=%+v)", v.ID, v.Constraint.String(), v.PackageQualifiers) + constraint := "(none)" + if v.Constraint != nil { + constraint = v.Constraint.String() + } + return fmt.Sprintf("Vuln(id=%s constraint=%q qualifiers=%+v)", v.ID, constraint, v.PackageQualifiers) } func (v *Vulnerability) hash() string { diff --git a/grype/vulnerability_matcher_test.go b/grype/vulnerability_matcher_test.go index 69dd41336d5..34615e8503f 100644 --- a/grype/vulnerability_matcher_test.go +++ b/grype/vulnerability_matcher_test.go @@ -689,6 +689,20 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) { }, Package: activerecordPkg, Details: match.Details{ + { + Type: match.ExactDirectMatch, + SearchedBy: map[string]any{ + "language": "ruby", + "namespace": "github:language:ruby", + "package": map[string]string{"name": "activerecord", "version": "3.7.5"}, + }, + Found: map[string]any{ + "versionConstraint": "< 3.7.6 (unknown)", + "vulnerabilityID": "GHSA-2014-fake-3", + }, + Matcher: "ruby-gem-matcher", + Confidence: 1, + }, { Type: match.CPEMatch, SearchedBy: search.CPEParameters{ @@ -711,20 +725,6 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) { Matcher: "ruby-gem-matcher", Confidence: 0.9, }, - { - Type: match.ExactDirectMatch, - SearchedBy: map[string]any{ - "language": "ruby", - "namespace": "github:language:ruby", - "package": map[string]string{"name": "activerecord", "version": "3.7.5"}, - }, - Found: map[string]any{ - "versionConstraint": "< 3.7.6 (unknown)", - "vulnerabilityID": "GHSA-2014-fake-3", - }, - Matcher: "ruby-gem-matcher", - Confidence: 1, - }, }, }, ), diff --git a/test/integration/match_by_image_test.go b/test/integration/match_by_image_test.go index 0adbb0fc8f8..a1305675572 100644 --- a/test/integration/match_by_image_test.go +++ b/test/integration/match_by_image_test.go @@ -55,8 +55,8 @@ func addAlpineMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Co Package: thePkg, Details: []match.Detail{ { - // note: the input pURL has an upstream reference (redundant) - Type: "exact-indirect-match", + Type: match.ExactDirectMatch, + Confidence: 1.0, SearchedBy: map[string]any{ "distro": map[string]string{ "type": "alpine", @@ -70,15 +70,14 @@ func addAlpineMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Co }, Found: map[string]any{ "versionConstraint": "< 0.9.10 (unknown)", - "vulnerabilityID": "CVE-alpine-libvncserver", + "vulnerabilityID": vulnObj.ID, }, - Matcher: "apk-matcher", - Confidence: 1, + Matcher: match.ApkMatcher, }, { - Type: match.ExactDirectMatch, - Confidence: 1.0, - SearchedBy: map[string]interface{}{ + // note: the input pURL has an upstream reference (redundant) + Type: "exact-indirect-match", + SearchedBy: map[string]any{ "distro": map[string]string{ "type": "alpine", "version": "3.12.0", @@ -89,11 +88,12 @@ func addAlpineMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Co "version": "0.9.9", }, }, - Found: map[string]interface{}{ + Found: map[string]any{ "versionConstraint": "< 0.9.10 (unknown)", - "vulnerabilityID": vulnObj.ID, + "vulnerabilityID": "CVE-alpine-libvncserver", }, - Matcher: match.ApkMatcher, + Matcher: "apk-matcher", + Confidence: 1, }, }, }) @@ -117,7 +117,7 @@ func addJavascriptMatches(t *testing.T, theSource source.Source, catalog *syftPk { Type: match.ExactDirectMatch, Confidence: 1.0, - SearchedBy: map[string]interface{}{ + SearchedBy: map[string]any{ "language": "javascript", "namespace": "github:language:javascript", "package": map[string]string{ @@ -125,7 +125,7 @@ func addJavascriptMatches(t *testing.T, theSource source.Source, catalog *syftPk "version": thePkg.Version, }, }, - Found: map[string]interface{}{ + Found: map[string]any{ "versionConstraint": "> 5, < 7.2.1 (unknown)", "vulnerabilityID": vulnObj.ID, }, @@ -157,7 +157,7 @@ func addPythonMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Co { Type: match.ExactDirectMatch, Confidence: 1.0, - SearchedBy: map[string]interface{}{ + SearchedBy: map[string]any{ "language": "python", "namespace": "github:language:python", "package": map[string]string{ @@ -165,7 +165,7 @@ func addPythonMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Co "version": thePkg.Version, }, }, - Found: map[string]interface{}{ + Found: map[string]any{ "versionConstraint": "< 2.6.2 (python)", "vulnerabilityID": vulnObj.ID, }, @@ -197,7 +197,7 @@ func addDotnetMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Co { Type: match.ExactDirectMatch, Confidence: 1.0, - SearchedBy: map[string]interface{}{ + SearchedBy: map[string]any{ "language": "dotnet", "namespace": "github:language:dotnet", "package": map[string]string{ @@ -205,7 +205,7 @@ func addDotnetMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Co "version": thePkg.Version, }, }, - Found: map[string]interface{}{ + Found: map[string]any{ "versionConstraint": ">= 3.7.0.0, < 3.7.12.0 (unknown)", "vulnerabilityID": vulnObj.ID, }, @@ -233,7 +233,7 @@ func addRubyMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Coll { Type: match.ExactDirectMatch, Confidence: 1.0, - SearchedBy: map[string]interface{}{ + SearchedBy: map[string]any{ "language": "ruby", "namespace": "github:language:ruby", "package": map[string]string{ @@ -241,7 +241,7 @@ func addRubyMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Coll "version": thePkg.Version, }, }, - Found: map[string]interface{}{ + Found: map[string]any{ "versionConstraint": "> 2.0.0, <= 2.1.4 (unknown)", "vulnerabilityID": vulnObj.ID, }, @@ -291,7 +291,7 @@ func addGolangMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Co { Type: match.ExactDirectMatch, Confidence: 1.0, - SearchedBy: map[string]interface{}{ + SearchedBy: map[string]any{ "language": "go", "namespace": "github:language:go", "package": map[string]string{ @@ -299,7 +299,7 @@ func addGolangMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Co "version": thePkg.Version, }, }, - Found: map[string]interface{}{ + Found: map[string]any{ "versionConstraint": "< 1.4.0 (unknown)", "vulnerabilityID": vulnObj.ID, }, @@ -338,7 +338,7 @@ func addJavaMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Coll { Type: match.ExactDirectMatch, Confidence: 1.0, - SearchedBy: map[string]interface{}{ + SearchedBy: map[string]any{ "language": "java", "namespace": "github:language:java", "package": map[string]string{ @@ -346,7 +346,7 @@ func addJavaMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Coll "version": thePkg.Version, }, }, - Found: map[string]interface{}{ + Found: map[string]any{ "versionConstraint": ">= 0.0.1, < 1.2.0 (unknown)", "vulnerabilityID": vulnObj.ID, }, @@ -375,7 +375,7 @@ func addDpkgMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Coll { Type: match.ExactIndirectMatch, Confidence: 1.0, - SearchedBy: map[string]interface{}{ + SearchedBy: map[string]any{ "distro": map[string]string{ "type": "debian", "version": "8", @@ -386,7 +386,7 @@ func addDpkgMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Coll "version": "1.8.2", }, }, - Found: map[string]interface{}{ + Found: map[string]any{ "versionConstraint": "<= 1.8.2 (deb)", "vulnerabilityID": vulnObj.ID, }, @@ -414,7 +414,7 @@ func addPortageMatches(t *testing.T, theSource source.Source, catalog *syftPkg.C { Type: match.ExactDirectMatch, Confidence: 1.0, - SearchedBy: map[string]interface{}{ + SearchedBy: map[string]any{ "distro": map[string]string{ "type": "gentoo", "version": "2.8", @@ -425,7 +425,7 @@ func addPortageMatches(t *testing.T, theSource source.Source, catalog *syftPkg.C "version": "1.5.1", }, }, - Found: map[string]interface{}{ + Found: map[string]any{ "versionConstraint": "< 1.6.0 (unknown)", "vulnerabilityID": vulnObj.ID, }, @@ -453,7 +453,7 @@ func addRhelMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Coll { Type: match.ExactDirectMatch, Confidence: 1.0, - SearchedBy: map[string]interface{}{ + SearchedBy: map[string]any{ "distro": map[string]string{ "type": "centos", "version": "8", @@ -464,7 +464,7 @@ func addRhelMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Coll "version": "0:0.9.2-1", }, }, - Found: map[string]interface{}{ + Found: map[string]any{ "versionConstraint": "<= 1.0.42 (rpm)", "vulnerabilityID": vulnObj.ID, }, @@ -493,7 +493,7 @@ func addSlesMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Coll { Type: match.ExactDirectMatch, Confidence: 1.0, - SearchedBy: map[string]interface{}{ + SearchedBy: map[string]any{ "distro": map[string]string{ "type": "sles", "version": "12.5", @@ -504,7 +504,7 @@ func addSlesMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Coll "version": "0:0.9.2-1", }, }, - Found: map[string]interface{}{ + Found: map[string]any{ "versionConstraint": "<= 1.0.42 (rpm)", "vulnerabilityID": vulnObj.ID, },