From cc3890305ef34701544633a5f5b0573a122035a9 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Sun, 20 Sep 2026 15:02:11 +0300 Subject: [PATCH 01/17] feat(sbom): mark only entry-point packages as directly attackable 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 --- pkg/sbom/cyclonedxutil/gost/upsert.go | 47 ++++++++++-- pkg/sbom/cyclonedxutil/gost/upsert_test.go | 85 ++++++++++++++++++++++ 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/pkg/sbom/cyclonedxutil/gost/upsert.go b/pkg/sbom/cyclonedxutil/gost/upsert.go index 19a6873b4e..9c6882be34 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert.go @@ -8,29 +8,64 @@ import ( ) // Upsert inserts or updates mandatory GOST properties in the BOM metadata component -// and every component, nested ones included. +// and every component, nested ones included. An attack surface of `yes` describes +// the components an attacker reaches directly, so it lands on the roots of the +// dependency tree; everything pulled in by another component gets `indirect`. +// Every other value, and the security function in all cases, applies unchanged. func Upsert(bom *cdx.BOM, config Config) error { if bom == nil { return fmt.Errorf("BOM is required") } + dependent := config + if config.AttackSurface == GostValueYes { + dependent.AttackSurface = GostValueIndirect + } + + targets := dependencyTargets(bom) + if bom.Metadata != nil && bom.Metadata.Component != nil { SetComponent(bom.Metadata.Component, config) - setComponents(lo.FromPtr(bom.Metadata.Component.Components), config) + setComponents(lo.FromPtr(bom.Metadata.Component.Components), config, dependent, targets) } - setComponents(lo.FromPtr(bom.Components), config) + setComponents(lo.FromPtr(bom.Components), config, dependent, targets) return nil } -func setComponents(components []cdx.Component, config Config) { +func setComponents(components []cdx.Component, root, dependent Config, targets map[string]struct{}) { for i := range components { - SetComponent(&components[i], config) - setComponents(lo.FromPtr(components[i].Components), config) + comp := &components[i] + + cfg := root + if _, ok := targets[comp.BOMRef]; ok { + cfg = dependent + } + + SetComponent(comp, cfg) + setComponents(lo.FromPtr(comp.Components), root, dependent, targets) } } +// dependencyTargets collects every bom-ref another component depends on. A +// component missing from the set is a root of the dependency tree: nothing else +// in the image pulls it in. An empty `dependencies` section therefore makes +// every component a root, which is what the catalogers that report no tree at +// all produce. +func dependencyTargets(bom *cdx.BOM) map[string]struct{} { + targets := make(map[string]struct{}) + for _, dep := range lo.FromPtr(bom.Dependencies) { + for _, ref := range lo.FromPtr(dep.Dependencies) { + if ref != dep.Ref { + targets[ref] = struct{}{} + } + } + } + + return targets +} + // SetComponent inserts or updates mandatory GOST properties in a single component. func SetComponent(comp *cdx.Component, config Config) { a := newAccessor(comp) diff --git a/pkg/sbom/cyclonedxutil/gost/upsert_test.go b/pkg/sbom/cyclonedxutil/gost/upsert_test.go index 89bc082c22..39d431e201 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert_test.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert_test.go @@ -151,4 +151,89 @@ var _ = Describe("Gost SBOM setter", func() { }, Succeed()), ) + + DescribeTable("attack surface follows the dependency tree", + func(bom *cdx.BOM, config Config, expected map[string]GostValue) { + Expect(Upsert(bom, config)).To(Succeed()) + + actual := map[string]GostValue{} + for i := range lo.FromPtr(bom.Components) { + comp := &(*bom.Components)[i] + actual[comp.BOMRef] = GetComponent(comp).AttackSurface + } + + Expect(actual).To(Equal(expected)) + }, + Entry("yes reaches only what nothing else depends on", + &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "curl"}, {BOMRef: "openssl"}, {BOMRef: "libc"}, {BOMRef: "jq"}}, + Dependencies: &[]cdx.Dependency{ + {Ref: "curl", Dependencies: &[]string{"openssl"}}, + {Ref: "openssl", Dependencies: &[]string{"libc"}}, + }, + }, + Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, + map[string]GostValue{ + "curl": GostValueYes, + "jq": GostValueYes, + "openssl": GostValueIndirect, + "libc": GostValueIndirect, + }), + Entry("a self-referencing entry does not demote its own subject", + &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "curl"}}, + Dependencies: &[]cdx.Dependency{{Ref: "curl", Dependencies: &[]string{"curl"}}}, + }, + Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, + map[string]GostValue{"curl": GostValueYes}), + Entry("no dependency tree makes every component a root", + &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "a"}, {BOMRef: "b"}}, + }, + Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, + map[string]GostValue{"a": GostValueYes, "b": GostValueYes}), + Entry("indirect applies to the whole tree, roots included", + &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "curl"}, {BOMRef: "openssl"}}, + Dependencies: &[]cdx.Dependency{{Ref: "curl", Dependencies: &[]string{"openssl"}}}, + }, + Config{AttackSurface: GostValueIndirect, SecurityFunction: GostValueNo}, + map[string]GostValue{"curl": GostValueIndirect, "openssl": GostValueIndirect}), + Entry("no applies to the whole tree, roots included", + &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "curl"}, {BOMRef: "openssl"}}, + Dependencies: &[]cdx.Dependency{{Ref: "curl", Dependencies: &[]string{"openssl"}}}, + }, + Config{AttackSurface: GostValueNo, SecurityFunction: GostValueNo}, + map[string]GostValue{"curl": GostValueNo, "openssl": GostValueNo}), + ) + + It("keeps the image root at the configured value while its packages are demoted", func() { + bom := &cdx.BOM{ + Metadata: &cdx.Metadata{Component: &cdx.Component{BOMRef: "image", Name: "image"}}, + Components: &[]cdx.Component{{BOMRef: "curl"}, {BOMRef: "openssl"}}, + Dependencies: &[]cdx.Dependency{{Ref: "curl", Dependencies: &[]string{"openssl"}}}, + } + + Expect(Upsert(bom, Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes})).To(Succeed()) + + Expect(GetComponent(bom.Metadata.Component).AttackSurface).To(Equal(GostValueYes)) + Expect(GetComponent(&(*bom.Components)[1]).AttackSurface).To(Equal(GostValueIndirect)) + }) + + It("demotes a nested component the tree depends on", func() { + bom := &cdx.BOM{ + Components: &[]cdx.Component{{ + BOMRef: "parent", + Components: &[]cdx.Component{{BOMRef: "nested"}, {BOMRef: "nested-root"}}, + }}, + Dependencies: &[]cdx.Dependency{{Ref: "parent", Dependencies: &[]string{"nested"}}}, + } + + Expect(Upsert(bom, Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes})).To(Succeed()) + + nested := lo.FromPtr((*bom.Components)[0].Components) + Expect(GetComponent(&nested[0]).AttackSurface).To(Equal(GostValueIndirect)) + Expect(GetComponent(&nested[1]).AttackSurface).To(Equal(GostValueYes)) + }) }) From 4aafd8dac72ecb1db4a81243bab5d637fe274a65 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Sun, 20 Sep 2026 15:02:12 +0300 Subject: [PATCH 02/17] test(sbom), docs(sbom): cover the attack surface split along the dependency tree Signed-off-by: Radmir Khurum --- docs/pages_en/usage/build/sbom.md | 4 +++- docs/pages_ru/usage/build/sbom.md | 4 +++- test/e2e/sbom/lifecycle_test.go | 5 ++++- test/pkg/sbom/helpers.go | 24 +++++++++++++++++++++--- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/pages_en/usage/build/sbom.md b/docs/pages_en/usage/build/sbom.md index 7edfc34f77..51f2ff9a9e 100644 --- a/docs/pages_en/usage/build/sbom.md +++ b/docs/pages_en/usage/build/sbom.md @@ -113,11 +113,13 @@ When building a multi-platform image, werf generates a separate SBOM artifact fo ## GOST security properties (`sbom.gost`) -To comply with GOST safety standards, you can configure mandatory security properties for all components in the SBOM. These properties will be injected into all direct components of the final SBOM. By default, both generated and user-defined SBOMs are enriched with `attackSurface=yes` and `securityFunction=yes`, unless specified otherwise at the project (meta) or image level. +To comply with GOST safety standards, you can configure mandatory security properties for all components in the SBOM. These properties will be injected into the whole component tree of the final SBOM. By default, both generated and user-defined SBOMs are enriched with `attackSurface=yes` and `securityFunction=yes`, unless specified otherwise at the project (meta) or image level. 1. `attackSurface`: The attack surface property (`yes` | `no` | `indirect`). 2. `securityFunction`: The security function property (`yes` | `no` | `indirect`). +`attackSurface: yes` follows the dependency tree recorded in the SBOM `dependencies` section: it lands on the components nothing else depends on, and every component pulled in by another one is recorded as `indirect`. When the catalogers of an ecosystem report no dependency tree at all, every component is a root and receives `yes`. `no` and `indirect`, and `securityFunction` in all cases, apply unchanged to the whole tree. + You can define these globally in `build.sbom.gost` or per-image in `image.sbom.gost`. Image-level configuration overrides global configuration. > **NOTE:** GOST properties integration is experimental and strictly tied to the `cyclonedx@1.6` standard. diff --git a/docs/pages_ru/usage/build/sbom.md b/docs/pages_ru/usage/build/sbom.md index 290bf5fd1e..daea57ac5e 100644 --- a/docs/pages_ru/usage/build/sbom.md +++ b/docs/pages_ru/usage/build/sbom.md @@ -113,11 +113,13 @@ werf всегда использует индекс на основе тегов ## Свойства безопасности ГОСТ (`sbom.gost`) -Для соответствия стандартам безопасности ГОСТ можно настроить обязательные свойства безопасности для всех компонентов в SBOM. Эти свойства будут внедрены во все прямые компоненты итогового SBOM. По умолчанию как генерируемый, так и определяемый пользователем SBOM-ы обогащаются значениями `attackSurface=yes` и `securityFunction=yes`, если не задано иное через настройки проекта (meta-уровень) или конкретного образа (image-уровень). +Для соответствия стандартам безопасности ГОСТ можно настроить обязательные свойства безопасности для всех компонентов в SBOM. Эти свойства будут внедрены во всё дерево компонентов итогового SBOM. По умолчанию как генерируемый, так и определяемый пользователем SBOM-ы обогащаются значениями `attackSurface=yes` и `securityFunction=yes`, если не задано иное через настройки проекта (meta-уровень) или конкретного образа (image-уровень). 1. `attackSurface`: Свойство поверхности атаки (`yes` | `no` | `indirect`). 2. `securityFunction`: Свойство функции безопасности (`yes` | `no` | `indirect`). +`attackSurface: yes` следует дереву зависимостей из секции `dependencies`: значение получают компоненты, от которых ничто не зависит, а каждый компонент, который потянул за собой другой, записывается как `indirect`. Если каталогеры экосистемы вообще не сообщают дерево зависимостей, корнями считаются все компоненты и каждый получает `yes`. Значения `no` и `indirect`, а также `securityFunction` в любом случае, применяются ко всему дереву без изменений. + Эти свойства можно определить глобально в `build.sbom.gost` или для конкретного образа в `image.sbom.gost`. Конфигурация на уровне образа переопределяет глобальную конфигурацию. > **ПРИМЕЧАНИЕ:** Интеграция свойств ГОСТ является экспериментальной и строго привязана к стандарту `cyclonedx@1.6`. diff --git a/test/e2e/sbom/lifecycle_test.go b/test/e2e/sbom/lifecycle_test.go index 4173674bfa..d8ce7a732f 100644 --- a/test/e2e/sbom/lifecycle_test.go +++ b/test/e2e/sbom/lifecycle_test.go @@ -96,7 +96,10 @@ var _ = Describe("SBOM lifecycle", Label("e2e", "sbom", "lifecycle", "simple"), // GOST properties from build.sbom.gost must be preserved through merge on every component. // NOTE: metadata.component of a merged BOM is a synthetic product identity from --app-name // and does NOT carry GOST — hence AssertGostPropertyOnComponents (not AssertGostProperty). - sbomtest.AssertGostPropertyOnComponents(merged, gost.PropertyAttackSurface, gost.GostValueYes) + // The default attack surface `yes` lands on the roots of the dependency tree; openssl is + // pulled in by curl and is demoted to `indirect`. + sbomtest.AssertGostPropertyOnComponent(merged, "curl", "8.12.1", gost.PropertyAttackSurface, gost.GostValueYes) + sbomtest.AssertGostPropertyOnComponent(merged, "openssl", "3.6.2", gost.PropertyAttackSurface, gost.GostValueIndirect) sbomtest.AssertGostPropertyOnComponents(merged, gost.PropertySecurityFunction, gost.GostValueYes) depRefPrefix := lo.Ternary(isprasFormat == "container", "backend/", "") diff --git a/test/pkg/sbom/helpers.go b/test/pkg/sbom/helpers.go index ef1d526e8d..23337c1fc6 100644 --- a/test/pkg/sbom/helpers.go +++ b/test/pkg/sbom/helpers.go @@ -207,11 +207,29 @@ func AssertNoComponent(bom *cdx.BOM, name string) { }) } +// AssertGostPropertyOnComponent asserts the GOST property on a single component. +// Use it where the value differs across the tree: an attack surface of `yes` +// lands on the roots of the dependency tree only, while everything another +// component depends on is demoted to `indirect` — see gost.Upsert. +func AssertGostPropertyOnComponent(bom *cdx.BOM, name, version, propertyName string, expected gost.GostValue) { + comp := FindComponent(bom, name, version) + ExpectWithOffset(1, comp).NotTo(BeNil(), + "component %s@%s not found", name, version) + + val, found := findProperty(comp.Properties, propertyName) + ExpectWithOffset(1, found).To(BeTrue(), + "component %s@%s missing GOST property %q", name, version, propertyName) + ExpectWithOffset(1, val).To(Equal(expected.String()), + "component %s@%s GOST property %q: expected %q, got %q", + name, version, propertyName, expected.String(), val) +} + // AssertGostPropertyOnMetadata asserts the GOST property on `bom.Metadata.Component` // only. Use it together with AssertGostPropertyOnComponents when a test needs to -// verify that both surfaces carry the same value (single-image builds, where werf -// applies the resolved image-level GOST config uniformly to metadata and to every -// component — see gost.Upsert in pkg/sbom/cyclonedxutil/gost/upsert.go). +// verify that both surfaces carry the same value — which holds for `no` and +// `indirect`, and for the security function in all cases, since those apply +// unchanged to the whole tree (see gost.Upsert in +// pkg/sbom/cyclonedxutil/gost/upsert.go). // Splitting the two checks documents the intent explicitly and produces a targeted // error message when only one of the surfaces regresses. func AssertGostPropertyOnMetadata(bom *cdx.BOM, propertyName string, expected gost.GostValue) { From dfa684b4c94cc26373f02a450a8aa714c78710e6 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Sun, 20 Sep 2026 16:12:55 +0300 Subject: [PATCH 03/17] fix(sbom): keep entry-point packages attackable when the image lists 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 --- pkg/sbom/cyclonedxutil/gost/upsert.go | 14 ++++++++ pkg/sbom/cyclonedxutil/gost/upsert_test.go | 38 +++++++++++++++++++++- test/pkg/sbom/helpers.go | 3 ++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/pkg/sbom/cyclonedxutil/gost/upsert.go b/pkg/sbom/cyclonedxutil/gost/upsert.go index 9c6882be34..87c7736e7d 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert.go @@ -53,9 +53,23 @@ func setComponents(components []cdx.Component, root, dependent Config, targets m // in the image pulls it in. An empty `dependencies` section therefore makes // every component a root, which is what the catalogers that report no tree at // all produce. +// +// Edges sourced at the image itself are skipped: a cataloger that lists every +// package under the image root describes what the image contains, not what one +// package pulls in, and honoring those edges would leave the tree without a +// single root. func dependencyTargets(bom *cdx.BOM) map[string]struct{} { + var rootRef string + if bom.Metadata != nil && bom.Metadata.Component != nil { + rootRef = bom.Metadata.Component.BOMRef + } + targets := make(map[string]struct{}) for _, dep := range lo.FromPtr(bom.Dependencies) { + if rootRef != "" && dep.Ref == rootRef { + continue + } + for _, ref := range lo.FromPtr(dep.Dependencies) { if ref != dep.Ref { targets[ref] = struct{}{} diff --git a/pkg/sbom/cyclonedxutil/gost/upsert_test.go b/pkg/sbom/cyclonedxutil/gost/upsert_test.go index 39d431e201..3fcdd91617 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert_test.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert_test.go @@ -186,6 +186,23 @@ var _ = Describe("Gost SBOM setter", func() { }, Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, map[string]GostValue{"curl": GostValueYes}), + Entry("an edge sourced at the image itself does not demote anything", + &cdx.BOM{ + Metadata: &cdx.Metadata{Component: &cdx.Component{BOMRef: "image"}}, + Components: &[]cdx.Component{{BOMRef: "curl"}, {BOMRef: "jq"}}, + Dependencies: &[]cdx.Dependency{ + {Ref: "image", Dependencies: &[]string{"curl", "jq"}}, + }, + }, + Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, + map[string]GostValue{"curl": GostValueYes, "jq": GostValueYes}), + Entry("a provides edge does not demote what it points at", + &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "openssl"}, {BOMRef: "libssl"}}, + Dependencies: &[]cdx.Dependency{{Ref: "openssl", Provides: &[]string{"libssl"}}}, + }, + Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, + map[string]GostValue{"openssl": GostValueYes, "libssl": GostValueYes}), Entry("no dependency tree makes every component a root", &cdx.BOM{ Components: &[]cdx.Component{{BOMRef: "a"}, {BOMRef: "b"}}, @@ -218,7 +235,26 @@ var _ = Describe("Gost SBOM setter", func() { Expect(Upsert(bom, Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes})).To(Succeed()) Expect(GetComponent(bom.Metadata.Component).AttackSurface).To(Equal(GostValueYes)) - Expect(GetComponent(&(*bom.Components)[1]).AttackSurface).To(Equal(GostValueIndirect)) + Expect(GetComponent(&(*bom.Components)[1])).To(Equal(Config{ + AttackSurface: GostValueIndirect, + SecurityFunction: GostValueYes, + }), "the security function is never split") + }) + + It("demotes a component nested under the metadata component", func() { + bom := &cdx.BOM{ + Metadata: &cdx.Metadata{Component: &cdx.Component{ + BOMRef: "image", + Components: &[]cdx.Component{{BOMRef: "curl"}, {BOMRef: "openssl"}}, + }}, + Dependencies: &[]cdx.Dependency{{Ref: "curl", Dependencies: &[]string{"openssl"}}}, + } + + Expect(Upsert(bom, Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes})).To(Succeed()) + + nested := lo.FromPtr(bom.Metadata.Component.Components) + Expect(GetComponent(&nested[0]).AttackSurface).To(Equal(GostValueYes)) + Expect(GetComponent(&nested[1]).AttackSurface).To(Equal(GostValueIndirect)) }) It("demotes a nested component the tree depends on", func() { diff --git a/test/pkg/sbom/helpers.go b/test/pkg/sbom/helpers.go index 23337c1fc6..3ff17276af 100644 --- a/test/pkg/sbom/helpers.go +++ b/test/pkg/sbom/helpers.go @@ -212,6 +212,9 @@ func AssertNoComponent(bom *cdx.BOM, name string) { // lands on the roots of the dependency tree only, while everything another // component depends on is demoted to `indirect` — see gost.Upsert. func AssertGostPropertyOnComponent(bom *cdx.BOM, name, version, propertyName string, expected gost.GostValue) { + ExpectWithOffset(1, propertyName).To(BeElementOf(gost.PropertyAttackSurface, gost.PropertySecurityFunction), + "unknown GOST property name %q", propertyName) + comp := FindComponent(bom, name, version) ExpectWithOffset(1, comp).NotTo(BeNil(), "component %s@%s not found", name, version) From efafd78368d5b509a11d64bdb9b90aefb11d8315 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 25 Sep 2026 11:35:06 +0300 Subject: [PATCH 04/17] test(sbom): run merged product SBOMs through the checker in both formats 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 --- test/e2e/sbom/lifecycle_test.go | 37 ++++++++++- test/pkg/sbom/helpers.go | 111 ++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/test/e2e/sbom/lifecycle_test.go b/test/e2e/sbom/lifecycle_test.go index d8ce7a732f..fbeefc56dd 100644 --- a/test/e2e/sbom/lifecycle_test.go +++ b/test/e2e/sbom/lifecycle_test.go @@ -70,7 +70,8 @@ var _ = Describe("SBOM lifecycle", Label("e2e", "sbom", "lifecycle", "simple"), mappingPath := filepath.Join(SuiteData.TmpDir, "lifecycle_multi_mapping_"+isprasFormat+".json") writeMappingFile(mappingPath, mapping) - mergeOut := werfProject.SbomMerge(ctx, &werf.SbomMergeOptions{ + mergedJSONPath := filepath.Join(SuiteData.TmpDir, "lifecycle_multi_merged_"+isprasFormat+".json") + werfProject.SbomMerge(ctx, &werf.SbomMergeOptions{ CommonOptions: werf.CommonOptions{ ExtraArgs: []string{ "--input", mappingPath, @@ -78,11 +79,18 @@ var _ = Describe("SBOM lifecycle", Label("e2e", "sbom", "lifecycle", "simple"), "--app-name", "lifecycle-product", "--app-version", "1.0.0", "--manufacturer", "e2e-test", + "--output", mergedJSONPath, }, }, }) - merged := sbomtest.MustParseSBOMOutput(mergeOut) + mergedJSON, err := os.ReadFile(mergedJSONPath) + Expect(err).NotTo(HaveOccurred()) + + merged := sbomtest.MustParseSBOMOutput(string(mergedJSON)) + sbomtest.AssertSpecVersion(merged, cdx.SpecVersion1_6) + sbomtest.AssertProductMetadata(merged, "lifecycle-product", "1.0.0", "e2e-test") + sbomtest.AssertUniqueBOMRefs(merged) sbomtest.AssertHasComponent(merged, "jq", "1.8.1") sbomtest.AssertHasComponent(merged, "yq", "4.53.6") @@ -102,6 +110,15 @@ var _ = Describe("SBOM lifecycle", Label("e2e", "sbom", "lifecycle", "simple"), sbomtest.AssertGostPropertyOnComponent(merged, "openssl", "3.6.2", gost.PropertyAttackSurface, gost.GostValueIndirect) sbomtest.AssertGostPropertyOnComponents(merged, gost.PropertySecurityFunction, gost.GostValueYes) + if isprasFormat == "container" { + sbomtest.AssertContainerComponents(merged, "frontend", "backend") + sbomtest.AssertGostPropertyOnContainers(merged, gost.PropertyAttackSurface, gost.GostValueYes) + sbomtest.AssertGostPropertyOnContainers(merged, gost.PropertySecurityFunction, gost.GostValueYes) + } else { + sbomtest.AssertFlatComponents(merged) + sbomtest.AssertNoDuplicateComponents(merged) + } + depRefPrefix := lo.Ternary(isprasFormat == "container", "backend/", "") sbomtest.AssertDependsOn(merged, depRefPrefix+"pkg:generic/curl@8.12.1?containerfactoryversion=v3.0.2", @@ -116,6 +133,22 @@ var _ = Describe("SBOM lifecycle", Label("e2e", "sbom", "lifecycle", "simple"), return lo.Ternary(isprasFormat == "container", name+"/"+ref, ref) }) } + + // The product assembled from two images with a split attack surface must + // still satisfy the ISPRAS checker, which requires a container to report + // exactly the maximum over the packages it holds. The oss schema is not + // run: the builder SBOM carries an `operating-system` component without + // a vcs reference that this schema rejects — see the pending oss entry + // of the single-image lifecycle below for when it comes back. + if isprasFormat == "container" { + validateOut := werfProject.SbomValidate(ctx, &werf.SbomValidateOptions{ + CommonOptions: werf.CommonOptions{ + ExtraArgs: []string{"--path", mergedJSONPath, "--ispras-format", isprasFormat}, + }, + }) + Expect(validateOut).To(ContainSubstring("OK"), + "merged product SBOM did not pass %q validation; output:\n%s", isprasFormat, validateOut) + } }, Entry("container format", "container"), Entry("oss format", "oss"), diff --git a/test/pkg/sbom/helpers.go b/test/pkg/sbom/helpers.go index 3ff17276af..d41263f41b 100644 --- a/test/pkg/sbom/helpers.go +++ b/test/pkg/sbom/helpers.go @@ -207,6 +207,88 @@ func AssertNoComponent(bom *cdx.BOM, name string) { }) } +// AssertProductMetadata asserts the synthetic product identity a merged BOM +// carries in `metadata`, built from the --app-name, --app-version and +// --manufacturer flags of `werf sbom merge`. +func AssertProductMetadata(bom *cdx.BOM, name, version, manufacturer string) { + ExpectWithOffset(1, bom.Metadata).NotTo(BeNil(), "merged BOM has no metadata") + ExpectWithOffset(1, bom.Metadata.Timestamp).NotTo(BeEmpty(), "merged BOM metadata has no timestamp") + + comp := bom.Metadata.Component + ExpectWithOffset(1, comp).NotTo(BeNil(), "merged BOM has no metadata component") + ExpectWithOffset(1, comp.Type).To(Equal(cdx.ComponentTypeApplication), + "product component type: expected %q, got %q", cdx.ComponentTypeApplication, comp.Type) + ExpectWithOffset(1, comp.Name).To(Equal(name)) + ExpectWithOffset(1, comp.Version).To(Equal(version)) + ExpectWithOffset(1, comp.Manufacturer).NotTo(BeNil(), "product component has no manufacturer") + ExpectWithOffset(1, comp.Manufacturer.Name).To(Equal(manufacturer)) +} + +// AssertContainerComponents asserts that the top level of an ISPRAS `container` +// product SBOM consists of exactly the given images, each holding packages. +func AssertContainerComponents(bom *cdx.BOM, names ...string) { + var got []string + for _, c := range lo.FromPtr(bom.Components) { + ExpectWithOffset(1, c.Type).To(Equal(cdx.ComponentTypeContainer), + "top-level component %q of a container SBOM is %q, expected a container", c.Name, c.Type) + ExpectWithOffset(1, lo.FromPtr(c.Components)).NotTo(BeEmpty(), + "container %q holds no packages", c.Name) + got = append(got, c.Name) + } + + ExpectWithOffset(1, got).To(ConsistOf(names)) +} + +// AssertFlatComponents asserts that an ISPRAS `oss` product SBOM is flat: no +// containers, no nesting, every package on the top level. +func AssertFlatComponents(bom *cdx.BOM) { + ExpectWithOffset(1, lo.FromPtr(bom.Components)).NotTo(BeEmpty(), "BOM has no components") + + for _, c := range lo.FromPtr(bom.Components) { + ExpectWithOffset(1, c.Type).NotTo(Equal(cdx.ComponentTypeContainer), + "component %q is a container, but an oss SBOM must be flat", c.Name) + ExpectWithOffset(1, lo.FromPtr(c.Components)).To(BeEmpty(), + "component %q has nested components, but an oss SBOM must be flat", c.Name) + } +} + +// AssertUniqueBOMRefs asserts that no two components of the BOM share a bom-ref. +// Merging rewrites the refs of components coming from different images, and a +// collision there silently redirects dependency edges to the wrong component. +func AssertUniqueBOMRefs(bom *cdx.BOM) { + seen := map[string][]string{} + walkComponents(bom.Components, func(c *cdx.Component) { + if c.BOMRef == "" { + ExpectWithOffset(1, c.BOMRef).NotTo(BeEmpty(), + "component %s@%s has no bom-ref", c.Name, c.Version) + return + } + seen[c.BOMRef] = append(seen[c.BOMRef], c.Name+"@"+c.Version) + }) + + for ref, owners := range seen { + ExpectWithOffset(1, owners).To(HaveLen(1), + "bom-ref %q is shared by %v", ref, owners) + } +} + +// AssertNoDuplicateComponents asserts that no package identity appears twice, +// which is what the flat `oss` format promises after deduplication. Identity +// includes the purl without the per-document `package-id` qualifier — the key +// the merge deduplicates by — so the same name and version reported by two +// catalogers under different purls (pm's `containerfactoryversion` against +// syft's bare binary match) count as two packages, as they do in the product. +func AssertNoDuplicateComponents(bom *cdx.BOM) { + counts := map[string]int{} + walkComponents(bom.Components, func(c *cdx.Component) { + counts[componentIdentity(c)]++ + }) + + for key, n := range counts { + ExpectWithOffset(1, n).To(Equal(1), "component %s appears %d times", key, n) + } +} + // AssertGostPropertyOnComponent asserts the GOST property on a single component. // Use it where the value differs across the tree: an attack surface of `yes` // lands on the roots of the dependency tree only, while everything another @@ -276,6 +358,35 @@ func AssertGostPropertyOnComponents(bom *cdx.BOM, propertyName string, expected "BOM has no components to assert GOST property on") } +// AssertGostPropertyOnContainers asserts the GOST property on every +// `container`-typed component of an ISPRAS `container` product SBOM. The value +// there is not injected from the config but computed as the maximum over the +// packages the container holds, which the ISPRAS checker requires to match +// exactly — so a container whose packages are split across attack surface +// values must still report the highest of them. +func AssertGostPropertyOnContainers(bom *cdx.BOM, propertyName string, expected gost.GostValue) { + ExpectWithOffset(1, propertyName).To(BeElementOf(gost.PropertyAttackSurface, gost.PropertySecurityFunction), + "unknown GOST property name %q", propertyName) + + checked := 0 + walkComponents(bom.Components, func(c *cdx.Component) { + if c.Type != cdx.ComponentTypeContainer { + return + } + + val, found := findProperty(c.Properties, propertyName) + ExpectWithOffset(1, found).To(BeTrue(), + "container %s missing GOST property %q", c.Name, propertyName) + ExpectWithOffset(1, val).To(Equal(expected.String()), + "container %s GOST property %q: expected %q, got %q", + c.Name, propertyName, expected.String(), val) + checked++ + }) + + ExpectWithOffset(1, checked).To(BeNumerically(">", 0), + "BOM has no container components to assert GOST property on") +} + func AssertSpecVersion(bom *cdx.BOM, expected cdx.SpecVersion) { ExpectWithOffset(1, bom.SpecVersion).To(Equal(expected), "expected spec version %q, got %q", expected, bom.SpecVersion) From abe8467d0122c394d9142394091d25ee4b8337ed Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 25 Sep 2026 11:35:07 +0300 Subject: [PATCH 05/17] fix(sbom): reject indirect as a security function value 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 --- docs/_data/werf_yaml.yml | 4 ++-- docs/pages_en/usage/build/sbom.md | 2 +- docs/pages_ru/usage/build/sbom.md | 2 +- pkg/config/raw_gost.go | 6 +++--- pkg/config/raw_gost_test.go | 6 ++++++ pkg/config/werf_schema_test.go | 12 +++++++++++- pkg/sbom/cyclonedxutil/gost/config.go | 9 ++++++++- pkg/sbom/cyclonedxutil/gost/upsert_test.go | 4 ++-- pkg/sbom/cyclonedxutil/gost/validator.go | 6 +++--- pkg/sbom/cyclonedxutil/gost/validator_test.go | 16 +++++++++++++++- schemas/werf.json | 3 +-- .../sbom/_fixtures/gost_toggle/state1/werf.yaml | 2 +- .../inject/ospm_gost_override/werf.yaml | 2 +- test/e2e/sbom/gost_cache_invalidation_test.go | 2 +- test/e2e/sbom/gost_test.go | 4 ++-- 15 files changed, 58 insertions(+), 22 deletions(-) diff --git a/docs/_data/werf_yaml.yml b/docs/_data/werf_yaml.yml index 21143a47a7..31714d8b51 100644 --- a/docs/_data/werf_yaml.yml +++ b/docs/_data/werf_yaml.yml @@ -80,8 +80,8 @@ sections: value: "string" default: "yes" description: - en: "Security function property (yes | no | indirect). Default: yes" - ru: "Свойство функции безопасности (yes | no | indirect). По умолчанию: yes" + en: "Security function property (yes | no). Default: yes" + ru: "Свойство функции безопасности (yes | no). По умолчанию: yes" - &image-section-sbom name: sbom description: diff --git a/docs/pages_en/usage/build/sbom.md b/docs/pages_en/usage/build/sbom.md index 51f2ff9a9e..77603ddca5 100644 --- a/docs/pages_en/usage/build/sbom.md +++ b/docs/pages_en/usage/build/sbom.md @@ -116,7 +116,7 @@ When building a multi-platform image, werf generates a separate SBOM artifact fo To comply with GOST safety standards, you can configure mandatory security properties for all components in the SBOM. These properties will be injected into the whole component tree of the final SBOM. By default, both generated and user-defined SBOMs are enriched with `attackSurface=yes` and `securityFunction=yes`, unless specified otherwise at the project (meta) or image level. 1. `attackSurface`: The attack surface property (`yes` | `no` | `indirect`). -2. `securityFunction`: The security function property (`yes` | `no` | `indirect`). +2. `securityFunction`: The security function property (`yes` | `no`). `attackSurface: yes` follows the dependency tree recorded in the SBOM `dependencies` section: it lands on the components nothing else depends on, and every component pulled in by another one is recorded as `indirect`. When the catalogers of an ecosystem report no dependency tree at all, every component is a root and receives `yes`. `no` and `indirect`, and `securityFunction` in all cases, apply unchanged to the whole tree. diff --git a/docs/pages_ru/usage/build/sbom.md b/docs/pages_ru/usage/build/sbom.md index daea57ac5e..a4e393ba53 100644 --- a/docs/pages_ru/usage/build/sbom.md +++ b/docs/pages_ru/usage/build/sbom.md @@ -116,7 +116,7 @@ werf всегда использует индекс на основе тегов Для соответствия стандартам безопасности ГОСТ можно настроить обязательные свойства безопасности для всех компонентов в SBOM. Эти свойства будут внедрены во всё дерево компонентов итогового SBOM. По умолчанию как генерируемый, так и определяемый пользователем SBOM-ы обогащаются значениями `attackSurface=yes` и `securityFunction=yes`, если не задано иное через настройки проекта (meta-уровень) или конкретного образа (image-уровень). 1. `attackSurface`: Свойство поверхности атаки (`yes` | `no` | `indirect`). -2. `securityFunction`: Свойство функции безопасности (`yes` | `no` | `indirect`). +2. `securityFunction`: Свойство функции безопасности (`yes` | `no`). `attackSurface: yes` следует дереву зависимостей из секции `dependencies`: значение получают компоненты, от которых ничто не зависит, а каждый компонент, который потянул за собой другой, записывается как `indirect`. Если каталогеры экосистемы вообще не сообщают дерево зависимостей, корнями считаются все компоненты и каждый получает `yes`. Значения `no` и `indirect`, а также `securityFunction` в любом случае, применяются ко всему дереву без изменений. diff --git a/pkg/config/raw_gost.go b/pkg/config/raw_gost.go index 8ad7b3caef..ba1842df75 100644 --- a/pkg/config/raw_gost.go +++ b/pkg/config/raw_gost.go @@ -35,11 +35,11 @@ func (g *rawGost) UnmarshalYAML(unmarshal func(interface{}) error) error { } func (g *rawGost) validate() error { - if g.AttackSurface != nil && !gost.IsValidGostValue(*g.AttackSurface) { + if g.AttackSurface != nil && !gost.IsValidAttackSurfaceValue(*g.AttackSurface) { return newDetailedConfigError(fmt.Sprintf("invalid 'attackSurface' value %q: expected 'yes', 'no' or 'indirect'", *g.AttackSurface), nil, g.doc) } - if g.SecurityFunction != nil && !gost.IsValidGostValue(*g.SecurityFunction) { - return newDetailedConfigError(fmt.Sprintf("invalid 'securityFunction' value %q: expected 'yes', 'no' or 'indirect'", *g.SecurityFunction), nil, g.doc) + if g.SecurityFunction != nil && !gost.IsValidSecurityFunctionValue(*g.SecurityFunction) { + return newDetailedConfigError(fmt.Sprintf("invalid 'securityFunction' value %q: expected 'yes' or 'no'", *g.SecurityFunction), nil, g.doc) } return nil } diff --git a/pkg/config/raw_gost_test.go b/pkg/config/raw_gost_test.go index 3d831b30e4..57f1b28b47 100644 --- a/pkg/config/raw_gost_test.go +++ b/pkg/config/raw_gost_test.go @@ -57,5 +57,11 @@ var _ = Describe("rawGost", func() { }, gost.Config{}, HaveOccurred()), + Entry("indirect is rejected for the security function", + map[string]interface{}{ + "securityFunction": "indirect", + }, + gost.Config{}, + MatchError(ContainSubstring("expected 'yes' or 'no'"))), ) }) diff --git a/pkg/config/werf_schema_test.go b/pkg/config/werf_schema_test.go index eef2d09bc0..7cde66e47f 100644 --- a/pkg/config/werf_schema_test.go +++ b/pkg/config/werf_schema_test.go @@ -131,7 +131,7 @@ build: standard: cyclonedx@1.6 gost: attackSurface: "yes" - securityFunction: indirect + securityFunction: no deploy: helmChartDir: .helm helmChartConfig: @@ -396,6 +396,16 @@ project: app build: sbom: standard: cyclonedx@1.6 +`), + Entry("indirect security function", ` +configVersion: 1 +project: app +build: + sbom: + enable: true + standard: cyclonedx@1.6 + gost: + securityFunction: indirect `), Entry("os-pm packages with workdir", ` image: app diff --git a/pkg/sbom/cyclonedxutil/gost/config.go b/pkg/sbom/cyclonedxutil/gost/config.go index 683ea114b2..3359831fb0 100644 --- a/pkg/sbom/cyclonedxutil/gost/config.go +++ b/pkg/sbom/cyclonedxutil/gost/config.go @@ -41,10 +41,17 @@ func (c Config) Merge(other Config) Config { return res } -func IsValidGostValue(v string) bool { +func IsValidAttackSurfaceValue(v string) bool { return v == GostValueYes.String() || v == GostValueNo.String() || v == GostValueIndirect.String() } +// IsValidSecurityFunctionValue rejects `indirect`: a component either implements +// a security function or does not, there is nothing for it to implement one +// through. +func IsValidSecurityFunctionValue(v string) bool { + return v == GostValueYes.String() || v == GostValueNo.String() +} + func (v GostValue) IsUndefined() bool { return v == GostValueUndefined } diff --git a/pkg/sbom/cyclonedxutil/gost/upsert_test.go b/pkg/sbom/cyclonedxutil/gost/upsert_test.go index 3fcdd91617..4611530e8f 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert_test.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert_test.go @@ -139,13 +139,13 @@ var _ = Describe("Gost SBOM setter", func() { &cdx.BOM{ Components: &[]cdx.Component{{Name: "test"}}, }, - Config{AttackSurface: GostValueIndirect, SecurityFunction: GostValueIndirect}, + Config{AttackSurface: GostValueIndirect, SecurityFunction: GostValueNo}, []cdx.Component{ { Name: "test", Properties: &[]cdx.Property{ {Name: PropertyAttackSurface, Value: "indirect"}, - {Name: PropertySecurityFunction, Value: "indirect"}, + {Name: PropertySecurityFunction, Value: "no"}, }, }, }, diff --git a/pkg/sbom/cyclonedxutil/gost/validator.go b/pkg/sbom/cyclonedxutil/gost/validator.go index 8637375055..54cf4bb801 100644 --- a/pkg/sbom/cyclonedxutil/gost/validator.go +++ b/pkg/sbom/cyclonedxutil/gost/validator.go @@ -51,15 +51,15 @@ func ValidateComponent(comp *cdx.Component) error { as, asOk := a.GetAttackSurface() if !asOk { missing = append(missing, PropertyAttackSurface) - } else if !IsValidGostValue(as.String()) { + } else if !IsValidAttackSurfaceValue(as.String()) { return fmt.Errorf("invalid value for %s: %q (expected 'yes', 'no' or 'indirect')", PropertyAttackSurface, as) } sf, sfOk := a.GetSecurityFunction() if !sfOk { missing = append(missing, PropertySecurityFunction) - } else if !IsValidGostValue(sf.String()) { - return fmt.Errorf("invalid value for %s: %q (expected 'yes', 'no' or 'indirect')", PropertySecurityFunction, sf) + } else if !IsValidSecurityFunctionValue(sf.String()) { + return fmt.Errorf("invalid value for %s: %q (expected 'yes' or 'no')", PropertySecurityFunction, sf) } if len(missing) > 0 { diff --git a/pkg/sbom/cyclonedxutil/gost/validator_test.go b/pkg/sbom/cyclonedxutil/gost/validator_test.go index 9a0ec1905b..0dbe26b99e 100644 --- a/pkg/sbom/cyclonedxutil/gost/validator_test.go +++ b/pkg/sbom/cyclonedxutil/gost/validator_test.go @@ -98,11 +98,25 @@ var _ = Describe("Gost SBOM validator", func() { Name: "test-comp", Properties: &[]cdx.Property{ {Name: PropertyAttackSurface, Value: "indirect"}, - {Name: PropertySecurityFunction, Value: "indirect"}, + {Name: PropertySecurityFunction, Value: "no"}, }, }, }, }, Succeed()), + Entry("should fail if the security function is 'indirect'", + &cdx.BOM{ + SpecVersion: cdx.SpecVersion1_6, + Components: &[]cdx.Component{ + { + Name: "test-comp", + Properties: &[]cdx.Property{ + {Name: PropertyAttackSurface, Value: "yes"}, + {Name: PropertySecurityFunction, Value: "indirect"}, + }, + }, + }, + }, + MatchError(ContainSubstring("expected 'yes' or 'no'"))), ) }) diff --git a/schemas/werf.json b/schemas/werf.json index 2de10cf41d..2b75421845 100644 --- a/schemas/werf.json +++ b/schemas/werf.json @@ -528,8 +528,7 @@ "type": "string", "enum": [ "yes", - "no", - "indirect" + "no" ] } } diff --git a/test/e2e/sbom/_fixtures/gost_toggle/state1/werf.yaml b/test/e2e/sbom/_fixtures/gost_toggle/state1/werf.yaml index 3069f7fe71..8f866cfa2d 100644 --- a/test/e2e/sbom/_fixtures/gost_toggle/state1/werf.yaml +++ b/test/e2e/sbom/_fixtures/gost_toggle/state1/werf.yaml @@ -6,7 +6,7 @@ build: standard: "cyclonedx@1.6" gost: attackSurface: no - securityFunction: indirect + securityFunction: no --- image: app from: scratch diff --git a/test/e2e/sbom/_fixtures/inject/ospm_gost_override/werf.yaml b/test/e2e/sbom/_fixtures/inject/ospm_gost_override/werf.yaml index 3e76127db9..bdf6b05162 100644 --- a/test/e2e/sbom/_fixtures/inject/ospm_gost_override/werf.yaml +++ b/test/e2e/sbom/_fixtures/inject/ospm_gost_override/werf.yaml @@ -13,7 +13,7 @@ from: registry.deckhouse.io/container-factory@sha256:7aac8d97a91ac233b19111aaad9 sbom: gost: attackSurface: no - securityFunction: indirect + securityFunction: no git: - add: / to: / diff --git a/test/e2e/sbom/gost_cache_invalidation_test.go b/test/e2e/sbom/gost_cache_invalidation_test.go index 5794529605..7bd13fa531 100644 --- a/test/e2e/sbom/gost_cache_invalidation_test.go +++ b/test/e2e/sbom/gost_cache_invalidation_test.go @@ -59,6 +59,6 @@ var _ = Describe("SBOM GOST cache invalidation", Label("e2e", "sbom", "gost", "s CommonOptions: werf.CommonOptions{ExtraArgs: []string{"app"}}, })) sbomtest.AssertGostPropertyOnMetadata(bom1, gost.PropertyAttackSurface, gost.GostValueNo) - sbomtest.AssertGostPropertyOnMetadata(bom1, gost.PropertySecurityFunction, gost.GostValueIndirect) + sbomtest.AssertGostPropertyOnMetadata(bom1, gost.PropertySecurityFunction, gost.GostValueNo) }) }) diff --git a/test/e2e/sbom/gost_test.go b/test/e2e/sbom/gost_test.go index 10c3c606c1..e690f2d5cf 100644 --- a/test/e2e/sbom/gost_test.go +++ b/test/e2e/sbom/gost_test.go @@ -83,8 +83,8 @@ var _ = Describe("SBOM GOST integration", Label("e2e", "sbom", "gost", "simple") // Image-level GOST override for an os-pm image must land on both // metadata.component and every collected pm component. sbomtest.AssertGostPropertyOnMetadata(bom, gost.PropertyAttackSurface, gost.GostValueNo) - sbomtest.AssertGostPropertyOnMetadata(bom, gost.PropertySecurityFunction, gost.GostValueIndirect) + sbomtest.AssertGostPropertyOnMetadata(bom, gost.PropertySecurityFunction, gost.GostValueNo) sbomtest.AssertGostPropertyOnComponents(bom, gost.PropertyAttackSurface, gost.GostValueNo) - sbomtest.AssertGostPropertyOnComponents(bom, gost.PropertySecurityFunction, gost.GostValueIndirect) + sbomtest.AssertGostPropertyOnComponents(bom, gost.PropertySecurityFunction, gost.GostValueNo) }) }) From 0b4582f17ea197d79d9af6e14d1feb2c181f21dd Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 25 Sep 2026 14:35:42 +0300 Subject: [PATCH 06/17] fix(sbom): keep mutually dependent packages attackable when nothing pulls them in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/sbom/cyclonedxutil/gost/upsert.go | 115 +++++++++++++++++---- pkg/sbom/cyclonedxutil/gost/upsert_test.go | 40 +++++++ 2 files changed, 136 insertions(+), 19 deletions(-) diff --git a/pkg/sbom/cyclonedxutil/gost/upsert.go b/pkg/sbom/cyclonedxutil/gost/upsert.go index 87c7736e7d..e68d846ff9 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert.go @@ -2,6 +2,8 @@ package gost import ( "fmt" + "maps" + "slices" cdx "github.com/CycloneDX/cyclonedx-go" "github.com/samber/lo" @@ -17,54 +19,59 @@ func Upsert(bom *cdx.BOM, config Config) error { return fmt.Errorf("BOM is required") } - dependent := config + dependencyConfig := config if config.AttackSurface == GostValueYes { - dependent.AttackSurface = GostValueIndirect + dependencyConfig.AttackSurface = GostValueIndirect } targets := dependencyTargets(bom) if bom.Metadata != nil && bom.Metadata.Component != nil { SetComponent(bom.Metadata.Component, config) - setComponents(lo.FromPtr(bom.Metadata.Component.Components), config, dependent, targets) + setComponents(lo.FromPtr(bom.Metadata.Component.Components), config, dependencyConfig, targets) } - setComponents(lo.FromPtr(bom.Components), config, dependent, targets) + setComponents(lo.FromPtr(bom.Components), config, dependencyConfig, targets) return nil } -func setComponents(components []cdx.Component, root, dependent Config, targets map[string]struct{}) { +func setComponents(components []cdx.Component, rootConfig, dependencyConfig Config, targets map[string]struct{}) { for i := range components { comp := &components[i] - cfg := root + cfg := rootConfig if _, ok := targets[comp.BOMRef]; ok { - cfg = dependent + cfg = dependencyConfig } SetComponent(comp, cfg) - setComponents(lo.FromPtr(comp.Components), root, dependent, targets) + setComponents(lo.FromPtr(comp.Components), rootConfig, dependencyConfig, targets) } } -// dependencyTargets collects every bom-ref another component depends on. A -// component missing from the set is a root of the dependency tree: nothing else -// in the image pulls it in. An empty `dependencies` section therefore makes -// every component a root, which is what the catalogers that report no tree at -// all produce. +// dependencyTargets collects every bom-ref some root of the dependency tree +// pulls in, directly or through other components. A component missing from the +// set is a root: nothing else in the image depends on it. An empty +// `dependencies` section therefore makes every component a root, which is what +// the catalogers that report no tree at all produce. // -// Edges sourced at the image itself are skipped: a cataloger that lists every -// package under the image root describes what the image contains, not what one -// package pulls in, and honoring those edges would leave the tree without a -// single root. +// Roots are found per strongly connected component rather than per node: OS +// package graphs contain mutual dependencies (libc and libgcc depend on each +// other), and counting in-edges alone would leave such a cycle with no root +// even when nothing outside of it depends on it. +// +// Edges sourced at the scanned image's own metadata component are skipped: a +// cataloger that lists every package under the image root describes what the +// image contains, not what one package pulls in, and honoring those edges would +// leave the tree without a single root. func dependencyTargets(bom *cdx.BOM) map[string]struct{} { var rootRef string if bom.Metadata != nil && bom.Metadata.Component != nil { rootRef = bom.Metadata.Component.BOMRef } - targets := make(map[string]struct{}) + edges := make(map[string][]string) for _, dep := range lo.FromPtr(bom.Dependencies) { if rootRef != "" && dep.Ref == rootRef { continue @@ -72,14 +79,84 @@ func dependencyTargets(bom *cdx.BOM) map[string]struct{} { for _, ref := range lo.FromPtr(dep.Dependencies) { if ref != dep.Ref { - targets[ref] = struct{}{} + edges[dep.Ref] = append(edges[dep.Ref], ref) + } + } + } + + componentOf := stronglyConnectedComponents(edges) + + dependedOn := make(map[int]struct{}) + for from, tos := range edges { + for _, to := range tos { + if componentOf[from] != componentOf[to] { + dependedOn[componentOf[to]] = struct{}{} } } } + targets := make(map[string]struct{}) + for ref, component := range componentOf { + if _, ok := dependedOn[component]; ok { + targets[ref] = struct{}{} + } + } + return targets } +// stronglyConnectedComponents runs Tarjan's algorithm over the adjacency list +// and labels every node with the index of its component. +func stronglyConnectedComponents(edges map[string][]string) map[string]int { + index := make(map[string]int) + lowlink := make(map[string]int) + onStack := make(map[string]bool) + componentOf := make(map[string]int) + var stack []string + nextIndex, nextComponent := 0, 0 + + var visit func(node string) + visit = func(node string) { + index[node] = nextIndex + lowlink[node] = nextIndex + nextIndex++ + stack = append(stack, node) + onStack[node] = true + + for _, next := range edges[node] { + if _, seen := index[next]; !seen { + visit(next) + lowlink[node] = min(lowlink[node], lowlink[next]) + } else if onStack[next] { + lowlink[node] = min(lowlink[node], index[next]) + } + } + + if lowlink[node] != index[node] { + return + } + + for { + top := stack[len(stack)-1] + stack = stack[:len(stack)-1] + onStack[top] = false + componentOf[top] = nextComponent + if top == node { + break + } + } + nextComponent++ + } + + for _, node := range slices.Sorted(maps.Keys(edges)) { + if _, seen := index[node]; !seen { + visit(node) + } + } + + return componentOf +} + // SetComponent inserts or updates mandatory GOST properties in a single component. func SetComponent(comp *cdx.Component, config Config) { a := newAccessor(comp) diff --git a/pkg/sbom/cyclonedxutil/gost/upsert_test.go b/pkg/sbom/cyclonedxutil/gost/upsert_test.go index 4611530e8f..72c02959b0 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert_test.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert_test.go @@ -209,6 +209,46 @@ var _ = Describe("Gost SBOM setter", func() { }, Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, map[string]GostValue{"a": GostValueYes, "b": GostValueYes}), + Entry("a cycle nothing else depends on is a root", + &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "libc"}, {BOMRef: "libgcc"}, {BOMRef: "zlib"}}, + Dependencies: &[]cdx.Dependency{ + {Ref: "libc", Dependencies: &[]string{"libgcc", "zlib"}}, + {Ref: "libgcc", Dependencies: &[]string{"libc"}}, + }, + }, + Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, + map[string]GostValue{ + "libc": GostValueYes, + "libgcc": GostValueYes, + "zlib": GostValueIndirect, + }), + Entry("a cycle another component depends on is demoted as a whole", + &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "curl"}, {BOMRef: "libc"}, {BOMRef: "libgcc"}}, + Dependencies: &[]cdx.Dependency{ + {Ref: "curl", Dependencies: &[]string{"libc"}}, + {Ref: "libc", Dependencies: &[]string{"libgcc"}}, + {Ref: "libgcc", Dependencies: &[]string{"libc"}}, + }, + }, + Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, + map[string]GostValue{ + "curl": GostValueYes, + "libc": GostValueIndirect, + "libgcc": GostValueIndirect, + }), + Entry("a cycle longer than two components stays one root", + &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "a"}, {BOMRef: "b"}, {BOMRef: "c"}}, + Dependencies: &[]cdx.Dependency{ + {Ref: "a", Dependencies: &[]string{"b"}}, + {Ref: "b", Dependencies: &[]string{"c"}}, + {Ref: "c", Dependencies: &[]string{"a"}}, + }, + }, + Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, + map[string]GostValue{"a": GostValueYes, "b": GostValueYes, "c": GostValueYes}), Entry("indirect applies to the whole tree, roots included", &cdx.BOM{ Components: &[]cdx.Component{{BOMRef: "curl"}, {BOMRef: "openssl"}}, From d2ca3be8c955ad484574a326b8c11db2b678d638 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 25 Sep 2026 14:35:42 +0300 Subject: [PATCH 07/17] docs(sbom), test(sbom): stop advertising indirect as a security function value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/werf/sbom/merge/merge_docs.go | 6 +++--- docs/_includes/reference/cli/werf_sbom_merge.md | 2 +- docs/pages_en/usage/build/sbom.md | 2 +- docs/pages_ru/usage/build/sbom.md | 2 +- pkg/sbom/ispras/gost_test.go | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cmd/werf/sbom/merge/merge_docs.go b/cmd/werf/sbom/merge/merge_docs.go index 79d295bf61..b30c630316 100644 --- a/cmd/werf/sbom/merge/merge_docs.go +++ b/cmd/werf/sbom/merge/merge_docs.go @@ -13,7 +13,7 @@ Two ISPRAS-defined output formats are supported: - "container": hierarchical — each image becomes a top-level container component with nested packages. - "oss": flat — all packages from all images are merged into a deduplicated flat list. -GOST properties (attack_surface, security_function) are aggregated bottom-up using the "yes > indirect > no" precedence rule. +GOST properties are aggregated bottom-up: attack_surface with the "yes > indirect > no" precedence rule, security_function with "yes > no". The flags --input, --ispras-format, --app-name, --app-version and --manufacturer are required. The merged SBOM is written to stdout unless --output is given.` @@ -23,8 +23,8 @@ The flags --input, --ispras-format, --app-name, --app-version and --manufacturer "Two ISPRAS-defined output formats are supported:\n" + "- `container`: hierarchical — each image becomes a top-level container component with nested packages.\n" + "- `oss`: flat — all packages from all images are merged into a deduplicated flat list.\n\n" + - "GOST properties (`attack_surface`, `security_function`) are aggregated bottom-up using " + - "the `yes > indirect > no` precedence rule.\n\n" + + "GOST properties are aggregated bottom-up: `attack_surface` with the `yes > indirect > no` " + + "precedence rule, `security_function` with `yes > no`.\n\n" + "The flags `--input`, `--ispras-format`, `--app-name`, `--app-version` and `--manufacturer` " + "are required. The merged SBOM is written to stdout unless `--output` is given." diff --git a/docs/_includes/reference/cli/werf_sbom_merge.md b/docs/_includes/reference/cli/werf_sbom_merge.md index dd687a03fd..fdef468f00 100644 --- a/docs/_includes/reference/cli/werf_sbom_merge.md +++ b/docs/_includes/reference/cli/werf_sbom_merge.md @@ -11,7 +11,7 @@ Two ISPRAS-defined output formats are supported: - `container`: hierarchical — each image becomes a top-level container component with nested packages. - `oss`: flat — all packages from all images are merged into a deduplicated flat list. -GOST properties (`attack_surface`, `security_function`) are aggregated bottom-up using the `yes > indirect > no` precedence rule. +GOST properties are aggregated bottom-up: `attack_surface` with the `yes > indirect > no` precedence rule, `security_function` with `yes > no`. The flags `--input`, `--ispras-format`, `--app-name`, `--app-version` and `--manufacturer` are required. The merged SBOM is written to stdout unless `--output` is given. diff --git a/docs/pages_en/usage/build/sbom.md b/docs/pages_en/usage/build/sbom.md index 77603ddca5..36477df9d6 100644 --- a/docs/pages_en/usage/build/sbom.md +++ b/docs/pages_en/usage/build/sbom.md @@ -177,6 +177,6 @@ Changing GOST properties (`sbom.gost`) does not affect stage digests. Cached sta [`werf sbom get`]({{ "/reference/cli/werf_sbom_get.html" | true_relative_url }}) retrieves the SBOM for an image described in `werf.yaml` and prints it to stdout. The SBOM is read as an OCI artifact from the container registry, so `--repo` is required. When invoked with an image name, the command runs the standard werf build conveyor: missing stages and SBOM artifacts are created, just like with `werf build` (with the `--require-built-images` flag the command fails instead). You can select a specific version with `--tag` or `--digest` (mutually exclusive) — in this mode the command only downloads the ready-made SBOM and fails if it is not found. -[`werf sbom merge`]({{ "/reference/cli/werf_sbom_merge.html" | true_relative_url }}) assembles a product-level SBOM from several per-image SBOMs. It takes a JSON file that maps image names to sha256 digests, pulls the individual SBOMs from the registry, and merges them into a single CycloneDX document with dependency graphs preserved. Two ISPRAS output formats are available: `container` (hierarchical, each image becomes a top-level component with nested packages) and `oss` (flat, all packages deduplicated into one list). GOST `attack_surface` and `security_function` properties are aggregated bottom-up with the precedence `yes > indirect > no`. +[`werf sbom merge`]({{ "/reference/cli/werf_sbom_merge.html" | true_relative_url }}) assembles a product-level SBOM from several per-image SBOMs. It takes a JSON file that maps image names to sha256 digests, pulls the individual SBOMs from the registry, and merges them into a single CycloneDX document with dependency graphs preserved. Two ISPRAS output formats are available: `container` (hierarchical, each image becomes a top-level component with nested packages) and `oss` (flat, all packages deduplicated into one list). GOST properties are aggregated bottom-up: `attack_surface` with the precedence `yes > indirect > no`, `security_function` with `yes > no`. [`werf sbom validate`]({{ "/reference/cli/werf_sbom_validate.html" | true_relative_url }}) checks a CycloneDX JSON file against ISPRAS schemas. It runs sbom-checker inside a Docker container and reports any violations, split into errors and warnings, with both counts shown in the summary. By default any error or warning fails the validation; pass `--warnings-non-fatal` to keep warnings informational (printed on stderr) so that only errors set a non-zero exit code. Both `oss` and `container` SBOM types are supported. diff --git a/docs/pages_ru/usage/build/sbom.md b/docs/pages_ru/usage/build/sbom.md index a4e393ba53..7fff3c1e95 100644 --- a/docs/pages_ru/usage/build/sbom.md +++ b/docs/pages_ru/usage/build/sbom.md @@ -177,6 +177,6 @@ WERF_EXTERNAL_REFS_SERVER_URL env var is required [`werf sbom get`]({{ "/reference/cli/werf_sbom_get.html" | true_relative_url }}) получает SBOM образа, описанного в `werf.yaml`, и выводит его в stdout. SBOM читается как OCI-артефакт из container registry, поэтому флаг `--repo` обязателен. При обращении по имени образа команда запускает стандартный сборочный конвейер werf: отсутствующие стадии и SBOM-артефакты досоздаются, как при `werf build` (с флагом `--require-built-images` команда вместо этого завершается ошибкой). Для выбора конкретной версии поддерживаются флаги `--tag` и `--digest` (взаимоисключающие) — в этом режиме команда только выгружает готовый SBOM и завершается ошибкой, если он не найден. -[`werf sbom merge`]({{ "/reference/cli/werf_sbom_merge.html" | true_relative_url }}) собирает SBOM уровня продукта из нескольких пообразных SBOM. На вход принимается JSON-файл с соответствием «имя образа → sha256-дайджест»; команда скачивает отдельные SBOM из registry и объединяет их в один CycloneDX-документ с сохранением графов зависимостей. Поддерживаются два выходных формата ИСПРАС: `container` (иерархический, каждый образ становится компонентом верхнего уровня с вложенными пакетами) и `oss` (плоский, все пакеты дедуплицируются в один список). Свойства ГОСТ `attack_surface` и `security_function` агрегируются снизу вверх по приоритету `yes > indirect > no`. +[`werf sbom merge`]({{ "/reference/cli/werf_sbom_merge.html" | true_relative_url }}) собирает SBOM уровня продукта из нескольких пообразных SBOM. На вход принимается JSON-файл с соответствием «имя образа → sha256-дайджест»; команда скачивает отдельные SBOM из registry и объединяет их в один CycloneDX-документ с сохранением графов зависимостей. Поддерживаются два выходных формата ИСПРАС: `container` (иерархический, каждый образ становится компонентом верхнего уровня с вложенными пакетами) и `oss` (плоский, все пакеты дедуплицируются в один список). Свойства ГОСТ агрегируются снизу вверх: `attack_surface` по приоритету `yes > indirect > no`, `security_function` — `yes > no`. [`werf sbom validate`]({{ "/reference/cli/werf_sbom_validate.html" | true_relative_url }}) проверяет CycloneDX JSON-файл по схемам ИСПРАС. Команда запускает sbom-checker в Docker-контейнере и выводит обнаруженные нарушения, разделяя их на ошибки и предупреждения, а в сводке показывает оба счётчика. По умолчанию валидацию проваливают и ошибки, и предупреждения; передайте `--warnings-non-fatal`, чтобы предупреждения оставались информационными (выводятся в stderr) и на код возврата влияли только ошибки. Поддерживаются типы SBOM `oss` и `container`. diff --git a/pkg/sbom/ispras/gost_test.go b/pkg/sbom/ispras/gost_test.go index f66ee207cd..0dac38677f 100644 --- a/pkg/sbom/ispras/gost_test.go +++ b/pkg/sbom/ispras/gost_test.go @@ -25,10 +25,10 @@ var _ = Describe("aggregateGOST", func() { Entry("empty", nil, GOSTValues{}), Entry("flat list", []cdx.Component{ - withGOST("a", gost.GostValueNo, gost.GostValueIndirect), + withGOST("a", gost.GostValueNo, gost.GostValueYes), withGOST("b", gost.GostValueIndirect, gost.GostValueNo), }, - GOSTValues{AttackSurface: gost.GostValueIndirect, SecurityFunction: gost.GostValueIndirect}), + GOSTValues{AttackSurface: gost.GostValueIndirect, SecurityFunction: gost.GostValueYes}), Entry("higher value only in a grandchild", []cdx.Component{ withGOST("parent", gost.GostValueNo, gost.GostValueNo, @@ -41,9 +41,9 @@ var _ = Describe("aggregateGOST", func() { Entry("fields are aggregated independently", []cdx.Component{ withGOST("parent", gost.GostValueYes, gost.GostValueNo, - withGOST("child", gost.GostValueNo, gost.GostValueIndirect), + withGOST("child", gost.GostValueNo, gost.GostValueYes), ), }, - GOSTValues{AttackSurface: gost.GostValueYes, SecurityFunction: gost.GostValueIndirect}), + GOSTValues{AttackSurface: gost.GostValueYes, SecurityFunction: gost.GostValueYes}), ) }) From 1e17936ba4b05b1bb408663760ec6820bc34d760 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Mon, 28 Sep 2026 00:09:08 +0300 Subject: [PATCH 08/17] fix(sbom): regenerate cached SBOMs so every image gets the split attack 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 --- pkg/build/sbom_step.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index 44954e5e37..b41d2c9b22 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_step.go @@ -279,7 +279,7 @@ func (step *sbomStep) scanCatalogerDir(ctx context.Context, scanOpts scanner.Sca return bom, nil } -const sbomArtifactFormatVersion = "5" +const sbomArtifactFormatVersion = "6" // calculateStableChecksum computes the SBOM artifact cache checksum. Together with the // parent stage digest it forms the cache key: a previously attached SBOM is reused only @@ -388,7 +388,7 @@ func (step *sbomStep) prepareGostComponents(ctx context.Context, mergeOpts *cycl }) } - // Skip GOST validation and upsert for base/import BOMs when GOST is not configured. + // Skip GOST validation for base/import BOMs when GOST is not configured. // Without this guard, components from patchers (e.g. PM BOMPatcher) that lack GOST // properties would fail validation even though GOST is not in use. if mergeOpts.Gost.AttackSurface.IsUndefined() && mergeOpts.Gost.SecurityFunction.IsUndefined() { @@ -399,18 +399,12 @@ func (step *sbomStep) prepareGostComponents(ctx context.Context, mergeOpts *cycl if err := gost.Validate(mergeOpts.BaseBOM); err != nil { return fmt.Errorf("base SBOM validation failed: %w", err) } - if err := gost.Upsert(mergeOpts.BaseBOM, mergeOpts.Gost); err != nil { - return fmt.Errorf("set GOST properties for base SBOM: %w", err) - } } for i, externalBOM := range mergeOpts.ImportBOMs { if err := gost.Validate(externalBOM); err != nil { return fmt.Errorf("external SBOM [%d] validation failed: %w", i, err) } - if err := gost.Upsert(externalBOM, mergeOpts.Gost); err != nil { - return fmt.Errorf("set GOST properties for external SBOM [%d]: %w", i, err) - } } return nil From 3c0d94d28fc595099213604d108f949d346c52cd Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Mon, 28 Sep 2026 00:09:08 +0300 Subject: [PATCH 09/17] fix(sbom): keep packages attackable when a service lists them as dependencies 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 --- pkg/sbom/cyclonedxutil/gost/upsert.go | 31 +++++++++++++--------- pkg/sbom/cyclonedxutil/gost/upsert_test.go | 24 +++++++++++++++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/pkg/sbom/cyclonedxutil/gost/upsert.go b/pkg/sbom/cyclonedxutil/gost/upsert.go index e68d846ff9..d107aaa42e 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert.go @@ -61,27 +61,25 @@ func setComponents(components []cdx.Component, rootConfig, dependencyConfig Conf // other), and counting in-edges alone would leave such a cycle with no root // even when nothing outside of it depends on it. // -// Edges sourced at the scanned image's own metadata component are skipped: a -// cataloger that lists every package under the image root describes what the -// image contains, not what one package pulls in, and honoring those edges would -// leave the tree without a single root. +// Only edges sourced at a component count. An edge from the scanned image's +// own metadata component describes what the image contains, not what one +// package pulls in, and honoring it would leave the tree without a single root; +// an edge from a service describes what the service uses, and a package is not +// pulled in by the service that calls it. func dependencyTargets(bom *cdx.BOM) map[string]struct{} { - var rootRef string + componentRefs := make(map[string]struct{}) + collectComponentRefs(lo.FromPtr(bom.Components), componentRefs) if bom.Metadata != nil && bom.Metadata.Component != nil { - rootRef = bom.Metadata.Component.BOMRef + collectComponentRefs(lo.FromPtr(bom.Metadata.Component.Components), componentRefs) } edges := make(map[string][]string) for _, dep := range lo.FromPtr(bom.Dependencies) { - if rootRef != "" && dep.Ref == rootRef { + if _, ok := componentRefs[dep.Ref]; !ok { continue } - for _, ref := range lo.FromPtr(dep.Dependencies) { - if ref != dep.Ref { - edges[dep.Ref] = append(edges[dep.Ref], ref) - } - } + edges[dep.Ref] = append(edges[dep.Ref], lo.FromPtr(dep.Dependencies)...) } componentOf := stronglyConnectedComponents(edges) @@ -105,6 +103,15 @@ func dependencyTargets(bom *cdx.BOM) map[string]struct{} { return targets } +func collectComponentRefs(components []cdx.Component, refs map[string]struct{}) { + for i := range components { + if components[i].BOMRef != "" { + refs[components[i].BOMRef] = struct{}{} + } + collectComponentRefs(lo.FromPtr(components[i].Components), refs) + } +} + // stronglyConnectedComponents runs Tarjan's algorithm over the adjacency list // and labels every node with the index of its component. func stronglyConnectedComponents(edges map[string][]string) map[string]int { diff --git a/pkg/sbom/cyclonedxutil/gost/upsert_test.go b/pkg/sbom/cyclonedxutil/gost/upsert_test.go index 72c02959b0..05940d42f7 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert_test.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert_test.go @@ -196,6 +196,16 @@ var _ = Describe("Gost SBOM setter", func() { }, Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, map[string]GostValue{"curl": GostValueYes, "jq": GostValueYes}), + Entry("an edge sourced at a service does not demote anything", + &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "curl"}, {BOMRef: "jq"}}, + Services: &[]cdx.Service{{BOMRef: "api"}}, + Dependencies: &[]cdx.Dependency{ + {Ref: "api", Dependencies: &[]string{"curl", "jq"}}, + }, + }, + Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, + map[string]GostValue{"curl": GostValueYes, "jq": GostValueYes}), Entry("a provides edge does not demote what it points at", &cdx.BOM{ Components: &[]cdx.Component{{BOMRef: "openssl"}, {BOMRef: "libssl"}}, @@ -312,4 +322,18 @@ var _ = Describe("Gost SBOM setter", func() { Expect(GetComponent(&nested[0]).AttackSurface).To(Equal(GostValueIndirect)) Expect(GetComponent(&nested[1]).AttackSurface).To(Equal(GostValueYes)) }) + + It("honors an edge sourced at a nested component", func() { + bom := &cdx.BOM{ + Components: &[]cdx.Component{ + {BOMRef: "parent", Components: &[]cdx.Component{{BOMRef: "nested"}}}, + {BOMRef: "openssl"}, + }, + Dependencies: &[]cdx.Dependency{{Ref: "nested", Dependencies: &[]string{"openssl"}}}, + } + + Expect(Upsert(bom, Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes})).To(Succeed()) + + Expect(GetComponent(&(*bom.Components)[1]).AttackSurface).To(Equal(GostValueIndirect)) + }) }) From 111d74279e195e79e7696414f0176900e4e8f956 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Tue, 29 Sep 2026 15:22:15 +0300 Subject: [PATCH 10/17] fix(sbom): stop marking the wrong package attackable when merged SBOMs 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 --- pkg/sbom/cyclonedxutil/merge.go | 4 + pkg/sbom/cyclonedxutil/merge_test.go | 77 ++++++++-- .../bomref.go => cyclonedxutil/namespace.go} | 6 +- pkg/sbom/cyclonedxutil/namespace_test.go | 112 +++++++++++++++ pkg/sbom/ispras/assembler_test.go | 133 ++++++------------ pkg/sbom/merge/merge.go | 2 +- 6 files changed, 221 insertions(+), 113 deletions(-) rename pkg/sbom/{ispras/bomref.go => cyclonedxutil/namespace.go} (96%) create mode 100644 pkg/sbom/cyclonedxutil/namespace_test.go diff --git a/pkg/sbom/cyclonedxutil/merge.go b/pkg/sbom/cyclonedxutil/merge.go index d5f4f2d44e..6cf785b98e 100644 --- a/pkg/sbom/cyclonedxutil/merge.go +++ b/pkg/sbom/cyclonedxutil/merge.go @@ -92,6 +92,10 @@ func MergeBOMs(target *cdx.BOM, opts MergeOpts) (*cdx.BOM, error) { linkSelfReferences(boms[i]) + if !opts.PreserveBOMRefs && boms[i] != nil && i < len(boms)-1 { + NamespaceBOMRefs(boms[i], fmt.Sprintf("merge-input-%d", i)) + } + if opts.IsolateComponents { Canonicalize(boms[i]) } diff --git a/pkg/sbom/cyclonedxutil/merge_test.go b/pkg/sbom/cyclonedxutil/merge_test.go index 5bbc724cef..132f9b5148 100644 --- a/pkg/sbom/cyclonedxutil/merge_test.go +++ b/pkg/sbom/cyclonedxutil/merge_test.go @@ -231,6 +231,7 @@ var _ = Describe("MergeBOMs", func() { Entry("concatenates in merge order (base → imports → target)", &cdx.BOM{ SpecVersion: cdx.SpecVersion1_6, + Components: &[]cdx.Component{{BOMRef: "target-ref", Name: "target"}}, Dependencies: &[]cdx.Dependency{ {Ref: "target-ref", Dependencies: &[]string{"dep-e"}}, }, @@ -238,22 +239,20 @@ var _ = Describe("MergeBOMs", func() { MergeOpts{ BaseBOM: &cdx.BOM{ SpecVersion: cdx.SpecVersion1_6, + Components: &[]cdx.Component{{BOMRef: "base-ref-1", Name: "base-1"}, {BOMRef: "base-ref-2", Name: "base-2"}}, Dependencies: &[]cdx.Dependency{ {Ref: "base-ref-1", Dependencies: &[]string{"dep-a"}}, {Ref: "base-ref-2"}, }, }, ImportBOMs: []*cdx.BOM{ - {SpecVersion: cdx.SpecVersion1_6, Dependencies: &[]cdx.Dependency{{Ref: "import1-ref"}}}, - {SpecVersion: cdx.SpecVersion1_6, Dependencies: &[]cdx.Dependency{{Ref: "import2-ref"}}}, + {SpecVersion: cdx.SpecVersion1_6, Components: &[]cdx.Component{{BOMRef: "import1-ref", Name: "import-1"}}, Dependencies: &[]cdx.Dependency{{Ref: "import1-ref"}}}, + {SpecVersion: cdx.SpecVersion1_6, Components: &[]cdx.Component{{BOMRef: "import2-ref", Name: "import-2"}}, Dependencies: &[]cdx.Dependency{{Ref: "import2-ref"}}}, }, }, func(result *cdx.BOM) { - Expect(dependencyRefs(result)).To(Equal([]string{ - "base-ref-1", "base-ref-2", - "import1-ref", "import2-ref", - "target-ref", - })) + Expect(dependencyRefs(result)).To(Equal(lo.Map(*result.Components, func(comp cdx.Component, _ int) string { return comp.BOMRef }))) + Expect(componentNames(result)).To(Equal([]string{"base-1", "base-2", "import-1", "import-2", "target"})) }, ), @@ -266,22 +265,35 @@ var _ = Describe("MergeBOMs", func() { ), Entry("deduplicates identical dependencies", - &cdx.BOM{SpecVersion: cdx.SpecVersion1_6, Dependencies: &[]cdx.Dependency{{Ref: "dup", Dependencies: &[]string{"dep-a"}}}}, - MergeOpts{BaseBOM: &cdx.BOM{SpecVersion: cdx.SpecVersion1_6, Dependencies: &[]cdx.Dependency{{Ref: "dup", Dependencies: &[]string{"dep-a"}}}}}, + &cdx.BOM{ + SpecVersion: cdx.SpecVersion1_6, + Components: &[]cdx.Component{{BOMRef: "dup", Name: "dup", PackageURL: "pkg:generic/dup@1"}}, + Dependencies: &[]cdx.Dependency{{Ref: "dup", Dependencies: &[]string{"dep-a"}}}, + }, + MergeOpts{BaseBOM: &cdx.BOM{ + SpecVersion: cdx.SpecVersion1_6, + Components: &[]cdx.Component{{BOMRef: "dup", Name: "dup", PackageURL: "pkg:generic/dup@1"}}, + Dependencies: &[]cdx.Dependency{{Ref: "dup", Dependencies: &[]string{"dep-a"}}}, + }}, func(result *cdx.BOM) { + Expect(*result.Components).To(HaveLen(1)) Expect(result.Dependencies).ToNot(BeNil()) Expect(*result.Dependencies).To(HaveLen(1)) - Expect((*result.Dependencies)[0].Ref).To(Equal("dup")) + Expect((*result.Dependencies)[0].Ref).To(Equal((*result.Components)[0].BOMRef)) }, ), Entry("handles nil target", nil, - MergeOpts{BaseBOM: &cdx.BOM{SpecVersion: cdx.SpecVersion1_6, Dependencies: &[]cdx.Dependency{{Ref: "base-ref", Dependencies: &[]string{"dep-a"}}}}}, + MergeOpts{BaseBOM: &cdx.BOM{ + SpecVersion: cdx.SpecVersion1_6, + Components: &[]cdx.Component{{BOMRef: "base-ref", Name: "base"}}, + Dependencies: &[]cdx.Dependency{{Ref: "base-ref", Dependencies: &[]string{"dep-a"}}}, + }}, func(result *cdx.BOM) { Expect(result.Dependencies).ToNot(BeNil()) Expect(*result.Dependencies).To(HaveLen(1)) - Expect((*result.Dependencies)[0].Ref).To(Equal("base-ref")) + Expect((*result.Dependencies)[0].Ref).To(Equal((*result.Components)[0].BOMRef)) }, ), @@ -289,15 +301,17 @@ var _ = Describe("MergeBOMs", func() { &cdx.BOM{SpecVersion: cdx.SpecVersion1_6}, MergeOpts{BaseBOM: &cdx.BOM{ SpecVersion: cdx.SpecVersion1_6, + Components: &[]cdx.Component{{BOMRef: "ref-1", Name: "one"}, {BOMRef: "dep-a", Name: "a"}, {BOMRef: "prov-a", Name: "p"}}, Dependencies: &[]cdx.Dependency{{Ref: "ref-1", Dependencies: &[]string{"dep-a"}, Provides: &[]string{"prov-a"}}}, }}, func(result *cdx.BOM) { Expect(result.Dependencies).ToNot(BeNil()) Expect(*result.Dependencies).To(HaveLen(1)) + refs := lo.Map(*result.Components, func(comp cdx.Component, _ int) string { return comp.BOMRef }) dep := (*result.Dependencies)[0] - Expect(dep.Ref).To(Equal("ref-1")) - Expect(*dep.Dependencies).To(Equal([]string{"dep-a"})) - Expect(*dep.Provides).To(Equal([]string{"prov-a"})) + Expect(dep.Ref).To(Equal(refs[0])) + Expect(*dep.Dependencies).To(Equal([]string{refs[1]})) + Expect(*dep.Provides).To(Equal([]string{refs[2]})) }, ), ) @@ -835,3 +849,36 @@ var _ = Describe("MergeBOMs input isolation", func() { Expect(err).To(MatchError(ContainSubstring("clone BOM for merge"))) }) }) + +var _ = Describe("MergeBOMs ref collisions", func() { + It("keeps the graphs of inputs apart when they reuse one ref for different packages", func() { + baseBOM := &cdx.BOM{ + SpecVersion: cdx.SpecVersion1_6, + Components: &[]cdx.Component{ + {BOMRef: "app", Type: cdx.ComponentTypeApplication, Name: "app", PackageURL: "pkg:generic/app@1"}, + {BOMRef: "shared", Type: cdx.ComponentTypeLibrary, Name: "library", PackageURL: "pkg:generic/library@1"}, + }, + Dependencies: &[]cdx.Dependency{{Ref: "app", Dependencies: &[]string{"shared"}}}, + } + importBOM := &cdx.BOM{ + SpecVersion: cdx.SpecVersion1_6, + Components: &[]cdx.Component{ + {BOMRef: "shared", Type: cdx.ComponentTypeLibrary, Name: "unrelated", PackageURL: "pkg:generic/unrelated@1"}, + }, + } + + result, err := MergeBOMs(nil, MergeOpts{BaseBOM: baseBOM, ImportBOMs: []*cdx.BOM{importBOM}}) + Expect(err).NotTo(HaveOccurred()) + Expect(gost.Upsert(result, gost.DefaultConfig())).To(Succeed()) + + actual := map[string]gost.GostValue{} + for _, comp := range *result.Components { + actual[comp.Name] = gost.GetComponent(&comp).AttackSurface + } + Expect(actual).To(Equal(map[string]gost.GostValue{ + "app": gost.GostValueYes, + "library": gost.GostValueIndirect, + "unrelated": gost.GostValueYes, + })) + }) +}) diff --git a/pkg/sbom/ispras/bomref.go b/pkg/sbom/cyclonedxutil/namespace.go similarity index 96% rename from pkg/sbom/ispras/bomref.go rename to pkg/sbom/cyclonedxutil/namespace.go index 86a4bc6f53..23ce13f89f 100644 --- a/pkg/sbom/ispras/bomref.go +++ b/pkg/sbom/cyclonedxutil/namespace.go @@ -1,4 +1,4 @@ -package ispras +package cyclonedxutil import ( "fmt" @@ -6,8 +6,6 @@ import ( cdx "github.com/CycloneDX/cyclonedx-go" "github.com/samber/lo" - - "github.com/werf/werf/v3/pkg/sbom/cyclonedxutil" ) // NamespaceBOMRefs prefixes every BOM ref declared by bom — the metadata @@ -61,7 +59,7 @@ func NamespaceBOMRefs(bom *cdx.BOM, prefix string) { } } - cyclonedxutil.RewriteRefs(bom, refMap) + RewriteRefs(bom, refMap) } func namespaceServiceBOMRefs(services []cdx.Service, prefix string, refMap map[string]string) { diff --git a/pkg/sbom/cyclonedxutil/namespace_test.go b/pkg/sbom/cyclonedxutil/namespace_test.go new file mode 100644 index 0000000000..1e5e8c9e06 --- /dev/null +++ b/pkg/sbom/cyclonedxutil/namespace_test.go @@ -0,0 +1,112 @@ +package cyclonedxutil + +import ( + cdx "github.com/CycloneDX/cyclonedx-go" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/samber/lo" +) + +var _ = Describe("NamespaceBOMRefs", func() { + It("namespaces every declared ref and every reference to it", func() { + bom := &cdx.BOM{ + SpecVersion: cdx.SpecVersion1_6, + Metadata: &cdx.Metadata{Component: &cdx.Component{BOMRef: "root", Type: cdx.ComponentTypeContainer, Name: "img"}}, + Components: &[]cdx.Component{ + {BOMRef: "os", Type: cdx.ComponentTypeOS, Name: "alpine"}, + {BOMRef: "lib", Type: cdx.ComponentTypeLibrary, Name: "lib"}, + }, + Dependencies: &[]cdx.Dependency{ + {Ref: "root", Dependencies: &[]string{"os"}}, + {Ref: "os", Dependencies: &[]string{"lib"}, Provides: &[]string{"lib"}}, + }, + Vulnerabilities: &[]cdx.Vulnerability{{ID: "CVE-1", Affects: &[]cdx.Affects{{Ref: "lib"}}}}, + } + NamespaceBOMRefs(bom, "img") + + Expect(bom.Metadata.Component.BOMRef).To(Equal("img/root")) + Expect(*bom.Dependencies).To(Equal([]cdx.Dependency{ + {Ref: "img/root", Dependencies: &[]string{"img/os"}}, + {Ref: "img/os", Dependencies: &[]string{"img/lib"}, Provides: &[]string{"img/lib"}}, + })) + Expect((*(*bom.Vulnerabilities)[0].Affects)[0].Ref).To(Equal("img/lib")) + }) + + It("namespaces service declarations and the references to them, nested services included", func() { + bom := &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "os", Type: cdx.ComponentTypeOS, Name: "alpine"}}, + Services: &[]cdx.Service{{BOMRef: "svc", Name: "api", Services: &[]cdx.Service{{BOMRef: "inner", Name: "sub"}}}}, + Dependencies: &[]cdx.Dependency{{Ref: "os", Dependencies: &[]string{"svc", "inner"}}, {Ref: "svc", Dependencies: &[]string{"os"}}}, + } + + NamespaceBOMRefs(bom, "img") + + Expect((*bom.Services)[0].BOMRef).To(Equal("img/svc")) + Expect((*(*bom.Services)[0].Services)[0].BOMRef).To(Equal("img/inner")) + Expect(*bom.Dependencies).To(Equal([]cdx.Dependency{ + {Ref: "img/os", Dependencies: &[]string{"img/svc", "img/inner"}}, + {Ref: "img/svc", Dependencies: &[]string{"img/os"}}, + })) + }) + + It("namespaces the refs declared by tools, formulation and vulnerabilities", func() { + bom := &cdx.BOM{ + Metadata: &cdx.Metadata{Tools: &cdx.ToolsChoice{ + Components: &[]cdx.Component{{BOMRef: "syft", Type: cdx.ComponentTypeApplication, Name: "syft"}}, + Services: &[]cdx.Service{{BOMRef: "scan", Name: "scan"}}, + }}, + Components: &[]cdx.Component{{BOMRef: "os", Type: cdx.ComponentTypeOS, Name: "alpine"}}, + Formulation: &[]cdx.Formula{{ + BOMRef: "formula", + Components: &[]cdx.Component{{BOMRef: "build-input", Type: cdx.ComponentTypeLibrary, Name: "in"}}, + Services: &[]cdx.Service{{BOMRef: "build-svc", Name: "ci"}}, + }}, + Vulnerabilities: &[]cdx.Vulnerability{{BOMRef: "vuln", ID: "CVE-1", Affects: &[]cdx.Affects{{Ref: "os"}}}}, + Compositions: &[]cdx.Composition{{Aggregate: cdx.CompositionAggregateComplete, Vulnerabilities: &[]cdx.BOMReference{"vuln"}}}, + Annotations: &[]cdx.Annotation{{BOMRef: "note", Subjects: &[]cdx.BOMReference{"syft", "formula"}, Text: "x"}}, + } + + NamespaceBOMRefs(bom, "img") + + Expect((*bom.Metadata.Tools.Components)[0].BOMRef).To(Equal("img/syft")) + Expect((*bom.Metadata.Tools.Services)[0].BOMRef).To(Equal("img/scan")) + Expect((*bom.Formulation)[0].BOMRef).To(Equal("img/formula")) + Expect((*(*bom.Formulation)[0].Components)[0].BOMRef).To(Equal("img/build-input")) + Expect((*(*bom.Formulation)[0].Services)[0].BOMRef).To(Equal("img/build-svc")) + Expect((*bom.Vulnerabilities)[0].BOMRef).To(Equal("img/vuln")) + Expect(*(*bom.Compositions)[0].Vulnerabilities).To(Equal([]cdx.BOMReference{"img/vuln"})) + Expect(*(*bom.Annotations)[0].Subjects).To(Equal([]cdx.BOMReference{"img/syft", "img/formula"})) + }) + + It("namespaces the refs of compositions and annotations and leaves BOM-Links alone", func() { + bom := &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "os", Type: cdx.ComponentTypeOS, Name: "alpine"}}, + Dependencies: &[]cdx.Dependency{{Ref: "os", Dependencies: &[]string{"urn:cdx:11111111-1111-1111-1111-111111111111/1#lib"}}}, + Compositions: &[]cdx.Composition{{BOMRef: "comp", Aggregate: cdx.CompositionAggregateComplete, Assemblies: &[]cdx.BOMReference{"os"}}}, + Annotations: &[]cdx.Annotation{{BOMRef: "note", Subjects: &[]cdx.BOMReference{"os", "urn:cdx:11111111-1111-1111-1111-111111111111/1#lib"}, Text: "x"}}, + } + + NamespaceBOMRefs(bom, "img") + + Expect((*bom.Compositions)[0].BOMRef).To(Equal("img/comp")) + Expect((*bom.Annotations)[0].BOMRef).To(Equal("img/note")) + Expect(*(*bom.Dependencies)[0].Dependencies).To(Equal([]string{"urn:cdx:11111111-1111-1111-1111-111111111111/1#lib"})) + Expect(*(*bom.Annotations)[0].Subjects).To(Equal([]cdx.BOMReference{"img/os", "urn:cdx:11111111-1111-1111-1111-111111111111/1#lib"})) + }) + + It("renames every ref at once when one new ref equals another old one", func() { + bom := &cdx.BOM{ + Components: &[]cdx.Component{ + {BOMRef: "lib", Type: cdx.ComponentTypeLibrary, Name: "lib", Version: "1"}, + {BOMRef: "img/lib", Type: cdx.ComponentTypeLibrary, Name: "other", Version: "2"}, + }, + Vulnerabilities: &[]cdx.Vulnerability{{ID: "CVE-1", Affects: &[]cdx.Affects{{Ref: "lib"}}}}, + } + + NamespaceBOMRefs(bom, "img") + + Expect(lo.Map(*bom.Components, func(c cdx.Component, _ int) string { return c.BOMRef })). + To(Equal([]string{"img/lib", "img/img/lib"})) + Expect((*(*bom.Vulnerabilities)[0].Affects)[0].Ref).To(Equal("img/lib")) + }) +}) diff --git a/pkg/sbom/ispras/assembler_test.go b/pkg/sbom/ispras/assembler_test.go index d061f42fec..8133e55a16 100644 --- a/pkg/sbom/ispras/assembler_test.go +++ b/pkg/sbom/ispras/assembler_test.go @@ -9,12 +9,13 @@ import ( . "github.com/onsi/gomega" "github.com/samber/lo" + "github.com/werf/werf/v3/pkg/sbom/cyclonedxutil" "github.com/werf/werf/v3/pkg/sbom/cyclonedxutil/gost" ) func imageBOM(imageName string) *cdx.BOM { bom := rawImageBOM(imageName) - NamespaceBOMRefs(bom, imageName) + cyclonedxutil.NamespaceBOMRefs(bom, imageName) return bom } @@ -122,8 +123,8 @@ var _ = Describe("ContainerAssembler", func() { bomB.Services = &[]cdx.Service{{BOMRef: "svc", Name: "api"}} *bomA.Dependencies = append(*bomA.Dependencies, cdx.Dependency{Ref: "os", Dependencies: &[]string{"svc"}}) *bomB.Dependencies = append(*bomB.Dependencies, cdx.Dependency{Ref: "lib", Dependencies: &[]string{"svc"}}) - NamespaceBOMRefs(bomA, "a") - NamespaceBOMRefs(bomB, "b") + cyclonedxutil.NamespaceBOMRefs(bomA, "a") + cyclonedxutil.NamespaceBOMRefs(bomB, "b") images := []*ImageSBOM{NewImageSBOM("a", bomA), NewImageSBOM("b", bomB)} @@ -144,7 +145,7 @@ var _ = Describe("ContainerAssembler", func() { {BOMRef: "nested", Type: cdx.ComponentTypeLibrary, Name: "nested", Version: "1"}, } *bom.Dependencies = append(*bom.Dependencies, cdx.Dependency{Ref: "lib", Dependencies: &[]string{"nested"}}) - NamespaceBOMRefs(bom, "a") + cyclonedxutil.NamespaceBOMRefs(bom, "a") result, err := (&ContainerAssembler{}).Assemble(context.Background(), []*ImageSBOM{NewImageSBOM("a", bom)}, ProductMeta{AppName: "app", AppVersion: "1"}) Expect(err).NotTo(HaveOccurred()) @@ -159,8 +160,8 @@ var _ = Describe("ContainerAssembler", func() { bomB.Services = &[]cdx.Service{{BOMRef: "svc", Name: "db"}} *bomA.Dependencies = append(*bomA.Dependencies, cdx.Dependency{Ref: "os", Dependencies: &[]string{"svc"}}) *bomB.Dependencies = append(*bomB.Dependencies, cdx.Dependency{Ref: "os", Dependencies: &[]string{"svc"}}) - NamespaceBOMRefs(bomA, "a") - NamespaceBOMRefs(bomB, "b") + cyclonedxutil.NamespaceBOMRefs(bomA, "a") + cyclonedxutil.NamespaceBOMRefs(bomB, "b") images := []*ImageSBOM{NewImageSBOM("a", bomA), NewImageSBOM("b", bomB)} @@ -233,7 +234,7 @@ var _ = Describe("ContainerAssembler", func() { {BOMRef: "a1", Subjects: &[]cdx.BOMReference{"formula"}, Text: "about the formula"}, {BOMRef: "a2", Subjects: &[]cdx.BOMReference{"composition"}, Text: "about the composition"}, } - NamespaceBOMRefs(bom, "a") + cyclonedxutil.NamespaceBOMRefs(bom, "a") result, err := (&ContainerAssembler{}).Assemble(context.Background(), []*ImageSBOM{NewImageSBOM("a", bom)}, ProductMeta{}) Expect(err).NotTo(HaveOccurred()) @@ -248,7 +249,7 @@ var _ = Describe("ContainerAssembler", func() { bom := rawImageBOM("a") bom.SerialNumber = "urn:uuid:11111111-1111-1111-1111-111111111111" bom.Annotations = &[]cdx.Annotation{{BOMRef: "a1", Subjects: &[]cdx.BOMReference{cdx.BOMReference(bom.SerialNumber)}, Text: "about the document"}} - NamespaceBOMRefs(bom, "a") + cyclonedxutil.NamespaceBOMRefs(bom, "a") result, err := (&ContainerAssembler{}).Assemble(context.Background(), []*ImageSBOM{NewImageSBOM("a", bom)}, ProductMeta{}) Expect(err).NotTo(HaveOccurred()) @@ -263,7 +264,7 @@ var _ = Describe("ContainerAssembler", func() { nested := cdx.Component{BOMRef: "nested", Type: cdx.ComponentTypeLibrary, Name: "nested", Version: "1.0"} gost.SetComponent(&nested, gost.Config{AttackSurface: gost.GostValueYes, SecurityFunction: gost.GostValueYes}) bom.Metadata.Component.Components = &[]cdx.Component{nested} - NamespaceBOMRefs(bom, "a") + cyclonedxutil.NamespaceBOMRefs(bom, "a") result, err := (&ContainerAssembler{}).Assemble(context.Background(), []*ImageSBOM{NewImageSBOM("a", bom)}, ProductMeta{}) Expect(err).NotTo(HaveOccurred()) @@ -357,7 +358,7 @@ var _ = Describe("ContainerAssembler", func() { Components: &components, Dependencies: &dependencies, } - NamespaceBOMRefs(bom, name) + cyclonedxutil.NamespaceBOMRefs(bom, name) return NewImageSBOM(name, bom) } @@ -412,93 +413,39 @@ var _ = Describe("OSSAssembler", func() { }) }) -var _ = Describe("NamespaceBOMRefs", func() { - It("namespaces every declared ref and every reference to it", func() { - bom := imageBOM("img") - - Expect(bom.Metadata.Component.BOMRef).To(Equal("img/root")) - Expect(*bom.Dependencies).To(Equal([]cdx.Dependency{ - {Ref: "img/root", Dependencies: &[]string{"img/os"}}, - {Ref: "img/os", Dependencies: &[]string{"img/lib"}, Provides: &[]string{"img/lib"}}, - })) - Expect((*(*bom.Vulnerabilities)[0].Affects)[0].Ref).To(Equal("img/lib")) - }) - - It("namespaces service declarations and the references to them, nested services included", func() { - bom := &cdx.BOM{ - Components: &[]cdx.Component{{BOMRef: "os", Type: cdx.ComponentTypeOS, Name: "alpine"}}, - Services: &[]cdx.Service{{BOMRef: "svc", Name: "api", Services: &[]cdx.Service{{BOMRef: "inner", Name: "sub"}}}}, - Dependencies: &[]cdx.Dependency{{Ref: "os", Dependencies: &[]string{"svc", "inner"}}, {Ref: "svc", Dependencies: &[]string{"os"}}}, +var _ = Describe("Assemble GOST input validation", func() { + legacyImage := func(name, securityFunction string, nested bool) *ImageSBOM { + bom := rawImageBOM(name) + lib := &(*bom.Components)[1] + props := []cdx.Property{ + {Name: gost.PropertyAttackSurface, Value: "yes"}, + {Name: gost.PropertySecurityFunction, Value: securityFunction}, } - - NamespaceBOMRefs(bom, "img") - - Expect((*bom.Services)[0].BOMRef).To(Equal("img/svc")) - Expect((*(*bom.Services)[0].Services)[0].BOMRef).To(Equal("img/inner")) - Expect(*bom.Dependencies).To(Equal([]cdx.Dependency{ - {Ref: "img/os", Dependencies: &[]string{"img/svc", "img/inner"}}, - {Ref: "img/svc", Dependencies: &[]string{"img/os"}}, - })) - }) - - It("namespaces the refs declared by tools, formulation and vulnerabilities", func() { - bom := &cdx.BOM{ - Metadata: &cdx.Metadata{Tools: &cdx.ToolsChoice{ - Components: &[]cdx.Component{{BOMRef: "syft", Type: cdx.ComponentTypeApplication, Name: "syft"}}, - Services: &[]cdx.Service{{BOMRef: "scan", Name: "scan"}}, - }}, - Components: &[]cdx.Component{{BOMRef: "os", Type: cdx.ComponentTypeOS, Name: "alpine"}}, - Formulation: &[]cdx.Formula{{ - BOMRef: "formula", - Components: &[]cdx.Component{{BOMRef: "build-input", Type: cdx.ComponentTypeLibrary, Name: "in"}}, - Services: &[]cdx.Service{{BOMRef: "build-svc", Name: "ci"}}, - }}, - Vulnerabilities: &[]cdx.Vulnerability{{BOMRef: "vuln", ID: "CVE-1", Affects: &[]cdx.Affects{{Ref: "os"}}}}, - Compositions: &[]cdx.Composition{{Aggregate: cdx.CompositionAggregateComplete, Vulnerabilities: &[]cdx.BOMReference{"vuln"}}}, - Annotations: &[]cdx.Annotation{{BOMRef: "note", Subjects: &[]cdx.BOMReference{"syft", "formula"}, Text: "x"}}, - } - - NamespaceBOMRefs(bom, "img") - - Expect((*bom.Metadata.Tools.Components)[0].BOMRef).To(Equal("img/syft")) - Expect((*bom.Metadata.Tools.Services)[0].BOMRef).To(Equal("img/scan")) - Expect((*bom.Formulation)[0].BOMRef).To(Equal("img/formula")) - Expect((*(*bom.Formulation)[0].Components)[0].BOMRef).To(Equal("img/build-input")) - Expect((*(*bom.Formulation)[0].Services)[0].BOMRef).To(Equal("img/build-svc")) - Expect((*bom.Vulnerabilities)[0].BOMRef).To(Equal("img/vuln")) - Expect(*(*bom.Compositions)[0].Vulnerabilities).To(Equal([]cdx.BOMReference{"img/vuln"})) - Expect(*(*bom.Annotations)[0].Subjects).To(Equal([]cdx.BOMReference{"img/syft", "img/formula"})) - }) - - It("namespaces the refs of compositions and annotations and leaves BOM-Links alone", func() { - bom := &cdx.BOM{ - Components: &[]cdx.Component{{BOMRef: "os", Type: cdx.ComponentTypeOS, Name: "alpine"}}, - Dependencies: &[]cdx.Dependency{{Ref: "os", Dependencies: &[]string{"urn:cdx:11111111-1111-1111-1111-111111111111/1#lib"}}}, - Compositions: &[]cdx.Composition{{BOMRef: "comp", Aggregate: cdx.CompositionAggregateComplete, Assemblies: &[]cdx.BOMReference{"os"}}}, - Annotations: &[]cdx.Annotation{{BOMRef: "note", Subjects: &[]cdx.BOMReference{"os", "urn:cdx:11111111-1111-1111-1111-111111111111/1#lib"}, Text: "x"}}, + if nested { + lib.Components = &[]cdx.Component{{BOMRef: "inner", Type: cdx.ComponentTypeLibrary, Name: "inner", Properties: &props}} + } else { + lib.Properties = &props } + cyclonedxutil.NamespaceBOMRefs(bom, name) - NamespaceBOMRefs(bom, "img") - - Expect((*bom.Compositions)[0].BOMRef).To(Equal("img/comp")) - Expect((*bom.Annotations)[0].BOMRef).To(Equal("img/note")) - Expect(*(*bom.Dependencies)[0].Dependencies).To(Equal([]string{"urn:cdx:11111111-1111-1111-1111-111111111111/1#lib"})) - Expect(*(*bom.Annotations)[0].Subjects).To(Equal([]cdx.BOMReference{"img/os", "urn:cdx:11111111-1111-1111-1111-111111111111/1#lib"})) - }) + return NewImageSBOM(name, bom) + } - It("renames every ref at once when one new ref equals another old one", func() { - bom := &cdx.BOM{ - Components: &[]cdx.Component{ - {BOMRef: "lib", Type: cdx.ComponentTypeLibrary, Name: "lib", Version: "1"}, - {BOMRef: "img/lib", Type: cdx.ComponentTypeLibrary, Name: "other", Version: "2"}, - }, - Vulnerabilities: &[]cdx.Vulnerability{{ID: "CVE-1", Affects: &[]cdx.Affects{{Ref: "lib"}}}}, - } + DescribeTable("rejects a legacy security function value and accepts the two-value domain", + func(assembler Assembler) { + for _, control := range []string{"yes", "no"} { + result, err := assembler.Assemble(context.Background(), []*ImageSBOM{legacyImage("a", control, false)}, ProductMeta{}) + Expect(err).NotTo(HaveOccurred(), control) + Expect(result).NotTo(BeNil(), control) + } - NamespaceBOMRefs(bom, "img") + _, err := assembler.Assemble(context.Background(), []*ImageSBOM{legacyImage("a", "indirect", false)}, ProductMeta{}) + Expect(err).To(MatchError(And(ContainSubstring(`image "a"`), ContainSubstring(`"lib"`), ContainSubstring("GOST:security_function"), ContainSubstring("indirect")))) - Expect(lo.Map(*bom.Components, func(c cdx.Component, _ int) string { return c.BOMRef })). - To(Equal([]string{"img/lib", "img/img/lib"})) - Expect((*(*bom.Vulnerabilities)[0].Affects)[0].Ref).To(Equal("img/lib")) - }) + _, err = assembler.Assemble(context.Background(), []*ImageSBOM{legacyImage("a", "indirect", true)}, ProductMeta{}) + Expect(err).To(MatchError(And(ContainSubstring(`"inner"`), ContainSubstring("indirect")))) + }, + Entry("container", &ContainerAssembler{}), + Entry("oss", &OSSAssembler{}), + ) }) diff --git a/pkg/sbom/merge/merge.go b/pkg/sbom/merge/merge.go index 5f219de7a6..855238deef 100644 --- a/pkg/sbom/merge/merge.go +++ b/pkg/sbom/merge/merge.go @@ -209,7 +209,7 @@ func PullAndParseImages(ctx context.Context, repo string, mapping map[string]str return fmt.Errorf("pull SBOM for %q: %w", imageName, err) } - ispras.NamespaceBOMRefs(bom, imageName) + cyclonedxutil.NamespaceBOMRefs(bom, imageName) images = append(images, ispras.NewImageSBOM(imageName, bom)) return nil }) From bd4f178c8497fcecba4d1f7f18e4adfb99832b3c Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Tue, 29 Sep 2026 15:22:15 +0300 Subject: [PATCH 11/17] fix(sbom): reject `security_function: indirect` in the images `sbom merge` 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 --- pkg/sbom/cyclonedxutil/gost/validator.go | 64 ++++++++++++++++++++---- pkg/sbom/ispras/assembler.go | 16 ++++++ pkg/sbom/ispras/container.go | 4 ++ pkg/sbom/ispras/oss.go | 4 ++ 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/pkg/sbom/cyclonedxutil/gost/validator.go b/pkg/sbom/cyclonedxutil/gost/validator.go index 54cf4bb801..e0ef19b47c 100644 --- a/pkg/sbom/cyclonedxutil/gost/validator.go +++ b/pkg/sbom/cyclonedxutil/gost/validator.go @@ -48,18 +48,15 @@ func ValidateComponent(comp *cdx.Component) error { a := newAccessor(comp) var missing []string - as, asOk := a.GetAttackSurface() - if !asOk { + if _, ok := a.GetAttackSurface(); !ok { missing = append(missing, PropertyAttackSurface) - } else if !IsValidAttackSurfaceValue(as.String()) { - return fmt.Errorf("invalid value for %s: %q (expected 'yes', 'no' or 'indirect')", PropertyAttackSurface, as) } - - sf, sfOk := a.GetSecurityFunction() - if !sfOk { + if _, ok := a.GetSecurityFunction(); !ok { missing = append(missing, PropertySecurityFunction) - } else if !IsValidSecurityFunctionValue(sf.String()) { - return fmt.Errorf("invalid value for %s: %q (expected 'yes' or 'no')", PropertySecurityFunction, sf) + } + + if err := ValidateComponentValues(comp); err != nil { + return err } if len(missing) > 0 { @@ -68,3 +65,52 @@ func ValidateComponent(comp *cdx.Component) error { return nil } + +// ValidateValues checks that every GOST property present on the metadata +// component or on any component, nested ones included, carries a valid value. +// A component without the properties passes: the caller fills them in later. +func ValidateValues(bom *cdx.BOM) error { + if bom == nil { + return fmt.Errorf("BOM is required") + } + + if bom.Metadata != nil && bom.Metadata.Component != nil { + if err := ValidateComponentValues(bom.Metadata.Component); err != nil { + return fmt.Errorf("metadata component %q: %w", bom.Metadata.Component.Name, err) + } + if err := validateComponentsValues(lo.FromPtr(bom.Metadata.Component.Components)); err != nil { + return err + } + } + + return validateComponentsValues(lo.FromPtr(bom.Components)) +} + +func validateComponentsValues(components []cdx.Component) error { + for i := range components { + if err := ValidateComponentValues(&components[i]); err != nil { + return fmt.Errorf("component %q: %w", components[i].Name, err) + } + if err := validateComponentsValues(lo.FromPtr(components[i].Components)); err != nil { + return err + } + } + + return nil +} + +// ValidateComponentValues checks the GOST properties a single component +// carries, ignoring the ones it lacks. +func ValidateComponentValues(comp *cdx.Component) error { + a := newAccessor(comp) + + if as, ok := a.GetAttackSurface(); ok && !IsValidAttackSurfaceValue(as.String()) { + return fmt.Errorf("invalid value for %s: %q (expected 'yes', 'no' or 'indirect')", PropertyAttackSurface, as) + } + + if sf, ok := a.GetSecurityFunction(); ok && !IsValidSecurityFunctionValue(sf.String()) { + return fmt.Errorf("invalid value for %s: %q (expected 'yes' or 'no')", PropertySecurityFunction, sf) + } + + return nil +} diff --git a/pkg/sbom/ispras/assembler.go b/pkg/sbom/ispras/assembler.go index 1f8fded01e..d2c76ab0c1 100644 --- a/pkg/sbom/ispras/assembler.go +++ b/pkg/sbom/ispras/assembler.go @@ -6,6 +6,8 @@ import ( "time" cdx "github.com/CycloneDX/cyclonedx-go" + + "github.com/werf/werf/v3/pkg/sbom/cyclonedxutil/gost" ) type Assembler interface { @@ -46,3 +48,17 @@ func imageBOMs(images []*ImageSBOM) []*cdx.BOM { } return boms } + +// validateImages rejects an image SBOM carrying a GOST value outside the +// 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 { + for _, img := range images { + if err := gost.ValidateValues(img.BOM); err != nil { + return fmt.Errorf("image %q: %w", img.Name, err) + } + } + + return nil +} diff --git a/pkg/sbom/ispras/container.go b/pkg/sbom/ispras/container.go index 4ba9c20dec..ebc96db8e6 100644 --- a/pkg/sbom/ispras/container.go +++ b/pkg/sbom/ispras/container.go @@ -25,6 +25,10 @@ type ContainerAssembler struct{} // it and the document properties of the image, which describe that image and // not the product. func (a *ContainerAssembler) Assemble(_ context.Context, images []*ImageSBOM, meta ProductMeta) (*cdx.BOM, error) { + if err := validateImages(images); err != nil { + return nil, err + } + wrapped := make([]*cdx.BOM, 0, len(images)) for _, img := range images { imgBOM, err := cyclonedxutil.CloneBOM(img.BOM) diff --git a/pkg/sbom/ispras/oss.go b/pkg/sbom/ispras/oss.go index b85c833af3..52b33bb54a 100644 --- a/pkg/sbom/ispras/oss.go +++ b/pkg/sbom/ispras/oss.go @@ -14,6 +14,10 @@ var _ Assembler = (*OSSAssembler)(nil) type OSSAssembler struct{} func (a *OSSAssembler) Assemble(_ context.Context, images []*ImageSBOM, meta ProductMeta) (*cdx.BOM, error) { + if err := validateImages(images); err != nil { + return nil, err + } + result, err := cyclonedxutil.MergeBOMs(nil, cyclonedxutil.MergeOpts{ ImportBOMs: imageBOMs(images), }) From 53d45a331e5478b1ead552d85c5e77b30515fff5 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Tue, 29 Sep 2026 16:57:51 +0300 Subject: [PATCH 12/17] fix(sbom): keep merge-internal prefixes out of vulnerability, composition 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 --- pkg/sbom/cyclonedxutil/bomref.go | 39 +++++++++++++++++++++++++++ pkg/sbom/cyclonedxutil/bomref_test.go | 26 ++++++++++++++++++ pkg/sbom/cyclonedxutil/merge.go | 3 +++ 3 files changed, 68 insertions(+) diff --git a/pkg/sbom/cyclonedxutil/bomref.go b/pkg/sbom/cyclonedxutil/bomref.go index e045cae185..be814ed490 100644 --- a/pkg/sbom/cyclonedxutil/bomref.go +++ b/pkg/sbom/cyclonedxutil/bomref.go @@ -84,6 +84,45 @@ func ensureUniqueBOMRefs(bom *cdx.BOM) { } walkServices(bom.Services) + if bom.Metadata != nil && bom.Metadata.Tools != nil { + walkComponents(bom.Metadata.Tools.Components) + walkServices(bom.Metadata.Tools.Services) + } + + for i := range lo.FromPtr(bom.Formulation) { + formula := &(*bom.Formulation)[i] + if formula.BOMRef != "" { + formula.BOMRef = assignNewRef(formula.BOMRef, "", serial, index, refMap) + } + index++ + walkComponents(formula.Components) + walkServices(formula.Services) + } + + for i := range lo.FromPtr(bom.Vulnerabilities) { + vuln := &(*bom.Vulnerabilities)[i] + if vuln.BOMRef != "" { + vuln.BOMRef = assignNewRef(vuln.BOMRef, "", serial, index, refMap) + } + index++ + } + + for i := range lo.FromPtr(bom.Compositions) { + composition := &(*bom.Compositions)[i] + if composition.BOMRef != "" { + composition.BOMRef = assignNewRef(composition.BOMRef, "", serial, index, refMap) + } + index++ + } + + for i := range lo.FromPtr(bom.Annotations) { + annotation := &(*bom.Annotations)[i] + if annotation.BOMRef != "" { + annotation.BOMRef = assignNewRef(annotation.BOMRef, "", serial, index, refMap) + } + index++ + } + RewriteRefs(bom, refMap) } diff --git a/pkg/sbom/cyclonedxutil/bomref_test.go b/pkg/sbom/cyclonedxutil/bomref_test.go index 0bc14a60eb..23d212c98a 100644 --- a/pkg/sbom/cyclonedxutil/bomref_test.go +++ b/pkg/sbom/cyclonedxutil/bomref_test.go @@ -127,6 +127,32 @@ var _ = Describe("ensureUniqueBOMRefs", func() { Expect(collectBOMRefs(bom)).To(Equal(first)) }) + It("derives the refs of vulnerabilities, compositions, annotations and formulas and keeps references to them", func() { + bom := &cdx.BOM{ + SerialNumber: "urn:uuid:test", + Components: &[]cdx.Component{{BOMRef: "lib", Name: "lib", PackageURL: "pkg:generic/lib@1"}}, + Vulnerabilities: &[]cdx.Vulnerability{ + {BOMRef: "merge-input-0/vuln", ID: "CVE-1", Affects: &[]cdx.Affects{{Ref: "lib"}}}, + }, + Compositions: &[]cdx.Composition{ + {BOMRef: "merge-input-0/comp", Aggregate: cdx.CompositionAggregateComplete, Assemblies: &[]cdx.BOMReference{"lib"}, Vulnerabilities: &[]cdx.BOMReference{"merge-input-0/vuln"}}, + }, + Annotations: &[]cdx.Annotation{ + {BOMRef: "merge-input-0/note", Text: "t", Subjects: &[]cdx.BOMReference{"merge-input-0/comp", "merge-input-0/formula"}}, + }, + Formulation: &[]cdx.Formula{{BOMRef: "merge-input-0/formula"}}, + } + ensureUniqueBOMRefs(bom) + + vuln := (*bom.Vulnerabilities)[0].BOMRef + comp := (*bom.Compositions)[0].BOMRef + formula := (*bom.Formulation)[0].BOMRef + Expect([]string{vuln, comp, (*bom.Annotations)[0].BOMRef, formula}).To(HaveEach(Not(ContainSubstring("merge-input-0/")))) + Expect(uniqueStrings(append(collectBOMRefs(bom), vuln, comp, (*bom.Annotations)[0].BOMRef, formula))).To(BeTrue()) + Expect(*(*bom.Compositions)[0].Vulnerabilities).To(Equal([]cdx.BOMReference{cdx.BOMReference(vuln)})) + Expect(*(*bom.Annotations)[0].Subjects).To(Equal([]cdx.BOMReference{cdx.BOMReference(comp), cdx.BOMReference(formula)})) + }) + It("skips components with empty bom-ref", func() { bom := &cdx.BOM{ SerialNumber: "urn:uuid:test", diff --git a/pkg/sbom/cyclonedxutil/merge.go b/pkg/sbom/cyclonedxutil/merge.go index 6cf785b98e..b93c2a63fa 100644 --- a/pkg/sbom/cyclonedxutil/merge.go +++ b/pkg/sbom/cyclonedxutil/merge.go @@ -12,6 +12,9 @@ import ( "github.com/werf/werf/v3/pkg/sbom/cyclonedxutil/gost" ) +// MergeOpts names the BOMs merged into the target. Every base and import BOM +// is a closed document: a BOM ref is local to the document that declares it, +// so a reference from one BOM into another names nothing and is dropped. type MergeOpts struct { BaseBOM *cdx.BOM ImportBOMs []*cdx.BOM From 90e8ade3ec954bdd6329c4a53ef83e02b5a1aeba Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Tue, 29 Sep 2026 16:58:36 +0300 Subject: [PATCH 13/17] docs(sbom): state that sbom merge rejects out-of-domain GOST values 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 --- cmd/werf/sbom/merge/merge_docs.go | 5 +++-- docs/_includes/reference/cli/werf_sbom_merge.md | 2 +- docs/pages_en/usage/build/sbom.md | 2 +- docs/pages_ru/usage/build/sbom.md | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/werf/sbom/merge/merge_docs.go b/cmd/werf/sbom/merge/merge_docs.go index b30c630316..5a067678c8 100644 --- a/cmd/werf/sbom/merge/merge_docs.go +++ b/cmd/werf/sbom/merge/merge_docs.go @@ -13,7 +13,7 @@ Two ISPRAS-defined output formats are supported: - "container": hierarchical — each image becomes a top-level container component with nested packages. - "oss": flat — all packages from all images are merged into a deduplicated flat list. -GOST properties are aggregated bottom-up: attack_surface with the "yes > indirect > no" precedence rule, security_function with "yes > no". +GOST properties are aggregated bottom-up: attack_surface with the "yes > indirect > no" precedence rule, security_function with "yes > no". An image SBOM carrying a GOST value outside these domains, such as security_function "indirect" written by an older werf, is rejected; rebuild the image first. The flags --input, --ispras-format, --app-name, --app-version and --manufacturer are required. The merged SBOM is written to stdout unless --output is given.` @@ -24,7 +24,8 @@ The flags --input, --ispras-format, --app-name, --app-version and --manufacturer "- `container`: hierarchical — each image becomes a top-level container component with nested packages.\n" + "- `oss`: flat — all packages from all images are merged into a deduplicated flat list.\n\n" + "GOST properties are aggregated bottom-up: `attack_surface` with the `yes > indirect > no` " + - "precedence rule, `security_function` with `yes > no`.\n\n" + + "precedence rule, `security_function` with `yes > no`. An image SBOM carrying a GOST value outside these domains, " + + "such as `security_function: indirect` written by an older werf, is rejected; rebuild the image first.\n\n" + "The flags `--input`, `--ispras-format`, `--app-name`, `--app-version` and `--manufacturer` " + "are required. The merged SBOM is written to stdout unless `--output` is given." diff --git a/docs/_includes/reference/cli/werf_sbom_merge.md b/docs/_includes/reference/cli/werf_sbom_merge.md index fdef468f00..277d5ce76e 100644 --- a/docs/_includes/reference/cli/werf_sbom_merge.md +++ b/docs/_includes/reference/cli/werf_sbom_merge.md @@ -11,7 +11,7 @@ Two ISPRAS-defined output formats are supported: - `container`: hierarchical — each image becomes a top-level container component with nested packages. - `oss`: flat — all packages from all images are merged into a deduplicated flat list. -GOST properties are aggregated bottom-up: `attack_surface` with the `yes > indirect > no` precedence rule, `security_function` with `yes > no`. +GOST properties are aggregated bottom-up: `attack_surface` with the `yes > indirect > no` precedence rule, `security_function` with `yes > no`. An image SBOM carrying a GOST value outside these domains, such as `security_function: indirect` written by an older werf, is rejected; rebuild the image first. The flags `--input`, `--ispras-format`, `--app-name`, `--app-version` and `--manufacturer` are required. The merged SBOM is written to stdout unless `--output` is given. diff --git a/docs/pages_en/usage/build/sbom.md b/docs/pages_en/usage/build/sbom.md index 36477df9d6..9ce9f20341 100644 --- a/docs/pages_en/usage/build/sbom.md +++ b/docs/pages_en/usage/build/sbom.md @@ -177,6 +177,6 @@ Changing GOST properties (`sbom.gost`) does not affect stage digests. Cached sta [`werf sbom get`]({{ "/reference/cli/werf_sbom_get.html" | true_relative_url }}) retrieves the SBOM for an image described in `werf.yaml` and prints it to stdout. The SBOM is read as an OCI artifact from the container registry, so `--repo` is required. When invoked with an image name, the command runs the standard werf build conveyor: missing stages and SBOM artifacts are created, just like with `werf build` (with the `--require-built-images` flag the command fails instead). You can select a specific version with `--tag` or `--digest` (mutually exclusive) — in this mode the command only downloads the ready-made SBOM and fails if it is not found. -[`werf sbom merge`]({{ "/reference/cli/werf_sbom_merge.html" | true_relative_url }}) assembles a product-level SBOM from several per-image SBOMs. It takes a JSON file that maps image names to sha256 digests, pulls the individual SBOMs from the registry, and merges them into a single CycloneDX document with dependency graphs preserved. Two ISPRAS output formats are available: `container` (hierarchical, each image becomes a top-level component with nested packages) and `oss` (flat, all packages deduplicated into one list). GOST properties are aggregated bottom-up: `attack_surface` with the precedence `yes > indirect > no`, `security_function` with `yes > no`. +[`werf sbom merge`]({{ "/reference/cli/werf_sbom_merge.html" | true_relative_url }}) assembles a product-level SBOM from several per-image SBOMs. It takes a JSON file that maps image names to sha256 digests, pulls the individual SBOMs from the registry, and merges them into a single CycloneDX document with dependency graphs preserved. Two ISPRAS output formats are available: `container` (hierarchical, each image becomes a top-level component with nested packages) and `oss` (flat, all packages deduplicated into one list). GOST properties are aggregated bottom-up: `attack_surface` with the precedence `yes > indirect > no`, `security_function` with `yes > no`. An image SBOM carrying a GOST value outside these domains — `security_function: indirect` written by an older werf, for instance — is rejected; rebuild the image first. [`werf sbom validate`]({{ "/reference/cli/werf_sbom_validate.html" | true_relative_url }}) checks a CycloneDX JSON file against ISPRAS schemas. It runs sbom-checker inside a Docker container and reports any violations, split into errors and warnings, with both counts shown in the summary. By default any error or warning fails the validation; pass `--warnings-non-fatal` to keep warnings informational (printed on stderr) so that only errors set a non-zero exit code. Both `oss` and `container` SBOM types are supported. diff --git a/docs/pages_ru/usage/build/sbom.md b/docs/pages_ru/usage/build/sbom.md index 7fff3c1e95..8bcdae225f 100644 --- a/docs/pages_ru/usage/build/sbom.md +++ b/docs/pages_ru/usage/build/sbom.md @@ -177,6 +177,6 @@ WERF_EXTERNAL_REFS_SERVER_URL env var is required [`werf sbom get`]({{ "/reference/cli/werf_sbom_get.html" | true_relative_url }}) получает SBOM образа, описанного в `werf.yaml`, и выводит его в stdout. SBOM читается как OCI-артефакт из container registry, поэтому флаг `--repo` обязателен. При обращении по имени образа команда запускает стандартный сборочный конвейер werf: отсутствующие стадии и SBOM-артефакты досоздаются, как при `werf build` (с флагом `--require-built-images` команда вместо этого завершается ошибкой). Для выбора конкретной версии поддерживаются флаги `--tag` и `--digest` (взаимоисключающие) — в этом режиме команда только выгружает готовый SBOM и завершается ошибкой, если он не найден. -[`werf sbom merge`]({{ "/reference/cli/werf_sbom_merge.html" | true_relative_url }}) собирает SBOM уровня продукта из нескольких пообразных SBOM. На вход принимается JSON-файл с соответствием «имя образа → sha256-дайджест»; команда скачивает отдельные SBOM из registry и объединяет их в один CycloneDX-документ с сохранением графов зависимостей. Поддерживаются два выходных формата ИСПРАС: `container` (иерархический, каждый образ становится компонентом верхнего уровня с вложенными пакетами) и `oss` (плоский, все пакеты дедуплицируются в один список). Свойства ГОСТ агрегируются снизу вверх: `attack_surface` по приоритету `yes > indirect > no`, `security_function` — `yes > no`. +[`werf sbom merge`]({{ "/reference/cli/werf_sbom_merge.html" | true_relative_url }}) собирает SBOM уровня продукта из нескольких пообразных SBOM. На вход принимается JSON-файл с соответствием «имя образа → sha256-дайджест»; команда скачивает отдельные SBOM из registry и объединяет их в один CycloneDX-документ с сохранением графов зависимостей. Поддерживаются два выходных формата ИСПРАС: `container` (иерархический, каждый образ становится компонентом верхнего уровня с вложенными пакетами) и `oss` (плоский, все пакеты дедуплицируются в один список). Свойства ГОСТ агрегируются снизу вверх: `attack_surface` по приоритету `yes > indirect > no`, `security_function` — `yes > no`. SBOM образа со значением ГОСТ вне этих доменов — например, `security_function: indirect`, записанным старой версией werf, — отклоняется; такой образ нужно сначала пересобрать. [`werf sbom validate`]({{ "/reference/cli/werf_sbom_validate.html" | true_relative_url }}) проверяет CycloneDX JSON-файл по схемам ИСПРАС. Команда запускает sbom-checker в Docker-контейнере и выводит обнаруженные нарушения, разделяя их на ошибки и предупреждения, а в сводке показывает оба счётчика. По умолчанию валидацию проваливают и ошибки, и предупреждения; передайте `--warnings-non-fatal`, чтобы предупреждения оставались информационными (выводятся в stderr) и на код возврата влияли только ошибки. Поддерживаются типы SBOM `oss` и `container`. From 981de738f612d9ca3d261a78c677dcde53cd8d5e Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Tue, 29 Sep 2026 17:25:00 +0300 Subject: [PATCH 14/17] refactor(sbom): split ref derivation and Tarjan traversal into helpers `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 --- pkg/sbom/cyclonedxutil/bomref.go | 100 +++++++++++--------------- pkg/sbom/cyclonedxutil/gost/upsert.go | 91 +++++++++++++---------- 2 files changed, 95 insertions(+), 96 deletions(-) diff --git a/pkg/sbom/cyclonedxutil/bomref.go b/pkg/sbom/cyclonedxutil/bomref.go index be814ed490..3d09634cc3 100644 --- a/pkg/sbom/cyclonedxutil/bomref.go +++ b/pkg/sbom/cyclonedxutil/bomref.go @@ -54,76 +54,62 @@ func ensureUniqueBOMRefs(bom *cdx.BOM) { return } - refMap := map[string]string{} - serial := bom.SerialNumber - index := 0 - - var walkComponents func(components *[]cdx.Component) - walkComponents = func(components *[]cdx.Component) { - for i := range lo.FromPtr(components) { - comp := &(*components)[i] - if comp.BOMRef != "" { - comp.BOMRef = assignNewRef(comp.BOMRef, comp.PackageURL, serial, index, refMap) - } - index++ - walkComponents(comp.Components) - } - } - walkComponents(bom.Components) - - var walkServices func(services *[]cdx.Service) - walkServices = func(services *[]cdx.Service) { - for i := range lo.FromPtr(services) { - svc := &(*services)[i] - if svc.BOMRef != "" { - svc.BOMRef = assignNewRef(svc.BOMRef, "", serial, index, refMap) - } - index++ - walkServices(svc.Services) - } - } - walkServices(bom.Services) + d := &refDeriver{serial: bom.SerialNumber, refMap: map[string]string{}} + d.components(bom.Components) + d.services(bom.Services) if bom.Metadata != nil && bom.Metadata.Tools != nil { - walkComponents(bom.Metadata.Tools.Components) - walkServices(bom.Metadata.Tools.Services) + d.components(bom.Metadata.Tools.Components) + d.services(bom.Metadata.Tools.Services) } - for i := range lo.FromPtr(bom.Formulation) { formula := &(*bom.Formulation)[i] - if formula.BOMRef != "" { - formula.BOMRef = assignNewRef(formula.BOMRef, "", serial, index, refMap) - } - index++ - walkComponents(formula.Components) - walkServices(formula.Services) + d.derive(&formula.BOMRef, "") + d.components(formula.Components) + d.services(formula.Services) } - for i := range lo.FromPtr(bom.Vulnerabilities) { - vuln := &(*bom.Vulnerabilities)[i] - if vuln.BOMRef != "" { - vuln.BOMRef = assignNewRef(vuln.BOMRef, "", serial, index, refMap) - } - index++ + d.derive(&(*bom.Vulnerabilities)[i].BOMRef, "") } - for i := range lo.FromPtr(bom.Compositions) { - composition := &(*bom.Compositions)[i] - if composition.BOMRef != "" { - composition.BOMRef = assignNewRef(composition.BOMRef, "", serial, index, refMap) - } - index++ + d.derive(&(*bom.Compositions)[i].BOMRef, "") } - for i := range lo.FromPtr(bom.Annotations) { - annotation := &(*bom.Annotations)[i] - if annotation.BOMRef != "" { - annotation.BOMRef = assignNewRef(annotation.BOMRef, "", serial, index, refMap) - } - index++ + d.derive(&(*bom.Annotations)[i].BOMRef, "") } - RewriteRefs(bom, refMap) + RewriteRefs(bom, d.refMap) +} + +// refDeriver assigns every declared ref of one BOM a value derived from the +// document serial and the position of the entity, recording the renames. +type refDeriver struct { + serial string + index int + refMap map[string]string +} + +func (d *refDeriver) derive(ref *string, purl string) { + if *ref != "" { + *ref = assignNewRef(*ref, purl, d.serial, d.index, d.refMap) + } + d.index++ +} + +func (d *refDeriver) components(components *[]cdx.Component) { + for i := range lo.FromPtr(components) { + comp := &(*components)[i] + d.derive(&comp.BOMRef, comp.PackageURL) + d.components(comp.Components) + } +} + +func (d *refDeriver) services(services *[]cdx.Service) { + for i := range lo.FromPtr(services) { + svc := &(*services)[i] + d.derive(&svc.BOMRef, "") + d.services(svc.Services) + } } // remapRef replaces a ref exactly once. The mapping describes a simultaneous diff --git a/pkg/sbom/cyclonedxutil/gost/upsert.go b/pkg/sbom/cyclonedxutil/gost/upsert.go index d107aaa42e..ddda28a74c 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert.go @@ -115,53 +115,66 @@ func collectComponentRefs(components []cdx.Component, refs map[string]struct{}) // stronglyConnectedComponents runs Tarjan's algorithm over the adjacency list // and labels every node with the index of its component. func stronglyConnectedComponents(edges map[string][]string) map[string]int { - index := make(map[string]int) - lowlink := make(map[string]int) - onStack := make(map[string]bool) - componentOf := make(map[string]int) - var stack []string - nextIndex, nextComponent := 0, 0 - - var visit func(node string) - visit = func(node string) { - index[node] = nextIndex - lowlink[node] = nextIndex - nextIndex++ - stack = append(stack, node) - onStack[node] = true - - for _, next := range edges[node] { - if _, seen := index[next]; !seen { - visit(next) - lowlink[node] = min(lowlink[node], lowlink[next]) - } else if onStack[next] { - lowlink[node] = min(lowlink[node], index[next]) - } - } + t := &tarjan{ + edges: edges, + index: make(map[string]int), + lowlink: make(map[string]int), + onStack: make(map[string]bool), + componentOf: make(map[string]int), + } - if lowlink[node] != index[node] { - return + for _, node := range slices.Sorted(maps.Keys(edges)) { + if _, seen := t.index[node]; !seen { + t.visit(node) } + } - for { - top := stack[len(stack)-1] - stack = stack[:len(stack)-1] - onStack[top] = false - componentOf[top] = nextComponent - if top == node { - break - } + return t.componentOf +} + +type tarjan struct { + edges map[string][]string + index map[string]int + lowlink map[string]int + onStack map[string]bool + componentOf map[string]int + stack []string + nextIndex int + nextComponent int +} + +func (t *tarjan) visit(node string) { + t.index[node] = t.nextIndex + t.lowlink[node] = t.nextIndex + t.nextIndex++ + t.stack = append(t.stack, node) + t.onStack[node] = true + + for _, next := range t.edges[node] { + if _, seen := t.index[next]; !seen { + t.visit(next) + t.lowlink[node] = min(t.lowlink[node], t.lowlink[next]) + } else if t.onStack[next] { + t.lowlink[node] = min(t.lowlink[node], t.index[next]) } - nextComponent++ } - for _, node := range slices.Sorted(maps.Keys(edges)) { - if _, seen := index[node]; !seen { - visit(node) - } + if t.lowlink[node] == t.index[node] { + t.popComponent(node) } +} - return componentOf +func (t *tarjan) popComponent(root string) { + for { + top := t.stack[len(t.stack)-1] + t.stack = t.stack[:len(t.stack)-1] + t.onStack[top] = false + t.componentOf[top] = t.nextComponent + if top == root { + break + } + } + t.nextComponent++ } // SetComponent inserts or updates mandatory GOST properties in a single component. From 143168449198d61df6473f724cb039a4bbf35617 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Wed, 30 Sep 2026 17:53:13 +0300 Subject: [PATCH 15/17] fix(sbom): keep every undeclared reference of a merged SBOM inside its 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 --- pkg/sbom/cyclonedxutil/namespace.go | 39 +++++++++++++++++++----- pkg/sbom/cyclonedxutil/namespace_test.go | 30 ++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/pkg/sbom/cyclonedxutil/namespace.go b/pkg/sbom/cyclonedxutil/namespace.go index 23ce13f89f..7f5e04b356 100644 --- a/pkg/sbom/cyclonedxutil/namespace.go +++ b/pkg/sbom/cyclonedxutil/namespace.go @@ -46,20 +46,45 @@ func NamespaceBOMRefs(bom *cdx.BOM, prefix string) { namespaceRef(&(*bom.Annotations)[i].BOMRef, prefix, refMap) } - namespaceUnknown := func(ref string) { - if _, known := refMap[ref]; known || ref == "" || strings.HasPrefix(ref, "urn:cdx:") { + namespaceUndeclaredReferences(bom, prefix, refMap) + + RewriteRefs(bom, refMap) +} + +// namespaceUndeclaredReferences prefixes every reference to a ref the document +// does not declare — a dangling edge, a vulnerability about a package the +// document lacks — so that it cannot land on an entity of another document +// once the two are merged. A BOM-Link and a reference to the document's own +// serial address a document, not an entity, and stay as they are. +func namespaceUndeclaredReferences(bom *cdx.BOM, prefix string, refMap map[string]string) { + visit := func(ref string) { + if _, known := refMap[ref]; known || ref == "" || ref == bom.SerialNumber || strings.HasPrefix(ref, "urn:cdx:") { return } refMap[ref] = namespacedRef(ref, prefix) } - for _, dep := range lo.FromPtr(bom.Dependencies) { - namespaceUnknown(dep.Ref) - for _, d := range lo.FromPtr(dep.Dependencies) { - namespaceUnknown(d) + visitAll := func(refs *[]cdx.BOMReference) { + for _, ref := range lo.FromPtr(refs) { + visit(string(ref)) } } - RewriteRefs(bom, refMap) + for _, dep := range lo.FromPtr(bom.Dependencies) { + visit(dep.Ref) + lo.ForEach(lo.FromPtr(dep.Dependencies), func(ref string, _ int) { visit(ref) }) + lo.ForEach(lo.FromPtr(dep.Provides), func(ref string, _ int) { visit(ref) }) + } + for _, vuln := range lo.FromPtr(bom.Vulnerabilities) { + lo.ForEach(lo.FromPtr(vuln.Affects), func(affects cdx.Affects, _ int) { visit(affects.Ref) }) + } + for _, composition := range lo.FromPtr(bom.Compositions) { + visitAll(composition.Assemblies) + visitAll(composition.Dependencies) + visitAll(composition.Vulnerabilities) + } + for _, annotation := range lo.FromPtr(bom.Annotations) { + visitAll(annotation.Subjects) + } } func namespaceServiceBOMRefs(services []cdx.Service, prefix string, refMap map[string]string) { diff --git a/pkg/sbom/cyclonedxutil/namespace_test.go b/pkg/sbom/cyclonedxutil/namespace_test.go index 1e5e8c9e06..8d610cdfda 100644 --- a/pkg/sbom/cyclonedxutil/namespace_test.go +++ b/pkg/sbom/cyclonedxutil/namespace_test.go @@ -94,6 +94,36 @@ var _ = Describe("NamespaceBOMRefs", func() { Expect(*(*bom.Annotations)[0].Subjects).To(Equal([]cdx.BOMReference{"img/os", "urn:cdx:11111111-1111-1111-1111-111111111111/1#lib"})) }) + It("namespaces a reference to a ref the document does not declare, wherever it occurs", func() { + bom := &cdx.BOM{ + Components: &[]cdx.Component{{BOMRef: "lib", Type: cdx.ComponentTypeLibrary, Name: "lib"}}, + Dependencies: &[]cdx.Dependency{{Ref: "lib", Dependencies: &[]string{"ghost-dep"}, Provides: &[]string{"ghost-prov"}}}, + Vulnerabilities: &[]cdx.Vulnerability{{ID: "CVE-1", Affects: &[]cdx.Affects{{Ref: "ghost-vuln"}}}}, + Compositions: &[]cdx.Composition{{Aggregate: cdx.CompositionAggregateComplete, Assemblies: &[]cdx.BOMReference{"ghost-asm"}, Dependencies: &[]cdx.BOMReference{"ghost-cdep"}}}, + Annotations: &[]cdx.Annotation{{Text: "t", Subjects: &[]cdx.BOMReference{"ghost-subj"}}}, + } + + NamespaceBOMRefs(bom, "img") + + Expect(*(*bom.Dependencies)[0].Dependencies).To(Equal([]string{"img/ghost-dep"})) + Expect(*(*bom.Dependencies)[0].Provides).To(Equal([]string{"img/ghost-prov"})) + Expect((*(*bom.Vulnerabilities)[0].Affects)[0].Ref).To(Equal("img/ghost-vuln")) + Expect(*(*bom.Compositions)[0].Assemblies).To(Equal([]cdx.BOMReference{"img/ghost-asm"})) + Expect(*(*bom.Compositions)[0].Dependencies).To(Equal([]cdx.BOMReference{"img/ghost-cdep"})) + Expect(*(*bom.Annotations)[0].Subjects).To(Equal([]cdx.BOMReference{"img/ghost-subj"})) + }) + + It("leaves a reference to the document's own serial alone", func() { + bom := &cdx.BOM{ + SerialNumber: "urn:uuid:11111111-1111-1111-1111-111111111111", + Annotations: &[]cdx.Annotation{{Text: "t", Subjects: &[]cdx.BOMReference{"urn:uuid:11111111-1111-1111-1111-111111111111"}}}, + } + + NamespaceBOMRefs(bom, "img") + + Expect(*(*bom.Annotations)[0].Subjects).To(Equal([]cdx.BOMReference{"urn:uuid:11111111-1111-1111-1111-111111111111"})) + }) + It("renames every ref at once when one new ref equals another old one", func() { bom := &cdx.BOM{ Components: &[]cdx.Component{ From 1fc3a83f3ac8faa7b9572b98d15c1de90657b318 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Wed, 30 Sep 2026 17:53:15 +0300 Subject: [PATCH 16/17] fix(sbom): tell how to recover when a base or imported SBOM fails GOST 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 --- pkg/build/sbom_step.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index b41d2c9b22..446ed3dac8 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_step.go @@ -397,13 +397,13 @@ func (step *sbomStep) prepareGostComponents(ctx context.Context, mergeOpts *cycl if mergeOpts.BaseBOM != nil { if err := gost.Validate(mergeOpts.BaseBOM); err != nil { - return fmt.Errorf("base SBOM validation failed: %w", err) + return fmt.Errorf("base SBOM validation failed (rebuild the base image with the current werf if its SBOM was built by an older one): %w", err) } } for i, externalBOM := range mergeOpts.ImportBOMs { if err := gost.Validate(externalBOM); err != nil { - return fmt.Errorf("external SBOM [%d] validation failed: %w", i, err) + return fmt.Errorf("external SBOM [%d] validation failed (rebuild the imported image with the current werf if its SBOM was built by an older one): %w", i, err) } } From b08ceaab4ef5295d567e3d436fbb47efa1927737 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Thu, 1 Oct 2026 07:58:13 +0300 Subject: [PATCH 17/17] fix(sbom): drop vulnerability references to packages no input declares 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 --- pkg/sbom/cyclonedxutil/canonicalize.go | 21 +++++++++++++++++++++ pkg/sbom/cyclonedxutil/canonicalize_test.go | 4 ++++ pkg/sbom/cyclonedxutil/merge_test.go | 19 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/pkg/sbom/cyclonedxutil/canonicalize.go b/pkg/sbom/cyclonedxutil/canonicalize.go index a442160b4f..e743ae1469 100644 --- a/pkg/sbom/cyclonedxutil/canonicalize.go +++ b/pkg/sbom/cyclonedxutil/canonicalize.go @@ -79,6 +79,10 @@ func CanonicalizeDocument(bom *cdx.BOM) { } knownRefs := collectKnownRefs(bom) bom.Dependencies = canonicalizeDependencies(bom.Dependencies, knownRefs) + for i := range lo.FromPtr(bom.Vulnerabilities) { + vuln := &(*bom.Vulnerabilities)[i] + vuln.Affects = filterKnownAffects(vuln.Affects, knownRefs) + } if bom.SerialNumber != "" { knownRefs[bom.SerialNumber] = struct{}{} } @@ -493,6 +497,23 @@ func filterKnownBOMReferences(refs *[]cdx.BOMReference, knownRefs map[string]str return dedupPtrSlice(&result) } +// filterKnownAffects drops the references of a vulnerability to entities the +// BOM does not declare, as canonicalizeDependencies does for edges. A +// vulnerability may only affect a component or a service, so unlike the +// dependency graph it has nothing to say in a BOM that declares none. +func filterKnownAffects(affects *[]cdx.Affects, knownRefs map[string]struct{}) *[]cdx.Affects { + if affects == nil { + return nil + } + + result := lo.Filter(*affects, func(a cdx.Affects, _ int) bool { + _, known := knownRefs[a.Ref] + return known || isBOMLink(a.Ref) + }) + + return dedupPtrSlice(&result) +} + // isBOMLink reports whether ref addresses an entity of another document, which // this one cannot check. func isBOMLink(ref string) bool { diff --git a/pkg/sbom/cyclonedxutil/canonicalize_test.go b/pkg/sbom/cyclonedxutil/canonicalize_test.go index d4585805f3..104949ba22 100644 --- a/pkg/sbom/cyclonedxutil/canonicalize_test.go +++ b/pkg/sbom/cyclonedxutil/canonicalize_test.go @@ -579,6 +579,10 @@ var _ = Describe("Canonicalize", func() { It("merges vulnerabilities sharing an id and source", func() { bom := &cdx.BOM{ + Components: &[]cdx.Component{ + {BOMRef: "a", Type: cdx.ComponentTypeLibrary, Name: "a"}, + {BOMRef: "b", Type: cdx.ComponentTypeLibrary, Name: "b"}, + }, Vulnerabilities: &[]cdx.Vulnerability{ {ID: "CVE-1", Source: &cdx.Source{Name: "nvd"}, Affects: &[]cdx.Affects{{Ref: "a"}}, CWEs: &[]int{79}}, { diff --git a/pkg/sbom/cyclonedxutil/merge_test.go b/pkg/sbom/cyclonedxutil/merge_test.go index 132f9b5148..2049e3f5e3 100644 --- a/pkg/sbom/cyclonedxutil/merge_test.go +++ b/pkg/sbom/cyclonedxutil/merge_test.go @@ -881,4 +881,23 @@ var _ = Describe("MergeBOMs ref collisions", func() { "unrelated": gost.GostValueYes, })) }) + + It("drops a vulnerability's reference to a package no input declares instead of leaking the input prefix", func() { + baseBOM := &cdx.BOM{ + SpecVersion: cdx.SpecVersion1_6, + Components: &[]cdx.Component{ + {BOMRef: "lib", Type: cdx.ComponentTypeLibrary, Name: "lib", PackageURL: "pkg:generic/lib@1"}, + }, + Vulnerabilities: &[]cdx.Vulnerability{ + {BOMRef: "vuln-1", ID: "CVE-1", Affects: &[]cdx.Affects{{Ref: "lib"}, {Ref: "ghost"}, {Ref: "urn:cdx:other/1#ghost"}}}, + }, + } + + result, err := MergeBOMs(nil, MergeOpts{BaseBOM: baseBOM}) + Expect(err).NotTo(HaveOccurred()) + + Expect(*result.Vulnerabilities).To(HaveLen(1)) + affected := lo.Map(*(*result.Vulnerabilities)[0].Affects, func(a cdx.Affects, _ int) string { return a.Ref }) + Expect(affected).To(ConsistOf((*result.Components)[0].BOMRef, "urn:cdx:other/1#ghost")) + }) })