subcog/security/
redactor.rs1use super::audit::global_logger;
6use super::{PiiDetector, SecretDetector};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum RedactionMode {
11 #[default]
13 Mask,
14 TypedMask,
16 Asterisks,
18 Remove,
20}
21
22#[derive(Debug, Clone)]
24pub struct RedactionConfig {
25 pub mode: RedactionMode,
27 pub redact_secrets: bool,
29 pub redact_pii: bool,
31 pub placeholder: String,
33}
34
35impl Default for RedactionConfig {
36 fn default() -> Self {
37 Self {
38 mode: RedactionMode::Mask,
39 redact_secrets: true,
40 redact_pii: false,
41 placeholder: "[REDACTED]".to_string(),
42 }
43 }
44}
45
46impl RedactionConfig {
47 #[must_use]
49 pub fn new() -> Self {
50 Self::default()
51 }
52
53 #[must_use]
55 pub const fn with_mode(mut self, mode: RedactionMode) -> Self {
56 self.mode = mode;
57 self
58 }
59
60 #[must_use]
62 pub const fn with_pii(mut self) -> Self {
63 self.redact_pii = true;
64 self
65 }
66
67 #[must_use]
69 pub const fn without_secrets(mut self) -> Self {
70 self.redact_secrets = false;
71 self
72 }
73
74 #[must_use]
76 pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
77 self.placeholder = placeholder.into();
78 self
79 }
80}
81
82pub struct ContentRedactor {
84 secret_detector: SecretDetector,
85 pii_detector: PiiDetector,
86 config: RedactionConfig,
87}
88
89impl ContentRedactor {
90 #[must_use]
92 pub fn new() -> Self {
93 Self {
94 secret_detector: SecretDetector::new(),
95 pii_detector: PiiDetector::new(),
96 config: RedactionConfig::default(),
97 }
98 }
99
100 #[must_use]
102 pub const fn with_config(config: RedactionConfig) -> Self {
103 Self {
104 secret_detector: SecretDetector::new(),
105 pii_detector: PiiDetector::new(),
106 config,
107 }
108 }
109
110 #[must_use]
112 pub const fn config(&self) -> &RedactionConfig {
113 &self.config
114 }
115
116 #[must_use]
118 pub fn redact(&self, content: &str) -> String {
119 let mut ranges: Vec<(usize, usize, String)> = Vec::new();
121
122 if self.config.redact_secrets {
123 for m in self.secret_detector.detect(content) {
124 let replacement = self.get_replacement(&m.secret_type, m.end - m.start);
125 ranges.push((m.start, m.end, replacement));
126 }
127 }
128
129 if self.config.redact_pii {
130 let pii_matches = self.pii_detector.detect(content);
131
132 self.log_pii_detection_if_any(&pii_matches);
134
135 for m in pii_matches {
136 let replacement = self.get_replacement(&m.pii_type, m.end - m.start);
137 ranges.push((m.start, m.end, replacement));
138 }
139 }
140
141 ranges.sort_by(|a, b| b.0.cmp(&a.0));
143
144 let mut filtered: Vec<(usize, usize, String)> = Vec::new();
146 for range in ranges {
147 if filtered.iter().all(|f| range.1 <= f.0 || range.0 >= f.1) {
148 filtered.push(range);
149 }
150 }
151
152 let mut result = content.to_string();
154 for (start, end, replacement) in filtered {
155 result.replace_range(start..end, &replacement);
156 }
157
158 result
159 }
160
161 #[must_use]
163 pub fn redact_with_flag(&self, content: &str) -> (String, bool) {
164 let redacted = self.redact(content);
165 let was_redacted = redacted != content;
166 (redacted, was_redacted)
167 }
168
169 #[must_use]
171 pub fn needs_redaction(&self, content: &str) -> bool {
172 if self.config.redact_secrets && self.secret_detector.contains_secrets(content) {
173 return true;
174 }
175 if self.config.redact_pii && self.pii_detector.contains_pii(content) {
176 return true;
177 }
178 false
179 }
180
181 #[must_use]
183 pub fn detected_types(&self, content: &str) -> Vec<String> {
184 let mut types = Vec::new();
185
186 if self.config.redact_secrets {
187 types.extend(self.secret_detector.detect_types(content));
188 }
189
190 if self.config.redact_pii {
191 types.extend(self.pii_detector.detect_types(content));
192 }
193
194 types
195 }
196
197 fn log_pii_detection_if_any(&self, pii_matches: &[super::PiiMatch]) {
201 if !pii_matches.is_empty()
202 && let Some(logger) = global_logger()
203 {
204 let pii_types: Vec<&str> = pii_matches.iter().map(|m| m.pii_type.as_str()).collect();
205 let mut entry = super::audit::AuditEntry::new("security", "pii_detected");
206 entry.metadata = serde_json::json!({
207 "pii_count": pii_matches.len(),
208 "pii_types": pii_types,
209 });
210 logger.log_entry(entry);
211 }
212 }
213
214 fn get_replacement(&self, type_name: &str, length: usize) -> String {
216 match self.config.mode {
217 RedactionMode::Mask => self.config.placeholder.clone(),
218 RedactionMode::TypedMask => {
219 format!("[REDACTED:{}]", type_name.to_uppercase().replace(' ', "_"))
220 },
221 RedactionMode::Asterisks => "*".repeat(length),
222 RedactionMode::Remove => String::new(),
223 }
224 }
225}
226
227impl Default for ContentRedactor {
228 fn default() -> Self {
229 Self::new()
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn test_redact_aws_key() {
239 let redactor = ContentRedactor::new();
240 let content = "AWS_KEY=AKIAIOSFODNN7EXAMPLE";
241 let redacted = redactor.redact(content);
242
243 assert!(!redacted.contains("AKIAIOSFODNN7EXAMPLE"));
244 assert!(redacted.contains("[REDACTED]"));
245 }
246
247 #[test]
248 fn test_redact_multiple_secrets() {
249 let redactor = ContentRedactor::new();
250 let content = "AKIAIOSFODNN7EXAMPLE and ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
251 let redacted = redactor.redact(content);
252
253 assert!(!redacted.contains("AKIA"));
254 assert!(!redacted.contains("ghp_"));
255 assert!(redacted.contains("[REDACTED]"));
256 }
257
258 #[test]
259 fn test_typed_mask_mode() {
260 let config = RedactionConfig::new().with_mode(RedactionMode::TypedMask);
261 let redactor = ContentRedactor::with_config(config);
262 let content = "Key: AKIAIOSFODNN7EXAMPLE";
263 let redacted = redactor.redact(content);
264
265 assert!(redacted.contains("[REDACTED:AWS_ACCESS_KEY_ID]"));
266 }
267
268 #[test]
269 fn test_asterisks_mode() {
270 let config = RedactionConfig::new().with_mode(RedactionMode::Asterisks);
271 let redactor = ContentRedactor::with_config(config);
272 let content = "Key: AKIAIOSFODNN7EXAMPLE";
273 let redacted = redactor.redact(content);
274
275 assert!(redacted.contains("****"));
277 assert!(!redacted.contains("AKIA"));
278 }
279
280 #[test]
281 fn test_remove_mode() {
282 let config = RedactionConfig::new().with_mode(RedactionMode::Remove);
283 let redactor = ContentRedactor::with_config(config);
284 let content = "Key: AKIAIOSFODNN7EXAMPLE here";
285 let redacted = redactor.redact(content);
286
287 assert!(!redacted.contains("AKIA"));
288 assert!(redacted.contains("Key: here"));
289 }
290
291 #[test]
292 fn test_redact_pii() {
293 let config = RedactionConfig::new().with_pii();
294 let redactor = ContentRedactor::with_config(config);
295 let content = "Email: test@example.com";
296 let redacted = redactor.redact(content);
297
298 assert!(!redacted.contains("test@example.com"));
299 assert!(redacted.contains("[REDACTED]"));
300 }
301
302 #[test]
303 fn test_no_redaction_needed() {
304 let redactor = ContentRedactor::new();
305 let content = "Just regular text";
306 let redacted = redactor.redact(content);
307
308 assert_eq!(redacted, content);
309 }
310
311 #[test]
312 fn test_redact_with_flag() {
313 let redactor = ContentRedactor::new();
314
315 let (redacted, was_redacted) = redactor.redact_with_flag("AKIAIOSFODNN7EXAMPLE");
316 assert!(was_redacted);
317 assert!(redacted.contains("[REDACTED]"));
318
319 let (redacted, was_redacted) = redactor.redact_with_flag("Just text");
320 assert!(!was_redacted);
321 assert_eq!(redacted, "Just text");
322 }
323
324 #[test]
325 fn test_needs_redaction() {
326 let redactor = ContentRedactor::new();
327
328 assert!(redactor.needs_redaction("AKIAIOSFODNN7EXAMPLE"));
329 assert!(!redactor.needs_redaction("Just text"));
330 }
331
332 #[test]
333 fn test_detected_types() {
334 let config = RedactionConfig::new().with_pii();
335 let redactor = ContentRedactor::with_config(config);
336 let content = "AKIAIOSFODNN7EXAMPLE and test@example.com";
337 let types = redactor.detected_types(content);
338
339 assert!(types.contains(&"AWS Access Key ID".to_string()));
340 assert!(types.contains(&"Email Address".to_string()));
341 }
342
343 #[test]
344 fn test_custom_placeholder() {
345 let config = RedactionConfig::new().with_placeholder("***HIDDEN***");
346 let redactor = ContentRedactor::with_config(config);
347 let content = "Key: AKIAIOSFODNN7EXAMPLE";
348 let redacted = redactor.redact(content);
349
350 assert!(redacted.contains("***HIDDEN***"));
351 }
352
353 #[test]
354 fn test_pii_only() {
355 let config = RedactionConfig::new().without_secrets().with_pii();
356 let redactor = ContentRedactor::with_config(config);
357 let content = "AKIAIOSFODNN7EXAMPLE and test@example.com";
358 let redacted = redactor.redact(content);
359
360 assert!(redacted.contains("AKIAIOSFODNN7EXAMPLE"));
362 assert!(!redacted.contains("test@example.com"));
363 }
364}