Skip to content
Merged
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
105 changes: 94 additions & 11 deletions pkg/leeway/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -2171,8 +2171,8 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
Commands: commands,
}

// Add subjects function for provenance generation
res.Subjects = createDockerSubjectsFunction(version, cfg)
// Add subjects function for provenance generation (image in Docker daemon)
res.Subjects = createDockerInspectSubjectsFunction(version, cfg)
} else if len(cfg.Image) > 0 && *cfg.ExportToCache {
// Export to cache for signing
log.WithField("package", p.FullName()).Debug("Exporting Docker image to cache (OCI layout)")
Expand Down Expand Up @@ -2228,17 +2228,22 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
Commands: commands,
}

// Add PostProcess to create structured metadata file
// Add PostProcess to create structured metadata file and set up subjects function
res.PostProcess = func(buildCtx *buildContext, pkg *Package, buildDir string) error {
mtime, err := pkg.getDeterministicMtime()
if err != nil {
return fmt.Errorf("failed to get deterministic mtime: %w", err)
}
return createDockerExportMetadata(buildDir, version, cfg, mtime)

// Create metadata
if err := createDockerExportMetadata(buildDir, version, cfg, mtime); err != nil {
return err
}

// Set up subjects function with buildDir for OCI layout extraction
res.Subjects = createOCILayoutSubjectsFunction(version, cfg, buildDir)
return nil
}

// Add subjects function for provenance generation
res.Subjects = createDockerSubjectsFunction(version, cfg)
}

return res, nil
Expand Down Expand Up @@ -2298,13 +2303,49 @@ func extractImageNameFromCache(pkgName, cacheBundleFN string) (imgname string, e
return "", nil
}

