diff --git a/cmd/werf/sbom/merge/merge_docs.go b/cmd/werf/sbom/merge/merge_docs.go index 79d295bf61..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 (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". 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.` @@ -23,8 +23,9 @@ 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`. 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/_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/_includes/reference/cli/werf_sbom_merge.md b/docs/_includes/reference/cli/werf_sbom_merge.md index dd687a03fd..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 (`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`. 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 7edfc34f77..9ce9f20341 100644 --- a/docs/pages_en/usage/build/sbom.md +++ b/docs/pages_en/usage/build/sbom.md @@ -113,10 +113,12 @@ 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`). +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. You can define these globally in `build.sbom.gost` or per-image in `image.sbom.gost`. Image-level configuration overrides global configuration. @@ -175,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`. 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 290bf5fd1e..8bcdae225f 100644 --- a/docs/pages_ru/usage/build/sbom.md +++ b/docs/pages_ru/usage/build/sbom.md @@ -113,10 +113,12 @@ 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`). +2. `securityFunction`: Свойство функции безопасности (`yes` | `no`). + +`attackSurface: yes` следует дереву зависимостей из секции `dependencies`: значение получают компоненты, от которых ничто не зависит, а каждый компонент, который потянул за собой другой, записывается как `indirect`. Если каталогеры экосистемы вообще не сообщают дерево зависимостей, корнями считаются все компоненты и каждый получает `yes`. Значения `no` и `indirect`, а также `securityFunction` в любом случае, применяются ко всему дереву без изменений. Эти свойства можно определить глобально в `build.sbom.gost` или для конкретного образа в `image.sbom.gost`. Конфигурация на уровне образа переопределяет глобальную конфигурацию. @@ -175,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`. 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`. diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index 44954e5e37..446ed3dac8 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() { @@ -397,19 +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) - } - if err := gost.Upsert(mergeOpts.BaseBOM, mergeOpts.Gost); err != nil { - return fmt.Errorf("set GOST properties for base SBOM: %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) - } - if err := gost.Upsert(externalBOM, mergeOpts.Gost); err != nil { - return fmt.Errorf("set GOST properties for external SBOM [%d]: %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) } } 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/bomref.go b/pkg/sbom/cyclonedxutil/bomref.go index e045cae185..3d09634cc3 100644 --- a/pkg/sbom/cyclonedxutil/bomref.go +++ b/pkg/sbom/cyclonedxutil/bomref.go @@ -54,37 +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) - } + 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 { + d.components(bom.Metadata.Tools.Components) + d.services(bom.Metadata.Tools.Services) } - 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) - } + for i := range lo.FromPtr(bom.Formulation) { + formula := &(*bom.Formulation)[i] + d.derive(&formula.BOMRef, "") + d.components(formula.Components) + d.services(formula.Services) + } + for i := range lo.FromPtr(bom.Vulnerabilities) { + d.derive(&(*bom.Vulnerabilities)[i].BOMRef, "") + } + for i := range lo.FromPtr(bom.Compositions) { + d.derive(&(*bom.Compositions)[i].BOMRef, "") } - walkServices(bom.Services) + for i := range lo.FromPtr(bom.Annotations) { + d.derive(&(*bom.Annotations)[i].BOMRef, "") + } + + RewriteRefs(bom, d.refMap) +} - RewriteRefs(bom, 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/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/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/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.go b/pkg/sbom/cyclonedxutil/gost/upsert.go index 19a6873b4e..ddda28a74c 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert.go @@ -2,33 +2,179 @@ package gost import ( "fmt" + "maps" + "slices" cdx "github.com/CycloneDX/cyclonedx-go" "github.com/samber/lo" ) // 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") } + dependencyConfig := config + if config.AttackSurface == GostValueYes { + 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) + setComponents(lo.FromPtr(bom.Metadata.Component.Components), config, dependencyConfig, targets) } - setComponents(lo.FromPtr(bom.Components), config) + setComponents(lo.FromPtr(bom.Components), config, dependencyConfig, targets) return nil } -func setComponents(components []cdx.Component, config Config) { +func setComponents(components []cdx.Component, rootConfig, dependencyConfig Config, targets map[string]struct{}) { for i := range components { - SetComponent(&components[i], config) - setComponents(lo.FromPtr(components[i].Components), config) + comp := &components[i] + + cfg := rootConfig + if _, ok := targets[comp.BOMRef]; ok { + cfg = dependencyConfig + } + + SetComponent(comp, cfg) + setComponents(lo.FromPtr(comp.Components), rootConfig, dependencyConfig, targets) + } +} + +// 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. +// +// 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. +// +// 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{} { + componentRefs := make(map[string]struct{}) + collectComponentRefs(lo.FromPtr(bom.Components), componentRefs) + if bom.Metadata != nil && bom.Metadata.Component != nil { + collectComponentRefs(lo.FromPtr(bom.Metadata.Component.Components), componentRefs) + } + + edges := make(map[string][]string) + for _, dep := range lo.FromPtr(bom.Dependencies) { + if _, ok := componentRefs[dep.Ref]; !ok { + continue + } + + edges[dep.Ref] = append(edges[dep.Ref], lo.FromPtr(dep.Dependencies)...) + } + + 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 +} + +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 { + t := &tarjan{ + edges: edges, + index: make(map[string]int), + lowlink: make(map[string]int), + onStack: make(map[string]bool), + componentOf: make(map[string]int), + } + + for _, node := range slices.Sorted(maps.Keys(edges)) { + if _, seen := t.index[node]; !seen { + t.visit(node) + } + } + + 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]) + } + } + + if t.lowlink[node] == t.index[node] { + t.popComponent(node) + } +} + +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. diff --git a/pkg/sbom/cyclonedxutil/gost/upsert_test.go b/pkg/sbom/cyclonedxutil/gost/upsert_test.go index 89bc082c22..05940d42f7 100644 --- a/pkg/sbom/cyclonedxutil/gost/upsert_test.go +++ b/pkg/sbom/cyclonedxutil/gost/upsert_test.go @@ -139,16 +139,201 @@ 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"}, }, }, }, 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("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("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"}}, + 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"}}, + }, + 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"}}, + 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])).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() { + 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)) + }) + + 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)) + }) }) diff --git a/pkg/sbom/cyclonedxutil/gost/validator.go b/pkg/sbom/cyclonedxutil/gost/validator.go index 8637375055..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 !IsValidGostValue(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 !IsValidGostValue(sf.String()) { - return fmt.Errorf("invalid value for %s: %q (expected 'yes', 'no' or 'indirect')", 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/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/pkg/sbom/cyclonedxutil/merge.go b/pkg/sbom/cyclonedxutil/merge.go index d5f4f2d44e..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 @@ -92,6 +95,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..2049e3f5e3 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,55 @@ 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, + })) + }) + + 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")) + }) +}) diff --git a/pkg/sbom/ispras/bomref.go b/pkg/sbom/cyclonedxutil/namespace.go similarity index 67% rename from pkg/sbom/ispras/bomref.go rename to pkg/sbom/cyclonedxutil/namespace.go index 86a4bc6f53..7f5e04b356 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 @@ -48,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)) } } - cyclonedxutil.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 new file mode 100644 index 0000000000..8d610cdfda --- /dev/null +++ b/pkg/sbom/cyclonedxutil/namespace_test.go @@ -0,0 +1,142 @@ +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("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{ + {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.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/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/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/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}), ) }) 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), }) 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 }) 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) }) }) diff --git a/test/e2e/sbom/lifecycle_test.go b/test/e2e/sbom/lifecycle_test.go index 4173674bfa..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") @@ -96,9 +104,21 @@ 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) + 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", @@ -113,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 ef1d526e8d..d41263f41b 100644 --- a/test/pkg/sbom/helpers.go +++ b/test/pkg/sbom/helpers.go @@ -207,11 +207,114 @@ 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 +// 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) + + 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) { @@ -255,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)