From a4f75795fc9e80c77c929d8bc55edeb86938d6d7 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 16 Oct 2026 10:04:26 +0300 Subject: [PATCH 1/4] feat(sbom): read which modules and crates a produced binary carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Go module and a Rust crate keep no files of their own in an image: they are compiled into a binary, so the binary's own metadata is the only place that knows they are there. Shipped-scope needs that answer per file to tell a component that is really shipped from one that only passed through the build. Classify a single binary from its bytes. The branches are ordered, and the order carries the correctness: - a test binary first. `go test -c` leaves no mark in the build info — not in the module path, and not in an empty dependency list, since the test binary inherits whatever the package under test imports. The path of the testing package's own source is in every one of them and a strip does not touch it; - wiped build info next, before the dependency list is read at all. An obfuscator leaves an empty list behind on a binary whose dependencies are alive, so reading that as knowledge would drop every one of them. Hold them all instead; - a non-empty list names the modules the linker put in, both sides of a replace among them; - an empty list on live build info is a main-only binary, and it holds nothing. Match modules by path, never by version: build info describes go.mod as it was when the binary was linked, and the SBOM describes it as it is now. Matching on version would turn that skew into a lost component. Split the error from reading build info by section: a binary carrying .gopclntab or .go.buildinfo is a Go binary that lost its metadata, so hold every module; anything else is not Go and holds no module at all. For Rust, read the .dep-v0 section cargo auditable writes and keep the runtime dependencies. The kind field is absent for a runtime dependency — the serialiser omits its default — so filter out build dependencies rather than filtering in runtime ones, which would silently drop the common case. Without that section nothing can be told about crates, so hold them all. Signed-off-by: Radmir Khurum --- pkg/sbom/shippedscope/binary.go | 236 +++++++++++++++++++ pkg/sbom/shippedscope/binary_helpers_test.go | 198 ++++++++++++++++ pkg/sbom/shippedscope/binary_test.go | 154 ++++++++++++ 3 files changed, 588 insertions(+) create mode 100644 pkg/sbom/shippedscope/binary.go create mode 100644 pkg/sbom/shippedscope/binary_helpers_test.go create mode 100644 pkg/sbom/shippedscope/binary_test.go diff --git a/pkg/sbom/shippedscope/binary.go b/pkg/sbom/shippedscope/binary.go new file mode 100644 index 0000000000..850157e613 --- /dev/null +++ b/pkg/sbom/shippedscope/binary.go @@ -0,0 +1,236 @@ +package shippedscope + +import ( + "bytes" + "compress/zlib" + "context" + "debug/buildinfo" + "debug/elf" + "encoding/json" + "fmt" + "io" + "runtime/debug" + + "github.com/werf/logboek" +) + +// Verdict is what a binary says about the components of one ecosystem. +type Verdict string + +const ( + // VerdictNone — the binary is not of that ecosystem and holds none of its components. + VerdictNone Verdict = "none" + // VerdictNames — the binary says which components it carries, and holds only those. + VerdictNames Verdict = "names" + // VerdictUnknown — the binary could carry components of that ecosystem but names + // none, so every one of them is held. "I don't know" is never "not shipped". + VerdictUnknown Verdict = "unknown" +) + +type Crate struct { + Name string + Version string +} + +// BinaryClassification is what one produced file says about the components compiled into +// it. A Go module and a Rust crate keep no files of their own in an image — the binary is +// the only place that knows they are there. +type BinaryClassification struct { + // TestBinary marks a binary `go test -c` produced. It carries the test's dependencies + // and ships with nothing, so it speaks for no component at all. + TestBinary bool + + Go Verdict + // Modules holds module paths, never versions: the build info of a binary describes + // go.mod as it was when the binary was linked, and the SBOM describes go.mod as it is + // now. Matching on version would turn that skew into a lost component. + Modules []string + + Rust Verdict + Crates []Crate +} + +const ( + testingSourcePath = "src/testing/testing.go" + // The section cargo-auditable writes, holding zlib-compressed JSON. + rustDepSection = ".dep-v0" +) + +// goSections name a Go binary even when its build info is gone. +var goSections = []string{".gopclntab", ".go.buildinfo"} + +// ClassifyBinary answers which components a produced binary carries, reading only the +// binary's own metadata. +// +// The branches are ordered, and the order is the point. A test binary is recognized first: +// it is nothing but dependencies, and `go test -c` leaves no mark in the build info — not +// in the path, and not in an empty dependency list, since a test binary inherits whatever +// the package under test imports. Build info that was wiped comes next, because an +// obfuscator leaves an empty dependency list behind on a binary whose dependencies are +// alive; reading that as knowledge would drop every one of them. +// +// A file that is not an ELF binary speaks for nobody: images here are Linux images, and a +// Go or Rust binary in one is an ELF. +func ClassifyBinary(ctx context.Context, binary io.ReaderAt, size int64) (BinaryClassification, error) { + classification := BinaryClassification{Go: VerdictNone, Rust: VerdictNone} + + file, err := elf.NewFile(binary) + if err != nil { + return classification, nil + } + defer file.Close() + + testBinary, err := carriesTestingSource(binary, size) + if err != nil { + return classification, err + } + if testBinary { + classification.TestBinary = true + + return classification, nil + } + + if section := file.Section(rustDepSection); section != nil { + crates, err := runtimeCrates(section) + if err != nil { + logboek.Context(ctx).Debug().LogF("read %s: %s\n", rustDepSection, err) + classification.Rust = VerdictUnknown + + return classification, nil + } + + classification.Rust = VerdictNames + classification.Crates = crates + + return classification, nil + } + + info, err := buildinfo.Read(binary) + if err != nil { + if carriesGoSections(file) { + classification.Go = VerdictUnknown + + return classification, nil + } + + // Not a Go binary, and no audit data: it holds no Go module, and whether it holds + // a crate cannot be told from a Rust binary built without cargo auditable. + classification.Rust = VerdictUnknown + + return classification, nil + } + + classification.Go, classification.Modules = goEvidence(info) + + return classification, nil +} + +// goEvidence reads the build info of a binary already known not to be a test binary. +func goEvidence(info *debug.BuildInfo) (Verdict, []string) { + if info.GoVersion == "" && info.Path == "" && len(info.Settings) == 0 { + return VerdictUnknown, nil + } + + var modules []string + for _, dep := range info.Deps { + modules = append(modules, dep.Path) + + if dep.Replace != nil { + modules = append(modules, dep.Replace.Path) + } + } + + return VerdictNames, modules +} + +func carriesGoSections(file *elf.File) bool { + for _, name := range goSections { + if file.Section(name) != nil { + return true + } + } + + return false +} + +// carriesTestingSource looks for the path of the testing package's own source, which the +// test runner inside every `go test -c` binary carries and a strip does not touch. +func carriesTestingSource(binary io.ReaderAt, size int64) (bool, error) { + const chunk = 1 << 20 + + needle := []byte(testingSourcePath) + // A match split across two reads is found by overlapping them. + overlap := len(needle) - 1 + + buffer := make([]byte, chunk+overlap) + for offset := int64(0); offset < size; offset += chunk { + read, err := binary.ReadAt(buffer, offset) + if err != nil && err != io.EOF { + return false, fmt.Errorf("read binary at %d: %w", offset, err) + } + + if bytes.Contains(buffer[:read], needle) { + return true, nil + } + + if err == io.EOF { + break + } + } + + return false, nil +} + +// auditData is what cargo auditable records: the crates of the build, each marked with the +// kind of dependency it was. +type auditData struct { + Packages []auditPackage `json:"packages"` +} + +type auditPackage struct { + Name string `json:"name"` + Version string `json:"version"` + // Kind is absent for a runtime dependency — the serialiser omits its default — so + // anything that is not a build dependency ships with the binary. + Kind string `json:"kind"` +} + +const buildDependency = "build" + +func runtimeCrates(section *elf.Section) ([]Crate, error) { + compressed, err := section.Data() + if err != nil { + return nil, fmt.Errorf("read section: %w", err) + } + + reader, err := zlib.NewReader(bytes.NewReader(compressed)) + if err != nil { + return nil, fmt.Errorf("decompress: %w", err) + } + defer reader.Close() + + // Audit data of a real binary is tens of kilobytes; the cap keeps a crafted section + // from being decompressed into memory without bound. + const maxAuditData = 8 << 20 + + data, err := io.ReadAll(io.LimitReader(reader, maxAuditData)) + if err != nil { + return nil, fmt.Errorf("decompress: %w", err) + } + + var audit auditData + if err := json.Unmarshal(data, &audit); err != nil { + return nil, fmt.Errorf("parse audit data: %w", err) + } + + var crates []Crate + for _, pkg := range audit.Packages { + if pkg.Kind == buildDependency { + continue + } + + crates = append(crates, Crate{Name: pkg.Name, Version: pkg.Version}) + } + + return crates, nil +} diff --git a/pkg/sbom/shippedscope/binary_helpers_test.go b/pkg/sbom/shippedscope/binary_helpers_test.go new file mode 100644 index 0000000000..733cd8c0a6 --- /dev/null +++ b/pkg/sbom/shippedscope/binary_helpers_test.go @@ -0,0 +1,198 @@ +package shippedscope + +import ( + "bytes" + "compress/zlib" + "debug/elf" + "encoding/binary" + "os" + "os/exec" + "path/filepath" + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var ( + buildOnce sync.Once + builtGo []byte + builtTest []byte + errBuild error +) + +// goBinary and goTestBinary return a Linux binary and the test binary of the same package, +// built once for the suite. They are built rather than committed because what is under +// test is how the Go toolchain of the day records itself. +func goBinary() []byte { + buildFixtures() + + return builtGo +} + +func goTestBinary() []byte { + buildFixtures() + + return builtTest +} + +func buildFixtures() { + GinkgoHelper() + + buildOnce.Do(func() { + builtGo, builtTest, errBuild = buildGoFixtures() + }) + + Expect(errBuild).NotTo(HaveOccurred()) +} + +func buildGoFixtures() ([]byte, []byte, error) { + root, err := os.MkdirTemp("", "shippedscope-fixtures") + if err != nil { + return nil, nil, err + } + defer os.RemoveAll(root) + + sources := map[string]string{ + "lib/go.mod": "module example.test/lib\n\ngo 1.24\n", + "lib/lib.go": "package lib\n\nfunc Greet() string { return \"hi\" }\n", + "app/go.mod": "module example.test/app\n\ngo 1.24\n\nrequire example.test/lib v0.0.0\n\nreplace example.test/lib => ../lib\n", + "app/main.go": "package main\n\nimport \"example.test/lib\"\n\n" + + "func main() { println(lib.Greet()) }\n", + "app/main_test.go": "package main\n\nimport (\n\t\"testing\"\n\n\t\"example.test/lib\"\n)\n\n" + + "func TestGreet(t *testing.T) {\n\tif lib.Greet() == \"\" {\n\t\tt.Fatal(\"empty\")\n\t}\n}\n", + } + for name, body := range sources { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, nil, err + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + return nil, nil, err + } + } + + app := filepath.Join(root, "app") + binary := filepath.Join(root, "app.bin") + testBinary := filepath.Join(root, "app.test") + + for _, args := range [][]string{ + {"build", "-o", binary, "."}, + {"test", "-c", "-o", testBinary, "."}, + } { + command := exec.Command("go", args...) + command.Dir = app + command.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "GOFLAGS=-mod=mod", "CGO_ENABLED=0") + + if output, err := command.CombinedOutput(); err != nil { + return nil, nil, &exec.ExitError{ProcessState: command.ProcessState, Stderr: output} + } + } + + built, err := os.ReadFile(binary) + if err != nil { + return nil, nil, err + } + + builtTest, err := os.ReadFile(testBinary) + if err != nil { + return nil, nil, err + } + + return built, builtTest, nil +} + +func auditSection(document string) []byte { + var compressed bytes.Buffer + + writer := zlib.NewWriter(&compressed) + _, err := writer.Write([]byte(document)) + Expect(err).NotTo(HaveOccurred()) + Expect(writer.Close()).To(Succeed()) + + return compressed.Bytes() +} + +// elfWithSections writes the smallest ELF64 that debug/elf reads: a header, the named +// sections, and the section name table. A real binary of the language under test would +// pull its whole toolchain into the suite. +func elfWithSections(sections map[string][]byte) []byte { + const ( + headerSize = 64 + sectionHeaderSize = 64 + ) + + names := make([]string, 0, len(sections)) + for name := range sections { + names = append(names, name) + } + + var nameTable bytes.Buffer + nameTable.WriteByte(0) + + nameOffsets := make(map[string]uint32, len(names)+1) + for _, name := range append(names, ".shstrtab") { + nameOffsets[name] = uint32(nameTable.Len()) + nameTable.WriteString(name) + nameTable.WriteByte(0) + } + + // Section 0 is the mandatory null entry; the name table goes last. + count := len(names) + 2 + body := new(bytes.Buffer) + offsets := make(map[string]uint64, len(names)) + + dataStart := uint64(headerSize + count*sectionHeaderSize) + for _, name := range names { + offsets[name] = dataStart + uint64(body.Len()) + body.Write(sections[name]) + } + + nameTableOffset := dataStart + uint64(body.Len()) + body.Write(nameTable.Bytes()) + + header := make([]byte, headerSize) + copy(header, []byte{0x7f, 'E', 'L', 'F'}) + header[elf.EI_CLASS] = byte(elf.ELFCLASS64) + header[elf.EI_DATA] = byte(elf.ELFDATA2LSB) + header[elf.EI_VERSION] = byte(elf.EV_CURRENT) + header[elf.EI_OSABI] = byte(elf.ELFOSABI_NONE) + + order := binary.LittleEndian + order.PutUint16(header[16:], uint16(elf.ET_EXEC)) + order.PutUint16(header[18:], uint16(elf.EM_X86_64)) + order.PutUint32(header[20:], uint32(elf.EV_CURRENT)) + order.PutUint64(header[40:], uint64(headerSize)) // e_shoff + order.PutUint16(header[52:], headerSize) // e_ehsize + order.PutUint16(header[58:], sectionHeaderSize) // e_shentsize + order.PutUint16(header[60:], uint16(count)) // e_shnum + order.PutUint16(header[62:], uint16(count-1)) // e_shstrndx + + sectionHeaders := new(bytes.Buffer) + sectionHeaders.Write(make([]byte, sectionHeaderSize)) + + for _, name := range names { + sectionHeaders.Write(sectionHeader(nameOffsets[name], elf.SHT_PROGBITS, offsets[name], uint64(len(sections[name])))) + } + sectionHeaders.Write(sectionHeader(nameOffsets[".shstrtab"], elf.SHT_STRTAB, nameTableOffset, uint64(nameTable.Len()))) + + file := new(bytes.Buffer) + file.Write(header) + file.Write(sectionHeaders.Bytes()) + file.Write(body.Bytes()) + + return file.Bytes() +} + +func sectionHeader(name uint32, kind elf.SectionType, offset, size uint64) []byte { + header := make([]byte, 64) + order := binary.LittleEndian + + order.PutUint32(header[0:], name) + order.PutUint32(header[4:], uint32(kind)) + order.PutUint64(header[24:], offset) + order.PutUint64(header[32:], size) + order.PutUint64(header[56:], 1) // sh_addralign + + return header +} diff --git a/pkg/sbom/shippedscope/binary_test.go b/pkg/sbom/shippedscope/binary_test.go new file mode 100644 index 0000000000..4fd7d472dc --- /dev/null +++ b/pkg/sbom/shippedscope/binary_test.go @@ -0,0 +1,154 @@ +package shippedscope + +import ( + "bytes" + "runtime/debug" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ClassifyBinary, build info", func() { + It("holds the modules the linker put in, by path and never by version", func() { + verdict, modules := goEvidence(&debug.BuildInfo{ + GoVersion: "go1.24.9", + Path: "example.test/app", + Deps: []*debug.Module{ + {Path: "github.com/spf13/cobra", Version: "v1.8.0"}, + {Path: "gopkg.in/yaml.v3", Version: "v3.0.1"}, + }, + }) + + Expect(verdict).To(Equal(VerdictNames)) + Expect(modules).To(Equal([]string{"github.com/spf13/cobra", "gopkg.in/yaml.v3"})) + }) + + It("holds both sides of a replace, so a fork answers under either path", func() { + verdict, modules := goEvidence(&debug.BuildInfo{ + GoVersion: "go1.24.9", + Path: "example.test/app", + Deps: []*debug.Module{ + { + Path: "github.com/spf13/cobra", + Version: "v1.8.0", + Replace: &debug.Module{Path: "github.com/werf/3p-cobra", Version: "v1.8.1"}, + }, + }, + }) + + Expect(verdict).To(Equal(VerdictNames)) + Expect(modules).To(ContainElements("github.com/spf13/cobra", "github.com/werf/3p-cobra")) + }) + + It("holds nothing for a binary that linked no dependency", func() { + verdict, modules := goEvidence(&debug.BuildInfo{ + GoVersion: "go1.24.9", + Path: "example.test/app", + Settings: []debug.BuildSetting{{Key: "GOARCH", Value: "amd64"}}, + }) + + Expect(verdict).To(Equal(VerdictNames)) + Expect(modules).To(BeEmpty()) + }) + + It("holds everything when the build info was wiped, empty dependencies and all", func() { + verdict, modules := goEvidence(&debug.BuildInfo{}) + + Expect(verdict).To(Equal(VerdictUnknown)) + Expect(modules).To(BeEmpty()) + }) +}) + +var _ = Describe("ClassifyBinary", func() { + It("reads the modules of a Go binary", func(ctx SpecContext) { + binary := goBinary() + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.TestBinary).To(BeFalse()) + Expect(classification.Go).To(Equal(VerdictNames)) + Expect(classification.Modules).To(ContainElement("example.test/lib")) + Expect(classification.Rust).To(Equal(VerdictNone)) + }) + + It("lets a test binary speak for nobody, however alive its dependencies are", func(ctx SpecContext) { + binary := goTestBinary() + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.TestBinary).To(BeTrue()) + Expect(classification.Go).To(Equal(VerdictNone)) + Expect(classification.Modules).To(BeEmpty()) + }) + + It("holds every crate the audit data calls a runtime dependency", func(ctx SpecContext) { + binary := elfWithSections(map[string][]byte{ + rustDepSection: auditSection(`{"packages":[ + {"name":"rsapp","version":"0.1.0","source":"local","root":true}, + {"name":"serde","version":"1.0.210","source":"crates.io"}, + {"name":"anyhow","version":"1.0.89","source":"crates.io","kind":"runtime"}, + {"name":"cc","version":"1.1.0","source":"crates.io","kind":"build"} + ]}`), + }) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.Rust).To(Equal(VerdictNames)) + Expect(classification.Crates).To(Equal([]Crate{ + {Name: "rsapp", Version: "0.1.0"}, + {Name: "serde", Version: "1.0.210"}, + {Name: "anyhow", Version: "1.0.89"}, + })) + Expect(classification.Go).To(Equal(VerdictNone)) + }) + + It("holds every crate when the audit data cannot be read", func(ctx SpecContext) { + binary := elfWithSections(map[string][]byte{ + rustDepSection: []byte("not a zlib stream"), + }) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.Rust).To(Equal(VerdictUnknown)) + Expect(classification.Crates).To(BeEmpty()) + }) + + It("holds every module of a Go binary whose build info is unreadable", func(ctx SpecContext) { + binary := elfWithSections(map[string][]byte{ + ".gopclntab": []byte("stripped of its build info"), + }) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.Go).To(Equal(VerdictUnknown)) + Expect(classification.Rust).To(Equal(VerdictNone)) + }) + + It("holds no Go module for a binary of another language, and every crate for want of audit data", func(ctx SpecContext) { + binary := elfWithSections(map[string][]byte{ + ".rodata": []byte("a C binary"), + }) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.Go).To(Equal(VerdictNone)) + Expect(classification.Rust).To(Equal(VerdictUnknown)) + }) + + It("speaks for nobody when the file is not a binary at all", func(ctx SpecContext) { + script := []byte("#!/bin/sh\necho hi\n") + + classification, err := ClassifyBinary(ctx, bytes.NewReader(script), int64(len(script))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.TestBinary).To(BeFalse()) + Expect(classification.Go).To(Equal(VerdictNone)) + Expect(classification.Rust).To(Equal(VerdictNone)) + }) +}) From 756666c68a9f2d88526de165e73e82678626538e Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 16 Oct 2026 11:08:31 +0300 Subject: [PATCH 2/4] fix(sbom): stop a tool that links testing from holding no component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review and a cross-check against the measurements behind the design found two ways a binary could silently lose every component it carries, which is the one failure direction this reader exists to avoid. The test-binary check matched the path of the testing package's own source. That string is in every binary linking the testing package, not only in one `go test -c` produced: gopls, mockgen and werf itself all carry it, and each would have been read as a test binary, holding nothing. Match the source path of the main `go test -c` generates instead — it is absent from all three and survives -trimpath and -ldflags="-s -w". The old needle does not survive -trimpath, so it also missed real test binaries. The wiped-build-info branch required an empty GoVersion. An obfuscator fills that field with a version string rather than clearing it, so the branch never fired and an obfuscated binary fell through to "no dependencies, holds nothing" with its dependencies alive. Test the module path and the settings, which are empty in that case and populated in every honest binary. Read Go and Rust independently rather than returning on the first match: a cgo binary linking an audited Rust archive carries the evidence of both, and whichever was read second was being dropped. A Go binary built without cgo now holds no crate instead of all of them, since it can link no Rust object. Resolve a file that claims to be an ELF but cannot be parsed, and a read that fails outright, to holding every component rather than none. Reject audit data whose recorded size exceeds the cap before reading it, because a compressed section is inflated inside Data() and the later limit would not apply. Signed-off-by: Radmir Khurum --- pkg/sbom/shippedscope/binary.go | 148 ++++++++++++++----- pkg/sbom/shippedscope/binary_helpers_test.go | 123 ++++++++++++--- pkg/sbom/shippedscope/binary_test.go | 107 +++++++++++++- 3 files changed, 314 insertions(+), 64 deletions(-) diff --git a/pkg/sbom/shippedscope/binary.go b/pkg/sbom/shippedscope/binary.go index 850157e613..a4ef09d64e 100644 --- a/pkg/sbom/shippedscope/binary.go +++ b/pkg/sbom/shippedscope/binary.go @@ -7,6 +7,7 @@ import ( "debug/buildinfo" "debug/elf" "encoding/json" + "errors" "fmt" "io" "runtime/debug" @@ -51,9 +52,12 @@ type BinaryClassification struct { } const ( - testingSourcePath = "src/testing/testing.go" + // The generated main of a `go test -c` binary. Its own source path is the only mark + // that tells a test binary from a tool that merely links the testing package. + testMainSource = "_testmain.go" // The section cargo-auditable writes, holding zlib-compressed JSON. - rustDepSection = ".dep-v0" + rustDepSection = ".dep-v0" + cgoEnabledSetting = "CGO_ENABLED" ) // goSections name a Go binary even when its build info is gone. @@ -69,65 +73,106 @@ var goSections = []string{".gopclntab", ".go.buildinfo"} // obfuscator leaves an empty dependency list behind on a binary whose dependencies are // alive; reading that as knowledge would drop every one of them. // -// A file that is not an ELF binary speaks for nobody: images here are Linux images, and a -// Go or Rust binary in one is an ELF. +// Go and Rust are read independently: a cgo binary linking an audited Rust archive carries +// the evidence of both, and an early return on either would drop the other. +// +// A file that is not an ELF at all speaks for nobody: images here are Linux images, and a +// Go or Rust binary in one is an ELF. A file that looks like an ELF but cannot be parsed +// holds everything instead — "I cannot read this" is not "this contains nothing". func ClassifyBinary(ctx context.Context, binary io.ReaderAt, size int64) (BinaryClassification, error) { - classification := BinaryClassification{Go: VerdictNone, Rust: VerdictNone} + none := BinaryClassification{Go: VerdictNone, Rust: VerdictNone} + unknown := BinaryClassification{Go: VerdictUnknown, Rust: VerdictUnknown} + + elfBinary, err := looksLikeELF(binary) + if err != nil { + return unknown, err + } + if !elfBinary { + return none, nil + } file, err := elf.NewFile(binary) if err != nil { - return classification, nil + logboek.Context(ctx).Debug().LogF("parse ELF: %s\n", err) + + return unknown, nil } defer file.Close() - testBinary, err := carriesTestingSource(binary, size) + testBinary, err := carriesTestMain(binary, size) if err != nil { - return classification, err + return unknown, err } if testBinary { + classification := none classification.TestBinary = true return classification, nil } - if section := file.Section(rustDepSection); section != nil { - crates, err := runtimeCrates(section) - if err != nil { - logboek.Context(ctx).Debug().LogF("read %s: %s\n", rustDepSection, err) - classification.Rust = VerdictUnknown - - return classification, nil - } + info, err := buildinfo.Read(binary) - classification.Rust = VerdictNames - classification.Crates = crates + classification := none + classification.Go, classification.Modules = goEvidenceOf(ctx, file, info, err) + classification.Rust, classification.Crates = rustEvidence(ctx, file, info) - return classification, nil - } + return classification, nil +} - info, err := buildinfo.Read(binary) +func goEvidenceOf(ctx context.Context, file *elf.File, info *debug.BuildInfo, err error) (Verdict, []string) { if err != nil { if carriesGoSections(file) { - classification.Go = VerdictUnknown + logboek.Context(ctx).Debug().LogF("read Go build info: %s\n", err) - return classification, nil + return VerdictUnknown, nil } - // Not a Go binary, and no audit data: it holds no Go module, and whether it holds - // a crate cannot be told from a Rust binary built without cargo auditable. - classification.Rust = VerdictUnknown + return VerdictNone, nil + } - return classification, nil + return goEvidence(info) +} + +func rustEvidence(ctx context.Context, file *elf.File, info *debug.BuildInfo) (Verdict, []Crate) { + section := file.Section(rustDepSection) + if section == nil { + if builtWithoutCgo(info) { + // A Go binary that links no C object carries no crate either. + return VerdictNone, nil + } + + // Nothing can be told about crates from a Rust binary built without cargo + // auditable, and a binary of another language is indistinguishable from one. + return VerdictUnknown, nil } - classification.Go, classification.Modules = goEvidence(info) + crates, err := runtimeCrates(section) + if err != nil { + logboek.Context(ctx).Debug().LogF("read %s: %s\n", rustDepSection, err) - return classification, nil + return VerdictUnknown, nil + } + + return VerdictNames, crates +} + +func looksLikeELF(binary io.ReaderAt) (bool, error) { + magic := make([]byte, 4) + + read, err := binary.ReadAt(magic, 0) + if err != nil && !errors.Is(err, io.EOF) { + return false, fmt.Errorf("read binary header: %w", err) + } + + return read == len(magic) && bytes.Equal(magic, []byte{0x7f, 'E', 'L', 'F'}), nil } // goEvidence reads the build info of a binary already known not to be a test binary. func goEvidence(info *debug.BuildInfo) (Verdict, []string) { - if info.GoVersion == "" && info.Path == "" && len(info.Settings) == 0 { + // An obfuscator leaves the module path and the settings empty while the dependency + // list it emptied describes a binary whose dependencies are alive. GoVersion is no + // part of the test: garble fills it with "unknown" rather than clearing it. + if info.Path == "" && len(info.Settings) == 0 { return VerdictUnknown, nil } @@ -143,6 +188,20 @@ func goEvidence(info *debug.BuildInfo) (Verdict, []string) { return VerdictNames, modules } +func builtWithoutCgo(info *debug.BuildInfo) bool { + if info == nil { + return false + } + + for _, setting := range info.Settings { + if setting.Key == cgoEnabledSetting { + return setting.Value == "0" + } + } + + return false +} + func carriesGoSections(file *elf.File) bool { for _, name := range goSections { if file.Section(name) != nil { @@ -153,19 +212,21 @@ func carriesGoSections(file *elf.File) bool { return false } -// carriesTestingSource looks for the path of the testing package's own source, which the -// test runner inside every `go test -c` binary carries and a strip does not touch. -func carriesTestingSource(binary io.ReaderAt, size int64) (bool, error) { +// carriesTestMain looks for the source path of the main `go test -c` generates, which +// survives -trimpath and -ldflags="-s -w". The path of the testing package's own source +// cannot serve here: every binary linking the testing package carries it, production tools +// among them, and a tool misread as a test binary would hold no component at all. +func carriesTestMain(binary io.ReaderAt, size int64) (bool, error) { const chunk = 1 << 20 - needle := []byte(testingSourcePath) + needle := []byte(testMainSource) // A match split across two reads is found by overlapping them. overlap := len(needle) - 1 buffer := make([]byte, chunk+overlap) for offset := int64(0); offset < size; offset += chunk { read, err := binary.ReadAt(buffer, offset) - if err != nil && err != io.EOF { + if err != nil && !errors.Is(err, io.EOF) { return false, fmt.Errorf("read binary at %d: %w", offset, err) } @@ -173,7 +234,7 @@ func carriesTestingSource(binary io.ReaderAt, size int64) (bool, error) { return true, nil } - if err == io.EOF { + if errors.Is(err, io.EOF) { break } } @@ -195,9 +256,18 @@ type auditPackage struct { Kind string `json:"kind"` } -const buildDependency = "build" +const ( + buildDependency = "build" + maxAuditData = 8 << 20 +) func runtimeCrates(section *elf.Section) ([]Crate, error) { + // Audit data of a real binary is tens of kilobytes. The size is checked before the + // section is read because debug/elf inflates a compressed section inside Data(). + if section.Size > maxAuditData { + return nil, fmt.Errorf("section is %d bytes, over the %d limit", section.Size, maxAuditData) + } + compressed, err := section.Data() if err != nil { return nil, fmt.Errorf("read section: %w", err) @@ -209,10 +279,6 @@ func runtimeCrates(section *elf.Section) ([]Crate, error) { } defer reader.Close() - // Audit data of a real binary is tens of kilobytes; the cap keeps a crafted section - // from being decompressed into memory without bound. - const maxAuditData = 8 << 20 - data, err := io.ReadAll(io.LimitReader(reader, maxAuditData)) if err != nil { return nil, fmt.Errorf("decompress: %w", err) diff --git a/pkg/sbom/shippedscope/binary_helpers_test.go b/pkg/sbom/shippedscope/binary_helpers_test.go index 733cd8c0a6..bc964f8387 100644 --- a/pkg/sbom/shippedscope/binary_helpers_test.go +++ b/pkg/sbom/shippedscope/binary_helpers_test.go @@ -5,9 +5,15 @@ import ( "compress/zlib" "debug/elf" "encoding/binary" + "errors" + "fmt" + "io" "os" "os/exec" "path/filepath" + "slices" + "sort" + "strings" "sync" . "github.com/onsi/ginkgo/v2" @@ -15,10 +21,11 @@ import ( ) var ( - buildOnce sync.Once - builtGo []byte - builtTest []byte - errBuild error + buildOnce sync.Once + builtGo []byte + builtTest []byte + builtLinkedGo []byte + errBuild error ) // goBinary and goTestBinary return a Linux binary and the test binary of the same package, @@ -36,20 +43,29 @@ func goTestBinary() []byte { return builtTest } +// goTestingLinkedBinary is an ordinary tool that imports the testing package, as gopls, +// mockgen and werf itself do. It carries the path of the testing package's own source +// while being no test binary at all. +func goTestingLinkedBinary() []byte { + buildFixtures() + + return builtLinkedGo +} + func buildFixtures() { GinkgoHelper() buildOnce.Do(func() { - builtGo, builtTest, errBuild = buildGoFixtures() + builtGo, builtTest, builtLinkedGo, errBuild = buildGoFixtures() }) Expect(errBuild).NotTo(HaveOccurred()) } -func buildGoFixtures() ([]byte, []byte, error) { +func buildGoFixtures() ([]byte, []byte, []byte, error) { root, err := os.MkdirTemp("", "shippedscope-fixtures") if err != nil { - return nil, nil, err + return nil, nil, nil, fmt.Errorf("create fixture root: %w", err) } defer os.RemoveAll(root) @@ -61,45 +77,107 @@ func buildGoFixtures() ([]byte, []byte, error) { "func main() { println(lib.Greet()) }\n", "app/main_test.go": "package main\n\nimport (\n\t\"testing\"\n\n\t\"example.test/lib\"\n)\n\n" + "func TestGreet(t *testing.T) {\n\tif lib.Greet() == \"\" {\n\t\tt.Fatal(\"empty\")\n\t}\n}\n", + "tool/go.mod": "module example.test/tool\n\ngo 1.24\n\nrequire example.test/lib v0.0.0\n\nreplace example.test/lib => ../lib\n", + "tool/main.go": "package main\n\nimport (\n\t\"testing\"\n\n\t\"example.test/lib\"\n)\n\n" + + "func main() {\n\ttesting.Init()\n\tprintln(lib.Greet())\n}\n", } for name, body := range sources { path := filepath.Join(root, name) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return nil, nil, err + return nil, nil, nil, fmt.Errorf("create %s: %w", name, err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - return nil, nil, err + return nil, nil, nil, fmt.Errorf("write %s: %w", name, err) } } - app := filepath.Join(root, "app") binary := filepath.Join(root, "app.bin") testBinary := filepath.Join(root, "app.test") - - for _, args := range [][]string{ - {"build", "-o", binary, "."}, - {"test", "-c", "-o", testBinary, "."}, - } { - command := exec.Command("go", args...) - command.Dir = app + toolBinary := filepath.Join(root, "tool.bin") + + builds := []struct { + module string + args []string + }{ + {module: "app", args: []string{"build", "-o", binary, "."}}, + {module: "app", args: []string{"test", "-c", "-o", testBinary, "."}}, + {module: "tool", args: []string{"build", "-o", toolBinary, "."}}, + } + for _, build := range builds { + command := exec.Command("go", build.args...) + command.Dir = filepath.Join(root, build.module) command.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "GOFLAGS=-mod=mod", "CGO_ENABLED=0") if output, err := command.CombinedOutput(); err != nil { - return nil, nil, &exec.ExitError{ProcessState: command.ProcessState, Stderr: output} + return nil, nil, nil, fmt.Errorf("go %s in %s: %w: %s", strings.Join(build.args, " "), build.module, err, output) } } built, err := os.ReadFile(binary) if err != nil { - return nil, nil, err + return nil, nil, nil, fmt.Errorf("read %s: %w", binary, err) } builtTest, err := os.ReadFile(testBinary) if err != nil { - return nil, nil, err + return nil, nil, nil, fmt.Errorf("read %s: %w", testBinary, err) + } + + builtTool, err := os.ReadFile(toolBinary) + if err != nil { + return nil, nil, nil, fmt.Errorf("read %s: %w", toolBinary, err) + } + + return built, builtTest, builtTool, nil +} + +// failingReaderAt serves the small reads that parsing an ELF needs and fails the large +// ones the whole-file scan makes. +type failingReaderAt struct { + io.ReaderAt + + failLargerThan int +} + +func (r failingReaderAt) ReadAt(p []byte, off int64) (int, error) { + if len(p) > r.failLargerThan { + return 0, errors.New("read failed") } - return built, builtTest, nil + return r.ReaderAt.ReadAt(p, off) +} + +// oversizedSection rewrites the recorded size of a section so it claims to hold more than +// the reader accepts, without the file actually carrying those bytes. +func oversizedSection(file []byte, name string) []byte { + parsed, err := elf.NewFile(bytes.NewReader(parsedCopy(file))) + Expect(err).NotTo(HaveOccurred()) + defer parsed.Close() + + index := -1 + for i, section := range parsed.Sections { + if section.Name == name { + index = i + + break + } + } + Expect(index).NotTo(Equal(-1)) + + const ( + headerSize = 64 + sectionHeaderSize = 64 + sizeOffset = 32 + ) + + rewritten := parsedCopy(file) + binary.LittleEndian.PutUint64(rewritten[headerSize+index*sectionHeaderSize+sizeOffset:], maxAuditData+1) + + return rewritten +} + +func parsedCopy(file []byte) []byte { + return append([]byte{}, file...) } func auditSection(document string) []byte { @@ -126,12 +204,13 @@ func elfWithSections(sections map[string][]byte) []byte { for name := range sections { names = append(names, name) } + sort.Strings(names) var nameTable bytes.Buffer nameTable.WriteByte(0) nameOffsets := make(map[string]uint32, len(names)+1) - for _, name := range append(names, ".shstrtab") { + for _, name := range append(slices.Clone(names), ".shstrtab") { nameOffsets[name] = uint32(nameTable.Len()) nameTable.WriteString(name) nameTable.WriteByte(0) diff --git a/pkg/sbom/shippedscope/binary_test.go b/pkg/sbom/shippedscope/binary_test.go index 4fd7d472dc..491ab2da69 100644 --- a/pkg/sbom/shippedscope/binary_test.go +++ b/pkg/sbom/shippedscope/binary_test.go @@ -2,6 +2,7 @@ package shippedscope import ( "bytes" + "debug/elf" "runtime/debug" . "github.com/onsi/ginkgo/v2" @@ -57,8 +58,28 @@ var _ = Describe("ClassifyBinary, build info", func() { Expect(verdict).To(Equal(VerdictUnknown)) Expect(modules).To(BeEmpty()) }) + + It("holds everything when an obfuscator left a version string behind", func() { + // What garble actually emits: it fills GoVersion rather than clearing it. + verdict, modules := goEvidence(&debug.BuildInfo{GoVersion: "unknown"}) + + Expect(verdict).To(Equal(VerdictUnknown)) + Expect(modules).To(BeEmpty()) + }) }) +var _ = DescribeTable("ClassifyBinary, crates of a Go binary", + func(settings []debug.BuildSetting, expected bool) { + Expect(builtWithoutCgo(&debug.BuildInfo{Settings: settings})).To(Equal(expected)) + }, + Entry("a binary linking no C object carries no crate", + []debug.BuildSetting{{Key: "CGO_ENABLED", Value: "0"}}, true), + Entry("a cgo binary may link an audit-less Rust archive", + []debug.BuildSetting{{Key: "CGO_ENABLED", Value: "1"}}, false), + Entry("build info that does not say leaves it open", + []debug.BuildSetting{{Key: "GOARCH", Value: "amd64"}}, false), +) + var _ = Describe("ClassifyBinary", func() { It("reads the modules of a Go binary", func(ctx SpecContext) { binary := goBinary() @@ -105,6 +126,90 @@ var _ = Describe("ClassifyBinary", func() { Expect(classification.Go).To(Equal(VerdictNone)) }) + It("lets a tool that merely links the testing package keep its modules", func(ctx SpecContext) { + binary := goTestingLinkedBinary() + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.TestBinary).To(BeFalse()) + Expect(classification.Go).To(Equal(VerdictNames)) + Expect(classification.Modules).To(ContainElement("example.test/lib")) + }) + + It("reads both ecosystems of a binary that carries evidence of each", func(ctx SpecContext) { + binary := elfWithSections(map[string][]byte{ + rustDepSection: auditSection(`{"packages":[{"name":"serde","version":"1.0.210","source":"crates.io"}]}`), + ".gopclntab": []byte("a Go binary whose build info cannot be read"), + }) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.Rust).To(Equal(VerdictNames)) + Expect(classification.Crates).To(Equal([]Crate{{Name: "serde", Version: "1.0.210"}})) + Expect(classification.Go).To(Equal(VerdictUnknown)) + }) + + It("holds everything when a file claims to be an ELF but cannot be parsed", func(ctx SpecContext) { + truncated := append([]byte{0x7f, 'E', 'L', 'F'}, bytes.Repeat([]byte{0}, 40)...) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(truncated), int64(len(truncated))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.Go).To(Equal(VerdictUnknown)) + Expect(classification.Rust).To(Equal(VerdictUnknown)) + }) + + It("finds the test main when it straddles two reads", func(ctx SpecContext) { + // The first read covers [0, chunk+overlap); a needle crossing its far edge is + // found only because the next read starts at chunk rather than where it ended. + const ( + chunk = 1 << 20 + overlap = len(testMainSource) - 1 + sectionHead = 64 + 3*64 + ) + + padding := bytes.Repeat([]byte{'x'}, chunk+overlap-sectionHead-4) + binary := elfWithSections(map[string][]byte{ + ".rodata": append(padding, []byte(testMainSource)...), + }) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.TestBinary).To(BeTrue()) + }) + + It("refuses audit data larger than the cap before reading it", func(ctx SpecContext) { + binary := oversizedSection(elfWithSections(map[string][]byte{ + rustDepSection: auditSection(`{"packages":[{"name":"serde","version":"1.0.210","source":"crates.io"}]}`), + }), rustDepSection) + + file, err := elf.NewFile(bytes.NewReader(binary)) + Expect(err).NotTo(HaveOccurred()) + defer file.Close() + + // A compressed section is inflated inside Data(), so the size has to be rejected + // before the read rather than capped after it. + _, err = runtimeCrates(file.Section(rustDepSection)) + Expect(err).To(MatchError(ContainSubstring("over the"))) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + Expect(classification.Rust).To(Equal(VerdictUnknown)) + }) + + It("holds everything when the binary cannot be read", func(ctx SpecContext) { + binary := goBinary() + + classification, err := ClassifyBinary(ctx, failingReaderAt{ReaderAt: bytes.NewReader(binary), failLargerThan: 1 << 19}, int64(len(binary))) + Expect(err).To(HaveOccurred()) + + Expect(classification.Go).To(Equal(VerdictUnknown)) + Expect(classification.Rust).To(Equal(VerdictUnknown)) + }) + It("holds every crate when the audit data cannot be read", func(ctx SpecContext) { binary := elfWithSections(map[string][]byte{ rustDepSection: []byte("not a zlib stream"), @@ -126,7 +231,7 @@ var _ = Describe("ClassifyBinary", func() { Expect(err).NotTo(HaveOccurred()) Expect(classification.Go).To(Equal(VerdictUnknown)) - Expect(classification.Rust).To(Equal(VerdictNone)) + Expect(classification.Rust).To(Equal(VerdictUnknown)) }) It("holds no Go module for a binary of another language, and every crate for want of audit data", func(ctx SpecContext) { From ef6e9335c48af37c8ba43cdea920ebdff5dce168 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 16 Oct 2026 11:31:24 +0300 Subject: [PATCH 3/4] test(sbom): guard the scan overlap, and skip the scan on non-Go binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec that claimed to cover the overlapping reads placed the mark across the far edge of the first read, which the next read covers whole. Removing the overlap left it green: it guarded the stride and not the overlap. Place the mark across both boundaries — the stride, which only a buffer longer than the stride spans, and the far edge, which only a read starting back at the stride spans. Each boundary now has a mutation that fails it. The scan read every ELF end to end before anything established the file was Go, so an image full of shared libraries paid for a Go-only question on each of them. Only a Go binary can be a test binary, so gate the scan on the Go sections. A binary of another language that happens to quote the mark is no longer read as a test binary either. Record in the godoc what the mark cannot distinguish: the `go` tool names the file it generates, so it reads as a test binary. Record what the Rust fail-open costs in aggregate — one unaudited ELF makes an image hold every crate — since that is the part task E has to live with. Signed-off-by: Radmir Khurum --- pkg/sbom/shippedscope/binary.go | 30 ++++++++++---- pkg/sbom/shippedscope/binary_helpers_test.go | 19 +++++++++ pkg/sbom/shippedscope/binary_test.go | 43 ++++++++++++-------- 3 files changed, 67 insertions(+), 25 deletions(-) diff --git a/pkg/sbom/shippedscope/binary.go b/pkg/sbom/shippedscope/binary.go index a4ef09d64e..6e6b5cc8a8 100644 --- a/pkg/sbom/shippedscope/binary.go +++ b/pkg/sbom/shippedscope/binary.go @@ -99,15 +99,19 @@ func ClassifyBinary(ctx context.Context, binary io.ReaderAt, size int64) (Binary } defer file.Close() - testBinary, err := carriesTestMain(binary, size) - if err != nil { - return unknown, err - } - if testBinary { - classification := none - classification.TestBinary = true + if carriesGoSections(file) { + // Only a Go binary can be a test binary, and the scan reads the file end to end: + // running it on every shared library in an image would cost seconds per image. + testBinary, err := carriesTestMain(binary, size) + if err != nil { + return unknown, err + } + if testBinary { + classification := none + classification.TestBinary = true - return classification, nil + return classification, nil + } } info, err := buildinfo.Read(binary) @@ -142,7 +146,10 @@ func rustEvidence(ctx context.Context, file *elf.File, info *debug.BuildInfo) (V } // Nothing can be told about crates from a Rust binary built without cargo - // auditable, and a binary of another language is indistinguishable from one. + // auditable, and a binary of another language is indistinguishable from one. The + // aggregate is blunt on purpose: one unaudited ELF — a libc, a shell — makes the + // image hold every crate. Narrowing it needs a test for "is this Rust at all", + // which no cheap and reliable mark provides today. return VerdictUnknown, nil } @@ -216,6 +223,11 @@ func carriesGoSections(file *elf.File) bool { // survives -trimpath and -ldflags="-s -w". The path of the testing package's own source // cannot serve here: every binary linking the testing package carries it, production tools // among them, and a tool misread as a test binary would hold no component at all. +// +// The mark is a string in the file, so a binary carrying the string for another reason +// reads as a test binary — the `go` tool itself does, since it has to name the file it +// generates. An image shipping the Go toolchain therefore holds no module on account of +// `go`, which is the safe direction for a build tool but worth knowing. func carriesTestMain(binary io.ReaderAt, size int64) (bool, error) { const chunk = 1 << 20 diff --git a/pkg/sbom/shippedscope/binary_helpers_test.go b/pkg/sbom/shippedscope/binary_helpers_test.go index bc964f8387..0f37ddee3b 100644 --- a/pkg/sbom/shippedscope/binary_helpers_test.go +++ b/pkg/sbom/shippedscope/binary_helpers_test.go @@ -180,6 +180,25 @@ func parsedCopy(file []byte) []byte { return append([]byte{}, file...) } +// elfWithTestMainAt places the test-main mark at an exact offset in the file, so a spec +// can put it across a read boundary without knowing the layout of the ELF writer. +func elfWithTestMainAt(offset int) []byte { + sections := func(padding int) map[string][]byte { + return map[string][]byte{ + // A test binary is a Go binary, and the scan runs only on those. + ".gopclntab": []byte("go"), + ".rodata": append(bytes.Repeat([]byte{'x'}, padding), []byte(testMainSource)...), + } + } + + empty := elfWithSections(sections(0)) + start := bytes.Index(empty, []byte(testMainSource)) + Expect(start).NotTo(Equal(-1)) + Expect(offset).To(BeNumerically(">=", start)) + + return elfWithSections(sections(offset - start)) +} + func auditSection(document string) []byte { var compressed bytes.Buffer diff --git a/pkg/sbom/shippedscope/binary_test.go b/pkg/sbom/shippedscope/binary_test.go index 491ab2da69..a9544b17e7 100644 --- a/pkg/sbom/shippedscope/binary_test.go +++ b/pkg/sbom/shippedscope/binary_test.go @@ -151,6 +151,18 @@ var _ = Describe("ClassifyBinary", func() { Expect(classification.Go).To(Equal(VerdictUnknown)) }) + It("does not read a binary of another language as a test binary", func(ctx SpecContext) { + binary := elfWithSections(map[string][]byte{ + ".rodata": []byte("a C binary quoting _testmain.go for its own reasons"), + }) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.TestBinary).To(BeFalse()) + Expect(classification.Go).To(Equal(VerdictNone)) + }) + It("holds everything when a file claims to be an ELF but cannot be parsed", func(ctx SpecContext) { truncated := append([]byte{0x7f, 'E', 'L', 'F'}, bytes.Repeat([]byte{0}, 40)...) @@ -161,25 +173,24 @@ var _ = Describe("ClassifyBinary", func() { Expect(classification.Rust).To(Equal(VerdictUnknown)) }) - It("finds the test main when it straddles two reads", func(ctx SpecContext) { - // The first read covers [0, chunk+overlap); a needle crossing its far edge is - // found only because the next read starts at chunk rather than where it ended. - const ( - chunk = 1 << 20 - overlap = len(testMainSource) - 1 - sectionHead = 64 + 3*64 - ) + DescribeTable("finds the test main however it straddles the reads", + func(ctx SpecContext, position func(chunk, overlap int) int) { + const chunk = 1 << 20 - padding := bytes.Repeat([]byte{'x'}, chunk+overlap-sectionHead-4) - binary := elfWithSections(map[string][]byte{ - ".rodata": append(padding, []byte(testMainSource)...), - }) + overlap := len(testMainSource) - 1 + binary := elfWithTestMainAt(position(chunk, overlap)) - classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) - Expect(err).NotTo(HaveOccurred()) + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) - Expect(classification.TestBinary).To(BeTrue()) - }) + Expect(classification.TestBinary).To(BeTrue()) + }, + // Crossing the stride is caught only by the buffer being longer than the stride. + Entry("across the stride boundary", func(chunk, _ int) int { return chunk - 4 }), + // Crossing the far edge of a read is caught only by the next read starting back + // at the stride rather than where the previous one ended. + Entry("across the far edge of the first read", func(chunk, overlap int) int { return chunk + overlap - 4 }), + ) It("refuses audit data larger than the cap before reading it", func(ctx SpecContext) { binary := oversizedSection(elfWithSections(map[string][]byte{ From 0e21c6f63edaa4ec99ab690406389f153b3b2e18 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 16 Oct 2026 11:53:44 +0300 Subject: [PATCH 4/4] fix(sbom): require two marks of a test binary, and hold for a sectionless ELF The `go` tool writes the generated test main's path as a literal, so the one mark that told a test binary apart also matched the toolchain itself, and an image shipping it would hold no module on its account. That is a drop, not the safe direction the godoc called it. Require the test runner's own function alongside: measured on go1.24.9, the toolchain carries the path and not the runner, a test binary carries both in every build mode, and no production tool at hand carries either. Sections were the only thing telling "not Go" from "Go I cannot read", so a binary stripped of its section headers was read as not Go and held nothing. Scan it for the test marks regardless, and hold every module when its build info cannot be read, as the Rust side already did. Reject a non-positive size instead of silently skipping the scan, wrap the errors leaving ClassifyBinary, and cap the inflated side of the audit data as well as the stored one: the two sizes are unrelated, and the previous cap truncated silently. Confirm the garble branch against a binary garble built here on go1.27: GoVersion="unknown", Path="", Settings=0, Deps=0. Signed-off-by: Radmir Khurum --- pkg/sbom/shippedscope/binary.go | 70 ++++++++++++----- pkg/sbom/shippedscope/binary_helpers_test.go | 25 +++++- pkg/sbom/shippedscope/binary_test.go | 80 +++++++++++++++++++- 3 files changed, 149 insertions(+), 26 deletions(-) diff --git a/pkg/sbom/shippedscope/binary.go b/pkg/sbom/shippedscope/binary.go index 6e6b5cc8a8..a6a07dab39 100644 --- a/pkg/sbom/shippedscope/binary.go +++ b/pkg/sbom/shippedscope/binary.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "runtime/debug" + "slices" "github.com/werf/logboek" ) @@ -52,9 +53,13 @@ type BinaryClassification struct { } const ( - // The generated main of a `go test -c` binary. Its own source path is the only mark - // that tells a test binary from a tool that merely links the testing package. + // Two marks together name a `go test -c` binary. The generated main's source path + // alone is not enough: the `go` tool carries it as the literal it writes that file + // with. The test runner's own function is not enough either: a binary embedding a + // test framework can reach it. Only a binary that both generates a test main and + // runs it is a test binary. testMainSource = "_testmain.go" + testRunnerFunc = "testing.tRunner" // The section cargo-auditable writes, holding zlib-compressed JSON. rustDepSection = ".dep-v0" cgoEnabledSetting = "CGO_ENABLED" @@ -83,9 +88,13 @@ func ClassifyBinary(ctx context.Context, binary io.ReaderAt, size int64) (Binary none := BinaryClassification{Go: VerdictNone, Rust: VerdictNone} unknown := BinaryClassification{Go: VerdictUnknown, Rust: VerdictUnknown} + if size <= 0 { + return unknown, fmt.Errorf("classify binary: size is %d", size) + } + elfBinary, err := looksLikeELF(binary) if err != nil { - return unknown, err + return unknown, fmt.Errorf("classify binary: %w", err) } if !elfBinary { return none, nil @@ -99,12 +108,14 @@ func ClassifyBinary(ctx context.Context, binary io.ReaderAt, size int64) (Binary } defer file.Close() - if carriesGoSections(file) { + if carriesGoSections(file) || len(file.Sections) == 0 { // Only a Go binary can be a test binary, and the scan reads the file end to end: - // running it on every shared library in an image would cost seconds per image. - testBinary, err := carriesTestMain(binary, size) + // running it on every shared library in an image would cost seconds per image. A + // binary with no section headers at all is scanned regardless, since nothing + // there says whether it is Go. + testBinary, err := carriesTestMarks(binary, size) if err != nil { - return unknown, err + return unknown, fmt.Errorf("classify binary: %w", err) } if testBinary { classification := none @@ -125,7 +136,9 @@ func ClassifyBinary(ctx context.Context, binary io.ReaderAt, size int64) (Binary func goEvidenceOf(ctx context.Context, file *elf.File, info *debug.BuildInfo, err error) (Verdict, []string) { if err != nil { - if carriesGoSections(file) { + // Sections are what tells "not Go" from "Go I cannot read". A binary stripped of + // its section headers says neither, so it holds every module rather than none. + if carriesGoSections(file) || len(file.Sections) == 0 { logboek.Context(ctx).Debug().LogF("read Go build info: %s\n", err) return VerdictUnknown, nil @@ -219,30 +232,40 @@ func carriesGoSections(file *elf.File) bool { return false } -// carriesTestMain looks for the source path of the main `go test -c` generates, which -// survives -trimpath and -ldflags="-s -w". The path of the testing package's own source -// cannot serve here: every binary linking the testing package carries it, production tools -// among them, and a tool misread as a test binary would hold no component at all. +// carriesTestMarks looks for both marks of a `go test -c` binary, which survive -trimpath +// and -ldflags="-s -w". The path of the testing package's own source cannot serve here: +// every binary linking the testing package carries it, production tools among them, and a +// tool misread as a test binary would hold no component at all. // -// The mark is a string in the file, so a binary carrying the string for another reason -// reads as a test binary — the `go` tool itself does, since it has to name the file it -// generates. An image shipping the Go toolchain therefore holds no module on account of -// `go`, which is the safe direction for a build tool but worth knowing. -func carriesTestMain(binary io.ReaderAt, size int64) (bool, error) { +// Both marks are needed because each has a witness that carries it alone. The `go` tool +// writes the generated main's path as a literal, so requiring it alone would make a +// shipped Go toolchain hold nothing; a binary embedding a test framework can reach the +// runner's own function without ever being a test. +func carriesTestMarks(binary io.ReaderAt, size int64) (bool, error) { const chunk = 1 << 20 - needle := []byte(testMainSource) + needles := [][]byte{[]byte(testMainSource), []byte(testRunnerFunc)} + // A match split across two reads is found by overlapping them. - overlap := len(needle) - 1 + overlap := 0 + for _, needle := range needles { + overlap = max(overlap, len(needle)-1) + } + found := make([]bool, len(needles)) buffer := make([]byte, chunk+overlap) + for offset := int64(0); offset < size; offset += chunk { read, err := binary.ReadAt(buffer, offset) if err != nil && !errors.Is(err, io.EOF) { return false, fmt.Errorf("read binary at %d: %w", offset, err) } - if bytes.Contains(buffer[:read], needle) { + for i, needle := range needles { + found[i] = found[i] || bytes.Contains(buffer[:read], needle) + } + + if !slices.Contains(found, false) { return true, nil } @@ -291,10 +314,15 @@ func runtimeCrates(section *elf.Section) ([]Crate, error) { } defer reader.Close() - data, err := io.ReadAll(io.LimitReader(reader, maxAuditData)) + // The stored size says nothing about the inflated one, so the inflated side is + // capped too, and reading one byte past the cap tells a bomb from data that fits. + data, err := io.ReadAll(io.LimitReader(reader, maxAuditData+1)) if err != nil { return nil, fmt.Errorf("decompress: %w", err) } + if len(data) > maxAuditData { + return nil, fmt.Errorf("audit data inflates over the %d limit", maxAuditData) + } var audit auditData if err := json.Unmarshal(data, &audit); err != nil { diff --git a/pkg/sbom/shippedscope/binary_helpers_test.go b/pkg/sbom/shippedscope/binary_helpers_test.go index 0f37ddee3b..3b12abba62 100644 --- a/pkg/sbom/shippedscope/binary_helpers_test.go +++ b/pkg/sbom/shippedscope/binary_helpers_test.go @@ -181,12 +181,13 @@ func parsedCopy(file []byte) []byte { } // elfWithTestMainAt places the test-main mark at an exact offset in the file, so a spec -// can put it across a read boundary without knowing the layout of the ELF writer. +// can put it across a read boundary without knowing the layout of the ELF writer. The +// runner mark goes first, where no boundary reaches it. func elfWithTestMainAt(offset int) []byte { sections := func(padding int) map[string][]byte { return map[string][]byte{ // A test binary is a Go binary, and the scan runs only on those. - ".gopclntab": []byte("go"), + ".gopclntab": []byte(testRunnerFunc), ".rodata": append(bytes.Repeat([]byte{'x'}, padding), []byte(testMainSource)...), } } @@ -199,6 +200,26 @@ func elfWithTestMainAt(offset int) []byte { return elfWithSections(sections(offset - start)) } +// withoutSectionHeaders strips the section header table the way a packer does, leaving a +// file debug/elf still opens but that says nothing about its own language. +func withoutSectionHeaders(file []byte) []byte { + stripped := parsedCopy(file) + order := binary.LittleEndian + order.PutUint64(stripped[40:], 0) // e_shoff + order.PutUint16(stripped[60:], 0) // e_shnum + order.PutUint16(stripped[62:], 0) // e_shstrndx + + return stripped +} + +// inflatingAuditSection compresses a document whose inflated size is far over the cap +// while its stored size stays well under it. +func inflatingAuditSection() []byte { + padding := strings.Repeat("a", maxAuditData+1) + + return auditSection(`{"packages":[{"name":"serde","version":"1.0.210","source":"crates.io"}],"pad":"` + padding + `"}`) +} + func auditSection(document string) []byte { var compressed bytes.Buffer diff --git a/pkg/sbom/shippedscope/binary_test.go b/pkg/sbom/shippedscope/binary_test.go index a9544b17e7..5ee0ad5e0f 100644 --- a/pkg/sbom/shippedscope/binary_test.go +++ b/pkg/sbom/shippedscope/binary_test.go @@ -151,6 +151,63 @@ var _ = Describe("ClassifyBinary", func() { Expect(classification.Go).To(Equal(VerdictUnknown)) }) + It("lets the go tool keep its modules although it names the file it generates", func(ctx SpecContext) { + binary := elfWithSections(map[string][]byte{ + ".gopclntab": []byte("go"), + ".rodata": []byte("cmd/go writes " + testMainSource + " and never runs it"), + }) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.TestBinary).To(BeFalse()) + }) + + It("lets a binary embedding a test framework keep its modules", func(ctx SpecContext) { + binary := elfWithSections(map[string][]byte{ + ".gopclntab": []byte("go"), + ".rodata": []byte("a framework reaching " + testRunnerFunc + " from its own main"), + }) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.TestBinary).To(BeFalse()) + }) + + It("holds every module of a binary whose section headers are gone", func(ctx SpecContext) { + binary := withoutSectionHeaders(elfWithSections(map[string][]byte{ + ".rodata": []byte("packed"), + })) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.Go).To(Equal(VerdictUnknown)) + Expect(classification.Rust).To(Equal(VerdictUnknown)) + }) + + It("still recognizes a test binary whose section headers are gone", func(ctx SpecContext) { + binary := withoutSectionHeaders(elfWithSections(map[string][]byte{ + ".rodata": []byte(testMainSource + " " + testRunnerFunc), + })) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + Expect(classification.TestBinary).To(BeTrue()) + }) + + It("refuses a size it cannot scan", func(ctx SpecContext) { + binary := goBinary() + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), 0) + Expect(err).To(MatchError(ContainSubstring("size is 0"))) + + Expect(classification.Go).To(Equal(VerdictUnknown)) + Expect(classification.Rust).To(Equal(VerdictUnknown)) + }) + It("does not read a binary of another language as a test binary", func(ctx SpecContext) { binary := elfWithSections(map[string][]byte{ ".rodata": []byte("a C binary quoting _testmain.go for its own reasons"), @@ -192,7 +249,7 @@ var _ = Describe("ClassifyBinary", func() { Entry("across the far edge of the first read", func(chunk, overlap int) int { return chunk + overlap - 4 }), ) - It("refuses audit data larger than the cap before reading it", func(ctx SpecContext) { + It("refuses audit data whose stored size is over the cap before reading it", func(ctx SpecContext) { binary := oversizedSection(elfWithSections(map[string][]byte{ rustDepSection: auditSection(`{"packages":[{"name":"serde","version":"1.0.210","source":"crates.io"}]}`), }), rustDepSection) @@ -201,8 +258,6 @@ var _ = Describe("ClassifyBinary", func() { Expect(err).NotTo(HaveOccurred()) defer file.Close() - // A compressed section is inflated inside Data(), so the size has to be rejected - // before the read rather than capped after it. _, err = runtimeCrates(file.Section(rustDepSection)) Expect(err).To(MatchError(ContainSubstring("over the"))) @@ -211,6 +266,25 @@ var _ = Describe("ClassifyBinary", func() { Expect(classification.Rust).To(Equal(VerdictUnknown)) }) + It("refuses audit data that inflates over the cap, however small it is stored", func(ctx SpecContext) { + binary := elfWithSections(map[string][]byte{ + rustDepSection: inflatingAuditSection(), + }) + + file, err := elf.NewFile(bytes.NewReader(binary)) + Expect(err).NotTo(HaveOccurred()) + defer file.Close() + + Expect(file.Section(rustDepSection).Size).To(BeNumerically("<", maxAuditData)) + + _, err = runtimeCrates(file.Section(rustDepSection)) + Expect(err).To(MatchError(ContainSubstring("inflates over the"))) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + Expect(classification.Rust).To(Equal(VerdictUnknown)) + }) + It("holds everything when the binary cannot be read", func(ctx SpecContext) { binary := goBinary()