Skip to content

Commit 2f4b11b

Browse files
Implement sbom export
1 parent 65d0e7f commit 2f4b11b

File tree

3 files changed

+252
-4
lines changed

3 files changed

+252
-4
lines changed

cmd/sbom-export.go

+217
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package cmd
2+
3+
import (
4+
"io"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
9+
"github.com/gitpod-io/leeway/pkg/leeway"
10+
log "github.com/sirupsen/logrus"
11+
"github.com/spf13/cobra"
12+
"slices"
13+
)
14+
15+
// sbomExportCmd represents the sbom export command
16+
var sbomExportCmd = &cobra.Command{
17+
Use: "export <package>",
18+
Short: "Exports the SBOM of a (previously built) package",
19+
Long: `Exports the SBOM of a (previously built) package.
20+
21+
When used with --with-dependencies, it exports SBOMs for the package and all its dependencies
22+
to the specified output directory.`,
23+
Args: cobra.MinimumNArgs(1),
24+
Run: func(cmd *cobra.Command, args []string) {
25+
// Get the package
26+
_, pkg, _, _ := getTarget(args, false)
27+
if pkg == nil {
28+
log.Fatal("sbom export requires a package")
29+
}
30+
31+
// Check if SBOM is enabled in workspace settings
32+
if !pkg.C.W.SBOM.Enabled {
33+
log.Fatal("SBOM export requires sbom.enabled=true in workspace settings")
34+
}
35+
36+
// Get build options and cache
37+
_, cache := getBuildOpts(cmd)
38+
39+
// Get package location in cache
40+
pkgFN, ok := cache.Location(pkg)
41+
if !ok {
42+
log.Fatalf("%s is not built", pkg.FullName())
43+
}
44+
45+
// Get output format and file
46+
format, _ := cmd.Flags().GetString("format")
47+
outputFile, _ := cmd.Flags().GetString("output")
48+
withDependencies, _ := cmd.Flags().GetBool("with-dependencies")
49+
outputDir, _ := cmd.Flags().GetString("output-dir")
50+
51+
// Validate format
52+
validFormats := []string{"cyclonedx", "spdx", "syft"}
53+
formatValid := slices.Contains(validFormats, format)
54+
if !formatValid {
55+
log.Fatalf("Unsupported format: %s. Supported formats are: %s", format, strings.Join(validFormats, ", "))
56+
}
57+
58+
// Validate flags for dependency export
59+
if withDependencies {
60+
if outputDir == "" {
61+
log.Fatal("--output-dir is required when using --with-dependencies")
62+
}
63+
if outputFile != "" {
64+
log.Fatal("--output and --output-dir cannot be used together")
65+
}
66+
}
67+
68+
// Handle exporting with dependencies
69+
if withDependencies {
70+
// Create output directory if it doesn't exist
71+
if err := os.MkdirAll(outputDir, 0755); err != nil {
72+
log.WithError(err).Fatalf("cannot create output directory %s", outputDir)
73+
}
74+
75+
// Get all dependencies
76+
deps := getAllDependencies(pkg)
77+
log.Infof("Exporting SBOMs for %s and %d dependencies to %s", pkg.FullName(), len(deps), outputDir)
78+
79+
// Add the package itself to the list
80+
packagesToExport := append([]*leeway.Package{pkg}, deps...)
81+
82+
// Export SBOM for each package
83+
var exportErrors []string
84+
for _, p := range packagesToExport {
85+
// Get package location in cache
86+
pFN, ok := cache.Location(p)
87+
if !ok {
88+
log.Warnf("Package %s is not built, skipping", p.FullName())
89+
continue
90+
}
91+
92+
// Create safe filename for the package
93+
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()
104+
105+
// Copy the SBOM content to the file
106+
_, err = io.Copy(file, sbomReader)
107+
return err
108+
})
109+
110+
if err != nil {
111+
if err == leeway.ErrNoSBOMFile {
112+
log.Fatalf("No SBOM file found in package %s", p.FullName())
113+
} else {
114+
log.Warnf("Cannot extract SBOM for package %s: %v", p.FullName(), err)
115+
exportErrors = append(exportErrors, p.FullName())
116+
}
117+
} else {
118+
log.Infof("SBOM exported to %s", outputPath)
119+
}
120+
}
121+
122+
// Report any errors
123+
if len(exportErrors) > 0 {
124+
log.Warnf("Failed to export SBOMs for %d packages: %s", len(exportErrors), strings.Join(exportErrors, ", "))
125+
}
126+
return
127+
}
128+
129+
// Handle single package export
130+
var output io.Writer = os.Stdout
131+
if outputFile != "" {
132+
// Create directory if it doesn't exist
133+
if dir := filepath.Dir(outputFile); dir != "" {
134+
if err := os.MkdirAll(dir, 0755); err != nil {
135+
log.WithError(err).Fatalf("cannot create output directory %s", dir)
136+
}
137+
}
138+
139+
file, err := os.Create(outputFile)
140+
if err != nil {
141+
log.WithError(err).Fatalf("cannot create output file %s", outputFile)
142+
}
143+
defer file.Close()
144+
output = file
145+
}
146+
147+
// Extract and output the SBOM
148+
err := leeway.AccessSBOMInCachedArchive(pkgFN, format, func(sbomReader io.Reader) error {
149+
log.Infof("Exporting SBOM in %s format", format)
150+
// Copy the SBOM content to the output
151+
_, err := io.Copy(output, sbomReader)
152+
return err
153+
})
154+
155+
if err != nil {
156+
if err == leeway.ErrNoSBOMFile {
157+
log.Fatalf("no SBOM file found in package %s", pkg.FullName())
158+
}
159+
log.WithError(err).Fatal("cannot extract SBOM")
160+
}
161+
162+
if outputFile != "" {
163+
log.Infof("SBOM exported to %s", outputFile)
164+
}
165+
},
166+
}
167+
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+
209+
func init() {
210+
sbomExportCmd.Flags().String("format", "cyclonedx", "SBOM format to export (cyclonedx, spdx, syft)")
211+
sbomExportCmd.Flags().StringP("output", "o", "", "Output file (defaults to stdout)")
212+
sbomExportCmd.Flags().Bool("with-dependencies", false, "Export SBOMs for the package and all its dependencies")
213+
sbomExportCmd.Flags().String("output-dir", "", "Output directory for exporting multiple SBOMs (required with --with-dependencies)")
214+
215+
sbomCmd.AddCommand(sbomExportCmd)
216+
addBuildFlags(sbomExportCmd)
217+
}

