diff --git a/cmd/build.go b/cmd/build.go index ba948aa..e6a1dd7 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -185,6 +185,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("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().Bool("report-github", os.Getenv("GITHUB_OUTPUT") != "", "Report package build success/failure to GitHub Actions using the GITHUB_OUTPUT environment variable") @@ -318,6 +319,11 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) { log.Fatal(err) } + inFlightChecksums, err := cmd.Flags().GetBool("in-flight-checksums") + if err != nil { + log.Fatal(err) + } + return []leeway.BuildOption{ leeway.WithLocalCache(localCache), leeway.WithRemoteCache(remoteCache), @@ -332,6 +338,7 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) { leeway.WithCompressionDisabled(dontCompress), leeway.WithFixedBuildDir(fixedBuildDir), leeway.WithDisableCoverage(disableCoverage), + leeway.WithInFlightChecksums(inFlightChecksums), }, localCache } diff --git a/cmd/build_test.go b/cmd/build_test.go new file mode 100644 index 0000000..98f1a80 --- /dev/null +++ b/cmd/build_test.go @@ -0,0 +1,161 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestBuildCommandFlags(t *testing.T) { + tests := []struct { + name string + args []string + wantFlag string + wantVal interface{} + }{ + { + name: "in-flight-checksums flag default", + args: []string{}, + wantFlag: "in-flight-checksums", + wantVal: false, + }, + { + name: "in-flight-checksums flag enabled", + args: []string{"--in-flight-checksums"}, + wantFlag: "in-flight-checksums", + wantVal: true, + }, + { + name: "in-flight-checksums flag explicitly disabled", + args: []string{"--in-flight-checksums=false"}, + wantFlag: "in-flight-checksums", + wantVal: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a new build command for each test + cmd := &cobra.Command{ + Use: "build", + Run: func(cmd *cobra.Command, args []string) { + // No-op for testing + }, + } + + // Add the build flags + addBuildFlags(cmd) + + // Set the args and parse + cmd.SetArgs(tt.args) + err := cmd.Execute() + if err != nil { + t.Fatalf("failed to execute command: %v", err) + } + + // Check if the flag exists + flag := cmd.Flags().Lookup(tt.wantFlag) + if flag == nil { + t.Fatalf("flag %s not found", tt.wantFlag) + } + + // Get the flag value + val, err := cmd.Flags().GetBool(tt.wantFlag) + if err != nil { + t.Fatalf("failed to get flag value: %v", err) + } + + if val != tt.wantVal { + t.Errorf("expected flag %s to be %v, got %v", tt.wantFlag, tt.wantVal, val) + } + }) + } +} + +func TestBuildCommandHelpText(t *testing.T) { + cmd := &cobra.Command{ + Use: "build", + Run: func(cmd *cobra.Command, args []string) { + // No-op for testing + }, + } + + addBuildFlags(cmd) + + // Check that the in-flight-checksums flag is documented + flag := cmd.Flags().Lookup("in-flight-checksums") + if flag == nil { + t.Fatal("in-flight-checksums flag not found") + } + + expectedUsage := "Enable checksumming of cache artifacts to prevent TOCTU attacks" + if flag.Usage != expectedUsage { + t.Errorf("expected flag usage to be %q, got %q", expectedUsage, flag.Usage) + } + + // Verify it's a boolean flag + if flag.Value.Type() != "bool" { + t.Errorf("expected flag type to be bool, got %s", flag.Value.Type()) + } + + // Verify default value + if flag.DefValue != "false" { + t.Errorf("expected default value to be false, got %s", flag.DefValue) + } +} + +func TestGetBuildOptsWithInFlightChecksums(t *testing.T) { + tests := []struct { + name string + inFlightChecksumsFlag bool + expectInFlightChecksums bool + }{ + { + name: "in-flight checksums disabled", + inFlightChecksumsFlag: false, + expectInFlightChecksums: false, + }, + { + name: "in-flight checksums enabled", + inFlightChecksumsFlag: true, + expectInFlightChecksums: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{ + Use: "build", + Run: func(cmd *cobra.Command, args []string) { + // No-op for testing + }, + } + + addBuildFlags(cmd) + + // Set the flag value + err := cmd.Flags().Set("in-flight-checksums", "false") + if tt.inFlightChecksumsFlag { + err = cmd.Flags().Set("in-flight-checksums", "true") + } + if err != nil { + t.Fatalf("failed to set flag: %v", err) + } + + // Test getBuildOpts function + opts, localCache := getBuildOpts(cmd) + + // We can't directly test the WithInFlightChecksums option since it's internal, + // but we can verify the function doesn't error and returns options + if opts == nil { + t.Error("expected build options but got nil") + } + if localCache == nil { + t.Error("expected local cache but got nil") + } + + // The actual verification of the in-flight checksums option would need + // to be done through integration tests or by exposing the option state + }) + } +} \ No newline at end of file diff --git a/pkg/leeway/build.go b/pkg/leeway/build.go index c1b38b8..69adf92 100644 --- a/pkg/leeway/build.go +++ b/pkg/leeway/build.go @@ -71,6 +71,11 @@ type buildContext struct { pkgLockCond *sync.Cond pkgLocks map[string]struct{} buildLimit *semaphore.Weighted + + // For in-flight checksumming + InFlightChecksums bool // Feature enabled flag + artifactChecksums map[string]string // path -> sha256 hex + artifactChecksumsMutex sync.RWMutex // Thread safety for parallel builds } const ( @@ -145,6 +150,14 @@ func newBuildContext(options buildOptions) (ctx *buildContext, err error) { return nil, xerrors.Errorf("cannot compute hash of myself: %w", err) } + // Initialize checksum storage based on feature flag + var checksumMap map[string]string + if options.InFlightChecksums { + checksumMap = make(map[string]string) + } else { + checksumMap = nil // Disable feature completely + } + ctx = &buildContext{ buildOptions: options, buildDir: buildDir, @@ -154,6 +167,9 @@ func newBuildContext(options buildOptions) (ctx *buildContext, err error) { pkgLocks: make(map[string]struct{}), buildLimit: buildLimit, leewayHash: hex.EncodeToString(leewayHash.Sum(nil)), + // In-flight checksumming initialization + InFlightChecksums: options.InFlightChecksums, + artifactChecksums: checksumMap, } err = os.MkdirAll(buildDir, 0755) @@ -251,6 +267,115 @@ func (c *buildContext) GetNewPackagesForCache() []*Package { return res } +func (ctx *buildContext) recordArtifactChecksum(path string) error { + if ctx.artifactChecksums == nil { + return nil + } + + checksum, err := computeSHA256(path) + if err != nil { + return fmt.Errorf("failed to compute checksum for %s: %w", path, err) + } + + // Thread-safe storage + ctx.artifactChecksumsMutex.Lock() + ctx.artifactChecksums[path] = checksum + ctx.artifactChecksumsMutex.Unlock() + + log.WithFields(log.Fields{ + "artifact": path, + "checksum": checksum[:16] + "...", // Log first 16 chars for debugging + }).Debug("Recorded cache artifact checksum") + + return nil +} + +// verifyArtifactChecksum +func (ctx *buildContext) verifyArtifactChecksum(path string) error { + if ctx.artifactChecksums == nil { + return nil + } + + // Get stored checksum + ctx.artifactChecksumsMutex.RLock() + expectedChecksum, exists := ctx.artifactChecksums[path] + ctx.artifactChecksumsMutex.RUnlock() + + if !exists { + return nil // Not tracked, skip verification + } + + // Compute current checksum + actualChecksum, err := computeSHA256(path) + if err != nil { + return fmt.Errorf("failed to verify checksum for %s: %w", path, err) + } + + // Detect tampering + if expectedChecksum != actualChecksum { + return fmt.Errorf("cache artifact %s modified (expected: %s..., actual: %s...)", + path, expectedChecksum[:16], actualChecksum[:16]) + } + + return nil +} + +// computeSHA256 computes the SHA256 hash of a file +func computeSHA256(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer func() { _ = file.Close() }() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} + +// verifyAllArtifactChecksums verifies all tracked cache artifacts before signing handoff +func verifyAllArtifactChecksums(buildctx *buildContext) error { + if buildctx.artifactChecksums == nil { + return nil // Feature disabled + } + + // Get snapshot of all artifacts to verify + buildctx.artifactChecksumsMutex.RLock() + checksumCount := len(buildctx.artifactChecksums) + artifactsToVerify := make([]string, 0, checksumCount) + for path := range buildctx.artifactChecksums { + artifactsToVerify = append(artifactsToVerify, path) + } + buildctx.artifactChecksumsMutex.RUnlock() + + if checksumCount == 0 { + log.Debug("No cache artifacts to verify") + return nil + } + + log.WithField("artifacts", checksumCount).Info("Verifying cache artifact integrity") + + // Verify each artifact + var verificationErrors []string + for _, path := range artifactsToVerify { + if err := buildctx.verifyArtifactChecksum(path); err != nil { + verificationErrors = append(verificationErrors, err.Error()) + } + } + + // Report results + if len(verificationErrors) > 0 { + return fmt.Errorf("checksum verification failures:\n%s", + strings.Join(verificationErrors, "\n")) + } + + log.WithField("artifacts", checksumCount).Info("All cache artifacts verified successfully") + return nil +} + type buildOptions struct { LocalCache cache.LocalCache RemoteCache cache.RemoteCache @@ -266,6 +391,7 @@ type buildOptions struct { JailedExecution bool UseFixedBuildDir bool DisableCoverage bool + InFlightChecksums bool context *buildContext } @@ -381,6 +507,14 @@ func WithDisableCoverage(disableCoverage bool) BuildOption { } } +// WithInFlightChecksums enables checksumming of cache artifacts to prevent TOCTU attacks +func WithInFlightChecksums(enabled bool) BuildOption { + return func(opts *buildOptions) error { + opts.InFlightChecksums = enabled + return nil + } +} + func withBuildContext(ctx *buildContext) BuildOption { return func(opts *buildOptions) error { opts.context = ctx @@ -564,6 +698,13 @@ func Build(pkg *Package, opts ...BuildOption) (err error) { } } + // Verify all cache artifact checksums before signing handoff + if ctx.InFlightChecksums { + if err := verifyAllArtifactChecksums(ctx); err != nil { + return fmt.Errorf("cache artifact integrity check failed - potential TOCTU attack detected: %w", err) + } + } + return nil } @@ -785,6 +926,14 @@ func (p *Package) build(buildctx *buildContext) (err error) { } } + // Record checksum immediately after cache artifact creation + if cacheArtifactPath, exists := buildctx.LocalCache.Location(p); exists { + if err := buildctx.recordArtifactChecksum(cacheArtifactPath); err != nil { + log.WithError(err).WithField("package", p.FullName()).Warn("Failed to record cache artifact checksum") + // Don't fail build - this is defensive, not critical path + } + } + // Register newly built package return buildctx.RegisterNewlyBuilt(p) } diff --git a/pkg/leeway/build_checksum_test.go b/pkg/leeway/build_checksum_test.go new file mode 100644 index 0000000..6b04a3a --- /dev/null +++ b/pkg/leeway/build_checksum_test.go @@ -0,0 +1,142 @@ +package leeway + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRecordArtifactChecksum(t *testing.T) { + // Test checksum recording works correctly + tmpDir := t.TempDir() + testArtifact := filepath.Join(tmpDir, "test.tar.gz") + err := os.WriteFile(testArtifact, []byte("test content"), 0644) + if err != nil { + t.Fatal(err) + } + + ctx := &buildContext{ + InFlightChecksums: true, + artifactChecksums: make(map[string]string), + } + + err = ctx.recordArtifactChecksum(testArtifact) + if err != nil { + t.Errorf("recordArtifactChecksum failed: %v", err) + } + + if len(ctx.artifactChecksums) != 1 { + t.Errorf("Expected 1 checksum, got %d", len(ctx.artifactChecksums)) + } +} + +func TestVerifyArtifactChecksum(t *testing.T) { + tmpDir := t.TempDir() + testArtifact := filepath.Join(tmpDir, "test.tar.gz") + err := os.WriteFile(testArtifact, []byte("test content"), 0644) + if err != nil { + t.Fatal(err) + } + + ctx := &buildContext{ + InFlightChecksums: true, + artifactChecksums: make(map[string]string), + } + + // Record initial checksum + err = ctx.recordArtifactChecksum(testArtifact) + if err != nil { + t.Fatal(err) + } + + // Verify unmodified file passes + err = ctx.verifyArtifactChecksum(testArtifact) + if err != nil { + t.Errorf("Verification should pass for unmodified file: %v", err) + } + + // Modify file to simulate TOCTU attack + err = os.WriteFile(testArtifact, []byte("tampered content"), 0644) + if err != nil { + t.Fatal(err) + } + + // Verify modified file fails with TOCTU message + err = ctx.verifyArtifactChecksum(testArtifact) + if err == nil { + t.Error("Verification should fail for tampered file") + } + if !strings.Contains(err.Error(), "cache artifact") || !strings.Contains(err.Error(), "modified") { + t.Errorf("Expected cache artifact modified error, got: %v", err) + } +} + +func TestInFlightChecksumsDisabled(t *testing.T) { + ctx := &buildContext{ + InFlightChecksums: false, + artifactChecksums: nil, + } + + // Both operations should be no-op + err := ctx.recordArtifactChecksum("nonexistent") + if err != nil { + t.Errorf("Disabled checksumming should be no-op: %v", err) + } + + err = ctx.verifyArtifactChecksum("nonexistent") + if err != nil { + t.Errorf("Disabled checksumming should be no-op: %v", err) + } +} + +func TestVerifyAllArtifactChecksums(t *testing.T) { + tmpDir := t.TempDir() + + // Create multiple test artifacts + artifacts := []string{ + filepath.Join(tmpDir, "pkg1.tar.gz"), + filepath.Join(tmpDir, "pkg2.tar.gz"), + } + + ctx := &buildContext{ + InFlightChecksums: true, + artifactChecksums: make(map[string]string), + } + + // Record checksums for all artifacts + for i, artifact := range artifacts { + content := fmt.Sprintf("package %d content", i) + err := os.WriteFile(artifact, []byte(content), 0644) + if err != nil { + t.Fatal(err) + } + + err = ctx.recordArtifactChecksum(artifact) + if err != nil { + t.Fatal(err) + } + } + + // Verify all pass initially + err := verifyAllArtifactChecksums(ctx) + if err != nil { + t.Errorf("All checksums should verify: %v", err) + } + + // Tamper with one artifact + err = os.WriteFile(artifacts[0], []byte("tampered!"), 0644) + if err != nil { + t.Fatal(err) + } + + // Verification should fail + err = verifyAllArtifactChecksums(ctx) + if err == nil { + t.Error("Verification should fail when artifact is tampered") + } + if !strings.Contains(err.Error(), "checksum verification failures") { + t.Errorf("Expected verification failure message, got: %v", err) + } +} \ No newline at end of file