diff --git a/grype/matcher/apk/matcher.go b/grype/matcher/apk/matcher.go index ac6ec12df96..e967f60b11d 100644 --- a/grype/matcher/apk/matcher.go +++ b/grype/matcher/apk/matcher.go @@ -35,26 +35,33 @@ 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 ignoreFilters []match.IgnoreFilter - // direct matches with package itself - directMatches, err := m.findMatchesForPackage(store, p, nil) + // direct matches with package itself (+ distro-fixed ignore rules when metadata implements FileOwner) + directMatches, directIgnores, err := m.findMatchesForPackage(store, p, nil) if err != nil { return nil, nil, err } matches = append(matches, directMatches...) + ignoreFilters = append(ignoreFilters, directIgnores...) // indirect matches, via package's origin package - indirectMatches, err := m.findMatchesForOriginPackage(store, p) + indirectMatches, indirectIgnores, err := m.findMatchesForOriginPackage(store, p) if err != nil { return nil, nil, err } matches = append(matches, indirectMatches...) + ignoreFilters = append(ignoreFilters, indirectIgnores...) // APK sources are also able to NAK vulnerabilities, so we want to return these as explicit ignores in order // to allow rules later to use these to ignore "the same" vulnerability found in "the same" locations naks, err := m.findNaksForPackage(store, p) + if err != nil { + return nil, nil, err + } + ignoreFilters = append(ignoreFilters, naks...) - return matches, naks, err + return matches, ignoreFilters, nil } //nolint:funlen,gocognit @@ -169,18 +176,18 @@ func vulnerabilitiesByID(vulns []vulnerability.Vulnerability) map[string][]vulne return results } -func (m *Matcher) findMatchesForPackage(store vulnerability.Provider, p pkg.Package, catalogPkg *pkg.Package) ([]match.Match, error) { +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, _, err := internal.MatchPackageByDistro(store, p, catalogPkg, m.Type(), nil) + secDBMatches, secDBIgnores, err := internal.MatchPackageByDistroWithOwnedFiles(store, p, catalogPkg, m.Type(), nil) if err != nil { - return nil, err + return nil, nil, err } // TODO: are there other errors that we should handle here that causes this to short circuit cpeMatches, err := m.cpeMatchesWithoutSecDBFixes(store, p) if err != nil && !errors.Is(err, internal.ErrEmptyCPEMatch) { - return nil, err + return nil, nil, err } var matches []match.Match @@ -191,25 +198,35 @@ func (m *Matcher) findMatchesForPackage(store vulnerability.Provider, p pkg.Pack // keep only unique CPE matches matches = append(matches, deduplicateMatches(secDBMatches, cpeMatches)...) - return matches, nil + return matches, secDBIgnores, nil } -func (m *Matcher) findMatchesForOriginPackage(store vulnerability.Provider, catalogPkg pkg.Package) ([]match.Match, error) { +func (m *Matcher) findMatchesForOriginPackage(store vulnerability.Provider, catalogPkg pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { var matches []match.Match + var ignores []match.IgnoreFilter for _, indirectPackage := range pkg.UpstreamPackages(catalogPkg) { - indirectMatches, err := m.findMatchesForPackage(store, indirectPackage, &catalogPkg) + indirectMatches, indirectIgnores, err := m.findMatchesForPackage(store, indirectPackage, &catalogPkg) if err != nil { - return nil, fmt.Errorf("failed to find vulnerabilities for apk upstream source package: %w", err) + return nil, nil, fmt.Errorf("failed to find vulnerabilities for apk upstream source package: %w", err) } matches = append(matches, indirectMatches...) + ignores = append(ignores, indirectIgnores...) } // we want to make certain that we are tracking the match based on the package from the SBOM (not the indirect package) // however, we also want to keep the indirect package around for future reference match.ConvertToIndirectMatches(matches, catalogPkg) - return matches, nil + return matches, ignores, nil +} + +// ownedFilesFromMetadata returns the files owned by a package if its metadata implements pkg.FileOwner. +func ownedFilesFromMetadata(p pkg.Package) []string { + if fo, ok := p.Metadata.(pkg.FileOwner); ok { + return fo.OwnedFiles() + } + return nil } // NAK entries are those reported as explicitly not vulnerable by the upstream provider, @@ -248,21 +265,21 @@ func (m *Matcher) findNaksForPackage(provider vulnerability.Provider, p pkg.Pack naks = append(naks, upstreamNaks...) } - meta, ok := p.Metadata.(pkg.ApkMetadata) - if !ok { + paths := ownedFilesFromMetadata(p) + if len(paths) == 0 { return nil, nil } var ignores []match.IgnoreFilter for _, nak := range naks { - for _, f := range meta.Files { + for _, path := range paths { ignores = append(ignores, match.IgnoreRule{ Vulnerability: nak.ID, IncludeAliases: true, Reason: "Explicit APK NAK", Package: match.IgnoreRulePackage{ - Location: f.Path, + Location: path, }, }) } diff --git a/grype/matcher/apk/matcher_test.go b/grype/matcher/apk/matcher_test.go index 6240e12d1ee..ba76251a076 100644 --- a/grype/matcher/apk/matcher_test.go +++ b/grype/matcher/apk/matcher_test.go @@ -1091,3 +1091,192 @@ func Test_nakIgnoreRules(t *testing.T) { }) } } + +func TestMatcherApk_DistroFixedIgnoreRules(t *testing.T) { + apkNamespace := "secdb:distro:wolfi:rolling" + + apkFiles := pkg.ApkMetadata{Files: []pkg.ApkFileRecord{ + {Path: "/usr/bin/kyverno"}, + {Path: "/usr/lib/kyverno/config"}, + }} + + tests := []struct { + name string + p pkg.Package + vulnerabilities []vulnerability.Vulnerability + expectedIgnoreVulnIDs []string + expectedMatchIDs []string + }{ + { + name: "package already at fixed version - should produce location-scoped ignore rules but no matches", + p: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "kyverno", + Version: "1.15.3-r0", + Type: syftPkg.ApkPkg, + Distro: distro.New(distro.Wolfi, "", ""), + Metadata: apkFiles, + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + PackageName: "kyverno", + Constraint: version.MustGetConstraint("< 1.15.3-r0", version.ApkFormat), + Reference: vulnerability.Reference{ID: "CVE-2026-22039", Namespace: apkNamespace}, + }, + }, + // one rule per owned path + expectedIgnoreVulnIDs: []string{"CVE-2026-22039", "CVE-2026-22039"}, + expectedMatchIDs: nil, + }, + { + name: "package still vulnerable - should produce matches but no ignore rules", + p: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "kyverno", + Version: "1.14.5-r0", + Type: syftPkg.ApkPkg, + Distro: distro.New(distro.Wolfi, "", ""), + Metadata: apkFiles, + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + PackageName: "kyverno", + Constraint: version.MustGetConstraint("< 1.15.3-r0", version.ApkFormat), + Reference: vulnerability.Reference{ID: "CVE-2026-22039", Namespace: apkNamespace}, + }, + }, + expectedIgnoreVulnIDs: nil, + expectedMatchIDs: []string{"CVE-2026-22039"}, + }, + { + name: "no distro data for the package - no ignore rules (search miss allows GHSA to stand)", + p: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "something-obscure", + Version: "1.0.0-r0", + Type: syftPkg.ApkPkg, + Distro: distro.New(distro.Wolfi, "", ""), + Metadata: apkFiles, + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + PackageName: "kyverno", + Constraint: version.MustGetConstraint("< 1.15.3-r0", version.ApkFormat), + Reference: vulnerability.Reference{ID: "CVE-2026-22039", Namespace: apkNamespace}, + }, + }, + expectedIgnoreVulnIDs: nil, + expectedMatchIDs: nil, + }, + { + name: "upstream package is fixed - should produce location-scoped ignore rules", + p: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "kyverno-cli", + Version: "1.15.3-r0", + Type: syftPkg.ApkPkg, + Distro: distro.New(distro.Wolfi, "", ""), + Metadata: apkFiles, + Upstreams: []pkg.UpstreamPackage{ + { + Name: "kyverno", + Version: "1.15.3-r0", + }, + }, + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + PackageName: "kyverno", + Constraint: version.MustGetConstraint("< 1.15.3-r0", version.ApkFormat), + Reference: vulnerability.Reference{ID: "CVE-2026-22039", Namespace: apkNamespace}, + }, + }, + // one rule per owned path + expectedIgnoreVulnIDs: []string{"CVE-2026-22039", "CVE-2026-22039"}, + expectedMatchIDs: nil, + }, + { + name: "fixed CVE with related GHSA - ignore rules include both IDs at all paths", + p: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "kyverno", + Version: "1.15.3-r0", + Type: syftPkg.ApkPkg, + Distro: distro.New(distro.Wolfi, "", ""), + Metadata: apkFiles, + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + PackageName: "kyverno", + Constraint: version.MustGetConstraint("< 1.15.3-r0", version.ApkFormat), + Reference: vulnerability.Reference{ID: "CVE-2026-22039", Namespace: apkNamespace}, + RelatedVulnerabilities: []vulnerability.Reference{ + {ID: "GHSA-8p9x-46gm-qfx2", Namespace: "github:language:go"}, + }, + }, + }, + // 2 IDs × 2 paths = 4 rules + expectedIgnoreVulnIDs: []string{"CVE-2026-22039", "CVE-2026-22039", "GHSA-8p9x-46gm-qfx2", "GHSA-8p9x-46gm-qfx2"}, + expectedMatchIDs: nil, + }, + { + name: "no APK metadata (no file list) - should NOT produce ignore rules even if fixed", + p: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "kyverno", + Version: "1.15.3-r0", + Type: syftPkg.ApkPkg, + Distro: distro.New(distro.Wolfi, "", ""), + // no Metadata + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + PackageName: "kyverno", + Constraint: version.MustGetConstraint("< 1.15.3-r0", version.ApkFormat), + Reference: vulnerability.Reference{ID: "CVE-2026-22039", Namespace: apkNamespace}, + }, + }, + expectedIgnoreVulnIDs: nil, + expectedMatchIDs: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + matcher := Matcher{} + + store := mock.VulnerabilityProvider(test.vulnerabilities...) + matches, ignoreFilters, err := matcher.Match(store, test.p) + require.NoError(t, err) + + // verify matches + var gotMatchIDs []string + for _, m := range matches { + gotMatchIDs = append(gotMatchIDs, m.Vulnerability.ID) + } + if test.expectedMatchIDs == nil { + assert.Empty(t, gotMatchIDs, "expected no matches") + } else { + assert.ElementsMatch(t, test.expectedMatchIDs, gotMatchIDs, "unexpected match IDs") + } + + // verify ignore rules - filter to only DistroPackageFixed rules (not NAK rules) + var gotIgnoreIDs []string + for _, filter := range ignoreFilters { + rule, ok := filter.(match.IgnoreRule) + require.True(t, ok, "expected IgnoreRule type") + if rule.Reason != "DistroPackageFixed" { + continue + } + gotIgnoreIDs = append(gotIgnoreIDs, rule.Vulnerability) + assert.True(t, rule.IncludeAliases, "expected IncludeAliases to be true") + assert.NotEmpty(t, rule.Package.Location, "expected location to be set on DistroPackageFixed rule") + } + if test.expectedIgnoreVulnIDs == nil { + assert.Empty(t, gotIgnoreIDs, "expected no ignore rules") + } else { + assert.ElementsMatch(t, test.expectedIgnoreVulnIDs, gotIgnoreIDs, "unexpected ignore rule vulnerability IDs") + } + }) + } +} diff --git a/grype/matcher/internal/distro.go b/grype/matcher/internal/distro.go index 32b72528b63..a50db6ed9dd 100644 --- a/grype/matcher/internal/distro.go +++ b/grype/matcher/internal/distro.go @@ -2,9 +2,11 @@ package internal import ( "fmt" + "slices" "strings" "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" @@ -54,6 +56,93 @@ func MatchPackageByDistro(provider vulnerability.Provider, searchPkg pkg.Package return matches, nil, err } +// MatchPackageByDistroWithOwnedFiles 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) { + // Use the SBOM package (not the synthetic upstream) for file ownership — the upstream + // package won't carry file metadata. + ownedFiles := ownedFilesFor(matchPackage(searchPkg, catalogPkg)) + if len(ownedFiles) == 0 { + return MatchPackageByDistro(provider, searchPkg, catalogPkg, upstreamMatcher, cfg) + } + + 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 + } + + // 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) + + // Fetch all vulnerabilities the distro knows about for this package (1 query, no version filter). + rp := result.NewProvider(provider, matchPackage(searchPkg, catalogPkg), upstreamMatcher) + + allVulns, err := rp.FindResults( + search.ByPackageName(searchPkg.Name), + search.ByDistro(*searchPkg.Distro), + OnlyQualifiedPackages(searchPkg), + ) + if err != nil { + return nil, nil, fmt.Errorf("matcher failed to fetch distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) + } + + // Split in memory: vulnerable vs. fixed. + 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() + ignores := distroFixedIgnoreRules(fixed, ownedFiles) + + return matches, ignores, nil +} + +// distroFixedIgnoreRules builds location-scoped ignore rules for vulnerabilities that the distro has +// assessed as fixed. Each vulnerability ID (including aliases) gets one rule per owned path. +func distroFixedIgnoreRules(fixed result.Set, ownedFiles []string) []match.IgnoreFilter { + var ignores []match.IgnoreFilter + for _, results := range fixed { + for _, r := range results { + for _, v := range r.Vulnerabilities { + ids := collectVulnerabilityIDs(v) + for _, id := range ids { + for _, path := range ownedFiles { + ignores = append(ignores, match.IgnoreRule{ + Vulnerability: id, + IncludeAliases: true, + Reason: "DistroPackageFixed", + Package: match.IgnoreRulePackage{ + Location: path, + }, + }) + } + } + } + } + } + return ignores +} + func matchPackage(searchPkg pkg.Package, catalogPkg *pkg.Package) pkg.Package { if catalogPkg != nil { return *catalogPkg @@ -91,6 +180,55 @@ func distroMatchDetails(upstreamMatcher match.MatcherType, searchPkg pkg.Package } } +// collectVulnerabilityIDs returns the primary ID plus all related/alias IDs for a vulnerability. +func collectVulnerabilityIDs(v vulnerability.Vulnerability) []string { + ids := []string{v.ID} + for _, related := range v.RelatedVulnerabilities { + if !slices.Contains(ids, related.ID) { + ids = append(ids, related.ID) + } + } + return ids +} + 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 + } + } + } + } + } +} + +// 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 +} diff --git a/grype/matcher/internal/distro_test.go b/grype/matcher/internal/distro_test.go index 97531d51891..a0ea940cb99 100644 --- a/grype/matcher/internal/distro_test.go +++ b/grype/matcher/internal/distro_test.go @@ -102,6 +102,227 @@ func TestFindMatchesByPackageDistro(t *testing.T) { assert.Empty(t, actual) } +func TestMatchPackageByDistroWithIgnoreRules(t *testing.T) { + ownedFiles := pkg.ApkMetadata{Files: []pkg.ApkFileRecord{ + {Path: "/usr/lib/python3/dist-packages/requests"}, + {Path: "/usr/bin/python3"}, + }} + + tests := []struct { + name string + pkg pkg.Package + vulnerabilities []vulnerability.Vulnerability + expectedIgnoreVulnIDs []string + expectedMatchIDs []string + expectNoIgnoreRules bool + }{ + { + name: "package version is already fixed - should produce ignore rules scoped to paths", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "python3-requests", + Version: "2.25.1-14.el8", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "8", ""), + Metadata: ownedFiles, + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + PackageName: "python3-requests", + Constraint: version.MustGetConstraint("< 2.25.1-14.el8", version.RpmFormat), + Reference: vulnerability.Reference{ID: "CVE-2023-backported", Namespace: "secdb:distro:redhat:8"}, + }, + }, + // one rule per (vulnID, path) pair + expectedIgnoreVulnIDs: []string{"CVE-2023-backported", "CVE-2023-backported"}, + }, + { + name: "package version is still vulnerable - should NOT produce ignore rules", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "python3-requests", + Version: "2.25.1-10.el8", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "8", ""), + Metadata: ownedFiles, + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + PackageName: "python3-requests", + Constraint: version.MustGetConstraint("< 2.25.1-14.el8", version.RpmFormat), + Reference: vulnerability.Reference{ID: "CVE-2023-backported", Namespace: "secdb:distro:redhat:8"}, + }, + }, + expectedMatchIDs: []string{"CVE-2023-backported"}, + expectNoIgnoreRules: true, + }, + { + name: "distro has no data about the package - should NOT produce ignore rules (search miss)", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "python3-something-obscure", + Version: "1.0.0-1.el8", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "8", ""), + Metadata: ownedFiles, + }, + vulnerabilities: []vulnerability.Vulnerability{ + // no vulnerabilities for this package in the distro feed + { + PackageName: "other-package", + Constraint: version.MustGetConstraint("< 2.0.0", version.RpmFormat), + Reference: vulnerability.Reference{ID: "CVE-2023-other", Namespace: "secdb:distro:redhat:8"}, + }, + }, + expectNoIgnoreRules: true, + }, + { + name: "mix of fixed and still-vulnerable CVEs - should only produce ignore rules for fixed ones", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "python3-requests", + Version: "2.25.1-14.el8", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "8", ""), + Metadata: ownedFiles, + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + // fixed: package version 2.25.1-14.el8 >= fix version + PackageName: "python3-requests", + Constraint: version.MustGetConstraint("< 2.25.1-14.el8", version.RpmFormat), + Reference: vulnerability.Reference{ID: "CVE-2023-already-fixed", Namespace: "secdb:distro:redhat:8"}, + }, + { + // still vulnerable: package version 2.25.1-14.el8 < 2.25.1-20.el8 + PackageName: "python3-requests", + Constraint: version.MustGetConstraint("< 2.25.1-20.el8", version.RpmFormat), + Reference: vulnerability.Reference{ID: "CVE-2023-still-vulnerable", Namespace: "secdb:distro:redhat:8"}, + }, + }, + expectedMatchIDs: []string{"CVE-2023-still-vulnerable"}, + // one rule per path for the fixed CVE only + expectedIgnoreVulnIDs: []string{"CVE-2023-already-fixed", "CVE-2023-already-fixed"}, + }, + { + name: "fixed CVE with related vulnerabilities - should produce ignore rules for all IDs at all paths", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "python3-requests", + Version: "2.25.1-14.el8", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "8", ""), + Metadata: ownedFiles, + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + PackageName: "python3-requests", + Constraint: version.MustGetConstraint("< 2.25.1-14.el8", version.RpmFormat), + Reference: vulnerability.Reference{ID: "CVE-2023-backported", Namespace: "secdb:distro:redhat:8"}, + RelatedVulnerabilities: []vulnerability.Reference{ + {ID: "GHSA-xxxx-yyyy-zzzz", Namespace: "github:language:python"}, + }, + }, + }, + // both IDs × 2 paths = 4 rules + expectedIgnoreVulnIDs: []string{"CVE-2023-backported", "CVE-2023-backported", "GHSA-xxxx-yyyy-zzzz", "GHSA-xxxx-yyyy-zzzz"}, + }, + { + name: "no distro on package - should NOT produce ignore rules", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "python3-requests", + Version: "2.25.1-14.el8", + Type: syftPkg.RpmPkg, + Distro: nil, + Metadata: ownedFiles, + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + PackageName: "python3-requests", + Constraint: version.MustGetConstraint("< 2.25.1-14.el8", version.RpmFormat), + Reference: vulnerability.Reference{ID: "CVE-2023-backported", Namespace: "secdb:distro:redhat:8"}, + }, + }, + expectNoIgnoreRules: true, + }, + { + name: "unknown version - should NOT produce ignore rules", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "python3-requests", + Version: "unknown", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "8", ""), + Metadata: ownedFiles, + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + PackageName: "python3-requests", + Constraint: version.MustGetConstraint("< 2.25.1-14.el8", version.RpmFormat), + Reference: vulnerability.Reference{ID: "CVE-2023-backported", Namespace: "secdb:distro:redhat:8"}, + }, + }, + expectNoIgnoreRules: true, + }, + { + name: "no FileOwner metadata - should NOT produce ignore rules even if fixed", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "python3-requests", + Version: "2.25.1-14.el8", + Type: syftPkg.RpmPkg, + Distro: distro.New(distro.RedHat, "8", ""), + // no Metadata — does not implement FileOwner + }, + vulnerabilities: []vulnerability.Vulnerability{ + { + PackageName: "python3-requests", + Constraint: version.MustGetConstraint("< 2.25.1-14.el8", version.RpmFormat), + Reference: vulnerability.Reference{ID: "CVE-2023-backported", Namespace: "secdb:distro:redhat:8"}, + }, + }, + expectNoIgnoreRules: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + store := mock.VulnerabilityProvider(test.vulnerabilities...) + + matches, ignoreFilters, err := MatchPackageByDistroWithOwnedFiles(store, test.pkg, nil, match.PythonMatcher, nil) + require.NoError(t, err) + + // verify matches + var gotMatchIDs []string + for _, m := range matches { + gotMatchIDs = append(gotMatchIDs, m.Vulnerability.ID) + } + if len(test.expectedMatchIDs) > 0 { + assert.ElementsMatch(t, test.expectedMatchIDs, gotMatchIDs, "unexpected match IDs") + } + + if test.expectNoIgnoreRules { + assert.Empty(t, ignoreFilters, "expected no ignore rules") + return + } + + // extract the vulnerability IDs from the ignore rules + var gotVulnIDs []string + for _, filter := range ignoreFilters { + rule, ok := filter.(match.IgnoreRule) + require.True(t, ok, "expected IgnoreRule type") + gotVulnIDs = append(gotVulnIDs, rule.Vulnerability) + assert.True(t, rule.IncludeAliases, "expected IncludeAliases to be true") + assert.Equal(t, "DistroPackageFixed", rule.Reason) + assert.NotEmpty(t, rule.Package.Location, "expected location to be set") + } + + assert.ElementsMatch(t, test.expectedIgnoreVulnIDs, gotVulnIDs, "unexpected ignore rule vulnerability IDs") + }) + } +} + func TestFindMatchesByPackageDistroSles(t *testing.T) { p := pkg.Package{ ID: pkg.ID(uuid.NewString()), diff --git a/grype/matcher/internal/result/results.go b/grype/matcher/internal/result/results.go index f8762f582c2..cd908e92bcc 100644 --- a/grype/matcher/internal/result/results.go +++ b/grype/matcher/internal/result/results.go @@ -276,6 +276,7 @@ func (s Set) Filter(criteria ...vulnerability.Criteria) Set { ID: result.ID, Vulnerabilities: vulns, Details: result.Details, + Package: result.Package, }) } diff --git a/grype/pkg/apk_metadata.go b/grype/pkg/apk_metadata.go index 5a0ac001e4a..94b4569cd8d 100644 --- a/grype/pkg/apk_metadata.go +++ b/grype/pkg/apk_metadata.go @@ -1,5 +1,13 @@ package pkg +import ( + "sort" + + "github.com/scylladb/go-set/strset" +) + +var _ FileOwner = (*ApkMetadata)(nil) + type ApkMetadata struct { Files []ApkFileRecord `json:"files"` } @@ -8,3 +16,15 @@ type ApkMetadata struct { type ApkFileRecord struct { Path string `json:"path"` } + +func (m ApkMetadata) OwnedFiles() []string { + s := strset.New() + for _, f := range m.Files { + if f.Path != "" { + s.Add(f.Path) + } + } + result := s.List() + sort.Strings(result) + return result +} diff --git a/grype/pkg/file_owner.go b/grype/pkg/file_owner.go new file mode 100644 index 00000000000..fba023d8b8a --- /dev/null +++ b/grype/pkg/file_owner.go @@ -0,0 +1,5 @@ +package pkg + +type FileOwner interface { + OwnedFiles() []string +} diff --git a/test/integration/compare_sbom_input_vs_lib_test.go b/test/integration/compare_sbom_input_vs_lib_test.go index 380263ccef3..0ab5d6c9101 100644 --- a/test/integration/compare_sbom_input_vs_lib_test.go +++ b/test/integration/compare_sbom_input_vs_lib_test.go @@ -89,6 +89,13 @@ func TestCompareSBOMInputToLibResults(t *testing.T) { name string image string format sbom.FormatEncoder + // knownFormatLossMatches lists match keys (vuln-pkg-version) that are expected to + // appear only in results from this SBOM format (and not from an image scan) due to + // metadata loss during format encoding. For example, SPDX does not preserve APK file + // ownership metadata, so distro-package-fixed ignore rules cannot be scoped to owned + // paths and are not emitted — causing matches that the image scan correctly suppresses + // to remain visible. This does NOT apply to syft-json, which preserves all metadata. + knownFormatLossMatches []string }{ { image: "anchore/test_images:vulnerabilities-alpine", @@ -118,12 +125,18 @@ func TestCompareSBOMInputToLibResults(t *testing.T) { image: "anchore/test_images:gems", format: must(spdxjson.NewFormatEncoderWithConfig(spdxjson.DefaultEncoderConfig())), name: "gems-spdx-json", + // SPDX does not preserve APK file ownership metadata, so distro-package-fixed + // ignore rules cannot be scoped to owned paths and are not emitted from the SBOM. + knownFormatLossMatches: []string{"GHSA-8cr8-4vfw-mr7h-rexml-3.2.3.1"}, }, { image: "anchore/test_images:gems", format: must(spdxtagvalue.NewFormatEncoderWithConfig(spdxtagvalue.DefaultEncoderConfig())), name: "gems-spdx-tag-value", + // SPDX does not preserve APK file ownership metadata, so distro-package-fixed + // ignore rules cannot be scoped to owned paths and are not emitted from the SBOM. + knownFormatLossMatches: []string{"GHSA-8cr8-4vfw-mr7h-rexml-3.2.3.1"}, }, { @@ -262,7 +275,11 @@ func TestCompareSBOMInputToLibResults(t *testing.T) { matchSetFromSbom := getMatchSet(matchesFromSbom) matchSetFromImage := getMatchSet(matchesFromImage) - assert.Empty(t, strset.Difference(matchSetFromSbom, matchSetFromImage).List(), "vulnerabilities present only in results when using sbom as input") + sbomOnly := strset.Difference(matchSetFromSbom, matchSetFromImage) + if len(tc.knownFormatLossMatches) > 0 { + sbomOnly.Remove(tc.knownFormatLossMatches...) + } + assert.Empty(t, sbomOnly.List(), "vulnerabilities present only in results when using sbom as input") assert.Empty(t, strset.Difference(matchSetFromImage, matchSetFromSbom).List(), "vulnerabilities present only in results when using image as input") // track all covered package types (for use after the test) diff --git a/test/quality/vulnerability-match-labels b/test/quality/vulnerability-match-labels index 1cb9f58e55d..54ec792cae7 160000 --- a/test/quality/vulnerability-match-labels +++ b/test/quality/vulnerability-match-labels @@ -1 +1 @@ -Subproject commit 1cb9f58e55dfaa5175bf5b9033e5dd08d8c60dfb +Subproject commit 54ec792cae7f2fc91b1eebc4721717e90a4a886f