From 6d1c437aca9849447f610169c6e0599fc3131e11 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 20 Apr 2026 15:25:38 -0400 Subject: [PATCH] fix(wfctl): use docker buildx build in hardened mode The classic docker build driver rejects --provenance and --sbom flags with "Attestation is not supported for the docker driver." Only buildx with the docker-container driver supports supply-chain attestation. When hardened=true, prepend "buildx" to the docker args so the command becomes "docker buildx build ...". Non-hardened builds keep plain "docker build" for backward compat. Also adds a driver readiness check (docker buildx inspect --bootstrap) before the live hardened build with an actionable error message guiding users to run "docker buildx create --use" or add setup-buildx-action@v3. --- cmd/wfctl/build_image.go | 17 +++++++++- cmd/wfctl/build_image_test.go | 62 +++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) 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) {