From c890e7f17f8db638d3f1558bf087b4cb2f3c2aad Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 23 Jul 2026 15:03:36 -0400 Subject: [PATCH] decode golang symbols (#5089) Signed-off-by: Alex Goodman --- syft/pkg/cataloger/golang/symbols.go | 56 +++++++++++++++++++++-- syft/pkg/cataloger/golang/symbols_test.go | 52 +++++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/syft/pkg/cataloger/golang/symbols.go b/syft/pkg/cataloger/golang/symbols.go index 1d8067dff..3b0987f2e 100644 --- a/syft/pkg/cataloger/golang/symbols.go +++ b/syft/pkg/cataloger/golang/symbols.go @@ -52,10 +52,7 @@ func getSymbols(r io.ReaderAt) (syms []binarySymbol, err error) { continue } seen[fn.Name] = struct{}{} - syms = append(syms, binarySymbol{ - packagePath: fn.PackageName(), - name: fn.Name, - }) + syms = append(syms, makeBinarySymbol(fn.Name, fn.PackageName())) } // debug/gosym only exposes top-level functions; functions that the compiler inlined into their @@ -71,7 +68,7 @@ func getSymbols(r io.ReaderAt) (syms []binarySymbol, err error) { continue } seen[name] = struct{}{} - syms = append(syms, binarySymbol{packagePath: pkgPath, name: name}) + syms = append(syms, makeBinarySymbol(name, pkgPath)) } return syms, nil @@ -94,6 +91,55 @@ func packagePathFromSymbolName(name string) string { return name[:slash+1+dot] } +// makeBinarySymbol builds a binarySymbol, unescaping the %xx sequences the go linker introduces in the +// import-path portion of a symbol name (see unescapePackagePath). The local-symbol suffix (method and +// function names) is left untouched, so only the path prefix shared by packagePath and name is rewritten. +func makeBinarySymbol(name, pkgPath string) binarySymbol { + unescaped := unescapePackagePath(pkgPath) + if unescaped != pkgPath && strings.HasPrefix(name, pkgPath) { + name = unescaped + name[len(pkgPath):] + } + return binarySymbol{packagePath: unescaped, name: name} +} + +// unescapePackagePath reverses the escaping cmd/internal/objabi.PathToPrefix applies to import paths in +// symbol names: bytes like '.' (at or after the final '/'), '%', '"', control bytes, and high bytes are +// written as lowercase "%xx". For example "gopkg.in/yaml.v2" is stored as "gopkg.in/yaml%2ev2", so this +// restores it before matching against the (unescaped) module paths from build info. A lone or malformed +// '%' sequence is left as-is. +func unescapePackagePath(path string) string { + if !strings.Contains(path, "%") { + return path + } + var b strings.Builder + b.Grow(len(path)) + for i := 0; i < len(path); i++ { + if path[i] == '%' && i+2 < len(path) { + if hi, ok1 := unhex(path[i+1]); ok1 { + if lo, ok2 := unhex(path[i+2]); ok2 { + b.WriteByte(hi<<4 | lo) + i += 2 + continue + } + } + } + b.WriteByte(path[i]) + } + return b.String() +} + +func unhex(c byte) (byte, bool) { + switch { + case c >= '0' && c <= '9': + return c - '0', true + case c >= 'a' && c <= 'f': + return c - 'a' + 10, true + case c >= 'A' && c <= 'F': + return c - 'A' + 10, true + } + return 0, false +} + // 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 diff --git a/syft/pkg/cataloger/golang/symbols_test.go b/syft/pkg/cataloger/golang/symbols_test.go index 72ca613da..5d140a431 100644 --- a/syft/pkg/cataloger/golang/symbols_test.go +++ b/syft/pkg/cataloger/golang/symbols_test.go @@ -97,6 +97,58 @@ func Test_moduleSymbols(t *testing.T) { } } +func Test_escapedImportPathAttribution(t *testing.T) { + // the go linker escapes the '.' in the last path element, so "gopkg.in/yaml.v2" appears in the symbol + // table as "gopkg.in/yaml%2ev2". attribution must still land these under the unescaped module path. + mainModule := &debug.Module{Path: "github.com/someorg/somecli"} + deps := []*debug.Module{{Path: "gopkg.in/yaml.v2"}} + + symbols := []binarySymbol{ + makeBinarySymbol("gopkg.in/yaml%2ev2.(*decoder).alias", "gopkg.in/yaml%2ev2"), + makeBinarySymbol("gopkg.in/yaml%2ev2.(*TypeError).Error", "gopkg.in/yaml%2ev2"), + } + + gotByModule, gotStdlib := moduleSymbols(symbols, mainModule, deps) + assert.Nil(t, gotStdlib) + assert.Equal(t, map[string]map[string][]string{ + "gopkg.in/yaml.v2": { + "gopkg.in/yaml.v2": {"(*TypeError).Error", "(*decoder).alias"}, + }, + }, gotByModule) +} + +func Test_unescapePackagePath(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"gopkg.in/yaml%2ev2", "gopkg.in/yaml.v2"}, + {"foo%25bar", "foo%bar"}, + {"foo%22bar", "foo\"bar"}, + // no escapes: unchanged + {"github.com/foo/bar", "github.com/foo/bar"}, + {"", ""}, + // malformed sequences are left as-is + {"foo%2", "foo%2"}, + {"foo%zz", "foo%zz"}, + {"foo%", "foo%"}, + // uppercase hex is accepted too + {"foo%2Ebar", "foo.bar"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.expected, unescapePackagePath(tt.input)) + }) + } +} + +func Test_makeBinarySymbol(t *testing.T) { + // only the import-path prefix is unescaped; the local-symbol suffix is preserved verbatim + sym := makeBinarySymbol("gopkg.in/yaml%2ev2.(*decoder).alias", "gopkg.in/yaml%2ev2") + assert.Equal(t, "gopkg.in/yaml.v2", sym.packagePath) + assert.Equal(t, "gopkg.in/yaml.v2.(*decoder).alias", sym.name) +} + func Test_localSymbolName(t *testing.T) { tests := []struct { name string