1use regex::Regex;
7use serde_json::{Map, Value};
8use sha2::{Digest, Sha256};
9use std::sync::{Mutex, OnceLock};
10
11#[cfg(feature = "governance")]
12use crate::classification::{classify_key, get_classification_policy};
13#[cfg(feature = "governance")]
14use crate::receipts::emit_receipt;
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum PIIMode {
18 Drop,
19 Redact,
20 Hash,
21 Truncate,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct PIIRule {
26 pub path: Vec<String>,
27 pub mode: PIIMode,
28 pub truncate_to: usize,
29}
30
31impl PIIRule {
32 pub fn new(path: Vec<String>, mode: PIIMode, truncate_to: usize) -> Self {
33 Self {
34 path,
35 mode,
36 truncate_to,
37 }
38 }
39}
40
41const REDACTED: &str = "***";
42const TRUNC_SUFFIX: &str = "...";
43const DEFAULT_SENSITIVE: &[&str] = &[
44 "password",
45 "passwd",
46 "secret",
47 "token",
48 "api_key",
49 "apikey",
50 "auth",
51 "authorization",
52 "credential",
53 "private_key",
54 "ssn",
55 "credit_card",
56 "creditcard",
57 "cvv",
58 "pin",
59 "account_number",
60 "cookie",
61];
62
63#[derive(Clone, Debug)]
65pub struct SecretPattern {
66 pub name: String,
67 pub pattern: Regex,
68}
69
70static RULES: OnceLock<Mutex<Vec<PIIRule>>> = OnceLock::new();
71static CUSTOM_SECRET_PATTERNS: OnceLock<Mutex<Vec<(String, Regex)>>> = OnceLock::new();
72
73#[cfg_attr(test, mutants::skip)] fn empty_pii_rules_mutex() -> Mutex<Vec<PIIRule>> {
75 Mutex::new(Vec::new())
76}
77
78fn rules() -> &'static Mutex<Vec<PIIRule>> {
79 RULES.get_or_init(empty_pii_rules_mutex)
80}
81
82#[cfg_attr(test, mutants::skip)] fn empty_custom_patterns_mutex() -> Mutex<Vec<(String, Regex)>> {
84 Mutex::new(Vec::new())
85}
86
87fn custom_secret_patterns() -> &'static Mutex<Vec<(String, Regex)>> {
88 CUSTOM_SECRET_PATTERNS.get_or_init(empty_custom_patterns_mutex)
89}
90
91fn compiled_builtin_secret_patterns() -> Vec<Regex> {
92 crate::secret_patterns_generated::PATTERNS
93 .iter()
94 .map(|(_name, pattern)| Regex::new(pattern).expect("generated pattern must be valid"))
95 .collect()
96}
97
98fn builtin_secret_patterns() -> &'static [Regex] {
99 static COMPILED: OnceLock<Vec<Regex>> = OnceLock::new();
100 COMPILED
101 .get_or_init(compiled_builtin_secret_patterns)
102 .as_slice()
103}
104
105fn is_secret(value: &Value) -> bool {
106 let text = match value {
107 Value::String(s) => s,
108 _ => return false,
109 };
110 detect_secret_in_string(text)
111}
112
113pub(crate) fn detect_secret_in_string(text: &str) -> bool {
117 if text.len() < crate::secret_patterns_generated::MIN_SECRET_LENGTH {
118 return false;
119 }
120 for pattern in builtin_secret_patterns() {
121 if pattern.is_match(text) {
122 return true;
123 }
124 }
125 let patterns = crate::_lock::lock(custom_secret_patterns());
126 for (_, pattern) in patterns.iter() {
127 if pattern.is_match(text) {
128 return true;
129 }
130 }
131 false
132}
133
134pub(crate) const REDACTED_SENTINEL: &str = REDACTED;
137
138pub fn register_secret_pattern(name: &str, pattern: Regex) {
141 let mut patterns = crate::_lock::lock(custom_secret_patterns());
142 let mut index = 0;
143 while index < patterns.len() {
144 if patterns[index].0 == name {
145 patterns[index].1 = pattern;
146 return;
147 }
148 index += 1;
149 }
150 patterns.push((name.to_string(), pattern));
151}
152
153pub fn get_secret_patterns() -> Vec<SecretPattern> {
155 let mut out: Vec<SecretPattern> = crate::secret_patterns_generated::PATTERNS
156 .iter()
157 .zip(builtin_secret_patterns().iter())
158 .map(|((name, _), p)| SecretPattern {
159 name: name.to_string(),
160 pattern: p.clone(),
161 })
162 .collect();
163 let patterns = crate::_lock::lock(custom_secret_patterns());
164 for (name, pattern) in patterns.iter() {
165 out.push(SecretPattern {
166 name: name.clone(),
167 pattern: pattern.clone(),
168 });
169 }
170 out
171}
172
173pub fn reset_secret_patterns_for_tests() {
175 crate::_lock::lock(custom_secret_patterns()).clear();
176}
177
178pub fn register_pii_rule(rule: PIIRule) {
179 crate::_lock::lock(rules()).push(rule);
180}
181
182pub fn replace_pii_rules(next: Vec<PIIRule>) {
183 *crate::_lock::lock(rules()) = next;
184}
185
186pub fn get_pii_rules() -> Vec<PIIRule> {
187 crate::_lock::lock(rules()).clone()
188}
189
190fn hash_value(value: &Value) -> String {
191 let mut hasher = Sha256::new();
192 match value {
193 Value::String(text) => hasher.update(text.as_bytes()),
194 _ => hasher.update(value.to_string().as_bytes()),
195 }
196 let digest = hasher.finalize();
197 format!("{:x}", digest)[..12].to_string()
198}
199
200fn mask_value(value: &Value, mode: &PIIMode, truncate_to: usize) -> Option<Value> {
201 match mode {
202 PIIMode::Drop => None,
203 PIIMode::Redact => Some(Value::String(REDACTED.to_string())),
204 PIIMode::Hash => Some(Value::String(hash_value(value))),
205 PIIMode::Truncate => {
206 let text = match value {
207 Value::String(value) => value.clone(),
208 _ => value.to_string(),
209 };
210 let char_count = text.chars().count();
211 if char_count <= truncate_to {
212 Some(Value::String(text))
213 } else {
214 let head: String = text.chars().take(truncate_to).collect();
215 Some(Value::String(format!("{head}{TRUNC_SUFFIX}")))
216 }
217 }
218 }
219}
220
221fn match_rule_path(rule_path: &[String], child_path: &[String]) -> bool {
223 if rule_path.len() != child_path.len() {
224 return false;
225 }
226 rule_path
227 .iter()
228 .zip(child_path.iter())
229 .all(|(rp, cp)| rp == "*" || rp == cp)
230}
231
232fn apply_rules(node: &Value, path: &[String], rules: &[PIIRule], max_depth: usize) -> Value {
233 if max_depth == 0 {
234 return node.clone();
235 }
236
237 match node {
238 Value::Object(map) => {
239 let mut out = Map::new();
240 for (key, value) in map {
241 let mut child_path = path.to_vec();
242 child_path.push(key.clone());
243 if let Some(rule) = rules
244 .iter()
245 .find(|rule| match_rule_path(&rule.path, &child_path))
246 {
247 if let Some(masked) = mask_value(value, &rule.mode, rule.truncate_to) {
248 out.insert(key.clone(), masked);
249 }
250 #[cfg(feature = "governance")]
251 emit_receipt(
252 &child_path.join("."),
253 &format!("{:?}", rule.mode).to_ascii_lowercase(),
254 &value.to_string(),
255 );
256 continue;
257 }
258
259 let lowered = key.to_ascii_lowercase();
260 if DEFAULT_SENSITIVE
261 .iter()
262 .any(|candidate| candidate == &lowered)
263 || is_secret(value)
264 {
265 out.insert(key.clone(), Value::String(REDACTED.to_string()));
266 #[cfg(feature = "governance")]
267 emit_receipt(&child_path.join("."), "redact", &value.to_string());
268 continue;
269 }
270
271 out.insert(
272 key.clone(),
273 apply_rules(value, &child_path, rules, max_depth - 1),
274 );
275 }
276 Value::Object(out)
277 }
278 Value::Array(values) => {
281 let mut star_path = path.to_vec();
282 star_path.push("*".to_string());
283 Value::Array(
284 values
285 .iter()
286 .map(|value| apply_rules(value, &star_path, rules, max_depth - 1))
287 .collect(),
288 )
289 }
290 _ => node.clone(),
291 }
292}
293
294#[cfg(feature = "governance")]
295fn annotate_governance_classes(cleaned: &mut Value) {
296 let Value::Object(map) = cleaned else {
297 return;
298 };
299 let policy = get_classification_policy();
300 let keys: Vec<String> = map.keys().cloned().collect();
301 for key in keys {
302 if let Some(label) = classify_key(&key) {
303 let action = policy.lookup_action(&label);
304 if action == "drop" {
305 map.remove(&key);
306 continue;
307 }
308 if matches!(action, "redact" | "hash" | "truncate") {
309 let current = map
310 .get(&key)
311 .cloned()
312 .expect("classification key snapshot must still exist");
313 let already_redacted = current.as_str().map(|s| s == REDACTED).unwrap_or(false);
314 if !already_redacted {
315 let mode = match action {
316 "redact" => PIIMode::Redact,
317 "hash" => PIIMode::Hash,
318 _ => PIIMode::Truncate,
319 };
320 let masked = mask_value(¤t, &mode, 8)
321 .expect("classification governance modes never drop values");
322 map.insert(key.clone(), masked);
323 }
324 }
325 map.insert(format!("__{key}__class"), Value::String(label));
326 }
327 }
328}
329
330pub fn sanitize_payload(payload: &Value, enabled: bool, max_depth: usize) -> Value {
331 if !enabled {
332 return payload.clone();
333 }
334 let rules = get_pii_rules();
335 #[cfg_attr(not(feature = "governance"), allow(unused_mut))]
338 let mut cleaned = apply_rules(payload, &[], &rules, max_depth.max(1));
339 #[cfg(feature = "governance")]
340 annotate_governance_classes(&mut cleaned);
341 cleaned
342}
343
344#[cfg(test)]
345#[path = "pii_tests.rs"]
346mod tests;