From 6e6ae95a2feff62de8958478a31ef2d9378ab77f Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 11 Sep 2026 11:17:27 +0300 Subject: [PATCH 1/4] feat(sbom): report the source language of cataloged packages Every component cataloged through a packages directive now carries the GOST:source_langs property required by the FSTEC component listing: a single property with the languages of the ecosystem that installed the package, which is the form the ISPRAS tooling reads (it takes the first property with that name and splits its value on commas). The language comes from the packages directive itself, registered per ecosystem and carried down to the per-directive scan, so it does not depend on scanner metadata. Packages installed by os-pm are prebuilt binaries of an arbitrary language and stay without the property. The SBOM artifact format version is bumped so that cached SBOMs are regenerated. Signed-off-by: Radmir Khurum --- pkg/build/sbom_step.go | 7 +- pkg/build/sbom_step_test.go | 14 ++- pkg/config/packages_directive.go | 13 +++ pkg/config/packages_directive_test.go | 18 ++++ pkg/sbom/cyclonedxutil/gost/accessor.go | 26 ++++-- pkg/sbom/cyclonedxutil/gost/source_langs.go | 57 ++++++++++++ .../cyclonedxutil/gost/source_langs_test.go | 91 +++++++++++++++++++ pkg/sbom/managedinput/managedinput.go | 3 + pkg/sbom/managedinput/managedinput_test.go | 18 +++- pkg/sbom/scanner/cataloger.go | 1 + pkg/sbom/scanner/scan_command.go | 2 +- 11 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 pkg/sbom/cyclonedxutil/gost/source_langs.go create mode 100644 pkg/sbom/cyclonedxutil/gost/source_langs_test.go 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/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...) } From da3d85b5a99958595a4ceda24bf2d2d6293e15ff Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 11 Sep 2026 11:17:32 +0300 Subject: [PATCH 2/4] feat(sbom): collect package source languages on image and product level A merged SBOM now carries GOST:source_langs on the product component and, in the container format, on the container component of every image, holding the sorted union of the languages of the components below it. Without it the image and product rows of the tabular component listing, which is generated from these properties, stayed empty while the package rows were filled in. Languages already present on a component (e.g. from a user-imported BOM) are unioned with the aggregated ones instead of suppressing them. Signed-off-by: Radmir Khurum --- pkg/sbom/ispras/assembler.go | 6 ++- pkg/sbom/ispras/container.go | 2 +- pkg/sbom/ispras/gost.go | 20 ++++++++ pkg/sbom/ispras/gost_test.go | 87 +++++++++++++++++++++++++++++++++++ pkg/sbom/ispras/oss.go | 2 +- pkg/sbom/ispras/suite_test.go | 13 ++++++ pkg/sbom/ispras/types.go | 1 + 7 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 pkg/sbom/ispras/gost_test.go create mode 100644 pkg/sbom/ispras/suite_test.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 { From 9c9010cf353d102df473d10609a811114f2b72fb Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 11 Sep 2026 11:17:38 +0300 Subject: [PATCH 3/4] test(sbom): cover source languages of cataloged packages end to end Assert GOST:source_langs on a component cataloged by the python-pip and javascript-npm directives, so the language registry stays wired from the packages directive down to the generated SBOM. Signed-off-by: Radmir Khurum --- test/e2e/sbom/gost_test.go | 31 +++++++++++++++++++++++++++++++ test/pkg/sbom/helpers.go | 10 ++++++++++ 2 files changed, 41 insertions(+) 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) From 602e275e8ac3f37d6176cec3eb5177e158dcc80e Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 11 Sep 2026 11:17:38 +0300 Subject: [PATCH 4/4] docs(sbom): document the GOST source languages property Describe how GOST:source_langs is derived from the packages directive, why os-pm packages carry no language, and how the languages are collected on merge. Signed-off-by: Radmir Khurum --- docs/pages_en/usage/build/sbom.md | 6 ++++++ docs/pages_ru/usage/build/sbom.md | 6 ++++++ 2 files changed, 12 insertions(+) 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-флага нет):