Skip to content

SBOM generation and CVE scanning #220

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

Merged
merged 1 commit into from
May 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,164 @@ environmentManifest:

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

## SBOM and Vulnerability Scanning

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.

### Enabling SBOM Generation

SBOM generation is configured in your `WORKSPACE.yaml` file:

```yaml
sbom:
enabled: true # Enable SBOM generation
scanVulnerabilities: true # Enable vulnerability scanning
failOn: ["critical", "high"] # Fail builds with vulnerabilities of these severities (default: build does not fail)
ignoreVulnerabilities: # Workspace-level ignore rules
- vulnerability: "CVE-2023-1234"
reason: "Not exploitable in our context"
```

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.

### SBOM Commands

Leeway provides two commands for working with SBOMs:

#### sbom export

The `sbom export` command allows you to export the SBOM of a previously built package:

```bash
# Export SBOM in CycloneDX format (default) to stdout
leeway sbom export some/component:package

# Export SBOM in a specific format to a file
leeway sbom export --format spdx --output sbom.spdx.json some/component:package

# Export SBOMs for a package and all its dependencies to a directory
leeway sbom export --with-dependencies --output-dir sboms/ some/component:package
```

Options:
- `--format`: SBOM format to export (cyclonedx, spdx, syft). Default is cyclonedx.
- `--output, -o`: Output file (defaults to stdout).
- `--with-dependencies`: Export SBOMs for the package and all its dependencies.
- `--output-dir`: Output directory for exporting multiple SBOMs (required with --with-dependencies).

This command uses existing SBOM files from previously built packages and requires SBOM generation to be enabled in the workspace settings.

#### sbom scan

The `sbom scan` command scans a package's SBOM for vulnerabilities and exports the results:

```bash
# Scan a package for vulnerabilities
leeway sbom scan --output-dir vuln-reports/ some/component:package

# Scan a package and all its dependencies for vulnerabilities
leeway sbom scan --with-dependencies --output-dir vuln-reports/ some/component:package
```

Options:
- `--output-dir`: Directory to export scan results (required).
- `--with-dependencies`: Scan the package and all its dependencies.

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).

### Vulnerability Scanning

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:

- `vulnerabilities.txt` - Human-readable table format
- `vulnerabilities.json` - Detailed JSON format
- `vulnerabilities.cdx.json` - CycloneDX format
- `vulnerabilities.sarif` - SARIF format for integration with code analysis tools

#### Configuring Build Failure Thresholds

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:

```yaml
failOn: ["critical", "high"] # Fail on critical and high vulnerabilities
```

Supported severity levels are: `critical`, `high`, `medium`, `low`, `negligible`, and `unknown`.

### Ignoring Vulnerabilities

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).

#### Ignore Rule Configuration

Ignore rules use Grype's powerful filtering capabilities:

```yaml
# In WORKSPACE.yaml (workspace-level rules)
sbom:
ignoreVulnerabilities:
# Basic usage - ignore a specific CVE
- vulnerability: "CVE-2023-1234"
reason: "Not exploitable in our context"

# Advanced usage - ignore a vulnerability only for a specific package
- vulnerability: "GHSA-abcd-1234-efgh"
reason: "Mitigated by our application architecture"
package:
name: "vulnerable-pkg"
version: "1.2.3"

# Using fix state
- vulnerability: "CVE-2023-5678"
reason: "Will be fixed in next dependency update"
fix-state: "fixed"

# Using VEX status
- vulnerability: "CVE-2023-9012"
reason: "Not affected as we don't use the vulnerable component"
vex-status: "not_affected"
vex-justification: "vulnerable_code_not_in_execute_path"
```

#### Package-Level Ignore Rules

You can also specify ignore rules for specific packages in their `BUILD.yaml` file:

```yaml
# In package BUILD.yaml
packages:
- name: my-package
type: go
# ... other package configuration ...
sbom:
ignoreVulnerabilities:
- vulnerability: "GHSA-abcd-1234-efgh"
reason: "Mitigated by our application architecture"
```

Package-level rules are combined with workspace-level rules during vulnerability scanning.

#### Available Ignore Rule Fields

Leeway's ignore rules support all of Grype's filtering capabilities:

