rust_secure_logger/
redaction.rs

1//! Log redaction module for PII protection v2.0
2//!
3//! Provides automatic redaction of sensitive data in log entries.
4
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Redaction pattern types
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
11pub enum RedactionPattern {
12    /// Social Security Number (XXX-XX-XXXX)
13    SSN,
14    /// Credit Card Number (16 digits)
15    CreditCard,
16    /// Email Address
17    Email,
18    /// Phone Number
19    PhoneNumber,
20    /// IP Address
21    IpAddress,
22    /// Bank Account Number
23    BankAccount,
24    /// API Key / Token
25    ApiKey,
26    /// Password field
27    Password,
28    /// Custom pattern
29    Custom(String),
30}
31
32/// Redaction configuration
33#[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
57/// Compiled regex patterns for efficient redaction
58struct 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
84/// Log redactor for automatic PII protection
85pub 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    /// Create a new log redactor with custom configuration
99    pub fn new(config: RedactionConfig) -> Self {
100        Self {
101            config,
102            patterns: CompiledPatterns::new(),
103            custom_patterns: HashMap::new(),
104        }
105    }
106
107    /// Add a custom redaction pattern
108    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    /// Redact sensitive data from a string
116    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    /// Redact using a simple pattern replacement
143    fn redact_pattern(&self, input: &str, pattern: &Regex, replacement: &str) -> String {
144        pattern.replace_all(input, replacement).to_string()
145    }
146
147    /// Redact credit card numbers, preserving last 4 digits
148    fn redact_credit_card(&self, input: &str) -> String {
149        self.patterns.credit_card.replace_all(input, |caps: &regex::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    /// Redact email addresses, preserving domain
161    fn redact_email(&self, input: &str) -> String {
162        self.patterns.email.replace_all(input, |caps: &regex::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    /// Redact API keys and tokens
174    fn redact_api_key(&self, input: &str) -> String {
175        self.patterns.api_key.replace_all(input, |caps: &regex::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    /// Redact passwords
187    fn redact_password(&self, input: &str) -> String {
188        self.patterns.password.replace_all(input, |caps: &regex::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    /// Check if a string contains sensitive data
200    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    /// Get list of detected sensitive data types
223    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}