From 1c26bb5314b49b9d691251febfb687678ee866ff Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Mon, 30 Mar 2026 15:31:22 -0400 Subject: [PATCH 01/23] feat: ignore overlapping fixed vulns for rpm + more Signed-off-by: Keith Zantow --- grype/matcher/apk/matcher.go | 20 +++++------ grype/matcher/dpkg/matcher.go | 6 ++-- grype/matcher/internal/common.go | 3 +- grype/matcher/internal/cpe.go | 20 +++++++---- grype/matcher/internal/distro.go | 2 +- grype/matcher/internal/ignores.go | 19 ++++++++++- grype/matcher/rpm/almalinux.go | 24 +++++++------ grype/matcher/rpm/matcher.go | 56 ++++++++++++++++++------------- grype/matcher/rpm/rhel_eus.go | 10 +++--- 9 files changed, 99 insertions(+), 61 deletions(-) diff --git a/grype/matcher/apk/matcher.go b/grype/matcher/apk/matcher.go index e2f5c547d5e..1bda2acc464 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) { @@ -185,7 +185,7 @@ func (m *Matcher) findMatchesForPackage(store vulnerability.Provider, p pkg.Pack } // 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) { @@ -257,5 +257,5 @@ func (m *Matcher) findNaksForPackage(provider vulnerability.Provider, p pkg.Pack naks = append(naks, upstreamNaks...) } - return internal.OwnershipIgnores(p, "Explicit APK NAK", naks...), nil + return internal.OwnershipAndPathIgnores(p, "Explicit APK NAK", naks...), nil } diff --git a/grype/matcher/dpkg/matcher.go b/grype/matcher/dpkg/matcher.go index 87e0066307d..c1c4ab341c6 100644 --- a/grype/matcher/dpkg/matcher.go +++ b/grype/matcher/dpkg/matcher.go @@ -37,6 +37,7 @@ func (m *Matcher) Type() match.MatcherType { } func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { + var ignores []match.IgnoreFilter matches := make([]match.Match, 0) sourceMatches, err := m.matchUpstreamPackages(store, p) @@ -57,7 +58,7 @@ func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Ma // 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,10 +66,11 @@ 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) { 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..2969d0751bf 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,16 @@ 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) { +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) @@ -81,26 +85,28 @@ func MatchPackageByCPEs(provider vulnerability.Provider, p pkg.Package, upstream } // find all vulnerability records in the DB for the given CPE (not including version comparisons) - vulns, err := provider.FindVulnerabilities( + all, err := provider.FindResults( search.ByCPE(c), OnlyVulnerableTargets(p), OnlyQualifiedPackages(p), - OnlyVulnerableVersions(verObj), OnlyNonWithdrawnVulnerabilities(), ) 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 by CPE pkg=%q: %w", p.Name, err) } + vulns := all.Filter(OnlyVulnerableVersions(verObj)) + ignores = append(ignores, OwnershipIgnores(p, "CPE not vulnerable", all.Remove(vulns).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/distro.go b/grype/matcher/internal/distro.go index 6ac1fd58026..74b92852d8b 100644 --- a/grype/matcher/internal/distro.go +++ b/grype/matcher/internal/distro.go @@ -106,7 +106,7 @@ func MatchPackageByDistroWithOwnedFiles(provider vulnerability.Provider, searchP matches := vulnerable.ToMatches() // 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()...) + ignores := OwnershipAndPathIgnores(matchPackage(searchPkg, catalogPkg), "DistroPackageFixed", fixed.Vulnerabilities()...) return matches, ignores, nil } diff --git a/grype/matcher/internal/ignores.go b/grype/matcher/internal/ignores.go index a8445a1d3d4..7f91c4af3df 100644 --- a/grype/matcher/internal/ignores.go +++ b/grype/matcher/internal/ignores.go @@ -10,7 +10,7 @@ import ( "github.com/anchore/syft/syft/artifact" ) -func OwnershipIgnores(p pkg.Package, reason string, ignoredVulnerabilities ...vulnerability.Vulnerability) []match.IgnoreFilter { +func OwnershipAndPathIgnores(p pkg.Package, reason string, ignoredVulnerabilities ...vulnerability.Vulnerability) []match.IgnoreFilter { var ignores []match.IgnoreFilter paths := ownedFilesFor(p) @@ -42,6 +42,23 @@ func OwnershipIgnores(p pkg.Package, reason string, ignoredVulnerabilities ...vu return ignores } +func OwnershipIgnores(p pkg.Package, reason string, ignoredVulnerabilities ...vulnerability.Vulnerability) []match.IgnoreFilter { + var ignores []match.IgnoreFilter + + for _, ignoredVulnerability := range ignoredVulnerabilities { + for _, ignoreVulnID := range collectVulnerabilityIDs(ignoredVulnerability) { + ignores = append(ignores, match.IgnoreRelatedPackage{ + Reason: fmt.Sprintf("%s by Ownership from package: %v", reason, p), + RelationshipType: artifact.OwnershipByFileOverlapRelationship, + VulnerabilityID: ignoreVulnID, + RelatedPackageID: p.ID, + }) + } + } + + return ignores +} + // ownedFilesFor returns the files owned by the package if its metadata implements [pkg.FileOwner]. func ownedFilesFor(p pkg.Package) []string { if fo, ok := p.Metadata.(pkg.FileOwner); ok { diff --git a/grype/matcher/rpm/almalinux.go b/grype/matcher/rpm/almalinux.go index 2e35bfd3390..20629285246 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,15 +63,16 @@ 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 @@ -81,16 +84,17 @@ 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 +106,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 +119,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 +133,7 @@ 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 + return updatedDisclosures.ToMatches(), ignored, nil } // findRelatedUnaffectedPackages searches for unaffected packages using source/binary RPM relationships diff --git a/grype/matcher/rpm/matcher.go b/grype/matcher/rpm/matcher.go index caf6f07fd2d..6fd47056c9d 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,18 @@ 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), - internal.OnlyVulnerableVersions(pkgVersion), ) 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) } - return disclosures.ToMatches(), nil + disclosures := all.Filter(internal.OnlyVulnerableVersions(pkgVersion)) + + return disclosures.ToMatches(), internal.OwnershipIgnores(searchPkg, "Distro Not Vulnerable", all.Remove(disclosures).Vulnerabilities()...), nil } func addEpochIfApplicable(p *pkg.Package) { 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). From c9e50b16f1fc6c00a686fb9dfb1b79c8e339771e Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Mon, 30 Mar 2026 16:03:33 -0400 Subject: [PATCH 02/23] chore: update tests Signed-off-by: Keith Zantow --- grype/matcher/internal/cpe_test.go | 2 +- grype/matcher/rpm/almalinux_test.go | 4 ++-- grype/matcher/rpm/rhel_eus_test.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/grype/matcher/internal/cpe_test.go b/grype/matcher/internal/cpe_test.go index 0d8f9f47145..4025a5fe0a7 100644 --- a/grype/matcher/internal/cpe_test.go +++ b/grype/matcher/internal/cpe_test.go @@ -999,7 +999,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 } diff --git a/grype/matcher/rpm/almalinux_test.go b/grype/matcher/rpm/almalinux_test.go index bb0ef71481b..8f852873d91 100644 --- a/grype/matcher/rpm/almalinux_test.go +++ b/grype/matcher/rpm/almalinux_test.go @@ -123,7 +123,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 +1564,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 diff --git a/grype/matcher/rpm/rhel_eus_test.go b/grype/matcher/rpm/rhel_eus_test.go index 3059af0ea00..c80c11a9b36 100644 --- a/grype/matcher/rpm/rhel_eus_test.go +++ b/grype/matcher/rpm/rhel_eus_test.go @@ -1484,7 +1484,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 { From 6907ec156430270d625fe88150b84e4b89c2d0bc Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Mon, 30 Mar 2026 16:56:51 -0400 Subject: [PATCH 03/23] fix: filterVulns should update version in match details Signed-off-by: Keith Zantow --- grype/matcher/internal/distro.go | 34 ----------- grype/matcher/internal/result/results.go | 44 ++++++++++++-- grype/matcher/internal/result/results_test.go | 57 +++++++++++++++++++ 3 files changed, 96 insertions(+), 39 deletions(-) diff --git a/grype/matcher/internal/distro.go b/grype/matcher/internal/distro.go index 74b92852d8b..bc8b115d508 100644 --- a/grype/matcher/internal/distro.go +++ b/grype/matcher/internal/distro.go @@ -99,10 +99,6 @@ func MatchPackageByDistroWithOwnedFiles(provider vulnerability.Provider, searchP vulnerable := allVulns.Filter(versionCriteria) fixed := allVulns.Remove(vulnerable) - // The superset query omits version criteria, so match details are missing the searched-by - // version. Patch it in from the search package before converting to matches. - patchDetailVersion(vulnerable, searchPkg.Version) - matches := vulnerable.ToMatches() // Use the SBOM package (not the synthetic upstream) for file ownership — the upstream package doesn't have file metadata. @@ -151,33 +147,3 @@ func distroMatchDetails(upstreamMatcher match.MatcherType, searchPkg pkg.Package func isUnknownVersion(v string) bool { return strings.ToLower(v) == "unknown" } - -// patchDetailVersion fills in the searched-by package version on match details that are missing it. -// This is needed when results come from a superset query (no version criteria), since -// result.Provider only populates the version from VersionCriteria in the query. -func patchDetailVersion(s result.Set, version string) { - for _, results := range s { - for i := range results { - for j := range results[i].Details { - d := &results[i].Details[j] - switch sb := d.SearchedBy.(type) { - case match.DistroParameters: - if sb.Package.Version == "" { - sb.Package.Version = version - d.SearchedBy = sb - } - case match.EcosystemParameters: - if sb.Package.Version == "" { - sb.Package.Version = version - d.SearchedBy = sb - } - case match.CPEParameters: - if sb.Package.Version == "" { - sb.Package.Version = version - d.SearchedBy = sb - } - } - } - } - } -} diff --git a/grype/matcher/internal/result/results.go b/grype/matcher/internal/result/results.go index bf1d42e2316..cb425cd9aa6 100644 --- a/grype/matcher/internal/result/results.go +++ b/grype/matcher/internal/result/results.go @@ -5,6 +5,7 @@ import ( "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/search" "github.com/anchore/grype/grype/vulnerability" "github.com/anchore/grype/internal/log" ) @@ -273,7 +274,7 @@ func (s Set) Filter(criteria ...vulnerability.Criteria) Set { var filteredResults []Result for _, result := range results { - vulns, err := filterVulns(result.Vulnerabilities, criteria) + vulns, details, err := filterVulns(result.Vulnerabilities, result.Details, criteria) if err != nil { log.WithFields("vulnerability", result.ID, "error", err).Debug("failed to filter vulns") // if there was an error filtering vulnerabilities, keep them all @@ -286,7 +287,7 @@ func (s Set) Filter(criteria ...vulnerability.Criteria) Set { filteredResults = append(filteredResults, Result{ ID: result.ID, Vulnerabilities: vulns, - Details: result.Details, + Details: details, Package: result.Package, }) } @@ -300,14 +301,20 @@ func (s Set) Filter(criteria ...vulnerability.Criteria) Set { return out } -func filterVulns(vulnerabilities []vulnerability.Vulnerability, criteria []vulnerability.Criteria) ([]vulnerability.Vulnerability, error) { +func filterVulns(vulnerabilities []vulnerability.Vulnerability, details match.Details, criteria []vulnerability.Criteria) ([]vulnerability.Vulnerability, match.Details, error) { var out []vulnerability.Vulnerability nextVulnerability: for _, v := range vulnerabilities { for _, c := range criteria { + if versionCriteria, ok := c.(*search.VersionCriteria); ok { + // The superset query omits version criteria, so match details are missing the searched-by + // version. Patch it in from the search package before converting to matches. + details = patchDetailVersion(details, versionCriteria.Version.Raw) + } + matches, dropReason, err := c.MatchesVulnerability(v) if err != nil { - return nil, err + return nil, details, err } if !matches { vulnerability.LogDropped(v.ID, "filterVulns", dropReason, c) @@ -316,5 +323,32 @@ nextVulnerability: } out = append(out, v) } - return out, nil + return out, details, nil +} + +// patchDetailVersion fills in the searched-by package version on match details that are missing it. +// This is needed when results come from a superset query (no version criteria), since +// result.Provider only populates the version from VersionCriteria in the query. +func patchDetailVersion(details match.Details, version string) match.Details { + for i := range details { + d := &details[i] + switch sb := d.SearchedBy.(type) { + case match.DistroParameters: + if sb.Package.Version == "" { + sb.Package.Version = version + d.SearchedBy = sb + } + case match.EcosystemParameters: + if sb.Package.Version == "" { + sb.Package.Version = version + d.SearchedBy = sb + } + case match.CPEParameters: + if sb.Package.Version == "" { + sb.Package.Version = version + d.SearchedBy = sb + } + } + } + return details } diff --git a/grype/matcher/internal/result/results_test.go b/grype/matcher/internal/result/results_test.go index 0d68e03c4f5..07b1053c724 100644 --- a/grype/matcher/internal/result/results_test.go +++ b/grype/matcher/internal/result/results_test.go @@ -704,6 +704,63 @@ func TestSet_Filter(t *testing.T) { }, want: Set{}, }, + { + name: "filter with version criteria patches match detail version", + receiver: Set{ + "vuln-1": []Result{ + { + ID: "vuln-1", + Vulnerabilities: []vulnerability.Vulnerability{ + { + Reference: vulnerability.Reference{ID: "CVE-2021-1"}, + PackageName: "test-Package", + Constraint: version.MustGetConstraint("< 2.0.0", version.SemanticFormat), + }, + }, + Details: match.Details{ + { + Type: match.ExactDirectMatch, + SearchedBy: match.EcosystemParameters{ + Package: match.PackageParameter{ + Name: "test-Package", + Version: "", + }, + }, + }, + }, + }, + }, + }, + criteria: []vulnerability.Criteria{ + search.ByPackageName("test-Package"), + search.ByVersion(*version.New("1.0.0", version.SemanticFormat)), + }, + want: Set{ + "vuln-1": []Result{ + { + ID: "vuln-1", + Vulnerabilities: []vulnerability.Vulnerability{ + { + Reference: vulnerability.Reference{ID: "CVE-2021-1"}, + PackageName: "test-Package", + Constraint: version.MustGetConstraint("< 2.0.0", version.SemanticFormat), + }, + }, + Details: match.Details{ + { + Type: match.ExactDirectMatch, + SearchedBy: match.EcosystemParameters{ + Package: match.PackageParameter{ + Name: "test-Package", + Version: "1.0.0", + }, + }, + }, + }, + }, + }, + }, + }, { name: "filter with no criteria returns original set", receiver: Set{ From 445ca53d618bf96d551fe9f8f0cd7bf08f9a5097 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Mon, 30 Mar 2026 18:31:49 -0400 Subject: [PATCH 04/23] fix: vulnerability provider constraint creation without version specified Signed-off-by: Keith Zantow --- grype/db/v6/vulnerability.go | 2 +- grype/db/v6/vulnerability_test.go | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/grype/db/v6/vulnerability.go b/grype/db/v6/vulnerability.go index a5c96ce6e62..ff05c0b7d79 100644 --- a/grype/db/v6/vulnerability.go +++ b/grype/db/v6/vulnerability.go @@ -129,7 +129,7 @@ func getVersionConstraint(affectedRanges []Range) (version.Constraint, error) { } versionFormat := version.ParseFormat(ty) - constraint, err := version.GetConstraint(strings.Join(constraints, ","), versionFormat) + constraint, err := version.GetConstraint(strings.Join(constraints, "||"), versionFormat) if err != nil { log.WithFields("error", err, "constraint", constraints).Debug("unable to parse constraint") return nil, err diff --git a/grype/db/v6/vulnerability_test.go b/grype/db/v6/vulnerability_test.go index 3cbdb059f88..79fa479ad58 100644 --- a/grype/db/v6/vulnerability_test.go +++ b/grype/db/v6/vulnerability_test.go @@ -532,6 +532,17 @@ func TestV5Namespace(t *testing.T) { } } +func Test_getVersionConstraint_multipleConstraintsUsesOrOperator(t *testing.T) { + ranges := []Range{ + {Version: Version{Type: "rpm", Constraint: "< 1.0.0"}}, + {Version: Version{Type: "rpm", Constraint: "< 2.0.0"}}, + } + constraint, err := getVersionConstraint(ranges) + require.NoError(t, err) + require.NotNil(t, constraint) + assert.Contains(t, constraint.String(), "||") +} + func Test_getRelatedVulnerabilities(t *testing.T) { tests := []struct { name string From cc440616867e563613edb9ef87a9ac8caa76f0f2 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Tue, 7 Apr 2026 13:59:03 -0400 Subject: [PATCH 05/23] perf: make relationships by overlapping locations Signed-off-by: Keith Zantow --- grype/matcher/apk/matcher.go | 2 +- grype/matcher/internal/distro.go | 2 +- grype/matcher/internal/ignores.go | 43 +--- grype/matcher/internal/result/results.go | 13 +- grype/pkg/package.go | 39 +++- grype/pkg/package_test.go | 275 +++++++++++++++++++++++ 6 files changed, 321 insertions(+), 53 deletions(-) diff --git a/grype/matcher/apk/matcher.go b/grype/matcher/apk/matcher.go index 1bda2acc464..f1a235f8d42 100644 --- a/grype/matcher/apk/matcher.go +++ b/grype/matcher/apk/matcher.go @@ -257,5 +257,5 @@ func (m *Matcher) findNaksForPackage(provider vulnerability.Provider, p pkg.Pack naks = append(naks, upstreamNaks...) } - return internal.OwnershipAndPathIgnores(p, "Explicit APK NAK", naks...), nil + return internal.OwnershipIgnores(p, "Explicit APK NAK", naks...), nil } diff --git a/grype/matcher/internal/distro.go b/grype/matcher/internal/distro.go index bc8b115d508..94e4d29e3e4 100644 --- a/grype/matcher/internal/distro.go +++ b/grype/matcher/internal/distro.go @@ -102,7 +102,7 @@ func MatchPackageByDistroWithOwnedFiles(provider vulnerability.Provider, searchP matches := vulnerable.ToMatches() // Use the SBOM package (not the synthetic upstream) for file ownership — the upstream package doesn't have file metadata. - ignores := OwnershipAndPathIgnores(matchPackage(searchPkg, catalogPkg), "DistroPackageFixed", fixed.Vulnerabilities()...) + ignores := OwnershipIgnores(matchPackage(searchPkg, catalogPkg), "DistroPackageFixed", fixed.Vulnerabilities()...) return matches, ignores, nil } diff --git a/grype/matcher/internal/ignores.go b/grype/matcher/internal/ignores.go index 7f91c4af3df..d3061791d8d 100644 --- a/grype/matcher/internal/ignores.go +++ b/grype/matcher/internal/ignores.go @@ -1,7 +1,6 @@ package internal import ( - "fmt" "slices" "github.com/anchore/grype/grype/match" @@ -10,45 +9,13 @@ import ( "github.com/anchore/syft/syft/artifact" ) -func OwnershipAndPathIgnores(p pkg.Package, reason string, ignoredVulnerabilities ...vulnerability.Vulnerability) []match.IgnoreFilter { - var ignores []match.IgnoreFilter - - paths := ownedFilesFor(p) - - for _, ignoredVulnerability := range ignoredVulnerabilities { - for _, ignoreVulnID := range collectVulnerabilityIDs(ignoredVulnerability) { - for _, path := range paths { - ignores = append(ignores, - match.IgnoreRule{ - Vulnerability: ignoreVulnID, - IncludeAliases: true, - Reason: fmt.Sprintf("%s from package: %v", reason, p), - Package: match.IgnoreRulePackage{ - Location: path, - }, - }, - ) - } - - ignores = append(ignores, match.IgnoreRelatedPackage{ - Reason: fmt.Sprintf("%s by Ownership from package: %v", reason, p), - RelationshipType: artifact.OwnershipByFileOverlapRelationship, - VulnerabilityID: ignoreVulnID, - RelatedPackageID: p.ID, - }) - } - } - - return ignores -} - func OwnershipIgnores(p pkg.Package, reason string, ignoredVulnerabilities ...vulnerability.Vulnerability) []match.IgnoreFilter { var ignores []match.IgnoreFilter for _, ignoredVulnerability := range ignoredVulnerabilities { for _, ignoreVulnID := range collectVulnerabilityIDs(ignoredVulnerability) { ignores = append(ignores, match.IgnoreRelatedPackage{ - Reason: fmt.Sprintf("%s by Ownership from package: %v", reason, p), + Reason: reason, RelationshipType: artifact.OwnershipByFileOverlapRelationship, VulnerabilityID: ignoreVulnID, RelatedPackageID: p.ID, @@ -59,14 +26,6 @@ func OwnershipIgnores(p pkg.Package, reason string, ignoredVulnerabilities ...vu return ignores } -// ownedFilesFor returns the files owned by the package if its metadata implements [pkg.FileOwner]. -func ownedFilesFor(p pkg.Package) []string { - if fo, ok := p.Metadata.(pkg.FileOwner); ok { - return fo.OwnedFiles() - } - return nil -} - // collectVulnerabilityIDs returns the primary ID plus all related/alias IDs for a vulnerability. func collectVulnerabilityIDs(v vulnerability.Vulnerability) []string { ids := make([]string, 1, len(v.RelatedVulnerabilities)+1) diff --git a/grype/matcher/internal/result/results.go b/grype/matcher/internal/result/results.go index cb425cd9aa6..d79c0424b54 100644 --- a/grype/matcher/internal/result/results.go +++ b/grype/matcher/internal/result/results.go @@ -306,12 +306,6 @@ func filterVulns(vulnerabilities []vulnerability.Vulnerability, details match.De nextVulnerability: for _, v := range vulnerabilities { for _, c := range criteria { - if versionCriteria, ok := c.(*search.VersionCriteria); ok { - // The superset query omits version criteria, so match details are missing the searched-by - // version. Patch it in from the search package before converting to matches. - details = patchDetailVersion(details, versionCriteria.Version.Raw) - } - matches, dropReason, err := c.MatchesVulnerability(v) if err != nil { return nil, details, err @@ -320,6 +314,11 @@ nextVulnerability: vulnerability.LogDropped(v.ID, "filterVulns", dropReason, c) continue nextVulnerability } + + if versionCriteria, ok := c.(*search.VersionCriteria); ok { + // version info needs to be captured when applicable, until there is a better audit mechanism + details = patchDetailVersion(details, versionCriteria.Version.Raw) + } } out = append(out, v) } @@ -327,8 +326,6 @@ nextVulnerability: } // patchDetailVersion fills in the searched-by package version on match details that are missing it. -// This is needed when results come from a superset query (no version criteria), since -// result.Provider only populates the version from VersionCriteria in the query. func patchDetailVersion(details match.Details, version string) match.Details { for i := range details { d := &details[i] diff --git a/grype/pkg/package.go b/grype/pkg/package.go index 1005477d0aa..a98355747de 100644 --- a/grype/pkg/package.go +++ b/grype/pkg/package.go @@ -93,12 +93,17 @@ func FromCollection(catalog *syftPkg.Collection, relationships []artifact.Relati return FromPackages(catalog.Sorted(), relationships, config, enhancers...) } +// FromPackages creates grype packages from syft packages, including relevant relationship mappings +// +//nolint:gocognit,funlen func FromPackages(syftPkgs []syftPkg.Package, relationships []artifact.Relationship, config SynthesisConfig, enhancers ...Enhancer) []Package { var pkgs []Package // if the user provided a distro explicitly, then use that over any distro that may be inferred from a package url enhancers = append([]Enhancer{applyDistroOverride(config.Distro.Override)}, enhancers...) + ownedLocations := map[string][]int{} + pkgIdx := make(map[ID]int) for _, p := range syftPkgs { @@ -112,10 +117,19 @@ func FromPackages(syftPkgs []syftPkg.Package, relationships []artifact.Relations } grypePkg := New(p, enhancers...) - pkgIdx[grypePkg.ID] = len(pkgs) + idx := len(pkgs) + pkgIdx[grypePkg.ID] = idx pkgs = append(pkgs, grypePkg) + + // track all owned locations + if owner, ok := p.Metadata.(FileOwner); ok { + for _, loc := range owner.OwnedFiles() { + ownedLocations[loc] = append(ownedLocations[loc], idx) + } + } } + hasOverlapRelationships := false for _, r := range relationships { if !retainRelationshipType(r.Type) { continue @@ -134,9 +148,32 @@ func FromPackages(syftPkgs []syftPkg.Package, relationships []artifact.Relations pkgs[fromPkg].RelatedPackages = map[artifact.RelationshipType][]*Package{} } + // not all SBOMs include this relationship, we want to recreate this if it's missing + if r.Type == artifact.OwnershipByFileOverlapRelationship { + hasOverlapRelationships = true + } pkgs[fromPkg].RelatedPackages[r.Type] = append(pkgs[fromPkg].RelatedPackages[r.Type], &pkgs[toPkg]) } + // recreate overlap-by-file-ownership based on owned files for SBOMs without these relationships + if !hasOverlapRelationships && len(ownedLocations) > 0 { + for i := range pkgs { + for _, loc := range pkgs[i].Locations.ToUnorderedSlice() { + if contained, ok := ownedLocations[loc.RealPath]; ok { + for _, ownerPackageIndex := range contained { + if ownerPackageIndex == i { + continue + } + if pkgs[i].RelatedPackages == nil { + pkgs[i].RelatedPackages = map[artifact.RelationshipType][]*Package{} + } + pkgs[i].RelatedPackages[artifact.OwnershipByFileOverlapRelationship] = append(pkgs[i].RelatedPackages[artifact.OwnershipByFileOverlapRelationship], &pkgs[ownerPackageIndex]) + } + } + } + } + } + return pkgs } diff --git a/grype/pkg/package_test.go b/grype/pkg/package_test.go index 8ab0dfafec4..6e406f8f490 100644 --- a/grype/pkg/package_test.go +++ b/grype/pkg/package_test.go @@ -1061,6 +1061,281 @@ func intRef(i int) *int { return &i } +func TestFromPackages_OwnershipByFileOverlap(t *testing.T) { + tests := []struct { + name string + syftPkgs []syftPkg.Package + relationships []artifact.Relationship + expectOverlapRelated map[string][]string // pkg name -> names of related packages via overlap + expectNoOverlapRelated []string // pkg names that should have no overlap relationships + }{ + { + name: "recreates overlap relationships from owned files when no overlap relationships exist", + syftPkgs: func() []syftPkg.Package { + // APK package that owns /usr/bin/python3 + apk := syftPkg.Package{ + Name: "python3", + Version: "3.9.2", + Type: syftPkg.ApkPkg, + Metadata: syftPkg.ApkDBEntry{ + Files: []syftPkg.ApkFileRecord{ + {Path: "/usr/bin/python3"}, + }, + }, + } + apk.SetID() + + // binary package located at /usr/bin/python3 + bin := syftPkg.Package{ + Name: "python", + Version: "3.9.2", + Type: syftPkg.BinaryPkg, + Locations: file.NewLocationSet( + file.NewLocation("/usr/bin/python3"), + ), + } + bin.SetID() + + return []syftPkg.Package{apk, bin} + }(), + relationships: nil, // no relationships provided + expectOverlapRelated: map[string][]string{ + "python": {"python3"}, // binary pkg should have overlap relationship to apk pkg + }, + expectNoOverlapRelated: []string{"python3"}, + }, + { + name: "does not recreate overlap when overlap relationships already exist", + syftPkgs: func() []syftPkg.Package { + apk := syftPkg.Package{ + Name: "python3", + Version: "3.9.2", + Type: syftPkg.ApkPkg, + Metadata: syftPkg.ApkDBEntry{ + Files: []syftPkg.ApkFileRecord{ + {Path: "/usr/bin/python3"}, + }, + }, + } + apk.SetID() + + bin := syftPkg.Package{ + Name: "python", + Version: "3.9.2", + Type: syftPkg.BinaryPkg, + Locations: file.NewLocationSet( + file.NewLocation("/usr/bin/python3"), + ), + } + bin.SetID() + + return []syftPkg.Package{apk, bin} + }(), + relationships: func() []artifact.Relationship { + apk := syftPkg.Package{ + Name: "python3", + Version: "3.9.2", + Type: syftPkg.ApkPkg, + Metadata: syftPkg.ApkDBEntry{ + Files: []syftPkg.ApkFileRecord{ + {Path: "/usr/bin/python3"}, + }, + }, + } + apk.SetID() + + bin := syftPkg.Package{ + Name: "python", + Version: "3.9.2", + Type: syftPkg.BinaryPkg, + Locations: file.NewLocationSet( + file.NewLocation("/usr/bin/python3"), + ), + } + bin.SetID() + + return []artifact.Relationship{ + { + From: apk, + To: bin, + Type: artifact.OwnershipByFileOverlapRelationship, + }, + } + }(), + // when relationships already exist, the existing relationship is used (inverted: bin -> apk) + expectOverlapRelated: map[string][]string{ + "python": {"python3"}, + }, + expectNoOverlapRelated: []string{"python3"}, + }, + { + name: "no overlap relationship when locations do not match owned files", + syftPkgs: func() []syftPkg.Package { + apk := syftPkg.Package{ + Name: "python3", + Version: "3.9.2", + Type: syftPkg.ApkPkg, + Metadata: syftPkg.ApkDBEntry{ + Files: []syftPkg.ApkFileRecord{ + {Path: "/usr/bin/python3"}, + }, + }, + } + apk.SetID() + + bin := syftPkg.Package{ + Name: "node", + Version: "18.0.0", + Type: syftPkg.BinaryPkg, + Locations: file.NewLocationSet( + file.NewLocation("/usr/bin/node"), // different path + ), + } + bin.SetID() + + return []syftPkg.Package{apk, bin} + }(), + relationships: nil, + expectNoOverlapRelated: []string{"python3", "node"}, + }, + { + name: "does not create self-referencing overlap relationship", + syftPkgs: func() []syftPkg.Package { + // package that owns the same location it is found at + apk := syftPkg.Package{ + Name: "bash", + Version: "5.1", + Type: syftPkg.ApkPkg, + Locations: file.NewLocationSet( + file.NewLocation("/usr/bin/bash"), + ), + Metadata: syftPkg.ApkDBEntry{ + Files: []syftPkg.ApkFileRecord{ + {Path: "/usr/bin/bash"}, + }, + }, + } + apk.SetID() + + return []syftPkg.Package{apk} + }(), + relationships: nil, + expectNoOverlapRelated: []string{"bash"}, + }, + { + name: "multiple packages sharing owned file location", + syftPkgs: func() []syftPkg.Package { + apk := syftPkg.Package{ + Name: "openssl", + Version: "1.1.1", + Type: syftPkg.ApkPkg, + Metadata: syftPkg.ApkDBEntry{ + Files: []syftPkg.ApkFileRecord{ + {Path: "/usr/lib/libssl.so"}, + }, + }, + } + apk.SetID() + + apk2 := syftPkg.Package{ + Name: "openssl-libs", + Version: "1.1.1", + Type: syftPkg.ApkPkg, + Metadata: syftPkg.ApkDBEntry{ + Files: []syftPkg.ApkFileRecord{ + {Path: "/usr/lib/libssl.so"}, + }, + }, + } + apk2.SetID() + + bin := syftPkg.Package{ + Name: "openssl-binary", + Version: "1.1.1", + Type: syftPkg.BinaryPkg, + Locations: file.NewLocationSet( + file.NewLocation("/usr/lib/libssl.so"), + ), + } + bin.SetID() + + return []syftPkg.Package{apk, apk2, bin} + }(), + relationships: nil, + expectOverlapRelated: map[string][]string{ + "openssl-binary": {"openssl", "openssl-libs"}, + }, + expectNoOverlapRelated: []string{"openssl", "openssl-libs"}, + }, + { + name: "package without FileOwner metadata does not create overlap", + syftPkgs: func() []syftPkg.Package { + npm := syftPkg.Package{ + Name: "lodash", + Version: "4.17.21", + Type: syftPkg.NpmPkg, + Locations: file.NewLocationSet( + file.NewLocation("/app/node_modules/lodash"), + ), + } + npm.SetID() + + npm2 := syftPkg.Package{ + Name: "underscore", + Version: "1.13.0", + Type: syftPkg.NpmPkg, + Locations: file.NewLocationSet( + file.NewLocation("/app/node_modules/underscore"), + ), + } + npm2.SetID() + + return []syftPkg.Package{npm, npm2} + }(), + relationships: nil, + expectNoOverlapRelated: []string{"lodash", "underscore"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := FromPackages(tt.syftPkgs, tt.relationships, SynthesisConfig{}) + + pkgByName := map[string]Package{} + for _, p := range pkgs { + pkgByName[p.Name] = p + } + + for pkgName, expectedRelated := range tt.expectOverlapRelated { + p, ok := pkgByName[pkgName] + if !ok { + t.Fatalf("expected package %q not found in results", pkgName) + } + + related := p.RelatedPackages[artifact.OwnershipByFileOverlapRelationship] + var relatedNames []string + for _, r := range related { + relatedNames = append(relatedNames, r.Name) + } + + assert.ElementsMatch(t, expectedRelated, relatedNames, + "package %q should have overlap relationships with %v, got %v", pkgName, expectedRelated, relatedNames) + } + + for _, pkgName := range tt.expectNoOverlapRelated { + p, ok := pkgByName[pkgName] + if !ok { + t.Fatalf("expected package %q not found in results", pkgName) + } + + related := p.RelatedPackages[artifact.OwnershipByFileOverlapRelationship] + assert.Empty(t, related, + "package %q should have no overlap relationships, got %d", pkgName, len(related)) + } + }) + } +} + func Test_RemovePackagesByOverlap(t *testing.T) { tests := []struct { name string From 31a562d4695cd1b22394c020f153bb4d87c1b056 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Tue, 7 Apr 2026 14:13:04 -0400 Subject: [PATCH 06/23] chore: update tests Signed-off-by: Keith Zantow --- grype/matcher/apk/matcher_test.go | 137 +++++++----------------------- 1 file changed, 29 insertions(+), 108 deletions(-) diff --git a/grype/matcher/apk/matcher_test.go b/grype/matcher/apk/matcher_test.go index a2dab63a61f..7d2912eb122 100644 --- a/grype/matcher/apk/matcher_test.go +++ b/grype/matcher/apk/matcher_test.go @@ -926,84 +926,13 @@ func Test_nakConstraint(t *testing.T) { } } -func TestNakIgnoreRulesIncludeRelatedPackageFilter(t *testing.T) { - // This test verifies that NAK entries (< 0 version constraint) produce IgnoreRelatedPackage filters - // in addition to location-based IgnoreRule filters, so that related packages (e.g. python packages - // owned by an APK package) are also ignored by relationship, not just by file location. - nakVuln := vulnerability.Vulnerability{ - Reference: vulnerability.Reference{ - ID: "GHSA-xjjg-vmw6-c2p9", - Namespace: "wolfi:distro:wolfi:rolling", - }, - PackageName: "httpie", - Constraint: version.MustGetConstraint("< 0", version.ApkFormat), - } - - vp := mock.VulnerabilityProvider(nakVuln) - - apkMatcher := &Matcher{} - - tests := []struct { - name string - pkg pkg.Package - expectLocationIgnores int - expectRelatedPackageIgnores int - }{ - { - name: "NAK with files produces both location and relationship ignores", - pkg: pkg.Package{ - ID: "apk-httpie-pkg", - Name: "httpie", - Distro: &distro.Distro{Type: distro.Wolfi}, - Metadata: pkg.ApkMetadata{Files: []pkg.ApkFileRecord{ - {Path: "/usr/lib/python3.14/site-packages/httpie-1.0.2.dist-info/METADATA"}, - }}, - }, - expectLocationIgnores: 1, - expectRelatedPackageIgnores: 1, - }, - { - name: "NAK without files produces only relationship ignores", - pkg: pkg.Package{ - ID: "apk-httpie-pkg-no-files", - Name: "httpie", - Distro: &distro.Distro{Type: distro.Wolfi}, - Metadata: pkg.ApkMetadata{Files: nil}, - }, - expectLocationIgnores: 0, - expectRelatedPackageIgnores: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, ignores, err := apkMatcher.Match(vp, tt.pkg) - require.NoError(t, err) - - var locationIgnores int - var relatedPkgIgnores int - for _, ignore := range ignores { - switch ignore.(type) { - case match.IgnoreRule: - locationIgnores++ - case match.IgnoreRelatedPackage: - relatedPkgIgnores++ - } - } - - assert.Equal(t, tt.expectLocationIgnores, locationIgnores, "location-based ignore count") - assert.Equal(t, tt.expectRelatedPackageIgnores, relatedPkgIgnores, "related-package ignore count") - }) - } -} - func Test_nakIgnoreRules(t *testing.T) { cases := []struct { - name string - pkgs []pkg.Package - vulns []vulnerability.Vulnerability - expectedLocationIgnores map[string][]string - errAssertion assert.ErrorAssertionFunc + name string + pkgs []pkg.Package + vulns []vulnerability.Vulnerability + expectedVulnIgnores []string + errAssertion assert.ErrorAssertionFunc }{ { name: "false positive in wolfi package adds index entry", @@ -1028,8 +957,8 @@ func Test_nakIgnoreRules(t *testing.T) { Constraint: version.MustGetConstraint("< 0", version.ApkFormat), }, }, - expectedLocationIgnores: map[string][]string{ - "/bin/foo-binary": {"GHSA-2014-fake-3"}, + expectedVulnIgnores: []string{ + "GHSA-2014-fake-3", }, errAssertion: assert.NoError, }, @@ -1061,8 +990,8 @@ func Test_nakIgnoreRules(t *testing.T) { Constraint: version.MustGetConstraint("< 0", version.ApkFormat), }, }, - expectedLocationIgnores: map[string][]string{ - "/bin/foo-subpackage-binary": {"GHSA-2014-fake-3"}, + expectedVulnIgnores: []string{ + "GHSA-2014-fake-3", }, errAssertion: assert.NoError, }, @@ -1089,11 +1018,11 @@ func Test_nakIgnoreRules(t *testing.T) { Constraint: version.MustGetConstraint("< 1.2.3-r4", version.ApkFormat), }, }, - expectedLocationIgnores: map[string][]string{}, - errAssertion: assert.NoError, + expectedVulnIgnores: []string{}, + errAssertion: assert.NoError, }, { - name: "no vuln data for wolfi package", + name: "no NAK for wolfi package", pkgs: []pkg.Package{ { Name: "foo", @@ -1105,31 +1034,26 @@ func Test_nakIgnoreRules(t *testing.T) { }}, }, }, - vulns: []vulnerability.Vulnerability{}, - expectedLocationIgnores: map[string][]string{}, - errAssertion: assert.NoError, - }, - { - name: "no files listed for a wolfi package", - pkgs: []pkg.Package{ + vulns: []vulnerability.Vulnerability{ { - Name: "foo", - Distro: &distro.Distro{Type: distro.Wolfi}, - Metadata: pkg.ApkMetadata{Files: nil}, + Reference: vulnerability.Reference{ + ID: "GHSA-2014-fake-2", + Namespace: "wolfi:distro:wolfi:rolling", + }, + PackageName: "not-foo", + Constraint: version.MustGetConstraint("< 0", version.ApkFormat), }, - }, - vulns: []vulnerability.Vulnerability{ { Reference: vulnerability.Reference{ ID: "GHSA-2014-fake-3", Namespace: "wolfi:distro:wolfi:rolling", }, PackageName: "foo", - Constraint: version.MustGetConstraint("< 0", version.ApkFormat), + Constraint: version.MustGetConstraint("< 1", version.ApkFormat), }, }, - expectedLocationIgnores: map[string][]string{}, - errAssertion: assert.NoError, + expectedVulnIgnores: []string{}, + errAssertion: assert.NoError, }, } @@ -1148,19 +1072,16 @@ func Test_nakIgnoreRules(t *testing.T) { allIgnores = append(allIgnores, ignores...) } - actualResult := map[string][]string{} + actualResult := []string{} for _, ignore := range allIgnores { - rule, ok := ignore.(match.IgnoreRule) - if !ok { - // skip non-IgnoreRule filters (e.g. IgnoreRelatedPackage) - continue - } - if rule.Package.Location == "" { - continue + switch rule := ignore.(type) { + case match.IgnoreRule: + actualResult = append(actualResult, rule.Vulnerability) + case match.IgnoreRelatedPackage: + actualResult = append(actualResult, rule.VulnerabilityID) } - actualResult[rule.Package.Location] = append(actualResult[rule.Package.Location], rule.Vulnerability) } - require.Equal(t, tt.expectedLocationIgnores, actualResult) + require.ElementsMatch(t, tt.expectedVulnIgnores, actualResult) }) } } From 840ea166770e3da9fc5d17172f6821fd65c52606 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Tue, 7 Apr 2026 14:36:02 -0400 Subject: [PATCH 07/23] chore: update match labels Signed-off-by: Keith Zantow --- test/quality/vulnerability-match-labels | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/quality/vulnerability-match-labels b/test/quality/vulnerability-match-labels index 54ec792cae7..2dc3c828717 160000 --- a/test/quality/vulnerability-match-labels +++ b/test/quality/vulnerability-match-labels @@ -1 +1 @@ -Subproject commit 54ec792cae7f2fc91b1eebc4721717e90a4a886f +Subproject commit 2dc3c828717741e26e3e780b24a3263cac450926 From e5d3168a6fe7b74e55fcbe470779563f13a6a31f Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Fri, 10 Apr 2026 18:39:30 -0400 Subject: [PATCH 08/23] fix: align vuln filter with vuln provider constraint filtering when error Signed-off-by: Keith Zantow --- grype/search/version_constraint.go | 7 ++++++- grype/search/version_constraint_test.go | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/grype/search/version_constraint.go b/grype/search/version_constraint.go index 18f8c7d6cef..10c51c82c3b 100644 --- a/grype/search/version_constraint.go +++ b/grype/search/version_constraint.go @@ -106,7 +106,12 @@ 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 this is probably not the correct behavior, but it's what the VulnProvider is doing today: dropping vulns when a version error occurs + // 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 { From 465d0e877b98e968c34855b6a1e80a76c0d792b2 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Wed, 15 Apr 2026 10:01:24 -0400 Subject: [PATCH 09/23] fix: ensure unaffected packages are applied everywhere as ignore filters Signed-off-by: Keith Zantow --- grype/matcher/apk/matcher.go | 2 +- grype/matcher/dpkg/matcher.go | 19 ++-- grype/matcher/dpkg/matcher_test.go | 2 +- grype/matcher/internal/cpe.go | 25 ++++-- grype/matcher/internal/distro.go | 101 +++++----------------- grype/matcher/internal/distro_test.go | 2 +- grype/matcher/internal/language.go | 9 +- grype/matcher/internal/result/provider.go | 6 +- grype/matcher/java/matcher.go | 24 +++-- grype/matcher/rpm/almalinux.go | 9 +- 10 files changed, 85 insertions(+), 114 deletions(-) diff --git a/grype/matcher/apk/matcher.go b/grype/matcher/apk/matcher.go index f1a235f8d42..269918043a6 100644 --- a/grype/matcher/apk/matcher.go +++ b/grype/matcher/apk/matcher.go @@ -179,7 +179,7 @@ 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 } diff --git a/grype/matcher/dpkg/matcher.go b/grype/matcher/dpkg/matcher.go index c1c4ab341c6..23535e36998 100644 --- a/grype/matcher/dpkg/matcher.go +++ b/grype/matcher/dpkg/matcher.go @@ -37,23 +37,20 @@ func (m *Matcher) Type() match.MatcherType { } func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { - var ignores []match.IgnoreFilter - 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) { @@ -73,23 +70,25 @@ func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Ma 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/dpkg/matcher_test.go b/grype/matcher/dpkg/matcher_test.go index f96e1704d00..f70dfa1f77a 100644 --- a/grype/matcher/dpkg/matcher_test.go +++ b/grype/matcher/dpkg/matcher_test.go @@ -35,7 +35,7 @@ func TestMatcherDpkg_matchBySourceIndirection(t *testing.T) { } vp := newMockProvider() - actual, err := matcher.matchUpstreamPackages(vp, p) + actual, _, err := matcher.matchUpstreamPackages(vp, p) assert.NoError(t, err, "unexpected err from matchUpstreamPackages", err) assert.Len(t, actual, 2, "unexpected indirect matches count") diff --git a/grype/matcher/internal/cpe.go b/grype/matcher/internal/cpe.go index 2969d0751bf..f9c1adaff36 100644 --- a/grype/matcher/internal/cpe.go +++ b/grype/matcher/internal/cpe.go @@ -38,6 +38,8 @@ 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 +// +//nolint:funlen func MatchPackageByCPEs(vulnProvider vulnerability.Provider, p pkg.Package, upstreamMatcher match.MatcherType) ([]match.Match, []match.IgnoreFilter, error) { provider := result.NewProvider(vulnProvider, p, upstreamMatcher) @@ -84,19 +86,32 @@ func MatchPackageByCPEs(vulnProvider vulnerability.Provider, p pkg.Package, upst verObj = version.New(searchVersion, format) } - // find all vulnerability records in the DB for the given CPE (not including version comparisons) - all, err := provider.FindResults( + criteria := []vulnerability.Criteria{ search.ByCPE(c), OnlyVulnerableTargets(p), OnlyQualifiedPackages(p), 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(OnlyVulnerableVersions(verObj)) - ignores = append(ignores, OwnershipIgnores(p, "CPE not vulnerable", all.Remove(vulns).Vulnerabilities()...)...) + vulns := all.Filter(versionCriteria) + + unaffected, err := provider.FindResults( + append(criteria, search.ForUnaffected(), versionCriteria)..., + ) + if err != nil { + 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 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/rpm/almalinux.go b/grype/matcher/rpm/almalinux.go index 20629285246..2ff86b8b242 100644 --- a/grype/matcher/rpm/almalinux.go +++ b/grype/matcher/rpm/almalinux.go @@ -71,6 +71,7 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa if err != nil { 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()...)...) @@ -93,6 +94,7 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa 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()...)...) @@ -114,6 +116,7 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa search.ByPackageName(binaryPkg.Name), search.ByExactDistro(*binaryPkg.Distro), // use exact AlmaLinux distro for unaffected lookup (no aliases) internal.OnlyQualifiedPackages(binaryPkg), + internal.OnlyVulnerableVersions(pkgVersion), search.ForUnaffected(), ) if err != nil { @@ -125,10 +128,11 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa // Step 4: Find AlmaLinux unaffected records for related packages (source/binary relationships) // This handles cases where AlmaLinux publishes unaffected records for binary packages (e.g., python3-tkinter) // but the disclosure is for the source package (e.g., python3) - relatedUnaffected := findRelatedUnaffectedPackages(provider, binaryPkg) + relatedUnaffected := findRelatedUnaffectedPackages(provider, binaryPkg, pkgVersion) // Merge all unaffected results allUnaffected := directUnaffected.Merge(relatedUnaffected) + ignored = append(ignored, internal.OwnershipIgnores(binaryPkg, "Distro Fixed", allUnaffected.Vulnerabilities()...)...) // Step 5: Apply filtering logic: if disclosure exists and no fix applies, the package is vulnerable updatedDisclosures := applyAlmaLinuxUnaffectedFiltering(allDisclosures, allUnaffected, pkgVersion) @@ -137,7 +141,7 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa } // findRelatedUnaffectedPackages searches for unaffected packages using source/binary RPM relationships -func findRelatedUnaffectedPackages(provider result.Provider, searchPkg pkg.Package) result.Set { +func findRelatedUnaffectedPackages(provider result.Provider, searchPkg pkg.Package, searchVersion *version.Version) result.Set { allResults := make(result.Set) // Get all related package names (source RPM, binary RPM patterns, etc.) @@ -153,6 +157,7 @@ func findRelatedUnaffectedPackages(provider result.Provider, searchPkg pkg.Packa search.ByPackageName(relatedName), search.ByExactDistro(*searchPkg.Distro), // use exact distro to avoid alias mapping internal.OnlyQualifiedPackages(searchPkg), + internal.OnlyVulnerableVersions(searchVersion), search.ForUnaffected(), ) if err != nil { From ebd04e2f0685a08afbe7e5980de69cc05e014e89 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Wed, 15 Apr 2026 12:14:34 -0400 Subject: [PATCH 10/23] fix: alma ignores & tests Signed-off-by: Keith Zantow --- grype/matcher/java/matcher_test.go | 4 ++-- grype/matcher/rpm/almalinux.go | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) 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 2ff86b8b242..31b338b5d4a 100644 --- a/grype/matcher/rpm/almalinux.go +++ b/grype/matcher/rpm/almalinux.go @@ -116,7 +116,6 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa search.ByPackageName(binaryPkg.Name), search.ByExactDistro(*binaryPkg.Distro), // use exact AlmaLinux distro for unaffected lookup (no aliases) internal.OnlyQualifiedPackages(binaryPkg), - internal.OnlyVulnerableVersions(pkgVersion), search.ForUnaffected(), ) if err != nil { @@ -128,20 +127,22 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa // Step 4: Find AlmaLinux unaffected records for related packages (source/binary relationships) // This handles cases where AlmaLinux publishes unaffected records for binary packages (e.g., python3-tkinter) // but the disclosure is for the source package (e.g., python3) - relatedUnaffected := findRelatedUnaffectedPackages(provider, binaryPkg, pkgVersion) + relatedUnaffected := findRelatedUnaffectedPackages(provider, binaryPkg) // Merge all unaffected results allUnaffected := directUnaffected.Merge(relatedUnaffected) - ignored = append(ignored, internal.OwnershipIgnores(binaryPkg, "Distro Fixed", allUnaffected.Vulnerabilities()...)...) // Step 5: Apply filtering logic: if disclosure exists and no fix applies, the package is vulnerable updatedDisclosures := applyAlmaLinuxUnaffectedFiltering(allDisclosures, allUnaffected, pkgVersion) + // 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 -func findRelatedUnaffectedPackages(provider result.Provider, searchPkg pkg.Package, searchVersion *version.Version) result.Set { +func findRelatedUnaffectedPackages(provider result.Provider, searchPkg pkg.Package) result.Set { allResults := make(result.Set) // Get all related package names (source RPM, binary RPM patterns, etc.) @@ -157,7 +158,6 @@ func findRelatedUnaffectedPackages(provider result.Provider, searchPkg pkg.Packa search.ByPackageName(relatedName), search.ByExactDistro(*searchPkg.Distro), // use exact distro to avoid alias mapping internal.OnlyQualifiedPackages(searchPkg), - internal.OnlyVulnerableVersions(searchVersion), search.ForUnaffected(), ) if err != nil { From c389ebcee92c9e260f63991a87c56b5221d1068f Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Wed, 15 Apr 2026 15:33:22 -0400 Subject: [PATCH 11/23] feat(os transformer): unaffected package handles Previously, vunnel dropped records in OS schema providers when the record indicated a package was not affected, since that could not result in a match. However, in order to suppress false positives in the case where a distro has recorded a package is not affected, and that package brings in a language package (npm, PyPI, etc), these records are forwarded by vunnel as if fixed at "version 0". In Grype, take version 0 FixedIn records and transform them into Unaffected Packages in the database. Notably, skip doing this for APKs, since Alpine already has a separate system of NAKs built around forwarding the APK "< 0" versions. Signed-off-by: Will Murphy --- .../os/testdata/rhel-8-not-affected.json | 52 +++++++++++ .../db/v6/build/transformers/os/transform.go | 46 +++++++++- .../build/transformers/os/transform_test.go | 87 +++++++++++++++++++ 3 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 grype/db/v6/build/transformers/os/testdata/rhel-8-not-affected.json 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() From 11b23be50f73f128c08db016bdd57e7e086254bb Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Fri, 17 Apr 2026 12:52:13 -0400 Subject: [PATCH 12/23] fix: missed unaffected selection Signed-off-by: Keith Zantow --- grype/matcher/rpm/matcher.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/grype/matcher/rpm/matcher.go b/grype/matcher/rpm/matcher.go index 6fd47056c9d..362d6ab8e98 100644 --- a/grype/matcher/rpm/matcher.go +++ b/grype/matcher/rpm/matcher.go @@ -238,10 +238,23 @@ func (m *Matcher) standardMatches(provider result.Provider, searchPkg pkg.Packag 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, nil, fmt.Errorf("matcher failed to fetch unaffected for distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) + } disclosures := all.Filter(internal.OnlyVulnerableVersions(pkgVersion)) - return disclosures.ToMatches(), internal.OwnershipIgnores(searchPkg, "Distro Not Vulnerable", all.Remove(disclosures).Vulnerabilities()...), nil + // 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) { From 57b495245397261532565806fd09551676015c46 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Fri, 24 Apr 2026 07:50:48 -0400 Subject: [PATCH 13/23] chore: add test cases Signed-off-by: Keith Zantow --- grype/testdata/.gitignore | 2 + .../results/gem/ghsa-688c-3x49-6rqj.json | 90 ++++++++ .../results/npm/ghsa-3787-6prv-h9w3.json | 96 +++++++++ .../results/npm/ghsa-9f24-jqhm-jfcw.json | 76 +++++++ .../results/npm/ghsa-9qxr-qj54-h672.json | 99 +++++++++ .../results/npm/ghsa-m4v8-wqvr-p9f7.json | 99 +++++++++ .../rhel/results/rhel-10/cve-2024-24750.json | 40 ++++ .../rhel/results/rhel-10/cve-2024-24758.json | 40 ++++ .../rhel/results/rhel-10/cve-2024-30260.json | 40 ++++ .../rhel/results/rhel-10/cve-2024-30261.json | 40 ++++ .../rhel/results/rhel-8/cve-2018-1000119.json | 40 ++++ .../vulnerability_matcher_validation_test.go | 195 ++++++++++++++++++ 12 files changed, 857 insertions(+) create mode 100644 grype/testdata/.gitignore create mode 100644 grype/testdata/unaffected-db/github/results/gem/ghsa-688c-3x49-6rqj.json create mode 100644 grype/testdata/unaffected-db/github/results/npm/ghsa-3787-6prv-h9w3.json create mode 100644 grype/testdata/unaffected-db/github/results/npm/ghsa-9f24-jqhm-jfcw.json create mode 100644 grype/testdata/unaffected-db/github/results/npm/ghsa-9qxr-qj54-h672.json create mode 100644 grype/testdata/unaffected-db/github/results/npm/ghsa-m4v8-wqvr-p9f7.json create mode 100644 grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-24750.json create mode 100644 grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-24758.json create mode 100644 grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-30260.json create mode 100644 grype/testdata/unaffected-db/rhel/results/rhel-10/cve-2024-30261.json create mode 100644 grype/testdata/unaffected-db/rhel/results/rhel-8/cve-2018-1000119.json create mode 100644 grype/vulnerability_matcher_validation_test.go diff --git a/grype/testdata/.gitignore b/grype/testdata/.gitignore new file mode 100644 index 00000000000..61584dc94c2 --- /dev/null +++ b/grype/testdata/.gitignore @@ -0,0 +1,2 @@ +checksums +metadata.json 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-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/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/vulnerability_matcher_validation_test.go b/grype/vulnerability_matcher_validation_test.go new file mode 100644 index 00000000000..740ac5c1941 --- /dev/null +++ b/grype/vulnerability_matcher_validation_test.go @@ -0,0 +1,195 @@ +package grype + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + v6 "github.com/anchore/grype/grype/db/v6" + "github.com/anchore/grype/grype/db/v6/testdb" + "github.com/anchore/grype/grype/distro" + "github.com/anchore/grype/grype/matcher" + "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/vulnerability" + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/file" + 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) { + vp := buildTestDB(t) + + defaultMatchers := matcher.NewDefaultMatchers(matcher.Config{}) + + tests := []struct { + name string + packages []pkg.Package + wantVulnIDs []string + wantIgnoreIDs []string + }{ + { + name: "RHEL10 unaffected nodejs-undici suppresses npm undici GHSAs (CVE-2024-24758 and related)", + packages: []pkg.Package{ + { + ID: pkg.ID("rpm-nodejs-undici"), + Name: "nodejs-undici", + Version: "0:5.28.0-1.el10", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "10", ""), + }, + { + ID: pkg.ID("npm-undici"), + Name: "undici", + Version: "5.28.0", + Type: syftPkg.NpmPkg, + Language: syftPkg.JavaScript, + Locations: file.NewLocationSet( + file.NewLocation("/usr/lib/node_modules/undici/package.json"), + ), + RelatedPackages: map[artifact.RelationshipType][]*pkg.Package{ + artifact.OwnershipByFileOverlapRelationship: { + {ID: pkg.ID("rpm-nodejs-undici"), Name: "nodejs-undici"}, + }, + }, + }, + }, + wantVulnIDs: nil, // all suppressed + }, + { + name: "RHEL8 unaffected rubygem-rack-protection suppresses gem GHSA-688c-3x49-6rqj (CVE-2018-1000119)", + packages: []pkg.Package{ + { + ID: pkg.ID("rpm-rubygem-rack-protection"), + Name: "rubygem-rack-protection", + Version: "0:1.5.3-5.el8", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "8", ""), + }, + { + ID: pkg.ID("gem-rack-protection"), + Name: "rack-protection", + Version: "1.5.3", + Type: syftPkg.GemPkg, + Language: syftPkg.Ruby, + Locations: file.NewLocationSet( + file.NewLocation("/usr/share/gems/gems/rack-protection-1.5.3/lib/rack/protection.rb"), + ), + RelatedPackages: map[artifact.RelationshipType][]*pkg.Package{ + artifact.OwnershipByFileOverlapRelationship: { + {ID: pkg.ID("rpm-rubygem-rack-protection"), Name: "rubygem-rack-protection"}, + }, + }, + }, + }, + wantVulnIDs: nil, // all suppressed + }, + { + name: "npm undici vulnerable without RPM ownership relationship", + packages: []pkg.Package{ + { + ID: pkg.ID("rpm-nodejs-undici"), + Name: "nodejs-undici", + Version: "0:5.28.0-1.el10", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "10", ""), + }, + { + ID: pkg.ID("npm-undici-standalone"), + Name: "undici", + Version: "5.28.0", + Type: syftPkg.NpmPkg, + Language: syftPkg.JavaScript, + Locations: file.NewLocationSet( + file.NewLocation("/app/node_modules/undici/package.json"), + ), + // 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 + }, + }, + { + name: "gem rack-protection vulnerable without RPM ownership relationship", + packages: []pkg.Package{ + { + ID: pkg.ID("rpm-rubygem-rack-protection"), + Name: "rubygem-rack-protection", + Version: "0:1.5.3-5.el8", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "8", ""), + }, + { + ID: pkg.ID("gem-rack-protection-standalone"), + Name: "rack-protection", + Version: "1.5.3", + Type: syftPkg.GemPkg, + Language: syftPkg.Ruby, + Locations: file.NewLocationSet( + file.NewLocation("/app/vendor/bundle/gems/rack-protection-1.5.3/lib/rack/protection.rb"), + ), + // 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: vp, + Matchers: defaultMatchers, + } + + matches, ignoredMatches, 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) + + if tt.wantIgnoreIDs != nil { + var gotIgnores []string + for _, m := range ignoredMatches { + gotIgnores = append(gotIgnores, m.Match.Vulnerability.ID) + } + require.ElementsMatch(t, tt.wantIgnoreIDs, gotIgnores) + } + }) + } +} + +// buildTestDB builds a grype v6 database from flat test data files and returns a vulnerability provider. +func buildTestDB(t *testing.T) vulnerability.Provider { + t.Helper() + + dbDir := filepath.Join(t.TempDir(), "db") + testdb.BuildFromFlatFileDir(t, + time.Now(), + dbDir, + "testdata/unaffected-db", + "**/*", + ) + + rdr, err := v6.NewReader(v6.Config{DBDirPath: dbDir}) + require.NoError(t, err) + t.Cleanup(func() { _ = rdr.Close() }) + + return v6.NewVulnerabilityProvider(rdr) +} From 812f3c22bbc39a6b1c772065338a38fd58b273c4 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Fri, 24 Apr 2026 09:37:29 -0400 Subject: [PATCH 14/23] chore: add test cases / pr feedback Signed-off-by: Keith Zantow --- .../results/npm/ghsa-2p57-rm9w-gvfp.json | 72 +++++++++++ .../rhel/results/rhel-9/cve-2024-29415.json | 62 ++++++++++ .../vulnerability_matcher_validation_test.go | 114 ++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 grype/testdata/unaffected-db/github/results/npm/ghsa-2p57-rm9w-gvfp.json create mode 100644 grype/testdata/unaffected-db/rhel/results/rhel-9/cve-2024-29415.json 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/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_matcher_validation_test.go b/grype/vulnerability_matcher_validation_test.go index 740ac5c1941..ac7eaa14693 100644 --- a/grype/vulnerability_matcher_validation_test.go +++ b/grype/vulnerability_matcher_validation_test.go @@ -120,6 +120,116 @@ func Test_UnaffectedFiltering(t *testing.T) { "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: []pkg.Package{ + { + ID: pkg.ID("rpm-npm"), + Name: "rpm-npm", + Version: "1:18.20.8-1.module+el9.5.0+22758+4ad2c198", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "9", ""), + Metadata: pkg.RpmMetadata{ + Epoch: ptr(1), + ModularityLabel: ptr("nodejs:18:9050020250203080038:8cf767d6"), + }, + Upstreams: []pkg.UpstreamPackage{ + {Name: "nodejs"}, + }, + }, + { + ID: pkg.ID("npm-ip"), + Name: "ip", + Version: "2.0.1", + Type: syftPkg.NpmPkg, + Language: syftPkg.JavaScript, + Locations: file.NewLocationSet( + file.NewLocation("/usr/lib/node_modules/npm/node_modules/ip/lib/ip.js"), + ), + RelatedPackages: map[artifact.RelationshipType][]*pkg.Package{ + artifact.OwnershipByFileOverlapRelationship: { + {ID: pkg.ID("rpm-npm"), Name: "rpm-npm"}, + }, + }, + }, + }, + wantVulnIDs: nil, // suppressed by upstream RPM upstream 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: []pkg.Package{ + { + ID: pkg.ID("rpm-npm-20"), + Name: "rpm-npm", + Version: "1:20.18.3-1.module+el9.5.0+22758+abc12345", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "9", ""), + Metadata: pkg.RpmMetadata{ + Epoch: ptr(1), + ModularityLabel: ptr("nodejs:20:9050020250203080038:8cf767d6"), + }, + Upstreams: []pkg.UpstreamPackage{ + {Name: "nodejs"}, + }, + }, + { + ID: pkg.ID("npm-ip-20"), + Name: "ip", + Version: "2.0.1", + Type: syftPkg.NpmPkg, + Language: syftPkg.JavaScript, + Locations: file.NewLocationSet( + file.NewLocation("/usr/lib/node_modules/npm/node_modules/ip/lib/ip.js"), + ), + RelatedPackages: map[artifact.RelationshipType][]*pkg.Package{ + artifact.OwnershipByFileOverlapRelationship: { + {ID: pkg.ID("rpm-npm-20"), Name: "rpm-npm"}, + }, + }, + }, + }, + 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{ + { + ID: pkg.ID("rpm-nodejs-20"), + Name: "nodejs", + Version: "1:20.18.3-1.module+el9.5.0+22758+abc12345", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "9", ""), + Metadata: pkg.RpmMetadata{ + Epoch: ptr(1), + ModularityLabel: ptr("nodejs:20:9050020250203080038:8cf767d6"), + }, + Upstreams: []pkg.UpstreamPackage{ + {Name: "nodejs"}, + }, + }, + { + ID: pkg.ID("npm-ip-standalone"), + Name: "ip", + Version: "2.0.1", + Type: syftPkg.NpmPkg, + Language: syftPkg.JavaScript, + Locations: file.NewLocationSet( + file.NewLocation("/app/node_modules/ip/lib/ip.js"), + ), + // 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{ @@ -193,3 +303,7 @@ func buildTestDB(t *testing.T) vulnerability.Provider { return v6.NewVulnerabilityProvider(rdr) } + +func ptr[T any](t T) *T { + return &t +} From 13541c4dee5360f6500a254349c65bd0299a07d3 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Fri, 24 Apr 2026 12:40:27 -0400 Subject: [PATCH 15/23] chore: move to new dbtest Signed-off-by: Keith Zantow --- .../vulnerability_matcher_validation_test.go | 418 +++++++----------- internal/dbtest/package.go | 29 +- 2 files changed, 181 insertions(+), 266 deletions(-) diff --git a/grype/vulnerability_matcher_validation_test.go b/grype/vulnerability_matcher_validation_test.go index ac7eaa14693..a4b16b12b8f 100644 --- a/grype/vulnerability_matcher_validation_test.go +++ b/grype/vulnerability_matcher_validation_test.go @@ -1,20 +1,14 @@ package grype import ( - "path/filepath" "testing" - "time" "github.com/stretchr/testify/require" - v6 "github.com/anchore/grype/grype/db/v6" - "github.com/anchore/grype/grype/db/v6/testdb" - "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/grype/matcher" "github.com/anchore/grype/grype/pkg" - "github.com/anchore/grype/grype/vulnerability" + "github.com/anchore/grype/internal/dbtest" "github.com/anchore/syft/syft/artifact" - "github.com/anchore/syft/syft/file" syftPkg "github.com/anchore/syft/syft/pkg" ) @@ -26,282 +20,180 @@ import ( // files via OwnershipByFileOverlapRelationship, the RPM's "unaffected" status should suppress the // GitHub advisory match on the language package. func Test_UnaffectedFiltering(t *testing.T) { - vp := buildTestDB(t) + dbtest.DBs(t, "unaffected-db").Run(func(t *testing.T, db *dbtest.DB) { + defaultMatchers := matcher.NewDefaultMatchers(matcher.Config{}) - defaultMatchers := matcher.NewDefaultMatchers(matcher.Config{}) + tests := []struct { + name string + packages []pkg.Package + wantVulnIDs []string + wantIgnoreIDs []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() - tests := []struct { - name string - packages []pkg.Package - wantVulnIDs []string - wantIgnoreIDs []string - }{ - { - name: "RHEL10 unaffected nodejs-undici suppresses npm undici GHSAs (CVE-2024-24758 and related)", - packages: []pkg.Package{ - { - ID: pkg.ID("rpm-nodejs-undici"), - Name: "nodejs-undici", - Version: "0:5.28.0-1.el10", - Type: syftPkg.RpmPkg, - Distro: distro.New(distro.RedHat, "10", ""), - }, - { - ID: pkg.ID("npm-undici"), - Name: "undici", - Version: "5.28.0", - Type: syftPkg.NpmPkg, - Language: syftPkg.JavaScript, - Locations: file.NewLocationSet( - file.NewLocation("/usr/lib/node_modules/undici/package.json"), - ), - RelatedPackages: map[artifact.RelationshipType][]*pkg.Package{ - artifact.OwnershipByFileOverlapRelationship: { - {ID: pkg.ID("rpm-nodejs-undici"), Name: "nodejs-undici"}, - }, - }, - }, + 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 }, - wantVulnIDs: nil, // all suppressed - }, - { - name: "RHEL8 unaffected rubygem-rack-protection suppresses gem GHSA-688c-3x49-6rqj (CVE-2018-1000119)", - packages: []pkg.Package{ - { - ID: pkg.ID("rpm-rubygem-rack-protection"), - Name: "rubygem-rack-protection", - Version: "0:1.5.3-5.el8", - Type: syftPkg.RpmPkg, - Distro: distro.New(distro.RedHat, "8", ""), - }, - { - ID: pkg.ID("gem-rack-protection"), - Name: "rack-protection", - Version: "1.5.3", - Type: syftPkg.GemPkg, - Language: syftPkg.Ruby, - Locations: file.NewLocationSet( - file.NewLocation("/usr/share/gems/gems/rack-protection-1.5.3/lib/rack/protection.rb"), - ), - RelatedPackages: map[artifact.RelationshipType][]*pkg.Package{ - artifact.OwnershipByFileOverlapRelationship: { - {ID: pkg.ID("rpm-rubygem-rack-protection"), Name: "rubygem-rack-protection"}, - }, - }, - }, + { + 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 }, - wantVulnIDs: nil, // all suppressed - }, - { - name: "npm undici vulnerable without RPM ownership relationship", - packages: []pkg.Package{ - { - ID: pkg.ID("rpm-nodejs-undici"), - Name: "nodejs-undici", - Version: "0:5.28.0-1.el10", - Type: syftPkg.RpmPkg, - Distro: distro.New(distro.RedHat, "10", ""), + { + 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 }, - { - ID: pkg.ID("npm-undici-standalone"), - Name: "undici", - Version: "5.28.0", - Type: syftPkg.NpmPkg, - Language: syftPkg.JavaScript, - Locations: file.NewLocationSet( - file.NewLocation("/app/node_modules/undici/package.json"), - ), - // 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 }, }, - 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: []pkg.Package{ - { - ID: pkg.ID("rpm-npm"), - Name: "rpm-npm", - Version: "1:18.20.8-1.module+el9.5.0+22758+4ad2c198", - Type: syftPkg.RpmPkg, - Distro: distro.New(distro.RedHat, "9", ""), - Metadata: pkg.RpmMetadata{ - Epoch: ptr(1), - ModularityLabel: ptr("nodejs:18:9050020250203080038:8cf767d6"), - }, - Upstreams: []pkg.UpstreamPackage{ - {Name: "nodejs"}, - }, - }, - { - ID: pkg.ID("npm-ip"), - Name: "ip", - Version: "2.0.1", - Type: syftPkg.NpmPkg, - Language: syftPkg.JavaScript, - Locations: file.NewLocationSet( - file.NewLocation("/usr/lib/node_modules/npm/node_modules/ip/lib/ip.js"), - ), - RelatedPackages: map[artifact.RelationshipType][]*pkg.Package{ - artifact.OwnershipByFileOverlapRelationship: { - {ID: pkg.ID("rpm-npm"), Name: "rpm-npm"}, - }, - }, - }, + { + // 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 }, - wantVulnIDs: nil, // suppressed by upstream RPM upstream 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: []pkg.Package{ - { - ID: pkg.ID("rpm-npm-20"), - Name: "rpm-npm", - Version: "1:20.18.3-1.module+el9.5.0+22758+abc12345", - Type: syftPkg.RpmPkg, - Distro: distro.New(distro.RedHat, "9", ""), - Metadata: pkg.RpmMetadata{ - Epoch: ptr(1), - ModularityLabel: ptr("nodejs:20:9050020250203080038:8cf767d6"), - }, - Upstreams: []pkg.UpstreamPackage{ - {Name: "nodejs"}, - }, - }, - { - ID: pkg.ID("npm-ip-20"), - Name: "ip", - Version: "2.0.1", - Type: syftPkg.NpmPkg, - Language: syftPkg.JavaScript, - Locations: file.NewLocationSet( - file.NewLocation("/usr/lib/node_modules/npm/node_modules/ip/lib/ip.js"), - ), - RelatedPackages: map[artifact.RelationshipType][]*pkg.Package{ - artifact.OwnershipByFileOverlapRelationship: { - {ID: pkg.ID("rpm-npm-20"), Name: "rpm-npm"}, - }, - }, - }, + { + // 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 }, - 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{ - { - ID: pkg.ID("rpm-nodejs-20"), - Name: "nodejs", - Version: "1:20.18.3-1.module+el9.5.0+22758+abc12345", - Type: syftPkg.RpmPkg, - Distro: distro.New(distro.RedHat, "9", ""), - Metadata: pkg.RpmMetadata{ - Epoch: ptr(1), - ModularityLabel: ptr("nodejs:20:9050020250203080038:8cf767d6"), - }, - Upstreams: []pkg.UpstreamPackage{ - {Name: "nodejs"}, - }, + { + // 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 }, - { - ID: pkg.ID("npm-ip-standalone"), - Name: "ip", - Version: "2.0.1", - Type: syftPkg.NpmPkg, - Language: syftPkg.JavaScript, - Locations: file.NewLocationSet( - file.NewLocation("/app/node_modules/ip/lib/ip.js"), - ), - // no RelatedPackages — no RPM ownership + wantVulnIDs: []string{ + "GHSA-2p57-rm9w-gvfp", // CVE-2024-29415 — ip <= 2.0.1 }, }, - 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{ - { - ID: pkg.ID("rpm-rubygem-rack-protection"), - Name: "rubygem-rack-protection", - Version: "0:1.5.3-5.el8", - Type: syftPkg.RpmPkg, - Distro: distro.New(distro.RedHat, "8", ""), + { + 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 }, - { - ID: pkg.ID("gem-rack-protection-standalone"), - Name: "rack-protection", - Version: "1.5.3", - Type: syftPkg.GemPkg, - Language: syftPkg.Ruby, - Locations: file.NewLocationSet( - file.NewLocation("/app/vendor/bundle/gems/rack-protection-1.5.3/lib/rack/protection.rb"), - ), - // no RelatedPackages — no RPM ownership + wantVulnIDs: []string{ + "GHSA-688c-3x49-6rqj", // CVE-2018-1000119 — rack-protection < 1.5.5 }, }, - 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: vp, - Matchers: defaultMatchers, - } + } - matches, ignoredMatches, 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) - - if tt.wantIgnoreIDs != nil { - var gotIgnores []string - for _, m := range ignoredMatches { - gotIgnores = append(gotIgnores, m.Match.Vulnerability.ID) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &VulnerabilityMatcher{ + VulnerabilityProvider: db, + Matchers: defaultMatchers, } - require.ElementsMatch(t, tt.wantIgnoreIDs, gotIgnores) - } - }) - } -} -// buildTestDB builds a grype v6 database from flat test data files and returns a vulnerability provider. -func buildTestDB(t *testing.T) vulnerability.Provider { - t.Helper() + matches, ignoredMatches, err := m.FindMatches(tt.packages, pkg.Context{}) + require.NoError(t, err) - dbDir := filepath.Join(t.TempDir(), "db") - testdb.BuildFromFlatFileDir(t, - time.Now(), - dbDir, - "testdata/unaffected-db", - "**/*", - ) - - rdr, err := v6.NewReader(v6.Config{DBDirPath: dbDir}) - require.NoError(t, err) - t.Cleanup(func() { _ = rdr.Close() }) + var gotMatches []string + for _, m := range matches.Sorted() { + gotMatches = append(gotMatches, m.Vulnerability.ID) + } + require.ElementsMatch(t, tt.wantVulnIDs, gotMatches) - return v6.NewVulnerabilityProvider(rdr) + if tt.wantIgnoreIDs != nil { + var gotIgnores []string + for _, m := range ignoredMatches { + gotIgnores = append(gotIgnores, m.Match.Vulnerability.ID) + } + require.ElementsMatch(t, tt.wantIgnoreIDs, gotIgnores) + } + }) + } + }) } func ptr[T any](t T) *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 } From f4ff230340db7c19119095fa0886a8b7922068a8 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Fri, 24 Apr 2026 13:15:33 -0400 Subject: [PATCH 16/23] chore: add metadata.json files Signed-off-by: Keith Zantow --- grype/testdata/.gitignore | 2 -- .../unaffected-db/github/metadata.json | 21 +++++++++++++++++++ .../testdata/unaffected-db/rhel/metadata.json | 21 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) delete mode 100644 grype/testdata/.gitignore create mode 100644 grype/testdata/unaffected-db/github/metadata.json create mode 100644 grype/testdata/unaffected-db/rhel/metadata.json diff --git a/grype/testdata/.gitignore b/grype/testdata/.gitignore deleted file mode 100644 index 61584dc94c2..00000000000 --- a/grype/testdata/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -checksums -metadata.json 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/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 From f67cda515f811eb17e3af0b19e1cd2ea189c6d72 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Fri, 24 Apr 2026 17:54:17 -0400 Subject: [PATCH 17/23] chore: update match labels to PR version Signed-off-by: Keith Zantow --- test/quality/vulnerability-match-labels | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/quality/vulnerability-match-labels b/test/quality/vulnerability-match-labels index 2dc3c828717..4918257016f 160000 --- a/test/quality/vulnerability-match-labels +++ b/test/quality/vulnerability-match-labels @@ -1 +1 @@ -Subproject commit 2dc3c828717741e26e3e780b24a3263cac450926 +Subproject commit 4918257016f5bc6de08cddcfaca2da3e59439ced From 04eebce24e880f0296c4eec9e2d62d7cfcb9c388 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Mon, 27 Apr 2026 09:31:35 -0400 Subject: [PATCH 18/23] chore: unused test variable Signed-off-by: Keith Zantow --- grype/vulnerability_matcher_validation_test.go | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/grype/vulnerability_matcher_validation_test.go b/grype/vulnerability_matcher_validation_test.go index a4b16b12b8f..7a38d6e0abe 100644 --- a/grype/vulnerability_matcher_validation_test.go +++ b/grype/vulnerability_matcher_validation_test.go @@ -24,10 +24,9 @@ func Test_UnaffectedFiltering(t *testing.T) { defaultMatchers := matcher.NewDefaultMatchers(matcher.Config{}) tests := []struct { - name string - packages []pkg.Package - wantVulnIDs []string - wantIgnoreIDs []string + name string + packages []pkg.Package + wantVulnIDs []string }{ { name: "RHEL10 unaffected nodejs-undici suppresses npm undici GHSAs (CVE-2024-24758 and related)", @@ -175,7 +174,7 @@ func Test_UnaffectedFiltering(t *testing.T) { Matchers: defaultMatchers, } - matches, ignoredMatches, err := m.FindMatches(tt.packages, pkg.Context{}) + matches, _, err := m.FindMatches(tt.packages, pkg.Context{}) require.NoError(t, err) var gotMatches []string @@ -183,14 +182,6 @@ func Test_UnaffectedFiltering(t *testing.T) { gotMatches = append(gotMatches, m.Vulnerability.ID) } require.ElementsMatch(t, tt.wantVulnIDs, gotMatches) - - if tt.wantIgnoreIDs != nil { - var gotIgnores []string - for _, m := range ignoredMatches { - gotIgnores = append(gotIgnores, m.Match.Vulnerability.ID) - } - require.ElementsMatch(t, tt.wantIgnoreIDs, gotIgnores) - } }) } }) From d22beaebf0d741abb99df7fd486f718e4e40188f Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Mon, 27 Apr 2026 13:06:50 -0400 Subject: [PATCH 19/23] chore: update comment Signed-off-by: Keith Zantow --- grype/search/version_constraint.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/grype/search/version_constraint.go b/grype/search/version_constraint.go index 10c51c82c3b..2152b2548a6 100644 --- a/grype/search/version_constraint.go +++ b/grype/search/version_constraint.go @@ -107,8 +107,9 @@ func (f *constraintFuncCriteria) MatchesVulnerability(value vulnerability.Vulner } matches, err := f.fn(value.Constraint) if err != nil { - // TODO this is probably not the correct behavior, but it's what the VulnProvider is doing today: dropping vulns when a version error occurs - // See: vulnerability_provider.go filterAffectedPackageRanges -- if we change the VP behavior, we need to change this behavior, too + // 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 } From d32c55a979e94dff36f3c73d07688fb65b312afe Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Wed, 29 Apr 2026 08:47:02 -0400 Subject: [PATCH 20/23] chore: update vuln match labels Signed-off-by: Keith Zantow --- test/quality/vulnerability-match-labels | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/quality/vulnerability-match-labels b/test/quality/vulnerability-match-labels index 4918257016f..bb4edfb5e46 160000 --- a/test/quality/vulnerability-match-labels +++ b/test/quality/vulnerability-match-labels @@ -1 +1 @@ -Subproject commit 4918257016f5bc6de08cddcfaca2da3e59439ced +Subproject commit bb4edfb5e4685a8bc03be1c4034377a8816ea7ff From e6ef3da239210f6d26905defd69f6e4cca9b8494 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Wed, 29 Apr 2026 14:01:53 -0400 Subject: [PATCH 21/23] chore: address PR comments: add tests Signed-off-by: Keith Zantow --- grype/matcher/internal/cpe_test.go | 94 ++++ grype/matcher/rpm/almalinux_test.go | 415 ++++++++++++++++++ .../mock/vulnerability_provider.go | 9 + 3 files changed, 518 insertions(+) diff --git a/grype/matcher/internal/cpe_test.go b/grype/matcher/internal/cpe_test.go index deab17d1208..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" ) @@ -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/rpm/almalinux_test.go b/grype/matcher/rpm/almalinux_test.go index 8f852873d91..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" ) @@ -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/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 }) From 18d617b82d0505d4c64e46af8d34b8b567458a60 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Wed, 29 Apr 2026 14:26:32 -0400 Subject: [PATCH 22/23] chore: address PR comments: add tests Signed-off-by: Keith Zantow --- grype/matcher/rpm/matcher_test.go | 138 +++++++++++++++++++++++++++++ grype/matcher/rpm/rhel_eus_test.go | 96 ++++++++++++++++++++ 2 files changed, 234 insertions(+) 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_test.go b/grype/matcher/rpm/rhel_eus_test.go index c80c11a9b36..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" ) @@ -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 } From 37bb161b44220bd01ccc03362ae75e313b69f610 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Wed, 29 Apr 2026 16:59:43 -0400 Subject: [PATCH 23/23] chore: update to match labels main Signed-off-by: Keith Zantow --- test/quality/vulnerability-match-labels | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/quality/vulnerability-match-labels b/test/quality/vulnerability-match-labels index bb4edfb5e46..df746a3d4c3 160000 --- a/test/quality/vulnerability-match-labels +++ b/test/quality/vulnerability-match-labels @@ -1 +1 @@ -Subproject commit bb4edfb5e4685a8bc03be1c4034377a8816ea7ff +Subproject commit df746a3d4c38ca90e15fb552f64fdf09668d9ded