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;
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
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
111/// Register a custom secret detection pattern. If *name* already exists, the
112/// pattern is replaced.
113pub 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
124/// Return all secret patterns (built-in and custom).
125pub 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
147/// Reset custom secret patterns — for test isolation only.
148pub 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}