Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/pages_en/usage/build/sbom.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/pages_ru/usage/build/sbom.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
13 changes: 13 additions & 0 deletions pkg/sbom/cyclonedxutil/gost/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
10 changes: 7 additions & 3 deletions pkg/sbom/cyclonedxutil/gost/upsert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
36 changes: 31 additions & 5 deletions pkg/sbom/cyclonedxutil/gost/upsert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
},
Expand All @@ -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"},
},
},
Expand Down Expand Up @@ -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"},
},
}},
Expand Down Expand Up @@ -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}),
)
})
34 changes: 28 additions & 6 deletions pkg/sbom/ispras/assembler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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"
Expand All @@ -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"}}}
Expand All @@ -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())
Expand Down
24 changes: 9 additions & 15 deletions pkg/sbom/ispras/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
7 changes: 6 additions & 1 deletion pkg/sbom/ispras/image_sbom.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
5 changes: 4 additions & 1 deletion test/e2e/sbom/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/", "")
Expand Down
17 changes: 17 additions & 0 deletions test/pkg/sbom/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading