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
103fn is_secret(value: &Value) -> bool {
104 let text = match value {
105 Value::String(s) => s,
106 _ => return false,
107 };
108 detect_secret_in_string(text)
109}
110
111pub(crate) fn detect_secret_in_string(text: &str) -> bool {
115 if text.len() < crate::secret_patterns_generated::MIN_SECRET_LENGTH {
116 return false;
117 }
118 for pattern in builtin_secret_patterns() {
119 if pattern.is_match(text) {
120 return true;
121 }
122 }
123 let patterns = crate::_lock::lock(custom_secret_patterns());
124 for (_, pattern) in patterns.iter() {
125 if pattern.is_match(text) {
126 return true;
127 }
128 }
129 false
130}
131
132pub(crate) const REDACTED_SENTINEL: &str = REDACTED;
135
136pub fn register_secret_pattern(name: &str, pattern: Regex) {
139 let mut patterns = crate::_lock::lock(custom_secret_patterns());
140 if let Some((_, existing)) = patterns
141 .iter_mut()
142 .find(|(existing_name, _)| existing_name == name)
143 {
144 *existing = pattern;
145 } else {
146 patterns.push((name.to_string(), pattern));
147 }
148}
149
150pub fn get_secret_patterns() -> Vec<SecretPattern> {
152 let mut out: Vec<SecretPattern> = crate::secret_patterns_generated::PATTERNS
153 .iter()
154 .zip(builtin_secret_patterns().iter())
155 .map(|((name, _), p)| SecretPattern {
156 name: name.to_string(),
157 pattern: p.clone(),
158 })
159 .collect();
160 let patterns = crate::_lock::lock(custom_secret_patterns());
161 for (name, pattern) in patterns.iter() {
162 out.push(SecretPattern {
163 name: name.clone(),
164 pattern: pattern.clone(),
165 });
166 }
167 out
168}
169
170pub fn reset_secret_patterns_for_tests() {
172 crate::_lock::lock(custom_secret_patterns()).clear();
173}
174
175pub fn register_pii_rule(rule: PIIRule) {
176 crate::_lock::lock(rules()).push(rule);
177}
178
179pub fn replace_pii_rules(next: Vec<PIIRule>) {
180 *crate::_lock::lock(rules()) = next;
181}
182
183pub fn get_pii_rules() -> Vec<PIIRule> {
184 crate::_lock::lock(rules()).clone()
185}
186
187fn hash_value(value: &Value) -> String {
188 let mut hasher = Sha256::new();
189 match value {
190 Value::String(text) => hasher.update(text.as_bytes()),
191 _ => hasher.update(value.to_string().as_bytes()),
192 }
193 let digest = hasher.finalize();
194 format!("{:x}", digest)[..12].to_string()
195}
196
197fn mask_value(value: &Value, mode: &PIIMode, truncate_to: usize) -> Option<Value> {
198 match mode {
199 PIIMode::Drop => None,
200 PIIMode::Redact => Some(Value::String(REDACTED.to_string())),
201 PIIMode::Hash => Some(Value::String(hash_value(value))),
202 PIIMode::Truncate => {
203 let text = match value {
204 Value::String(value) => value.clone(),
205 _ => value.to_string(),
206 };
207 let char_count = text.chars().count();
208 if char_count <= truncate_to {
209 Some(Value::String(text))
210 } else {
211 let head: String = text.chars().take(truncate_to).collect();
212 Some(Value::String(format!("{head}{TRUNC_SUFFIX}")))
213 }
214 }
215 }
216}
217
218fn match_rule_path(rule_path: &[String], child_path: &[String]) -> bool {
220 if rule_path.len() != child_path.len() {
221 return false;
222 }
223 rule_path
224 .iter()
225 .zip(child_path.iter())
226 .all(|(rp, cp)| rp == "*" || rp == cp)
227}
228
229fn apply_rules(node: &Value, path: &[String], rules: &[PIIRule], max_depth: usize) -> Value {
230 if max_depth == 0 {
231 return node.clone();
232 }
233
234 match node {
235 Value::Object(map) => {
236 let mut out = Map::new();
237 for (key, value) in map {
238 let mut child_path = path.to_vec();
239 child_path.push(key.clone());
240 if let Some(rule) = rules
241 .iter()
242 .find(|rule| match_rule_path(&rule.path, &child_path))
243 {
244 if let Some(masked) = mask_value(value, &rule.mode, rule.truncate_to) {
245 out.insert(key.clone(), masked);
246 }
247 record_redaction(
248 &child_path.join("."),
249 &format!("{:?}", rule.mode).to_ascii_lowercase(),
250 value,
251 );
252 continue;
253 }
254
255 let lowered = key.to_ascii_lowercase();
256 if DEFAULT_SENSITIVE
257 .iter()
258 .any(|candidate| candidate == &lowered)
259 || is_secret(value)
260 {
261 out.insert(key.clone(), Value::String(REDACTED.to_string()));
262 record_redaction(&child_path.join("."), "redact", value);
263 continue;
264 }
265
266 out.insert(
267 key.clone(),
268 apply_rules(value, &child_path, rules, max_depth - 1),
269 );
270 }
271 Value::Object(out)
272 }
273 Value::Array(values) => {
276 let mut star_path = path.to_vec();
277 star_path.push("*".to_string());
278 Value::Array(
279 values
280 .iter()
281 .map(|value| apply_rules(value, &star_path, rules, max_depth - 1))
282 .collect(),
283 )
284 }
285 _ => node.clone(),
286 }
287}
288
289fn annotate_governance_classes(cleaned: &mut Value) {
290 let Value::Object(map) = cleaned else {
291 return;
292 };
293 let policy = get_classification_policy();
294 let keys: Vec<String> = map.keys().cloned().collect();
295 for key in keys {
296 if let Some(label) = classify_key(&key) {
297 let action = policy.lookup_action(&label);
298 if action == "drop" {
299 map.remove(&key);
300 continue;
301 }
302 if matches!(action, "redact" | "hash" | "truncate") {
303 let current = map
304 .get(&key)
305 .cloned()
306 .expect("classification key snapshot must still exist");
307 let already_redacted = current.as_str().map(|s| s == REDACTED).unwrap_or(false);
308 if !already_redacted {
309 let mode = match action {
310 "redact" => PIIMode::Redact,
311 "hash" => PIIMode::Hash,
312 _ => PIIMode::Truncate,
313 };
314 let masked = mask_value(¤t, &mode, 8)
315 .expect("classification governance modes never drop values");
316 map.insert(key.clone(), masked);
317 }
318 }
319 map.insert(format!("__{key}__class"), Value::String(label));
320 }
321 }
322}
323
324pub fn sanitize_payload(payload: &Value, enabled: bool, max_depth: usize) -> Value {
325 if !enabled {
326 return payload.clone();
327 }
328 let rules = get_pii_rules();
329 let mut cleaned = apply_rules(payload, &[], &rules, max_depth.max(1));
330 annotate_governance_classes(&mut cleaned);
331 cleaned
332}
333
334#[cfg(test)]
335#[path = "pii_tests.rs"]
336mod tests;