diff --git a/cmd/wfctl/build_image.go b/cmd/wfctl/build_image.go index c55cb794..8fedfc96 100644 --- a/cmd/wfctl/build_image.go +++ b/cmd/wfctl/build_image.go @@ -99,7 +99,13 @@ func buildWithDockerfile(ctr config.CIContainerTarget, tag string, dryRun bool, } imageRef := imageRefForContainer(ctr, tag, registries) - args := []string{"build", "--file", dockerfile, "--tag", imageRef} + // hardened mode uses buildx for provenance/SBOM attestation support. + var args []string + if hardened { + args = []string{"buildx", "build", "--file", dockerfile, "--tag", imageRef} + } else { + args = []string{"build", "--file", dockerfile, "--tag", imageRef} + } // Platforms (BuildKit multi-arch). if len(ctr.Platforms) > 0 { @@ -157,6 +163,15 @@ func buildWithDockerfile(ctr config.CIContainerTarget, tag string, dryRun bool, return nil } + if hardened { + // buildx with the docker-container driver is required for attestation flags. + // Verify a non-default builder is active; the default "docker" driver rejects --provenance. + if err := exec.Command("docker", "buildx", "inspect", "--bootstrap").Run(); err != nil { + return fmt.Errorf("hardened build requires docker buildx: run 'docker buildx create --use' " + + "or add 'docker/setup-buildx-action@v3' to your CI workflow (%w)", err) + } + } + //nolint:gosec // G204: docker command constructed from validated config fields cmd := exec.Command("docker", args...) cmd.Stdout = out diff --git a/cmd/wfctl/build_image_test.go b/cmd/wfctl/build_image_test.go index 11d3f4de..33501d0f 100644 --- a/cmd/wfctl/build_image_test.go +++ b/cmd/wfctl/build_image_test.go @@ -156,6 +156,68 @@ func TestRunBuildImage_NotHardenedNoProvenanceArgs(t *testing.T) { } } +// TestRunBuildImage_HardenedUsesBuildx verifies hardened=true produces "docker buildx build". +func TestRunBuildImage_HardenedUsesBuildx(t *testing.T) { + dir := t.TempDir() + cfg := `ci: + build: + security: + hardened: true + containers: + - name: app + method: dockerfile + dockerfile: Dockerfile +` + cfgPath := filepath.Join(dir, "ci.yaml") + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("WFCTL_BUILD_DRY_RUN", "1") + t.Setenv("DOCKER_BUILDKIT", "1") + + var buf bytes.Buffer + if err := runBuildImageWithOutput([]string{"--config", cfgPath}, &buf); err != nil { + t.Fatalf("hardened buildx dry-run: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "docker buildx build") { + t.Errorf("expected 'docker buildx build' in hardened dry-run output, got: %q", out) + } +} + +// TestRunBuildImage_NonHardenedUsesPlainDocker verifies hardened=false produces "docker build" (no buildx). +func TestRunBuildImage_NonHardenedUsesPlainDocker(t *testing.T) { + dir := t.TempDir() + cfg := `ci: + build: + security: + hardened: false + containers: + - name: app + method: dockerfile + dockerfile: Dockerfile +` + cfgPath := filepath.Join(dir, "ci.yaml") + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("WFCTL_BUILD_DRY_RUN", "1") + + var buf bytes.Buffer + if err := runBuildImageWithOutput([]string{"--config", cfgPath}, &buf); err != nil { + t.Fatalf("non-hardened dry-run: %v", err) + } + + out := buf.String() + if strings.Contains(out, "buildx") { + t.Errorf("expected no 'buildx' in non-hardened dry-run output, got: %q", out) + } + if !strings.Contains(out, "docker build") { + t.Errorf("expected 'docker build' in non-hardened dry-run output, got: %q", out) + } +} + // TestImageRefForContainer_RegistryNameResolvesToPath verifies that imageRefForContainer // resolves a registry name to its path via ci.registries. func TestImageRefForContainer_RegistryNameResolvesToPath(t *testing.T) {