1use regex::Regex;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
11pub enum RedactionPattern {
12 SSN,
14 CreditCard,
16 Email,
18 PhoneNumber,
20 IpAddress,
22 BankAccount,
24 ApiKey,
26 Password,
28 Custom(String),
30}
31
32#[derive(Debug, Clone)]
34pub struct RedactionConfig {
35 pub enabled_patterns: Vec<RedactionPattern>,
36 pub replacement_char: char,
37 pub preserve_format: bool,
38}
39
40impl Default for RedactionConfig {
41 fn default() -> Self {
42 Self {
43 enabled_patterns: vec![
44 RedactionPattern::SSN,
45 RedactionPattern::CreditCard,
46 RedactionPattern::Email,
47 RedactionPattern::PhoneNumber,
48 RedactionPattern::Password,
49 RedactionPattern::ApiKey,
50 ],
51 replacement_char: '*',
52 preserve_format: true,
53 }
54 }
55}
56
57struct CompiledPatterns {
59 ssn: Regex,
60 credit_card: Regex,
61 email: Regex,
62 phone: Regex,
63 ip_address: Regex,
64 bank_account: Regex,
65 api_key: Regex,
66 password: Regex,
67}
68
69impl CompiledPatterns {
70 fn new() -> Self {
71 Self {
72 ssn: Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap(),
73 credit_card: Regex::new(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b").unwrap(),
74 email: Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b").unwrap(),
75 phone: Regex::new(r"\b(\+?1?[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b").unwrap(),
76 ip_address: Regex::new(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b").unwrap(),
77 bank_account: Regex::new(r"\b\d{8,17}\b").unwrap(),
78 api_key: Regex::new(r"(?i)(api[_-]?key|token|secret|bearer)\s*[:=]\s*['\x22]?[A-Za-z0-9_-]{20,}['\x22]?").unwrap(),
79 password: Regex::new(r"(?i)(password|passwd|pwd)\s*[:=]\s*['\x22]?[^\s'\x22]+['\x22]?").unwrap(),
80 }
81 }
82}
83
84pub struct LogRedactor {
86 config: RedactionConfig,
87 patterns: CompiledPatterns,
88 custom_patterns: HashMap<String, Regex>,
89}
90
91impl Default for LogRedactor {
92 fn default() -> Self {
93 Self::new(RedactionConfig::default())
94 }
95}
96
97impl LogRedactor {
98 pub fn new(config: RedactionConfig) -> Self {
100 Self {
101 config,
102 patterns: CompiledPatterns::new(),
103 custom_patterns: HashMap::new(),
104 }
105 }
106
107 pub fn add_custom_pattern(&mut self, name: &str, pattern: &str) -> Result<(), regex::Error> {
109 let regex = Regex::new(pattern)?;
110 self.custom_patterns.insert(name.to_string(), regex);
111 self.config.enabled_patterns.push(RedactionPattern::Custom(name.to_string()));
112 Ok(())
113 }
114
115 pub fn redact(&self, input: &str) -> String {
117 let mut result = input.to_string();
118
119 for pattern_type in &self.config.enabled_patterns {
120 result = match pattern_type {
121 RedactionPattern::SSN => self.redact_pattern(&result, &self.patterns.ssn, "***-**-****"),
122 RedactionPattern::CreditCard => self.redact_credit_card(&result),
123 RedactionPattern::Email => self.redact_email(&result),
124 RedactionPattern::PhoneNumber => self.redact_pattern(&result, &self.patterns.phone, "***-***-****"),
125 RedactionPattern::IpAddress => self.redact_pattern(&result, &self.patterns.ip_address, "***.***.***.***"),
126 RedactionPattern::BankAccount => self.redact_pattern(&result, &self.patterns.bank_account, "********"),
127 RedactionPattern::ApiKey => self.redact_api_key(&result),
128 RedactionPattern::Password => self.redact_password(&result),
129 RedactionPattern::Custom(name) => {
130 if let Some(regex) = self.custom_patterns.get(name) {
131 self.redact_pattern(&result, regex, "[REDACTED]")
132 } else {
133 result
134 }
135 }
136 };
137 }
138
139 result
140 }
141
142 fn redact_pattern(&self, input: &str, pattern: &Regex, replacement: &str) -> String {
144 pattern.replace_all(input, replacement).to_string()
145 }
146
147 fn redact_credit_card(&self, input: &str) -> String {
149 self.patterns.credit_card.replace_all(input, |caps: ®ex::Captures| {
150 let matched = caps.get(0).unwrap().as_str();
151 let digits: String = matched.chars().filter(|c| c.is_ascii_digit()).collect();
152 if digits.len() >= 4 {
153 format!("****-****-****-{}", &digits[digits.len()-4..])
154 } else {
155 "****-****-****-****".to_string()
156 }
157 }).to_string()
158 }
159
160 fn redact_email(&self, input: &str) -> String {
162 self.patterns.email.replace_all(input, |caps: ®ex::Captures| {
163 let matched = caps.get(0).unwrap().as_str();
164 if let Some(at_pos) = matched.find('@') {
165 let domain = &matched[at_pos..];
166 format!("****{}", domain)
167 } else {
168 "****@****.***".to_string()
169 }
170 }).to_string()
171 }
172
173 fn redact_api_key(&self, input: &str) -> String {
175 self.patterns.api_key.replace_all(input, |caps: ®ex::Captures| {
176 let matched = caps.get(0).unwrap().as_str();
177 if let Some(eq_pos) = matched.find([':', '=']) {
178 let prefix = &matched[..=eq_pos];
179 format!("{} [REDACTED]", prefix.trim_end_matches([':', '=', ' ']))
180 } else {
181 "[REDACTED API KEY]".to_string()
182 }
183 }).to_string()
184 }
185
186 fn redact_password(&self, input: &str) -> String {
188 self.patterns.password.replace_all(input, |caps: ®ex::Captures| {
189 let matched = caps.get(0).unwrap().as_str();
190 if let Some(eq_pos) = matched.find([':', '=']) {
191 let prefix = &matched[..=eq_pos];
192 format!("{} [REDACTED]", prefix.trim_end_matches([':', '=', ' ']))
193 } else {
194 "[REDACTED PASSWORD]".to_string()
195 }
196 }).to_string()
197 }
198
199 pub fn contains_sensitive_data(&self, input: &str) -> bool {
201 for pattern_type in &self.config.enabled_patterns {
202 let has_match = match pattern_type {
203 RedactionPattern::SSN => self.patterns.ssn.is_match(input),
204 RedactionPattern::CreditCard => self.patterns.credit_card.is_match(input),
205 RedactionPattern::Email => self.patterns.email.is_match(input),
206 RedactionPattern::PhoneNumber => self.patterns.phone.is_match(input),
207 RedactionPattern::IpAddress => self.patterns.ip_address.is_match(input),
208 RedactionPattern::BankAccount => self.patterns.bank_account.is_match(input),
209 RedactionPattern::ApiKey => self.patterns.api_key.is_match(input),
210 RedactionPattern::Password => self.patterns.password.is_match(input),
211 RedactionPattern::Custom(name) => {
212 self.custom_patterns.get(name).map_or(false, |r| r.is_match(input))
213 }
214 };
215 if has_match {
216 return true;
217 }
218 }
219 false
220 }
221
222 pub fn detect_sensitive_types(&self, input: &str) -> Vec<RedactionPattern> {
224 let mut found = Vec::new();
225
226 for pattern_type in &self.config.enabled_patterns {
227 let has_match = match pattern_type {
228 RedactionPattern::SSN => self.patterns.ssn.is_match(input),
229 RedactionPattern::CreditCard => self.patterns.credit_card.is_match(input),
230 RedactionPattern::Email => self.patterns.email.is_match(input),
231 RedactionPattern::PhoneNumber => self.patterns.phone.is_match(input),
232 RedactionPattern::IpAddress => self.patterns.ip_address.is_match(input),
233 RedactionPattern::BankAccount => self.patterns.bank_account.is_match(input),
234 RedactionPattern::ApiKey => self.patterns.api_key.is_match(input),
235 RedactionPattern::Password => self.patterns.password.is_match(input),
236 RedactionPattern::Custom(name) => {
237 self.custom_patterns.get(name).map_or(false, |r| r.is_match(input))
238 }
239 };
240 if has_match {
241 found.push(pattern_type.clone());
242 }
243 }
244
245 found
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn test_ssn_redaction() {
255 let redactor = LogRedactor::default();
256 let input = "User SSN: 123-45-6789 was verified";
257 let output = redactor.redact(input);
258 assert!(output.contains("***-**-****"));
259 assert!(!output.contains("123-45-6789"));
260 }
261
262 #[test]
263 fn test_credit_card_redaction() {
264 let redactor = LogRedactor::default();
265 let input = "Card: 4111-1111-1111-1234 processed";
266 let output = redactor.redact(input);
267 assert!(output.contains("****-****-****-1234"));
268 assert!(!output.contains("4111-1111-1111"));
269 }
270
271 #[test]
272 fn test_email_redaction() {
273 let redactor = LogRedactor::default();
274 let input = "User email: john.doe@example.com logged in";
275 let output = redactor.redact(input);
276 assert!(output.contains("@example.com"));
277 assert!(!output.contains("john.doe"));
278 }
279
280 #[test]
281 fn test_password_redaction() {
282 let redactor = LogRedactor::default();
283 let input = "Login attempt with password=secretpass123";
284 let output = redactor.redact(input);
285 assert!(output.contains("[REDACTED]"));
286 assert!(!output.contains("secretpass123"));
287 }
288
289 #[test]
290 fn test_api_key_redaction() {
291 let redactor = LogRedactor::default();
292 let input = "Request with api_key: abcdef1234567890abcdef1234567890";
293 let output = redactor.redact(input);
294 assert!(output.contains("[REDACTED]"));
295 assert!(!output.contains("abcdef1234567890"));
296 }
297
298 #[test]
299 fn test_contains_sensitive_data() {
300 let redactor = LogRedactor::default();
301 assert!(redactor.contains_sensitive_data("SSN: 123-45-6789"));
302 assert!(!redactor.contains_sensitive_data("Normal log message"));
303 }
304
305 #[test]
306 fn test_detect_sensitive_types() {
307 let redactor = LogRedactor::default();
308 let input = "User john@example.com with SSN 123-45-6789";
309 let types = redactor.detect_sensitive_types(input);
310 assert!(types.contains(&RedactionPattern::Email));
311 assert!(types.contains(&RedactionPattern::SSN));
312 }
313
314 #[test]
315 fn test_multiple_redactions() {
316 let redactor = LogRedactor::default();
317 let input = "User 123-45-6789 email: test@example.com card: 4111111111111234";
318 let output = redactor.redact(input);
319 assert!(!output.contains("123-45-6789"));
320 assert!(!output.contains("test@example.com"));
321 assert!(output.contains("****-****-****-1234"));
322 }
323
324 #[test]
325 fn test_custom_pattern() {
326 let mut redactor = LogRedactor::default();
327 redactor.add_custom_pattern("employee_id", r"EMP-\d{6}").unwrap();
328 let input = "Employee EMP-123456 accessed system";
329 let output = redactor.redact(input);
330 assert!(output.contains("[REDACTED]"));
331 assert!(!output.contains("EMP-123456"));
332 }
333}