Skip to content

Commit 3facf99

Browse files
leodidoona-agent
andcommitted
feat(slsa): implement SLSA L3 auto-enablement with precedence hierarchy
SUMMARY When provenance.slsa: true is configured in WORKSPACE.yaml, automatically enable all SLSA L3 runtime features to ensure build integrity and supply chain security. FEATURES Automatically enables when provenance.slsa: true: - Cache verification (LEEWAY_SLSA_CACHE_VERIFICATION=true) - In-flight checksums (LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS=true) - Docker export mode (LEEWAY_DOCKER_EXPORT_TO_CACHE=true) - Source URI (LEEWAY_SLSA_SOURCE_URI from Git origin) PRECEDENCE HIERARCHY Implements 5-layer precedence for Docker export mode: 1. CLI flag (--docker-export-to-cache) - highest priority 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) BREAKING CHANGES - ExportToCache field changed from bool to *bool in DockerPkgConfig - Enables pointer-based detection: nil (not set) vs false (explicit) - Allows package-level overrides of workspace SLSA defaults ARTIFACT DISTINGUISHABILITY Artifacts built with SLSA enabled include "provenance: version=3 slsa" in their manifest, changing the version hash. This ensures SLSA L3 artifacts are automatically distinguishable from legacy artifacts in the cache, preventing collision and enabling proper verification. BACKWARD COMPATIBILITY Fully backward compatible: - Existing workspaces without provenance.slsa continue working unchanged - Explicit environment variables take precedence over auto-set values - Package-level exportToCache config still respected - All existing tests updated and passing DOCUMENTATION - Fixed SLSA version reference (v0.1 → v0.2) - Added "Automatic SLSA L3 Feature Activation" section - Added configuration precedence documentation - Added 4 usage scenarios with examples - Added troubleshooting guidance TESTING - 16 new test scenarios covering all precedence layers - TestDockerExport_PrecedenceHierarchy: 11 scenarios - TestWorkspace_ApplySLSADefaults: 5 scenarios - All existing tests updated for pointer-based config - Smoke test verified in real workspace Co-authored-by: Ona <[email protected]>
1 parent a9124b3 commit 3facf99

File tree

7 files changed

+631
-42
lines changed

7 files changed

+631
-42
lines changed

README.md

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ variables have an effect on leeway:
517517
- `LEEWAY_EXPERIMENTAL`: Enables exprimental features
518518

519519
# Provenance (SLSA) - EXPERIMENTAL
520-
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**.
520+
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**.
521521

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

529529
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.
530530

531+
## Automatic SLSA L3 Feature Activation
532+
533+
When `provenance.slsa: true` is set, Leeway automatically enables all SLSA L3 runtime features to ensure build integrity and artifact distinguishability:
534+
535+
- ✅ **Cache verification**: Downloads are verified against Sigstore attestations
536+
- ✅ **In-flight checksums**: Build artifacts are checksummed during the build to prevent tampering
537+
- ✅ **Docker export mode**: Docker images go through the cache and signing flow (workspace default)
538+
539+
These features are automatically enabled by setting environment variables:
540+
- `LEEWAY_SLSA_CACHE_VERIFICATION=true`
541+
- `LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS=true`
542+
- `LEEWAY_DOCKER_EXPORT_TO_CACHE=true`
543+
- `LEEWAY_SLSA_SOURCE_URI` (set from Git origin)
544+
545+
### Configuration Precedence
546+
547+
The Docker export mode follows a clear precedence hierarchy (highest to lowest):
548+
549+
1. **CLI flag** - `leeway build --docker-export-to-cache=false`
550+
2. **Explicit environment variable** - Set before workspace loading
551+
3. **Package config** - `exportToCache: false` in BUILD.yaml (Docker packages only)
552+
4. **Workspace default** - Auto-set by `provenance.slsa: true`
553+
5. **Global default** - `false` (legacy behavior)
554+
555+
### Examples
556+
557+
**Scenario 1: SLSA enabled, all Docker packages export by default**
558+
```yaml
559+
# WORKSPACE.yaml
560+
provenance:
561+
enabled: true
562+
slsa: true
563+
564+
# backend/BUILD.yaml
565+
packages:
566+
- name: backend
567+
type: docker
568+
config:
569+
dockerfile: Dockerfile
570+
image:
571+
- registry.example.com/backend:latest
572+
# No exportToCache specified → inherits workspace default (export enabled)
573+
```
574+
575+
**Scenario 2: SLSA enabled, but one package opts out**
576+
```yaml
577+
# WORKSPACE.yaml
578+
provenance:
579+
enabled: true
580+
slsa: true
581+
582+
# backend/BUILD.yaml
583+
packages:
584+
- name: backend
585+
type: docker
586+
config:
587+
dockerfile: Dockerfile
588+
image:
589+
- registry.example.com/backend:latest
590+
exportToCache: false # Explicit opt-out - push directly
591+
```
592+
593+
**Scenario 3: Force export OFF for testing**
594+
```bash
595+
# Set before running leeway - overrides package config and workspace default
596+
export LEEWAY_DOCKER_EXPORT_TO_CACHE=false
597+
leeway build :backend
598+
# User override wins over package config and workspace default
599+
```
600+
601+
**Scenario 4: CLI flag for one-off override**
602+
```bash
603+
# Override everything for this build only
604+
leeway build :backend --docker-export-to-cache=true
605+
# CLI flag has highest priority
606+
```
607+
608+
### Artifact Distinguishability
609+
610+
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.
611+
612+
```yaml
613+
# With SLSA enabled:
614+
buildProcessVersion: 1
615+
provenance: version=3 slsa # ← N.B.
616+
sbom: version=1
617+
environment: f92ccd7479251ffa...
618+
619+
# Without SLSA:
620+
buildProcessVersion: 1
621+
sbom: version=1
622+
environment: f92ccd7479251ffa...
623+
```
624+
531625
## Dirty vs clean Git working copy
532626
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)).
533627

