Skip to content
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
139 changes: 138 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ variables have an effect on leeway:
- `LEEWAY_EXPERIMENTAL`: Enables exprimental features

# Provenance (SLSA) - EXPERIMENTAL
leeway can produce provenance information as part of a build. At the moment only [SLSA](https://slsa.dev/spec/v0.1/) is supported. This supoprt is **experimental**.
leeway can produce provenance information as part of a build. At the moment only [SLSA Provenance v0.2](https://slsa.dev/provenance/v0.2) is supported. This support is **experimental**.

Provenance generation is enabled in the `WORKSPACE.YAML` file.
```YAML
Expand All @@ -528,6 +528,100 @@ provenance:

Once enabled, all packages carry an [attestation bundle](https://github.com/in-toto/attestation/blob/main/spec/bundle.md) which is compliant to the [SLSA v0.2 spec](https://slsa.dev/provenance/v0.2) in their cached archive. The bundle is complete, i.e. not only contains the attestation for the package build, but also those of its dependencies.

## Automatic SLSA L3 Feature Activation

When `provenance.slsa: true` is set, Leeway automatically enables all SLSA L3 runtime features to ensure build integrity and artifact distinguishability:

- ✅ **Cache verification**: Downloads are verified against Sigstore attestations
- ✅ **In-flight checksums**: Build artifacts are checksummed during the build to prevent tampering
- ✅ **Docker export mode**: Docker images go through the cache and signing flow (workspace default)

These features are automatically enabled by setting environment variables:
- `LEEWAY_SLSA_CACHE_VERIFICATION=true`
- `LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS=true`
- `LEEWAY_DOCKER_EXPORT_TO_CACHE=true`
- `LEEWAY_SLSA_SOURCE_URI` (set from Git origin)

### Configuration Precedence

The Docker export mode follows a clear precedence hierarchy (highest to lowest):

1. **CLI flag** - `leeway build --docker-export-to-cache=false`
2. **Explicit environment variable** - Set before workspace loading
3. **Package config** - `exportToCache: false` in BUILD.yaml (Docker packages only)
4. **Workspace default** - Auto-set by `provenance.slsa: true`
5. **Global default** - `false` (legacy behavior)

### Examples

**Scenario 1: SLSA enabled, all Docker packages export by default**
```yaml
# WORKSPACE.yaml
provenance:
enabled: true
slsa: true

# backend/BUILD.yaml
packages:
- name: backend
type: docker
config:
dockerfile: Dockerfile
image:
- registry.example.com/backend:latest
# No exportToCache specified → inherits workspace default (export enabled)
```

**Scenario 2: SLSA enabled, but one package opts out**
```yaml
# WORKSPACE.yaml
provenance:
enabled: true
slsa: true

# backend/BUILD.yaml
packages:
- name: backend
type: docker
config:
dockerfile: Dockerfile
image:
- registry.example.com/backend:latest
exportToCache: false # Explicit opt-out - push directly
```

**Scenario 3: Force export OFF for testing**
```bash
# Set before running leeway - overrides package config and workspace default
export LEEWAY_DOCKER_EXPORT_TO_CACHE=false
leeway build :backend
# User override wins over package config and workspace default
```

**Scenario 4: CLI flag for one-off override**
```bash
# Override everything for this build only
leeway build :backend --docker-export-to-cache=true
# CLI flag has highest priority
```

### Artifact Distinguishability

When SLSA provenance is enabled, the package manifest includes `provenance: version=3 slsa`, which changes the artifact version hash. This ensures artifacts built with SLSA L3 features are automatically distinguishable from legacy artifacts in the cache.

```yaml
# With SLSA enabled:
buildProcessVersion: 1
provenance: version=3 slsa # ← N.B.
sbom: version=1
environment: f92ccd7479251ffa...

# Without SLSA:
buildProcessVersion: 1
sbom: version=1
environment: f92ccd7479251ffa...
```

## Dirty vs clean Git working copy
When building from a clean Git working copy, leeway will use a reference to the Git remote origin as [material](https://github.com/in-toto/in-toto-golang/blob/26b6a96f8a7537f27b7483e19dd68e022b179ea6/in_toto/model.go#L360) (part of the SLSA [link](https://github.com/slsa-framework/slsa/blob/main/controls/attestations.md)).

Expand Down Expand Up @@ -560,6 +654,49 @@ leeway provenance export --decode file://some-bundle.jsonl
- provenance is part of the leeway package version, i.e. when you enable provenance that will naturally invalidate previously built packages.
- if attestation bundle entries grow too large this can break the build process. Use `LEEWAY_MAX_PROVENANCE_BUNDLE_SIZE` to set the buffer size in bytes. This defaults to 2MiB. The larger this buffer is, the larger bundle entries can be used, but the more memory the build process will consume. If you exceed the default, inspect the bundles first (especially the one that fails to load) and see if the produced `subjects` make sense.

## Troubleshooting SLSA L3 Features

**Features not activating?**

Check if SLSA is properly enabled in your workspace:
```bash
# Verify workspace config
cat WORKSPACE.yaml | grep -A2 provenance

# Check environment variables are set
env | grep LEEWAY_

# Enable verbose logging to see activation
leeway build -v :package 2>&1 | grep "SLSA\|provenance"
```

**Docker export not working as expected?**

Verify the precedence hierarchy:
```bash
# Check if CLI flag is set
leeway build :package --docker-export-to-cache=true -v

# Check if environment variable is set
echo $LEEWAY_DOCKER_EXPORT_TO_CACHE

# Check package config in BUILD.yaml
grep -A5 "exportToCache" BUILD.yaml
```

**Environment variables set before workspace loading?**

User environment variables must be set BEFORE running leeway:
```bash
# Correct: set before running leeway
export LEEWAY_DOCKER_EXPORT_TO_CACHE=false
leeway build :package

# Incorrect: too late, workspace already loaded
leeway build :package
export LEEWAY_DOCKER_EXPORT_TO_CACHE=false
```

# Debugging
When a build fails, or to get an idea of how leeway assembles dependencies, run your build with `leeway build -c local` (local cache only) and inspect your `$LEEWAY_BUILD_DIR`.

Expand Down
28 changes: 17 additions & 11 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ func init() {
}

func addBuildFlags(cmd *cobra.Command) {
cacheDefault := os.Getenv("LEEWAY_DEFAULT_CACHE_LEVEL")
cacheDefault := os.Getenv(EnvvarDefaultCacheLevel)
if cacheDefault == "" {
cacheDefault = "remote"
}
Expand All @@ -204,13 +204,25 @@ func addBuildFlags(cmd *cobra.Command) {
cmd.Flags().String("slsa-source-uri", "", "Expected source URI for SLSA verification (required when verification enabled)")
cmd.Flags().Bool("in-flight-checksums", false, "Enable checksumming of cache artifacts to prevent TOCTU attacks")
cmd.Flags().String("report", "", "Generate a HTML report after the build has finished. (e.g. --report myreport.html)")
cmd.Flags().String("report-segment", os.Getenv("LEEWAY_SEGMENT_KEY"), "Report build events to segment using the segment key (defaults to $LEEWAY_SEGMENT_KEY)")
cmd.Flags().String("report-segment", os.Getenv(EnvvarSegmentKey), "Report build events to segment using the segment key (defaults to $LEEWAY_SEGMENT_KEY)")
cmd.Flags().Bool("report-github", os.Getenv("GITHUB_OUTPUT") != "", "Report package build success/failure to GitHub Actions using the GITHUB_OUTPUT environment variable")
cmd.Flags().Bool("fixed-build-dir", true, "Use a fixed build directory for each package, instead of based on the package version, to better utilize caches based on absolute paths (defaults to true)")
cmd.Flags().Bool("docker-export-to-cache", false, "Export Docker images to cache instead of pushing directly (enables SLSA L3 compliance)")
}

func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) {
// Track if user explicitly set LEEWAY_DOCKER_EXPORT_TO_CACHE before workspace loading.
// This allows us to distinguish:
// - User set explicitly: High priority (overrides package config)
// - Workspace auto-set: Low priority (package config can override)
dockerExportEnvSet := false
dockerExportEnvValue := false
if envVal := os.Getenv(EnvvarDockerExportToCache); envVal != "" {
dockerExportEnvSet = true
dockerExportEnvValue = (envVal == "true" || envVal == "1")
log.WithField("value", envVal).Debug("User explicitly set LEEWAY_DOCKER_EXPORT_TO_CACHE before workspace loading")
}

cm, _ := cmd.Flags().GetString("cache")
log.WithField("cacheMode", cm).Debug("configuring caches")
cacheLevel := leeway.CacheLevel(cm)
Expand Down Expand Up @@ -348,24 +360,17 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) {
inFlightChecksums = inFlightChecksumsDefault
}

// Get docker export to cache setting with proper precedence:
// 1. CLI flag (if explicitly set)
// 2. Environment variable (if set)
// 3. Package config (default)
// Get docker export to cache setting - CLI flag takes highest precedence
dockerExportToCache := false
dockerExportSet := false

if cmd.Flags().Changed("docker-export-to-cache") {
// Flag was explicitly set by user - this takes precedence
// Flag was explicitly set by user - this takes precedence over everything
dockerExportToCache, err = cmd.Flags().GetBool("docker-export-to-cache")
if err != nil {
log.Fatal(err)
}
dockerExportSet = true
} else if envVal := os.Getenv("LEEWAY_DOCKER_EXPORT_TO_CACHE"); envVal != "" {
// Env var set (flag not set) - env var takes precedence over package config
dockerExportToCache = envVal == "true" || envVal == "1"
dockerExportSet = true
}

return []leeway.BuildOption{
Expand All @@ -384,6 +389,7 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) {
leeway.WithDisableCoverage(disableCoverage),
leeway.WithInFlightChecksums(inFlightChecksums),
leeway.WithDockerExportToCache(dockerExportToCache, dockerExportSet),
leeway.WithDockerExportEnv(dockerExportEnvValue, dockerExportEnvSet),
}, localCache
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func TestInFlightChecksumsEnvironmentVariable(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Set environment variable using t.Setenv for proper cleanup
if tt.envValue != "" {
t.Setenv("LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS", tt.envValue)
t.Setenv(EnvvarEnableInFlightChecksums, tt.envValue)
}

// Create test command
Expand All @@ -136,7 +136,7 @@ func TestInFlightChecksumsEnvironmentVariable(t *testing.T) {
}

// Test the actual logic from getBuildOpts
inFlightChecksumsDefault := os.Getenv("LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS") == "true"
inFlightChecksumsDefault := os.Getenv(EnvvarEnableInFlightChecksums) == "true"
inFlightChecksums, err := cmd.Flags().GetBool("in-flight-checksums")
if err != nil {
t.Fatalf("failed to get flag: %v", err)
Expand Down
24 changes: 21 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,24 @@ const (

// EnvvarEnableInFlightChecksums enables in-flight checksumming of cache artifacts
EnvvarEnableInFlightChecksums = "LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS"

// EnvvarDockerExportToCache controls whether Docker images are exported to cache instead of pushed directly
EnvvarDockerExportToCache = "LEEWAY_DOCKER_EXPORT_TO_CACHE"

// EnvvarDefaultCacheLevel sets the default cache level
EnvvarDefaultCacheLevel = "LEEWAY_DEFAULT_CACHE_LEVEL"

// EnvvarSegmentKey configures Segment analytics key
EnvvarSegmentKey = "LEEWAY_SEGMENT_KEY"

// EnvvarTrace enables tracing output
EnvvarTrace = "LEEWAY_TRACE"

// EnvvarProvenanceKeypath configures provenance key path
EnvvarProvenanceKeypath = "LEEWAY_PROVENANCE_KEYPATH"

// EnvvarExperimental enables experimental features
EnvvarExperimental = "LEEWAY_EXPERIMENTAL"
)

const (
Expand Down Expand Up @@ -116,7 +134,7 @@ variables have an effect on leeway:
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
tp := os.Getenv("LEEWAY_TRACE")
tp := os.Getenv(EnvvarTrace)
if tp != "" {
f, err := os.OpenFile(tp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
Expand Down Expand Up @@ -166,7 +184,7 @@ func getWorkspace() (leeway.Workspace, error) {
return leeway.Workspace{}, err
}

return leeway.FindWorkspace(workspace, args, variant, os.Getenv("LEEWAY_PROVENANCE_KEYPATH"))
return leeway.FindWorkspace(workspace, args, variant, os.Getenv(EnvvarProvenanceKeypath))
}

func getBuildArgs() (leeway.Arguments, error) {
Expand All @@ -186,7 +204,7 @@ func getBuildArgs() (leeway.Arguments, error) {
}

func addExperimentalCommand(parent, child *cobra.Command) {
if os.Getenv("LEEWAY_EXPERIMENTAL") != "true" {
if os.Getenv(EnvvarExperimental) != "true" {
return
}

Expand Down
Loading
Loading