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: 4 additions & 2 deletions docs/pages_en/usage/build/sbom.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ Currently, this option uses the following _defaults_:
| **Scanner** | syft |
| **Scanner Image** | anchore/syft:v1.45.1 |
| **Image Pull Policy** | `PullIfMissing` |
| **Data Source Connection Method** | daemon + socket via volume (for Docker) |
| **Path in Source Image** | OS root |
| **Data Source Connection Method** | Directory scan of the spec/lock files extracted from the built image, without the Docker socket |
| **Path in Source Image** | The declared `packages` spec/lock files |
| **Scan Settings** | [link](https://github.com/anchore/syft/wiki/Configuration#list-of-configurable-values) |
| **Output Standard** | `CycloneDX@1.6` |
| **Output Format** | `JSON` |

For stapel images with file-based `packages`, each declared spec file (for example `go.mod` or `requirements.txt`) is read from the built image and scanned directly as a directory source, without mounting the Docker socket. A declared lock file (for example `go.sum`) is included when present but is optional — a module with no dependencies has none, and its absence is tolerated with a warning, since without the lock transitive dependencies may be missing from the SBOM. If a required spec file is not present as a regular file in the built image — for example removed by a later stage, or present only as a symlink — the build fails with an error naming the directive and the missing path.

## Base image requirements

When SBOM generation is enabled, every base image referenced via `from` or `fromImage` and every image referenced via `import` **must have an SBOM artifact attached in the registry**. There is no alternative to this requirement; the only exception is described below.
Expand Down
6 changes: 4 additions & 2 deletions docs/pages_ru/usage/build/sbom.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ build:
| **Сканер** | syft |
| **Образ сканера** | anchore/syft:v1.45.1 |
| **Политика получения образа** | `PullIfMissing` |
| **Способ подключения к источнику данных** | daemon + socket via volume (для Docker) |
| **Путь в образе источнике** | корень OS |
| **Способ подключения к источнику данных** | Сканирование каталога с извлечёнными из собранного образа spec/lock-файлами, без Docker-сокета |
| **Путь в образе источнике** | Объявленные spec/lock-файлы `packages` |
| **Настройки сканирования** | [ссылка](https://github.com/anchore/syft/wiki/Configuration#list-of-configurable-values) |
| **Исходящий стандарт** | `CycloneDX@1.6` |
| **Исходящий формат** | `JSON` |

Для stapel-образов с file-based `packages` каждый объявленный spec-файл (например, `go.mod` или `requirements.txt`) читается из собранного образа и сканируется напрямую как каталог-источник, без монтирования Docker-сокета. Объявленный lock-файл (например, `go.sum`) добавляется, если присутствует, но не обязателен — у модуля без зависимостей его нет, и его отсутствие допустимо и сопровождается предупреждением, поскольку без lock-файла в SBOM могут отсутствовать транзитивные зависимости. Если обязательный spec-файл отсутствует в собранном образе как обычный файл — например, удалён более поздней стадией или присутствует только как симлинк — сборка завершается ошибкой с указанием директивы и отсутствующего пути.

## Требования к базовому образу

Когда генерация SBOM включена, каждый базовый образ, указанный через `from` или `fromImage`, и каждый образ, указанный через `import`, **должен иметь прикреплённый SBOM-артефакт в registry**. Альтернативы этому требованию нет; единственное исключение описано ниже.
Expand Down
5 changes: 5 additions & 0 deletions pkg/build/build_phase.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,11 @@ func (phase *BuildPhase) scanOptionsForImage(img *image.Image) scanner.ScanOptio
catalogers := managedinput.ToCatalogers(stapelConfig.ImageBaseConfig().Packages)
for i := range scanOpts.Commands {
scanOpts.Commands[i].Catalogers = catalogers
// File-based stapel packages are cataloged by scanning the declared spec/lock files
// extracted from the image (a directory source), not the whole image filesystem.
if len(catalogers) > 0 {
scanOpts.Commands[i].SourceType = scanner.SourceTypeDir
}
}

return scanOpts
Expand Down
113 changes: 102 additions & 11 deletions pkg/build/sbom_step.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"sync"
"time"

cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/sigstore/sigstore/pkg/signature"
Expand Down Expand Up @@ -69,6 +70,7 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string,
parentDigest := stageDesc.Info.GetDigest()

scanOpts.Commands[0].SourcePath = stageDesc.Info.Name
catalogers := scanOpts.Commands[0].Catalogers

if err := step.prepareGostComponents(ctx, &mergeOpts); err != nil {
return err
Expand All @@ -94,16 +96,18 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string,
return logboek.Context(ctx).Default().LogProcess("image %s: SBOM processing", werfImgName).DoError(func() error {
var targetBOM *cdx.BOM

if !syftScanRequired(isStapel, scanOpts.Commands[0].Catalogers) {
switch {
case !syftScanRequired(isStapel, catalogers):
targetBOM = cyclonedxutil.NewBOM()
targetBOM.Metadata = &cdx.Metadata{
Component: &cdx.Component{
Type: cdx.ComponentTypeContainer,
Name: stageDesc.Info.Repository,
Version: stageDesc.Info.Tag,
},
restoreImageMetadata(targetBOM, stageDesc)
case isStapel:
var err error
targetBOM, err = step.scanFileBasedPackages(ctx, stageDesc.Info.Name, scanOpts, catalogers, targetPlatform)
if err != nil {
return err
}
} else {
restoreImageMetadata(targetBOM, stageDesc)
default:
bomJSON, err := step.containerBackend.GenerateSBOM(ctx, scanOpts)
if err != nil {
return fmt.Errorf("generate SBOM: %w", err)
Expand All @@ -113,8 +117,6 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string,
if err != nil {
return fmt.Errorf("parse scanned BOM: %w", err)
}

managedinput.FilterBOMBySourcePaths(targetBOM, scanOpts.Commands[0].Catalogers)
}

resultBOM := targetBOM
Expand Down Expand Up @@ -186,7 +188,96 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string,
})
}

const sbomArtifactFormatVersion = "3"
// restoreImageMetadata sets the BOM's top-level component to the scanned image while
// keeping any syft-provided metadata (tools, timestamp). A directory source reports the
// temporary scan directory as its component, so it must be replaced. When no timestamp is
// present — the skip-scan path builds a fresh BOM — one is stamped, since a per-image SBOM
// without a timestamp is rejected by downstream validators.
func restoreImageMetadata(bom *cdx.BOM, stageDesc *image.StageDesc) {
if bom.Metadata == nil {
bom.Metadata = &cdx.Metadata{}
}
bom.Metadata.Component = containerComponent(stageDesc)
if bom.Metadata.Timestamp == "" {
bom.Metadata.Timestamp = time.Now().UTC().Format(time.RFC3339)
}
}

// containerComponent builds the top-level container component of an image BOM.
func containerComponent(stageDesc *image.StageDesc) *cdx.Component {
return &cdx.Component{
Type: cdx.ComponentTypeContainer,
Name: stageDesc.Info.Repository,
Version: stageDesc.Info.Tag,
}
}

// scanFileBasedPackages catalogs the file-based packages of a stapel image by scanning,
// per directive, only the spec/lock files extracted from the built image (a directory
// source), then unions the per-directive BOMs. This avoids walking the whole image
// filesystem and needs no docker.sock in the scanner container.
func (step *sbomStep) scanFileBasedPackages(ctx context.Context, imageRef string, scanOpts scanner.ScanOptions, catalogers []scanner.Cataloger, targetPlatform string) (*cdx.BOM, error) {
Comment thread
reyreavman marked this conversation as resolved.
scannedBOMs := make([]*cdx.BOM, 0, len(catalogers))
for _, cataloger := range catalogers {
dir, cleanup, err := managedinput.MaterializeCatalogerInputs(ctx, step.containerBackend, imageRef, cataloger, targetPlatform)
if err != nil {
return nil, fmt.Errorf("materialize inputs for cataloger %q: %w", cataloger.Name, err)
}

bom, err := step.scanCatalogerDir(ctx, scanOpts, cataloger, dir)
cleanup(ctx)
if err != nil {
return nil, err
}

scannedBOMs = append(scannedBOMs, bom)
}

// Guarded against an empty catalogers slice, even though the isStapel switch arm only
// runs when syftScanRequired already established len(catalogers) > 0.
if len(scannedBOMs) == 0 {
return cyclonedxutil.NewBOM(), nil
}

// MergeBOMs unions components and dedups by normalized PURL; on a cross-directive PURL
// collision it is the first directive's component that is dropped (mergeOrder appends
// the target last, dedup is first-occurrence-wins). Harmless for component identity.
merged, err := cyclonedxutil.MergeBOMs(scannedBOMs[0], cyclonedxutil.MergeOpts{ImportBOMs: scannedBOMs[1:]})
Comment thread
reyreavman marked this conversation as resolved.
if err != nil {
return nil, fmt.Errorf("union per-directive BOMs: %w", err)
}

return merged, nil
}

func (step *sbomStep) scanCatalogerDir(ctx context.Context, scanOpts scanner.ScanOptions, cataloger scanner.Cataloger, dir string) (*cdx.BOM, error) {
cmd := scanOpts.Commands[0]
cmd.Catalogers = []scanner.Cataloger{cataloger}
cmd.SourceType = scanner.SourceTypeDir
cmd.SourcePath = dir

perDirectiveOpts := scanOpts
perDirectiveOpts.Commands = []scanner.ScanCommand{cmd}

bomJSON, err := step.containerBackend.GenerateSBOM(ctx, perDirectiveOpts)
if err != nil {
return nil, fmt.Errorf("generate SBOM for cataloger %q: %w", cataloger.Name, err)
}

bom, err := cyclonedxutil.BuildCycloneDX16BOMFromJSON(bomJSON)
if err != nil {
return nil, fmt.Errorf("parse scanned BOM for cataloger %q: %w", cataloger.Name, err)
}

// A directory source makes syft emit a PURL-less type=file component for each scanned
// manifest file; drop them so only real packages remain. This is what makes omitting the
// post-scan source-path filter safe (see SYFT_FILE_METADATA_SELECTION in the docker backend).
cyclonedxutil.DropSyftSourceFileComponents(bom)

return bom, nil
}

const sbomArtifactFormatVersion = "4"

// 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
113 changes: 113 additions & 0 deletions pkg/build/sbom_step_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,119 @@ var _ = Describe("SbomStep", func() {
)
})

Describe("scanFileBasedPackages", func() {
makeBOMJSON := func(timestamp string, comps ...cdx.Component) []byte {
bom := cyclonedxutil.NewBOM()
bom.Metadata = &cdx.Metadata{
Timestamp: timestamp,
Component: &cdx.Component{Type: cdx.ComponentTypeFile, Name: "/scan"},
}
list := append([]cdx.Component{}, comps...)
bom.Components = &list
data, err := cyclonedxutil.ToJSON(bom)
Expect(err).To(Succeed())
return data
}

It("scans one dir source per cataloger, unions components, drops source files, keeps syft metadata", func(specCtx SpecContext) {
ctx := logging.WithLogger(specCtx)
ctrl := gomock.NewController(GinkgoT())
mockBackend := mock.NewMockContainerBackend(ctrl)

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"}},
}

mockBackend.EXPECT().
ReadFileFromImage(gomock.Any(), imageRef, gomock.Any(), gomock.Any()).
Return([]byte("manifest\n"), nil).
AnyTimes()

goBOM := makeBOMJSON("2026-01-01T00:00:00Z",
cdx.Component{BOMRef: "lo", Type: cdx.ComponentTypeLibrary, Name: "github.com/samber/lo", Version: "v1.47.0", PackageURL: "pkg:golang/github.com/samber/lo@v1.47.0"},
// a PURL-less type=file entry a dir scan emits for the manifest itself
cdx.Component{BOMRef: "gomod-file", Type: cdx.ComponentTypeFile, Name: "go.mod"},
)
pipBOM := makeBOMJSON("2026-02-02T00:00:00Z",
cdx.Component{BOMRef: "flask", Type: cdx.ComponentTypeLibrary, Name: "flask", Version: "3.0.0", PackageURL: "pkg:pypi/flask@3.0.0"},
)

mockBackend.EXPECT().
GenerateSBOM(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, opts scanner.ScanOptions) ([]byte, error) {
Expect(opts.Commands).To(HaveLen(1))
Expect(opts.Commands[0].SourceType).To(Equal(scanner.SourceTypeDir), "each per-directive scan must use a directory source")
Expect(opts.Commands[0].Catalogers).To(HaveLen(1), "each scan must run exactly one cataloger")
switch opts.Commands[0].Catalogers[0].Name {
case "go-module-file-cataloger":
return goBOM, nil
case "python-package-cataloger":
return pipBOM, nil
default:
return nil, errors.New("unexpected cataloger: " + opts.Commands[0].Catalogers[0].Name)
}
}).
Times(2)

step := &sbomStep{containerBackend: mockBackend}
bom, err := step.scanFileBasedPackages(ctx, imageRef, scanner.DefaultSyftScanOptions(), catalogers, "")
Expect(err).To(Succeed())
Expect(bom).ToNot(BeNil())

names := []string{}
for _, c := range *bom.Components {
names = append(names, c.Name)
}
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"))

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")
})
})

Describe("restoreImageMetadata", func() {
stageDesc := &werfImage.StageDesc{Info: &werfImage.Info{Repository: "example.com/app", Tag: "v1"}}

expectContainerComponent := func(bom *cdx.BOM) {
Expect(bom.Metadata).ToNot(BeNil())
Expect(bom.Metadata.Component).ToNot(BeNil())
Expect(bom.Metadata.Component.Type).To(Equal(cdx.ComponentTypeContainer))
Expect(bom.Metadata.Component.Name).To(Equal("example.com/app"))
Expect(bom.Metadata.Component.Version).To(Equal("v1"))
}

It("allocates metadata and stamps a timestamp when the BOM has none", func() {
bom := &cdx.BOM{}

restoreImageMetadata(bom, stageDesc)

expectContainerComponent(bom)
Expect(bom.Metadata.Timestamp).ToNot(BeEmpty(), "a per-image SBOM must carry a timestamp for downstream validators")
})

It("keeps syft's tools and timestamp and replaces only the scan-directory component", func() {
bom := &cdx.BOM{
Metadata: &cdx.Metadata{
Timestamp: "2020-01-02T03:04:05Z",
Tools: &cdx.ToolsChoice{Components: &[]cdx.Component{{Type: cdx.ComponentTypeApplication, Name: "syft", Version: "1.45.1"}}},
Component: &cdx.Component{Type: cdx.ComponentTypeFile, Name: "/scan"},
},
}

restoreImageMetadata(bom, stageDesc)

expectContainerComponent(bom)
Expect(bom.Metadata.Timestamp).To(Equal("2020-01-02T03:04:05Z"), "syft's own timestamp must survive")
Expect(bom.Metadata.Tools).ToNot(BeNil())
Expect(bom.Metadata.Tools.Components).ToNot(BeNil())
Expect(*bom.Metadata.Tools.Components).To(HaveLen(1))
Expect((*bom.Metadata.Tools.Components)[0].Name).To(Equal("syft"), "syft tools provenance must survive")
})
})

Describe("isTrustedBuilderImage()", func() {
DescribeTable("should detect trusted builder images",
func(labels map[string]string, expected bool) {
Expand Down
25 changes: 21 additions & 4 deletions pkg/container_backend/docker_server_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -705,29 +705,46 @@ func (backend *DockerServerBackend) GenerateSBOM(ctx context.Context, scanOpts s
return bomJSON, err
}

const sbomScanDirContainerMountPath = "/scan"

func mapSbomScanOptionsToDockerRunCommand(workingTreeDir, billsDir string, billNames []string, scanOpts scanner.ScanOptions) []string {
args := []string{
"--rm",
"--name", fmt.Sprintf("%s%s", image.SBOMScannerContainerNamePrefix, uuid.New().String()),
"--pull", scanOpts.PullPolicy.String(),
"--entrypoint", "", // clear default image entrypoint
"--volume", "/var/run/docker.sock:/var/run/docker.sock", // TODO: return error on non Unix systems
}

// TODO (zaytsev): the code support only single command at this moment
scanCmd := scanOpts.Commands[0] // TODO (zaytsev): support multiple commands

switch scanCmd.SourceType {
case scanner.SourceTypeDir:
Comment thread
reyreavman marked this conversation as resolved.
// Scan only the spec/lock files materialized on the host; the scanner reads them
// directly from a bind mount, so no docker.sock access to the image is needed.
args = append(args, "--volume", fmt.Sprintf("%s:%s:ro", scanCmd.SourcePath, sbomScanDirContainerMountPath))
Comment thread
reyreavman marked this conversation as resolved.
Comment thread
reyreavman marked this conversation as resolved.
scanCmd.SourcePath = sbomScanDirContainerMountPath
default:
scanCmd.SourceType = scanner.SourceTypeDocker
args = append(args, "--volume", "/var/run/docker.sock:/var/run/docker.sock") // TODO: return error on non Unix systems
}

billHostPath := filepath.Join(workingTreeDir, billsDir, billNames[0])
billContainerPath := filepath.Join("/tmp", billsDir, billNames[0])
args = append(args, "--volume", fmt.Sprintf("%s:%s", billHostPath, billContainerPath))

args = append(args,
"-e", "SYFT_GOLANG_MAIN_MODULE_VERSION_FROM_CONTENTS=false",
// SYFT_FILE_METADATA_SELECTION=none is load-bearing for a directory source: without it
// syft emits an extra PURL-less type=file component per scanned manifest, which dedup
// (it keeps PURL-less components) would not remove. Do not drop this env var (contrary
// to the task note claiming it is unknown to syft v1.45.1 — it is honored); the
// directory-scan path additionally strips such components defensively in
// cyclonedxutil.DropSyftSourceFileComponents.
"-e", "SYFT_FILE_METADATA_SELECTION=none",
)

args = append(args, scanOpts.Image)

scanCmd := scanOpts.Commands[0] // TODO (zaytsev): support multiple commands
scanCmd.SourceType = scanner.SourceTypeDocker
scanCmd.OutputPath = billContainerPath

args = append(args, strings.Split(scanCmd.String(), " ")...)
Expand Down
Loading
Loading