Skip to main content

chio_guards/response_sanitization/
simple.rs

1use regex::Regex;
2
3use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError};
4
5use super::detectors::compile_required_pattern;
6
7// ===========================================================================
8// Backwards-compatible simple API.
9// ===========================================================================
10
11/// Classification level for a detected pattern.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SensitivityLevel {
14    /// Low sensitivity -- may produce false positives (e.g., phone numbers).
15    Low,
16    /// Medium sensitivity -- likely PII (e.g., email addresses).
17    Medium,
18    /// High sensitivity -- definite PII/PHI (e.g., SSN, medical record numbers).
19    High,
20}
21
22/// A named pattern that matches sensitive data.
23#[derive(Debug, Clone)]
24pub struct SensitivePattern {
25    /// Human-readable name for the pattern.
26    pub name: String,
27    /// The compiled regex.
28    regex: Regex,
29    /// Classification level.
30    pub level: SensitivityLevel,
31    /// Replacement string for redaction.
32    pub redaction: String,
33}
34
35/// Action to take when sensitive data is detected by the simple guard.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum SanitizationAction {
38    /// Block the response entirely.
39    Block,
40    /// Redact the matching patterns and allow the response.
41    Redact,
42}
43
44pub(super) fn default_patterns() -> Vec<SensitivePattern> {
45    // (regex, name, level, redaction) for each built-in PII/PHI detector.
46    let specs: [(&str, &str, SensitivityLevel, &str); 7] = [
47        (
48            r"\b\d{3}-\d{2}-\d{4}\b",
49            "SSN",
50            SensitivityLevel::High,
51            "[SSN REDACTED]",
52        ),
53        (
54            r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
55            "email",
56            SensitivityLevel::Medium,
57            "[EMAIL REDACTED]",
58        ),
59        (
60            r"\b(?:\(\d{3}\)\s*|\d{3}[-.])\d{3}[-.]?\d{4}\b",
61            "phone",
62            SensitivityLevel::Low,
63            "[PHONE REDACTED]",
64        ),
65        (
66            r"\b(?:\d{4}[-\s]?){3}\d{4}\b",
67            "credit-card",
68            SensitivityLevel::High,
69            "[CARD REDACTED]",
70        ),
71        (
72            r"\b(?:\d{2}/\d{2}/\d{4}|\d{4}-\d{2}-\d{2})\b",
73            "date-of-birth",
74            SensitivityLevel::Low,
75            "[DATE REDACTED]",
76        ),
77        (
78            r"\bMRN[:\s#]*\d{6,12}\b",
79            "MRN",
80            SensitivityLevel::High,
81            "[MRN REDACTED]",
82        ),
83        (
84            r"\b[A-Z]\d{2}(?:\.\d{1,4})?\b",
85            "ICD-10",
86            SensitivityLevel::Medium,
87            "[ICD REDACTED]",
88        ),
89    ];
90
91    let mut patterns = Vec::with_capacity(specs.len());
92    for (pattern, name, level, redaction) in specs {
93        match compile_required_pattern(pattern) {
94            Ok(regex) => patterns.push(SensitivePattern {
95                name: name.to_string(),
96                regex,
97                level,
98                redaction: redaction.to_string(),
99            }),
100            Err(_) => {
101                // Fail closed: a built-in constant pattern failed to compile.
102                // Shipping the reduced set would let that PII/PHI category
103                // through, so redact every response instead (the simple-guard
104                // analogue of the OutputSanitizer fail-closed path). The
105                // catch-all is itself a constant, so its fallback is
106                // unreachable in practice.
107                if let Ok(catch_all) = compile_required_pattern(r"[\s\S]+") {
108                    return vec![SensitivePattern {
109                        name: "redaction_unavailable_fail_closed".to_string(),
110                        regex: catch_all,
111                        level: SensitivityLevel::High,
112                        redaction: "[REDACTED]".to_string(),
113                    }];
114                }
115                return patterns;
116            }
117        }
118    }
119    patterns
120}
121
122/// Guard that scans responses for PII/PHI patterns and redacts or blocks them.
123pub struct ResponseSanitizationGuard {
124    patterns: Vec<SensitivePattern>,
125    min_level: SensitivityLevel,
126    action: SanitizationAction,
127}
128
129impl ResponseSanitizationGuard {
130    pub fn new(min_level: SensitivityLevel, action: SanitizationAction) -> Self {
131        Self {
132            patterns: default_patterns(),
133            min_level,
134            action,
135        }
136    }
137
138    pub fn with_patterns(
139        patterns: Vec<SensitivePattern>,
140        min_level: SensitivityLevel,
141        action: SanitizationAction,
142    ) -> Self {
143        Self {
144            patterns,
145            min_level,
146            action,
147        }
148    }
149
150    pub fn with_additional_patterns(
151        additional_patterns: Vec<SensitivePattern>,
152        min_level: SensitivityLevel,
153        action: SanitizationAction,
154    ) -> Self {
155        let mut patterns = default_patterns();
156        patterns.extend(additional_patterns);
157        Self {
158            patterns,
159            min_level,
160            action,
161        }
162    }
163
164    pub fn scan(&self, text: &str) -> Vec<(String, String)> {
165        let mut findings = Vec::new();
166        for pattern in &self.patterns {
167            if level_ord(pattern.level) < level_ord(self.min_level) {
168                continue;
169            }
170            for m in pattern.regex.find_iter(text) {
171                findings.push((pattern.name.clone(), m.as_str().to_string()));
172            }
173        }
174        findings
175    }
176
177    pub fn redact(&self, text: &str) -> (String, usize) {
178        let mut result = text.to_string();
179        let mut count = 0usize;
180        for pattern in &self.patterns {
181            if level_ord(pattern.level) < level_ord(self.min_level) {
182                continue;
183            }
184            let match_count = pattern.regex.find_iter(&result).count();
185            if match_count > 0 {
186                result = pattern
187                    .regex
188                    .replace_all(&result, pattern.redaction.as_str())
189                    .to_string();
190                count = count.saturating_add(match_count);
191            }
192        }
193        (result, count)
194    }
195
196    pub fn scan_response(&self, response: &serde_json::Value) -> ScanResult {
197        let text = response.to_string();
198        let findings = self.scan(&text);
199        if findings.is_empty() {
200            return ScanResult::Clean;
201        }
202        match self.action {
203            SanitizationAction::Block => ScanResult::Blocked(findings),
204            SanitizationAction::Redact => {
205                let (redacted, count) = self.redact(&text);
206                ScanResult::Redacted {
207                    redacted_text: redacted,
208                    redaction_count: count,
209                    findings,
210                }
211            }
212        }
213    }
214}
215
216#[derive(Debug)]
217pub enum ScanResult {
218    Clean,
219    Blocked(Vec<(String, String)>),
220    Redacted {
221        redacted_text: String,
222        redaction_count: usize,
223        findings: Vec<(String, String)>,
224    },
225}
226
227fn level_ord(level: SensitivityLevel) -> u8 {
228    match level {
229        SensitivityLevel::Low => 0,
230        SensitivityLevel::Medium => 1,
231        SensitivityLevel::High => 2,
232    }
233}
234
235impl Guard for ResponseSanitizationGuard {
236    fn name(&self) -> &str {
237        "response-sanitization"
238    }
239
240    fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
241        let args_text = ctx.request.arguments.to_string();
242        let findings = self.scan(&args_text);
243        if findings.is_empty() {
244            Ok(GuardDecision::allow())
245        } else {
246            Ok(GuardDecision::deny(Vec::new()))
247        }
248    }
249}
250
251/// Build a `SensitivePattern` from components. Returns None if the regex is invalid.
252pub fn build_pattern(
253    name: &str,
254    regex_str: &str,
255    level: SensitivityLevel,
256    redaction: &str,
257) -> Option<SensitivePattern> {
258    Regex::new(regex_str).ok().map(|regex| SensitivePattern {
259        name: name.to_string(),
260        regex,
261        level,
262        redaction: redaction.to_string(),
263    })
264}