Repository navigation
feat(sbom): decide which components an image actually ships - #452
Conversation
Shipped-scope has the pieces it needs — an index of the image's files, each component's own files, what every produced binary carries — but nothing yet puts them together into an answer per component. This adds the rule. A component is dropped only when every check misses, from strongest to weakest, each one there for the case a deliberate change to a file defeats the previous: strip, patchelf, a certificate bundle regenerated after install. A Go module or a crate is held by any produced binary that names it. One binary names many, so no exclusivity applies; a binary that could carry components but names none holds all of them; a test binary names nothing. A produced set the build could not establish holds everything, a produced set known to be empty holds nothing — the two were one case in the prototype and collapsing them turns "no go build in this image" into a silent keep. A file-backed component is held by a file of its own in the image, byte for byte, provided no other component of the same ecosystem claims those bytes. Ownership is counted by (name, version), so identical copies of one package are one owner and do not block each other; and within an ecosystem, not across, because a deb shipping an npm tree and the npm packages in it both legitimately own the same files. A component whose every matching file is shared is held only when no co-owner proved itself by a file of its own: the one physical file belongs to whoever did, and this is what tells a stale openssl from the fresh one whose c_rehash it shares. Path and name are looked for only among files no proven component explains, symlinks of proven components included — a symlink carries no bytes and would otherwise hold a stale version through its path. A zero-size file proves nothing. Names every package carries prove nothing either. The rule knows neither where a component's files come from nor which files are produced; it takes occurrences and classifications and returns a decision with the reason and the image paths that made it, so a consumer can record where the component is in this image rather than where it was in the donor. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
Verification
Review focus
Follow-up
|
reyreavman
left a comment
There was a problem hiding this comment.
Code Review Report
Base: main (11a39f65, #442 merged), head 3c255c83
Diff: 2 files, +795/-0 — Match in pkg/sbom/shippedscope, no callers yet.
Context: concept §4 (rule), §3.3; Kaiten card E (71736166). Consumes #433 (FileIndex), #435 (Fileset → Occurrence), #442 (BinaryClassification).
Verdict
- Technical: The rule is the concept's §4, pass for pass, and every mutation that targets a concept decision — exclusivity per ecosystem, co-owner proven by content, path/name only over unexplained files, test binary silent,
nilvs empty — fails its spec. Result is order-independent (probed: fresh/stale swapped → identical decisions). Two gaps, neither blocking: a proven owner'sMatchedomits the shared files it is credited with, so the B→C chain loses evidence; the name tier is O(occurrences × |index|) per component, which onncurses(2917 files) × a 27k-file image is ~80M basename comparisons. - Product: Three departures from the prototype are argued in the body and I agree with all three. One concept item is absent and unmentioned: §4 says the path tier applies "с учётом add→to импорта";
Matchcompares donor paths verbatim. Either the caller (F) relocates occurrences before calling, or this needs to be here — state which. - Risk: None user-facing. The go/fail-open reason ordering hides a stronger verdict behind a weaker one (Minor #3).
DoD Criteria
From card E "Готово, когда" + concept §4.
| Criteria | Inferred? | Met? | Evidence |
|---|---|---|---|
| 1a: binary-evidence held by any binary naming it, no exclusivity | no | ✅ | match.go:332-356; spec lets one binary hold several modules |
1b: file-evidence, exclusivity by (kind, digest) within ecosystem, owner = (name, version) |
no | ✅ | collectOwners :379; M3 fails deb/npm spec |
| content-shared only when no co-owner proven by content | no | ✅ | :186-190; M1 fails openssl spec |
| 2: path then name, only over files unexplained by 1a/1b; symlinks of proven components explained | no | ✅ | :170-174, :204; M2 fails 3 specs |
| zero-size file is not evidence | no | ✅ | indexByDigest :268 |
Occurrences == nil → no-data; [] → no-match |
no | ✅ | :141, :198; spec :122 |
| test binary speaks for nobody and does not fail open | no | ✅ | :306; M4 fails 2 specs |
produced set nil → no-data; empty → not-linked |
no | ✅ | :300-303; specs :63, :71 |
| path tier "с учётом add→to" | no | ❌ | not in Match; not in body — Major #1 |
| mutations "глобальная эксклюзивность", "владелец по экземпляру", "без тиров", "нулевой файл" fail specs | no | ✅ | M3; copies spec :185; M2; zero-size spec |
bench-e2e/expected.json verdicts reproduce |
no | ✅ | match_test.go:294-355 — as a unit spec over the same components; the real stand is not driven |
task format/build/lint/test:unit green |
no | ✅ | build OK, lint 0, 108/108 |
Issues
Major
add→torelocation is neither applied nor delegated —match.go:203-216. Inline.
Minor
- A proven owner's
Matchedomits the shared files credited to it —match.go:156-160. Inline with probe. goVerdict/rustVerdictreportfail-openfor a module a binary explicitly names —match.go:333-340. Inline; M5 (swap the cases) stays green, so no spec pins either order.- Name tier scans the whole index per occurrence —
match.go:221-245. Inline. - Name uniqueness is counted across ecosystems while content exclusivity is per ecosystem —
collectNames :401. The debnode-lodash/ npmlodashpair that the exclusivity rule was carved out for blocks each other's name tier (lodash.jshas two owners). Probably fine — name is the weakest tier and both are held by content in that case — but it is an asymmetry the godoc should state.
Mutation log
| Mutation | Result |
|---|---|
| M1: shared hit always held (prototype behaviour) | ✅ fails credits a shared file to the owner that proved itself |
M2: path tier ignores explained |
✅ 3 fail |
| M3: ownership keyed without ecosystem | ✅ fails deb/npm spec |
| M4: test binary contributes evidence | ✅ 2 fail |
M5: modules[...] checked before goFailOpen |
green 108/108 — Minor #3 |
Probes: component order swapped → identical decisions; two stale versions sharing a rewritten path → both path (concept-accepted fail-open; path tier does not mark explained, matching §4 "только 1a/1b объясняют").
Risks
| № | Risk | Type | Likelihood | Severity | Location | Circumstances | Consequences | Recommendation |
|---|---|---|---|---|---|---|---|---|
| 1 | Relocated import (add: /usr/bin/jq → to: /bin/jq) misses the path tier |
UX/Product | Likely | Medium | match.go:207 |
Any import with to ≠ add, file rewritten in place |
Component falls to name tier or drops | Major #1 |
| 2 | Chain B→C loses shared-file evidence | UX/Product | Possible | Low | match.go:156-160 |
F rewrites occurrences from Matched |
Downstream image sees fewer proofs | Minor #2 |
| 3 | Name tier wall time on large packages | Operational | Possible | Low | match.go:227,236 |
ncurses-class components not held by content |
Seconds per component | Minor #4 |
Not verified
- The real
bench-e2estand — the spec encodes its expected verdicts over hand-written components, not over the built images. - Unscoped
task lint/task test:unit— scoped to the package.
| for _, occurrence := range component.Occurrences { | ||
| if explained[occurrence.Path] { | ||
| continue | ||
| } | ||
| if _, ok := index.Files[occurrence.Path]; !ok { | ||
| if _, ok := index.Symlinks[occurrence.Path]; !ok { | ||
| continue | ||
| } | ||
| } |
There was a problem hiding this comment.
Major. Concept §4: «путь компонента (с учётом add→to импорта) существует в образе». occurrence.Path here is the donor's path, compared verbatim against index. For the common import: {add: /usr/bin/jq, to: /bin/jq} the content tier still works (digest), but the moment the file is rewritten in place — the exact case the path tier exists for — the lookup is /usr/bin/jq against an image that has /bin/jq, and the component falls through to the name tier or drops.
The e2e stand spec hides this: jq is matched by content (/usr/bin/jq → /bin/jq, same digest), so the relocation never reaches the path tier there.
Two places it can live: (a) the caller (F) rewrites each Occurrence.Path through the image's import list before calling Match — then say so in the Occurrence godoc ("paths as they would be in this image"); (b) Match takes the relocations. (a) keeps this package free of werf.yaml knowledge and matches the current signature; I would go with it, but the contract has to be written down — otherwise F will pass donor paths and the tier silently never fires.
There was a problem hiding this comment.
Fixed in 9536952. The contract is now stated on Occurrence: the path is the caller's to relocate (an occurrence recorded under /usr/bin/jq and imported with to: /bin arrives as /bin/jq); the rule compares verbatim and knows nothing of imports. Card F (occurrence rewriting) is where the relocation is applied — the rule stays free of werf.yaml semantics.
| if len(owners[ownerKey{component.Ecosystem, occurrence.Digest}]) > 1 { | ||
| shared = append(shared, sharedHit{component: i, digest: occurrence.Digest, path: imagePath}) | ||
|
|
||
| continue | ||
| } |
There was a problem hiding this comment.
Minor. A shared hit is parked and later credited only to a component that is not yet shipped (:181). The proven owner never gets it:
fresh openssl 3.0.13: Reason=content Matched=[/usr/lib/libssl.so.3] ← /usr/bin/c_rehash missing
stale openssl 3.0.11: Reason=no-match
c_rehash is in the image and the rule has decided it belongs to 3.0.13 (that is why 3.0.11 is dropped). Concept §3.3: «на KEEP occurrences переписываются под фактически найденные файлы в B» — if F builds the new occurrences from Matched, image C importing only c_rehash from B will not hold openssl. Fix: in the shared pass, when the component is already shipped by content, append hit.path to its Matched instead of continue.
There was a problem hiding this comment.
Fixed in 9536952. A proven owner is now credited with every shared file it is credited with, and a content-shared component with every shared hit, not only the first. Pinned by proved itself by a file of its own (Matched now carries c_rehash too) and the new credits a content-shared component with every shared file; mutation _ = hit.path on the append is killed.
| switch { | ||
| case !e.known: | ||
| return Decision{Shipped: true, Reason: ReasonNoData} | ||
| case e.goFailOpen: | ||
| return Decision{Shipped: true, Reason: ReasonFailOpen} | ||
| case e.modules[component.Name]: | ||
| return Decision{Shipped: true, Reason: ReasonBinary} | ||
| } |
There was a problem hiding this comment.
Minor. goFailOpen is tested before modules[name], so a module that a produced binary explicitly names is reported fail-open whenever any other produced binary is unreadable. Both are Shipped: true, but F logs the reason and the marking data is what H ("переход на удаление") will be decided on: a module held by binary is a strong keep; one held by fail-open is noise. Swapping the two cases (named → binary, otherwise fail-open → fail-open) is strictly more informative and changes no Shipped value. Mutation M5 does exactly that swap and stays 108/108 green — pin whichever order you choose with a spec that has one named module and one unreadable binary. Same for rustVerdict.
There was a problem hiding this comment.
Fixed in 9536952: modules[name] / crates[...] are tested before the fail-open flag for both Go and Rust. Pinned by reports a module a binary names as held by that binary, even when another binary is unreadable; the swap-back mutation is killed.
| for _, occurrence := range component.Occurrences { | ||
| name := path.Base(occurrence.Path) | ||
| if isGenericName(name) || len(names[name]) > 1 { | ||
| continue | ||
| } | ||
|
|
||
| for imagePath := range index.Files { | ||
| if explained[imagePath] || path.Base(imagePath) != name { | ||
| continue | ||
| } | ||
|
|
||
| decision.Shipped = true | ||
| decision.Reason = ReasonName | ||
| decision.Matched = append(decision.Matched, imagePath) | ||
| } | ||
| for imagePath := range index.Symlinks { | ||
| if explained[imagePath] || path.Base(imagePath) != name { | ||
| continue | ||
| } | ||
|
|
||
| decision.Shipped = true | ||
| decision.Reason = ReasonName | ||
| decision.Matched = append(decision.Matched, imagePath) | ||
| } | ||
| } |
There was a problem hiding this comment.
Minor (perf). For every occurrence of every component that reached this tier, the whole index.Files and index.Symlinks are walked comparing path.Base. ncurses from the concept has 2917 occurrences; against ruby:3.4's 26.7k files that is ~78M path.Base calls for one component. Build map[basename][]imagePath once beside indexByDigest and look up — same shape as names, one line in the loop.
There was a problem hiding this comment.
Fixed in 9536952: indexByName builds a basename → paths index once (files and symlinks), the tier looks names up instead of walking the image per occurrence.
…ipped-scope rule Pass 1b keyed ownership by digest alone, so two components each shipping a byte-identical file at its own path contested one another: the one without a second unique file was dropped while its own file sat in the image, and the other was credited with a path it never declared. A component's image path is now contested only by the components that declare that path; every owner of the bytes contests it only when nobody declares it, i.e. the file was moved on its way in. Also from review: the path tier's relocation contract is stated on Occurrence (the caller relocates add→to, the rule compares verbatim); a proven owner and a content-shared component are credited with every shared file in Matched, not only the first; a module a binary names reads `binary` even when another binary is unreadable; the name tier uses a basename index instead of walking the image per occurrence; ProducedBinaries carries a Known flag instead of a nil sentinel; Checked counts only files whose bytes were looked for. Specs pin each branch and the stand asserts reasons, not just the verdict. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
|
Addressed the review in 9536952, plus an independent review pass that found one more Major the first pass missed. Independent review, Major — two files with the same bytes at two paths were one file. Pass 1b keyed ownership by digest alone. Input: Also from the independent pass: a content-shared component's second shared hit was never appended to Checked and left as is, with the decision stated in the godoc:
Open, outside this diff: Verification: |
reyreavman
left a comment
There was a problem hiding this comment.
Code Review — follow-up on 9536952b
Delta since 3c255c83: 2 files, +260/-83.
Verdict
No findings. All five items from the previous round are closed, and the commit also fixes a real defect I had not caught: ownership keyed by digest alone let two components that each ship a byte-identical file at their own path contest each other. Per-path ownership (ownersAtPath, falling back to all owners of the bytes only when nobody declares the image path) is the right model and is pinned by its own spec.
| # | Finding | Status |
|---|---|---|
| Major 1 | add→to relocation contract | ✅ stated on Occurrence (match.go:22-25): caller relocates, rule compares verbatim |
| Minor 2 | proven owner's Matched omits shared files |
✅ match.go:231-235; openssl spec now asserts [c_rehash, libssl.so.3] |
| Minor 3 | fail-open shadows explicit binary |
✅ cases swapped (:387-390, :400-403); new spec pins it |
| Minor 4 | name tier O(occ × index) | ✅ indexByName (:320) |
| Minor 5 | name uniqueness cross-ecosystem | ✅ documented on collectNames (:464-466) as deliberate |
Also: ProducedBinaries.Known replaces the nil sentinel — zero value is fail-open, which is the right default for a struct a caller may forget to fill; Checked excludes symlinks; stand spec asserts reasons.
Mutation log
| Mutation | Result |
|---|---|
| ownership by digest only (per-path removed) | ✅ fails treats two files with the same bytes at two paths as two files |
| proven owner not credited with shared files | ✅ fails credits a shared file to the owner that proved itself |
goFailOpen before modules[...] |
✅ fails reports a module a binary names as held by that binary… |
115/115 otherwise; lint 0 issues; tree restored after each.
Nothing outstanding on this PR from my side — I would approve.
…#453) ## Summary An image's SBOM says a component is there; it does not say which files are that component's. A child image built FROM this one, or importing files out of it, inherits the component list wholesale with nothing to check it against, so a component whose files never arrived stays in the child's SBOM and is reported as its attack surface. Every image whose SBOM werf generates now records, per component, the files that component put into the image, which is the evidence the shipped-scope rule (#452) needs to answer that question on the child. ## What - A component installed by os-pm, pip, npm/yarn/pnpm or luarocks gets `evidence.occurrences` in the image's SBOM: one entry per file, `location` = the path in this image, `additionalContext` = `sha256:<hex>` for a regular file and `symlink` for a symlink. Every occurrence werf writes carries one or the other, so a reader can tell them from occurrences that arrived in a merged document. - The files are the image's, not the manifest's: the digest comes from the image content, and a path a manifest declares but the image no longer holds is left out. - A component none of those four ecosystems names gets no `evidence.occurrences` at all — a Go module, a deb/apk/rpm package, an egg-info install, a Yarn PnP cache. Nothing is known about its files and nothing is claimed. - A component that names no file of its own gets none either: an os-pm metapackage, a distribution whose `RECORD` is empty, a rock whose manifest lists nothing. - An occurrence list that is present but empty is the opposite statement — the component's files were looked for and none are in the image. That distinction is what a consumer turns into keep-or-drop. - An image installed by a pm too old to write `/var/lib/pm/files.json` gets no os-pm occurrences and one warning line, as before; its os-pm components stay unmarked rather than marked absent. - A component installed more than once under different paths — the npm case — carries the files of every copy. - The SBOM artifact format version goes to 11, so an image with an SBOM already attached regenerates instead of serving the occurrence-free one from cache. - The SBOM grows by one occurrence per recorded file: about 190 B of JSON, 45 B gzipped. VERIFIED: on `node:20-slim` 203 components and 1949 files take the component list from 26 KB to 400 KB (4 KB to 85 KB gzipped); on `python:3.12-slim` one component with 442 files adds 79 KB (19 KB gzipped). A project with a 100k-file `node_modules` would add on the order of 19 MB, 4 MB gzipped. There is no cap and no opt-out flag. - Generating an SBOM reads the built image out of the container backend it was pulled into — `SaveImageToStream`, the same read `MutateAndPushImage` does — not from the registry a second time. The cost is a save of the uncompressed image to a file under the werf tmp dir for the duration of the read (two copies on Buildah, whose `SaveImageToStream` writes its own), and no network. It happens only for an image that has a component of one of the four ecosystems. - An image with no component of those four ecosystems — a scratch Go image, a Dockerfile build with only deb packages — is not read at all and costs nothing. - A failure to save the image or read its file lists warns and leaves the SBOM without these records; it never fails the build. What can fail it is the host — an image that is not there, a `files.json` that does not parse, a disk that is full — not the network, so the SBOM that gets cached in its place is the one the next build would produce again. The warning names the werf image and the stage image: `image app: unable to read the built image <stage ref>; the SBOM records none of the files its components consist of`. - Deliberately unchanged: no component is removed, reordered or re-versioned, and nothing reads the occurrences yet — `Match` from #452 still has no caller. Nothing relocates an inherited occurrence's path through an `import`'s `to:`; that is the consumer's, as `Occurrence`'s contract states. ## Why The shipped-scope rule landed in #452 as a pure function with no input: it takes components with the files they claim, and werf had nowhere to get those from. os-pm, pip, npm and luarocks each record what they installed, and the image's own layers give the content, so the donor is the only place where the two can be put together — by the time a child is built, the manifests may not have come along at all. The alternative was to have the child derive the file list from the donor's manifests directly, which fails for exactly the case this is for: a distroless final gets the files and not `/var/lib/pm`, `.dist-info` or `node_modules/*/package.json`. Occurrences are written after `Canonicalize` rather than before it. Canonicalization folds duplicate components together, and its evidence merge drops a list that comes out empty — which is precisely the "looked for, none found" signal here, so writing earlier would erase it on every component that appears twice after a merge. --------- Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
🤖 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).
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)
unknownholds every module (or crate): "I don't know" is never "not shipped".nil) holds every binary-backed component asno-data; a produced set known to be empty holds none — an image that declaresgo.modbut never runsgo buildhas all its modules dropped asnot-linked.File-backed components (os-pm, Python, JavaScript, Lua)
contentwhen a file of its own is in the image byte for byte, provided no other component of the same ecosystem claims those bytes.(name, version): five identical copies ofdebug@2.6.9under differentnode_modulesare one owner and do not block each other.content-sharedonly when no co-owner proved itself by a file of its own; otherwise it is dropped. A staleopensslsharingc_rehashwith the fresh one is dropped once the fresh one is proven bylibssl.so.3.pathwhen one of its declared paths still exists in the image and no proven component explains it: a certificate bundle regenerated after install stays held.namewhen 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, …).Occurrences == nil) is held asno-data; one whose files were looked for and none found (Occurrencesempty) is dropped asno-match— these are different answers and the rule keeps them apart.not shipped <ecosystem> <name>@<version>: checked N files, 0 matches.Output
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.