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