Skip to main content

context_forge/
scrub.rs

1//! Secret scrubbing applied to entry content before persistence.
2//!
3//! This module is pure (no I/O): it compiles a fixed set of regular
4//! expressions once and applies them to redact common credential formats
5//! (cloud provider keys, API tokens, private key blocks, JWTs, bearer
6//! tokens) before content reaches storage.
7//!
8//! See the crate-level `# Security` section for the untrusted-memory
9//! doctrine that governs how retrieved content must be treated by callers.
10
11use std::borrow::Cow;
12use std::sync::OnceLock;
13
14use regex::Regex;
15use serde::{Deserialize, Serialize};
16
17/// Configuration for secret scrubbing.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct ScrubConfig {
21    /// Whether secret scrubbing is applied at save time. Defaults to `true`.
22    ///
23    /// Disabling this is an explicit opt-out: callers who set this to
24    /// `false` are asserting that `content` passed to
25    /// [`crate::ContextForge::save`] will never contain secrets, or that
26    /// they have their own scrubbing in place.
27    pub enabled: bool,
28}
29
30impl Default for ScrubConfig {
31    /// Defaults to `enabled: true` — secret scrubbing is on unless a caller
32    /// explicitly opts out.
33    fn default() -> Self {
34        Self { enabled: true }
35    }
36}
37
38/// A single compiled redaction pattern: a regex and the label used in its
39/// `[REDACTED:<label>]` replacement.
40struct Pattern {
41    regex: Regex,
42    label: &'static str,
43}
44
45/// Hardcoded source patterns: `(label, regex source)`.
46///
47/// The `openai-key` label intentionally appears twice (two distinct regex
48/// shapes share one label).
49const PATTERN_SOURCES: &[(&str, &str)] = &[
50    ("aws-key", r"\bAKIA[0-9A-Z]{16}\b"),
51    ("github-token", r"\bgh[pousr]_[A-Za-z0-9]{36,255}\b"),
52    ("anthropic-key", r"\bsk-ant-[A-Za-z0-9\-_]{20,}\b"),
53    (
54        "openai-key",
55        r"\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b",
56    ),
57    ("openai-key", r"\bsk-proj-[A-Za-z0-9\-_]{20,}\b"),
58    ("slack-token", r"\bxox[baprs]-[A-Za-z0-9\-]{10,}\b"),
59    (
60        "discord-token",
61        r"\b[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27,}\b",
62    ),
63    (
64        "private-key",
65        r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
66    ),
67    (
68        "jwt",
69        r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b",
70    ),
71    (
72        "generic-bearer",
73        r"(?i)\b(bearer|authorization:)\s+[A-Za-z0-9\-._~+/]{20,}=*",
74    ),
75];
76
77/// Returns the compiled pattern set, building it on first use.
78///
79/// # Panics
80///
81/// Panics if any hardcoded pattern in [`PATTERN_SOURCES`] fails to compile.
82/// All patterns are fixed literals validated by [`pattern_set_compiles`]; a
83/// failure here indicates a programming error in this crate (a malformed
84/// built-in security pattern), which must fail loudly rather than silently
85/// disable scrubbing for that pattern.
86#[allow(
87    clippy::expect_used,
88    reason = "hardcoded built-in security patterns must compile; a failure is a programming \
89              error that must panic loudly rather than silently skip a redaction pattern"
90)]
91fn pattern_set() -> &'static Vec<Pattern> {
92    static PATTERNS: OnceLock<Vec<Pattern>> = OnceLock::new();
93    PATTERNS.get_or_init(|| {
94        PATTERN_SOURCES
95            .iter()
96            .map(|&(label, source)| Pattern {
97                regex: Regex::new(source).unwrap_or_else(|err| {
98                    panic!("built-in scrub pattern {label:?} failed to compile: {err}")
99                }),
100                label,
101            })
102            .collect()
103    })
104}
105
106/// Scrub known secret formats from `text`, replacing each match with
107/// `[REDACTED:<label>]`.
108///
109/// If `config.enabled` is `false`, returns `text` unchanged (borrowed, no
110/// allocation). Otherwise applies every built-in redaction pattern in
111/// order; the result is allocation-free (`Cow::Borrowed`) when no pattern
112/// matches.
113#[must_use]
114pub fn scrub_secrets<'a>(text: &'a str, config: &ScrubConfig) -> Cow<'a, str> {
115    if !config.enabled {
116        return Cow::Borrowed(text);
117    }
118
119    let mut current = Cow::Borrowed(text);
120    for pattern in pattern_set() {
121        if pattern.regex.is_match(&current) {
122            let replacement = format!("[REDACTED:{}]", pattern.label);
123            let replaced = pattern
124                .regex
125                .replace_all(&current, replacement.as_str())
126                .into_owned();
127            current = Cow::Owned(replaced);
128        }
129    }
130    current
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn cfg() -> ScrubConfig {
138        ScrubConfig::default()
139    }
140
141    #[test]
142    fn pattern_set_compiles() {
143        // Forces initialization; panics if any hardcoded pattern is malformed.
144        let patterns = pattern_set();
145        assert_eq!(patterns.len(), PATTERN_SOURCES.len());
146    }
147
148    #[test]
149    fn disabled_config_returns_borrowed_unchanged() {
150        let text = "this has a secret AKIAABCDEFGHIJKLMNOP in it";
151        let cfg = ScrubConfig { enabled: false };
152        let result = scrub_secrets(text, &cfg);
153        assert_eq!(result, text);
154        assert!(matches!(result, Cow::Borrowed(_)));
155    }
156
157    #[test]
158    fn no_match_returns_borrowed() {
159        let text = "nothing sensitive here, just plain text";
160        let result = scrub_secrets(text, &cfg());
161        assert_eq!(result, text);
162        assert!(matches!(result, Cow::Borrowed(_)));
163    }
164
165    // -- aws-key --------------------------------------------------------
166
167    #[test]
168    fn aws_key_positive() {
169        let text = "key=AKIAABCDEFGHIJKLMNOP end";
170        let result = scrub_secrets(text, &cfg());
171        assert_eq!(result, "key=[REDACTED:aws-key] end");
172    }
173
174    #[test]
175    fn aws_key_near_miss() {
176        // Too short (15 chars after AKIA instead of 16).
177        let text = "key=AKIAABCDEFGHIJKLMNO end";
178        let result = scrub_secrets(text, &cfg());
179        assert_eq!(result, text);
180    }
181
182    // -- github-token -----------------------------------------------------
183
184    #[test]
185    fn github_token_positive() {
186        let token = format!("ghp_{}", "a".repeat(36));
187        let text = format!("token: {token}");
188        let result = scrub_secrets(&text, &cfg());
189        assert_eq!(result, "token: [REDACTED:github-token]");
190    }
191
192    #[test]
193    fn github_token_near_miss() {
194        // Too short (35 chars after ghp_).
195        let token = format!("ghp_{}", "a".repeat(35));
196        let text = format!("token: {token}");
197        let result = scrub_secrets(&text, &cfg());
198        assert_eq!(result, text);
199    }
200
201    // -- anthropic-key ------------------------------------------------------
202
203    #[test]
204    fn anthropic_key_positive() {
205        let token = format!("sk-ant-{}", "A".repeat(20));
206        let text = format!("ANTHROPIC_API_KEY={token}");
207        let result = scrub_secrets(&text, &cfg());
208        assert_eq!(result, "ANTHROPIC_API_KEY=[REDACTED:anthropic-key]");
209    }
210
211    #[test]
212    fn anthropic_key_near_miss() {
213        let token = format!("sk-ant-{}", "A".repeat(19));
214        let text = format!("ANTHROPIC_API_KEY={token}");
215        let result = scrub_secrets(&text, &cfg());
216        assert_eq!(result, text);
217    }
218
219    // -- openai-key (legacy form) ---------------------------------------
220
221    #[test]
222    fn openai_key_legacy_positive() {
223        let token = format!("sk-{}T3BlbkFJ{}", "a".repeat(20), "b".repeat(20));
224        let text = format!("OPENAI_API_KEY={token}");
225        let result = scrub_secrets(&text, &cfg());
226        assert_eq!(result, "OPENAI_API_KEY=[REDACTED:openai-key]");
227    }
228
229    #[test]
230    fn openai_key_legacy_near_miss() {
231        // Missing the T3BlbkFJ marker.
232        let token = format!("sk-{}X3BlbkFJ{}", "a".repeat(20), "b".repeat(20));
233        let text = format!("OPENAI_API_KEY={token}");
234        let result = scrub_secrets(&text, &cfg());
235        assert_eq!(result, text);
236    }
237
238    // -- openai-key (project form) ---------------------------------------
239
240    #[test]
241    fn openai_key_proj_positive() {
242        let token = format!("sk-proj-{}", "A".repeat(20));
243        let text = format!("OPENAI_API_KEY={token}");
244        let result = scrub_secrets(&text, &cfg());
245        assert_eq!(result, "OPENAI_API_KEY=[REDACTED:openai-key]");
246    }
247
248    #[test]
249    fn openai_key_proj_near_miss() {
250        let token = format!("sk-proj-{}", "A".repeat(19));
251        let text = format!("OPENAI_API_KEY={token}");
252        let result = scrub_secrets(&text, &cfg());
253        assert_eq!(result, text);
254    }
255
256    // -- slack-token ---------------------------------------------------------
257
258    #[test]
259    fn slack_token_positive() {
260        let text = "SLACK_TOKEN=xoxb-1234567890-abcdefghij";
261        let result = scrub_secrets(text, &cfg());
262        assert_eq!(result, "SLACK_TOKEN=[REDACTED:slack-token]");
263    }
264
265    #[test]
266    fn slack_token_near_miss() {
267        // Too short suffix (9 chars instead of >=10).
268        let text = "SLACK_TOKEN=xoxb-123456789";
269        let result = scrub_secrets(text, &cfg());
270        assert_eq!(result, text);
271    }
272
273    // -- discord-token -------------------------------------------------------
274
275    #[test]
276    fn discord_token_positive() {
277        let token = format!("M{}.{}.{}", "A".repeat(23), "a".repeat(6), "b".repeat(27));
278        let text = format!("DISCORD_TOKEN={token}");
279        let result = scrub_secrets(&text, &cfg());
280        assert_eq!(result, "DISCORD_TOKEN=[REDACTED:discord-token]");
281    }
282
283    #[test]
284    fn discord_token_near_miss() {
285        // Wrong first character (not M/N).
286        let token = format!("X{}.{}.{}", "A".repeat(23), "a".repeat(6), "b".repeat(27));
287        let text = format!("DISCORD_TOKEN={token}");
288        let result = scrub_secrets(&text, &cfg());
289        assert_eq!(result, text);
290    }
291
292    // -- private-key ----------------------------------------------------------
293
294    #[test]
295    fn private_key_positive() {
296        let text = "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAK\nmore lines\n-----END RSA PRIVATE KEY-----";
297        let result = scrub_secrets(text, &cfg());
298        assert_eq!(result, "[REDACTED:private-key]");
299    }
300
301    #[test]
302    fn private_key_near_miss() {
303        let text = "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAK\nno end marker here";
304        let result = scrub_secrets(text, &cfg());
305        assert_eq!(result, text);
306    }
307
308    // -- jwt ------------------------------------------------------------------
309
310    #[test]
311    fn jwt_positive() {
312        let token = format!(
313            "eyJ{}.{}.{}",
314            "a".repeat(10),
315            "b".repeat(10),
316            "c".repeat(10)
317        );
318        let text = format!("Authorization-payload: {token}");
319        let result = scrub_secrets(&text, &cfg());
320        assert!(result.contains("[REDACTED:jwt]"));
321        assert!(!result.contains(&token));
322    }
323
324    #[test]
325    fn jwt_near_miss() {
326        // Only two segments, not three.
327        let token = format!("eyJ{}.{}", "a".repeat(10), "b".repeat(10));
328        let text = format!("payload: {token}");
329        let result = scrub_secrets(&text, &cfg());
330        assert_eq!(result, text);
331    }
332
333    // -- generic-bearer ---------------------------------------------------------
334
335    #[test]
336    fn generic_bearer_positive() {
337        let text = format!("Authorization: Bearer {}", "a".repeat(25));
338        let result = scrub_secrets(&text, &cfg());
339        assert!(result.contains("[REDACTED:generic-bearer]"));
340        assert!(!result.to_lowercase().contains("bearer aaaa"));
341    }
342
343    #[test]
344    fn generic_bearer_near_miss() {
345        // Token too short (< 20 chars).
346        let text = format!("Bearer {}", "a".repeat(10));
347        let result = scrub_secrets(&text, &cfg());
348        assert_eq!(result, text);
349    }
350
351    // -- multi-secret and idempotency -----------------------------------------
352
353    #[test]
354    fn multi_secret_string() {
355        let aws = "AKIAABCDEFGHIJKLMNOP";
356        let gh_token = format!("ghp_{}", "a".repeat(36));
357        let text = format!("aws={aws} github={gh_token} done");
358        let result = scrub_secrets(&text, &cfg());
359        assert!(result.contains("[REDACTED:aws-key]"));
360        assert!(result.contains("[REDACTED:github-token]"));
361        assert!(!result.contains(aws));
362        assert!(!result.contains(&gh_token));
363    }
364
365    #[test]
366    fn idempotent() {
367        let aws = "AKIAABCDEFGHIJKLMNOP";
368        let gh_token = format!("ghp_{}", "a".repeat(36));
369        let text = format!("aws={aws} github={gh_token} bearer={}", "x".repeat(25));
370
371        let once = scrub_secrets(&text, &cfg()).into_owned();
372        let twice = scrub_secrets(&once, &cfg()).into_owned();
373        assert_eq!(once, twice);
374    }
375}