From d14e67949bcf951029fb345d833e67e838adce7f Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 18 Sep 2026 03:11:20 +0300 Subject: [PATCH 1/3] fix(sbom): stop marking every package of an image directly attackable An image declared with attackSurface: yes now carries that value on the image component only; the packages it contains are reachable through the image and get indirect. Other values, and securityFunction in all cases, keep applying unchanged to the whole tree. Signed-off-by: Radmir Khurum --- pkg/sbom/cyclonedxutil/gost/config.go | 13 ++++++++ pkg/sbom/cyclonedxutil/gost/upsert.go | 10 ++++-- pkg/sbom/cyclonedxutil/gost/upsert_test.go | 36 +++++++++++++++++++--- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/pkg/sbom/cyclonedxutil/gost/config.go b/pkg/sbom/cyclonedxutil/gost/config.go index 683ea114b2..8cd8e3da94 100644 --- a/pkg/sbom/cyclonedxutil/gost/config.go +++ b/pkg/sbom/cyclonedxutil/gost/config.go @@ -41,6 +41,19 @@ func (c Config) Merge(other Config) Config { return res } +// ForDescendants derives the values carried by everything below the image root. +// An attack surface declared accessible applies to the image as a whole: an +// attacker reaches the packages it contains only through the image, so they are +// exposed indirectly. Every other value, and the security function in all cases, +// applies unchanged down the whole tree. +func (c Config) ForDescendants() Config { + res := c + if res.AttackSurface == GostValueYes { + res.AttackSurface = GostValueIndirect + } + return res +} + func IsValidGostValue(v string) bool { return v == GostValueYes.String() || v == GostValueNo.String() || v == GostValueIndirect.String() } diff --git a/pkg/sbom/cyclonedxutil/gost/upsert.go b/pkg/sbom/cyclonedxutil/gost/upsert.go index 19a6873b4e..4b862f0488 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert.go @@ -8,18 +8,22 @@ 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. The metadata component is the image +// itself and gets the configured values; everything below it gets the values +// derived for descendants (see Config.ForDescendants). func Upsert(bom *cdx.BOM, config Config) error { if bom == nil { return fmt.Errorf("BOM is required") } + descendants := config.ForDescendants() + 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), descendants) } - setComponents(lo.FromPtr(bom.Components), config) + setComponents(lo.FromPtr(bom.Components), descendants) return nil } diff --git a/pkg/sbom/cyclonedxutil/gost/upsert_test.go b/pkg/sbom/cyclonedxutil/gost/upsert_test.go index 89bc082c22..6245cbea67 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert_test.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert_test.go @@ -33,7 +33,7 @@ var _ = Describe("Gost SBOM setter", func() { { Name: "test", Properties: &[]cdx.Property{ - {Name: PropertyAttackSurface, Value: "yes"}, + {Name: PropertyAttackSurface, Value: "indirect"}, {Name: PropertySecurityFunction, Value: "no"}, }, }, @@ -56,7 +56,7 @@ var _ = Describe("Gost SBOM setter", func() { { Name: "test", Properties: &[]cdx.Property{ - {Name: PropertyAttackSurface, Value: "yes"}, + {Name: PropertyAttackSurface, Value: "indirect"}, {Name: PropertySecurityFunction, Value: "yes"}, }, }, @@ -98,19 +98,19 @@ var _ = Describe("Gost SBOM setter", func() { []cdx.Component{{ Name: "parent", Properties: &[]cdx.Property{ - {Name: PropertyAttackSurface, Value: "yes"}, + {Name: PropertyAttackSurface, Value: "indirect"}, {Name: PropertySecurityFunction, Value: "no"}, }, Components: &[]cdx.Component{{ Name: "child", Properties: &[]cdx.Property{ - {Name: PropertyAttackSurface, Value: "yes"}, + {Name: PropertyAttackSurface, Value: "indirect"}, {Name: PropertySecurityFunction, Value: "no"}, }, Components: &[]cdx.Component{{ Name: "grandchild", Properties: &[]cdx.Property{ - {Name: PropertyAttackSurface, Value: "yes"}, + {Name: PropertyAttackSurface, Value: "indirect"}, {Name: PropertySecurityFunction, Value: "no"}, }, }}, @@ -151,4 +151,30 @@ var _ = Describe("Gost SBOM setter", func() { }, Succeed()), ) + + DescribeTable("attack surface split between the image root and its content", + func(config, expectedRoot, expectedComponent Config) { + bom := &cdx.BOM{ + Metadata: &cdx.Metadata{Component: &cdx.Component{Name: "image"}}, + Components: &[]cdx.Component{{Name: "pkg"}}, + } + + Expect(Upsert(bom, config)).To(Succeed()) + + Expect(GetComponent(bom.Metadata.Component)).To(Equal(expectedRoot)) + Expect(GetComponent(&(*bom.Components)[0])).To(Equal(expectedComponent)) + }, + Entry("yes reaches the packages only through the image", + Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, + Config{AttackSurface: GostValueYes, SecurityFunction: GostValueYes}, + Config{AttackSurface: GostValueIndirect, SecurityFunction: GostValueYes}), + Entry("indirect applies to the whole tree unchanged", + Config{AttackSurface: GostValueIndirect, SecurityFunction: GostValueNo}, + Config{AttackSurface: GostValueIndirect, SecurityFunction: GostValueNo}, + Config{AttackSurface: GostValueIndirect, SecurityFunction: GostValueNo}), + Entry("no applies to the whole tree unchanged", + Config{AttackSurface: GostValueNo, SecurityFunction: GostValueNo}, + Config{AttackSurface: GostValueNo, SecurityFunction: GostValueNo}, + Config{AttackSurface: GostValueNo, SecurityFunction: GostValueNo}), + ) }) From 042864177f47429882112418119427581523fb7f Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 18 Sep 2026 03:11:21 +0300 Subject: [PATCH 2/3] fix(sbom): keep the image itself listed in a container-format product SBOM The container wrapping an image dissolved the image root component into itself, so the ISPRAS rule binding a container to the maximum over its content had nothing carrying the image's own attack surface. Keep the root as the first component of its container and let the container derive its values from it. Signed-off-by: Radmir Khurum --- pkg/sbom/ispras/assembler_test.go | 34 +++++++++++++++++++++++++------ pkg/sbom/ispras/container.go | 24 ++++++++-------------- pkg/sbom/ispras/image_sbom.go | 7 ++++++- 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/pkg/sbom/ispras/assembler_test.go b/pkg/sbom/ispras/assembler_test.go index 894a5c83b0..2133fa5f9a 100644 --- a/pkg/sbom/ispras/assembler_test.go +++ b/pkg/sbom/ispras/assembler_test.go @@ -83,7 +83,7 @@ var _ = Describe("ContainerAssembler", func() { Expect(refs).To(HaveKey(ref)) } } - Expect((*result.Dependencies)[0]).To(Equal(cdx.Dependency{Ref: "a", Dependencies: &[]string{"a/os"}})) + Expect((*result.Dependencies)[0]).To(Equal(cdx.Dependency{Ref: "a/root", Dependencies: &[]string{"a/os"}})) }) It("keeps the same package in every container it belongs to", func() { @@ -98,10 +98,31 @@ var _ = Describe("ContainerAssembler", func() { containers := *result.Components Expect(containers).To(HaveLen(2)) for _, container := range containers { - Expect(*container.Components).To(HaveLen(2)) + Expect(*container.Components).To(HaveLen(3)) } }) + It("keeps the image root component inside its container", func() { + bom := imageBOM("a") + gost.SetComponent(bom.Metadata.Component, gost.Config{AttackSurface: gost.GostValueYes, SecurityFunction: gost.GostValueYes}) + + result, err := (&ContainerAssembler{}).Assemble(context.Background(), []*ImageSBOM{NewImageSBOM("a", bom)}, ProductMeta{AppName: "app", AppVersion: "1", Manufacturer: "m"}) + Expect(err).NotTo(HaveOccurred()) + + container := (*result.Components)[0] + Expect(container.BOMRef).To(Equal("a")) + Expect(container.Name).To(Equal("a")) + + root := (*container.Components)[0] + Expect(root.BOMRef).To(Equal("a/root")) + Expect(root.Name).To(Equal("registry.example.com/a")) + Expect(root.Version).NotTo(BeEmpty(), "the ISPRAS schema requires a version on a nested component") + Expect(gost.GetComponent(&root)).To(Equal(gost.Config{AttackSurface: gost.GostValueYes, SecurityFunction: gost.GostValueYes})) + + Expect(gost.GetComponent(&container)).To(Equal(gost.Config{AttackSurface: gost.GostValueYes, SecurityFunction: gost.GostValueYes}), + "the container equals the maximum over its content, which the root now carries") + }) + It("keeps images apart when their metadata purls are equal", func() { bomA, bomB := imageBOM("a"), imageBOM("b") bomA.Metadata.Component.PackageURL = "pkg:oci/shared@sha256:aaa" @@ -116,11 +137,11 @@ var _ = Describe("ContainerAssembler", func() { Expect(containers).To(HaveLen(2)) Expect(lo.Map(containers, func(c cdx.Component, _ int) string { return c.BOMRef })).To(ConsistOf("a", "b")) for _, container := range containers { - Expect(*container.Components).To(HaveLen(2)) + Expect(*container.Components).To(HaveLen(3)) } }) - It("redirects every reference to the image root, not only dependency subjects", func() { + It("keeps every reference to the image root resolvable and the caller's BOM untouched", func() { bom := imageBOM("a") bom.Vulnerabilities = &[]cdx.Vulnerability{{ID: "CVE-2", Affects: &[]cdx.Affects{{Ref: "a/root"}}}} bom.Dependencies = &[]cdx.Dependency{{Ref: "a/os", Dependencies: &[]string{"a/root"}}} @@ -130,8 +151,9 @@ var _ = Describe("ContainerAssembler", func() { result, err := (&ContainerAssembler{}).Assemble(context.Background(), []*ImageSBOM{NewImageSBOM("a", bom)}, ProductMeta{AppName: "app", AppVersion: "1", Manufacturer: "m"}) Expect(err).NotTo(HaveOccurred()) - Expect(*(*result.Vulnerabilities)[0].Affects).To(Equal([]cdx.Affects{{Ref: "a"}})) - Expect(*result.Dependencies).To(Equal([]cdx.Dependency{{Ref: "a/os", Dependencies: &[]string{"a"}}})) + Expect(*(*result.Vulnerabilities)[0].Affects).To(Equal([]cdx.Affects{{Ref: "a/root"}})) + Expect(*result.Dependencies).To(Equal([]cdx.Dependency{{Ref: "a/os", Dependencies: &[]string{"a/root"}}})) + Expect(collectRefs(*result.Components)).To(HaveKey("a/root")) after, err := json.Marshal(bom) Expect(err).NotTo(HaveOccurred()) diff --git a/pkg/sbom/ispras/container.go b/pkg/sbom/ispras/container.go index 8e20e4e726..db7141f416 100644 --- a/pkg/sbom/ispras/container.go +++ b/pkg/sbom/ispras/container.go @@ -18,8 +18,9 @@ type ContainerAssembler struct{} // before merging, so that identical packages coming from different images stay // in their own container instead of collapsing into a single entry, and the BOM // refs namespaced per image keep matching the merged dependency graph. The -// container replaces the image's root component, taking over every reference to -// it. +// image's own root component is kept inside its container: the ISPRAS checker +// binds a container's GOST values to the maximum over its content, so an image +// declared accessible needs a component carrying that value below the container. func (a *ContainerAssembler) Assemble(_ context.Context, images []*ImageSBOM, meta ProductMeta) (*cdx.BOM, error) { wrapped := make([]*cdx.BOM, 0, len(images)) for _, img := range images { @@ -29,25 +30,18 @@ func (a *ContainerAssembler) Assemble(_ context.Context, images []*ImageSBOM, me } container := cdx.Component{BOMRef: img.Name, Type: cdx.ComponentTypeContainer, Name: img.Name} + container.ExternalReferences = imgBOM.ExternalReferences + container.Properties = imgBOM.Properties + imgComponents := lo.FromPtr(imgBOM.Components) if imgBOM.Metadata != nil && imgBOM.Metadata.Component != nil { - root := imgBOM.Metadata.Component - container = *root - container.BOMRef = img.Name - container.Type = cdx.ComponentTypeContainer - container.Name = img.Name - - if root.BOMRef != "" { - cyclonedxutil.RewriteRefs(imgBOM, map[string]string{root.BOMRef: img.Name}) - } + root := *imgBOM.Metadata.Component + container.Version = root.Version + imgComponents = append([]cdx.Component{root}, imgComponents...) } - container.ExternalReferences = imgBOM.ExternalReferences - container.Properties = imgBOM.Properties - setMissingGOSTOnComponent(&container, img.GOST) - imgComponents := lo.FromPtr(imgBOM.Components) if len(imgComponents) > 0 { container.Components = &imgComponents } diff --git a/pkg/sbom/ispras/image_sbom.go b/pkg/sbom/ispras/image_sbom.go index fd94003e27..8398d0f60b 100644 --- a/pkg/sbom/ispras/image_sbom.go +++ b/pkg/sbom/ispras/image_sbom.go @@ -6,9 +6,14 @@ import ( ) func NewImageSBOM(name string, bom *cdx.BOM) *ImageSBOM { + components := lo.FromPtr(bom.Components) + if bom.Metadata != nil && bom.Metadata.Component != nil { + components = append([]cdx.Component{*bom.Metadata.Component}, components...) + } + return &ImageSBOM{ Name: name, BOM: bom, - GOST: aggregateGOST(lo.FromPtr(bom.Components)), + GOST: aggregateGOST(components), } } From 7cd9867ee5a4d3a44d30ad4706386aee47e2b01f Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 18 Sep 2026 03:11:21 +0300 Subject: [PATCH 3/3] docs(sbom): describe the attack surface split between image and packages 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 | 17 +++++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/pages_en/usage/build/sbom.md b/docs/pages_en/usage/build/sbom.md index 81ac42fd26..5ed71f8f0b 100644 --- a/docs/pages_en/usage/build/sbom.md +++ b/docs/pages_en/usage/build/sbom.md @@ -111,11 +111,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`). +The unit of accounting is the image. `attackSurface: yes` states that the image itself exposes an interface to an attacker, so the image component carries `yes` while the packages it contains — reachable only through the image — carry `indirect`. `no` and `indirect`, and `securityFunction` in all cases, apply unchanged to the image and every package below it. + 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 d8714481e5..e361548f4a 100644 --- a/docs/pages_ru/usage/build/sbom.md +++ b/docs/pages_ru/usage/build/sbom.md @@ -111,11 +111,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` означает, что интерфейс нарушителю предоставляет сам образ, поэтому компонент образа получает `yes`, а входящие в него пакеты — доступные только через образ — получают `indirect`. Значения `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 854cb3c17b..149f69d350 100644 --- a/test/e2e/sbom/lifecycle_test.go +++ b/test/e2e/sbom/lifecycle_test.go @@ -103,7 +103,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` belongs to the image itself; its packages are reachable + // only through it and carry `indirect`. + sbomtest.AssertGostPropertyOnComponent(merged, "jq", "1.8.1", gost.PropertyAttackSurface, gost.GostValueIndirect) + sbomtest.AssertGostPropertyOnComponent(merged, "yq", "4.48.1", 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 2a6cb862c2..2e1b54918e 100644 --- a/test/pkg/sbom/helpers.go +++ b/test/pkg/sbom/helpers.go @@ -207,6 +207,23 @@ 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: the image root carries the +// configured attack surface while the packages below it carry the value derived +// for descendants — see gost.Config.ForDescendants. +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