diff --git a/syft/pkg/cataloger/golang/parse_go_binary.go b/syft/pkg/cataloger/golang/parse_go_binary.go index e44732364..8031cc65b 100644 --- a/syft/pkg/cataloger/golang/parse_go_binary.go +++ b/syft/pkg/cataloger/golang/parse_go_binary.go @@ -82,11 +82,12 @@ func (c *goBinaryCataloger) recordStdlibSymbols(coord file.Coordinates, symbols c.stdlibSymbols[coord] = slices.Compact(merged) } -// stdlibSymbolsFor returns the standard-library symbols recorded for a binary location. +// 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 c.stdlibSymbols[coord] + return slices.Clone(c.stdlibSymbols[coord]) } // parseGoBinary catalogs packages found in the "buildinfo" section of a binary built by the go compiler. @@ -429,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, ",")...) diff --git a/syft/pkg/cataloger/golang/symbols.go b/syft/pkg/cataloger/golang/symbols.go index 3b49d4c9f..d2dc244cd 100644 --- a/syft/pkg/cataloger/golang/symbols.go +++ b/syft/pkg/cataloger/golang/symbols.go @@ -47,7 +47,7 @@ func getSymbols(r io.ReaderAt) (syms []binarySymbol, err error) { seen := make(map[string]struct{}) for _, fn := range table.Funcs { - if fn.Sym == nil { + if fn.Sym == nil || isCompilerGeneratedName(fn.Name) { continue } seen[fn.Name] = struct{}{} @@ -79,8 +79,11 @@ func getSymbols(r io.ReaderAt) (syms []binarySymbol, err error) { // 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. +// "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 "" + } slash := strings.LastIndex(name, "/") dot := strings.IndexByte(name[slash+1:], '.') if dot < 0 { @@ -89,6 +92,15 @@ func packagePathFromSymbolName(name string) string { return name[:slash+1+dot] } +// isCompilerGeneratedName reports whether a symbol name was synthesized by the compiler or linker rather +// than declared in Go source. These names use ':' or '..' (e.g. "type:.eq.*", "type..hash.*", +// "go:string.*") — byte sequences that never appear in a real Go import path or identifier — so they +// 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 { + return strings.Contains(name, ":") || strings.Contains(name, "..") +} + // 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 @@ -143,7 +155,7 @@ func funcNameTable(pclntab []byte) []string { } var names []string - for _, raw := range bytes.Split(pclntab[start:end], []byte{0}) { + for raw := range bytes.SplitSeq(pclntab[start:end], []byte{0}) { if len(raw) == 0 { continue } @@ -264,9 +276,6 @@ func moduleSymbols(symbols []binarySymbol, main *debug.Module, deps []*debug.Mod // "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 := path - if i := strings.Index(path, "/"); i >= 0 { - first = path[:i] - } + first, _, _ := strings.Cut(path, "/") return first != "" && !strings.Contains(first, ".") } diff --git a/syft/pkg/cataloger/golang/symbols_test.go b/syft/pkg/cataloger/golang/symbols_test.go index 9c36e408c..b9569108f 100644 --- a/syft/pkg/cataloger/golang/symbols_test.go +++ b/syft/pkg/cataloger/golang/symbols_test.go @@ -1,6 +1,7 @@ package golang import ( + "debug/gosym" "os" "runtime" "runtime/debug" @@ -124,4 +125,65 @@ func Test_getSymbols(t *testing.T) { } 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"}, + // no package-qualifying dot + {"runtime", ""}, + // compiler/linker-generated symbols belong to no package + {"type:.eq.[]string", ""}, + {"type..hash.runtime._type", ""}, + {"go:string.\"foo\"", ""}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, packagePathFromSymbolName(test.name)) + }) + } }