diff --git a/cmd/entire/cli/review/manifest_test.go b/cmd/entire/cli/review/manifest_test.go index f8ec21bf8..4b79fcbcb 100644 --- a/cmd/entire/cli/review/manifest_test.go +++ b/cmd/entire/cli/review/manifest_test.go @@ -23,7 +23,13 @@ const manifestTokenTestAgentType agenttypes.AgentType = "Review Token Test" func TestHydrateReviewSummaryTokensFromStates_PopulatesTokensFromSessionState(t *testing.T) { t.Parallel() - started := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC) + // Time-relative so this test doesn't go stale: session.StateStore.Load + // auto-deletes sessions whose StartedAt is older than 7 days + // (StaleSessionThreshold), and a hardcoded fixed date silently starts + // failing once the calendar clock crosses that threshold. Use "an hour + // ago" so we exercise the 5-second jitter check inside + // matchReviewSessionState while staying well inside the staleness window. + started := time.Now().UTC().Add(-time.Hour) summary := reviewtypes.RunSummary{ StartedAt: started, AgentRuns: []reviewtypes.AgentRun{ @@ -67,7 +73,13 @@ func TestHydrateReviewSummaryTokensFromStates_FallsBackToTranscript(t *testing.T return manifestTokenTestAgent{}, nil } - started := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC) + // Time-relative so this test doesn't go stale: session.StateStore.Load + // auto-deletes sessions whose StartedAt is older than 7 days + // (StaleSessionThreshold), and a hardcoded fixed date silently starts + // failing once the calendar clock crosses that threshold. Use "an hour + // ago" so we exercise the 5-second jitter check inside + // matchReviewSessionState while staying well inside the staleness window. + started := time.Now().UTC().Add(-time.Hour) tmp := t.TempDir() transcriptPath := filepath.Join(tmp, "review.jsonl") transcript := "review transcript\n" @@ -112,7 +124,13 @@ func TestReviewSummaryTokenEnricher_LoadsCurrentSessionState(t *testing.T) { if err != nil { t.Fatalf("NewStateStore: %v", err) } - started := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC) + // Time-relative so this test doesn't go stale: session.StateStore.Load + // auto-deletes sessions whose StartedAt is older than 7 days + // (StaleSessionThreshold), and a hardcoded fixed date silently starts + // failing once the calendar clock crosses that threshold. Use "an hour + // ago" so we exercise the 5-second jitter check inside + // matchReviewSessionState while staying well inside the staleness window. + started := time.Now().UTC().Add(-time.Hour) if err := store.Save(ctx, &session.State{ SessionID: "codex-session-token", Kind: session.KindAgentReview, diff --git a/redact/redact.go b/redact/redact.go index 8e743da4c..5d18af7bc 100644 --- a/redact/redact.go +++ b/redact/redact.go @@ -30,6 +30,16 @@ var credentialedURIPattern = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]{1,31}:// // compose both the env-var assignment regex and the JSON-key regex so the // vendor list stays in one place. const dbPasswordKeyShape = `(?:db|database|pg|postgres|postgresql|mysql|mariadb|redis|mongo|mongodb|sqlserver|mssql|jdbc)(?:[_-]+[a-z0-9]+)*[_-]*(?:password|passwd|pwd)` //nolint:gosec // regex literal, not a credential +// secretValueKeyShape matches credential-style key names. Two alternation +// branches: +// 1. Explicit shapes (api_key, auth_token, client_secret, …) with an +// optional prefix. +// 2. Bare `secret`/`token` ONLY when preceded by at least one prefix segment +// (e.g. csrf_token, auth_secret). Standalone `{"token":"…"}` / +// `{"secret":"…"}` JSON fields are intentionally NOT matched here — +// entropy + Betterleaks cover real high-entropy values, and bare +// `token`/`secret` keys appear in many non-credential contexts. +const secretValueKeyShape = `(?:(?:[a-z0-9]+[_-]+)*(?:api[_-]*key|auth[_-]*token|access[_-]*token|refresh[_-]*token|client[_-]*secret|consumer[_-]*secret|secret[_-]*key|private[_-]*key|ssh[_-]*key)|(?:[a-z0-9]+[_-]+)+(?:secret|token))` //nolint:gosec // regex literal, not a credential var ( jdbcPattern = regexp.MustCompile(`(?i)\bjdbc:[^\s"'<>` + "`" + `]+`) @@ -40,6 +50,14 @@ var ( // boundary, so APP_DB_PASSWORD matches via the leading `_` but mydbpassword // does not. credentialValuePattern = regexp.MustCompile(`(?i)(?:^|[^A-Za-z0-9])(` + dbPasswordKeyShape + `)\s*=\s*("[^"]*"|'[^']*'|[^\s,;&]+)`) + // secretValuePattern targets shell/env-var assignments (`KEY=value`). It uses + // `=` only, not `:`, to avoid colliding with English prose ("the token: foo") + // in transcripts. YAML/JSON `key: value` shapes are handled by the JSON-aware + // path (`secretJSONKeyRegex`) which sees structured boundaries, not text. + secretValuePattern = regexp.MustCompile(`(?i)(?:^|[^A-Za-z0-9])(` + secretValueKeyShape + `)\s*=\s*("[^"]*"|'[^']*'|[^\s,;&]+)`) + shellStdinSecretPattern = regexp.MustCompile( + `(?is)\bprintf\s+(?:(?:%[A-Za-z]|'%-?[0-9.]*[A-Za-z](?:\\n)?'|"%-?[0-9.]*[A-Za-z](?:\\n)?")\s+)?("[^"\r\n]{1,512}"|'[^'\r\n]{1,512}'|[^\s|&;]{1,512})\s*\|\s*[^&;\r\n]{0,300}\bsecrets?\s+(?:put|set|add|create)\s+` + secretValueKeyShape + `\b`, + ) keywordHostPattern = regexp.MustCompile(`(?i)(?:^|\s)host=`) keywordUserPattern = regexp.MustCompile(`(?i)(?:^|\s)user=`) @@ -49,6 +67,7 @@ var ( // credentialJSONKeyRegex operates on output of normalizeCredentialJSONKey // (already lowercased, `-`/` `/`.` → `_`), so the `(?i)` flag is unnecessary. credentialJSONKeyRegex = regexp.MustCompile(`^` + dbPasswordKeyShape + `$`) + secretJSONKeyRegex = regexp.MustCompile(`^` + secretValueKeyShape + `$`) genericPasswordKeyRegex = regexp.MustCompile(`(?i)^(?:password|passwd|pwd)$`) ) @@ -61,6 +80,25 @@ const entropyThreshold = 4.5 // RedactedPlaceholder is the replacement text used for redacted secrets. const RedactedPlaceholder = "REDACTED" +// nonSecretTokenPrefixes lists key-name prefixes that, when followed by +// `_token` or `_secret`, denote routinely-non-sensitive debug or pagination +// identifiers (e.g. cancel_token, pagination_token). These are excluded from +// JSON-key redaction so they remain readable in transcripts. Real +// high-entropy values still get caught by the entropy detector / Betterleaks +// via the per-value String() call in collectJSONLReplacements. +var nonSecretTokenPrefixes = map[string]struct{}{ + "cancel": {}, + "continuation": {}, + "cursor": {}, + "idempotency": {}, + "next": {}, + "page": {}, + "pagination": {}, + "prev": {}, + "previous": {}, + "sync": {}, +} + // placeholderSecretValues lists lowercase values that should be treated as // non-secrets when they appear as a credential value: prior redactions // (REDACTED / [REDACTED] / ), common documentation placeholders, @@ -160,8 +198,9 @@ var connectionStringRules = []connectionStringRule{ // 3. Credentialed URIs: URLs containing userinfo passwords // 4. Database connection strings: JDBC, keyword DSNs, and semicolon strings // 5. User-defined custom rules: configured via ConfigureCustomRules -// 6. Bounded credential key/value pairs: DB_PASSWORD=... -// 7. PII detection: email, phone, address patterns (only when configured via ConfigurePII) +// 6. Shell stdin literals piped into secret-management commands +// 7. Bounded credential key/value pairs: DB_PASSWORD=..., GITHUB_CLIENT_SECRET=... +// 8. PII detection: email, phone, address patterns (only when configured via ConfigurePII) // A string is redacted if ANY method flags it. func String(s string) string { var regions []taggedRegion @@ -186,6 +225,10 @@ func String(s string) string { } } + if isNonSecretIdentifierAssignment(s[start:end]) { + continue + } + if shannonEntropy(s[start:end]) > entropyThreshold { regions = append(regions, taggedRegion{region: region{start, end}}) } @@ -221,10 +264,13 @@ func String(s string) string { // 5. User-defined custom rules (secrets — only runs when configured). regions = append(regions, detectCustomRules(getCustomRulesConfig(), s)...) - // 6. Bounded credential key/value detection (secrets — always on). + // 6. Shell stdin literals piped into secret-management commands. + regions = append(regions, detectShellStdinSecrets(s)...) + + // 7. Bounded credential key/value detection (secrets — always on). regions = append(regions, detectCredentialValues(s)...) - // 7. PII detection (opt-in — only runs when configured). + // 8. PII detection (opt-in — only runs when configured). regions = append(regions, detectPII(getPIIConfig(), s)...) if len(regions) == 0 { @@ -343,13 +389,13 @@ func hasSemicolonConnectionPassword(candidate string) bool { hasNonPlaceholderPasswordAssignment(candidate) } -func detectCredentialValues(s string) []taggedRegion { +func detectShellStdinSecrets(s string) []taggedRegion { var regions []taggedRegion - for _, loc := range credentialValuePattern.FindAllStringSubmatchIndex(s, -1) { - if len(loc) < 6 || loc[4] < 0 || loc[5] < 0 { + for _, loc := range shellStdinSecretPattern.FindAllStringSubmatchIndex(s, -1) { + if len(loc) < 4 || loc[2] < 0 || loc[3] < 0 { continue } - start, end := unquoteRange(s, loc[4], loc[5]) + start, end := unquoteRange(s, loc[2], loc[3]) if hasNonPlaceholderPasswordValue(s[start:end]) { regions = append(regions, taggedRegion{region: region{start, end}}) } @@ -357,6 +403,26 @@ func detectCredentialValues(s string) []taggedRegion { return regions } +func detectCredentialValues(s string) []taggedRegion { + patterns := []*regexp.Regexp{ + credentialValuePattern, + secretValuePattern, + } + var regions []taggedRegion + for _, pattern := range patterns { + for _, loc := range pattern.FindAllStringSubmatchIndex(s, -1) { + if len(loc) < 6 || loc[4] < 0 || loc[5] < 0 { + continue + } + start, end := unquoteRange(s, loc[4], loc[5]) + if hasNonPlaceholderPasswordValue(s[start:end]) { + regions = append(regions, taggedRegion{region: region{start, end}}) + } + } + } + return regions +} + func unquoteRange(s string, start, end int) (int, int) { if end-start < 2 { return start, end @@ -396,12 +462,31 @@ func isPlaceholderSecretValue(value string) bool { if strings.HasPrefix(normalized, "${") && strings.HasSuffix(normalized, "}") { return true } + if isShellVariableReference(trimmed) { + return true + } if _, ok := placeholderSecretValues[normalized]; ok { return true } return isRepeatedCharPlaceholder(normalized) } +func isShellVariableReference(s string) bool { + if len(s) < 2 || s[0] != '$' { + return false + } + if s[1] != '_' && (s[1] < 'A' || s[1] > 'Z') && (s[1] < 'a' || s[1] > 'z') { + return false + } + for i := 2; i < len(s); i++ { + c := s[i] + if c != '_' && (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && (c < '0' || c > '9') { + return false + } + } + return true +} + // bracketedPlaceholderInteriorRE matches the inside of a "<…>" placeholder // shape: lowercase letters joined by `-` or `_`. Digits, mixed case, and // special chars are rejected so values like `` or `` @@ -442,12 +527,63 @@ func isRepeatedCharPlaceholder(s string) bool { func isCredentialJSONSecretKey(key string, credentialContext bool) bool { normalized := normalizeCredentialJSONKey(key) - if credentialJSONKeyRegex.MatchString(normalized) { + if isKnownNonSecretTokenKey(normalized) { + return false + } + if isSensitiveNormalizedJSONValueKey(normalized) { return true } return credentialContext && genericPasswordKeyRegex.MatchString(normalized) } +// isKnownNonSecretTokenKey reports whether the normalized JSON key is +// `_token` or `_secret` where prefix is in +// nonSecretTokenPrefixes. Used to short-circuit JSON-key redaction for keys +// like `cancel_token`, `pagination_token`, `idempotency_token` that are +// routinely non-credentials. +func isKnownNonSecretTokenKey(normalized string) bool { + for _, suffix := range []string{"_token", "_secret"} { + if prefix, ok := strings.CutSuffix(normalized, suffix); ok { + if _, allowed := nonSecretTokenPrefixes[prefix]; allowed { + return true + } + } + } + return false +} + +func isNonSecretIdentifierAssignment(candidate string) bool { + key, value, ok := strings.Cut(candidate, "=") + if !ok || key == "" || value == "" { + return false + } + normalized := normalizeCredentialJSONKey(key) + if isSensitiveNormalizedJSONValueKey(normalized) || genericPasswordKeyRegex.MatchString(normalized) { + return false + } + if isHighEntropySecretCandidate(value) { + return false + } + return normalized == "id" || + normalized == "account" || + strings.HasSuffix(normalized, "_id") || + strings.HasSuffix(normalized, "_account") +} + +func isHighEntropySecretCandidate(value string) bool { + for _, loc := range secretPattern.FindAllStringIndex(value, -1) { + if shannonEntropy(value[loc[0]:loc[1]]) > entropyThreshold { + return true + } + } + return false +} + +func isSensitiveNormalizedJSONValueKey(normalized string) bool { + return credentialJSONKeyRegex.MatchString(normalized) || + secretJSONKeyRegex.MatchString(normalized) +} + func isCredentialJSONObject(obj map[string]any) bool { var hasHost, hasUser bool for key := range obj { @@ -692,6 +828,16 @@ func shouldSkipJSONLField(key string) bool { return true } + // Skip account fields. These are identifier-shape (e.g., + // google_adsense_account, aws_account, service_account), not credential + // fields. Protects against entropy / vendor-regex false positives when + // account identifiers happen to look high-entropy. A misnamed credential + // field literally named `*_account` would slip through — acceptable trade + // because real credential fields use `*_secret`, `*_token`, etc. + if strings.HasSuffix(lower, "account") { + return true + } + // Skip common path and directory fields from agent transcripts. // These appear frequently in tool calls and are structural, not secrets. switch lower { diff --git a/redact/redact_test.go b/redact/redact_test.go index 632535fc7..6917a9ddb 100644 --- a/redact/redact_test.go +++ b/redact/redact_test.go @@ -224,6 +224,12 @@ func TestShouldSkipJSONLField(t *testing.T) { {"ids", true}, {"session_ids", true}, {"userIds", true}, + // Fields ending in "account" should be skipped (identifier shape, + // not credential): google_adsense_account, aws_account_id, etc. + {"account", true}, + {"google_adsense_account", true}, + {"aws_account", true}, + {"service_account", true}, // Exact match "signature" should be skipped. {"signature", true}, // Path-related fields should be skipped. @@ -564,6 +570,11 @@ func TestString_BoundedCredentialValueRedaction(t *testing.T) { input: "DB__PASSWORD=secret123", want: "DB__PASSWORD=REDACTED", }, + { + name: "github client secret env var", + input: "GITHUB_CLIENT_SECRET=correct-horse-client", + want: "GITHUB_CLIENT_SECRET=REDACTED", + }, }) } @@ -655,6 +666,26 @@ func TestString_BoundedCredentialValueOverRedactionGuards(t *testing.T) { input: "DB_PASSWORD=placeholder", want: "DB_PASSWORD=placeholder", }, + { + name: "google adsense account is preserved as identifier", + input: "GOOGLE_ADSENSE_ACCOUNT=pub-1234567890123456", + want: "GOOGLE_ADSENSE_ACCOUNT=pub-1234567890123456", + }, + { + name: "prose with token colon does not over-redact next word", + input: "Got the token: hunter2 don't merge yet", + want: "Got the token: hunter2 don't merge yet", + }, + { + name: "prose with secret colon does not over-redact next word", + input: "secret: don't merge yet", + want: "secret: don't merge yet", + }, + { + name: "prose with api_key colon does not over-redact next word", + input: "The api_key: was wrong, retry the request", + want: "The api_key: was wrong, retry the request", + }, }) } @@ -767,19 +798,20 @@ func TestJSONLContent_DatabaseCredentialRedaction(t *testing.T) { func TestJSONLContent_StructuredCredentialFieldsRedacted(t *testing.T) { t.Parallel() - input := `{"type":"assistant","env":{"DB_PASSWORD":"correct-horse-db","REDIS_PASSWORD":"${REDIS_PASSWORD}","note":"correct-horse-db"},"db":{"password":"correct-horse-db","host":"db.example.com","user":"svc"},"session_id":"ses_37273a1fdffegpYbwUTqEkPsQ0"}` + input := `{"type":"assistant","env":{"DB_PASSWORD":"correct-horse-db","GITHUB_CLIENT_SECRET":"correct-horse-client","REDIS_PASSWORD":"${REDIS_PASSWORD}","note":"correct-horse-db"},"db":{"password":"correct-horse-db","host":"db.example.com","user":"svc"},"session_id":"ses_37273a1fdffegpYbwUTqEkPsQ0"}` result, err := JSONLContent(input) if err != nil { t.Fatalf("unexpected error: %v", err) } - for _, leaked := range []string{`"DB_PASSWORD":"correct-horse-db"`, `"password":"correct-horse-db"`} { + for _, leaked := range []string{`"DB_PASSWORD":"correct-horse-db"`, `"GITHUB_CLIENT_SECRET":"correct-horse-client"`, `"password":"correct-horse-db"`} { if strings.Contains(result, leaked) { t.Fatalf("expected structured credential field %q to be redacted, got: %s", leaked, result) } } for _, preserved := range []string{ `"DB_PASSWORD":"REDACTED"`, + `"GITHUB_CLIENT_SECRET":"REDACTED"`, `"REDIS_PASSWORD":"${REDIS_PASSWORD}"`, `"password":"REDACTED"`, `"host":"db.example.com"`, @@ -815,6 +847,44 @@ func TestJSONLContent_NormalizedCredentialKeysRedacted(t *testing.T) { } } +func TestJSONLContent_ShellStdinSecretCommandRedactsPrintfLiteral(t *testing.T) { + t.Parallel() + keyName := strings.Join([]string{"EXAMPLE", "API", "KEY"}, "_") + secret := "correct-horse-client" + input := `{"type":"user","message":{"content":[{"type":"tool_result","content":"tail -2 && printf '` + secret + `' | examplectl secret put ` + keyName + ` 2>&1 | tail -2"}]}}` + + result, err := JSONLContent(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(result, secret) { + t.Fatalf("expected shell stdin secret literal to be redacted, got: %s", result) + } + if !strings.Contains(result, "printf 'REDACTED' | examplectl secret put "+keyName) { + t.Fatalf("expected command context to be preserved, got: %s", result) + } +} + +func TestJSONLContent_ShellStdinSecretCommandOverRedactionGuards(t *testing.T) { + t.Parallel() + keyName := strings.Join([]string{"EXAMPLE", "API", "KEY"}, "_") + clientIDName := strings.Join([]string{"EXAMPLE", "CLIENT", "ID"}, "_") + input := `{"type":"user","message":{"content":[{"type":"tool_result","content":"printf 'example-client-id.apps.example.test' | examplectl secret put ` + clientIDName + `\nprintf \"$` + keyName + `\" | examplectl secret put ` + keyName + `"}]}}` + + result, err := JSONLContent(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, preserved := range []string{ + "printf 'example-client-id.apps.example.test' | examplectl secret put " + clientIDName, + `printf \"$` + keyName + `\" | examplectl secret put ` + keyName, + } { + if !strings.Contains(result, preserved) { + t.Fatalf("expected %q to be preserved, got: %s", preserved, result) + } + } +} + func TestJSONLContent_DottedCredentialKeysRedacted(t *testing.T) { t.Parallel() input := `{"config":{"db.password":"correct-horse-db","mysql.root.password":"correct-horse-mysql","note":"correct-horse-db"}}` @@ -1270,6 +1340,124 @@ func TestString_RedactionIsIdempotent(t *testing.T) { } } +// Pins that `*_account` JSON keys are preserved by structure, not by entropy +// coincidence. The value here is intentionally high-entropy (well above the +// 4.5 threshold) so the test would fail if `_account` were not in the skip +// list — proving the protection is design-driven, not luck-driven. +func TestJSONLContent_AccountJSONKeysPreservedDespiteHighEntropy(t *testing.T) { + t.Parallel() + cases := []string{ + `{"google_adsense_account":"` + highEntropySecret + `"}`, + `{"aws_account":"` + highEntropySecret + `"}`, + `{"service_account":"` + highEntropySecret + `"}`, + } + for _, in := range cases { + out, err := JSONLContent(in) + if err != nil { + t.Fatalf("unexpected error for %q: %v", in, err) + } + if out != in { + t.Errorf("expected %q preserved (account is identifier-shape), got: %s", in, out) + } + } +} + +// Pins that JSON keys with known non-secret prefixes (cancel/pagination/ +// continuation/idempotency/cursor/next/previous/page/sync) followed by +// `_token` or `_secret` are NOT flat-redacted. These are routinely safe +// debug/pagination identifiers. Real high-entropy credential-shaped values +// still get caught by entropy + Betterleaks via the per-value String() call. +func TestJSONLContent_NonSecretTokenPrefixesPreserved(t *testing.T) { + t.Parallel() + cases := []string{ + `{"cancel_token":"abc-123-xyz-cancel-debug"}`, + `{"pagination_token":"page_500_offset_10000"}`, + `{"continuation_token":"foo_bar_baz_continuation_12345"}`, + `{"idempotency_token":"req-7f2a-deadbeef"}`, + `{"cursor_token":"opaque-cursor-value"}`, + `{"next_token":"page-2-marker"}`, + `{"previous_token":"page-1-marker"}`, + `{"prev_token":"page-1-marker"}`, + `{"page_token":"page-2-marker"}`, + `{"sync_token":"sync-state-marker"}`, + `{"cancel_secret":"cancel-state-marker"}`, + } + for _, in := range cases { + out, err := JSONLContent(in) + if err != nil { + t.Fatalf("unexpected error for %q: %v", in, err) + } + if out != in { + t.Errorf("expected %q preserved (non-secret prefix), got: %s", in, out) + } + } +} + +// Pins that prefixed `*_token` keys NOT in the non-secret allowlist still +// redact — e.g. csrf_token, auth_token, oauth_token, refresh_token. Prevents +// the allowlist from expanding to swallow real credential shapes. +func TestJSONLContent_UnknownTokenPrefixesStillRedacted(t *testing.T) { + t.Parallel() + cases := []struct{ in, wantSub string }{ + {`{"csrf_token":"abc-csrf-real-secret"}`, `"csrf_token":"REDACTED"`}, + {`{"auth_token":"abc-auth-real-secret"}`, `"auth_token":"REDACTED"`}, + {`{"oauth_token":"abc-oauth-real-secret"}`, `"oauth_token":"REDACTED"`}, + {`{"random_token":"abc-random-real-secret"}`, `"random_token":"REDACTED"`}, + } + for _, c := range cases { + out, err := JSONLContent(c.in) + if err != nil { + t.Fatalf("unexpected error for %q: %v", c.in, err) + } + if !strings.Contains(out, c.wantSub) { + t.Errorf("input %q: expected %q in output, got: %s", c.in, c.wantSub, out) + } + } +} + +// Pins that bare `{"token":"…"}` and `{"secret":"…"}` JSON fields are NOT +// flat-redacted by the structural layer. Standalone `token`/`secret` keys +// appear in many non-credential contexts (debug, request IDs, etc.); real +// high-entropy values still get caught by entropy + Betterleaks. +func TestJSONLContent_BareTokenSecretJSONKeysPreserved(t *testing.T) { + t.Parallel() + cases := []string{ + `{"token":"abc-def-ghi-not-a-secret-debug-id"}`, + `{"secret":"abc-def-ghi-not-a-secret-debug-id"}`, + } + for _, in := range cases { + out, err := JSONLContent(in) + if err != nil { + t.Fatalf("unexpected error for %q: %v", in, err) + } + if out != in { + t.Errorf("expected %q preserved (bare token/secret keys are not credentials), got: %s", in, out) + } + } +} + +// Pins that prefixed `*_token` / `*_secret` JSON fields continue to redact — +// these are the structural credential shapes the layer targets. +func TestJSONLContent_PrefixedTokenSecretJSONKeysRedacted(t *testing.T) { + t.Parallel() + cases := []struct{ in, wantSub string }{ + {`{"csrf_token":"abc-csrf-real-secret"}`, `"csrf_token":"REDACTED"`}, + {`{"auth_token":"abc-auth-real-secret"}`, `"auth_token":"REDACTED"`}, + {`{"api_key":"abc-api-real-secret"}`, `"api_key":"REDACTED"`}, + {`{"client_secret":"abc-client-real-secret"}`, `"client_secret":"REDACTED"`}, + {`{"refresh_token":"abc-refresh-real-secret"}`, `"refresh_token":"REDACTED"`}, + } + for _, c := range cases { + out, err := JSONLContent(c.in) + if err != nil { + t.Fatalf("unexpected error for %q: %v", c.in, err) + } + if !strings.Contains(out, c.wantSub) { + t.Errorf("input %q: expected %q in output, got: %s", c.in, c.wantSub, out) + } + } +} + // Pins keyed-JSON replacement as (key, value) rather than (path, value): a // shared value under the same key name redacts in every context, not just // the credential one. Conservative on purpose — flag if changed.