cmd/sbom.go

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
// sbomCmd represents the sbom command
8+
var sbomCmd = &cobra.Command{
9+
Use: "sbom <command>",
10+
Short: "Helpful commands for working with Software Bill of Materials (SBOM)",
11+
Args: cobra.MinimumNArgs(1),
12+
}
13+
14+
func init() {
15+
rootCmd.AddCommand(sbomCmd)
16+
}

pkg/leeway/sbom.go

+19-4
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,8 @@ func ScanAllPackagesForVulnerabilities(buildctx *buildContext, packages []*Packa
421421
}()
422422

423423
// Extract the SBOM file directly from the package archive
424-
err = AccessSBOMInCachedArchive(location, func(sbomReader io.Reader) error {
424+
// For vulnerability scanning, we use the CycloneDX format
425+
err = AccessSBOMInCachedArchive(location, "cyclonedx", func(sbomReader io.Reader) error {
425426
// Copy the SBOM content to the temporary file
426427
sbomFile, err := os.OpenFile(tempFileName, os.O_WRONLY, 0644)
427428
if err != nil {
@@ -679,13 +680,27 @@ var ErrNoSBOMFile = fmt.Errorf("no SBOM file found")
679680

680681
// AccessSBOMInCachedArchive provides access to the SBOM file in a cached build artifact.
681682
// If no such file exists, ErrNoSBOMFile is returned.
682-
func AccessSBOMInCachedArchive(fn string, handler func(sbomFile io.Reader) error) (err error) {
683+
// The format parameter specifies which SBOM format to extract (cyclonedx, spdx, or syft).
684+
func AccessSBOMInCachedArchive(fn string, format string, handler func(sbomFile io.Reader) error) (err error) {
683685
defer func() {
684686
if err != nil && err != ErrNoSBOMFile {
685-
err = fmt.Errorf("error extracting SBOM from %s: %w", fn, err)
687+
err = fmt.Errorf("error extracting SBOM from %s (format: %s): %w", fn, format, err)
686688
}
687689
}()
688690

691+
// Determine which SBOM filename to look for based on the format
692+
var sbomFilename string
693+
switch format {
694+
case "cyclonedx":
695+
sbomFilename = sbomCycloneDXFilename
696+
case "spdx":
697+
sbomFilename = sbomSPDXFilename
698+
case "syft":
699+
sbomFilename = sbomSyftFilename
700+
default:
701+
return fmt.Errorf("unsupported SBOM format: %s", format)
702+
}
703+
689704
f, err := os.Open(fn)
690705
if err != nil {
691706
return err
@@ -719,7 +734,7 @@ func AccessSBOMInCachedArchive(fn string, handler func(sbomFile io.Reader) error
719734
break
720735
}
721736

722-
if !strings.HasSuffix(hdr.Name, sbomCycloneDXFilename) {
737+
if !strings.HasSuffix(hdr.Name, sbomFilename) {
723738
continue
724739
}
725740

0 commit comments

Comments
 (0)