Skip to main content

lean_ctx/core/
redaction.rs

1macro_rules! static_regex {
2    ($pattern:expr_2021) => {{
3        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
4        RE.get_or_init(|| {
5            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
6        })
7    }};
8}
9
10pub fn redaction_enabled_for_active_role() -> bool {
11    let role = crate::core::roles::active_role();
12    if role.role.name == "admin" {
13        role.io.redact_outputs
14    } else {
15        // Contract: redaction never disabled for non-admin roles.
16        true
17    }
18}
19
20/// Redacts text bound for the model (tool output). Only this entry point
21/// counts toward the value surface: persistence passes (`redact_text`) keep
22/// secrets out of files, not out of the model context, and an already
23/// redacted `[REDACTED:…]` marker never matches again, so nested passes over
24/// one output do not double count.
25pub fn redact_text_if_enabled(input: &str) -> String {
26    if !redaction_enabled_for_active_role() {
27        return input.to_string();
28    }
29    let (out, found) = redact_found(input, config_exclude_patterns().as_slice());
30    let hits = found.len();
31    // Engine metrics K21: fingerprints only, and only while sharing is on.
32    if hits > 0 {
33        crate::core::hive_signals::note_secrets(&found);
34    }
35    crate::core::security_events::note(
36        crate::core::security_events::SecurityKind::SecretRedacted,
37        hits,
38    );
39    out
40}
41
42/// #718: unquoted identifier or property-access chains (`SvelteKit`,
43/// `inputEnv.POCKETBASE_SUPERUSER_PASSWORD`, `serverEnv.getStripeSecretKey`,
44/// `confirmRequiredEndpointKeySchema`) are code REFERENCES to a secret, never
45/// the literal value — the value lives in a gitignored `.env`. Digits make a
46/// token secret-shaped (base64/hex), so any digit keeps the redaction
47/// (conservative: `password=hunter2` stays covered).
48/// #827: detect pure numeric values — integers, floats, scientific notation.
49/// These are never secrets even when the key contains `token` or `key`.
50fn looks_like_number(v: &str) -> bool {
51    if v.is_empty() {
52        return false;
53    }
54    let s = v.trim_start_matches(['+', '-']);
55    if s.is_empty() {
56        return false;
57    }
58    // Integer, float, or scientific notation (1.4e-06, 0.5, 600, 1e10)
59    s.parse::<f64>().is_ok()
60        && s.chars()
61            .all(|c| c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-')
62}
63
64fn looks_like_uuid(value: &str) -> bool {
65    let value = value
66        .trim()
67        .trim_matches(|c| c == '"' || c == '\'' || c == '`' || c == '{' || c == '}');
68    let groups: Vec<&str> = value.split('-').collect();
69    groups.len() == 5
70        && [8, 4, 4, 4, 12].iter().zip(&groups).all(|(len, group)| {
71            group.len() == *len && group.chars().all(|ch| ch.is_ascii_hexdigit())
72        })
73}
74
75/// #827: env-variable reference patterns that should not be redacted.
76/// Matches: `os.environ/NAME`, `os.getenv("NAME")`, `process.env.NAME`,
77/// `inputEnv.NAME`, `${NAME}`, `$NAME`, `%NAME%` (Windows).
78fn is_env_reference(v: &str) -> bool {
79    if v.starts_with("os.environ/")
80        || v.starts_with("os.getenv(")
81        || v.starts_with("process.env.")
82        || v.starts_with("System.getenv(")
83        || v.starts_with("ENV[")
84        || v.starts_with("env(")
85    {
86        return true;
87    }
88    // Variable interpolation: ${VAR}, $VAR, %VAR%
89    if (v.starts_with("${") && v.ends_with('}'))
90        || (v.starts_with('$')
91            && v[1..]
92                .chars()
93                .all(|c| c.is_ascii_alphanumeric() || c == '_'))
94        || (v.starts_with('%') && v.ends_with('%') && v.len() > 2)
95    {
96        return true;
97    }
98    // Dotted identifier chains with an env-like prefix
99    if let Some(prefix) = v.split('.').next() {
100        let pl = prefix.to_ascii_lowercase();
101        if matches!(
102            pl.as_str(),
103            "env" | "inputenv" | "serverenv" | "secrets" | "vars" | "environ"
104        ) && v.contains('.')
105        {
106            return true;
107        }
108    }
109    false
110}
111
112fn is_identifier_reference(value: &str) -> bool {
113    let v = value.trim();
114    if v.is_empty() || v.starts_with('"') || v.starts_with('\'') || v.starts_with('`') {
115        return false;
116    }
117    // #827: env-variable reference patterns are not secrets.
118    // `os.environ/MY_SERVICE_API_KEY`, `process.env.NAME`, `${VAR}`, `$VAR`.
119    if is_env_reference(v) {
120        return true;
121    }
122    if v.contains(|c: char| c.is_ascii_digit()) {
123        return false;
124    }
125    v.split('.').all(|segment| {
126        let mut chars = segment.chars();
127        matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$')
128            && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
129    })
130}
131
132/// #718: obvious provider-token placeholders, `your_key_here`,
133/// `<insert-token>`) are documentation, not secrets — `.env.example` files
134/// must survive ctx_read verbatim. #1831: so is an asterisk mask (`*****`),
135/// which is what a redactor itself writes in place of a secret.
136fn is_placeholder_value(value: &str) -> bool {
137    let v = value
138        .trim()
139        .trim_matches(|c| c == '"' || c == '\'' || c == '`')
140        .to_ascii_lowercase();
141    if v.starts_with('<') && v.ends_with('>') {
142        return true;
143    }
144    if !v.is_empty() && v.chars().all(|c| c == '*') {
145        return true;
146    }
147    const MARKERS: &[&str] = &[
148        "change_me",
149        "change-me",
150        "changeme",
151        "example",
152        "placeholder",
153        "your_",
154        "your-",
155        "xxx",
156        "dummy",
157        "sample",
158        "todo",
159        "fixme",
160        "replace_me",
161        "replace-me",
162        "redacted",
163        "...",
164        "…",
165    ];
166    MARKERS.iter().any(|m| v.contains(m))
167}
168
169/// Right-hand sides that look like `key: value` but are obviously not secrets:
170/// TypeScript type annotations and language literals. Redacting these corrupts
171/// source files read through `ctx_read` (GH #430), so the key/value rules skip
172/// them. Compared case-insensitively after trimming surrounding quotes.
173fn is_non_secret_literal(value: &str) -> bool {
174    let v = value
175        .trim()
176        .trim_matches(|c| c == '"' || c == '\'' || c == '`');
177    // #827: pure numbers (integer, float, scientific notation) are never secrets.
178    // `input_cost_per_token: 1.4e-06` must not be redacted.
179    if looks_like_number(v) {
180        return true;
181    }
182    // Type expressions are never flat secret tokens: real keys/tokens are drawn
183    // from `[A-Za-z0-9+/=_-]`, whereas type annotations carry angle brackets,
184    // unions, arrays or call/object syntax. `password: Promise<string>` and
185    // `apiKey: Record<string, unknown>` must survive ctx_read verbatim (GH #430).
186    if v.contains(['<', '>', '|', '(', ')', '[', ']', '{', '}']) {
187        return true;
188    }
189    matches!(
190        v.to_ascii_lowercase().as_str(),
191        "" | "undefined"
192            | "null"
193            | "none"
194            | "nil"
195            | "true"
196            | "false"
197            | "string"
198            | "number"
199            | "boolean"
200            | "bigint"
201            | "symbol"
202            | "object"
203            | "any"
204            | "unknown"
205            | "never"
206            | "void"
207            | "nan"
208            | "date"
209    ) || is_primitive_type_name(v)
210}
211
212/// Primitive type names of Rust, Go, Java and C-family languages. In
213/// `const MAX_TOKEN: usize = 4` the "value" after `TOKEN:` is a type
214/// annotation, never a credential; masking it corrupts every read of such a
215/// source. Matched exactly (case-sensitive), so a password that merely
216/// contains one of these words is still protected.
217fn is_primitive_type_name(value: &str) -> bool {
218    matches!(
219        value,
220        "bool"
221            | "char"
222            | "str"
223            | "usize"
224            | "isize"
225            | "u8"
226            | "u16"
227            | "u32"
228            | "u64"
229            | "u128"
230            | "i8"
231            | "i16"
232            | "i32"
233            | "i64"
234            | "i128"
235            | "f32"
236            | "f64"
237            | "int"
238            | "int8"
239            | "int16"
240            | "int32"
241            | "int64"
242            | "uint"
243            | "uint8"
244            | "uint16"
245            | "uint32"
246            | "uint64"
247            | "uintptr"
248            | "float"
249            | "float32"
250            | "float64"
251            | "double"
252            | "long"
253            | "short"
254            | "byte"
255            | "rune"
256            | "error"
257            | "Self"
258    )
259}
260
261/// One redaction rule: a labelled regex plus how the match is rebuilt.
262struct Rule {
263    label: &'static str,
264    re: &'static regex::Regex,
265    /// When set, group 1 is a prefix to keep and group 2 is the secret value;
266    /// the match is left untouched if that value is a non-secret literal
267    /// (`password: undefined`), an identifier reference
268    /// (`serverEnv.getStripeSecretKey`) or a provider-token placeholder —
269    /// see `is_benign_secret_value` (GH #430, #718).
270    guard_value: bool,
271}
272
273/// Normalization used only for matching: remove invisible zero-width format
274/// characters and fold full-width ASCII plus ideographic space to their ASCII
275/// forms. Original text and byte boundaries are retained for span-only edits.
276fn normalize_for_secret_matching(input: &str) -> (String, Vec<usize>) {
277    let mut normalized = String::with_capacity(input.len());
278    let mut original_boundaries = vec![0usize];
279
280    for (start, ch) in input.char_indices() {
281        let end = start + ch.len_utf8();
282        let Some(folded) = fold_secret_match_char(ch) else {
283            if let Some(boundary) = original_boundaries.last_mut() {
284                *boundary = end;
285            }
286            continue;
287        };
288        let folded_len = folded.len_utf8();
289        normalized.push(folded);
290        for byte in 1..=folded_len {
291            original_boundaries.push(if byte == folded_len { end } else { start });
292        }
293    }
294
295    (normalized, original_boundaries)
296}
297
298fn fold_secret_match_char(ch: char) -> Option<char> {
299    if matches!(
300        ch,
301        '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2060}' | '\u{feff}'
302    ) {
303        return None;
304    }
305    Some(match ch {
306        '\u{3000}' => ' ',
307        '\u{ff01}'..='\u{ff5e}' => char::from_u32(ch as u32 - 0xfee0).unwrap_or(ch),
308        _ => ch,
309    })
310}
311
312/// A normalized view for detection-only callers that do not need span offsets.
313pub(crate) fn normalize_secret_text(input: &str) -> String {
314    input.chars().filter_map(fold_secret_match_char).collect()
315}
316
317/// The markers of the "Private key block" rule, as that rule spells them
318/// (only a PRIVATE KEY END closes a block: a certificate's END does not).
319const PRIVATE_KEY_KINDS: &str = r"(?:RSA\s+|EC\s+|DSA\s+|OPENSSH\s+)?PRIVATE\s+KEY-----";
320
321fn private_key_marker(end: bool) -> &'static regex::Regex {
322    static HEADER: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
323    static END: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
324    let (cell, word) = if end {
325        (&END, "END")
326    } else {
327        (&HEADER, "BEGIN")
328    };
329    cell.get_or_init(|| {
330        regex::Regex::new(&format!(r"-----{word}\s+{PRIVATE_KEY_KINDS}"))
331            .expect("BUG: invalid private-key marker")
332    })
333}
334
335/// Whether `text`, normalized as the "Private key block" rule sees it,
336/// holds a private-key header with no private-key END after it: the rule's
337/// match would continue in the text that follows.
338pub fn ends_inside_private_key_block(text: &str) -> bool {
339    let normalized = normalize_secret_text(text);
340    let last = |end: bool| {
341        private_key_marker(end)
342            .find_iter(&normalized)
343            .last()
344            .map(|found| found.start())
345    };
346    match (last(false), last(true)) {
347        (Some(header), Some(end)) => header > end,
348        (Some(_), None) => true,
349        (None, _) => false,
350    }
351}
352
353/// Whether `text`, normalized as the "Private key block" rule sees it,
354/// holds a private-key END marker (the one the rule closes a block on).
355pub fn contains_private_key_end(text: &str) -> bool {
356    private_key_marker(true).is_match(&normalize_secret_text(text))
357}
358
359/// Whether the word after a bare `Bearer` is a credential rather than prose or a
360/// placeholder. Precision matters on the default redaction path ("Bearer
361/// authentication", "Bearer YOUR_TOKEN_HERE" must survive), but the identifier
362/// heuristic cannot be used: an opaque base64 token can be letters only. A
363/// credential is long and mixes character classes.
364pub(crate) fn bearer_value_is_credential(value: &str) -> bool {
365    let classes = [
366        value.bytes().any(|b| b.is_ascii_uppercase()),
367        value.bytes().any(|b| b.is_ascii_lowercase()),
368        value.bytes().any(|b| b.is_ascii_digit()),
369    ];
370    value.len() >= 16
371        && classes.iter().filter(|present| **present).count() >= 2
372        && !is_placeholder_value(value)
373}
374
375/// Combined benign-value check for key/value secret rules (#430 + #718):
376/// language literals and type annotations, unquoted identifier/property
377/// references, and documentation placeholders are never redacted.
378pub(crate) fn is_benign_secret_value(value: &str) -> bool {
379    is_comparison_operator_residue(value)
380        || is_non_secret_literal(value)
381        || is_identifier_reference(value)
382        || is_placeholder_value(value)
383        || looks_like_uuid(value)
384}
385
386/// Benign-value check for a *quoted* value. A quoted string is a literal, never
387/// an identifier reference, so that heuristic is skipped (`API_KEY="<letters>"`
388/// is a credential). Literals, placeholders, UUIDs and plain numbers — digests,
389/// counters, ids in JSON — stay benign.
390pub(crate) fn is_benign_quoted_value(value: &str) -> bool {
391    is_comparison_operator_residue(value)
392        || is_non_secret_literal(value)
393        || is_placeholder_value(value)
394        || looks_like_uuid(value)
395        || (!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()))
396}
397
398/// Whether a "Generic long secret" match is the slug of a knowledge fact's
399/// own key: the value of a JSON member named exactly `key` (`"key":"…"` or
400/// `"key": "…"`, the quoted form a serialized fact carries) that reads as
401/// words. The rule fires on any 32+ character value after a name ending in
402/// `key`, so every long fact key was masked — all to the same marker, which
403/// merged distinct facts on export and import and broke the ids readers
404/// derive from keys.
405///
406/// Nothing else is exempt: free text (`key: …`, `SIGNING_KEY=…`), any other
407/// member name (`encryption_key`, `auth`, `credential`), and a value that
408/// does not read as words stay masked. `prefix` is the rule's kept group
409/// (the character before the name, the name, the separator).
410///
411/// Known residual risk: a passphrase made of real words joined by `-`
412/// (diceware) stored as the value of a member named `key` reads as a slug
413/// and is kept. A secret belongs under a secret's name, where it is masked.
414pub(crate) fn is_fact_key_slug(prefix: &str, value: &str) -> bool {
415    matches!(prefix, "\"key\":\"" | "\"key\": \"") && looks_like_word_slug(value)
416}
417
418/// Lowercase words and short numbers joined by `-`
419/// (`governor-design-pull-forward-2026-10-29`): at least three parts, at
420/// least two of them words, and at least half of the letter parts words; a
421/// number has at most 8 digits (dates, years, counters — not a PIN or an
422/// account number). A word has a vowel in at least a fifth of its letters
423/// and no run of more than four consonants, or is a one-vowel word of five
424/// to nine letters whose consonants before and after the vowel are a common
425/// English onset and coda (`rights`, `scripts`, `strict`). A part that is
426/// neither must be one of a fixed list of abbreviations (`mr`, `mcp`), which
427/// do not count as words. Random letter groups (`qhztvbkr`, `xkq`) fail.
428/// Any part with a digit beside letters, an upper-case letter or another
429/// character fails the slug.
430fn looks_like_word_slug(value: &str) -> bool {
431    let parts: Vec<&str> = value.split('-').collect();
432    let mut words = 0usize;
433    let mut letter_parts = 0usize;
434    let all_parts_ok = parts.iter().all(|part| {
435        if !part.is_empty() && part.len() <= 8 && part.bytes().all(|b| b.is_ascii_digit()) {
436            return true;
437        }
438        if part.len() < 2 || part.len() > 24 || !part.bytes().all(|b| b.is_ascii_lowercase()) {
439            return false;
440        }
441        letter_parts += 1;
442        let word = is_slug_word(part);
443        words += usize::from(word);
444        word || SLUG_ABBREVIATIONS.contains(part)
445    });
446    parts.len() >= 3 && all_parts_ok && words >= 2 && words * 2 >= letter_parts
447}
448
449/// Vowel-free abbreviations a fact key may carry. A fixed list: a rule for
450/// "short and vowel-free" let random letter groups pass as abbreviations.
451const SLUG_ABBREVIATIONS: &[&str] = &[
452    "cd", "cdn", "cfg", "cmd", "csv", "ctx", "db", "dns", "gc", "grpc", "http", "js", "jwt", "mcp",
453    "md", "ml", "mr", "msg", "npm", "pdf", "pkg", "png", "pr", "rs", "sql", "src", "ssh", "svg",
454    "tls", "tmp", "ts", "ttl", "xml",
455];
456
457/// Consonants before the single vowel of a one-vowel word.
458const SLUG_ONSETS: &[&str] = &[
459    "", "b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "n", "p", "q", "r", "s", "t", "v", "w",
460    "x", "z", "bl", "br", "ch", "cl", "cr", "dr", "fl", "fr", "gl", "gr", "ph", "pl", "pr", "sc",
461    "sh", "sk", "sl", "sm", "sn", "sp", "st", "sw", "th", "tr", "tw", "wh", "wr", "chr", "sch",
462    "scr", "shr", "spl", "spr", "str", "thr",
463];
464
465/// Consonants after the single vowel of a one-vowel word.
466const SLUG_CODAS: &[&str] = &[
467    "bs", "ch", "ck", "cks", "ct", "cts", "ds", "ft", "fts", "ght", "ghts", "gs", "ks", "ld",
468    "lds", "lf", "lk", "lks", "ll", "lls", "lm", "lp", "ls", "lt", "lts", "mp", "mps", "ms", "nch",
469    "nd", "nds", "ng", "ngs", "ngth", "ngths", "nk", "nks", "ns", "nt", "nth", "nths", "nts", "ps",
470    "pt", "pts", "rch", "rd", "rds", "rk", "rks", "rm", "rms", "rn", "rns", "rst", "rt", "rth",
471    "rts", "sh", "sk", "sks", "sp", "ss", "st", "sts", "tch", "th", "ths", "ts", "ws", "x", "xt",
472    "xts",
473];
474
475fn is_slug_word(part: &str) -> bool {
476    let is_vowel = |b: u8| matches!(b, b'a' | b'e' | b'i' | b'o' | b'u' | b'y');
477    let vowels = part.bytes().filter(|b| is_vowel(*b)).count();
478    let mut run = 0usize;
479    let mut longest_run = 0usize;
480    for b in part.bytes() {
481        run = if is_vowel(b) { 0 } else { run + 1 };
482        longest_run = longest_run.max(run);
483    }
484    if vowels * 5 >= part.len() && longest_run <= 4 {
485        return true;
486    }
487    if vowels != 1 || !(5..=9).contains(&part.len()) {
488        return false;
489    }
490    let Some(at) = part.bytes().position(is_vowel) else {
491        return false;
492    };
493    SLUG_ONSETS.contains(&&part[..at]) && SLUG_CODAS.contains(&&part[at + 1..])
494}
495
496/// #1095: when the regex `key=` consumes one `=` from `==`, the captured
497/// "value" is a bare `=` (or `==`, `!=`). A comparison operator is never
498/// a secret; redacting it corrupts source semantics.
499fn is_comparison_operator_residue(value: &str) -> bool {
500    let v = value.trim();
501    matches!(v, "=" | "==" | "!=" | "<=" | ">=" | "===" | "!==")
502}
503
504/// The single source of truth for secret patterns. `shell::redact` delegates
505/// here so the two layers can never drift apart again.
506///
507/// #718 word boundaries: the key/value alternations start with
508/// `(?:^|[^a-z0-9])` (consumed into the kept prefix — the regex crate has no
509/// lookbehind) so camelCase subwords (`superuserPassword`,
510/// `getStripeSecretKey`) never trigger a rule, while SNAKE_CASE env names
511/// (`GITHUB_FEEDBACK_TOKEN`) still do: `_` remains a permitted predecessor.
512fn redaction_rules() -> Vec<Rule> {
513    vec![
514        Rule {
515            label: "Bearer token",
516            // Not the identifier guard (a letters-only base64 token is still a
517            // credential); `bearer_value_is_credential` filters prose instead.
518            re: static_regex!(r"(?i)(bearer[ \t]+)([a-zA-Z0-9_\-\.]{8,})"),
519            guard_value: false,
520        },
521        Rule {
522            label: "Authorization header",
523            // Any token-character credential, however short: `Basic dTpw` (u:p) is
524            // four characters. Token characters only, so adjacent text survives.
525            re: static_regex!(
526                r"(?i)(authorization[ \t]*:[ \t]*(?:basic|bearer|token)[ \t]+)([A-Za-z0-9._~+/=-]+)"
527            ),
528            guard_value: false,
529        },
530        // Whole token is the secret — no prefix group, so the entire match is
531        // replaced. (Previously group 1 captured the key itself and leaked it.)
532        Rule {
533            label: "AWS key",
534            re: static_regex!(r"(?:AKIA|ASIA)[0-9A-Z]{16}"),
535            guard_value: false,
536        },
537        Rule {
538            label: "Private key block",
539            // No kept prefix: the whole block, markers included, is replaced.
540            // A block without its END marker (a truncated file, a line window,
541            // a log tail) is still masked: the header plus every following
542            // key-material line — PEM headers of encrypted keys, then pure
543            // base64 lines. The first other line (code, prose) ends it, so a
544            // source that only mentions the header keeps the rest of the file.
545            re: static_regex!(
546                r"(?s)-----BEGIN\s+(?:RSA\s+|EC\s+|DSA\s+|OPENSSH\s+)?PRIVATE\s+KEY-----(?:.+?-----END\s+(?:RSA\s+|EC\s+|DSA\s+|OPENSSH\s+)?PRIVATE\s+KEY-----|(?-s:[^\n]*)(?:\r?\n[A-Za-z-]+:(?-s:[^\n]*))*(?:\r?\n[A-Za-z0-9+/=]+[ \t]*(?m:$))*)"
547            ),
548            guard_value: false,
549        },
550        Rule {
551            label: "GitHub token",
552            re: static_regex!(r"(gh[pousr]_)[a-zA-Z0-9_]{20,}"),
553            guard_value: false,
554        },
555        Rule {
556            label: "GitHub fine-grained token",
557            re: static_regex!(r"(github_pat_)[A-Za-z0-9_]{20,}"),
558            guard_value: false,
559        },
560        Rule {
561            label: "GitLab token",
562            re: static_regex!(r"(glpat-)[A-Za-z0-9_\-]{20,}"),
563            guard_value: false,
564        },
565        // Provider keys are masked whole — the prefix names the provider and
566        // key class, so it is not kept. Same patterns as the detector, which
567        // also matches them inside nested values without a `key=` prefix.
568        Rule {
569            label: "Anthropic API key",
570            re: crate::core::secret_detection::anthropic_key_re(),
571            guard_value: false,
572        },
573        Rule {
574            label: "OpenAI API key",
575            re: crate::core::secret_detection::openai_key_re(),
576            guard_value: false,
577        },
578        Rule {
579            label: "JWT",
580            re: static_regex!(
581                r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}(?:\.[A-Za-z0-9_\-]{8,})?"
582            ),
583            guard_value: false,
584        },
585        Rule {
586            label: "Slack token",
587            re: static_regex!(r"(xox[bpas]-)[0-9a-zA-Z\-]{10,}"),
588            guard_value: false,
589        },
590        Rule {
591            label: "Stripe key",
592            re: static_regex!(r"([sr]k_live_)[0-9a-zA-Z]{10,}"),
593            guard_value: false,
594        },
595        Rule {
596            label: "npm token",
597            re: static_regex!(r"(npm_)[A-Za-z0-9]{10,}"),
598            guard_value: false,
599        },
600        // Key/value secrets run after provider-specific forms so `key=<AWS key id>`
601        // retains the more precise provider class label.
602        Rule {
603            label: "API key param",
604            re: static_regex!(
605                r#"(?im)((?:^|[^a-z0-9])(?:aws[_-]?secret[_-]?access[_-]?key|secret[_-]?access[_-]?key|api[_-]?key|apikey|access[_-]?key|secret[_-]?key|access[_-]?token|client[_-]?secret|token|password|passwd|pwd|secret)(?:\\?["'])?[ \t]*[=:][ \t]*(?:\\?["'])?)([^\s\r\n,;&"']+)"#
606            ),
607            guard_value: true,
608        },
609        Rule {
610            label: "URL-encoded secret",
611            re: static_regex!(
612                r"(?im)((?:^|[^a-z0-9])(?:aws[_-]?secret[_-]?access[_-]?key|secret[_-]?access[_-]?key|api[_-]?key|apikey|access[_-]?key|secret[_-]?key|access[_-]?token|client[_-]?secret|token|password|passwd|pwd|secret)[ \t]*[=:][ \t]*)(?:((?:%[0-9a-f]{2}|[A-Za-z0-9_\-]){20,}))"
613            ),
614            guard_value: true,
615        },
616        // Group 1 = prefix (kept), group 2 = the 32+ char value. Guarded since
617        // #718: 32-char identifiers like `confirmRequiredEndpointKeySchema`
618        // are references, not secrets.
619        Rule {
620            label: "Generic long secret",
621            re: static_regex!(
622                r#"(?im)((?:^|[^a-z0-9])(?:key|token|secret|password|credential|auth)(?:\\?["'])?[ \t]*[=:][ \t]*(?:\\?["'])?)([a-zA-Z0-9+/=\-_]{32,})"#
623            ),
624            guard_value: true,
625        },
626    ]
627}
628
629pub fn redact_text(input: &str) -> String {
630    redact_text_with_excludes(input, &[])
631}
632
633thread_local! {
634    /// The public key a package signature was verified with, while its
635    /// document is being released on this thread.
636    static VERIFIED_SIGNER_KEY: std::cell::RefCell<Option<String>> =
637        const { std::cell::RefCell::new(None) };
638}
639
640/// Run `release` with `key` known as the Ed25519 PUBLIC key a package
641/// signature has just been verified with. Inside, the built-in "Generic long
642/// secret" rule — and only that rule — does not read the member
643/// `"signer_public_key":"<key>"` (a bundle) or `"public_key":"<key>"` (a
644/// package manifest's signature) as a secret: a public key is none, and the
645/// rule fires on it only because a 64-digit value follows a name ending in
646/// `key`. Every other rule, every configured pattern, every policy pack and
647/// the gateway still see the original text with the real key in it and
648/// decide as always. A `key` that is not 64 lowercase hex digits exempts
649/// nothing.
650pub fn with_verified_signer_key<T>(key: &str, release: impl FnOnce() -> T) -> T {
651    let well_formed = key.len() == 64
652        && key
653            .bytes()
654            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
655    if !well_formed {
656        return release();
657    }
658    struct Restore(Option<String>);
659    impl Drop for Restore {
660        fn drop(&mut self) {
661            VERIFIED_SIGNER_KEY.with(|slot| *slot.borrow_mut() = self.0.take());
662        }
663    }
664    let _restore = Restore(VERIFIED_SIGNER_KEY.with(|slot| slot.replace(Some(key.to_owned()))));
665    release()
666}
667
668/// Whether the generic rule's match is the verified signer key in its own
669/// member: `before` is the text up to the match, which starts at the `_` of
670/// `_key`.
671fn is_verified_signer_key(before: &str, value: &str) -> bool {
672    (before.ends_with("\"signer_public") || before.ends_with("\"public"))
673        && VERIFIED_SIGNER_KEY.with(|slot| slot.borrow().as_deref() == Some(value))
674}
675
676/// #718: `redact_text` with subtractive user patterns from
677/// `[secret_detection].exclude_patterns` — a match covered by any exclude
678/// regex is kept verbatim, so known-safe naming conventions can be carved out
679/// without disabling secret detection wholesale.
680pub fn redact_text_with_excludes(input: &str, excludes: &[regex::Regex]) -> String {
681    redact_counting(input, excludes).0
682}
683
684/// Database URL with inline credentials: scheme (1), user (2, may be empty —
685/// `redis://:pw@host`), password (3), host (4). The password runs to the last
686/// `@` before the host, so an unencoded `@` in it cannot leak a tail. Shared
687/// with the detector so both layers agree on what a credential is.
688pub(crate) fn database_url_re() -> &'static regex::Regex {
689    static_regex!(
690        r"(?i)((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis)://)([^:/@?#\s]*):([^/?#\s]+)@([^@/?#\s]+)"
691    )
692}
693
694/// True when a [`database_url_re`] match carries a real credential. A
695/// placeholder password is not one, and neither is an already redacted
696/// `scheme://[REDACTED:db_url]@host` — its `:` would otherwise read as
697/// `user:password`, so a re-scan of redacted output found a "residual" secret
698/// and the context gateway withheld the whole file.
699pub(crate) fn database_url_has_credential(caps: &regex::Captures) -> bool {
700    caps.get(2)
701        .is_none_or(|user| !user.as_str().starts_with("[REDACTED"))
702        && caps
703            .get(3)
704            .is_some_and(|password| !is_placeholder_value(password.as_str()))
705}
706
707/// [`redact_text_with_excludes`] plus the number of secrets it replaced.
708///
709/// One mask label per secret kind: the rule's own `label`, whoever asks. The
710/// gateway's scanner (`secret_detection::scan_and_redact`) and a project
711/// pack's built-in pass (`policy::content::evaluate_text`) both end here, so a
712/// value masked by one is spelled exactly as the other would spell it and a
713/// second pass over masked text changes nothing. (The scanner used to map the
714/// labels to its pattern names — `[REDACTED:aws_key]` next to
715/// `[REDACTED:AWS key]` — which made every order-of-masking comparison fail
716/// for a built-in secret.)
717fn redact_counting(input: &str, excludes: &[regex::Regex]) -> (String, usize) {
718    let (out, found) = redact_found(input, excludes);
719    (out, found.len())
720}
721
722/// The redacted text and the label of each redacted span (never its text).
723fn redact_found(input: &str, excludes: &[regex::Regex]) -> (String, Vec<&'static str>) {
724    let (normalized, boundaries) = normalize_for_secret_matching(input);
725    let mut replacements = Vec::new();
726    // The label of each replacement, index for index.
727    let mut labels: Vec<&'static str> = Vec::new();
728
729    // userinfo with a password in any other URL (`https://user:pw@host`): the
730    // tokens elsewhere in such a URL were masked, the password stayed (focus
731    // analysis 2026-10-15).
732    let url_re = static_regex!(
733        r"(?i)((?:https?|ftps?|sftp|ssh|amqps?|smtps?|ldaps?|wss?|git)://)([^:/@?#\s]+):([^@/?#\s]+)@([^/?#\s]+)"
734    );
735    for (re, label) in [
736        (database_url_re(), "Database credentials"),
737        (url_re, "URL credentials"),
738    ] {
739        for caps in re.captures_iter(&normalized) {
740            let Some(whole) = caps.get(0) else {
741                continue;
742            };
743            let start = boundaries[whole.start()];
744            let end = boundaries[whole.end()];
745            let original = input.get(start..end).unwrap_or(whole.as_str());
746            if excluded(excludes, whole.as_str()) || excluded(excludes, original) {
747                continue;
748            }
749            // A placeholder password or an already redacted URL is no
750            // credential (shared with the detector, see `database_url_re`).
751            if !database_url_has_credential(&caps) {
752                continue;
753            }
754            let scheme = caps
755                .get(1)
756                .map_or("", |m| original_span(input, &boundaries, m));
757            let host = caps
758                .get(4)
759                .map_or("", |m| original_span(input, &boundaries, m));
760            replacements.push((start, end, format!("{scheme}[REDACTED:{label}]@{host}")));
761            labels.push(label);
762        }
763    }
764
765    for rule in redaction_rules() {
766        for caps in rule.re.captures_iter(&normalized) {
767            let Some(whole) = caps.get(0) else {
768                continue;
769            };
770            let start = boundaries[whole.start()];
771            let end = boundaries[whole.end()];
772            let original = input.get(start..end).unwrap_or(whole.as_str());
773            if excluded(excludes, whole.as_str()) || excluded(excludes, original) {
774                continue;
775            }
776            // A quoted value is a literal, never an identifier reference, so the
777            // benign-value guard does not apply to it.
778            if rule.guard_value
779                && let Some(value) = caps.get(2)
780                && if is_quoted_at(&normalized, value.start()) {
781                    is_benign_quoted_value(value.as_str())
782                } else {
783                    is_benign_secret_value(value.as_str())
784                }
785            {
786                continue;
787            }
788            if rule.label == "Bearer token"
789                && caps
790                    .get(2)
791                    .is_some_and(|value| !bearer_value_is_credential(value.as_str()))
792            {
793                continue;
794            }
795            if rule.label == "Generic long secret"
796                && caps.get(2).is_some_and(|value| {
797                    caps.get(1)
798                        .is_some_and(|prefix| is_fact_key_slug(prefix.as_str(), value.as_str()))
799                        || is_verified_signer_key(&normalized[..whole.start()], value.as_str())
800                })
801            {
802                continue;
803            }
804            // Overlapping matches: the wider span wins. Skipping a wider match (a whole
805            // private-key block) because a narrower rule already hit inside it would
806            // leak the rest of the block.
807            let overlapping: Vec<usize> = replacements
808                .iter()
809                .enumerate()
810                .filter(|(_, (other_start, other_end, _))| start < *other_end && *other_start < end)
811                .map(|(i, _)| i)
812                .collect();
813            if overlapping.iter().any(|&i| {
814                let (other_start, other_end, _) = &replacements[i];
815                *other_start <= start && end <= *other_end
816            }) {
817                continue;
818            }
819            let start = overlapping
820                .iter()
821                .map(|&i| replacements[i].0)
822                .fold(start, usize::min);
823            let end = overlapping
824                .iter()
825                .map(|&i| replacements[i].1)
826                .fold(end, usize::max);
827            for &i in overlapping.iter().rev() {
828                replacements.remove(i);
829                labels.remove(i);
830            }
831
832            let label = rule.label;
833            let replacement = caps.get(1).map_or_else(
834                || format!("[REDACTED:{label}]"),
835                |prefix| {
836                    let original_prefix = original_span(input, &boundaries, prefix);
837                    format!("{original_prefix}[REDACTED:{label}]")
838                },
839            );
840            replacements.push((start, end, replacement));
841            labels.push(label);
842        }
843    }
844
845    let mut spans: Vec<((usize, usize, String), &'static str)> =
846        replacements.into_iter().zip(labels).collect();
847    spans.sort_by_key(|((start, _, _), _)| *start);
848    let mut found = Vec::with_capacity(spans.len());
849    let mut out = input.to_string();
850    for ((start, end, replacement), label) in spans.into_iter().rev() {
851        found.push(label);
852        out.replace_range(start..end, &replacement);
853    }
854    found.reverse();
855    (out, found)
856}
857
858fn original_span<'a>(input: &'a str, boundaries: &[usize], matched: regex::Match<'_>) -> &'a str {
859    let start = boundaries.get(matched.start()).copied().unwrap_or_default();
860    let end = boundaries.get(matched.end()).copied().unwrap_or(start);
861    input.get(start..end).unwrap_or("")
862}
863
864/// Whether the byte before `pos` in `text` is a quote (the value is a quoted literal).
865fn is_quoted_at(text: &str, pos: usize) -> bool {
866    pos > 0 && matches!(text.as_bytes()[pos - 1], b'"' | b'\'')
867}
868
869fn excluded(excludes: &[regex::Regex], value: &str) -> bool {
870    excludes.iter().any(|pattern| pattern.is_match(value))
871}
872
873/// Compile the configured `exclude_patterns` (#718). Invalid regexes are
874/// skipped — a broken exclude must never disable redaction.
875///
876/// #952: regex compilation — the expensive part of this call — used to run
877/// on every redaction call (`ctx_read`, `ctx_shell`, `ctx_execute` all funnel
878/// through `redact_text_if_enabled`) regardless of whether the config had
879/// changed. Cached here, keyed on `Arc::ptr_eq` against the config `Arc`:
880/// `Config::load_arc` returns the *same* `Arc` when the underlying config
881/// content hash is unchanged, so a real config edit is exactly what
882/// invalidates this cache too — no separate change-detection needed.
883pub fn config_exclude_patterns() -> std::sync::Arc<Vec<regex::Regex>> {
884    type Cache = Option<(
885        std::sync::Arc<crate::core::config::Config>,
886        std::sync::Arc<Vec<regex::Regex>>,
887    )>;
888    static CACHE: std::sync::Mutex<Cache> = std::sync::Mutex::new(None);
889
890    let cfg = crate::core::config::Config::load_arc();
891
892    if let Ok(guard) = CACHE.lock()
893        && let Some((cached_cfg, patterns)) = &*guard
894        && std::sync::Arc::ptr_eq(cached_cfg, &cfg)
895    {
896        return std::sync::Arc::clone(patterns);
897    }
898
899    let compiled = std::sync::Arc::new(
900        cfg.secret_detection
901            .exclude_patterns
902            .iter()
903            .filter_map(|p| regex::Regex::new(p).ok())
904            .collect::<Vec<_>>(),
905    );
906
907    if let Ok(mut guard) = CACHE.lock() {
908        *guard = Some((cfg, std::sync::Arc::clone(&compiled)));
909    }
910
911    compiled
912}
913
914/// Apply caller-supplied policy redaction patterns on top of the built-in
915/// secret rules: each regex match becomes `[REDACTED:<label>]`. Returns the
916/// transformed text and the number of redactions applied (for audit counts).
917///
918/// Used by context policy packs (GL #673) so a pack's `[redaction]` block
919/// actually removes matching content from what the model sees. The patterns are
920/// the pack's `[redaction]` entries, precompiled by
921/// [`crate::core::policy::runtime`].
922#[must_use]
923pub fn redact_with_patterns(input: &str, patterns: &[(String, regex::Regex)]) -> (String, usize) {
924    let mut out = input.to_string();
925    let mut hits = 0usize;
926    for (label, re) in patterns {
927        let mut local = 0usize;
928        out = re
929            .replace_all(&out, |_caps: &regex::Captures| {
930                local += 1;
931                format!("[REDACTED:{label}]")
932            })
933            .to_string();
934        hits += local;
935    }
936    (out, hits)
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942
943    /// A package manifest's verified signature key is kept in its own
944    /// member, as written or nested in an envelope; any other value there,
945    /// and any key outside the exemption, is still masked.
946    #[test]
947    fn a_verified_package_signer_key_is_not_masked() {
948        let key = "ab".repeat(32);
949        let plain = format!(r#"{{"signature":{{"public_key":"{key}"}}}}"#);
950        let nested = serde_json::to_string(&plain).expect("nested");
951        assert!(
952            !redact_text(&plain).contains(&key),
953            "masked without the exemption"
954        );
955        with_verified_signer_key(&key, || {
956            assert_eq!(redact_text(&plain), plain);
957            assert_eq!(redact_text(&nested), nested);
958            let other = plain.replace(&key, &"cd".repeat(32));
959            assert_ne!(redact_text(&other), other, "only the verified key");
960        });
961    }
962
963    #[test]
964    fn standalone_provider_keys_are_fully_masked_in_nested_values() {
965        let suffix = "aB3_".repeat(9);
966        for prefix in ["sk-proj-", "sk-svcacct-", "sk-ant-"] {
967            let secret = format!("{prefix}{suffix}");
968            let raw = serde_json::json!({"nested":[secret.clone()]}).to_string();
969            let safe = redact_text(&raw);
970            assert!(!safe.contains(&suffix), "must mask the entire token");
971            assert!(!safe.contains(prefix), "must not retain the secret prefix");
972            assert!(safe.contains("REDACTED"));
973            assert!(!crate::core::secret_detection::detect_secrets(&secret).is_empty());
974        }
975        assert_eq!(
976            redact_text("sk-proj-your_key_here"),
977            "sk-proj-your_key_here"
978        );
979    }
980
981    // --- #952: exclude_patterns compilation cache ---
982
983    #[test]
984    fn config_exclude_patterns_reuses_compiled_regexes_when_config_unchanged() {
985        // Other tests swap the config dir; "unchanged" needs the env lock.
986        let _lock = crate::core::data_dir::test_env_lock();
987        let first = config_exclude_patterns();
988        let second = config_exclude_patterns();
989        assert!(
990            std::sync::Arc::ptr_eq(&first, &second),
991            "unchanged config must reuse the cached compiled patterns, not recompile"
992        );
993    }
994
995    #[test]
996    fn redacts_bearer_token() {
997        let s = "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345";
998        let out = redact_text(s);
999        assert!(out.contains("[REDACTED"));
1000        assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
1001    }
1002
1003    #[test]
1004    fn redacts_private_key_block() {
1005        let s = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
1006        let out = redact_text(s);
1007        assert!(out.contains("[REDACTED"));
1008        assert!(!out.contains("\nabc\n"));
1009    }
1010
1011    /// A key cut off before its END marker — a line window, a truncated file,
1012    /// a log tail — is masked through its last key-material line; text before
1013    /// the header and code after the key material survive.
1014    #[test]
1015    fn redacts_a_truncated_private_key_but_not_the_code_around_it() {
1016        let s = concat!(
1017            "config:\n-----BEGIN RSA PRIVATE",
1018            " KEY-----\nProc-Type: 4,ENCRYPTED\nMIIEpAIBAAKCAQEA\nqx7Wmore"
1019        );
1020        let out = redact_text(s);
1021        assert!(out.starts_with("config:\n"), "{out}");
1022        assert!(out.contains("[REDACTED"), "{out}");
1023        for leaked in ["RSA PRIVATE", "Proc-Type", "MIIEpAIBAAKCAQEA", "qx7Wmore"] {
1024            assert!(!out.contains(leaked), "{leaked} leaked: {out}");
1025        }
1026
1027        // A source that only names the header keeps the rest of the file.
1028        let source = concat!(
1029            "const HEADER: &str = \"-----BEGIN PRIVATE",
1030            " KEY-----\";\nfn parse(pem: &str) -> bool {\n    pem.starts_with(HEADER)\n}\n"
1031        );
1032        let out = redact_text(source);
1033        assert!(out.contains("fn parse(pem: &str) -> bool {"), "{out}");
1034        assert!(out.contains("pem.starts_with(HEADER)"), "{out}");
1035    }
1036
1037    #[test]
1038    fn redacts_api_key_param_value() {
1039        let out = redact_text("password=hunter2-super-secret-value");
1040        assert!(
1041            out.contains("password=[REDACTED:API key param]"),
1042            "got: {out}"
1043        );
1044        assert!(!out.contains("hunter2"));
1045    }
1046
1047    /// GH #430: TypeScript type annotations and language literals must NOT be
1048    /// redacted — over-eager masking corrupted source files read via ctx_read.
1049    #[test]
1050    fn keeps_non_secret_literals() {
1051        for s in [
1052            "password: undefined",
1053            "secret: string",
1054            "token: null",
1055            "apiKey: boolean",
1056            "password = false",
1057            "secret: any",
1058            "let pwd: number = 1",
1059        ] {
1060            assert_eq!(redact_text(s), s, "must not redact non-secret literal: {s}");
1061        }
1062    }
1063
1064    /// GH #430: TS type annotations (generics, unions, arrays, function/object
1065    /// types) carry angle brackets / brackets that real secret tokens never do,
1066    /// so they must survive verbatim even when the key looks sensitive.
1067    #[test]
1068    fn keeps_type_annotations() {
1069        for s in [
1070            "password: Promise<string>",
1071            "apiKey: Record<string, unknown>",
1072            "token: string[]",
1073            "secret: () => void",
1074            "password: string | undefined",
1075            "credential: { value: string }",
1076        ] {
1077            assert_eq!(redact_text(s), s, "must not redact type annotation: {s}");
1078        }
1079    }
1080
1081    /// A primitive type after a sensitive-looking name is a declaration, not
1082    /// a credential (`const ESTIMATED_CHARS_PER_TOKEN: u64 = 4;` was read
1083    /// back as `[REDACTED:API key param] = 4`). A value that merely contains
1084    /// a type name stays protected.
1085    #[test]
1086    fn keeps_primitive_type_declarations() {
1087        for s in [
1088            "const ESTIMATED_CHARS_PER_TOKEN: u64 = 4;",
1089            "const MAX_TOKEN: usize = 4;",
1090            "    max_token: u32,",
1091            "var Token int64",
1092            "secret: bool = false",
1093            "fn token(&self) -> Self",
1094            "private long token;",
1095        ] {
1096            assert_eq!(redact_text(s), s, "must not redact a type declaration: {s}");
1097        }
1098        let out = redact_text("password: usize4Hunter2");
1099        assert!(
1100            out.contains("[REDACTED"),
1101            "a real value stays masked: {out}"
1102        );
1103    }
1104
1105    /// Whole-token secrets must be removed, not annotated in place — previously
1106    /// the closure kept group 1 (the key itself) and only appended `[REDACTED]`.
1107    #[test]
1108    fn fully_redacts_aws_key() {
1109        let aws_key = concat!("AK", "IAIOSFODNN7EXAMPLE");
1110        let out = redact_text(aws_key);
1111        assert!(!out.contains(aws_key), "AWS key leaked: {out}");
1112        assert!(out.contains("[REDACTED:AWS key]"));
1113    }
1114
1115    #[test]
1116    fn fully_redacts_generic_long_secret() {
1117        // `credential=` is not covered by the API-key-param rule, so this
1118        // exercises the generic fallback (the previously leaky path).
1119        let secret = "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6"; // 32 chars
1120        let out = redact_text(&format!("credential={secret}"));
1121        assert!(!out.contains(secret), "long secret leaked: {out}");
1122        assert!(
1123            out.contains("credential=[REDACTED:Generic long secret]"),
1124            "got: {out}"
1125        );
1126    }
1127
1128    #[test]
1129    fn redacts_github_token_keeping_prefix() {
1130        let github_token = concat!("gh", "p_", "abcdefghijklmnopqrstuvwxyz0123");
1131        let out = redact_text(github_token);
1132        assert!(
1133            out.starts_with(concat!("gh", "p_", "[REDACTED:GitHub token]")),
1134            "got: {out}"
1135        );
1136        assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
1137    }
1138
1139    // ── #718: benign identifier references, prose and placeholders ──
1140
1141    /// Repro 1: prose that mentions a keyword must not have the following
1142    /// word redacted — "token: SvelteKit's…" is documentation, not a secret.
1143    #[test]
1144    fn keeps_prose_identifier_after_keyword() {
1145        let s = "the CSRF token: SvelteKit's native origin-check on form actions";
1146        assert_eq!(redact_text(s), s, "prose must survive verbatim");
1147    }
1148
1149    /// Repro 2: camelCase subwords must not trigger the keyword alternation,
1150    /// and identifier/property-access RHS values are references, not secrets.
1151    #[test]
1152    fn keeps_identifier_and_property_references() {
1153        for s in [
1154            "superuserPassword: inputEnv.POCKETBASE_SUPERUSER_PASSWORD",
1155            "export const getStripeSecretKey = serverEnv.getStripeSecretKey;",
1156            "const apiKey = config.stripeApiKey",
1157        ] {
1158            assert_eq!(redact_text(s), s, "identifier reference redacted: {s}");
1159        }
1160    }
1161
1162    /// Repro 3: a 32+ char identifier (Zod schema name) is a reference —
1163    /// "Generic long secret" needs the same value guard as the API-key rule.
1164    #[test]
1165    fn keeps_long_schema_identifier() {
1166        let s = "endpoint_key: confirmRequiredEndpointKeySchema,";
1167        assert_eq!(redact_text(s), s, "schema identifier must not be redacted");
1168    }
1169
1170    /// Repro 4: obvious placeholder values (.env.example) are documentation.
1171    #[test]
1172    fn keeps_placeholder_values() {
1173        for s in [
1174            concat!("GITHUB_FEEDBACK_TOKEN=gh", "p_change_me"),
1175            "API_KEY=your_key_here",
1176            "password=<insert-password>",
1177            "SECRET_KEY=xxxxxxxx",
1178        ] {
1179            assert_eq!(redact_text(s), s, "placeholder redacted: {s}");
1180        }
1181    }
1182
1183    /// #1830: a keyword with no value on its line must not take the next
1184    /// line's diff marker or key for its value.
1185    #[test]
1186    fn a_valueless_key_does_not_reach_into_the_next_line() {
1187        for s in [
1188            "+token:\n+  accesstoken: acc",
1189            "token:\n  refreshtoken: ref",
1190            "password =\n-  old_value",
1191            "Authorization: Bearer\nnext_line_word",
1192            "credential:\n  ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef012345",
1193        ] {
1194            assert_eq!(redact_text(s), s, "crossed a line break: {s:?}");
1195        }
1196        // The value on the key's own line is still redacted.
1197        let out = redact_text("token:\n  password: hunter2-real-value");
1198        assert!(!out.contains("hunter2"), "leaked: {out}");
1199        assert!(
1200            out.starts_with("token:\n  password: [REDACTED"),
1201            "got: {out}"
1202        );
1203    }
1204
1205    /// #1831: an asterisk mask is what a redactor writes; it is not a secret,
1206    /// and a `full` read must round-trip it into an edit.
1207    #[test]
1208    fn keeps_asterisk_masks() {
1209        for s in [
1210            "password: *****",
1211            "password: ***",
1212            r#"expected := []string{"password: *****", "title: My Home"}"#,
1213            "token=*",
1214        ] {
1215            assert_eq!(redact_text(s), s, "mask redacted: {s}");
1216        }
1217        // A value that merely contains asterisks is not a mask.
1218        assert!(redact_text("password: ab*cd1234").contains("[REDACTED"));
1219    }
1220
1221    #[test]
1222    fn key_value_redaction_never_crosses_a_line_break() {
1223        let samples = [
1224            concat!("token:", "\n", "refreshtoken: harmless"),
1225            concat!("Authorization: Bearer", "\n", "next_line_word"),
1226            concat!(
1227                "credential:",
1228                "\n  ",
1229                "ABCDEFGHIJKLMNOP",
1230                "QRSTUVWXYZabcdef",
1231                "012345"
1232            ),
1233        ];
1234        for s in samples {
1235            assert_eq!(redact_text(s), s, "redaction crossed a line: {s:?}");
1236        }
1237        let same_line_secret = ["token: ", "abc123", "def456", "ghi789"].concat();
1238        let out = redact_text(&same_line_secret);
1239        assert!(
1240            out.contains("[REDACTED:API key param]"),
1241            "same-line secrets must remain redacted: {out}"
1242        );
1243    }
1244
1245    #[test]
1246    fn asterisk_masks_survive_redaction() {
1247        for s in [
1248            "password: *****",
1249            "password: ***",
1250            r#"expected := []string{"password: *****", "title: My Home"}"#,
1251            "token=*",
1252        ] {
1253            assert_eq!(redact_text(s), s, "mask changed: {s}");
1254        }
1255    }
1256
1257    /// The flip side: real secret-shaped values must STILL be redacted after
1258    /// the #718 guards.
1259    #[test]
1260    fn still_redacts_real_secret_values() {
1261        // Digit-bearing value after a snake_case env name.
1262        let out = redact_text("GITHUB_TOKEN=ghpA1b2c3d4e5f6g7h8");
1263        assert!(!out.contains("ghpA1b2c3d4e5f6g7h8"), "leaked: {out}");
1264        // SNAKE_CASE env assignment with digits (the _ predecessor stays a
1265        // word boundary that MATCHES).
1266        let out = redact_text("MY_SECRET=abc123def456ghi789");
1267        assert!(!out.contains("abc123def456ghi789"), "leaked: {out}");
1268        // Quoted 32+ char literal: a quoted value is never an identifier
1269        // reference, so the Generic-long-secret guard keeps redacting it.
1270        let quoted = "key: 'abcdefghijklmnopqrstuvwxyzabcdef'";
1271        let out = redact_text(quoted);
1272        assert!(
1273            !out.contains("abcdefghijklmnopqrstuvwxyzabcdef"),
1274            "leaked: {out}"
1275        );
1276    }
1277
1278    /// #718: exclude_patterns carve matches out subtractively.
1279    #[test]
1280    fn exclude_patterns_skip_matching_redactions() {
1281        let excludes = vec![regex::Regex::new(r"LCTX_TEST_\w+").unwrap()];
1282        let input = "token=LCTX_TEST_a1b2c3d4e5";
1283        assert_eq!(
1284            redact_text_with_excludes(input, &excludes),
1285            input,
1286            "excluded match must stay verbatim"
1287        );
1288        // Without the exclude the same value IS redacted (digits → secret).
1289        assert!(redact_text(input).contains("[REDACTED"));
1290    }
1291
1292    #[test]
1293    fn identifier_and_placeholder_heuristics() {
1294        assert!(is_identifier_reference("serverEnv.getStripeSecretKey"));
1295        assert!(is_identifier_reference("confirmRequiredEndpointKeySchema"));
1296        assert!(is_identifier_reference("$scope._private"));
1297        assert!(!is_identifier_reference("abc123"), "digits → secret-shaped");
1298        assert!(!is_identifier_reference("\"quoted\""), "literal value");
1299        assert!(!is_identifier_reference("a-b"), "dash is not identifier");
1300        assert!(is_placeholder_value(concat!("gh", "p_change_me")));
1301        assert!(is_placeholder_value("<token>"));
1302        assert!(is_placeholder_value("your_api_key_123"));
1303        assert!(!is_placeholder_value("A1b2C3d4E5f6G7h8"));
1304    }
1305
1306    #[test]
1307    fn policy_patterns_redact_with_label_and_count() {
1308        let patterns = vec![(
1309            "employee_id".to_string(),
1310            regex::Regex::new(r"EMP-\d{4}").unwrap(),
1311        )];
1312        let (out, hits) = redact_with_patterns("user EMP-1234 and EMP-5678", &patterns);
1313        assert_eq!(hits, 2);
1314        assert!(!out.contains("EMP-1234"));
1315        assert!(out.contains("[REDACTED:employee_id]"));
1316    }
1317
1318    #[test]
1319    fn policy_patterns_noop_when_no_match() {
1320        let patterns = vec![("iban".to_string(), regex::Regex::new(r"CH\d{2}").unwrap())];
1321        let (out, hits) = redact_with_patterns("nothing sensitive here", &patterns);
1322        assert_eq!(hits, 0);
1323        assert_eq!(out, "nothing sensitive here");
1324    }
1325
1326    /// GH #827: scientific notation and plain numbers after keys containing
1327    /// `token` must not be redacted — these are pricing/cost fields.
1328    #[test]
1329    fn keeps_numeric_values_827() {
1330        for (input, desc) in [
1331            ("input_cost_per_token: 1.4e-06", "scientific notation"),
1332            ("output_cost_per_token: 4.4e-06", "scientific notation"),
1333            (
1334                "cache_read_input_token_cost: 1.9e-07",
1335                "scientific notation",
1336            ),
1337            ("token: 600", "plain integer"),
1338            ("secret: 0.5", "decimal float"),
1339            ("api_key: 42", "small integer"),
1340            ("password: 3.14", "pi float"),
1341        ] {
1342            assert_eq!(
1343                redact_text(input),
1344                input,
1345                "must not redact numeric value ({desc}): {input}"
1346            );
1347        }
1348    }
1349
1350    /// GH #827: env-variable references must not be redacted — they are
1351    /// pointers to secrets, not the secrets themselves.
1352    #[test]
1353    fn keeps_env_references_827() {
1354        for (input, desc) in [
1355            (
1356                "api_key: os.environ/MY_SERVICE_API_KEY",
1357                "Python os.environ/",
1358            ),
1359            ("secret: os.getenv(MY_KEY)", "Python os.getenv()"),
1360            ("token: process.env.API_TOKEN", "Node process.env"),
1361            (
1362                "password: inputEnv.POCKETBASE_SUPERUSER_PASSWORD",
1363                "inputEnv dot ref",
1364            ),
1365            ("api_key: ${MY_API_KEY}", "shell interpolation ${}"),
1366            ("secret: $MY_SECRET", "shell $VAR"),
1367            ("token: %API_TOKEN%", "Windows %VAR%"),
1368            ("api_key: ENV[API_KEY]", "Ruby ENV[]"),
1369            ("secret: env(SECRET_KEY)", "Laravel env()"),
1370            ("password: System.getenv(DB_PASS)", "Java System.getenv"),
1371        ] {
1372            assert_eq!(
1373                redact_text(input),
1374                input,
1375                "must not redact env ref ({desc}): {input}"
1376            );
1377        }
1378    }
1379
1380    /// GH #827: real secrets must STILL be redacted (regression guard).
1381    #[test]
1382    fn still_redacts_real_secrets_827() {
1383        for input in [
1384            "api_key: sk-1234567890abcdef1234567890abcdef",
1385            "password: hunter2-super-secret-value",
1386            "token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
1387        ] {
1388            let out = redact_text(input);
1389            assert!(
1390                out.contains("[REDACTED"),
1391                "must redact real secret: {input} -> {out}"
1392            );
1393        }
1394    }
1395
1396    /// GH #1095: `token == ""` in Go source must not be redacted — the regex
1397    /// consumes the first `=` leaving group 2 = `=` (comparison residue).
1398    #[test]
1399    fn keeps_comparison_operators_in_source() {
1400        for s in [
1401            r#"if token == "" {"#,
1402            r#"if token, err = checkPulsares(); token == "" || err != nil {"#,
1403            r#"if password != "" {"#,
1404            r"assert secret == expected_value",
1405        ] {
1406            assert_eq!(redact_text(s), s, "comparison operator corrupted: {s}");
1407        }
1408    }
1409
1410    /// A knowledge fact's own key is a slug, not a secret: every long one
1411    /// was masked to the same marker, merging distinct facts on export and
1412    /// import. Only that member, in its quoted JSON form, with words for
1413    /// parts, is kept; every other place and shape stays masked.
1414    #[test]
1415    fn only_a_word_slug_as_a_fact_key_is_kept() {
1416        for kept in [
1417            r#"{"key":"governor-design-pull-forward-2026-10-29"}"#,
1418            r#"{"key": "hive-desktop-knowledge-speed-round-one"}"#,
1419            // Words with one vowel in six or seven letters, abbreviations.
1420            r#"{"key":"gotcha-code-reviewer-no-probe-rights"}"#,
1421            r#"{"key":"hive-mr-ci-scripts-strict-backlog-check"}"#,
1422        ] {
1423            assert_eq!(redact_text(kept), kept);
1424        }
1425        let masked_cases = [
1426            // Random letter groups, a hex digest, a long number.
1427            r#"{"key":"qhztvbkr-plmnwxsd-rtvbnmkl-zxcvbnml"}"#.to_owned(),
1428            r#"{"key":"deadbeef-cafebabe-facefeed-0123456789"}"#.to_owned(),
1429            format!(r#"{{"key":"my-pin-{}"}}"#, "4".repeat(26)),
1430            // Mostly vowel-free groups: abbreviations do not make a slug.
1431            r#"{"key":"zxq-vbk-pwm-rtk-qzx-bnm-lkj-word-also"}"#.to_owned(),
1432            // A slug under any other name, or in free text.
1433            r#"{"auth":"governor-design-pull-forward-2026-10-29"}"#.to_owned(),
1434            r#"{"credential":"governor-design-pull-forward-2026-10-29"}"#.to_owned(),
1435            r#"{"encryption_key":"correct-horse-battery-staple-orbit-lamp"}"#.to_owned(),
1436            "SIGNING_KEY=governor-design-pull-forward-2026-10-29".to_owned(),
1437            "MASTER_KEY=governor-design-pull-forward-2026-10-29".to_owned(),
1438            "key: hive-desktop-knowledge-speed-round-one".to_owned(),
1439        ];
1440        for masked in masked_cases {
1441            assert!(redact_text(&masked).contains("[REDACTED"), "{masked}");
1442        }
1443    }
1444
1445    /// The fact-key exemption must not let random keys through: lowercase
1446    /// letter groups joined by `-` under a JSON `key` member stay masked by
1447    /// the redactor and found by secret detection (`high_entropy_secret`,
1448    /// 40+ characters) about as often as before the exemption existed
1449    /// (at most ~4% of a shape reads as words). Seeded, so deterministic.
1450    #[test]
1451    fn random_lowercase_keys_under_a_key_member_stay_masked() {
1452        let mut state = 0x9e37_79b9_7f4a_7c15_u64;
1453        let mut letter = || {
1454            state ^= state << 13;
1455            state ^= state >> 7;
1456            state ^= state << 17;
1457            char::from(b'a' + u8::try_from(state % 26).unwrap_or(0))
1458        };
1459        let mut random_key = |groups: usize, size: usize| {
1460            (0..groups)
1461                .map(|_| (0..size).map(|_| letter()).collect::<String>())
1462                .collect::<Vec<_>>()
1463                .join("-")
1464        };
1465        const SAMPLES: usize = 2000;
1466        const MAX_KEPT: usize = SAMPLES * 6 / 100;
1467        for (groups, size) in [(8, 4), (9, 3), (12, 2), (5, 6)] {
1468            let kept = (0..SAMPLES)
1469                .filter(|_| {
1470                    let doc = format!(r#"{{"key":"{}"}}"#, random_key(groups, size));
1471                    !redact_text(&doc).contains("[REDACTED")
1472                })
1473                .count();
1474            assert!(
1475                kept <= MAX_KEPT,
1476                "{groups}x{size}: {kept} of {SAMPLES} random keys kept"
1477            );
1478        }
1479        for (groups, size) in [(10, 4), (11, 3), (14, 2), (6, 6)] {
1480            let missed = (0..SAMPLES)
1481                .filter(|_| {
1482                    let doc = format!(r#"{{"key":"{}"}}"#, random_key(groups, size));
1483                    crate::core::secret_detection::detect_secrets(&doc).is_empty()
1484                })
1485                .count();
1486            assert!(
1487                missed <= MAX_KEPT,
1488                "{groups}x{size}: {missed} of {SAMPLES} random keys undetected"
1489            );
1490        }
1491    }
1492}