- `vulnerability`: The vulnerability ID to ignore (e.g., "CVE-2023-1234")
- `reason`: The reason for ignoring this vulnerability (required)
- `namespace`: The vulnerability namespace (e.g., "github:golang")
- `fix-state`: The fix state to match (e.g., "fixed", "not-fixed", "unknown")
- `package`: Package-specific criteria (see below)
- `vex-status`: VEX status (e.g., "affected", "fixed", "not_affected")
- `vex-justification`: Justification for the VEX status
- `match-type`: The type of match to ignore (e.g., "exact-direct-dependency")

The `package` field can contain:
- `name`: Package name (supports regex)
- `version`: Package version
- `language`: Package language
- `type`: Package type
- `location`: Package location (supports glob patterns)
- `upstream-name`: Upstream package name (supports regex)

# Configuration
Leeway is configured exclusively through the WORKSPACE.yaml/BUILD.yaml files and environment variables. The following environment
variables have an effect on leeway:
Expand Down
9 changes: 8 additions & 1 deletion WORKSPACE.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ environmentManifest:
provenance:
enabled: true
slsa: true
sbom:
enabled: true
scanVulnerabilities: true
# failOn: ["critical", "high"]
# ignoreVulnerabilities:
# - vulnerability: GHSA-265r-hfxg-fhmg
# reason: "Not exploitable in our context"
variants:
- name: nogit
srcs:
exclude:
- "**/.git"
- "**/.git"
203 changes: 203 additions & 0 deletions cmd/sbom-export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package cmd

import (
"context"
"io"
"os"
"path/filepath"
"strings"

"github.com/gitpod-io/leeway/pkg/leeway"
"github.com/gitpod-io/leeway/pkg/leeway/cache"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)

// sbomExportCmd represents the sbom export command
var sbomExportCmd = &cobra.Command{
Use: "export [package]",
Short: "Exports the SBOM of a (previously built) package",
Long: `Exports the SBOM of a (previously built) package.

When used with --with-dependencies, it exports SBOMs for the package and all its dependencies
to the specified output directory.

If no package is specified, the workspace's default target is used.`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
// Get the package
_, pkg, _, _ := getTarget(args, false)
if pkg == nil {
log.Fatal("sbom export requires a package or a default target in the workspace")
}

// Get build options and cache
_, localCache := getBuildOpts(cmd)

// Get output format and file
format, _ := cmd.Flags().GetString("format")
outputFile, _ := cmd.Flags().GetString("output")
withDependencies, _ := cmd.Flags().GetBool("with-dependencies")
outputDir, _ := cmd.Flags().GetString("output-dir")

// Validate format using the utility function
formatValid, validFormats := leeway.ValidateSBOMFormat(format)
if !formatValid {
log.Fatalf("Unsupported format: %s. Supported formats are: %s", format, strings.Join(validFormats, ", "))
}

// Validate flags for dependency export
if withDependencies {
if outputDir == "" {
log.Fatal("--output-dir is required when using --with-dependencies")
}
if outputFile != "" {
log.Fatal("--output and --output-dir cannot be used together")
}
}

var allpkg []*leeway.Package
allpkg = append(allpkg, pkg)

if withDependencies {
// Get all dependencies
deps := pkg.GetTransitiveDependencies()
log.Infof("Exporting SBOMs for %s and %d dependencies to %s", pkg.FullName(), len(deps), outputDir)

allpkg = append(allpkg, deps...)
}

for _, p := range allpkg {
var outputPath string
if outputFile == "" {
safeFilename := p.FilesystemSafeName()
outputPath = filepath.Join(outputDir, safeFilename+leeway.GetSBOMFileExtension(format))
} else {
outputPath = outputFile
}
exportSBOM(p, localCache, outputPath, format)
}
},
}

func init() {
sbomExportCmd.Flags().String("format", "cyclonedx", "SBOM format to export (cyclonedx, spdx, syft)")
sbomExportCmd.Flags().StringP("output", "o", "", "Output file (defaults to stdout)")
sbomExportCmd.Flags().Bool("with-dependencies", false, "Export SBOMs for the package and all its dependencies")
sbomExportCmd.Flags().String("output-dir", "", "Output directory for exporting multiple SBOMs (required with --with-dependencies)")

sbomCmd.AddCommand(sbomExportCmd)
addBuildFlags(sbomExportCmd)
}

