rovenue/logging/
redact.rs

1use std::collections::HashMap;
2
3const SENSITIVE_KEY_TOKENS: &[&str] = &[
4    "token",
5    "receipt",
6    "email",
7    "app_user_id",
8    "authorization",
9    "signature",
10    "jws",
11    "password",
12    "secret",
13];
14
15/// Known credential prefixes (case-insensitive) — any token starting with
16/// one of these is treated as a credential and masked.
17const CREDENTIAL_PREFIXES: &[&str] =
18    &["pk_", "sk_", "rk_", "user_", "sub_", "rcpt", "cus_", "tok_"];
19
20/// Minimum length for an opaque run of `[A-Za-z0-9_\-]` characters that
21/// should be treated as a secret identifier / key.
22const OPAQUE_TOKEN_MIN_LEN: usize = 20;
23
24/// Returns `true` if `word` looks like a secret/credential that should be
25/// redacted from log messages.
26fn is_secret_token(word: &str) -> bool {
27    // 1. Email: contains '@' and '.' (already handled in the loop, but keep
28    //    the predicate complete for standalone use).
29    if word.contains('@') && word.contains('.') {
30        return true;
31    }
32
33    // 2. Known credential prefix (case-insensitive).
34    let lower = word.to_lowercase();
35    if CREDENTIAL_PREFIXES.iter().any(|p| lower.starts_with(p)) {
36        return true;
37    }
38
39    // 3. JWS/JWT-ish: contains two or more '.' with non-trivial alphanumeric
40    //    segments (at least 4 chars each) between them.
41    {
42        let parts: Vec<&str> = word.split('.').collect();
43        if parts.len() >= 3
44            && parts.iter().all(|p| {
45                p.len() >= 4
46                    && p.chars()
47                        .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
48            })
49        {
50            return true;
51        }
52    }
53
54    // 4. Long opaque run: ≥ 20 chars, only [A-Za-z0-9_\-].
55    if word.len() >= OPAQUE_TOKEN_MIN_LEN
56        && word
57            .chars()
58            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
59    {
60        return true;
61    }
62
63    false
64}
65
66pub fn redact_fields(fields: HashMap<String, String>) -> HashMap<String, String> {
67    fields
68        .into_iter()
69        .map(|(k, v)| {
70            let lk = k.to_lowercase();
71            if SENSITIVE_KEY_TOKENS.iter().any(|t| lk.contains(t)) {
72                (k, "[redacted]".to_string())
73            } else {
74                (k, v)
75            }
76        })
77        .collect()
78}
79
80pub fn redact_message(input: &str) -> String {
81    let mut out: Vec<String> = Vec::new();
82    for word in input.split_whitespace() {
83        if is_secret_token(word) {
84            out.push("[redacted]".to_string());
85        } else {
86            out.push(word.to_string());
87        }
88    }
89    let joined = out.join(" ");
90    // Collapse `Bearer <token>` → `Bearer [redacted]`.
91    redact_bearer(&joined)
92}
93
94fn redact_bearer(s: &str) -> String {
95    let mut result = String::with_capacity(s.len());
96    let mut tokens = s.split(' ').peekable();
97    while let Some(tok) = tokens.next() {
98        result.push_str(tok);
99        if tok == "Bearer" && tokens.next().is_some() {
100            result.push_str(" [redacted]");
101        }
102        if tokens.peek().is_some() {
103            result.push(' ');
104        }
105    }
106    result
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn fields_with_sensitive_keys_are_masked() {
115        let mut f = HashMap::new();
116        f.insert("status".to_string(), "401".to_string());
117        f.insert(
118            "authorization".to_string(),
119            "Bearer pk_live_abc".to_string(),
120        );
121        f.insert("app_user_id".to_string(), "user_42".to_string());
122        f.insert("receipt_data".to_string(), "MIIxyz".to_string());
123        let out = redact_fields(f);
124        assert_eq!(out.get("status").unwrap(), "401");
125        assert_eq!(out.get("authorization").unwrap(), "[redacted]");
126        assert_eq!(out.get("app_user_id").unwrap(), "[redacted]");
127        assert_eq!(out.get("receipt_data").unwrap(), "[redacted]");
128    }
129
130    #[test]
131    fn message_masks_bearer_and_email() {
132        let m = redact_message("auth Bearer pk_live_secret failed for jane@example.com");
133        assert!(!m.contains("pk_live_secret"), "bearer token leaked: {m}");
134        assert!(!m.contains("jane@example.com"), "email leaked: {m}");
135        assert!(m.contains("[redacted]"));
136    }
137
138    #[test]
139    fn clean_message_is_unchanged() {
140        assert_eq!(
141            redact_message("request timed out after 3 attempts"),
142            "request timed out after 3 attempts"
143        );
144    }
145
146    #[test]
147    fn message_masks_credential_prefix_tokens() {
148        // pk_ prefix
149        let m = redact_message("configure pk_live_abc123XYZ failed");
150        assert!(!m.contains("pk_live_abc123XYZ"), "pk_ token leaked: {m}");
151        assert!(m.contains("[redacted]"), "pk_ token not masked: {m}");
152
153        // sk_ prefix
154        let m2 = redact_message("secret sk_test_xyz used");
155        assert!(!m2.contains("sk_test_xyz"), "sk_ token leaked: {m2}");
156
157        // user_ prefix
158        let m3 = redact_message("subscriber user_42 not found");
159        assert!(!m3.contains("user_42"), "user_ id leaked: {m3}");
160        assert!(m3.contains("[redacted]"), "user_ id not masked: {m3}");
161    }
162
163    #[test]
164    fn message_masks_long_opaque_tokens() {
165        // 30-char hex/base64url token (≥ 20 chars, only [A-Za-z0-9_\-])
166        let long_token = "aB3xK9mZqL7nP2rTvWsYdCeUfGhIjOk"; // 32 chars
167        assert!(long_token.len() >= 20);
168        let m = redact_message(&format!("token value is {long_token} here"));
169        assert!(!m.contains(long_token), "long token leaked: {m}");
170        assert!(m.contains("[redacted]"), "long token not masked: {m}");
171    }
172
173    #[test]
174    fn message_masks_jws_jwt_tokens() {
175        // Realistic JWT/JWS shape: three base64url segments separated by '.'
176        let jws = "eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJ1c3JfMTIzIn0.SflKxwRJSMeKKF2QT4fwpMeJf36PO";
177        let m = redact_message(&format!("got jws {jws} for request"));
178        assert!(!m.contains(jws), "JWS leaked: {m}");
179        assert!(m.contains("[redacted]"), "JWS not masked: {m}");
180    }
181
182    #[test]
183    fn message_does_not_over_redact_normal_words() {
184        // Short plain words, numbers, HTTP status phrases must pass through.
185        let cases = ["request timed out after 3 attempts", "status 404 not found"];
186        for case in &cases {
187            let m = redact_message(case);
188            assert_eq!(m, *case, "normal message was over-redacted: {m}");
189        }
190    }
191}