@@ -560,6 +654,49 @@ leeway provenance export --decode file://some-bundle.jsonl
560654
- provenance is part of the leeway package version, i.e. when you enable provenance that will naturally invalidate previously built packages.
561655
- 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.
562656

657+
## Troubleshooting SLSA L3 Features
658+
659+
**Features not activating?**
660+
661+
Check if SLSA is properly enabled in your workspace:
662+
```bash
663+
# Verify workspace config
664+
cat WORKSPACE.yaml | grep -A2 provenance
665+
666+
# Check environment variables are set
667+
env | grep LEEWAY_
668+
669+
# Enable verbose logging to see activation
670+
leeway build -v :package 2>&1 | grep "SLSA\|provenance"
671+
```
672+
673+
**Docker export not working as expected?**
674+
675+
Verify the precedence hierarchy:
676+
```bash
677+
# Check if CLI flag is set
678+
leeway build :package --docker-export-to-cache=true -v
679+
680+
# Check if environment variable is set
681+
echo $LEEWAY_DOCKER_EXPORT_TO_CACHE
682+
683+
# Check package config in BUILD.yaml
684+
grep -A5 "exportToCache" BUILD.yaml
685+
```
686+
687+
**Environment variables set before workspace loading?**
688+
689+
User environment variables must be set BEFORE running leeway:
690+
```bash
691+
# Correct: set before running leeway
692+
export LEEWAY_DOCKER_EXPORT_TO_CACHE=false
693+
leeway build :package
694+
695+
# Incorrect: too late, workspace already loaded
696+
leeway build :package
697+
export LEEWAY_DOCKER_EXPORT_TO_CACHE=false
698+
```
699+
563700
# Debugging
564701
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`.
565702

cmd/build.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,18 @@ func addBuildFlags(cmd *cobra.Command) {
211211
}
212212

213213
func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) {
214+
// Track if user explicitly set LEEWAY_DOCKER_EXPORT_TO_CACHE before workspace loading.
215+
// This allows us to distinguish:
216+
// - User set explicitly: High priority (overrides package config)
217+
// - Workspace auto-set: Low priority (package config can override)
218+
dockerExportEnvSet := false
219+
dockerExportEnvValue := false
220+
if envVal := os.Getenv("LEEWAY_DOCKER_EXPORT_TO_CACHE"); envVal != "" {
221+
dockerExportEnvSet = true
222+
dockerExportEnvValue = (envVal == "true" || envVal == "1")
223+
log.WithField("value", envVal).Debug("User explicitly set LEEWAY_DOCKER_EXPORT_TO_CACHE before workspace loading")
224+
}
225+
214226
cm, _ := cmd.Flags().GetString("cache")
215227
log.WithField("cacheMode", cm).Debug("configuring caches")
216228
cacheLevel := leeway.CacheLevel(cm)
@@ -348,24 +360,17 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) {
348360
inFlightChecksums = inFlightChecksumsDefault
349361
}
350362

351-
// Get docker export to cache setting with proper precedence:
352-
// 1. CLI flag (if explicitly set)
353-
// 2. Environment variable (if set)
354-
// 3. Package config (default)
363+
// Get docker export to cache setting - CLI flag takes highest precedence
355364
dockerExportToCache := false
356365
dockerExportSet := false
357366

358367
if cmd.Flags().Changed("docker-export-to-cache") {
359-
// Flag was explicitly set by user - this takes precedence
368+
// Flag was explicitly set by user - this takes precedence over everything
360369
dockerExportToCache, err = cmd.Flags().GetBool("docker-export-to-cache")
361370
if err != nil {
362371
log.Fatal(err)
363372
}
364373
dockerExportSet = true
365-
} else if envVal := os.Getenv("LEEWAY_DOCKER_EXPORT_TO_CACHE"); envVal != "" {
366-
// Env var set (flag not set) - env var takes precedence over package config
367-
dockerExportToCache = envVal == "true" || envVal == "1"
368-
dockerExportSet = true
369374
}
370375

371376
return []leeway.BuildOption{
@@ -384,6 +389,7 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) {
384389
leeway.WithDisableCoverage(disableCoverage),
385390
leeway.WithInFlightChecksums(inFlightChecksums),
386391
leeway.WithDockerExportToCache(dockerExportToCache, dockerExportSet),
392+
leeway.WithDockerExportEnv(dockerExportEnvValue, dockerExportEnvSet),
387393
}, localCache
388394
}
389395

pkg/leeway/build.go

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,13 @@ type buildOptions struct {
393393
DisableCoverage bool
394394
InFlightChecksums bool
395395
DockerExportToCache bool
396-
DockerExportSet bool // Track if explicitly set via CLI flag or env var
396+
DockerExportSet bool // Track if explicitly set via CLI flag
397+
398+
// Docker export control - User environment variable
399+
// These track if the env var was set by the USER (before workspace loading)
400+
// vs. auto-set by workspace. This enables proper precedence.
401+
DockerExportEnvValue bool // Value from explicit user env var
402+
DockerExportEnvSet bool // Whether user explicitly set env var (before workspace)
397403

398404
context *buildContext
399405
}
@@ -526,6 +532,15 @@ func WithDockerExportToCache(exportToCache bool, explicitlySet bool) BuildOption
526532
}
527533
}
528534

535+
// WithDockerExportEnv configures whether user explicitly set DOCKER_EXPORT_TO_CACHE env var
536+
func WithDockerExportEnv(value, isSet bool) BuildOption {
537+
return func(opts *buildOptions) error {
538+
opts.DockerExportEnvValue = value
539+
opts.DockerExportEnvSet = isSet
540+
return nil
541+
}
542+
}
543+
529544
func withBuildContext(ctx *buildContext) BuildOption {
530545
return func(opts *buildOptions) error {
531546
opts.context = ctx
@@ -1701,18 +1716,72 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
17011716
return nil, err
17021717
}
17031718

1704-
// Apply global export mode setting from build options (CLI flag or env var)
1705-
// This overrides the package-level configuration in BOTH directions
1719+
// Determine final exportToCache value with proper precedence
1720+
//
1721+
// Precedence (highest to lowest):
1722+
// 1. CLI flag (--docker-export-to-cache)
1723+
// 2. User environment variable (set before workspace loading)
1724+
// 3. Package config (exportToCache in BUILD.yaml)
1725+
// 4. Workspace default (auto-set by provenance.slsa: true)
1726+
// 5. Global default (false - legacy behavior)
1727+
1728+
var exportToCache bool
1729+
var source string // Track decision source for logging
1730+
1731+
// Layer 5 & 4: Start with workspace default
1732+
// At this point, workspace loading already auto-set LEEWAY_DOCKER_EXPORT_TO_CACHE
1733+
// if provenance.slsa: true
1734+
envExport := os.Getenv("LEEWAY_DOCKER_EXPORT_TO_CACHE")
1735+
if envExport == "true" || envExport == "1" {
1736+
exportToCache = true
1737+
source = "workspace_default"
1738+
} else {
1739+
exportToCache = false
1740+
source = "global_default"
1741+
}
1742+
1743+
// Layer 3: Package config (if explicitly set)
1744+
// This OVERRIDES workspace default
1745+
if cfg.ExportToCache != nil {
1746+
exportToCache = *cfg.ExportToCache
1747+
source = "package_config"
1748+
log.WithFields(log.Fields{
1749+
"package": p.FullName(),
1750+
"value": exportToCache,
1751+
}).Debug("Using explicit package exportToCache config")
1752+
}
1753+
1754+
// Layer 2: Explicit user environment variable
1755+
// This OVERRIDES package config and workspace default
1756+
if buildctx.DockerExportEnvSet {
1757+
exportToCache = buildctx.DockerExportEnvValue
1758+
source = "user_env_var"
1759+
log.WithFields(log.Fields{
1760+
"package": p.FullName(),
1761+
"value": exportToCache,
1762+
}).Info("Docker export overridden by explicit user environment variable")
1763+
}
1764+
1765+
// Layer 1: CLI flag (highest priority)
1766+
// This OVERRIDES everything
17061767
if buildctx.DockerExportSet {
1707-
if cfg.ExportToCache != buildctx.DockerExportToCache {
1708-
log.WithField("package", p.FullName()).
1709-
WithField("package_config", cfg.ExportToCache).
1710-
WithField("override_value", buildctx.DockerExportToCache).
1711-
Info("Docker export mode overridden via CLI flag or environment variable")
1712-
}
1713-
cfg.ExportToCache = buildctx.DockerExportToCache
1768+
exportToCache = buildctx.DockerExportToCache
1769+
source = "cli_flag"
1770+
log.WithFields(log.Fields{
1771+
"package": p.FullName(),
1772+
"value": exportToCache,
1773+
}).Info("Docker export overridden by CLI flag")
17141774
}
1715-
// else: respect package config (no override)
1775+
1776+
// Log final decision at debug level
1777+
log.WithFields(log.Fields{
1778+
"package": p.FullName(),
1779+
"value": exportToCache,
1780+
"source": source,
1781+
}).Debug("Docker export mode determined")
1782+
1783+
// Update cfg for use in the rest of the function
1784+
cfg.ExportToCache = &exportToCache
17161785

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

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

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

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

0 commit comments

Comments
 (0)