syft/syft/pkg/cataloger/golang/symbol_selector.go
Alex Goodman da745b13e8
feat(golang): add extended-stdlib scope and module patterns for symbol capture (#5154)
* feat(golang): add extended-stdlib scope and include patterns for symbol capture

`golang.capture-symbols` decides how much symbol data lands in the SBOM for grype's reachability analysis. It's `none`, `stdlib`, or `all` today, and the useful middle is missing: `stdlib` stops at the standard library, `all` multiplies SBOM size.

A new `extended-stdlib` configurable covers stdlib plus everything under `golang.org/x/`:

```yaml
golang:
  capture-symbols: extended-stdlib
```

Also, a new `capture-symbols-include` configurable for modules that are noisy in your binaries but not everyone's. It's unioned with whatever the scope selects, so it only ever widens:

```yaml
golang:
  capture-symbols: extended-stdlib
  capture-symbols-include:
    - github.com/klauspost/**
```

Patterns are standard doublestar globs, which matters because module paths carry `/v2`-style suffixes:

```yaml
golang:
  capture-symbols-include:
    - github.com/klauspost/*     # compress, but not compress/v2
    - github.com/klauspost/**    # both
    - k8s.io/client-go           # exact match only
```

Ordering is `none` < `stdlib` < `extended-stdlib` < `all`. The existing three values
and the `none` default are unchanged, and the include list is inert under `none`.
Presets compile into glob lists internally, so a single matcher answers "does this
module get symbols" instead of a preset branch sitting next to a separate glob branch.

An unrecognized `capture-symbols` value still falls back to `none`, but warns now
instead of doing it silently. A malformed include pattern warns and gets skipped.

One thing worth a look beyond the feature: the `Symbols` field description in the JSON
schema was wrong after this (it claimed only `all` and `stdlib` populate anything), and
that description lives in the already-published `16.1.10`. Rather than bump a version for
a sentence, `16.1.10` is amended in place and `schema/json/README.md` grows an explicit
exception for description-only changes: descriptions only, no shape change of any kind,
`$id` unchanged. Anything else still needs a bump. Happy to split that into its own PR if
you'd rather review the policy separately.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* refactor(golang): rename capture-symbols-include to capture-symbols-modules

The key's entries are go module paths, and `-include` sitting next to `capture-symbols` reads as plausibly taking symbol or package names instead. Those spellings parse and match nothing, which is quieter than the confusion `-include` was picked to avoid, so the name now says what the list holds.

`golang.CatalogerConfig.CaptureSymbolsModules` and `WithCaptureSymbolsModules` rename with it. Nothing behavioral changes; the key is new in this PR so there is no compatibility surface.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* feat(golang): match capture-symbols-modules across major version suffixes

`github.com/anchore/*` covered `github.com/anchore/syft` and silently stopped covering it the day it became `github.com/anchore/syft/v2`. The config keeps parsing, nothing warns, and symbols quietly go missing from the SBOM. Exact paths had the same hole: `github.com/klauspost/compress` did not cover `compress/v2` either, so no spelling short of `**` survived a major bump.

A major version suffix is part of a module's path but not part of its identity, so patterns are now matched against the module path both with and without it, using `module.SplitPathVersion` from `golang.org/x/mod` (already a direct dep, already used in this package for `PseudoVersion`).

```yaml
golang:
  capture-symbols-modules:
    - github.com/klauspost/*          # compress and compress/v2
    - github.com/klauspost/compress   # same module at every major version
    - github.com/klauspost/compress/v2  # v2 alone
```

Only a trailing suffix is a version, which is Go's own rule. In `github.com/anchore/syft/v2/thing` the `v2` is an ordinary path element naming a major subdirectory a nested module lives in, so it stays literal and `github.com/anchore/**/thing` is how you reach it. `/v0` and `/v1` are not valid suffixes and are left alone.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-08-07 12:44:42 +00:00

114 lines
4.4 KiB
Go

package golang
import (
"slices"
"github.com/bmatcuk/doublestar/v4"
"golang.org/x/mod/module"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/cataloging"
)
// scopePatterns maps a capture-symbols scope onto the go module path globs it selects. The "none" and
// "all" scopes are intentionally absent: both are answered without matching. The "stdlib" scope selects
// no modules at all (the synthetic stdlib package is not a module and is handled separately).
var scopePatterns = map[cataloging.SymbolScope][]string{
cataloging.SymbolScopeExtendedStdlib: {"golang.org/x/**"},
}
// symbolSelector decides which go module paths get function symbols attached to their metadata. The scope
// preset and any user-supplied module patterns are compiled into a single glob list so there is exactly
// one matcher answering "does this module path get symbols" (two matchers over the same subject drift).
type symbolSelector struct {
scope cataloging.SymbolScope
patterns []string
}
func newSymbolSelector(scope cataloging.SymbolScope, modules []string) symbolSelector {
// normalize here rather than trusting the caller: the CLI runs Parse in PostLoad, but a library
// consumer setting CaptureSymbols directly (or via WithCaptureSymbols) does not, and an unnormalized
// value falls through both switches below into stdlib-only capture rather than the intended scope.
scope = scope.Parse()
var patterns []string
for _, pattern := range slices.Concat(scopePatterns[scope], modules) {
if !doublestar.ValidatePattern(pattern) {
// a typo in a filter that decides what gets vulnerability-scanned must not pass quietly:
// someone would believe they captured symbols they did not. Drop just this pattern and keep
// the rest of the selection in force.
log.WithFields("pattern", pattern).Warn("ignoring malformed golang capture-symbols-modules pattern")
continue
}
patterns = append(patterns, pattern)
}
return symbolSelector{scope: scope, patterns: patterns}
}
// enabled reports whether symbols should be extracted from the binary at all.
func (s symbolSelector) enabled() bool {
return s.scope != cataloging.SymbolScopeNone
}
// selects reports whether the given go module path gets symbols. The binary's own main module is treated
// exactly like a dependency.
func (s symbolSelector) selects(modulePath string) bool {
switch s.scope {
case cataloging.SymbolScopeNone:
return false
case cataloging.SymbolScopeAll:
return true
}
// a major version suffix is part of a module's path but not part of its identity, so patterns are matched
// against the path both with and without it: `github.com/foo/bar` and `github.com/foo/*` each select
// `github.com/foo/bar/v2`, which is what whoever wrote either one meant, and a config does not quietly
// stop covering a module the day it bumps a major version. Spelling a suffix out in the pattern still
// selects that major version alone, since the unsuffixed path cannot match a pattern carrying one.
// Only a trailing suffix is a version: in `github.com/foo/v2/bar` the `v2` is an ordinary path element,
// and SplitPathVersion leaves it there.
unversioned, major, ok := module.SplitPathVersion(modulePath)
versioned := ok && major != ""
for _, pattern := range s.patterns {
if globMatches(pattern, modulePath) {
return true
}
if versioned && globMatches(pattern, unversioned) {
return true
}
}
return false
}
// globMatches treats a pattern that cannot compile as no match, which newSymbolSelector has already warned about.
func globMatches(pattern, modulePath string) bool {
matched, err := doublestar.Match(pattern, modulePath)
if err != nil {
// unreachable: newSymbolSelector rejects (and warns about) patterns that cannot compile
return false
}
return matched
}
// filter drops the entries of a module-path-keyed symbol map that the selector does not select. It returns
// nil when nothing is selected so callers attach no symbols map at all (rather than an empty one, which
// would defeat the omitempty JSON tag).
func (s symbolSelector) filter(byModule map[string]map[string][]string) map[string]map[string][]string {
switch s.scope {
case cataloging.SymbolScopeNone:
return nil
case cataloging.SymbolScopeAll:
return byModule
}
for modulePath := range byModule {
if !s.selects(modulePath) {
delete(byModule, modulePath)
}
}
if len(byModule) == 0 {
return nil
}
return byModule
}