From 772055306627f3bf36afff1ee770e7dfd568e6e7 Mon Sep 17 00:00:00 2001 From: Leo Di Donato <120051+leodido@users.noreply.github.com> Date: Fri, 21 Nov 2025 00:02:57 +0000 Subject: [PATCH 1/3] fix: support SBOM generation with OCI layout export When exportToCache is enabled, Docker images are exported in OCI layout format (image.tar) and never loaded into Docker daemon. SBOM generation was failing because it tried to inspect the Docker daemon. This fix detects OCI layout export and uses Syft's oci-archive source provider to scan the image.tar directly, enabling SBOM generation for all three formats (CycloneDX, SPDX, Syft) in SLSA L3 compliant builds. Co-authored-by: Ona --- pkg/leeway/sbom.go | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/pkg/leeway/sbom.go b/pkg/leeway/sbom.go index 20dfe56..52a6569 100644 --- a/pkg/leeway/sbom.go +++ b/pkg/leeway/sbom.go @@ -235,16 +235,41 @@ func writeSBOM(buildctx *buildContext, p *Package, builddir string) (err error) // Get the appropriate source based on package type var src source.Source if p.Type == DockerPackage { - buildctx.Reporter.PackageBuildLog(p, false, []byte("Generating SBOM from Docker image\n")) - - version, err := p.Version() - if err != nil { - return xerrors.Errorf("failed to get package version: %w", err) + cfg, ok := p.Config.(DockerPkgConfig) + if !ok { + return xerrors.Errorf("package should have Docker config") } - src, err = syft.GetSource(context.Background(), version, nil) - if err != nil { - return xerrors.Errorf("failed to get Docker image source for SBOM generation: %w", err) + // Check if OCI layout export is enabled + if cfg.ExportToCache != nil && *cfg.ExportToCache { + // OCI layout path - scan from oci-archive + buildctx.Reporter.PackageBuildLog(p, false, []byte("Generating SBOM from OCI layout\n")) + + ociLayoutPath := filepath.Join(builddir, "image.tar") + if _, err := os.Stat(ociLayoutPath); err != nil { + return xerrors.Errorf("OCI layout image.tar not found in %s: %w", builddir, err) + } + + // Syft will auto-detect the OCI archive format from the file path + // Use explicit source provider configuration to ensure oci-archive is tried + srcCfg := syft.DefaultGetSourceConfig().WithSources("oci-archive") + src, err = syft.GetSource(context.Background(), ociLayoutPath, srcCfg) + if err != nil { + return xerrors.Errorf("failed to get OCI archive source for SBOM generation: %w", err) + } + } else { + // Traditional Docker daemon path + buildctx.Reporter.PackageBuildLog(p, false, []byte("Generating SBOM from Docker image\n")) + + version, err := p.Version() + if err != nil { + return xerrors.Errorf("failed to get package version: %w", err) + } + + src, err = syft.GetSource(context.Background(), version, nil) + if err != nil { + return xerrors.Errorf("failed to get Docker image source for SBOM generation: %w", err) + } } } else { buildctx.Reporter.PackageBuildLog(p, false, []byte("Generating SBOM from filesystem\n")) From 372a050dde2a520fe93012408fe219403c719c1a Mon Sep 17 00:00:00 2001 From: Leo Di Donato <120051+leodido@users.noreply.github.com> Date: Fri, 21 Nov 2025 00:03:08 +0000 Subject: [PATCH 2/3] test: add integration tests for SBOM with OCI layout Add comprehensive integration test that verifies SBOM generation works correctly for both Docker daemon and OCI layout export paths. The test validates: - Build succeeds without errors for both exportToCache modes - All 3 SBOM formats are generated (CycloneDX, SPDX, Syft) - SBOM files are valid JSON with expected structure - Format-specific fields are present (bomFormat, spdxVersion) Includes git repository initialization with fixed timestamps for deterministic test results, following existing test patterns. Co-authored-by: Ona --- pkg/leeway/build_integration_test.go | 286 +++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/pkg/leeway/build_integration_test.go b/pkg/leeway/build_integration_test.go index 165d148..ff22973 100644 --- a/pkg/leeway/build_integration_test.go +++ b/pkg/leeway/build_integration_test.go @@ -195,6 +195,24 @@ CMD ["echo", "test"]` t.Fatal(err) } + // Create initial git commit for SBOM timestamp + gitAdd := exec.Command("git", "add", ".") + gitAdd.Dir = tmpDir + if err := gitAdd.Run(); err != nil { + t.Fatalf("Failed to git add: %v", err) + } + + // Use fixed timestamp for deterministic git commit + gitCommit := exec.Command("git", "commit", "-m", "initial") + gitCommit.Dir = tmpDir + gitCommit.Env = append(os.Environ(), + "GIT_AUTHOR_DATE=2021-01-01T00:00:00Z", + "GIT_COMMITTER_DATE=2021-01-01T00:00:00Z", + ) + if err := gitCommit.Run(); err != nil { + t.Fatalf("Failed to git commit: %v", err) + } + // Load workspace workspace, err := FindWorkspace(tmpDir, Arguments{}, "", "") if err != nil { @@ -1196,3 +1214,271 @@ RUN echo "test content" > /test.txt }) } } + + +// TestDockerPackage_SBOM_OCI_Integration verifies SBOM generation works with OCI layout export. +// Tests two scenarios: +// 1. SBOM with Docker daemon (exportToCache=false) - traditional path +// 2. SBOM with OCI layout (exportToCache=true) - should scan oci-archive:image.tar +// +// This test validates the fix for the issue where SBOM generation fails with OCI layout +// because it tries to inspect the Docker daemon instead of scanning the OCI archive. +func TestDockerPackage_SBOM_OCI_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Ensure Docker is available + if err := exec.Command("docker", "version").Run(); err != nil { + t.Skip("Docker not available, skipping integration test") + } + + tests := []struct { + name string + exportToCache bool + description string + }{ + { + name: "sbom_with_docker_daemon", + exportToCache: false, + description: "SBOM generation from Docker daemon (traditional path)", + }, + { + name: "sbom_with_oci_layout", + exportToCache: true, + description: "SBOM generation from OCI layout (oci-archive:image.tar)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Logf("Testing: %s", tt.description) + + // Create docker-container builder for OCI export if needed + if tt.exportToCache { + builderName := "leeway-sbom-test-builder" + createBuilder := exec.Command("docker", "buildx", "create", "--name", builderName, "--driver", "docker-container", "--bootstrap") + if err := createBuilder.Run(); err != nil { + t.Logf("Builder creation failed (might already exist): %v", err) + } + defer func() { + removeBuilder := exec.Command("docker", "buildx", "rm", builderName) + _ = removeBuilder.Run() + }() + + useBuilder := exec.Command("docker", "buildx", "use", builderName) + if err := useBuilder.Run(); err != nil { + t.Fatalf("Failed to use builder: %v", err) + } + } + + // Create temporary workspace + tmpDir := t.TempDir() + + // Initialize git repository for SBOM timestamp normalization + gitInit := exec.Command("git", "init") + gitInit.Dir = tmpDir + if err := gitInit.Run(); err != nil { + t.Fatalf("Failed to initialize git repository: %v", err) + } + + // Configure git user for commits + gitConfigName := exec.Command("git", "config", "user.name", "Test User") + gitConfigName.Dir = tmpDir + if err := gitConfigName.Run(); err != nil { + t.Fatalf("Failed to configure git user.name: %v", err) + } + + gitConfigEmail := exec.Command("git", "config", "user.email", "test@example.com") + gitConfigEmail.Dir = tmpDir + if err := gitConfigEmail.Run(); err != nil { + t.Fatalf("Failed to configure git user.email: %v", err) + } + + // Create WORKSPACE.yaml with SBOM enabled + workspaceYAML := `defaultTarget: "app:docker" +sbom: + enabled: true + scanVulnerabilities: false` + workspacePath := filepath.Join(tmpDir, "WORKSPACE.yaml") + if err := os.WriteFile(workspacePath, []byte(workspaceYAML), 0644); err != nil { + t.Fatal(err) + } + + // Create component directory + appDir := filepath.Join(tmpDir, "app") + if err := os.MkdirAll(appDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a simple Dockerfile with some packages for SBOM to scan + dockerfile := `FROM alpine:latest +RUN apk add --no-cache curl wget +LABEL test="sbom-test" +CMD ["echo", "test"]` + + dockerfilePath := filepath.Join(appDir, "Dockerfile") + if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil { + t.Fatal(err) + } + + // Create BUILD.yaml + buildYAML := fmt.Sprintf(`packages: +- name: docker + type: docker + config: + dockerfile: Dockerfile + exportToCache: %t`, tt.exportToCache) + + buildPath := filepath.Join(appDir, "BUILD.yaml") + if err := os.WriteFile(buildPath, []byte(buildYAML), 0644); err != nil { + t.Fatal(err) + } + + // Create initial git commit for SBOM timestamp + gitAdd := exec.Command("git", "add", ".") + gitAdd.Dir = tmpDir + if err := gitAdd.Run(); err != nil { + t.Fatalf("Failed to git add: %v", err) + } + + // Use fixed timestamp for deterministic git commit + gitCommit := exec.Command("git", "commit", "-m", "initial") + gitCommit.Dir = tmpDir + gitCommit.Env = append(os.Environ(), + "GIT_AUTHOR_DATE=2021-01-01T00:00:00Z", + "GIT_COMMITTER_DATE=2021-01-01T00:00:00Z", + ) + if err := gitCommit.Run(); err != nil { + t.Fatalf("Failed to git commit: %v", err) + } + + // Load workspace + workspace, err := FindWorkspace(tmpDir, Arguments{}, "", "") + if err != nil { + t.Fatal(err) + } + + // Verify SBOM is enabled + if !workspace.SBOM.Enabled { + t.Fatal("SBOM should be enabled in workspace") + } + + // Create build context + cacheDir := filepath.Join(tmpDir, ".cache") + cache, err := local.NewFilesystemCache(cacheDir) + if err != nil { + t.Fatal(err) + } + + buildCtx, err := newBuildContext(buildOptions{ + LocalCache: cache, + DockerExportToCache: tt.exportToCache, + DockerExportSet: true, + Reporter: NewConsoleReporter(), + }) + if err != nil { + t.Fatal(err) + } + + // Get the package + pkg, ok := workspace.Packages["app:docker"] + if !ok { + t.Fatal("package app:docker not found") + } + + // Build the package - this should generate SBOM + err = pkg.build(buildCtx) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + + t.Logf("✅ Build succeeded with exportToCache=%v", tt.exportToCache) + + // Verify SBOM files were created in the cache + cacheLoc, exists := cache.Location(pkg) + if !exists { + t.Fatal("Package not found in cache") + } + + // Extract and verify SBOM files from cache + sbomFormats := []string{ + "sbom.cdx.json", // CycloneDX + "sbom.spdx.json", // SPDX + "sbom.json", // Syft (native format) + } + + foundSBOMs := make(map[string]bool) + + // Open the cache tar.gz + f, err := os.Open(cacheLoc) + if err != nil { + t.Fatalf("Failed to open cache file: %v", err) + } + defer f.Close() + + gzin, err := gzip.NewReader(f) + if err != nil { + t.Fatalf("Failed to create gzip reader: %v", err) + } + defer gzin.Close() + + tarin := tar.NewReader(gzin) + for { + hdr, err := tarin.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("Failed to read tar: %v", err) + } + + filename := filepath.Base(hdr.Name) + for _, sbomFile := range sbomFormats { + if filename == sbomFile { + foundSBOMs[sbomFile] = true + t.Logf("✅ Found SBOM file: %s (size: %d bytes)", sbomFile, hdr.Size) + + // Read and validate SBOM content + sbomContent := make([]byte, hdr.Size) + if _, err := io.ReadFull(tarin, sbomContent); err != nil { + t.Fatalf("Failed to read SBOM content: %v", err) + } + + // Validate it's valid JSON + var sbomData map[string]interface{} + if err := json.Unmarshal(sbomContent, &sbomData); err != nil { + t.Fatalf("SBOM file %s is not valid JSON: %v", sbomFile, err) + } + + // Check for expected content based on format + if strings.Contains(sbomFile, "cdx") { + if _, ok := sbomData["bomFormat"]; !ok { + t.Errorf("CycloneDX SBOM missing bomFormat field") + } + } else if strings.Contains(sbomFile, "spdx") { + if _, ok := sbomData["spdxVersion"]; !ok { + t.Errorf("SPDX SBOM missing spdxVersion field") + } + } + + t.Logf("✅ SBOM file %s is valid JSON with expected structure", sbomFile) + } + } + } + + // Verify all SBOM formats were generated + for _, sbomFile := range sbomFormats { + if !foundSBOMs[sbomFile] { + t.Errorf("❌ SBOM file %s not found in cache", sbomFile) + } + } + + if len(foundSBOMs) == len(sbomFormats) { + t.Logf("✅ All %d SBOM formats generated successfully", len(sbomFormats)) + } + + t.Logf("✅ SBOM generation works correctly with exportToCache=%v", tt.exportToCache) + }) + } +} From eeb64b5ef181cf36f32cb0f7472ad884490195c2 Mon Sep 17 00:00:00 2001 From: Leo Di Donato <120051+leodido@users.noreply.github.com> Date: Fri, 21 Nov 2025 09:29:59 +0000 Subject: [PATCH 3/3] fix: add git initialization to TestDockerPackage_ExportToCache_Integration The test was failing in CI because it tried to run git commands without first initializing a git repository. This adds the missing git init and git config steps, following the same pattern as other integration tests. Also adds GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM environment variables to ensure tests work in CI environments where global git config might not be available. Fixes integration test failures in CI. Co-authored-by: Ona --- pkg/leeway/build_integration_test.go | 91 +++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 15 deletions(-) diff --git a/pkg/leeway/build_integration_test.go b/pkg/leeway/build_integration_test.go index ff22973..beea7a2 100644 --- a/pkg/leeway/build_integration_test.go +++ b/pkg/leeway/build_integration_test.go @@ -195,9 +195,35 @@ CMD ["echo", "test"]` t.Fatal(err) } + // Initialize git repository for SBOM timestamp normalization + { + gitInit := exec.Command("git", "init") + gitInit.Dir = tmpDir + gitInit.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if err := gitInit.Run(); err != nil { + t.Fatalf("Failed to initialize git repository: %v", err) + } + + // Configure git user for commits + gitConfigName := exec.Command("git", "config", "user.name", "Test User") + gitConfigName.Dir = tmpDir + gitConfigName.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if err := gitConfigName.Run(); err != nil { + t.Fatalf("Failed to configure git user.name: %v", err) + } + + gitConfigEmail := exec.Command("git", "config", "user.email", "test@example.com") + gitConfigEmail.Dir = tmpDir + gitConfigEmail.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if err := gitConfigEmail.Run(); err != nil { + t.Fatalf("Failed to configure git user.email: %v", err) + } + } + // Create initial git commit for SBOM timestamp gitAdd := exec.Command("git", "add", ".") gitAdd.Dir = tmpDir + gitAdd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") if err := gitAdd.Run(); err != nil { t.Fatalf("Failed to git add: %v", err) } @@ -206,6 +232,8 @@ CMD ["echo", "test"]` gitCommit := exec.Command("git", "commit", "-m", "initial") gitCommit.Dir = tmpDir gitCommit.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", "GIT_AUTHOR_DATE=2021-01-01T00:00:00Z", "GIT_COMMITTER_DATE=2021-01-01T00:00:00Z", ) @@ -1276,23 +1304,28 @@ func TestDockerPackage_SBOM_OCI_Integration(t *testing.T) { tmpDir := t.TempDir() // Initialize git repository for SBOM timestamp normalization - gitInit := exec.Command("git", "init") - gitInit.Dir = tmpDir - if err := gitInit.Run(); err != nil { - t.Fatalf("Failed to initialize git repository: %v", err) - } + { + gitInit := exec.Command("git", "init") + gitInit.Dir = tmpDir + gitInit.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if err := gitInit.Run(); err != nil { + t.Fatalf("Failed to initialize git repository: %v", err) + } - // Configure git user for commits - gitConfigName := exec.Command("git", "config", "user.name", "Test User") - gitConfigName.Dir = tmpDir - if err := gitConfigName.Run(); err != nil { - t.Fatalf("Failed to configure git user.name: %v", err) - } + // Configure git user for commits + gitConfigName := exec.Command("git", "config", "user.name", "Test User") + gitConfigName.Dir = tmpDir + gitConfigName.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if err := gitConfigName.Run(); err != nil { + t.Fatalf("Failed to configure git user.name: %v", err) + } - gitConfigEmail := exec.Command("git", "config", "user.email", "test@example.com") - gitConfigEmail.Dir = tmpDir - if err := gitConfigEmail.Run(); err != nil { - t.Fatalf("Failed to configure git user.email: %v", err) + gitConfigEmail := exec.Command("git", "config", "user.email", "test@example.com") + gitConfigEmail.Dir = tmpDir + gitConfigEmail.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if err := gitConfigEmail.Run(); err != nil { + t.Fatalf("Failed to configure git user.email: %v", err) + } } // Create WORKSPACE.yaml with SBOM enabled @@ -1335,9 +1368,35 @@ CMD ["echo", "test"]` t.Fatal(err) } + // Initialize git repository for SBOM timestamp normalization + { + gitInit := exec.Command("git", "init") + gitInit.Dir = tmpDir + gitInit.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if err := gitInit.Run(); err != nil { + t.Fatalf("Failed to initialize git repository: %v", err) + } + + // Configure git user for commits + gitConfigName := exec.Command("git", "config", "user.name", "Test User") + gitConfigName.Dir = tmpDir + gitConfigName.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if err := gitConfigName.Run(); err != nil { + t.Fatalf("Failed to configure git user.name: %v", err) + } + + gitConfigEmail := exec.Command("git", "config", "user.email", "test@example.com") + gitConfigEmail.Dir = tmpDir + gitConfigEmail.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if err := gitConfigEmail.Run(); err != nil { + t.Fatalf("Failed to configure git user.email: %v", err) + } + } + // Create initial git commit for SBOM timestamp gitAdd := exec.Command("git", "add", ".") gitAdd.Dir = tmpDir + gitAdd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") if err := gitAdd.Run(); err != nil { t.Fatalf("Failed to git add: %v", err) } @@ -1346,6 +1405,8 @@ CMD ["echo", "test"]` gitCommit := exec.Command("git", "commit", "-m", "initial") gitCommit.Dir = tmpDir gitCommit.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", "GIT_AUTHOR_DATE=2021-01-01T00:00:00Z", "GIT_COMMITTER_DATE=2021-01-01T00:00:00Z", )