1use regex::Regex;
7use serde_json::{Map, Value};
8use sha2::{Digest, Sha256};
9use std::sync::{Mutex, OnceLock};
10
11use crate::classification::{classify_key, get_classification_policy};
12use crate::receipts::record_redaction;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum PIIMode {
16 Drop,
17 Redact,
18 Hash,
19 Truncate,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct PIIRule {
24 pub path: Vec<String>,
25 pub mode: PIIMode,
26 pub truncate_to: usize,
27}
28
29impl PIIRule {
30 pub fn new(path: Vec<String>, mode: PIIMode, truncate_to: usize) -> Self {
31 Self {
32 path,
33 mode,
34 truncate_to,
35 }
36 }
37}
38
39const REDACTED: &str = "***";
40const TRUNC_SUFFIX: &str = "...";
41const DEFAULT_SENSITIVE: &[&str] = &[
42 "password",
43 "passwd",
44 "secret",
45 "token",
46 "api_key",
47 "apikey",
48 "auth",
49 "authorization",
50 "credential",
51 "private_key",
52 "ssn",
53 "credit_card",
54 "creditcard",
55 "cvv",
56 "pin",
57 "account_number",
58 "cookie",
59];
60
61#[derive(Clone, Debug)]
63pub struct SecretPattern {
64 pub name: String,
65 pub pattern: Regex,
66}
67
68static RULES: OnceLock<Mutex<Vec<PIIRule>>> = OnceLock::new();
69static CUSTOM_SECRET_PATTERNS: OnceLock<Mutex<Vec<(String, Regex)>>> = OnceLock::new();
70
71#[cfg_attr(test, mutants::skip)] fn empty_pii_rules_mutex() -> Mutex<Vec<PIIRule>> {
73 Mutex::new(Vec::new())
74}
75
76fn rules() -> &'static Mutex<Vec<PIIRule>> {
77 RULES.get_or_init(empty_pii_rules_mutex)
78}
79
80#[cfg_attr(test, mutants::skip)] fn empty_custom_patterns_mutex() -> Mutex<Vec<(String, Regex)>> {
82 Mutex::new(Vec::new())
83}
84
85fn custom_secret_patterns() -> &'static Mutex<Vec<(String, Regex)>> {
86 CUSTOM_SECRET_PATTERNS.get_or_init(empty_custom_patterns_mutex)
87}
88
89fn compiled_builtin_secret_patterns() -> Vec<Regex> {
90 crate::secret_patterns_generated::PATTERNS
91 .iter()
92 .map(|(_name, pattern)| Regex::new(pattern).expect("generated pattern must be valid"))
93 .collect()
94}
95
96fn builtin_secret_patterns() -> &'static [Regex] {
97 static COMPILED: OnceLock<Vec<Regex>> = OnceLock::new();
98 COMPILED
99 .get_or_init(compiled_builtin_secret_patterns)
100 .as_slice()
101}
102
103const PATH_MIN_SEGMENTS: usize = 3;
105
106fn looks_like_path(span: &str) -> bool {
119 let segments: Vec<&str> = span.split('/').filter(|s| !s.is_empty()).collect();
120 if segments.len() < PATH_MIN_SEGMENTS {
121 return false;
122 }
123 let wordy = segments
124 .iter()
125 .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase()))
126 .count();
127 wordy * 2 >= segments.len()
128}
129
130fn secret_spans(text: &str) -> Vec<(usize, usize)> {
148 if text.len() < crate::secret_patterns_generated::MIN_SECRET_LENGTH {
149 return Vec::new();
150 }
151 let mut spans: Vec<(usize, usize)> = Vec::new();
152 let mut collect = |pattern: &Regex| {
153 if pattern.find(text).is_none() {
154 return;
155 }
156 for m in pattern.find_iter(text) {
157 if m.start() == m.end() {
161 continue;
162 }
163 if !looks_like_path(m.as_str()) {
164 spans.push(expand_to_token(text, m.start(), m.end()));
165 }
166 }
167 };
168 for pattern in builtin_secret_patterns() {
169 collect(pattern);
170 }
171 {
172 let patterns = crate::_lock::lock(custom_secret_patterns());
173 for (_, pattern) in patterns.iter() {
174 collect(pattern);
175 }
176 }
177 merge_spans(spans)
178}
179
180fn expand_to_token(text: &str, start: usize, end: usize) -> (usize, usize) {
192 let left = text[..start]
193 .rfind(|c: char| c.is_ascii_whitespace())
194 .map_or(0, |index| index + 1);
195 let right = text[end..]
196 .find(|c: char| c.is_ascii_whitespace())
197 .map_or(text.len(), |index| end + index);
198 (left, right)
199}
200
201fn merge_spans(mut spans: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
210 spans.sort_unstable();
211 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(spans.len());
212 for (start, end) in spans {
213 match merged.last_mut() {
214 Some(last) if start <= last.1 => last.1 = last.1.max(end),
215 _ => merged.push((start, end)),
216 }
217 }
218 merged
219}
220
221pub(crate) fn redact_if_secret(text: &str) -> Option<String> {
238 let spans = secret_spans(text);
239 if spans.is_empty() {
240 return None;
241 }
242 let mut out = String::with_capacity(text.len());
243 let mut previous_end = 0usize;
244 for (start, end) in spans {
245 out.push_str(&text[previous_end..start]);
246 out.push_str(REDACTED);
247 previous_end = end;
248 }
249 out.push_str(&text[previous_end..]);
250 Some(out)
251}
252
253pub(crate) const REDACTED_SENTINEL: &str = REDACTED;
256
257pub fn register_secret_pattern(name: &str, pattern: Regex) {
260 let mut patterns = crate::_lock::lock(custom_secret_patterns());
261 if let Some((_, existing)) = patterns
262 .iter_mut()
263 .find(|(existing_name, _)| existing_name == name)
264 {
265 *existing = pattern;
266 } else {
267 patterns.push((name.to_string(), pattern));
268 }
269}
270
271pub fn get_secret_patterns() -> Vec<SecretPattern> {
273 let mut out: Vec<SecretPattern> = crate::secret_patterns_generated::PATTERNS
274 .iter()
275 .zip(builtin_secret_patterns().iter())
276 .map(|((name, _), p)| SecretPattern {
277 name: name.to_string(),
278 pattern: p.clone(),
279 })
280 .collect();
281 let patterns = crate::_lock::lock(custom_secret_patterns());
282 for (name, pattern) in patterns.iter() {
283 out.push(SecretPattern {
284 name: name.clone(),
285 pattern: pattern.clone(),
286 });
287 }
288 out
289}
290
291pub fn reset_secret_patterns_for_tests() {
293 crate::_lock::lock(custom_secret_patterns()).clear();
294}
295
296pub fn register_pii_rule(rule: PIIRule) {
297 crate::_lock::lock(rules()).push(rule);
298}
299
300pub fn replace_pii_rules(next: Vec<PIIRule>) {
301 *crate::_lock::lock(rules()) = next;
302}
303
304pub fn get_pii_rules() -> Vec<PIIRule> {
305 crate::_lock::lock(rules()).clone()
306}
307
308fn hash_value(value: &Value) -> String {
309 let mut hasher = Sha256::new();
310 match value {
311 Value::String(text) => hasher.update(text.as_bytes()),
312 _ => hasher.update(value.to_string().as_bytes()),
313 }
314 let digest = hasher.finalize();
315 format!("{:x}", digest)[..12].to_string()
316}
317
318fn mask_value(value: &Value, mode: &PIIMode, truncate_to: usize) -> Option<Value> {
319 match mode {
320 PIIMode::Drop => None,
321 PIIMode::Redact => Some(Value::String(REDACTED.to_string())),
322 PIIMode::Hash => Some(Value::String(hash_value(value))),
323 PIIMode::Truncate => {
324 let text = match value {
325 Value::String(value) => value.clone(),
326 _ => value.to_string(),
327 };
328 let char_count = text.chars().count();
329 if char_count <= truncate_to {
330 Some(Value::String(text))
331 } else {
332 let head: String = text.chars().take(truncate_to).collect();
333 Some(Value::String(format!("{head}{TRUNC_SUFFIX}")))
334 }
335 }
336 }
337}
338
339fn match_rule_path(rule_path: &[String], child_path: &[String]) -> bool {
341 if rule_path.len() != child_path.len() {
342 return false;
343 }
344 rule_path
345 .iter()
346 .zip(child_path.iter())
347 .all(|(rp, cp)| rp == "*" || rp == cp)
348}
349
350fn apply_rules(node: &Value, path: &[String], rules: &[PIIRule], max_depth: usize) -> Value {
351 if max_depth == 0 {
352 return node.clone();
353 }
354
355 match node {
356 Value::Object(map) => {
357 let mut out = Map::new();
358 for (key, value) in map {
359 let mut child_path = path.to_vec();
360 child_path.push(key.clone());
361 if let Some(rule) = rules
362 .iter()
363 .find(|rule| match_rule_path(&rule.path, &child_path))
364 {
365 if let Some(masked) = mask_value(value, &rule.mode, rule.truncate_to) {
366 out.insert(key.clone(), masked);
367 }
368 record_redaction(
369 &child_path.join("."),
370 &format!("{:?}", rule.mode).to_ascii_lowercase(),
371 value,
372 );
373 continue;
374 }
375
376 let lowered = key.to_ascii_lowercase();
377 let sensitive_key = DEFAULT_SENSITIVE
378 .iter()
379 .any(|candidate| candidate == &lowered);
380 let replacement = if sensitive_key {
389 Some(REDACTED.to_string())
390 } else {
391 match value {
392 Value::String(text) => redact_if_secret(text),
393 _ => None,
394 }
395 };
396 if let Some(replacement) = replacement {
397 out.insert(key.clone(), Value::String(replacement));
398 record_redaction(&child_path.join("."), "redact", value);
399 continue;
400 }
401
402 out.insert(
403 key.clone(),
404 apply_rules(value, &child_path, rules, max_depth - 1),
405 );
406 }
407 Value::Object(out)
408 }
409 Value::Array(values) => {
412 let mut star_path = path.to_vec();
413 star_path.push("*".to_string());
414 Value::Array(
415 values
416 .iter()
417 .map(|value| apply_rules(value, &star_path, rules, max_depth - 1))
418 .collect(),
419 )
420 }
421 _ => node.clone(),
422 }
423}
424
425fn annotate_governance_classes(cleaned: &mut Value) {
426 let Value::Object(map) = cleaned else {
427 return;
428 };
429 let policy = get_classification_policy();
430 let keys: Vec<String> = map.keys().cloned().collect();
431 for key in keys {
432 if let Some(label) = classify_key(&key) {
433 let action = policy.lookup_action(&label);
434 if action == "drop" {
435 map.remove(&key);
436 continue;
437 }
438 if matches!(action, "redact" | "hash" | "truncate") {
439 let current = map
440 .get(&key)
441 .cloned()
442 .expect("classification key snapshot must still exist");
443 let already_redacted = current.as_str().map(|s| s == REDACTED).unwrap_or(false);
444 if !already_redacted {
445 let mode = match action {
446 "redact" => PIIMode::Redact,
447 "hash" => PIIMode::Hash,
448 _ => PIIMode::Truncate,
449 };
450 let masked = mask_value(¤t, &mode, 8)
451 .expect("classification governance modes never drop values");
452 map.insert(key.clone(), masked);
453 }
454 }
455 map.insert(format!("__{key}__class"), Value::String(label));
456 }
457 }
458}
459
460pub fn sanitize_payload(payload: &Value, enabled: bool, max_depth: usize) -> Value {
461 if !enabled {
462 return payload.clone();
463 }
464 let rules = get_pii_rules();
465 let mut cleaned = apply_rules(payload, &[], &rules, max_depth.max(1));
466 annotate_governance_classes(&mut cleaned);
467 cleaned
468}
469
470#[cfg(test)]
471#[path = "pii_tests.rs"]
472mod tests;