Skip to main content

wm_memory/
credentials.rs

1//! Credential-shape detection for memory content.
2//!
3//! Phase 3 secrets hygiene: `wm ingest` refuses credential-shaped
4//! *filenames* (`.env`, keys, certs); this module extends the same
5//! discipline to *content*. A store that silently swallows an API key
6//! becomes a liability the moment it is backed up, mesh-synced, or fed
7//! into a model context — so writes that look credential-bearing are
8//! flagged at the tool layer (warn + advise a keyring, not refuse:
9//! false-positive-proof refusal would train agents to hide secrets
10//! worse).
11//!
12//! High-precision heuristics only — the goal is to warn on real
13//! credentials without crying wolf on ordinary prose.
14
15#![forbid(unsafe_code)]
16
17/// Kinds of credential shapes the detector recognizes.
18pub const ADVICE: &str = "keep the secret in a keyring (OS keychain, pass, systemd-credentials) and store a reference in memory instead; memory privacy flags are not encryption";
19
20/// Detect credential-shaped content. Returns the matched kinds
21/// (e.g. `["private_key_pem", "github_token"]`); empty means clean.
22#[must_use]
23pub fn credential_shaped_content(content: &str) -> Vec<&'static str> {
24    let mut kinds: Vec<&'static str> = Vec::new();
25    let push = |k: &'static str, kinds: &mut Vec<&'static str>| {
26        if !kinds.contains(&k) {
27            kinds.push(k);
28        }
29    };
30
31    // 1. PEM private keys (RSA/OpenSSH/EC/PKCS8/PGP/encrypted).
32    if content.contains("-----BEGIN") && content.contains("PRIVATE KEY") {
33        push("private_key_pem", &mut kinds);
34    }
35
36    // 2. AWS access key ids: AKIA + 16 uppercase/digits.
37    if token_after(content, "AKIA", 16, |c| {
38        c.is_ascii_uppercase() || c.is_ascii_digit()
39    }) {
40        push("aws_access_key_id", &mut kinds);
41    }
42
43    // 3. GitHub tokens.
44    let alnum = |c: char| c.is_ascii_alphanumeric() || c == '_';
45    if token_after(content, "ghp_", 30, alnum)
46        || token_after(content, "gho_", 30, alnum)
47        || token_after(content, "github_pat_", 20, alnum)
48    {
49        push("github_token", &mut kinds);
50    }
51
52    // 4. OpenAI-style keys: sk- + 20 token chars.
53    if token_after(content, "sk-", 20, |c| {
54        c.is_ascii_alphanumeric() || c == '_' || c == '-'
55    }) {
56        push("openai_style_key", &mut kinds);
57    }
58
59    // 5. Slack tokens: xox{b,p,a,r,s}-.
60    if ["xoxb-", "xoxp-", "xoxa-", "xoxr-", "xoxs-"]
61        .iter()
62        .any(|p| token_after(content, p, 10, |c| c.is_ascii_alphanumeric() || c == '-'))
63    {
64        push("slack_token", &mut kinds);
65    }
66
67    // 6. JWTs: two base64url segments separated by dots.
68    if content.match_indices("eyJ").count() >= 2 {
69        push("jwt", &mut kinds);
70    }
71
72    // 7. Assignment shapes: password/secret/api_key/token followed by a
73    //    delimiter and a 16+ char value.
74    if assignment_shaped(content) {
75        push("credential_assignment", &mut kinds);
76    }
77
78    // 8. Credential-bearing URI userinfo (`scheme://user:pass@host`),
79    //    independent of any surrounding variable name — a bare URL in prose
80    //    and `DATABASE_URL=...` are the same shape (2026-09-21 review:
81    //    connection strings survived `--redact` because only keyed
82    //    assignments were scanned).
83    if uri_userinfo_span(content).is_some() {
84        push("credential_uri", &mut kinds);
85    }
86
87    kinds
88}
89
90/// Scan for `prefix` followed by at least `min_len` charset characters.
91fn token_after(
92    haystack: &str,
93    prefix: &str,
94    min_len: usize,
95    charset: impl Fn(char) -> bool,
96) -> bool {
97    let mut from = 0usize;
98    while let Some(pos) = haystack[from..].find(prefix) {
99        let abs = from + pos + prefix.len();
100        let run = haystack[abs..].chars().take_while(|c| charset(*c)).count();
101        if run >= min_len {
102            return true;
103        }
104        from = abs;
105    }
106    false
107}
108
109/// Assignment-key names (case-insensitive) whose `=`/`:` value is treated as
110/// a secret. Compound keys are listed explicitly because the delimiter must
111/// immediately follow the key name: `secret` alone never matches
112/// `AWS_SECRET_ACCESS_KEY=...` (the `_` blocks the delimiter check).
113///
114/// 2026-09-21 review: `token` was missing although the detection comment
115/// claimed it, so `TOKEN=...` and every `*_token=...` compound (the
116/// delimiter follows the `token` substring) survived `--redact`. Compounds
117/// ending in a listed key are covered by that key; only compounds where the
118/// suffix blocks the delimiter (`secret_access_key`) need their own entry.
119const ASSIGNMENT_KEYS: &[&str] = &[
120    "password",
121    "passwd",
122    "passphrase",
123    "api_key",
124    "api-key",
125    "apikey",
126    "secret",
127    "token",
128    "access_token",
129    "secret_access_key",
130    "aws_secret_access_key",
131    "secret_key",
132    "client_secret",
133    "private_key",
134    "auth_token",
135    "refresh_token",
136];
137
138/// Case-insensitive `password = "..."` / `api_key: ...` detection with a
139/// 16+ character non-space value.
140fn assignment_shaped(content: &str) -> bool {
141    let lower = content.to_lowercase();
142    for key in ASSIGNMENT_KEYS {
143        let mut from = 0usize;
144        while let Some(pos) = lower[from..].find(key) {
145            let abs = from + pos + key.len();
146            let rest = lower[abs..].trim_start();
147            // JSON-style keys close the quote first: `"api_key": "..."`.
148            let rest = rest.strip_prefix('"').unwrap_or(rest).trim_start();
149            let Some(delim) = rest.chars().next() else {
150                break;
151            };
152            if delim == ':' || delim == '=' {
153                let value = rest[1..].trim_start();
154                let value = value.strip_prefix(['"', '\'']).unwrap_or(value);
155                // Redaction markers must never re-trigger detection, or the
156                // scrubber loops on its own output. `value` comes from the
157                // lowercased text, so the marker check is case-insensitive.
158                let is_marker = value
159                    .get(..10)
160                    .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
161                if !is_marker {
162                    let run: usize = value
163                        .chars()
164                        .take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'')
165                        .map(char::len_utf8)
166                        .sum();
167                    if run >= 16 {
168                        return true;
169                    }
170                }
171            }
172            from = abs;
173        }
174    }
175    false
176}
177
178/// Redact credential-shaped spans, replacing them with `[REDACTED:<kind>]`.
179///
180/// Detection is [`credential_shaped_content`]; when nothing fires the text is
181/// returned unchanged. Redaction is span-oriented (PEM blocks, prefixed
182/// tokens, assignment values) and deliberately over-redacts rather than
183/// under-redacts. Returns the redacted text and the kinds that fired, using
184/// the same labels as detection.
185#[must_use]
186pub fn redact_credential_content(content: &str) -> (String, Vec<&'static str>) {
187    let kinds = credential_shaped_content(content);
188    if kinds.is_empty() {
189        return (content.to_string(), kinds);
190    }
191
192    let mut out = content.to_string();
193
194    if kinds.contains(&"private_key_pem") {
195        while let Some((start, end)) = pem_block_span(&out) {
196            out.replace_range(start..end, "[REDACTED:private_key_pem]");
197        }
198        // Detection fires on any content holding both "-----BEGIN" and
199        // "PRIVATE KEY" — including truncated/example fragments with no
200        // complete END block, which the span loop above cannot match.
201        // Neutralize the marker strings so the pass is idempotent.
202        out = out.replace("PRIVATE KEY-----", "[REDACTED:pem-key]");
203        out = out.replace("-----BEGIN", "[REDACTED:pem-begin]");
204        out = out.replace("-----END", "[REDACTED:pem-end]");
205    }
206
207    if kinds.contains(&"credential_assignment") {
208        while let Some((start, end)) = assignment_value_span(&out) {
209            out.replace_range(start..end, "[REDACTED:credential_assignment]");
210        }
211    }
212
213    if kinds.contains(&"credential_uri") {
214        while let Some((start, end)) = uri_userinfo_span(&out) {
215            out.replace_range(start..end, "[REDACTED:credential_uri]");
216        }
217    }
218
219    // JWT detection fires on any two `eyJ` occurrences (fragments included),
220    // so redaction must remove every occurrence — a min-run scan left short
221    // fragments detectable and the apply pass non-idempotent.
222    if kinds.contains(&"jwt") {
223        while let Some(pos) = out.find("eyJ") {
224            out.replace_range(pos..pos + "eyJ".len(), "[REDACTED:jwt]");
225        }
226    }
227
228    type TokenSpec = (&'static str, &'static str, usize, fn(char) -> bool);
229    let token_specs: &[TokenSpec] = &[
230        ("aws_access_key_id", "AKIA", 16, |c: char| {
231            c.is_ascii_uppercase() || c.is_ascii_digit()
232        }),
233        ("github_token", "ghp_", 30, |c: char| {
234            c.is_ascii_alphanumeric() || c == '_'
235        }),
236        ("github_token", "gho_", 30, |c: char| {
237            c.is_ascii_alphanumeric() || c == '_'
238        }),
239        ("github_token", "github_pat_", 20, |c: char| {
240            c.is_ascii_alphanumeric() || c == '_'
241        }),
242        ("openai_style_key", "sk-", 20, |c: char| {
243            c.is_ascii_alphanumeric() || c == '_' || c == '-'
244        }),
245        ("slack_token", "xoxb-", 10, |c: char| {
246            c.is_ascii_alphanumeric() || c == '-'
247        }),
248        ("slack_token", "xoxp-", 10, |c: char| {
249            c.is_ascii_alphanumeric() || c == '-'
250        }),
251        ("slack_token", "xoxa-", 10, |c: char| {
252            c.is_ascii_alphanumeric() || c == '-'
253        }),
254        ("slack_token", "xoxr-", 10, |c: char| {
255            c.is_ascii_alphanumeric() || c == '-'
256        }),
257        ("slack_token", "xoxs-", 10, |c: char| {
258            c.is_ascii_alphanumeric() || c == '-'
259        }),
260    ];
261    for (kind, prefix, min_len, charset) in token_specs {
262        while let Some((start, end)) = prefixed_token_span(&out, prefix, *min_len, *charset) {
263            out.replace_range(start..end, &format!("[REDACTED:{kind}]"));
264        }
265    }
266
267    (out, kinds)
268}
269
270/// Span of the first PEM private-key block (including its BEGIN/END markers).
271fn pem_block_span(text: &str) -> Option<(usize, usize)> {
272    let begin = text.find("-----BEGIN")?;
273    let key_at = text[begin..].find("PRIVATE KEY-----")? + begin;
274    let end_at = text[key_at..].find("-----END")? + key_at;
275    let marker_at = text[end_at..].find("PRIVATE KEY-----")? + end_at;
276    Some((begin, marker_at + "PRIVATE KEY-----".len()))
277}
278
279/// Span of the first assignment *value* (the 16+ char secret, not the key).
280fn assignment_value_span(text: &str) -> Option<(usize, usize)> {
281    for key in ASSIGNMENT_KEYS {
282        let mut from = 0usize;
283        while let Some(pos) = find_ascii_case_insensitive(text, key, from) {
284            let after = pos + key.len();
285            let rest_raw = &text[after..];
286            // JSON-style keys close their quote first: `"api_key": "..."`.
287            let quoted = rest_raw.strip_prefix('"').is_some();
288            let rest = rest_raw.strip_prefix('"').unwrap_or(rest_raw);
289            let ws = rest.len() - rest.trim_start().len();
290            let delim_pos = after + usize::from(quoted) + ws;
291            let delim = text[delim_pos..].chars().next();
292            if matches!(delim, Some(':' | '=')) {
293                let tail = &text[delim_pos + 1..];
294                let vws = tail.len() - tail.trim_start().len();
295                let mut vstart = delim_pos + 1 + vws;
296                if let Some(quote) = text[vstart..].chars().next() {
297                    if quote == '"' || quote == '\'' {
298                        vstart += quote.len_utf8();
299                    }
300                }
301                let mut bytes = 0usize;
302                for c in text[vstart..].chars() {
303                    if c.is_whitespace() || c == '"' || c == '\'' {
304                        break;
305                    }
306                    bytes += c.len_utf8();
307                }
308                let is_marker = text[vstart..]
309                    .get(..10)
310                    .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
311                if bytes >= 16 && !is_marker {
312                    return Some((vstart, vstart + bytes));
313                }
314            }
315            from = after;
316        }
317    }
318    None
319}
320
321/// Span of the first credential-bearing URI userinfo
322/// (`scheme://user:pass@host`).
323///
324/// Structural, not key-based: the authority (between `://` and the first
325/// `/`, `?`, `#`, or whitespace) must contain `@`, and the userinfo before
326/// the last `@` must contain a colon with a non-empty password. URLs without
327/// userinfo (`https://example.com/x`), bare usernames
328/// (`ssh://git@github.com:22/repo`), and `host:port` pairs stay clean.
329/// Redaction replaces the whole userinfo — user and password — deliberately
330/// over- rather than under-redacting.
331fn uri_userinfo_span(text: &str) -> Option<(usize, usize)> {
332    let mut from = 0usize;
333    while let Some(rel) = text[from..].find("://") {
334        let sep = from + rel;
335        let authority_start = sep + 3;
336        let scheme_start = text[..sep]
337            .rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-'))
338            .map_or(0, |i| {
339                // `rfind` returns the byte index of the char start; advance by
340                // its UTF-8 width so a multibyte neighbor (e.g. '†' before
341                // "://") cannot land the slice mid-character (2026-09-23
342                // ingest panic: "start byte index ... is not a char boundary").
343                i + text[i..].chars().next().map_or(1, char::len_utf8)
344            });
345        let scheme = &text[scheme_start..sep];
346        let scheme_ok = scheme.starts_with(|c: char| c.is_ascii_alphabetic());
347        if scheme_ok {
348            let authority_end = text[authority_start..]
349                .find(|c: char| c == '/' || c == '?' || c == '#' || c.is_whitespace())
350                .map_or(text.len(), |i| authority_start + i);
351            if let Some(at_rel) = text[authority_start..authority_end].rfind('@') {
352                let at = authority_start + at_rel;
353                let userinfo = &text[authority_start..at];
354                if let Some(colon_rel) = userinfo.rfind(':') {
355                    let password = &userinfo[colon_rel + 1..];
356                    // The redaction marker must never re-trigger detection,
357                    // or the apply pass is not idempotent.
358                    let is_marker = userinfo
359                        .get(..10)
360                        .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
361                    if !password.is_empty() && !is_marker {
362                        return Some((authority_start, at));
363                    }
364                }
365            }
366        }
367        from = authority_start;
368    }
369    None
370}
371
372/// Span of the first `prefix` + charset run of at least `min_len` characters.
373fn prefixed_token_span(
374    text: &str,
375    prefix: &str,
376    min_len: usize,
377    charset: fn(char) -> bool,
378) -> Option<(usize, usize)> {
379    let mut from = 0usize;
380    while let Some(pos) = text[from..].find(prefix) {
381        let start = from + pos;
382        let value_start = start + prefix.len();
383        let mut bytes = 0usize;
384        let mut count = 0usize;
385        for c in text[value_start..].chars() {
386            if !charset(c) {
387                break;
388            }
389            bytes += c.len_utf8();
390            count += 1;
391        }
392        if count >= min_len {
393            return Some((start, value_start + bytes));
394        }
395        from = value_start;
396    }
397    None
398}
399
400/// ASCII-case-insensitive substring search starting at `from`.
401fn find_ascii_case_insensitive(haystack: &str, needle: &str, from: usize) -> Option<usize> {
402    let h = haystack.as_bytes();
403    let n = needle.as_bytes();
404    if n.is_empty() || from >= h.len() || n.len() > h.len() - from {
405        return None;
406    }
407    (from..=h.len() - n.len()).find(|&i| h[i..i + n.len()].eq_ignore_ascii_case(n))
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn detects_private_keys_aws_and_github() {
416        let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----";
417        assert_eq!(credential_shaped_content(pem), vec!["private_key_pem"]);
418
419        let aws = "access id AKIAIOSFODNN7EXAMPLE found in logs";
420        assert_eq!(credential_shaped_content(aws), vec!["aws_access_key_id"]);
421
422        let gh = "token ghp_0123456789abcdefghijklmnopqrstuvwxyzABC pasted";
423        assert_eq!(credential_shaped_content(gh), vec!["github_token"]);
424    }
425
426    #[test]
427    fn detects_sk_slack_jwt_and_assignments() {
428        let sk = "key: sk-proj0123456789abcdefghijklmnopqrstuv";
429        assert_eq!(credential_shaped_content(sk), vec!["openai_style_key"]);
430
431        // Assembled at runtime: the raw Slack token shape must never appear
432        // in source (GitHub push protection blocks it), while the detector
433        // must still match the real shape at runtime.
434        let slack = format!(
435            "xoxb-{}-{}-{}",
436            "123456789012", "1234567890123", "abcdefghijklmnop"
437        );
438        assert_eq!(credential_shaped_content(&slack), vec!["slack_token"]);
439
440        let jwt = "header eyJhbGciOiJIUzI1NiJ9.payload eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig";
441        assert_eq!(credential_shaped_content(jwt), vec!["jwt"]);
442
443        let assign = "connect with DATABASE_PASSWORD=correct-horse-battery-staple-1 tomorrow";
444        assert_eq!(
445            credential_shaped_content(assign),
446            vec!["credential_assignment"]
447        );
448    }
449
450    #[test]
451    fn detects_aws_secret_and_compound_assignment_keys() {
452        // Regression (P0, 2026-09-14): in AWS_SECRET_ACCESS_KEY the `secret`
453        // key name is followed by `_`, so the delimiter check never fired and
454        // the secret survived `wm ingest --redact`. Compound keys now need
455        // no special-casing at the call sites — they are listed explicitly.
456        let aws = "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
457        assert_eq!(
458            credential_shaped_content(aws),
459            vec!["credential_assignment"]
460        );
461        let (redacted, kinds) = redact_credential_content(aws);
462        assert!(kinds.contains(&"credential_assignment"));
463        assert_eq!(
464            redacted,
465            "AWS_SECRET_ACCESS_KEY=[REDACTED:credential_assignment]"
466        );
467        assert!(
468            credential_shaped_content(&redacted).is_empty(),
469            "redacted AWS secret must read clean: {redacted}"
470        );
471        let (twice, _) = redact_credential_content(&redacted);
472        assert_eq!(redacted, twice);
473
474        // Compound keys with identifier suffixes need explicit listing.
475        for text in [
476            "secret_access_key=0123456789abcdef",
477            "secret_key: 0123456789abcdef",
478            "refresh_token=0123456789abcdef",
479            "auth_token=0123456789abcdef",
480        ] {
481            assert_eq!(
482                credential_shaped_content(text),
483                vec!["credential_assignment"],
484                "{text}"
485            );
486        }
487
488        // Prose naming the key without an assignment stays clean.
489        assert!(
490            credential_shaped_content("the aws secret access key rotation policy was updated")
491                .is_empty()
492        );
493    }
494
495    #[test]
496    fn ordinary_prose_stays_clean() {
497        assert!(
498            credential_shaped_content("remember that the password policy requires rotation")
499                .is_empty()
500        );
501        assert!(credential_shaped_content("api_key rotation happens quarterly").is_empty());
502        assert!(credential_shaped_content("short token: abc123").is_empty());
503        assert!(
504            credential_shaped_content("the sk- prefix marks OpenAI keys in general").is_empty()
505        );
506        assert!(credential_shaped_content("AKIA is the AWS key prefix").is_empty());
507        assert!(credential_shaped_content("we discussed jwt sessions at length").is_empty());
508    }
509
510    #[test]
511    fn dedupes_kinds() {
512        let both = "AKIAIOSFODNN7EXAMPLE and AKIAIOSFODNN7EXAMPLE again";
513        assert_eq!(credential_shaped_content(both), vec!["aws_access_key_id"]);
514    }
515
516    #[test]
517    fn redacts_private_key_blocks() {
518        let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowSECRET\n-----END RSA PRIVATE KEY-----\nafter";
519        let (redacted, kinds) = redact_credential_content(pem);
520        assert!(kinds.contains(&"private_key_pem"));
521        assert!(!redacted.contains("MIIEowSECRET"), "key body must be gone");
522        assert!(!redacted.contains("BEGIN RSA PRIVATE KEY"));
523        assert_eq!(redacted, "before\n[REDACTED:private_key_pem]\nafter");
524    }
525
526    #[test]
527    fn redacts_assignment_values_and_tokens() {
528        let text = "db password=correct-horse-battery-staple and key sk-proj0123456789abcdefghijklmnopqrstuv";
529        let (redacted, _) = redact_credential_content(text);
530        assert!(redacted.contains("password=[REDACTED:credential_assignment]"));
531        assert!(!redacted.contains("correct-horse-battery-staple"));
532        assert!(!redacted.contains("sk-proj0123456789abcdefghijklmnopqrstuv"));
533        assert!(redacted.contains("[REDACTED:openai_style_key]"));
534
535        let aws = "id AKIAIOSFODNN7EXAMPLE here";
536        let (redacted, _) = redact_credential_content(aws);
537        assert_eq!(redacted, "id [REDACTED:aws_access_key_id] here");
538    }
539
540    /// 2026-09-15 review: JSON-style keys close their quote before the
541    /// delimiter (`"api_key": "..."`), so the assignment detector missed
542    /// them — exactly the shape a `.jsonl` credential file uses.
543    #[test]
544    fn redacts_json_style_assignment_values() {
545        let json = r#"{"api_key": "supersecretvalue12345", "note": "plain"}"#;
546        let (redacted, kinds) = redact_credential_content(json);
547        assert!(
548            kinds.contains(&"credential_assignment"),
549            "JSON assignment must be detected: {kinds:?}"
550        );
551        assert!(
552            !redacted.contains("supersecretvalue12345"),
553            "JSON assignment value must be scrubbed: {redacted}"
554        );
555        assert!(
556            redacted.contains("plain"),
557            "non-secret values stay: {redacted}"
558        );
559    }
560
561    #[test]
562    fn clean_content_passes_through_unchanged() {
563        let text = "remember that the password policy requires rotation";
564        let (redacted, kinds) = redact_credential_content(text);
565        assert!(kinds.is_empty());
566        assert_eq!(redacted, text);
567    }
568
569    #[test]
570    fn redaction_is_idempotent() {
571        let text = "key sk-proj0123456789abcdefghijklmnopqrstuv end";
572        let (once, _) = redact_credential_content(text);
573        let (twice, kinds) = redact_credential_content(&once);
574        assert_eq!(once, twice);
575        assert!(
576            kinds.is_empty(),
577            "redacted marker must read clean: {kinds:?}"
578        );
579    }
580
581    #[test]
582    fn assignment_marker_does_not_retrigger_detection() {
583        // Regression: detection lowercases before scanning, so the marker
584        // guard must compare case-insensitively or apply-pass runs are never
585        // idempotent (found by the wm redact-content store pass, 2026-09-11).
586        let text = "db password=correct-horse-battery-staple";
587        let (once, _) = redact_credential_content(text);
588        assert!(
589            credential_shaped_content(&once).is_empty(),
590            "redacted assignment must read clean: {once}"
591        );
592        let (twice, kinds) = redact_credential_content(&once);
593        assert_eq!(once, twice);
594        assert!(kinds.is_empty());
595    }
596
597    #[test]
598    fn short_jwt_fragments_are_redacted_too() {
599        // Regression: detection counts any two `eyJ` occurrences, but the
600        // redactor used to demand an 8-char run — short fragments stayed
601        // detectable and the store pass kept re-finding them (2026-09-11).
602        let text = "tokens eyJab and eyJcd appeared in logs";
603        let (once, kinds) = redact_credential_content(text);
604        assert!(kinds.contains(&"jwt"));
605        assert!(
606            credential_shaped_content(&once).is_empty(),
607            "short fragments must read clean after redaction: {once}"
608        );
609        let (twice, _) = redact_credential_content(&once);
610        assert_eq!(once, twice);
611    }
612
613    #[test]
614    fn pem_fragments_are_redacted_too() {
615        // Regression: a truncated/example PEM with no END block fires
616        // detection but has no complete span; the marker strings themselves
617        // must be neutralized so the store pass is idempotent (2026-09-11).
618        let text = "docs explain -----BEGIN PRIVATE KEY----- when truncated";
619        let (once, kinds) = redact_credential_content(text);
620        assert!(kinds.contains(&"private_key_pem"));
621        assert!(
622            credential_shaped_content(&once).is_empty(),
623            "PEM fragments must read clean after redaction: {once}"
624        );
625        let (twice, _) = redact_credential_content(&once);
626        assert_eq!(once, twice);
627    }
628
629    /// 2026-09-21 reviewer P0: `TOKEN=...` survived `--redact` because
630    /// `ASSIGNMENT_KEYS` omitted `token`, and connection strings survived
631    /// because only keyed assignments were scanned.
632    #[test]
633    fn reviewer_fixtures_are_detected_and_redacted() {
634        let token = "TOKEN=generic_token_value_0123456789abcdef";
635        assert!(
636            credential_shaped_content(token).contains(&"credential_assignment"),
637            "plain token assignments must fire"
638        );
639        let (red, kinds) = redact_credential_content(token);
640        assert!(kinds.contains(&"credential_assignment"));
641        assert!(!red.contains("generic_token_value_0123456789abcdef"));
642        assert!(
643            credential_shaped_content(&red).is_empty(),
644            "redacted token must read clean: {red}"
645        );
646
647        let uri = "DATABASE_URL=postgres://alice:fakepassword123456@db.example.com/prod";
648        assert!(
649            credential_shaped_content(uri).contains(&"credential_uri"),
650            "URI userinfo must be detected independently of the key name"
651        );
652        let (red, kinds) = redact_credential_content(uri);
653        assert!(kinds.contains(&"credential_uri"));
654        assert!(!red.contains("fakepassword123456"), "password must be gone");
655        assert!(
656            !red.contains("alice"),
657            "userinfo over-redaction is deliberate"
658        );
659        assert!(red.contains("db.example.com"), "host stays: {red}");
660        assert!(
661            credential_shaped_content(&red).is_empty(),
662            "redacted URI must read clean: {red}"
663        );
664        let (twice, _) = redact_credential_content(&red);
665        assert_eq!(red, twice, "URI redaction must be idempotent");
666
667        // Empty user, non-empty password (`redis://:pass@host`).
668        let redis = "REDIS_URL=redis://:hunter2hunter2@cache.internal:6379/0";
669        let (red, kinds) = redact_credential_content(redis);
670        assert!(kinds.contains(&"credential_uri"));
671        assert!(!red.contains("hunter2hunter2"));
672        assert!(credential_shaped_content(&red).is_empty());
673
674        // JSON form, mixed case, quotes, and surrounding whitespace.
675        let json = r#"{"Database_Url": "Postgres://Alice:Passw0rd123456@Db.Example.com/prod"}"#;
676        let (red, kinds) = redact_credential_content(json);
677        assert!(
678            kinds.contains(&"credential_uri"),
679            "JSON URI must fire: {kinds:?}"
680        );
681        assert!(!red.contains("Passw0rd123456"));
682        assert!(credential_shaped_content(&red).is_empty());
683
684        // Compound token names are covered by the `token` key: the delimiter
685        // immediately follows the substring.
686        for text in [
687            "BOT_TOKEN=0123456789abcdef",
688            "SESSION_TOKEN: 0123456789abcdef",
689            "bearer_token=0123456789abcdef",
690            "PASSPHRASE=correct-horse-battery-staple",
691        ] {
692            assert!(
693                credential_shaped_content(text).contains(&"credential_assignment"),
694                "{text}"
695            );
696        }
697    }
698
699    #[test]
700    fn uri_lookalikes_stay_clean() {
701        for text in [
702            "see https://example.com/path for details",
703            "ssh://git@github.com:22/repo",
704            "connect to http://127.0.0.1:8080/status",
705            "https://user@example.com/profile",
706            "the scheme: separator is not a URL",
707            "note:// just a label",
708        ] {
709            assert!(
710                credential_shaped_content(text).is_empty(),
711                "lookalike must stay clean: {text}"
712            );
713        }
714    }
715
716    /// Regression (2026-09-23 fleet report): a multibyte character directly
717    /// before the scheme separator made the backward scheme scan slice at
718    /// `char_start + 1`, panicking mid-character during `wm ingest` on Codex
719    /// session logs ("start byte index ... is not a char boundary").
720    #[test]
721    fn uri_scan_is_char_boundary_safe_next_to_multibyte_text() {
722        // '†' is 3 bytes; the old code produced a mid-char slice here.
723        let text = "†††https://alice:fakepassword123456@db.example.com/prod";
724        assert_eq!(
725            credential_shaped_content(text),
726            vec!["credential_uri".to_string()],
727            "multibyte neighbors must not break URI detection"
728        );
729        let (redacted, _) = redact_credential_content(text);
730        assert!(redacted.contains("[REDACTED:"), "{redacted}");
731        assert!(!redacted.contains("fakepassword123456"), "{redacted}");
732
733        // And a long multibyte run before a lookalike stays clean, no panic.
734        let lookalike = "†".repeat(64) + "https://example.com/path";
735        assert!(credential_shaped_content(&lookalike).is_empty());
736    }
737}