Skip to content

feat(sbom): read which modules and crates a produced binary carries - #442

Merged
reyreavman merged 4 commits into
mainfrom
feat/sbom/classify-produced-binaries
Oct 19, 2026
Merged

reyreavman merged 4 commits into
mainfrom
feat/sbom/classify-produced-binaries

Conversation

@reyreavman

@reyreavman reyreavman commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Shipped-scope needs to know which components a produced binary carries, because a Go module and a Rust crate keep no files of their own in an image — they are compiled in, and the binary's own metadata is the only witness. This adds that reader: given the bytes of one file, it answers which modules and crates that file holds, or says it cannot tell. Nothing calls it yet; it is the input the matching rule will consume.

What

  • A go test -c binary speaks for no component at all, even when its dependency list is full: it is recognized before anything else is read, by two marks together — the source path of the main that go test -c generates, and the test runner's own function. Each mark alone has a false witness: the go tool writes the path as a literal, and a binary embedding a test framework reaches the runner; so a shipped Go toolchain, a tool that merely links the testing package — gopls, mockgen, werf itself — and a framework-embedding binary all keep their modules. Both marks survive -trimpath and -ldflags="-s -w".
  • A binary whose build info was wiped holds every module of go.mod, not none: an empty dependency list is checked only after that case is ruled out, so an obfuscated binary with live dependencies does not drop them. The test is an empty module path and empty settings, because an obfuscator writes a version string rather than clearing it.
  • A binary with a non-empty dependency list holds exactly those modules, both sides of a replace among them, so a forked module answers under the upstream path and the fork path alike.
  • A main-only binary — live build info, empty list — holds no module.
  • Modules are matched by path and never by version: build info describes go.mod as of the link, the SBOM describes it as of now, and matching versions would turn that skew into a component silently dropped from the SBOM.
  • A binary that fails to yield build info but carries .gopclntab or .go.buildinfo holds every module; a binary carrying neither is not Go and holds no module. A binary stripped of its section headers altogether says neither, so it holds every module and is scanned for the test marks regardless.
  • A Rust binary built with cargo auditable holds the crates its .dep-v0 section calls runtime dependencies; build dependencies are excluded. Absent, unreadable or corrupt audit data holds every crate instead, as does audit data over the 8 MiB cap by stored or inflated size — except on a Go binary built without cgo, which can link no Rust object and so holds none.
  • Go and Rust are read independently, so a cgo binary carrying the evidence of both keeps both.
  • A file that is not an ELF speaks for nobody. A file that claims to be one but cannot be parsed, and a file that cannot be read at all, hold every component of both ecosystems: "I cannot read this" is never "this contains nothing".
  • No user-visible surface changes: no flag, no log line, no change to any SBOM werf produces today. The invariant a reviewer checks is the branch order above — every "I don't know" resolves to holding components, never to dropping them.

Why

The shipped-scope rule cuts an SBOM down to what a final image actually ships. For file-backed ecosystems the files themselves are the evidence, but a compiled binary erases that: after go build, nothing on disk says which modules went in. Without this reader the rule has two bad options — keep every module of go.mod, which leaves the SBOM as bloated as it is today, or drop modules on a guess, which is worse than bloat because a vulnerable component disappears from the report.

The alternative worth arguing for is matching binaries by version as well as path, which looks stricter. It is not: the binary records the dependency graph frozen at link time, while the SBOM is generated from the current source tree, so any rebuild skew — a bumped dependency, a +dirty suffix, a replace pointing at a local path — makes the version differ and the component vanish. Path alone is the stable key; the version in the SBOM is already authoritative.

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 <radmir.khurum@flant.com>
@reyreavman

Copy link
Copy Markdown
Collaborator Author

