rovenue/logging/
redact.rs1use 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
15const CREDENTIAL_PREFIXES: &[&str] =
18 &["pk_", "sk_", "rk_", "user_", "sub_", "rcpt", "cus_", "tok_"];
19
20const OPAQUE_TOKEN_MIN_LEN: usize = 20;
23
24fn is_secret_token(word: &str) -> bool {
27 if word.contains('@') && word.contains('.') {
30 return true;
31 }
32
33 let lower = word.to_lowercase();
35 if CREDENTIAL_PREFIXES.iter().any(|p| lower.starts_with(p)) {
36 return true;
37 }
38
39 {
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 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 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 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 let m2 = redact_message("secret sk_test_xyz used");
155 assert!(!m2.contains("sk_test_xyz"), "sk_ token leaked: {m2}");
156
157 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 let long_token = "aB3xK9mZqL7nP2rTvWsYdCeUfGhIjOk"; 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 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 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}