Skip to main content

har/
redact.rs

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
61/// Redact secret-bearing chunks from a free-form value: split on common
62/// delimiters and replace any opaque chunk with the redaction marker.
63pub 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
93/// Rebuild a URL with opaque path segments and sensitive/opaque query values
94/// redacted. `unsafe_include` returns the raw URL. Falls back to `redact_value`
95/// on parse failure.
96pub 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
150/// Redact and truncate a request/response body for safe single-line display.
151/// JSON bodies have sensitive keys recursively replaced; newlines/tabs are
152/// collapsed to spaces so snippets stay on one line. `max` bounds the char count.
153pub 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); // 10 + ellipsis
266    }
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        // benign name, but opaque base64url value (>=32, mixed case + digit) -> redacted
316        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}