Skip to content

Commit fa6c30f

Browse files
sbom export scan refactoring
1 parent 17b514f commit fa6c30f

4 files changed

Lines changed: 101 additions & 111 deletions

File tree

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,49 @@ sbom:
342342

343343
When enabled, Leeway automatically generates SBOMs for each package during the build process in multiple formats (CycloneDX, SPDX, and Syft JSON) using [Syft](https://github.com/anchore/syft). These SBOMs are included in the package's build artifacts.
344344

345+
### SBOM Commands
346+
347+
Leeway provides two commands for working with SBOMs:
348+
349+
#### sbom export
350+
351+
The `sbom export` command allows you to export the SBOM of a previously built package:
352+
353+
```bash
354+
# Export SBOM in CycloneDX format (default) to stdout
355+
leeway sbom export some/component:package
356+
357+
# Export SBOM in a specific format to a file
358+
leeway sbom export --format spdx --output sbom.spdx.json some/component:package
359+
360+
# Export SBOMs for a package and all its dependencies to a directory
361+
leeway sbom export --with-dependencies --output-dir sboms/ some/component:package
362+
```
363+
364+
Options:
365+
- `--format`: SBOM format to export (cyclonedx, spdx, syft). Default is cyclonedx.
366+
- `--output, -o`: Output file (defaults to stdout).
367+
- `--with-dependencies`: Export SBOMs for the package and all its dependencies.
368+
- `--output-dir`: Output directory for exporting multiple SBOMs (required with --with-dependencies).
369+
370+
#### sbom scan
371+
372+
The `sbom scan` command scans a package's SBOM for vulnerabilities and exports the results:
373+
374+
```bash
375+
# Scan a package for vulnerabilities
376+
leeway sbom scan --output-dir vuln-reports/ some/component:package
377+
378+
# Scan a package and all its dependencies for vulnerabilities
379+
leeway sbom scan --with-dependencies --output-dir vuln-reports/ some/component:package
380+
```
381+
382+
Options:
383+
- `--output-dir`: Directory to export scan results (required).
384+
- `--with-dependencies`: Scan the package and all its dependencies.
385+
386+
This command uses existing SBOM files from previously built packages and requires SBOM generation and vulnerability scanning to be enabled in the workspace settings.
387+
345388
### Vulnerability Scanning
346389

347390
When `scanVulnerabilities` is enabled, Leeway scans the generated SBOMs for vulnerabilities using [Grype](https://github.com/anchore/grype). The scan results are written to the build directory in multiple formats:

cmd/sbom-export.go

Lines changed: 6 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"github.com/gitpod-io/leeway/pkg/leeway"
1010
log "github.com/sirupsen/logrus"
1111
"github.com/spf13/cobra"
12-
"slices"
1312
)
1413

1514
// sbomExportCmd represents the sbom export command
@@ -48,9 +47,8 @@ to the specified output directory.`,
4847
withDependencies, _ := cmd.Flags().GetBool("with-dependencies")
4948
outputDir, _ := cmd.Flags().GetString("output-dir")
5049

51-
// Validate format
52-
validFormats := []string{"cyclonedx", "spdx", "syft"}
53-
formatValid := slices.Contains(validFormats, format)
50+
// Validate format using the utility function
51+
formatValid, validFormats := leeway.ValidateSBOMFormat(format)
5452
if !formatValid {
5553
log.Fatalf("Unsupported format: %s. Supported formats are: %s", format, strings.Join(validFormats, ", "))
5654
}
@@ -73,7 +71,7 @@ to the specified output directory.`,
7371
}
7472

7573
// Get all dependencies
76-
deps := getAllDependencies(pkg)
74+
deps := pkg.GetTransitiveDependencies()
7775
log.Infof("Exporting SBOMs for %s and %d dependencies to %s", pkg.FullName(), len(deps), outputDir)
7876

7977
// Add the package itself to the list
@@ -91,21 +89,10 @@ to the specified output directory.`,
9189

9290
// Create safe filename for the package
9391
safeFilename := strings.ReplaceAll(p.FullName(), "/", "_")
94-
outputPath := filepath.Join(outputDir, safeFilename+getFormatExtension(format))
95-
96-
// Extract and output the SBOM
97-
err := leeway.AccessSBOMInCachedArchive(pFN, format, func(sbomReader io.Reader) error {
98-
// Create the output file
99-
file, err := os.Create(outputPath)
100-
if err != nil {
101-
return err
102-
}
103-
defer file.Close()
92+
outputPath := filepath.Join(outputDir, safeFilename+leeway.GetSBOMFormatExtension(format))
10493

105-
// Copy the SBOM content to the file
106-
_, err = io.Copy(file, sbomReader)
107-
return err
108-
})
94+
// Extract and output the SBOM using the WriteFileHandler
95+
err := leeway.AccessSBOMInCachedArchive(pFN, format, leeway.WriteFileHandler(outputPath))
10996

11097
if err != nil {
11198
if err == leeway.ErrNoSBOMFile {
@@ -165,47 +152,6 @@ to the specified output directory.`,
165152
},
166153
}
167154

