Skip to main content

agent_first_http/shared/
redact.rs

1//! Header-name redaction list applied to `network.json` by default (and to
2//! any tool-originated log line). Per `design.md §"Secrets are redacted"`,
3//! credential-bearing headers are replaced with `"[redacted]"` unless the
4//! caller passes `--no-network-redact`.
5//!
6//! Server response **bodies** pass through unmodified — redaction is for
7//! tool-captured metadata only.
8
9/// Header names that are always redacted (case-insensitive match).
10pub const ALWAYS_REDACTED: &[&str] = &[
11    "cookie",
12    "set-cookie",
13    "authorization",
14    "proxy-authorization",
15];
16
17/// Header-name *suffixes* that trigger redaction (case-insensitive match
18/// against the full header). Catches `x-api-token`, `x-csrf-token`,
19/// `x-shared-secret`, etc.
20pub const REDACTED_SUFFIXES: &[&str] = &["-token", "-secret"];
21
22/// Returns true if `header_name` should be redacted under the default
23/// policy. Case-insensitive.
24#[must_use]
25pub fn should_redact(header_name: &str) -> bool {
26    let lower = header_name.to_ascii_lowercase();
27    if ALWAYS_REDACTED.iter().any(|h| *h == lower) {
28        return true;
29    }
30    REDACTED_SUFFIXES
31        .iter()
32        .any(|suffix| lower.ends_with(suffix))
33}
34
35/// Sentinel string written in place of redacted values.
36pub const REDACTED_VALUE: &str = "[redacted]";
37
38/// Mask the password component of every `scheme://user:pass@host` userinfo
39/// found in arbitrary text, leaving the rest byte-for-byte intact.
40///
41/// Used on browser stderr lines surfaced by `/diagnostics`, which can echo
42/// afhttp's own `--proxy-server=http://user:pass@host` launch argument. This
43/// is afhttp's own injected credential — never page-captured data — so masking
44/// it does not violate faithful capture. afdata's `redact_url_secrets` only
45/// operates on a string that is itself a single URL, so it cannot be used on a
46/// prose line; this is the narrow, userinfo-password-only equivalent.
47#[must_use]
48pub fn redact_userinfo_passwords(line: &str) -> String {
49    let mut out = String::with_capacity(line.len());
50    let mut rest = line;
51    while let Some(pos) = rest.find("://") {
52        // Copy through the scheme separator.
53        out.push_str(&rest[..pos + 3]);
54        let after = &rest[pos + 3..];
55        // Authority ends at the first '/', '?', '#', or whitespace.
56        let auth_end = after
57            .find(|c: char| matches!(c, '/' | '?' | '#') || c.is_whitespace())
58            .unwrap_or(after.len());
59        let authority = &after[..auth_end];
60        match (authority.find('@'), authority.find(':')) {
61            (Some(at), Some(colon)) if colon < at => {
62                out.push_str(&authority[..colon]);
63                out.push_str(":***");
64                out.push_str(&authority[at..]);
65            }
66            _ => out.push_str(authority),
67        }
68        rest = &after[auth_end..];
69    }
70    out.push_str(rest);
71    out
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn always_redacted_headers_match_case_insensitively() {
80        for h in ["Cookie", "COOKIE", "cookie", "Set-Cookie", "SET-COOKIE"] {
81            assert!(should_redact(h), "{h}");
82        }
83        assert!(should_redact("Authorization"));
84        assert!(should_redact("Proxy-Authorization"));
85    }
86
87    #[test]
88    fn suffix_match_catches_token_and_secret_headers() {
89        assert!(should_redact("X-Api-Token"));
90        assert!(should_redact("x-csrf-token"));
91        assert!(should_redact("X-Shared-Secret"));
92        assert!(should_redact("x-bearer-secret"));
93    }
94
95    #[test]
96    fn unrelated_headers_pass_through() {
97        for h in ["Content-Type", "User-Agent", "Accept", "X-Trace-Id"] {
98            assert!(!should_redact(h), "{h}");
99        }
100    }
101
102    #[test]
103    fn userinfo_password_is_masked_in_text() {
104        assert_eq!(
105            redact_userinfo_passwords("launch --proxy-server=http://user:pass@proxy:8080 done"),
106            "launch --proxy-server=http://user:***@proxy:8080 done"
107        );
108        // username-only userinfo, host:port, and non-URL text are untouched.
109        assert_eq!(
110            redact_userinfo_passwords("http://user@host/x"),
111            "http://user@host/x"
112        );
113        assert_eq!(
114            redact_userinfo_passwords("connect socks5://10.0.0.5:1080 now"),
115            "connect socks5://10.0.0.5:1080 now"
116        );
117        assert_eq!(redact_userinfo_passwords("no url here"), "no url here");
118    }
119}