1use crate::opaque::is_opaque;
2
3pub const REDACTED: &str = "<redacted>";
4
5const SENSITIVE_HEADERS: &[&str] = &[
6 "authorization",
7 "cookie",
8 "set-cookie",
9 "proxy-authorization",
10 "x-api-key",
11 "x-auth-token",
12 "x-amz-security-token",
13];
14
15const SENSITIVE_QUERY_KEYS: &[&str] = &[
16 "token",
17 "access_token",
18 "refresh_token",
19 "id_token",
20 "key",
21 "api_key",
22 "apikey",
23 "sig",
24 "signature",
25 "password",
26 "secret",
27];
28
29const URL_VALUED_HEADERS: &[&str] = &["location", "referer", "content-location"];
30
31const VALUE_DELIMS: &[char] = &[
32 ' ', '\t', '\n', '\r', ';', ',', '&', '=', '/', '?', '"', '{', '}', '[', ']', ':',
33];
34
35pub fn redact_header_value(name: &str, value: &str, unsafe_include: bool) -> String {
36 if unsafe_include {
37 return value.to_string();
38 }
39 let lname = name.to_ascii_lowercase();
40 if SENSITIVE_HEADERS.iter().any(|h| *h == lname) {
41 return REDACTED.to_string();
42 }
43 if URL_VALUED_HEADERS.iter().any(|h| *h == lname) {
44 return redact_url(value, false);
45 }
46 redact_value(value, false)
47}
48
49pub fn redact_query_value(name: &str, value: &str, unsafe_include: bool) -> String {
50 if unsafe_include {
51 return value.to_string();
52 }
53 let lname = name.to_ascii_lowercase();
54 if SENSITIVE_QUERY_KEYS.iter().any(|k| *k == lname) || is_opaque(value) {
55 REDACTED.to_string()
56 } else {
57 value.to_string()
58 }
59}
60
61pub fn redact_value(value: &str, unsafe_include: bool) -> String {
64 if unsafe_include {
65 return value.to_string();
66 }
67 let mut out = String::with_capacity(value.len());
68 let mut chunk = String::new();
69 for ch in value.chars() {
70 if VALUE_DELIMS.contains(&ch) {
71 flush_chunk(&mut out, &mut chunk);
72 out.push(ch);
73 } else {
74 chunk.push(ch);
75 }
76 }
77 flush_chunk(&mut out, &mut chunk);
78 out
79}
80
81fn flush_chunk(out: &mut String, chunk: &mut String) {
82 if chunk.is_empty() {
83 return;
84 }
85 if is_opaque(chunk) {
86 out.push_str(REDACTED);
87 } else {
88 out.push_str(chunk);
89 }
90 chunk.clear();
91}
92
93pub fn redact_url(url: &str, unsafe_include: bool) -> String {
97 if unsafe_include {
98 return url.to_string();
99 }
100 let Ok(u) = url::Url::parse(url) else {
101 return redact_value(url, false);
102 };
103
104 let path: String = u
105 .path()
106 .split('/')
107 .map(|seg| if is_opaque(seg) { REDACTED } else { seg })
108 .collect::<Vec<_>>()
109 .join("/");
110
111 let pairs: Vec<(String, String)> = u
112 .query_pairs()
113 .map(|(k, v)| {
114 let rv = redact_query_value(k.as_ref(), v.as_ref(), false);
115 (k.into_owned(), rv)
116 })
117 .collect();
118
119 let mut out = String::new();
120 out.push_str(u.scheme());
121 out.push_str("://");
122 if let Some(host) = u.host_str() {
123 out.push_str(host);
124 }
125 if let Some(port) = u.port() {
126 out.push_str(&format!(":{port}"));
127 }
128 out.push_str(&path);
129 if !pairs.is_empty() {
130 out.push('?');
131 let q: Vec<String> = pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
132 out.push_str(&q.join("&"));
133 }
134 out
135}
136
137const SENSITIVE_BODY_KEYS: &[&str] = &[
138 "password",
139 "token",
140 "secret",
141 "authorization",
142 "access_token",
143 "refresh_token",
144 "id_token",
145 "api_key",
146 "apikey",
147 "client_secret",
148];
149
150pub fn redact_body(body: &str, unsafe_include: bool, max: usize) -> String {
154 let scrubbed = if unsafe_include {
155 body.to_string()
156 } else if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(body) {
157 redact_json(&mut v);
158 serde_json::to_string(&v).unwrap_or_default()
159 } else {
160 body.to_string()
161 };
162 truncate(&collapse_newlines(&scrubbed), max)
163}
164
165fn collapse_newlines(s: &str) -> String {
166 s.chars()
167 .map(|c| {
168 if c == '\n' || c == '\r' || c == '\t' {
169 ' '
170 } else {
171 c
172 }
173 })
174 .collect()
175}
176
177fn redact_json(v: &mut serde_json::Value) {
178 match v {
179 serde_json::Value::Object(map) => {
180 for (k, val) in map.iter_mut() {
181 let lk = k.to_ascii_lowercase();
182 if SENSITIVE_BODY_KEYS.iter().any(|s| lk.contains(s)) {
183 *val = serde_json::Value::String(REDACTED.to_string());
184 } else {
185 redact_json(val);
186 }
187 }
188 }
189 serde_json::Value::Array(arr) => {
190 for e in arr.iter_mut() {
191 redact_json(e);
192 }
193 }
194 _ => {}
195 }
196}
197
198fn truncate(s: &str, max: usize) -> String {
199 if s.chars().count() <= max {
200 s.to_string()
201 } else {
202 let t: String = s.chars().take(max).collect();
203 format!("{t}…")
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::{redact_header_value, redact_query_value};
210
211 #[test]
212 fn redacts_authorization_header() {
213 assert_eq!(
214 redact_header_value("Authorization", "Bearer abc", false),
215 "<redacted>"
216 );
217 }
218
219 #[test]
220 fn passes_through_safe_header() {
221 assert_eq!(
222 redact_header_value("Accept", "application/json", false),
223 "application/json"
224 );
225 }
226
227 #[test]
228 fn unsafe_flag_disables_redaction() {
229 assert_eq!(
230 redact_header_value("Authorization", "Bearer abc", true),
231 "Bearer abc"
232 );
233 }
234
235 #[test]
236 fn redacts_token_query_param() {
237 assert_eq!(
238 redact_query_value("access_token", "xyz", false),
239 "<redacted>"
240 );
241 assert_eq!(redact_query_value("page", "2", false), "2");
242 }
243
244 #[test]
245 fn redacts_sensitive_json_keys() {
246 let body = r#"{"user":"bob","access_token":"abc","nested":{"password":"x"}}"#;
247 let out = super::redact_body(body, false, 1000);
248 assert!(out.contains("bob"));
249 assert!(!out.contains("abc"));
250 assert!(!out.contains("\"x\""));
251 assert!(out.contains("<redacted>"));
252 }
253
254 #[test]
255 fn unsafe_body_passthrough() {
256 let body = r#"{"access_token":"abc"}"#;
257 let out = super::redact_body(body, true, 1000);
258 assert!(out.contains("abc"));
259 }
260
261 #[test]
262 fn truncates_long_body() {
263 let body = "x".repeat(500);
264 let out = super::redact_body(&body, false, 10);
265 assert!(out.chars().count() <= 11); }
267
268 #[test]
269 fn redact_url_masks_opaque_path_keeps_numeric() {
270 let url = "https://h.example.com/cfg/eyJrZXkiOiJzZWNyZXQiLCJuIjoxMjN9==/users/123";
271 let out = super::redact_url(url, false);
272 assert!(out.contains("/cfg/<redacted>/users/123"));
273 assert!(!out.contains("eyJrZXki"));
274 }
275
276 #[test]
277 fn redact_url_masks_opaque_query_keeps_safe() {
278 let url = "https://h.example.com/x?token=eyJhbGciOiJIUzI1NiJ9abc123XYZ&page=2";
279 let out = super::redact_url(url, false);
280 assert!(out.contains("page=2"));
281 assert!(out.contains("token=<redacted>"));
282 }
283
284 #[test]
285 fn redact_url_unsafe_is_raw() {
286 let url = "https://h.example.com/cfg/eyJrZXkiOiJzZWNyZXQiLCJuIjoxMjN9==/x";
287 assert_eq!(super::redact_url(url, true), url);
288 }
289
290 #[test]
291 fn header_location_value_is_url_redacted() {
292 let v = "https://h.example.com/%7B%22k%22%3A%22eyJzZWNyZXQiOnRydWV9%22%7D/manifest.json";
293 let out = super::redact_header_value("Location", v, false);
294 assert!(out.contains("<redacted>"));
295 assert!(!out.contains("eyJzZWNyZXQi"));
296 }
297
298 #[test]
299 fn header_value_opaque_substring_redacted() {
300 let v = "report-to; s=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9abcXYZ123";
301 let out = super::redact_header_value("Report-To", v, false);
302 assert!(out.contains("<redacted>"));
303 }
304
305 #[test]
306 fn header_accept_untouched() {
307 assert_eq!(
308 super::redact_header_value("Accept", "application/json", false),
309 "application/json"
310 );
311 }
312
313 #[test]
314 fn query_value_redacted_when_opaque() {
315 assert_eq!(
317 super::redact_query_value("d", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", false),
318 "<redacted>"
319 );
320 }
321
322 #[test]
323 fn body_snippet_is_single_line() {
324 let body = "line one\nline two\tindented\r\nline three";
325 let out = super::redact_body(body, false, 1000);
326 assert!(!out.contains('\n'));
327 assert!(!out.contains('\t'));
328 assert!(!out.contains('\r'));
329 }
330}