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 +} 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