diff --git a/grype/db/v6/package_store.go b/grype/db/v6/package_store.go index 67896082daf..77e28b58739 100644 --- a/grype/db/v6/package_store.go +++ b/grype/db/v6/package_store.go @@ -41,9 +41,14 @@ type GetPackageOptions struct { type PackageSpecifiers []*PackageSpecifier type PackageSpecifier struct { - Name string - Ecosystem string - CPE *cpe.Attributes + Name string + // NamePrefix, when set, restricts results to packages whose name begins with the prefix + // followed by a "/" path-segment boundary (case-insensitive). It is mutually exclusive + // with Name. This is used to surface advisories pinned at sub-paths of a module name + // (e.g. Go import-path granularity advisories under a containing module). + NamePrefix string + Ecosystem string + CPE *cpe.Attributes } func (p *PackageSpecifier) String() string { @@ -56,6 +61,10 @@ func (p *PackageSpecifier) String() string { args = append(args, fmt.Sprintf("name=%s", p.Name)) } + if p.NamePrefix != "" { + args = append(args, fmt.Sprintf("name-prefix=%s", p.NamePrefix)) + } + if p.Ecosystem != "" { args = append(args, fmt.Sprintf("ecosystem=%s", p.Ecosystem)) } @@ -303,6 +312,13 @@ func (s *packageStore) handlePackage(query *gorm.DB, p *PackageSpecifier, allowB if p.Name != "" { query = query.Where("packages.name = ? collate nocase", p.Name) + } else if p.NamePrefix != "" { + // match any package whose name begins with NamePrefix followed by a "/" path-segment + // boundary, e.g. NamePrefix "golang.org/x/crypto" -> "golang.org/x/crypto/ssh" but not + // "golang.org/x/cryptographer". The "/" requirement is enforced both here (so the SQL + // returns no false positives) and again at the criteria level for safety. The ESCAPE + // clause neutralizes any wildcard metacharacters that may appear in the prefix. + query = query.Where(`packages.name LIKE ? ESCAPE '\' collate nocase`, escapeLikePattern(p.NamePrefix)+"/%") } if p.Ecosystem != "" { query = query.Where("packages.ecosystem = ? collate nocase", p.Ecosystem) @@ -317,6 +333,14 @@ func (s *packageStore) handlePackage(query *gorm.DB, p *PackageSpecifier, allowB return query } +// escapeLikePattern escapes SQLite LIKE wildcard metacharacters ("%", "_") and the escape +// character itself ("\") so a user-supplied substring can be safely embedded into a LIKE +// pattern. Use together with `LIKE ? ESCAPE '\'` in the query. +func escapeLikePattern(s string) string { + r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return r.Replace(s) +} + func (s *packageStore) handleVulnerabilityOptions(query *gorm.DB, configs []VulnerabilitySpecifier, tableName string) (*gorm.DB, error) { if len(configs) == 0 { return query, nil diff --git a/grype/db/v6/search_query.go b/grype/db/v6/search_query.go index 42c3b4ea160..75ea8a894e3 100644 --- a/grype/db/v6/search_query.go +++ b/grype/db/v6/search_query.go @@ -55,6 +55,9 @@ func (b *searchQueryBuilder) ApplyCriteria(criteriaSet []vulnerability.Criteria) case *search.PackageNameCriteria: b.handlePackageName(c) applied = true + case *search.PackageNamePrefixCriteria: + b.handlePackageNamePrefix(c) + applied = true case *search.UnaffectedCriteria: b.handleUnaffected(c) applied = true @@ -88,6 +91,13 @@ func (b *searchQueryBuilder) handlePackageName(c *search.PackageNameCriteria) { b.query.pkgSpec.Name = c.PackageName } +func (b *searchQueryBuilder) handlePackageNamePrefix(c *search.PackageNamePrefixCriteria) { + if b.query.pkgSpec == nil { + b.query.pkgSpec = &PackageSpecifier{} + } + b.query.pkgSpec.NamePrefix = c.PackageNamePrefix +} + func (b *searchQueryBuilder) handleUnaffected(_ *search.UnaffectedCriteria) { b.query.unaffectedOnly = true } diff --git a/grype/db/v6/search_query_test.go b/grype/db/v6/search_query_test.go index b49a18992ce..ccb45796fca 100644 --- a/grype/db/v6/search_query_test.go +++ b/grype/db/v6/search_query_test.go @@ -28,6 +28,17 @@ func TestNewSearchCriteria(t *testing.T) { require.Equal(t, "test-package", input.pkgSpec.Name) }, }, + { + name: "package name prefix criteria sets correct fields", + criteria: []vulnerability.Criteria{ + search.ByPackageNamePrefix("golang.org/x/crypto"), + }, + validate: func(t *testing.T, input *searchQuery) { + require.NotNil(t, input.pkgSpec) + require.Equal(t, "golang.org/x/crypto", input.pkgSpec.NamePrefix) + require.Empty(t, input.pkgSpec.Name) + }, + }, { name: "unaffected criteria sets flag", criteria: []vulnerability.Criteria{ diff --git a/grype/matcher/golang/matcher.go b/grype/matcher/golang/matcher.go index 7a0c4a177bb..0fb86bda14d 100644 --- a/grype/matcher/golang/matcher.go +++ b/grype/matcher/golang/matcher.go @@ -60,7 +60,50 @@ func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Ma return matches, nil, nil } - return internal.MatchPackageByEcosystemAndCPEs(store, p, m.Type(), searchByCPE(p.Name, m.cfg)) + matches, ignored, err := internal.MatchPackageByEcosystemAndCPEs(store, p, m.Type(), searchByCPE(p.Name, m.cfg)) + if err != nil { + return nil, nil, err + } + + // Go advisories are routinely filed against an import path inside a larger module + // (e.g. "golang.org/x/crypto/ssh" inside "golang.org/x/crypto"). Go binaries only + // retain module-granularity build info via debug/buildinfo, so syft cannot emit the + // import-path entries the advisory expects. Without a fallback the exact-name lookup + // silently misses these. We supplement the exact match with a prefix-search keyed on + // p.Name + "/", so an SBOM module also surfaces advisories pinned to import paths + // strictly under it. The path-segment boundary in the prefix prevents a sibling + // module with a coincident name prefix (e.g. "golang.org/x/cryptographer") from + // matching "golang.org/x/crypto" inputs. + if shouldSearchSubPathAdvisories(p) { + subMatches, subIgnored, err := matchSubPathAdvisories(store, p, m.Type()) + if err != nil { + return nil, nil, err + } + matches = append(matches, subMatches...) + ignored = append(ignored, subIgnored...) + } + + return matches, ignored, nil +} + +// shouldSearchSubPathAdvisories reports whether the package name resembles a Go module +// path that may host advisories at sub-import-path granularity. We require the name to +// contain at least one "/" so e.g. the synthetic "stdlib" entry, single-segment names, +// or empty names don't fan out across the entire Go advisory corpus. +func shouldSearchSubPathAdvisories(p pkg.Package) bool { + if p.Type != syftPkg.GoModulePkg { + return false + } + return strings.Contains(p.Name, "/") +} + +// matchSubPathAdvisories looks up vulnerabilities pinned at an import-path strictly under +// p.Name and returns matches against the SBOM package's version. The normal ecosystem +// pipeline (version constraints, unaffected handling, qualified-package filters) is reused +// via internal.MatchPackageByEcosystemPackageNamePrefix. CPEs are intentionally not +// re-queried here; the exact-name path above already covered them for p.Name. +func matchSubPathAdvisories(store vulnerability.Provider, p pkg.Package, matcherType match.MatcherType) ([]match.Match, []match.IgnoreFilter, error) { + return internal.MatchPackageByEcosystemPackageNamePrefix(store, p, p.Name, matcherType) } func searchByCPE(name string, cfg MatcherConfig) bool { diff --git a/grype/matcher/golang/matcher_test.go b/grype/matcher/golang/matcher_test.go index 5693d53228c..27bdb33c199 100644 --- a/grype/matcher/golang/matcher_test.go +++ b/grype/matcher/golang/matcher_test.go @@ -1,10 +1,20 @@ package golang import ( + "slices" + "sort" + "strings" "testing" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/version" + "github.com/anchore/grype/grype/vulnerability" + "github.com/anchore/grype/grype/vulnerability/mock" "github.com/anchore/grype/internal/dbtest" syftPkg "github.com/anchore/syft/syft/pkg" ) @@ -167,3 +177,164 @@ func TestMatcher_SearchForStdlib(t *testing.T) { }) } } + +// TestMatcher_ImportPathGranularityAdvisories covers the case where a Go advisory is filed +// against an import path inside a larger module (e.g. "golang.org/x/crypto/ssh") while the +// SBOM only carries the module path ("golang.org/x/crypto"). The matcher should surface +// such advisories via a path-segment-bounded prefix search on the module name, while +// avoiding false positives where another module shares only a name prefix substring. +func TestMatcher_ImportPathGranularityAdvisories(t *testing.T) { + // vulnerable version range covers both packages so version isn't the limiting factor. + cryptoSSHVuln := vulnerability.Vulnerability{ + Reference: vulnerability.Reference{ + ID: "GHSA-import-path-1", + Namespace: "github:language:go", + }, + PackageName: "golang.org/x/crypto/ssh", + Constraint: version.MustGetConstraint("< 0.99.0", version.GolangFormat), + } + otelBaggageVuln := vulnerability.Vulnerability{ + Reference: vulnerability.Reference{ + ID: "GHSA-mh2q-q3fh-2475", + Namespace: "github:language:go", + }, + PackageName: "go.opentelemetry.io/otel/baggage", + Constraint: version.MustGetConstraint("< 1.40.1", version.GolangFormat), + } + otelPropagationVuln := vulnerability.Vulnerability{ + Reference: vulnerability.Reference{ + ID: "GHSA-mh2q-q3fh-2475", + Namespace: "github:language:go", + }, + PackageName: "go.opentelemetry.io/otel/propagation", + Constraint: version.MustGetConstraint("< 1.40.1", version.GolangFormat), + } + // boundary trap: a sibling module whose name shares only the "golang.org/x/crypto" + // substring without a "/" segment break. The fix must not surface this advisory for + // SBOM packages named "golang.org/x/crypto". + cryptographerVuln := vulnerability.Vulnerability{ + Reference: vulnerability.Reference{ + ID: "GHSA-boundary-trap", + Namespace: "github:language:go", + }, + PackageName: "golang.org/x/cryptographer", + Constraint: version.MustGetConstraint("< 99.0.0", version.GolangFormat), + } + // exact-match advisory against the parent module itself, used to confirm the + // supplemental prefix search does not regress the existing exact-name path. + parentExactVuln := vulnerability.Vulnerability{ + Reference: vulnerability.Reference{ + ID: "GHSA-parent-exact", + Namespace: "github:language:go", + }, + PackageName: "golang.org/x/crypto", + Constraint: version.MustGetConstraint("< 99.0.0", version.GolangFormat), + } + + store := mock.VulnerabilityProvider( + cryptoSSHVuln, + otelBaggageVuln, + otelPropagationVuln, + cryptographerVuln, + parentExactVuln, + ) + + cases := []struct { + name string + pkg pkg.Package + expectedIDs []string + }{ + { + name: "module sbom surfaces import-path advisory under it", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "golang.org/x/crypto", + Version: "v0.10.0", + Language: syftPkg.Go, + Type: syftPkg.GoModulePkg, + }, + // expects the import-path advisory AND the parent-exact advisory; the + // boundary-trap advisory ("golang.org/x/cryptographer") must not surface. + expectedIDs: []string{"GHSA-import-path-1", "GHSA-parent-exact"}, + }, + { + name: "otel module sbom surfaces sibling import-path advisories filed under it", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "go.opentelemetry.io/otel", + Version: "v1.40.0", + Language: syftPkg.Go, + Type: syftPkg.GoModulePkg, + }, + // both baggage and propagation advisories share the same GHSA ID; we + // expect to see the GHSA at least once. + expectedIDs: []string{"GHSA-mh2q-q3fh-2475"}, + }, + { + name: "exact import-path sbom still matches exact advisory (no regression)", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "golang.org/x/crypto/ssh", + Version: "v0.10.0", + Language: syftPkg.Go, + Type: syftPkg.GoModulePkg, + }, + expectedIDs: []string{"GHSA-import-path-1"}, + }, + { + name: "single-segment package name does not fan out to corpus", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "stdlib", + Version: "v0.10.0", + Language: syftPkg.Go, + Type: syftPkg.GoModulePkg, + }, + expectedIDs: nil, + }, + { + name: "non-go package type is not affected by prefix search", + pkg: pkg.Package{ + ID: pkg.ID(uuid.NewString()), + Name: "golang.org/x/crypto", + Version: "v0.10.0", + Language: syftPkg.Python, + Type: syftPkg.PythonPkg, + }, + expectedIDs: nil, + }, + } + + matcher := NewGolangMatcher(MatcherConfig{}) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + actual, _, err := matcher.Match(store, c.pkg) + require.NoError(t, err) + + var gotIDs []string + for _, m := range actual { + if !slices.Contains(gotIDs, m.Vulnerability.ID) { + gotIDs = append(gotIDs, m.Vulnerability.ID) + } + } + sort.Strings(gotIDs) + + expected := append([]string(nil), c.expectedIDs...) + sort.Strings(expected) + + assert.Equal(t, expected, gotIDs, "unexpected vulnerability matches for package %q", c.pkg.Name) + + // double-check the boundary trap never surfaces, regardless of the + // per-case expectation. + for _, m := range actual { + assert.NotEqual(t, "GHSA-boundary-trap", m.Vulnerability.ID, + "boundary-trap advisory for %q must not match SBOM package %q", + m.Vulnerability.PackageName, c.pkg.Name) + } + }) + } + + // verify the helper directly so the path-segment boundary is locked down. + assert.True(t, strings.HasPrefix("golang.org/x/crypto/ssh", "golang.org/x/crypto/")) + assert.False(t, strings.HasPrefix("golang.org/x/cryptographer", "golang.org/x/crypto/")) +} diff --git a/grype/matcher/internal/language.go b/grype/matcher/internal/language.go index bb0e2a45100..97e4ff6ffcb 100644 --- a/grype/matcher/internal/language.go +++ b/grype/matcher/internal/language.go @@ -30,6 +30,19 @@ func MatchPackageByLanguage(store vulnerability.Provider, p pkg.Package, matcher } func MatchPackageByEcosystemPackageName(vp vulnerability.Provider, p pkg.Package, packageName string, matcherType match.MatcherType) ([]match.Match, []match.IgnoreFilter, error) { + return matchPackageByEcosystem(vp, p, matcherType, search.ByPackageName(packageName)) +} + +// MatchPackageByEcosystemPackageNamePrefix runs the same ecosystem-language match pipeline as +// MatchPackageByEcosystemPackageName, but selects vulnerabilities whose advisory package name +// is a path-segment-bounded sub-path of the supplied prefix. This surfaces advisories pinned +// at an import-path under a containing module (notably for Go) when the SBOM only carries the +// module path. Version, qualified-package, and unaffected-nak handling are preserved. +func MatchPackageByEcosystemPackageNamePrefix(vp vulnerability.Provider, p pkg.Package, prefix string, matcherType match.MatcherType) ([]match.Match, []match.IgnoreFilter, error) { + return matchPackageByEcosystem(vp, p, matcherType, search.ByPackageNamePrefix(prefix)) +} + +func matchPackageByEcosystem(vp vulnerability.Provider, p pkg.Package, matcherType match.MatcherType, nameCriterion vulnerability.Criteria) ([]match.Match, []match.IgnoreFilter, error) { if isUnknownVersion(p.Version) { log.WithFields("package", p.Name).Trace("skipping package with unknown version") return nil, nil, nil @@ -39,7 +52,7 @@ func MatchPackageByEcosystemPackageName(vp vulnerability.Provider, p pkg.Package criteria := []vulnerability.Criteria{ search.ByEcosystem(p.Language, p.Type), - search.ByPackageName(packageName), + nameCriterion, OnlyQualifiedPackages(p), OnlyNonWithdrawnVulnerabilities(), } diff --git a/grype/search/package_name.go b/grype/search/package_name.go index 0ecad9a1851..4a5c71cf625 100644 --- a/grype/search/package_name.go +++ b/grype/search/package_name.go @@ -29,3 +29,35 @@ func (v *PackageNameCriteria) MatchesVulnerability(vuln vulnerability.Vulnerabil var _ interface { vulnerability.Criteria } = (*PackageNameCriteria)(nil) + +// ByPackageNamePrefix returns criteria restricting vulnerabilities to those whose package name +// begins with the provided prefix followed by a "/" path-segment boundary. This is intended for +// ecosystems (such as Go modules) where an advisory may be filed at an import-path granularity +// inside a larger module while the SBOM only carries the module path. The boundary check ensures +// the prefix only matches when the advisory name represents an import path strictly under the +// supplied module: prefix "golang.org/x/crypto" matches "golang.org/x/crypto/ssh" but not +// "golang.org/x/cryptographer". +func ByPackageNamePrefix(prefix string) vulnerability.Criteria { + return &PackageNamePrefixCriteria{ + PackageNamePrefix: prefix, + } +} + +type PackageNamePrefixCriteria struct { + PackageNamePrefix string +} + +func (v *PackageNamePrefixCriteria) MatchesVulnerability(vuln vulnerability.Vulnerability) (bool, string, error) { + if v.PackageNamePrefix == "" { + return false, "empty package name prefix", nil + } + prefix := strings.ToLower(v.PackageNamePrefix) + "/" + if !strings.HasPrefix(strings.ToLower(vuln.PackageName), prefix) { + return false, fmt.Sprintf("vulnerability package name %q does not start with expected prefix %q", vuln.PackageName, v.PackageNamePrefix+"/"), nil + } + return true, "", nil +} + +var _ interface { + vulnerability.Criteria +} = (*PackageNamePrefixCriteria)(nil) diff --git a/grype/search/package_name_test.go b/grype/search/package_name_test.go index 990a487e8a9..57d81395373 100644 --- a/grype/search/package_name_test.go +++ b/grype/search/package_name_test.go @@ -59,3 +59,84 @@ func Test_ByPackageName(t *testing.T) { }) } } + +func Test_ByPackageNamePrefix(t *testing.T) { + tests := []struct { + name string + prefix string + input vulnerability.Vulnerability + matches bool + reason string + }{ + { + name: "advisory pinned at sub-import-path matches module prefix", + prefix: "golang.org/x/crypto", + input: vulnerability.Vulnerability{ + PackageName: "golang.org/x/crypto/ssh", + }, + matches: true, + }, + { + name: "case-insensitive prefix match", + prefix: "Golang.org/X/Crypto", + input: vulnerability.Vulnerability{ + PackageName: "golang.org/x/crypto/ssh", + }, + matches: true, + }, + { + name: "deeper sub-path matches", + prefix: "golang.org/x/crypto", + input: vulnerability.Vulnerability{ + PackageName: "golang.org/x/crypto/ssh/internal/buffer", + }, + matches: true, + }, + { + name: "exact name does not match prefix (must be strictly under)", + prefix: "golang.org/x/crypto", + input: vulnerability.Vulnerability{ + PackageName: "golang.org/x/crypto", + }, + matches: false, + reason: `vulnerability package name "golang.org/x/crypto" does not start with expected prefix "golang.org/x/crypto/"`, + }, + { + name: "sibling with shared substring but no segment break does not match", + prefix: "golang.org/x/crypto", + input: vulnerability.Vulnerability{ + PackageName: "golang.org/x/cryptographer", + }, + matches: false, + reason: `vulnerability package name "golang.org/x/cryptographer" does not start with expected prefix "golang.org/x/crypto/"`, + }, + { + name: "unrelated module does not match", + prefix: "golang.org/x/crypto", + input: vulnerability.Vulnerability{ + PackageName: "github.com/foo/bar", + }, + matches: false, + reason: `vulnerability package name "github.com/foo/bar" does not start with expected prefix "golang.org/x/crypto/"`, + }, + { + name: "empty prefix never matches", + prefix: "", + input: vulnerability.Vulnerability{ + PackageName: "anything", + }, + matches: false, + reason: "empty package name prefix", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + constraint := ByPackageNamePrefix(tt.prefix) + matches, reason, err := constraint.MatchesVulnerability(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.matches, matches) + assert.Equal(t, tt.reason, reason) + }) + } +}