diff --git a/grype/db/v6/build/transformers/os/testdata/rhel-8-not-affected.json b/grype/db/v6/build/transformers/os/testdata/rhel-8-not-affected.json new file mode 100644 index 00000000000..3edf13811c2 --- /dev/null +++ b/grype/db/v6/build/transformers/os/testdata/rhel-8-not-affected.json @@ -0,0 +1,52 @@ +[ + { + "Vulnerability": { + "CVSS": [ + { + "base_metrics": { + "base_score": 7.8, + "base_severity": "High", + "exploitability_score": 1.8, + "impact_score": 5.9 + }, + "status": "draft", + "vector_string": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H", + "version": "3.1" + } + ], + "Description": "Test vulnerability with a not-affected package and an affected package.", + "FixedIn": [ + { + "Name": "ghostscript", + "NamespaceName": "rhel:8", + "VendorAdvisory": { + "AdvisorySummary": [], + "NoAdvisory": false + }, + "Version": "0", + "VersionFormat": "rpm" + }, + { + "Name": "firefox", + "NamespaceName": "rhel:8", + "VendorAdvisory": { + "AdvisorySummary": [ + { + "ID": "RHSA-2020:1341", + "Link": "https://access.redhat.com/errata/RHSA-2020:1341" + } + ], + "NoAdvisory": false + }, + "Version": "0:68.6.1-1.el8_1", + "VersionFormat": "rpm" + } + ], + "Link": "https://access.redhat.com/security/cve/CVE-2020-99999", + "Metadata": {}, + "Name": "CVE-2020-99999", + "NamespaceName": "rhel:8", + "Severity": "Medium" + } + } +] diff --git a/grype/db/v6/build/transformers/os/transform.go b/grype/db/v6/build/transformers/os/transform.go index 6404b697214..6493662c961 100644 --- a/grype/db/v6/build/transformers/os/transform.go +++ b/grype/db/v6/build/transformers/os/transform.go @@ -49,17 +49,56 @@ func Transform(vulnerability unmarshal.OSVulnerability, state provider.State) ([ }, } - for _, a := range getAffectedPackages(vulnerability) { + affected, unaffected := getPackages(vulnerability) + for _, a := range affected { in = append(in, a) } + for _, u := range unaffected { + in = append(in, u) + } return transformers.NewEntries(in...), nil } -func getAffectedPackages(vuln unmarshal.OSVulnerability) []db.AffectedPackageHandle { +func isNotAffectedGroup(fixedIns []unmarshal.OSFixedIn) bool { + for _, f := range fixedIns { + if versionutil.CleanFixedInVersion(f.Version) != "0" { + return false + } + } + return true +} + +func getPackages(vuln unmarshal.OSVulnerability) ([]db.AffectedPackageHandle, []db.UnaffectedPackageHandle) { var afs []db.AffectedPackageHandle + var unafs []db.UnaffectedPackageHandle groups := groupFixedIns(vuln) for group, fixedIns := range groups { + // APK providers already handle not-affected signaling in their own matching layer, + // so skip emitting unaffected package handles for them. + pkgType := getPackageType(group.osName) + if pkgType != pkg.ApkPkg && isNotAffectedGroup(fixedIns) { + unafs = append(unafs, db.UnaffectedPackageHandle{ + OperatingSystem: getOperatingSystem(group.osName, group.id, group.osVersion, group.osChannel), + Package: getPackage(group), + BlobValue: &db.PackageBlob{ + CVEs: getAliases(vuln), + Ranges: []db.Range{ + { + Version: db.Version{ + Type: fixedIns[0].VersionFormat, + Constraint: "", + }, + Fix: &db.Fix{ + State: db.NotAffectedFixStatus, + }, + }, + }, + }, + }) + continue + } + // we only care about a single qualifier: rpm modules. The important thing to note about this is that // a package with no module vs a package with a module should be detectable in the DB. var qualifiers *db.PackageQualifiers @@ -99,8 +138,9 @@ func getAffectedPackages(vuln unmarshal.OSVulnerability) []db.AffectedPackageHan // stable ordering sort.Sort(internal.ByAffectedPackage(afs)) + sort.Sort(internal.ByUnaffectedPackage(unafs)) - return afs + return afs, unafs } func getFix(fixedInEntry unmarshal.OSFixedIn) *db.Fix { diff --git a/grype/db/v6/build/transformers/os/transform_test.go b/grype/db/v6/build/transformers/os/transform_test.go index 9185c0380a5..9736a9127e4 100644 --- a/grype/db/v6/build/transformers/os/transform_test.go +++ b/grype/db/v6/build/transformers/os/transform_test.go @@ -1249,6 +1249,85 @@ func TestTransform(t *testing.T) { }, }, }, + { + name: "testdata/rhel-8-not-affected.json", + provider: "rhel", + want: []transformers.RelatedEntries{ + { + VulnerabilityHandle: &db.VulnerabilityHandle{ + Name: "CVE-2020-99999", + Status: "active", + ProviderID: "rhel", + Provider: expectedProvider("rhel"), + BlobValue: &db.VulnerabilityBlob{ + ID: "CVE-2020-99999", + Description: "Test vulnerability with a not-affected package and an affected package.", + References: []db.Reference{ + {URL: "https://access.redhat.com/security/cve/CVE-2020-99999"}, + }, + Severities: []db.Severity{ + { + Scheme: db.SeveritySchemeCHMLN, + Value: "medium", + Rank: 1, + }, + { + Scheme: db.SeveritySchemeCVSS, + Value: db.CVSSSeverity{ + Vector: "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H", + Version: "3.1", + }, + Rank: 2, + }, + }, + }, + }, + Related: append( + affectedPkgSlice( + db.AffectedPackageHandle{ + OperatingSystem: rhel8OS, + Package: &db.Package{Ecosystem: "rpm", Name: "firefox"}, + BlobValue: &db.PackageBlob{ + Qualifiers: &db.PackageQualifiers{RpmModularity: strRef("")}, + Ranges: []db.Range{ + { + Version: db.Version{Type: "rpm", Constraint: "< 0:68.6.1-1.el8_1"}, + Fix: &db.Fix{ + Version: "0:68.6.1-1.el8_1", + State: db.FixedStatus, + Detail: &db.FixDetail{ + References: []db.Reference{ + { + ID: "RHSA-2020:1341", + URL: "https://access.redhat.com/errata/RHSA-2020:1341", + Tags: []string{db.AdvisoryReferenceTag}, + }, + }, + }, + }, + }, + }, + }, + }, + ), + unaffectedPkgSlice( + db.UnaffectedPackageHandle{ + OperatingSystem: rhel8OS, + Package: &db.Package{Ecosystem: "rpm", Name: "ghostscript"}, + BlobValue: &db.PackageBlob{ + Ranges: []db.Range{ + { + Version: db.Version{Type: "rpm"}, + Fix: &db.Fix{State: db.NotAffectedFixStatus}, + }, + }, + }, + }, + )..., + ), + }, + }, + }, } for _, test := range tests { @@ -1717,6 +1796,14 @@ func affectedPkgSlice(a ...db.AffectedPackageHandle) []any { return r } +func unaffectedPkgSlice(a ...db.UnaffectedPackageHandle) []any { + var r []any + for _, v := range a { + r = append(r, v) + } + return r +} + func loadFixture(t *testing.T, fixturePath string) []unmarshal.OSVulnerability { t.Helper() diff --git a/grype/matcher/apk/matcher.go b/grype/matcher/apk/matcher.go index e2f5c547d5e..269918043a6 100644 --- a/grype/matcher/apk/matcher.go +++ b/grype/matcher/apk/matcher.go @@ -65,14 +65,14 @@ func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Ma } //nolint:funlen,gocognit -func (m *Matcher) cpeMatchesWithoutSecDBFixes(provider vulnerability.Provider, p pkg.Package) ([]match.Match, error) { +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, err := internal.MatchPackageByCPEs(provider, p, m.Type()) + 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, nil + return cpeMatches, ignored, nil } cpeMatchesByID := matchesByID(cpeMatches) @@ -83,7 +83,7 @@ func (m *Matcher) cpeMatchesWithoutSecDBFixes(provider vulnerability.Provider, p search.ByPackageName(p.Name), search.ByDistro(*p.Distro)) if err != nil { - return nil, err + return nil, nil, err } for _, upstreamPkg := range pkg.UpstreamPackages(p) { @@ -91,7 +91,7 @@ func (m *Matcher) cpeMatchesWithoutSecDBFixes(provider vulnerability.Provider, p search.ByPackageName(upstreamPkg.Name), search.ByDistro(*upstreamPkg.Distro)) if err != nil { - return nil, err + return nil, nil, err } secDBVulnerabilities = append(secDBVulnerabilities, secDBVulnerabilitiesForUpstream...) } @@ -131,7 +131,7 @@ cveLoop: // ...is the current package vulnerable? vulnerable, err := vuln.Constraint.Satisfied(verObj) if err != nil { - return nil, err + return nil, nil, err } if vulnerable { @@ -141,7 +141,7 @@ cveLoop: } } } - return finalCpeMatches, nil + return finalCpeMatches, ignored, nil } func deduplicateMatches(secDBMatches, cpeMatches []match.Match) (matches []match.Match) { @@ -179,13 +179,13 @@ func vulnerabilitiesByID(vulns []vulnerability.Vulnerability) map[string][]vulne 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.MatchPackageByDistroWithOwnedFiles(store, p, catalogPkg, m.Type(), nil) + 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, err := m.cpeMatchesWithoutSecDBFixes(store, p) + cpeMatches, cpeIgnores, err := m.cpeMatchesWithoutSecDBFixes(store, p) if err != nil && !errors.Is(err, internal.ErrEmptyCPEMatch) { return nil, nil, err } @@ -198,7 +198,7 @@ func (m *Matcher) findMatchesForPackage(store vulnerability.Provider, p pkg.Pack // keep only unique CPE matches matches = append(matches, deduplicateMatches(secDBMatches, cpeMatches)...) - return matches, secDBIgnores, nil + return matches, append(secDBIgnores, cpeIgnores...), nil } func (m *Matcher) findMatchesForOriginPackage(store vulnerability.Provider, catalogPkg pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { diff --git a/grype/matcher/apk/matcher_test.go b/grype/matcher/apk/matcher_test.go index 7edc188b5ca..d65333e084f 100644 --- a/grype/matcher/apk/matcher_test.go +++ b/grype/matcher/apk/matcher_test.go @@ -1128,6 +1128,14 @@ func Test_ignoreFilters(t *testing.T) { }, }, vulns: []vulnerability.Vulnerability{ + { + Reference: vulnerability.Reference{ + ID: "GHSA-2014-fake-2", + Namespace: "wolfi:distro:wolfi:rolling", + }, + PackageName: "not-foo", + Constraint: version.MustGetConstraint("< 0", version.ApkFormat), + }, { Reference: vulnerability.Reference{ ID: "GHSA-2014-fake-3", diff --git a/grype/matcher/dpkg/matcher.go b/grype/matcher/dpkg/matcher.go index 87e0066307d..23535e36998 100644 --- a/grype/matcher/dpkg/matcher.go +++ b/grype/matcher/dpkg/matcher.go @@ -37,27 +37,25 @@ func (m *Matcher) Type() match.MatcherType { } func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { - matches := make([]match.Match, 0) - - sourceMatches, err := m.matchUpstreamPackages(store, p) + matches, ignores, err := m.matchUpstreamPackages(store, p) if err != nil { return nil, nil, fmt.Errorf("failed to match by source indirection: %w", err) } - matches = append(matches, sourceMatches...) versionConfig := version.ComparisonConfig{ MissingEpochStrategy: m.cfg.MissingEpochStrategy, } - exactMatches, _, err := internal.MatchPackageByDistro(store, p, nil, m.Type(), &versionConfig) + exactMatches, exactIgnores, err := internal.MatchPackageByDistro(store, p, nil, m.Type(), &versionConfig) if err != nil { return nil, nil, fmt.Errorf("failed to match by exact package name: %w", err) } matches = append(matches, exactMatches...) + ignores = append(ignores, exactIgnores...) // if configured, also search by CPEs for packages from EOL distros if m.cfg.UseCPEsForEOL && internal.IsDistroEOL(store, p.Distro) { log.WithFields("package", p.Name, "distro", p.Distro).Debug("distro is EOL, searching by CPEs") - cpeMatches, err := internal.MatchPackageByCPEs(store, p, m.Type()) + cpeMatches, ignored, err := internal.MatchPackageByCPEs(store, p, m.Type()) switch { case errors.Is(err, internal.ErrEmptyCPEMatch): log.WithFields("package", p.Name).Debug("package has no CPEs for EOL fallback matching") @@ -65,29 +63,32 @@ func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Ma log.WithFields("package", p.Name, "error", err).Debug("failed to match by CPEs for EOL distro") default: matches = append(matches, cpeMatches...) + ignores = append(ignores, ignored...) } } - return matches, nil, nil + return matches, ignores, nil } -func (m *Matcher) matchUpstreamPackages(store vulnerability.Provider, p pkg.Package) ([]match.Match, error) { +func (m *Matcher) matchUpstreamPackages(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { var matches []match.Match + var ignores []match.IgnoreFilter versionConfig := version.ComparisonConfig{ MissingEpochStrategy: m.cfg.MissingEpochStrategy, } for _, indirectPackage := range pkg.UpstreamPackages(p) { - indirectMatches, _, err := internal.MatchPackageByDistro(store, indirectPackage, &p, m.Type(), &versionConfig) + indirectMatches, ignored, err := internal.MatchPackageByDistro(store, indirectPackage, &p, m.Type(), &versionConfig) if err != nil { - return nil, fmt.Errorf("failed to find vulnerabilities for dpkg upstream source package: %w", err) + return nil, nil, fmt.Errorf("failed to find vulnerabilities for dpkg upstream source package: %w", err) } matches = append(matches, indirectMatches...) + ignores = append(ignores, ignored...) } // 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, p) - return matches, nil + return matches, ignores, nil } diff --git a/grype/matcher/internal/common.go b/grype/matcher/internal/common.go index 23f5d14ca18..145656097d3 100644 --- a/grype/matcher/internal/common.go +++ b/grype/matcher/internal/common.go @@ -31,13 +31,14 @@ func MatchPackageByEcosystemPackageNameAndCPEs(store vulnerability.Provider, p p log.Debugf("could not match by package ecosystem (package=%+v): %v", p, err) } if includeCPEs { - cpeMatches, err := MatchPackageByCPEs(store, p, matcher) + cpeMatches, ignores, err := MatchPackageByCPEs(store, p, matcher) if errors.Is(err, ErrEmptyCPEMatch) { log.Debugf("attempted CPE search on %s, which has no CPEs. Consider re-running with --add-cpes-if-none", p.Name) } else if err != nil { log.Debugf("could not match by package CPE (package=%+v): %v", p, err) } matches = append(matches, cpeMatches...) + ignored = append(ignored, ignores...) } return matches, ignored, nil } diff --git a/grype/matcher/internal/cpe.go b/grype/matcher/internal/cpe.go index fc8486b6078..f9c1adaff36 100644 --- a/grype/matcher/internal/cpe.go +++ b/grype/matcher/internal/cpe.go @@ -9,6 +9,7 @@ import ( "github.com/facebookincubator/nvdtools/wfn" "github.com/anchore/grype/grype/match" + "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" @@ -37,13 +38,18 @@ func alpineCPEComparableVersion(version string) string { 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 -func MatchPackageByCPEs(provider vulnerability.Provider, p pkg.Package, upstreamMatcher match.MatcherType) ([]match.Match, error) { +// +//nolint:funlen +func MatchPackageByCPEs(vulnProvider vulnerability.Provider, p pkg.Package, upstreamMatcher match.MatcherType) ([]match.Match, []match.IgnoreFilter, error) { + provider := result.NewProvider(vulnProvider, p, upstreamMatcher) + + 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, ErrEmptyCPEMatch + return nil, nil, ErrEmptyCPEMatch } matchesByFingerprint := make(map[match.Fingerprint]match.Match) @@ -80,27 +86,42 @@ func MatchPackageByCPEs(provider vulnerability.Provider, p pkg.Package, upstream verObj = version.New(searchVersion, format) } - // find all vulnerability records in the DB for the given CPE (not including version comparisons) - vulns, err := provider.FindVulnerabilities( + criteria := []vulnerability.Criteria{ search.ByCPE(c), OnlyVulnerableTargets(p), OnlyQualifiedPackages(p), - OnlyVulnerableVersions(verObj), OnlyNonWithdrawnVulnerabilities(), + } + + versionCriteria := OnlyVulnerableVersions(verObj) + + // find all vulnerability records in the DB for the given CPE (not including version comparisons) + all, err := provider.FindResults(criteria...) + if err != nil { + return nil, nil, fmt.Errorf("matcher failed to fetch by CPE pkg=%q: %w", p.Name, err) + } + + vulns := all.Filter(versionCriteria) + + unaffected, err := provider.FindResults( + append(criteria, search.ForUnaffected(), versionCriteria)..., ) if err != nil { - return nil, fmt.Errorf("matcher failed to fetch by CPE pkg=%q: %w", p.Name, err) + return nil, nil, fmt.Errorf("matcher failed to fetch unaffected CPE records for pkg=%q: %w", p.Name, err) } + 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 { + for _, vuln := range vulns.Vulnerabilities() { addNewMatch(matchesByFingerprint, vuln, p, verObj, upstreamMatcher, c) } } - return toMatches(matchesByFingerprint), nil + return toMatches(matchesByFingerprint), ignores, nil } func transformJvmVersion(searchVersion, updateCpeField string) string { diff --git a/grype/matcher/internal/cpe_test.go b/grype/matcher/internal/cpe_test.go index 03a292eba78..76c4fd8086e 100644 --- a/grype/matcher/internal/cpe_test.go +++ b/grype/matcher/internal/cpe_test.go @@ -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/syft/syft/artifact" "github.com/anchore/syft/syft/cpe" syftPkg "github.com/anchore/syft/syft/pkg" ) @@ -999,7 +1000,7 @@ func TestFindMatchesByPackageCPE(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - actual, err := MatchPackageByCPEs(newCPETestStore(), test.p, matcher) + actual, _, err := MatchPackageByCPEs(newCPETestStore(), test.p, matcher) if test.wantErr == nil { test.wantErr = require.NoError } @@ -1442,3 +1443,96 @@ func TestCPESearchHit_Equals(t *testing.T) { }) } } + +func TestMatchPackageByCPEs_IgnoreFilters(t *testing.T) { + store := mock.VulnerabilityProvider([]vulnerability.Vulnerability{ + { + Reference: vulnerability.Reference{ID: "CVE-MATCH", Namespace: "nvd:cpe"}, + PackageName: "libfoo", + Constraint: version.MustGetConstraint("< 2.0.0", version.UnknownFormat), + CPEs: []cpe.CPE{cpe.Must("cpe:2.3:a:vendor:libfoo:*:*:*:*:*:*:*:*", "")}, + }, + { + Reference: vulnerability.Reference{ID: "CVE-NO-MATCH", Namespace: "nvd:cpe"}, + PackageName: "libfoo", + Constraint: version.MustGetConstraint("< 1.0.0", version.UnknownFormat), + CPEs: []cpe.CPE{cpe.Must("cpe:2.3:a:vendor:libfoo:*:*:*:*:*:*:*:*", "")}, + }, + { + Reference: vulnerability.Reference{ID: "CVE-UNAFFECTED", Namespace: "nvd:cpe"}, + PackageName: "libfoo", + Constraint: version.MustGetConstraint("> 0.5.0", version.UnknownFormat), + CPEs: []cpe.CPE{cpe.Must("cpe:2.3:a:vendor:libfoo:*:*:*:*:*:*:*:*", "")}, + Unaffected: true, + }, + }...) + + tests := []struct { + name string + p pkg.Package + expectedIgnores []match.IgnoreFilter + }{ + { + name: "version outside constraint produces ignore", + p: pkg.Package{ + Name: "libfoo", + Version: "1.5.0", + Type: syftPkg.BinaryPkg, + CPEs: []cpe.CPE{cpe.Must("cpe:2.3:a:vendor:libfoo:*:*:*:*:*:*:*:*", "")}, + }, + // 1.5.0 < 2.0.0 → CVE-MATCH is a match (not ignored) + // 1.5.0 >= 1.0.0 → CVE-NO-MATCH is not vulnerable → ignored + // 1.5.0 >= 0.5.0 → CVE-UNAFFECTED is not vulnerable → ignored + expectedIgnores: []match.IgnoreFilter{ + match.IgnoreRelatedPackage{ + Reason: "CPE not vulnerable", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-NO-MATCH", + }, + match.IgnoreRelatedPackage{ + Reason: "CPE not vulnerable", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-UNAFFECTED", + }, + }, + }, + { + name: "no ignores when all vulns match, unaffected doesn't match", + p: pkg.Package{ + Name: "libfoo", + Version: "0.5.0", + Type: syftPkg.BinaryPkg, + CPEs: []cpe.CPE{cpe.Must("cpe:2.3:a:vendor:libfoo:*:*:*:*:*:*:*:*", "")}, + }, + // 0.5.0 < 2.0.0 → match, 0.5.0 < 1.0.0 → match; both vulnerable, no ignores + // 0.5.0 < 0.5.0 → CVE-UNAFFECTED is not unaffected, no ignores + expectedIgnores: nil, + }, + { + name: "only unaffected matches", + p: pkg.Package{ + Name: "libfoo", + Version: "0.6.0", + Type: syftPkg.BinaryPkg, + CPEs: []cpe.CPE{cpe.Must("cpe:2.3:a:vendor:libfoo:*:*:*:*:*:*:*:*", "")}, + }, + // 0.6.0 > 0.5.0 → CVE-UNAFFECTED is unaffected → ignores + expectedIgnores: []match.IgnoreFilter{ + match.IgnoreRelatedPackage{ + Reason: "CPE not vulnerable", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-UNAFFECTED", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, ignores, err := MatchPackageByCPEs(store, test.p, match.StockMatcher) + require.NoError(t, err) + + require.ElementsMatch(t, test.expectedIgnores, ignores) + }) + } +} diff --git a/grype/matcher/internal/distro.go b/grype/matcher/internal/distro.go index 94e4d29e3e4..d28e3181a82 100644 --- a/grype/matcher/internal/distro.go +++ b/grype/matcher/internal/distro.go @@ -13,57 +13,10 @@ import ( "github.com/anchore/grype/internal/log" ) -func MatchPackageByDistro(provider vulnerability.Provider, searchPkg pkg.Package, catalogPkg *pkg.Package, upstreamMatcher match.MatcherType, cfg *version.ComparisonConfig) ([]match.Match, []match.IgnoreFilter, error) { - if searchPkg.Distro == nil { - return nil, nil, nil - } - - if isUnknownVersion(searchPkg.Version) { - log.WithFields("package", searchPkg.Name).Trace("skipping package with unknown version") - return nil, nil, nil - } - - var matches []match.Match - - // Create version with config embedded if provided - var pkgVersion *version.Version - if cfg != nil { - pkgVersion = version.NewWithConfig(searchPkg.Version, pkg.VersionFormat(searchPkg), *cfg) - } else { - pkgVersion = version.New(searchPkg.Version, pkg.VersionFormat(searchPkg)) - } - - versionCriteria := OnlyVulnerableVersions(pkgVersion) - - vulns, err := provider.FindVulnerabilities( - search.ByPackageName(searchPkg.Name), - search.ByDistro(*searchPkg.Distro), - OnlyQualifiedPackages(searchPkg), - versionCriteria, - ) - if err != nil { - return nil, nil, fmt.Errorf("matcher failed to fetch distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) - } - - for _, vuln := range vulns { - matches = append(matches, match.Match{ - Vulnerability: vuln, - Package: matchPackage(searchPkg, catalogPkg), - Details: distroMatchDetails(upstreamMatcher, searchPkg, catalogPkg, vuln), - }) - } - return matches, nil, err -} - -// MatchPackageByDistroWithOwnedFiles searches for all vulnerabilities the distro knows about for a +// MatchPackageByDistro searches for all vulnerabilities the distro knows about for a // package in a single query, then partitions the results in memory into vulnerable matches and -// location-scoped ignore rules for fixed vulnerabilities. The ignore rules are scoped to files -// owned by the package so they only suppress findings for co-located packages. -// -// Owned files are discovered by checking whether the package metadata (on either catalogPkg or -// searchPkg) implements [pkg.FileOwner]. When no owned files are available, this falls back to -// [MatchPackageByDistro] (version-filtered query, no ignore rules) to avoid over-fetching. -func MatchPackageByDistroWithOwnedFiles(provider vulnerability.Provider, searchPkg pkg.Package, catalogPkg *pkg.Package, upstreamMatcher match.MatcherType, cfg *version.ComparisonConfig) ([]match.Match, []match.IgnoreFilter, error) { +// fixes to ignore on overlapping packages such as an APK which owns NPM +func MatchPackageByDistro(provider vulnerability.Provider, searchPkg pkg.Package, catalogPkg *pkg.Package, upstreamMatcher match.MatcherType, cfg *version.ComparisonConfig) ([]match.Match, []match.IgnoreFilter, error) { if searchPkg.Distro == nil { return nil, nil, nil } @@ -99,11 +52,27 @@ func MatchPackageByDistroWithOwnedFiles(provider vulnerability.Provider, searchP vulnerable := allVulns.Filter(versionCriteria) fixed := allVulns.Remove(vulnerable) - matches := vulnerable.ToMatches() + // include any unaffected results that match this package as filters + unaffected, err := rp.FindResults( + search.ByDistro(*searchPkg.Distro), + search.ByPackageName(searchPkg.Name), + search.ForUnaffected(), + versionCriteria, + ) + if err != nil { + return nil, nil, fmt.Errorf("matcher failed to fetch unaffected distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) + } + + // remove any unaffected results that match this package as matches + vulnerable = vulnerable.Remove(unaffected) + + fixed = fixed.Merge(unaffected) // 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()...) + matches := vulnerable.ToMatches() + return matches, ignores, nil } @@ -114,36 +83,6 @@ func matchPackage(searchPkg pkg.Package, catalogPkg *pkg.Package) pkg.Package { return searchPkg } -func distroMatchDetails(upstreamMatcher match.MatcherType, searchPkg pkg.Package, catalogPkg *pkg.Package, vuln vulnerability.Vulnerability) []match.Detail { - ty := match.ExactIndirectMatch - if catalogPkg == nil { - ty = match.ExactDirectMatch - } - - return []match.Detail{ - { - Type: ty, - Matcher: upstreamMatcher, - SearchedBy: match.DistroParameters{ - Distro: match.DistroIdentification{ - Type: searchPkg.Distro.Type.String(), - Version: searchPkg.Distro.Version, - }, - Package: match.PackageParameter{ - Name: searchPkg.Name, - Version: searchPkg.Version, - }, - Namespace: vuln.Namespace, - }, - Found: match.DistroResult{ - VulnerabilityID: vuln.ID, - VersionConstraint: vuln.Constraint.String(), - }, - Confidence: 1.0, // TODO: this is hard coded for now - }, - } -} - func isUnknownVersion(v string) bool { return strings.ToLower(v) == "unknown" } diff --git a/grype/matcher/internal/distro_test.go b/grype/matcher/internal/distro_test.go index a8bf3909c6e..c09ba03c004 100644 --- a/grype/matcher/internal/distro_test.go +++ b/grype/matcher/internal/distro_test.go @@ -272,7 +272,7 @@ func TestMatchPackageByDistroWithIgnoreRules(t *testing.T) { t.Run(test.name, func(t *testing.T) { store := mock.VulnerabilityProvider(test.vulnerabilities...) - matches, ignoreFilters, err := MatchPackageByDistroWithOwnedFiles(store, test.pkg, nil, match.PythonMatcher, nil) + matches, ignoreFilters, err := MatchPackageByDistro(store, test.pkg, nil, match.PythonMatcher, nil) require.NoError(t, err) // verify matches diff --git a/grype/matcher/internal/language.go b/grype/matcher/internal/language.go index 0cf9ae60c2e..bb0e2a45100 100644 --- a/grype/matcher/internal/language.go +++ b/grype/matcher/internal/language.go @@ -41,19 +41,22 @@ func MatchPackageByEcosystemPackageName(vp vulnerability.Provider, p pkg.Package search.ByEcosystem(p.Language, p.Type), search.ByPackageName(packageName), OnlyQualifiedPackages(p), - OnlyVulnerableVersions(version.New(p.Version, pkg.VersionFormat(p))), OnlyNonWithdrawnVulnerabilities(), } + versionCriteria := OnlyVulnerableVersions(version.New(p.Version, pkg.VersionFormat(p))) + // TODO: previous impl set confidence to 1, this results in // a confidence of zero. What should it be? - disclosures, err := provider.FindResults(criteria...) + all, err := provider.FindResults(criteria...) if err != nil { return nil, nil, fmt.Errorf("matcher failed to fetch disclosure language=%q pkg=%q: %w", p.Language, p.Name, err) } + disclosures := all.Filter(versionCriteria) + // we want to perform the same results, but look for explicit naks, which indicates that a vulnerability should not apply - criteria = append(criteria, search.ForUnaffected()) + criteria = append(criteria, search.ForUnaffected(), versionCriteria) unaffected, err := provider.FindResults(criteria...) if err != nil { return nil, nil, fmt.Errorf("matcher failed to fetch resolution language=%q pkg=%q: %w", p.Language, p.Name, err) diff --git a/grype/matcher/internal/result/provider.go b/grype/matcher/internal/result/provider.go index c6a7d6b3c79..98c977562fa 100644 --- a/grype/matcher/internal/result/provider.go +++ b/grype/matcher/internal/result/provider.go @@ -100,10 +100,14 @@ func extractSearchParameters(criteriaSet []vulnerability.Criteria, vuln vulnerab case *search.DistroCriteria: for _, d := range c.Distros { + version := d.VersionString() + if version == "rolling" { // rolling is a made-up term to find records in the database + version = "" + } distroParams = append(distroParams, match.DistroParameters{ Distro: match.DistroIdentification{ Type: d.Type.String(), - Version: d.VersionString(), + Version: version, }, Namespace: vuln.Namespace, // TODO: this is a holdover and will be removed in the future }) diff --git a/grype/matcher/java/matcher.go b/grype/matcher/java/matcher.go index 11567ad6767..fd921fa018d 100644 --- a/grype/matcher/java/matcher.go +++ b/grype/matcher/java/matcher.go @@ -52,9 +52,10 @@ func (m *Matcher) Type() match.MatcherType { func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { var matches []match.Match + var ignores []match.IgnoreFilter if m.cfg.SearchMavenUpstream { - upstreamMatches, err := m.matchUpstreamMavenPackages(store, p) + upstreamMatches, ignored, err := m.matchUpstreamMavenPackages(store, p) if err != nil { if strings.Contains(err.Error(), "no artifact found") { log.Debugf("no upstream maven artifact found for %s", p.Name) @@ -63,21 +64,24 @@ func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Ma } } else { matches = append(matches, upstreamMatches...) + ignores = append(ignores, ignored...) } } - criteriaMatches, ignores, err := internal.MatchPackageByEcosystemAndCPEs(store, p, m.Type(), m.cfg.UseCPEs) + criteriaMatches, ignored, err := internal.MatchPackageByEcosystemAndCPEs(store, p, m.Type(), m.cfg.UseCPEs) if err != nil { return nil, nil, fmt.Errorf("failed to match by exact package: %w", err) } matches = append(matches, criteriaMatches...) + ignores = append(ignores, ignored...) return matches, ignores, nil } -func (m *Matcher) matchUpstreamMavenPackages(store vulnerability.Provider, p pkg.Package) ([]match.Match, error) { +func (m *Matcher) matchUpstreamMavenPackages(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { var matches []match.Match + var ignores []match.IgnoreFilter ctx := context.Background() @@ -89,26 +93,28 @@ func (m *Matcher) matchUpstreamMavenPackages(store vulnerability.Provider, p pkg log.Debugf("searching maven, POM data missing for %s", p.Name) indirectPackage, err := m.GetMavenPackageBySha(ctx, digest) if err != nil { - return nil, err + return nil, nil, err } - indirectMatches, _, err := internal.MatchPackageByLanguage(store, *indirectPackage, m.Type()) + indirectMatches, ignored, err := internal.MatchPackageByLanguage(store, *indirectPackage, m.Type()) if err != nil { - return nil, err + return nil, nil, err } matches = append(matches, indirectMatches...) + ignores = append(ignores, ignored...) } } else { log.Debugf("skipping maven search, POM data present for %s", p.Name) - indirectMatches, _, err := internal.MatchPackageByLanguage(store, p, m.Type()) + indirectMatches, ignored, err := internal.MatchPackageByLanguage(store, p, m.Type()) if err != nil { - return nil, err + return nil, nil, err } matches = append(matches, indirectMatches...) + ignores = append(ignores, ignored...) } match.ConvertToIndirectMatches(matches, p) - return matches, nil + return matches, ignores, nil } func (m *Matcher) shouldSearchMavenBySha(p pkg.Package) (bool, []string) { diff --git a/grype/matcher/java/matcher_test.go b/grype/matcher/java/matcher_test.go index 83e2a4b4eb3..8a48a759bad 100644 --- a/grype/matcher/java/matcher_test.go +++ b/grype/matcher/java/matcher_test.go @@ -110,7 +110,7 @@ func TestMatcherJava_matchUpstreamMavenPackage(t *testing.T) { matcher := newMatcher(mockMavenSearcher{ pkg: p.packages[0], }) - actual, _ := matcher.matchUpstreamMavenPackages(store, p.packages[0]) + actual, _, _ := matcher.matchUpstreamMavenPackages(store, p.packages[0]) assert.Len(t, actual, 2, "unexpected matches count") @@ -145,7 +145,7 @@ func TestMatcherJava_matchUpstreamMavenPackage(t *testing.T) { t.Run(p.testname, func(t *testing.T) { matcher := newMatcher(mockMavenSearcher{simulateRateLimiting: true}) - _, err := matcher.matchUpstreamMavenPackages(store, p.packages[0]) + _, _, err := matcher.matchUpstreamMavenPackages(store, p.packages[0]) if p.testExpectRateLimit { assert.Errorf(t, err, "should have gotten an error from the rate limiting") diff --git a/grype/matcher/rpm/almalinux.go b/grype/matcher/rpm/almalinux.go index 2e04ba16a89..1fa89001458 100644 --- a/grype/matcher/rpm/almalinux.go +++ b/grype/matcher/rpm/almalinux.go @@ -49,11 +49,13 @@ func markAsIndirectMatches(results result.Set) result.Set { // 2. Search for RHEL disclosures for all upstream (source) packages // 3. Search for AlmaLinux unaffected records for the binary package and related packages // 4. Apply filtering logic to determine which disclosures are still vulnerable on AlmaLinux -func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Package) ([]match.Match, error) { +func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { if strings.HasSuffix(binaryPkg.Name, "-debuginfo") || strings.HasSuffix(binaryPkg.Name, "-debugsource") { - return nil, nil // almalinux explicitly never publishes advisories for RPMs that are only debug material + return nil, nil, nil // almalinux explicitly never publishes advisories for RPMs that are only debug material } + var ignored []match.IgnoreFilter + // Create a RHEL-compatible distro for finding base disclosures rhelCompatibleDistro := *binaryPkg.Distro rhelCompatibleDistro.Type = distro.RedHat // treat as RHEL for disclosure lookup @@ -61,16 +63,18 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa pkgVersion := version.New(binaryPkg.Version, pkg.VersionFormat(binaryPkg)) // Step 1: Find RHEL disclosures for the binary package (direct match) - binaryDisclosures, err := provider.FindResults( + all, err := provider.FindResults( search.ByPackageName(binaryPkg.Name), search.ByDistro(rhelCompatibleDistro), internal.OnlyQualifiedPackages(binaryPkg), - internal.OnlyVulnerableVersions(pkgVersion), ) if err != nil { - return nil, fmt.Errorf("matcher failed to fetch RHEL disclosures for AlmaLinux binary pkg=%q: %w", binaryPkg.Name, err) + return nil, nil, fmt.Errorf("matcher failed to fetch RHEL disclosures for AlmaLinux binary pkg=%q: %w", binaryPkg.Name, err) } + binaryDisclosures := all.Filter(internal.OnlyVulnerableVersions(pkgVersion)) + ignored = append(ignored, internal.OwnershipIgnores(binaryPkg, "Distro Fixed", all.Remove(binaryDisclosures).Vulnerabilities()...)...) + // Step 2: Find RHEL disclosures for upstream (source) packages (indirect match) // Note: We do NOT add epochs to upstream package versions because sourceRPMs often omit epochs // even when the source package has a non-zero epoch. See the comment in matchUpstreamPackages @@ -81,17 +85,19 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa // This avoids false positives where binary package epochs differ from source package epochs upstreamVersion := version.New(upstreamPkg.Version, pkg.VersionFormat(upstreamPkg)) - upstreamResults, err := provider.FindResults( + all, err = provider.FindResults( search.ByPackageName(upstreamPkg.Name), search.ByDistro(rhelCompatibleDistro), internal.OnlyQualifiedPackages(upstreamPkg), - internal.OnlyVulnerableVersions(upstreamVersion), ) if err != nil { log.WithFields("error", err, "upstreamPkg", upstreamPkg.Name, "binaryPkg", binaryPkg.Name).Debug("failed to fetch RHEL disclosures for upstream package") continue } + upstreamResults := all.Filter(internal.OnlyVulnerableVersions(upstreamVersion)) + ignored = append(ignored, internal.OwnershipIgnores(binaryPkg, "Distro Fixed", all.Remove(upstreamResults).Vulnerabilities()...)...) + // Mark these as indirect matches since they came from upstream package search upstreamResults = markAsIndirectMatches(upstreamResults) @@ -102,7 +108,7 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa allDisclosures := binaryDisclosures.Merge(upstreamDisclosures) if len(allDisclosures) == 0 { - return nil, nil + return nil, ignored, nil } // Step 3: Find AlmaLinux unaffected records for the binary package @@ -115,7 +121,7 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa if err != nil { log.WithFields("error", err, "distro", binaryPkg.Distro, "pkg", binaryPkg.Name).Debug("failed to fetch AlmaLinux unaffected packages") // If we can't get unaffected data, return the original disclosures - return allDisclosures.ToMatches(), nil + return allDisclosures.ToMatches(), ignored, nil } // Step 4: Find AlmaLinux unaffected records for related packages (source/binary relationships) @@ -129,7 +135,10 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa // Step 5: Apply filtering logic: if disclosure exists and no fix applies, the package is vulnerable updatedDisclosures := applyAlmaLinuxUnaffectedFiltering(allDisclosures, allUnaffected, pkgVersion) - return updatedDisclosures.ToMatches(), nil + // allUnaffected is not filtered by version constraint, but the correct set gets applied to updatedDisclosures + ignored = append(ignored, internal.OwnershipIgnores(binaryPkg, "Alma Unaffected", allUnaffected.Remove(updatedDisclosures).Vulnerabilities()...)...) + + return updatedDisclosures.ToMatches(), ignored, nil } // findRelatedUnaffectedPackages searches for unaffected packages using source/binary RPM relationships diff --git a/grype/matcher/rpm/almalinux_test.go b/grype/matcher/rpm/almalinux_test.go index bb0ef71481b..91829f62b1d 100644 --- a/grype/matcher/rpm/almalinux_test.go +++ b/grype/matcher/rpm/almalinux_test.go @@ -17,6 +17,7 @@ import ( "github.com/anchore/grype/grype/version" "github.com/anchore/grype/grype/vulnerability" "github.com/anchore/grype/grype/vulnerability/mock" + "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" syftPkg "github.com/anchore/syft/syft/pkg" ) @@ -123,7 +124,7 @@ func TestModularityExcludesDisclosure(t *testing.T) { return result.Set{}, nil } - _, err := almaLinuxMatchesWithUpstreams(mockProvider, testPkg) + _, _, err := almaLinuxMatchesWithUpstreams(mockProvider, testPkg) require.NoError(t, err) require.GreaterOrEqual(t, len(capturedCriteria), 2, "FindResults should be called for both RHEL disclosures and AlmaLinux advisories") @@ -1564,7 +1565,7 @@ func TestAlmaLinuxMatching(t *testing.T) { } // Call the matcher - matches, err := almaLinuxMatchesWithUpstreams(mockProvider, tt.pkg) + matches, _, err := almaLinuxMatchesWithUpstreams(mockProvider, tt.pkg) require.NoError(t, err) // Compare matches using cmp.Diff @@ -1614,3 +1615,417 @@ func createExpectedDetails(pkg pkg.Package, vuln vulnerability.Vulnerability) [] func strPtr(s string) *string { return &s } + +func TestAlmaLinuxIgnoreFilters(t *testing.T) { + tests := []struct { + name string + pkg pkg.Package + rhelVulns []vulnerability.Vulnerability + almaVulns []vulnerability.Vulnerability + + expectedIgnores []match.IgnoreFilter + }{ + { + name: "no ignores when package is vulnerable and no unaffected record", + + pkg: pkg.Package{ + ID: pkg.ID("httpd-no-ignores"), + Name: "httpd", + Version: "2.4.37-10.el8", + Type: syftPkg.RpmPkg, + Distro: &distro.Distro{ + Type: distro.AlmaLinux, + Version: "8", + }, + }, + + rhelVulns: []vulnerability.Vulnerability{ + { + PackageName: "httpd", + Reference: vulnerability.Reference{ + ID: "CVE-2023-1234", + Namespace: "redhat:distro:redhat:8", + }, + Constraint: createConstraint(t, ">= 0", version.RpmFormat), + Fix: vulnerability.Fix{ + State: vulnerability.FixStateNotFixed, + }, + }, + { + PackageName: "not-httpd", + Reference: vulnerability.Reference{ + ID: "CVE-2023-1234", + Namespace: "redhat:distro:redhat:8", + }, + Constraint: createConstraint(t, ">= 0", version.RpmFormat), + Fix: vulnerability.Fix{ + State: vulnerability.FixStateNotFixed, + }, + }, + }, + + almaVulns: []vulnerability.Vulnerability{}, + + expectedIgnores: nil, + }, + { + name: "ignore when RHEL not vulnerable", + + pkg: pkg.Package{ + ID: pkg.ID("httpd-distro-fixed"), + Name: "httpd", + Version: "2.4.37-51.el8", + Type: syftPkg.RpmPkg, + Distro: &distro.Distro{ + Type: distro.AlmaLinux, + Version: "8", + }, + }, + + rhelVulns: []vulnerability.Vulnerability{ + { + PackageName: "httpd", + Reference: vulnerability.Reference{ + ID: "CVE-2023-1234", + Namespace: "redhat:distro:redhat:8", + }, + Constraint: createConstraint(t, "< 2.4.37-50.el8", version.RpmFormat), + Fix: vulnerability.Fix{ + Versions: []string{"2.4.37-50.el8"}, + State: vulnerability.FixStateFixed, + }, + }, + }, + + almaVulns: []vulnerability.Vulnerability{}, + + expectedIgnores: []match.IgnoreFilter{ + match.IgnoreRelatedPackage{ + Reason: "Distro Fixed", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-2023-1234", + RelatedPackageID: pkg.ID("httpd-distro-fixed"), + }, + }, + }, + { + name: "ignore when alma fixed and unaffected", + + pkg: pkg.Package{ + ID: pkg.ID("open-vm-tools-unaffected"), + Name: "open-vm-tools", + Version: "12.3.5-2.el8.alma.1", + Type: syftPkg.RpmPkg, + Distro: &distro.Distro{ + Type: distro.AlmaLinux, + Version: "8.0", + }, + }, + + rhelVulns: []vulnerability.Vulnerability{ + { + PackageName: "open-vm-tools", + Reference: vulnerability.Reference{ + ID: "CVE-2025-22247", + Namespace: "redhat:distro:redhat:8", + }, + Constraint: createConstraint(t, ">= 0", version.RpmFormat), + Fix: vulnerability.Fix{ + State: vulnerability.FixStateNotFixed, + }, + }, + }, + + almaVulns: []vulnerability.Vulnerability{ + { + PackageName: "open-vm-tools", + Reference: vulnerability.Reference{ + ID: "ALSA-2025:A001", + Namespace: "almalinux:distro:almalinux:8", + }, + RelatedVulnerabilities: []vulnerability.Reference{ + {ID: "CVE-2025-22247"}, + }, + Constraint: createConstraint(t, ">= 12.3.5-2.el8.alma.1", version.RpmFormat), + Fix: vulnerability.Fix{ + Versions: []string{"12.3.5-2.el8.alma.1"}, + State: vulnerability.FixStateFixed, + }, + Unaffected: true, + }, + }, + + expectedIgnores: []match.IgnoreFilter{ + match.IgnoreRelatedPackage{ + Reason: "Alma Unaffected", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "ALSA-2025:A001", + RelatedPackageID: pkg.ID("open-vm-tools-unaffected"), + }, + match.IgnoreRelatedPackage{ + Reason: "Alma Unaffected", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-2025-22247", + RelatedPackageID: pkg.ID("open-vm-tools-unaffected"), + }, + }, + }, + { + name: "ignore with multiple related CVEs", + + pkg: pkg.Package{ + ID: pkg.ID("python38-multi-cve"), + Name: "python38", + Version: "3.8.17-2.module_el8.9.0+3633+e453b53a", + Type: syftPkg.RpmPkg, + Distro: &distro.Distro{ + Type: distro.AlmaLinux, + Version: "8.9", + }, + Metadata: pkg.RpmMetadata{ + ModularityLabel: strPtr("python38:3.8:8090020230810123456:3b72e4d2"), + }, + }, + + rhelVulns: []vulnerability.Vulnerability{ + { + PackageName: "python38", + Reference: vulnerability.Reference{ + ID: "CVE-2007-4559", + Namespace: "redhat:distro:redhat:8", + }, + Constraint: createConstraint(t, ">= 0", version.RpmFormat), + Fix: vulnerability.Fix{ + State: vulnerability.FixStateNotFixed, + }, + }, + }, + + almaVulns: []vulnerability.Vulnerability{ + { + PackageName: "python38", + Reference: vulnerability.Reference{ + ID: "ALSA-2023:7050", + Namespace: "almalinux:distro:almalinux:8", + }, + RelatedVulnerabilities: []vulnerability.Reference{ + {ID: "CVE-2007-4559"}, + {ID: "CVE-2023-32681"}, + }, + Constraint: createConstraint(t, ">= 3.8.17-2.module_el8.9.0+3633+e453b53a", version.RpmFormat), + Fix: vulnerability.Fix{ + Versions: []string{"3.8.17-2.module_el8.9.0+3633+e453b53a"}, + State: vulnerability.FixStateFixed, + }, + Unaffected: true, + }, + }, + + expectedIgnores: []match.IgnoreFilter{ + match.IgnoreRelatedPackage{ + Reason: "Alma Unaffected", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "ALSA-2023:7050", + RelatedPackageID: pkg.ID("python38-multi-cve"), + }, + match.IgnoreRelatedPackage{ + Reason: "Alma Unaffected", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-2007-4559", + RelatedPackageID: pkg.ID("python38-multi-cve"), + }, + match.IgnoreRelatedPackage{ + Reason: "Alma Unaffected", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-2023-32681", + RelatedPackageID: pkg.ID("python38-multi-cve"), + }, + }, + }, + { + name: "mixed ignores: Distro Fixed from RHEL and Alma Unaffected from AlmaLinux", + + pkg: pkg.Package{ + ID: pkg.ID("httpd-mixed"), + Name: "httpd", + Version: "2.4.37-47.el8.alma", + Type: syftPkg.RpmPkg, + Distro: &distro.Distro{ + Type: distro.AlmaLinux, + Version: "8.7", + }, + }, + + rhelVulns: []vulnerability.Vulnerability{ + { + // pkg version 47 < 50 → vulnerable per RHEL, becomes a disclosure + PackageName: "httpd", + Reference: vulnerability.Reference{ + ID: "CVE-2023-1234", + Namespace: "redhat:distro:redhat:8", + }, + Constraint: createConstraint(t, "< 2.4.37-50.el8", version.RpmFormat), + Fix: vulnerability.Fix{ + Versions: []string{"2.4.37-50.el8"}, + State: vulnerability.FixStateFixed, + }, + }, + { + // pkg version 47 >= 45 → NOT vulnerable per RHEL, filtered as "Distro Fixed" + PackageName: "httpd", + Reference: vulnerability.Reference{ + ID: "CVE-2022-9999", + Namespace: "redhat:distro:redhat:8", + }, + Constraint: createConstraint(t, "< 2.4.37-45.el8", version.RpmFormat), + Fix: vulnerability.Fix{ + Versions: []string{"2.4.37-45.el8"}, + State: vulnerability.FixStateFixed, + }, + }, + }, + + almaVulns: []vulnerability.Vulnerability{ + { + // AlmaLinux says CVE-2023-1234 is unaffected at >= 40, pkg is 47 → filtered as "Alma Unaffected" + PackageName: "httpd", + Reference: vulnerability.Reference{ + ID: "ALSA-2023:1234", + Namespace: "almalinux:distro:almalinux:8", + }, + RelatedVulnerabilities: []vulnerability.Reference{ + {ID: "CVE-2023-1234"}, + }, + Constraint: createConstraint(t, ">= 2.4.37-40.el8.alma", version.RpmFormat), + Fix: vulnerability.Fix{ + Versions: []string{"2.4.37-40.el8.alma"}, + State: vulnerability.FixStateFixed, + }, + Unaffected: true, + }, + }, + + expectedIgnores: []match.IgnoreFilter{ + // CVE-2022-9999: pkg version already past RHEL fix → Distro Fixed + match.IgnoreRelatedPackage{ + Reason: "Distro Fixed", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-2022-9999", + RelatedPackageID: pkg.ID("httpd-mixed"), + }, + // CVE-2023-1234: vulnerable per RHEL but AlmaLinux marks as unaffected → Alma Unaffected + match.IgnoreRelatedPackage{ + Reason: "Alma Unaffected", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "ALSA-2023:1234", + RelatedPackageID: pkg.ID("httpd-mixed"), + }, + match.IgnoreRelatedPackage{ + Reason: "Alma Unaffected", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-2023-1234", + RelatedPackageID: pkg.ID("httpd-mixed"), + }, + }, + }, + { + name: "upstream RHEL ignore", + pkg: pkg.Package{ + ID: pkg.ID("perl-errno-upstream-fixed"), + Name: "perl-Errno", + Version: "0:1.28-422.el8.0.1", + Type: syftPkg.RpmPkg, + Distro: &distro.Distro{ + Type: distro.AlmaLinux, + Version: "8.10", + }, + Upstreams: []pkg.UpstreamPackage{ + { + Name: "perl", + Version: "5.26.3-422.el8.0.1", + }, + }, + Metadata: pkg.RpmMetadata{ + Epoch: intPtr(0), + }, + }, + + rhelVulns: []vulnerability.Vulnerability{ + { + PackageName: "perl", + Reference: vulnerability.Reference{ + ID: "CVE-2020-10543", + Namespace: "redhat:distro:redhat:8", + }, + Constraint: createConstraint(t, "< 4:5.26.3-419.el8", version.RpmFormat), + Fix: vulnerability.Fix{ + Versions: []string{"4:5.26.3-419.el8"}, + State: vulnerability.FixStateFixed, + }, + }, + }, + + almaVulns: []vulnerability.Vulnerability{}, + + expectedIgnores: []match.IgnoreFilter{ + match.IgnoreRelatedPackage{ + Reason: "Distro Fixed", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-2020-10543", + RelatedPackageID: pkg.ID("perl-errno-upstream-fixed"), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + allVulns := append(tt.rhelVulns, tt.almaVulns...) + mockProvider := &MockProvider{ + findResultsFunc: func(criteria ...vulnerability.Criteria) (result.Set, error) { + vulnProvider := mock.VulnerabilityProvider(allVulns...) + vulns, err := vulnProvider.FindVulnerabilities(criteria...) + if err != nil { + return nil, err + } + + resultSet := make(result.Set) + for _, vuln := range vulns { + r := result.Result{ + ID: vuln.ID, + Vulnerabilities: []vulnerability.Vulnerability{vuln}, + Package: &tt.pkg, + Details: []match.Detail{{ + Type: match.ExactDirectMatch, + Matcher: match.RpmMatcher, + SearchedBy: match.DistroParameters{ + Distro: match.DistroIdentification{ + Type: tt.pkg.Distro.Type.String(), + Version: tt.pkg.Distro.Version, + }, + Package: match.PackageParameter{ + Name: tt.pkg.Name, + Version: tt.pkg.Version, + }, + Namespace: vuln.Namespace, + }, + Found: match.DistroResult{ + VulnerabilityID: vuln.ID, + VersionConstraint: vuln.Constraint.String(), + }, + Confidence: 1.0, + }}, + } + resultSet[vuln.ID] = append(resultSet[vuln.ID], r) + } + return resultSet, nil + }, + } + + _, ignores, err := almaLinuxMatchesWithUpstreams(mockProvider, tt.pkg) + require.NoError(t, err) + + require.ElementsMatch(t, tt.expectedIgnores, ignores) + }) + } +} diff --git a/grype/matcher/rpm/matcher.go b/grype/matcher/rpm/matcher.go index caf6f07fd2d..362d6ab8e98 100644 --- a/grype/matcher/rpm/matcher.go +++ b/grype/matcher/rpm/matcher.go @@ -42,35 +42,39 @@ func (m *Matcher) Type() match.MatcherType { //nolint:funlen func (m *Matcher) Match(vp vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { var matches []match.Match + var ignored []match.IgnoreFilter // Handle AlmaLinux matching at the top level before the binary/upstream split // AlmaLinux matching needs to handle both binary and upstream packages internally if p.Distro != nil && shouldUseAlmaLinuxMatching(p.Distro) { - almaMatches, err := m.matchAlmaLinux(vp, p) + almaMatches, ignores, err := m.matchAlmaLinux(vp, p) if err != nil { return nil, nil, fmt.Errorf("failed to match AlmaLinux: %w", err) } matches = append(matches, almaMatches...) + ignored = append(ignored, ignores...) } else { // For non-AlmaLinux distros, use the standard binary/upstream split - exactMatches, err := m.matchPackage(vp, p) + exactMatches, ignores, err := m.matchPackage(vp, p) if err != nil { return nil, nil, fmt.Errorf("failed to match by exact package name: %w", err) } matches = append(matches, exactMatches...) + ignored = append(ignored, ignores...) - sourceMatches, err := m.matchUpstreamPackages(vp, p) + sourceMatches, ignores, err := m.matchUpstreamPackages(vp, p) if err != nil { return nil, nil, fmt.Errorf("failed to match by source indirection: %w", err) } matches = append(matches, sourceMatches...) + ignored = append(ignored, ignores...) } // if configured, also search by CPEs for packages from EOL distros if m.cfg.UseCPEsForEOL && internal.IsDistroEOL(vp, p.Distro) { log.WithFields("package", p.Name, "distro", p.Distro).Debug("distro is EOL, searching by CPEs") - cpeMatches, err := internal.MatchPackageByCPEs(vp, p, m.Type()) + cpeMatches, ignores, err := internal.MatchPackageByCPEs(vp, p, m.Type()) switch { case errors.Is(err, internal.ErrEmptyCPEMatch): log.WithFields("package", p.Name).Debug("package has no CPEs for EOL fallback matching") @@ -78,23 +82,24 @@ func (m *Matcher) Match(vp vulnerability.Provider, p pkg.Package) ([]match.Match log.WithFields("package", p.Name, "error", err).Debug("failed to match by CPEs for EOL distro") default: matches = append(matches, cpeMatches...) + ignored = append(ignored, ignores...) } } - return matches, nil, nil + return matches, ignored, nil } // matchAlmaLinux handles AlmaLinux-specific matching logic that considers both binary and upstream packages // This must be called at the top level (before the binary/upstream split) because AlmaLinux matching // needs to search for RHEL disclosures for both the binary package and its upstreams, then filter // using AlmaLinux unaffected records for both the binary package and related packages -func (m *Matcher) matchAlmaLinux(vp vulnerability.Provider, p pkg.Package) ([]match.Match, error) { +func (m *Matcher) matchAlmaLinux(vp vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { if p.Distro == nil { - return nil, nil + return nil, nil, nil } if isUnknownVersion(p.Version) { log.WithFields("package", p.Name).Trace("skipping package with unknown version") - return nil, nil + return nil, nil, nil } provider := result.NewProvider(vp, p, m.Type()) @@ -122,7 +127,7 @@ func (m *Matcher) matchAlmaLinux(vp vulnerability.Provider, p pkg.Package) ([]ma // comparison for the above-mentioned reasons --essentially for the source RPM // case). To do this, we fill in missing epoch values in the package versions with // an explicit 0. -func (m *Matcher) matchPackage(vp vulnerability.Provider, p pkg.Package) ([]match.Match, error) { +func (m *Matcher) matchPackage(vp vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { provider := result.NewProvider(vp, p, m.Type()) // we want to ensure that the version ALWAYS has an epoch specified... but at the same time we do not want to modify the @@ -130,12 +135,12 @@ func (m *Matcher) matchPackage(vp vulnerability.Provider, p pkg.Package) ([]matc // then patch the epoch into the version of the package that we are searching with. addEpochIfApplicable(&p) - matches, err := m.findMatches(provider, p) + matches, ignores, err := m.findMatches(provider, p) if err != nil { - return nil, fmt.Errorf("failed to find vulnerabilities by dpkg source indirection: %w", err) + return nil, nil, fmt.Errorf("failed to find vulnerabilities by dpkg source indirection: %w", err) } - return matches, nil + return matches, ignores, nil } // matchUpstreamPackages finds matches with a synthetic package based on the sourceRPM (indirect match). @@ -180,29 +185,31 @@ func (m *Matcher) matchPackage(vp vulnerability.Provider, p pkg.Package) ([]matc // being compared (in our example, no perl epoch on one side means we should // really assume an epoch of 4 on the other side). This could still lead to // problems since an epoch delimits potentially non-comparable version lineages. -func (m *Matcher) matchUpstreamPackages(vp vulnerability.Provider, p pkg.Package) ([]match.Match, error) { +func (m *Matcher) matchUpstreamPackages(vp vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { provider := result.NewProvider(vp, p, m.Type()) var matches []match.Match + var ignored []match.IgnoreFilter for _, indirectPackage := range pkg.UpstreamPackages(p) { - indirectMatches, err := m.findMatches(provider, indirectPackage) + indirectMatches, ignores, err := m.findMatches(provider, indirectPackage) if err != nil { - return nil, fmt.Errorf("failed to find vulnerabilities for rpm upstream source package: %w", err) + return nil, nil, fmt.Errorf("failed to find vulnerabilities for rpm upstream source package: %w", err) } matches = append(matches, indirectMatches...) + ignored = append(ignored, ignores...) } - return matches, nil + return matches, ignored, nil } -func (m *Matcher) findMatches(provider result.Provider, searchPkg pkg.Package) ([]match.Match, error) { +func (m *Matcher) findMatches(provider result.Provider, searchPkg pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { if searchPkg.Distro == nil { - return nil, nil + return nil, nil, nil } if isUnknownVersion(searchPkg.Version) { log.WithFields("package", searchPkg.Name).Trace("skipping package with unknown version") - return nil, nil + return nil, nil, nil } switch { @@ -213,7 +220,7 @@ func (m *Matcher) findMatches(provider result.Provider, searchPkg pkg.Package) ( } } -func (m *Matcher) standardMatches(provider result.Provider, searchPkg pkg.Package) ([]match.Match, error) { +func (m *Matcher) standardMatches(provider result.Provider, searchPkg pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { // Create version with config embedded pkgVersion := version.NewWithConfig( searchPkg.Version, @@ -223,17 +230,31 @@ func (m *Matcher) standardMatches(provider result.Provider, searchPkg pkg.Packag }, ) - disclosures, err := provider.FindResults( + all, err := provider.FindResults( + search.ByPackageName(searchPkg.Name), + search.ByDistro(*searchPkg.Distro), + internal.OnlyQualifiedPackages(searchPkg), + ) + if err != nil { + return nil, nil, fmt.Errorf("matcher failed to fetch disclosures for distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) + } + unaffected, err := provider.FindResults( search.ByPackageName(searchPkg.Name), search.ByDistro(*searchPkg.Distro), internal.OnlyQualifiedPackages(searchPkg), internal.OnlyVulnerableVersions(pkgVersion), + search.ForUnaffected(), ) if err != nil { - return nil, fmt.Errorf("matcher failed to fetch disclosures for distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) + return nil, nil, fmt.Errorf("matcher failed to fetch unaffected for distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) } - return disclosures.ToMatches(), nil + disclosures := all.Filter(internal.OnlyVulnerableVersions(pkgVersion)) + + // return all unaffected vulns for this version + unaffected = unaffected.Merge(all.Remove(disclosures)) + + return disclosures.ToMatches(), internal.OwnershipIgnores(searchPkg, "Distro Not Vulnerable", unaffected.Vulnerabilities()...), nil } func addEpochIfApplicable(p *pkg.Package) { diff --git a/grype/matcher/rpm/matcher_test.go b/grype/matcher/rpm/matcher_test.go index 3a9c69872f9..8e297f20ceb 100644 --- a/grype/matcher/rpm/matcher_test.go +++ b/grype/matcher/rpm/matcher_test.go @@ -11,7 +11,10 @@ import ( "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/version" "github.com/anchore/grype/grype/vulnerability" + "github.com/anchore/grype/grype/vulnerability/mock" + "github.com/anchore/syft/syft/artifact" syftCpe "github.com/anchore/syft/syft/cpe" syftPkg "github.com/anchore/syft/syft/pkg" ) @@ -327,6 +330,141 @@ func TestMatcherRpm(t *testing.T) { } } +func TestMatcherRpm_IgnoreFilters(t *testing.T) { + d := distro.New(distro.RedHat, "8", "") + tests := []struct { + name string + p pkg.Package + vulns []vulnerability.Vulnerability + expectedIgnores []match.IgnoreFilter + }{ + { + name: "direct match not vulnerable produces Distro Not Vulnerable ignore", + p: pkg.Package{ + ID: pkg.ID("httpd-fixed"), + Name: "httpd", + Version: "2.4.37-51.el8", + Type: syftPkg.RpmPkg, + }, + vulns: []vulnerability.Vulnerability{ + { + PackageName: "httpd", + Reference: vulnerability.Reference{ID: "CVE-2023-1234", Namespace: "redhat:distro:redhat:8"}, + Constraint: version.MustGetConstraint("< 0:2.4.37-50.el8", version.RpmFormat), + }, + }, + // pkg version 51 > fix 50 → not vulnerable → ignore + expectedIgnores: []match.IgnoreFilter{ + match.IgnoreRelatedPackage{ + Reason: "Distro Not Vulnerable", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-2023-1234", + RelatedPackageID: pkg.ID("httpd-fixed"), + }, + }, + }, + { + name: "upstream not vulnerable produces ignore", + p: pkg.Package{ + ID: pkg.ID("neutron-libs-fixed"), + Name: "neutron-libs", + Version: "7.1.3-6", + Type: syftPkg.RpmPkg, + Upstreams: []pkg.UpstreamPackage{ + { + Name: "neutron", + Version: "7.1.3-6.el8", + }, + }, + }, + vulns: []vulnerability.Vulnerability{ + { + // upstream vuln where pkg version is NOT vulnerable (7.1.3-6 >= 7.0.4-1) + PackageName: "neutron", + Reference: vulnerability.Reference{ID: "CVE-2013-old", Namespace: "redhat:distro:redhat:8"}, + Constraint: version.MustGetConstraint("< 7.0.4-1", version.RpmFormat), + }, + }, + expectedIgnores: []match.IgnoreFilter{ + match.IgnoreRelatedPackage{ + Reason: "Distro Not Vulnerable", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-2013-old", + RelatedPackageID: pkg.ID("neutron-libs-fixed"), + }, + }, + }, + { + name: "no ignores when all vulns are vulnerable", + p: pkg.Package{ + ID: pkg.ID("httpd-vuln"), + Name: "httpd", + Version: "2.4.37-10.el8", + Type: syftPkg.RpmPkg, + }, + vulns: []vulnerability.Vulnerability{ + { + PackageName: "httpd", + Reference: vulnerability.Reference{ID: "CVE-2023-1234", Namespace: "redhat:distro:redhat:8"}, + Constraint: version.MustGetConstraint("< 0:2.4.37-50.el8", version.RpmFormat), + }, + }, + // pkg version 10 < 50 → vulnerable → no ignores + expectedIgnores: nil, + }, + { + name: "unaffected record produces ignore", + p: pkg.Package{ + ID: pkg.ID("httpd-unaffected"), + Name: "httpd", + Version: "2.4.37-51.el8", + Type: syftPkg.RpmPkg, + }, + vulns: []vulnerability.Vulnerability{ + { + PackageName: "httpd", + Reference: vulnerability.Reference{ID: "CVE-2023-1234", Namespace: "redhat:distro:redhat:8"}, + Constraint: version.MustGetConstraint("< 0:2.4.37-50.el8", version.RpmFormat), + }, + { + // unaffected record: pkg version 51 satisfies ">= 2.4.37-51.el8" → ignored + PackageName: "httpd", + Reference: vulnerability.Reference{ID: "CVE-2023-5678", Namespace: "redhat:distro:redhat:8"}, + Constraint: version.MustGetConstraint(">= 0:2.4.37-51.el8", version.RpmFormat), + Unaffected: true, + }, + }, + expectedIgnores: []match.IgnoreFilter{ + match.IgnoreRelatedPackage{ + Reason: "Distro Not Vulnerable", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-2023-1234", + RelatedPackageID: pkg.ID("httpd-unaffected"), + }, + match.IgnoreRelatedPackage{ + Reason: "Distro Not Vulnerable", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-2023-5678", + RelatedPackageID: pkg.ID("httpd-unaffected"), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.p.Distro = d + store := mock.VulnerabilityProvider(tt.vulns...) + matcher := Matcher{} + + _, ignores, err := matcher.Match(store, tt.p) + require.NoError(t, err) + + require.ElementsMatch(t, tt.expectedIgnores, ignores) + }) + } +} + func Test_addEpochIfApplicable(t *testing.T) { tests := []struct { name string diff --git a/grype/matcher/rpm/rhel_eus.go b/grype/matcher/rpm/rhel_eus.go index 0e586475f31..9d131c20a7f 100644 --- a/grype/matcher/rpm/rhel_eus.go +++ b/grype/matcher/rpm/rhel_eus.go @@ -163,7 +163,7 @@ func shouldUseRedhatEUSMatching(d *distro.Distro) bool { // Any disclosure that does not apply to the original package version (e.g. a fix was found) at this point has been removed. // // The final step is to render the final matches from the merged collection. -func redhatEUSMatches(provider result.Provider, searchPkg pkg.Package, missingEpochStrategy version.MissingEpochStrategy) ([]match.Match, error) { +func redhatEUSMatches(provider result.Provider, searchPkg pkg.Package, missingEpochStrategy version.MissingEpochStrategy) ([]match.Match, []match.IgnoreFilter, error) { distroWithoutEUS := *searchPkg.Distro distroWithoutEUS.Channels = nil // clear the EUS channel so that we can search for the base distro @@ -184,11 +184,11 @@ func redhatEUSMatches(provider result.Provider, searchPkg pkg.Package, missingEp internal.OnlyVulnerableVersions(pkgVersion), // if these records indicate the version of the package is not vulnerable, do not include them ) if err != nil { - return nil, fmt.Errorf("matcher failed to fetch disclosures for distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) + return nil, nil, fmt.Errorf("matcher failed to fetch disclosures for distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) } if len(disclosures) == 0 { - return nil, nil + return nil, nil, nil } // find all base distro fixes (e.g. '>= 9.0 && < 10') and EUS fixes for the package in the specific minor version of the distro (e.g. '9.4+eus') @@ -202,7 +202,7 @@ func redhatEUSMatches(provider result.Provider, searchPkg pkg.Package, missingEp ) if err != nil { - return nil, fmt.Errorf("matcher failed to fetch resolutions for distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) + return nil, nil, fmt.Errorf("matcher failed to fetch resolutions for distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) } eusFixes := resolutions.Filter(search.ByFixedVersion(*pkgVersion)) @@ -218,7 +218,7 @@ func redhatEUSMatches(provider result.Provider, searchPkg pkg.Package, missingEp // Note: we pass searchPkg.Distro (the EUS distro) to filter out fixes not reachable for this EUS version remaining = remaining.Merge(resolutions, mergeEUSAdvisoriesIntoMainDisclosures(pkgVersion, searchPkg.Distro)) - return remaining.ToMatches(), err + return remaining.ToMatches(), internal.OwnershipIgnores(searchPkg, "Distro Not Vulnerable", eusFixes.Vulnerabilities()...), err } // mergeEUSAdvisoriesIntoMainDisclosures returns a function that will filter disclosures based on the provided advisory information (by fix version only). diff --git a/grype/matcher/rpm/rhel_eus_test.go b/grype/matcher/rpm/rhel_eus_test.go index 3059af0ea00..0a08724bc73 100644 --- a/grype/matcher/rpm/rhel_eus_test.go +++ b/grype/matcher/rpm/rhel_eus_test.go @@ -16,6 +16,7 @@ import ( "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/version" "github.com/anchore/grype/grype/vulnerability" + "github.com/anchore/syft/syft/artifact" syftPkg "github.com/anchore/syft/syft/pkg" ) @@ -1484,7 +1485,7 @@ func TestRedhatEUSMatches(t *testing.T) { resultProvider := result.NewProvider(vulnProvider, tt.catalogPkg, match.RpmMatcher) - got, err := redhatEUSMatches(resultProvider, *tt.searchPkg, "zero") + got, _, err := redhatEUSMatches(resultProvider, *tt.searchPkg, "zero") tt.wantErr(t, err) if err != nil { @@ -1508,6 +1509,101 @@ func TestRedhatEUSMatches(t *testing.T) { } } +func TestRedhatEUSIgnoreFilters(t *testing.T) { + tests := []struct { + name string + pkg pkg.Package + disclosureVulns []vulnerability.Vulnerability + resolutionVulns []vulnerability.Vulnerability + expectedIgnores []match.IgnoreFilter + }{ + { + name: "fixed EUS vulnerability produces ignore", + pkg: pkg.Package{ + ID: pkg.ID("kernel-fixed"), + Name: "kernel", + Version: "5.14.0-503.11.1.el9_5", + Type: syftPkg.RpmPkg, + Distro: newEUSDistro("9.4"), + }, + disclosureVulns: []vulnerability.Vulnerability{ + { + Reference: vulnerability.Reference{ID: "CVE-2024-1111", Namespace: "ns"}, + PackageName: "kernel", + Constraint: version.MustGetConstraint("< 5.14.0-600.el9", version.RpmFormat), + Fix: vulnerability.Fix{State: vulnerability.FixStateUnknown}, + }, + }, + resolutionVulns: []vulnerability.Vulnerability{ + { + Reference: vulnerability.Reference{ID: "CVE-2024-1111", Namespace: "ns"}, + PackageName: "kernel", + Constraint: version.MustGetConstraint("< 5.14.0-400.el9_4", version.RpmFormat), + Fix: vulnerability.Fix{ + State: vulnerability.FixStateFixed, + Versions: []string{"5.14.0-400.el9_4"}, + }, + }, + }, + // pkg version 503 > fix 400 → EUS fix already applied → ignore + expectedIgnores: []match.IgnoreFilter{ + match.IgnoreRelatedPackage{ + Reason: "Distro Not Vulnerable", + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: "CVE-2024-1111", + RelatedPackageID: pkg.ID("kernel-fixed"), + }, + }, + }, + { + name: "no ignores when package is still vulnerable", + pkg: pkg.Package{ + ID: pkg.ID("kernel-vuln"), + Name: "kernel", + Version: "5.14.0-200.el9_4", + Type: syftPkg.RpmPkg, + Distro: newEUSDistro("9.4"), + }, + disclosureVulns: []vulnerability.Vulnerability{ + { + Reference: vulnerability.Reference{ID: "CVE-2024-2222", Namespace: "ns"}, + PackageName: "kernel", + Constraint: version.MustGetConstraint("< 5.14.0-600.el9", version.RpmFormat), + Fix: vulnerability.Fix{State: vulnerability.FixStateUnknown}, + }, + }, + resolutionVulns: []vulnerability.Vulnerability{ + { + Reference: vulnerability.Reference{ID: "CVE-2024-2222", Namespace: "ns"}, + PackageName: "kernel", + Constraint: version.MustGetConstraint("< 5.14.0-400.el9_4", version.RpmFormat), + Fix: vulnerability.Fix{ + State: vulnerability.FixStateFixed, + Versions: []string{"5.14.0-400.el9_4"}, + }, + }, + }, + // pkg version 200 < fix 400 → still vulnerable → no ignores + expectedIgnores: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vulnProvider := newMockVulnProvider() + vulnProvider.setDisclosureVulns(tt.disclosureVulns) + vulnProvider.setResolutionVulns(tt.resolutionVulns) + + resultProvider := result.NewProvider(vulnProvider, tt.pkg, match.RpmMatcher) + + _, ignores, err := redhatEUSMatches(resultProvider, tt.pkg, "zero") + require.NoError(t, err) + + require.ElementsMatch(t, tt.expectedIgnores, ignores) + }) + } +} + func strRef(s string) *string { return &s } diff --git a/grype/search/version_constraint.go b/grype/search/version_constraint.go index 18f8c7d6cef..2152b2548a6 100644 --- a/grype/search/version_constraint.go +++ b/grype/search/version_constraint.go @@ -106,7 +106,13 @@ func (f *constraintFuncCriteria) MatchesVulnerability(value vulnerability.Vulner return false, "no version constraint", nil } matches, err := f.fn(value.Constraint) - // TODO: should we do something about this? + if err != nil { + // TODO revisit this. Returning no error has the effect of dropping the vulnerability in the case an error occurs parsing a package or other version. + // this replicates the existing VulnerabilityProvider behavior; see: vulnerability_provider.go filterAffectedPackageRanges + // if we change the VP behavior, we need to change this behavior, too + log.WithFields("error", err, "constraint", value.Constraint).Debug("match constraint error") + return false, "version check error", nil + } return matches, "", err } diff --git a/grype/search/version_constraint_test.go b/grype/search/version_constraint_test.go index 3604bd3f82f..f119900709a 100644 --- a/grype/search/version_constraint_test.go +++ b/grype/search/version_constraint_test.go @@ -36,6 +36,15 @@ func Test_ByVersion(t *testing.T) { matches: false, reason: "", // we don't expect a reason to be raised up at this level }, + { + name: "version parser failure does not return error", + version: "badverison-1&170", + input: vulnerability.Vulnerability{ + Constraint: version.MustGetConstraint("< 2.0", version.SemanticFormat), + }, + matches: false, + reason: "version check error", + }, } for _, tt := range tests { diff --git a/grype/testdata/unaffected-db/github/metadata.json b/grype/testdata/unaffected-db/github/metadata.json new file mode 100644 index 00000000000..a43ebe1c24b --- /dev/null +++ b/grype/testdata/unaffected-db/github/metadata.json @@ -0,0 +1,21 @@ +{ + "provider": "github", + "urls": [ + "https://api.github.com/graphql" + ], + "store": "flat-file", + "timestamp": "2026-04-24T16:27:34.242623+00:00", + "version": 2, + "distribution_version": 1, + "processor": "vunnel@0.55.3.post12+15bf0b9", + "listing": { + "digest": "4f4bb278a3c74075", + "path": "results/listing.xxh64", + "algorithm": "xxh64" + }, + "schema": { + "version": "1.0.3", + "url": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/provider-workspace-state/schema-1.0.3.json" + }, + "stale": false +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/github/results/gem/ghsa-688c-3x49-6rqj.json b/grype/testdata/unaffected-db/github/results/gem/ghsa-688c-3x49-6rqj.json new file mode 100644 index 00000000000..cfa4d5a9a4d --- /dev/null +++ b/grype/testdata/unaffected-db/github/results/gem/ghsa-688c-3x49-6rqj.json @@ -0,0 +1,90 @@ +{ + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/github-security-advisory/schema-1.0.3.json", + "identifier": "github:gem/ghsa-688c-3x49-6rqj", + "item": { + "Vulnerability": {}, + "Advisory": { + "Classification": "GENERAL", + "Severity": "Medium", + "CVSS": { + "version": "3.0", + "vector_string": "CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N", + "base_metrics": { + "base_score": 5.9, + "exploitability_score": 2.2, + "impact_score": 3.6, + "base_severity": "Medium" + }, + "status": "N/A" + }, + "cvss_severities": [ + { + "version": "3.0", + "vector": "CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N" + } + ], + "FixedIn": [ + { + "name": "rack-protection", + "identifier": "1.5.5", + "ecosystem": "gem", + "namespace": "github:gem", + "range": "< 1.5.5", + "available": { + "date": "2020-07-28", + "kind": "first-observed" + } + }, + { + "name": "rack-protection", + "identifier": "2.0.0", + "ecosystem": "gem", + "namespace": "github:gem", + "range": ">= 2.0.0.beta1 <= 2.0.0.rc3", + "available": { + "date": "2023-01-27", + "kind": "first-observed" + } + } + ], + "Summary": "rack-protection gem timing attack vulnerability when validating CSRF token", + "url": "https://github.com/advisories/GHSA-688c-3x49-6rqj", + "CVE": [ + "CVE-2018-1000119" + ], + "Metadata": { + "CVE": [ + "CVE-2018-1000119" + ] + }, + "ghsaId": "GHSA-688c-3x49-6rqj", + "published": "2018-03-07T22:22:22Z", + "updated": "2023-08-29T15:23:28Z", + "withdrawn": null, + "references": [ + { + "url": "https://nvd.nist.gov/vuln/detail/CVE-2018-1000119" + }, + { + "url": "https://github.com/sinatra/rack-protection/pull/98" + }, + { + "url": "https://github.com/sinatra/sinatra/commit/8aa6c42ef724f93ae309fb7c5668e19ad547eceb#commitcomment-27964109" + }, + { + "url": "https://access.redhat.com/errata/RHSA-2018:1060" + }, + { + "url": "https://www.debian.org/security/2018/dsa-4247" + }, + { + "url": "https://github.com/rubysec/ruby-advisory-db/blob/master/gems/rack-protection/CVE-2018-1000119.yml" + }, + { + "url": "https://github.com/advisories/GHSA-688c-3x49-6rqj" + } + ], + "namespace": "github:gem" + } + } +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/github/results/npm/ghsa-2p57-rm9w-gvfp.json b/grype/testdata/unaffected-db/github/results/npm/ghsa-2p57-rm9w-gvfp.json new file mode 100644 index 00000000000..053a1f02974 --- /dev/null +++ b/grype/testdata/unaffected-db/github/results/npm/ghsa-2p57-rm9w-gvfp.json @@ -0,0 +1,72 @@ +{ + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/github-security-advisory/schema-1.0.3.json", + "identifier": "github:npm/ghsa-2p57-rm9w-gvfp", + "item": { + "Vulnerability": {}, + "Advisory": { + "Classification": "GENERAL", + "Severity": "High", + "CVSS": { + "version": "3.1", + "vector_string": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H", + "base_metrics": { + "base_score": 8.1, + "exploitability_score": 2.2, + "impact_score": 5.9, + "base_severity": "High" + }, + "status": "N/A" + }, + "cvss_severities": [ + { + "version": "3.1", + "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + ], + "FixedIn": [ + { + "name": "ip", + "identifier": "None", + "ecosystem": "npm", + "namespace": "github:npm", + "range": "<= 2.0.1" + } + ], + "Summary": "ip SSRF improper categorization in isPublic", + "url": "https://github.com/advisories/GHSA-2p57-rm9w-gvfp", + "CVE": [ + "CVE-2024-29415" + ], + "Metadata": { + "CVE": [ + "CVE-2024-29415" + ] + }, + "ghsaId": "GHSA-2p57-rm9w-gvfp", + "published": "2024-06-02T22:29:29Z", + "updated": "2025-01-17T21:31:39Z", + "withdrawn": null, + "references": [ + { + "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-29415" + }, + { + "url": "https://github.com/indutny/node-ip/issues/150" + }, + { + "url": "https://github.com/indutny/node-ip/pull/143" + }, + { + "url": "https://github.com/indutny/node-ip/pull/144" + }, + { + "url": "https://security.netapp.com/advisory/ntap-20250117-0010" + }, + { + "url": "https://github.com/advisories/GHSA-2p57-rm9w-gvfp" + } + ], + "namespace": "github:npm" + } + } +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/github/results/npm/ghsa-3787-6prv-h9w3.json b/grype/testdata/unaffected-db/github/results/npm/ghsa-3787-6prv-h9w3.json new file mode 100644 index 00000000000..f7aa821eb46 --- /dev/null +++ b/grype/testdata/unaffected-db/github/results/npm/ghsa-3787-6prv-h9w3.json @@ -0,0 +1,96 @@ +{ + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/github-security-advisory/schema-1.0.3.json", + "identifier": "github:npm/ghsa-3787-6prv-h9w3", + "item": { + "Vulnerability": {}, + "Advisory": { + "Classification": "GENERAL", + "Severity": "Low", + "CVSS": { + "version": "3.1", + "vector_string": "CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L", + "base_metrics": { + "base_score": 3.9, + "exploitability_score": 0.5, + "impact_score": 3.4, + "base_severity": "Low" + }, + "status": "N/A" + }, + "cvss_severities": [ + { + "version": "3.1", + "vector": "CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L" + } + ], + "FixedIn": [ + { + "name": "undici", + "identifier": "5.28.3", + "ecosystem": "npm", + "namespace": "github:npm", + "range": "<= 5.28.2", + "available": { + "date": "2024-02-17", + "kind": "first-observed" + } + }, + { + "name": "undici", + "identifier": "6.6.1", + "ecosystem": "npm", + "namespace": "github:npm", + "range": ">= 6.0.0 <= 6.6.0", + "available": { + "date": "2024-02-17", + "kind": "first-observed" + } + } + ], + "Summary": "Undici proxy-authorization header not cleared on cross-origin redirect in fetch", + "url": "https://github.com/advisories/GHSA-3787-6prv-h9w3", + "CVE": [ + "CVE-2024-24758" + ], + "Metadata": { + "CVE": [ + "CVE-2024-24758" + ] + }, + "ghsaId": "GHSA-3787-6prv-h9w3", + "published": "2024-02-16T16:02:52Z", + "updated": "2026-03-18T05:06:59Z", + "withdrawn": null, + "references": [ + { + "url": "https://github.com/nodejs/undici/security/advisories/GHSA-3787-6prv-h9w3" + }, + { + "url": "https://github.com/nodejs/undici/commit/b9da3e40f1f096a06b4caedbb27c2568730434ef" + }, + { + "url": "https://github.com/nodejs/undici/commit/d3aa574b1259c1d8d329a0f0f495ee82882b1458" + }, + { + "url": "https://github.com/nodejs/undici/releases/tag/v5.28.3" + }, + { + "url": "https://github.com/nodejs/undici/releases/tag/v6.6.1" + }, + { + "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-24758" + }, + { + "url": "https://security.netapp.com/advisory/ntap-20240419-0007" + }, + { + "url": "http://www.openwall.com/lists/oss-security/2024/03/11/1" + }, + { + "url": "https://github.com/advisories/GHSA-3787-6prv-h9w3" + } + ], + "namespace": "github:npm" + } + } +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/github/results/npm/ghsa-9f24-jqhm-jfcw.json b/grype/testdata/unaffected-db/github/results/npm/ghsa-9f24-jqhm-jfcw.json new file mode 100644 index 00000000000..992825ff990 --- /dev/null +++ b/grype/testdata/unaffected-db/github/results/npm/ghsa-9f24-jqhm-jfcw.json @@ -0,0 +1,76 @@ +{ + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/github-security-advisory/schema-1.0.3.json", + "identifier": "github:npm/ghsa-9f24-jqhm-jfcw", + "item": { + "Vulnerability": {}, + "Advisory": { + "Classification": "GENERAL", + "Severity": "Medium", + "CVSS": { + "version": "3.1", + "vector_string": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H", + "base_metrics": { + "base_score": 6.5, + "exploitability_score": 2.8, + "impact_score": 3.6, + "base_severity": "Medium" + }, + "status": "N/A" + }, + "cvss_severities": [ + { + "version": "3.1", + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H" + } + ], + "FixedIn": [ + { + "name": "undici", + "identifier": "6.6.1", + "ecosystem": "npm", + "namespace": "github:npm", + "range": ">= 6.0.0 <= 6.6.0", + "available": { + "date": "2024-02-17", + "kind": "first-observed" + } + } + ], + "Summary": "fetch(url) leads to a memory leak in undici", + "url": "https://github.com/advisories/GHSA-9f24-jqhm-jfcw", + "CVE": [ + "CVE-2024-24750" + ], + "Metadata": { + "CVE": [ + "CVE-2024-24750" + ] + }, + "ghsaId": "GHSA-9f24-jqhm-jfcw", + "published": "2024-02-16T15:59:38Z", + "updated": "2026-03-20T05:06:24Z", + "withdrawn": null, + "references": [ + { + "url": "https://github.com/nodejs/undici/security/advisories/GHSA-9f24-jqhm-jfcw" + }, + { + "url": "https://github.com/nodejs/undici/commit/87a48113f1f68f60aa09abb07276d7c35467c663" + }, + { + "url": "https://github.com/nodejs/undici/releases/tag/v6.6.1" + }, + { + "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-24750" + }, + { + "url": "https://security.netapp.com/advisory/ntap-20240419-0006" + }, + { + "url": "https://github.com/advisories/GHSA-9f24-jqhm-jfcw" + } + ], + "namespace": "github:npm" + } + } +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/github/results/npm/ghsa-9qxr-qj54-h672.json b/grype/testdata/unaffected-db/github/results/npm/ghsa-9qxr-qj54-h672.json new file mode 100644 index 00000000000..cc7dc60d010 --- /dev/null +++ b/grype/testdata/unaffected-db/github/results/npm/ghsa-9qxr-qj54-h672.json @@ -0,0 +1,99 @@ +{ + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/github-security-advisory/schema-1.0.3.json", + "identifier": "github:npm/ghsa-9qxr-qj54-h672", + "item": { + "Vulnerability": {}, + "Advisory": { + "Classification": "GENERAL", + "Severity": "Low", + "CVSS": { + "version": "3.1", + "vector_string": "CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:N/I:L/A:N", + "base_metrics": { + "base_score": 2.6, + "exploitability_score": 1.2, + "impact_score": 1.4, + "base_severity": "Low" + }, + "status": "N/A" + }, + "cvss_severities": [ + { + "version": "3.1", + "vector": "CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:N/I:L/A:N" + } + ], + "FixedIn": [ + { + "name": "undici", + "identifier": "5.28.4", + "ecosystem": "npm", + "namespace": "github:npm", + "range": "< 5.28.4", + "available": { + "date": "2024-04-05", + "kind": "first-observed" + } + }, + { + "name": "undici", + "identifier": "6.11.1", + "ecosystem": "npm", + "namespace": "github:npm", + "range": ">= 6.0.0 < 6.11.1", + "available": { + "date": "2024-04-05", + "kind": "first-observed" + } + } + ], + "Summary": "Undici's fetch with integrity option is too lax when algorithm is specified but hash value is in incorrect", + "url": "https://github.com/advisories/GHSA-9qxr-qj54-h672", + "CVE": [ + "CVE-2024-30261" + ], + "Metadata": { + "CVE": [ + "CVE-2024-30261" + ] + }, + "ghsaId": "GHSA-9qxr-qj54-h672", + "published": "2024-04-04T14:20:54Z", + "updated": "2025-11-04T19:44:42Z", + "withdrawn": null, + "references": [ + { + "url": "https://github.com/nodejs/undici/security/advisories/GHSA-9qxr-qj54-h672" + }, + { + "url": "https://github.com/nodejs/undici/commit/2b39440bd9ded841c93dd72138f3b1763ae26055" + }, + { + "url": "https://github.com/nodejs/undici/commit/d542b8cd39ec1ba303f038ea26098c3f355974f3" + }, + { + "url": "https://hackerone.com/reports/2377760" + }, + { + "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-30261" + }, + { + "url": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/HQVHWAS6WDXXIU7F72XI55VZ2LTZUB33" + }, + { + "url": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/P6Q4RGETHVYVHDIQGTJGU5AV6NJEI67E" + }, + { + "url": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/NC3V3HFZ5MOJRZDY5ZELL6REIRSPFROJ" + }, + { + "url": "https://security.netapp.com/advisory/ntap-20240905-0008" + }, + { + "url": "https://github.com/advisories/GHSA-9qxr-qj54-h672" + } + ], + "namespace": "github:npm" + } + } +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/github/results/npm/ghsa-m4v8-wqvr-p9f7.json b/grype/testdata/unaffected-db/github/results/npm/ghsa-m4v8-wqvr-p9f7.json new file mode 100644 index 00000000000..47befba6d5f --- /dev/null +++ b/grype/testdata/unaffected-db/github/results/npm/ghsa-m4v8-wqvr-p9f7.json @@ -0,0 +1,99 @@ +{ + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/github-security-advisory/schema-1.0.3.json", + "identifier": "github:npm/ghsa-m4v8-wqvr-p9f7", + "item": { + "Vulnerability": {}, + "Advisory": { + "Classification": "GENERAL", + "Severity": "Low", + "CVSS": { + "version": "3.1", + "vector_string": "CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L", + "base_metrics": { + "base_score": 3.9, + "exploitability_score": 0.5, + "impact_score": 3.4, + "base_severity": "Low" + }, + "status": "N/A" + }, + "cvss_severities": [ + { + "version": "3.1", + "vector": "CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L" + } + ], + "FixedIn": [ + { + "name": "undici", + "identifier": "5.28.4", + "ecosystem": "npm", + "namespace": "github:npm", + "range": "< 5.28.4", + "available": { + "date": "2024-04-05", + "kind": "first-observed" + } + }, + { + "name": "undici", + "identifier": "6.11.1", + "ecosystem": "npm", + "namespace": "github:npm", + "range": ">= 6.0.0 < 6.11.1", + "available": { + "date": "2024-04-05", + "kind": "first-observed" + } + } + ], + "Summary": "Undici's Proxy-Authorization header not cleared on cross-origin redirect for dispatch, request, stream, pipeline", + "url": "https://github.com/advisories/GHSA-m4v8-wqvr-p9f7", + "CVE": [ + "CVE-2024-30260" + ], + "Metadata": { + "CVE": [ + "CVE-2024-30260" + ] + }, + "ghsaId": "GHSA-m4v8-wqvr-p9f7", + "published": "2024-04-04T14:20:39Z", + "updated": "2025-11-04T19:44:30Z", + "withdrawn": null, + "references": [ + { + "url": "https://github.com/nodejs/undici/security/advisories/GHSA-m4v8-wqvr-p9f7" + }, + { + "url": "https://github.com/nodejs/undici/commit/64e3402da4e032e68de46acb52800c9a06aaea3f" + }, + { + "url": "https://github.com/nodejs/undici/commit/6805746680d27a5369d7fb67bc05f95a28247d75" + }, + { + "url": "https://hackerone.com/reports/2408074" + }, + { + "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-30260" + }, + { + "url": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/HQVHWAS6WDXXIU7F72XI55VZ2LTZUB33" + }, + { + "url": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/NC3V3HFZ5MOJRZDY5ZELL6REIRSPFROJ" + }, + { + "url": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/P6Q4RGETHVYVHDIQGTJGU5AV6NJEI67E" + }, + { + "url": "https://security.netapp.com/advisory/ntap-20240905-0008" + }, + { + "url": "https://github.com/advisories/GHSA-m4v8-wqvr-p9f7" + } + ], + "namespace": "github:npm" + } + } +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/rhel/metadata.json b/grype/testdata/unaffected-db/rhel/metadata.json new file mode 100644 index 00000000000..905166c6132 --- /dev/null +++ b/grype/testdata/unaffected-db/rhel/metadata.json @@ -0,0 +1,21 @@ +{ + "provider": "rhel", + "urls": [ + "https://api.github.com/graphql" + ], + "store": "flat-file", + "timestamp": "2026-04-24T16:27:34.242623+00:00", + "version": 2, + "distribution_version": 1, + "processor": "vunnel@0.55.3.post12+15bf0b9", + "listing": { + "digest": "4f4bb278a3c74075", + "path": "results/listing.xxh64", + "algorithm": "xxh64" + }, + "schema": { + "version": "1.0.3", + "url": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/provider-workspace-state/schema-1.0.3.json" + }, + "stale": false +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-24750.json b/grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-24750.json new file mode 100644 index 00000000000..c477997cab8 --- /dev/null +++ b/grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-24750.json @@ -0,0 +1,40 @@ +{ + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json", + "identifier": "rhel:10/cve-2024-24750", + "item": { + "Vulnerability": { + "Severity": "Medium", + "NamespaceName": "rhel:10", + "FixedIn": [ + { + "Name": "nodejs-undici", + "Version": "0", + "Module": null, + "VersionFormat": "rpm", + "NamespaceName": "rhel:10", + "VendorAdvisory": { + "NoAdvisory": false, + "AdvisorySummary": [] + } + } + ], + "Link": "https://access.redhat.com/security/cve/CVE-2024-24750", + "Description": "An uncontrolled resource consumption flaw was found in undici. Calling `fetch(url)` and not consuming the incoming body or consuming it very slowly leads to a memory leak.", + "Metadata": {}, + "Name": "CVE-2024-24750", + "CVSS": [ + { + "version": "3.1", + "status": "draft", + "vector_string": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H", + "base_metrics": { + "base_score": 6.5, + "exploitability_score": 2.8, + "impact_score": 3.6, + "base_severity": "Medium" + } + } + ] + } + } +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-24758.json b/grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-24758.json new file mode 100644 index 00000000000..bce9072903a --- /dev/null +++ b/grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-24758.json @@ -0,0 +1,40 @@ +{ + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json", + "identifier": "rhel:10/cve-2024-24758", + "item": { + "Vulnerability": { + "Severity": "Low", + "NamespaceName": "rhel:10", + "FixedIn": [ + { + "Name": "nodejs-undici", + "Version": "0", + "Module": null, + "VersionFormat": "rpm", + "NamespaceName": "rhel:10", + "VendorAdvisory": { + "NoAdvisory": false, + "AdvisorySummary": [] + } + } + ], + "Link": "https://access.redhat.com/security/cve/CVE-2024-24758", + "Description": "A sensitive information exposure vulnerability was found in undici. In this issue, it cleared Authorization headers on cross-origin redirects but did not clear the `Proxy-Authentication` headers.", + "Metadata": {}, + "Name": "CVE-2024-24758", + "CVSS": [ + { + "version": "3.1", + "status": "draft", + "vector_string": "CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L", + "base_metrics": { + "base_score": 3.9, + "exploitability_score": 0.5, + "impact_score": 3.4, + "base_severity": "Low" + } + } + ] + } + } +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-30260.json b/grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-30260.json new file mode 100644 index 00000000000..f09950d8f7e --- /dev/null +++ b/grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-30260.json @@ -0,0 +1,40 @@ +{ + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json", + "identifier": "rhel:10/cve-2024-30260", + "item": { + "Vulnerability": { + "Severity": "Low", + "NamespaceName": "rhel:10", + "FixedIn": [ + { + "Name": "nodejs-undici", + "Version": "0", + "Module": null, + "VersionFormat": "rpm", + "NamespaceName": "rhel:10", + "VendorAdvisory": { + "NoAdvisory": false, + "AdvisorySummary": [] + } + } + ], + "Link": "https://access.redhat.com/security/cve/CVE-2024-30260", + "Description": "A flaw was found in the nodejs-undici package. Proxy-Authorization headers are not cleared on cross-origin redirects, which can allow for the exposure of sensitive data or allow an attacker to capture the persistent proxy-authentication header.", + "Metadata": {}, + "Name": "CVE-2024-30260", + "CVSS": [ + { + "version": "3.1", + "status": "verified", + "vector_string": "CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L", + "base_metrics": { + "base_score": 3.9, + "exploitability_score": 0.5, + "impact_score": 3.4, + "base_severity": "Low" + } + } + ] + } + } +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-30261.json b/grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-30261.json new file mode 100644 index 00000000000..6a14dce76d4 --- /dev/null +++ b/grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-30261.json @@ -0,0 +1,40 @@ +{ + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json", + "identifier": "rhel:10/cve-2024-30261", + "item": { + "Vulnerability": { + "Severity": "Low", + "NamespaceName": "rhel:10", + "FixedIn": [ + { + "Name": "nodejs-undici", + "Version": "0", + "Module": null, + "VersionFormat": "rpm", + "NamespaceName": "rhel:10", + "VendorAdvisory": { + "NoAdvisory": false, + "AdvisorySummary": [] + } + } + ], + "Link": "https://access.redhat.com/security/cve/CVE-2024-30261", + "Description": "A flaw was found in the nodejs-undici package. This issue may allow an attacker to alter the integrity option passed to fetch(), allowing fetch() to accept requests as valid even if they have been tampered with.", + "Metadata": {}, + "Name": "CVE-2024-30261", + "CVSS": [ + { + "version": "3.1", + "status": "verified", + "vector_string": "CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:N/I:L/A:N", + "base_metrics": { + "base_score": 2.6, + "exploitability_score": 1.2, + "impact_score": 1.4, + "base_severity": "Low" + } + } + ] + } + } +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/rhel/results/rhel-8/cve-2018-1000119.json b/grype/testdata/unaffected-db/rhel/results/rhel-8/cve-2018-1000119.json new file mode 100644 index 00000000000..4266289449b --- /dev/null +++ b/grype/testdata/unaffected-db/rhel/results/rhel-8/cve-2018-1000119.json @@ -0,0 +1,40 @@ +{ + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json", + "identifier": "rhel:8/cve-2018-1000119", + "item": { + "Vulnerability": { + "Severity": "Medium", + "NamespaceName": "rhel:8", + "FixedIn": [ + { + "Name": "rubygem-rack-protection", + "Version": "0", + "Module": null, + "VersionFormat": "rpm", + "NamespaceName": "rhel:8", + "VendorAdvisory": { + "NoAdvisory": false, + "AdvisorySummary": [] + } + } + ], + "Link": "https://access.redhat.com/security/cve/CVE-2018-1000119", + "Description": "Sinatra rack-protection versions 1.5.4 and 2.0.0.rc3 and earlier contains a timing attack vulnerability in the CSRF token checking that can result in signatures can be exposed. This attack appear to be exploitable via network connectivity to the ruby application. This vulnerability appears to have been fixed in 1.5.5 and 2.0.0.", + "Metadata": {}, + "Name": "CVE-2018-1000119", + "CVSS": [ + { + "version": "3.0", + "status": "verified", + "vector_string": "CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N", + "base_metrics": { + "base_score": 3.7, + "exploitability_score": 2.2, + "impact_score": 1.4, + "base_severity": "Low" + } + } + ] + } + } +} \ No newline at end of file diff --git a/grype/testdata/unaffected-db/rhel/results/rhel-9/cve-2024-29415.json b/grype/testdata/unaffected-db/rhel/results/rhel-9/cve-2024-29415.json new file mode 100644 index 00000000000..b812bfc5345 --- /dev/null +++ b/grype/testdata/unaffected-db/rhel/results/rhel-9/cve-2024-29415.json @@ -0,0 +1,62 @@ +{ + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json", + "identifier": "rhel:9/cve-2024-29415", + "item": { + "Vulnerability": { + "Severity": "High", + "NamespaceName": "rhel:9", + "FixedIn": [ + { + "Name": "nodejs", + "Version": "0", + "Module": null, + "VersionFormat": "rpm", + "NamespaceName": "rhel:9", + "VendorAdvisory": { + "NoAdvisory": false, + "AdvisorySummary": [] + } + }, + { + "Name": "nodejs", + "Version": "0", + "Module": "nodejs:18", + "VersionFormat": "rpm", + "NamespaceName": "rhel:9", + "VendorAdvisory": { + "NoAdvisory": false, + "AdvisorySummary": [] + } + }, + { + "Name": "nodejs", + "Version": "0", + "Module": "nodejs:20", + "VersionFormat": "rpm", + "NamespaceName": "rhel:9", + "VendorAdvisory": { + "NoAdvisory": false, + "AdvisorySummary": [] + } + } + ], + "Link": "https://access.redhat.com/security/cve/CVE-2024-29415", + "Description": "A flaw was found in node-ip. The fix for CVE-2023-42282 in the ip package for Node.js was incomplete, and the issue may still be triggered using some IP addresses.", + "Metadata": {}, + "Name": "CVE-2024-29415", + "CVSS": [ + { + "version": "3.1", + "status": "verified", + "vector_string": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "base_metrics": { + "base_score": 9.8, + "exploitability_score": 3.9, + "impact_score": 5.9, + "base_severity": "Critical" + } + } + ] + } + } +} \ No newline at end of file diff --git a/grype/vulnerability/mock/vulnerability_provider.go b/grype/vulnerability/mock/vulnerability_provider.go index cd03208711f..ddeef0de21d 100644 --- a/grype/vulnerability/mock/vulnerability_provider.go +++ b/grype/vulnerability/mock/vulnerability_provider.go @@ -61,12 +61,21 @@ func (s *mockProvider) FindVulnerabilities(criteria ...vulnerability.Criteria) ( out = append(out, s.Vulnerabilities...) return filterE(out, func(v vulnerability.Vulnerability) (bool, error) { for _, row := range search.CriteriaIterator(criteria) { + // FIXME: searchForUnaffected is to emulate behavior in the v6 VulnerabilityProvider, which does not include + // unaffected results unless search.UnaffectedCriteria is present + searchForUnaffected := false for _, c := range row { + if _, ok := c.(*search.UnaffectedCriteria); ok { + searchForUnaffected = true + } matches, _, err := c.MatchesVulnerability(v) if !matches || err != nil { return false, err } } + if !searchForUnaffected && v.Unaffected { + return false, nil + } } return true, nil }) diff --git a/grype/vulnerability_matcher_validation_test.go b/grype/vulnerability_matcher_validation_test.go new file mode 100644 index 00000000000..7a38d6e0abe --- /dev/null +++ b/grype/vulnerability_matcher_validation_test.go @@ -0,0 +1,192 @@ +package grype + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/anchore/grype/grype/matcher" + "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/internal/dbtest" + "github.com/anchore/syft/syft/artifact" + syftPkg "github.com/anchore/syft/syft/pkg" +) + +// Test_UnaffectedFiltering exercises the RPM unaffected + GitHub advisory ownership ignore mechanism +// using a real built grype database from the testdata/unaffected-db fixtures. +// +// Scenario: RHEL marks packages as "unaffected" (Version: 0) for certain CVEs. The same CVEs have +// GitHub advisories for language packages (npm, gem). When an RPM package owns the language package's +// files via OwnershipByFileOverlapRelationship, the RPM's "unaffected" status should suppress the +// GitHub advisory match on the language package. +func Test_UnaffectedFiltering(t *testing.T) { + dbtest.DBs(t, "unaffected-db").Run(func(t *testing.T, db *dbtest.DB) { + defaultMatchers := matcher.NewDefaultMatchers(matcher.Config{}) + + tests := []struct { + name string + packages []pkg.Package + wantVulnIDs []string + }{ + { + name: "RHEL10 unaffected nodejs-undici suppresses npm undici GHSAs (CVE-2024-24758 and related)", + packages: func() []pkg.Package { + rpmPkg := dbtest.NewPackage("nodejs-undici", "0:5.28.0-1.el10", syftPkg.RpmPkg). + WithDistro(dbtest.RHEL10). + Build() + + npmPkg := dbtest.NewPackage("undici", "5.28.0", syftPkg.NpmPkg). + WithLanguage(syftPkg.JavaScript). + WithLocation("/usr/lib/node_modules/undici/package.json"). + WithRelatedPackage(artifact.OwnershipByFileOverlapRelationship, &rpmPkg). + Build() + + return []pkg.Package{rpmPkg, npmPkg} + }(), + wantVulnIDs: nil, // all suppressed + }, + { + name: "RHEL8 unaffected rubygem-rack-protection suppresses gem GHSA-688c-3x49-6rqj (CVE-2018-1000119)", + packages: func() []pkg.Package { + rpmPkg := dbtest.NewPackage("rubygem-rack-protection", "0:1.5.3-5.el8", syftPkg.RpmPkg). + WithDistro(dbtest.RHEL8). + Build() + + gemPkg := dbtest.NewPackage("rack-protection", "1.5.3", syftPkg.GemPkg). + WithLanguage(syftPkg.Ruby). + WithLocation("/usr/share/gems/gems/rack-protection-1.5.3/lib/rack/protection.rb"). + WithRelatedPackage(artifact.OwnershipByFileOverlapRelationship, &rpmPkg). + Build() + + return []pkg.Package{rpmPkg, gemPkg} + }(), + wantVulnIDs: nil, // all suppressed + }, + { + name: "npm undici vulnerable without RPM ownership relationship", + packages: []pkg.Package{ + dbtest.NewPackage("nodejs-undici", "0:5.28.0-1.el10", syftPkg.RpmPkg). + WithDistro(dbtest.RHEL10). + Build(), + dbtest.NewPackage("undici", "5.28.0", syftPkg.NpmPkg). + WithLanguage(syftPkg.JavaScript). + WithLocation("/app/node_modules/undici/package.json"). + Build(), // no RelatedPackages — no RPM ownership + }, + wantVulnIDs: []string{ + "GHSA-3787-6prv-h9w3", // CVE-2024-24758 — undici <= 5.28.2 + "GHSA-9qxr-qj54-h672", // CVE-2024-30261 — undici < 5.28.4 + "GHSA-m4v8-wqvr-p9f7", // CVE-2024-30260 — undici < 5.28.4 + }, + }, + { + // The RHEL NAK for CVE-2024-29415 is on the *upstream* RPM "nodejs" (not the binary RPM name), + // and includes modularity entries for nodejs:18 and nodejs:20. The npm "ip" package is bundled + // inside the nodejs RPM, so the RPM owns the npm package's files via OwnershipByFileOverlap. + // The upstream match path + modularity qualifier should produce an ownership ignore that + // suppresses the GitHub advisory GHSA-2p57-rm9w-gvfp on the npm "ip" package. + name: "RHEL9 unaffected upstream nodejs (nodejs:18 module) suppresses npm ip GHSA-2p57-rm9w-gvfp", + packages: func() []pkg.Package { + rpmPkg := dbtest.NewPackage("npm", "1:18.20.8-1.module+el9.5.0+22758+4ad2c198", syftPkg.RpmPkg). + WithDistro(dbtest.RHEL9). + WithMetadata(pkg.RpmMetadata{ + Epoch: ptr(1), + ModularityLabel: ptr("nodejs:18:9050020250203080038:8cf767d6"), + }). + WithUpstream("nodejs", ""). + Build() + + npmPkg := dbtest.NewPackage("ip", "2.0.1", syftPkg.NpmPkg). + WithLanguage(syftPkg.JavaScript). + WithLocation("/usr/lib/node_modules/npm/node_modules/ip/lib/ip.js"). + WithRelatedPackage(artifact.OwnershipByFileOverlapRelationship, &rpmPkg). + Build() + + return []pkg.Package{rpmPkg, npmPkg} + }(), + wantVulnIDs: nil, // suppressed by upstream RPM NAK + }, + { + // Same as above but with nodejs:20 modularity — exercises the second module entry. + name: "RHEL9 unaffected upstream nodejs (nodejs:20 module) suppresses npm ip GHSA-2p57-rm9w-gvfp", + packages: func() []pkg.Package { + rpmPkg := dbtest.NewPackage("npm", "1:20.18.3-1.module+el9.5.0+22758+abc12345", syftPkg.RpmPkg). + WithDistro(dbtest.RHEL9). + WithMetadata(pkg.RpmMetadata{ + Epoch: ptr(1), + ModularityLabel: ptr("nodejs:20:9050020250203080038:8cf767d6"), + }). + WithUpstream("nodejs", ""). + Build() + + npmPkg := dbtest.NewPackage("ip", "2.0.1", syftPkg.NpmPkg). + WithLanguage(syftPkg.JavaScript). + WithLocation("/usr/lib/node_modules/npm/node_modules/ip/lib/ip.js"). + WithRelatedPackage(artifact.OwnershipByFileOverlapRelationship, &rpmPkg). + Build() + + return []pkg.Package{rpmPkg, npmPkg} + }(), + wantVulnIDs: nil, // suppressed by upstream RPM NAK + }, + { + // npm "ip" without any RPM ownership should still match the GitHub advisory. + name: "npm ip vulnerable without RPM ownership relationship (CVE-2024-29415)", + packages: []pkg.Package{ + dbtest.NewPackage("nodejs", "1:20.18.3-1.module+el9.5.0+22758+abc12345", syftPkg.RpmPkg). + WithDistro(dbtest.RHEL9). + WithMetadata(pkg.RpmMetadata{ + Epoch: ptr(1), + ModularityLabel: ptr("nodejs:20:9050020250203080038:8cf767d6"), + }). + WithUpstream("nodejs", ""). + Build(), + dbtest.NewPackage("ip", "2.0.1", syftPkg.NpmPkg). + WithLanguage(syftPkg.JavaScript). + WithLocation("/app/node_modules/ip/lib/ip.js"). + Build(), // no RelatedPackages — no RPM ownership + }, + wantVulnIDs: []string{ + "GHSA-2p57-rm9w-gvfp", // CVE-2024-29415 — ip <= 2.0.1 + }, + }, + { + name: "gem rack-protection vulnerable without RPM ownership relationship", + packages: []pkg.Package{ + dbtest.NewPackage("rubygem-rack-protection", "0:1.5.3-5.el8", syftPkg.RpmPkg). + WithDistro(dbtest.RHEL8). + Build(), + dbtest.NewPackage("rack-protection", "1.5.3", syftPkg.GemPkg). + WithLanguage(syftPkg.Ruby). + WithLocation("/app/vendor/bundle/gems/rack-protection-1.5.3/lib/rack/protection.rb"). + Build(), // no RelatedPackages — no RPM ownership + }, + wantVulnIDs: []string{ + "GHSA-688c-3x49-6rqj", // CVE-2018-1000119 — rack-protection < 1.5.5 + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &VulnerabilityMatcher{ + VulnerabilityProvider: db, + Matchers: defaultMatchers, + } + + matches, _, err := m.FindMatches(tt.packages, pkg.Context{}) + require.NoError(t, err) + + var gotMatches []string + for _, m := range matches.Sorted() { + gotMatches = append(gotMatches, m.Vulnerability.ID) + } + require.ElementsMatch(t, tt.wantVulnIDs, gotMatches) + }) + } + }) +} + +func ptr[T any](t T) *T { + return &t +} diff --git a/internal/dbtest/package.go b/internal/dbtest/package.go index 68c194b80fb..ebfa1e8030b 100644 --- a/internal/dbtest/package.go +++ b/internal/dbtest/package.go @@ -5,7 +5,9 @@ import ( "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/grype/pkg" + "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/cpe" + "github.com/anchore/syft/syft/file" syftPkg "github.com/anchore/syft/syft/pkg" ) @@ -27,9 +29,10 @@ var ( Alpine318 = distro.New(distro.Alpine, "3.18", "") Alpine319 = distro.New(distro.Alpine, "3.19", "") - RHEL7 = distro.New(distro.RedHat, "7", "") - RHEL8 = distro.New(distro.RedHat, "8", "") - RHEL9 = distro.New(distro.RedHat, "9", "") + RHEL7 = distro.New(distro.RedHat, "7", "") + RHEL8 = distro.New(distro.RedHat, "8", "") + RHEL9 = distro.New(distro.RedHat, "9", "") + RHEL10 = distro.New(distro.RedHat, "10", "") ) // PackageBuilder provides a fluent API for building test packages. @@ -111,7 +114,27 @@ func (b *PackageBuilder) WithLicenses(licenses ...string) *PackageBuilder { return b } +// WithLocation adds a file location to the package. +func (b *PackageBuilder) WithLocation(path string) *PackageBuilder { + b.pkg.Locations = file.NewLocationSet( + append(b.pkg.Locations.ToSlice(), file.NewLocation(path))..., + ) + return b +} + +// WithRelatedPackage adds a related package via the given relationship type. +func (b *PackageBuilder) WithRelatedPackage(relationshipType artifact.RelationshipType, related *pkg.Package) *PackageBuilder { + if b.pkg.RelatedPackages == nil { + b.pkg.RelatedPackages = make(map[artifact.RelationshipType][]*pkg.Package) + } + b.pkg.RelatedPackages[relationshipType] = append(b.pkg.RelatedPackages[relationshipType], related) + return b +} + // Build returns the constructed package. func (b *PackageBuilder) Build() pkg.Package { + if b.pkg.ID == "" { + b.pkg.ID = pkg.ID(uuid.New().String()) + } return b.pkg } diff --git a/test/quality/vulnerability-match-labels b/test/quality/vulnerability-match-labels index 2dc3c828717..df746a3d4c3 160000 --- a/test/quality/vulnerability-match-labels +++ b/test/quality/vulnerability-match-labels @@ -1 +1 @@ -Subproject commit 2dc3c828717741e26e3e780b24a3263cac450926 +Subproject commit df746a3d4c38ca90e15fb552f64fdf09668d9ded