Skip to content

Commit a926587

Browse files
Add SBOM generation and vulnerability scanning
This commit adds comprehensive Software Bill of Materials (SBOM) generation and vulnerability scanning capabilities to Leeway: - Generate SBOMs for packages during build in multiple formats (CycloneDX, SPDX, Syft) - Scan SBOMs for vulnerabilities using Grype - Add new commands: - `leeway sbom export` to export SBOMs from built packages - `leeway sbom scan` to scan packages for vulnerabilities - Configure SBOM generation and scanning in WORKSPACE.yaml - Support vulnerability filtering with ignore rules at workspace and package levels - Generate vulnerability reports in multiple formats (JSON, text, CycloneDX, SARIF) - Add documentation in README.md with examples and configuration options This feature helps identify and manage security vulnerabilities in the software supply chain by providing visibility into package dependencies and their known vulnerabilities.
1 parent a2f98b6 commit a926587

File tree

13 files changed

+4097
-111
lines changed

13 files changed

+4097
-111
lines changed

README.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,164 @@ environmentManifest:
322322

323323
Using this mechanism you can also overwrite the default manifest entries, e.g. "go" or "yarn".
324324

325+
## SBOM and Vulnerability Scanning
326+
327+
Leeway includes built-in support for Software Bill of Materials (SBOM) generation and vulnerability scanning. This feature helps you identify and manage security vulnerabilities in your software supply chain.
328+
329+
### Enabling SBOM Generation
330+
331+
SBOM generation is configured in your `WORKSPACE.yaml` file:
332+
333+
```yaml
334+
sbom:
335+
enabled: true # Enable SBOM generation
336+
scanVulnerabilities: true # Enable vulnerability scanning
337+
failOn: ["critical", "high"] # Fail builds with vulnerabilities of these severities (default: build does not fail)
338+
ignoreVulnerabilities: # Workspace-level ignore rules
339+
- vulnerability: "CVE-2023-1234"
340+
reason: "Not exploitable in our context"
341+
```
342+
343+
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.
344+
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+
This command uses existing SBOM files from previously built packages and requires SBOM generation to be enabled in the workspace settings.
371+
372+
#### sbom scan
373+
374+
The `sbom scan` command scans a package's SBOM for vulnerabilities and exports the results:
375+
376+
```bash
377+
# Scan a package for vulnerabilities
378+
leeway sbom scan --output-dir vuln-reports/ some/component:package
379+
380+
# Scan a package and all its dependencies for vulnerabilities
381+
leeway sbom scan --with-dependencies --output-dir vuln-reports/ some/component:package
382+
```
383+
384+
Options:
385+
- `--output-dir`: Directory to export scan results (required).
386+
- `--with-dependencies`: Scan the package and all its dependencies.
387+
388+
This command uses existing SBOM files from previously built packages and requires SBOM generation to be enabled in the workspace settings (vulnerability scanning does not need to be enabled).
389+
390+
### Vulnerability Scanning
391+
392+
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:
393+
394+
- `vulnerabilities.txt` - Human-readable table format
395+
- `vulnerabilities.json` - Detailed JSON format
396+
- `vulnerabilities.cdx.json` - CycloneDX format
397+
- `vulnerabilities.sarif` - SARIF format for integration with code analysis tools
398+
399+
#### Configuring Build Failure Thresholds
400+
401+
The `failOn` setting determines which vulnerability severity levels will cause a build to fail. Omit this configuration to generate only the reports without causing the build to fail. For example:
402+
403+
```yaml
404+
failOn: ["critical", "high"] # Fail on critical and high vulnerabilities
405+
```
406+
407+
Supported severity levels are: `critical`, `high`, `medium`, `low`, `negligible`, and `unknown`.
408+
409+
### Ignoring Vulnerabilities
410+
411+
Leeway provides a flexible system for ignoring specific vulnerabilities. Ignore rules can be defined at both the workspace level (in `WORKSPACE.yaml`) and the package level (in `BUILD.yaml`). For detailed documentation on ignore rules, see [Grype's documentation on specifying matches to ignore](https://github.com/anchore/grype/blob/main/README.md#specifying-matches-to-ignore).
412+
413+
#### Ignore Rule Configuration
414+
415+
Ignore rules use Grype's powerful filtering capabilities:
416+
417+
```yaml
418+
# In WORKSPACE.yaml (workspace-level rules)
419+
sbom:
420+
ignoreVulnerabilities:
421+
# Basic usage - ignore a specific CVE
422+
- vulnerability: "CVE-2023-1234"
423+
reason: "Not exploitable in our context"
424+
425+
# Advanced usage - ignore a vulnerability only for a specific package
426+
- vulnerability: "GHSA-abcd-1234-efgh"
427+
reason: "Mitigated by our application architecture"
428+
package:
429+
name: "vulnerable-pkg"
430+
version: "1.2.3"
431+
432+
# Using fix state
433+
- vulnerability: "CVE-2023-5678"
434+
reason: "Will be fixed in next dependency update"
435+
fix-state: "fixed"
436+
437+
# Using VEX status
438+
- vulnerability: "CVE-2023-9012"
439+
reason: "Not affected as we don't use the vulnerable component"
440+
vex-status: "not_affected"
441+
vex-justification: "vulnerable_code_not_in_execute_path"
442+
```
443+
444+
#### Package-Level Ignore Rules
445+
446+
You can also specify ignore rules for specific packages in their `BUILD.yaml` file:
447+
448+
```yaml
449+
# In package BUILD.yaml
450+
packages:
451+
- name: my-package
452+
type: go
453+
# ... other package configuration ...
454+
sbom:
455+
ignoreVulnerabilities:
456+
- vulnerability: "GHSA-abcd-1234-efgh"
457+
reason: "Mitigated by our application architecture"
458+
```
459+
460+
Package-level rules are combined with workspace-level rules during vulnerability scanning.
461+
462+
#### Available Ignore Rule Fields
463+
464+
Leeway's ignore rules support all of Grype's filtering capabilities:
465+
466+
- `vulnerability`: The vulnerability ID to ignore (e.g., "CVE-2023-1234")
467+
- `reason`: The reason for ignoring this vulnerability (required)
468+
- `namespace`: The vulnerability namespace (e.g., "github:golang")
469+
- `fix-state`: The fix state to match (e.g., "fixed", "not-fixed", "unknown")
470+
- `package`: Package-specific criteria (see below)
471+
- `vex-status`: VEX status (e.g., "affected", "fixed", "not_affected")
472+
- `vex-justification`: Justification for the VEX status
473+
- `match-type`: The type of match to ignore (e.g., "exact-direct-dependency")
474+
475+
The `package` field can contain:
476+
- `name`: Package name (supports regex)
477+
- `version`: Package version
478+
- `language`: Package language
479+
- `type`: Package type
480+
- `location`: Package location (supports glob patterns)
481+
- `upstream-name`: Upstream package name (supports regex)
482+
325483
# Configuration
326484
Leeway is configured exclusively through the WORKSPACE.yaml/BUILD.yaml files and environment variables. The following environment
327485
variables have an effect on leeway:

WORKSPACE.yaml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,15 @@ environmentManifest:
99
provenance:
1010
enabled: true
1111
slsa: true
12+
sbom:
13+
enabled: true
14+
scanVulnerabilities: true
15+
# failOn: ["critical", "high"]
16+
# ignoreVulnerabilities:
17+
# - vulnerability: GHSA-265r-hfxg-fhmg
18+
# reason: "Not exploitable in our context"
1219
variants:
1320
- name: nogit
1421
srcs:
1522
exclude:
16-
- "**/.git"
23+
- "**/.git"

cmd/sbom-export.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/gitpod-io/leeway/pkg/leeway"
11+
"github.com/gitpod-io/leeway/pkg/leeway/cache"
12+
log "github.com/sirupsen/logrus"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
// sbomExportCmd represents the sbom export command
17+
var sbomExportCmd = &cobra.Command{
18+
Use: "export [package]",
19+
Short: "Exports the SBOM of a (previously built) package",
20+
Long: `Exports the SBOM of a (previously built) package.
21+
22+
When used with --with-dependencies, it exports SBOMs for the package and all its dependencies
23+
to the specified output directory.
24+
25+
If no package is specified, the workspace's default target is used.`,
26+
Args: cobra.MaximumNArgs(1),
27+
Run: func(cmd *cobra.Command, args []string) {
28+
// Get the package
29+
_, pkg, _, _ := getTarget(args, false)
30+
if pkg == nil {
31+
log.Fatal("sbom export requires a package or a default target in the workspace")
32+
}
33+
34+
// Get build options and cache
35+
_, localCache := getBuildOpts(cmd)
36+
37+
// Get output format and file
38+
format, _ := cmd.Flags().GetString("format")
39+
outputFile, _ := cmd.Flags().GetString("output")
40+
withDependencies, _ := cmd.Flags().GetBool("with-dependencies")
41+
outputDir, _ := cmd.Flags().GetString("output-dir")
42+
43+
// Validate format using the utility function
44+
formatValid, validFormats := leeway.ValidateSBOMFormat(format)
45+
if !formatValid {
46+
log.Fatalf("Unsupported format: %s. Supported formats are: %s", format, strings.Join(validFormats, ", "))
47+
}
48+
49+
// Validate flags for dependency export
50+
if withDependencies {
51+
if outputDir == "" {
52+
log.Fatal("--output-dir is required when using --with-dependencies")
53+
}
54+
if outputFile != "" {
55+
log.Fatal("--output and --output-dir cannot be used together")
56+
}
57+
}
58+
59+
var allpkg []*leeway.Package
60+
allpkg = append(allpkg, pkg)
61+
62+
if withDependencies {
63+
// Get all dependencies
64+
deps := pkg.GetTransitiveDependencies()
65+
log.Infof("Exporting SBOMs for %s and %d dependencies to %s", pkg.FullName(), len(deps), outputDir)
66+
67+
allpkg = append(allpkg, deps...)
68+
}
69+
70+
for _, p := range allpkg {
71+
var outputPath string
72+
if outputFile == "" {
73+
safeFilename := p.FilesystemSafeName()
74+
outputPath = filepath.Join(outputDir, safeFilename+leeway.GetSBOMFileExtension(format))
75+
} else {
76+
outputPath = outputFile
77+
}
78+
exportSBOM(p, localCache, outputPath, format)
79+
}
80+
},
81+
}
82+
83+
func init() {
84+
sbomExportCmd.Flags().String("format", "cyclonedx", "SBOM format to export (cyclonedx, spdx, syft)")
85+
sbomExportCmd.Flags().StringP("output", "o", "", "Output file (defaults to stdout)")
86+
sbomExportCmd.Flags().Bool("with-dependencies", false, "Export SBOMs for the package and all its dependencies")
87+
sbomExportCmd.Flags().String("output-dir", "", "Output directory for exporting multiple SBOMs (required with --with-dependencies)")
88+
89+
sbomCmd.AddCommand(sbomExportCmd)
90+
addBuildFlags(sbomExportCmd)
91+
}
92+
93+
// exportSBOM extracts and writes an SBOM from a package's cached archive.
94+
// It retrieves the package from the cache, creates the output file if needed,
95+
// and extracts the SBOM in the specified format. If outputFile is empty,
96+
// the SBOM is written to stdout.
97+
func exportSBOM(pkg *leeway.Package, localCache cache.LocalCache, outputFile string, format string) {
98+
pkgFN := GetPackagePath(pkg, localCache)
99+
100+
var output io.Writer = os.Stdout
101+
102+
// Create directory if it doesn't exist
103+
if dir := filepath.Dir(outputFile); dir != "" {
104+
if err := os.MkdirAll(dir, 0755); err != nil {
105+
log.WithError(err).Fatalf("cannot create output directory %s", dir)
106+
}
107+
}
108+
109+
file, err := os.Create(outputFile)
110+
if err != nil {
111+
log.WithError(err).Fatalf("cannot create output file %s", outputFile)
112+
}
113+
defer file.Close()
114+
output = file
115+
116+
// Extract and output the SBOM
117+
err = leeway.AccessSBOMInCachedArchive(pkgFN, format, func(sbomReader io.Reader) error {
118+
log.Infof("Exporting SBOM in %s format", format)
119+
_, err := io.Copy(output, sbomReader)
120+
return err
121+
})
122+
123+
if err != nil {
124+
if err == leeway.ErrNoSBOMFile {
125+
log.Fatalf("no SBOM file found in package %s", pkg.FullName())
126+
}
127+
log.WithError(err).Fatal("cannot extract SBOM")
128+
}
129+
130+
if outputFile != "" {
131+
log.Infof("SBOM exported to %s", outputFile)
132+
}
133+
}
134+
135+
// GetPackagePath retrieves the filesystem path to a package's cached archive.
136+
// It first checks the local cache, and if not found, attempts to download
137+
// the package from the remote cache. This function verifies that SBOM is enabled
138+
// in the workspace settings and returns the path to the package archive.
139+
// If the package cannot be found in either cache, it exits with a fatal error.
140+
func GetPackagePath(pkg *leeway.Package, localCache cache.LocalCache) (packagePath string) {
141+
// Check if SBOM is enabled in workspace settings
142+
if !pkg.C.W.SBOM.Enabled {
143+
log.Fatal("SBOM export/scan requires sbom.enabled=true in workspace settings")
144+
}
145+
146+
if log.IsLevelEnabled(log.DebugLevel) {
147+
v, err := pkg.Version()
148+
if err != nil {
149+
log.WithError(err).Fatal("error getting version")
150+
}
151+
log.Debugf("Exporting SBOM of package %s (version %s)", pkg.FullName(), v)
152+
}
153+
154+
// Get package location in local cache
155+
pkgFN, ok := localCache.Location(pkg)
156+
if !ok {
157+
// Package not found in local cache, check if it's in the remote cache
158+
log.Debugf("Package %s not found in local cache, checking remote cache", pkg.FullName())
159+
160+
remoteCache := getRemoteCache()
161+
remoteCache = &pullOnlyRemoteCache{C: remoteCache}
162+
163+
// Convert to cache.Package interface
164+
pkgsToCheck := []cache.Package{pkg}
165+
166+
if log.IsLevelEnabled(log.DebugLevel) {
167+
v, err := pkgsToCheck[0].Version()
168+
if err != nil {
169+
log.WithError(err).Fatal("error getting version")
170+
}
171+
log.Debugf("Checking remote of package %s (version %s)", pkgsToCheck[0].FullName(), v)
172+
}
173+
174+
// Check if the package exists in the remote cache
175+
existingPkgs, err := remoteCache.ExistingPackages(context.Background(), pkgsToCheck)
176+
if err != nil {
177+
log.WithError(err).Warnf("Failed to check if package %s exists in remote cache", pkg.FullName())
178+
log.Fatalf("%s is not built", pkg.FullName())
179+
} else {
180+
_, existsInRemote := existingPkgs[pkg]
181+
if existsInRemote {
182+
log.Infof("Package %s found in remote cache, downloading...", pkg.FullName())
183+
184+
// Download the package from the remote cache
185+
err := remoteCache.Download(context.Background(), localCache, pkgsToCheck)
186+
if err != nil {
187+
log.WithError(err).Fatalf("Failed to download package %s from remote cache", pkg.FullName())
188+
}
189+
190+
// Check if the download was successful
191+
pkgFN, ok = localCache.Location(pkg)
192+
if !ok {
193+
log.Fatalf("Failed to download package %s from remote cache", pkg.FullName())
194+
}
195+
196+
log.Infof("Successfully downloaded package %s from remote cache", pkg.FullName())
197+
} else {
198+
log.Fatalf("%s is not built", pkg.FullName())
199+
}
200+
}
201+
}
202+
return pkgFN
203+
}

0 commit comments

Comments
 (0)