Skip to content

feat(sbom): decide which components an image actually ships - #452

Merged
reyreavman merged 2 commits into
mainfrom
feat/sbom/shipped-scope-rule
Oct 19, 2026
Merged

reyreavman merged 2 commits into
mainfrom
feat/sbom/shipped-scope-rule

Conversation

@reyreavman

Copy link
Copy Markdown
Collaborator

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.

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>
@reyreavman

Copy link
Copy Markdown
Collaborator Author

Verification

  • ./pkg/sbom/... on buildah-commons (linux/amd64, cgo): green; shippedscope 108/108.
  • Mutation: owner key without ecosystem (exclusivity counted globally, all three sites) → counts ownership within an ecosystem… fails.
  • Mutation: owners never deduplicated (owner per instance) → counts ownership by package version… fails.
  • Mutation: path tier short-circuited to no-match → holds a component whose file was rewritten in place, by its path fails.
  • Mutation: name tier requires zero declarers (never fires) → holds a relocated and rewritten file by its unique name fails.
  • Mutation: zero-size files indexed as evidence → does not take a zero-size file as evidence fails.
  • Mutation: test binary contributes its modules → lets a test binary speak for nobody… fails.
  • Mutation: content-shared granted without checking co-owners → credits a shared file to the owner that proved itself… fails.
  • Mutation: explained-set ignored in the path tier → does not let a path explained by a proven component hold a stale one fails.
  • Mutation: proven components' symlinks not marked explained → does not let a proven component's symlink hold a stale version by path fails.
  • Mutation: nil and empty produced set collapsed → drops every module when the build is known to have produced no binary fails.
  • All ten mutants compiled; a mutant that fails to compile is reported separately and not counted as killed.
  • Not run: the concept's bench-e2e stand against real Docker images — it needs the step that writes a donor's filesets into its SBOM, which is a separate card; the stand's components and files are encoded as a unit spec instead.

Review focus

  • Match is one function on purpose: the passes share the explained set and the order between them is the rule. Check that nothing explained in pass 1 can be used by pass 2, and that content-shared is resolved only after every component has had its pass-1 chance.
  • isGenericName is a judgment list. A name on it can never hold a component by the name tier; a name missing from it can. Argue with the list.
  • The produced-set nil/empty distinction is carried by the pointer, not a field. Confirm that reads as intended at the call site F will add.

Follow-up

  • Card 71982124: write each component's files into its SBOM as evidence.occurrences in ConvergeWithMerge, so the final image has what this rule needs for inherited components.
  • Card F: call the rule in ConvergeWithMerge, set werf:shipped=false on dropped components, rewrite occurrences of kept ones to the paths in Matched.
  • Run bench-e2e end to end once both land.

@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 (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, nil vs empty — fails its spec. Result is order-independent (probed: fresh/stale swapped → identical decisions). Two gaps, neither blocking: a proven owner's Matched omits the shared files it is credited with, so the B→C chain loses evidence; the name tier is O(occurrences × |index|) per component, which on ncurses (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 импорта"; Match compares 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

  1. add→to relocation is neither applied nor delegated — match.go:203-216. Inline.

Minor

  1. A proven owner's Matched omits the shared files credited to it — match.go:156-160. Inline with probe.
  2. goVerdict/rustVerdict report fail-open for a module a binary explicitly names — match.go:333-340. Inline; M5 (swap the cases) stays green, so no spec pins either order.
  3. Name tier scans the whole index per occurrence — match.go:221-245. Inline.
  4. Name uniqueness is counted across ecosystems while content exclusivity is per ecosystem — collectNames :401. The deb node-lodash / npm lodash pair that the exclusivity rule was carved out for blocks each other's name tier (lodash.js has 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-e2e stand — 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.

Comment on lines +203 to +211
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
}
}

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. 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.

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.

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.

Comment thread pkg/sbom/shippedscope/match.go Outdated
Comment on lines +156 to +160
if len(owners[ownerKey{component.Ecosystem, occurrence.Digest}]) > 1 {
shared = append(shared, sharedHit{component: i, digest: occurrence.Digest, path: imagePath})

continue
}

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. 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.

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.

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.

Comment on lines +333 to +340
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}
}

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. 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.

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.

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.

Comment on lines +221 to +245
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)
}
}

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). 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.

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.

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>
@reyreavman

Copy link
Copy Markdown
Collaborator Author

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: x ships /x/f (digest d), y ships /y/f (same d) and /y/g; all three in the image. Before: y proven by /y/g, x read y as a proven co-owner and was dropped with its own file byte-for-byte at its own path — and y's Matched carried /x/f, a path it never declared. Realistic: Debian source-split packages with identical copyright/doc stubs, vendored copies (pip/_vendor/six.py vs six/six.py). Fix: an image path is contested only by the components that declare that path; all owners of the bytes contest it only when nobody declares it (the file was moved on its way in). Pinned by treats two files with the same bytes at two paths as two files and lets every owner of the bytes contest a file nobody declares at its image path; the mutation restoring digest-only ownership is killed.

Also from the independent pass: a content-shared component's second shared hit was never appended to Matched (pinned, mutation killed); ProducedBinaries is now a value with a Known flag instead of a nil sentinel (CODESTYLE: no nil arguments); Checked counts only files whose bytes were looked for, symlinks carry none; index == nil panics early; slices.Contains* → lo.*; new specs pin the all-symlink component, the three-way share with one proven owner, and the co-owner self-exclusion (two copies of debug@2.6.9); the stand spec now asserts reasons, not just the verdict, and indexes /app/bin/app instead of /app as a regular file.

Checked and left as is, with the decision stated in the godoc:

  • Go/file ecosystems do not see each other; a produced Go binary can hold an unrelated file-backed component of the same name through the name tier — that is the holding direction.
  • A binary imported from a donor is not in ProducedBinaries: the modules it carries are the donor's SBOM's to prove and arrive as components with their own evidence. Stated on the type.

Open, outside this diff: binary.go collects info.Deps but never info.Main.Path. syft's binary cataloger does emit the main module as a component (example.test/app UNKNOWN in a local scan), so in an image whose SBOM carries its own main module it would read not-linked. One line in ClassifyBinary, but it is card D's file, not E's — I'd rather take it as a follow-up than widen this PR. Say if you want it here.

Verification: task lint 0 issues; 115/115 locally and on buildah-commons (./pkg/sbom/...); 17 compiling mutants killed across both reviews (one equivalent mutant — Shipped vs Reason == content where both branches append — discounted).

@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 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.

@reyreavman
reyreavman marked this pull request as ready for review October 19, 2026 07:31
@reyreavman
reyreavman merged commit 69de584 into main Oct 19, 2026
7 checks passed
@reyreavman
reyreavman deleted the feat/sbom/shipped-scope-rule branch October 19, 2026 07:31
reyreavman added a commit that referenced this pull request Oct 19, 2026
…#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>
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