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;
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
73fn rules() -> &'static Mutex<Vec<PIIRule>> {
74 RULES.get_or_init(|| Mutex::new(Vec::new()))
75}
76
77fn custom_secret_patterns() -> &'static Mutex<Vec<(String, Regex)>> {
78 CUSTOM_SECRET_PATTERNS.get_or_init(|| Mutex::new(Vec::new()))
79}
80
81fn builtin_secret_patterns() -> &'static [Regex] {
82 static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
83 PATTERNS
84 .get_or_init(|| {
85 vec![
86 Regex::new(r"(?:AKIA|ASIA)[A-Z0-9]{16}").expect("valid regex"),
87 Regex::new(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}").expect("valid regex"),
88 Regex::new(r"gh[pos]_[A-Za-z0-9_]{36,}").expect("valid regex"),
89 Regex::new(r"[0-9a-fA-F]{40,}").expect("valid regex"),
90 Regex::new(r"[A-Za-z0-9+/]{40,}={0,2}").expect("valid regex"),
91 ]
92 })
93 .as_slice()
94}
95
96fn is_secret(value: &Value) -> bool {
97 let text = match value {
98 Value::String(s) => s,
99 _ => return false,
100 };
101 if builtin_secret_patterns().iter().any(|p| p.is_match(text)) {
102 return true;
103 }
104 custom_secret_patterns()
105 .lock()
106 .expect("custom patterns lock poisoned")
107 .iter()
108 .any(|(_, p)| p.is_match(text))
109}
110
111pub fn register_secret_pattern(name: &str, pattern: Regex) {
114 let mut patterns = custom_secret_patterns()
115 .lock()
116 .expect("custom patterns lock poisoned");
117 if let Some(entry) = patterns.iter_mut().find(|(n, _)| n == name) {
118 entry.1 = pattern;
119 } else {
120 patterns.push((name.to_string(), pattern));
121 }
122}
123
124pub fn get_secret_patterns() -> Vec<SecretPattern> {
126 let mut out: Vec<SecretPattern> = builtin_secret_patterns()
127 .iter()
128 .enumerate()
129 .map(|(i, p)| SecretPattern {
130 name: format!("builtin-{i}"),
131 pattern: p.clone(),
132 })
133 .collect();
134 for (name, pattern) in custom_secret_patterns()
135 .lock()
136 .expect("custom patterns lock poisoned")
137 .iter()
138 {
139 out.push(SecretPattern {
140 name: name.clone(),
141 pattern: pattern.clone(),
142 });
143 }
144 out
145}
146
147pub fn reset_secret_patterns_for_tests() {
149 custom_secret_patterns()
150 .lock()
151 .expect("custom patterns lock poisoned")
152 .clear();
153}
154
155pub fn register_pii_rule(rule: PIIRule) {
156 rules().lock().expect("pii lock poisoned").push(rule);
157}
158
159pub fn replace_pii_rules(next: Vec<PIIRule>) {
160 *rules().lock().expect("pii lock poisoned") = next;
161}
162
163pub fn get_pii_rules() -> Vec<PIIRule> {
164 rules().lock().expect("pii lock poisoned").clone()
165}
166
167fn hash_value(value: &Value) -> String {
168 let mut hasher = Sha256::new();
169 match value {
170 Value::String(text) => hasher.update(text.as_bytes()),
171 _ => hasher.update(value.to_string().as_bytes()),
172 }
173 let digest = hasher.finalize();
174 format!("{:x}", digest)[..12].to_string()
175}
176
177fn mask_value(value: &Value, mode: &PIIMode, truncate_to: usize) -> Option<Value> {
178 match mode {
179 PIIMode::Drop => None,
180 PIIMode::Redact => Some(Value::String(REDACTED.to_string())),
181 PIIMode::Hash => Some(Value::String(hash_value(value))),
182 PIIMode::Truncate => {
183 let text = match value {
184 Value::String(value) => value.clone(),
185 _ => value.to_string(),
186 };
187 let char_count = text.chars().count();
188 if char_count <= truncate_to {
189 Some(Value::String(text))
190 } else {
191 let head: String = text.chars().take(truncate_to).collect();
192 Some(Value::String(format!("{head}{TRUNC_SUFFIX}")))
193 }
194 }
195 }
196}
197
198fn apply_rules(node: &Value, path: &[String], rules: &[PIIRule], max_depth: usize) -> Value {
199 if max_depth == 0 {
200 return node.clone();
201 }
202
203 match node {
204 Value::Object(map) => {
205 let mut out = Map::new();
206 for (key, value) in map {
207 let mut child_path = path.to_vec();
208 child_path.push(key.clone());
209 if let Some(rule) = rules.iter().find(|rule| rule.path == child_path) {
210 if let Some(masked) = mask_value(value, &rule.mode, rule.truncate_to) {
211 out.insert(key.clone(), masked);
212 }
213 #[cfg(feature = "governance")]
214 emit_receipt(
215 &child_path.join("."),
216 &format!("{:?}", rule.mode).to_ascii_lowercase(),
217 &value.to_string(),
218 );
219 continue;
220 }
221
222 let lowered = key.to_ascii_lowercase();
223 if DEFAULT_SENSITIVE
224 .iter()
225 .any(|candidate| candidate == &lowered)
226 || is_secret(value)
227 {
228 out.insert(key.clone(), Value::String(REDACTED.to_string()));
229 #[cfg(feature = "governance")]
230 emit_receipt(&child_path.join("."), "redact", &value.to_string());
231 continue;
232 }
233
234 out.insert(
235 key.clone(),
236 apply_rules(value, &child_path, rules, max_depth - 1),
237 );
238 }
239 Value::Object(out)
240 }
241 Value::Array(values) => Value::Array(
242 values
243 .iter()
244 .map(|value| apply_rules(value, path, rules, max_depth - 1))
245 .collect(),
246 ),
247 _ => node.clone(),
248 }
249}
250
251pub fn sanitize_payload(payload: &Value, enabled: bool, max_depth: usize) -> Value {
252 if !enabled {
253 return payload.clone();
254 }
255 let rules = get_pii_rules();
256 let mut cleaned = apply_rules(payload, &[], &rules, max_depth.max(1));
257 #[cfg(feature = "governance")]
258 if let (Value::Object(original), Value::Object(map)) = (payload, &mut cleaned) {
259 let keys: Vec<String> = original.keys().cloned().collect();
260 for key in keys {
261 if let Some(label) = classify_key(&key) {
262 map.insert(format!("__{key}__class"), Value::String(label));
263 }
264 }
265 }
266 cleaned
267}