Skip to main content

ijima_server/
redaction.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Redaction filter for the personal → shared promotion boundary.
5//!
6//! Per `docs/discovery/memory-service-design.md` §2 and D9, this is the
7//! **one place** content filtering happens. Personal memories are stored
8//! verbatim ("store everything"); when an author promotes a memory to a
9//! shared namespace, the redactor scrubs secrets and PII first. Never at
10//! auto-capture.
11//!
12//! ## Rules (v0, regex-based)
13//!
14//! | Category | Pattern |
15//! |---|---|
16//! | `api_key` | OpenAI `sk-...`, AWS `AKIA...`, generic 40+ hex/token |
17//! | `bearer_token` | `Bearer <token>` |
18//! | `private_key` | PEM `-----BEGIN ... PRIVATE KEY-----` blocks |
19//! | `email` | RFC-ish email addresses |
20//! | `ipv4` | Dotted-quad addresses |
21//!
22//! Replaced with `[REDACTED:<category>]`. A richer detector (secret-
23//! scanning service, NER for PII) can swap in behind the same
24//! [`Redactor`] interface later.
25
26use serde::Serialize;
27
28/// One category of redaction that fired, with a count.
29#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
30pub struct Redaction {
31    /// The rule category that matched (e.g. `"api_key"`, `"email"`).
32    pub category: &'static str,
33    /// How many matches were replaced.
34    pub count: usize,
35}
36
37/// The outcome of redacting a text.
38#[derive(Debug, Clone)]
39pub struct RedactionResult {
40    /// The scrubbed text.
41    pub text: String,
42    /// Which categories fired and how many times.
43    pub redactions: Vec<Redaction>,
44}
45
46/// A rule-based content scrubber.
47pub struct Redactor {
48    rules: Vec<Rule>,
49}
50
51struct Rule {
52    category: &'static str,
53    pattern: regex::Regex,
54    replacement: String,
55}
56
57impl Default for Redactor {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl Redactor {
64    /// Constructs the standard ruleset.
65    #[allow(clippy::needless_pass_by_value)]
66    pub fn new() -> Self {
67        let rules = vec![
68            Rule {
69                category: "private_key",
70                pattern: regex::Regex::new(
71                    r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
72                )
73                .expect("private_key regex"),
74                replacement: "[REDACTED:private_key]".into(),
75            },
76            Rule {
77                category: "bearer_token",
78                pattern: regex::Regex::new(r"(?i)bearer [a-zA-Z0-9._\-]{20,}")
79                    .expect("bearer regex"),
80                replacement: "[REDACTED:bearer_token]".into(),
81            },
82            Rule {
83                category: "api_key",
84                pattern: regex::Regex::new(r"(sk-[a-zA-Z0-9]{20,}|AKIA[0-9A-Z]{16}|[a-f0-9]{40})")
85                    .expect("api_key regex"),
86                replacement: "[REDACTED:api_key]".into(),
87            },
88            Rule {
89                category: "email",
90                pattern: regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
91                    .expect("email regex"),
92                replacement: "[REDACTED:email]".into(),
93            },
94            Rule {
95                category: "ipv4",
96                pattern: regex::Regex::new(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")
97                    .expect("ipv4 regex"),
98                replacement: "[REDACTED:ipv4]".into(),
99            },
100        ];
101        Self { rules }
102    }
103
104    /// Scrubs `text`, returning the redacted result + a summary of what
105    /// was removed. Clean text passes through unchanged with an empty
106    /// redactions list.
107    pub fn redact(&self, text: &str) -> RedactionResult {
108        let mut scrubbed = text.to_string();
109        let mut redactions = Vec::new();
110        for rule in &self.rules {
111            let count = rule.pattern.find_iter(&scrubbed.clone()).count();
112            if count > 0 {
113                scrubbed = rule
114                    .pattern
115                    .replace_all(&scrubbed, &rule.replacement)
116                    .into_owned();
117                redactions.push(Redaction {
118                    category: rule.category,
119                    count,
120                });
121            }
122        }
123        RedactionResult {
124            text: scrubbed,
125            redactions,
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn clean_text_passes_through() {
136        let r = Redactor::new();
137        let result = r.redact("The cat sat on the mat");
138        assert_eq!(result.text, "The cat sat on the mat");
139        assert!(result.redactions.is_empty());
140    }
141
142    #[test]
143    fn email_is_redacted() {
144        let r = Redactor::new();
145        let result = r.redact("contact elliott@example.com for details");
146        assert!(result.text.contains("[REDACTED:email]"));
147        assert!(!result.text.contains("elliott@example.com"));
148        assert_eq!(
149            result.redactions,
150            vec![Redaction {
151                category: "email",
152                count: 1
153            }]
154        );
155    }
156
157    #[test]
158    fn openai_api_key_is_redacted() {
159        let r = Redactor::new();
160        let result = r.redact("key: sk-abcdefghijklmnopqrstuvwxyz1234567890");
161        assert!(result.text.contains("[REDACTED:api_key]"));
162        assert!(!result.text.contains("sk-abcdef"));
163    }
164
165    #[test]
166    fn aws_key_is_redacted() {
167        let r = Redactor::new();
168        let result = r.redact("creds: AKIAIOSFODNN7EXAMPLE");
169        assert!(result.text.contains("[REDACTED:api_key]"));
170    }
171
172    #[test]
173    fn hex_sha1_is_redacted() {
174        let r = Redactor::new();
175        let result = r.redact("commit: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2");
176        assert!(result.text.contains("[REDACTED:api_key]"));
177    }
178
179    #[test]
180    fn bearer_token_is_redacted() {
181        let r = Redactor::new();
182        let result = r.redact("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig");
183        assert!(result.text.contains("[REDACTED:bearer_token]"));
184        assert!(!result.text.contains("eyJhbG"));
185    }
186
187    #[test]
188    fn private_key_block_is_redacted() {
189        let r = Redactor::new();
190        let text =
191            "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----";
192        let result = r.redact(text);
193        assert!(result.text.contains("[REDACTED:private_key]"));
194        assert!(!result.text.contains("MIIEpA"));
195    }
196
197    #[test]
198    fn ipv4_is_redacted() {
199        let r = Redactor::new();
200        let result = r.redact("server at 10.0.0.5 is down");
201        assert!(result.text.contains("[REDACTED:ipv4]"));
202        assert!(!result.text.contains("10.0.0.5"));
203    }
204
205    #[test]
206    fn multiple_categories_in_one_text() {
207        let r = Redactor::new();
208        let text = "email alice@test.com and use key sk-abcdefghijklmnopqrstuvwxyz1234567890";
209        let result = r.redact(text);
210        let cats: Vec<&str> = result.redactions.iter().map(|x| x.category).collect();
211        assert!(cats.contains(&"email"));
212        assert!(cats.contains(&"api_key"));
213        assert!(!result.text.contains("alice@test.com"));
214        assert!(!result.text.contains("sk-abc"));
215    }
216
217    #[test]
218    fn version_numbers_are_not_redacted_as_api_keys() {
219        // 0.1.0 should not be mistaken for a 40-hex api key.
220        let r = Redactor::new();
221        let result = r.redact("version 0.1.0 released");
222        assert_eq!(result.text, "version 0.1.0 released");
223        // ipv4 may catch it, but that's acceptable for a version that
224        // looks like an IP — the point is no false api_key hit.
225        assert!(!result.redactions.iter().any(|x| x.category == "api_key"));
226    }
227}