diff --git a/README.md b/README.md index 700a739..849904f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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)). @@ -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`. diff --git a/cmd/build.go b/cmd/build.go index 226bd35..357a473 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -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) @@ -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{ @@ -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 } diff --git a/pkg/leeway/build.go b/pkg/leeway/build.go index 01d7e4c..4f3cc23 100644 --- a/pkg/leeway/build.go +++ b/pkg/leeway/build.go @@ -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 } @@ -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 @@ -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) @@ -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)") @@ -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") diff --git a/pkg/leeway/build_test.go b/pkg/leeway/build_test.go index ea1edd6..73d6dff 100644 --- a/pkg/leeway/build_test.go +++ b/pkg/leeway/build_test.go @@ -10,6 +10,11 @@ import ( log "github.com/sirupsen/logrus" ) +// Helper function for tests +func boolPtr(b bool) *bool { + return &b +} + const dummyDocker = `#!/bin/bash POSITIONAL_ARGS=() @@ -150,6 +155,185 @@ func TestBuildDockerDeps(t *testing.T) { } } +func TestDockerExport_PrecedenceHierarchy(t *testing.T) { + tests := []struct { + name string + packageConfig *bool // nil = not set, &true = true, &false = false + workspaceEnvSet bool // Simulates workspace auto-set + userEnvSet bool // Simulates user explicit set + userEnvValue bool + cliSet bool + cliValue bool + expectedFinal bool + expectedSource string + }{ + { + name: "No config, no overrides - global default", + packageConfig: nil, + workspaceEnvSet: false, + expectedFinal: false, + expectedSource: "global_default", + }, + { + name: "Workspace SLSA enabled - workspace default", + packageConfig: nil, + workspaceEnvSet: true, // provenance.slsa: true + expectedFinal: true, + expectedSource: "workspace_default", + }, + { + name: "Package explicitly false - overrides workspace", + packageConfig: boolPtr(false), + workspaceEnvSet: true, + expectedFinal: false, + expectedSource: "package_config", + }, + { + name: "Package explicitly true - overrides workspace", + packageConfig: boolPtr(true), + workspaceEnvSet: false, + expectedFinal: true, + expectedSource: "package_config", + }, + { + name: "User env false - overrides package true", + packageConfig: boolPtr(true), + userEnvSet: true, + userEnvValue: false, + expectedFinal: false, + expectedSource: "user_env_var", + }, + { + name: "User env true - overrides package false", + packageConfig: boolPtr(false), + userEnvSet: true, + userEnvValue: true, + expectedFinal: true, + expectedSource: "user_env_var", + }, + { + name: "CLI true - overrides everything (package false, user false)", + packageConfig: boolPtr(false), + userEnvSet: true, + userEnvValue: false, + cliSet: true, + cliValue: true, + expectedFinal: true, + expectedSource: "cli_flag", + }, + { + name: "CLI false - overrides everything (workspace true, package true)", + packageConfig: boolPtr(true), + workspaceEnvSet: true, + cliSet: true, + cliValue: false, + expectedFinal: false, + expectedSource: "cli_flag", + }, + { + name: "Full hierarchy - CLI wins", + packageConfig: boolPtr(true), + workspaceEnvSet: true, + userEnvSet: true, + userEnvValue: true, + cliSet: true, + cliValue: false, + expectedFinal: false, + expectedSource: "cli_flag", + }, + { + name: "User env wins over package and workspace", + packageConfig: boolPtr(true), + workspaceEnvSet: true, + userEnvSet: true, + userEnvValue: false, + cliSet: false, + expectedFinal: false, + expectedSource: "user_env_var", + }, + { + name: "Package wins over workspace", + packageConfig: boolPtr(false), + workspaceEnvSet: true, + userEnvSet: false, + cliSet: false, + expectedFinal: false, + expectedSource: "package_config", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup environment to simulate workspace auto-set + if tt.workspaceEnvSet { + t.Setenv("LEEWAY_DOCKER_EXPORT_TO_CACHE", "true") + } else { + t.Setenv("LEEWAY_DOCKER_EXPORT_TO_CACHE", "") + } + + // Create mock build context + buildctx := &struct { + DockerExportEnvSet bool + DockerExportEnvValue bool + DockerExportSet bool + DockerExportToCache bool + }{ + DockerExportEnvSet: tt.userEnvSet, + DockerExportEnvValue: tt.userEnvValue, + DockerExportSet: tt.cliSet, + DockerExportToCache: tt.cliValue, + } + + // Create config + cfg := struct { + ExportToCache *bool + }{ + ExportToCache: tt.packageConfig, + } + + // Simulate the precedence logic from buildDocker + var exportToCache bool + var source string + + // Layer 5 & 4: Workspace default + 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 cfg.ExportToCache != nil { + exportToCache = *cfg.ExportToCache + source = "package_config" + } + + // Layer 2: User env var + if buildctx.DockerExportEnvSet { + exportToCache = buildctx.DockerExportEnvValue + source = "user_env_var" + } + + // Layer 1: CLI flag + if buildctx.DockerExportSet { + exportToCache = buildctx.DockerExportToCache + source = "cli_flag" + } + + // Verify + if exportToCache != tt.expectedFinal { + t.Errorf("exportToCache = %v, want %v", exportToCache, tt.expectedFinal) + } + if source != tt.expectedSource { + t.Errorf("source = %v, want %v", source, tt.expectedSource) + } + }) + } +} + func TestDockerPkgConfig_ExportToCache(t *testing.T) { tests := []struct { name string @@ -167,7 +351,7 @@ func TestDockerPkgConfig_ExportToCache(t *testing.T) { name: "explicit export to cache", config: leeway.DockerPkgConfig{ Image: []string{"test:latest"}, - ExportToCache: true, + ExportToCache: boolPtr(true), }, expectedExport: true, }, @@ -175,7 +359,7 @@ func TestDockerPkgConfig_ExportToCache(t *testing.T) { name: "explicit push directly", config: leeway.DockerPkgConfig{ Image: []string{"test:latest"}, - ExportToCache: false, + ExportToCache: boolPtr(false), }, expectedExport: false, }, @@ -183,8 +367,12 @@ func TestDockerPkgConfig_ExportToCache(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.config.ExportToCache != tt.expectedExport { - t.Errorf("ExportToCache = %v, want %v", tt.config.ExportToCache, tt.expectedExport) + actualExport := false + if tt.config.ExportToCache != nil { + actualExport = *tt.config.ExportToCache + } + if actualExport != tt.expectedExport { + t.Errorf("ExportToCache = %v, want %v", actualExport, tt.expectedExport) } }) } @@ -230,7 +418,7 @@ func TestBuildDocker_ExportToCache(t *testing.T) { Config: leeway.DockerPkgConfig{ Dockerfile: "Dockerfile", Image: []string{"test:latest"}, - ExportToCache: true, + ExportToCache: boolPtr(true), }, }, }, @@ -245,52 +433,60 @@ func TestBuildDocker_ExportToCache(t *testing.T) { } } + func TestDockerPackage_BuildContextOverride(t *testing.T) { tests := []struct { name string - packageConfigValue bool + packageConfigValue *bool // nil = not set buildContextExportFlag bool buildContextExportSet bool expectedFinal bool }{ { name: "no override - use package config false", - packageConfigValue: false, + packageConfigValue: boolPtr(false), buildContextExportFlag: false, buildContextExportSet: false, expectedFinal: false, }, { name: "no override - use package config true", - packageConfigValue: true, + packageConfigValue: boolPtr(true), buildContextExportFlag: false, buildContextExportSet: false, expectedFinal: true, }, + { + name: "no override - package config not set (nil)", + packageConfigValue: nil, + buildContextExportFlag: false, + buildContextExportSet: false, + expectedFinal: false, // defaults to false + }, { name: "CLI flag enables export (overrides package false)", - packageConfigValue: false, + packageConfigValue: boolPtr(false), buildContextExportFlag: true, buildContextExportSet: true, expectedFinal: true, }, { name: "CLI flag keeps export enabled (package true)", - packageConfigValue: true, + packageConfigValue: boolPtr(true), buildContextExportFlag: true, buildContextExportSet: true, expectedFinal: true, }, { name: "CLI flag disables export (overrides package true) - CRITICAL TEST", - packageConfigValue: true, + packageConfigValue: boolPtr(true), buildContextExportFlag: false, buildContextExportSet: true, expectedFinal: false, }, { name: "CLI flag keeps export disabled (package false)", - packageConfigValue: false, + packageConfigValue: boolPtr(false), buildContextExportFlag: false, buildContextExportSet: true, expectedFinal: false, @@ -303,14 +499,19 @@ func TestDockerPackage_BuildContextOverride(t *testing.T) { ExportToCache: tt.packageConfigValue, } - // Simulate the build context override logic from buildDocker - // This mimics: if buildctx.DockerExportSet { cfg.ExportToCache = buildctx.DockerExportToCache } + // Simulate the simplified build context override logic + // In the new implementation, CLI flag always wins if set if tt.buildContextExportSet { - cfg.ExportToCache = tt.buildContextExportFlag + cfg.ExportToCache = boolPtr(tt.buildContextExportFlag) } - if cfg.ExportToCache != tt.expectedFinal { - t.Errorf("ExportToCache = %v, want %v", cfg.ExportToCache, tt.expectedFinal) + actualFinal := false + if cfg.ExportToCache != nil { + actualFinal = *cfg.ExportToCache + } + + if actualFinal != tt.expectedFinal { + t.Errorf("ExportToCache = %v, want %v", actualFinal, tt.expectedFinal) } }) } @@ -398,3 +599,4 @@ func TestDockerPostProcessing(t *testing.T) { test.Run() } } + diff --git a/pkg/leeway/package.go b/pkg/leeway/package.go index 8fc2b83..0630d29 100644 --- a/pkg/leeway/package.go +++ b/pkg/leeway/package.go @@ -549,8 +549,10 @@ type DockerPkgConfig struct { // ExportToCache controls whether Docker images are exported to cache instead of pushed immediately. // When true, images are saved as .tar files and go through the standard cache flow. - // When false (default), images are pushed directly to registries (legacy behavior). - ExportToCache bool `yaml:"exportToCache,omitempty"` + // When false, images are pushed directly to registries (legacy behavior). + // When nil (not set), inherits from workspace SLSA configuration. + // Using pointer allows distinguishing "not set" from "explicitly false". + ExportToCache *bool `yaml:"exportToCache,omitempty"` } // AdditionalSources returns a list of unresolved sources coming in through this configuration diff --git a/pkg/leeway/workspace.go b/pkg/leeway/workspace.go index 6efb44e..9a4198d 100644 --- a/pkg/leeway/workspace.go +++ b/pkg/leeway/workspace.go @@ -59,6 +59,56 @@ type WorkspaceProvenance struct { key *in_toto.Key `yaml:"-"` } +// ApplySLSADefaults automatically enables SLSA L3 runtime features when +// SLSA provenance is enabled in workspace configuration. +// +// Sets environment variables as defaults (only if not already set): +// - LEEWAY_SLSA_CACHE_VERIFICATION +// - LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS +// - LEEWAY_DOCKER_EXPORT_TO_CACHE +// - LEEWAY_SLSA_SOURCE_URI (from Git origin) +// +// These can be overridden by explicit user env vars or CLI flags. +func (w *Workspace) ApplySLSADefaults() { + if !w.Provenance.Enabled || !w.Provenance.SLSA { + return + } + + log.Info("SLSA provenance enabled - activating SLSA L3 runtime features") + + // Auto-enable cache verification (global feature) + if setEnvDefault("LEEWAY_SLSA_CACHE_VERIFICATION", "true") { + log.Debug("Auto-enabled: LEEWAY_SLSA_CACHE_VERIFICATION=true") + } + + // Auto-enable in-flight checksumming (global feature) + if setEnvDefault("LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS", "true") { + log.Debug("Auto-enabled: LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS=true") + } + + // Auto-enable Docker export mode (workspace default, packages can override) + if setEnvDefault("LEEWAY_DOCKER_EXPORT_TO_CACHE", "true") { + log.Debug("Auto-enabled: LEEWAY_DOCKER_EXPORT_TO_CACHE=true (package config can override)") + } + + // Auto-set source URI from Git origin + if w.Git.Origin != "" { + if setEnvDefault("LEEWAY_SLSA_SOURCE_URI", w.Git.Origin) { + log.WithField("source_uri", w.Git.Origin).Debug("Auto-set SLSA source URI from Git origin") + } + } +} + +// setEnvDefault sets an environment variable to the given value if not already set. +// Returns true if the value was set, false if it was already present. +func setEnvDefault(key, value string) bool { + if os.Getenv(key) == "" { + os.Setenv(key, value) + return true + } + return false +} + func DiscoverWorkspaceRoot() (string, error) { wd, err := os.Getwd() if err != nil { @@ -332,6 +382,9 @@ func loadWorkspace(ctx context.Context, path string, args Arguments, variant str workspace.Git = *gitnfo } + // Apply SLSA defaults after workspace is fully loaded and Git info is available + workspace.ApplySLSADefaults() + // now that we have all components/packages, we can link things if opts != nil && opts.PrelinkModifier != nil { opts.PrelinkModifier(workspace.Packages) diff --git a/pkg/leeway/workspace_test.go b/pkg/leeway/workspace_test.go index ee4bee3..1f9efbf 100644 --- a/pkg/leeway/workspace_test.go +++ b/pkg/leeway/workspace_test.go @@ -312,3 +312,123 @@ func TestPackageDefinition(t *testing.T) { }) } } + +func TestWorkspace_ApplySLSADefaults(t *testing.T) { + tests := []struct { + name string + provenanceEnabled bool + provenanceSLSA bool + gitOrigin string + existingEnvVars map[string]string + expectedEnvVars map[string]string + }{ + { + name: "SLSA enabled - sets all defaults", + provenanceEnabled: true, + provenanceSLSA: true, + gitOrigin: "github.com/gitpod-io/leeway", + existingEnvVars: map[string]string{}, + expectedEnvVars: map[string]string{ + "LEEWAY_SLSA_CACHE_VERIFICATION": "true", + "LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS": "true", + "LEEWAY_DOCKER_EXPORT_TO_CACHE": "true", + "LEEWAY_SLSA_SOURCE_URI": "github.com/gitpod-io/leeway", + }, + }, + { + name: "SLSA disabled - no defaults set", + provenanceEnabled: true, + provenanceSLSA: false, + existingEnvVars: map[string]string{}, + expectedEnvVars: map[string]string{ + "LEEWAY_SLSA_CACHE_VERIFICATION": "", + "LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS": "", + "LEEWAY_DOCKER_EXPORT_TO_CACHE": "", + "LEEWAY_SLSA_SOURCE_URI": "", + }, + }, + { + name: "Provenance disabled - no defaults set", + provenanceEnabled: false, + provenanceSLSA: true, + existingEnvVars: map[string]string{}, + expectedEnvVars: map[string]string{ + "LEEWAY_SLSA_CACHE_VERIFICATION": "", + "LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS": "", + "LEEWAY_DOCKER_EXPORT_TO_CACHE": "", + "LEEWAY_SLSA_SOURCE_URI": "", + }, + }, + { + name: "Existing env vars - respects user overrides", + provenanceEnabled: true, + provenanceSLSA: true, + gitOrigin: "github.com/gitpod-io/leeway", + existingEnvVars: map[string]string{ + "LEEWAY_SLSA_CACHE_VERIFICATION": "false", + "LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS": "false", + }, + expectedEnvVars: map[string]string{ + "LEEWAY_SLSA_CACHE_VERIFICATION": "false", // Not overridden + "LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS": "false", // Not overridden + "LEEWAY_DOCKER_EXPORT_TO_CACHE": "true", // Set (wasn't present) + "LEEWAY_SLSA_SOURCE_URI": "github.com/gitpod-io/leeway", + }, + }, + { + name: "SLSA enabled without Git origin", + provenanceEnabled: true, + provenanceSLSA: true, + gitOrigin: "", + existingEnvVars: map[string]string{}, + expectedEnvVars: map[string]string{ + "LEEWAY_SLSA_CACHE_VERIFICATION": "true", + "LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS": "true", + "LEEWAY_DOCKER_EXPORT_TO_CACHE": "true", + "LEEWAY_SLSA_SOURCE_URI": "", // Not set without Git origin + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear environment variables for clean test + envVarsToCheck := []string{ + "LEEWAY_SLSA_CACHE_VERIFICATION", + "LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS", + "LEEWAY_DOCKER_EXPORT_TO_CACHE", + "LEEWAY_SLSA_SOURCE_URI", + } + for _, key := range envVarsToCheck { + t.Setenv(key, "") + } + + // Set existing env vars for this test + for key, val := range tt.existingEnvVars { + t.Setenv(key, val) + } + + // Create test workspace + ws := &leeway.Workspace{ + Provenance: leeway.WorkspaceProvenance{ + Enabled: tt.provenanceEnabled, + SLSA: tt.provenanceSLSA, + }, + Git: leeway.GitInfo{ + Origin: tt.gitOrigin, + }, + } + + // Apply defaults + ws.ApplySLSADefaults() + + // Verify environment variables + for key, expected := range tt.expectedEnvVars { + actual := os.Getenv(key) + if actual != expected { + t.Errorf("%s: expected %q, got %q", key, expected, actual) + } + } + }) + } +}