shared_logging/
redaction.rs

1//! Redaction helpers for PII, secrets, and tokens.
2
3use regex::Regex;
4use std::sync::LazyLock;
5
6/// Redaction marker for sensitive data.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Redaction {
9    /// Redact completely (show only [REDACTED])
10    Full,
11    /// Show partial data (e.g., last 4 digits)
12    Partial,
13    /// Hash the value
14    Hash,
15}
16
17impl Redaction {
18    /// Apply redaction to a value.
19    pub fn apply(&self, value: &str) -> String {
20        match self {
21            Redaction::Full => "[REDACTED]".to_string(),
22            Redaction::Partial => {
23                if value.len() <= 4 {
24                    "[REDACTED]".to_string()
25                } else {
26                    format!("****{}", &value[value.len().saturating_sub(4)..])
27                }
28            }
29            Redaction::Hash => {
30                use std::collections::hash_map::DefaultHasher;
31                use std::hash::{Hash, Hasher};
32                let mut hasher = DefaultHasher::new();
33                value.hash(&mut hasher);
34                format!("hash:{}", hasher.finish())
35            }
36        }
37    }
38}
39
40/// Redactor for detecting and redacting sensitive data.
41pub struct Redactor {
42    /// Patterns for detecting PII
43    pii_patterns: Vec<Regex>,
44    /// Patterns for detecting secrets/tokens
45    secret_patterns: Vec<Regex>,
46}
47
48impl Redactor {
49    /// Create a new redactor with default patterns.
50    pub fn new() -> Self {
51        Self {
52            pii_patterns: Self::default_pii_patterns(),
53            secret_patterns: Self::default_secret_patterns(),
54        }
55    }
56
57    /// Create a redactor with custom patterns.
58    pub fn with_patterns(
59        pii_patterns: Vec<Regex>,
60        secret_patterns: Vec<Regex>,
61    ) -> Self {
62        Self {
63            pii_patterns,
64            secret_patterns,
65        }
66    }
67
68    /// Default PII detection patterns.
69    fn default_pii_patterns() -> Vec<Regex> {
70        vec![
71            // Email addresses
72            Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap(),
73            // Credit card numbers (basic pattern)
74            Regex::new(r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b").unwrap(),
75            // SSN (US)
76            Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap(),
77            // Phone numbers (US format)
78            Regex::new(r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b").unwrap(),
79        ]
80    }
81
82    /// Default secret/token detection patterns.
83    fn default_secret_patterns() -> Vec<Regex> {
84        vec![
85            // API keys (common patterns)
86            Regex::new(r"\b[A-Za-z0-9]{32,}\b").unwrap(),
87            // Bearer tokens
88            Regex::new(r"(?i)bearer\s+[A-Za-z0-9\-._~+/]+=*").unwrap(),
89            // JWT tokens
90            Regex::new(r"\beyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b").unwrap(),
91            // AWS access keys
92            Regex::new(r"\bAKIA[0-9A-Z]{16}\b").unwrap(),
93            // Private keys (basic detection)
94            Regex::new(r"-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----").unwrap(),
95        ]
96    }
97
98    /// Check if a value contains PII.
99    pub fn contains_pii(&self, value: &str) -> bool {
100        self.pii_patterns.iter().any(|pattern| pattern.is_match(value))
101    }
102
103    /// Check if a value contains secrets/tokens.
104    pub fn contains_secret(&self, value: &str) -> bool {
105        self.secret_patterns.iter().any(|pattern| pattern.is_match(value))
106    }
107
108    /// Redact sensitive data in a string.
109    pub fn redact(&self, value: &str, redaction: Redaction) -> String {
110        let mut result = value.to_string();
111
112        // Redact secrets first (most sensitive)
113        for pattern in &self.secret_patterns {
114            result = pattern
115                .replace_all(&result, |caps: &regex::Captures<'_>| {
116                    redaction.apply(&caps[0])
117                })
118                .to_string();
119        }
120
121        // Redact PII
122        for pattern in &self.pii_patterns {
123            result = pattern
124                .replace_all(&result, |caps: &regex::Captures<'_>| {
125                    redaction.apply(&caps[0])
126                })
127                .to_string();
128        }
129
130        result
131    }
132
133    /// Redact a field value based on its name.
134    pub fn redact_field(&self, field_name: &str, value: &str) -> String {
135        // Check field name for common sensitive field names
136        let lower_name = field_name.to_lowercase();
137        if lower_name.contains("password")
138            || lower_name.contains("secret")
139            || lower_name.contains("token")
140            || lower_name.contains("key")
141            || lower_name.contains("api_key")
142            || lower_name.contains("access_token")
143            || lower_name.contains("refresh_token")
144        {
145            return Redaction::Full.apply(value);
146        }
147
148        // Check if value contains secrets
149        if self.contains_secret(value) {
150            return Redaction::Full.apply(value);
151        }
152
153        // Check if value contains PII
154        if self.contains_pii(value) {
155            return Redaction::Partial.apply(value);
156        }
157
158        value.to_string()
159    }
160}
161
162impl Default for Redactor {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168/// Global redactor instance.
169static GLOBAL_REDACTOR: LazyLock<Redactor> = LazyLock::new(|| Redactor::new());
170
171/// Redact a value using the global redactor.
172pub fn redact(value: &str, redaction: Redaction) -> String {
173    GLOBAL_REDACTOR.redact(value, redaction)
174}
175
176/// Redact a field value using the global redactor.
177pub fn redact_field(field_name: &str, value: &str) -> String {
178    GLOBAL_REDACTOR.redact_field(field_name, value)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_redaction_full() {
187        assert_eq!(Redaction::Full.apply("secret123"), "[REDACTED]");
188    }
189
190    #[test]
191    fn test_redaction_partial() {
192        let result = Redaction::Partial.apply("1234567890");
193        assert!(result.ends_with("7890"));
194        assert!(result.starts_with("****"));
195    }
196
197    #[test]
198    fn test_redactor_email() {
199        let redactor = Redactor::new();
200        assert!(redactor.contains_pii("test@example.com"));
201        let redacted = redactor.redact("Contact: test@example.com", Redaction::Partial);
202        assert!(redacted.contains("****"));
203    }
204
205    #[test]
206    fn test_redactor_secret() {
207        let redactor = Redactor::new();
208        assert!(redactor.contains_secret("Bearer abc123token456"));
209        let redacted = redactor.redact("Authorization: Bearer abc123token456", Redaction::Full);
210        assert_eq!(redacted, "Authorization: [REDACTED]");
211    }
212
213    #[test]
214    fn test_redact_field() {
215        let redactor = Redactor::new();
216        assert_eq!(
217            redactor.redact_field("password", "secret123"),
218            "[REDACTED]"
219        );
220        assert_eq!(
221            redactor.redact_field("email", "test@example.com"),
222            "****.com"
223        );
224    }
225}
226