Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions grype/db/v6/package_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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))
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions grype/db/v6/search_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
11 changes: 11 additions & 0 deletions grype/db/v6/search_query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
45 changes: 44 additions & 1 deletion grype/matcher/golang/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
171 changes: 171 additions & 0 deletions grype/matcher/golang/matcher_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -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/"))
}
15 changes: 14 additions & 1 deletion grype/matcher/internal/language.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
}
Expand Down
Loading