Verification

  • Mutation: test-binary check moved below the build-info branches → lets a test binary speak for nobody, however alive its dependencies are failed (the fixture test binary has a live dependency, so the reordered code classified it as a normal binary naming example.test/lib).
  • Mutation: wiped-build-info branch made unreachable → holds everything when the build info was wiped, empty dependencies and all failed.
  • Mutation: dep.Path → dep.Path+"@"+dep.Version → holds the modules the linker put in, by path and never by version failed.
  • Mutation: crate filter flipped to kind != "runtime" → holds every crate the audit data calls a runtime dependency failed. This is the trap in the format: kind is omitted for runtime dependencies, so filtering in kind == "runtime" drops the entire common case while still looking like a working filter.
  • Mutation: .gopclntab dropped from the Go section list, and the section check short-circuited to false → holds every module of a Go binary whose build info is unreadable failed in both.
  • The .dep-v0 format was taken from the cargo-auditable source, not from prose: zlib (RFC1950, so compress/zlib, not compress/flate), no framing, kind with #[serde(default, skip_serializing_if)], and no development value on the wire — dev dependencies are dropped before serialization.

Review focus

  • binary_helpers_test.go builds its two Go fixtures with go build / go test -c into a temp dir at suite time, rather than committing binaries. It needs a working toolchain in the unit-test environment and no network (both modules are local, wired by replace). If that is not a trade this repo wants in unit tests, the alternative is committed fixtures — which then freeze the toolchain version whose metadata layout is the thing under test.
  • The synthetic ELF writer in the same file produces the smallest file debug/elf accepts: header, sections, name table, no program headers. It exists so the Rust and non-Go cases do not drag a Rust toolchain into the suite.
  • Confirm the Rust fallback is the policy we want: a binary that is not Go and has no .dep-v0 holds every crate, which means one C binary in an image pins all of Cargo.lock. That is the deliberately blunt pre-existing rule, and distinguishing a Rust binary without audit data from a C binary is not cheap.

Follow-up

  • The produced-set — which files a build actually produced, from the stage diffs — is not in this diff and has no card yet; it is the other input the matching rule needs.
  • Wire this reader into the two-pass rule (card E), which stays blocked until that produced-set exists.

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 <radmir.khurum@flant.com>
@reyreavman

Copy link
Copy Markdown
Collaborator Author

Second round — two silent-drop defects, both fixed in 756666c68a

An independent review of the diff plus a cross-check against the measurements the design was built on found two ways a binary could lose every component it carries. Both are the failure direction this reader exists to prevent, so writing them down rather than quietly fixing them.

1. The test-binary check fired on ordinary tools. It matched src/testing/testing.go, which is carried by every binary that links the testing package — not only by one go test -c produced. Verified on this machine: gopls, ./bin/werf and mockgen each contain the string. Every one of them would have been classified a test binary and held nothing — for werf that is ~390 live modules dropped. The check now matches _testmain.go, the source path of the main go test -c generates: absent from all three, present in the test binary under -trimpath, -ldflags="-s -w" and both together. The old needle additionally disappears under -trimpath, so it was missing real test binaries too.

2. The wiped-build-info branch never fired on the obfuscator it was written for. It required GoVersion == "", but garble writes GoVersion="unknown" and clears Path and Settings — measured on the concept's own bench. So an obfuscated binary fell through to "empty Deps, main-only, holds nothing" while its dependencies were alive, which is exactly the outcome the branch exists to prevent. The test is now Path == "" && len(Settings) == 0, true for garble and false for every honest binary.

Three more from the review, all valid:

  • Go and Rust were read with early returns, so a cgo binary linking an audited Rust archive lost whichever ecosystem was read second. They are now independent. A Go binary built without cgo (CGO_ENABLED=0 in its build settings) holds no crate rather than all of them — it can link no Rust object.
  • A file whose first bytes are ELF magic but which fails to parse, and a read that fails outright, resolved to holding nothing, indistinguishable from a shell script. Both now hold everything.
  • Audit data whose recorded size exceeds the 8 MiB cap is rejected before the read: debug/elf inflates a SHF_COMPRESSED section inside Data(), so the io.LimitReader after it would never have applied.

