//! The redactor — applies the policy to free text and to structured //! diagnostic data. use std::collections::HashSet; use std::sync::{Arc, RwLock}; use crate::policy::{RedactionPolicy, MAX_PROTOCOL_STRING_LEN}; use crate::REDACTION_MARKER; /// Runtime registry of *literal* known secrets. The host application /// (e.g. the secure-storage layer) registers a secret here when it /// loads one into memory, so even if it slips into a log line verbatim /// it gets scrubbed. /// /// This is the strongest defense for SS-AUD-003 and REDACT-TC-002 — /// regexes can miss; literal substring matches cannot. #[derive(Debug, Clone, Default)] pub struct KnownSecretRegistry { inner: Arc>>, } impl KnownSecretRegistry { pub fn new() -> Self { Self::default() } /// Register a literal secret string. Empty strings are ignored /// (to avoid degenerate `s.replace("", _)` behaviour). pub fn register(&self, secret: &str) { if secret.is_empty() { return; } self.inner.write().unwrap().insert(secret.to_string()); } pub fn forget(&self, secret: &str) { self.inner.write().unwrap().remove(secret); } pub fn snapshot(&self) -> Vec { self.inner.read().unwrap().iter().cloned().collect() } } pub struct Redactor { policy: RedactionPolicy, known: KnownSecretRegistry, } impl Redactor { pub fn new(policy: RedactionPolicy, known: KnownSecretRegistry) -> Self { Self { policy, known } } pub fn with_default_policy() -> Self { Self::new(RedactionPolicy::default_policy(), KnownSecretRegistry::new()) } pub fn known_secrets(&self) -> &KnownSecretRegistry { &self.known } pub fn policy(&self) -> &RedactionPolicy { &self.policy } /// Redact `input` according to the policy + known secret registry. /// /// Order: /// 1. Known literal secrets (defence in depth — never miss a value). /// 2. Regex rules (with optional capture-group narrowing). /// 3. Length cap for hostile / oversized strings (REDACT-TC-009). pub fn redact_text(&self, input: &str) -> String { // 1. Literal known secrets. let mut text = input.to_string(); for known in self.known.snapshot() { if !known.is_empty() { text = text.replace(&known, REDACTION_MARKER); } } // 2. Regex rules. for rule in &self.policy.rules { text = match rule.redact_group { None => rule .pattern .replace_all(&text, REDACTION_MARKER) .into_owned(), Some(group) => rule .pattern .replace_all(&text, |caps: ®ex::Captures<'_>| { let full = caps.get(0).map(|m| m.as_str()).unwrap_or(""); match caps.get(group) { Some(target) => full.replace(target.as_str(), REDACTION_MARKER), None => full.to_string(), } }) .into_owned(), }; } // 3. Length cap. if text.len() > MAX_PROTOCOL_STRING_LEN { // Find the last char boundary inside the budget so we // never split a UTF-8 codepoint. let mut cut = MAX_PROTOCOL_STRING_LEN; while cut > 0 && !text.is_char_boundary(cut) { cut -= 1; } let mut truncated = text[..cut].to_string(); truncated.push_str("…[truncated]"); text = truncated; } text } /// True if the policy classifies `field_name` as a sensitive /// structured field that must be redacted wholesale. pub fn is_sensitive_field(&self, field_name: &str) -> bool { let lower = field_name.to_lowercase(); self.policy .redact_field_substrings .iter() .any(|needle| lower.contains(needle)) } }