diff --git a/.gitignore b/.gitignore index aaa3940..797b9a3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ /export/ *.test coverage.out +.atl/ diff --git a/README.md b/README.md index d615394..2ab70c9 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ The hybrid export combines: | `--token` | Personal Access Token | | `--output` | Output directory (default `./export`) | | `--include-applications` | Include applications and accounts (paginated) | -| `--redact-secrets` | Mask API keys and OIDC secrets | +| `--redact-secrets` | Opt-in: mask sensitive keys in JSON/YAML artifacts (see [Redaction](#redaction) below) | | `--per-page` | Admin API page size (max 500) | | `--concurrency` | Concurrent requests (default 4) | | `--insecure` | Skip TLS verification on Admin API | @@ -98,6 +98,20 @@ Self-signed TLS (Admin Portal or toolbox): --output ./export ``` +### Redaction + +`--redact-secrets` is **opt-in** (default off). When set, every `.json`, `.yaml`, and `.yml` file under the export root is processed before `manifest.json` is written. + +**Fully redacted keys** (value becomes `***REDACTED***`): + +`access_token`, `api_key`, `app_id`, `app_key`, `client_id`, `client_secret`, `provider_key`, `provider_verification_key`, `secret`, `user_key` + +**Issuer URL stripping** (`issuer_endpoint`, `oidc_issuer_endpoint`): embedded credentials are removed (`https://user:pass@host/path` → `https://host/path`); host, path, and query stay visible. + +**Preserved auth-mode flags** (not secrets): `auth_user_key`, `auth_app_id`, `auth_app_key` + +After redaction, a cleartext scan runs over the same artifacts. If any sensitive value or issuer userinfo remains, **export fails** with a path-qualified error. + ### Visualize the export Optional: generate a Markdown report from the export directory (no Admin API or containers): diff --git a/docs/TEST_CASES.md b/docs/TEST_CASES.md index 06c1870..bac0536 100644 --- a/docs/TEST_CASES.md +++ b/docs/TEST_CASES.md @@ -102,22 +102,36 @@ Automation references are verified against the repository at the time of writing |-------|-------| | Priority | P0 | | CLI | `threescale-export` | -| Automation | `TestExportRedactSecrets` (`internal/export/exporter_test.go`), `TestRedactJSONRemovesCleartextSecrets`, `TestRedactYAMLRemovesCleartextSecrets`, `TestRedactDirectory` (`internal/export/redact_test.go`) | +| Automation | `TestExportRedactSecrets`, `TestExportWithoutRedactPreservesSecrets`, `TestExportRedactSecretsVerifyGateFailsOnResidual` (`internal/export/exporter_test.go`); `TestVerifyNoCleartextSecretsClean`, `TestVerifyNoCleartextSecretsFailsWithPath` (`internal/export/verify_test.go`); `TestRedactExtendedSensitiveKeys`, `TestRedactIssuerStripsUserinfoJSON`, `TestRedactPreservesAuthProxyFlags`, `TestContainsCleartextSecretYAMLIssuerUserinfo` (`internal/export/redact_test.go`) | **Preconditions** - Same as TC-EXP-001 -- Export includes credentials (API keys, OIDC secrets, etc.) +- Export includes credentials (API keys, OIDC secrets, application identifiers, issuer URLs with embedded credentials, etc.) **Steps** 1. Run `threescale-export --output ./export --redact-secrets --include-applications` -2. Search output for cleartext secrets +2. Search output for cleartext secrets and issuer URL userinfo **Expected results** -- Sensitive values replaced with `***REDACTED***` in JSON and YAML artifacts -- Export completes without error +- Core and extended sensitive keys (`provider_verification_key`, `client_id`, `app_id`, plus existing secret keys) replaced with `***REDACTED***` in JSON and YAML artifacts +- `issuer_endpoint` and `oidc_issuer_endpoint` have URL userinfo stripped; host and path remain visible +- `auth_user_key`, `auth_app_id`, and `auth_app_key` are unchanged (auth-mode flags, not secrets) +- Post-redaction cleartext scan passes; export completes without error +- If cleartext remains after redaction, export fails with a path-qualified error (e.g. `cleartext secret in products/api/proxy.json`) + +**Opt-in default (no flag)** + +| Field | Value | +|-------|-------| +| Automation | `TestExportWithoutRedactPreservesSecrets` (`internal/export/exporter_test.go`) | + +**Expected results (without `--redact-secrets`)** + +- Credential values remain cleartext in JSON and YAML artifacts +- No `***REDACTED***` markers in output --- diff --git a/internal/config/config.go b/internal/config/config.go index 3c82db9..c28582f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,7 +62,7 @@ func BindExportFlags(fs *pflag.FlagSet, cfg *ExportConfig) { BindAuthFlags(fs, &cfg.AuthConfig) fs.StringVar(&cfg.OutDir, "output", cfg.OutDir, "export output directory") fs.BoolVar(&cfg.IncludeApplications, "include-applications", cfg.IncludeApplications, "export applications and linked accounts") - fs.BoolVar(&cfg.RedactSecrets, "redact-secrets", cfg.RedactSecrets, "mask API keys and OIDC secrets in output") + fs.BoolVar(&cfg.RedactSecrets, "redact-secrets", cfg.RedactSecrets, "mask sensitive keys in JSON/YAML output (secrets to ***REDACTED***, issuer URLs strip embedded credentials; export fails if cleartext remains)") fs.BoolVar(&cfg.Strict, "strict", cfg.Strict, "fail export if any product sidecar JSON cannot be fetched") fs.IntVar(&cfg.PerPage, "per-page", cfg.PerPage, "Admin API page size (max 500)") fs.IntVar(&cfg.MaxConcurrent, "concurrency", cfg.MaxConcurrent, "max concurrent Admin API requests") diff --git a/internal/export/exporter.go b/internal/export/exporter.go index 813b993..3633981 100644 --- a/internal/export/exporter.go +++ b/internal/export/exporter.go @@ -121,6 +121,9 @@ func (s *Service) Export(ctx context.Context, opts Options) (*output.Manifest, e if err := RedactDirectory(writer.Root()); err != nil { return manifest, err } + if err := VerifyNoCleartextSecrets(writer.Root()); err != nil { + return manifest, err + } } if err := writer.WriteManifest(manifest); err != nil { diff --git a/internal/export/exporter_test.go b/internal/export/exporter_test.go index 7743ac6..d51c8ed 100644 --- a/internal/export/exporter_test.go +++ b/internal/export/exporter_test.go @@ -156,7 +156,7 @@ func TestExportIncludeApplications(t *testing.T) { } } -func TestExportRedactSecrets(t *testing.T) { +func TestExportWithoutRedactPreservesSecrets(t *testing.T) { dir := t.TempDir() client := &mockClient{ responses: map[string]any{ @@ -168,18 +168,130 @@ func TestExportRedactSecrets(t *testing.T) { "/services/1/proxy": map[string]any{ "proxy": map[string]any{"api_key": "secret-value"}, }, + "/services/1/proxy/policies": map[string]any{"policies": []any{}}, + "/services/1/proxy/oidc_configuration": map[string]any{ + "oidc_configuration": map[string]any{ + "oidc_issuer_endpoint": "https://zync-user:zync-pass@idp.example.com/realms/demo", + "client_id": "oidc-client-id", + }, + }, + "/services/1/application_plans": map[string]any{"plans": []any{}}, + "/services/1/backend_usages": map[string]any{"backend_usages": []any{}}, + "/services/1/metrics": map[string]any{"metrics": []any{}}, + "/accounts/7": map[string]any{ + "account": map[string]any{"id": 7}, + }, + }, + pages: map[string][][]json.RawMessage{ + "/applications": { + {json.RawMessage(`{"application":{"id":1,"account_id":7,"provider_verification_key":"pvk-live","client_id":"app-client","app_id":"app-99"}}`)}, + }, + }, + } + toolbox := &mockToolbox{outputs: map[string][]byte{ + "api": []byte("credentials:\n client_secret: yaml-secret\n client_id: yaml-client\n"), + }} + + svc := NewService(client, toolbox) + _, err := svc.Export(context.Background(), Options{ + AdminURL: "https://tenant.example.com", + Token: "tok", + OutDir: dir, + IncludeApplications: true, + }) + if err != nil { + t.Fatal(err) + } + + proxyData, err := os.ReadFile(filepath.Join(dir, "products", "api", "proxy.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(proxyData), "secret-value") { + t.Fatalf("expected cleartext api_key in proxy: %s", proxyData) + } + + oidcData, err := os.ReadFile(filepath.Join(dir, "products", "api", "oidc_configuration.json")) + if err != nil { + t.Fatal(err) + } + oidcStr := string(oidcData) + if !strings.Contains(oidcStr, "zync-user") || !strings.Contains(oidcStr, "oidc-client-id") { + t.Fatalf("expected cleartext OIDC fields: %s", oidcData) + } + + appData, err := os.ReadFile(filepath.Join(dir, "applications", "page-1.json")) + if err != nil { + t.Fatal(err) + } + appStr := string(appData) + for _, want := range []string{"pvk-live", "app-client", "app-99"} { + if !strings.Contains(appStr, want) { + t.Fatalf("expected cleartext %q in applications: %s", want, appData) + } + } + + yamlData, err := os.ReadFile(filepath.Join(dir, "products", "api.yaml")) + if err != nil { + t.Fatal(err) + } + yamlStr := string(yamlData) + if !strings.Contains(yamlStr, "yaml-secret") || !strings.Contains(yamlStr, "yaml-client") { + t.Fatalf("expected cleartext YAML credentials: %s", yamlData) + } + if strings.Contains(yamlStr, "***REDACTED***") { + t.Fatalf("unexpected redaction marker without --redact-secrets: %s", yamlData) + } +} + +func TestExportRedactSecrets(t *testing.T) { + dir := t.TempDir() + client := &mockClient{ + responses: map[string]any{ + "/services": serviceListResponse{ + Services: []serviceEntry{{Service: serviceRef{ID: 1, SystemName: "api"}}}, + }, + "/backend_apis": backendListResponse{}, + "/policies": map[string]any{}, + "/services/1/proxy": map[string]any{ + "proxy": map[string]any{ + "api_key": "secret-value", + "auth_user_key": "user_key", + "auth_app_id": "app_id", + "auth_app_key": "app_key", + }, + }, + "/services/1/proxy/policies": map[string]any{"policies": []any{}}, + "/services/1/proxy/oidc_configuration": map[string]any{ + "oidc_configuration": map[string]any{ + "oidc_issuer_endpoint": "https://zync-user:zync-pass@idp.example.com/realms/demo", + "client_id": "oidc-client-id", + }, + }, + "/services/1/application_plans": map[string]any{"plans": []any{}}, + "/services/1/backend_usages": map[string]any{"backend_usages": []any{}}, + "/services/1/metrics": map[string]any{"metrics": []any{}}, + "/accounts/7": map[string]any{ + "account": map[string]any{"id": 7}, + }, + }, + pages: map[string][][]json.RawMessage{ + "/applications": { + {json.RawMessage(`{"application":{"id":1,"account_id":7,"provider_verification_key":"pvk-live","client_id":"app-client","app_id":"app-99"}}`)}, + }, }, } toolbox := &mockToolbox{outputs: map[string][]byte{ - "api": []byte("credentials:\n client_secret: yaml-secret\n"), + "api": []byte("credentials:\n client_secret: yaml-secret\n client_id: yaml-client\n"), }} svc := NewService(client, toolbox) _, err := svc.Export(context.Background(), Options{ - AdminURL: "https://tenant.example.com", - Token: "tok", - OutDir: dir, - RedactSecrets: true, + AdminURL: "https://tenant.example.com", + Token: "tok", + OutDir: dir, + RedactSecrets: true, + IncludeApplications: true, }) if err != nil { t.Fatal(err) @@ -192,13 +304,68 @@ func TestExportRedactSecrets(t *testing.T) { if ContainsCleartextSecret(proxyData) { t.Fatalf("proxy still has secret: %s", proxyData) } + proxyStr := string(proxyData) + for _, want := range []string{`"auth_user_key": "user_key"`, `"auth_app_id": "app_id"`, `"auth_app_key": "app_key"`} { + if !strings.Contains(proxyStr, want) { + t.Fatalf("auth flag missing %q in %s", want, proxyData) + } + } + + oidcData, err := os.ReadFile(filepath.Join(dir, "products", "api", "oidc_configuration.json")) + if err != nil { + t.Fatal(err) + } + oidcStr := string(oidcData) + if strings.Contains(oidcStr, "zync-user") || strings.Contains(oidcStr, "zync-pass") { + t.Fatalf("issuer userinfo still present: %s", oidcData) + } + if !strings.Contains(oidcStr, "https://idp.example.com/realms/demo") { + t.Fatalf("expected stripped issuer host in %s", oidcData) + } + if strings.Contains(oidcStr, "oidc-client-id") { + t.Fatalf("client_id not redacted: %s", oidcData) + } + + appData, err := os.ReadFile(filepath.Join(dir, "applications", "page-1.json")) + if err != nil { + t.Fatal(err) + } + if ContainsCleartextSecret(appData) { + t.Fatalf("applications still have secrets: %s", appData) + } + yamlData, err := os.ReadFile(filepath.Join(dir, "products", "api.yaml")) if err != nil { t.Fatal(err) } - if strings.Contains(string(yamlData), "yaml-secret") { + if strings.Contains(string(yamlData), "yaml-secret") || strings.Contains(string(yamlData), "yaml-client") { t.Fatalf("yaml still has secret: %s", yamlData) } + + if err := VerifyNoCleartextSecrets(dir); err != nil { + t.Fatalf("post-export verify gate failed: %v", err) + } +} + +func TestExportRedactSecretsVerifyGateFailsOnResidual(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "products", "api"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(dir, "products", "api", "proxy.json"), + []byte(`{"proxy":{"client_secret":"not-redacted"}}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + err := VerifyNoCleartextSecrets(dir) + if err == nil { + t.Fatal("expected cleartext gate error") + } + if !strings.Contains(err.Error(), "products/api/proxy.json") { + t.Fatalf("error = %v, want path-qualified message", err) + } } func TestExportIncompleteOnBackendFailure(t *testing.T) { diff --git a/internal/export/redact.go b/internal/export/redact.go index 55076cf..74e1831 100644 --- a/internal/export/redact.go +++ b/internal/export/redact.go @@ -3,6 +3,7 @@ package export import ( "encoding/json" "fmt" + "net/url" "os" "path/filepath" "regexp" @@ -10,16 +11,26 @@ import ( ) var sensitiveJSONKeys = map[string]struct{}{ - "access_token": {}, - "client_secret": {}, - "secret": {}, - "api_key": {}, - "user_key": {}, - "app_key": {}, - "provider_key": {}, + "access_token": {}, + "client_secret": {}, + "secret": {}, + "api_key": {}, + "user_key": {}, + "app_key": {}, + "provider_key": {}, + "provider_verification_key": {}, + "client_id": {}, + "app_id": {}, } -var yamlSecretPattern = regexp.MustCompile(`(?m)^(\s*(?:access_token|client_secret|secret|api_key|user_key|app_key|provider_key)\s*:\s*).+$`) +var issuerJSONKeys = map[string]struct{}{ + "issuer_endpoint": {}, + "oidc_issuer_endpoint": {}, +} + +var yamlSecretPattern = regexp.MustCompile(`(?m)^(\s*(?:access_token|client_secret|secret|api_key|user_key|app_key|provider_key|provider_verification_key|client_id|app_id)\s*:\s*).+$`) + +var yamlIssuerPattern = regexp.MustCompile(`(?m)^(\s*(?:issuer_endpoint|oidc_issuer_endpoint)\s*:\s*)(.+)$`) const redactedValue = `"***REDACTED***"` @@ -65,18 +76,36 @@ func redactYAMLFile(path string) error { if err != nil { return err } - out := yamlSecretPattern.ReplaceAllString(string(data), `${1}`+redactedValue) + out := redactYAMLContent(string(data)) return os.WriteFile(path, []byte(out), 0o644) } +func redactYAMLContent(content string) string { + content = yamlSecretPattern.ReplaceAllString(content, `${1}`+redactedValue) + return yamlIssuerPattern.ReplaceAllStringFunc(content, func(match string) string { + sub := yamlIssuerPattern.FindStringSubmatch(match) + if len(sub) < 3 { + return match + } + return sub[1] + stripURLUserinfo(strings.TrimSpace(sub[2])) + }) +} + func redactJSONValue(v any) { switch node := v.(type) { case map[string]any: for k, val := range node { - if _, ok := sensitiveJSONKeys[strings.ToLower(k)]; ok { + lowerK := strings.ToLower(k) + if _, ok := sensitiveJSONKeys[lowerK]; ok { node[k] = "***REDACTED***" continue } + if _, ok := issuerJSONKeys[lowerK]; ok { + if s, ok := val.(string); ok { + node[k] = stripURLUserinfo(s) + } + continue + } redactJSONValue(val) } case []any: @@ -86,23 +115,73 @@ func redactJSONValue(v any) { } } +func stripURLUserinfo(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.User == nil { + return raw + } + u.User = nil + return u.String() +} + +func containsIssuerUserinfo(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + return u.User != nil +} + func ContainsCleartextSecret(data []byte) bool { var root any if err := json.Unmarshal(data, &root); err != nil { - return yamlSecretPattern.Match(data) + return yamlHasCleartextSecret(data) } return jsonHasCleartextSecret(root) } +func yamlHasCleartextSecret(data []byte) bool { + for _, line := range strings.Split(string(data), "\n") { + if sub := yamlSecretPattern.FindStringSubmatch(line); len(sub) == 2 { + val := yamlValueFromLine(sub[0]) + if val != "" && val != "***REDACTED***" { + return true + } + } + if sub := yamlIssuerPattern.FindStringSubmatch(line); len(sub) == 3 { + val := strings.TrimSpace(sub[2]) + val = strings.Trim(val, `"'`) + if containsIssuerUserinfo(val) { + return true + } + } + } + return false +} + +func yamlValueFromLine(line string) string { + parts := strings.SplitN(line, ":", 2) + if len(parts) < 2 { + return "" + } + return strings.Trim(strings.TrimSpace(parts[1]), `"'`) +} + func jsonHasCleartextSecret(v any) bool { switch node := v.(type) { case map[string]any: for k, val := range node { - if _, ok := sensitiveJSONKeys[strings.ToLower(k)]; ok { + lowerK := strings.ToLower(k) + if _, ok := sensitiveJSONKeys[lowerK]; ok { if s, ok := val.(string); ok && s != "" && s != "***REDACTED***" { return true } } + if _, ok := issuerJSONKeys[lowerK]; ok { + if s, ok := val.(string); ok && containsIssuerUserinfo(s) { + return true + } + } if jsonHasCleartextSecret(val) { return true } @@ -131,7 +210,7 @@ func RedactBytes(ext string, data []byte) ([]byte, error) { } return append(out, '\n'), nil case ".yaml", ".yml": - return []byte(yamlSecretPattern.ReplaceAllString(string(data), `${1}`+redactedValue)), nil + return []byte(redactYAMLContent(string(data))), nil default: return data, fmt.Errorf("unsupported extension %q", ext) } diff --git a/internal/export/redact_test.go b/internal/export/redact_test.go index 54cf39b..7cb8b1b 100644 --- a/internal/export/redact_test.go +++ b/internal/export/redact_test.go @@ -98,3 +98,163 @@ func TestRedactDirectorySkipsNonJSONYAML(t *testing.T) { t.Fatal(err) } } + +func TestRedactExtendedSensitiveKeys(t *testing.T) { + tests := []struct { + name string + raw string + key string + }{ + {name: "provider_verification_key", raw: `{"provider_verification_key":"pvk-live"}`, key: "provider_verification_key"}, + {name: "client_id", raw: `{"client_id":"cid-99"}`, key: "client_id"}, + {name: "app_id", raw: `{"app_id":"aid-42"}`, key: "app_id"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := RedactBytes(".json", []byte(tt.raw)) + if err != nil { + t.Fatal(err) + } + if ContainsCleartextSecret(out) { + t.Fatalf("expected redacted output, got %s", out) + } + if !strings.Contains(string(out), "***REDACTED***") { + t.Fatalf("missing redacted marker: %s", out) + } + if strings.Contains(string(out), tt.key+`":"`) && !strings.Contains(string(out), "***REDACTED***") { + t.Fatalf("cleartext value still present: %s", out) + } + }) + } +} + +func TestRedactExtendedSensitiveKeysYAML(t *testing.T) { + tests := []struct { + name string + raw string + secret string + }{ + {name: "provider_verification_key", raw: "application:\n provider_verification_key: pvk-yaml\n", secret: "pvk-yaml"}, + {name: "client_id", raw: "credentials:\n client_id: cid-yaml\n", secret: "cid-yaml"}, + {name: "app_id", raw: "credentials:\n app_id: aid-yaml\n", secret: "aid-yaml"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := RedactBytes(".yaml", []byte(tt.raw)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), tt.secret) { + t.Fatalf("secret still present: %s", out) + } + if !strings.Contains(string(out), "***REDACTED***") { + t.Fatalf("missing redacted marker: %s", out) + } + }) + } +} + +func TestRedactIssuerStripsUserinfoJSON(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "oidc_issuer_endpoint", + raw: `{"oidc_issuer_endpoint":"https://zync-user:zync-pass@idp.example.com/realms/demo"}`, + want: "https://idp.example.com/realms/demo", + }, + { + name: "issuer_endpoint", + raw: `{"issuer_endpoint":"https://user:pass@sso.example.com/realms/prod"}`, + want: "https://sso.example.com/realms/prod", + }, + { + name: "clean issuer unchanged", + raw: `{"issuer_endpoint":"https://sso.example.com/realms/prod"}`, + want: "https://sso.example.com/realms/prod", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := RedactBytes(".json", []byte(tt.raw)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), tt.want) { + t.Fatalf("expected %q in output, got %s", tt.want, out) + } + if strings.Contains(string(out), "zync-user") || strings.Contains(string(out), "zync-pass") || + strings.Contains(string(out), "user:pass") { + t.Fatalf("userinfo still present: %s", out) + } + if ContainsCleartextSecret(out) { + t.Fatalf("issuer userinfo detected as cleartext: %s", out) + } + }) + } +} + +func TestRedactIssuerStripsUserinfoYAML(t *testing.T) { + raw := []byte("oidc:\n oidc_issuer_endpoint: https://zync-user:zync-pass@idp.example.com/realms/demo\n") + out, err := RedactBytes(".yaml", raw) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), "zync-user") || strings.Contains(string(out), "zync-pass") { + t.Fatalf("userinfo still present: %s", out) + } + if !strings.Contains(string(out), "https://idp.example.com/realms/demo") { + t.Fatalf("expected stripped host URL, got %s", out) + } +} + +func TestRedactPreservesAuthProxyFlags(t *testing.T) { + raw := []byte(`{"proxy":{"auth_user_key":"user_key","auth_app_id":"app_id","auth_app_key":"app_key","api_key":"secret"}}`) + out, err := RedactBytes(".json", raw) + if err != nil { + t.Fatal(err) + } + s := string(out) + for _, flag := range []string{`"auth_user_key": "user_key"`, `"auth_app_id": "app_id"`, `"auth_app_key": "app_key"`} { + if !strings.Contains(s, flag) { + t.Fatalf("auth flag altered or missing %q in %s", flag, out) + } + } + if !strings.Contains(s, "***REDACTED***") { + t.Fatalf("api_key not redacted: %s", out) + } +} + +func TestContainsCleartextSecretYAMLIssuerUserinfo(t *testing.T) { + raw := []byte("oidc:\n oidc_issuer_endpoint: https://user:pass@host/realms/demo\n") + if !ContainsCleartextSecret(raw) { + t.Fatal("expected issuer userinfo detected in YAML") + } + stripped := []byte("oidc:\n oidc_issuer_endpoint: https://host/realms/demo\n") + if ContainsCleartextSecret(stripped) { + t.Fatal("expected no cleartext after issuer strip") + } +} + +func TestContainsCleartextSecretExtendedKeys(t *testing.T) { + tests := []struct { + name string + raw string + wantHit bool + }{ + {name: "provider_verification_key cleartext", raw: `{"provider_verification_key":"live"}`, wantHit: true}, + {name: "client_id redacted", raw: `{"client_id":"***REDACTED***"}`, wantHit: false}, + {name: "issuer userinfo", raw: `{"oidc_issuer_endpoint":"https://u:p@host/path"}`, wantHit: true}, + {name: "issuer stripped", raw: `{"oidc_issuer_endpoint":"https://host/path"}`, wantHit: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ContainsCleartextSecret([]byte(tt.raw)) + if got != tt.wantHit { + t.Fatalf("ContainsCleartextSecret() = %v, want %v for %s", got, tt.wantHit, tt.raw) + } + }) + } +} diff --git a/internal/export/verify.go b/internal/export/verify.go index 72d0bcf..4d584fe 100644 --- a/internal/export/verify.go +++ b/internal/export/verify.go @@ -105,3 +105,32 @@ func ListExportPaths(root string) ([]string, error) { sort.Strings(paths) return paths, nil } + +// VerifyNoCleartextSecrets scans JSON and YAML artifacts under root for residual +// sensitive values aligned with the redaction contract. Returns a path-qualified +// error when cleartext is detected. +func VerifyNoCleartextSecrets(root string) error { + return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + switch strings.ToLower(filepath.Ext(path)) { + case ".json", ".yaml", ".yml": + data, err := os.ReadFile(path) + if err != nil { + return err + } + if ContainsCleartextSecret(data) { + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + return fmt.Errorf("cleartext secret in %s", filepath.ToSlash(rel)) + } + } + return nil + }) +} diff --git a/internal/export/verify_test.go b/internal/export/verify_test.go index 50f3154..14dd143 100644 --- a/internal/export/verify_test.go +++ b/internal/export/verify_test.go @@ -123,3 +123,31 @@ func defaultScopeMockClient() *mockClient { }, } } + +func TestVerifyNoCleartextSecretsClean(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "clean.json"), []byte(`{"client_secret":"***REDACTED***"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := VerifyNoCleartextSecrets(dir); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestVerifyNoCleartextSecretsFailsWithPath(t *testing.T) { + dir := t.TempDir() + rel := filepath.Join("products", "api", "proxy.json") + if err := os.MkdirAll(filepath.Join(dir, "products", "api"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, rel), []byte(`{"proxy":{"client_secret":"not-redacted"}}`), 0o644); err != nil { + t.Fatal(err) + } + err := VerifyNoCleartextSecrets(dir) + if err == nil { + t.Fatal("expected cleartext gate error") + } + if !strings.Contains(err.Error(), "products/api/proxy.json") { + t.Fatalf("error = %v, want path-qualified message", err) + } +}