From 10a168a56528f405ff8eeb2732afcfb1c2a25af2 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 6 Aug 2026 10:29:12 -0400 Subject: [PATCH 1/3] 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 --- cmd/syft/internal/options/catalog.go | 3 +- cmd/syft/internal/options/golang.go | 32 ++- cmd/syft/internal/options/golang_test.go | 48 +++- internal/capabilities/appconfig.yaml | 4 +- schema/json/README.md | 21 ++ schema/json/schema-16.1.10.json | 2 +- schema/json/schema-latest.json | 2 +- syft/cataloging/symbols.go | 6 + syft/cataloging/symbols_test.go | 5 + syft/pkg/cataloger/golang/capabilities.yaml | 5 +- syft/pkg/cataloger/golang/config.go | 15 +- syft/pkg/cataloger/golang/parse_go_binary.go | 16 +- .../cataloger/golang/parse_go_binary_test.go | 114 ++++++++- syft/pkg/cataloger/golang/symbol_selector.go | 93 ++++++++ .../cataloger/golang/symbol_selector_test.go | 218 ++++++++++++++++++ syft/pkg/golang.go | 6 +- 16 files changed, 562 insertions(+), 28 deletions(-) create mode 100644 syft/pkg/cataloger/golang/symbol_selector.go create mode 100644 syft/pkg/cataloger/golang/symbol_selector_test.go diff --git a/cmd/syft/internal/options/catalog.go b/cmd/syft/internal/options/catalog.go index 0d7e94b5022..6acb8e5dcdf 100644 --- a/cmd/syft/internal/options/catalog.go +++ b/cmd/syft/internal/options/catalog.go @@ -199,7 +199,8 @@ func (cfg Catalog) ToPackagesConfig() pkgcataloging.Config { WithFromLDFlags(cfg.Golang.MainModuleVersion.FromLDFlags), ). WithUsePackagesLib(*multiLevelOption(true, enrichmentEnabled(cfg.Enrich, task.Go, task.Golang), cfg.Golang.UsePackagesLib)). - WithCaptureSymbols(cfg.Golang.CaptureSymbols), + WithCaptureSymbols(cfg.Golang.CaptureSymbols). + WithCaptureSymbolsInclude(cfg.Golang.CaptureSymbolsInclude), JavaScript: javascript.DefaultCatalogerConfig(). WithIncludeDevDependencies(*multiLevelOption(false, cfg.JavaScript.IncludeDevDependencies)). WithSearchRemoteLicenses(*multiLevelOption(false, enrichmentEnabled(cfg.Enrich, task.JavaScript, task.Node, task.NPM), cfg.JavaScript.SearchRemoteLicenses)). diff --git a/cmd/syft/internal/options/golang.go b/cmd/syft/internal/options/golang.go index c811a34c307..7fb52c238ba 100644 --- a/cmd/syft/internal/options/golang.go +++ b/cmd/syft/internal/options/golang.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/anchore/clio" + "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/cataloging" "github.com/anchore/syft/syft/pkg/cataloger/golang" ) @@ -19,6 +20,7 @@ type golangConfig struct { MainModuleVersion golangMainModuleVersionConfig `json:"main-module-version" yaml:"main-module-version" mapstructure:"main-module-version"` UsePackagesLib *bool `json:"use-packages-lib" yaml:"use-packages-lib" mapstructure:"use-packages-lib"` CaptureSymbols cataloging.SymbolScope `json:"capture-symbols" yaml:"capture-symbols" mapstructure:"capture-symbols"` + CaptureSymbolsInclude []string `json:"capture-symbols-include" yaml:"capture-symbols-include" mapstructure:"capture-symbols-include"` } var _ interface { @@ -42,8 +44,14 @@ if unset this defaults to $GONOPROXY`) always show (devel) as the version. Use these options to control heuristics to guess a more accurate version from the binary.`) descriptions.Add(&o.UsePackagesLib, `use the golang.org/x/tools/go/packages library, which executes golang tooling found on the path in addition to potential network access to get the most accurate results`) + // note: descriptions must be static string literals; the app config discovery that generates the + // capability docs reads them straight out of the AST descriptions.Add(&o.CaptureSymbols, `capture function symbols from the binary symbol table (pclntab). valid values are: -"none" (disabled), "stdlib" (only the synthetic stdlib package), and "all" (all module packages plus stdlib)`) +"none" (disabled), "stdlib" (only the synthetic stdlib package), "extended-stdlib" (stdlib plus every +module under golang.org/x/), and "all" (all module packages plus stdlib)`) + descriptions.Add(&o.CaptureSymbolsInclude, `glob patterns matched against go module paths (e.g. github.com/klauspost/**) that should have symbols +captured in addition to whatever capture-symbols selects. ** crosses path separators, * does not. +this can only widen the selection, never narrow it, and is inert when capture-symbols is none`) descriptions.Add(&o.MainModuleVersion.FromLDFlags, `look for LD flags that appear to be setting a version (e.g. -X main.version=1.0.0)`) descriptions.Add(&o.MainModuleVersion.FromBuildSettings, `use the build settings (e.g. vcs.version & vcs.time) to craft a v0 pseudo version (e.g. v0.0.0-20220308212642-53e6d0aaf6fb) when a more accurate version cannot be found otherwise`) @@ -51,7 +59,24 @@ a more accurate version from the binary.`) } func (o *golangConfig) PostLoad() error { + raw := strings.TrimSpace(string(o.CaptureSymbols)) o.CaptureSymbols = o.CaptureSymbols.Parse() + + // an unrecognized value still resolves to "none", but say so rather than silently capturing nothing. + // stay quiet for unset and an explicit "none", which is the default and would otherwise warn on nearly + // every scan. + if o.CaptureSymbols == cataloging.SymbolScopeNone && raw != "" && !strings.EqualFold(raw, string(cataloging.SymbolScopeNone)) { + log.Warnf("unknown golang.capture-symbols value %q, defaulting to %q (valid values: none, stdlib, extended-stdlib, all)", raw, cataloging.SymbolScopeNone) + } + + // trim only, deliberately not Flatten: viper already splits a comma-separated scalar (env var or a bare + // yaml string) into a slice before this point, and there is no CLI flag feeding this key. The one thing + // Flatten would add is splitting commas *inside* a list entry, which silently breaks doublestar brace + // alternation like github.com/{foo,bar}/**. Viper's split does not trim, so that part is still needed. + for i, pattern := range o.CaptureSymbolsInclude { + o.CaptureSymbolsInclude[i] = strings.TrimSpace(pattern) + } + return nil } @@ -76,7 +101,8 @@ func defaultGolangConfig() golangConfig { FromContents: def.MainModuleVersion.FromContents, FromBuildSettings: def.MainModuleVersion.FromBuildSettings, }, - UsePackagesLib: nil, // this defaults to true, which is the API default - CaptureSymbols: def.CaptureSymbols, + UsePackagesLib: nil, // this defaults to true, which is the API default + CaptureSymbols: def.CaptureSymbols, + CaptureSymbolsInclude: def.CaptureSymbolsInclude, } } diff --git a/cmd/syft/internal/options/golang_test.go b/cmd/syft/internal/options/golang_test.go index 64334990491..fb694ea550e 100644 --- a/cmd/syft/internal/options/golang_test.go +++ b/cmd/syft/internal/options/golang_test.go @@ -10,10 +10,11 @@ import ( func Test_golangConfig_PostLoad(t *testing.T) { tests := []struct { - name string - cfg golangConfig - expected cataloging.SymbolScope - wantErr assert.ErrorAssertionFunc + name string + cfg golangConfig + expected cataloging.SymbolScope + expectedInclude []string + wantErr assert.ErrorAssertionFunc }{ { name: "normalize all", @@ -25,6 +26,32 @@ func Test_golangConfig_PostLoad(t *testing.T) { cfg: golangConfig{CaptureSymbols: "stdlib"}, expected: cataloging.SymbolScopeStdlib, }, + { + name: "normalize extended-stdlib", + cfg: golangConfig{CaptureSymbols: " Extended-Stdlib "}, + expected: cataloging.SymbolScopeExtendedStdlib, + }, + { + name: "include patterns keep embedded commas", + cfg: golangConfig{ + CaptureSymbols: "stdlib", + // brace alternation contains a comma; splitting on it would corrupt the pattern + CaptureSymbolsInclude: []string{"github.com/{foo,bar}/**", "golang.org/x/**"}, + }, + expected: cataloging.SymbolScopeStdlib, + expectedInclude: []string{"github.com/{foo,bar}/**", "golang.org/x/**"}, + }, + { + // viper splits a comma-separated scalar (env var or bare yaml string) but does not trim, + // so a leading space would otherwise survive into a pattern that silently matches nothing + name: "include patterns are trimmed", + cfg: golangConfig{ + CaptureSymbols: "stdlib", + CaptureSymbolsInclude: []string{"golang.org/x/**", " github.com/foo/** "}, + }, + expected: cataloging.SymbolScopeStdlib, + expectedInclude: []string{"golang.org/x/**", "github.com/foo/**"}, + }, { name: "empty defaults to none", cfg: golangConfig{CaptureSymbols: ""}, @@ -32,7 +59,7 @@ func Test_golangConfig_PostLoad(t *testing.T) { }, { name: "invalid value defaults to none", - cfg: golangConfig{CaptureSymbols: "bogus"}, + cfg: golangConfig{CaptureSymbols: "stdlbi"}, expected: cataloging.SymbolScopeNone, }, { @@ -40,6 +67,16 @@ func Test_golangConfig_PostLoad(t *testing.T) { cfg: golangConfig{CaptureSymbols: "true"}, expected: cataloging.SymbolScopeNone, }, + { + name: "explicit none resolves to none", + cfg: golangConfig{CaptureSymbols: "none"}, + expected: cataloging.SymbolScopeNone, + }, + { + name: "explicit none is not case sensitive", + cfg: golangConfig{CaptureSymbols: " NONE "}, + expected: cataloging.SymbolScopeNone, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -52,6 +89,7 @@ func Test_golangConfig_PostLoad(t *testing.T) { return } assert.Equal(t, tt.expected, tt.cfg.CaptureSymbols) + assert.Equal(t, tt.expectedInclude, tt.cfg.CaptureSymbolsInclude) }) } } diff --git a/internal/capabilities/appconfig.yaml b/internal/capabilities/appconfig.yaml index 295df39df21..e8b53353061 100644 --- a/internal/capabilities/appconfig.yaml +++ b/internal/capabilities/appconfig.yaml @@ -15,7 +15,9 @@ application: # AUTO-GENERATED - application-level config keys - key: dotnet.relax-dll-claims-when-bundling-detected description: show all packages from the deps.json if bundling tooling is present as a dependency (e.g. ILRepack) - key: golang.capture-symbols - description: 'capture function symbols from the binary symbol table (pclntab). valid values are: "none" (disabled), "stdlib" (only the synthetic stdlib package), and "all" (all module packages plus stdlib)' + description: 'capture function symbols from the binary symbol table (pclntab). valid values are: "none" (disabled), "stdlib" (only the synthetic stdlib package), "extended-stdlib" (stdlib plus every module under golang.org/x/), and "all" (all module packages plus stdlib)' + - key: golang.capture-symbols-include + description: glob patterns matched against go module paths (e.g. github.com/klauspost/**) that should have symbols captured in addition to whatever capture-symbols selects. ** crosses path separators, * does not. this can only widen the selection, never narrow it, and is inert when capture-symbols is none - key: golang.local-mod-cache-dir description: specify an explicit go mod cache directory, if unset this defaults to $GOPATH/pkg/mod or $HOME/go/pkg/mod - key: golang.local-vendor-dir diff --git a/schema/json/README.md b/schema/json/README.md index 52fb639f56f..37fd50b0c54 100644 --- a/schema/json/README.md +++ b/schema/json/README.md @@ -35,3 +35,24 @@ Create the new schema by running `make generate-json-schema` from the root of th - If there is an existing schema for the given version and the new schema **does not** match the existing schema, an error is shown indicating to increment the version appropriately (see the "Versioning" section) ***Note: never delete a JSON schema and never change an existing JSON schema once it has been published in a release!*** Only add new schemas with a newly incremented version. All previous schema files must be stored in the `schema/json/` directory. + +### Exception: `description`-only corrections + +A published schema may be amended in place for one narrow case: the change touches only `description` text and leaves the data shape identical. Descriptions are documentation carried alongside the schema rather than constraints a validator evaluates, so correcting one cannot invalidate a document that already validated against that version. Minting a new version instead would leave the old one permanently describing the tool incorrectly, and spend a version number on no semantic change. + +This applies when **every** one of the following holds: + +- the only differences are `description` values +- no field, type, enum, `required` entry, or `$ref` is added, removed, or altered +- the `$id` version is unchanged + +Anything else, including adding a field that happens to be optional, is a schema change and needs a version bump per the "Versioning" section above. + +The generator blocks an in-place edit by design, since it refuses to overwrite a file that differs from what it would produce. To amend one, delete the schema file and regenerate so it is rewritten from the current Go doc comments: + +```bash +rm schema/json/schema-$VERSION.json +make generate-json-schema +``` + +The result is byte-identical to what the generator produces, so do not hand-edit the JSON. Re-running `make generate-json-schema` afterwards should report `No change to the existing schema!`, and `make check-json-schema-drift` should pass. Confirm with `git diff` that the only changes are the intended description lines. diff --git a/schema/json/schema-16.1.10.json b/schema/json/schema-16.1.10.json index 87756c843a9..009460bdf2f 100644 --- a/schema/json/schema-16.1.10.json +++ b/schema/json/schema-16.1.10.json @@ -1662,7 +1662,7 @@ "type": "array" }, "type": "object", - "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, while\nthe \"stdlib\" scope populates only the stdlib package." + "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, the\n\"extended-stdlib\" scope populates the stdlib package plus every module under golang.org/x/, and the\n\"stdlib\" scope populates only the stdlib package. The capture-symbols-include glob patterns populate\nany additional modules they match." } }, "type": "object", diff --git a/schema/json/schema-latest.json b/schema/json/schema-latest.json index 87756c843a9..009460bdf2f 100644 --- a/schema/json/schema-latest.json +++ b/schema/json/schema-latest.json @@ -1662,7 +1662,7 @@ "type": "array" }, "type": "object", - "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, while\nthe \"stdlib\" scope populates only the stdlib package." + "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, the\n\"extended-stdlib\" scope populates the stdlib package plus every module under golang.org/x/, and the\n\"stdlib\" scope populates only the stdlib package. The capture-symbols-include glob patterns populate\nany additional modules they match." } }, "type": "object", diff --git a/syft/cataloging/symbols.go b/syft/cataloging/symbols.go index 4e892fceed3..2b7907eda62 100644 --- a/syft/cataloging/symbols.go +++ b/syft/cataloging/symbols.go @@ -12,6 +12,10 @@ const ( // SymbolScopeStdlib captures symbols only for the synthetic "stdlib" package, leaving module packages without symbols. SymbolScopeStdlib SymbolScope = "stdlib" + // SymbolScopeExtendedStdlib captures symbols for the synthetic "stdlib" package as well as every module + // under golang.org/x/ (the extended standard library). + SymbolScopeExtendedStdlib SymbolScope = "extended-stdlib" + // SymbolScopeAll captures symbols for all module packages as well as the synthetic "stdlib" package. SymbolScopeAll SymbolScope = "all" ) @@ -21,6 +25,8 @@ func (s SymbolScope) Parse() SymbolScope { switch strings.ToLower(strings.TrimSpace(string(s))) { case string(SymbolScopeAll): return SymbolScopeAll + case string(SymbolScopeExtendedStdlib): + return SymbolScopeExtendedStdlib case string(SymbolScopeStdlib): return SymbolScopeStdlib } diff --git a/syft/cataloging/symbols_test.go b/syft/cataloging/symbols_test.go index 307119a1544..8d8d12717be 100644 --- a/syft/cataloging/symbols_test.go +++ b/syft/cataloging/symbols_test.go @@ -16,6 +16,11 @@ func Test_SymbolScope_Parse(t *testing.T) { {" all ", SymbolScopeAll}, {"stdlib", SymbolScopeStdlib}, {"Stdlib", SymbolScopeStdlib}, + {"extended-stdlib", SymbolScopeExtendedStdlib}, + {"Extended-Stdlib", SymbolScopeExtendedStdlib}, + {"EXTENDED-STDLIB", SymbolScopeExtendedStdlib}, + {" extended-stdlib ", SymbolScopeExtendedStdlib}, + {"extended_stdlib", SymbolScopeNone}, {"none", SymbolScopeNone}, {"", SymbolScopeNone}, {"true", SymbolScopeNone}, diff --git a/syft/pkg/cataloger/golang/capabilities.yaml b/syft/pkg/cataloger/golang/capabilities.yaml index 01e24b146f6..1593f6adbf2 100644 --- a/syft/pkg/cataloger/golang/capabilities.yaml +++ b/syft/pkg/cataloger/golang/capabilities.yaml @@ -25,8 +25,11 @@ configs: # AUTO-GENERATED - config structs and their fields description: NoProxy is a list of glob patterns that match go module names that should not be fetched from the go proxy. When not set, syft will use the GOPRIVATE and GONOPROXY env vars. app_key: golang.no-proxy - key: CaptureSymbols - description: CaptureSymbols controls extracting function symbols from the binary symbol table (pclntab). Valid values are "none" (disabled), "stdlib" (only the synthetic stdlib package), and "all" (all module packages plus stdlib). + description: CaptureSymbols controls extracting function symbols from the binary symbol table (pclntab). Valid values are "none" (disabled), "stdlib" (only the synthetic stdlib package), "extended-stdlib" (stdlib plus every module under golang.org/x/), and "all" (all module packages plus stdlib). app_key: golang.capture-symbols + - key: CaptureSymbolsInclude + description: CaptureSymbolsInclude is a list of glob patterns (doublestar syntax, where ** crosses path separators and * does not) matched against go module paths. Matching modules get symbols in addition to whatever CaptureSymbols selects, so this can only widen the selection and never narrow it. It has no effect under the "none" scope. + app_key: golang.capture-symbols-include catalogers: - ecosystem: go # MANUAL name: go-module-binary-cataloger # AUTO-GENERATED diff --git a/syft/pkg/cataloger/golang/config.go b/syft/pkg/cataloger/golang/config.go index 228cd67b18e..9b01eee2224 100644 --- a/syft/pkg/cataloger/golang/config.go +++ b/syft/pkg/cataloger/golang/config.go @@ -51,10 +51,18 @@ type CatalogerConfig struct { MainModuleVersion MainModuleVersionConfig `yaml:"main-module-version" json:"main-module-version" mapstructure:"main-module-version"` // CaptureSymbols controls extracting function symbols from the binary symbol table (pclntab). Valid values are - // "none" (disabled), "stdlib" (only the synthetic stdlib package), and "all" (all module packages plus stdlib). + // "none" (disabled), "stdlib" (only the synthetic stdlib package), "extended-stdlib" (stdlib plus every module + // under golang.org/x/), and "all" (all module packages plus stdlib). // app-config: golang.capture-symbols CaptureSymbols cataloging.SymbolScope `yaml:"capture-symbols" json:"capture-symbols" mapstructure:"capture-symbols"` + // CaptureSymbolsInclude is a list of glob patterns (doublestar syntax, where ** crosses path separators + // and * does not) matched against go module paths. Matching modules get symbols in addition to whatever + // CaptureSymbols selects, so this can only widen the selection and never narrow it. It has no effect + // under the "none" scope. + // app-config: golang.capture-symbols-include + CaptureSymbolsInclude []string `yaml:"capture-symbols-include,omitempty" json:"capture-symbols-include,omitempty" mapstructure:"capture-symbols-include"` + // Whether to use the golang.org/x/tools/go/packages, which executes golang tooling found on the path in addition to potential network access UsePackagesLib bool `json:"use-packages-lib" yaml:"use-packages-lib" mapstructure:"use-packages-lib"` } @@ -196,6 +204,11 @@ func (g CatalogerConfig) WithCaptureSymbols(input cataloging.SymbolScope) Catalo return g } +func (g CatalogerConfig) WithCaptureSymbolsInclude(input []string) CatalogerConfig { + g.CaptureSymbolsInclude = input + return g +} + func (g CatalogerConfig) WithUsePackagesLib(useLib bool) CatalogerConfig { g.UsePackagesLib = useLib return g diff --git a/syft/pkg/cataloger/golang/parse_go_binary.go b/syft/pkg/cataloger/golang/parse_go_binary.go index a4d24c918e4..f5b53409bba 100644 --- a/syft/pkg/cataloger/golang/parse_go_binary.go +++ b/syft/pkg/cataloger/golang/parse_go_binary.go @@ -21,7 +21,6 @@ import ( "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/artifact" - "github.com/anchore/syft/syft/cataloging" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/internal/unionreader" "github.com/anchore/syft/syft/pkg" @@ -51,7 +50,7 @@ const devel = "(devel)" type goBinaryCataloger struct { licenseResolver goLicenseResolver mainModuleVersion MainModuleVersionConfig - symbolScope cataloging.SymbolScope + symbolSelector symbolSelector // stdlibSymbols holds the standard-library function symbols discovered per binary (keyed by the // binary's location), grouped by import path, populated during parsing and consumed by stdlibProcessor @@ -65,7 +64,7 @@ func newGoBinaryCataloger(opts CatalogerConfig) *goBinaryCataloger { return &goBinaryCataloger{ licenseResolver: newGoLicenseResolver(binaryCatalogerName, opts), mainModuleVersion: opts.MainModuleVersion, - symbolScope: opts.CaptureSymbols, + symbolSelector: newSymbolSelector(opts.CaptureSymbols, opts.CaptureSymbolsInclude), stdlibSymbols: make(map[file.Coordinates]map[string][]string), } } @@ -121,7 +120,7 @@ func (c *goBinaryCataloger) parseGoBinary(ctx context.Context, resolver file.Res } defer internal.CloseAndLogError(reader.ReadCloser, reader.RealPath) - mods, errs := scanFile(reader.Location, unionReader, c.symbolScope != cataloging.SymbolScopeNone) + mods, errs := scanFile(reader.Location, unionReader, c.symbolSelector.enabled()) var rels []artifact.Relationship for _, mod := range mods { @@ -184,11 +183,10 @@ func (c *goBinaryCataloger) buildGoPkgInfo(ctx context.Context, resolver file.Re symbolsByModule, stdlibSymbols := moduleSymbols(mod.symbols, &mod.Main, mod.Deps) c.recordStdlibSymbols(location.Coordinates, stdlibSymbols) - if c.symbolScope != cataloging.SymbolScopeAll { - // only the "all" scope attaches per-module symbols; for the "stdlib" scope we keep just the - // recorded stdlib symbols. nil map lookups below then yield nil symbol lists for each module. - symbolsByModule = nil - } + // keep only the modules the selector covers; the main module goes through the same map, so this + // incidentally decides the main module too (which is intended: it is treated like any dependency). + // unselected modules fall out entirely, so the lookups below yield nil rather than an empty map. + symbolsByModule = c.symbolSelector.filter(symbolsByModule) var pkgs []pkg.Package for _, dep := range mod.Deps { diff --git a/syft/pkg/cataloger/golang/parse_go_binary_test.go b/syft/pkg/cataloger/golang/parse_go_binary_test.go index 9881f91cc0b..4bbc8716f79 100644 --- a/syft/pkg/cataloger/golang/parse_go_binary_test.go +++ b/syft/pkg/cataloger/golang/parse_go_binary_test.go @@ -4,12 +4,14 @@ import ( "bufio" "bytes" "context" + "encoding/json" "errors" "io" "os" "os/exec" "path/filepath" "runtime/debug" + "slices" "strconv" "strings" "syscall" @@ -1441,9 +1443,12 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) { tests := []struct { name string scope cataloging.SymbolScope + include []string + extraDeps []*debug.Module symbols []binarySymbol wantMainSyms map[string][]string wantDepSyms map[string][]string + wantExtraSyms []map[string][]string wantStdlibSyms map[string][]string }{ { @@ -1468,6 +1473,84 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) { }, wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}}, }, + { + // golang.org/x/net is reached through a vendored import path here, which must still be + // attributed to (and selected by) the module that owns it + name: "extended-stdlib captures golang.org/x modules and stdlib only", + scope: cataloging.SymbolScopeExtendedStdlib, + extraDeps: extendedDeps, + symbols: slices.Concat(populatedSymbols, extendedSymbols), + wantExtraSyms: []map[string][]string{ + {"golang.org/x/net/http2": {"NewClientConn"}}, + nil, + }, + wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}}, + }, + { + name: "include patterns widen extended-stdlib", + scope: cataloging.SymbolScopeExtendedStdlib, + include: []string{"github.com/klauspost/**"}, + extraDeps: extendedDeps, + symbols: slices.Concat(populatedSymbols, extendedSymbols), + wantExtraSyms: []map[string][]string{ + {"golang.org/x/net/http2": {"NewClientConn"}}, + {"github.com/klauspost/compress/zstd": {"NewReader"}}, + }, + wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}}, + }, + { + name: "include patterns can select the main module", + scope: cataloging.SymbolScopeStdlib, + include: []string{"github.com/anchore/**"}, + extraDeps: extendedDeps, + symbols: slices.Concat(populatedSymbols, extendedSymbols), + // the main package is keyed by the "main" import path the linker assigns, not its real path + wantMainSyms: map[string][]string{"main": {"main"}}, + wantExtraSyms: []map[string][]string{nil, nil}, + wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}}, + }, + { + name: "include patterns are inert under the none scope", + scope: cataloging.SymbolScopeNone, + include: []string{"github.com/**", "golang.org/x/**"}, + extraDeps: extendedDeps, + // scanFile never runs under "none", so the build info carries no symbols to begin with + symbols: nil, + wantExtraSyms: []map[string][]string{nil, nil}, + }, + { + // all short-circuits ahead of the pattern walk, so a narrow include list cannot subtract + // from it. this is also the guard on that short-circuit still existing. + name: "include patterns cannot narrow the all scope", + scope: cataloging.SymbolScopeAll, + include: []string{"golang.org/x/**"}, + extraDeps: extendedDeps, + symbols: slices.Concat(populatedSymbols, extendedSymbols), + wantMainSyms: map[string][]string{"main": {"main"}}, + wantDepSyms: map[string][]string{ + "github.com/foo/bar": {"Parse"}, + "github.com/foo/bar/baz": {"Helper"}, + }, + wantExtraSyms: []map[string][]string{ + {"golang.org/x/net/http2": {"NewClientConn"}}, + {"github.com/klauspost/compress/zstd": {"NewReader"}}, + }, + wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}}, + }, + { + // an include that restates what the preset already covers must not duplicate or drop anything: + // selection is a per-module boolean, so overlap is idempotent + name: "include overlapping the preset changes nothing", + scope: cataloging.SymbolScopeExtendedStdlib, + include: []string{"golang.org/x/**"}, + extraDeps: extendedDeps, + symbols: slices.Concat(populatedSymbols, extendedSymbols), + wantExtraSyms: []map[string][]string{ + {"golang.org/x/net/http2": {"NewClientConn"}}, + nil, + }, + wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}}, + }, } for _, tt := range tests { @@ -1476,27 +1559,52 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) { BuildInfo: &debug.BuildInfo{ GoVersion: "go1.22.0", Main: debug.Module{Path: "github.com/anchore/syft", Version: "v1.0.0"}, - Deps: []*debug.Module{{Path: "github.com/foo/bar", Version: "v1.2.3"}}, + Deps: append([]*debug.Module{{Path: "github.com/foo/bar", Version: "v1.2.3"}}, tt.extraDeps...), }, arch: "amd64", symbols: tt.symbols, } - c := newGoBinaryCataloger(CatalogerConfig{CaptureSymbols: tt.scope}) + c := newGoBinaryCataloger(CatalogerConfig{CaptureSymbols: tt.scope, CaptureSymbolsInclude: tt.include}) reader, err := unionreader.GetUnionReader(io.NopCloser(strings.NewReader(""))) require.NoError(t, err) mainPkg, pkgs := c.buildGoPkgInfo(context.Background(), fileresolver.Empty{}, location, mod, mod.arch, reader) require.NotNil(t, mainPkg) - require.Len(t, pkgs, 1) + require.Len(t, pkgs, 1+len(tt.extraDeps)) assert.Equal(t, tt.wantMainSyms, mainPkg.Metadata.(pkg.GolangBinaryBuildinfoEntry).Symbols, "main module symbols") assert.Equal(t, tt.wantDepSyms, pkgs[0].Metadata.(pkg.GolangBinaryBuildinfoEntry).Symbols, "dependency symbols") + for i, want := range tt.wantExtraSyms { + assert.Equal(t, want, pkgs[1+i].Metadata.(pkg.GolangBinaryBuildinfoEntry).Symbols, "symbols for %s", pkgs[1+i].Name) + } assert.Equal(t, tt.wantStdlibSyms, c.stdlibSymbolsFor(location.Coordinates), "recorded stdlib symbols") + + // a module selected by nothing must carry no symbols key at all: assert on the serialized form, + // since an empty (non-nil) map would still emit "symbols":{} despite the omitempty tag + for _, p := range append([]pkg.Package{*mainPkg}, pkgs...) { + encoded, err := json.Marshal(p.Metadata) + require.NoError(t, err) + if p.Metadata.(pkg.GolangBinaryBuildinfoEntry).Symbols == nil { + assert.NotContains(t, string(encoded), `"symbols"`, "expected no symbols key for %s", p.Name) + } + } }) } } +var ( + extendedDeps = []*debug.Module{ + {Path: "golang.org/x/net", Version: "v0.30.0"}, + {Path: "github.com/klauspost/compress", Version: "v1.17.0"}, + } + + extendedSymbols = []binarySymbol{ + {packagePath: "vendor/golang.org/x/net/http2", name: "vendor/golang.org/x/net/http2.NewClientConn"}, + {packagePath: "github.com/klauspost/compress/zstd", name: "github.com/klauspost/compress/zstd.NewReader"}, + } +) + // Test_recordStdlibSymbols_merge covers the merge path where the same binary location records stdlib // symbols more than once. This happens in production for universal/fat Mach-O binaries: scanFile yields // one build info per architecture and each is recorded under the same location coordinates. diff --git a/syft/pkg/cataloger/golang/symbol_selector.go b/syft/pkg/cataloger/golang/symbol_selector.go new file mode 100644 index 00000000000..63b6831a004 --- /dev/null +++ b/syft/pkg/cataloger/golang/symbol_selector.go @@ -0,0 +1,93 @@ +package golang + +import ( + "slices" + + "github.com/bmatcuk/doublestar/v4" + + "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 include 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, include []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], include) { + 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-include 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 + } + for _, pattern := range s.patterns { + matched, err := doublestar.Match(pattern, modulePath) + if err != nil { + // unreachable: newSymbolSelector rejects (and warns about) patterns that cannot compile + continue + } + if matched { + return true + } + } + return false +} + +// 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 +} diff --git a/syft/pkg/cataloger/golang/symbol_selector_test.go b/syft/pkg/cataloger/golang/symbol_selector_test.go new file mode 100644 index 00000000000..0c1d0368a81 --- /dev/null +++ b/syft/pkg/cataloger/golang/symbol_selector_test.go @@ -0,0 +1,218 @@ +package golang + +import ( + "maps" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/cataloging" +) + +func Test_symbolSelector_selects(t *testing.T) { + tests := []struct { + name string + scope cataloging.SymbolScope + include []string + modulePath string + want bool + }{ + { + name: "none selects nothing", + scope: cataloging.SymbolScopeNone, + modulePath: "github.com/foo/bar", + }, + { + name: "none is inert even with includes", + scope: cataloging.SymbolScopeNone, + include: []string{"github.com/foo/**"}, + modulePath: "github.com/foo/bar", + }, + { + name: "all selects everything", + scope: cataloging.SymbolScopeAll, + modulePath: "github.com/foo/bar", + want: true, + }, + { + name: "stdlib selects no modules", + scope: cataloging.SymbolScopeStdlib, + modulePath: "golang.org/x/crypto", + }, + { + name: "extended-stdlib matches a golang.org/x module", + scope: cataloging.SymbolScopeExtendedStdlib, + modulePath: "golang.org/x/crypto", + want: true, + }, + { + // ** crosses path separators, which is what lets one pattern cover the whole subtree + name: "extended-stdlib matches a nested golang.org/x module", + scope: cataloging.SymbolScopeExtendedStdlib, + modulePath: "golang.org/x/tools/gopls", + want: true, + }, + { + // the pattern must not match on a bare prefix, only at a path boundary + name: "extended-stdlib does not match golang.org/xtra", + scope: cataloging.SymbolScopeExtendedStdlib, + modulePath: "golang.org/xtra", + }, + { + name: "extended-stdlib does not match an unrelated module", + scope: cataloging.SymbolScopeExtendedStdlib, + modulePath: "github.com/klauspost/compress", + }, + { + // a single star does not cross "/", which is why doublestar is needed: go module paths carry + // /v2-style major version suffixes that must not be swept up by a single-segment pattern + name: "single star matches one path segment", + scope: cataloging.SymbolScopeStdlib, + include: []string{"github.com/klauspost/*"}, + modulePath: "github.com/klauspost/compress", + want: true, + }, + { + name: "single star does not cross a separator", + scope: cataloging.SymbolScopeStdlib, + include: []string{"github.com/klauspost/*"}, + modulePath: "github.com/klauspost/compress/v2", + }, + { + name: "doublestar crosses a separator", + scope: cataloging.SymbolScopeStdlib, + include: []string{"github.com/klauspost/**"}, + modulePath: "github.com/klauspost/compress/v2", + want: true, + }, + { + name: "includes widen a preset", + scope: cataloging.SymbolScopeExtendedStdlib, + include: []string{"github.com/klauspost/**"}, + modulePath: "github.com/klauspost/compress", + want: true, + }, + { + name: "includes cannot narrow a preset", + scope: cataloging.SymbolScopeExtendedStdlib, + include: []string{"github.com/klauspost/**"}, + modulePath: "golang.org/x/net", + want: true, + }, + { + name: "exact module path", + scope: cataloging.SymbolScopeStdlib, + include: []string{"google.golang.org/grpc"}, + modulePath: "google.golang.org/grpc", + want: true, + }, + { + name: "empty includes are a no-op", + scope: cataloging.SymbolScopeStdlib, + include: []string{}, + modulePath: "github.com/foo/bar", + }, + { + // all short-circuits before any matching, so an include list cannot subtract from it + name: "includes cannot narrow all", + scope: cataloging.SymbolScopeAll, + include: []string{"github.com/klauspost/**"}, + modulePath: "github.com/foo/bar", + want: true, + }, + { + // selection is a per-module boolean, so a pattern overlapping the preset is idempotent + name: "include overlapping the preset is idempotent", + scope: cataloging.SymbolScopeExtendedStdlib, + include: []string{"golang.org/x/**"}, + modulePath: "golang.org/x/net", + want: true, + }, + { + // library consumers set CaptureSymbols directly without going through PostLoad, so the + // selector normalizes rather than falling through to stdlib-only capture + name: "unnormalized scope is parsed", + scope: "All", + modulePath: "github.com/foo/bar", + want: true, + }, + { + name: "unrecognized scope resolves to none", + scope: "bogus", + include: []string{"github.com/foo/**"}, + modulePath: "github.com/foo/bar", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, newSymbolSelector(tt.scope, tt.include).selects(tt.modulePath)) + }) + } +} + +// a malformed pattern must be dropped at construction, leaving the remaining patterns in force rather +// than aborting the selection or silently matching nothing +func Test_symbolSelector_malformedPattern(t *testing.T) { + s := newSymbolSelector(cataloging.SymbolScopeExtendedStdlib, []string{"[", "github.com/klauspost/**"}) + + require.Equal(t, []string{"golang.org/x/**", "github.com/klauspost/**"}, s.patterns) + assert.True(t, s.selects("golang.org/x/net"), "preset still applies") + assert.True(t, s.selects("github.com/klauspost/compress"), "valid sibling pattern still applies") + assert.False(t, s.selects("github.com/foo"), "malformed pattern selects nothing") +} + +func Test_symbolSelector_filter(t *testing.T) { + symbols := func() map[string]map[string][]string { + return map[string]map[string][]string{ + "golang.org/x/net": {"golang.org/x/net/http2": {"NewClientConn"}}, + "github.com/klauspost/compress": {"github.com/klauspost/compress/zstd": {"NewReader"}}, + } + } + + tests := []struct { + name string + scope cataloging.SymbolScope + include []string + want []string // remaining module paths + }{ + { + name: "none drops everything", + scope: cataloging.SymbolScopeNone, + }, + { + name: "stdlib drops every module", + scope: cataloging.SymbolScopeStdlib, + }, + { + name: "extended-stdlib keeps only golang.org/x", + scope: cataloging.SymbolScopeExtendedStdlib, + want: []string{"golang.org/x/net"}, + }, + { + name: "includes widen the selection", + scope: cataloging.SymbolScopeExtendedStdlib, + include: []string{"github.com/klauspost/**"}, + want: []string{"golang.org/x/net", "github.com/klauspost/compress"}, + }, + { + name: "all keeps everything", + scope: cataloging.SymbolScopeAll, + want: []string{"golang.org/x/net", "github.com/klauspost/compress"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := newSymbolSelector(tt.scope, tt.include).filter(symbols()) + if len(tt.want) == 0 { + // nil, not an empty map, so the omitempty JSON tag keeps the field out of output + assert.Nil(t, got) + return + } + assert.ElementsMatch(t, tt.want, slices.Collect(maps.Keys(got))) + }) + } +} diff --git a/syft/pkg/golang.go b/syft/pkg/golang.go index 8b133a8de72..b13e086a25d 100644 --- a/syft/pkg/golang.go +++ b/syft/pkg/golang.go @@ -30,8 +30,10 @@ type GolangBinaryBuildinfoEntry struct { // name is the import path, a ".", and the local name. One exception: the binary's main package appears // under the key "main" (the name the linker assigns), not its original source import path, which is not // recoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers - // this package: the "all" scope populates every module package plus the synthetic stdlib package, while - // the "stdlib" scope populates only the stdlib package. + // this package: the "all" scope populates every module package plus the synthetic stdlib package, the + // "extended-stdlib" scope populates the stdlib package plus every module under golang.org/x/, and the + // "stdlib" scope populates only the stdlib package. The capture-symbols-include glob patterns populate + // any additional modules they match. Symbols map[string][]string `json:"symbols,omitempty"` } From 448ec176dd4038b4d900a85f3576caf1ef80d9a8 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 6 Aug 2026 12:57:07 -0400 Subject: [PATCH 2/3] 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 --- cmd/syft/internal/options/catalog.go | 2 +- cmd/syft/internal/options/golang.go | 10 ++-- cmd/syft/internal/options/golang_test.go | 16 +++---- internal/capabilities/appconfig.yaml | 2 +- schema/json/schema-16.1.10.json | 2 +- schema/json/schema-latest.json | 2 +- syft/pkg/cataloger/golang/capabilities.yaml | 6 +-- syft/pkg/cataloger/golang/config.go | 10 ++-- syft/pkg/cataloger/golang/parse_go_binary.go | 2 +- .../cataloger/golang/parse_go_binary_test.go | 28 +++++------ syft/pkg/cataloger/golang/symbol_selector.go | 8 ++-- .../cataloger/golang/symbol_selector_test.go | 48 +++++++++---------- syft/pkg/golang.go | 2 +- 13 files changed, 69 insertions(+), 69 deletions(-) diff --git a/cmd/syft/internal/options/catalog.go b/cmd/syft/internal/options/catalog.go index 6acb8e5dcdf..3ff68ecbd57 100644 --- a/cmd/syft/internal/options/catalog.go +++ b/cmd/syft/internal/options/catalog.go @@ -200,7 +200,7 @@ func (cfg Catalog) ToPackagesConfig() pkgcataloging.Config { ). WithUsePackagesLib(*multiLevelOption(true, enrichmentEnabled(cfg.Enrich, task.Go, task.Golang), cfg.Golang.UsePackagesLib)). WithCaptureSymbols(cfg.Golang.CaptureSymbols). - WithCaptureSymbolsInclude(cfg.Golang.CaptureSymbolsInclude), + WithCaptureSymbolsModules(cfg.Golang.CaptureSymbolsModules), JavaScript: javascript.DefaultCatalogerConfig(). WithIncludeDevDependencies(*multiLevelOption(false, cfg.JavaScript.IncludeDevDependencies)). WithSearchRemoteLicenses(*multiLevelOption(false, enrichmentEnabled(cfg.Enrich, task.JavaScript, task.Node, task.NPM), cfg.JavaScript.SearchRemoteLicenses)). diff --git a/cmd/syft/internal/options/golang.go b/cmd/syft/internal/options/golang.go index 7fb52c238ba..73f8818c921 100644 --- a/cmd/syft/internal/options/golang.go +++ b/cmd/syft/internal/options/golang.go @@ -20,7 +20,7 @@ type golangConfig struct { MainModuleVersion golangMainModuleVersionConfig `json:"main-module-version" yaml:"main-module-version" mapstructure:"main-module-version"` UsePackagesLib *bool `json:"use-packages-lib" yaml:"use-packages-lib" mapstructure:"use-packages-lib"` CaptureSymbols cataloging.SymbolScope `json:"capture-symbols" yaml:"capture-symbols" mapstructure:"capture-symbols"` - CaptureSymbolsInclude []string `json:"capture-symbols-include" yaml:"capture-symbols-include" mapstructure:"capture-symbols-include"` + CaptureSymbolsModules []string `json:"capture-symbols-modules" yaml:"capture-symbols-modules" mapstructure:"capture-symbols-modules"` } var _ interface { @@ -49,7 +49,7 @@ a more accurate version from the binary.`) descriptions.Add(&o.CaptureSymbols, `capture function symbols from the binary symbol table (pclntab). valid values are: "none" (disabled), "stdlib" (only the synthetic stdlib package), "extended-stdlib" (stdlib plus every module under golang.org/x/), and "all" (all module packages plus stdlib)`) - descriptions.Add(&o.CaptureSymbolsInclude, `glob patterns matched against go module paths (e.g. github.com/klauspost/**) that should have symbols + descriptions.Add(&o.CaptureSymbolsModules, `glob patterns matched against go module paths (e.g. github.com/klauspost/**) that should have symbols captured in addition to whatever capture-symbols selects. ** crosses path separators, * does not. this can only widen the selection, never narrow it, and is inert when capture-symbols is none`) descriptions.Add(&o.MainModuleVersion.FromLDFlags, `look for LD flags that appear to be setting a version (e.g. -X main.version=1.0.0)`) @@ -73,8 +73,8 @@ func (o *golangConfig) PostLoad() error { // yaml string) into a slice before this point, and there is no CLI flag feeding this key. The one thing // Flatten would add is splitting commas *inside* a list entry, which silently breaks doublestar brace // alternation like github.com/{foo,bar}/**. Viper's split does not trim, so that part is still needed. - for i, pattern := range o.CaptureSymbolsInclude { - o.CaptureSymbolsInclude[i] = strings.TrimSpace(pattern) + for i, pattern := range o.CaptureSymbolsModules { + o.CaptureSymbolsModules[i] = strings.TrimSpace(pattern) } return nil @@ -103,6 +103,6 @@ func defaultGolangConfig() golangConfig { }, UsePackagesLib: nil, // this defaults to true, which is the API default CaptureSymbols: def.CaptureSymbols, - CaptureSymbolsInclude: def.CaptureSymbolsInclude, + CaptureSymbolsModules: def.CaptureSymbolsModules, } } diff --git a/cmd/syft/internal/options/golang_test.go b/cmd/syft/internal/options/golang_test.go index fb694ea550e..b8c1f13709c 100644 --- a/cmd/syft/internal/options/golang_test.go +++ b/cmd/syft/internal/options/golang_test.go @@ -13,7 +13,7 @@ func Test_golangConfig_PostLoad(t *testing.T) { name string cfg golangConfig expected cataloging.SymbolScope - expectedInclude []string + expectedModules []string wantErr assert.ErrorAssertionFunc }{ { @@ -32,25 +32,25 @@ func Test_golangConfig_PostLoad(t *testing.T) { expected: cataloging.SymbolScopeExtendedStdlib, }, { - name: "include patterns keep embedded commas", + name: "module patterns keep embedded commas", cfg: golangConfig{ CaptureSymbols: "stdlib", // brace alternation contains a comma; splitting on it would corrupt the pattern - CaptureSymbolsInclude: []string{"github.com/{foo,bar}/**", "golang.org/x/**"}, + CaptureSymbolsModules: []string{"github.com/{foo,bar}/**", "golang.org/x/**"}, }, expected: cataloging.SymbolScopeStdlib, - expectedInclude: []string{"github.com/{foo,bar}/**", "golang.org/x/**"}, + expectedModules: []string{"github.com/{foo,bar}/**", "golang.org/x/**"}, }, { // viper splits a comma-separated scalar (env var or bare yaml string) but does not trim, // so a leading space would otherwise survive into a pattern that silently matches nothing - name: "include patterns are trimmed", + name: "module patterns are trimmed", cfg: golangConfig{ CaptureSymbols: "stdlib", - CaptureSymbolsInclude: []string{"golang.org/x/**", " github.com/foo/** "}, + CaptureSymbolsModules: []string{"golang.org/x/**", " github.com/foo/** "}, }, expected: cataloging.SymbolScopeStdlib, - expectedInclude: []string{"golang.org/x/**", "github.com/foo/**"}, + expectedModules: []string{"golang.org/x/**", "github.com/foo/**"}, }, { name: "empty defaults to none", @@ -89,7 +89,7 @@ func Test_golangConfig_PostLoad(t *testing.T) { return } assert.Equal(t, tt.expected, tt.cfg.CaptureSymbols) - assert.Equal(t, tt.expectedInclude, tt.cfg.CaptureSymbolsInclude) + assert.Equal(t, tt.expectedModules, tt.cfg.CaptureSymbolsModules) }) } } diff --git a/internal/capabilities/appconfig.yaml b/internal/capabilities/appconfig.yaml index e8b53353061..9c4bc0419e1 100644 --- a/internal/capabilities/appconfig.yaml +++ b/internal/capabilities/appconfig.yaml @@ -16,7 +16,7 @@ application: # AUTO-GENERATED - application-level config keys description: show all packages from the deps.json if bundling tooling is present as a dependency (e.g. ILRepack) - key: golang.capture-symbols description: 'capture function symbols from the binary symbol table (pclntab). valid values are: "none" (disabled), "stdlib" (only the synthetic stdlib package), "extended-stdlib" (stdlib plus every module under golang.org/x/), and "all" (all module packages plus stdlib)' - - key: golang.capture-symbols-include + - key: golang.capture-symbols-modules description: glob patterns matched against go module paths (e.g. github.com/klauspost/**) that should have symbols captured in addition to whatever capture-symbols selects. ** crosses path separators, * does not. this can only widen the selection, never narrow it, and is inert when capture-symbols is none - key: golang.local-mod-cache-dir description: specify an explicit go mod cache directory, if unset this defaults to $GOPATH/pkg/mod or $HOME/go/pkg/mod diff --git a/schema/json/schema-16.1.10.json b/schema/json/schema-16.1.10.json index 009460bdf2f..37a6582388f 100644 --- a/schema/json/schema-16.1.10.json +++ b/schema/json/schema-16.1.10.json @@ -1662,7 +1662,7 @@ "type": "array" }, "type": "object", - "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, the\n\"extended-stdlib\" scope populates the stdlib package plus every module under golang.org/x/, and the\n\"stdlib\" scope populates only the stdlib package. The capture-symbols-include glob patterns populate\nany additional modules they match." + "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, the\n\"extended-stdlib\" scope populates the stdlib package plus every module under golang.org/x/, and the\n\"stdlib\" scope populates only the stdlib package. The capture-symbols-modules glob patterns populate\nany additional modules they match." } }, "type": "object", diff --git a/schema/json/schema-latest.json b/schema/json/schema-latest.json index 009460bdf2f..37a6582388f 100644 --- a/schema/json/schema-latest.json +++ b/schema/json/schema-latest.json @@ -1662,7 +1662,7 @@ "type": "array" }, "type": "object", - "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, the\n\"extended-stdlib\" scope populates the stdlib package plus every module under golang.org/x/, and the\n\"stdlib\" scope populates only the stdlib package. The capture-symbols-include glob patterns populate\nany additional modules they match." + "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, the\n\"extended-stdlib\" scope populates the stdlib package plus every module under golang.org/x/, and the\n\"stdlib\" scope populates only the stdlib package. The capture-symbols-modules glob patterns populate\nany additional modules they match." } }, "type": "object", diff --git a/syft/pkg/cataloger/golang/capabilities.yaml b/syft/pkg/cataloger/golang/capabilities.yaml index 1593f6adbf2..a8543431022 100644 --- a/syft/pkg/cataloger/golang/capabilities.yaml +++ b/syft/pkg/cataloger/golang/capabilities.yaml @@ -27,9 +27,9 @@ configs: # AUTO-GENERATED - config structs and their fields - key: CaptureSymbols description: CaptureSymbols controls extracting function symbols from the binary symbol table (pclntab). Valid values are "none" (disabled), "stdlib" (only the synthetic stdlib package), "extended-stdlib" (stdlib plus every module under golang.org/x/), and "all" (all module packages plus stdlib). app_key: golang.capture-symbols - - key: CaptureSymbolsInclude - description: CaptureSymbolsInclude is a list of glob patterns (doublestar syntax, where ** crosses path separators and * does not) matched against go module paths. Matching modules get symbols in addition to whatever CaptureSymbols selects, so this can only widen the selection and never narrow it. It has no effect under the "none" scope. - app_key: golang.capture-symbols-include + - key: CaptureSymbolsModules + description: CaptureSymbolsModules is a list of glob patterns (doublestar syntax, where ** crosses path separators and * does not) matched against go module paths. Matching modules get symbols in addition to whatever CaptureSymbols selects, so this can only widen the selection and never narrow it. It has no effect under the "none" scope. + app_key: golang.capture-symbols-modules catalogers: - ecosystem: go # MANUAL name: go-module-binary-cataloger # AUTO-GENERATED diff --git a/syft/pkg/cataloger/golang/config.go b/syft/pkg/cataloger/golang/config.go index 9b01eee2224..bb4839d1e37 100644 --- a/syft/pkg/cataloger/golang/config.go +++ b/syft/pkg/cataloger/golang/config.go @@ -56,12 +56,12 @@ type CatalogerConfig struct { // app-config: golang.capture-symbols CaptureSymbols cataloging.SymbolScope `yaml:"capture-symbols" json:"capture-symbols" mapstructure:"capture-symbols"` - // CaptureSymbolsInclude is a list of glob patterns (doublestar syntax, where ** crosses path separators + // CaptureSymbolsModules is a list of glob patterns (doublestar syntax, where ** crosses path separators // and * does not) matched against go module paths. Matching modules get symbols in addition to whatever // CaptureSymbols selects, so this can only widen the selection and never narrow it. It has no effect // under the "none" scope. - // app-config: golang.capture-symbols-include - CaptureSymbolsInclude []string `yaml:"capture-symbols-include,omitempty" json:"capture-symbols-include,omitempty" mapstructure:"capture-symbols-include"` + // app-config: golang.capture-symbols-modules + CaptureSymbolsModules []string `yaml:"capture-symbols-modules,omitempty" json:"capture-symbols-modules,omitempty" mapstructure:"capture-symbols-modules"` // Whether to use the golang.org/x/tools/go/packages, which executes golang tooling found on the path in addition to potential network access UsePackagesLib bool `json:"use-packages-lib" yaml:"use-packages-lib" mapstructure:"use-packages-lib"` @@ -204,8 +204,8 @@ func (g CatalogerConfig) WithCaptureSymbols(input cataloging.SymbolScope) Catalo return g } -func (g CatalogerConfig) WithCaptureSymbolsInclude(input []string) CatalogerConfig { - g.CaptureSymbolsInclude = input +func (g CatalogerConfig) WithCaptureSymbolsModules(input []string) CatalogerConfig { + g.CaptureSymbolsModules = input return g } diff --git a/syft/pkg/cataloger/golang/parse_go_binary.go b/syft/pkg/cataloger/golang/parse_go_binary.go index f5b53409bba..f1a905673f9 100644 --- a/syft/pkg/cataloger/golang/parse_go_binary.go +++ b/syft/pkg/cataloger/golang/parse_go_binary.go @@ -64,7 +64,7 @@ func newGoBinaryCataloger(opts CatalogerConfig) *goBinaryCataloger { return &goBinaryCataloger{ licenseResolver: newGoLicenseResolver(binaryCatalogerName, opts), mainModuleVersion: opts.MainModuleVersion, - symbolSelector: newSymbolSelector(opts.CaptureSymbols, opts.CaptureSymbolsInclude), + symbolSelector: newSymbolSelector(opts.CaptureSymbols, opts.CaptureSymbolsModules), stdlibSymbols: make(map[file.Coordinates]map[string][]string), } } diff --git a/syft/pkg/cataloger/golang/parse_go_binary_test.go b/syft/pkg/cataloger/golang/parse_go_binary_test.go index 4bbc8716f79..14d03e206da 100644 --- a/syft/pkg/cataloger/golang/parse_go_binary_test.go +++ b/syft/pkg/cataloger/golang/parse_go_binary_test.go @@ -1443,7 +1443,7 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) { tests := []struct { name string scope cataloging.SymbolScope - include []string + modules []string extraDeps []*debug.Module symbols []binarySymbol wantMainSyms map[string][]string @@ -1487,9 +1487,9 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) { wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}}, }, { - name: "include patterns widen extended-stdlib", + name: "module patterns widen extended-stdlib", scope: cataloging.SymbolScopeExtendedStdlib, - include: []string{"github.com/klauspost/**"}, + modules: []string{"github.com/klauspost/**"}, extraDeps: extendedDeps, symbols: slices.Concat(populatedSymbols, extendedSymbols), wantExtraSyms: []map[string][]string{ @@ -1499,9 +1499,9 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) { wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}}, }, { - name: "include patterns can select the main module", + name: "module patterns can select the main module", scope: cataloging.SymbolScopeStdlib, - include: []string{"github.com/anchore/**"}, + modules: []string{"github.com/anchore/**"}, extraDeps: extendedDeps, symbols: slices.Concat(populatedSymbols, extendedSymbols), // the main package is keyed by the "main" import path the linker assigns, not its real path @@ -1510,20 +1510,20 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) { wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}}, }, { - name: "include patterns are inert under the none scope", + name: "module patterns are inert under the none scope", scope: cataloging.SymbolScopeNone, - include: []string{"github.com/**", "golang.org/x/**"}, + modules: []string{"github.com/**", "golang.org/x/**"}, extraDeps: extendedDeps, // scanFile never runs under "none", so the build info carries no symbols to begin with symbols: nil, wantExtraSyms: []map[string][]string{nil, nil}, }, { - // all short-circuits ahead of the pattern walk, so a narrow include list cannot subtract + // all short-circuits ahead of the pattern walk, so a narrow module pattern list cannot subtract // from it. this is also the guard on that short-circuit still existing. - name: "include patterns cannot narrow the all scope", + name: "module patterns cannot narrow the all scope", scope: cataloging.SymbolScopeAll, - include: []string{"golang.org/x/**"}, + modules: []string{"golang.org/x/**"}, extraDeps: extendedDeps, symbols: slices.Concat(populatedSymbols, extendedSymbols), wantMainSyms: map[string][]string{"main": {"main"}}, @@ -1538,11 +1538,11 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) { wantStdlibSyms: map[string][]string{"net/http": {"(*Client).Do"}}, }, { - // an include that restates what the preset already covers must not duplicate or drop anything: + // a module pattern that restates what the preset already covers must not duplicate or drop anything: // selection is a per-module boolean, so overlap is idempotent - name: "include overlapping the preset changes nothing", + name: "a module pattern overlapping the preset changes nothing", scope: cataloging.SymbolScopeExtendedStdlib, - include: []string{"golang.org/x/**"}, + modules: []string{"golang.org/x/**"}, extraDeps: extendedDeps, symbols: slices.Concat(populatedSymbols, extendedSymbols), wantExtraSyms: []map[string][]string{ @@ -1565,7 +1565,7 @@ func Test_buildGoPkgInfo_symbolScope(t *testing.T) { symbols: tt.symbols, } - c := newGoBinaryCataloger(CatalogerConfig{CaptureSymbols: tt.scope, CaptureSymbolsInclude: tt.include}) + c := newGoBinaryCataloger(CatalogerConfig{CaptureSymbols: tt.scope, CaptureSymbolsModules: tt.modules}) reader, err := unionreader.GetUnionReader(io.NopCloser(strings.NewReader(""))) require.NoError(t, err) diff --git a/syft/pkg/cataloger/golang/symbol_selector.go b/syft/pkg/cataloger/golang/symbol_selector.go index 63b6831a004..ff2c4542d06 100644 --- a/syft/pkg/cataloger/golang/symbol_selector.go +++ b/syft/pkg/cataloger/golang/symbol_selector.go @@ -17,26 +17,26 @@ var scopePatterns = map[cataloging.SymbolScope][]string{ } // symbolSelector decides which go module paths get function symbols attached to their metadata. The scope -// preset and any user-supplied include patterns are compiled into a single glob list so there is exactly +// 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, include []string) symbolSelector { +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], include) { + 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-include pattern") + log.WithFields("pattern", pattern).Warn("ignoring malformed golang capture-symbols-modules pattern") continue } patterns = append(patterns, pattern) diff --git a/syft/pkg/cataloger/golang/symbol_selector_test.go b/syft/pkg/cataloger/golang/symbol_selector_test.go index 0c1d0368a81..d1c55fc1636 100644 --- a/syft/pkg/cataloger/golang/symbol_selector_test.go +++ b/syft/pkg/cataloger/golang/symbol_selector_test.go @@ -15,7 +15,7 @@ func Test_symbolSelector_selects(t *testing.T) { tests := []struct { name string scope cataloging.SymbolScope - include []string + modules []string modulePath string want bool }{ @@ -25,9 +25,9 @@ func Test_symbolSelector_selects(t *testing.T) { modulePath: "github.com/foo/bar", }, { - name: "none is inert even with includes", + name: "none is inert even with module patterns", scope: cataloging.SymbolScopeNone, - include: []string{"github.com/foo/**"}, + modules: []string{"github.com/foo/**"}, modulePath: "github.com/foo/bar", }, { @@ -70,63 +70,63 @@ func Test_symbolSelector_selects(t *testing.T) { // /v2-style major version suffixes that must not be swept up by a single-segment pattern name: "single star matches one path segment", scope: cataloging.SymbolScopeStdlib, - include: []string{"github.com/klauspost/*"}, + modules: []string{"github.com/klauspost/*"}, modulePath: "github.com/klauspost/compress", want: true, }, { name: "single star does not cross a separator", scope: cataloging.SymbolScopeStdlib, - include: []string{"github.com/klauspost/*"}, + modules: []string{"github.com/klauspost/*"}, modulePath: "github.com/klauspost/compress/v2", }, { name: "doublestar crosses a separator", scope: cataloging.SymbolScopeStdlib, - include: []string{"github.com/klauspost/**"}, + modules: []string{"github.com/klauspost/**"}, modulePath: "github.com/klauspost/compress/v2", want: true, }, { - name: "includes widen a preset", + name: "module patterns widen a preset", scope: cataloging.SymbolScopeExtendedStdlib, - include: []string{"github.com/klauspost/**"}, + modules: []string{"github.com/klauspost/**"}, modulePath: "github.com/klauspost/compress", want: true, }, { - name: "includes cannot narrow a preset", + name: "module patterns cannot narrow a preset", scope: cataloging.SymbolScopeExtendedStdlib, - include: []string{"github.com/klauspost/**"}, + modules: []string{"github.com/klauspost/**"}, modulePath: "golang.org/x/net", want: true, }, { name: "exact module path", scope: cataloging.SymbolScopeStdlib, - include: []string{"google.golang.org/grpc"}, + modules: []string{"google.golang.org/grpc"}, modulePath: "google.golang.org/grpc", want: true, }, { - name: "empty includes are a no-op", + name: "an empty module pattern list is a no-op", scope: cataloging.SymbolScopeStdlib, - include: []string{}, + modules: []string{}, modulePath: "github.com/foo/bar", }, { - // all short-circuits before any matching, so an include list cannot subtract from it - name: "includes cannot narrow all", + // all short-circuits before any matching, so a module pattern list cannot subtract from it + name: "module patterns cannot narrow all", scope: cataloging.SymbolScopeAll, - include: []string{"github.com/klauspost/**"}, + modules: []string{"github.com/klauspost/**"}, modulePath: "github.com/foo/bar", want: true, }, { // selection is a per-module boolean, so a pattern overlapping the preset is idempotent - name: "include overlapping the preset is idempotent", + name: "a module pattern overlapping the preset is idempotent", scope: cataloging.SymbolScopeExtendedStdlib, - include: []string{"golang.org/x/**"}, + modules: []string{"golang.org/x/**"}, modulePath: "golang.org/x/net", want: true, }, @@ -141,14 +141,14 @@ func Test_symbolSelector_selects(t *testing.T) { { name: "unrecognized scope resolves to none", scope: "bogus", - include: []string{"github.com/foo/**"}, + modules: []string{"github.com/foo/**"}, modulePath: "github.com/foo/bar", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, newSymbolSelector(tt.scope, tt.include).selects(tt.modulePath)) + assert.Equal(t, tt.want, newSymbolSelector(tt.scope, tt.modules).selects(tt.modulePath)) }) } } @@ -175,7 +175,7 @@ func Test_symbolSelector_filter(t *testing.T) { tests := []struct { name string scope cataloging.SymbolScope - include []string + modules []string want []string // remaining module paths }{ { @@ -192,9 +192,9 @@ func Test_symbolSelector_filter(t *testing.T) { want: []string{"golang.org/x/net"}, }, { - name: "includes widen the selection", + name: "module patterns widen the selection", scope: cataloging.SymbolScopeExtendedStdlib, - include: []string{"github.com/klauspost/**"}, + modules: []string{"github.com/klauspost/**"}, want: []string{"golang.org/x/net", "github.com/klauspost/compress"}, }, { @@ -206,7 +206,7 @@ func Test_symbolSelector_filter(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := newSymbolSelector(tt.scope, tt.include).filter(symbols()) + got := newSymbolSelector(tt.scope, tt.modules).filter(symbols()) if len(tt.want) == 0 { // nil, not an empty map, so the omitempty JSON tag keeps the field out of output assert.Nil(t, got) diff --git a/syft/pkg/golang.go b/syft/pkg/golang.go index b13e086a25d..60f374d6f75 100644 --- a/syft/pkg/golang.go +++ b/syft/pkg/golang.go @@ -32,7 +32,7 @@ type GolangBinaryBuildinfoEntry struct { // recoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers // this package: the "all" scope populates every module package plus the synthetic stdlib package, the // "extended-stdlib" scope populates the stdlib package plus every module under golang.org/x/, and the - // "stdlib" scope populates only the stdlib package. The capture-symbols-include glob patterns populate + // "stdlib" scope populates only the stdlib package. The capture-symbols-modules glob patterns populate // any additional modules they match. Symbols map[string][]string `json:"symbols,omitempty"` } From 2ac071f77fe645a2c93d42f42b271a18d7a5375c Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 6 Aug 2026 14:10:12 -0400 Subject: [PATCH 3/3] 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 --- cmd/syft/internal/options/golang.go | 2 + internal/capabilities/appconfig.yaml | 2 +- syft/pkg/cataloger/golang/capabilities.yaml | 2 +- syft/pkg/cataloger/golang/config.go | 4 +- syft/pkg/cataloger/golang/symbol_selector.go | 30 ++++++-- .../cataloger/golang/symbol_selector_test.go | 68 ++++++++++++++++++- 6 files changed, 97 insertions(+), 11 deletions(-) diff --git a/cmd/syft/internal/options/golang.go b/cmd/syft/internal/options/golang.go index 73f8818c921..e8147a06d93 100644 --- a/cmd/syft/internal/options/golang.go +++ b/cmd/syft/internal/options/golang.go @@ -51,6 +51,8 @@ a more accurate version from the binary.`) module under golang.org/x/), and "all" (all module packages plus stdlib)`) descriptions.Add(&o.CaptureSymbolsModules, `glob patterns matched against go module paths (e.g. github.com/klauspost/**) that should have symbols captured in addition to whatever capture-symbols selects. ** crosses path separators, * does not. +a trailing major version suffix is ignored when matching, so github.com/foo/* covers github.com/foo/bar/v2; +spelling a suffix out in the pattern selects only that major version. this can only widen the selection, never narrow it, and is inert when capture-symbols is none`) descriptions.Add(&o.MainModuleVersion.FromLDFlags, `look for LD flags that appear to be setting a version (e.g. -X main.version=1.0.0)`) descriptions.Add(&o.MainModuleVersion.FromBuildSettings, `use the build settings (e.g. vcs.version & vcs.time) to craft a v0 pseudo version diff --git a/internal/capabilities/appconfig.yaml b/internal/capabilities/appconfig.yaml index 9c4bc0419e1..e15fdde0f10 100644 --- a/internal/capabilities/appconfig.yaml +++ b/internal/capabilities/appconfig.yaml @@ -17,7 +17,7 @@ application: # AUTO-GENERATED - application-level config keys - key: golang.capture-symbols description: 'capture function symbols from the binary symbol table (pclntab). valid values are: "none" (disabled), "stdlib" (only the synthetic stdlib package), "extended-stdlib" (stdlib plus every module under golang.org/x/), and "all" (all module packages plus stdlib)' - key: golang.capture-symbols-modules - description: glob patterns matched against go module paths (e.g. github.com/klauspost/**) that should have symbols captured in addition to whatever capture-symbols selects. ** crosses path separators, * does not. this can only widen the selection, never narrow it, and is inert when capture-symbols is none + description: glob patterns matched against go module paths (e.g. github.com/klauspost/**) that should have symbols captured in addition to whatever capture-symbols selects. ** crosses path separators, * does not. a trailing major version suffix is ignored when matching, so github.com/foo/* covers github.com/foo/bar/v2; spelling a suffix out in the pattern selects only that major version. this can only widen the selection, never narrow it, and is inert when capture-symbols is none - key: golang.local-mod-cache-dir description: specify an explicit go mod cache directory, if unset this defaults to $GOPATH/pkg/mod or $HOME/go/pkg/mod - key: golang.local-vendor-dir diff --git a/syft/pkg/cataloger/golang/capabilities.yaml b/syft/pkg/cataloger/golang/capabilities.yaml index a8543431022..c9c314b191b 100644 --- a/syft/pkg/cataloger/golang/capabilities.yaml +++ b/syft/pkg/cataloger/golang/capabilities.yaml @@ -28,7 +28,7 @@ configs: # AUTO-GENERATED - config structs and their fields description: CaptureSymbols controls extracting function symbols from the binary symbol table (pclntab). Valid values are "none" (disabled), "stdlib" (only the synthetic stdlib package), "extended-stdlib" (stdlib plus every module under golang.org/x/), and "all" (all module packages plus stdlib). app_key: golang.capture-symbols - key: CaptureSymbolsModules - description: CaptureSymbolsModules is a list of glob patterns (doublestar syntax, where ** crosses path separators and * does not) matched against go module paths. Matching modules get symbols in addition to whatever CaptureSymbols selects, so this can only widen the selection and never narrow it. It has no effect under the "none" scope. + description: 'CaptureSymbolsModules is a list of glob patterns (doublestar syntax, where ** crosses path separators and * does not) matched against go module paths. Matching modules get symbols in addition to whatever CaptureSymbols selects, so this can only widen the selection and never narrow it. It has no effect under the "none" scope. A trailing major version suffix is not part of a module''s identity, so a pattern matches with or without it: both "github.com/foo/bar" and "github.com/foo/*" select "github.com/foo/bar/v2". A pattern that spells out a suffix selects only that major version.' app_key: golang.capture-symbols-modules catalogers: - ecosystem: go # MANUAL diff --git a/syft/pkg/cataloger/golang/config.go b/syft/pkg/cataloger/golang/config.go index bb4839d1e37..3d69bd8d7b0 100644 --- a/syft/pkg/cataloger/golang/config.go +++ b/syft/pkg/cataloger/golang/config.go @@ -59,7 +59,9 @@ type CatalogerConfig struct { // CaptureSymbolsModules is a list of glob patterns (doublestar syntax, where ** crosses path separators // and * does not) matched against go module paths. Matching modules get symbols in addition to whatever // CaptureSymbols selects, so this can only widen the selection and never narrow it. It has no effect - // under the "none" scope. + // under the "none" scope. A trailing major version suffix is not part of a module's identity, so a + // pattern matches with or without it: both "github.com/foo/bar" and "github.com/foo/*" select + // "github.com/foo/bar/v2". A pattern that spells out a suffix selects only that major version. // app-config: golang.capture-symbols-modules CaptureSymbolsModules []string `yaml:"capture-symbols-modules,omitempty" json:"capture-symbols-modules,omitempty" mapstructure:"capture-symbols-modules"` diff --git a/syft/pkg/cataloger/golang/symbol_selector.go b/syft/pkg/cataloger/golang/symbol_selector.go index ff2c4542d06..59333a9c53f 100644 --- a/syft/pkg/cataloger/golang/symbol_selector.go +++ b/syft/pkg/cataloger/golang/symbol_selector.go @@ -4,6 +4,7 @@ import ( "slices" "github.com/bmatcuk/doublestar/v4" + "golang.org/x/mod/module" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/cataloging" @@ -58,19 +59,38 @@ func (s symbolSelector) selects(modulePath string) bool { 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 { - matched, err := doublestar.Match(pattern, modulePath) - if err != nil { - // unreachable: newSymbolSelector rejects (and warns about) patterns that cannot compile - continue + if globMatches(pattern, modulePath) { + return true } - if matched { + 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). diff --git a/syft/pkg/cataloger/golang/symbol_selector_test.go b/syft/pkg/cataloger/golang/symbol_selector_test.go index d1c55fc1636..b9f992465a6 100644 --- a/syft/pkg/cataloger/golang/symbol_selector_test.go +++ b/syft/pkg/cataloger/golang/symbol_selector_test.go @@ -66,8 +66,6 @@ func Test_symbolSelector_selects(t *testing.T) { modulePath: "github.com/klauspost/compress", }, { - // a single star does not cross "/", which is why doublestar is needed: go module paths carry - // /v2-style major version suffixes that must not be swept up by a single-segment pattern name: "single star matches one path segment", scope: cataloging.SymbolScopeStdlib, modules: []string{"github.com/klauspost/*"}, @@ -75,10 +73,35 @@ func Test_symbolSelector_selects(t *testing.T) { want: true, }, { - name: "single star does not cross a separator", + // a single star does not cross "/", but a major version suffix is not a path segment for this + // purpose: the module is matched with the suffix stripped as well, so a config written before a + // major bump keeps covering the module after it + name: "single star reaches across a major version suffix", scope: cataloging.SymbolScopeStdlib, modules: []string{"github.com/klauspost/*"}, modulePath: "github.com/klauspost/compress/v2", + want: true, + }, + { + name: "single star does not cross a separator that is not a version suffix", + scope: cataloging.SymbolScopeStdlib, + modules: []string{"github.com/klauspost/*"}, + modulePath: "github.com/klauspost/compress/internal/thing", + }, + { + // only a trailing suffix is a version. here "v2" is an ordinary path element naming the major + // subdirectory a nested module lives in, so it is matched literally and ** is the way to reach it + name: "a mid-path version-like element is an ordinary segment", + scope: cataloging.SymbolScopeStdlib, + modules: []string{"github.com/anchore/*/thing"}, + modulePath: "github.com/anchore/syft/v2/thing", + }, + { + name: "doublestar reaches a mid-path version-like element", + scope: cataloging.SymbolScopeStdlib, + modules: []string{"github.com/anchore/**/thing"}, + modulePath: "github.com/anchore/syft/v2/thing", + want: true, }, { name: "doublestar crosses a separator", @@ -87,6 +110,45 @@ func Test_symbolSelector_selects(t *testing.T) { modulePath: "github.com/klauspost/compress/v2", want: true, }, + { + name: "an exact path covers every major version of that module", + scope: cataloging.SymbolScopeStdlib, + modules: []string{"github.com/klauspost/compress"}, + modulePath: "github.com/klauspost/compress/v2", + want: true, + }, + { + // spelling the suffix out is how a single major version is targeted: v1's path carries no suffix, + // so there is nothing for a suffixed pattern to match + name: "a pattern naming a major version selects only that one", + scope: cataloging.SymbolScopeStdlib, + modules: []string{"github.com/klauspost/compress/v2"}, + modulePath: "github.com/klauspost/compress", + }, + { + name: "a pattern naming a major version selects it", + scope: cataloging.SymbolScopeStdlib, + modules: []string{"github.com/klauspost/compress/v2"}, + modulePath: "github.com/klauspost/compress/v2", + want: true, + }, + { + // gopkg.in spells the major version as a .vN suffix on the last element, which SplitPathVersion + // understands, so it strips the same way + name: "gopkg.in style suffixes strip too", + scope: cataloging.SymbolScopeStdlib, + modules: []string{"gopkg.in/yaml"}, + modulePath: "gopkg.in/yaml.v2", + want: true, + }, + { + // /v1 and /v0 are not valid major version suffixes, so this is not a versioned path at all and + // nothing is stripped from it + name: "a v1 element is not a version suffix", + scope: cataloging.SymbolScopeStdlib, + modules: []string{"github.com/klauspost/compress"}, + modulePath: "github.com/klauspost/compress/v1", + }, { name: "module patterns widen a preset", scope: cataloging.SymbolScopeExtendedStdlib,