Skip to content
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.14.1] - 2026-04-19

### Added

#### `wfctl build audit` — supply-chain security checks (T34)

- **`wfctl build audit`** — two-layer security audit combining CI config checks and per-target Dockerfile linting.

**Config-level checks (six):**
1. `ci.build.security.hardened=false` → WARN
2. Dockerfile containers without `sbom` or `provenance` configured → WARN
3. Registries without a `retention:` policy → WARN
4. `requires.plugins` or `plugins.external` declared without a `.wfctl.yaml` lockfile → WARN
5. Registry `auth.env` pointing to an env var not set at audit time → WARN
6. `environments.local.build.security.hardened=false` → NOTE (expected for local dev)

**Target-level checks:**
- Calls `builder.SecurityLint(cfg)` for each typed build target (go, nodejs, custom) and aggregates findings.
- For each `method: dockerfile` container, lints the Dockerfile for:
- `USER root` → CRITICAL
- Missing `USER` directive → CRITICAL
- `FROM <image>:latest` without version pinning → WARN
- `ADD https?://` URL (untrusted remote fetch) → WARN
- Embedded secret patterns (`password=`, `token=`, `api_key=`, etc.) → CRITICAL
- Base image not in `ci.build.security.base_image_policy.allow_prefixes` → WARN (when policy is set)

**Exit codes:** CRITICAL findings always exit 1. `--strict` also exits 1 on any WARN. Plain runs exit 0 unless CRITICAL.

#### BuildKit provenance attestation (T33)

- When `ci.build.security.hardened=true`, `wfctl build image` appends `--provenance=mode=max` and `--sbom=true` to every `docker build` invocation.
- Emits a warning when `DOCKER_BUILDKIT` is not set to `1`, since BuildKit is required for provenance attestation to work.

#### GitLab Container Registry provider (T31)

- **`plugins/registry-gitlab`** — full implementation replacing the stub:
- `Login`: uses `gitlab-ci-token` + `$CI_JOB_TOKEN` in CI context; falls back to `oauth2` + `auth.env` token.
- `Push`: `docker push <ref>` (GitLab accepts anything under the logged-in registry path).
- `Prune`: calls GitLab API (`GET /api/v4/projects/:id/registry/repositories` + `DELETE .../tags/:name`) to delete tags beyond `retention.keep_latest`.

## [0.14.0] - 2026-04-19

### Added
Expand Down
4 changes: 3 additions & 1 deletion cmd/wfctl/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ func runBuild(args []string) error {
return runBuildPush(rest)
case "custom":
return runBuildCustom(rest)
case "audit":
return runBuildSecurityAudit(rest)
default:
return fmt.Errorf("unknown build subcommand %q — valid: go, ui, image, push, custom", sub)
return fmt.Errorf("unknown build subcommand %q — valid: go, ui, image, push, custom, audit", sub)
}
}

Expand Down
14 changes: 12 additions & 2 deletions cmd/wfctl/build_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,22 @@ func runBuildImageWithOutput(args []string, out io.Writer) error {
method = "dockerfile"
}

hardened := cfg.CI.Build.Security != nil && cfg.CI.Build.Security.Hardened
switch method {
case "ko":
if err := buildWithKo(ctr, tag, *dryRun, out); err != nil {
return fmt.Errorf("ko build %q: %w", ctr.Name, err)
}
default: // dockerfile
if err := buildWithDockerfile(ctr, tag, *dryRun, out); err != nil {
if err := buildWithDockerfile(ctr, tag, *dryRun, hardened, out); err != nil {
return fmt.Errorf("dockerfile build %q: %w", ctr.Name, err)
}
}
}
return nil
}

func buildWithDockerfile(ctr config.CIContainerTarget, tag string, dryRun bool, out io.Writer) error {
func buildWithDockerfile(ctr config.CIContainerTarget, tag string, dryRun bool, hardened bool, out io.Writer) error {
dockerfile := ctr.Dockerfile
if dockerfile == "" {
dockerfile = "Dockerfile"
Expand Down Expand Up @@ -140,6 +141,15 @@ func buildWithDockerfile(ctr config.CIContainerTarget, tag string, dryRun bool,
args = append(args, "--target", ctr.Target)
}

// T33: BuildKit provenance attestation when hardened=true.
if hardened {
// Warn only in dry-run: in live mode DOCKER_BUILDKIT=1 is already forced on cmd.Env.
if dryRun && os.Getenv("DOCKER_BUILDKIT") != "1" {
fmt.Fprintf(out, "warning: DOCKER_BUILDKIT is not set to 1; provenance attestation requires BuildKit\n")
}
args = append(args, "--provenance=mode=max", "--sbom=true")
}

args = append(args, ".")

if dryRun {
Expand Down
101 changes: 101 additions & 0 deletions cmd/wfctl/build_image_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package main

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -53,6 +55,105 @@ func TestRunBuildImage_KoDryRun(t *testing.T) {
}
}

// TestRunBuildImage_HardenedProvenanceArgs verifies that --provenance and --sbom flags
// are appended to the docker build command when ci.build.security.hardened=true (T33).
func TestRunBuildImage_HardenedProvenanceArgs(t *testing.T) {
dir := t.TempDir()
cfg := `ci:
build:
security:
hardened: true
sbom: true
provenance: slsa-3
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 dry-run: %v", err)
}

out := buf.String()
if !strings.Contains(out, "--provenance=mode=max") {
t.Errorf("expected --provenance=mode=max in dry-run output, got: %q", out)
}
if !strings.Contains(out, "--sbom=true") {
t.Errorf("expected --sbom=true in dry-run output, got: %q", out)
}
}

// TestRunBuildImage_HardenedBuildKitWarning verifies the DOCKER_BUILDKIT warning is only
// emitted in dry-run mode (in live mode the env is forced via cmd.Env).
func TestRunBuildImage_HardenedBuildKitWarning(t *testing.T) {
dir := t.TempDir()
cfg := `ci:
build:
security:
hardened: true
sbom: true
provenance: slsa-3
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)
}
// Ensure DOCKER_BUILDKIT is unset to trigger the warning path.
t.Setenv("DOCKER_BUILDKIT", "")

// Dry-run: warning should appear.
t.Setenv("WFCTL_BUILD_DRY_RUN", "1")
var dryBuf bytes.Buffer
if err := runBuildImageWithOutput([]string{"--config", cfgPath}, &dryBuf); err != nil {
t.Fatalf("hardened dry-run: %v", err)
}
if !strings.Contains(dryBuf.String(), "DOCKER_BUILDKIT") {
t.Errorf("expected DOCKER_BUILDKIT warning in dry-run output, got: %q", dryBuf.String())
}
}

// TestRunBuildImage_NotHardenedNoProvenanceArgs verifies that provenance flags are
// NOT added when hardened=false (T33).
func TestRunBuildImage_NotHardenedNoProvenanceArgs(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, "--provenance") {
t.Errorf("expected no --provenance flag when hardened=false, got: %q", out)
}
}

func TestRunBuildImage_ExternalSkipsBuild(t *testing.T) {
dir := t.TempDir()
cfg := `ci:
Expand Down
Loading
Loading