diff --git a/pkg/sbom/shippedscope/binary.go b/pkg/sbom/shippedscope/binary.go new file mode 100644 index 0000000000..a6a07dab39 --- /dev/null +++ b/pkg/sbom/shippedscope/binary.go @@ -0,0 +1,342 @@ +package shippedscope + +import ( + "bytes" + "compress/zlib" + "context" + "debug/buildinfo" + "debug/elf" + "encoding/json" + "errors" + "fmt" + "io" + "runtime/debug" + "slices" + + "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 ( + // 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" +) + +// 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. +// +// 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) { + 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, fmt.Errorf("classify binary: %w", err) + } + if !elfBinary { + return none, nil + } + + file, err := elf.NewFile(binary) + if err != nil { + logboek.Context(ctx).Debug().LogF("parse ELF: %s\n", err) + + return unknown, nil + } + defer file.Close() + + 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. 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, fmt.Errorf("classify binary: %w", err) + } + if testBinary { + classification := none + classification.TestBinary = true + + return classification, nil + } + } + + info, err := buildinfo.Read(binary) + + classification := none + classification.Go, classification.Modules = goEvidenceOf(ctx, file, info, err) + classification.Rust, classification.Crates = rustEvidence(ctx, file, info) + + return classification, nil +} + +func goEvidenceOf(ctx context.Context, file *elf.File, info *debug.BuildInfo, err error) (Verdict, []string) { + if err != nil { + // 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 + } + + return VerdictNone, 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. 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 + } + + crates, err := runtimeCrates(section) + if err != nil { + logboek.Context(ctx).Debug().LogF("read %s: %s\n", rustDepSection, err) + + 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) { + // 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 + } + + 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 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 { + return true + } + } + + return false +} + +// 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. +// +// 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 + + needles := [][]byte{[]byte(testMainSource), []byte(testRunnerFunc)} + + // A match split across two reads is found by overlapping them. + 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) + } + + for i, needle := range needles { + found[i] = found[i] || bytes.Contains(buffer[:read], needle) + } + + if !slices.Contains(found, false) { + return true, nil + } + + if errors.Is(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" + 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) + } + + reader, err := zlib.NewReader(bytes.NewReader(compressed)) + if err != nil { + return nil, fmt.Errorf("decompress: %w", err) + } + defer reader.Close() + + // 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 { + 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..3b12abba62 --- /dev/null +++ b/pkg/sbom/shippedscope/binary_helpers_test.go @@ -0,0 +1,317 @@ +package shippedscope + +import ( + "bytes" + "compress/zlib" + "debug/elf" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strings" + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var ( + 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, +// 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 +} + +// 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, builtLinkedGo, errBuild = buildGoFixtures() + }) + + Expect(errBuild).NotTo(HaveOccurred()) +} + +func buildGoFixtures() ([]byte, []byte, []byte, error) { + root, err := os.MkdirTemp("", "shippedscope-fixtures") + if err != nil { + return nil, nil, nil, fmt.Errorf("create fixture root: %w", 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", + "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, nil, fmt.Errorf("create %s: %w", name, err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + return nil, nil, nil, fmt.Errorf("write %s: %w", name, err) + } + } + + binary := filepath.Join(root, "app.bin") + testBinary := filepath.Join(root, "app.test") + 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, 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, nil, fmt.Errorf("read %s: %w", binary, err) + } + + builtTest, err := os.ReadFile(testBinary) + if err != nil { + 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 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...) +} + +// 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. 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(testRunnerFunc), + ".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)) +} + +// 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 + + 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) + } + sort.Strings(names) + + var nameTable bytes.Buffer + nameTable.WriteByte(0) + + nameOffsets := make(map[string]uint32, len(names)+1) + for _, name := range append(slices.Clone(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..5ee0ad5e0f --- /dev/null +++ b/pkg/sbom/shippedscope/binary_test.go @@ -0,0 +1,344 @@ +package shippedscope + +import ( + "bytes" + "debug/elf" + "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()) + }) + + 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() + + 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("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("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"), + }) + + 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)...) + + 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)) + }) + + DescribeTable("finds the test main however it straddles the reads", + func(ctx SpecContext, position func(chunk, overlap int) int) { + const chunk = 1 << 20 + + overlap := len(testMainSource) - 1 + binary := elfWithTestMainAt(position(chunk, overlap)) + + classification, err := ClassifyBinary(ctx, bytes.NewReader(binary), int64(len(binary))) + Expect(err).NotTo(HaveOccurred()) + + 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 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) + + file, err := elf.NewFile(bytes.NewReader(binary)) + Expect(err).NotTo(HaveOccurred()) + defer file.Close() + + _, 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("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() + + 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"), + }) + + 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(VerdictUnknown)) + }) + + 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)) + }) +})