From a1306b011e75ffa66a9ad9ed933395d0d4cb8463 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Wed, 9 Sep 2026 14:32:01 +0300 Subject: [PATCH 1/6] feat(sbom): generate file-based package SBOMs without docker.sock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the SBOM of a stapel image with file-based packages was produced by running syft against the whole image filesystem via a docker: source. That required mounting /var/run/docker.sock into the scanner container and then post-filtering the resulting BOM down to the declared spec/lock files, so SBOM generation could not run where the docker socket is unavailable or forbidden. Now every packages directive is scanned on its own: its declared spec and lock files are read from the built image (the same image-read mechanism os-pm uses), materialized into a temporary directory with their workdir-relative layout, and scanned with a dir: source and the directive's cataloger — one scanner run per directive, with no docker.sock mount. The per-directive BOMs are then unioned. Because the targeted scan only ever sees the declared files, the redundant post-scan FilterBOMBySourcePaths pass and its cataloger filter modes are removed. Dockerfile images keep the full-image docker: scan. The SBOM artifact format version is bumped 3 -> 4 so images carrying a legacy full-scan SBOM regenerate it, while unchanged rebuilds still reuse the cache. Signed-off-by: Radmir Khurum --- pkg/build/build_phase.go | 5 + pkg/build/sbom_step.go | 87 ++- .../docker_server_backend.go | 19 +- .../docker_server_sbom_test.go | 46 ++ pkg/sbom/managedinput/managedinput.go | 103 --- pkg/sbom/managedinput/managedinput_test.go | 616 +----------------- pkg/sbom/managedinput/materialize.go | 73 +++ pkg/sbom/managedinput/materialize_test.go | 127 ++++ pkg/sbom/scanner/cataloger.go | 14 +- 9 files changed, 347 insertions(+), 743 deletions(-) create mode 100644 pkg/container_backend/docker_server_sbom_test.go create mode 100644 pkg/sbom/managedinput/materialize.go create mode 100644 pkg/sbom/managedinput/materialize_test.go diff --git a/pkg/build/build_phase.go b/pkg/build/build_phase.go index be2045ca60..1b6b674cc4 100644 --- a/pkg/build/build_phase.go +++ b/pkg/build/build_phase.go @@ -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 diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index cf1b7a0621..0974211c33 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_step.go @@ -69,6 +69,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 @@ -94,16 +95,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, - }, + targetBOM.Metadata = containerMetadata(stageDesc) + case isStapel: + var err error + targetBOM, err = step.scanFileBasedPackages(ctx, stageDesc.Info.Name, scanOpts, catalogers, targetPlatform) + if err != nil { + return err } - } else { + targetBOM.Metadata = containerMetadata(stageDesc) + default: bomJSON, err := step.containerBackend.GenerateSBOM(ctx, scanOpts) if err != nil { return fmt.Errorf("generate SBOM: %w", err) @@ -113,8 +116,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 @@ -186,7 +187,71 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, }) } -const sbomArtifactFormatVersion = "3" +// containerMetadata builds the top-level container component of an image BOM. The +// targeted directory scan reports the temporary scan directory as its source, so the +// image identity is restored here explicitly (same as the skip-scan branch). +func containerMetadata(stageDesc *image.StageDesc) *cdx.Metadata { + return &cdx.Metadata{ + Component: &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) { + 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, err + } + + bom, err := step.scanCatalogerDir(ctx, scanOpts, cataloger, dir) + cleanup(ctx) + if err != nil { + return nil, err + } + + scannedBOMs = append(scannedBOMs, bom) + } + + merged, err := cyclonedxutil.MergeBOMs(scannedBOMs[0], cyclonedxutil.MergeOpts{ImportBOMs: scannedBOMs[1:]}) + 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) + } + + 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 diff --git a/pkg/container_backend/docker_server_backend.go b/pkg/container_backend/docker_server_backend.go index 4fbc2c5911..a74962586c 100644 --- a/pkg/container_backend/docker_server_backend.go +++ b/pkg/container_backend/docker_server_backend.go @@ -705,16 +705,29 @@ 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: + // 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)) + 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)) @@ -726,8 +739,6 @@ func mapSbomScanOptionsToDockerRunCommand(workingTreeDir, billsDir string, billN 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(), " ")...) diff --git a/pkg/container_backend/docker_server_sbom_test.go b/pkg/container_backend/docker_server_sbom_test.go new file mode 100644 index 0000000000..18864531cc --- /dev/null +++ b/pkg/container_backend/docker_server_sbom_test.go @@ -0,0 +1,46 @@ +package container_backend + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/werf/werf/v2/pkg/sbom/scanner" +) + +var _ = Describe("mapSbomScanOptionsToDockerRunCommand", func() { + newScanOpts := func(sourceType scanner.SourceType, sourcePath string) scanner.ScanOptions { + cmd := scanner.NewSyftScanCommand() + cmd.SourceType = sourceType + cmd.SourcePath = sourcePath + return scanner.ScanOptions{ + Image: "anchore/syft:v1.45.1", + PullPolicy: scanner.PullIfMissing, + Commands: []scanner.ScanCommand{cmd}, + } + } + + It("scans a directory source over a bind mount without docker.sock", func() { + scanOpts := newScanOpts(scanner.SourceTypeDir, "/host/scan/dir") + billNames := scanner.BillNamesFromCommands(scanOpts.Commands) + + args := mapSbomScanOptionsToDockerRunCommand("/wt", "sbom", billNames, scanOpts) + joined := strings.Join(args, " ") + + Expect(joined).ToNot(ContainSubstring("/var/run/docker.sock")) + Expect(args).To(ContainElement("/host/scan/dir:/scan:ro")) + Expect(joined).To(ContainSubstring("scan dir:/scan")) + }) + + It("keeps the docker.sock mount and docker source for full-image scans", func() { + scanOpts := newScanOpts(scanner.SourceTypeDocker, "example.com/app:latest") + billNames := scanner.BillNamesFromCommands(scanOpts.Commands) + + args := mapSbomScanOptionsToDockerRunCommand("/wt", "sbom", billNames, scanOpts) + joined := strings.Join(args, " ") + + Expect(args).To(ContainElement("/var/run/docker.sock:/var/run/docker.sock")) + Expect(joined).To(ContainSubstring("scan docker:example.com/app:latest")) + }) +}) diff --git a/pkg/sbom/managedinput/managedinput.go b/pkg/sbom/managedinput/managedinput.go index 6bf948417d..d377aa7f8e 100644 --- a/pkg/sbom/managedinput/managedinput.go +++ b/pkg/sbom/managedinput/managedinput.go @@ -3,9 +3,7 @@ package managedinput import ( "path" "slices" - "strings" - cdx "github.com/CycloneDX/cyclonedx-go" "github.com/samber/lo" "github.com/werf/werf/v2/pkg/config" @@ -15,7 +13,6 @@ import ( type inputResolver struct { inputType config.PackagesDirectiveType catalogerName string - filterMode scanner.CatalogerFilterMode sourcePaths func(directive *config.PackagesDirective) []string workdir func(directive *config.PackagesDirective) string } @@ -40,11 +37,9 @@ func buildResolvers() []inputResolver { if t == config.PackagesDirectiveTypeOSPM { continue } - filterMode := filterModeForEcosystem(t) built = append(built, inputResolver{ inputType: eco.Type, catalogerName: eco.CatalogerName, - filterMode: filterMode, sourcePaths: func(d *config.PackagesDirective) []string { paths := []string{path.Join(d.FileBased.Workdir, d.FileBased.Spec)} if d.FileBased.Lock != "" { @@ -60,10 +55,6 @@ func buildResolvers() []inputResolver { return built } -func filterModeForEcosystem(_ config.PackagesDirectiveType) scanner.CatalogerFilterMode { - return scanner.CatalogerFilterExactPath -} - func ToCatalogers(packages []*config.PackagesDirective) []scanner.Cataloger { var catalogers []scanner.Cataloger @@ -77,7 +68,6 @@ func ToCatalogers(packages []*config.PackagesDirective) []scanner.Cataloger { catalogers = append(catalogers, scanner.Cataloger{ Name: res.catalogerName, - FilterMode: res.filterMode, SourcePaths: res.sourcePaths(directive), Workdir: res.workdir(directive), }) @@ -85,96 +75,3 @@ func ToCatalogers(packages []*config.PackagesDirective) []scanner.Cataloger { return catalogers } - -func FilterBOMBySourcePaths(bom *cdx.BOM, catalogers []scanner.Cataloger) { - if bom == nil || bom.Components == nil || len(catalogers) == 0 { - return - } - - type catalogerFilter struct { - name string - filterMode scanner.CatalogerFilterMode - paths map[string]struct{} - workdir string - } - - filters := make([]catalogerFilter, 0, len(catalogers)) - for _, cat := range catalogers { - paths := make(map[string]struct{}, len(cat.SourcePaths)) - for _, p := range cat.SourcePaths { - paths[p] = struct{}{} - } - filters = append(filters, catalogerFilter{ - name: cat.Name, - filterMode: cat.FilterMode, - paths: paths, - workdir: cat.Workdir, - }) - } - - filtered := lo.Filter(*bom.Components, func(comp cdx.Component, _ int) bool { - for _, f := range filters { - if !componentFoundByCataloger(comp, f.name) { - continue - } - switch f.filterMode { - case scanner.CatalogerFilterCatalogerOnly: - return true - case scanner.CatalogerFilterWorkdirPrefix: - if componentMatchesWorkdirPrefix(comp, f.workdir) { - return true - } - default: - if componentMatchesAllowedPaths(comp, f.paths) { - return true - } - } - } - return false - }) - - *bom.Components = filtered -} - -func componentFoundByCataloger(comp cdx.Component, catalogerName string) bool { - if comp.Properties == nil { - return false - } - for _, prop := range *comp.Properties { - if prop.Name == "syft:package:foundBy" { - return prop.Value == catalogerName - } - } - return false -} - -func componentMatchesAllowedPaths(comp cdx.Component, allowedPaths map[string]struct{}) bool { - if comp.Properties == nil { - return false - } - for _, prop := range *comp.Properties { - if !strings.HasPrefix(prop.Name, "syft:location:") || !strings.HasSuffix(prop.Name, ":path") { - continue - } - if _, ok := allowedPaths[prop.Value]; ok { - return true - } - } - return false -} - -func componentMatchesWorkdirPrefix(comp cdx.Component, workdir string) bool { - if comp.Properties == nil { - return false - } - prefix := workdir + "/" - for _, prop := range *comp.Properties { - if !strings.HasPrefix(prop.Name, "syft:location:") || !strings.HasSuffix(prop.Name, ":path") { - continue - } - if strings.HasPrefix(prop.Value, prefix) { - return true - } - } - return false -} diff --git a/pkg/sbom/managedinput/managedinput_test.go b/pkg/sbom/managedinput/managedinput_test.go index a997cd51b8..f20fddf491 100644 --- a/pkg/sbom/managedinput/managedinput_test.go +++ b/pkg/sbom/managedinput/managedinput_test.go @@ -1,10 +1,8 @@ package managedinput import ( - "fmt" "sort" - cdx "github.com/CycloneDX/cyclonedx-go" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -61,8 +59,8 @@ var _ = Describe("ToCatalogers", func() { }, }, []scanner.Cataloger{ - {Name: "go-module-file-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/api/go.mod", "/app/api/go.sum"}, Workdir: "/app/api"}, - {Name: "go-module-file-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/cli/go.mod", "/app/cli/go.sum"}, Workdir: "/app/cli"}, + {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/api/go.mod", "/app/api/go.sum"}, Workdir: "/app/api"}, + {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/cli/go.mod", "/app/cli/go.sum"}, Workdir: "/app/cli"}, }, ), @@ -98,7 +96,7 @@ var _ = Describe("ToCatalogers", func() { }, }, []scanner.Cataloger{ - {Name: "go-module-file-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/go.mod", "/app/go.sum"}, Workdir: "/app"}, + {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/go.mod", "/app/go.sum"}, Workdir: "/app"}, }, ), @@ -108,611 +106,3 @@ var _ = Describe("ToCatalogers", func() { ), ) }) - -var _ = Describe("FilterBOMBySourcePaths", func() { - goModProps := func(path string) *[]cdx.Property { - return &[]cdx.Property{ - {Name: "syft:package:foundBy", Value: "go-module-file-cataloger"}, - {Name: "syft:location:0:path", Value: path}, - } - } - - osProps := func() *[]cdx.Property { - return &[]cdx.Property{ - {Name: "syft:package:foundBy", Value: "dpkg-db-cataloger"}, - {Name: "syft:location:0:path", Value: "/var/lib/dpkg/status"}, - } - } - - DescribeTable("filter behavior", - func(bom *cdx.BOM, catalogers []scanner.Cataloger, expectedNames []string) { - FilterBOMBySourcePaths(bom, catalogers) - if bom == nil { - return - } - var names []string - for _, c := range *bom.Components { - names = append(names, c.Name) - } - Expect(names).To(Equal(expectedNames)) - }, - - Entry("keeps only components found by declared catalogers with matching paths", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "github.com/foo/bar", Properties: goModProps("/app/api/go.mod")}, - {Name: "github.com/baz/qux", Properties: goModProps("/vendor/tool/go.mod")}, - {Name: "curl", Properties: osProps()}, - }, - }, - []scanner.Cataloger{ - {Name: "go-module-file-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/api/go.mod", "/app/api/go.sum"}, Workdir: "/app/api"}, - }, - []string{"github.com/foo/bar"}, - ), - - Entry("does nothing when no catalogers are provided", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "github.com/foo/bar", Properties: goModProps("/app/api/go.mod")}, - }, - }, - []scanner.Cataloger(nil), - []string{"github.com/foo/bar"}, - ), - - Entry("does nothing when BOM is nil", - (*cdx.BOM)(nil), - []scanner.Cataloger{{Name: "x", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/go.mod"}, Workdir: "/app"}}, - []string(nil), - ), - ) -}) - -var _ = Describe("ToCatalogers rust", func() { - DescribeTable("maps rust-cargo directive to rust-cargo-lock-cataloger", - func(packages []*config.PackagesDirective, expected []scanner.Cataloger) { - Expect(ToCatalogers(packages)).To(Equal(expected)) - }, - - Entry("rust-cargo maps to rust-cargo-lock-cataloger with spec and lock paths", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypeRustCargo, - FileBased: config.FileBasedSpec{Workdir: "/app", Spec: "Cargo.toml", Lock: "Cargo.lock"}, - }, - }, - []scanner.Cataloger{ - {Name: "rust-cargo-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/Cargo.toml", "/app/Cargo.lock"}, Workdir: "/app"}, - }, - ), - - Entry("rust-cargo with nested workdir includes correct paths", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypeRustCargo, - FileBased: config.FileBasedSpec{Workdir: "/src/service", Spec: "Cargo.toml", Lock: "Cargo.lock"}, - }, - }, - []scanner.Cataloger{ - {Name: "rust-cargo-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/src/service/Cargo.toml", "/src/service/Cargo.lock"}, Workdir: "/src/service"}, - }, - ), - - Entry("multiple rust-cargo entries produce multiple catalogers", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypeRustCargo, - FileBased: config.FileBasedSpec{Workdir: "/app", Spec: "Cargo.toml", Lock: "Cargo.lock"}, - }, - { - Type: config.PackagesDirectiveTypeRustCargo, - FileBased: config.FileBasedSpec{Workdir: "/lib", Spec: "Cargo.toml", Lock: "Cargo.lock"}, - }, - }, - []scanner.Cataloger{ - {Name: "rust-cargo-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/Cargo.toml", "/app/Cargo.lock"}, Workdir: "/app"}, - {Name: "rust-cargo-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/lib/Cargo.toml", "/lib/Cargo.lock"}, Workdir: "/lib"}, - }, - ), - ) -}) - -var _ = Describe("ToCatalogers javascript", func() { - DescribeTable("maps javascript directives to javascript-lock-cataloger", - func(packages []*config.PackagesDirective, expected []scanner.Cataloger) { - Expect(ToCatalogers(packages)).To(Equal(expected)) - }, - - Entry("javascript-npm maps to javascript-lock-cataloger with spec and lock paths", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypeJavaScriptNpm, - FileBased: config.FileBasedSpec{Workdir: "/app", Spec: "package.json", Lock: "package-lock.json"}, - }, - }, - []scanner.Cataloger{ - {Name: "javascript-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/package.json", "/app/package-lock.json"}, Workdir: "/app"}, - }, - ), - - Entry("javascript-yarn maps to javascript-lock-cataloger with spec and yarn.lock paths", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypeJavaScriptYarn, - FileBased: config.FileBasedSpec{Workdir: "/app", Spec: "package.json", Lock: "yarn.lock"}, - }, - }, - []scanner.Cataloger{ - {Name: "javascript-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/package.json", "/app/yarn.lock"}, Workdir: "/app"}, - }, - ), - - Entry("javascript-pnpm maps to javascript-lock-cataloger with spec and pnpm-lock.yaml paths", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypeJavaScriptPnpm, - FileBased: config.FileBasedSpec{Workdir: "/app", Spec: "package.json", Lock: "pnpm-lock.yaml"}, - }, - }, - []scanner.Cataloger{ - {Name: "javascript-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/package.json", "/app/pnpm-lock.yaml"}, Workdir: "/app"}, - }, - ), - - Entry("javascript-npm with nested workdir includes correct paths", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypeJavaScriptNpm, - FileBased: config.FileBasedSpec{Workdir: "/src/web", Spec: "package.json", Lock: "package-lock.json"}, - }, - }, - []scanner.Cataloger{ - {Name: "javascript-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/src/web/package.json", "/src/web/package-lock.json"}, Workdir: "/src/web"}, - }, - ), - - Entry("multiple javascript entries produce multiple catalogers", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypeJavaScriptNpm, - FileBased: config.FileBasedSpec{Workdir: "/app", Spec: "package.json", Lock: "package-lock.json"}, - }, - { - Type: config.PackagesDirectiveTypeJavaScriptPnpm, - FileBased: config.FileBasedSpec{Workdir: "/sdk", Spec: "package.json", Lock: "pnpm-lock.yaml"}, - }, - }, - []scanner.Cataloger{ - {Name: "javascript-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/package.json", "/app/package-lock.json"}, Workdir: "/app"}, - {Name: "javascript-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/sdk/package.json", "/sdk/pnpm-lock.yaml"}, Workdir: "/sdk"}, - }, - ), - ) -}) - -var _ = Describe("ToCatalogers lua", func() { - DescribeTable("maps lua-rock directive to lua-rock-cataloger", - func(packages []*config.PackagesDirective, expected []scanner.Cataloger) { - Expect(ToCatalogers(packages)).To(Equal(expected)) - }, - - Entry("lua-rock maps to lua-rock-cataloger with spec path only (no lock)", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypeLuaRock, - FileBased: config.FileBasedSpec{Workdir: "/app", Spec: "app-0.1-1.rockspec", Lock: ""}, - }, - }, - []scanner.Cataloger{ - {Name: "lua-rock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/app-0.1-1.rockspec"}, Workdir: "/app"}, - }, - ), - - Entry("lua-rock with nested spec path includes correct path", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypeLuaRock, - FileBased: config.FileBasedSpec{Workdir: "/src", Spec: "rockspecs/app-0.1-1.rockspec", Lock: ""}, - }, - }, - []scanner.Cataloger{ - {Name: "lua-rock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/src/rockspecs/app-0.1-1.rockspec"}, Workdir: "/src"}, - }, - ), - - Entry("multiple lua-rock entries produce multiple catalogers", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypeLuaRock, - FileBased: config.FileBasedSpec{Workdir: "/app", Spec: "app-0.1-1.rockspec", Lock: ""}, - }, - { - Type: config.PackagesDirectiveTypeLuaRock, - FileBased: config.FileBasedSpec{Workdir: "/lib", Spec: "lib-2.0-1.rockspec", Lock: ""}, - }, - }, - []scanner.Cataloger{ - {Name: "lua-rock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/app-0.1-1.rockspec"}, Workdir: "/app"}, - {Name: "lua-rock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/lib/lib-2.0-1.rockspec"}, Workdir: "/lib"}, - }, - ), - ) -}) - -var _ = Describe("ToCatalogers python", func() { - DescribeTable("maps python directives to python-package-cataloger", - func(packages []*config.PackagesDirective, expected []scanner.Cataloger) { - Expect(ToCatalogers(packages)).To(Equal(expected)) - }, - - Entry("python-pip maps to python-package-cataloger with spec path only (no lock)", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypePythonPip, - FileBased: config.FileBasedSpec{Workdir: "/app", Spec: "requirements.txt", Lock: ""}, - }, - }, - []scanner.Cataloger{ - {Name: "python-package-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/requirements.txt"}, Workdir: "/app"}, - }, - ), - - Entry("python-uv maps to python-package-cataloger with spec and lock paths", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypePythonUV, - FileBased: config.FileBasedSpec{Workdir: "/app", Spec: "pyproject.toml", Lock: "uv.lock"}, - }, - }, - []scanner.Cataloger{ - {Name: "python-package-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/pyproject.toml", "/app/uv.lock"}, Workdir: "/app"}, - }, - ), - - Entry("python-poetry maps to python-package-cataloger with spec and lock paths", - []*config.PackagesDirective{ - { - Type: config.PackagesDirectiveTypePythonPoetry, - FileBased: config.FileBasedSpec{Workdir: "/svc", Spec: "pyproject.toml", Lock: "poetry.lock"}, - }, - }, - []scanner.Cataloger{ - {Name: "python-package-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/svc/pyproject.toml", "/svc/poetry.lock"}, Workdir: "/svc"}, - }, - ), - ) -}) - -var _ = Describe("FilterBOMBySourcePaths python declared", func() { - pythonProps := func(specPath string) *[]cdx.Property { - return &[]cdx.Property{ - {Name: "syft:package:foundBy", Value: "python-package-cataloger"}, - {Name: "syft:location:0:path", Value: specPath}, - } - } - - goModProps := func(path string) *[]cdx.Property { - return &[]cdx.Property{ - {Name: "syft:package:foundBy", Value: "go-module-file-cataloger"}, - {Name: "syft:location:0:path", Value: path}, - } - } - - DescribeTable("exact-match filtering for python declared and go-mod", - func(bom *cdx.BOM, catalogers []scanner.Cataloger, expectedNames []string) { - FilterBOMBySourcePaths(bom, catalogers) - Expect(*bom.Components).To(HaveLen(len(expectedNames))) - for i, name := range expectedNames { - Expect((*bom.Components)[i].Name).To(Equal(name)) - } - }, - - Entry("python-uv: keeps component with matching pyproject.toml path", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "requests", Properties: pythonProps("/app/pyproject.toml")}, - {Name: "flask", Properties: pythonProps("/other/pyproject.toml")}, - }, - }, - []scanner.Cataloger{ - {Name: "python-package-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/pyproject.toml", "/app/uv.lock"}, Workdir: "/app"}, - }, - []string{"requests"}, - ), - - Entry("python-pip: keeps component with matching requirements.txt path", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "requests", Properties: pythonProps("/app/requirements.txt")}, - {Name: "flask", Properties: pythonProps("/other/requirements.txt")}, - }, - }, - []scanner.Cataloger{ - {Name: "python-package-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/requirements.txt"}, Workdir: "/app"}, - }, - []string{"requests"}, - ), - - Entry("python-poetry: keeps component with matching lock path", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "requests", Properties: pythonProps("/svc/poetry.lock")}, - {Name: "flask", Properties: pythonProps("/app/poetry.lock")}, - }, - }, - []scanner.Cataloger{ - {Name: "python-package-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/svc/pyproject.toml", "/svc/poetry.lock"}, Workdir: "/svc"}, - }, - []string{"requests"}, - ), - - Entry("regression: go-mod exact-match still works alongside python cataloger", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "github.com/foo/bar", Properties: goModProps("/app/go.mod")}, - {Name: "github.com/baz/qux", Properties: goModProps("/other/go.mod")}, - }, - }, - []scanner.Cataloger{ - {Name: "go-module-file-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/go.mod", "/app/go.sum"}, Workdir: "/app"}, - }, - []string{"github.com/foo/bar"}, - ), - ) -}) - -var _ = Describe("FilterBOMBySourcePaths rust-cargo declared", func() { - cargoProps := func(paths ...string) *[]cdx.Property { - props := []cdx.Property{ - {Name: "syft:package:foundBy", Value: "rust-cargo-lock-cataloger"}, - } - for i, p := range paths { - props = append(props, cdx.Property{ - Name: fmt.Sprintf("syft:location:%d:path", i), - Value: p, - }) - } - return &props - } - - goModProps := func(path string) *[]cdx.Property { - return &[]cdx.Property{ - {Name: "syft:package:foundBy", Value: "go-module-file-cataloger"}, - {Name: "syft:location:0:path", Value: path}, - } - } - - DescribeTable("exact-match path filtering for rust-cargo", - func(bom *cdx.BOM, catalogers []scanner.Cataloger, expectedNames []string) { - FilterBOMBySourcePaths(bom, catalogers) - Expect(*bom.Components).To(HaveLen(len(expectedNames))) - for i, name := range expectedNames { - Expect((*bom.Components)[i].Name).To(Equal(name)) - } - }, - - Entry("rust component matching Cargo.toml path is kept", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "anyhow", Properties: cargoProps("/app/Cargo.toml")}, - {Name: "serde", Properties: cargoProps("/other/Cargo.toml")}, - }, - }, - []scanner.Cataloger{ - {Name: "rust-cargo-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/Cargo.toml", "/app/Cargo.lock"}, Workdir: "/app"}, - }, - []string{"anyhow"}, - ), - - Entry("rust component matching Cargo.lock path is kept", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "anyhow", Properties: cargoProps("/app/Cargo.lock")}, - {Name: "serde", Properties: cargoProps("/other/Cargo.lock")}, - }, - }, - []scanner.Cataloger{ - {Name: "rust-cargo-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/Cargo.toml", "/app/Cargo.lock"}, Workdir: "/app"}, - }, - []string{"anyhow"}, - ), - - Entry("rust component from different workdir is filtered out", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "anyhow", Properties: cargoProps("/app/Cargo.toml")}, - {Name: "anyhow", Properties: cargoProps("/lib/Cargo.toml")}, - }, - }, - []scanner.Cataloger{ - {Name: "rust-cargo-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/Cargo.toml", "/app/Cargo.lock"}, Workdir: "/app"}, - }, - []string{"anyhow"}, - ), - - Entry("regression: go-mod exact-match still works alongside rust-cargo cataloger", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "github.com/foo/bar", Properties: goModProps("/app/go.mod")}, - {Name: "anyhow", Properties: cargoProps("/crate/Cargo.toml")}, - }, - }, - []scanner.Cataloger{ - {Name: "go-module-file-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/go.mod", "/app/go.sum"}, Workdir: "/app"}, - {Name: "rust-cargo-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/crate/Cargo.toml", "/crate/Cargo.lock"}, Workdir: "/crate"}, - }, - []string{"github.com/foo/bar", "anyhow"}, - ), - ) -}) - -var _ = Describe("FilterBOMBySourcePaths javascript declared", func() { - javascriptProps := func(paths ...string) *[]cdx.Property { - props := []cdx.Property{ - {Name: "syft:package:foundBy", Value: "javascript-lock-cataloger"}, - } - for i, p := range paths { - props = append(props, cdx.Property{ - Name: fmt.Sprintf("syft:location:%d:path", i), - Value: p, - }) - } - return &props - } - - goModProps := func(path string) *[]cdx.Property { - return &[]cdx.Property{ - {Name: "syft:package:foundBy", Value: "go-module-file-cataloger"}, - {Name: "syft:location:0:path", Value: path}, - } - } - - DescribeTable("exact-match path filtering for javascript", - func(bom *cdx.BOM, catalogers []scanner.Cataloger, expectedNames []string) { - FilterBOMBySourcePaths(bom, catalogers) - Expect(*bom.Components).To(HaveLen(len(expectedNames))) - for i, name := range expectedNames { - Expect((*bom.Components)[i].Name).To(Equal(name)) - } - }, - - Entry("javascript-npm component matching package.json path is kept", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "lodash", Properties: javascriptProps("/app/package.json")}, - {Name: "express", Properties: javascriptProps("/other/package.json")}, - }, - }, - []scanner.Cataloger{ - {Name: "javascript-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/package.json", "/app/package-lock.json"}, Workdir: "/app"}, - }, - []string{"lodash"}, - ), - - Entry("javascript-yarn component matching yarn.lock path is kept", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "lodash", Properties: javascriptProps("/app/yarn.lock")}, - {Name: "express", Properties: javascriptProps("/other/yarn.lock")}, - }, - }, - []scanner.Cataloger{ - {Name: "javascript-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/package.json", "/app/yarn.lock"}, Workdir: "/app"}, - }, - []string{"lodash"}, - ), - - Entry("javascript-pnpm component matching pnpm-lock.yaml path is kept", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "lodash", Properties: javascriptProps("/app/pnpm-lock.yaml")}, - {Name: "express", Properties: javascriptProps("/other/pnpm-lock.yaml")}, - }, - }, - []scanner.Cataloger{ - {Name: "javascript-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/package.json", "/app/pnpm-lock.yaml"}, Workdir: "/app"}, - }, - []string{"lodash"}, - ), - - Entry("javascript component from different workdir is filtered out", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "lodash", Properties: javascriptProps("/app/package.json")}, - {Name: "lodash", Properties: javascriptProps("/lib/package.json")}, - }, - }, - []scanner.Cataloger{ - {Name: "javascript-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/package.json", "/app/package-lock.json"}, Workdir: "/app"}, - }, - []string{"lodash"}, - ), - - Entry("regression: go-mod exact-match still works alongside javascript cataloger", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "github.com/foo/bar", Properties: goModProps("/app/go.mod")}, - {Name: "lodash", Properties: javascriptProps("/app/package.json")}, - }, - }, - []scanner.Cataloger{ - {Name: "go-module-file-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/go.mod", "/app/go.sum"}, Workdir: "/app"}, - {Name: "javascript-lock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/package.json", "/app/package-lock.json"}, Workdir: "/app"}, - }, - []string{"github.com/foo/bar", "lodash"}, - ), - ) -}) - -var _ = Describe("FilterBOMBySourcePaths lua-rock declared", func() { - luaProps := func(paths ...string) *[]cdx.Property { - props := []cdx.Property{ - {Name: "syft:package:foundBy", Value: "lua-rock-cataloger"}, - } - for i, p := range paths { - props = append(props, cdx.Property{ - Name: fmt.Sprintf("syft:location:%d:path", i), - Value: p, - }) - } - return &props - } - - goModProps := func(path string) *[]cdx.Property { - return &[]cdx.Property{ - {Name: "syft:package:foundBy", Value: "go-module-file-cataloger"}, - {Name: "syft:location:0:path", Value: path}, - } - } - - DescribeTable("exact-match path filtering for lua-rock", - func(bom *cdx.BOM, catalogers []scanner.Cataloger, expectedNames []string) { - FilterBOMBySourcePaths(bom, catalogers) - Expect(*bom.Components).To(HaveLen(len(expectedNames))) - for i, name := range expectedNames { - Expect((*bom.Components)[i].Name).To(Equal(name)) - } - }, - - Entry("lua component matching rockspec path is kept", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "app", Properties: luaProps("/app/app-0.1-1.rockspec")}, - {Name: "other", Properties: luaProps("/other/other-0.1-1.rockspec")}, - }, - }, - []scanner.Cataloger{ - {Name: "lua-rock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/app-0.1-1.rockspec"}, Workdir: "/app"}, - }, - []string{"app"}, - ), - - Entry("lua component from different workdir is filtered out", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "app", Properties: luaProps("/app/app-0.1-1.rockspec")}, - {Name: "app", Properties: luaProps("/lib/app-0.1-1.rockspec")}, - }, - }, - []scanner.Cataloger{ - {Name: "lua-rock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/app-0.1-1.rockspec"}, Workdir: "/app"}, - }, - []string{"app"}, - ), - - Entry("regression: go-mod exact-match still works alongside lua-rock cataloger", - &cdx.BOM{ - Components: &[]cdx.Component{ - {Name: "github.com/foo/bar", Properties: goModProps("/app/go.mod")}, - {Name: "app", Properties: luaProps("/rock/app-0.1-1.rockspec")}, - }, - }, - []scanner.Cataloger{ - {Name: "go-module-file-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/app/go.mod", "/app/go.sum"}, Workdir: "/app"}, - {Name: "lua-rock-cataloger", FilterMode: scanner.CatalogerFilterExactPath, SourcePaths: []string{"/rock/app-0.1-1.rockspec"}, Workdir: "/rock"}, - }, - []string{"github.com/foo/bar", "app"}, - ), - ) -}) diff --git a/pkg/sbom/managedinput/materialize.go b/pkg/sbom/managedinput/materialize.go new file mode 100644 index 0000000000..8613f2230b --- /dev/null +++ b/pkg/sbom/managedinput/materialize.go @@ -0,0 +1,73 @@ +package managedinput + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/werf/logboek" + "github.com/werf/werf/v2/pkg/container_backend" + "github.com/werf/werf/v2/pkg/sbom/scanner" +) + +// MaterializeCatalogerInputs extracts a cataloger's declared spec/lock files from the +// built image and writes them into a fresh temporary directory, preserving their layout +// relative to the directive workdir so that syft's directory-source catalogers can link +// a spec to its lock (e.g. go.mod next to go.sum). The returned directory and its files +// are world-readable so the unprivileged scanner container can read them. The caller must +// invoke the returned cleanup once the scan is done. +func MaterializeCatalogerInputs(ctx context.Context, backend container_backend.ContainerBackend, imageRef string, cataloger scanner.Cataloger, targetPlatform string) (string, func(context.Context), error) { + dir, err := os.MkdirTemp("", "sbom-dirscan-*") + if err != nil { + return "", nil, fmt.Errorf("create scan dir: %w", err) + } + + cleanup := func(ctx context.Context) { + if err := os.RemoveAll(dir); err != nil { + logboek.Context(ctx).Warn().LogF("WARNING: unable to remove scan dir %q: %s\n", dir, err) + } + } + + if err := os.Chmod(dir, 0o755); err != nil { + cleanup(ctx) + return "", nil, fmt.Errorf("chmod scan dir %q: %w", dir, err) + } + + for _, sourcePath := range cataloger.SourcePaths { + data, err := backend.ReadFileFromImage(ctx, imageRef, sourcePath, container_backend.ReadFileFromImageOpts{TargetPlatform: targetPlatform}) + if err != nil { + cleanup(ctx) + return "", nil, fmt.Errorf("read %s from image %q for cataloger %q: %w", sourcePath, imageRef, cataloger.Name, err) + } + + destPath := filepath.Join(dir, relativeToWorkdir(sourcePath, cataloger.Workdir)) + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + cleanup(ctx) + return "", nil, fmt.Errorf("create scan subdir for %s: %w", destPath, err) + } + if err := os.WriteFile(destPath, data, 0o644); err != nil { + cleanup(ctx) + return "", nil, fmt.Errorf("write %s: %w", destPath, err) + } + if err := os.Chmod(destPath, 0o644); err != nil { + cleanup(ctx) + return "", nil, fmt.Errorf("chmod %s: %w", destPath, err) + } + } + + return dir, cleanup, nil +} + +// relativeToWorkdir maps an in-image absolute path to its path relative to the directive +// workdir, so a materialized file keeps the position the cataloger expects. Paths outside +// the workdir fall back to their base name. +func relativeToWorkdir(sourcePath, workdir string) string { + workdir = strings.TrimSuffix(workdir, "/") + if workdir != "" && strings.HasPrefix(sourcePath, workdir+"/") { + return strings.TrimPrefix(sourcePath, workdir+"/") + } + return path.Base(sourcePath) +} diff --git a/pkg/sbom/managedinput/materialize_test.go b/pkg/sbom/managedinput/materialize_test.go new file mode 100644 index 0000000000..4bd7ef600d --- /dev/null +++ b/pkg/sbom/managedinput/materialize_test.go @@ -0,0 +1,127 @@ +package managedinput + +import ( + "context" + "errors" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + + "github.com/werf/werf/v2/pkg/container_backend" + "github.com/werf/werf/v2/pkg/sbom/scanner" + "github.com/werf/werf/v2/test/mock" +) + +var _ = Describe("MaterializeCatalogerInputs", func() { + var ( + ctrl *gomock.Controller + mockBackend *mock.MockContainerBackend + ctx context.Context + imageRef string + ) + + BeforeEach(func() { + ctrl = gomock.NewController(GinkgoT()) + mockBackend = mock.NewMockContainerBackend(ctrl) + ctx = context.Background() + imageRef = "test-image:latest" + }) + + AfterEach(func() { + ctrl.Finish() + }) + + It("materializes spec and lock adjacent, relative to the workdir, world-readable", func() { + cataloger := scanner.Cataloger{ + Name: "go-module-file-cataloger", + Workdir: "/app/api", + SourcePaths: []string{"/app/api/go.mod", "/app/api/go.sum"}, + } + mockBackend.EXPECT(). + ReadFileFromImage(ctx, imageRef, "/app/api/go.mod", container_backend.ReadFileFromImageOpts{}). + Return([]byte("module example.com/app\n"), nil) + mockBackend.EXPECT(). + ReadFileFromImage(ctx, imageRef, "/app/api/go.sum", container_backend.ReadFileFromImageOpts{}). + Return([]byte("example.com/dep v1.0.0 h1:deadbeef\n"), nil) + + dir, cleanup, err := MaterializeCatalogerInputs(ctx, mockBackend, imageRef, cataloger, "") + Expect(err).To(Succeed()) + DeferCleanup(func() { cleanup(ctx) }) + + specPath := filepath.Join(dir, "go.mod") + lockPath := filepath.Join(dir, "go.sum") + + specContent, err := os.ReadFile(specPath) + Expect(err).To(Succeed()) + Expect(string(specContent)).To(Equal("module example.com/app\n")) + + lockContent, err := os.ReadFile(lockPath) + Expect(err).To(Succeed()) + Expect(string(lockContent)).To(Equal("example.com/dep v1.0.0 h1:deadbeef\n")) + + Expect(filepath.Dir(specPath)).To(Equal(filepath.Dir(lockPath)), + "spec and lock must be materialized in the same directory so the cataloger can link them") + + specInfo, err := os.Stat(specPath) + Expect(err).To(Succeed()) + Expect(specInfo.Mode().Perm()&0o004).To(Equal(os.FileMode(0o004)), "spec must be world-readable") + + dirInfo, err := os.Stat(dir) + Expect(err).To(Succeed()) + Expect(dirInfo.Mode().Perm()&0o005).To(Equal(os.FileMode(0o005)), "scan dir must be world-readable and traversable") + }) + + It("materializes only the spec when the directive declares no lock", func() { + cataloger := scanner.Cataloger{ + Name: "python-package-cataloger", + Workdir: "/app", + SourcePaths: []string{"/app/requirements.txt"}, + } + mockBackend.EXPECT(). + ReadFileFromImage(ctx, imageRef, "/app/requirements.txt", container_backend.ReadFileFromImageOpts{}). + Return([]byte("flask==3.0.0\n"), nil) + + dir, cleanup, err := MaterializeCatalogerInputs(ctx, mockBackend, imageRef, cataloger, "") + Expect(err).To(Succeed()) + DeferCleanup(func() { cleanup(ctx) }) + + content, err := os.ReadFile(filepath.Join(dir, "requirements.txt")) + Expect(err).To(Succeed()) + Expect(string(content)).To(Equal("flask==3.0.0\n")) + }) + + It("forwards the target platform to the image read", func() { + cataloger := scanner.Cataloger{ + Name: "go-module-file-cataloger", + Workdir: "/app", + SourcePaths: []string{"/app/go.mod"}, + } + mockBackend.EXPECT(). + ReadFileFromImage(ctx, imageRef, "/app/go.mod", container_backend.ReadFileFromImageOpts{TargetPlatform: "linux/arm64"}). + Return([]byte("module example.com/app\n"), nil) + + dir, cleanup, err := MaterializeCatalogerInputs(ctx, mockBackend, imageRef, cataloger, "linux/arm64") + Expect(err).To(Succeed()) + DeferCleanup(func() { cleanup(ctx) }) + Expect(dir).ToNot(BeEmpty()) + }) + + It("fails naming the cataloger and path when a declared file is absent from the image", func() { + cataloger := scanner.Cataloger{ + Name: "go-module-file-cataloger", + Workdir: "/app", + SourcePaths: []string{"/app/go.mod"}, + } + mockBackend.EXPECT(). + ReadFileFromImage(ctx, imageRef, "/app/go.mod", container_backend.ReadFileFromImageOpts{}). + Return(nil, errors.New("no regular file at /app/go.mod")) + + _, _, err := MaterializeCatalogerInputs(ctx, mockBackend, imageRef, cataloger, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("go-module-file-cataloger")) + Expect(err.Error()).To(ContainSubstring("/app/go.mod")) + }) +}) diff --git a/pkg/sbom/scanner/cataloger.go b/pkg/sbom/scanner/cataloger.go index 687d3f556d..91864b428a 100644 --- a/pkg/sbom/scanner/cataloger.go +++ b/pkg/sbom/scanner/cataloger.go @@ -1,20 +1,10 @@ package scanner -// CatalogerFilterMode controls how BOM components are matched back to a cataloger's scope. -type CatalogerFilterMode int - -const ( - CatalogerFilterExactPath CatalogerFilterMode = iota - CatalogerFilterWorkdirPrefix CatalogerFilterMode = iota - CatalogerFilterCatalogerOnly CatalogerFilterMode = iota -) - // Cataloger is a syft cataloger to enable for a scan, together with the in-image -// file paths it targets (e.g. go.mod / go.sum) and the filter mode that controls -// how BOM components are matched back to this cataloger's scope. +// file paths it targets (e.g. go.mod / go.sum) and the workdir those paths are +// declared under, used to materialize them for a targeted directory scan. type Cataloger struct { Name string - FilterMode CatalogerFilterMode SourcePaths []string Workdir string } From 82ea3f98845d06726e56f129bad9c8a77fe4ba68 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Thu, 10 Sep 2026 10:11:50 +0300 Subject: [PATCH 2/6] fix(sbom): keep in-image file paths and syft metadata in dir scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on the targeted directory-scan SBOM path. Materialize each declared spec/lock under its full in-image path instead of a workdir-relative one, so a dir source records the real location (/app/api/go.mod) rather than a path that never existed in the image (/go.mod). The rebase is anchored and cleaned so a "..", a leading slash or a relative source path cannot escape the scan directory. The scan layout now depends only on the source paths, so Cataloger.Workdir — and the cache-key ambiguity of it being absent from the scan-command checksum — is removed. Restore only the container component of the scanned BOM's metadata and keep syft's own tools and timestamp; replacing the whole metadata dropped the timestamp, and a per-image SBOM without one is rejected by the ISPRAS validator. Stamp the skip-scan branch with a timestamp too. A directory source makes syft emit a PURL-less type=file component for each scanned manifest, which dedup never removes; drop these after every per-directive scan so only real packages remain, which is what keeps the post-scan source-path filter safely removed. Record that SYFT_FILE_METADATA_SELECTION=none is required for this — the scanner honors it, contrary to the task note. Wrap the materialize error, guard the per-directive union against an empty slice, and document its first-directive-wins dedup order. Add a unit test for scanFileBasedPackages, and document the directory scan and the build-fails-on-a -missing-declared-file behavior in the SBOM docs (en/ru). Signed-off-by: Radmir Khurum --- docs/pages_en/usage/build/sbom.md | 6 +- docs/pages_ru/usage/build/sbom.md | 6 +- pkg/build/sbom_step.go | 47 ++++++++---- pkg/build/sbom_step_test.go | 73 +++++++++++++++++++ .../docker_server_backend.go | 6 ++ pkg/sbom/cyclonedxutil/source_file.go | 32 ++++++++ pkg/sbom/cyclonedxutil/source_file_test.go | 59 +++++++++++++++ pkg/sbom/managedinput/managedinput.go | 5 -- pkg/sbom/managedinput/managedinput_test.go | 6 +- pkg/sbom/managedinput/materialize.go | 27 ++----- pkg/sbom/managedinput/materialize_test.go | 32 ++++++-- pkg/sbom/scanner/cataloger.go | 5 +- 12 files changed, 249 insertions(+), 55 deletions(-) create mode 100644 pkg/sbom/cyclonedxutil/source_file.go create mode 100644 pkg/sbom/cyclonedxutil/source_file_test.go diff --git a/docs/pages_en/usage/build/sbom.md b/docs/pages_en/usage/build/sbom.md index 81ac42fd26..7ba2bdec5d 100644 --- a/docs/pages_en/usage/build/sbom.md +++ b/docs/pages_en/usage/build/sbom.md @@ -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** | Dockerfile images: daemon + socket via volume (for Docker). Stapel images with file-based `packages`: directory scan of the declared spec/lock files extracted from the built image, no socket. | +| **Path in Source Image** | OS root (Dockerfile images); the declared `packages` spec/lock files (stapel file-based packages) | | **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/lock file (for example `go.mod`/`go.sum` or `requirements.txt`) is read from the built image and scanned directly as a directory source, without mounting the Docker socket. If a file declared in `packages` 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. diff --git a/docs/pages_ru/usage/build/sbom.md b/docs/pages_ru/usage/build/sbom.md index d8714481e5..b8a1a784a2 100644 --- a/docs/pages_ru/usage/build/sbom.md +++ b/docs/pages_ru/usage/build/sbom.md @@ -44,12 +44,14 @@ build: | **Сканер** | syft | | **Образ сканера** | anchore/syft:v1.45.1 | | **Политика получения образа** | `PullIfMissing` | -| **Способ подключения к источнику данных** | daemon + socket via volume (для Docker) | -| **Путь в образе источнике** | корень OS | +| **Способ подключения к источнику данных** | Образы Dockerfile: daemon + socket via volume (для Docker). Stapel-образы с file-based `packages`: сканирование каталога с извлечёнными из собранного образа spec/lock-файлами, без socket. | +| **Путь в образе источнике** | корень OS (образы Dockerfile); объявленные spec/lock-файлы `packages` (stapel file-based packages) | | **Настройки сканирования** | [ссылка](https://github.com/anchore/syft/wiki/Configuration#list-of-configurable-values) | | **Исходящий стандарт** | `CycloneDX@1.6` | | **Исходящий формат** | `JSON` | +Для stapel-образов с file-based `packages` каждый объявленный spec/lock-файл (например, `go.mod`/`go.sum` или `requirements.txt`) читается из собранного образа и сканируется напрямую как каталог-источник, без монтирования Docker-сокета. Если объявленный в `packages` файл отсутствует в собранном образе как обычный файл — например, удалён более поздней стадией или присутствует только как симлинк — сборка завершается ошибкой с указанием директивы и отсутствующего пути. + ## Требования к базовому образу Когда генерация SBOM включена, каждый базовый образ, указанный через `from` или `fromImage`, и каждый образ, указанный через `import`, **должен иметь прикреплённый SBOM-артефакт в registry**. Альтернативы этому требованию нет; единственное исключение описано ниже. diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index 0974211c33..7b49db2aa6 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_step.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "sync" + "time" cdx "github.com/CycloneDX/cyclonedx-go" "github.com/sigstore/sigstore/pkg/signature" @@ -98,14 +99,22 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, switch { case !syftScanRequired(isStapel, catalogers): targetBOM = cyclonedxutil.NewBOM() - targetBOM.Metadata = containerMetadata(stageDesc) + targetBOM.Metadata = &cdx.Metadata{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Component: containerComponent(stageDesc), + } case isStapel: var err error targetBOM, err = step.scanFileBasedPackages(ctx, stageDesc.Info.Name, scanOpts, catalogers, targetPlatform) if err != nil { return err } - targetBOM.Metadata = containerMetadata(stageDesc) + // Keep syft's own metadata (tools, timestamp) from the scan and restore only the + // image component, which a directory source otherwise reports as the scan directory. + if targetBOM.Metadata == nil { + targetBOM.Metadata = &cdx.Metadata{} + } + targetBOM.Metadata.Component = containerComponent(stageDesc) default: bomJSON, err := step.containerBackend.GenerateSBOM(ctx, scanOpts) if err != nil { @@ -187,16 +196,14 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, }) } -// containerMetadata builds the top-level container component of an image BOM. The -// targeted directory scan reports the temporary scan directory as its source, so the -// image identity is restored here explicitly (same as the skip-scan branch). -func containerMetadata(stageDesc *image.StageDesc) *cdx.Metadata { - return &cdx.Metadata{ - Component: &cdx.Component{ - Type: cdx.ComponentTypeContainer, - Name: stageDesc.Info.Repository, - Version: stageDesc.Info.Tag, - }, +// containerComponent builds the top-level container component of an image BOM. The +// targeted directory scan reports the temporary scan directory as its source component, +// so the image identity is restored from this instead. +func containerComponent(stageDesc *image.StageDesc) *cdx.Component { + return &cdx.Component{ + Type: cdx.ComponentTypeContainer, + Name: stageDesc.Info.Repository, + Version: stageDesc.Info.Tag, } } @@ -209,7 +216,7 @@ func (step *sbomStep) scanFileBasedPackages(ctx context.Context, imageRef string for _, cataloger := range catalogers { dir, cleanup, err := managedinput.MaterializeCatalogerInputs(ctx, step.containerBackend, imageRef, cataloger, targetPlatform) if err != nil { - return nil, err + return nil, fmt.Errorf("materialize inputs for cataloger %q: %w", cataloger.Name, err) } bom, err := step.scanCatalogerDir(ctx, scanOpts, cataloger, dir) @@ -221,6 +228,15 @@ func (step *sbomStep) scanFileBasedPackages(ctx context.Context, imageRef string 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:]}) if err != nil { return nil, fmt.Errorf("union per-directive BOMs: %w", err) @@ -248,6 +264,11 @@ func (step *sbomStep) scanCatalogerDir(ctx context.Context, scanOpts scanner.Sca 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 } diff --git a/pkg/build/sbom_step_test.go b/pkg/build/sbom_step_test.go index 8f22cd6cd2..b8b6aa2c17 100644 --- a/pkg/build/sbom_step_test.go +++ b/pkg/build/sbom_step_test.go @@ -125,6 +125,79 @@ 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("isTrustedBuilderImage()", func() { DescribeTable("should detect trusted builder images", func(labels map[string]string, expected bool) { diff --git a/pkg/container_backend/docker_server_backend.go b/pkg/container_backend/docker_server_backend.go index a74962586c..21fc76cc9b 100644 --- a/pkg/container_backend/docker_server_backend.go +++ b/pkg/container_backend/docker_server_backend.go @@ -734,6 +734,12 @@ func mapSbomScanOptionsToDockerRunCommand(workingTreeDir, billsDir string, billN 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", ) diff --git a/pkg/sbom/cyclonedxutil/source_file.go b/pkg/sbom/cyclonedxutil/source_file.go new file mode 100644 index 0000000000..e52ba78153 --- /dev/null +++ b/pkg/sbom/cyclonedxutil/source_file.go @@ -0,0 +1,32 @@ +package cyclonedxutil + +import ( + cdx "github.com/CycloneDX/cyclonedx-go" +) + +// DropSyftSourceFileComponents removes the PURL-less type=file components that a syft +// directory-source scan emits for the scanned manifest files themselves (e.g. a +// "/scan/go.mod" file entry). These are not packages, and dedupComponentsByPURL keeps +// PURL-less components, so nothing downstream would drop them. A targeted directory scan +// runs this after each scan so only real package components remain — the property that +// lets the post-scan source-path filter be omitted. Components with a PackageURL, and +// non-file components, are always kept. +func DropSyftSourceFileComponents(bom *cdx.BOM) { + if bom == nil || bom.Components == nil { + return + } + + kept := make([]cdx.Component, 0, len(*bom.Components)) + for _, comp := range *bom.Components { + if comp.Type == cdx.ComponentTypeFile && comp.PackageURL == "" { + continue + } + kept = append(kept, comp) + } + + if len(kept) == 0 { + bom.Components = nil + return + } + *bom.Components = kept +} diff --git a/pkg/sbom/cyclonedxutil/source_file_test.go b/pkg/sbom/cyclonedxutil/source_file_test.go new file mode 100644 index 0000000000..bdca3fa933 --- /dev/null +++ b/pkg/sbom/cyclonedxutil/source_file_test.go @@ -0,0 +1,59 @@ +package cyclonedxutil + +import ( + cdx "github.com/CycloneDX/cyclonedx-go" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("DropSyftSourceFileComponents", func() { + comps := func(cs ...cdx.Component) *cdx.BOM { + list := append([]cdx.Component{}, cs...) + return &cdx.BOM{Components: &list} + } + + names := func(bom *cdx.BOM) []string { + if bom.Components == nil { + return nil + } + out := make([]string, 0, len(*bom.Components)) + for _, c := range *bom.Components { + out = append(out, c.Name) + } + return out + } + + It("drops PURL-less type=file components a dir scan emits for the manifest files", func() { + bom := comps( + cdx.Component{Type: cdx.ComponentTypeFile, Name: "go.mod"}, + cdx.Component{Type: cdx.ComponentTypeLibrary, Name: "github.com/samber/lo", PackageURL: "pkg:golang/github.com/samber/lo@v1.47.0"}, + ) + + DropSyftSourceFileComponents(bom) + + Expect(names(bom)).To(Equal([]string{"github.com/samber/lo"})) + }) + + It("keeps a type=file component that carries a PackageURL", func() { + bom := comps( + cdx.Component{Type: cdx.ComponentTypeFile, Name: "some-artifact", PackageURL: "pkg:generic/some-artifact"}, + ) + + DropSyftSourceFileComponents(bom) + + Expect(names(bom)).To(Equal([]string{"some-artifact"})) + }) + + It("nils out Components when only source file entries remain", func() { + bom := comps(cdx.Component{Type: cdx.ComponentTypeFile, Name: "go.mod"}) + + DropSyftSourceFileComponents(bom) + + Expect(bom.Components).To(BeNil()) + }) + + It("is a no-op on a nil BOM or nil component list", func() { + Expect(func() { DropSyftSourceFileComponents(nil) }).ToNot(Panic()) + Expect(func() { DropSyftSourceFileComponents(&cdx.BOM{}) }).ToNot(Panic()) + }) +}) diff --git a/pkg/sbom/managedinput/managedinput.go b/pkg/sbom/managedinput/managedinput.go index d377aa7f8e..6cbe1ed089 100644 --- a/pkg/sbom/managedinput/managedinput.go +++ b/pkg/sbom/managedinput/managedinput.go @@ -14,7 +14,6 @@ type inputResolver struct { inputType config.PackagesDirectiveType catalogerName string sourcePaths func(directive *config.PackagesDirective) []string - workdir func(directive *config.PackagesDirective) string } var resolvers = buildResolvers() @@ -47,9 +46,6 @@ func buildResolvers() []inputResolver { } return paths }, - workdir: func(d *config.PackagesDirective) string { - return d.FileBased.Workdir - }, }) } return built @@ -69,7 +65,6 @@ func ToCatalogers(packages []*config.PackagesDirective) []scanner.Cataloger { catalogers = append(catalogers, scanner.Cataloger{ Name: res.catalogerName, SourcePaths: res.sourcePaths(directive), - Workdir: res.workdir(directive), }) } diff --git a/pkg/sbom/managedinput/managedinput_test.go b/pkg/sbom/managedinput/managedinput_test.go index f20fddf491..57776bbc4d 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"}, Workdir: "/app/api"}, - {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/cli/go.mod", "/app/cli/go.sum"}, Workdir: "/app/cli"}, + {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"}}, }, ), @@ -96,7 +96,7 @@ var _ = Describe("ToCatalogers", func() { }, }, []scanner.Cataloger{ - {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/go.mod", "/app/go.sum"}, Workdir: "/app"}, + {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/go.mod", "/app/go.sum"}}, }, ), diff --git a/pkg/sbom/managedinput/materialize.go b/pkg/sbom/managedinput/materialize.go index 8613f2230b..102abe0024 100644 --- a/pkg/sbom/managedinput/materialize.go +++ b/pkg/sbom/managedinput/materialize.go @@ -4,9 +4,7 @@ import ( "context" "fmt" "os" - "path" "path/filepath" - "strings" "github.com/werf/logboek" "github.com/werf/werf/v2/pkg/container_backend" @@ -14,11 +12,11 @@ import ( ) // MaterializeCatalogerInputs extracts a cataloger's declared spec/lock files from the -// built image and writes them into a fresh temporary directory, preserving their layout -// relative to the directive workdir so that syft's directory-source catalogers can link -// a spec to its lock (e.g. go.mod next to go.sum). The returned directory and its files -// are world-readable so the unprivileged scanner container can read them. The caller must -// invoke the returned cleanup once the scan is done. +// built image and writes them into a fresh temporary directory under their full in-image +// path, so a directory-source scan records the same locations the files had in the image +// (e.g. /app/api/go.mod) and keeps a spec next to its lock. The returned directory and +// its files are world-readable so the unprivileged scanner container can read them. The +// caller must invoke the returned cleanup once the scan is done. func MaterializeCatalogerInputs(ctx context.Context, backend container_backend.ContainerBackend, imageRef string, cataloger scanner.Cataloger, targetPlatform string) (string, func(context.Context), error) { dir, err := os.MkdirTemp("", "sbom-dirscan-*") if err != nil { @@ -43,7 +41,9 @@ func MaterializeCatalogerInputs(ctx context.Context, backend container_backend.C return "", nil, fmt.Errorf("read %s from image %q for cataloger %q: %w", sourcePath, imageRef, cataloger.Name, err) } - destPath := filepath.Join(dir, relativeToWorkdir(sourcePath, cataloger.Workdir)) + // Rebase the in-image path onto the scan dir. Anchoring at "/" and cleaning first + // collapses any ".." and leading slash, so the result can never escape dir. + destPath := filepath.Join(dir, filepath.Clean("/"+sourcePath)) if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { cleanup(ctx) return "", nil, fmt.Errorf("create scan subdir for %s: %w", destPath, err) @@ -60,14 +60,3 @@ func MaterializeCatalogerInputs(ctx context.Context, backend container_backend.C return dir, cleanup, nil } - -// relativeToWorkdir maps an in-image absolute path to its path relative to the directive -// workdir, so a materialized file keeps the position the cataloger expects. Paths outside -// the workdir fall back to their base name. -func relativeToWorkdir(sourcePath, workdir string) string { - workdir = strings.TrimSuffix(workdir, "/") - if workdir != "" && strings.HasPrefix(sourcePath, workdir+"/") { - return strings.TrimPrefix(sourcePath, workdir+"/") - } - return path.Base(sourcePath) -} diff --git a/pkg/sbom/managedinput/materialize_test.go b/pkg/sbom/managedinput/materialize_test.go index 4bd7ef600d..cc2d64cb02 100644 --- a/pkg/sbom/managedinput/materialize_test.go +++ b/pkg/sbom/managedinput/materialize_test.go @@ -34,10 +34,9 @@ var _ = Describe("MaterializeCatalogerInputs", func() { ctrl.Finish() }) - It("materializes spec and lock adjacent, relative to the workdir, world-readable", func() { + It("materializes spec and lock under their full in-image path, adjacent, world-readable", func() { cataloger := scanner.Cataloger{ Name: "go-module-file-cataloger", - Workdir: "/app/api", SourcePaths: []string{"/app/api/go.mod", "/app/api/go.sum"}, } mockBackend.EXPECT(). @@ -51,8 +50,10 @@ var _ = Describe("MaterializeCatalogerInputs", func() { Expect(err).To(Succeed()) DeferCleanup(func() { cleanup(ctx) }) - specPath := filepath.Join(dir, "go.mod") - lockPath := filepath.Join(dir, "go.sum") + // The full in-image path is preserved so a dir:/scan scan records /app/api/go.mod, + // not a workdir-relative /go.mod. + specPath := filepath.Join(dir, "app", "api", "go.mod") + lockPath := filepath.Join(dir, "app", "api", "go.sum") specContent, err := os.ReadFile(specPath) Expect(err).To(Succeed()) @@ -77,7 +78,6 @@ var _ = Describe("MaterializeCatalogerInputs", func() { It("materializes only the spec when the directive declares no lock", func() { cataloger := scanner.Cataloger{ Name: "python-package-cataloger", - Workdir: "/app", SourcePaths: []string{"/app/requirements.txt"}, } mockBackend.EXPECT(). @@ -88,15 +88,32 @@ var _ = Describe("MaterializeCatalogerInputs", func() { Expect(err).To(Succeed()) DeferCleanup(func() { cleanup(ctx) }) - content, err := os.ReadFile(filepath.Join(dir, "requirements.txt")) + content, err := os.ReadFile(filepath.Join(dir, "app", "requirements.txt")) Expect(err).To(Succeed()) Expect(string(content)).To(Equal("flask==3.0.0\n")) }) + It("keeps a materialized file inside the scan dir even if the source path contains ..", func() { + cataloger := scanner.Cataloger{ + Name: "go-module-file-cataloger", + SourcePaths: []string{"/app/../../../etc/go.mod"}, + } + mockBackend.EXPECT(). + ReadFileFromImage(ctx, imageRef, "/app/../../../etc/go.mod", container_backend.ReadFileFromImageOpts{}). + Return([]byte("module example.com/app\n"), nil) + + dir, cleanup, err := MaterializeCatalogerInputs(ctx, mockBackend, imageRef, cataloger, "") + Expect(err).To(Succeed()) + DeferCleanup(func() { cleanup(ctx) }) + + content, err := os.ReadFile(filepath.Join(dir, "etc", "go.mod")) + Expect(err).To(Succeed()) + Expect(string(content)).To(Equal("module example.com/app\n")) + }) + It("forwards the target platform to the image read", func() { cataloger := scanner.Cataloger{ Name: "go-module-file-cataloger", - Workdir: "/app", SourcePaths: []string{"/app/go.mod"}, } mockBackend.EXPECT(). @@ -112,7 +129,6 @@ var _ = Describe("MaterializeCatalogerInputs", func() { It("fails naming the cataloger and path when a declared file is absent from the image", func() { cataloger := scanner.Cataloger{ Name: "go-module-file-cataloger", - Workdir: "/app", SourcePaths: []string{"/app/go.mod"}, } mockBackend.EXPECT(). diff --git a/pkg/sbom/scanner/cataloger.go b/pkg/sbom/scanner/cataloger.go index 91864b428a..95e910f142 100644 --- a/pkg/sbom/scanner/cataloger.go +++ b/pkg/sbom/scanner/cataloger.go @@ -1,10 +1,9 @@ package scanner // Cataloger is a syft cataloger to enable for a scan, together with the in-image -// file paths it targets (e.g. go.mod / go.sum) and the workdir those paths are -// declared under, used to materialize them for a targeted directory scan. +// file paths it targets (e.g. go.mod / go.sum), which are materialized under their +// full in-image path for a targeted directory scan. type Cataloger struct { Name string SourcePaths []string - Workdir string } From da7087a2b7d08498ccf2fa14738b8dc6a82c2d6e Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Thu, 10 Sep 2026 11:23:08 +0300 Subject: [PATCH 3/6] fix(sbom): make targeted-scan files readable under any umask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review pass follow-ups on the directory-scan SBOM path. MkdirTemp, MkdirAll and WriteFile are all umask-subject, so under a restrictive umask (e.g. 077) the scan root and the nested per-path directories materialized for every scan were 0700 — not traversable by a non-root scanner container, contradicting the function's own world-readable contract. Force the whole materialized tree world-readable (directories also executable) in one pass after writing, instead of the piecemeal chmods that missed the MkdirAll directories. A test under umask 077 pins the intermediate app/, app/api/ directories, which the previous permission assertions (scan root and file only) passed for the wrong reason — a default 022 umask already yields 0755. Extract the image-metadata restoration into restoreImageMetadata and cover it with a unit test: replacing only the container component while keeping syft's tools and timestamp, and stamping a timestamp when the BOM has none. The previous test asserted timestamp survival only through the per-directive union, leaving the actual restoration in ConvergeWithMerge unpinned; reverting it to replace the whole metadata now fails a test. Signed-off-by: Radmir Khurum --- pkg/build/sbom_step.go | 31 ++++++++++-------- pkg/build/sbom_step_test.go | 40 +++++++++++++++++++++++ pkg/sbom/managedinput/materialize.go | 31 +++++++++++++----- pkg/sbom/managedinput/materialize_test.go | 32 ++++++++++++++++++ 4 files changed, 112 insertions(+), 22 deletions(-) diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index 7b49db2aa6..019c604b69 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_step.go @@ -99,22 +99,14 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, switch { case !syftScanRequired(isStapel, catalogers): targetBOM = cyclonedxutil.NewBOM() - targetBOM.Metadata = &cdx.Metadata{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Component: containerComponent(stageDesc), - } + restoreImageMetadata(targetBOM, stageDesc) case isStapel: var err error targetBOM, err = step.scanFileBasedPackages(ctx, stageDesc.Info.Name, scanOpts, catalogers, targetPlatform) if err != nil { return err } - // Keep syft's own metadata (tools, timestamp) from the scan and restore only the - // image component, which a directory source otherwise reports as the scan directory. - if targetBOM.Metadata == nil { - targetBOM.Metadata = &cdx.Metadata{} - } - targetBOM.Metadata.Component = containerComponent(stageDesc) + restoreImageMetadata(targetBOM, stageDesc) default: bomJSON, err := step.containerBackend.GenerateSBOM(ctx, scanOpts) if err != nil { @@ -196,9 +188,22 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, }) } -// containerComponent builds the top-level container component of an image BOM. The -// targeted directory scan reports the temporary scan directory as its source component, -// so the image identity is restored from this instead. +// 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, diff --git a/pkg/build/sbom_step_test.go b/pkg/build/sbom_step_test.go index b8b6aa2c17..6a1556ba01 100644 --- a/pkg/build/sbom_step_test.go +++ b/pkg/build/sbom_step_test.go @@ -198,6 +198,46 @@ var _ = Describe("SbomStep", func() { }) }) + 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) { diff --git a/pkg/sbom/managedinput/materialize.go b/pkg/sbom/managedinput/materialize.go index 102abe0024..831ccad9d4 100644 --- a/pkg/sbom/managedinput/materialize.go +++ b/pkg/sbom/managedinput/materialize.go @@ -3,6 +3,7 @@ package managedinput import ( "context" "fmt" + "io/fs" "os" "path/filepath" @@ -29,11 +30,6 @@ func MaterializeCatalogerInputs(ctx context.Context, backend container_backend.C } } - if err := os.Chmod(dir, 0o755); err != nil { - cleanup(ctx) - return "", nil, fmt.Errorf("chmod scan dir %q: %w", dir, err) - } - for _, sourcePath := range cataloger.SourcePaths { data, err := backend.ReadFileFromImage(ctx, imageRef, sourcePath, container_backend.ReadFileFromImageOpts{TargetPlatform: targetPlatform}) if err != nil { @@ -52,11 +48,28 @@ func MaterializeCatalogerInputs(ctx context.Context, backend container_backend.C cleanup(ctx) return "", nil, fmt.Errorf("write %s: %w", destPath, err) } - if err := os.Chmod(destPath, 0o644); err != nil { - cleanup(ctx) - return "", nil, fmt.Errorf("chmod %s: %w", destPath, err) - } + } + + // MkdirTemp, MkdirAll and WriteFile are all umask-subject, so under a restrictive umask + // the scan root and its nested directories would not be traversable by the scanner + // container's user. Force the whole tree world-readable (dirs also executable). + if err := makeTreeWorldReadable(dir); err != nil { + cleanup(ctx) + return "", nil, fmt.Errorf("make scan dir %q world-readable: %w", dir, err) } return dir, cleanup, nil } + +func makeTreeWorldReadable(root string) error { + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + mode := fs.FileMode(0o644) + if d.IsDir() { + mode = 0o755 + } + return os.Chmod(path, mode) + }) +} diff --git a/pkg/sbom/managedinput/materialize_test.go b/pkg/sbom/managedinput/materialize_test.go index cc2d64cb02..b81a4e5b4e 100644 --- a/pkg/sbom/managedinput/materialize_test.go +++ b/pkg/sbom/managedinput/materialize_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "syscall" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -75,6 +76,37 @@ var _ = Describe("MaterializeCatalogerInputs", func() { Expect(dirInfo.Mode().Perm()&0o005).To(Equal(os.FileMode(0o005)), "scan dir must be world-readable and traversable") }) + It("makes intermediate MkdirAll directories world-traversable under a restrictive umask", func() { + // MkdirAll is umask-subject, so under umask 077 the app/ and app/api/ chain would be + // 0700; the post-write walk must relax the whole tree. Without setting the umask the + // assertion would pass for the wrong reason, since a default 022 umask already yields 0755. + previousUmask := syscall.Umask(0o077) + defer syscall.Umask(previousUmask) + + cataloger := scanner.Cataloger{ + Name: "go-module-file-cataloger", + SourcePaths: []string{"/app/api/go.mod"}, + } + mockBackend.EXPECT(). + ReadFileFromImage(ctx, imageRef, "/app/api/go.mod", container_backend.ReadFileFromImageOpts{}). + Return([]byte("module example.com/app\n"), nil) + + dir, cleanup, err := MaterializeCatalogerInputs(ctx, mockBackend, imageRef, cataloger, "") + Expect(err).To(Succeed()) + DeferCleanup(func() { cleanup(ctx) }) + + for _, d := range []string{dir, filepath.Join(dir, "app"), filepath.Join(dir, "app", "api")} { + info, err := os.Stat(d) + Expect(err).To(Succeed()) + Expect(info.Mode().Perm()&0o005).To(Equal(os.FileMode(0o005)), + "intermediate dir %q must be world-readable and traversable — MkdirAll is umask-subject", d) + } + + fileInfo, err := os.Stat(filepath.Join(dir, "app", "api", "go.mod")) + Expect(err).To(Succeed()) + Expect(fileInfo.Mode().Perm()&0o004).To(Equal(os.FileMode(0o004)), "file must stay world-readable under a restrictive umask") + }) + It("materializes only the spec when the directive declares no lock", func() { cataloger := scanner.Cataloger{ Name: "python-package-cataloger", From e997a700e87615f5c030f2d1a3024714f69750cc Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 11 Sep 2026 10:14:15 +0300 Subject: [PATCH 4/6] fix(sbom): stop failing the build when a declared lock file is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stapel image with file-based `packages` failed the build when a declared lock file was not present in the image. go-mod always declares go.sum (the ecosystem's DefaultLockFile), but a module with no dependencies produces none, so the targeted directory scan aborted with: materialize inputs for cataloger "go-module-file-cataloger": read /app/go.sum ...: Could not find the file /app/go.sum in container ... The old full-image scan tolerated this — it simply did not catalog a file that was not there. The targeted scan read every declared path and hard-failed on absence. Split a cataloger's inputs into required and optional: the spec (e.g. go.mod) must be present and still fails the build with a directive-and-path error when missing, while the lock (e.g. go.sum) is best-effort — an absent one is skipped, restoring the previous behavior. Optional paths join the scan cache key so a declared lock still contributes to cache identity. A unit test covers a go-mod directive whose image has go.mod but no go.sum: materialization succeeds and skips the lock. Signed-off-by: Radmir Khurum --- docs/pages_en/usage/build/sbom.md | 2 +- docs/pages_ru/usage/build/sbom.md | 2 +- pkg/sbom/managedinput/managedinput.go | 21 +++++----- pkg/sbom/managedinput/managedinput_test.go | 18 +++++++-- pkg/sbom/managedinput/materialize.go | 46 ++++++++++++++++------ pkg/sbom/managedinput/materialize_test.go | 34 ++++++++++++++-- pkg/sbom/scanner/cataloger.go | 12 ++++-- pkg/sbom/scanner/scan_command.go | 1 + 8 files changed, 101 insertions(+), 35 deletions(-) diff --git a/docs/pages_en/usage/build/sbom.md b/docs/pages_en/usage/build/sbom.md index 7ba2bdec5d..fc2cd4057b 100644 --- a/docs/pages_en/usage/build/sbom.md +++ b/docs/pages_en/usage/build/sbom.md @@ -50,7 +50,7 @@ Currently, this option uses the following _defaults_: | **Output Standard** | `CycloneDX@1.6` | | **Output Format** | `JSON` | -For stapel images with file-based `packages`, each declared spec/lock file (for example `go.mod`/`go.sum` or `requirements.txt`) is read from the built image and scanned directly as a directory source, without mounting the Docker socket. If a file declared in `packages` 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. +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. 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 diff --git a/docs/pages_ru/usage/build/sbom.md b/docs/pages_ru/usage/build/sbom.md index b8a1a784a2..ef433bbb66 100644 --- a/docs/pages_ru/usage/build/sbom.md +++ b/docs/pages_ru/usage/build/sbom.md @@ -50,7 +50,7 @@ build: | **Исходящий стандарт** | `CycloneDX@1.6` | | **Исходящий формат** | `JSON` | -Для stapel-образов с file-based `packages` каждый объявленный spec/lock-файл (например, `go.mod`/`go.sum` или `requirements.txt`) читается из собранного образа и сканируется напрямую как каталог-источник, без монтирования Docker-сокета. Если объявленный в `packages` файл отсутствует в собранном образе как обычный файл — например, удалён более поздней стадией или присутствует только как симлинк — сборка завершается ошибкой с указанием директивы и отсутствующего пути. +Для stapel-образов с file-based `packages` каждый объявленный spec-файл (например, `go.mod` или `requirements.txt`) читается из собранного образа и сканируется напрямую как каталог-источник, без монтирования Docker-сокета. Объявленный lock-файл (например, `go.sum`) добавляется, если присутствует, но не обязателен — у модуля без зависимостей его нет, и его отсутствие допустимо. Если обязательный spec-файл отсутствует в собранном образе как обычный файл — например, удалён более поздней стадией или присутствует только как симлинк — сборка завершается ошибкой с указанием директивы и отсутствующего пути. ## Требования к базовому образу diff --git a/pkg/sbom/managedinput/managedinput.go b/pkg/sbom/managedinput/managedinput.go index 6cbe1ed089..22dc3151b8 100644 --- a/pkg/sbom/managedinput/managedinput.go +++ b/pkg/sbom/managedinput/managedinput.go @@ -13,7 +13,6 @@ import ( type inputResolver struct { inputType config.PackagesDirectiveType catalogerName string - sourcePaths func(directive *config.PackagesDirective) []string } var resolvers = buildResolvers() @@ -39,13 +38,6 @@ func buildResolvers() []inputResolver { built = append(built, inputResolver{ inputType: eco.Type, catalogerName: eco.CatalogerName, - sourcePaths: func(d *config.PackagesDirective) []string { - paths := []string{path.Join(d.FileBased.Workdir, d.FileBased.Spec)} - if d.FileBased.Lock != "" { - paths = append(paths, path.Join(d.FileBased.Workdir, d.FileBased.Lock)) - } - return paths - }, }) } return built @@ -62,10 +54,17 @@ func ToCatalogers(packages []*config.PackagesDirective) []scanner.Cataloger { continue } - catalogers = append(catalogers, scanner.Cataloger{ + cataloger := scanner.Cataloger{ Name: res.catalogerName, - SourcePaths: res.sourcePaths(directive), - }) + SourcePaths: []string{path.Join(directive.FileBased.Workdir, directive.FileBased.Spec)}, + } + // The lock is optional: a spec with no dependencies (e.g. a go module without a + // go.sum) has none, and the build must not fail over its absence. + if directive.FileBased.Lock != "" { + cataloger.OptionalSourcePaths = []string{path.Join(directive.FileBased.Workdir, directive.FileBased.Lock)} + } + + catalogers = append(catalogers, cataloger) } return catalogers diff --git a/pkg/sbom/managedinput/managedinput_test.go b/pkg/sbom/managedinput/managedinput_test.go index 57776bbc4d..260b6649df 100644 --- a/pkg/sbom/managedinput/managedinput_test.go +++ b/pkg/sbom/managedinput/managedinput_test.go @@ -59,8 +59,20 @@ 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"}, OptionalSourcePaths: []string{"/app/api/go.sum"}}, + {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/cli/go.mod"}, OptionalSourcePaths: []string{"/app/cli/go.sum"}}, + }, + ), + + Entry("pip entries with no lock declare only a required spec", + []*config.PackagesDirective{ + { + Type: config.PackagesDirectiveTypePythonPip, + FileBased: config.FileBasedSpec{Workdir: "/app", Spec: "requirements.txt"}, + }, + }, + []scanner.Cataloger{ + {Name: "python-package-cataloger", SourcePaths: []string{"/app/requirements.txt"}}, }, ), @@ -96,7 +108,7 @@ 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"}, OptionalSourcePaths: []string{"/app/go.sum"}}, }, ), diff --git a/pkg/sbom/managedinput/materialize.go b/pkg/sbom/managedinput/materialize.go index 831ccad9d4..24cc3c128c 100644 --- a/pkg/sbom/managedinput/materialize.go +++ b/pkg/sbom/managedinput/materialize.go @@ -15,9 +15,12 @@ import ( // MaterializeCatalogerInputs extracts a cataloger's declared spec/lock files from the // built image and writes them into a fresh temporary directory under their full in-image // path, so a directory-source scan records the same locations the files had in the image -// (e.g. /app/api/go.mod) and keeps a spec next to its lock. The returned directory and -// its files are world-readable so the unprivileged scanner container can read them. The -// caller must invoke the returned cleanup once the scan is done. +// (e.g. /app/api/go.mod) and keeps a spec next to its lock. Required inputs (SourcePaths) +// must be present — the build fails otherwise; optional inputs (OptionalSourcePaths, e.g. a +// go.sum a depless module never produces) are skipped when absent, matching the previous +// full-image scan. The returned directory and its files are world-readable so the +// unprivileged scanner container can read them. The caller must invoke the returned cleanup +// once the scan is done. func MaterializeCatalogerInputs(ctx context.Context, backend container_backend.ContainerBackend, imageRef string, cataloger scanner.Cataloger, targetPlatform string) (string, func(context.Context), error) { dir, err := os.MkdirTemp("", "sbom-dirscan-*") if err != nil { @@ -30,23 +33,29 @@ func MaterializeCatalogerInputs(ctx context.Context, backend container_backend.C } } + opts := container_backend.ReadFileFromImageOpts{TargetPlatform: targetPlatform} + for _, sourcePath := range cataloger.SourcePaths { - data, err := backend.ReadFileFromImage(ctx, imageRef, sourcePath, container_backend.ReadFileFromImageOpts{TargetPlatform: targetPlatform}) + data, err := backend.ReadFileFromImage(ctx, imageRef, sourcePath, opts) if err != nil { cleanup(ctx) return "", nil, fmt.Errorf("read %s from image %q for cataloger %q: %w", sourcePath, imageRef, cataloger.Name, err) } - - // Rebase the in-image path onto the scan dir. Anchoring at "/" and cleaning first - // collapses any ".." and leading slash, so the result can never escape dir. - destPath := filepath.Join(dir, filepath.Clean("/"+sourcePath)) - if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + if err := writeMaterializedFile(dir, sourcePath, data); err != nil { cleanup(ctx) - return "", nil, fmt.Errorf("create scan subdir for %s: %w", destPath, err) + return "", nil, err + } + } + + for _, sourcePath := range cataloger.OptionalSourcePaths { + data, err := backend.ReadFileFromImage(ctx, imageRef, sourcePath, opts) + if err != nil { + logboek.Context(ctx).Debug().LogF("skip optional %s for cataloger %q: not present in image %q: %s\n", sourcePath, cataloger.Name, imageRef, err) + continue } - if err := os.WriteFile(destPath, data, 0o644); err != nil { + if err := writeMaterializedFile(dir, sourcePath, data); err != nil { cleanup(ctx) - return "", nil, fmt.Errorf("write %s: %w", destPath, err) + return "", nil, err } } @@ -61,6 +70,19 @@ func MaterializeCatalogerInputs(ctx context.Context, backend container_backend.C return dir, cleanup, nil } +func writeMaterializedFile(dir, sourcePath string, data []byte) error { + // Rebase the in-image path onto the scan dir. Anchoring at "/" and cleaning first + // collapses any ".." and leading slash, so the result can never escape dir. + destPath := filepath.Join(dir, filepath.Clean("/"+sourcePath)) + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return fmt.Errorf("create scan subdir for %s: %w", destPath, err) + } + if err := os.WriteFile(destPath, data, 0o644); err != nil { + return fmt.Errorf("write %s: %w", destPath, err) + } + return nil +} + func makeTreeWorldReadable(root string) error { return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { diff --git a/pkg/sbom/managedinput/materialize_test.go b/pkg/sbom/managedinput/materialize_test.go index b81a4e5b4e..0753e726d6 100644 --- a/pkg/sbom/managedinput/materialize_test.go +++ b/pkg/sbom/managedinput/materialize_test.go @@ -37,8 +37,9 @@ var _ = Describe("MaterializeCatalogerInputs", func() { It("materializes spec and lock under their full in-image path, adjacent, world-readable", func() { cataloger := 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/api/go.mod"}, + OptionalSourcePaths: []string{"/app/api/go.sum"}, } mockBackend.EXPECT(). ReadFileFromImage(ctx, imageRef, "/app/api/go.mod", container_backend.ReadFileFromImageOpts{}). @@ -158,7 +159,34 @@ var _ = Describe("MaterializeCatalogerInputs", func() { Expect(dir).ToNot(BeEmpty()) }) - It("fails naming the cataloger and path when a declared file is absent from the image", func() { + It("skips an optional lock file that is absent from the image without failing", func() { + // A go module with no dependencies has no go.sum; the old full-image scan simply did + // not catalog it, and the build must not fail over its absence. + cataloger := scanner.Cataloger{ + Name: "go-module-file-cataloger", + SourcePaths: []string{"/app/go.mod"}, + OptionalSourcePaths: []string{"/app/go.sum"}, + } + mockBackend.EXPECT(). + ReadFileFromImage(ctx, imageRef, "/app/go.mod", container_backend.ReadFileFromImageOpts{}). + Return([]byte("module example.com/app\n"), nil) + mockBackend.EXPECT(). + ReadFileFromImage(ctx, imageRef, "/app/go.sum", container_backend.ReadFileFromImageOpts{}). + Return(nil, errors.New("Could not find the file /app/go.sum in container werf.read_file.x")) + + dir, cleanup, err := MaterializeCatalogerInputs(ctx, mockBackend, imageRef, cataloger, "") + Expect(err).To(Succeed()) + DeferCleanup(func() { cleanup(ctx) }) + + content, err := os.ReadFile(filepath.Join(dir, "app", "go.mod")) + Expect(err).To(Succeed()) + Expect(string(content)).To(Equal("module example.com/app\n")) + + _, err = os.Stat(filepath.Join(dir, "app", "go.sum")) + Expect(os.IsNotExist(err)).To(BeTrue(), "the absent optional lock must not be materialized") + }) + + It("fails naming the cataloger and path when a required spec is absent from the image", func() { cataloger := scanner.Cataloger{ Name: "go-module-file-cataloger", SourcePaths: []string{"/app/go.mod"}, diff --git a/pkg/sbom/scanner/cataloger.go b/pkg/sbom/scanner/cataloger.go index 95e910f142..9ae171a79f 100644 --- a/pkg/sbom/scanner/cataloger.go +++ b/pkg/sbom/scanner/cataloger.go @@ -1,9 +1,13 @@ package scanner -// Cataloger is a syft cataloger to enable for a scan, together with the in-image -// file paths it targets (e.g. go.mod / go.sum), which are materialized under their +// Cataloger is a syft cataloger to enable for a scan, together with the in-image file +// paths it targets. SourcePaths are required inputs (the spec, e.g. go.mod): a directive +// scan fails if any is absent from the image. OptionalSourcePaths are best-effort inputs +// (the lock, e.g. go.sum): absent ones are skipped, matching the previous full-image scan +// which simply did not catalog a file that was not there. All are materialized under their // full in-image path for a targeted directory scan. type Cataloger struct { - Name string - SourcePaths []string + Name string + SourcePaths []string + OptionalSourcePaths []string } diff --git a/pkg/sbom/scanner/scan_command.go b/pkg/sbom/scanner/scan_command.go index 0ebfb49ae6..b2cef3e060 100644 --- a/pkg/sbom/scanner/scan_command.go +++ b/pkg/sbom/scanner/scan_command.go @@ -92,6 +92,7 @@ func (c ScanCommand) Checksum() string { for _, cat := range c.Catalogers { args = append(args, "cataloger", cat.Name) args = append(args, cat.SourcePaths...) + args = append(args, cat.OptionalSourcePaths...) } return util.Sha256Hash(args...) From 08ecdeb78dc8e9d5d5c451886855445b6b646790 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 11 Sep 2026 17:23:07 +0300 Subject: [PATCH 5/6] docs(sbom): correct scan defaults for stapel-only SBOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Data Source Connection Method" and "Path in Source Image" rows described a Dockerfile full-image scan (daemon + socket via volume, OS root). SBOM is not supported for Dockerfile images — validateSbomOnlyWithStapelImages rejects them — so that path never runs. Describe what actually happens: for stapel images with file-based packages, a directory scan of the spec/lock files extracted from the built image, without the Docker socket. Signed-off-by: Radmir Khurum --- docs/pages_en/usage/build/sbom.md | 4 ++-- docs/pages_ru/usage/build/sbom.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/pages_en/usage/build/sbom.md b/docs/pages_en/usage/build/sbom.md index fc2cd4057b..6448933171 100644 --- a/docs/pages_en/usage/build/sbom.md +++ b/docs/pages_en/usage/build/sbom.md @@ -44,8 +44,8 @@ Currently, this option uses the following _defaults_: | **Scanner** | syft | | **Scanner Image** | anchore/syft:v1.45.1 | | **Image Pull Policy** | `PullIfMissing` | -| **Data Source Connection Method** | Dockerfile images: daemon + socket via volume (for Docker). Stapel images with file-based `packages`: directory scan of the declared spec/lock files extracted from the built image, no socket. | -| **Path in Source Image** | OS root (Dockerfile images); the declared `packages` spec/lock files (stapel file-based packages) | +| **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` | diff --git a/docs/pages_ru/usage/build/sbom.md b/docs/pages_ru/usage/build/sbom.md index ef433bbb66..c09854cdd7 100644 --- a/docs/pages_ru/usage/build/sbom.md +++ b/docs/pages_ru/usage/build/sbom.md @@ -44,8 +44,8 @@ build: | **Сканер** | syft | | **Образ сканера** | anchore/syft:v1.45.1 | | **Политика получения образа** | `PullIfMissing` | -| **Способ подключения к источнику данных** | Образы Dockerfile: daemon + socket via volume (для Docker). Stapel-образы с file-based `packages`: сканирование каталога с извлечёнными из собранного образа spec/lock-файлами, без socket. | -| **Путь в образе источнике** | корень OS (образы Dockerfile); объявленные spec/lock-файлы `packages` (stapel file-based packages) | +| **Способ подключения к источнику данных** | Сканирование каталога с извлечёнными из собранного образа spec/lock-файлами, без Docker-сокета | +| **Путь в образе источнике** | Объявленные spec/lock-файлы `packages` | | **Настройки сканирования** | [ссылка](https://github.com/anchore/syft/wiki/Configuration#list-of-configurable-values) | | **Исходящий стандарт** | `CycloneDX@1.6` | | **Исходящий формат** | `JSON` | From c8be0019f9daf4b1bc5bd7a4fdf0a53ced0faf77 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Mon, 14 Sep 2026 07:42:52 +0300 Subject: [PATCH 6/6] feat(sbom): warn when a lock file is missing from the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An absent lock file is tolerated so that a module with no dependencies builds, but the same skip also fires when a lock that should exist is gone — removed by a later stage, or present only as a symlink. In that case the scan sees the spec alone and transitive dependencies silently drop out of the SBOM, with the only trace at debug level. Surface the skip as a warning naming the lock path, image and cataloger, so a missing lock is visible in the build output. The message states that the absence is expected for a project without dependencies, since for Go that is the normal state (go.sum is only written when there are modules to verify), so the warning stays truthful for both cases. A unit test pins the warning. Signed-off-by: Radmir Khurum --- docs/pages_en/usage/build/sbom.md | 2 +- docs/pages_ru/usage/build/sbom.md | 2 +- pkg/sbom/managedinput/materialize.go | 2 +- pkg/sbom/managedinput/materialize_test.go | 15 +++++++++++++-- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/pages_en/usage/build/sbom.md b/docs/pages_en/usage/build/sbom.md index 6448933171..7210d6ebba 100644 --- a/docs/pages_en/usage/build/sbom.md +++ b/docs/pages_en/usage/build/sbom.md @@ -50,7 +50,7 @@ Currently, this option uses the following _defaults_: | **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. 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. +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 diff --git a/docs/pages_ru/usage/build/sbom.md b/docs/pages_ru/usage/build/sbom.md index c09854cdd7..488cc37167 100644 --- a/docs/pages_ru/usage/build/sbom.md +++ b/docs/pages_ru/usage/build/sbom.md @@ -50,7 +50,7 @@ build: | **Исходящий стандарт** | `CycloneDX@1.6` | | **Исходящий формат** | `JSON` | -Для stapel-образов с file-based `packages` каждый объявленный spec-файл (например, `go.mod` или `requirements.txt`) читается из собранного образа и сканируется напрямую как каталог-источник, без монтирования Docker-сокета. Объявленный lock-файл (например, `go.sum`) добавляется, если присутствует, но не обязателен — у модуля без зависимостей его нет, и его отсутствие допустимо. Если обязательный spec-файл отсутствует в собранном образе как обычный файл — например, удалён более поздней стадией или присутствует только как симлинк — сборка завершается ошибкой с указанием директивы и отсутствующего пути. +Для stapel-образов с file-based `packages` каждый объявленный spec-файл (например, `go.mod` или `requirements.txt`) читается из собранного образа и сканируется напрямую как каталог-источник, без монтирования Docker-сокета. Объявленный lock-файл (например, `go.sum`) добавляется, если присутствует, но не обязателен — у модуля без зависимостей его нет, и его отсутствие допустимо и сопровождается предупреждением, поскольку без lock-файла в SBOM могут отсутствовать транзитивные зависимости. Если обязательный spec-файл отсутствует в собранном образе как обычный файл — например, удалён более поздней стадией или присутствует только как симлинк — сборка завершается ошибкой с указанием директивы и отсутствующего пути. ## Требования к базовому образу diff --git a/pkg/sbom/managedinput/materialize.go b/pkg/sbom/managedinput/materialize.go index 24cc3c128c..81caf72c9a 100644 --- a/pkg/sbom/managedinput/materialize.go +++ b/pkg/sbom/managedinput/materialize.go @@ -50,7 +50,7 @@ func MaterializeCatalogerInputs(ctx context.Context, backend container_backend.C for _, sourcePath := range cataloger.OptionalSourcePaths { data, err := backend.ReadFileFromImage(ctx, imageRef, sourcePath, opts) if err != nil { - logboek.Context(ctx).Debug().LogF("skip optional %s for cataloger %q: not present in image %q: %s\n", sourcePath, cataloger.Name, imageRef, err) + logboek.Context(ctx).Warn().LogF("WARNING: lock file %s not found in image %q for cataloger %q; scanning the spec only. This is expected for a project without dependencies; otherwise transitive dependencies will be missing from the SBOM\n", sourcePath, imageRef, cataloger.Name) continue } if err := writeMaterializedFile(dir, sourcePath, data); err != nil { diff --git a/pkg/sbom/managedinput/materialize_test.go b/pkg/sbom/managedinput/materialize_test.go index 0753e726d6..137b60d3f9 100644 --- a/pkg/sbom/managedinput/materialize_test.go +++ b/pkg/sbom/managedinput/materialize_test.go @@ -5,12 +5,14 @@ import ( "errors" "os" "path/filepath" + "strings" "syscall" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" + "github.com/werf/logboek" "github.com/werf/werf/v2/pkg/container_backend" "github.com/werf/werf/v2/pkg/sbom/scanner" "github.com/werf/werf/v2/test/mock" @@ -159,9 +161,14 @@ var _ = Describe("MaterializeCatalogerInputs", func() { Expect(dir).ToNot(BeEmpty()) }) - It("skips an optional lock file that is absent from the image without failing", func() { + It("skips an optional lock file that is absent from the image and warns about it", func() { // A go module with no dependencies has no go.sum; the old full-image scan simply did - // not catalog it, and the build must not fail over its absence. + // not catalog it, and the build must not fail over its absence. But a lock that should + // exist may also be gone (removed by a later stage, or a symlink), which silently drops + // transitive dependencies — so the skip must be visible to the user. + var output strings.Builder + ctx := logboek.NewContext(ctx, logboek.NewLogger(&output, &output)) + cataloger := scanner.Cataloger{ Name: "go-module-file-cataloger", SourcePaths: []string{"/app/go.mod"}, @@ -184,6 +191,10 @@ var _ = Describe("MaterializeCatalogerInputs", func() { _, err = os.Stat(filepath.Join(dir, "app", "go.sum")) Expect(os.IsNotExist(err)).To(BeTrue(), "the absent optional lock must not be materialized") + + Expect(output.String()).To(ContainSubstring("WARNING: lock file /app/go.sum not found in image"), + "skipping a declared lock must be surfaced as a warning, not hidden at debug level") + Expect(output.String()).To(ContainSubstring("go-module-file-cataloger")) }) It("fails naming the cataloger and path when a required spec is absent from the image", func() {