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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/pages_en/usage/build/sbom.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions docs/pages_ru/usage/build/sbom.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-флага нет):
Expand Down
7 changes: 6 additions & 1 deletion pkg/build/sbom_step.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions pkg/build/sbom_step_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down Expand Up @@ -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")
})
Expand Down
13 changes: 13 additions & 0 deletions pkg/config/packages_directive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -53,6 +57,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{
return cmd
},
CatalogerName: "go-module-file-cataloger",
SourceLang: "Go",
},
PackagesDirectiveTypePythonUV: {
Type: PackagesDirectiveTypePythonUV,
Expand All @@ -66,6 +71,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{
return cmd
},
CatalogerName: "python-package-cataloger",
SourceLang: "Python",
},
PackagesDirectiveTypePythonPip: {
Type: PackagesDirectiveTypePythonPip,
Expand All @@ -79,6 +85,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{
return cmd
},
CatalogerName: "python-package-cataloger",
SourceLang: "Python",
},
PackagesDirectiveTypePythonPoetry: {
Type: PackagesDirectiveTypePythonPoetry,
Expand All @@ -92,6 +99,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{
return cmd
},
CatalogerName: "python-package-cataloger",
SourceLang: "Python",
},
PackagesDirectiveTypeRustCargo: {
Type: PackagesDirectiveTypeRustCargo,
Expand All @@ -105,6 +113,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{
return cmd
},
CatalogerName: "rust-cargo-lock-cataloger",
SourceLang: "Rust",
},
PackagesDirectiveTypeJavaScriptNpm: {
Type: PackagesDirectiveTypeJavaScriptNpm,
Expand All @@ -118,6 +127,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{
return cmd
},
CatalogerName: "javascript-lock-cataloger",
SourceLang: "JavaScript",
},
PackagesDirectiveTypeJavaScriptYarn: {
Type: PackagesDirectiveTypeJavaScriptYarn,
Expand All @@ -131,6 +141,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{
return cmd
},
CatalogerName: "javascript-lock-cataloger",
SourceLang: "JavaScript",
},
PackagesDirectiveTypeJavaScriptPnpm: {
Type: PackagesDirectiveTypeJavaScriptPnpm,
Expand All @@ -144,6 +155,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{
return cmd
},
CatalogerName: "javascript-lock-cataloger",
SourceLang: "JavaScript",
},
PackagesDirectiveTypeLuaRock: {
Type: PackagesDirectiveTypeLuaRock,
Expand All @@ -157,6 +169,7 @@ var ecosystems = map[PackagesDirectiveType]PackageEcosystem{
return cmd
},
CatalogerName: "lua-rock-cataloger",
SourceLang: "Lua",
},
PackagesDirectiveTypeOSPM: {
Type: PackagesDirectiveTypeOSPM,
Expand Down
18 changes: 18 additions & 0 deletions pkg/config/packages_directive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
})
26 changes: 19 additions & 7 deletions pkg/sbom/cyclonedxutil/gost/accessor.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,35 @@ 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) {
if val.IsUndefined() {
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
}
}
Expand All @@ -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,
})
}
57 changes: 57 additions & 0 deletions pkg/sbom/cyclonedxutil/gost/source_langs.go
Original file line number Diff line number Diff line change
@@ -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)
}
91 changes: 91 additions & 0 deletions pkg/sbom/cyclonedxutil/gost/source_langs_test.go
Original file line number Diff line number Diff line change
@@ -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"}))
})
})
Loading