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
20pub fn redact_text_if_enabled(input: &str) -> String {
21    if !redaction_enabled_for_active_role() {
22        return input.to_string();
23    }
24    redact_text_with_excludes(input, config_exclude_patterns().as_slice())
25}
26
27/// #718: unquoted identifier or property-access chains (`SvelteKit`,
28/// `inputEnv.POCKETBASE_SUPERUSER_PASSWORD`, `serverEnv.getStripeSecretKey`,
29/// `confirmRequiredEndpointKeySchema`) are code REFERENCES to a secret, never
30/// the literal value — the value lives in a gitignored `.env`. Digits make a
31/// token secret-shaped (base64/hex), so any digit keeps the redaction
32/// (conservative: `password=hunter2` stays covered).
33/// #827: detect pure numeric values — integers, floats, scientific notation.
34/// These are never secrets even when the key contains `token` or `key`.
35fn looks_like_number(v: &str) -> bool {
36    if v.is_empty() {
37        return false;
38    }
39    let s = v.trim_start_matches(['+', '-']);
40    if s.is_empty() {
41        return false;
42    }
43    // Integer, float, or scientific notation (1.4e-06, 0.5, 600, 1e10)
44    s.parse::<f64>().is_ok()
45        && s.chars()
46            .all(|c| c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-')
47}
48
49/// #827: env-variable reference patterns that should not be redacted.
50/// Matches: `os.environ/NAME`, `os.getenv("NAME")`, `process.env.NAME`,
51/// `inputEnv.NAME`, `${NAME}`, `$NAME`, `%NAME%` (Windows).
52fn is_env_reference(v: &str) -> bool {
53    if v.starts_with("os.environ/")
54        || v.starts_with("os.getenv(")
55        || v.starts_with("process.env.")
56        || v.starts_with("System.getenv(")
57        || v.starts_with("ENV[")
58        || v.starts_with("env(")
59    {
60        return true;
61    }
62    // Variable interpolation: ${VAR}, $VAR, %VAR%
63    if (v.starts_with("${") && v.ends_with('}'))
64        || (v.starts_with('$')
65            && v[1..]
66                .chars()
67                .all(|c| c.is_ascii_alphanumeric() || c == '_'))
68        || (v.starts_with('%') && v.ends_with('%') && v.len() > 2)
69    {
70        return true;
71    }
72    // Dotted identifier chains with an env-like prefix
73    if let Some(prefix) = v.split('.').next() {
74        let pl = prefix.to_ascii_lowercase();
75        if matches!(
76            pl.as_str(),
77            "env" | "inputenv" | "serverenv" | "secrets" | "vars" | "environ"
78        ) && v.contains('.')
79        {
80            return true;
81        }
82    }
83    false
84}
85
86fn is_identifier_reference(value: &str) -> bool {
87    let v = value.trim();
88    if v.is_empty() || v.starts_with('"') || v.starts_with('\'') || v.starts_with('`') {
89        return false;
90    }
91    // #827: env-variable reference patterns are not secrets.
92    // `os.environ/MY_SERVICE_API_KEY`, `process.env.NAME`, `${VAR}`, `$VAR`.
93    if is_env_reference(v) {
94        return true;
95    }
96    if v.contains(|c: char| c.is_ascii_digit()) {
97        return false;
98    }
99    v.split('.').all(|segment| {
100        let mut chars = segment.chars();
101        matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$')
102            && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
103    })
104}
105
106/// #718: obvious placeholder/example values (`ghp_change_me`, `your_key_here`,
107/// `<insert-token>`) are documentation, not secrets — `.env.example` files
108/// must survive ctx_read verbatim.
109fn is_placeholder_value(value: &str) -> bool {
110    let v = value
111        .trim()
112        .trim_matches(|c| c == '"' || c == '\'' || c == '`')
113        .to_ascii_lowercase();
114    if v.starts_with('<') && v.ends_with('>') {
115        return true;
116    }
117    const MARKERS: &[&str] = &[
118        "change_me",
119        "change-me",
120        "changeme",
121        "example",
122        "placeholder",
123        "your_",
124        "your-",
125        "xxx",
126        "dummy",
127        "sample",
128        "todo",
129        "fixme",
130        "replace_me",
131        "replace-me",
132    ];
133    MARKERS.iter().any(|m| v.contains(m))
134}
135
136/// Right-hand sides that look like `key: value` but are obviously not secrets:
137/// TypeScript type annotations and language literals. Redacting these corrupts
138/// source files read through `ctx_read` (GH #430), so the key/value rules skip
139/// them. Compared case-insensitively after trimming surrounding quotes.
140fn is_non_secret_literal(value: &str) -> bool {
141    let v = value
142        .trim()
143        .trim_matches(|c| c == '"' || c == '\'' || c == '`');
144    // #827: pure numbers (integer, float, scientific notation) are never secrets.
145    // `input_cost_per_token: 1.4e-06` must not be redacted.
146    if looks_like_number(v) {
147        return true;
148    }
149    // Type expressions are never flat secret tokens: real keys/tokens are drawn
150    // from `[A-Za-z0-9+/=_-]`, whereas type annotations carry angle brackets,
151    // unions, arrays or call/object syntax. `password: Promise<string>` and
152    // `apiKey: Record<string, unknown>` must survive ctx_read verbatim (GH #430).
153    if v.contains(['<', '>', '|', '(', ')', '[', ']', '{', '}']) {
154        return true;
155    }
156    matches!(
157        v.to_ascii_lowercase().as_str(),
158        "" | "undefined"
159            | "null"
160            | "none"
161            | "nil"
162            | "true"
163            | "false"
164            | "string"
165            | "number"
166            | "boolean"
167            | "bigint"
168            | "symbol"
169            | "object"
170            | "any"
171            | "unknown"
172            | "never"
173            | "void"
174            | "nan"
175            | "date"
176    )
177}
178
179/// One redaction rule: a labelled regex plus how the match is rebuilt.
180struct Rule {
181    label: &'static str,
182    re: &'static regex::Regex,
183    /// When set, group 1 is a prefix to keep and group 2 is the secret value;
184    /// the match is left untouched if that value is a non-secret literal
185    /// (`password: undefined`), an identifier reference
186    /// (`serverEnv.getStripeSecretKey`) or a placeholder (`ghp_change_me`) —
187    /// see `is_benign_secret_value` (GH #430, #718).
188    guard_value: bool,
189}
190
191/// Combined benign-value check for key/value secret rules (#430 + #718):
192/// language literals and type annotations, unquoted identifier/property
193/// references, and documentation placeholders are never redacted. Quoted
194/// string values stay protected — they ARE literal values.
195pub(crate) fn is_benign_secret_value(value: &str) -> bool {
196    is_comparison_operator_residue(value)
197        || is_non_secret_literal(value)
198        || is_identifier_reference(value)
199        || is_placeholder_value(value)
200}
201
202/// #1095: when the regex `key=` consumes one `=` from `==`, the captured
203/// "value" is a bare `=` (or `==`, `!=`). A comparison operator is never
204/// a secret; redacting it corrupts source semantics.
205fn is_comparison_operator_residue(value: &str) -> bool {
206    let v = value.trim();
207    matches!(v, "=" | "==" | "!=" | "<=" | ">=" | "===" | "!==")
208}
209
210/// The single source of truth for secret patterns. `shell::redact` delegates
211/// here so the two layers can never drift apart again.
212///
213/// #718 word boundaries: the key/value alternations start with
214/// `(?:^|[^a-z0-9])` (consumed into the kept prefix — the regex crate has no
215/// lookbehind) so camelCase subwords (`superuserPassword`,
216/// `getStripeSecretKey`) never trigger a rule, while SNAKE_CASE env names
217/// (`GITHUB_FEEDBACK_TOKEN`) still do: `_` remains a permitted predecessor.
218fn redaction_rules() -> Vec<Rule> {
219    vec![
220        Rule {
221            label: "Bearer token",
222            re: static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
223            guard_value: false,
224        },
225        Rule {
226            label: "Authorization header",
227            re: static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
228            guard_value: false,
229        },
230        // Key/value secrets: group 1 = predecessor + `name=`/`name: ` prefix
231        // (kept), group 2 = the value (redacted unless benign — GH #430/#718).
232        Rule {
233            label: "API key param",
234            re: static_regex!(
235                r#"(?im)((?:^|[^a-z0-9])(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)([^\s\r\n,;&"']+)"#
236            ),
237            guard_value: true,
238        },
239        // Whole token is the secret — no prefix group, so the entire match is
240        // replaced. (Previously group 1 captured the key itself and leaked it.)
241        Rule {
242            label: "AWS key",
243            re: static_regex!(r"AKIA[0-9A-Z]{12,}"),
244            guard_value: false,
245        },
246        Rule {
247            label: "Private key block",
248            re: static_regex!(
249                r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----"
250            ),
251            guard_value: false,
252        },
253        Rule {
254            label: "GitHub token",
255            re: static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
256            guard_value: false,
257        },
258        // Group 1 = prefix (kept), group 2 = the 32+ char value. Guarded since
259        // #718: 32-char identifiers like `confirmRequiredEndpointKeySchema`
260        // are references, not secrets.
261        Rule {
262            label: "Generic long secret",
263            re: static_regex!(
264                r#"(?im)((?:^|[^a-z0-9])(?:key|token|secret|password|credential|auth)\s*[=:]\s*)(['"]?[a-zA-Z0-9+/=\-_]{32,}['"]?)"#
265            ),
266            guard_value: true,
267        },
268    ]
269}
270
271pub fn redact_text(input: &str) -> String {
272    redact_text_with_excludes(input, &[])
273}
274
275/// #718: `redact_text` with subtractive user patterns from
276/// `[secret_detection].exclude_patterns` — a match covered by any exclude
277/// regex is kept verbatim, so known-safe naming conventions can be carved out
278/// without disabling secret detection wholesale.
279pub fn redact_text_with_excludes(input: &str, excludes: &[regex::Regex]) -> String {
280    let mut out = input.to_string();
281    for rule in redaction_rules() {
282        out = rule
283            .re
284            .replace_all(&out, |caps: &regex::Captures| {
285                let whole = caps.get(0).map_or("", |m| m.as_str());
286                if excludes.iter().any(|ex| ex.is_match(whole)) {
287                    return whole.to_string();
288                }
289                if rule.guard_value
290                    && let Some(value) = caps.get(2)
291                    && is_benign_secret_value(value.as_str())
292                {
293                    // Not a secret (identifier reference, literal, placeholder)
294                    // — keep verbatim (#430, #718).
295                    return whole.to_string();
296                }
297                match caps.get(1) {
298                    Some(prefix) => format!("{}[REDACTED:{}]", prefix.as_str(), rule.label),
299                    None => format!("[REDACTED:{}]", rule.label),
300                }
301            })
302            .to_string();
303    }
304    out
305}
306
307/// Compile the configured `exclude_patterns` (#718). Invalid regexes are
308/// skipped — a broken exclude must never disable redaction.
309///
310/// #952: regex compilation — the expensive part of this call — used to run
311/// on every redaction call (`ctx_read`, `ctx_shell`, `ctx_execute` all funnel
312/// through `redact_text_if_enabled`) regardless of whether the config had
313/// changed. Cached here, keyed on `Arc::ptr_eq` against the config `Arc`:
314/// `Config::load_arc` returns the *same* `Arc` when the underlying config
315/// content hash is unchanged, so a real config edit is exactly what
316/// invalidates this cache too — no separate change-detection needed.
317pub fn config_exclude_patterns() -> std::sync::Arc<Vec<regex::Regex>> {
318    type Cache = Option<(
319        std::sync::Arc<crate::core::config::Config>,
320        std::sync::Arc<Vec<regex::Regex>>,
321    )>;
322    static CACHE: std::sync::Mutex<Cache> = std::sync::Mutex::new(None);
323
324    let cfg = crate::core::config::Config::load_arc();
325
326    if let Ok(guard) = CACHE.lock()
327        && let Some((cached_cfg, patterns)) = &*guard
328        && std::sync::Arc::ptr_eq(cached_cfg, &cfg)
329    {
330        return std::sync::Arc::clone(patterns);
331    }
332
333    let compiled = std::sync::Arc::new(
334        cfg.secret_detection
335            .exclude_patterns
336            .iter()
337            .filter_map(|p| regex::Regex::new(p).ok())
338            .collect::<Vec<_>>(),
339    );
340
341    if let Ok(mut guard) = CACHE.lock() {
342        *guard = Some((cfg, std::sync::Arc::clone(&compiled)));
343    }
344
345    compiled
346}
347
348/// Apply caller-supplied policy redaction patterns on top of the built-in
349/// secret rules: each regex match becomes `[REDACTED:<label>]`. Returns the
350/// transformed text and the number of redactions applied (for audit counts).
351///
352/// Used by context policy packs (GL #673) so a pack's `[redaction]` block
353/// actually removes matching content from what the model sees. The patterns are
354/// the pack's `[redaction]` entries, precompiled by
355/// [`crate::core::policy::runtime`].
356#[must_use]
357pub fn redact_with_patterns(input: &str, patterns: &[(String, regex::Regex)]) -> (String, usize) {
358    let mut out = input.to_string();
359    let mut hits = 0usize;
360    for (label, re) in patterns {
361        let mut local = 0usize;
362        out = re
363            .replace_all(&out, |_caps: &regex::Captures| {
364                local += 1;
365                format!("[REDACTED:{label}]")
366            })
367            .to_string();
368        hits += local;
369    }
370    (out, hits)
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    // --- #952: exclude_patterns compilation cache ---
378
379    #[test]
380    fn config_exclude_patterns_reuses_compiled_regexes_when_config_unchanged() {
381        let first = config_exclude_patterns();
382        let second = config_exclude_patterns();
383        assert!(
384            std::sync::Arc::ptr_eq(&first, &second),
385            "unchanged config must reuse the cached compiled patterns, not recompile"
386        );
387    }
388
389    #[test]
390    fn redacts_bearer_token() {
391        let s = "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345";
392        let out = redact_text(s);
393        assert!(out.contains("[REDACTED"));
394        assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
395    }
396
397    #[test]
398    fn redacts_private_key_block() {
399        let s = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
400        let out = redact_text(s);
401        assert!(out.contains("[REDACTED"));
402        assert!(!out.contains("\nabc\n"));
403    }
404
405    #[test]
406    fn redacts_api_key_param_value() {
407        let out = redact_text("password=hunter2-super-secret-value");
408        assert!(
409            out.contains("password=[REDACTED:API key param]"),
410            "got: {out}"
411        );
412        assert!(!out.contains("hunter2"));
413    }
414
415    /// GH #430: TypeScript type annotations and language literals must NOT be
416    /// redacted — over-eager masking corrupted source files read via ctx_read.
417    #[test]
418    fn keeps_non_secret_literals() {
419        for s in [
420            "password: undefined",
421            "secret: string",
422            "token: null",
423            "apiKey: boolean",
424            "password = false",
425            "secret: any",
426            "let pwd: number = 1",
427        ] {
428            assert_eq!(redact_text(s), s, "must not redact non-secret literal: {s}");
429        }
430    }
431
432    /// GH #430: TS type annotations (generics, unions, arrays, function/object
433    /// types) carry angle brackets / brackets that real secret tokens never do,
434    /// so they must survive verbatim even when the key looks sensitive.
435    #[test]
436    fn keeps_type_annotations() {
437        for s in [
438            "password: Promise<string>",
439            "apiKey: Record<string, unknown>",
440            "token: string[]",
441            "secret: () => void",
442            "password: string | undefined",
443            "credential: { value: string }",
444        ] {
445            assert_eq!(redact_text(s), s, "must not redact type annotation: {s}");
446        }
447    }
448
449    /// Whole-token secrets must be removed, not annotated in place — previously
450    /// the closure kept group 1 (the key itself) and only appended `[REDACTED]`.
451    #[test]
452    fn fully_redacts_aws_key() {
453        let out = redact_text("AKIAIOSFODNN7EXAMPLE");
454        assert!(
455            !out.contains("AKIAIOSFODNN7EXAMPLE"),
456            "AWS key leaked: {out}"
457        );
458        assert!(out.contains("[REDACTED:AWS key]"));
459    }
460
461    #[test]
462    fn fully_redacts_generic_long_secret() {
463        // `credential=` is not covered by the API-key-param rule, so this
464        // exercises the generic fallback (the previously leaky path).
465        let secret = "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6"; // 32 chars
466        let out = redact_text(&format!("credential={secret}"));
467        assert!(!out.contains(secret), "long secret leaked: {out}");
468        assert!(
469            out.contains("credential=[REDACTED:Generic long secret]"),
470            "got: {out}"
471        );
472    }
473
474    #[test]
475    fn redacts_github_token_keeping_prefix() {
476        let out = redact_text("ghp_abcdefghijklmnopqrstuvwxyz0123");
477        assert!(out.starts_with("ghp_[REDACTED:GitHub token]"), "got: {out}");
478        assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
479    }
480
481    // ── #718: benign identifier references, prose and placeholders ──
482
483    /// Repro 1: prose that mentions a keyword must not have the following
484    /// word redacted — "token: SvelteKit's…" is documentation, not a secret.
485    #[test]
486    fn keeps_prose_identifier_after_keyword() {
487        let s = "the CSRF token: SvelteKit's native origin-check on form actions";
488        assert_eq!(redact_text(s), s, "prose must survive verbatim");
489    }
490
491    /// Repro 2: camelCase subwords must not trigger the keyword alternation,
492    /// and identifier/property-access RHS values are references, not secrets.
493    #[test]
494    fn keeps_identifier_and_property_references() {
495        for s in [
496            "superuserPassword: inputEnv.POCKETBASE_SUPERUSER_PASSWORD",
497            "export const getStripeSecretKey = serverEnv.getStripeSecretKey;",
498            "const apiKey = config.stripeApiKey",
499        ] {
500            assert_eq!(redact_text(s), s, "identifier reference redacted: {s}");
501        }
502    }
503
504    /// Repro 3: a 32+ char identifier (Zod schema name) is a reference —
505    /// "Generic long secret" needs the same value guard as the API-key rule.
506    #[test]
507    fn keeps_long_schema_identifier() {
508        let s = "endpoint_key: confirmRequiredEndpointKeySchema,";
509        assert_eq!(redact_text(s), s, "schema identifier must not be redacted");
510    }
511
512    /// Repro 4: obvious placeholder values (.env.example) are documentation.
513    #[test]
514    fn keeps_placeholder_values() {
515        for s in [
516            "GITHUB_FEEDBACK_TOKEN=ghp_change_me",
517            "API_KEY=your_key_here",
518            "password=<insert-password>",
519            "SECRET_KEY=xxxxxxxx",
520        ] {
521            assert_eq!(redact_text(s), s, "placeholder redacted: {s}");
522        }
523    }
524
525    /// The flip side: real secret-shaped values must STILL be redacted after
526    /// the #718 guards.
527    #[test]
528    fn still_redacts_real_secret_values() {
529        // Digit-bearing value after a snake_case env name.
530        let out = redact_text("GITHUB_TOKEN=ghpA1b2c3d4e5f6g7h8");
531        assert!(!out.contains("ghpA1b2c3d4e5f6g7h8"), "leaked: {out}");
532        // SNAKE_CASE env assignment with digits (the _ predecessor stays a
533        // word boundary that MATCHES).
534        let out = redact_text("MY_SECRET=abc123def456ghi789");
535        assert!(!out.contains("abc123def456ghi789"), "leaked: {out}");
536        // Quoted 32+ char literal: a quoted value is never an identifier
537        // reference, so the Generic-long-secret guard keeps redacting it.
538        let quoted = "key: 'abcdefghijklmnopqrstuvwxyzabcdef'";
539        let out = redact_text(quoted);
540        assert!(
541            !out.contains("abcdefghijklmnopqrstuvwxyzabcdef"),
542            "leaked: {out}"
543        );
544    }
545
546    /// #718: exclude_patterns carve matches out subtractively.
547    #[test]
548    fn exclude_patterns_skip_matching_redactions() {
549        let excludes = vec![regex::Regex::new(r"LCTX_TEST_\w+").unwrap()];
550        let input = "token=LCTX_TEST_a1b2c3d4e5";
551        assert_eq!(
552            redact_text_with_excludes(input, &excludes),
553            input,
554            "excluded match must stay verbatim"
555        );
556        // Without the exclude the same value IS redacted (digits → secret).
557        assert!(redact_text(input).contains("[REDACTED"));
558    }
559
560    #[test]
561    fn identifier_and_placeholder_heuristics() {
562        assert!(is_identifier_reference("serverEnv.getStripeSecretKey"));
563        assert!(is_identifier_reference("confirmRequiredEndpointKeySchema"));
564        assert!(is_identifier_reference("$scope._private"));
565        assert!(!is_identifier_reference("abc123"), "digits → secret-shaped");
566        assert!(!is_identifier_reference("\"quoted\""), "literal value");
567        assert!(!is_identifier_reference("a-b"), "dash is not identifier");
568        assert!(is_placeholder_value("ghp_change_me"));
569        assert!(is_placeholder_value("<token>"));
570        assert!(is_placeholder_value("your_api_key_123"));
571        assert!(!is_placeholder_value("A1b2C3d4E5f6G7h8"));
572    }
573
574    #[test]
575    fn policy_patterns_redact_with_label_and_count() {
576        let patterns = vec![(
577            "employee_id".to_string(),
578            regex::Regex::new(r"EMP-\d{4}").unwrap(),
579        )];
580        let (out, hits) = redact_with_patterns("user EMP-1234 and EMP-5678", &patterns);
581        assert_eq!(hits, 2);
582        assert!(!out.contains("EMP-1234"));
583        assert!(out.contains("[REDACTED:employee_id]"));
584    }
585
586    #[test]
587    fn policy_patterns_noop_when_no_match() {
588        let patterns = vec![("iban".to_string(), regex::Regex::new(r"CH\d{2}").unwrap())];
589        let (out, hits) = redact_with_patterns("nothing sensitive here", &patterns);
590        assert_eq!(hits, 0);
591        assert_eq!(out, "nothing sensitive here");
592    }
593
594    /// GH #827: scientific notation and plain numbers after keys containing
595    /// `token` must not be redacted — these are pricing/cost fields.
596    #[test]
597    fn keeps_numeric_values_827() {
598        for (input, desc) in [
599            ("input_cost_per_token: 1.4e-06", "scientific notation"),
600            ("output_cost_per_token: 4.4e-06", "scientific notation"),
601            (
602                "cache_read_input_token_cost: 1.9e-07",
603                "scientific notation",
604            ),
605            ("token: 600", "plain integer"),
606            ("secret: 0.5", "decimal float"),
607            ("api_key: 42", "small integer"),
608            ("password: 3.14", "pi float"),
609        ] {
610            assert_eq!(
611                redact_text(input),
612                input,
613                "must not redact numeric value ({desc}): {input}"
614            );
615        }
616    }
617
618    /// GH #827: env-variable references must not be redacted — they are
619    /// pointers to secrets, not the secrets themselves.
620    #[test]
621    fn keeps_env_references_827() {
622        for (input, desc) in [
623            (
624                "api_key: os.environ/MY_SERVICE_API_KEY",
625                "Python os.environ/",
626            ),
627            ("secret: os.getenv(MY_KEY)", "Python os.getenv()"),
628            ("token: process.env.API_TOKEN", "Node process.env"),
629            (
630                "password: inputEnv.POCKETBASE_SUPERUSER_PASSWORD",
631                "inputEnv dot ref",
632            ),
633            ("api_key: ${MY_API_KEY}", "shell interpolation ${}"),
634            ("secret: $MY_SECRET", "shell $VAR"),
635            ("token: %API_TOKEN%", "Windows %VAR%"),
636            ("api_key: ENV[API_KEY]", "Ruby ENV[]"),
637            ("secret: env(SECRET_KEY)", "Laravel env()"),
638            ("password: System.getenv(DB_PASS)", "Java System.getenv"),
639        ] {
640            assert_eq!(
641                redact_text(input),
642                input,
643                "must not redact env ref ({desc}): {input}"
644            );
645        }
646    }
647
648    /// GH #827: real secrets must STILL be redacted (regression guard).
649    #[test]
650    fn still_redacts_real_secrets_827() {
651        for input in [
652            "api_key: sk-1234567890abcdef1234567890abcdef",
653            "password: hunter2-super-secret-value",
654            "token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
655        ] {
656            let out = redact_text(input);
657            assert!(
658                out.contains("[REDACTED"),
659                "must redact real secret: {input} -> {out}"
660            );
661        }
662    }
663
664    /// GH #1095: `token == ""` in Go source must not be redacted — the regex
665    /// consumes the first `=` leaving group 2 = `=` (comparison residue).
666    #[test]
667    fn keeps_comparison_operators_in_source() {
668        for s in [
669            r#"if token == "" {"#,
670            r#"if token, err = checkPulsares(); token == "" || err != nil {"#,
671            r#"if password != "" {"#,
672            r"assert secret == expected_value",
673        ] {
674            assert_eq!(redact_text(s), s, "comparison operator corrupted: {s}");
675        }
676    }
677}