diff --git a/docs/pages_en/usage/build/sbom.md b/docs/pages_en/usage/build/sbom.md index 81ac42fd26..7210d6ebba 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** | Directory scan of the spec/lock files extracted from the built image, without the Docker socket | +| **Path in Source Image** | The declared `packages` spec/lock files | | **Scan Settings** | [link](https://github.com/anchore/syft/wiki/Configuration#list-of-configurable-values) | | **Output Standard** | `CycloneDX@1.6` | | **Output Format** | `JSON` | +For stapel images with file-based `packages`, each declared spec file (for example `go.mod` or `requirements.txt`) is read from the built image and scanned directly as a directory source, without mounting the Docker socket. A declared lock file (for example `go.sum`) is included when present but is optional — a module with no dependencies has none, and its absence is tolerated with a warning, since without the lock transitive dependencies may be missing from the SBOM. If a required spec file is not present as a regular file in the built image — for example removed by a later stage, or present only as a symlink — the build fails with an error naming the directive and the missing path. + ## Base image requirements When SBOM generation is enabled, every base image referenced via `from` or `fromImage` and every image referenced via `import` **must have an SBOM artifact attached in the registry**. There is no alternative to this requirement; the only exception is described below. diff --git a/docs/pages_ru/usage/build/sbom.md b/docs/pages_ru/usage/build/sbom.md index d8714481e5..488cc37167 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 | +| **Способ подключения к источнику данных** | Сканирование каталога с извлечёнными из собранного образа spec/lock-файлами, без Docker-сокета | +| **Путь в образе источнике** | Объявленные spec/lock-файлы `packages` | | **Настройки сканирования** | [ссылка](https://github.com/anchore/syft/wiki/Configuration#list-of-configurable-values) | | **Исходящий стандарт** | `CycloneDX@1.6` | | **Исходящий формат** | `JSON` | +Для stapel-образов с file-based `packages` каждый объявленный spec-файл (например, `go.mod` или `requirements.txt`) читается из собранного образа и сканируется напрямую как каталог-источник, без монтирования Docker-сокета. Объявленный lock-файл (например, `go.sum`) добавляется, если присутствует, но не обязателен — у модуля без зависимостей его нет, и его отсутствие допустимо и сопровождается предупреждением, поскольку без lock-файла в SBOM могут отсутствовать транзитивные зависимости. Если обязательный spec-файл отсутствует в собранном образе как обычный файл — например, удалён более поздней стадией или присутствует только как симлинк — сборка завершается ошибкой с указанием директивы и отсутствующего пути. + ## Требования к базовому образу Когда генерация SBOM включена, каждый базовый образ, указанный через `from` или `fromImage`, и каждый образ, указанный через `import`, **должен иметь прикреплённый SBOM-артефакт в registry**. Альтернативы этому требованию нет; единственное исключение описано ниже. 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..019c604b69 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" @@ -69,6 +70,7 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, parentDigest := stageDesc.Info.GetDigest() scanOpts.Commands[0].SourcePath = stageDesc.Info.Name + catalogers := scanOpts.Commands[0].Catalogers if err := step.prepareGostComponents(ctx, &mergeOpts); err != nil { return err @@ -94,16 +96,18 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, return logboek.Context(ctx).Default().LogProcess("image %s: SBOM processing", werfImgName).DoError(func() error { var targetBOM *cdx.BOM - if !syftScanRequired(isStapel, scanOpts.Commands[0].Catalogers) { + switch { + case !syftScanRequired(isStapel, catalogers): targetBOM = cyclonedxutil.NewBOM() - targetBOM.Metadata = &cdx.Metadata{ - Component: &cdx.Component{ - Type: cdx.ComponentTypeContainer, - Name: stageDesc.Info.Repository, - Version: stageDesc.Info.Tag, - }, + restoreImageMetadata(targetBOM, stageDesc) + case isStapel: + var err error + targetBOM, err = step.scanFileBasedPackages(ctx, stageDesc.Info.Name, scanOpts, catalogers, targetPlatform) + if err != nil { + return err } - } else { + restoreImageMetadata(targetBOM, stageDesc) + default: bomJSON, err := step.containerBackend.GenerateSBOM(ctx, scanOpts) if err != nil { return fmt.Errorf("generate SBOM: %w", err) @@ -113,8 +117,6 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, if err != nil { return fmt.Errorf("parse scanned BOM: %w", err) } - - managedinput.FilterBOMBySourcePaths(targetBOM, scanOpts.Commands[0].Catalogers) } resultBOM := targetBOM @@ -186,7 +188,96 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, }) } -const sbomArtifactFormatVersion = "3" +// restoreImageMetadata sets the BOM's top-level component to the scanned image while +// keeping any syft-provided metadata (tools, timestamp). A directory source reports the +// temporary scan directory as its component, so it must be replaced. When no timestamp is +// present — the skip-scan path builds a fresh BOM — one is stamped, since a per-image SBOM +// without a timestamp is rejected by downstream validators. +func restoreImageMetadata(bom *cdx.BOM, stageDesc *image.StageDesc) { + if bom.Metadata == nil { + bom.Metadata = &cdx.Metadata{} + } + bom.Metadata.Component = containerComponent(stageDesc) + if bom.Metadata.Timestamp == "" { + bom.Metadata.Timestamp = time.Now().UTC().Format(time.RFC3339) + } +} + +// containerComponent builds the top-level container component of an image BOM. +func containerComponent(stageDesc *image.StageDesc) *cdx.Component { + return &cdx.Component{ + Type: cdx.ComponentTypeContainer, + Name: stageDesc.Info.Repository, + Version: stageDesc.Info.Tag, + } +} + +// scanFileBasedPackages catalogs the file-based packages of a stapel image by scanning, +// per directive, only the spec/lock files extracted from the built image (a directory +// source), then unions the per-directive BOMs. This avoids walking the whole image +// filesystem and needs no docker.sock in the scanner container. +func (step *sbomStep) scanFileBasedPackages(ctx context.Context, imageRef string, scanOpts scanner.ScanOptions, catalogers []scanner.Cataloger, targetPlatform string) (*cdx.BOM, error) { + scannedBOMs := make([]*cdx.BOM, 0, len(catalogers)) + for _, cataloger := range catalogers { + dir, cleanup, err := managedinput.MaterializeCatalogerInputs(ctx, step.containerBackend, imageRef, cataloger, targetPlatform) + if err != nil { + return nil, fmt.Errorf("materialize inputs for cataloger %q: %w", cataloger.Name, err) + } + + bom, err := step.scanCatalogerDir(ctx, scanOpts, cataloger, dir) + cleanup(ctx) + if err != nil { + return nil, err + } + + scannedBOMs = append(scannedBOMs, bom) + } + + // Guarded against an empty catalogers slice, even though the isStapel switch arm only + // runs when syftScanRequired already established len(catalogers) > 0. + if len(scannedBOMs) == 0 { + return cyclonedxutil.NewBOM(), nil + } + + // MergeBOMs unions components and dedups by normalized PURL; on a cross-directive PURL + // collision it is the first directive's component that is dropped (mergeOrder appends + // the target last, dedup is first-occurrence-wins). Harmless for component identity. + merged, err := cyclonedxutil.MergeBOMs(scannedBOMs[0], cyclonedxutil.MergeOpts{ImportBOMs: scannedBOMs[1:]}) + if err != nil { + return nil, fmt.Errorf("union per-directive BOMs: %w", err) + } + + return merged, nil +} + +func (step *sbomStep) scanCatalogerDir(ctx context.Context, scanOpts scanner.ScanOptions, cataloger scanner.Cataloger, dir string) (*cdx.BOM, error) { + cmd := scanOpts.Commands[0] + cmd.Catalogers = []scanner.Cataloger{cataloger} + cmd.SourceType = scanner.SourceTypeDir + cmd.SourcePath = dir + + perDirectiveOpts := scanOpts + perDirectiveOpts.Commands = []scanner.ScanCommand{cmd} + + bomJSON, err := step.containerBackend.GenerateSBOM(ctx, perDirectiveOpts) + if err != nil { + return nil, fmt.Errorf("generate SBOM for cataloger %q: %w", cataloger.Name, err) + } + + bom, err := cyclonedxutil.BuildCycloneDX16BOMFromJSON(bomJSON) + if err != nil { + return nil, fmt.Errorf("parse scanned BOM for cataloger %q: %w", cataloger.Name, err) + } + + // A directory source makes syft emit a PURL-less type=file component for each scanned + // manifest file; drop them so only real packages remain. This is what makes omitting the + // post-scan source-path filter safe (see SYFT_FILE_METADATA_SELECTION in the docker backend). + cyclonedxutil.DropSyftSourceFileComponents(bom) + + return bom, nil +} + +const sbomArtifactFormatVersion = "4" // calculateStableChecksum computes the SBOM artifact cache checksum. Together with the // parent stage digest it forms the cache key: a previously attached SBOM is reused only diff --git a/pkg/build/sbom_step_test.go b/pkg/build/sbom_step_test.go index 8f22cd6cd2..6a1556ba01 100644 --- a/pkg/build/sbom_step_test.go +++ b/pkg/build/sbom_step_test.go @@ -125,6 +125,119 @@ var _ = Describe("SbomStep", func() { ) }) + Describe("scanFileBasedPackages", func() { + makeBOMJSON := func(timestamp string, comps ...cdx.Component) []byte { + bom := cyclonedxutil.NewBOM() + bom.Metadata = &cdx.Metadata{ + Timestamp: timestamp, + Component: &cdx.Component{Type: cdx.ComponentTypeFile, Name: "/scan"}, + } + list := append([]cdx.Component{}, comps...) + bom.Components = &list + data, err := cyclonedxutil.ToJSON(bom) + Expect(err).To(Succeed()) + return data + } + + It("scans one dir source per cataloger, unions components, drops source files, keeps syft metadata", func(specCtx SpecContext) { + ctx := logging.WithLogger(specCtx) + ctrl := gomock.NewController(GinkgoT()) + mockBackend := mock.NewMockContainerBackend(ctrl) + + imageRef := "app:latest" + catalogers := []scanner.Cataloger{ + {Name: "go-module-file-cataloger", SourcePaths: []string{"/app/go.mod", "/app/go.sum"}}, + {Name: "python-package-cataloger", SourcePaths: []string{"/svc/requirements.txt"}}, + } + + mockBackend.EXPECT(). + ReadFileFromImage(gomock.Any(), imageRef, gomock.Any(), gomock.Any()). + Return([]byte("manifest\n"), nil). + AnyTimes() + + goBOM := makeBOMJSON("2026-01-01T00:00:00Z", + cdx.Component{BOMRef: "lo", Type: cdx.ComponentTypeLibrary, Name: "github.com/samber/lo", Version: "v1.47.0", PackageURL: "pkg:golang/github.com/samber/lo@v1.47.0"}, + // a PURL-less type=file entry a dir scan emits for the manifest itself + cdx.Component{BOMRef: "gomod-file", Type: cdx.ComponentTypeFile, Name: "go.mod"}, + ) + pipBOM := makeBOMJSON("2026-02-02T00:00:00Z", + cdx.Component{BOMRef: "flask", Type: cdx.ComponentTypeLibrary, Name: "flask", Version: "3.0.0", PackageURL: "pkg:pypi/flask@3.0.0"}, + ) + + mockBackend.EXPECT(). + GenerateSBOM(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, opts scanner.ScanOptions) ([]byte, error) { + Expect(opts.Commands).To(HaveLen(1)) + Expect(opts.Commands[0].SourceType).To(Equal(scanner.SourceTypeDir), "each per-directive scan must use a directory source") + Expect(opts.Commands[0].Catalogers).To(HaveLen(1), "each scan must run exactly one cataloger") + switch opts.Commands[0].Catalogers[0].Name { + case "go-module-file-cataloger": + return goBOM, nil + case "python-package-cataloger": + return pipBOM, nil + default: + return nil, errors.New("unexpected cataloger: " + opts.Commands[0].Catalogers[0].Name) + } + }). + Times(2) + + step := &sbomStep{containerBackend: mockBackend} + bom, err := step.scanFileBasedPackages(ctx, imageRef, scanner.DefaultSyftScanOptions(), catalogers, "") + Expect(err).To(Succeed()) + Expect(bom).ToNot(BeNil()) + + names := []string{} + for _, c := range *bom.Components { + names = append(names, c.Name) + } + Expect(names).To(ConsistOf("github.com/samber/lo", "flask"), "components from both directives are unioned and the source file is dropped") + Expect(names).ToNot(ContainElement("go.mod")) + + Expect(bom.Metadata).ToNot(BeNil()) + Expect(bom.Metadata.Timestamp).To(Equal("2026-01-01T00:00:00Z"), "syft metadata (timestamp) from the first directive is preserved, not discarded") + }) + }) + + Describe("restoreImageMetadata", func() { + stageDesc := &werfImage.StageDesc{Info: &werfImage.Info{Repository: "example.com/app", Tag: "v1"}} + + expectContainerComponent := func(bom *cdx.BOM) { + Expect(bom.Metadata).ToNot(BeNil()) + Expect(bom.Metadata.Component).ToNot(BeNil()) + Expect(bom.Metadata.Component.Type).To(Equal(cdx.ComponentTypeContainer)) + Expect(bom.Metadata.Component.Name).To(Equal("example.com/app")) + Expect(bom.Metadata.Component.Version).To(Equal("v1")) + } + + It("allocates metadata and stamps a timestamp when the BOM has none", func() { + bom := &cdx.BOM{} + + restoreImageMetadata(bom, stageDesc) + + expectContainerComponent(bom) + Expect(bom.Metadata.Timestamp).ToNot(BeEmpty(), "a per-image SBOM must carry a timestamp for downstream validators") + }) + + It("keeps syft's tools and timestamp and replaces only the scan-directory component", func() { + bom := &cdx.BOM{ + Metadata: &cdx.Metadata{ + Timestamp: "2020-01-02T03:04:05Z", + Tools: &cdx.ToolsChoice{Components: &[]cdx.Component{{Type: cdx.ComponentTypeApplication, Name: "syft", Version: "1.45.1"}}}, + Component: &cdx.Component{Type: cdx.ComponentTypeFile, Name: "/scan"}, + }, + } + + restoreImageMetadata(bom, stageDesc) + + expectContainerComponent(bom) + Expect(bom.Metadata.Timestamp).To(Equal("2020-01-02T03:04:05Z"), "syft's own timestamp must survive") + Expect(bom.Metadata.Tools).ToNot(BeNil()) + Expect(bom.Metadata.Tools.Components).ToNot(BeNil()) + Expect(*bom.Metadata.Tools.Components).To(HaveLen(1)) + Expect((*bom.Metadata.Tools.Components)[0].Name).To(Equal("syft"), "syft tools provenance must survive") + }) + }) + Describe("isTrustedBuilderImage()", func() { DescribeTable("should detect trusted builder images", func(labels map[string]string, expected bool) { diff --git a/pkg/container_backend/docker_server_backend.go b/pkg/container_backend/docker_server_backend.go index 4fbc2c5911..21fc76cc9b 100644 --- a/pkg/container_backend/docker_server_backend.go +++ b/pkg/container_backend/docker_server_backend.go @@ -705,29 +705,46 @@ func (backend *DockerServerBackend) GenerateSBOM(ctx context.Context, scanOpts s return bomJSON, err } +const sbomScanDirContainerMountPath = "/scan" + func mapSbomScanOptionsToDockerRunCommand(workingTreeDir, billsDir string, billNames []string, scanOpts scanner.ScanOptions) []string { args := []string{ "--rm", "--name", fmt.Sprintf("%s%s", image.SBOMScannerContainerNamePrefix, uuid.New().String()), "--pull", scanOpts.PullPolicy.String(), "--entrypoint", "", // clear default image entrypoint - "--volume", "/var/run/docker.sock:/var/run/docker.sock", // TODO: return error on non Unix systems } - // TODO (zaytsev): the code support only single command at this moment + scanCmd := scanOpts.Commands[0] // TODO (zaytsev): support multiple commands + + switch scanCmd.SourceType { + case scanner.SourceTypeDir: + // 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)) args = append(args, "-e", "SYFT_GOLANG_MAIN_MODULE_VERSION_FROM_CONTENTS=false", + // SYFT_FILE_METADATA_SELECTION=none is load-bearing for a directory source: without it + // syft emits an extra PURL-less type=file component per scanned manifest, which dedup + // (it keeps PURL-less components) would not remove. Do not drop this env var (contrary + // to the task note claiming it is unknown to syft v1.45.1 — it is honored); the + // directory-scan path additionally strips such components defensively in + // cyclonedxutil.DropSyftSourceFileComponents. "-e", "SYFT_FILE_METADATA_SELECTION=none", ) args = append(args, scanOpts.Image) - scanCmd := scanOpts.Commands[0] // TODO (zaytsev): support multiple commands - scanCmd.SourceType = scanner.SourceTypeDocker scanCmd.OutputPath = billContainerPath args = append(args, strings.Split(scanCmd.String(), " ")...) 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/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 6bf948417d..22dc3151b8 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,9 +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 } var resolvers = buildResolvers() @@ -40,30 +35,14 @@ 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 != "" { - paths = append(paths, path.Join(d.FileBased.Workdir, d.FileBased.Lock)) - } - return paths - }, - workdir: func(d *config.PackagesDirective) string { - return d.FileBased.Workdir - }, }) } return built } -func filterModeForEcosystem(_ config.PackagesDirectiveType) scanner.CatalogerFilterMode { - return scanner.CatalogerFilterExactPath -} - func ToCatalogers(packages []*config.PackagesDirective) []scanner.Cataloger { var catalogers []scanner.Cataloger @@ -75,106 +54,18 @@ func ToCatalogers(packages []*config.PackagesDirective) []scanner.Cataloger { continue } - catalogers = append(catalogers, scanner.Cataloger{ + cataloger := scanner.Cataloger{ Name: res.catalogerName, - FilterMode: res.filterMode, - SourcePaths: res.sourcePaths(directive), - Workdir: res.workdir(directive), - }) - } - - 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{}{} + SourcePaths: []string{path.Join(directive.FileBased.Workdir, directive.FileBased.Spec)}, } - 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 - } - } + // 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)} } - return false - }) - - *bom.Components = filtered -} -func componentFoundByCataloger(comp cdx.Component, catalogerName string) bool { - if comp.Properties == nil { - return false + catalogers = append(catalogers, cataloger) } - 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 + return catalogers } diff --git a/pkg/sbom/managedinput/managedinput_test.go b/pkg/sbom/managedinput/managedinput_test.go index a997cd51b8..260b6649df 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,20 @@ 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"}, 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"}}, }, ), @@ -98,7 +108,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"}, OptionalSourcePaths: []string{"/app/go.sum"}}, }, ), @@ -108,611 +118,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..81caf72c9a --- /dev/null +++ b/pkg/sbom/managedinput/materialize.go @@ -0,0 +1,97 @@ +package managedinput + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + + "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 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. 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 { + 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) + } + } + + opts := container_backend.ReadFileFromImageOpts{TargetPlatform: targetPlatform} + + for _, sourcePath := range cataloger.SourcePaths { + 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) + } + if err := writeMaterializedFile(dir, sourcePath, data); err != nil { + cleanup(ctx) + return "", nil, err + } + } + + for _, sourcePath := range cataloger.OptionalSourcePaths { + data, err := backend.ReadFileFromImage(ctx, imageRef, sourcePath, opts) + if err != nil { + 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 { + cleanup(ctx) + return "", nil, 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 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 { + 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 new file mode 100644 index 0000000000..137b60d3f9 --- /dev/null +++ b/pkg/sbom/managedinput/materialize_test.go @@ -0,0 +1,214 @@ +package managedinput + +import ( + "context" + "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" +) + +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 under their full in-image path, adjacent, world-readable", func() { + cataloger := scanner.Cataloger{ + 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{}). + 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) }) + + // 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()) + 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("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", + 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, "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", + 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("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. 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"}, + 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") + + 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() { + cataloger := scanner.Cataloger{ + Name: "go-module-file-cataloger", + 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..9ae171a79f 100644 --- a/pkg/sbom/scanner/cataloger.go +++ b/pkg/sbom/scanner/cataloger.go @@ -1,20 +1,13 @@ 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. +// 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 - FilterMode CatalogerFilterMode - SourcePaths []string - Workdir 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...)