mirror of
https://github.com/anchore/syft.git
synced 2026-08-19 16:48:27 +02:00
feat: optionally capture golang binary symbols (#4988)
--------- Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
parent
3252b6f806
commit
1dcac54b0e
@ -198,7 +198,8 @@ func (cfg Catalog) ToPackagesConfig() pkgcataloging.Config {
|
||||
WithFromBuildSettings(cfg.Golang.MainModuleVersion.FromBuildSettings).
|
||||
WithFromLDFlags(cfg.Golang.MainModuleVersion.FromLDFlags),
|
||||
).
|
||||
WithUsePackagesLib(*multiLevelOption(true, enrichmentEnabled(cfg.Enrich, task.Go, task.Golang), cfg.Golang.UsePackagesLib)),
|
||||
WithUsePackagesLib(*multiLevelOption(true, enrichmentEnabled(cfg.Enrich, task.Go, task.Golang), cfg.Golang.UsePackagesLib)).
|
||||
WithCaptureSymbols(cfg.Golang.CaptureSymbols),
|
||||
JavaScript: javascript.DefaultCatalogerConfig().
|
||||
WithIncludeDevDependencies(*multiLevelOption(false, cfg.JavaScript.IncludeDevDependencies)).
|
||||
WithSearchRemoteLicenses(*multiLevelOption(false, enrichmentEnabled(cfg.Enrich, task.JavaScript, task.Node, task.NPM), cfg.JavaScript.SearchRemoteLicenses)).
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/golang"
|
||||
)
|
||||
|
||||
@ -17,10 +18,12 @@ type golangConfig struct {
|
||||
NoProxy string `json:"no-proxy" yaml:"no-proxy" mapstructure:"no-proxy"`
|
||||
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"`
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
clio.FieldDescriber
|
||||
clio.PostLoader
|
||||
} = (*golangConfig)(nil)
|
||||
|
||||
func (o *golangConfig) DescribeFields(descriptions clio.FieldDescriptionSet) {
|
||||
@ -39,12 +42,19 @@ 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`)
|
||||
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)`)
|
||||
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`)
|
||||
descriptions.Add(&o.MainModuleVersion.FromContents, `search for semver-like strings in the binary contents`)
|
||||
}
|
||||
|
||||
func (o *golangConfig) PostLoad() error {
|
||||
o.CaptureSymbols = o.CaptureSymbols.Parse()
|
||||
return nil
|
||||
}
|
||||
|
||||
type golangMainModuleVersionConfig struct {
|
||||
FromLDFlags bool `json:"from-ld-flags" yaml:"from-ld-flags" mapstructure:"from-ld-flags"`
|
||||
FromContents bool `json:"from-contents" yaml:"from-contents" mapstructure:"from-contents"`
|
||||
@ -67,5 +77,6 @@ func defaultGolangConfig() golangConfig {
|
||||
FromBuildSettings: def.MainModuleVersion.FromBuildSettings,
|
||||
},
|
||||
UsePackagesLib: nil, // this defaults to true, which is the API default
|
||||
CaptureSymbols: def.CaptureSymbols,
|
||||
}
|
||||
}
|
||||
|
||||
57
cmd/syft/internal/options/golang_test.go
Normal file
57
cmd/syft/internal/options/golang_test.go
Normal file
@ -0,0 +1,57 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
)
|
||||
|
||||
func Test_golangConfig_PostLoad(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg golangConfig
|
||||
expected cataloging.SymbolScope
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "normalize all",
|
||||
cfg: golangConfig{CaptureSymbols: "all"},
|
||||
expected: cataloging.SymbolScopeAll,
|
||||
},
|
||||
{
|
||||
name: "normalize stdlib",
|
||||
cfg: golangConfig{CaptureSymbols: "stdlib"},
|
||||
expected: cataloging.SymbolScopeStdlib,
|
||||
},
|
||||
{
|
||||
name: "empty defaults to none",
|
||||
cfg: golangConfig{CaptureSymbols: ""},
|
||||
expected: cataloging.SymbolScopeNone,
|
||||
},
|
||||
{
|
||||
name: "invalid value defaults to none",
|
||||
cfg: golangConfig{CaptureSymbols: "bogus"},
|
||||
expected: cataloging.SymbolScopeNone,
|
||||
},
|
||||
{
|
||||
name: "boolean spellings default to none",
|
||||
cfg: golangConfig{CaptureSymbols: "true"},
|
||||
expected: cataloging.SymbolScopeNone,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.wantErr == nil {
|
||||
tt.wantErr = assert.NoError
|
||||
}
|
||||
err := tt.cfg.PostLoad()
|
||||
tt.wantErr(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tt.expected, tt.cfg.CaptureSymbols)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,8 @@ application: # AUTO-GENERATED - application-level config keys
|
||||
description: treat DLL claims or on-disk evidence for child packages as DLL claims or on-disk evidence for any parent package
|
||||
- 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)'
|
||||
- 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
|
||||
|
||||
@ -3,7 +3,7 @@ package internal
|
||||
const (
|
||||
// JSONSchemaVersion is the current schema version output by the JSON encoder
|
||||
// This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment.
|
||||
JSONSchemaVersion = "16.1.8"
|
||||
JSONSchemaVersion = "16.1.9"
|
||||
|
||||
// Changelog
|
||||
// 16.1.0 - reformulated the python pdm fields (added "URL" and removed the unused "path" field).
|
||||
@ -15,4 +15,5 @@ const (
|
||||
// 16.1.6 - add Dependencies to ElixirMixLockEntry metadata
|
||||
// 16.1.7 - add AppleAppBundleEntry metadata type for the apple app bundle cataloger
|
||||
// 16.1.8 - add VcpkgManifest metadata type for vcpkg manifest support
|
||||
// 16.1.9 - add Symbols to GolangBinaryBuildinfoEntry metadata
|
||||
)
|
||||
|
||||
4520
schema/json/schema-16.1.9.json
Normal file
4520
schema/json/schema-16.1.9.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "anchore.io/schema/syft/json/16.1.8/document",
|
||||
"$id": "anchore.io/schema/syft/json/16.1.9/document",
|
||||
"$ref": "#/$defs/Document",
|
||||
"$defs": {
|
||||
"AlpmDbEntry": {
|
||||
@ -1653,6 +1653,13 @@
|
||||
},
|
||||
"type": "array",
|
||||
"description": "GoExperiments lists experimental Go features enabled during compilation (e.g., \"arenas\", \"cgocheck2\")."
|
||||
},
|
||||
"symbols": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"description": "Symbols are the fully qualified function symbols from this module that are compiled into the binary\n(e.g., \"github.com/foo/bar.(*Type).Method\"), extracted from the binary symbol table (pclntab).\nPopulated only when the golang cataloger's capture-symbols scope covers this package: the \"all\" scope\npopulates every module package plus the synthetic stdlib package, while the \"stdlib\" scope populates\nonly the stdlib package."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@ -4394,39 +4401,47 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
"type": "array",
|
||||
"description": "Description is the human-readable description of the package (may be a single string or a list of paragraphs in the manifest)."
|
||||
},
|
||||
"documentation": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "Documentation is the URL to the package's documentation."
|
||||
},
|
||||
"full-version": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "FullVersion is the complete version string including the port-version suffix (e.g. \"1.2.3#2\")."
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "Version is the upstream package version without the port-version suffix (e.g. \"1.2.3\")."
|
||||
},
|
||||
"port-version": {
|
||||
"type": "integer"
|
||||
"type": "integer",
|
||||
"description": "PortVersion is the vcpkg-specific packaging revision for a given upstream version."
|
||||
},
|
||||
"maintainers": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
"type": "array",
|
||||
"description": "Maintainers are the people responsible for maintaining the vcpkg port."
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "Name is the package name as declared in the manifest."
|
||||
},
|
||||
"supports": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "Supports is the platform expression describing which triplets the package can be built for (e.g. \"!windows\")."
|
||||
},
|
||||
"registry": {
|
||||
"$ref": "#/$defs/VcpkgRegistryEntry",
|
||||
"description": "to show where it came from"
|
||||
"description": "Registry indicates where the package definition came from."
|
||||
},
|
||||
"triplet": {
|
||||
"type": "string",
|
||||
"description": "found by looking at build folder to find target. ex. \"x64-linux\""
|
||||
"description": "Triplet is the build target discovered from the build folder (e.g. \"x64-linux\")."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@ -4436,7 +4451,7 @@
|
||||
"port-version",
|
||||
"name"
|
||||
],
|
||||
"description": "used for metadata."
|
||||
"description": "VcpkgManifest summarizes the data found in a vcpkg manifest (vcpkg.json) relevant to a single vcpkg package."
|
||||
},
|
||||
"VcpkgRegistryEntry": {
|
||||
"properties": {
|
||||
|
||||
28
syft/cataloging/symbols.go
Normal file
28
syft/cataloging/symbols.go
Normal file
@ -0,0 +1,28 @@
|
||||
package cataloging
|
||||
|
||||
import "strings"
|
||||
|
||||
// SymbolScope controls which packages get function symbols (from a binary's symbol table) attached to their metadata.
|
||||
type SymbolScope string
|
||||
|
||||
const (
|
||||
// SymbolScopeNone disables symbol capture entirely.
|
||||
SymbolScopeNone SymbolScope = "none"
|
||||
|
||||
// SymbolScopeStdlib captures symbols only for the synthetic "stdlib" package, leaving module packages without symbols.
|
||||
SymbolScopeStdlib SymbolScope = "stdlib"
|
||||
|
||||
// SymbolScopeAll captures symbols for all module packages as well as the synthetic "stdlib" package.
|
||||
SymbolScopeAll SymbolScope = "all"
|
||||
)
|
||||
|
||||
// Parse normalizes a SymbolScope, treating empty (unset) and unrecognized values as SymbolScopeNone.
|
||||
func (s SymbolScope) Parse() SymbolScope {
|
||||
switch strings.ToLower(strings.TrimSpace(string(s))) {
|
||||
case string(SymbolScopeAll):
|
||||
return SymbolScopeAll
|
||||
case string(SymbolScopeStdlib):
|
||||
return SymbolScopeStdlib
|
||||
}
|
||||
return SymbolScopeNone
|
||||
}
|
||||
30
syft/cataloging/symbols_test.go
Normal file
30
syft/cataloging/symbols_test.go
Normal file
@ -0,0 +1,30 @@
|
||||
package cataloging
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_SymbolScope_Parse(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected SymbolScope
|
||||
}{
|
||||
{"all", SymbolScopeAll},
|
||||
{"ALL", SymbolScopeAll},
|
||||
{" all ", SymbolScopeAll},
|
||||
{"stdlib", SymbolScopeStdlib},
|
||||
{"Stdlib", SymbolScopeStdlib},
|
||||
{"none", SymbolScopeNone},
|
||||
{"", SymbolScopeNone},
|
||||
{"true", SymbolScopeNone},
|
||||
{"false", SymbolScopeNone},
|
||||
{"bogus", SymbolScopeNone},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, SymbolScope(test.input).Parse())
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,9 @@ configs: # AUTO-GENERATED - config structs and their fields
|
||||
- key: NoProxy
|
||||
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).
|
||||
app_key: golang.capture-symbols
|
||||
catalogers:
|
||||
- ecosystem: go # MANUAL
|
||||
name: go-module-binary-cataloger # AUTO-GENERATED
|
||||
|
||||
@ -26,10 +26,11 @@ func NewGoModuleFileCataloger(opts CatalogerConfig) pkg.Cataloger {
|
||||
|
||||
// NewGoModuleBinaryCataloger returns a new cataloger object that searches within binaries built by the go compiler.
|
||||
func NewGoModuleBinaryCataloger(opts CatalogerConfig) pkg.Cataloger {
|
||||
c := newGoBinaryCataloger(opts)
|
||||
return generic.NewCataloger(binaryCatalogerName).
|
||||
WithParserByMimeTypes(
|
||||
newGoBinaryCataloger(opts).parseGoBinary,
|
||||
c.parseGoBinary,
|
||||
mimetype.ExecutableMIMETypeSet.List()...,
|
||||
).
|
||||
WithResolvingProcessors(stdlibProcessor)
|
||||
WithResolvingProcessors(c.stdlibProcessor)
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/anchore/go-homedir"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -49,6 +50,11 @@ 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).
|
||||
// app-config: golang.capture-symbols
|
||||
CaptureSymbols cataloging.SymbolScope `yaml:"capture-symbols" json:"capture-symbols" mapstructure:"capture-symbols"`
|
||||
|
||||
// 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"`
|
||||
}
|
||||
@ -76,6 +82,7 @@ func DefaultCatalogerConfig() CatalogerConfig {
|
||||
UsePackagesLib: true,
|
||||
MainModuleVersion: DefaultMainModuleVersionConfig(),
|
||||
LocalModCacheDir: defaultGoModDir(),
|
||||
CaptureSymbols: cataloging.SymbolScopeNone,
|
||||
}
|
||||
|
||||
// first process the proxy settings
|
||||
@ -184,6 +191,11 @@ func (g CatalogerConfig) WithMainModuleVersion(input MainModuleVersionConfig) Ca
|
||||
return g
|
||||
}
|
||||
|
||||
func (g CatalogerConfig) WithCaptureSymbols(input cataloging.SymbolScope) CatalogerConfig {
|
||||
g.CaptureSymbols = input
|
||||
return g
|
||||
}
|
||||
|
||||
func (g CatalogerConfig) WithUsePackagesLib(useLib bool) CatalogerConfig {
|
||||
g.UsePackagesLib = useLib
|
||||
return g
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/anchore/go-homedir"
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
)
|
||||
|
||||
func Test_Config(t *testing.T) {
|
||||
@ -58,6 +59,7 @@ func Test_Config(t *testing.T) {
|
||||
NoProxy: []string{"my.private", "no.proxy"},
|
||||
MainModuleVersion: DefaultMainModuleVersionConfig(),
|
||||
UsePackagesLib: true,
|
||||
CaptureSymbols: cataloging.SymbolScopeNone,
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -86,6 +88,7 @@ func Test_Config(t *testing.T) {
|
||||
NoProxy: []string{"alt.no.proxy"},
|
||||
MainModuleVersion: DefaultMainModuleVersionConfig(),
|
||||
UsePackagesLib: true,
|
||||
CaptureSymbols: cataloging.SymbolScopeNone,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -45,7 +45,7 @@ func (c *goBinaryCataloger) newGoBinaryPackage(dep *debug.Module, m pkg.GolangBi
|
||||
return p
|
||||
}
|
||||
|
||||
func newBinaryMetadata(dep *debug.Module, mainModule, goVersion, architecture string, buildSettings pkg.KeyValues, cryptoSettings, experiments []string) pkg.GolangBinaryBuildinfoEntry {
|
||||
func newBinaryMetadata(dep *debug.Module, mainModule, goVersion, architecture string, buildSettings pkg.KeyValues, cryptoSettings, experiments, symbols []string) pkg.GolangBinaryBuildinfoEntry {
|
||||
if dep.Replace != nil {
|
||||
dep = dep.Replace
|
||||
}
|
||||
@ -58,6 +58,7 @@ func newBinaryMetadata(dep *debug.Module, mainModule, goVersion, architecture st
|
||||
MainModule: mainModule,
|
||||
GoCryptoSettings: cryptoSettings,
|
||||
GoExperiments: experiments,
|
||||
Symbols: symbols,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/mod/module"
|
||||
@ -20,6 +21,7 @@ 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"
|
||||
@ -49,15 +51,45 @@ const devel = "(devel)"
|
||||
type goBinaryCataloger struct {
|
||||
licenseResolver goLicenseResolver
|
||||
mainModuleVersion MainModuleVersionConfig
|
||||
symbolScope cataloging.SymbolScope
|
||||
|
||||
// stdlibSymbols holds the standard-library function symbols discovered per binary (keyed by the
|
||||
// binary's location), populated during parsing and consumed by stdlibProcessor when it builds the
|
||||
// synthetic "stdlib" package. Guarded by stdlibSymbolsMu because parsers run concurrently.
|
||||
stdlibSymbols map[file.Coordinates][]string
|
||||
stdlibSymbolsMu sync.Mutex
|
||||
}
|
||||
|
||||
func newGoBinaryCataloger(opts CatalogerConfig) *goBinaryCataloger {
|
||||
return &goBinaryCataloger{
|
||||
licenseResolver: newGoLicenseResolver(binaryCatalogerName, opts),
|
||||
mainModuleVersion: opts.MainModuleVersion,
|
||||
symbolScope: opts.CaptureSymbols,
|
||||
stdlibSymbols: make(map[file.Coordinates][]string),
|
||||
}
|
||||
}
|
||||
|
||||
// recordStdlibSymbols merges the standard-library symbols discovered for a binary location so the
|
||||
// stdlib processor can attach them to the synthetic stdlib package.
|
||||
func (c *goBinaryCataloger) recordStdlibSymbols(coord file.Coordinates, symbols []string) {
|
||||
if len(symbols) == 0 {
|
||||
return
|
||||
}
|
||||
c.stdlibSymbolsMu.Lock()
|
||||
defer c.stdlibSymbolsMu.Unlock()
|
||||
merged := slices.Concat(c.stdlibSymbols[coord], symbols)
|
||||
slices.Sort(merged)
|
||||
c.stdlibSymbols[coord] = slices.Compact(merged)
|
||||
}
|
||||
|
||||
// stdlibSymbolsFor returns the standard-library symbols recorded for a binary location. It returns a copy
|
||||
// so callers cannot alias (and later mutate or race on) the map's internal slice.
|
||||
func (c *goBinaryCataloger) stdlibSymbolsFor(coord file.Coordinates) []string {
|
||||
c.stdlibSymbolsMu.Lock()
|
||||
defer c.stdlibSymbolsMu.Unlock()
|
||||
return slices.Clone(c.stdlibSymbols[coord])
|
||||
}
|
||||
|
||||
// parseGoBinary catalogs packages found in the "buildinfo" section of a binary built by the go compiler.
|
||||
func (c *goBinaryCataloger) parseGoBinary(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
var pkgs []pkg.Package
|
||||
@ -68,7 +100,7 @@ func (c *goBinaryCataloger) parseGoBinary(ctx context.Context, resolver file.Res
|
||||
}
|
||||
defer internal.CloseAndLogError(reader.ReadCloser, reader.RealPath)
|
||||
|
||||
mods, errs := scanFile(reader.Location, unionReader)
|
||||
mods, errs := scanFile(reader.Location, unionReader, c.symbolScope != cataloging.SymbolScopeNone)
|
||||
|
||||
var rels []artifact.Relationship
|
||||
for _, mod := range mods {
|
||||
@ -128,6 +160,15 @@ func (c *goBinaryCataloger) buildGoPkgInfo(ctx context.Context, resolver file.Re
|
||||
mod.Main = createMainModuleFromPath(mod)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var pkgs []pkg.Package
|
||||
for _, dep := range mod.Deps {
|
||||
if dep == nil {
|
||||
@ -147,6 +188,7 @@ func (c *goBinaryCataloger) buildGoPkgInfo(ctx context.Context, resolver file.Re
|
||||
nil,
|
||||
mod.cryptoSettings,
|
||||
experiments,
|
||||
symbolsByModule[dep.Path],
|
||||
)
|
||||
|
||||
p := c.newGoBinaryPackage(
|
||||
@ -164,7 +206,7 @@ func (c *goBinaryCataloger) buildGoPkgInfo(ctx context.Context, resolver file.Re
|
||||
return nil, pkgs
|
||||
}
|
||||
|
||||
main := c.makeGoMainPackage(ctx, resolver, mod, arch, location, reader)
|
||||
main := c.makeGoMainPackage(ctx, resolver, mod, arch, location, reader, symbolsByModule[mod.Main.Path])
|
||||
|
||||
return &main, pkgs
|
||||
}
|
||||
@ -179,7 +221,7 @@ func missingMainModule(mod *extendedBuildInfo) bool {
|
||||
return mod.Main == moduleFromPartialPackageBuild
|
||||
}
|
||||
|
||||
func (c *goBinaryCataloger) makeGoMainPackage(ctx context.Context, resolver file.Resolver, mod *extendedBuildInfo, arch string, location file.Location, reader io.ReadSeekCloser) pkg.Package {
|
||||
func (c *goBinaryCataloger) makeGoMainPackage(ctx context.Context, resolver file.Resolver, mod *extendedBuildInfo, arch string, location file.Location, reader io.ReadSeekCloser, symbols []string) pkg.Package {
|
||||
gbs := getBuildSettings(mod.Settings)
|
||||
lics := c.licenseResolver.getLicenses(ctx, resolver, mod.Main.Path, mod.Main.Version)
|
||||
gover, experiments := getExperimentsFromVersion(mod.GoVersion)
|
||||
@ -192,6 +234,7 @@ func (c *goBinaryCataloger) makeGoMainPackage(ctx context.Context, resolver file
|
||||
gbs,
|
||||
mod.cryptoSettings,
|
||||
experiments,
|
||||
symbols,
|
||||
)
|
||||
|
||||
if mod.Main.Version == devel {
|
||||
@ -387,7 +430,7 @@ func getExperimentsFromVersion(version string) (string, []string) {
|
||||
version, rest, ok := strings.Cut(version, " ")
|
||||
if ok {
|
||||
// Assume they may add more non-version chunks in the future, so only look for "X:".
|
||||
for _, chunk := range strings.Split(rest, " ") {
|
||||
for chunk := range strings.SplitSeq(rest, " ") {
|
||||
if strings.HasPrefix(rest, "X:") {
|
||||
csv := strings.TrimPrefix(chunk, "X:")
|
||||
experiments = append(experiments, strings.Split(csv, ",")...)
|
||||
|
||||
@ -18,6 +18,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/internal/fileresolver"
|
||||
"github.com/anchore/syft/syft/internal/unionreader"
|
||||
@ -1422,3 +1423,71 @@ type alwaysErrorReader struct{}
|
||||
func (alwaysErrorReader) Read(_ []byte) (int, error) {
|
||||
return 0, errors.New("read from always error reader")
|
||||
}
|
||||
|
||||
func Test_buildGoPkgInfo_symbolScope(t *testing.T) {
|
||||
location := file.NewLocationFromCoordinates(file.Coordinates{RealPath: "/a-path", FileSystemID: "layer-id"})
|
||||
|
||||
// the symbols a binary would carry once scanFile has extracted them: one main-package symbol, one
|
||||
// dependency symbol, and one standard-library symbol. For the "none" scope scanFile never runs, so the
|
||||
// build info carries no symbols at all.
|
||||
populatedSymbols := []binarySymbol{
|
||||
{packagePath: "main", name: "main.main"},
|
||||
{packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"},
|
||||
{packagePath: "net/http", name: "net/http.(*Client).Do"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
scope cataloging.SymbolScope
|
||||
symbols []binarySymbol
|
||||
wantMainSyms []string
|
||||
wantDepSyms []string
|
||||
wantStdlibSyms []string
|
||||
}{
|
||||
{
|
||||
name: "none captures nothing",
|
||||
scope: cataloging.SymbolScopeNone,
|
||||
symbols: nil,
|
||||
},
|
||||
{
|
||||
name: "stdlib captures only the stdlib package",
|
||||
scope: cataloging.SymbolScopeStdlib,
|
||||
symbols: populatedSymbols,
|
||||
wantStdlibSyms: []string{"net/http.(*Client).Do"},
|
||||
},
|
||||
{
|
||||
name: "all captures module and stdlib packages",
|
||||
scope: cataloging.SymbolScopeAll,
|
||||
symbols: populatedSymbols,
|
||||
wantMainSyms: []string{"main.main"},
|
||||
wantDepSyms: []string{"github.com/foo/bar.Parse"},
|
||||
wantStdlibSyms: []string{"net/http.(*Client).Do"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mod := &extendedBuildInfo{
|
||||
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"}},
|
||||
},
|
||||
arch: "amd64",
|
||||
symbols: tt.symbols,
|
||||
}
|
||||
|
||||
c := newGoBinaryCataloger(CatalogerConfig{CaptureSymbols: tt.scope})
|
||||
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)
|
||||
|
||||
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")
|
||||
assert.Equal(t, tt.wantStdlibSyms, c.stdlibSymbolsFor(location.Coordinates), "recorded stdlib symbols")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,10 +18,11 @@ type extendedBuildInfo struct {
|
||||
*debug.BuildInfo
|
||||
cryptoSettings []string
|
||||
arch string
|
||||
symbols []binarySymbol
|
||||
}
|
||||
|
||||
// scanFile scans file to try to report the Go and module versions.
|
||||
func scanFile(location file.Location, reader unionreader.UnionReader) ([]*extendedBuildInfo, error) {
|
||||
func scanFile(location file.Location, reader unionreader.UnionReader, captureSymbols bool) ([]*extendedBuildInfo, error) {
|
||||
// NOTE: multiple readers are returned to cover universal binaries, which are files
|
||||
// with more than one binary
|
||||
readers, errs := unionreader.GetReaders(reader)
|
||||
@ -61,7 +62,18 @@ func scanFile(location file.Location, reader unionreader.UnionReader) ([]*extend
|
||||
}
|
||||
}
|
||||
|
||||
builds = append(builds, &extendedBuildInfo{BuildInfo: bi, cryptoSettings: v, arch: arch})
|
||||
var symbols []binarySymbol
|
||||
if captureSymbols {
|
||||
symbols, err = getSymbols(r)
|
||||
if err != nil {
|
||||
log.WithFields("file", location.RealPath, "error", err).Trace("unable to read golang symbol info")
|
||||
// don't skip this build info.
|
||||
// we can still catalog packages, even if we can't get the symbol information
|
||||
errs = unknown.Appendf(errs, location, "unable to read golang symbol info: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
builds = append(builds, &extendedBuildInfo{BuildInfo: bi, cryptoSettings: v, arch: arch, symbols: symbols})
|
||||
}
|
||||
return builds, errs
|
||||
}
|
||||
|
||||
@ -12,12 +12,12 @@ import (
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
func stdlibProcessor(ctx context.Context, _ file.Resolver, pkgs []pkg.Package, relationships []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
compilerPkgs, newRelationships := stdlibPackageAndRelationships(ctx, pkgs)
|
||||
func (c *goBinaryCataloger) stdlibProcessor(ctx context.Context, _ file.Resolver, pkgs []pkg.Package, relationships []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
compilerPkgs, newRelationships := c.stdlibPackageAndRelationships(ctx, pkgs)
|
||||
return append(pkgs, compilerPkgs...), append(relationships, newRelationships...), err
|
||||
}
|
||||
|
||||
func stdlibPackageAndRelationships(ctx context.Context, pkgs []pkg.Package) ([]pkg.Package, []artifact.Relationship) {
|
||||
func (c *goBinaryCataloger) stdlibPackageAndRelationships(ctx context.Context, pkgs []pkg.Package) ([]pkg.Package, []artifact.Relationship) {
|
||||
var goCompilerPkgs []pkg.Package
|
||||
var relationships []artifact.Relationship
|
||||
totalLocations := file.NewLocationSet()
|
||||
@ -33,7 +33,7 @@ func stdlibPackageAndRelationships(ctx context.Context, pkgs []pkg.Package) ([]p
|
||||
continue
|
||||
}
|
||||
|
||||
stdLibPkg := newGoStdLib(ctx, mValue.GoCompiledVersion, goPkg.Locations)
|
||||
stdLibPkg := newGoStdLib(ctx, mValue.GoCompiledVersion, goPkg.Locations, c.stdlibSymbolsFor(location.Coordinates))
|
||||
if stdLibPkg == nil {
|
||||
continue
|
||||
}
|
||||
@ -50,7 +50,7 @@ func stdlibPackageAndRelationships(ctx context.Context, pkgs []pkg.Package) ([]p
|
||||
return goCompilerPkgs, relationships
|
||||
}
|
||||
|
||||
func newGoStdLib(ctx context.Context, version string, location file.LocationSet) *pkg.Package {
|
||||
func newGoStdLib(ctx context.Context, version string, location file.LocationSet, symbols []string) *pkg.Package {
|
||||
stdlibCpe, err := generateStdlibCpe(version)
|
||||
if err != nil {
|
||||
return nil
|
||||
@ -66,6 +66,7 @@ func newGoStdLib(ctx context.Context, version string, location file.LocationSet)
|
||||
Type: pkg.GoModulePkg,
|
||||
Metadata: pkg.GolangBinaryBuildinfoEntry{
|
||||
GoCompiledVersion: version,
|
||||
Symbols: symbols,
|
||||
},
|
||||
}
|
||||
goCompilerPkg.SetID()
|
||||
@ -79,10 +80,10 @@ func generateStdlibCpe(version string) (stdlibCpe cpe.CPE, err error) {
|
||||
|
||||
// we also need to trim starting from the first +<metadata> to
|
||||
// correctly extract potential rc candidate information for cpe generation
|
||||
// ex: 2.0.0-rc.1+build.123 -> 2.0.0-rc.1; if no + is found then + is returned
|
||||
after, _, found := strings.Cut("+", version)
|
||||
// ex: 2.0.0-rc.1+build.123 -> 2.0.0-rc.1; if no + is found version is unchanged
|
||||
before, _, found := strings.Cut(version, "+")
|
||||
if found {
|
||||
version = after
|
||||
version = before
|
||||
}
|
||||
|
||||
// extracting <version> and <candidate>
|
||||
|
||||
@ -88,7 +88,8 @@ func Test_stdlibPackageAndRelationships(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotPkgs, gotRels := stdlibPackageAndRelationships(ctx, tt.pkgs)
|
||||
c := &goBinaryCataloger{stdlibSymbols: make(map[file.Coordinates][]string)}
|
||||
gotPkgs, gotRels := c.stdlibPackageAndRelationships(ctx, tt.pkgs)
|
||||
assert.Len(t, gotPkgs, tt.wantPkgs)
|
||||
assert.Len(t, gotRels, tt.wantRels)
|
||||
})
|
||||
@ -137,7 +138,8 @@ func Test_stdlibPackageAndRelationships_values(t *testing.T) {
|
||||
Type: artifact.DependencyOfRelationship,
|
||||
}
|
||||
|
||||
gotPkgs, gotRels := stdlibPackageAndRelationships(ctx, []pkg.Package{p})
|
||||
c := &goBinaryCataloger{stdlibSymbols: make(map[file.Coordinates][]string)}
|
||||
gotPkgs, gotRels := c.stdlibPackageAndRelationships(ctx, []pkg.Package{p})
|
||||
require.Len(t, gotPkgs, 1)
|
||||
|
||||
gotPkg := gotPkgs[0]
|
||||
|
||||
332
syft/pkg/cataloger/golang/symbols.go
Normal file
332
syft/pkg/cataloger/golang/symbols.go
Normal file
@ -0,0 +1,332 @@
|
||||
package golang
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"debug/elf"
|
||||
"debug/gosym"
|
||||
"debug/macho"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// mainPackage is the import path the linker assigns to the binary's main package.
|
||||
const mainPackage = "main"
|
||||
|
||||
// binarySymbol represents a single function symbol extracted from a go binary's pclntab.
|
||||
type binarySymbol struct {
|
||||
// packagePath is the import path of the package that owns the symbol (e.g. "github.com/foo/bar/internal/baz")
|
||||
packagePath string
|
||||
|
||||
// name is the fully qualified symbol name (e.g. "github.com/foo/bar/internal/baz.(*Type).Method")
|
||||
name string
|
||||
}
|
||||
|
||||
// getSymbols extracts all function symbols from the pclntab of a go binary. The pclntab is required by the
|
||||
// go runtime (for panic tracebacks and GC), so it is present even in binaries built with -ldflags="-s -w".
|
||||
func getSymbols(r io.ReaderAt) (syms []binarySymbol, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// the gosym package can panic on malformed pclntab data
|
||||
syms = nil
|
||||
err = fmt.Errorf("recovered from panic while reading pclntab: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
pclntab, textStart, err := readPclntab(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
table, err := gosym.NewTable(nil, gosym.NewLineTable(pclntab, textStart))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse pclntab: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
for _, fn := range table.Funcs {
|
||||
if fn.Sym == nil || isCompilerGeneratedName(fn.Name) {
|
||||
continue
|
||||
}
|
||||
seen[fn.Name] = struct{}{}
|
||||
syms = append(syms, binarySymbol{
|
||||
packagePath: fn.PackageName(),
|
||||
name: fn.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// debug/gosym only exposes top-level functions; functions that the compiler inlined into their
|
||||
// callers are absent from table.Funcs even though their names are recorded in the pclntab funcname
|
||||
// table (used to reconstruct inlined frames in tracebacks). Recover those names so that a
|
||||
// vulnerable-but-inlined function (e.g. a small stdlib wrapper) is still reported as present.
|
||||
for _, name := range funcNameTable(pclntab) {
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
pkgPath := packagePathFromSymbolName(name)
|
||||
if pkgPath == "" {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
syms = append(syms, binarySymbol{packagePath: pkgPath, name: name})
|
||||
}
|
||||
|
||||
return syms, nil
|
||||
}
|
||||
|
||||
// packagePathFromSymbolName derives the owning package import path from a fully qualified symbol name.
|
||||
// The package path is everything up to the first "." that follows the final "/" — e.g.
|
||||
// "path/filepath.IsLocal" -> "path/filepath" and "golang.org/x/net/html.(*Tokenizer).Next" ->
|
||||
// "golang.org/x/net/html". Returns "" when the name has no package-qualifying dot or is compiler-generated.
|
||||
func packagePathFromSymbolName(name string) string {
|
||||
if isCompilerGeneratedName(name) {
|
||||
return ""
|
||||
}
|
||||
name = nameWithoutTypeArgs(name)
|
||||
slash := strings.LastIndex(name, "/")
|
||||
dot := strings.IndexByte(name[slash+1:], '.')
|
||||
if dot < 0 {
|
||||
return ""
|
||||
}
|
||||
return name[:slash+1+dot]
|
||||
}
|
||||
|
||||
// nameWithoutTypeArgs strips the type-argument portion from an instantiated generic symbol name, e.g.
|
||||
// "foo/bar.Do[net/url.Values]" -> "foo/bar.Do". The slashes and dots inside the brackets would otherwise
|
||||
// corrupt package-path derivation (yielding "foo/bar.Do[net" for the example above). Mirrors
|
||||
// debug/gosym's (*Sym).nameWithoutInst.
|
||||
func nameWithoutTypeArgs(name string) string {
|
||||
start := strings.IndexByte(name, '[')
|
||||
if start < 0 {
|
||||
return name
|
||||
}
|
||||
end := strings.LastIndexByte(name, ']')
|
||||
if end < 0 {
|
||||
// malformed: an opening bracket should always have a closing one
|
||||
return name
|
||||
}
|
||||
return name[:start] + name[end+1:]
|
||||
}
|
||||
|
||||
// oldStyleCompilerGeneratedPrefixes match compiler/linker-generated symbols from toolchains older than
|
||||
// go1.20, which used "." where newer toolchains use ":" (e.g. "go.buildid" is now "go:buildid",
|
||||
// "go.type.*" is now "go:type.*"). These must be prefix (not substring) matches, and a bare "go." prefix
|
||||
// is not enough: legitimate module paths such as "go.uber.org/zap" also start with "go.".
|
||||
var oldStyleCompilerGeneratedPrefixes = []string{
|
||||
"go.buildid",
|
||||
"go.builtin.",
|
||||
"go.constinfo.",
|
||||
"go.cuinfo.",
|
||||
"go.func.",
|
||||
"go.importpath.",
|
||||
"go.info.",
|
||||
"go.interface.",
|
||||
"go.itab.",
|
||||
"go.itablink.",
|
||||
"go.map.",
|
||||
"go.shape.",
|
||||
"go.string.",
|
||||
"go.type.",
|
||||
"go.typelink.",
|
||||
"type.",
|
||||
}
|
||||
|
||||
// isCompilerGeneratedName reports whether a symbol name was synthesized by the compiler or linker rather
|
||||
// than declared in Go source. Since go1.20 these names contain ':' or '..' (e.g. "type:.eq.*",
|
||||
// "go:string.*") — byte sequences that never appear in a real Go import path or identifier. Older
|
||||
// toolchains used '.' as the separator (e.g. "go.type.*", "type..hash.*"), which is matched against the
|
||||
// known reserved prefixes. Such names belong to no package and are dropped rather than mis-attributed
|
||||
// (e.g. bucketed under a bogus "type" stdlib package).
|
||||
func isCompilerGeneratedName(name string) bool {
|
||||
if strings.Contains(name, ":") || strings.Contains(name, "..") {
|
||||
return true
|
||||
}
|
||||
for _, prefix := range oldStyleCompilerGeneratedPrefixes {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// funcNameTable returns every function name recorded in the pclntab's funcname table, including the
|
||||
// names of inlined functions that debug/gosym does not expose. It parses the pclntab header for the
|
||||
// Go 1.16+ layouts; on any unrecognized layout or out-of-bounds offset it returns nil (fail-soft), so
|
||||
// callers fall back to the debug/gosym function set. See the runtime's pcHeader / moduledata layout.
|
||||
func funcNameTable(pclntab []byte) []string {
|
||||
if len(pclntab) < 8 {
|
||||
return nil
|
||||
}
|
||||
|
||||
magic := binary.LittleEndian.Uint32(pclntab[0:4])
|
||||
// the field before funcnameOffset is textStart, which exists in the 1.18+ headers but not 1.16/1.17
|
||||
var hasTextStart bool
|
||||
switch magic {
|
||||
case 0xfffffff1, 0xfffffff0: // go1.20+, go1.18/1.19
|
||||
hasTextStart = true
|
||||
case 0xfffffffa: // go1.16/1.17
|
||||
hasTextStart = false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
ptrSize := int(pclntab[7])
|
||||
if ptrSize != 4 && ptrSize != 8 {
|
||||
return nil
|
||||
}
|
||||
|
||||
readWord := func(idx int) (uint64, bool) {
|
||||
off := 8 + idx*ptrSize
|
||||
if off+ptrSize > len(pclntab) {
|
||||
return 0, false
|
||||
}
|
||||
if ptrSize == 8 {
|
||||
return binary.LittleEndian.Uint64(pclntab[off : off+8]), true
|
||||
}
|
||||
return uint64(binary.LittleEndian.Uint32(pclntab[off : off+4])), true
|
||||
}
|
||||
|
||||
// header words after (nfunc, nfiles): [textStart,] funcnameOffset, cuOffset, ...
|
||||
funcnameIdx := 2
|
||||
if hasTextStart {
|
||||
funcnameIdx = 3
|
||||
}
|
||||
funcnameOffset, ok1 := readWord(funcnameIdx)
|
||||
cuOffset, ok2 := readWord(funcnameIdx + 1)
|
||||
if !ok1 || !ok2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
start, end := int(funcnameOffset), int(cuOffset)
|
||||
if start < 0 || end > len(pclntab) || start >= end {
|
||||
return nil
|
||||
}
|
||||
|
||||
var names []string
|
||||
for raw := range bytes.SplitSeq(pclntab[start:end], []byte{0}) {
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
names = append(names, string(raw))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// readPclntab locates the pclntab and the start address of the text segment within the binary.
|
||||
func readPclntab(r io.ReaderAt) (pclntab []byte, textStart uint64, err error) {
|
||||
ident := make([]byte, 16)
|
||||
if n, err := r.ReadAt(ident, 0); n < len(ident) || err != nil {
|
||||
return nil, 0, errUnrecognizedFormat
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(string(ident), "\x7FELF"):
|
||||
f, err := elf.NewFile(r)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("unable to parse ELF binary: %w", err)
|
||||
}
|
||||
sect := f.Section(".gopclntab")
|
||||
if sect == nil {
|
||||
return nil, 0, fmt.Errorf("no .gopclntab section found")
|
||||
}
|
||||
pclntab, err := sect.Data()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("unable to read .gopclntab section: %w", err)
|
||||
}
|
||||
text := f.Section(".text")
|
||||
if text == nil {
|
||||
return nil, 0, fmt.Errorf("no .text section found")
|
||||
}
|
||||
return pclntab, text.Addr, nil
|
||||
case strings.HasPrefix(string(ident), "\xFE\xED\xFA") || strings.HasPrefix(string(ident[1:]), "\xFA\xED\xFE"):
|
||||
f, err := macho.NewFile(r)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("unable to parse Mach-O binary: %w", err)
|
||||
}
|
||||
sect := f.Section("__gopclntab")
|
||||
if sect == nil {
|
||||
return nil, 0, fmt.Errorf("no __gopclntab section found")
|
||||
}
|
||||
pclntab, err := sect.Data()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("unable to read __gopclntab section: %w", err)
|
||||
}
|
||||
text := f.Section("__text")
|
||||
if text == nil {
|
||||
return nil, 0, fmt.Errorf("no __text section found")
|
||||
}
|
||||
return pclntab, text.Addr, nil
|
||||
}
|
||||
|
||||
// note: PE and XCOFF binaries do not place the pclntab in a dedicated section; locating it requires
|
||||
// walking the symbol table for runtime.pclntab markers, which is not yet supported here
|
||||
return nil, 0, errUnrecognizedFormat
|
||||
}
|
||||
|
||||
// moduleSymbols attributes each extracted symbol to the module that owns it (by longest module path prefix
|
||||
// of the symbol's package path) and returns a sorted, deduplicated list of symbol names per module path.
|
||||
// Symbols from the "main" package are attributed to the main module. Standard-library symbols (which belong
|
||||
// to no module) are collected separately and returned as the second value so they can be attached to the
|
||||
// synthetic "stdlib" package. Compiler/runtime-internal symbols that are neither module-owned nor a
|
||||
// recognizable stdlib import path are dropped.
|
||||
func moduleSymbols(symbols []binarySymbol, main *debug.Module, deps []*debug.Module) (byModule map[string][]string, stdlib []string) {
|
||||
if len(symbols) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var modulePaths []string
|
||||
if main != nil && main.Path != "" {
|
||||
modulePaths = append(modulePaths, main.Path)
|
||||
}
|
||||
for _, dep := range deps {
|
||||
if dep != nil && dep.Path != "" {
|
||||
modulePaths = append(modulePaths, dep.Path)
|
||||
}
|
||||
}
|
||||
|
||||
results := make(map[string][]string)
|
||||
for _, sym := range symbols {
|
||||
pkgPath := sym.packagePath
|
||||
if pkgPath == mainPackage && main != nil {
|
||||
// the linker renames the main package's import path to "main"
|
||||
pkgPath = main.Path
|
||||
}
|
||||
|
||||
var best string
|
||||
for _, modPath := range modulePaths {
|
||||
if len(modPath) > len(best) && (pkgPath == modPath || strings.HasPrefix(pkgPath, modPath+"/")) {
|
||||
best = modPath
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
if pkgPath != mainPackage && isStandardImportPath(pkgPath) {
|
||||
stdlib = append(stdlib, sym.name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
results[best] = append(results[best], sym.name)
|
||||
}
|
||||
|
||||
for modPath, names := range results {
|
||||
slices.Sort(names)
|
||||
results[modPath] = slices.Compact(names)
|
||||
}
|
||||
if len(stdlib) > 0 {
|
||||
slices.Sort(stdlib)
|
||||
stdlib = slices.Compact(stdlib)
|
||||
}
|
||||
|
||||
return results, stdlib
|
||||
}
|
||||
|
||||
// isStandardImportPath reports whether path is a Go standard-library import path. This mirrors the rule
|
||||
// the Go toolchain uses: a path is standard if the element before its first slash contains no dot (e.g.
|
||||
// "net/http", "runtime", "internal/abi"), which distinguishes it from module paths like
|
||||
// "github.com/foo/bar" whose leading element is a domain name.
|
||||
func isStandardImportPath(path string) bool {
|
||||
first, _, _ := strings.Cut(path, "/")
|
||||
return first != "" && !strings.Contains(first, ".")
|
||||
}
|
||||
201
syft/pkg/cataloger/golang/symbols_test.go
Normal file
201
syft/pkg/cataloger/golang/symbols_test.go
Normal file
@ -0,0 +1,201 @@
|
||||
package golang
|
||||
|
||||
import (
|
||||
"debug/gosym"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_moduleSymbols(t *testing.T) {
|
||||
mainModule := &debug.Module{Path: "github.com/someorg/somecli"}
|
||||
deps := []*debug.Module{
|
||||
{Path: "github.com/foo/bar"},
|
||||
{Path: "github.com/foo/bar/v2"},
|
||||
nil,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
symbols []binarySymbol
|
||||
expected map[string][]string
|
||||
expectedStdlib []string
|
||||
}{
|
||||
{
|
||||
name: "no symbols",
|
||||
symbols: nil,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "attribute symbols by longest module path prefix",
|
||||
symbols: []binarySymbol{
|
||||
{packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"},
|
||||
{packagePath: "github.com/foo/bar/internal/util", name: "github.com/foo/bar/internal/util.(*Helper).Do"},
|
||||
{packagePath: "github.com/foo/bar/v2", name: "github.com/foo/bar/v2.Parse"},
|
||||
},
|
||||
expected: map[string][]string{
|
||||
"github.com/foo/bar": {
|
||||
"github.com/foo/bar.Parse",
|
||||
"github.com/foo/bar/internal/util.(*Helper).Do",
|
||||
},
|
||||
"github.com/foo/bar/v2": {
|
||||
"github.com/foo/bar/v2.Parse",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "main package symbols are attributed to the main module",
|
||||
symbols: []binarySymbol{
|
||||
{packagePath: "main", name: "main.main"},
|
||||
{packagePath: "github.com/someorg/somecli/cmd", name: "github.com/someorg/somecli/cmd.Execute"},
|
||||
},
|
||||
expected: map[string][]string{
|
||||
"github.com/someorg/somecli": {
|
||||
"github.com/someorg/somecli/cmd.Execute",
|
||||
"main.main",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stdlib and runtime symbols are collected separately",
|
||||
symbols: []binarySymbol{
|
||||
{packagePath: "runtime", name: "runtime.main"},
|
||||
{packagePath: "net/http", name: "net/http.(*Client).Do"},
|
||||
{packagePath: "internal/abi", name: "internal/abi.(*Type).Kind"},
|
||||
},
|
||||
expected: map[string][]string{},
|
||||
expectedStdlib: []string{
|
||||
"internal/abi.(*Type).Kind",
|
||||
"net/http.(*Client).Do",
|
||||
"runtime.main",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate symbols are deduplicated",
|
||||
symbols: []binarySymbol{
|
||||
{packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"},
|
||||
{packagePath: "github.com/foo/bar", name: "github.com/foo/bar.Parse"},
|
||||
},
|
||||
expected: map[string][]string{
|
||||
"github.com/foo/bar": {
|
||||
"github.com/foo/bar.Parse",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotByModule, gotStdlib := moduleSymbols(tt.symbols, mainModule, deps)
|
||||
assert.Equal(t, tt.expected, gotByModule)
|
||||
assert.Equal(t, tt.expectedStdlib, gotStdlib)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getSymbols(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("PE binaries are not supported for symbol extraction")
|
||||
}
|
||||
|
||||
// the test executable is itself a go binary with a pclntab, which makes for a hermetic fixture
|
||||
exe, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
|
||||
f, err := os.Open(exe)
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
|
||||
symbols, err := getSymbols(f)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, symbols)
|
||||
|
||||
var foundRuntime, foundTesting bool
|
||||
for _, sym := range symbols {
|
||||
switch {
|
||||
case sym.packagePath == "runtime" && sym.name == "runtime.main":
|
||||
foundRuntime = true
|
||||
case sym.packagePath == "testing" && sym.name == "testing.tRunner":
|
||||
foundTesting = true
|
||||
}
|
||||
}
|
||||
assert.True(t, foundRuntime, "expected to find runtime.main symbol")
|
||||
assert.True(t, foundTesting, "expected to find testing.tRunner symbol")
|
||||
|
||||
// the recovery loop relies on packagePathFromSymbolName, so confirm at least one recovered name is
|
||||
// present that debug/gosym's table.Funcs does not surface directly (i.e. an inlined function).
|
||||
require.True(t, hasInlinedOnlySymbol(t, exe, symbols), "expected to recover at least one inlined-only symbol")
|
||||
}
|
||||
|
||||
// hasInlinedOnlySymbol reports whether syms contains a function name that is absent from the raw
|
||||
// debug/gosym function table for the same binary — i.e. a name that could only have come from the
|
||||
// funcname-table recovery path.
|
||||
func hasInlinedOnlySymbol(t *testing.T, exe string, syms []binarySymbol) bool {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.Open(exe)
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
|
||||
pclntab, textStart, err := readPclntab(f)
|
||||
require.NoError(t, err)
|
||||
|
||||
table, err := gosym.NewTable(nil, gosym.NewLineTable(pclntab, textStart))
|
||||
require.NoError(t, err)
|
||||
|
||||
gosymNames := make(map[string]struct{}, len(table.Funcs))
|
||||
for _, fn := range table.Funcs {
|
||||
gosymNames[fn.Name] = struct{}{}
|
||||
}
|
||||
|
||||
for _, sym := range syms {
|
||||
if _, ok := gosymNames[sym.name]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Test_packagePathFromSymbolName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"path/filepath.IsLocal", "path/filepath"},
|
||||
// pointer-receiver method
|
||||
{"golang.org/x/net/html.(*Tokenizer).Next", "golang.org/x/net/html"},
|
||||
// versioned (major-version-suffixed) module path
|
||||
{"github.com/foo/bar/v2.Parse", "github.com/foo/bar/v2"},
|
||||
{"github.com/foo/bar/v2.(*Client).Do", "github.com/foo/bar/v2"},
|
||||
{"github.com/foo/bar.Parse.func1", "github.com/foo/bar"},
|
||||
{"main.main", "main"},
|
||||
{"runtime.gcBgMarkWorker", "runtime"},
|
||||
// generic instantiations: type arguments must not corrupt the package path
|
||||
{"foo/bar.Do[net/url.Values]", "foo/bar"},
|
||||
{"github.com/foo/bar.Map[go.shape.int,go.shape.string]", "github.com/foo/bar"},
|
||||
{"main.Do[go.shape.int]", "main"},
|
||||
// no package-qualifying dot
|
||||
{"runtime", ""},
|
||||
// compiler/linker-generated symbols belong to no package
|
||||
{"type:.eq.[]string", ""},
|
||||
{"type..hash.runtime._type", ""},
|
||||
{"go:string.\"foo\"", ""},
|
||||
// pre-go1.20 toolchains generated symbols with "." where newer ones use ":"
|
||||
{"go.buildid", ""},
|
||||
{"go.type.*runtime._type", ""},
|
||||
{"go.itab.*os.File,io.Reader", ""},
|
||||
{"go.string.\"foo\"", ""},
|
||||
// module paths that begin with "go." are not compiler-generated
|
||||
{"go.uber.org/zap.(*Logger).Info", "go.uber.org/zap"},
|
||||
{"go.opentelemetry.io/otel.Tracer", "go.opentelemetry.io/otel"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, packagePathFromSymbolName(test.name))
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,13 @@ type GolangBinaryBuildinfoEntry struct {
|
||||
|
||||
// GoExperiments lists experimental Go features enabled during compilation (e.g., "arenas", "cgocheck2").
|
||||
GoExperiments []string `json:"goExperiments,omitempty" cyclonedx:"goExperiments"`
|
||||
|
||||
// Symbols are the fully qualified function symbols from this module that are compiled into the binary
|
||||
// (e.g., "github.com/foo/bar.(*Type).Method"), extracted from the binary symbol table (pclntab).
|
||||
// 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.
|
||||
Symbols []string `json:"symbols,omitempty"`
|
||||
}
|
||||
|
||||
// GolangModuleEntry represents all captured data for a Golang source scan with go.mod/go.sum
|
||||
|
||||
@ -8,19 +8,36 @@ const (
|
||||
Builtin VcpkgRegistryKind = "builtin"
|
||||
)
|
||||
|
||||
// used for metadata. Includes summary of data found in manifest (vcpkg.json file) relevant to just that vcpkg
|
||||
// VcpkgManifest summarizes the data found in a vcpkg manifest (vcpkg.json) relevant to a single vcpkg package.
|
||||
type VcpkgManifest struct {
|
||||
Description []string `json:"description,omitempty"`
|
||||
Documentation string `json:"documentation,omitempty"`
|
||||
FullVersion string `json:"full-version"`
|
||||
Version string `json:"version"`
|
||||
PortVersion int `json:"port-version"`
|
||||
Maintainers []string `json:"maintainers,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Supports string `json:"supports,omitempty"`
|
||||
// to show where it came from
|
||||
// Description is the human-readable description of the package (may be a single string or a list of paragraphs in the manifest).
|
||||
Description []string `json:"description,omitempty"`
|
||||
|
||||
// Documentation is the URL to the package's documentation.
|
||||
Documentation string `json:"documentation,omitempty"`
|
||||
|
||||
// FullVersion is the complete version string including the port-version suffix (e.g. "1.2.3#2").
|
||||
FullVersion string `json:"full-version"`
|
||||
|
||||
// Version is the upstream package version without the port-version suffix (e.g. "1.2.3").
|
||||
Version string `json:"version"`
|
||||
|
||||
// PortVersion is the vcpkg-specific packaging revision for a given upstream version.
|
||||
PortVersion int `json:"port-version"`
|
||||
|
||||
// Maintainers are the people responsible for maintaining the vcpkg port.
|
||||
Maintainers []string `json:"maintainers,omitempty"`
|
||||
|
||||
// Name is the package name as declared in the manifest.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Supports is the platform expression describing which triplets the package can be built for (e.g. "!windows").
|
||||
Supports string `json:"supports,omitempty"`
|
||||
|
||||
// Registry indicates where the package definition came from.
|
||||
Registry *VcpkgRegistryEntry `json:"registry,omitempty"`
|
||||
// found by looking at build folder to find target. ex. "x64-linux"
|
||||
|
||||
// Triplet is the build target discovered from the build folder (e.g. "x64-linux").
|
||||
Triplet string `json:"triplet,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user