chio_guards/response_sanitization/
simple.rs1use regex::Regex;
2
3use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError};
4
5use super::detectors::compile_required_pattern;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SensitivityLevel {
14 Low,
16 Medium,
18 High,
20}
21
22#[derive(Debug, Clone)]
24pub struct SensitivePattern {
25 pub name: String,
27 regex: Regex,
29 pub level: SensitivityLevel,
31 pub redaction: String,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum SanitizationAction {
38 Block,
40 Redact,
42}
43
44pub(super) fn default_patterns() -> Vec<SensitivePattern> {
45 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 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
122pub 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
251pub 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}