// exportSBOM extracts and writes an SBOM from a package's cached archive.
// It retrieves the package from the cache, creates the output file if needed,
// and extracts the SBOM in the specified format. If outputFile is empty,
// the SBOM is written to stdout.
func exportSBOM(pkg *leeway.Package, localCache cache.LocalCache, outputFile string, format string) {
pkgFN := GetPackagePath(pkg, localCache)

var output io.Writer = os.Stdout

// Create directory if it doesn't exist
if dir := filepath.Dir(outputFile); dir != "" {
if err := os.MkdirAll(dir, 0755); err != nil {
log.WithError(err).Fatalf("cannot create output directory %s", dir)
}
}

file, err := os.Create(outputFile)
if err != nil {
log.WithError(err).Fatalf("cannot create output file %s", outputFile)
}
defer file.Close()
output = file

// Extract and output the SBOM
err = leeway.AccessSBOMInCachedArchive(pkgFN, format, func(sbomReader io.Reader) error {
log.Infof("Exporting SBOM in %s format", format)
_, err := io.Copy(output, sbomReader)
return err
})

if err != nil {
if err == leeway.ErrNoSBOMFile {
log.Fatalf("no SBOM file found in package %s", pkg.FullName())
}
log.WithError(err).Fatal("cannot extract SBOM")
}

if outputFile != "" {
log.Infof("SBOM exported to %s", outputFile)
}
}

// GetPackagePath retrieves the filesystem path to a package's cached archive.
// It first checks the local cache, and if not found, attempts to download
// the package from the remote cache. This function verifies that SBOM is enabled
// in the workspace settings and returns the path to the package archive.
// If the package cannot be found in either cache, it exits with a fatal error.
func GetPackagePath(pkg *leeway.Package, localCache cache.LocalCache) (packagePath string) {
// Check if SBOM is enabled in workspace settings
if !pkg.C.W.SBOM.Enabled {
log.Fatal("SBOM export/scan requires sbom.enabled=true in workspace settings")
}

if log.IsLevelEnabled(log.DebugLevel) {
v, err := pkg.Version()
if err != nil {
log.WithError(err).Fatal("error getting version")
}
log.Debugf("Exporting SBOM of package %s (version %s)", pkg.FullName(), v)
}

// Get package location in local cache
pkgFN, ok := localCache.Location(pkg)
if !ok {
// Package not found in local cache, check if it's in the remote cache
log.Debugf("Package %s not found in local cache, checking remote cache", pkg.FullName())

remoteCache := getRemoteCache()
remoteCache = &pullOnlyRemoteCache{C: remoteCache}

// Convert to cache.Package interface
pkgsToCheck := []cache.Package{pkg}

if log.IsLevelEnabled(log.DebugLevel) {
v, err := pkgsToCheck[0].Version()
if err != nil {
log.WithError(err).Fatal("error getting version")
}
log.Debugf("Checking remote of package %s (version %s)", pkgsToCheck[0].FullName(), v)
}

// Check if the package exists in the remote cache
existingPkgs, err := remoteCache.ExistingPackages(context.Background(), pkgsToCheck)
if err != nil {
log.WithError(err).Warnf("Failed to check if package %s exists in remote cache", pkg.FullName())
log.Fatalf("%s is not built", pkg.FullName())
} else {
_, existsInRemote := existingPkgs[pkg]
if existsInRemote {
log.Infof("Package %s found in remote cache, downloading...", pkg.FullName())

// Download the package from the remote cache
err := remoteCache.Download(context.Background(), localCache, pkgsToCheck)
if err != nil {
log.WithError(err).Fatalf("Failed to download package %s from remote cache", pkg.FullName())
}

// Check if the download was successful
pkgFN, ok = localCache.Location(pkg)
if !ok {
log.Fatalf("Failed to download package %s from remote cache", pkg.FullName())
}

log.Infof("Successfully downloaded package %s from remote cache", pkg.FullName())
} else {
log.Fatalf("%s is not built", pkg.FullName())
}
}
}
return pkgFN
}
Loading
Loading