Skip to content

feat(sbom): mark only entry-point packages as directly attackable - #332

Merged
reyreavman merged 17 commits into
mainfrom
feat/sbom/gost-attack-surface-roots
Oct 1, 2026
Merged

reyreavman merged 17 commits into
mainfrom
feat/sbom/gost-attack-surface-roots

Conversation

@reyreavman

@reyreavman reyreavman commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

sbom.gost.attackSurface: yes no longer lands on every package of an image. It now follows the dependency tree recorded in the SBOM dependencies section: components nothing else depends on keep yes, everything pulled in by another component is written as indirect. sbom.gost.securityFunction stops accepting indirect — the value has meaning only for the attack surface.

Implements card 69801379: gost_attack_surface is set to yes from the dependency tree, and gost_security_function accepts only yes/no.

What

Breaking

  • BREAKING: securityFunction: indirect in werf.yaml now fails the build with invalid 'securityFunction' value "indirect": expected 'yes' or 'no'. Anyone who set it has to choose yes or no; there is no compatibility path, the value never had a defined meaning for a security function.
  • BREAKING: a base or imported SBOM carrying GOST:security_function: indirect fails 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.
  • BREAKING: werf sbom merge rejects an image SBOM carrying GOST:security_function: indirect, on any component including nested ones, with image "<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.
  • BREAKING: every SBOM built with the default config changes content. sbom.gost.attackSurface defaults to yes, so images that never set it move from yes on every package to yes on roots and indirect on 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

  • A component no other component depends on gets the configured attackSurface; a component some other component depends on gets indirect when the configured value is yes.
  • Roots are computed per strongly connected component: a dependency cycle nothing outside it depends on keeps yes for all its members, a cycle something else depends on is demoted whole. OS package graphs contain such cycles — dpkg libc6⇄libgcc-s1, apk musl⇄musl-utils.
  • Only an edge sourced at a component is a dependency edge. An edge from the image's own metadata.component (a cataloger listing every package under the image root) or from a services[] entry (an imported SBOM describing what a service calls) leaves every package it points at a root.
  • A provides relation is not a dependency edge: a component that only provides an alias of another stays a root.
  • A self-referencing dependsOn entry does not demote its own subject.
  • An empty dependencies section 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.
  • Base and import BOMs are namespaced before their graphs meet: a bom-ref is document-local, and two inputs reusing one ref for different packages no longer redirect edges onto whichever component won the merge.
  • The merge derives a ref for every entity a BOM declares — vulnerabilities, compositions, annotations, formulas and tools included, not only components and services — so no merge-internal prefix reaches the output. Those refs were previously copied from the input verbatim.
  • A vulnerability's affects entry 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. Dangling dependsOn, composition and annotation references were already dropped this way; affects was carried out as is.
  • metadata.component keeps the configured value; the container component of an ISPRAS container product SBOM therefore still reports the maximum over the packages it holds.
  • no and indirect, and securityFunction in all cases, apply unchanged to the whole tree.

Surfaces

  • schemas/werf.json accepts only yes/no for securityFunction; attackSurface keeps yes/no/indirect.
  • werf sbom merge --help and the usage docs state that attack_surface aggregates with yes > indirect > no and security_function with yes > no, and that an image SBOM with a value outside these domains is rejected and has to be rebuilt.
  • Docs in both languages state the root rule and the empty-tree case.

Why

Stamping yes on 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 reports depends (pkg/sbom/packages/os_pm/os_pm.go:92) and syft emits dependencies for 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 indirect while its container keeps the configured yes, and the ISPRAS checker rejects that mismatch.

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

Base automatically changed from fix/sbom/canonical-merge to main September 25, 2026 08:25
@reyreavman
reyreavman force-pushed the feat/sbom/gost-attack-surface-roots branch from b6fce96 to df5c001 Compare September 27, 2026 20:09
@reyreavman

