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    kinds
79}
80
81/// Scan for `prefix` followed by at least `min_len` charset characters.
82fn token_after(
83    haystack: &str,
84    prefix: &str,
85    min_len: usize,
86    charset: impl Fn(char) -> bool,
87) -> bool {
88    let mut from = 0usize;
89    while let Some(pos) = haystack[from..].find(prefix) {
90        let abs = from + pos + prefix.len();
91        let run = haystack[abs..].chars().take_while(|c| charset(*c)).count();
92        if run >= min_len {
93            return true;
94        }
95        from = abs;
96    }
97    false
98}
99
100/// Assignment-key names (case-insensitive) whose `=`/`:` value is treated as
101/// a secret. Compound keys are listed explicitly because the delimiter must
102/// immediately follow the key name: `secret` alone never matches
103/// `AWS_SECRET_ACCESS_KEY=...` (the `_` blocks the delimiter check).
104const ASSIGNMENT_KEYS: &[&str] = &[
105    "password",
106    "passwd",
107    "api_key",
108    "api-key",
109    "apikey",
110    "secret",
111    "access_token",
112    "secret_access_key",
113    "aws_secret_access_key",
114    "secret_key",
115    "client_secret",
116    "private_key",
117    "auth_token",
118    "refresh_token",
119];
120
121/// Case-insensitive `password = "..."` / `api_key: ...` detection with a
122/// 16+ character non-space value.
123fn assignment_shaped(content: &str) -> bool {
124    let lower = content.to_lowercase();
125    for key in ASSIGNMENT_KEYS {
126        let mut from = 0usize;
127        while let Some(pos) = lower[from..].find(key) {
128            let abs = from + pos + key.len();
129            let rest = lower[abs..].trim_start();
130            // JSON-style keys close the quote first: `"api_key": "..."`.
131            let rest = rest.strip_prefix('"').unwrap_or(rest).trim_start();
132            let Some(delim) = rest.chars().next() else {
133                break;
134            };
135            if delim == ':' || delim == '=' {
136                let value = rest[1..].trim_start();
137                let value = value.strip_prefix(['"', '\'']).unwrap_or(value);
138                // Redaction markers must never re-trigger detection, or the
139                // scrubber loops on its own output. `value` comes from the
140                // lowercased text, so the marker check is case-insensitive.
141                let is_marker = value
142                    .get(..10)
143                    .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
144                if !is_marker {
145                    let run: usize = value
146                        .chars()
147                        .take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'')
148                        .map(char::len_utf8)
149                        .sum();
150                    if run >= 16 {
151                        return true;
152                    }
153                }
154            }
155            from = abs;
156        }
157    }
158    false
159}
160
161/// Redact credential-shaped spans, replacing them with `[REDACTED:<kind>]`.
162///
163/// Detection is [`credential_shaped_content`]; when nothing fires the text is
164/// returned unchanged. Redaction is span-oriented (PEM blocks, prefixed
165/// tokens, assignment values) and deliberately over-redacts rather than
166/// under-redacts. Returns the redacted text and the kinds that fired, using
167/// the same labels as detection.
168#[must_use]
169pub fn redact_credential_content(content: &str) -> (String, Vec<&'static str>) {
170    let kinds = credential_shaped_content(content);
171    if kinds.is_empty() {
172        return (content.to_string(), kinds);
173    }
174
175    let mut out = content.to_string();
176
177    if kinds.contains(&"private_key_pem") {
178        while let Some((start, end)) = pem_block_span(&out) {
179            out.replace_range(start..end, "[REDACTED:private_key_pem]");
180        }
181        // Detection fires on any content holding both "-----BEGIN" and
182        // "PRIVATE KEY" — including truncated/example fragments with no
183        // complete END block, which the span loop above cannot match.
184        // Neutralize the marker strings so the pass is idempotent.
185        out = out.replace("PRIVATE KEY-----", "[REDACTED:pem-key]");
186        out = out.replace("-----BEGIN", "[REDACTED:pem-begin]");
187        out = out.replace("-----END", "[REDACTED:pem-end]");
188    }
189
190    if kinds.contains(&"credential_assignment") {
191        while let Some((start, end)) = assignment_value_span(&out) {
192            out.replace_range(start..end, "[REDACTED:credential_assignment]");
193        }
194    }
195
196    // JWT detection fires on any two `eyJ` occurrences (fragments included),
197    // so redaction must remove every occurrence — a min-run scan left short
198    // fragments detectable and the apply pass non-idempotent.
199    if kinds.contains(&"jwt") {
200        while let Some(pos) = out.find("eyJ") {
201            out.replace_range(pos..pos + "eyJ".len(), "[REDACTED:jwt]");
202        }
203    }
204
205    type TokenSpec = (&'static str, &'static str, usize, fn(char) -> bool);
206    let token_specs: &[TokenSpec] = &[
207        ("aws_access_key_id", "AKIA", 16, |c: char| {
208            c.is_ascii_uppercase() || c.is_ascii_digit()
209        }),
210        ("github_token", "ghp_", 30, |c: char| {
211            c.is_ascii_alphanumeric() || c == '_'
212        }),
213        ("github_token", "gho_", 30, |c: char| {
214            c.is_ascii_alphanumeric() || c == '_'
215        }),
216        ("github_token", "github_pat_", 20, |c: char| {
217            c.is_ascii_alphanumeric() || c == '_'
218        }),
219        ("openai_style_key", "sk-", 20, |c: char| {
220            c.is_ascii_alphanumeric() || c == '_' || c == '-'
221        }),
222        ("slack_token", "xoxb-", 10, |c: char| {
223            c.is_ascii_alphanumeric() || c == '-'
224        }),
225        ("slack_token", "xoxp-", 10, |c: char| {
226            c.is_ascii_alphanumeric() || c == '-'
227        }),
228        ("slack_token", "xoxa-", 10, |c: char| {
229            c.is_ascii_alphanumeric() || c == '-'
230        }),
231        ("slack_token", "xoxr-", 10, |c: char| {
232            c.is_ascii_alphanumeric() || c == '-'
233        }),
234        ("slack_token", "xoxs-", 10, |c: char| {
235            c.is_ascii_alphanumeric() || c == '-'
236        }),
237    ];
238    for (kind, prefix, min_len, charset) in token_specs {
239        while let Some((start, end)) = prefixed_token_span(&out, prefix, *min_len, *charset) {
240            out.replace_range(start..end, &format!("[REDACTED:{kind}]"));
241        }
242    }
243
244    (out, kinds)
245}
246
247/// Span of the first PEM private-key block (including its BEGIN/END markers).
248fn pem_block_span(text: &str) -> Option<(usize, usize)> {
249    let begin = text.find("-----BEGIN")?;
250    let key_at = text[begin..].find("PRIVATE KEY-----")? + begin;
251    let end_at = text[key_at..].find("-----END")? + key_at;
252    let marker_at = text[end_at..].find("PRIVATE KEY-----")? + end_at;
253    Some((begin, marker_at + "PRIVATE KEY-----".len()))
254}
255
256/// Span of the first assignment *value* (the 16+ char secret, not the key).
257fn assignment_value_span(text: &str) -> Option<(usize, usize)> {
258    for key in ASSIGNMENT_KEYS {
259        let mut from = 0usize;
260        while let Some(pos) = find_ascii_case_insensitive(text, key, from) {
261            let after = pos + key.len();
262            let rest_raw = &text[after..];
263            // JSON-style keys close their quote first: `"api_key": "..."`.
264            let quoted = rest_raw.strip_prefix('"').is_some();
265            let rest = rest_raw.strip_prefix('"').unwrap_or(rest_raw);
266            let ws = rest.len() - rest.trim_start().len();
267            let delim_pos = after + usize::from(quoted) + ws;
268            let delim = text[delim_pos..].chars().next();
269            if matches!(delim, Some(':' | '=')) {
270                let tail = &text[delim_pos + 1..];
271                let vws = tail.len() - tail.trim_start().len();
272                let mut vstart = delim_pos + 1 + vws;
273                if let Some(quote) = text[vstart..].chars().next() {
274                    if quote == '"' || quote == '\'' {
275                        vstart += quote.len_utf8();
276                    }
277                }
278                let mut bytes = 0usize;
279                for c in text[vstart..].chars() {
280                    if c.is_whitespace() || c == '"' || c == '\'' {
281                        break;
282                    }
283                    bytes += c.len_utf8();
284                }
285                let is_marker = text[vstart..]
286                    .get(..10)
287                    .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
288                if bytes >= 16 && !is_marker {
289                    return Some((vstart, vstart + bytes));
290                }
291            }
292            from = after;
293        }
294    }
295    None
296}
297
298/// Span of the first `prefix` + charset run of at least `min_len` characters.
299fn prefixed_token_span(
300    text: &str,
301    prefix: &str,
302    min_len: usize,
303    charset: fn(char) -> bool,
304) -> Option<(usize, usize)> {
305    let mut from = 0usize;
306    while let Some(pos) = text[from..].find(prefix) {
307        let start = from + pos;
308        let value_start = start + prefix.len();
309        let mut bytes = 0usize;
310        let mut count = 0usize;
311        for c in text[value_start..].chars() {
312            if !charset(c) {
313                break;
314            }
315            bytes += c.len_utf8();
316            count += 1;
317        }
318        if count >= min_len {
319            return Some((start, value_start + bytes));
320        }
321        from = value_start;
322    }
323    None
324}
325
326/// ASCII-case-insensitive substring search starting at `from`.
327fn find_ascii_case_insensitive(haystack: &str, needle: &str, from: usize) -> Option<usize> {
328    let h = haystack.as_bytes();
329    let n = needle.as_bytes();
330    if n.is_empty() || from >= h.len() || n.len() > h.len() - from {
331        return None;
332    }
333    (from..=h.len() - n.len()).find(|&i| h[i..i + n.len()].eq_ignore_ascii_case(n))
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn detects_private_keys_aws_and_github() {
342        let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----";
343        assert_eq!(credential_shaped_content(pem), vec!["private_key_pem"]);
344
345        let aws = "access id AKIAIOSFODNN7EXAMPLE found in logs";
346        assert_eq!(credential_shaped_content(aws), vec!["aws_access_key_id"]);
347
348        let gh = "token ghp_0123456789abcdefghijklmnopqrstuvwxyzABC pasted";
349        assert_eq!(credential_shaped_content(gh), vec!["github_token"]);
350    }
351
352    #[test]
353    fn detects_sk_slack_jwt_and_assignments() {
354        let sk = "key: sk-proj0123456789abcdefghijklmnopqrstuv";
355        assert_eq!(credential_shaped_content(sk), vec!["openai_style_key"]);
356
357        // Assembled at runtime: the raw Slack token shape must never appear
358        // in source (GitHub push protection blocks it), while the detector
359        // must still match the real shape at runtime.
360        let slack = format!(
361            "xoxb-{}-{}-{}",
362            "123456789012", "1234567890123", "abcdefghijklmnop"
363        );
364        assert_eq!(credential_shaped_content(&slack), vec!["slack_token"]);
365
366        let jwt = "header eyJhbGciOiJIUzI1NiJ9.payload eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig";
367        assert_eq!(credential_shaped_content(jwt), vec!["jwt"]);
368
369        let assign = "connect with DATABASE_PASSWORD=correct-horse-battery-staple-1 tomorrow";
370        assert_eq!(
371            credential_shaped_content(assign),
372            vec!["credential_assignment"]
373        );
374    }
375
376    #[test]
377    fn detects_aws_secret_and_compound_assignment_keys() {
378        // Regression (P0, 2026-09-14): in AWS_SECRET_ACCESS_KEY the `secret`
379        // key name is followed by `_`, so the delimiter check never fired and
380        // the secret survived `wm ingest --redact`. Compound keys now need
381        // no special-casing at the call sites — they are listed explicitly.
382        let aws = "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
383        assert_eq!(
384            credential_shaped_content(aws),
385            vec!["credential_assignment"]
386        );
387        let (redacted, kinds) = redact_credential_content(aws);
388        assert!(kinds.contains(&"credential_assignment"));
389        assert_eq!(
390            redacted,
391            "AWS_SECRET_ACCESS_KEY=[REDACTED:credential_assignment]"
392        );
393        assert!(
394            credential_shaped_content(&redacted).is_empty(),
395            "redacted AWS secret must read clean: {redacted}"
396        );
397        let (twice, _) = redact_credential_content(&redacted);
398        assert_eq!(redacted, twice);
399
400        // Compound keys with identifier suffixes need explicit listing.
401        for text in [
402            "secret_access_key=0123456789abcdef",
403            "secret_key: 0123456789abcdef",
404            "refresh_token=0123456789abcdef",
405            "auth_token=0123456789abcdef",
406        ] {
407            assert_eq!(
408                credential_shaped_content(text),
409                vec!["credential_assignment"],
410                "{text}"
411            );
412        }
413
414        // Prose naming the key without an assignment stays clean.
415        assert!(
416            credential_shaped_content("the aws secret access key rotation policy was updated")
417                .is_empty()
418        );
419    }
420
421    #[test]
422    fn ordinary_prose_stays_clean() {
423        assert!(
424            credential_shaped_content("remember that the password policy requires rotation")
425                .is_empty()
426        );
427        assert!(credential_shaped_content("api_key rotation happens quarterly").is_empty());
428        assert!(credential_shaped_content("short token: abc123").is_empty());
429        assert!(
430            credential_shaped_content("the sk- prefix marks OpenAI keys in general").is_empty()
431        );
432        assert!(credential_shaped_content("AKIA is the AWS key prefix").is_empty());
433        assert!(credential_shaped_content("we discussed jwt sessions at length").is_empty());
434    }
435
436    #[test]
437    fn dedupes_kinds() {
438        let both = "AKIAIOSFODNN7EXAMPLE and AKIAIOSFODNN7EXAMPLE again";
439        assert_eq!(credential_shaped_content(both), vec!["aws_access_key_id"]);
440    }
441
442    #[test]
443    fn redacts_private_key_blocks() {
444        let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowSECRET\n-----END RSA PRIVATE KEY-----\nafter";
445        let (redacted, kinds) = redact_credential_content(pem);
446        assert!(kinds.contains(&"private_key_pem"));
447        assert!(!redacted.contains("MIIEowSECRET"), "key body must be gone");
448        assert!(!redacted.contains("BEGIN RSA PRIVATE KEY"));
449        assert_eq!(redacted, "before\n[REDACTED:private_key_pem]\nafter");
450    }
451
452    #[test]
453    fn redacts_assignment_values_and_tokens() {
454        let text = "db password=correct-horse-battery-staple and key sk-proj0123456789abcdefghijklmnopqrstuv";
455        let (redacted, _) = redact_credential_content(text);
456        assert!(redacted.contains("password=[REDACTED:credential_assignment]"));
457        assert!(!redacted.contains("correct-horse-battery-staple"));
458        assert!(!redacted.contains("sk-proj0123456789abcdefghijklmnopqrstuv"));
459        assert!(redacted.contains("[REDACTED:openai_style_key]"));
460
461        let aws = "id AKIAIOSFODNN7EXAMPLE here";
462        let (redacted, _) = redact_credential_content(aws);
463        assert_eq!(redacted, "id [REDACTED:aws_access_key_id] here");
464    }
465
466    /// 2026-09-15 review: JSON-style keys close their quote before the
467    /// delimiter (`"api_key": "..."`), so the assignment detector missed
468    /// them — exactly the shape a `.jsonl` credential file uses.
469    #[test]
470    fn redacts_json_style_assignment_values() {
471        let json = r#"{"api_key": "supersecretvalue12345", "note": "plain"}"#;
472        let (redacted, kinds) = redact_credential_content(json);
473        assert!(
474            kinds.contains(&"credential_assignment"),
475            "JSON assignment must be detected: {kinds:?}"
476        );
477        assert!(
478            !redacted.contains("supersecretvalue12345"),
479            "JSON assignment value must be scrubbed: {redacted}"
480        );
481        assert!(
482            redacted.contains("plain"),
483            "non-secret values stay: {redacted}"
484        );
485    }
486
487    #[test]
488    fn clean_content_passes_through_unchanged() {
489        let text = "remember that the password policy requires rotation";
490        let (redacted, kinds) = redact_credential_content(text);
491        assert!(kinds.is_empty());
492        assert_eq!(redacted, text);
493    }
494
495    #[test]
496    fn redaction_is_idempotent() {
497        let text = "key sk-proj0123456789abcdefghijklmnopqrstuv end";
498        let (once, _) = redact_credential_content(text);
499        let (twice, kinds) = redact_credential_content(&once);
500        assert_eq!(once, twice);
501        assert!(
502            kinds.is_empty(),
503            "redacted marker must read clean: {kinds:?}"
504        );
505    }
506
507    #[test]
508    fn assignment_marker_does_not_retrigger_detection() {
509        // Regression: detection lowercases before scanning, so the marker
510        // guard must compare case-insensitively or apply-pass runs are never
511        // idempotent (found by the wm redact-content store pass, 2026-09-11).
512        let text = "db password=correct-horse-battery-staple";
513        let (once, _) = redact_credential_content(text);
514        assert!(
515            credential_shaped_content(&once).is_empty(),
516            "redacted assignment must read clean: {once}"
517        );
518        let (twice, kinds) = redact_credential_content(&once);
519        assert_eq!(once, twice);
520        assert!(kinds.is_empty());
521    }
522
523    #[test]
524    fn short_jwt_fragments_are_redacted_too() {
525        // Regression: detection counts any two `eyJ` occurrences, but the
526        // redactor used to demand an 8-char run — short fragments stayed
527        // detectable and the store pass kept re-finding them (2026-09-11).
528        let text = "tokens eyJab and eyJcd appeared in logs";
529        let (once, kinds) = redact_credential_content(text);
530        assert!(kinds.contains(&"jwt"));
531        assert!(
532            credential_shaped_content(&once).is_empty(),
533            "short fragments must read clean after redaction: {once}"
534        );
535        let (twice, _) = redact_credential_content(&once);
536        assert_eq!(once, twice);
537    }
538
539    #[test]
540    fn pem_fragments_are_redacted_too() {
541        // Regression: a truncated/example PEM with no END block fires
542        // detection but has no complete span; the marker strings themselves
543        // must be neutralized so the store pass is idempotent (2026-09-11).
544        let text = "docs explain -----BEGIN PRIVATE KEY----- when truncated";
545        let (once, kinds) = redact_credential_content(text);
546        assert!(kinds.contains(&"private_key_pem"));
547        assert!(
548            credential_shaped_content(&once).is_empty(),
549            "PEM fragments must read clean after redaction: {once}"
550        );
551        let (twice, _) = redact_credential_content(&once);
552        assert_eq!(once, twice);
553    }
554}