Verification

  • Mutation: needle reverted to src/testing/testing.go → lets a tool that merely links the testing package keep its modules failed. That mutation is the previous commit's code.
  • Mutation: GoVersion == "" added back to the wiped-info test → holds everything when an obfuscator left a version string behind failed. Also the previous commit's code.
  • Mutation: scan advanced by chunk+overlap instead of chunk → finds the test main when it straddles two reads failed. The spec places the needle across the far edge of the first window, where the overlap is the only thing that finds it.
  • Mutation: section size check disabled → refuses audit data larger than the cap before reading it failed. That spec asserts the error text, because by verdict the two paths are identical — both fail open — and only the point of rejection differs.
  • Mutation: builtWithoutCgo forced to true, and its nil guard inverted → the crates of a Go binary table failed on both.
  • Mutation: ELF parse failure resolved to none → holds everything when a file claims to be an ELF but cannot be parsed failed.
  • task build, unscoped task lint (0 issues), ./pkg/sbom/... green; 45/45 in the package.

Review focus

  • The _testmain.go needle is a string in the file table, not a symbol, so a binary that embeds Go test source would still read as a test binary. The consequence is bounded — it holds nothing instead of its modules — but it is the same class of false positive as the one fixed here, one order rarer.

Follow-up

  • UNVERIFIED on a real obfuscated binary: garble requires Go >= 1.27 and fails against a toolchain from the module cache, so the branch is covered by the field values measured on the bench rather than by classifying a binary garble produced here. Classifying one would settle it.

@reyreavman reyreavman left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Report

Base: main (ba4cc59c), head 756666c6
Diff: 3 files, +838/-0 — ClassifyBinary in pkg/sbom/shippedscope, no callers yet.
Context: shipped-scope concept §4 / Kaiten card D (71736124).

Verdict

  • Technical: Branch order matches card D exactly and every "I don't know" resolves to holding, as the body promises. Three of four mutations fail the spec written for them. The fourth — overlap removed from the chunked scan — survives: the straddle spec places the needle at chunk+8, fully inside the second read, so it guards the stride and not the overlap. A probe with the needle at chunk-5 shows the overlap is load-bearing and untested. One perf gap: the whole-file scan runs on every ELF, including every libc.so, before anything says the file is Go.
  • Product: The _testmain.go needle survives -trimpath -ldflags="-s -w" — verified by rebuilding fixtures with those flags. It is a string literal in the go tool itself (2 hits in bin/go), so an image shipping the Go toolchain reads go as a test binary; acceptable, but should be in the godoc. Rust fail-open is per concept, but its aggregate effect needs stating for E: any non-Go, non-audited ELF in the image — bash, libc.so — says Unknown for crates, so every glibc image holds every crate forever.
  • Risk: No user surface. Risks are deferred to E and listed below.

DoD Criteria

From card D "Готово, когда" + PR body.

Criteria Inferred? Met? Evidence
Test binary recognised before anything else; mark survives strip no ✅ binary.go:102-111; probe: -trimpath -s -w test binary → TestBinary=true, app → false
Wiped build info → fail-open on whole go.mod, checked before Deps no ✅ binary.go:175; M2 fails spec :62
Deps ≠ ∅ → modules by path, both sides of replace no ✅ binary.go:179-188; spec :27
Deps = ∅ → main-only, holds nothing no ✅ spec :44
buildinfo.Read error split by .gopclntab/.go.buildinfo no ✅ binary.go:122-134; specs :225,:237
Rust: .dep-v0, runtime only, absent → fail-open no ✅ binary.go:136-157; M3 fails 2 specs
Mutation "test binary checked after Deps" fails a spec no ✅ tool-linking-testing spec :129 would catch it (M1 analogue)
Mutation "Deps=∅ as knowledge for garble" fails a spec no ✅ M2
Mutation "match by version" fails a spec no ⚠️ goEvidence returns paths only; a version-matching mutation would change the return type — not a runtime-mutable claim. Fine.
Unit specs on fixture binaries per branch no ✅ fixtures built by the suite from source; synthetic ELF for the rest
task format/build/lint/test:unit green no ✅ build OK, lint 0, 45/45

Issues

Major

  1. Overlap in carriesTestMain is untested; the spec that claims to test it cannot fail on its removal — binary_test.go:164-182. Inline.

Minor

  1. Whole-file scan on every ELF, Go or not — binary.go:102. Inline.
  2. Rust fail-open on every non-Rust ELF — binary.go:144-146. Inline. Design note for E, not a defect here.
  3. go tool is read as a test binary — binary.go:55-57. Inline. Godoc.

