diff --git a/docs/pages_en/usage/build/sbom.md b/docs/pages_en/usage/build/sbom.md index 7ba2bdec5d..ffc2963250 100644 --- a/docs/pages_en/usage/build/sbom.md +++ b/docs/pages_en/usage/build/sbom.md @@ -133,6 +133,12 @@ build: securityFunction: no ``` +### Source languages (`GOST:source_langs`) + +The `GOST:source_langs` property is filled in automatically and needs no configuration: every component cataloged through a `packages` directive gets the source language of that directive's ecosystem (`go-mod` — `Go`, `python-pip`/`python-poetry`/`python-uv` — `Python`, `rust-cargo` — `Rust`, `javascript-npm`/`javascript-yarn`/`javascript-pnpm` — `JavaScript`, `lua-rock` — `Lua`). + +Packages installed by `os-pm` are prebuilt binaries of an arbitrary language, so they carry no source language. When SBOMs are merged with `werf sbom merge`, the languages of all images are collected on the product component, and in the `container` format the languages of an image's components are additionally collected on that image's container component. + ## VCS external references enrichment When SBOM is enabled, werf enriches components with VCS external references at build time via an external purl resolution service. The service URL is set with the `WERF_EXTERNAL_REFS_SERVER_URL` environment variable (there is no CLI flag): diff --git a/docs/pages_ru/usage/build/sbom.md b/docs/pages_ru/usage/build/sbom.md index b8a1a784a2..d2a7d2c79a 100644 --- a/docs/pages_ru/usage/build/sbom.md +++ b/docs/pages_ru/usage/build/sbom.md @@ -133,6 +133,12 @@ build: securityFunction: no ``` +### Языки исходного кода (`GOST:source_langs`) + +Свойство `GOST:source_langs` заполняется автоматически и не требует настройки: каждый компонент, найденный по директиве `packages`, получает язык исходного кода экосистемы этой директивы (`go-mod` — `Go`, `python-pip`/`python-poetry`/`python-uv` — `Python`, `rust-cargo` — `Rust`, `javascript-npm`/`javascript-yarn`/`javascript-pnpm` — `JavaScript`, `lua-rock` — `Lua`). + +Пакеты, устанавливаемые через `os-pm`, представляют собой собранные бинарные файлы на произвольном языке, поэтому язык исходного кода для них не указывается. При объединении SBOM командой `werf sbom merge` языки всех образов собираются на компоненте продукта, а в формате `container` языки компонентов образа дополнительно собираются на container-компоненте этого образа. + ## Обогащение VCS external references При включённом SBOM werf на этапе сборки обогащает компоненты VCS external references через внешний сервис разрешения purl. URL сервиса задаётся переменной окружения `WERF_EXTERNAL_REFS_SERVER_URL` (CLI-флага нет): diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index 019c604b69..56bb7a2dc8 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_step.go @@ -9,6 +9,7 @@ import ( "time" cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/samber/lo" "github.com/sigstore/sigstore/pkg/signature" "github.com/werf/common-go/pkg/util" @@ -274,10 +275,14 @@ func (step *sbomStep) scanCatalogerDir(ctx context.Context, scanOpts scanner.Sca // post-scan source-path filter safe (see SYFT_FILE_METADATA_SELECTION in the docker backend). cyclonedxutil.DropSyftSourceFileComponents(bom) + for i := range lo.FromPtr(bom.Components) { + gost.SetComponentSourceLangs(&(*bom.Components)[i], []string{cataloger.SourceLang}) + } + return bom, nil } -const sbomArtifactFormatVersion = "4" +const sbomArtifactFormatVersion = "5" // 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 diff --git a/pkg/build/sbom_step_test.go b/pkg/build/sbom_step_test.go index 6a1556ba01..5d34f5b5cf 100644 --- a/pkg/build/sbom_step_test.go +++ b/pkg/build/sbom_step_test.go @@ -146,8 +146,8 @@ var _ = Describe("SbomStep", func() { imageRef := "app:latest" catalogers := []scanner.Cataloger{ - {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/go.mod", "/app/go.sum"}}, - {Name: "python-package-cataloger", SourcePaths: []string{"/svc/requirements.txt"}}, + {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/go.mod", "/app/go.sum"}, SourceLang: "Go"}, + {Name: "python-package-cataloger", SourcePaths: []string{"/svc/requirements.txt"}, SourceLang: "Python"}, } mockBackend.EXPECT(). @@ -193,6 +193,16 @@ var _ = Describe("SbomStep", func() { Expect(names).To(ConsistOf("github.com/samber/lo", "flask"), "components from both directives are unioned and the source file is dropped") Expect(names).ToNot(ContainElement("go.mod")) + langsByName := map[string][]string{} + for i := range *bom.Components { + comp := &(*bom.Components)[i] + langsByName[comp.Name] = gost.GetComponentSourceLangs(comp) + } + Expect(langsByName).To(Equal(map[string][]string{ + "github.com/samber/lo": {"Go"}, + "flask": {"Python"}, + }), "each component carries the source language of the directive that cataloged it") + Expect(bom.Metadata).ToNot(BeNil()) Expect(bom.Metadata.Timestamp).To(Equal("2026-01-01T00:00:00Z"), "syft metadata (timestamp) from the first directive is preserved, not discarded") }) diff --git a/pkg/config/packages_directive.go b/pkg/config/packages_directive.go index 834825fac7..5463c44b29 100644 --- a/pkg/config/packages_directive.go +++ b/pkg/config/packages_directive.go @@ -38,6 +38,10 @@ type PackageEcosystem struct { DefaultLockFile string InstallCmd func(workdir string, files FileBasedSpec, pkgs []string, env map[string]string) string CatalogerName string + // SourceLang is the source language of the packages this ecosystem installs, as + // reported in the GOST:source_langs property. Empty when the ecosystem installs + // packages of an arbitrary language: os-pm distributes prebuilt binaries. + SourceLang string } var ecosystems = map[PackagesDirectiveType]PackageEcosystem{ @@ -53,6 +57,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{ return cmd }, CatalogerName: "go-module-file-cataloger", + SourceLang: "Go", }, PackagesDirectiveTypePythonUV: { Type: PackagesDirectiveTypePythonUV, @@ -66,6 +71,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{ return cmd }, CatalogerName: "python-package-cataloger", + SourceLang: "Python", }, PackagesDirectiveTypePythonPip: { Type: PackagesDirectiveTypePythonPip, @@ -79,6 +85,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{ return cmd }, CatalogerName: "python-package-cataloger", + SourceLang: "Python", }, PackagesDirectiveTypePythonPoetry: { Type: PackagesDirectiveTypePythonPoetry, @@ -92,6 +99,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{ return cmd }, CatalogerName: "python-package-cataloger", + SourceLang: "Python", }, PackagesDirectiveTypeRustCargo: { Type: PackagesDirectiveTypeRustCargo, @@ -105,6 +113,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{ return cmd }, CatalogerName: "rust-cargo-lock-cataloger", + SourceLang: "Rust", }, PackagesDirectiveTypeJavaScriptNpm: { Type: PackagesDirectiveTypeJavaScriptNpm, @@ -118,6 +127,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{ return cmd }, CatalogerName: "javascript-lock-cataloger", + SourceLang: "JavaScript", }, PackagesDirectiveTypeJavaScriptYarn: { Type: PackagesDirectiveTypeJavaScriptYarn, @@ -131,6 +141,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{ return cmd }, CatalogerName: "javascript-lock-cataloger", + SourceLang: "JavaScript", }, PackagesDirectiveTypeJavaScriptPnpm: { Type: PackagesDirectiveTypeJavaScriptPnpm, @@ -144,6 +155,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{ return cmd }, CatalogerName: "javascript-lock-cataloger", + SourceLang: "JavaScript", }, PackagesDirectiveTypeLuaRock: { Type: PackagesDirectiveTypeLuaRock, @@ -157,6 +169,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{ return cmd }, CatalogerName: "lua-rock-cataloger", + SourceLang: "Lua", }, PackagesDirectiveTypeOSPM: { Type: PackagesDirectiveTypeOSPM, diff --git a/pkg/config/packages_directive_test.go b/pkg/config/packages_directive_test.go index a300c72b4f..cd3c6af1d0 100644 --- a/pkg/config/packages_directive_test.go +++ b/pkg/config/packages_directive_test.go @@ -14,5 +14,23 @@ var _ = Describe("package ecosystem registration", func() { Expect(ecosystem.DefaultSpecFile).To(BeEmpty()) Expect(ecosystem.DefaultLockFile).To(BeEmpty()) Expect(ecosystem.CatalogerName).To(Equal(metadata.CatalogerName)) + Expect(ecosystem.SourceLang).To(BeEmpty()) }) + + DescribeTable("registers the source language of a file-based ecosystem", + func(directiveType PackagesDirectiveType, expectedLang string) { + ecosystem, ok := Ecosystems()[directiveType] + Expect(ok).To(BeTrue()) + Expect(ecosystem.SourceLang).To(Equal(expectedLang)) + }, + Entry("go-mod", PackagesDirectiveTypeGoMod, "Go"), + Entry("python-uv", PackagesDirectiveTypePythonUV, "Python"), + Entry("python-pip", PackagesDirectiveTypePythonPip, "Python"), + Entry("python-poetry", PackagesDirectiveTypePythonPoetry, "Python"), + Entry("rust-cargo", PackagesDirectiveTypeRustCargo, "Rust"), + Entry("javascript-npm", PackagesDirectiveTypeJavaScriptNpm, "JavaScript"), + Entry("javascript-yarn", PackagesDirectiveTypeJavaScriptYarn, "JavaScript"), + Entry("javascript-pnpm", PackagesDirectiveTypeJavaScriptPnpm, "JavaScript"), + Entry("lua-rock", PackagesDirectiveTypeLuaRock, "Lua"), + ) }) diff --git a/pkg/sbom/cyclonedxutil/gost/accessor.go b/pkg/sbom/cyclonedxutil/gost/accessor.go index 3ab7171225..adf805b938 100644 --- a/pkg/sbom/cyclonedxutil/gost/accessor.go +++ b/pkg/sbom/cyclonedxutil/gost/accessor.go @@ -30,12 +30,11 @@ func (a *accessor) SetSecurityFunction(val GostValue) { } func (a *accessor) getProperty(name string) (GostValue, bool) { - for _, prop := range lo.FromPtr(a.comp.Properties) { - if prop.Name == name { - return GostValue(prop.Value), true - } + raw, found := a.getRawProperty(name) + if !found { + return GostValueUndefined, false } - return GostValueUndefined, false + return GostValue(raw), true } func (a *accessor) setProperty(name string, val GostValue) { @@ -43,10 +42,23 @@ func (a *accessor) setProperty(name string, val GostValue) { return } + a.setRawProperty(name, val.String()) +} + +func (a *accessor) getRawProperty(name string) (string, bool) { + for _, prop := range lo.FromPtr(a.comp.Properties) { + if prop.Name == name { + return prop.Value, true + } + } + return "", false +} + +func (a *accessor) setRawProperty(name, value string) { // update case for i, prop := range lo.FromPtr(a.comp.Properties) { if prop.Name == name { - (*a.comp.Properties)[i].Value = val.String() + (*a.comp.Properties)[i].Value = value return } } @@ -58,6 +70,6 @@ func (a *accessor) setProperty(name string, val GostValue) { // insert case *a.comp.Properties = append(*a.comp.Properties, cdx.Property{ Name: name, - Value: val.String(), + Value: value, }) } diff --git a/pkg/sbom/cyclonedxutil/gost/source_langs.go b/pkg/sbom/cyclonedxutil/gost/source_langs.go new file mode 100644 index 0000000000..d368e4cba9 --- /dev/null +++ b/pkg/sbom/cyclonedxutil/gost/source_langs.go @@ -0,0 +1,57 @@ +package gost + +import ( + "strings" + + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/samber/lo" +) + +const PropertySourceLangs = "GOST:source_langs" + +const sourceLangsSeparator = ", " + +// SetComponentSourceLangs writes the source languages of a component as a single +// comma-separated GOST:source_langs property. Consumers of the listing read only the +// first property with that name, so the languages must not be spread over repeated ones. +func SetComponentSourceLangs(comp *cdx.Component, langs []string) { + normalized := normalizeSourceLangs(langs) + if len(normalized) == 0 { + return + } + + newAccessor(comp).setRawProperty(PropertySourceLangs, strings.Join(normalized, sourceLangsSeparator)) +} + +func GetComponentSourceLangs(comp *cdx.Component) []string { + raw, found := newAccessor(comp).getRawProperty(PropertySourceLangs) + if !found { + return nil + } + + return normalizeSourceLangs(strings.Split(raw, ",")) +} + +// CollectSourceLangs unions the source languages of the given components and their +// nested ones, in order of first appearance. +func CollectSourceLangs(components []cdx.Component) []string { + var langs []string + for i := range components { + langs = append(langs, GetComponentSourceLangs(&components[i])...) + langs = append(langs, CollectSourceLangs(lo.FromPtr(components[i].Components))...) + } + + return normalizeSourceLangs(langs) +} + +func normalizeSourceLangs(langs []string) []string { + trimmed := lo.FilterMap(langs, func(lang string, _ int) (string, bool) { + lang = strings.TrimSpace(lang) + return lang, lang != "" + }) + if len(trimmed) == 0 { + return nil + } + + return lo.Uniq(trimmed) +} diff --git a/pkg/sbom/cyclonedxutil/gost/source_langs_test.go b/pkg/sbom/cyclonedxutil/gost/source_langs_test.go new file mode 100644 index 0000000000..04d93e032f --- /dev/null +++ b/pkg/sbom/cyclonedxutil/gost/source_langs_test.go @@ -0,0 +1,91 @@ +package gost + +import ( + cdx "github.com/CycloneDX/cyclonedx-go" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/samber/lo" +) + +var _ = Describe("Gost source languages", func() { + DescribeTable("SetComponentSourceLangs", + func(comp *cdx.Component, langs []string, expectedProperties []cdx.Property) { + SetComponentSourceLangs(comp, langs) + Expect(lo.FromPtr(comp.Properties)).To(Equal(expectedProperties)) + }, + Entry("should add a single property with the languages joined", + &cdx.Component{Name: "test"}, + []string{"Go", "Python"}, + []cdx.Property{{Name: PropertySourceLangs, Value: "Go, Python"}}), + Entry("should skip empty and duplicate languages", + &cdx.Component{Name: "test"}, + []string{"Go", " ", "Go", "", "Python"}, + []cdx.Property{{Name: PropertySourceLangs, Value: "Go, Python"}}), + Entry("should keep the component untouched when no language is given", + &cdx.Component{Name: "test"}, + []string{""}, + nil), + Entry("should update an existing property instead of adding a second one", + &cdx.Component{ + Name: "test", + Properties: &[]cdx.Property{{Name: PropertySourceLangs, Value: "Go"}}, + }, + []string{"Rust"}, + []cdx.Property{{Name: PropertySourceLangs, Value: "Rust"}}), + ) + + DescribeTable("GetComponentSourceLangs", + func(comp *cdx.Component, expected []string) { + Expect(GetComponentSourceLangs(comp)).To(Equal(expected)) + }, + Entry("should return nil when the property is missing", + &cdx.Component{Name: "test"}, nil), + Entry("should split the comma-separated value", + &cdx.Component{ + Name: "test", + Properties: &[]cdx.Property{{Name: PropertySourceLangs, Value: "Go, Python"}}, + }, + []string{"Go", "Python"}), + Entry("should tolerate irregular separators", + &cdx.Component{ + Name: "test", + Properties: &[]cdx.Property{{Name: PropertySourceLangs, Value: "Go,, Rust ,"}}, + }, + []string{"Go", "Rust"}), + ) + + DescribeTable("CollectSourceLangs", + func(components []cdx.Component, expected []string) { + Expect(CollectSourceLangs(components)).To(Equal(expected)) + }, + Entry("should return nil for components without languages", + []cdx.Component{{Name: "test"}}, nil), + Entry("should union languages in order of first appearance", + []cdx.Component{ + {Name: "a", Properties: &[]cdx.Property{{Name: PropertySourceLangs, Value: "Python"}}}, + {Name: "b", Properties: &[]cdx.Property{{Name: PropertySourceLangs, Value: "Go, Python"}}}, + }, + []string{"Python", "Go"}), + Entry("should include nested components", + []cdx.Component{ + { + Name: "container", + Properties: &[]cdx.Property{{Name: PropertySourceLangs, Value: "Go"}}, + Components: &[]cdx.Component{ + {Name: "nested", Properties: &[]cdx.Property{{Name: PropertySourceLangs, Value: "Lua"}}}, + }, + }, + }, + []string{"Go", "Lua"}), + ) + + It("should not be affected by an Upsert of the other GOST properties", func() { + comp := cdx.Component{Name: "test"} + SetComponentSourceLangs(&comp, []string{"Go"}) + + bom := &cdx.BOM{Components: &[]cdx.Component{comp}} + Expect(Upsert(bom, Config{AttackSurface: GostValueYes, SecurityFunction: GostValueNo})).To(Succeed()) + + Expect(GetComponentSourceLangs(&(*bom.Components)[0])).To(Equal([]string{"Go"})) + }) +}) diff --git a/pkg/sbom/ispras/assembler.go b/pkg/sbom/ispras/assembler.go index 1f8fded01e..e5c743fb07 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/v2/pkg/sbom/cyclonedxutil/gost" ) type Assembler interface { @@ -23,7 +25,7 @@ func NewAssembler(format Format) (Assembler, error) { } } -func buildProductMetadata(meta ProductMeta) *cdx.Metadata { +func buildProductMetadata(meta ProductMeta, sourceLangs []string) *cdx.Metadata { metaComponent := &cdx.Component{ Type: cdx.ComponentTypeApplication, Name: meta.AppName, @@ -33,6 +35,8 @@ func buildProductMetadata(meta ProductMeta) *cdx.Metadata { }, } + gost.SetComponentSourceLangs(metaComponent, sourceLangs) + return &cdx.Metadata{ Timestamp: time.Now().UTC().Format(time.RFC3339), Component: metaComponent, diff --git a/pkg/sbom/ispras/container.go b/pkg/sbom/ispras/container.go index 93a55e2dda..ae4796fa47 100644 --- a/pkg/sbom/ispras/container.go +++ b/pkg/sbom/ispras/container.go @@ -51,7 +51,7 @@ func (a *ContainerAssembler) Assemble(_ context.Context, images []*ImageSBOM, me result.Components = nil } - result.Metadata = buildProductMetadata(meta) + result.Metadata = buildProductMetadata(meta, aggregateSourceLangs(images)) return result, nil } diff --git a/pkg/sbom/ispras/gost.go b/pkg/sbom/ispras/gost.go index 795bfc2aad..d336fdb5c7 100644 --- a/pkg/sbom/ispras/gost.go +++ b/pkg/sbom/ispras/gost.go @@ -1,7 +1,10 @@ package ispras import ( + "sort" + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/samber/lo" "github.com/werf/werf/v2/pkg/sbom/cyclonedxutil/gost" ) @@ -20,9 +23,24 @@ func aggregateGOST(components []cdx.Component) GOSTValues { result.AttackSurface = maxGOSTValue(result.AttackSurface, cfg.AttackSurface) result.SecurityFunction = maxGOSTValue(result.SecurityFunction, cfg.SecurityFunction) } + result.SourceLangs = gost.CollectSourceLangs(components) return result } +// aggregateSourceLangs unions the source languages of the images. The result is sorted: +// images are assembled in a non-deterministic order, and the product SBOM has to stay +// comparable across runs. +func aggregateSourceLangs(images []*ImageSBOM) []string { + var langs []string + for _, img := range images { + langs = append(langs, img.GOST.SourceLangs...) + } + langs = lo.Uniq(langs) + sort.Strings(langs) + + return langs +} + func maxGOSTValue(a, b gost.GostValue) gost.GostValue { if gostPrecedence[b] > gostPrecedence[a] { return b @@ -47,4 +65,6 @@ func setMissingGOSTOnComponent(comp *cdx.Component, values GOSTValues) { AttackSurface: attack, SecurityFunction: security, }) + + gost.SetComponentSourceLangs(comp, append(gost.GetComponentSourceLangs(comp), values.SourceLangs...)) } diff --git a/pkg/sbom/ispras/gost_test.go b/pkg/sbom/ispras/gost_test.go new file mode 100644 index 0000000000..bae0f1146f --- /dev/null +++ b/pkg/sbom/ispras/gost_test.go @@ -0,0 +1,87 @@ +package ispras + +import ( + "context" + + cdx "github.com/CycloneDX/cyclonedx-go" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/werf/werf/v2/pkg/sbom/cyclonedxutil/gost" +) + +func componentWithLangs(name, langs string) cdx.Component { + comp := cdx.Component{ + Type: cdx.ComponentTypeLibrary, + BOMRef: name, + Name: name, + Properties: &[]cdx.Property{{Name: gost.PropertyAttackSurface, Value: gost.GostValueYes.String()}}, + } + if langs != "" { + *comp.Properties = append(*comp.Properties, cdx.Property{Name: gost.PropertySourceLangs, Value: langs}) + } + + return comp +} + +func imageSBOM(name string, components ...cdx.Component) *ImageSBOM { + return NewImageSBOM(name, &cdx.BOM{ + BOMFormat: "CycloneDX", + SpecVersion: cdx.SpecVersion1_6, + Components: &components, + }) +} + +var _ = Describe("Gost source languages aggregation", func() { + It("aggregates the languages of the image components", func() { + img := imageSBOM("backend", componentWithLangs("a", "Go"), componentWithLangs("b", "Python"), componentWithLangs("c", "")) + + Expect(img.GOST.SourceLangs).To(Equal([]string{"Go", "Python"})) + }) + + It("sets the union of the image languages on the container component", func() { + bom, err := (&ContainerAssembler{}).Assemble( + context.Background(), + []*ImageSBOM{imageSBOM("backend", componentWithLangs("a", "Go"), componentWithLangs("b", "Python"))}, + ProductMeta{AppName: "product", AppVersion: "1.0"}, + ) + Expect(err).To(Succeed()) + + containers := *bom.Components + Expect(containers).To(HaveLen(1)) + Expect(gost.GetComponentSourceLangs(&containers[0])).To(Equal([]string{"Go", "Python"})) + }) + + It("unions the image languages with the ones already set on the container component", func() { + img := imageSBOM("backend", componentWithLangs("a", "Go")) + img.BOM.Properties = &[]cdx.Property{{Name: gost.PropertySourceLangs, Value: "Rust"}} + + bom, err := (&ContainerAssembler{}).Assemble( + context.Background(), + []*ImageSBOM{img}, + ProductMeta{AppName: "product", AppVersion: "1.0"}, + ) + Expect(err).To(Succeed()) + + containers := *bom.Components + Expect(gost.GetComponentSourceLangs(&containers[0])).To(Equal([]string{"Rust", "Go"})) + }) + + DescribeTable("sets the union of all image languages on the product component", + func(assembler Assembler) { + bom, err := assembler.Assemble( + context.Background(), + []*ImageSBOM{ + imageSBOM("backend", componentWithLangs("a", "Go")), + imageSBOM("frontend", componentWithLangs("b", "JavaScript"), componentWithLangs("c", "Go")), + }, + ProductMeta{AppName: "product", AppVersion: "1.0"}, + ) + Expect(err).To(Succeed()) + + Expect(gost.GetComponentSourceLangs(bom.Metadata.Component)).To(Equal([]string{"Go", "JavaScript"})) + }, + Entry("container format", &ContainerAssembler{}), + Entry("oss format", &OSSAssembler{}), + ) +}) diff --git a/pkg/sbom/ispras/oss.go b/pkg/sbom/ispras/oss.go index 8c39a4e295..3e659e9093 100644 --- a/pkg/sbom/ispras/oss.go +++ b/pkg/sbom/ispras/oss.go @@ -21,7 +21,7 @@ func (a *OSSAssembler) Assemble(_ context.Context, images []*ImageSBOM, meta Pro return nil, fmt.Errorf("merge image BOMs: %w", err) } - result.Metadata = buildProductMetadata(meta) + result.Metadata = buildProductMetadata(meta, aggregateSourceLangs(images)) return result, nil } diff --git a/pkg/sbom/ispras/suite_test.go b/pkg/sbom/ispras/suite_test.go new file mode 100644 index 0000000000..926dd23d82 --- /dev/null +++ b/pkg/sbom/ispras/suite_test.go @@ -0,0 +1,13 @@ +package ispras + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestIspras(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Ispras Suite") +} diff --git a/pkg/sbom/ispras/types.go b/pkg/sbom/ispras/types.go index 44e08c562b..07d944234e 100644 --- a/pkg/sbom/ispras/types.go +++ b/pkg/sbom/ispras/types.go @@ -15,6 +15,7 @@ type ImageSBOM struct { type GOSTValues struct { AttackSurface gost.GostValue SecurityFunction gost.GostValue + SourceLangs []string } type ProductMeta struct { diff --git a/pkg/sbom/managedinput/managedinput.go b/pkg/sbom/managedinput/managedinput.go index 6cbe1ed089..5f9f05d0db 100644 --- a/pkg/sbom/managedinput/managedinput.go +++ b/pkg/sbom/managedinput/managedinput.go @@ -13,6 +13,7 @@ import ( type inputResolver struct { inputType config.PackagesDirectiveType catalogerName string + sourceLang string sourcePaths func(directive *config.PackagesDirective) []string } @@ -39,6 +40,7 @@ func buildResolvers() []inputResolver { built = append(built, inputResolver{ inputType: eco.Type, catalogerName: eco.CatalogerName, + sourceLang: eco.SourceLang, sourcePaths: func(d *config.PackagesDirective) []string { paths := []string{path.Join(d.FileBased.Workdir, d.FileBased.Spec)} if d.FileBased.Lock != "" { @@ -65,6 +67,7 @@ func ToCatalogers(packages []*config.PackagesDirective) []scanner.Cataloger { catalogers = append(catalogers, scanner.Cataloger{ Name: res.catalogerName, SourcePaths: res.sourcePaths(directive), + SourceLang: res.sourceLang, }) } diff --git a/pkg/sbom/managedinput/managedinput_test.go b/pkg/sbom/managedinput/managedinput_test.go index 57776bbc4d..71c132abb2 100644 --- a/pkg/sbom/managedinput/managedinput_test.go +++ b/pkg/sbom/managedinput/managedinput_test.go @@ -59,8 +59,8 @@ var _ = Describe("ToCatalogers", func() { }, }, []scanner.Cataloger{ - {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/api/go.mod", "/app/api/go.sum"}}, - {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/cli/go.mod", "/app/cli/go.sum"}}, + {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/api/go.mod", "/app/api/go.sum"}, SourceLang: "Go"}, + {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/cli/go.mod", "/app/cli/go.sum"}, SourceLang: "Go"}, }, ), @@ -96,7 +96,19 @@ var _ = Describe("ToCatalogers", func() { }, }, []scanner.Cataloger{ - {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/go.mod", "/app/go.sum"}}, + {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/go.mod", "/app/go.sum"}, SourceLang: "Go"}, + }, + ), + + Entry("the cataloger carries the source language of its ecosystem", + []*config.PackagesDirective{ + { + Type: config.PackagesDirectiveTypePythonPip, + FileBased: config.FileBasedSpec{Workdir: "/app", Spec: "requirements.txt"}, + }, + }, + []scanner.Cataloger{ + {Name: "python-package-cataloger", SourcePaths: []string{"/app/requirements.txt"}, SourceLang: "Python"}, }, ), diff --git a/pkg/sbom/scanner/cataloger.go b/pkg/sbom/scanner/cataloger.go index 95e910f142..4da1239b6a 100644 --- a/pkg/sbom/scanner/cataloger.go +++ b/pkg/sbom/scanner/cataloger.go @@ -6,4 +6,5 @@ package scanner type Cataloger struct { Name string SourcePaths []string + SourceLang string } diff --git a/pkg/sbom/scanner/scan_command.go b/pkg/sbom/scanner/scan_command.go index 0ebfb49ae6..cee8678b64 100644 --- a/pkg/sbom/scanner/scan_command.go +++ b/pkg/sbom/scanner/scan_command.go @@ -90,7 +90,7 @@ func (c ScanCommand) Checksum() string { } for _, cat := range c.Catalogers { - args = append(args, "cataloger", cat.Name) + args = append(args, "cataloger", cat.Name, cat.SourceLang) args = append(args, cat.SourcePaths...) } diff --git a/test/e2e/sbom/gost_test.go b/test/e2e/sbom/gost_test.go index 4f4cbbcc4f..2ec48fa47b 100644 --- a/test/e2e/sbom/gost_test.go +++ b/test/e2e/sbom/gost_test.go @@ -108,4 +108,35 @@ var _ = Describe("SBOM GOST integration", Label("e2e", "sbom", "gost", "simple") XEntry("with local repo using Native Buildah with chroot isolation", sbomTestOptions{setupEnvOptions{ContainerBackendMode: "native-chroot"}}), XEntry("with local repo using Native Buildah with rootless isolation", sbomTestOptions{setupEnvOptions{ContainerBackendMode: "native-rootless"}}), ) + + DescribeTable("the source language of the packages directive lands on its components", + func(ctx SpecContext, testOpts sbomTestOptions, ecosystem, fixture, componentName, componentVersion, expectedLang string) { + setupSbomBuildEnv(testOpts.setupEnvOptions) + + repoDirname := "repo_sbom_gost_source_langs_" + ecosystem + SuiteData.InitTestRepo(ctx, repoDirname, fixture) + testRepoPath := SuiteData.GetTestRepoPath(repoDirname) + + builderEnv := buildTrustedBuilderBase(ctx, testRepoPath, "sbom-gost-source-langs-builder-"+ecosystem) + + werfProject := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + werfProject.Build(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{Envs: builderEnv}}) + + sbomOut := werfProject.SbomGet(ctx, &werf.SbomGetOptions{ + CommonOptions: werf.CommonOptions{ + ExtraArgs: []string{"app"}, + Envs: builderEnv, + }, + }) + + bom := sbomtest.MustParseSBOMOutput(sbomOut) + sbomtest.AssertSourceLangsOnComponent(bom, componentName, componentVersion, []string{expectedLang}) + }, + Entry("python-pip using Vanilla Docker", + sbomTestOptions{setupEnvOptions{ContainerBackendMode: "vanilla-docker"}}, + "pip", "inject/pip_simple", "requests", "2.32.3", "Python"), + Entry("javascript-npm using Vanilla Docker", + sbomTestOptions{setupEnvOptions{ContainerBackendMode: "vanilla-docker"}}, + "npm", "inject/npm_simple", "lodash", "4.17.21", "JavaScript"), + ) }) diff --git a/test/pkg/sbom/helpers.go b/test/pkg/sbom/helpers.go index d023062d26..d43f5ca100 100644 --- a/test/pkg/sbom/helpers.go +++ b/test/pkg/sbom/helpers.go @@ -255,6 +255,16 @@ func AssertGostPropertyOnComponents(bom *cdx.BOM, propertyName string, expected "BOM has no components to assert GOST property on") } +// AssertSourceLangsOnComponent asserts the GOST:source_langs property of a single +// component, identified by name and version. +func AssertSourceLangsOnComponent(bom *cdx.BOM, name, version string, expected []string) { + comp := FindComponent(bom, name, version) + ExpectWithOffset(1, comp).NotTo(BeNil(), + "component %s@%s not found in BOM", name, version) + ExpectWithOffset(1, gost.GetComponentSourceLangs(comp)).To(Equal(expected), + "component %s@%s GOST source languages", name, version) +} + func AssertSpecVersion(bom *cdx.BOM, expected cdx.SpecVersion) { ExpectWithOffset(1, bom.SpecVersion).To(Equal(expected), "expected spec version %q, got %q", expected, bom.SpecVersion)