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
7 changes: 7 additions & 0 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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),
Expand All @@ -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
}

Expand Down
161 changes: 161 additions & 0 deletions cmd/build_test.go
Original file line number Diff line number Diff line change
@@ -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
})
}
}
149 changes: 149 additions & 0 deletions pkg/leeway/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -266,6 +391,7 @@ type buildOptions struct {
JailedExecution bool
UseFixedBuildDir bool
DisableCoverage bool
InFlightChecksums bool

context *buildContext
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}
Expand Down
Loading