168-
// Helper function to get all dependencies of a package
169-
func getAllDependencies(pkg *leeway.Package) []*leeway.Package {
170-
// Use a map to avoid duplicates
171-
depsMap := make(map[string]*leeway.Package)
172-
173-
// Recursively collect dependencies
174-
var collectDeps func(p *leeway.Package)
175-
collectDeps = func(p *leeway.Package) {
176-
for _, dep := range p.GetDependencies() {
177-
if _, exists := depsMap[dep.FullName()]; !exists {
178-
depsMap[dep.FullName()] = dep
179-
collectDeps(dep)
180-
}
181-
}
182-
}
183-
184-
collectDeps(pkg)
185-
186-
// Convert map to slice
187-
deps := make([]*leeway.Package, 0, len(depsMap))
188-
for _, dep := range depsMap {
189-
deps = append(deps, dep)
190-
}
191-
192-
return deps
193-
}
194-
195-
// Helper function to get file extension for the format
196-
func getFormatExtension(format string) string {
197-
switch format {
198-
case "cyclonedx":
199-
return ".cdx.json"
200-
case "spdx":
201-
return ".spdx.json"
202-
case "syft":
203-
return ".json"
204-
default:
205-
return ".json"
206-
}
207-
}
208-
209155
func init() {
210156
sbomExportCmd.Flags().String("format", "cyclonedx", "SBOM format to export (cyclonedx, spdx, syft)")
211157
sbomExportCmd.Flags().StringP("output", "o", "", "Output file (defaults to stdout)")

cmd/sbom-scan.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,6 @@ When used with --with-dependencies, it scans the package and all its dependencie
3232
log.Fatal("SBOM scanning requires sbom.enabled=true in workspace settings")
3333
}
3434

35-
// Check if vulnerability scanning is enabled
36-
if !pkg.C.W.SBOM.ScanVulnerabilities {
37-
log.Fatal("SBOM scanning requires sbom.scanVulnerabilities=true in workspace settings")
38-
}
39-
4035
// Get build options and cache
4136
_, cache := getBuildOpts(cmd)
4237

@@ -71,6 +66,7 @@ When used with --with-dependencies, it scans the package and all its dependencie
7166
reporter := leeway.NewConsoleReporter()
7267

7368
// Use the ScanPackageSBOM function to scan the package
69+
// This function will use GetAllPackageDependencies internally when withDependencies is true
7470
if err := leeway.ScanPackageSBOM(pkg, reporter, cache, outputDir, withDependencies); err != nil {
7571
log.WithError(err).Fatalf("Failed to scan package %s for vulnerabilities", pkg.FullName())
7672
}

pkg/leeway/sbom.go

