Skip to content
Open
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
24 changes: 15 additions & 9 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,18 @@ func addBuildFlags(cmd *cobra.Command) {
}

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("LEEWAY_DOCKER_EXPORT_TO_CACHE"); 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
95 changes: 82 additions & 13 deletions pkg/leeway/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,13 @@ type buildOptions struct {
DisableCoverage bool
InFlightChecksums bool
DockerExportToCache bool
DockerExportSet bool // Track if explicitly set via CLI flag or env var
DockerExportSet bool // Track if explicitly set via CLI flag

// Docker export control - User environment variable
// These track if the env var was set by the USER (before workspace loading)
// vs. auto-set by workspace. This enables proper precedence.
DockerExportEnvValue bool // Value from explicit user env var
DockerExportEnvSet bool // Whether user explicitly set env var (before workspace)

context *buildContext
}
Expand Down Expand Up @@ -526,6 +532,15 @@ func WithDockerExportToCache(exportToCache bool, explicitlySet bool) BuildOption
}
}

// WithDockerExportEnv configures whether user explicitly set DOCKER_EXPORT_TO_CACHE env var
func WithDockerExportEnv(value, isSet bool) BuildOption {
return func(opts *buildOptions) error {
opts.DockerExportEnvValue = value
opts.DockerExportEnvSet = isSet
return nil
}
}

func withBuildContext(ctx *buildContext) BuildOption {
return func(opts *buildOptions) error {
opts.context = ctx
Expand Down Expand Up @@ -1701,18 +1716,72 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
return nil, err
}

// Apply global export mode setting from build options (CLI flag or env var)
// This overrides the package-level configuration in BOTH directions
// Determine final exportToCache value with proper precedence
//
// Precedence (highest to lowest):
// 1. CLI flag (--docker-export-to-cache)
// 2. User environment variable (set before workspace loading)
// 3. Package config (exportToCache in BUILD.yaml)
// 4. Workspace default (auto-set by provenance.slsa: true)
// 5. Global default (false - legacy behavior)

var exportToCache bool
var source string // Track decision source for logging

// Layer 5 & 4: Start with workspace default
// At this point, workspace loading already auto-set LEEWAY_DOCKER_EXPORT_TO_CACHE
// if provenance.slsa: true
envExport := os.Getenv("LEEWAY_DOCKER_EXPORT_TO_CACHE")
if envExport == "true" || envExport == "1" {
exportToCache = true
source = "workspace_default"
} else {
exportToCache = false
source = "global_default"
}

// Layer 3: Package config (if explicitly set)
// This OVERRIDES workspace default
if cfg.ExportToCache != nil {
exportToCache = *cfg.ExportToCache
source = "package_config"
log.WithFields(log.Fields{
"package": p.FullName(),
"value": exportToCache,
}).Debug("Using explicit package exportToCache config")
}

// Layer 2: Explicit user environment variable
// This OVERRIDES package config and workspace default
if buildctx.DockerExportEnvSet {
exportToCache = buildctx.DockerExportEnvValue
source = "user_env_var"
log.WithFields(log.Fields{
"package": p.FullName(),
"value": exportToCache,
}).Info("Docker export overridden by explicit user environment variable")
}

// Layer 1: CLI flag (highest priority)
// This OVERRIDES everything
if buildctx.DockerExportSet {
if cfg.ExportToCache != buildctx.DockerExportToCache {
log.WithField("package", p.FullName()).
WithField("package_config", cfg.ExportToCache).
WithField("override_value", buildctx.DockerExportToCache).
Info("Docker export mode overridden via CLI flag or environment variable")
}
cfg.ExportToCache = buildctx.DockerExportToCache
exportToCache = buildctx.DockerExportToCache
source = "cli_flag"
log.WithFields(log.Fields{
"package": p.FullName(),
"value": exportToCache,
}).Info("Docker export overridden by CLI flag")
}
// else: respect package config (no override)

// Log final decision at debug level
log.WithFields(log.Fields{
"package": p.FullName(),
"value": exportToCache,
"source": source,
}).Debug("Docker export mode determined")

// Update cfg for use in the rest of the function
cfg.ExportToCache = &exportToCache

var (
commands = make(map[PackageBuildPhase][][]string)
Expand Down Expand Up @@ -1874,7 +1943,7 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
))

commands[PackageBuildPhasePackage] = pkgcmds
} else if len(cfg.Image) > 0 && !cfg.ExportToCache {
} else if len(cfg.Image) > 0 && (cfg.ExportToCache == nil || !*cfg.ExportToCache) {
// Image push workflow
log.WithField("images", cfg.Image).Debug("configuring image push (legacy behavior)")

Expand Down Expand Up @@ -1931,7 +2000,7 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p

// Add subjects function for provenance generation
res.Subjects = createDockerSubjectsFunction(version, cfg)
} else if len(cfg.Image) > 0 && cfg.ExportToCache {
} else if len(cfg.Image) > 0 && cfg.ExportToCache != nil && *cfg.ExportToCache {
// Export to cache for signing
log.WithField("package", p.FullName()).Debug("Exporting Docker image to cache")

Expand Down
Loading