reyreavman commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Verification

  • Two-image stapel build (base-layer: jq; app: curl, built fromImage base-layer) pushed to a local registry, werf sbom merge in both formats, output through 3p-ispras-sbom-checker:master: --ispras-format container → файл корректный, --ispras-format oss → файл корректный. curl (declared in packages, nothing depends on it) came out yes, its transitive content (brotli, libc, libidn2, libpsl, libunistring, openssl, zstd) indirect, the container the maximum over both.
  • Mutation: never demote (dependencyConfig := config) → 3 specs; demote everything (ignore the target set) → 7; demote metadata.component too → 1; accept edges from any source, not only components → an edge sourced at a service does not demote anything and an edge sourced at the image itself does not demote anything; stop collecting refs nested under metadata.component → demotes a component nested under the metadata component; stop recursing into nested components when collecting refs → honors an edge sourced at a nested component; count intra-SCC edges as incoming → a cycle nothing else depends on is a root; disable the Tarjan back-edge lowlink update → the same spec; disable the tree-edge lowlink update → a cycle longer than two components stays one root; mark every SCC as depended on → 4 specs; widen IsValidSecurityFunctionValue back to three values → raw_gost_test.go, validator_test.go and the schema/parser parity entry in werf_schema_test.go.
  • Independent reviewer, third round (after the second human review): base BOM with Vulnerabilities: [{BOMRef: "vuln-1"}] came out as merge-input-0/vuln-1 — the namespacing prefix leaked into every ref ensureUniqueBOMRefs did not derive. Fixed in d1f856e; regression derives the refs of vulnerabilities, compositions, annotations and formulas and keeps references to them, mutation "drop the new walks" caught by it. The same reviewer checked the SCC classification, the ValidateValues placement relative to the container assembler's mutations, and the build-path Upsert ordering — no findings.
  • Independent reviewer, fourth round (Claude, against 1cac0416e7, following review + challenge-review): traced the refDeriver and tarjan refactors against the pre-refactor code — byte-identical derived refs, no behavioral drift; confirmed ValidateValues runs before any assembler mutation and has no bypass; confirmed no downstream reliance on vulnerability/composition/annotation refs matching the input. Two Minors, both fixed: NamespaceBOMRefs prefixed an undeclared reference only inside dependencies, not in provides, vulnerabilities[].affects, compositions or annotations (76e6c2325e; regression covers all six places plus the document's own serial staying bare); the build-time SBOM validation failed error gained the rebuild hint the sbom merge docs already carry.
  • Independent reviewer, fifth round (GPT, delta-only against 1fc3a83f3a after the rebase onto main): confirmed the sbom_step.go conflict resolution keeps feat(sbom): generate file-based package SBOMs without docker.sock #307's scanFileBasedPackages path intact and every BOM reaching the output still passes the single gost.Upsert; confirmed componentIdentity is strictly finer than the merge's componentKey, so the dedup assertion still catches a same-purl regression; confirmed the oss conditional drops only the checker run and nothing else. No findings; one Nit — the oss skip in the multi-image spec will not re-enable itself when the single-image XEntry does.
  • Static re-review at 15c6f047a7 (one Minor): a dangling vulnerabilities[].affects[].ref of a base or imported BOM leaked the merge-input-N/ prefix into the product, since Canonicalize filtered dangling refs out of every section but that one. Fixed in b08ceaab4e — affects is filtered against the declared entities as the other sections are, unconditionally since an affects ref may only name a component or a service; regression drops a vulnerability's reference to a package no input declares instead of leaking the input prefix, red on revert with the leaked ref.
  • Independent reviewer, sixth round (Claude, the affects fix only, against 6ec9ead75c): confirmed the filter sits where canonicalizeDependencies does and that neither the serial nor formula/composition refs are legitimate affects targets per the 1.6 schema (refLinkType | bomLinkElementType, component or service); confirmed the regression fails under a strip-the-prefix implementation and under a filter without the BOM-Link exemption. Two Minors, both taken in b08ceaab4e: the len(knownRefs) == 0 guard copied from the dependency graph made the outcome depend on whether the vulnerabilities carried bom-refs — removed, the filter is unconditional; the commit now states that a dangling affects ref of a never-merged document is dropped as well.
  • Mutation, second round: skip namespacing of base/import BOMs in MergeBOMs → keeps the graphs of inputs apart when they reuse one ref for different packages (merge + Upsert, the reviewer's reproducer: app=yes, library=indirect, unrelated=yes); validateImages never fails, ValidateValues stops recursing into nested components, ValidateComponentValues drops the security function check → each caught by both entries (container, oss) of rejects a legacy security function value and accepts the two-value domain.
  • Probes outside the suite, on the committed code: a 20000-package chain (no recursion limit reached, first package yes, last indirect); a dependsOn ref pointing at no component; an edge pointing into the image's own root ref — neither produces a spurious demotion.
  • test/e2e/sbom (labelFilter=sbom parallel=1) on a Linux host with Docker, kind and a local registry, re-run on the current head b08ceaab4e (after the rebase onto main — test(sbom): build e2e fixtures straight from the base-images builders #323 fixtures, fix(sbom): stop ispras validation failing on containers without a description #382, feat(sbom): carry STREEBOG source-distribution digests through the CycloneDX 1.6 SBOM #383 — and the affects fix): 45 passed, 0 failed, 1 pending, 2 skipped, 15m30s. task lint 0 issues and task test:unit 78 of 79 on the same host at 1fc3a83f3a (the one failure is the pre-existing pkg/buildah GetRegistryMirrorsFromConfig reading the host's registries config). The rebase surfaced two things in the multi-image lifecycle spec, both fixed in test(sbom): run merged product SBOMs through the checker in both formats: AssertNoDuplicateComponents keyed by name@version and flagged pm's openssl@3.6.2?containerfactoryversion=v3.0.2 against syft's bare openssl@3.6.2 from the new builder as a duplicate — it now keys by the same purl-derived identity the merge deduplicates by; and the ISPRAS oss schema rejects the builder's operating-system component without a vcs reference, the same reason main's single-image oss entry is XEntry — the multi-image spec keeps every oss assertion except that checker run.

Review focus

  • Only dependsOn is treated as an edge; provides is not. A component that merely provides an alias of another is still a root. Confirm that matches how the ISPRAS exporters read the tree.
  • Ecosystems with no dependency tree degenerate to a uniform yes (measured: 457 components / 0 edges for a real go.mod). Documented, not special-cased.
  • A package present in two images can carry different values in the product SBOM, because the value belongs to the image: jq is yes in a base image and indirect in an image that pulls it in through something else.

Follow-up

  • Release note for the four breaking claims: securityFunction: indirect is rejected in werf.yaml, in imported SBOMs and in the images sbom merge combines; the default attackSurface: yes now splits into yes/indirect along the dependency tree, and every cached SBOM is regenerated on the next build.

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

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: split curl yes/openssl indirect + sbom validate in 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.

Comment thread pkg/sbom/cyclonedxutil/gost/upsert.go Outdated
}

for _, ref := range lo.FromPtr(dep.Dependencies) {
if ref != dep.Ref {

@reyreavman reyreavman Sep 27, 2026 •

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

@reyreavman reyreavman Sep 27, 2026 •

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.

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.

Comment thread pkg/sbom/cyclonedxutil/gost/upsert.go Outdated

edges := make(map[string][]string)
for _, dep := range lo.FromPtr(bom.Dependencies) {
if rootRef != "" && dep.Ref == rootRef {

@reyreavman reyreavman Sep 27, 2026 •

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

@reyreavman reyreavman Sep 27, 2026 •

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

@reyreavman reyreavman Sep 27, 2026 •

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

@reyreavman reyreavman Sep 27, 2026 •

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

@reyreavman
reyreavman marked this pull request as ready for review September 27, 2026 21:21

@Fral738 Fral738 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two output-correctness issues remain:

  1. 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-ref for different packages. The existing merge concatenates their graphs before assigning unique refs, so the new classification can mark an unrelated root indirect and the actual dependency yes. 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.

  2. sbom merge still produces GOST:security_function=indirect from legacy image SBOMs. PullAndParseImages decodes and namespaces downloaded BOMs without applying the new restriction. Both assemblers accept a BOM containing one package with attack_surface=yes, security_function=indirect and return a product retaining indirect; the container format also assigns it to the container. Equivalent security_function=no controls produce no in 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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,
}))

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

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.

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 {

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.

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>
@reyreavman
reyreavman force-pushed the feat/sbom/gost-attack-surface-roots branch from 15c6f04 to fb6b774 Compare September 30, 2026 15:04
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>
@reyreavman
reyreavman force-pushed the feat/sbom/gost-attack-surface-roots branch from fb6b774 to 1fc3a83 Compare September 30, 2026 16:35
@reyreavman

Copy link
Copy Markdown
Collaborator Author

Re-review (static) — head 15c6f047, 7 new commits since ecb866d3

Base main (1065a95fcc). Verified locally in a clean worktree at the PR head: task build — exit 0; task test:unit over ./pkg/sbom/... ./pkg/config/... ./pkg/build/... — 22 suites, all green. Three mutations on the new logic all discriminate (see below). Nothing published to files; sbom merge e2e not run (static review).

Previously-raised issues — all resolved

  • sbomArtifactFormatVersion bumped to 6 (pkg/build/sbom_step.go:191); default-config content change documented as BREAKING in the description.
  • Dead self-reference guard gone; the services[] edge case is now handled by filtering edge sources to component refs (gost/upsert.go dependencyTargets/collectComponentRefs), covered by a dedicated test.
  • Redundant intermediate gost.Upsert on base/import BOMs removed; only Validate remains.

New work in this batch — assessment

The ref-collision fix (bc4e687f11) and the closed-document contract on MergeOpts are sound: base/import BOMs are namespaced right after cloning, so two inputs reusing one bom-ref for different packages no longer redirect dependency edges onto the merge winner. Mutation (disable the per-input NamespaceBOMRefs) → MergeBOMs ref collisions fails, as expected. validateImages in both assemblers (bbcac4cde7) rejects a legacy security_function: indirect, nested components included; mutation (short-circuit validateImages to nil) → both Assemble GOST input validation entries fail. The refDeriver/tarjan refactor (1cac0416e7) is behavior-preserving; mutation (drop the vuln/composition/annotation/formula derivation) → the new ensureUniqueBOMRefs entry fails.

Finding — Minor (incomplete fix in the exact area d1f856e4 targeted)

d1f856e4 set out to "keep merge-internal prefixes out of vulnerability, composition and annotation refs", but vulnerabilities[].affects[].ref was missed. A base or imported SBOM carrying a vulnerability whose affects references a component the document does not declare (a dangling ref) leaks the internal prefix into the merged output:

  • Build path (MergeBOMs with a base/import BOM): the dangling ref comes out merge-input-0/ghost.
  • sbom merge oss product: merge-input-1/<image>/ghost.
  • sbom merge container product: keeps the <image>/ghost image prefix.

Root cause: namespaceUndeclaredReferences (pkg/sbom/cyclonedxutil/namespace.go:77-79) prefixes the dangling affects ref so it cannot land on another document's entity — correct — but the symmetric cleanup never runs for it. ensureUniqueBOMRefs only re-derives declared entities (pkg/sbom/cyclonedxutil/bomref.go:57-79), and canonicalizeDependencies filters dangling dependencies refs against knownRefs while vulnerabilities[].affects[].ref is only dedup'd, never filtered (pkg/sbom/cyclonedxutil/canonicalize.go). So the merge-input-N/ prefix — newly introduced by the per-input namespacing this PR adds in MergeBOMs — reaches the product SBOM.

Reproduced with a throwaway probe on the committed head (a base BOM with vulnerabilities[0].affects = [{lib}, {ghost}], ghost undeclared): build path → merge-input-0/ghost; both assemblers leak as above. Likelihood is low in practice — syft without a scanner emits no vulnerabilities, so this needs a user-supplied base/imported SBOM that already carries vulnerabilities with a dangling affects ref — hence Minor, not blocking. Suggestion: extend the undeclared-reference cleanup to strip the prefix from affects refs on the way out (or filter dangling affects refs the way dangling dependencies are filtered), and add a regression entry alongside the 76e6c2325e test.

Risks

Unchanged from the prior pass: the only material residual risk is whether the "root of the dependency tree = directly attackable" semantics match how ISPRAS reads the tree — not verifiable from the code (you raise it yourself in Review focus). The affects-ref leak above is a correctness nit, not a GOST-attack-surface risk.

Not verified

  • test/e2e/sbom — per your Verification note, run on a Linux host (43 passed); I did not reproduce.
  • Conformance of the root rule to the ISPRAS/GOST requirement.

No blockers from me.

@reyreavman

reyreavman commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

Fixed in b08ceaa. vulnerabilities[].affects is now filtered against the declared entities in Canonicalize, exactly as dependencies, compositions and annotations already were — a dangling ref is dropped rather than carried out with the merge-input-N/ prefix, a BOM-Link is kept. Regression next to the ref-collision spec: a base BOM whose vulnerability affects lib, undeclared ghost and a BOM-Link comes out affecting the derived lib ref and the BOM-Link only; reverting the filter fails it with the leaked merge-input-0/ghost. Both assemblers go through the same Canonicalize, so the sbom merge paths are covered by the same change. Note the wider effect, stated in the commit: a dangling affects ref in a single document that never went through a merge is dropped too — an affects ref may only name a component or a service of the document, so unlike the dependency graph it is filtered unconditionally.

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>
@reyreavman
reyreavman force-pushed the feat/sbom/gost-attack-surface-roots branch from 6ec9ead to b08ceaa Compare October 1, 2026 05:06
@reyreavman

Copy link
Copy Markdown
Collaborator Author

Re-review — head b08ceaab, rebased onto main (1065a95fcc), +1 commit

Verified in a clean worktree at the head: task build — exit 0; task test:unit over ./pkg/sbom/... ./pkg/config/... ./pkg/build/... — 23 suites green. CI lint green on this head; unit was still pending when I looked.

Previous finding — resolved

b08ceaab closes the vulnerabilities[].affects[].ref prefix leak at the right layer: CanonicalizeDocument now filters affects against knownRefs the same way canonicalizeDependencies filters edges, BOM-Links kept (pkg/sbom/cyclonedxutil/canonicalize.go filterKnownAffects). Mutation (make the filter keep everything) → drops a vulnerability's reference to a package no input declares… fails, so the regression test discriminates. The adjusted merges vulnerabilities sharing an id and source test is the expected consequence of the new precondition, not a weakening.

Rebase interaction — checked, no issue

main brought the file-based stapel scan (#307): per-directive syft dir scans are unioned via MergeBOMs and then restoreImageMetadata replaces metadata.component with a werf container component that has no bom-ref. That leaves the first directive's syft root entry (<dir-root> → [packages]) in dependencies with a source nothing declares. I traced it against dependencyTargets: edge sources are filtered to component refs, so that orphan root does not demote anything, and the final Canonicalize drops the dangling entry. Same holds when no base/import merge runs at all (mergeOpts.IsEmpty() and no os-pm). sbomArtifactFormatVersion does not collide with main (main is still 5, head 6).

Minor

  • The PR description does not yet state the user-visible side of b08ceaab: an affects reference that names no component or service of the document is now dropped — on the build path and in both sbom merge assemblers, including a single document that never went through a merge. The commit body says it; the description (which becomes the squash commit body) only mentions ref derivation for vulnerabilities. Worth one line under Attack surface or Surfaces.

Not verified

No blockers.

@reyreavman

Copy link
Copy Markdown
Collaborator Author

Description updated — Attack surface now carries the line: an affects entry pointing at a component or service the BOM does not declare is dropped, in a merged product and in a single built image alike, BOM-Links kept. On the e2e not-verified item: test:e2e paths=./test/e2e/sbom labelFilter=sbom was run on a Linux host at b08ceaab4e itself (after the rebase) — 45 passed, 0 failed, 1 pending, 2 skipped; the Verification comment reflects that head.

@reyreavman
reyreavman merged commit a0b171e into main Oct 1, 2026
10 of 13 checks passed
@reyreavman
reyreavman deleted the feat/sbom/gost-attack-surface-roots branch October 1, 2026 05:57
reyreavman pushed a commit that referenced this pull request Oct 1, 2026
🤖 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).
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.

2 participants