Skip to main content

trace_redact/
lib.rs

1//! # trace-redact
2//!
3//! Walk a `serde_json::Value` (agent trace, OTel span attributes) and
4//! redact sensitive values in place. Two layers of detection:
5//!
6//! 1. **Key-name match** — fields named `api_key`, `token`,
7//!    `authorization`, `password`, etc., are replaced regardless of
8//!    value shape.
9//! 2. **Value-pattern match** — string values that look like API keys,
10//!    bearer tokens, emails, phone numbers, or SSNs are replaced.
11//!
12//! ## Example
13//!
14//! ```
15//! use trace_redact::redact;
16//! use serde_json::{json, Value};
17//!
18//! let mut v: Value = json!({
19//!     "model": "claude-sonnet-4-5",
20//!     "headers": { "authorization": "Bearer sk-live-AAAABBBBCCCCDDDD" },
21//!     "user_email": "jane@example.com",
22//! });
23//! redact(&mut v);
24//! assert_eq!(v["headers"]["authorization"], json!("[REDACTED]"));
25//! ```
26
27#![deny(missing_docs)]
28
29use serde_json::Value;
30
31/// Replacement token written into redacted slots.
32pub const REPLACEMENT: &str = "[REDACTED]";
33
34/// Field-name list (lowercased) that always triggers redaction.
35const SENSITIVE_KEYS: &[&str] = &[
36    "api_key",
37    "apikey",
38    "token",
39    "access_token",
40    "refresh_token",
41    "id_token",
42    "authorization",
43    "password",
44    "secret",
45    "x-api-key",
46    "anthropic-api-key",
47    "openai-api-key",
48];
49
50/// Walk `v` and redact in place.
51pub fn redact(v: &mut Value) {
52    match v {
53        Value::Object(map) => {
54            let keys: Vec<String> = map.keys().cloned().collect();
55            for k in keys {
56                if is_sensitive_key(&k) {
57                    if let Some(slot) = map.get_mut(&k) {
58                        *slot = Value::String(REPLACEMENT.to_string());
59                    }
60                    continue;
61                }
62                if let Some(slot) = map.get_mut(&k) {
63                    redact(slot);
64                }
65            }
66        }
67        Value::Array(items) => {
68            for item in items.iter_mut() {
69                redact(item);
70            }
71        }
72        Value::String(s) => {
73            if looks_sensitive(s) {
74                *v = Value::String(REPLACEMENT.to_string());
75            }
76        }
77        _ => {}
78    }
79}
80
81fn is_sensitive_key(k: &str) -> bool {
82    let lk = k.to_ascii_lowercase();
83    SENSITIVE_KEYS.iter().any(|s| *s == lk)
84}
85
86/// True for strings that pattern-match an API key, bearer token, email,
87/// phone, or SSN.
88pub fn looks_sensitive(s: &str) -> bool {
89    is_api_keyish(s)
90        || s.starts_with("Bearer ")
91        || is_email(s)
92        || is_ssn(s)
93        || is_phone(s)
94}
95
96fn is_api_keyish(s: &str) -> bool {
97    // Common prefixes followed by 16+ url-safe chars.
98    let prefixes = ["sk-", "ghp_", "xoxb-", "sk_live_", "sk_test_", "rk_live_"];
99    if prefixes.iter().any(|p| s.starts_with(p)) {
100        let tail_len = s.split_once(|c: char| c == '-' || c == '_')
101            .map(|(_, t)| t.len())
102            .unwrap_or(0);
103        return tail_len >= 16;
104    }
105    false
106}
107
108fn is_email(s: &str) -> bool {
109    let parts: Vec<&str> = s.split('@').collect();
110    parts.len() == 2 && !parts[0].is_empty() && parts[1].contains('.')
111}
112
113fn is_ssn(s: &str) -> bool {
114    s.len() == 11
115        && s.chars().enumerate().all(|(i, c)| match i {
116            3 | 6 => c == '-',
117            _ => c.is_ascii_digit(),
118        })
119}
120
121fn is_phone(s: &str) -> bool {
122    let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
123    (10..=12).contains(&digits.len()) && s.chars().any(|c| c == '-' || c == '(' || c == ' ')
124}