Skip to main content

subcog/security/
redactor.rs

1//! Content redaction.
2//!
3//! Redacts sensitive content (secrets and PII) from text.
4
5use super::audit::global_logger;
6use super::{PiiDetector, SecretDetector};
7
8/// Redaction mode.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum RedactionMode {
11    /// Replace with `[REDACTED]`.
12    #[default]
13    Mask,
14    /// Replace with type-specific placeholder (e.g., `SECRET:AWS_KEY`).
15    TypedMask,
16    /// Replace with asterisks of same length.
17    Asterisks,
18    /// Remove entirely.
19    Remove,
20}
21
22/// Configuration for content redaction.
23#[derive(Debug, Clone)]
24pub struct RedactionConfig {
25    /// Redaction mode.
26    pub mode: RedactionMode,
27    /// Redact secrets.
28    pub redact_secrets: bool,
29    /// Redact PII.
30    pub redact_pii: bool,
31    /// Placeholder for redacted content.
32    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    /// Creates a new redaction config.
48    #[must_use]
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Sets the redaction mode.
54    #[must_use]
55    pub const fn with_mode(mut self, mode: RedactionMode) -> Self {
56        self.mode = mode;
57        self
58    }
59
60    /// Enables PII redaction.
61    #[must_use]
62    pub const fn with_pii(mut self) -> Self {
63        self.redact_pii = true;
64        self
65    }
66
67    /// Disables secret redaction.
68    #[must_use]
69    pub const fn without_secrets(mut self) -> Self {
70        self.redact_secrets = false;
71        self
72    }
73
74    /// Sets a custom placeholder.
75    #[must_use]
76    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
77        self.placeholder = placeholder.into();
78        self
79    }
80}
81
82/// Redacts sensitive content from text.
83pub struct ContentRedactor {
84    secret_detector: SecretDetector,
85    pii_detector: PiiDetector,
86    config: RedactionConfig,
87}
88
89impl ContentRedactor {
90    /// Creates a new content redactor with default config.
91    #[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    /// Creates a new content redactor with custom config.
101    #[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    /// Returns the configuration.
111    #[must_use]
112    pub const fn config(&self) -> &RedactionConfig {
113        &self.config
114    }
115
116    /// Redacts sensitive content, returning the redacted text.
117    #[must_use]
118    pub fn redact(&self, content: &str) -> String {
119        // Collect all matches to redact
120        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            // Log PII detection for audit (GDPR/SOC2 compliance)
133            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        // Sort by start position (reverse order for replacement)
142        ranges.sort_by(|a, b| b.0.cmp(&a.0));
143
144        // Remove overlapping ranges (keep earliest)
145        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        // Apply replacements (in reverse order to preserve positions)
153        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    /// Returns the redacted content and a flag indicating if anything was redacted.
162    #[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    /// Checks if content needs redaction.
170    #[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    /// Returns the types of sensitive content found.
182    #[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    /// Logs PII detection events for audit compliance (GDPR/SOC2).
198    ///
199    /// Only logs when matches are found to avoid noise.
200    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    /// Gets the replacement string based on mode.
215    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        // The asterisks should be the same length as the matched text
276        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        // Secret should remain, PII should be redacted
361        assert!(redacted.contains("AKIAIOSFODNN7EXAMPLE"));
362        assert!(!redacted.contains("test@example.com"));
363    }
364}