agent_first_http/shared/
redact.rs1pub const ALWAYS_REDACTED: &[&str] = &[
11 "cookie",
12 "set-cookie",
13 "authorization",
14 "proxy-authorization",
15];
16
17pub const REDACTED_SUFFIXES: &[&str] = &["-token", "-secret"];
21
22pub const URL_SECRET_NAMES: &[&str] = &[
28 "access_token",
29 "api_key",
30 "auth",
31 "authorization",
32 "code",
33 "handoff",
34 "handoff_secret",
35 "id_token",
36 "key",
37 "password",
38 "passwd",
39 "session",
40 "sessionid",
41 "sig",
42 "signature",
43 "token",
44];
45
46#[must_use]
49pub fn should_redact(header_name: &str) -> bool {
50 let lower = header_name.to_ascii_lowercase();
51 if ALWAYS_REDACTED.iter().any(|h| *h == lower) {
52 return true;
53 }
54 REDACTED_SUFFIXES
55 .iter()
56 .any(|suffix| lower.ends_with(suffix))
57}
58
59pub const REDACTED_VALUE: &str = "[redacted]";
61
62#[must_use]
67fn url_redactor() -> agent_first_data::Redactor {
68 agent_first_data::Redactor::new().secret_names(URL_SECRET_NAMES.iter().copied())
69}
70
71#[must_use]
74pub fn redact_url(url: &str) -> String {
75 url_redactor().url(url)
76}
77
78#[must_use]
85pub fn redact_url_fields(value: &serde_json::Value) -> serde_json::Value {
86 let mut value = value.clone();
87 redact_url_fields_in_place(&mut value);
88 value
89}
90
91#[must_use]
94pub fn redact_value(value: &serde_json::Value) -> serde_json::Value {
95 agent_first_data::Redactor::new().value(&redact_url_fields(value))
96}
97
98fn redact_url_fields_in_place(value: &mut serde_json::Value) {
99 match value {
100 serde_json::Value::Object(fields) => {
101 for (name, value) in fields {
102 if (name.ends_with("_url") || name.ends_with("_URL"))
103 && let serde_json::Value::String(url) = value
104 {
105 *url = redact_url(url);
106 } else {
107 redact_url_fields_in_place(value);
108 }
109 }
110 }
111 serde_json::Value::Array(values) => {
112 for value in values {
113 redact_url_fields_in_place(value);
114 }
115 }
116 _ => {}
117 }
118}
119
120#[must_use]
130pub fn redact_userinfo_passwords(line: &str) -> String {
131 let mut out = String::with_capacity(line.len());
132 let mut rest = line;
133 while let Some(pos) = rest.find("://") {
134 out.push_str(&rest[..pos + 3]);
136 let after = &rest[pos + 3..];
137 let auth_end = after
139 .find(|c: char| matches!(c, '/' | '?' | '#') || c.is_whitespace())
140 .unwrap_or(after.len());
141 let authority = &after[..auth_end];
142 match (authority.find('@'), authority.find(':')) {
143 (Some(at), Some(colon)) if colon < at => {
144 out.push_str(&authority[..colon]);
145 out.push_str(":***");
146 out.push_str(&authority[at..]);
147 }
148 _ => out.push_str(authority),
149 }
150 rest = &after[auth_end..];
151 }
152 out.push_str(rest);
153 out
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn always_redacted_headers_match_case_insensitively() {
162 for h in ["Cookie", "COOKIE", "cookie", "Set-Cookie", "SET-COOKIE"] {
163 assert!(should_redact(h), "{h}");
164 }
165 assert!(should_redact("Authorization"));
166 assert!(should_redact("Proxy-Authorization"));
167 }
168
169 #[test]
170 fn suffix_match_catches_token_and_secret_headers() {
171 assert!(should_redact("X-Api-Token"));
172 assert!(should_redact("x-csrf-token"));
173 assert!(should_redact("X-Shared-Secret"));
174 assert!(should_redact("x-bearer-secret"));
175 }
176
177 #[test]
178 fn unrelated_headers_pass_through() {
179 for h in ["Content-Type", "User-Agent", "Accept", "X-Trace-Id"] {
180 assert!(!should_redact(h), "{h}");
181 }
182 }
183
184 #[test]
185 fn url_redaction_covers_userinfo_and_legacy_query_names() {
186 assert_eq!(
187 redact_url("https://user:pass@example.test/path?token=abc&safe=ok&next_secret=hidden"),
188 "https://user:***@example.test/path?token=***&safe=ok&next_secret=***"
189 );
190 }
191
192 #[test]
193 fn url_compatibility_names_do_not_redact_ordinary_fields() {
194 let redacted = redact_value(&serde_json::json!({
195 "code": "fetch",
196 "token": "public field",
197 "request_url": "https://example.test/?code=otp&token=bearer&safe=ok",
198 "token_secret": "private"
199 }));
200 assert_eq!(redacted["code"], "fetch");
201 assert_eq!(redacted["token"], "public field");
202 assert_eq!(
203 redacted["request_url"],
204 "https://example.test/?code=***&token=***&safe=ok"
205 );
206 assert_eq!(redacted["token_secret"], "***");
207 }
208
209 #[test]
210 fn userinfo_password_is_masked_in_text() {
211 assert_eq!(
212 redact_userinfo_passwords("launch --proxy-server=http://user:pass@proxy:8080 done"),
213 "launch --proxy-server=http://user:***@proxy:8080 done"
214 );
215 assert_eq!(
217 redact_userinfo_passwords("http://user@host/x"),
218 "http://user@host/x"
219 );
220 assert_eq!(
221 redact_userinfo_passwords("connect socks5://10.0.0.5:1080 now"),
222 "connect socks5://10.0.0.5:1080 now"
223 );
224 assert_eq!(redact_userinfo_passwords("no url here"), "no url here");
225 }
226}