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
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/// A secret pattern with a diagnostic name and the compiled regex.
62#[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)] // Equivalent mutants only rewrite Vec::new() syntax.
72fn 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)] // Equivalent mutants only rewrite Vec::new() syntax.
81fn 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
111/// Returns true when *text* matches any built-in or custom secret pattern.
112/// Used by the logger pipeline to scrub free-form message strings (which
113/// the map-based [`sanitize_payload`] engine doesn't see).
114pub(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
132/// The redaction sentinel emitted when a value or string matches a
133/// secret pattern (matches Python's `***` and Go's `piicore.Redacted`).
134pub(crate) const REDACTED_SENTINEL: &str = REDACTED;
135
136/// Register a custom secret detection pattern. If *name* already exists, the
137/// pattern is replaced.
138pub 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
150/// Return all secret patterns (built-in and custom).
151pub 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
170/// Reset custom secret patterns — for test isolation only.
171pub 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
218/// Segment-wise path match: `"*"` in *rule_path* matches any single segment.
219fn 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        // Fix 5: push "*" as path segment when recursing into array elements so
274        // rules like ["users", "*", "email"] can match each element's "email" key.
275        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(&current, &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;