feat(sbom): mark only entry-point packages as directly attackable - #332
Conversation
b6fce96 to
df5c001
Compare
Verification
Review focus
Follow-up
|
There was a problem hiding this comment.
Review (static)
Base main (adf4b96767), head df5c0018. Locally: task build — OK, task test:unit paths="./pkg/sbom/... ./pkg/config/..." — 16 suites passed. Ran 7 mutations over upsert.go/config.go: 6 are caught by tests, one is not (see inline on upsert.go:81). e2e not run (static review; CI e2e had not started at review time).
Major
1. sbomArtifactFormatVersion not bumped — pkg/build/sbom_step.go:191 stays "5". The comment on calculateStableChecksum (:200-202) says generator logic changes are covered by exactly this constant, and history confirms it (72de28c78b 2→3, #329 →5). This PR changes the result of gost.Upsert for the default yes/yes config, but the cache key does not: for already-built images werf reuses the old artifact with uniform yes. The description's claim "attackSurface: yes no longer lands on every package" is false for the cache, and a product SBOM from images built before and after the update is internally inconsistent (some images all yes, some split). Suggest "5" → "6".
2. Default SBOM content changes without a flag. DefaultConfig() is always yes/yes (config.go:26-31), so the split marking turns on for everyone with SBOM enabled. The description marks only the two securityFunction: indirect items as BREAKING, although this change is more visible to the user. Mitigation is the experimental warning at sbom_step.go:296. At minimum add a third BREAKING item to the description (it becomes the squash commit body) and to the release note in Follow-up.
Minor
3. sbom_step.go:311,320 — the intermediate gost.Upsert calls on base/import BOMs now compute roots over an isolated tree, and the final Upsert (:170) recomputes everything over the merged graph and overwrites (accessor.go:47-52, update case). Not a bug, but the result of those passes is always discarded; only Validate could stay there.
Risks
| # | Risk | Likelihood | Severity | Location |
|---|---|---|---|---|
| 1 | Product SBOM from pre-/post-update images is inconsistent (cache) | Likely | Medium | sbom_step.go:191 |
| 2 | "Tree root = directly attackable" may not match ISPRAS expectations — not verifiable from code, needs confirmation (raised in Review focus) | Possible | High | upsert.go:22-25 |
| 3 | Child image build fails on security_function: indirect in a base SBOM built by an older werf; the error text does not suggest rebuilding the base |
Possible | Medium | sbom_step.go:308-309 |
| 4 | Edges from services[] demote packages to indirect in an imported SBOM |
Unlikely | Low | upsert.go:74-84 |
Not verified
test/e2e/sbom(lifecycle_test.go:114-152: splitcurl yes/openssl indirect+sbom validatein both formats) — not run by me or by the author (per Verification); CI e2e pending at review time.- Whether the "roots →
yes" rule matches ISPRAS/GOST requirements.
| } | ||
|
|
||
| for _, ref := range lo.FromPtr(dep.Dependencies) { | ||
| if ref != dep.Ref { |
There was a problem hiding this comment.
Minor. After the move to SCC (26aaf30a33) this guard is dead: a curl→curl loop stays inside its own SCC and is cut off by the componentOf[from] != componentOf[to] check on line 92.
Checked by mutation if ref != dep.Ref → if true: all 30 specs of the package stay green. The Verification line "drop the self-reference guard → 1" refers to the pre-SCC state. Suggest removing the guard (or keeping it and fixing Verification). The spec "a self-referencing entry does not demote its own subject" is valid as a behavioral spec and should stay.
There was a problem hiding this comment.
Removed in ecb866d together with the source filter — a loop stays inside its own SCC and never touches dependedOn. The behavioral self-reference spec stays. The Verification line about that mutation is dropped and replaced by the three new ones.
|
|
||
| edges := make(map[string][]string) | ||
| for _, dep := range lo.FromPtr(bom.Dependencies) { | ||
| if rootRef != "" && dep.Ref == rootRef { |
There was a problem hiding this comment.
Minor / known limitation. Only the edge from metadata.component is skipped. A dependencies[].ref entry pointing at a services[].bom-ref (legal in CycloneDX; collectKnownRefs in canonicalize.go:522-539 keeps such refs) is treated as a package dependency and demotes it to indirect. Syft and pm do not emit services, but an imported user SBOM may. Either filter edge sources against the set of component refs, or record this as a known limitation in a comment.
There was a problem hiding this comment.
Fixed in ecb866d: edge sources are filtered against the set of component bom-refs (nested ones and those under metadata.component included). The same rule absorbs the image-root special case — the root is simply not a component source. New spec: an edge sourced at a service does not demote anything; the mutation "accept any source" is caught by it and by the image-root spec, "do not recurse into nested components" by the new honors an edge sourced at a nested component.
| } | ||
|
|
||
| dependencyConfig := config | ||
| if config.AttackSurface == GostValueYes { |
There was a problem hiding this comment.
Major (see summary, items 1–2). DefaultConfig() is always yes/yes, so this branch changes SBOM content for every user with SBOM enabled. Meanwhile sbomArtifactFormatVersion in pkg/build/sbom_step.go:191 is not bumped: for already-built images the cached artifact with uniform yes is reused, and a product assembled from old and new images ends up inconsistent.
There was a problem hiding this comment.
Fixed in 0cc52d6: sbomArtifactFormatVersion 5 → 6. A third BREAKING claim is added to the description (the default yes now splits, and every cached SBOM is regenerated), and the release note in Follow-up covers it. Minor 3 from the summary is in the same commit: the intermediate gost.Upsert on base/import BOMs is gone, Validate stays. The final pass overwrites every value through the accessor's update branch, so nothing the intermediate pass computed ever reached the output.
Fral738
left a comment
There was a problem hiding this comment.
Two output-correctness issues remain:
-
Attack-surface classification can be assigned to the wrong package after a build-time merge. Valid base/import documents may reuse a document-local
bom-reffor different packages. The existing merge concatenates their graphs before assigning unique refs, so the new classification can mark an unrelated rootindirectand the actual dependencyyes. The inline comment contains a reproducer and the required regression. The relevant merge boundary is MergeBOMs, and the shared old-ref mapping is populated in assignNewRef. Namespace each cloned input document's refs and references before combining them. Although the graph collision predates this change, using that graph to attest attack surface makes the wrong classification a new consequence. -
sbom mergestill producesGOST:security_function=indirectfrom legacy image SBOMs. PullAndParseImages decodes and namespaces downloaded BOMs without applying the new restriction. Both assemblers accept a BOM containing one package withattack_surface=yes, security_function=indirectand return a product retainingindirect; the container format also assigns it to the container. Equivalentsecurity_function=nocontrols producenoin both formats. The new build-time validation and cache-version bump do not protect a merge of already-published image digests. Reject forbidden security-function values at the merge input boundary, including nested components, and cover both formats with legacy-input regressions and valid yes/no controls. The updated help and description claim a two-value security-function domain, but this output path still uses the old three-value behavior.
| dependencyConfig.AttackSurface = GostValueIndirect | ||
| } | ||
|
|
||
| targets := dependencyTargets(bom) |
There was a problem hiding this comment.
The graph passed to this new classification can point at the wrong package after merging valid documents with colliding document-local refs. This silently demotes an independent package and promotes the actual dependency.
A base BOM containing app -> library (library.bom-ref = shared, PURL pkg:generic/library@1) plus an imported, independent unrelated package (bom-ref = shared, PURL pkg:generic/unrelated@1) reproduces it through MergeBOMs followed by Upsert: library=yes, unrelated=indirect. Both inputs pass GOST validation. Giving the import a distinct ref instead produces the expected library=indirect, unrelated=yes.
Namespace the refs and all references in each cloned input before build-time merge, not after concatenation. Add a merge-plus-Upsert regression that reuses the same ref across different input documents and requires:
Expect(actual).To(Equal(map[string]gost.GostValue{
"app": gost.GostValueYes,
"library": gost.GostValueIndirect,
"unrelated": gost.GostValueYes,
}))There was a problem hiding this comment.
Fixed in bc4e687. Every base and import BOM is namespaced right after cloning (NamespaceBOMRefs, moved from ispras into cyclonedxutil next to the merge), so the graphs never meet under a shared document-local ref; ensureUniqueBOMRefs then derives the final refs as before and the prefix disappears from the output. The target BOM is left as is: it is the document the merge is producing, not an input. The regression is your reproducer verbatim — MergeBOMs followed by Upsert on app → shared(library) plus an imported shared(unrelated) — asserting app=yes, library=indirect, unrelated=yes; reverting the namespacing fails exactly that spec. Four existing dependency-merge specs relied on edges pointing at refs no component declared and had to get real components, since a dangling ref is now dropped as it would be in a real document.
There was a problem hiding this comment.
Follow-up in d1f856e: an independent pass over the namespacing found the merge-input-N/ prefix surviving on refs ensureUniqueBOMRefs never derived — vulnerabilities, compositions, annotations, formulas, tools. It now derives a ref for every declared entity, so the prefix is purely internal. The closed-document contract you named is stated on MergeOpts. Docs and --help for sbom merge (8384da0) now say an out-of-domain GOST value gets the image rejected and it has to be rebuilt.
| // accepted domain. Images built before the domain shrank still hold | ||
| // `security_function: indirect` in the registry, and a product must not | ||
| // inherit it. | ||
| func validateImages(images []*ImageSBOM) error { |
There was a problem hiding this comment.
Summary item 2, fixed in bbcac4c. Both assemblers run gost.ValidateValues over every image before merging: it checks the values present on the metadata component and on every component, nested ones included, and rejects security_function: indirect (and any attack-surface value outside yes/no/indirect) with image "<name>": component "<name>": invalid value for GOST:security_function: "indirect" (expected yes or no). A component without the properties still passes — an image without GOST data keeps being filled from the aggregate, which is what the container format relies on. ValidateComponent (the build-time boundary) is refactored to share the value check, so the two boundaries cannot drift apart again. Regression: a DescribeTable over both assemblers with legacy indirect on a top-level and on a nested component rejected, yes/no controls accepted; three mutations (skip the call, skip nesting, drop the check) each fail both entries. The description now carries this as a fourth BREAKING claim.
attackSurface: yes now follows the dependency tree recorded in the SBOM: it stays on the components nothing else depends on, and every component pulled in by another is written as indirect. Ecosystems whose catalogers report no tree keep the previous uniform behaviour, since every component is then a root. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
…ndency tree Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
…them all A cataloger that records every package under the image root described what the image contains, not what one package pulls in, so honoring those edges left the tree without a single root and demoted everything to indirect. Skip edges sourced at the image itself, and pin the security function against the split. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
15c6f04 to
fb6b774
Compare
The lifecycle specs only parsed the merge output and asserted GOST properties with our own helpers, so a product whose packages split between yes and indirect never reached the ISPRAS checker, and the oss format was never validated on a real build at all. Merge into a file, hand it to `sbom validate` in both formats, and assert the product identity, top-level shape, bom-ref uniqueness and oss deduplication on the way. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
The security function is a yes/no property; indirect has meaning only for the attack surface, where it marks components reached through the dependency tree. Split the value check per property so that werf.yaml and imported SBOMs carrying securityFunction: indirect are refused instead of propagated to the product SBOM. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
…ulls them in OS package graphs contain cycles: libc and libgcc depend on each other, musl and musl-utils likewise. Counting in-edges per package marked both sides of such a cycle as pulled in by another package and demoted them to indirect, so an image whose packages all sit in one cycle ended up with every package indirect while its container kept the configured yes — a mismatch the ISPRAS checker rejects. Detect roots per strongly connected component instead: a cycle no outside package depends on is a root and keeps yes as a whole, a cycle something else depends on is demoted as a whole. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
…ion value The merge command and usage docs still described security_function as aggregated with the yes > indirect > no rule, and the ispras aggregation test built fixtures with security_function: indirect — a value the parser and validator now refuse. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
…ck surface The SBOM artifact is cached by a checksum that covers the generator version, and the attack surface split along the dependency tree changed what the generator emits without bumping it. An image built before the change kept its artifact with `yes` on every package, so a product assembled from old and new images carried two different rules side by side. Bumping the format version invalidates every cached artifact once. The GOST pass on base and imported SBOMs before the merge is dropped: the pass on the merged result overwrites every value it produced, and the roots it computed on an isolated tree never reached the output. The validation of those SBOMs stays. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
…ndencies A `dependencies` entry sourced at a service is legal in CycloneDX and survives canonicalization, but a package is not pulled in by the service that calls it. Only edges sourced at a component now count, which also covers the image's own metadata component that was special-cased before. The self-reference guard is gone with it: a loop stays inside its own strongly connected component and never marks anything as depended on. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
…s reuse a bom-ref A bom-ref is local to its document, so a base or imported SBOM may name a package with the same ref another input uses for a different one. The merge concatenated the graphs before making the refs unique, and every edge pointing at that ref landed on whichever component won: an independent package came out `indirect` while the real dependency stayed `yes`. Every base and import BOM is now namespaced right after cloning, before its graph meets the others, the same way `sbom merge` already keeps image documents apart; the unique refs derived afterwards erase the prefix again. `NamespaceBOMRefs` moves next to the merge it now serves. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
…erge` combines Images built before the security function domain shrank to `yes`/`no` still carry `indirect` in the registry, and the product assembled from them kept it, in the container format on the container itself too, while the command help promised a two-value domain. Both assemblers now check every GOST value present on the image SBOMs, nested components included, before merging. Missing values still pass: an image without GOST properties gets them from the aggregate as before. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
…tion and annotation refs Namespacing the merged BOMs left `merge-input-N/` on every ref the merge did not derive afresh: the refs of vulnerabilities, compositions, annotations, formulas and tools kept the prefix while components and services got their unique refs. The merge now derives a ref for every entity a BOM declares, the same way it already did for components and services, and every reference to them follows. The contract that each merged BOM is a closed document is stated on `MergeOpts`. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
The command help and the usage pages promised the aggregation rules but not what happens to an image SBOM that carries a value outside them, so the rejection of a legacy `security_function: indirect` read like a bug with no way out. Both now say the image is rejected and has to be rebuilt, in the CLI reference and in both languages of the usage docs. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
`ensureUniqueBOMRefs` grew a loop per entity kind and Tarjan's visit carried the component pop inline; both crossed the cognitive complexity limit the repository's static analysis enforces. The derivation moves onto a small `refDeriver` that owns the serial, the position counter and the rename map, and the traversal onto a `tarjan` struct with the pop as its own method. Behavior and derived refs are unchanged. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
…s own document Namespacing prefixed a reference to a ref the document did not declare only when it sat in `dependencies`; the same reference in `provides`, in the packages a vulnerability affects, in a composition or in an annotation kept its bare name and could land on an entity of another input once the documents were merged. Every place a ref can be referenced is now covered. A reference to the document's own serial is left alone: it addresses the document, not an entity, and becomes a BOM-Link during the merge. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
…T validation A base image built by an older werf carries `security_function: indirect` and now fails the child build, but the error named the value without saying what to do about it, unlike the `sbom merge` docs. Both wraps now say to rebuild the image with the current werf. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
fb6b774 to
1fc3a83
Compare
Re-review (static) — head
|
|
Fixed in b08ceaa. |
Canonicalize filtered dangling refs out of dependencies, compositions and annotations but only deduplicated vulnerabilities[].affects, so a reference to a package the input did not declare kept the internal merge-input prefix all the way into the product SBOM. Filter affects against the declared entities as the other sections are, BOM-Links included. This also drops a dangling affects reference from a single document that never went through a merge, on the build path as well as in both assemblers: an affects ref may only name a component or a service of the document, so there is nothing to keep. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
6ec9ead to
b08ceaa
Compare
Re-review — head
|
|
Description updated — Attack surface now carries the line: an |
🤖 I have created a release *beep* *boop* --- ## [3.6.1-dk.2](v3.6.1-dk.1...v3.6.1-dk.2) (2026-10-01) ### Features * **sbom:** add --warnings-non-fatal flag to sbom validate ([#331](#331)) ([91e1a30](91e1a30)) * **sbom:** carry STREEBOG source-distribution digests through the CycloneDX 1.6 SBOM ([#383](#383)) ([1065a95](1065a95)) * **sbom:** generate file-based package SBOMs without docker.sock ([#307](#307)) ([6681160](6681160)) * **sbom:** mark only entry-point packages as directly attackable ([#332](#332)) ([a0b171e](a0b171e)) * **sbom:** report the source language of cataloged packages ([#328](#328)) ([df4ba44](df4ba44)) ### Bug Fixes * **ci:** honor cancellation of build and test workflows ([#381](#381)) ([63f7581](63f7581)) * **sbom, vex, build:** keep artifacts with the image in every repository ([#282](#282)) ([5544c87](5544c87)) * **sbom:** restore the build after the file-based SBOM scan merge ([#380](#380)) ([4d67f69](4d67f69)) * **sbom:** stop ispras validation failing on containers without a description ([#382](#382)) ([c3c9abd](c3c9abd)) * **sbom:** stop printing every validation finding twice in sbom validate ([#350](#350)) ([6f0af68](6f0af68)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
Summary
sbom.gost.attackSurface: yesno longer lands on every package of an image. It now follows the dependency tree recorded in the SBOMdependenciessection: components nothing else depends on keepyes, everything pulled in by another component is written asindirect.sbom.gost.securityFunctionstops acceptingindirect— the value has meaning only for the attack surface.Implements card 69801379:
gost_attack_surfaceis set toyesfrom the dependency tree, andgost_security_functionaccepts onlyyes/no.What
Breaking
securityFunction: indirectinwerf.yamlnow fails the build withinvalid 'securityFunction' value "indirect": expected 'yes' or 'no'. Anyone who set it has to chooseyesorno; there is no compatibility path, the value never had a defined meaning for a security function.GOST:security_function: indirectfails the build with the same message instead of propagating the value into the product SBOM. A base image built by an older werf with that value has to be rebuilt.werf sbom mergerejects an image SBOM carryingGOST:security_function: indirect, on any component including nested ones, withimage "<name>": component "<name>": invalid value for GOST:security_function: "indirect" (expected 'yes' or 'no'). Images built by an older werf with that value have to be rebuilt before they can be merged into a product; an image without GOST properties at all is still accepted and filled from the aggregate as before.sbom.gost.attackSurfacedefaults toyes, so images that never set it move fromyeson every package toyeson roots andindirecton the rest. The SBOM artifact format version is bumped, so the next build regenerates every cached SBOM instead of reusing the old uniform one.Attack surface
attackSurface; a component some other component depends on getsindirectwhen the configured value isyes.yesfor all its members, a cycle something else depends on is demoted whole. OS package graphs contain such cycles — dpkglibc6⇄libgcc-s1, apkmusl⇄musl-utils.metadata.component(a cataloger listing every package under the image root) or from aservices[]entry (an imported SBOM describing what a service calls) leaves every package it points at a root.providesrelation is not a dependency edge: a component that only provides an alias of another stays a root.dependsOnentry does not demote its own subject.dependenciessection makes every component a root, so ecosystems whose catalogers report no tree (go-mod,python-pip— syft emits zero edges) keep exactly the previous output.bom-refis document-local, and two inputs reusing one ref for different packages no longer redirect edges onto whichever component won the merge.affectsentry pointing at a component or service the BOM does not declare is dropped, in a merged product and in a single built image alike; a BOM-Link to another document is kept. DanglingdependsOn, composition and annotation references were already dropped this way;affectswas carried out as is.metadata.componentkeeps the configured value; the container component of an ISPRAScontainerproduct SBOM therefore still reports the maximum over the packages it holds.noandindirect, andsecurityFunctionin all cases, apply unchanged to the whole tree.Surfaces
schemas/werf.jsonaccepts onlyyes/noforsecurityFunction;attackSurfacekeepsyes/no/indirect.werf sbom merge --helpand the usage docs state thatattack_surfaceaggregates withyes > indirect > noandsecurity_functionwithyes > no, and that an image SBOM with a value outside these domains is rejected and has to be rebuilt.Why
Stamping
yeson every package attests to a regulator that each of them exposes an interface an attacker reaches directly. The dependency tree is already in the SBOM — pm reportsdepends(pkg/sbom/packages/os_pm/os_pm.go:92) and syft emitsdependenciesfor the ecosystems that have a resolver — so the distinction between what the image pulls in on purpose and what came along with it is available at build time.Roots are detected per strongly connected component rather than by counting in-edges per package. Counting in-edges marks both members of a mutual dependency as pulled in by another package; an image whose packages all sit in one cycle then ends up with every package
indirectwhile its container keeps the configuredyes, and the ISPRAS checker rejects that mismatch.indirectdescribes a component reached through the dependency tree. A security function either is or is not implemented by a component, so the graph has nothing to say about it and the value was silently carried into the product SBOM.