-
Notifications
You must be signed in to change notification settings - Fork 0
feat(sbom): generate file-based package SBOMs without docker.sock #307
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a1306b0
82ea3f9
da7087a
e997a70
08ecdeb
c8be001
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scanning only spec/lock files removes JavaScript license metadata that the old image scan retained. Syft v1.45.1's javascript-lock-cataloger enriches lock-derived components from node_modules//package.json, while remote license lookup is disabled by default. A real scan of an image containing /app/yarn.lock and /app/node_modules/is-number/package.json with license=MIT, compared with the materialized spec/lock-only directory using the same cataloger and scanner environment, yields: The old exact-path filter keeps that component because its recorded source is yarn.lock, not node_modules. Preserve local license enrichment for the selected components, for example by reading the relevant installed package.json files separately and enriching the targeted BOM, and add a real-scanner regression assertion for licenses rather than only name/version/PURL. |
||
| 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:]}) | ||
|
reyreavman marked this conversation as resolved.
|
||
| if err != nil { | ||
| return nil, fmt.Errorf("union per-directive BOMs: %w", err) | ||
| } | ||
|
|
||
| return merged, nil | ||
| } | ||
|
|
||
| func (step *sbomStep) scanCatalogerDir(ctx context.Context, scanOpts scanner.ScanOptions, cataloger scanner.Cataloger, dir string) (*cdx.BOM, error) { | ||
| cmd := scanOpts.Commands[0] | ||
| cmd.Catalogers = []scanner.Cataloger{cataloger} | ||
| cmd.SourceType = scanner.SourceTypeDir | ||
| cmd.SourcePath = dir | ||
|
|
||
| perDirectiveOpts := scanOpts | ||
| perDirectiveOpts.Commands = []scanner.ScanCommand{cmd} | ||
|
|
||
| bomJSON, err := step.containerBackend.GenerateSBOM(ctx, perDirectiveOpts) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("generate SBOM for cataloger %q: %w", cataloger.Name, err) | ||
| } | ||
|
|
||
| bom, err := cyclonedxutil.BuildCycloneDX16BOMFromJSON(bomJSON) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("parse scanned BOM for cataloger %q: %w", cataloger.Name, err) | ||
| } | ||
|
|
||
| // A directory source makes syft emit a PURL-less type=file component for each scanned | ||
| // manifest file; drop them so only real packages remain. This is what makes omitting the | ||
| // post-scan source-path filter safe (see SYFT_FILE_METADATA_SELECTION in the docker backend). | ||
| cyclonedxutil.DropSyftSourceFileComponents(bom) | ||
|
|
||
| return bom, nil | ||
| } | ||
|
|
||
| const sbomArtifactFormatVersion = "4" | ||
|
|
||
| // calculateStableChecksum computes the SBOM artifact cache checksum. Together with the | ||
| // parent stage digest it forms the cache key: a previously attached SBOM is reused only | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.