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                let run: usize = value
125                    .chars()
126                    .take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'')
127                    .map(char::len_utf8)
128                    .sum();
129                if run >= 16 {
130                    return true;
131                }
132            }
133            from = abs;
134        }
135    }
136    false
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn detects_private_keys_aws_and_github() {
145        let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----";
146        assert_eq!(credential_shaped_content(pem), vec!["private_key_pem"]);
147
148        let aws = "access id AKIAIOSFODNN7EXAMPLE found in logs";
149        assert_eq!(credential_shaped_content(aws), vec!["aws_access_key_id"]);
150
151        let gh = "token ghp_0123456789abcdefghijklmnopqrstuvwxyzABC pasted";
152        assert_eq!(credential_shaped_content(gh), vec!["github_token"]);
153    }
154
155    #[test]
156    fn detects_sk_slack_jwt_and_assignments() {
157        let sk = "key: sk-proj0123456789abcdefghijklmnopqrstuv";
158        assert_eq!(credential_shaped_content(sk), vec!["openai_style_key"]);
159
160        // Assembled at runtime: the raw Slack token shape must never appear
161        // in source (GitHub push protection blocks it), while the detector
162        // must still match the real shape at runtime.
163        let slack = format!(
164            "xoxb-{}-{}-{}",
165            "123456789012", "1234567890123", "abcdefghijklmnop"
166        );
167        assert_eq!(credential_shaped_content(&slack), vec!["slack_token"]);
168
169        let jwt = "header eyJhbGciOiJIUzI1NiJ9.payload eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig";
170        assert_eq!(credential_shaped_content(jwt), vec!["jwt"]);
171
172        let assign = "connect with DATABASE_PASSWORD=correct-horse-battery-staple-1 tomorrow";
173        assert_eq!(
174            credential_shaped_content(assign),
175            vec!["credential_assignment"]
176        );
177    }
178
179    #[test]
180    fn ordinary_prose_stays_clean() {
181        assert!(
182            credential_shaped_content("remember that the password policy requires rotation")
183                .is_empty()
184        );
185        assert!(credential_shaped_content("api_key rotation happens quarterly").is_empty());
186        assert!(credential_shaped_content("short token: abc123").is_empty());
187        assert!(
188            credential_shaped_content("the sk- prefix marks OpenAI keys in general").is_empty()
189        );
190        assert!(credential_shaped_content("AKIA is the AWS key prefix").is_empty());
191        assert!(credential_shaped_content("we discussed jwt sessions at length").is_empty());
192    }
193
194    #[test]
195    fn dedupes_kinds() {
196        let both = "AKIAIOSFODNN7EXAMPLE and AKIAIOSFODNN7EXAMPLE again";
197        assert_eq!(credential_shaped_content(both), vec!["aws_access_key_id"]);
198    }
199}