Lines changed: 51 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -374,11 +374,11 @@ func ScanPackageSBOM(p *Package, reporter Reporter, localCache cache.LocalCache,
374374
LocalCache: localCache,
375375
},
376376
}
377-
377+
378378
// If we need to scan dependencies as well
379379
if withDependencies {
380-
// Get all dependencies
381-
deps := getAllDependencies(p)
380+
// Get all dependencies using the Package's GetTransitiveDependencies method
381+
deps := p.GetTransitiveDependencies()
382382
log.Infof("Scanning %s and %d dependencies for vulnerabilities", p.FullName(), len(deps))
383383

384384
// Add the package itself to the list
@@ -387,7 +387,7 @@ func ScanPackageSBOM(p *Package, reporter Reporter, localCache cache.LocalCache,
387387
// Call the existing function with all packages and the custom output directory
388388
return ScanAllPackagesForVulnerabilities(ctx, packagesToScan, outputDir)
389389
}
390-
390+
391391
// Just scan the single package
392392
return ScanAllPackagesForVulnerabilities(ctx, []*Package{p}, outputDir)
393393
}
@@ -418,7 +418,7 @@ func ScanAllPackagesForVulnerabilities(buildctx *buildContext, packages []*Packa
418418
if len(customOutputDir) > 0 && customOutputDir[0] != "" {
419419
// Use custom output directory if provided
420420
outputDir = customOutputDir[0]
421-
421+
422422
// Create the output directory if it doesn't exist
423423
if err := os.MkdirAll(outputDir, 0755); err != nil {
424424
errMsg := fmt.Sprintf("failed to create output directory %s: %s", outputDir, err)
@@ -429,7 +429,7 @@ func ScanAllPackagesForVulnerabilities(buildctx *buildContext, packages []*Packa
429429
// Use default timestamp-based directory structure
430430
reportLocation := GetVulnerabilityReportLocation(p, timestamp)
431431
outputDir = reportLocation.PackageDir
432-
432+
433433
// Create the directory for this package's vulnerability reports
434434
if err := os.MkdirAll(outputDir, 0755); err != nil {
435435
errMsg := fmt.Sprintf("failed to create vulnerability reports directory for package %s: %s", p.FullName(), err)
@@ -466,26 +466,9 @@ func ScanAllPackagesForVulnerabilities(buildctx *buildContext, packages []*Packa
466466
}
467467
}()
468468

469-
// Extract the SBOM file directly from the package archive
469+
// Extract the SBOM file directly from the package archive using WriteFileHandler
470470
// For vulnerability scanning, we use the CycloneDX format
471-
err = AccessSBOMInCachedArchive(location, "cyclonedx", func(sbomReader io.Reader) error {
472-
// Copy the SBOM content to the temporary file
473-
sbomFile, err := os.OpenFile(tempFileName, os.O_WRONLY, 0644)
474-
if err != nil {
475-
return xerrors.Errorf("failed to open temporary file for writing: %w", err)
476-
}
477-
defer func() {
478-
if err := sbomFile.Close(); err != nil {
479-
buildctx.Reporter.PackageBuildLog(p, false, []byte("failed to close SBOM file: "+err.Error()+"\n"))
480-
}
481-
}()
482-
483-
_, err = io.Copy(sbomFile, sbomReader)
484-
if err != nil {
485-
return xerrors.Errorf("failed to write SBOM content to temporary file: %w", err)
486-
}
487-
return nil
488-
})
471+
err = AccessSBOMInCachedArchive(location, "cyclonedx", WriteFileHandler(tempFileName))
489472

490473
if err != nil {
491474
if err == ErrNoSBOMFile {
@@ -721,33 +704,55 @@ func loadVulnerabilityDB(p *Package, buildctx *buildContext) (vulnerability.Prov
721704
return provider, status, nil
722705
}
723706

724-
// Helper function to get all dependencies of a package
725-
func getAllDependencies(pkg *Package) []*Package {
726-
// Use a map to avoid duplicates
727-
depsMap := make(map[string]*Package)
728-
729-
// Recursively collect dependencies
730-
var collectDeps func(p *Package)
731-
collectDeps = func(p *Package) {
732-
for _, dep := range p.GetDependencies() {
733-
if _, exists := depsMap[dep.FullName()]; !exists {
734-
depsMap[dep.FullName()] = dep
735-
collectDeps(dep)
707+
// WriteFileHandler returns a handler function for AccessSBOMInCachedArchive that writes to a file
708+
func WriteFileHandler(outputPath string) func(io.Reader) error {
709+
return func(r io.Reader) error {
710+
// Create directories if needed
711+
if dir := filepath.Dir(outputPath); dir != "" {
712+
if err := os.MkdirAll(dir, 0755); err != nil {
713+
return fmt.Errorf("cannot create output directory %s: %w", dir, err)
736714
}
737715
}
716+
717+
// Create and write to file
718+
file, err := os.Create(outputPath)
719+
if err != nil {
720+
return fmt.Errorf("cannot create output file %s: %w", outputPath, err)
721+
}
722+
defer file.Close()
723+
724+
_, err = io.Copy(file, r)
725+
return err
738726
}
739-
740-
collectDeps(pkg)
741-
742-
// Convert map to slice
743-
deps := make([]*Package, 0, len(depsMap))
744-
for _, dep := range depsMap {
745-
deps = append(deps, dep)
727+
}
728+
729+
// ValidateSBOMFormat checks if the provided format is supported
730+
func ValidateSBOMFormat(format string) (bool, []string) {
731+
validFormats := []string{"cyclonedx", "spdx", "syft"}
732+
for _, f := range validFormats {
733+
if format == f {
734+
return true, validFormats
735+
}
746736
}
747-
748-
return deps
737+
return false, validFormats
749738
}
750739

740+
// GetSBOMFormatExtension returns the file extension for the given SBOM format
741+
func GetSBOMFormatExtension(format string) string {
742+
switch format {
743+
case "cyclonedx":
744+
return ".cdx.json"
745+
case "spdx":
746+
return ".spdx.json"
747+
case "syft":
748+
return ".json"
749+
default:
750+
return ".json"
751+
}
752+
}
753+
754+
// ErrNoSBOMFile is returned when no SBOM file is found in a cached archive
755+
751756
// ErrNoSBOMFile is returned when no SBOM file is found in a cached archive
752757
var ErrNoSBOMFile = fmt.Errorf("no SBOM file found")
753758

0 commit comments

Comments
 (0)