Mutation log

Mutation Result
M1: needle reverted to testing/testing.go ✅ fails lets a tool that merely links the testing package…
M2: wiped check on GoVersion == "" ✅ fails …obfuscator left a version string behind
M3: filter kind == "runtime" in, instead of build out ✅ 2 fail
M4: buffer = chunk (overlap removed) ❌ 45/45 green — Major #1

Probes: -trimpath -ldflags="-s -w" fixtures classify correctly; needle at chunk-5 → false under M4, true unmutated. Tree restored after each; final 45/45; lint 0 issues.

Risks

№ Risk Type Likelihood Severity Location Circumstances Consequences Recommendation
1 Real test binary missed when _testmain.go lands on a 1 MiB boundary Technical Unlikely Medium binary.go:224-233 Only if overlap regresses; today it is correct but unguarded Test binary holds its modules → false KEEP in E Major #1: fix the spec
2 Rust shipped-scope effectively off on every glibc image UX/Product Likely Medium binary.go:144-146 Any ELF that is neither Go-without-cgo nor audited Rust E keeps every crate; SBOM as bloated for Rust as today Minor #3: an "is this Rust at all" test, or document for E
3 Index time dominated by scanning C binaries for a Go needle Operational Likely Low binary.go:102 Image with many ELFs (/usr/lib/*.so) Seconds per image, scales with rootfs size Minor #2: gate on carriesGoSections

Not verified

  • garble output (Path=="" && Settings==∅) — no garble here; taken from the commit body's measurement.
  • Real cargo auditable binary — synthetic .dep-v0 only; the JSON shape matches the auditable-serde schema I know.
  • Unscoped task lint / task test:unit — scoped to the package.

Comment thread pkg/sbom/shippedscope/binary_test.go Outdated
Comment on lines +164 to +176
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)...),
})

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major. This spec does not test what its name and comment say. With one section, data starts at sectionHead = 256; padding = chunk+overlap-256-4, so the needle starts at chunk + overlap - 4 = chunk + 7 and lies entirely inside the second read [chunk, 2·chunk+overlap). It therefore passes whether or not the buffer has an overlap:

M4: buffer := make([]byte, chunk)   // overlap removed
→ 45/45 green

The overlap is load-bearing — probe with the needle actually crossing the edge:

needle at [chunk-5, chunk+7):  unmutated → TestBinary=true,  M4 → false

What this spec currently guards is the stride (offset += chunk, not chunk+overlap) — useful, but a different property, and the comment at :165-166 describes that one. Fix: put the needle so it straddles, e.g. padding := bytes.Repeat('x', chunk-sectionHead-len(testMainSource)/2), and keep the current placement as a second entry if you want the stride guarded too. A DescribeTable over needle offsets {chunk-1, chunk-len+1, chunk+7} reads well.

Comment thread pkg/sbom/shippedscope/binary.go Outdated
}
defer file.Close()

testBinary, err := carriesTestMain(binary, size)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (perf). The full-file scan runs before anything has said the file is Go. For an image rootfs that means every libc.so.6, bash, libssl.so — every ELF — is read end to end looking for a Go-only string. On a typical glibc image that is hundreds of MB of pure overhead for files that cannot be test binaries.

A go test -c binary always carries .gopclntab (-s -w strips symtab and DWARF, not pclntab — the runtime needs it), so gate the scan:

testBinary := false
if carriesGoSections(file) {
	testBinary, err = carriesTestMain(binary, size)
	…
}

The truncated-ELF and read-failure specs still hold (they fail before or inside this branch respectively). Branch order from card D is preserved — the scan still precedes buildinfo.Read.

Comment on lines +144 to +146
// 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (design note for E, not a defect here). Per concept this is right — no .dep-v0, nothing can be said. But think about the aggregate: rule E will see one verdict per produced file, and every bash, dash, libc.so.6, libstdc++.so in the image answers Unknown for Rust. Unless E restricts classification to the produced set (card D's diff(install) ∪ …) and that set never contains a C binary, every glibc image holds every crate forever and Rust shipped-scope is a no-op.

Two cheap ways to narrow it, either now or in E:

  • an "is this Rust at all" test analogous to goSections: release Rust binaries keep /rustc/<hash>/library/ paths in panic strings and a .rustc-less but recognisable rust_eh_personality / rust_begin_unwind symbol even after strip (-s keeps dynamic symbols; static binaries keep neither — fallback to the path needle);
  • or state in the godoc that Rust == Unknown from a file without Go sections means "not evidence either way" and let E count only files that returned Names for Rust.

Either way, write down which one E is expected to do; otherwise the first measurement on a real image will look like a bug in this reader.

Comment thread pkg/sbom/shippedscope/binary.go Outdated
Comment on lines +55 to +57
// 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"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor. The needle is a string literal in cmd/go/internal/load — the go tool itself carries it:

$ grep -c -a _testmain.go $(go env GOROOT)/bin/go
2

(gofmt, golangci-lint, werf — 0.) So an image shipping the Go toolchain reads /usr/local/go/bin/go as a test binary and it holds none of its modules. For a builder image that is harmless — the toolchain is not something E should keep modules for anyway — but it is a known false positive of a whole-file substring scan, and the next person to hit it will think the reader is broken. One sentence in the godoc of ClassifyBinary or on this constant.

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 <radmir.khurum@flant.com>
@reyreavman

Copy link
Copy Markdown
Collaborator Author

Third round — ef6e9335c4

Major #1 is right, and the mutation log in my previous comment overstated what was proven. The straddle spec placed the mark at chunk+overlap-4, across the far edge of the first read — a boundary the stride mutation breaks, but which the next read covers whole when the overlap is removed. So the spec guarded the stride and left the overlap unguarded. Reproduced exactly as reported: overlap := 0 → 45/45 green.

Worth noting why my own check missed it: I mutated buffer := make([]byte, chunk+overlap) → make([]byte, chunk), which leaves overlap unused, so the package failed to compile — and my mutation script reads "no FAIL! in the output" as survived. A non-compiling mutation looked exactly like a killed one. Checking the compile status is now part of the loop.

There are two boundaries, and each needs its own mark position:

  • across the stride (chunk-4) — spanned only by a buffer longer than the stride;
  • across the far edge of the first read (chunk+overlap-4) — spanned only by the next read starting back at the stride rather than where the previous ended.

The spec is now a table over both. overlap := 0 fails the first entry, offset += chunk + overlap fails the second.

Minor #2 — fixed. The scan is gated on carriesGoSections: only a Go binary can be a test binary, so a libc.so is no longer read end to end to answer a Go-only question. This also changes behaviour on a binary of another language that happens to quote _testmain.go — it is no longer a test binary — so there is a spec for that, and if true in place of the gate fails it.

Minor #4 — in the godoc. The mark is a string, and the go tool names the file it generates, so go reads as a test binary. An image shipping the Go toolchain holds no module on account of go: the safe direction for a build tool, but it should not be folklore.

Minor #3 — in the godoc, as a design note for E. One unaudited ELF — a libc, a shell — makes the image hold every crate. The aggregate is blunt by design, and narrowing it needs an "is this Rust at all" test that no cheap, reliable mark provides today. Carried into card I rather than this PR.

Verification

  • Mutation: overlap := 0 → across the stride boundary fails.
  • Mutation: offset += chunk + overlap → across the far edge of the first read fails.
  • Mutation: gate replaced by if true → does not read a binary of another language as a test binary fails.
  • task build, unscoped task lint (0 issues), ./pkg/sbom/... green; 47/47 in the package.

Review focus

  • elfWithTestMainAt positions the mark by building the file once, locating the mark, and rebuilding with the padding that lands it on the requested offset — so the spec does not encode the ELF writer's layout, which was how the previous version drifted off the boundary it meant to test.

@reyreavman reyreavman left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — follow-up on ef6e9335

Delta since 756666c6: 3 files, +67/-25.

Verdict

No findings. All four items from the previous round are closed and each is now guarded by a mutation that fails for the right reason.

# Finding Status
Major 1 overlap untested ✅ DescribeTable with the mark at chunk-4 and chunk+overlap-4; elfWithTestMainAt places it by absolute offset
Minor 2 scan on every ELF ✅ gated on carriesGoSections (binary.go:102); new spec does not read a binary of another language as a test binary
Minor 3 Rust fail-open in aggregate ✅ stated in the comment at binary.go:148-152 for E to live with
Minor 4 go tool reads as a test binary ✅ godoc on carriesTestMain

Mutation log

Mutation Result
buffer = chunk (overlap removed) ✅ fails across the stride boundary
stride = chunk+overlap ✅ fails across the far edge of the first read
scan gate if true ✅ fails does not read a binary of another language… on TestBinary being true

47/47 otherwise; tree restored after each.

Nothing outstanding on this PR from my side.

…less 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 <radmir.khurum@flant.com>
@reyreavman

Copy link
Copy Markdown
Collaborator Author

Fourth round — 0e21c6f63e, after an independent review of ef6e9335c4

The previous round closed the human review but had no independent pass of its own; this one does. It found two majors of the same shape as every defect so far — a check that looked right and dropped everything — and both are fixed.

Major — the go toolchain was read as a test binary and dropped all its modules. cmd/go writes the generated test main's path as a literal, so _testmain.go alone matched the toolchain itself: grep -c gives 2 hits in bin/go, 0 in werf, gopls, mockgen, ginkgo, golangci-lint, dlv. The previous godoc called this "the safe direction for a build tool"; by this design's own rule it is not — an image shipping the toolchain would hold no module on its account. The test binary is now named by two marks together: the generated main's path and the test runner's own function testing.tRunner. Measured on go1.24.9:

binary _testmain.go testing.tRunner
go test -c output, plain / -trimpath / -s -w / both 1 1–2
bin/go 2 0
werf, gopls, mockgen, ginkgo, golangci-lint, dlv 0 0

Each mark has a witness that carries it alone — the toolchain for the path, a binary embedding a test framework for the runner — so neither suffices by itself, and both have a spec that fails when the condition degrades to one mark.

Major — an ELF stripped of its section headers dropped all Go modules. goEvidenceOf used the Go sections as the only thing telling "not Go" from "Go I cannot read"; a packed or objcopy --strip-section-headers'd binary parses fine with zero sections and fell into "not Go → holds nothing", while the Rust side in the same situation already returned Unknown. A sectionless binary now holds every module and is scanned for the test marks regardless. The remaining asymmetry is named in the godoc: a sectionless test binary whose build info is still readable holds too much, not too little.

Minors. size <= 0 is rejected instead of silently skipping the scan; errors leaving ClassifyBinary are wrapped; the audit-data cap now covers the inflated side as well as the stored one — the two are unrelated, and the previous LimitReader truncated silently so a bomb and data that fits were indistinguishable. A fixture whose stored size is well under the cap and inflates past it has a spec, and if false in place of the check fails it.

Open question from the review — garble, settled by measurement rather than by the concept's table. Installed go1.27, built the fixture with garble build, read it with debug/buildinfo: GoVersion="unknown" Path="" Main="" Settings=0 Deps=0. The branch condition Path == "" && len(Settings) == 0 matches the real output.

Open question — CGO_ENABLED=0 → no crates. Holds for linkage: a pure-Go binary cannot link a Rust object. A pure-Go binary can go:embed a Rust-built artifact; that artifact is then a file of its own in the image, or inside a Go binary we do not unpack. Either way it is not this classifier's call — recorded as a limit for card E, not changed here.

Verification

  • Eight mutations, all killed with compiling mutants — the previous round's harness read a compile failure as a kill, so each mutation here was re-formed until it compiled: one-mark-suffices (×2), gate without the sectionless case, gate removed, inflated cap removed, size < 0, overlap zeroed, stride without overlap.
  • task build, unscoped task lint — 0 issues.
  • ./pkg/sbom/... on buildah-commons (linux/amd64, cgo): green; shippedscope 53/53. The full task test:unit on the VM has one failure in pkg/build/image (build_context_lfs_test.go:51); it fails identically on origin/main in a fresh worktree, so it is pre-existing on that host and not this diff.

Review focus

  • carriesTestMarks: both needles are looked for in one pass over the same overlapping buffer; overlap is the longer needle's length minus one, so each boundary spec still guards what it says.
  • withoutSectionHeaders zeroes e_shoff/e_shnum/e_shstrndx the way a packer leaves them; debug/elf opens the result with zero sections, which is the real-world input the branch is for.

@reyreavman reyreavman left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — follow-up on 0e21c6f6

Delta since ef6e9335: 3 files, +149/-26.

Verdict

No findings. The commit turns my Minor #4 (go tool reads as a test binary, "safe direction") into a proper fix — and it is right to: holding nothing for a shipped toolchain is a drop. The two-mark rule is verified against real binaries and a stripped test build; the sectionless-ELF and inflated-cap changes are each guarded by a spec that fails on reversal.

Verified

Claim How Result
go carries the path and not the runner grep -c -a on $GOROOT/bin/go _testmain.go=2 testing.tRunner=0
no production tool carries either same on werf, golangci-lint, gofmt all 0 / 0
both marks survive -trimpath -ldflags="-s -w" probe: go test -c with those flags, GOOS=linux main=true runner=true TestBinary=true

Mutation log

Mutation Result
only _testmain.go required ✅ fails lets the go tool keep its modules…
len(file.Sections) == 0 removed from both sites ✅ 2 fail — sectionless test binary, sectionless unreadable build info
inflated-size cap removed ✅ fails refuses audit data that inflates over the cap…

53/53 otherwise; tree restored after each.

Note for E

A sectionless ELF of any language is now scanned end to end and holds every module when its build info is unreadable. That is the right fail-open, but it means a UPX-packed or sstrip-ed C binary in the produced set costs a full read and keeps the whole go.mod. Rare in practice; mentioning it so E's measurements have the explanation ready.

Nothing outstanding on this PR from my side — I would approve.

@reyreavman
reyreavman marked this pull request as ready for review October 19, 2026 05:36
@reyreavman
reyreavman merged commit 11a39f6 into main Oct 19, 2026
9 of 14 checks passed
@reyreavman
reyreavman deleted the feat/sbom/classify-produced-binaries branch October 19, 2026 05:45
reyreavman added a commit that referenced this pull request Oct 19, 2026
## Summary

Shipped-scope now has its decision: given what an image's SBOM says each
component ships, what every binary the build produced carries, and what
files the image actually holds, the rule says per component whether the
image ships it, and why. Nothing calls it yet; it is the judgment that
marking (card F) will apply.

## What

### Binary-backed components (Go modules, Rust crates)

- A module is held when any produced binary names its import path; a
crate when any produced binary names its name and version. One binary
legitimately names many, so no exclusivity applies.
- Any produced binary whose Go (or Rust) evidence reads `unknown` holds
every module (or crate): "I don't know" is never "not shipped".
- A test binary contributes nothing, whatever it names, and does not
fail anything open either.
- A produced set that could not be established (`nil`) holds every
binary-backed component as `no-data`; a produced set known to be empty
holds none — an image that declares `go.mod` but never runs `go build`
has all its modules dropped as `not-linked`.
- Go and Rust evidence are read independently: a cgo binary naming its
modules while carrying no audit data holds its modules exactly and every
crate.

### File-backed components (os-pm, Python, JavaScript, Lua)

- A component is held as `content` when a file of its own is in the
image byte for byte, provided no other component of the same ecosystem
claims those bytes.
- Ownership is counted by `(name, version)`: five identical copies of
`debug@2.6.9` under different `node_modules` are one owner and do not
block each other.
- Ownership is counted within an ecosystem, not across: a deb shipping
an npm tree and the npm packages in that tree both hold their files.
- A component whose every matching file is shared is held as
`content-shared` only when no co-owner proved itself by a file of its
own; otherwise it is dropped. A stale `openssl` sharing `c_rehash` with
the fresh one is dropped once the fresh one is proven by `libssl.so.3`.
- A zero-size file proves nothing.
- A component none of whose bytes match is held as `path` when one of
its declared paths still exists in the image and no proven component
explains it: a certificate bundle regenerated after install stays held.
- Failing that, as `name` when a file named like one of its exists
unexplained, no other component declares that name, and the name is not
one every package carries (`LICENSE`, `README`, `package.json`,
`__init__.py`, …).
- Path and name look only at files no proven component explains, the
symlinks of proven components included: a stale version is not rescued
by the path or symlink the fresh version now owns.
- A component nothing is known about (`Occurrences == nil`) is held as
`no-data`; one whose files were looked for and none found (`Occurrences`
empty) is dropped as `no-match` — these are different answers and the
rule keeps them apart.
- Every drop is one debug log line: `not shipped <ecosystem>
<name>@<version>: checked N files, 0 matches`.

### Output

- Decisions come back in the order of components, each with the reason
and the image paths that proved it, so a consumer can record where the
component is in this image rather than where it was in the donor.
- No user-visible surface changes: no flag, no log at default level, no
change to any SBOM werf produces today.
- VERIFIED: the concept's end-to-end stand (17 components across Go,
Python, JavaScript and os-pm, built from a builder image into a
distroless final) reproduces its expected verdicts as a unit spec over
the same components and files.

## Why

Shipped-scope cuts an SBOM to what the final image ships. The index of
the image's files (#433), each installed component's own files (#435)
and what every produced binary carries (#442) are the evidence; this is
the first place the evidence meets a component and yields an answer.
Without it the SBOM lists everything the build touched, build-only
tooling included, and every vulnerability in that tooling is reported
against an image that does not contain it.

Three decisions here depart from the Python prototype the concept was
validated on. The prototype held a component with no exclusive file
unconditionally; here it is held only when no co-owner is proven,
because the real source of such components is a stale version next to a
fresh one, and a fail-open there hides exactly what marking before
deletion is meant to surface. The prototype matched Go modules by binary
hash; here by name, because the classifier (#442) reads the module list
out of the binary and the binary is read live from the image being
matched, so no donor hash is needed. The prototype had no path or name
tier; here both, because a file rewritten in place after install —
`update-ca-certificates`, `compileall` — defeats the content check for a
component that is unquestionably shipped.

---------

Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
alexey-igrychev pushed a commit that referenced this pull request Oct 20, 2026
🤖 I have created a release *beep* *boop*
---


##
[3.11.0-dk.1](v3.10.0-dk.3...v3.11.0-dk.1)
(2026-10-20)


### Features

* **build:** publish built images to multiple final repositories
([werf#8087](https://github.com/deckhouse/delivery-kit/issues/8087))
([9a30573](9a30573))
* **build:** publish cached images with require-built-images
([93776dc](93776dc))
* **sbom:** decide which components an image actually ships
([#452](#452))
([69de584](69de584))
* **sbom:** read which modules and crates a produced binary carries
([#442](#442))
([11a39f6](11a39f6))
* **sbom:** record which files each component of an image consists of
([#453](#453))
([fcc8e93](fcc8e93))


### Bug Fixes

* **build:** avoid panic on cache-repo errors without final-repo
([werf#8089](https://github.com/deckhouse/delivery-kit/issues/8089))
([f553a94](f553a94))
* **build:** reduce native build delays on populated image stores
([werf#8064](https://github.com/deckhouse/delivery-kit/issues/8064))
([d7e8f38](d7e8f38))
* **build:** retain actionable check-mode diagnostics
([93dc456](93dc456))
* **cleanup:** limit registry credentials to deletion commands
([werf#8086](https://github.com/deckhouse/delivery-kit/issues/8086))
([5fe00b3](5fe00b3))
* **deploy, plan:** respect `--releases-history-max` with `--use-plan`
([werf#8085](https://github.com/deckhouse/delivery-kit/issues/8085))
([9bb995b](9bb995b))
* **deploy, tracking:** honor `$WERF_NO_POD_LOGS`
([werf#8091](https://github.com/deckhouse/delivery-kit/issues/8091))
([97866b8](97866b8))
* **deploy:** respect `--no-pod-logs` with `--use-plan`
([werf#8092](https://github.com/deckhouse/delivery-kit/issues/8092))
([ffe489e](ffe489e))


### Miscellaneous Chores

* **release:** pin upstream base to 3.11.0-dk.1
([e955d96](e955d96))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant