dwara_core/ai/
redaction.rs1use crate::config::ai::RedactionConfig;
18use regex::RegexSet;
19use serde_json::Value;
20
21const BUILTIN_PATTERNS: &[&str] = &[
25 r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
27 r"(?:sk|pk|sk-|pk-|AKIA|ghp|gho|xox[baprs])-[a-zA-Z0-9]{20,}",
30 r"Bearer [a-zA-Z0-9._-]+",
31 r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b",
34 r"\+?\d{1,3}[-.\s]?\(?\d{1,4}\)?[-.\s]?\d{1,4}[-.\s]?\d{1,9}",
38];
39
40pub fn builtin_pii_patterns() -> &'static [&'static str] {
44 BUILTIN_PATTERNS
45}
46
47#[derive(Debug, Clone)]
52pub struct Redactor {
53 set: RegexSet,
54 replacement: String,
55}
56
57impl Redactor {
58 pub fn compile(cfg: &RedactionConfig) -> Self {
62 let mut patterns: Vec<String> = BUILTIN_PATTERNS.iter().map(|s| s.to_string()).collect();
63 patterns.extend(cfg.patterns.iter().cloned());
64 let replacement = if cfg.replacement.is_empty() {
65 "[REDACTED]".to_string()
66 } else {
67 cfg.replacement.clone()
68 };
69 let set = match RegexSet::new(patterns.iter().map(|s| s.as_str())) {
75 Ok(s) => s,
76 Err(e) => {
77 tracing::error!(
78 code = "ai_redaction_compile_failed",
79 "redaction patterns failed to compile at runtime \
80 (validation should have caught this); redaction is \
81 INERT for this generation: {e}"
82 );
83 RegexSet::new::<[&str; 0], &str>([]).unwrap_or_else(|_| {
84 RegexSet::new([r"a^"]).unwrap()
87 })
88 }
89 };
90 Redactor { set, replacement }
91 }
92
93 pub fn inert() -> Self {
96 Redactor {
97 set: RegexSet::new::<[&str; 0], &str>([])
98 .unwrap_or_else(|_| RegexSet::new([r"a^"]).unwrap()),
99 replacement: "[REDACTED]".to_string(),
100 }
101 }
102
103 pub fn redact(&self, text: &str) -> String {
106 if self.set.is_empty() {
107 return text.to_string();
108 }
109 let mut out = String::with_capacity(text.len());
114 let mut cursor = 0usize;
115 let bytes = text.as_bytes();
116 while cursor < bytes.len() {
117 let mut earliest: Option<(usize, usize)> = None;
120 for pat_idx in self.set.matches(&text[cursor..]).iter() {
121 let pat = &self.set.patterns()[pat_idx];
128 if let Ok(re) = regex::Regex::new(pat) {
129 if let Some(m) = re.find_at(text, cursor) {
130 let (s, e) = (m.start(), m.end());
131 if earliest.is_none() || s < earliest.unwrap().0 {
132 earliest = Some((s, e));
133 }
134 }
135 }
136 }
137 match earliest {
138 Some((s, e)) => {
139 out.push_str(&text[cursor..s]);
140 out.push_str(&self.replacement);
141 cursor = e;
142 }
143 None => {
144 out.push_str(&text[cursor..]);
145 break;
146 }
147 }
148 }
149 out
150 }
151
152 pub fn redact_json(&self, json: &Value) -> Value {
156 match json {
157 Value::String(s) => Value::String(self.redact(s)),
158 Value::Array(arr) => Value::Array(arr.iter().map(|v| self.redact_json(v)).collect()),
159 Value::Object(map) => {
160 let redacted: serde_json::Map<String, Value> = map
161 .iter()
162 .map(|(k, v)| (k.clone(), self.redact_json(v)))
163 .collect();
164 Value::Object(redacted)
165 }
166 other => other.clone(),
167 }
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 fn default_redactor() -> Redactor {
176 Redactor::compile(&RedactionConfig::default())
177 }
178
179 #[test]
180 fn redacts_email() {
181 let r = default_redactor();
182 let out = r.redact("contact me at alice@example.com please");
183 assert!(!out.contains("alice@example.com"));
184 assert!(out.contains("[REDACTED]"));
185 }
186
187 #[test]
188 fn redacts_phone() {
189 let r = default_redactor();
190 let out = r.redact("call +1-555-123-4567 now");
191 assert!(!out.contains("+1-555-123-4567"));
192 assert!(out.contains("[REDACTED]"));
193 }
194
195 #[test]
196 fn redacts_api_key() {
197 let r = default_redactor();
198 let out = r.redact("key: sk-abcdefghijklmnopqrstuvwxyz1234");
199 assert!(!out.contains("sk-abcdefghijklmnopqrstuvwxyz1234"));
200 assert!(out.contains("[REDACTED]"));
201 }
202
203 #[test]
204 fn redacts_bearer_token() {
205 let r = default_redactor();
206 let out = r.redact("Authorization: Bearer eyJhbGciOiJIUzI1");
207 assert!(!out.contains("Bearer eyJhbGciOiJIUzI1"));
208 assert!(out.contains("[REDACTED]"));
209 }
210
211 #[test]
212 fn redacts_credit_card() {
213 let r = default_redactor();
214 let out = r.redact("card: 4111-1111-1111-1111");
215 assert!(!out.contains("4111-1111-1111-1111"));
216 assert!(out.contains("[REDACTED]"));
217 }
218
219 #[test]
220 fn custom_pattern_alongside_builtins() {
221 let cfg = RedactionConfig {
222 patterns: vec![r"SSN-\d{3}-\d{2}".to_string()],
223 replacement: "[REDACTED]".to_string(),
224 };
225 let r = Redactor::compile(&cfg);
226 let out = r.redact("email alice@example.com ssn SSN-123-45");
227 assert!(!out.contains("alice@example.com"));
228 assert!(!out.contains("SSN-123-45"));
229 assert!(out.contains("[REDACTED]"));
230 }
231
232 #[test]
233 fn redact_json_preserves_structure() {
234 let r = default_redactor();
235 let input = serde_json::json!({
236 "model": "gpt-4",
237 "messages": [
238 {"role": "user", "content": "email alice@example.com"}
239 ],
240 "count": 42,
241 "flag": true
242 });
243 let out = r.redact_json(&input);
244 assert_eq!(out["model"], "gpt-4");
245 assert_eq!(out["count"], 42);
246 assert_eq!(out["flag"], true);
247 assert!(out["messages"][0]["content"]
248 .as_str()
249 .unwrap()
250 .contains("[REDACTED]"));
251 assert!(!out["messages"][0]["content"]
252 .as_str()
253 .unwrap()
254 .contains("alice@example.com"));
255 }
256
257 #[test]
258 fn custom_replacement_string() {
259 let cfg = RedactionConfig {
260 patterns: vec![],
261 replacement: "<HIDDEN>".to_string(),
262 };
263 let r = Redactor::compile(&cfg);
264 let out = r.redact("email alice@example.com");
265 assert!(out.contains("<HIDDEN>"));
266 assert!(!out.contains("alice@example.com"));
267 }
268
269 #[test]
270 fn inert_redactor_scrubs_nothing() {
271 let r = Redactor::inert();
272 let out = r.redact("email alice@example.com");
273 assert_eq!(out, "email alice@example.com");
274 }
275}