Skip to main content

provide_telemetry/
pii.rs

1// SPDX-FileCopyrightText: Copyright (C) 2026 provide.io llc
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-Comment: Part of provide-telemetry.
4//
5
6use 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/// A secret pattern with a diagnostic name and the compiled regex.
64#[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)] // Equivalent mutants only rewrite Vec::new() syntax.
74fn 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)] // Equivalent mutants only rewrite Vec::new() syntax.
83fn 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
113/// Returns true when *text* matches any built-in or custom secret pattern.
114/// Used by the logger pipeline to scrub free-form message strings (which
115/// the map-based [`sanitize_payload`] engine doesn't see).
116pub(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
134/// The redaction sentinel emitted when a value or string matches a
135/// secret pattern (matches Python's `***` and Go's `piicore.Redacted`).
136pub(crate) const REDACTED_SENTINEL: &str = REDACTED;
137
138/// Register a custom secret detection pattern. If *name* already exists, the
139/// pattern is replaced.
140pub 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
153/// Return all secret patterns (built-in and custom).
154pub 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
173/// Reset custom secret patterns — for test isolation only.
174pub 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
221/// Segment-wise path match: `"*"` in *rule_path* matches any single segment.
222fn 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        // Fix 5: push "*" as path segment when recursing into array elements so
279        // rules like ["users", "*", "email"] can match each element's "email" key.
280        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(&current, &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    // `mut` is consumed below only under the governance feature; without it
336    // the binding is genuinely unused-mut and clippy -D warnings fails.
337    #[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;