shared_logging/
redaction.rs1use regex::Regex;
4use std::sync::LazyLock;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Redaction {
9 Full,
11 Partial,
13 Hash,
15}
16
17impl Redaction {
18 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
40pub struct Redactor {
42 pii_patterns: Vec<Regex>,
44 secret_patterns: Vec<Regex>,
46}
47
48impl Redactor {
49 pub fn new() -> Self {
51 Self {
52 pii_patterns: Self::default_pii_patterns(),
53 secret_patterns: Self::default_secret_patterns(),
54 }
55 }
56
57 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 fn default_pii_patterns() -> Vec<Regex> {
70 vec![
71 Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap(),
73 Regex::new(r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b").unwrap(),
75 Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap(),
77 Regex::new(r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b").unwrap(),
79 ]
80 }
81
82 fn default_secret_patterns() -> Vec<Regex> {
84 vec![
85 Regex::new(r"\b[A-Za-z0-9]{32,}\b").unwrap(),
87 Regex::new(r"(?i)bearer\s+[A-Za-z0-9\-._~+/]+=*").unwrap(),
89 Regex::new(r"\beyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b").unwrap(),
91 Regex::new(r"\bAKIA[0-9A-Z]{16}\b").unwrap(),
93 Regex::new(r"-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----").unwrap(),
95 ]
96 }
97
98 pub fn contains_pii(&self, value: &str) -> bool {
100 self.pii_patterns.iter().any(|pattern| pattern.is_match(value))
101 }
102
103 pub fn contains_secret(&self, value: &str) -> bool {
105 self.secret_patterns.iter().any(|pattern| pattern.is_match(value))
106 }
107
108 pub fn redact(&self, value: &str, redaction: Redaction) -> String {
110 let mut result = value.to_string();
111
112 for pattern in &self.secret_patterns {
114 result = pattern
115 .replace_all(&result, |caps: ®ex::Captures<'_>| {
116 redaction.apply(&caps[0])
117 })
118 .to_string();
119 }
120
121 for pattern in &self.pii_patterns {
123 result = pattern
124 .replace_all(&result, |caps: ®ex::Captures<'_>| {
125 redaction.apply(&caps[0])
126 })
127 .to_string();
128 }
129
130 result
131 }
132
133 pub fn redact_field(&self, field_name: &str, value: &str) -> String {
135 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 if self.contains_secret(value) {
150 return Redaction::Full.apply(value);
151 }
152
153 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
168static GLOBAL_REDACTOR: LazyLock<Redactor> = LazyLock::new(|| Redactor::new());
170
171pub fn redact(value: &str, redaction: Redaction) -> String {
173 GLOBAL_REDACTOR.redact(value, redaction)
174}
175
176pub 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