diff --git a/README.md b/README.md index 849904f..20c776d 100644 --- a/README.md +++ b/README.md @@ -533,15 +533,41 @@ Once enabled, all packages carry an [attestation bundle](https://github.com/in-t 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 +- ✅ **Require attestation**: Missing/invalid attestations trigger local rebuilds (strict mode) - ✅ **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_SLSA_REQUIRE_ATTESTATION=true` - `LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS=true` - `LEEWAY_DOCKER_EXPORT_TO_CACHE=true` - `LEEWAY_SLSA_SOURCE_URI` (set from Git origin) +### SLSA Cache Verification Modes + +When cache verification is enabled, Leeway can operate in two modes: + +**Permissive Mode** (`LEEWAY_SLSA_REQUIRE_ATTESTATION=false`, default when manually enabling): +- Missing/invalid attestation → Download artifact without verification (with warning) +- Provides graceful degradation and backward compatibility +- Useful during migration or when some artifacts lack attestations + +**Strict Mode** (`LEEWAY_SLSA_REQUIRE_ATTESTATION=true`, auto-enabled with `provenance.slsa: true`): +- Missing/invalid attestation → Skip download, build locally with correct attestation +- Enforces strict security and enables self-healing (e.g., cross-PR attestation mismatches) +- Recommended for production environments requiring SLSA L3 compliance + +You can override the mode using: +```bash +# Disable strict mode temporarily +leeway build :app --slsa-require-attestation=false + +# Or via environment variable +export LEEWAY_SLSA_REQUIRE_ATTESTATION=false +leeway build :app +``` + ### Configuration Precedence The Docker export mode follows a clear precedence hierarchy (highest to lowest): diff --git a/cmd/build.go b/cmd/build.go index 5179bf7..d5d4f88 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -202,6 +202,7 @@ func addBuildFlags(cmd *cobra.Command) { cmd.Flags().StringToString("docker-build-options", nil, "Options passed to all 'docker build' commands") cmd.Flags().Bool("slsa-cache-verification", false, "Enable SLSA verification for cached artifacts") cmd.Flags().String("slsa-source-uri", "", "Expected source URI for SLSA verification (required when verification enabled)") + cmd.Flags().Bool("slsa-require-attestation", false, "Require SLSA attestations (missing/invalid → build locally)") 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(EnvvarSegmentKey), "Report build events to segment using the segment key (defaults to $LEEWAY_SEGMENT_KEY)") @@ -442,6 +443,7 @@ func parseSLSAConfig(cmd *cobra.Command) (*cache.SLSAConfig, error) { // Get SLSA verification settings from environment variables (defaults) slsaVerificationEnabled := os.Getenv(EnvvarSLSACacheVerification) == "true" slsaSourceURI := os.Getenv(EnvvarSLSASourceURI) + requireAttestation := os.Getenv(EnvvarSLSARequireAttestation) == "true" // CLI flags override environment variables (if cmd is provided) if cmd != nil { @@ -455,6 +457,11 @@ func parseSLSAConfig(cmd *cobra.Command) (*cache.SLSAConfig, error) { slsaSourceURI = flagValue } } + if cmd.Flags().Changed("slsa-require-attestation") { + if flagValue, err := cmd.Flags().GetBool("slsa-require-attestation"); err == nil { + requireAttestation = flagValue + } + } } // If verification is disabled, return nil @@ -471,7 +478,7 @@ func parseSLSAConfig(cmd *cobra.Command) (*cache.SLSAConfig, error) { Verification: true, SourceURI: slsaSourceURI, TrustedRoots: []string{"https://fulcio.sigstore.dev"}, - RequireAttestation: false, // Default: missing attestation → download without verification + RequireAttestation: requireAttestation, }, nil } diff --git a/cmd/build_test.go b/cmd/build_test.go index 67066b9..9ea903b 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -32,6 +32,24 @@ func TestBuildCommandFlags(t *testing.T) { wantFlag: "in-flight-checksums", wantVal: false, }, + { + name: "slsa-require-attestation flag default", + args: []string{}, + wantFlag: "slsa-require-attestation", + wantVal: false, + }, + { + name: "slsa-require-attestation flag enabled", + args: []string{"--slsa-require-attestation"}, + wantFlag: "slsa-require-attestation", + wantVal: true, + }, + { + name: "slsa-require-attestation flag explicitly disabled", + args: []string{"--slsa-require-attestation=false"}, + wantFlag: "slsa-require-attestation", + wantVal: false, + }, } for _, tt := range tests { @@ -240,3 +258,137 @@ func TestGetBuildOptsWithInFlightChecksums(t *testing.T) { }) } } + +func TestParseSLSAConfig(t *testing.T) { + tests := []struct { + name string + envVerification string + envSourceURI string + envRequireAttestation string + flagVerification *bool + flagSourceURI *string + flagRequireAttestation *bool + wantConfig bool + wantRequireAttestation bool + wantError bool + }{ + { + name: "verification disabled", + wantConfig: false, + }, + { + name: "verification enabled via env, no source URI", + envVerification: "true", + wantError: true, + }, + { + name: "verification enabled via env with source URI", + envVerification: "true", + envSourceURI: "https://github.com/gitpod-io/leeway", + wantConfig: true, + }, + { + name: "require attestation via env", + envVerification: "true", + envSourceURI: "https://github.com/gitpod-io/leeway", + envRequireAttestation: "true", + wantConfig: true, + wantRequireAttestation: true, + }, + { + name: "require attestation via flag overrides env", + envVerification: "true", + envSourceURI: "https://github.com/gitpod-io/leeway", + envRequireAttestation: "false", + flagRequireAttestation: boolPtr(true), + wantConfig: true, + wantRequireAttestation: true, + }, + { + name: "flag disables require attestation", + envVerification: "true", + envSourceURI: "https://github.com/gitpod-io/leeway", + envRequireAttestation: "true", + flagRequireAttestation: boolPtr(false), + wantConfig: true, + wantRequireAttestation: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set environment variables + if tt.envVerification != "" { + t.Setenv(EnvvarSLSACacheVerification, tt.envVerification) + } + if tt.envSourceURI != "" { + t.Setenv(EnvvarSLSASourceURI, tt.envSourceURI) + } + if tt.envRequireAttestation != "" { + t.Setenv(EnvvarSLSARequireAttestation, tt.envRequireAttestation) + } + + // Create test command + cmd := &cobra.Command{ + Use: "build", + Run: func(cmd *cobra.Command, args []string) {}, + } + addBuildFlags(cmd) + + // Set flags if specified + if tt.flagVerification != nil { + if err := cmd.Flags().Set("slsa-cache-verification", boolToString(*tt.flagVerification)); err != nil { + t.Fatalf("failed to set verification flag: %v", err) + } + } + if tt.flagSourceURI != nil { + if err := cmd.Flags().Set("slsa-source-uri", *tt.flagSourceURI); err != nil { + t.Fatalf("failed to set source URI flag: %v", err) + } + } + if tt.flagRequireAttestation != nil { + if err := cmd.Flags().Set("slsa-require-attestation", boolToString(*tt.flagRequireAttestation)); err != nil { + t.Fatalf("failed to set require attestation flag: %v", err) + } + } + + // Test parseSLSAConfig + config, err := parseSLSAConfig(cmd) + + if tt.wantError { + if err == nil { + t.Error("expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.wantConfig { + if config == nil { + t.Fatal("expected config but got nil") + } + if config.RequireAttestation != tt.wantRequireAttestation { + t.Errorf("expected RequireAttestation=%v, got %v", tt.wantRequireAttestation, config.RequireAttestation) + } + } else { + if config != nil { + t.Errorf("expected nil config but got %+v", config) + } + } + }) + } +} + +func boolPtr(b bool) *bool { + return &b +} + +func boolToString(b bool) string { + if b { + return "true" + } + return "false" +} diff --git a/cmd/root.go b/cmd/root.go index de1d233..4431a3b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,6 +31,9 @@ const ( // EnvvarSLSASourceURI configures the expected source URI for SLSA verification EnvvarSLSASourceURI = "LEEWAY_SLSA_SOURCE_URI" + // EnvvarSLSARequireAttestation requires SLSA attestations (missing/invalid → build locally) + EnvvarSLSARequireAttestation = "LEEWAY_SLSA_REQUIRE_ATTESTATION" + // EnvvarEnableInFlightChecksums enables in-flight checksumming of cache artifacts EnvvarEnableInFlightChecksums = "LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS" @@ -120,6 +123,7 @@ variables have an effect on leeway: LEEWAY_DEFAULT_CACHE_LEVEL Sets the default cache level for builds. Defaults to "remote". LEEWAY_SLSA_CACHE_VERIFICATION Enables SLSA verification for cached artifacts (true/false). LEEWAY_SLSA_SOURCE_URI Expected source URI for SLSA verification (github.com/owner/repo). +LEEWAY_SLSA_REQUIRE_ATTESTATION Require valid attestations; missing/invalid → build locally (true/false). LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS Enable checksumming of cache artifacts (true/false). LEEWAY_EXPERIMENTAL Enables experimental leeway features and commands. `), diff --git a/pkg/leeway/cache/remote/s3.go b/pkg/leeway/cache/remote/s3.go index 5baebe7..0e48b04 100644 --- a/pkg/leeway/cache/remote/s3.go +++ b/pkg/leeway/cache/remote/s3.go @@ -442,7 +442,11 @@ func (s *S3Cache) downloadOriginal(ctx context.Context, p cache.Package, version // This function tries multiple extensions (.tar.gz, .tar) and their corresponding attestations. // Returns nil (not an error) when no suitable artifacts are found to allow graceful fallback to local builds. // -// Future CLI flag consideration: --slsa-require-attestation could set RequireAttestation=true +// Configuration: +// RequireAttestation can be set via: +// - Environment variable: LEEWAY_SLSA_REQUIRE_ATTESTATION=true +// - CLI flag: --slsa-require-attestation +// - Workspace config: Automatically enabled when provenance.slsa=true in WORKSPACE.yaml func (s *S3Cache) downloadWithSLSAVerification(ctx context.Context, p cache.Package, version, localPath string) error { log.WithFields(log.Fields{ "package": p.FullName(), diff --git a/pkg/leeway/cache/types.go b/pkg/leeway/cache/types.go index 0e0c184..b52c80e 100644 --- a/pkg/leeway/cache/types.go +++ b/pkg/leeway/cache/types.go @@ -4,19 +4,23 @@ // The cache system supports SLSA (Supply-chain Levels for Software Artifacts) verification // for enhanced security. The behavior is controlled by the SLSAConfig.RequireAttestation field: // -// - RequireAttestation=false (default): Missing attestation → download without verification -// This provides graceful degradation and backward compatibility. +// - RequireAttestation=false (default): Missing/invalid attestation → download without verification +// This provides graceful degradation and backward compatibility. The artifact is downloaded +// and used, but a warning is logged about the missing or invalid attestation. // -// - RequireAttestation=true: Missing attestation → skip download, allow local build fallback -// This enforces strict security but may impact build performance. +// - RequireAttestation=true: Missing/invalid attestation → skip download, allow local build fallback +// This enforces strict security but may impact build performance. When verification fails, +// the artifact is not downloaded, forcing a local rebuild with proper attestation. // // The cache system is designed to never fail builds due to cache issues. When artifacts // cannot be downloaded (missing, verification failed, network issues), the system gracefully // falls back to local builds. // -// Future Evolution: -// A CLI flag like --slsa-require-attestation could be added to set RequireAttestation=true -// for environments that require strict SLSA compliance. +// Configuration: +// RequireAttestation can be controlled via: +// - Environment variable: LEEWAY_SLSA_REQUIRE_ATTESTATION=true +// - CLI flag: --slsa-require-attestation +// - Workspace SLSA config: Automatically enabled when provenance.slsa=true in WORKSPACE.yaml package cache import ( diff --git a/pkg/leeway/workspace.go b/pkg/leeway/workspace.go index 5a70a46..87cc722 100644 --- a/pkg/leeway/workspace.go +++ b/pkg/leeway/workspace.go @@ -37,6 +37,9 @@ const ( // EnvvarSLSASourceURI configures the expected source URI for SLSA verification EnvvarSLSASourceURI = "LEEWAY_SLSA_SOURCE_URI" + // EnvvarSLSARequireAttestation requires SLSA attestations (missing/invalid → build locally) + EnvvarSLSARequireAttestation = "LEEWAY_SLSA_REQUIRE_ATTESTATION" + // EnvvarEnableInFlightChecksums enables in-flight checksumming of cache artifacts EnvvarEnableInFlightChecksums = "LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS" ) @@ -75,6 +78,7 @@ type WorkspaceProvenance struct { // // Sets environment variables as defaults (only if not already set): // - LEEWAY_SLSA_CACHE_VERIFICATION +// - LEEWAY_SLSA_REQUIRE_ATTESTATION // - LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS // - LEEWAY_DOCKER_EXPORT_TO_CACHE // - LEEWAY_SLSA_SOURCE_URI (from Git origin) @@ -92,6 +96,11 @@ func (w *Workspace) ApplySLSADefaults() { log.Debug("Auto-enabled: LEEWAY_SLSA_CACHE_VERIFICATION=true") } + // Auto-enable require attestation for strict SLSA compliance (global feature) + if setEnvDefault(EnvvarSLSARequireAttestation, "true") { + log.Debug("Auto-enabled: LEEWAY_SLSA_REQUIRE_ATTESTATION=true") + } + // Auto-enable in-flight checksumming (global feature) if setEnvDefault(EnvvarEnableInFlightChecksums, "true") { log.Debug("Auto-enabled: LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS=true")