Repository navigation
feat(sbom): collect the files each installed component ships - #435
Conversation
Reading a manifest after the index is built would cost a second pass over the layers, which on registry images is mostly gzip. The walk already has every file's bytes in hand, so a caller can now name the files it wants kept. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
CollectFilesets walks an image once and answers, per os-pm package, Python distribution, npm package and Lua rock, which files are that component's. Each ecosystem draws its own boundary from the manifest it writes — RECORD, package.json, rock_manifest, pm's files.json — never from a directory, so namespace packages, nested node_modules copies and rocks spread over shared trees come out right. Digests always come from the image, not from the manifest: a file an install hook rewrote ships with its current content. An image installed by a pm too old to write files.json yields no os-pm filesets and a warning rather than a failed build. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
Verification
Review focus
Follow-up
|
…d read hardlinked manifests Review of #435. A package.json anywhere under node_modules counted as a package, so the fixtures and examples packages ship became phantom components that also claimed the package's own files; only a direct child of node_modules, a scope directory in it, or a distribution's nodejs directory counts now. A nested-tree check matched a directory merely ending in node_modules. A manifest the image reaches through a hardlink got its digest but not its bytes, so a hardlinked files.json read as "pm too old"; captured bytes now travel with the link, across layers when needed. CollectFilesets returns whether os-pm packages lack a file list instead of only logging it, so a consumer can tell that from an image without os-pm packages. Lua manifest strings are unescaped the way luarocks escapes them. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
reyreavman
left a comment
There was a problem hiding this comment.
Code Review Report
Base: main (6c93b138; #433 already merged as 42f5d0aa), head f66f377c
Diff: 11 files, +1321/-26 — CollectFilesets plus four ecosystem readers in pkg/sbom/shippedscope; IndexImageFiles gains an internal capture hook. No callers yet.
Context: shipped-scope concept, Kaiten card C (71736129).
Verdict
- Technical: Solid. Eight mutations each fail exactly the spec written for them, including the two subtle ones in the capture hook (
bodyOwed, link-name-decides-capture). One real defect found by probe: the.pyc→ source mapping cuts the stem at the first dot, so bytecode of a dotted source name is credited to the wrong distribution. Two nits on determinism and a hand-written Lua parser that is fine for what luarocks emits. - Product: Boundaries match card C and the concept (§3.1, §4). The
files.jsonshape is honestly marked UNVERIFIED in the body — good; the shape has to be pinned with thepmchange before F lands, otherwise os-pm stays in fail-open forever. - Risk: No user impact yet. Deferred: wrong
.pycattribution feeds E a false KEEP for one distribution and a lost proof for another.
DoD Criteria
From card C "Готово, когда" + PR body.
| Criteria | Inferred? | Met? | Evidence |
|---|---|---|---|
os-pm from files.json; missing → fail-open with warning, not build error |
no | ✅ | os_pm_fileset.go:33-42; M8 fails spec :106 |
Python from RECORD, not directory; namespace split |
no | ✅ | python_fileset.go:35-42; spec :9 |
PyYAML → yaml (name ≠ directory) |
no | ✅ | spec :49; M5 fails 5 specs |
.pyc credited to its .py, with and without the .py present |
no | spec :49,:93 pass; dotted stem mis-credited — Major #1 |
|
JS by last node_modules segment; nested trees split |
no | ✅ | javascript_fileset.go:100-112; M1/M2 each fail 1 spec |
/usr/share/nodejs/<name>/ for distribution packages |
no | ✅ | :13, spec :46 |
Lua from rock_manifest + install roots share/lua, lib/lua, bin |
no | ✅ | lua_fileset.go:36-40; M7 fails spec :68 |
| Digests from the image, never from the manifest | yes | ✅ | collect() reads index.Files; spec os_pm_fileset_test.go:38 |
Hashes as sha256:<hex> like FileIndex.Files[].Digest |
no | ✅ | shared File type |
| Single image pass (no second layer walk for manifests) | yes | ✅ | indexImageFiles(..., capture), file_index.go:63 |
IndexImageFiles behaviour unchanged |
yes | ✅ | 24 index specs still green; capture=nil path |
task format/build/lint/test:unit green |
no | ✅ | build OK, lint 0 issues, 52/52 |
Issues
Major
.pycstem cut at the first dot credits bytecode to the wrong source —python_fileset.go:99. Inline with reproduction.
Minor
Filesets.Filesetsorder is non-deterministic —fileset.go:65-67,os_pm_fileset.go:50,python_fileset.go:24,javascript_fileset.go:26,lua_fileset.go:17. Every reader ranges over a map. Harmless for the join in E, but the "not shipped …" log lines F emits will shuffle between identical builds and so will any report a consumer writes.slices.Sorted(maps.Keys(...))asresolveHardlinksalready does.- Hand-rolled Lua table parser —
lua_fileset.go:105-253. 150 lines for a format luarocks writes with one serializer. It is correct for that output (nested tables,%qstrings,["key"] =and bare keys) and the "not a table → skip" guard is right. CODESTYLE says "for complex things use libraries"; I looked and there is no small Lua-table reader ingo.mod, so this is acceptable — just noting that a numeric value, a comment, or--[[ ]]would make the whole manifest skip silently. Fine for now; a Debug log on parse failure would make that visible. recordPathsswallows CSV errors —python_fileset.go:69-71. A malformed line is skipped withcontinue; withLazyQuotesthat is rare, but a Debug log would help when a distribution comes up empty.
Mutation log
| Mutation | Result |
|---|---|
M1: inPackageTree substring match instead of segment |
✅ fails …directory whose name merely ends the same way |
M2: any package.json under node_modules counts |
✅ fails leaves a fixture a package ships… |
M3: bodyOwed returns false (early exit ignores owed bodies) |
✅ fails …hardlinks to a file another layer wrote |
| M4: capture decided by target name, not link name | ✅ 2 fail — both hardlinked-manifest specs |
| M5: Python name not normalised | ✅ 5 fail |
M6: .pyc stem via TrimSuffix(Ext) (last dot) |
✅ 2 fail — both compiled-file specs |
M7: Lua lib root hardcodes 5.4 |
✅ fails takes the lua version of the tree… |
M8: OSPMFileListMissing never set |
✅ fails carries on without the file list… |
Tree restored after each; final 52/52.
Risks
| № | Risk | Type | Likelihood | Severity | Location | Circumstances | Consequences | Recommendation |
|---|---|---|---|---|---|---|---|---|
| 1 | Bytecode of a dotted source credited to another distribution | Technical | Possible | Medium | python_fileset.go:99 |
Any site-packages holding name.sub.py beside name.py |
In E: one distribution gets a false KEEP(content), the owner loses a proof | Major #1 |
| 2 | files.json shape diverges from what pm eventually writes |
Operational | Possible | Medium | os_pm_fileset.go:19-23 |
pm install change lands with a different schema |
Every os-pm image silently in fail-open (OSPMFileListMissing=true) |
Pin the schema with the pm team before F; the body already flags this |
Not verified
- Real
RECORD/package.json/rock_manifestfrom live images — fixtures only; no e2e in this PR (card C asks for unit specs only). - Unscoped
task lint/task test:unit— scoped to./pkg/sbom/shippedscope/...; the package is isolated. - Caller tracing — not applicable, new exported symbols with no callers.
| stem, _, _ := strings.Cut(path.Base(file), ".") | ||
| source := path.Join(path.Dir(dir), stem+".py") |
There was a problem hiding this comment.
Major. strings.Cut(base, ".") takes everything before the first dot, but a source name may itself hold dots. Probe:
imageOf([]tarEntry{
regularFile("site/foo.py", "foo"),
regularFile("site/foo.bar.py", "foobar"),
regularFile("site/__pycache__/foo.bar.cpython-312.pyc", "compiled foobar"),
regularFile("site/a-1.0.dist-info/RECORD", "foo.py,,\n"),
regularFile("site/b-1.0.dist-info/RECORD", "foo.bar.py,,\n"),
})
// a(foo.py) has foo.bar.cpython-312.pyc = true ← wrong owner
// b(foo.bar.py) has it = false ← real owner loses itSuch names are not importable, but they do exist in site-packages (vendored data, conf.d-style modules, _vendor trees) and compileall compiles them regardless. In E that is a false KEEP(content) for a and a lost proof for b.
The bytecode name is <stem>.<tag>[.opt-N].pyc where <tag> is the interpreter tag (cpython-312, pypy310-pp73). Cut from the right instead: strip .pyc, strip an optional .opt-N, then strip the last dotted component — everything left is the stem. Mutation M6 (TrimSuffix(Ext)) already shows two specs guard the current behaviour; add this probe as the third.
| filesets.Filesets = append(filesets.Filesets, pythonFilesets(index, manifests)...) | ||
| filesets.Filesets = append(filesets.Filesets, javascriptFilesets(index, manifests)...) | ||
| filesets.Filesets = append(filesets.Filesets, luaFilesets(index, manifests)...) |
There was a problem hiding this comment.
Minor. Every reader ranges over a map (manifests, and list in os-pm), so the order of Filesets.Filesets changes run to run. The join in E does not care, but the per-component log lines F will emit (not shipped name@ver: …) and any report a consumer writes will shuffle between two builds of the same image. resolveHardlinks already sorts its keys for exactly this reason — do the same here, or sort once at the end of CollectFilesets by (Ecosystem, Name, Version).
| // parseRockManifest reads the Lua table luarocks writes: nested tables of string keys and | ||
| // string values, and nothing else of the language. | ||
| func parseRockManifest(body []byte) map[string]any { | ||
| text := string(body) | ||
|
|
||
| start := strings.Index(text, "{") | ||
| if start < 0 { | ||
| return nil | ||
| } | ||
|
|
||
| table, _, ok := parseLuaTable(text[start:]) | ||
| if !ok { | ||
| return nil | ||
| } | ||
|
|
||
| return table | ||
| } |
There was a problem hiding this comment.
Minor. The parser is correct for what luarocks' serializer emits and the fail-closed guard (not a table → no fileset) is the right default. Two notes, neither blocking:
- any token the grammar does not know (a numeric value, a
--comment, a[[long string]]) makes the whole manifest skip silently — aDebugline here would save an hour when a rock comes up empty; - the
.rockspecentry at the top level of the manifest resolves to<rockDir>/<name>.rockspecvia the "not an install root" branch, which is right, but the comment at:46-47reads as if that were the exceptional case; it is the normal one fordoc,conf, and the rockspec.
| if err != nil { | ||
| continue | ||
| } |
There was a problem hiding this comment.
Minor. A malformed line is dropped with a bare continue. With LazyQuotes this is rare, but when it happens the distribution silently loses files and E will mark it. A Debug log with the manifest path and line is enough.
…d list filesets in one order Review of #435. The stem of a .pyc was cut at the first dot, so foo.bar.cpython-312.pyc went to foo.py rather than foo.bar.py: one distribution kept a file it never had, another lost a proof. The name is now taken apart from the right, past the interpreter tag and an optional opt-N. Filesets come out sorted by ecosystem, name and version, so a consumer that logs or reports per component sees the same order on every build. A kept body is never nil now, so an empty RECORD yields an empty fileset rather than none. A RECORD line or a rock manifest that does not parse leaves a debug line behind. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
|
Thanks — all four addressed in Major #1, Minor #2, order. Minor #3, Lua parser. A manifest that is not the table luarocks writes now leaves a Minor #4, RECORD lines. A line the CSV reader rejects leaves a One more thing the order spec surfaced: a kept body was On risk #2 ( |
reyreavman
left a comment
There was a problem hiding this comment.
Code Review Report — follow-up on d32ff126
Base: main (6c93b138), head d32ff126
Delta since f66f377c: 8 files, +159/-14.
Verdict
- Technical: All four findings from the previous round are addressed, and the fix for Major #1 is proven by two new specs. One residual in the same function:
bytecodeStemcuts.opt-at its first occurrence, so a stem that itself contains.opt-is lost entirely —ok=false, the bytecode is credited to nobody. Same class of defect the commit fixes, one step further in. - Product: Deterministic order is now a tested contract (
fileset_test.go:9); debug lines on unreadable RECORD lines and rock manifests are in. Nothing else outstanding. - Risk: Residual is narrower than the original (a dotted stem with
.opt-in it) and fails closed (file dropped, not mis-credited). Low.
Previous findings
| # | Finding | Status |
|---|---|---|
| Major 1 | .pyc stem cut at first dot |
✅ bytecodeStem cuts from the right — but see Minor #1 below |
| Minor 2 | non-deterministic Filesets order |
✅ readers iterate sorted keys + SortStableFunc in CollectFilesets; spec fileset_test.go:9 |
| Minor 3 | Lua parse failure silent | ✅ lua_fileset.go:39; comment at :54-55 reworded |
| Minor 4 | recordPaths swallows CSV errors |
✅ python_fileset.go:76 |
Bonus: readFile now returns a non-nil body for an empty kept file (file_index.go:158), so an empty RECORD yields an empty fileset rather than none — correct and documented.
Issues
Minor
strings.Cut(rest, ".opt-")takes the first.opt-, not the last —python_fileset.go:124. Inline with probe.
Verification
task build OK, lint:golangci-lint on the package 0 issues, 55/55 specs. Probe of bytecodeStem on seven names: six correct, one wrong (inline).
Not verified
- Unscoped
task lint/task test:unit— scoped to./pkg/sbom/shippedscope/....
| if tagged, _, ok := strings.Cut(rest, ".opt-"); ok { | ||
| rest = tagged | ||
| } |
There was a problem hiding this comment.
Minor. strings.Cut splits at the first .opt-. The optimisation suffix is always the last dotted component before .pyc, so a stem that itself contains .opt- is cut too early:
foo.cpython-312.pyc -> "foo" ok
foo.bar.cpython-312.pyc -> "foo.bar" ok
foo.cpython-312.opt-1.pyc -> "foo" ok
my.opt-config.cpython-312.pyc -> "my" false ← dropped
my.opt-config.cpython-312.opt-2.pyc -> "my" false ← dropped
In the two bad cases rest becomes "my", cutLast finds no dot, ok=false, and compiledSources skips the file — the bytecode belongs to no distribution. Fails closed, unlike the original defect, but it is the same shape one level down.
Since the structure is <stem>.<tag>[.opt-N], strip from the right in order: .pyc, then the last dotted component if it matches opt-N, then the last dotted component (the tag). cutLast twice with a check on the middle piece does it:
rest := strings.TrimSuffix(name, ".pyc")
if stem, last, ok := cutLast(rest, "."); ok && strings.HasPrefix(last, "opt-") {
rest = stem
}
stem, _, ok := cutLast(rest, ".")Please add my.opt-config.cpython-312.pyc to the dotted-stem spec so the two shapes are both pinned.
Review of #435. The suffix was cut at the first ".opt-", so bytecode of a source whose own name holds it — my.opt-config.py — lost its stem and was credited to no distribution at all. It is the last dotted component before the interpreter tag, and is now taken as that. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
|
Fixed in
One mutation I left unpinned and want to name rather than quietly skip:
|
reyreavman
left a comment
There was a problem hiding this comment.
Code Review — b55aeeea
Verdict: no findings. Closes the last Minor from the previous round.
bytecodeStemnow strips theopt-Ncomponent withcutLast+HasPrefix("opt-"), exactly the shape proposed —python_fileset.go:126.- Both shapes pinned in the dotted-stem spec (
my.opt-config.cpython-312.pycand.opt-2.pyc,HaveLen(3)). - Mutation (revert to
strings.Cut(rest, ".opt-")) fails that spec; 55/55 otherwise.
Nothing outstanding on this PR from my side.
🤖 I have created a release *beep* *boop* --- ## [3.10.0-dk.2](v3.10.0-dk.1...v3.10.0-dk.2) (2026-10-18) ### Features * **sbom:** collect the files each installed component ships ([#435](#435)) ([a591e0d](a591e0d)) ### Bug Fixes * **elf:** sign files with shared string tables ([#444](#444)) ([66c5f97](66c5f97)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
## 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>
Summary
werf can now answer, for an image it has built, which files each installed component ships:
shippedscope.CollectFilesetsreads the image once and returns one fileset per os-pm package, Python distribution, npm package and Lua rock, each as the set of(path, sha256)the image actually holds, plus whether os-pm packages went without a file list. Nothing calls it yet — it is the component-side input the shipped-scope rule joins against the final image's file index.What
Boundaries
RECORDdeclares, resolved against the directory holding its.dist-info; two distributions writing into one namespace directory (google/auth,google/protobuf) each get only their own files..pynamed inRECORDalso brings the__pycache__/<stem>.<tag>[.opt-N].pyccompiled from it, whether or not the.pyis still in the image;RECORDnever lists bytecode an installer did not write. The stem is read from the right, sofoo.bar.cpython-312.pycgoes tofoo.bar.py, notfoo.py.Typing_Extensions→typing-extensions); the version is the one in the.dist-infodirectory name.package.json, minus everynode_modulessubtree inside it (by path segment:lib/my_node_modules/stays); a second copy nested under a dependent is its own fileset.package.jsondirectly under anode_modulesdirectory, under a@scopein one, or under/usr/share/nodejsor/usr/lib/nodejscounts. A fixture or examplepackage.jsona package ships inside its own tree yields no fileset and its files stay the package's; an application's ownpackage.jsonyields nothing; so does one declaring nonameorversion.rock_manifestnames:bin,lib,luasections resolve into<prefix>/bin,<prefix>/lib/lua/<ver>,<prefix>/share/lua/<ver>for therocks-<ver>tree the rock sits in, every other section resolves inside the rock directory. Names luarocks had to escape (\",\\) are unescaped. A manifest that is not a Lua table yields nothing; the unversionedrocks/tree of LuaRocks 2 is not read./var/lib/pm/files.json.Shared rules
Filesets come out sorted by ecosystem, name, version, the same on every build of the same image.
A distribution whose
RECORDis empty yields an empty fileset, not no fileset.Every digest comes from the image, never from the manifest: a file an install hook rewrote after unpacking (
update-ca-certificates,compileall) is reported with the content it ships with.A path a manifest declares but the image no longer holds is left out; a symlink at a declared path is kept with its target.
An image with
/var/lib/pm/index.jsonbut nofiles.jsonyields no os-pm filesets, setsFilesets.OSPMFileListMissingand logsWARNING: /var/lib/pm/index.json is present but /var/lib/pm/files.json is not …; the build goes on. An image with neither file leaves the flag unset. Afiles.jsonthat does not parse fails the call.A manifest the image holds through a hardlink — in the same layer or another — is read like the file itself.
Components installed some other way — a Python
.egg-info, a LuaRocks 2 tree, a Yarn PnP cache — get no fileset; the absence says nothing about whether they shipped.UNVERIFIED: the
files.jsonshape ({"<name>": {"version", "files": {path: digest}, "symlinks": {path: target}}}) is proposed by this PR, not yet produced bypm; the matchingpm installchange settles it.IndexImageFilesis unchanged in behavior and cost; the walk gained an internal hook that keeps the bytes of chosen files so manifests cost no second pass over the layers. Only when a hardlinked manifest's target sits in another layer does the hardlink pass read further down than it otherwise would.Why
The shipped-scope rule decides whether a donor's component survives into the final image by checking whether any of the component's files is still there. That needs the component's file list from the donor, and no existing source gives it: syft's SBOM has no per-file data for these ecosystems, and a directory is the wrong unit for all four — namespace packages share one, npm nests copies inside each other, luarocks spreads one rock over shared trees, and os-pm has no directory at all. Each ecosystem's own install manifest is the only thing that knows, so that is what is read, with the image's content taking precedence over whatever hash the manifest recorded.
A second image pass to read the manifests was rejected: on registry images the pass is dominated by gzip, measured at 51–88% of a full index in #433.