// createDockerSubjectsFunction creates a function that generates SLSA provenance subjects for Docker images
func createDockerSubjectsFunction(version string, cfg DockerPkgConfig) func() ([]in_toto.Subject, error) {
// extractDigestFromOCILayout extracts the image digest from an OCI layout directory
func extractDigestFromOCILayout(ociLayoutPath string) (common.DigestSet, error) {
// Read index.json from OCI layout
indexPath := filepath.Join(ociLayoutPath, "index.json")
indexData, err := os.ReadFile(indexPath)
if err != nil {
return nil, xerrors.Errorf("failed to read OCI index.json: %w", err)
}

// Parse index.json to get manifest digest
var index struct {
Manifests []struct {
Digest string `json:"digest"`
} `json:"manifests"`
}

if err := json.Unmarshal(indexData, &index); err != nil {
return nil, xerrors.Errorf("failed to parse OCI index.json: %w", err)
}

if len(index.Manifests) == 0 {
return nil, xerrors.Errorf("no manifests found in OCI index.json")
}

// Extract digest from first manifest (format: "sha256:abc123...")
digestStr := index.Manifests[0].Digest
parts := strings.Split(digestStr, ":")
if len(parts) != 2 {
return nil, xerrors.Errorf("invalid digest format in OCI index: %s", digestStr)
}

return common.DigestSet{
parts[0]: parts[1],
}, nil
}

// createDockerInspectSubjectsFunction creates a function that generates SLSA provenance subjects
// by inspecting the Docker image in the daemon (legacy push workflow)
func createDockerInspectSubjectsFunction(version string, cfg DockerPkgConfig) func() ([]in_toto.Subject, error) {
return func() ([]in_toto.Subject, error) {
subjectLogger := log.WithField("operation", "provenance-subjects")
subjectLogger.Debug("Calculating provenance subjects for Docker images")
subjectLogger.Debug("Extracting digest from Docker daemon")

// Get image digest with improved error handling
out, err := exec.Command("docker", "inspect", version).CombinedOutput()
if err != nil {
return nil, xerrors.Errorf("failed to inspect image %s: %w\nOutput: %s",
Expand Down Expand Up @@ -2370,6 +2411,48 @@ func createDockerSubjectsFunction(version string, cfg DockerPkgConfig) func() ([
}
}

// createOCILayoutSubjectsFunction creates a function that generates SLSA provenance subjects
// by extracting the digest from OCI layout files (exportToCache workflow)
func createOCILayoutSubjectsFunction(version string, cfg DockerPkgConfig, buildDir string) func() ([]in_toto.Subject, error) {
return func() ([]in_toto.Subject, error) {
subjectLogger := log.WithField("operation", "provenance-subjects")
subjectLogger.Debug("Extracting digest from OCI layout")

ociLayoutPath := filepath.Join(buildDir, "image.tar.extracted")

// Extract image.tar to temporary directory
if err := os.MkdirAll(ociLayoutPath, 0755); err != nil {
return nil, xerrors.Errorf("failed to create OCI layout extraction directory: %w", err)
}
defer os.RemoveAll(ociLayoutPath)

// Extract image.tar
imageTarPath := filepath.Join(buildDir, "image.tar")
extractCmd := exec.Command("tar", "-xf", imageTarPath, "-C", ociLayoutPath)
if out, err := extractCmd.CombinedOutput(); err != nil {
return nil, xerrors.Errorf("failed to extract OCI layout: %w\nOutput: %s", err, string(out))
}

digest, err := extractDigestFromOCILayout(ociLayoutPath)
if err != nil {
return nil, xerrors.Errorf("failed to extract digest from OCI layout: %w", err)
}

subjectLogger.WithField("digest", digest).Debug("Found image digest from OCI layout")

// Create subjects for each image
result := make([]in_toto.Subject, 0, len(cfg.Image))
for _, tag := range cfg.Image {
result = append(result, in_toto.Subject{
Name: tag,
Digest: digest,
})
}

return result, nil
}
}

// DockerImageMetadata holds metadata for exported Docker images
type DockerImageMetadata struct {
ImageNames []string `json:"image_names" yaml:"image_names"`
Expand Down
217 changes: 217 additions & 0 deletions pkg/leeway/build_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,28 @@ func TestDockerPackage_OCILayout_Determinism_Integration(t *testing.T) {
t.Skip("Docker buildx not available, skipping integration test")
}

// Create docker-container builder for OCI export
builderName := "leeway-slsa-test-builder"
createBuilder := exec.Command("docker", "buildx", "create", "--name", builderName, "--driver", "docker-container", "--bootstrap")
if err := createBuilder.Run(); err != nil {
// Builder might already exist, try to use it
t.Logf("Warning: failed to create builder (might already exist): %v", err)
}
defer func() {
// Cleanup builder
exec.Command("docker", "buildx", "rm", builderName).Run()
}()

// Use the builder
useBuilder := exec.Command("docker", "buildx", "use", builderName)
if err := useBuilder.Run(); err != nil {
t.Fatalf("Failed to use builder: %v", err)
}
defer func() {
// Switch back to default
exec.Command("docker", "buildx", "use", "default").Run()
}()

// Create test workspace
tmpDir := t.TempDir()
wsDir := filepath.Join(tmpDir, "workspace")
Expand Down Expand Up @@ -752,3 +774,198 @@ func checksumFile(path string) (string, error) {

return fmt.Sprintf("%x", h.Sum(nil)), nil
}

// TestDockerPackage_OCILayout_SLSA_Integration tests that SLSA provenance generation
// works correctly with OCI layout export (regression test for docker inspect bug)
func TestDockerPackage_OCILayout_SLSA_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")
}

// Ensure buildx is available
if err := exec.Command("docker", "buildx", "version").Run(); err != nil {
t.Skip("Docker buildx not available, skipping integration test")
}

// Create docker-container builder for OCI export
builderName := "leeway-slsa-test-builder"
createBuilder := exec.Command("docker", "buildx", "create", "--name", builderName, "--driver", "docker-container", "--bootstrap")
if err := createBuilder.Run(); err != nil {
// Builder might already exist, try to use it
t.Logf("Warning: failed to create builder (might already exist): %v", err)
}
defer func() {
// Cleanup builder
exec.Command("docker", "buildx", "rm", builderName).Run()
}()

// Use the builder
useBuilder := exec.Command("docker", "buildx", "use", builderName)
if err := useBuilder.Run(); err != nil {
t.Fatalf("Failed to use builder: %v", err)
}
defer func() {
// Switch back to default
exec.Command("docker", "buildx", "use", "default").Run()
}()

// Create test workspace
tmpDir := t.TempDir()
wsDir := filepath.Join(tmpDir, "workspace")
if err := os.MkdirAll(wsDir, 0755); err != nil {
t.Fatal(err)
}

// Create WORKSPACE.yaml with SLSA enabled
workspaceYAML := `defaultTarget: ":test-image"
provenance:
slsa: true
`
if err := os.WriteFile(filepath.Join(wsDir, "WORKSPACE.yaml"), []byte(workspaceYAML), 0644); err != nil {
t.Fatal(err)
}

// Create Dockerfile
dockerfile := `FROM alpine:3.18
ARG SOURCE_DATE_EPOCH
RUN echo "Build time: $SOURCE_DATE_EPOCH" > /build-time.txt
CMD ["cat", "/build-time.txt"]
`
if err := os.WriteFile(filepath.Join(wsDir, "Dockerfile"), []byte(dockerfile), 0644); err != nil {
t.Fatal(err)
}

// Create BUILD.yaml with exportToCache
buildYAML := `packages:
- name: test-image
type: docker
config:
dockerfile: Dockerfile
image:
- localhost/leeway-slsa-test:latest
exportToCache: true
`
if err := os.WriteFile(filepath.Join(wsDir, "BUILD.yaml"), []byte(buildYAML), 0644); err != nil {
t.Fatal(err)
}

// Initialize git repo
gitInit := exec.Command("git", "init")
gitInit.Dir = wsDir
if err := gitInit.Run(); err != nil {
t.Fatal(err)
}

gitConfigName := exec.Command("git", "config", "user.name", "Test User")
gitConfigName.Dir = wsDir
if err := gitConfigName.Run(); err != nil {
t.Fatal(err)
}

gitConfigEmail := exec.Command("git", "config", "user.email", "test@example.com")
gitConfigEmail.Dir = wsDir
if err := gitConfigEmail.Run(); err != nil {
t.Fatal(err)
}

gitAdd := exec.Command("git", "add", ".")
gitAdd.Dir = wsDir
if err := gitAdd.Run(); err != nil {
t.Fatal(err)
}

gitCommit := exec.Command("git", "commit", "-m", "initial")
gitCommit.Dir = wsDir
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.Fatal(err)
}

// Build with SLSA enabled
cacheDir := filepath.Join(tmpDir, "cache")
cache, err := local.NewFilesystemCache(cacheDir)
if err != nil {
t.Fatal(err)
}

buildCtx, err := newBuildContext(buildOptions{
LocalCache: cache,
DockerExportToCache: true,
DockerExportSet: true,
Reporter: NewConsoleReporter(),
})
if err != nil {
t.Fatal(err)
}

ws, err := FindWorkspace(wsDir, Arguments{}, "", "")
if err != nil {
t.Fatal(err)
}

pkg, ok := ws.Packages["//:test-image"]
if !ok {
t.Fatal("package //:test-image not found")
}

// Build the package - this should trigger SLSA provenance generation
// which calls the Subjects function that extracts digest from OCI layout
if err := pkg.build(buildCtx); err != nil {
t.Fatalf("build failed: %v", err)
}

// Verify that the build succeeded and created the cache artifact
// Find the cache file (it might have a different name)
cacheFiles, err := filepath.Glob(filepath.Join(cacheDir, "*.tar.gz"))
if err != nil || len(cacheFiles) == 0 {
t.Fatal("No cache file found after build")
}
cachePath := cacheFiles[0]
t.Logf("Found cache artifact: %s", cachePath)

// Verify the OCI layout was created (image.tar inside the cache)
// This confirms that OCI export worked
f, err := os.Open(cachePath)
if err != nil {
t.Fatalf("failed to open cache file: %v", err)
}
defer f.Close()

gzr, err := gzip.NewReader(f)
if err != nil {
t.Fatalf("failed to create gzip reader: %v", err)
}
defer gzr.Close()

tr := tar.NewReader(gzr)
foundImageTar := false
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("failed to read tar: %v", err)
}
if hdr.Name == "./image.tar" {
foundImageTar = true
break
}
}

if !foundImageTar {
t.Fatal("image.tar not found in cache artifact (OCI layout not created)")
}

t.Logf("✅ Build succeeded with OCI layout export")
t.Logf("✅ No 'docker inspect' error occurred")
t.Logf("✅ This confirms the fix works: digest extracted from OCI layout instead of Docker daemon")
}
Loading
Loading