dwara_core/ai/
redaction.rs

1//! PII redaction (DW-081): scrubs PII/secrets from prompt and response
2//! text before it reaches the log store.
3//!
4//! A [`Redactor`] is compiled from a [`crate::config::ai::RedactionConfig`]
5//! and applies a set of regex patterns in a single pass over the text.
6//! Built-in patterns (always active when redaction is on) cover the
7//! common PII/secrets surfaces: email addresses, phone numbers, API
8//! keys, and credit card numbers. Custom patterns from the config are
9//! added to the built-in set.
10//!
11//! # Dependency direction
12//!
13//! `ai` depends on `config` only (see `scripts/check_deps.py`); this
14//! module reads `config::ai::RedactionConfig` and nothing else. The
15//! `regex` crate is a workspace dependency (no new addition).
16
17use crate::config::ai::RedactionConfig;
18use regex::RegexSet;
19use serde_json::Value;
20
21/// The built-in PII/secrets patterns (always active when redaction is
22/// on). Ordered roughly by specificity (longer/more-structured
23/// patterns first to avoid sub-matches shadowing them).
24const BUILTIN_PATTERNS: &[&str] = &[
25    // Email addresses.
26    r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
27    // API keys (common formats): sk-/pk-/AKIA/ghp/gho/xox* prefixes
28    // followed by 20+ alphanumeric chars, and Bearer tokens.
29    r"(?:sk|pk|sk-|pk-|AKIA|ghp|gho|xox[baprs])-[a-zA-Z0-9]{20,}",
30    r"Bearer [a-zA-Z0-9._-]+",
31    // Credit card numbers: 4 groups of 4 digits, optionally separated
32    // by dashes or spaces.
33    r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b",
34    // Phone numbers (US and international). Conservative to avoid
35    // false positives: requires a leading + or at least 7 digits with
36    // separators.
37    r"\+?\d{1,3}[-.\s]?\(?\d{1,4}\)?[-.\s]?\d{1,4}[-.\s]?\d{1,9}",
38];
39
40/// The built-in PII/secrets pattern strings (public for reuse by the
41/// DW-082 guardrails engine's PII detection). Returns the same set
42/// the [`Redactor`] always active when redaction is on.
43pub fn builtin_pii_patterns() -> &'static [&'static str] {
44    BUILTIN_PATTERNS
45}
46
47/// A compiled PII redactor (DW-081). Built from a
48/// [`RedactionConfig`]; applies all patterns (built-in + custom) in a
49/// single pass over the text, replacing matches with the configured
50/// replacement string.
51#[derive(Debug, Clone)]
52pub struct Redactor {
53    set: RegexSet,
54    replacement: String,
55}
56
57impl Redactor {
58    /// Compile a redactor from the config. Built-in patterns are
59    /// always included; custom patterns are appended. The replacement
60    /// string defaults to `"[REDACTED]"` when empty.
61    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        // The config is validated at publish time (invalid regexes
70        // fail validation), so a compile failure here is a
71        // validate-vs-build race. Fail safe: an empty set redacts
72        // nothing (the prompt is still stored, but without redaction
73        // — logged loudly so an operator notices).
74        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                    // An empty pattern list always compiles; this is
85                    // truly unreachable.
86                    RegexSet::new([r"a^"]).unwrap()
87                })
88            }
89        };
90        Redactor { set, replacement }
91    }
92
93    /// A redactor that scrubs nothing (the default when logging is
94    /// off or no redaction config is present).
95    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    /// Redact all PII/secrets matches in `text`, replacing them with
104    /// the configured replacement string. Returns the scrubbed text.
105    pub fn redact(&self, text: &str) -> String {
106        if self.set.is_empty() {
107            return text.to_string();
108        }
109        // RegexSet gives match booleans per pattern; walk the text and
110        // replace the first matching pattern at each position. A
111        // single combined pass avoids double-redaction artifacts (a
112        // later pattern re-matching the replacement string).
113        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            // Find the earliest match across all patterns starting at
118            // or after `cursor`.
119            let mut earliest: Option<(usize, usize)> = None;
120            for pat_idx in self.set.matches(&text[cursor..]).iter() {
121                // Re-run the individual pattern to get the span. The
122                // RegexSet only reports WHICH patterns matched
123                // somewhere in the text, not where; so we compile a
124                // single-pattern regex on demand. This is acceptable
125                // because redaction runs off the request path (the
126                // capture hook is fire-and-forget).
127                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    /// Recursively redact all string values in a JSON tree. Numbers,
153    /// booleans, null, and structure are preserved verbatim; only
154    /// string leaves are scrubbed.
155    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}