Skip to main content

kimetsu_brain/
redact.rs

1//! v0.4.5: secret redaction at ingest.
2//!
3//! Every memory.text and provenance snapshot that lands in brain.db
4//! passes through [`redact_secrets`] first. The pattern set catches
5//! the credential formats most likely to leak into an agent's tool
6//! output (OAuth bearers, API keys, JWTs, AWS access pairs, etc.)
7//! and replaces each match with a `[REDACTED:<kind>]` placeholder.
8//!
9//! Why redact at ingest, not later: brain.db is durable + content-
10//! addressable + replicated across user / project scopes. A leak
11//! that lands here lives forever and shows up in every retrieval
12//! capsule, agent.done summary, and proposal review screen. The
13//! cost of one false positive (a config string redacted) is much
14//! lower than the cost of one true positive sitting in
15//! `~/.kimetsu/brain.db` for years.
16//!
17//! Detection layers, applied in order:
18//!   1. Exact-prefix patterns: `sk-ant-`, `sk-`, `ghp_`, `gho_`,
19//!      `ghu_`, `ghs_`, `ghr_`, `github_pat_`, `xox[bopasr]-`,
20//!      `AKIA`/`ASIA` (AWS access key IDs), `eyJ` (JWT header).
21//!   2. Generic key/token assignments: `api[_-]?key\s*=\s*<value>`,
22//!      `token\s*[:=]\s*<value>`, `password\s*[:=]\s*<value>`,
23//!      `bearer\s+<value>` (the value must look secret — high
24//!      entropy, no spaces, length ≥ 12).
25//!   3. High-entropy fallback (off by default): would catch random
26//!      base64 strings of length ≥ 40 with entropy > 4.5 bits/char.
27//!      Deferred to v0.4.5.1 so we don't false-positive on hashes,
28//!      ulids, and content-addressable refs.
29//!
30//! The redaction is greedy + non-overlapping: a string matched by
31//! pattern (1) won't be re-scanned for pattern (2). [`RedactionResult`]
32//! returns both the redacted text AND a per-kind tally so callers
33//! (chat REPL banner, CLI warning, MCP response field) can surface
34//! "we found 2 secrets in this memory, kinds: aws_access_key,
35//! github_pat" without re-parsing the redacted text.
36
37use std::sync::OnceLock;
38
39use regex::Regex;
40use serde::Serialize;
41
42/// One detected secret. `kind` is a stable string id usable in
43/// telemetry and human-readable output.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct RedactedMatch {
46    pub kind: &'static str,
47    /// Byte offset within the ORIGINAL (pre-redaction) text where
48    /// the match started.
49    pub start: usize,
50    /// Length of the matched run (bytes, original text).
51    pub len: usize,
52}
53
54/// Output of [`redact_secrets`]. `text` is the redacted string;
55/// `matches` is the per-secret tally + locations.
56#[derive(Debug, Clone, Default, Serialize)]
57pub struct RedactionResult {
58    pub text: String,
59    pub matches: Vec<RedactedMatch>,
60}
61
62impl RedactionResult {
63    pub fn was_redacted(&self) -> bool {
64        !self.matches.is_empty()
65    }
66    /// One-liner like `"redacted 2 secrets: github_pat, openai_api_key"`.
67    /// Empty when nothing was redacted.
68    pub fn summary(&self) -> String {
69        if self.matches.is_empty() {
70            return String::new();
71        }
72        let mut kinds: Vec<&'static str> = self.matches.iter().map(|m| m.kind).collect();
73        kinds.sort_unstable();
74        kinds.dedup();
75        format!(
76            "redacted {} secret{}: {}",
77            self.matches.len(),
78            if self.matches.len() == 1 { "" } else { "s" },
79            kinds.join(", ")
80        )
81    }
82}
83
84/// Redact `text` against the bundled secret pattern set. Returns
85/// the redacted version + per-match tally. Allocates only when at
86/// least one match is found — for the common case (clean text) the
87/// result borrows nothing and matches is empty.
88pub fn redact_secrets(text: &str) -> RedactionResult {
89    let patterns = patterns();
90    // Collect non-overlapping matches across all patterns. Each
91    // pattern walks the full text; we sort + dedupe by start, then
92    // discard overlapping later matches.
93    let mut spans: Vec<(usize, usize, &'static str)> = Vec::new();
94    for pat in patterns {
95        for m in pat.regex.find_iter(text) {
96            spans.push((m.start(), m.end(), pat.kind));
97        }
98    }
99    if spans.is_empty() {
100        return RedactionResult {
101            text: text.to_string(),
102            matches: Vec::new(),
103        };
104    }
105    spans.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
106    // Drop overlaps: keep the first span; skip any subsequent span
107    // whose start < current_end.
108    let mut accepted: Vec<(usize, usize, &'static str)> = Vec::new();
109    let mut cursor = 0usize;
110    for (start, end, kind) in spans {
111        if start < cursor {
112            continue;
113        }
114        accepted.push((start, end, kind));
115        cursor = end;
116    }
117
118    // Rebuild the redacted text + match list.
119    let mut redacted = String::with_capacity(text.len());
120    let mut matches: Vec<RedactedMatch> = Vec::with_capacity(accepted.len());
121    let mut last = 0usize;
122    for (start, end, kind) in accepted {
123        redacted.push_str(&text[last..start]);
124        redacted.push_str(&format!("[REDACTED:{kind}]"));
125        matches.push(RedactedMatch {
126            kind,
127            start,
128            len: end - start,
129        });
130        last = end;
131    }
132    redacted.push_str(&text[last..]);
133    RedactionResult {
134        text: redacted,
135        matches,
136    }
137}
138
139struct SecretPattern {
140    kind: &'static str,
141    regex: Regex,
142}
143
144fn patterns() -> &'static [SecretPattern] {
145    static CELL: OnceLock<Vec<SecretPattern>> = OnceLock::new();
146    CELL.get_or_init(|| {
147        // Each pattern's `kind` is stable telemetry-grade ID; the
148        // regex MUST be anchored at a unique prefix so we don't
149        // shadow normal source text. Use word boundaries where the
150        // pattern's prefix isn't already distinctive.
151        let mut out = Vec::new();
152        out.push(SecretPattern {
153            kind: "anthropic_oauth",
154            // Anthropic OAuth tokens: `sk-ant-` prefix + opaque tail.
155            // Tail is at least 32 chars of [A-Za-z0-9_-].
156            regex: Regex::new(r"sk-ant-[A-Za-z0-9_-]{32,}").unwrap(),
157        });
158        out.push(SecretPattern {
159            kind: "openai_api_key",
160            // OpenAI keys: `sk-` (not `sk-ant-`) + 32+ chars.
161            // Negative lookahead isn't available in `regex`; we
162            // order anthropic_oauth FIRST so it claims those bytes
163            // before openai_api_key sees them.
164            regex: Regex::new(r"sk-[A-Za-z0-9_-]{32,}").unwrap(),
165        });
166        out.push(SecretPattern {
167            kind: "github_pat",
168            // Classic + fine-grained GitHub PATs.
169            // ghp_/gho_/ghu_/ghs_/ghr_ + 36 base62.
170            // github_pat_ + base62/underscore length 50+.
171            regex: Regex::new(
172                r"(?:ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{50,}",
173            )
174            .unwrap(),
175        });
176        out.push(SecretPattern {
177            kind: "slack_token",
178            regex: Regex::new(r"xox[bopasr]-[A-Za-z0-9-]{10,}").unwrap(),
179        });
180        out.push(SecretPattern {
181            kind: "aws_access_key",
182            // AKIA = long-lived, ASIA = STS temporary. Exactly 16
183            // uppercase alphanum after the prefix per AWS docs.
184            regex: Regex::new(r"(?:AKIA|ASIA)[A-Z0-9]{16}").unwrap(),
185        });
186        out.push(SecretPattern {
187            kind: "jwt",
188            // Three base64url segments separated by dots; first
189            // starts with `eyJ` (base64url of `{"`). Cap total at
190            // 4096 chars so a runaway match doesn't blow the line.
191            regex: Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(),
192        });
193        out.push(SecretPattern {
194            kind: "private_key_pem",
195            // PEM-encoded private key BEGIN line — match the whole
196            // block so the entire payload is wiped.
197            regex: Regex::new(
198                r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
199            )
200            .unwrap(),
201        });
202        out.push(SecretPattern {
203            kind: "google_api_key",
204            // Google-style API keys: AIza + 35 char tail.
205            regex: Regex::new(r"AIza[0-9A-Za-z_-]{35}").unwrap(),
206        });
207        // Generic-assignment patterns. Lower-priority than the
208        // shape-specific ones above; ordered after them so e.g.
209        // `api_key=sk-...` claims the openai_api_key kind, not the
210        // generic_api_key kind.
211        out.push(SecretPattern {
212            kind: "generic_bearer",
213            // `Bearer <token>` in HTTP-style logs.
214            regex: Regex::new(r"(?i)bearer\s+[A-Za-z0-9_\-\.=]{12,}").unwrap(),
215        });
216        out.push(SecretPattern {
217            kind: "generic_api_key",
218            // `api_key = "..."` / `api-key:"..."` / `api_key=...`.
219            // Captures both quoted and bare values; value must be
220            // ≥12 chars of secret-looking content.
221            regex: Regex::new(
222                r#"(?i)api[_\-]?key\s*[:=]\s*"?[A-Za-z0-9_\-]{12,}"?"#,
223            )
224            .unwrap(),
225        });
226        out.push(SecretPattern {
227            kind: "generic_token",
228            regex: Regex::new(r#"(?i)\btoken\s*[:=]\s*"?[A-Za-z0-9_\-\.]{20,}"?"#).unwrap(),
229        });
230        out.push(SecretPattern {
231            kind: "generic_password",
232            regex: Regex::new(r#"(?i)\bpassword\s*[:=]\s*"?[^\s"]{8,}"?"#).unwrap(),
233        });
234        out
235    })
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn clean_text_round_trips_untouched() {
244        let raw = "use ripgrep before broad file reads, prefer thiserror for errors";
245        let r = redact_secrets(raw);
246        assert!(!r.was_redacted());
247        assert_eq!(r.text, raw);
248        assert!(r.summary().is_empty());
249    }
250
251    #[test]
252    fn anthropic_oauth_token_is_redacted() {
253        let raw = "export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf";
254        let r = redact_secrets(raw);
255        assert!(r.was_redacted(), "{:?}", r);
256        assert!(r.text.contains("[REDACTED:anthropic_oauth]"));
257        assert!(!r.text.contains("sk-ant-api03"));
258        assert_eq!(r.matches.len(), 1);
259        assert_eq!(r.matches[0].kind, "anthropic_oauth");
260    }
261
262    #[test]
263    fn openai_key_is_redacted_without_shadowing_anthropic_prefix() {
264        // Ensure pattern ordering: anthropic claims sk-ant-... first.
265        let raw =
266            "two: sk-1234567890abcdef1234567890abcdef AND sk-ant-1234567890abcdef1234567890abcdef";
267        let r = redact_secrets(raw);
268        let kinds: Vec<_> = r.matches.iter().map(|m| m.kind).collect();
269        assert!(kinds.contains(&"openai_api_key"));
270        assert!(kinds.contains(&"anthropic_oauth"));
271        assert!(r.text.contains("[REDACTED:openai_api_key]"));
272        assert!(r.text.contains("[REDACTED:anthropic_oauth]"));
273    }
274
275    #[test]
276    fn github_pat_classic_and_fine_grained_redacted() {
277        // Realistic lengths: classic PATs are `ghp_` + 36 base62;
278        // fine-grained PATs are `github_pat_` + ≥50 base62/underscore.
279        let raw = concat!(
280            "classic: ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ ",
281            "fine: github_pat_11AAA_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJabcdef",
282        );
283        let r = redact_secrets(raw);
284        let kinds: Vec<_> = r.matches.iter().map(|m| m.kind).collect();
285        assert_eq!(
286            kinds.iter().filter(|k| **k == "github_pat").count(),
287            2,
288            "two github_pat matches expected; got matches: {:?}",
289            r.matches
290        );
291        assert!(!r.text.contains("ghp_abcdef"));
292        assert!(!r.text.contains("github_pat_11AAA"));
293    }
294
295    #[test]
296    fn slack_aws_jwt_pem_google_all_redact() {
297        let raw = concat!(
298            "slack=xoxb-12345678-abcdefghijklmnop ",
299            "aws=AKIAIOSFODNN7EXAMPLE ",
300            "jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abcdef ",
301            "google=AIzaSyDx0o-1234567890abcdefghijklmnopqrs ",
302            "pem=-----BEGIN RSA PRIVATE KEY-----\nABCDEFG\n-----END RSA PRIVATE KEY-----"
303        );
304        let r = redact_secrets(raw);
305        let kinds: Vec<&'static str> = {
306            let mut k = r.matches.iter().map(|m| m.kind).collect::<Vec<_>>();
307            k.sort_unstable();
308            k.dedup();
309            k
310        };
311        for expected in [
312            "aws_access_key",
313            "google_api_key",
314            "jwt",
315            "private_key_pem",
316            "slack_token",
317        ] {
318            assert!(kinds.contains(&expected), "missing kind {expected}: {kinds:?}");
319        }
320    }
321
322    #[test]
323    fn generic_assignments_match_only_with_secret_looking_value() {
324        // Should match: long enough value.
325        let bad = "config: api_key = \"abcdef1234567890\" \n token : 0123456789abcdefghij1234567890\n password = hunter2hunter2";
326        let r_bad = redact_secrets(bad);
327        let kinds: Vec<_> = r_bad.matches.iter().map(|m| m.kind).collect();
328        assert!(kinds.contains(&"generic_api_key"));
329        assert!(kinds.contains(&"generic_token"));
330        assert!(kinds.contains(&"generic_password"));
331
332        // Should NOT match: too-short value or non-secret-looking.
333        let safe = "api_key = short  token: 12345  password = a";
334        let r_safe = redact_secrets(safe);
335        assert!(
336            r_safe.matches.is_empty(),
337            "short values should not trip generic patterns: {r_safe:?}"
338        );
339    }
340
341    #[test]
342    fn bearer_token_in_curl_log_is_redacted() {
343        let raw = "curl -H 'Authorization: Bearer abc123def456ghi789' https://api.example.com";
344        let r = redact_secrets(raw);
345        assert!(r.was_redacted());
346        assert_eq!(r.matches[0].kind, "generic_bearer");
347        assert!(r.text.contains("[REDACTED:generic_bearer]"));
348        assert!(!r.text.contains("abc123def456ghi789"));
349    }
350
351    #[test]
352    fn overlapping_matches_keep_first_only() {
353        // The same byte run could match two patterns (e.g. an
354        // openai_api_key inside a `Bearer ` prefix). The earlier
355        // pattern claims it; we don't double-redact.
356        let raw = "Authorization: Bearer sk-1234567890abcdef1234567890abcdef1234";
357        let r = redact_secrets(raw);
358        assert_eq!(
359            r.matches.len(),
360            1,
361            "non-overlapping rule should pick one: {r:?}"
362        );
363    }
364
365    #[test]
366    fn summary_lists_unique_kinds() {
367        let raw = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ and ghp_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ";
368        let r = redact_secrets(raw);
369        let summary = r.summary();
370        assert!(summary.contains("github_pat"));
371        // Only one kind reported even though two matches happened.
372        assert!(summary.starts_with("redacted 2 secrets: github_pat"));
373    }
374
375    #[test]
376    fn match_offsets_point_into_original_text() {
377        let raw = "prefix sk-ant-api03-1234567890abcdef1234567890abcdef suffix";
378        let r = redact_secrets(raw);
379        assert_eq!(r.matches.len(), 1);
380        let m = &r.matches[0];
381        // The matched bytes start at "sk-ant-..." and extend over the token.
382        let original_match = &raw[m.start..m.start + m.len];
383        assert!(original_match.starts_with("sk-ant-api03"));
384    }
385
386    #[test]
387    fn redaction_preserves_non_secret_surroundings() {
388        let raw =
389            "# Save to .env\nCLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf\n# Use it";
390        let r = redact_secrets(raw);
391        assert!(r.text.starts_with("# Save to .env"));
392        assert!(r.text.ends_with("# Use it"));
393        assert!(r.text.contains("CLAUDE_CODE_OAUTH_TOKEN=[REDACTED:anthropic_oauth]"));
394    }
395}