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
17 changes: 16 additions & 1 deletion cmd/wfctl/build_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In hardened mode this switches to docker buildx build, but no output mode is specified. With the recommended docker-container driver, buildx build does not load the image into the local Docker image store by default, which will break subsequent wfctl build push/docker push steps that expect the tagged image to exist locally. Consider adding an explicit output mode (e.g., load for single-platform builds or push when appropriate) and adjusting tests accordingly.

Suggested change
args = []string{"buildx", "build", "--file", dockerfile, "--tag", imageRef}
args = []string{"buildx", "build", "--file", dockerfile, "--tag", imageRef}
// With the recommended docker-container driver, buildx does not load
// the resulting image into the local Docker image store unless an
// explicit output mode is set. Preserve the non-hardened local-build
// behavior for builds that can be loaded into the daemon.
if len(ctr.Platforms) <= 1 {
args = append(args, "--load")
}

Copilot uses AI. Check for mistakes.
} else {
args = []string{"build", "--file", dockerfile, "--tag", imageRef}
}

// Platforms (BuildKit multi-arch).
if len(ctr.Platforms) > 0 {
Expand Down Expand Up @@ -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)
}
Comment on lines +166 to +172

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The buildx readiness check runs docker buildx inspect --bootstrap but doesn’t actually verify that the active builder is using a driver that supports attestations (the default "docker" driver can still inspect successfully, and the subsequent build will fail with the same attestation error). Consider checking the inspect output (or using a formatted inspect) to assert the driver is non-docker (e.g., docker-container), and include stderr/stdout in the returned error to make failures actionable.

Copilot uses AI. Check for mistakes.
}

//nolint:gosec // G204: docker command constructed from validated config fields
cmd := exec.Command("docker", args...)
cmd.Stdout = out
Expand Down
62 changes: 62 additions & 0 deletions cmd/wfctl/build_image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading