From 81e2ca58cb636258000bbad6d3c7a77d08e9d2ff Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 9 May 2026 01:34:19 -0400 Subject: [PATCH 1/2] test(wfctl): failing test for infra audit-secrets command Adds TestInfraAuditSecrets_TwoEntryAntiPattern and TestInfraAuditSecrets_CanonicalShape_Passes covering Task 1 of plan docs/plans/2026-05-08-spaces-key-iac-resource.md. Test currently fails with `undefined: runInfraAuditSecrets`. Task 2 implements the command to make these pass. Co-Authored-By: Claude Opus 4.7 --- cmd/wfctl/infra_audit_secrets_test.go | 58 +++++++++++++++++++ .../testdata/audit-secrets/two-entry-bad.yaml | 10 ++++ 2 files changed, 68 insertions(+) create mode 100644 cmd/wfctl/infra_audit_secrets_test.go create mode 100644 cmd/wfctl/testdata/audit-secrets/two-entry-bad.yaml diff --git a/cmd/wfctl/infra_audit_secrets_test.go b/cmd/wfctl/infra_audit_secrets_test.go new file mode 100644 index 000000000..0ceaf973b --- /dev/null +++ b/cmd/wfctl/infra_audit_secrets_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestInfraAuditSecrets_TwoEntryAntiPattern(t *testing.T) { + tmp := t.TempDir() + cfg := filepath.Join(tmp, "infra.yaml") + if err := os.WriteFile(cfg, []byte(`secrets: + generate: + - key: SPACES_access_key + type: provider_credential + source: digitalocean.spaces + name: test-key + - key: SPACES_secret_key + type: provider_credential + source: digitalocean.spaces + name: test-key +`), 0644); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + exitCode := runInfraAuditSecrets([]string{"-c", cfg}, &out) + if exitCode == 0 { + t.Fatalf("expected non-zero exit on anti-pattern; got 0\nout=%s", out.String()) + } + if !bytes.Contains(out.Bytes(), []byte("two-entry provider_credential")) { + t.Errorf("expected 'two-entry provider_credential' in output; got: %s", out.String()) + } + if !bytes.Contains(out.Bytes(), []byte("SPACES_access_key")) { + t.Errorf("expected offending key 'SPACES_access_key' in output; got: %s", out.String()) + } +} + +func TestInfraAuditSecrets_CanonicalShape_Passes(t *testing.T) { + tmp := t.TempDir() + cfg := filepath.Join(tmp, "infra.yaml") + if err := os.WriteFile(cfg, []byte(`secrets: + generate: + - key: SPACES + type: provider_credential + source: digitalocean.spaces + name: test-key +`), 0644); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + exitCode := runInfraAuditSecrets([]string{"-c", cfg}, &out) + if exitCode != 0 { + t.Fatalf("expected zero exit on canonical shape; got %d\nout=%s", exitCode, out.String()) + } +} diff --git a/cmd/wfctl/testdata/audit-secrets/two-entry-bad.yaml b/cmd/wfctl/testdata/audit-secrets/two-entry-bad.yaml new file mode 100644 index 000000000..f0290bdc3 --- /dev/null +++ b/cmd/wfctl/testdata/audit-secrets/two-entry-bad.yaml @@ -0,0 +1,10 @@ +secrets: + generate: + - key: SPACES_access_key + type: provider_credential + source: digitalocean.spaces + name: test-key + - key: SPACES_secret_key + type: provider_credential + source: digitalocean.spaces + name: test-key From da8a9d14dda223018bc31b15e520195e5d410ba5 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 9 May 2026 01:38:36 -0400 Subject: [PATCH 2/2] feat(wfctl): infra audit-secrets reports provider_credential anti-patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `wfctl infra audit-secrets` per Task 2 of plan docs/plans/2026-05-08-spaces-key-iac-resource.md (commit 316559f7). The command reads `secrets.generate` from infra.yaml (or path passed via -c/--config) and reports three anti-patterns, exiting non-zero on any finding so CI can block bad configs before plan/apply: 1. Two-entry provider_credential — keys ending in `_access_key` / `_secret_key` with type=provider_credential. Canonical shape uses a single bundle key (e.g. SPACES); the bootstrap layer auto-derives sub-keys from providerCredentialSubKeys. 2. Duplicate provider_credential `name` across multiple entries — each such entry creates a separate cloud resource (the doubled-create symptom that motivated this audit). 3. Unknown provider_credential `source` — workflow doesn't know how to derive sub-keys; likely a stale config or version mismatch. Registered under the `infra` subcommand group in cmd/wfctl/infra.go. Failing tests from the previous commit now PASS: GOWORK=off go test ./cmd/wfctl -run TestInfraAuditSecrets -v Representative invocation against the bad fixture exits 1 with three FINDING lines as expected. Co-Authored-By: Claude Opus 4.7 --- cmd/wfctl/infra.go | 6 ++ cmd/wfctl/infra_audit_secrets.go | 94 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 cmd/wfctl/infra_audit_secrets.go diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 26d4e7c0a..befa1e33b 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -85,6 +85,11 @@ func runInfra(args []string) error { return runInfraSecurityCheck(args[1:]) case "cleanup": return runInfraCleanup(args[1:]) + case "audit-secrets": + if rc := runInfraAuditSecrets(args[1:], os.Stdout); rc != 0 { + return fmt.Errorf("audit-secrets exited with code %d", rc) + } + return nil default: return infraUsage() } @@ -108,6 +113,7 @@ Actions: align Validate IaC config + plan alignment (8 rule families) security-check Scan plan.json for security policy violations cleanup Tag-based force-cleanup across providers (--tag NAME [--fix]) + audit-secrets Report provider_credential anti-patterns in secrets.generate Options: --config Config file (default: infra.yaml or config/infra.yaml) diff --git a/cmd/wfctl/infra_audit_secrets.go b/cmd/wfctl/infra_audit_secrets.go new file mode 100644 index 000000000..8b4fa907b --- /dev/null +++ b/cmd/wfctl/infra_audit_secrets.go @@ -0,0 +1,94 @@ +package main + +import ( + "flag" + "fmt" + "io" + "strings" +) + +// runInfraAuditSecrets reads infra.yaml's secrets.generate block and reports +// known anti-patterns. Exit non-zero on any finding so CI blocks misconfiguration +// before plan/apply. +// +// Detected anti-patterns: +// 1. Two-entry provider_credential: keys ending in `_access_key` or `_secret_key` +// with type=provider_credential AND a known source. The canonical shape uses +// a single entry whose key is the credential bundle name (e.g. SPACES); the +// bootstrap layer auto-derives the sub-keys (SPACES_access_key / +// SPACES_secret_key) from providerCredentialSubKeys. +// 2. Same `name` across multiple provider_credential entries (the doubled-create +// symptom — each entry creates a separate cloud resource). +// 3. provider_credential with source whose subkeys are not in +// providerCredentialSubKeys (lurking misconfiguration; the bootstrap layer +// wouldn't know how to derive sub-keys). +func runInfraAuditSecrets(args []string, w io.Writer) int { + fs := flag.NewFlagSet("infra audit-secrets", flag.ContinueOnError) + fs.SetOutput(w) + var configFile string + fs.StringVar(&configFile, "c", "infra.yaml", "Config file") + fs.StringVar(&configFile, "config", "infra.yaml", "Config file") + if err := fs.Parse(args); err != nil { + return 2 + } + + cfg, err := parseSecretsConfig(configFile) + if err != nil { + fmt.Fprintf(w, "audit-secrets: parse %q: %v\n", configFile, err) + return 2 + } + if cfg == nil { + fmt.Fprintln(w, "audit-secrets: no findings") + return 0 + } + + findings := 0 + seenNames := map[string]string{} // name -> first key seen with that name + + for _, gen := range cfg.Generate { + if gen.Type != "provider_credential" { + continue + } + // Anti-pattern 1: key ending in _access_key/_secret_key + if hasSubKeySuffix(gen.Key) { + fmt.Fprintf(w, "FINDING (two-entry provider_credential): key %q must be canonical (e.g. %q), not the auto-derived sub-key\n", + gen.Key, stripSubKeySuffix(gen.Key)) + findings++ + } + // Anti-pattern 2: same name across multiple entries + if gen.Name != "" { + if prior, ok := seenNames[gen.Name]; ok { + fmt.Fprintf(w, "FINDING (duplicate provider_credential name): name %q used by both %q and %q (each entry creates a separate cloud resource)\n", + gen.Name, prior, gen.Key) + findings++ + } + seenNames[gen.Name] = gen.Key + } + // Anti-pattern 3: unknown source + if _, ok := providerCredentialSubKeys[gen.Source]; !ok { + fmt.Fprintf(w, "FINDING (unknown provider_credential source): %q has no subkey mapping; check workflow version supports source\n", gen.Source) + findings++ + } + } + + if findings > 0 { + fmt.Fprintf(w, "\naudit-secrets: %d finding(s) — see https://docs.gocodealone.com/workflow/wfctl/infra-audit-secrets\n", findings) + return 1 + } + fmt.Fprintln(w, "audit-secrets: no findings") + return 0 +} + +func hasSubKeySuffix(key string) bool { + return strings.HasSuffix(key, "_access_key") || strings.HasSuffix(key, "_secret_key") +} + +func stripSubKeySuffix(key string) string { + if strings.HasSuffix(key, "_access_key") { + return key[:len(key)-len("_access_key")] + } + if strings.HasSuffix(key, "_secret_key") { + return key[:len(key)-len("_secret_key")] + } + return key +}