1use std::sync::LazyLock;
9
10use regex::Regex;
11
12static SECRET_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
13 let pattern = |source: &str| Regex::new(source).expect("static redaction regex must compile");
14 vec![
15 (
16 pattern(
17 r#"(?i)\b([A-Z0-9_]*(?:API[_-]?KEY|APIKEY|SECRET|TOKEN|PASSWORD|PASSWD|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?))\b\s*[:=]\s*(?:"(?:\\.|[^"\\\r\n])*"?|'(?:\\.|[^'\\\r\n])*'?|[^\s"']+)"#,
18 ),
19 "${1}=[REDACTED]",
20 ),
21 (
22 pattern(r#"(?i)\b(Bearer)\s+[A-Za-z0-9._~+/\-]+={0,}"#),
23 "${1} [REDACTED]",
24 ),
25 (
26 pattern(
27 r#"(?i)\b((?:Proxy-)?Authorization)\b\s*[:=]\s*((?:[A-Za-z][A-Za-z0-9._~+/-]*\s+)?)[^\r\n]+"#,
28 ),
29 "${1}: ${2}[REDACTED]",
30 ),
31 (
32 pattern(r#"\bsk-(?:ant-)?[A-Za-z0-9._\-]{12,}"#),
33 "[REDACTED]",
34 ),
35 (pattern(r#"\bAKIA[0-9A-Z]{16}\b"#), "[REDACTED]"),
36 (pattern(r#"\bgh[pousr]_[A-Za-z0-9]{20,}\b"#), "[REDACTED]"),
37 (pattern(r#"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"#), "[REDACTED]"),
38 (pattern(r#"\bAIza[0-9A-Za-z._\-]{20,}\b"#), "[REDACTED]"),
39 (pattern(r#"\bgsk_[A-Za-z0-9]{20,}\b"#), "[REDACTED]"),
40 (
41 pattern(r#"\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b"#),
42 "[REDACTED]",
43 ),
44 (
45 pattern(r#"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"#),
46 "[REDACTED]",
47 ),
48 (
49 pattern(
50 r#"(?is)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\z)"#,
51 ),
52 "[REDACTED PRIVATE KEY]",
53 ),
54 (pattern(r#"(://[^/:@\s]+:)[^/@\s]{3,}@"#), "${1}[REDACTED]@"),
55 (
56 pattern(
57 r#"(?i)([?&](?:access[_-]?key|access[_-]?token|api[_-]?key|apikey|auth|authorization|client[_-]?secret|code|credential|googleaccessid|id[_-]?token|jwt|key|key-pair-id|password|policy|refresh[_-]?token|secret|session[_-]?token|sig|signature|token|x-amz-credential|x-amz-signature)=)[^&#\s]+"#,
58 ),
59 "${1}[REDACTED]",
60 ),
61 ]
62});
63
64static HTTP_URL_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
65 Regex::new(r#"(?i)https?://[^\s<>"']+"#).expect("static URL redaction regex must compile")
66});
67
68const BARE_CREDENTIAL_KEYS: &[&str] = &[
69 "authorization",
70 "auth",
71 "password",
72 "token",
73 "secret",
74 "apikey",
75];
76
77fn key_names_credential(key: &str) -> bool {
78 let normalized = normalize_credential_key(key);
79 if BARE_CREDENTIAL_KEYS.iter().any(|bare| normalized == *bare) {
80 return true;
81 }
82
83 let segments: Vec<_> = normalized
84 .split('_')
85 .filter(|segment| !segment.is_empty())
86 .collect();
87 if segments.iter().any(|segment| {
88 matches!(
89 *segment,
90 "password" | "passwd" | "secret" | "credential" | "credentials" | "apikey"
91 )
92 }) {
93 return true;
94 }
95 if segments
96 .windows(2)
97 .any(|pair| matches!(pair, ["api" | "access" | "private", "key"]))
98 {
99 return true;
100 }
101
102 segments.last() == Some(&"token")
108}
109
110fn normalize_credential_key(key: &str) -> String {
111 let mut normalized = String::with_capacity(key.len());
112 let mut previous_was_lower_or_digit = false;
113 for ch in key.chars() {
114 if matches!(ch, '-' | ' ' | '.') {
115 if !normalized.ends_with('_') {
116 normalized.push('_');
117 }
118 previous_was_lower_or_digit = false;
119 } else {
120 if ch.is_ascii_uppercase() && previous_was_lower_or_digit {
121 normalized.push('_');
122 }
123 normalized.extend(ch.to_lowercase());
124 previous_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
125 }
126 }
127 normalized
128}
129
130pub fn redact_secrets(input: &str) -> String {
132 let mut output = redact_secret_shapes(input);
133 if HTTP_URL_PATTERN.is_match(&output) {
134 output = std::borrow::Cow::Owned(
135 HTTP_URL_PATTERN
136 .replace_all(&output, |captures: ®ex::Captures<'_>| {
137 sanitize_url_match(&captures[0])
138 })
139 .into_owned(),
140 );
141 }
142 output.into_owned()
143}
144
145fn redact_secret_shapes(input: &str) -> std::borrow::Cow<'_, str> {
146 let mut output = std::borrow::Cow::Borrowed(input);
147 for (regex, replacement) in SECRET_PATTERNS.iter() {
148 if regex.is_match(&output) {
149 output = std::borrow::Cow::Owned(regex.replace_all(&output, *replacement).into_owned());
150 }
151 }
152 output
153}
154
155fn sanitize_url_match(raw: &str) -> String {
156 let core = raw.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']', '}']);
157 let trailing = &raw[core.len()..];
158 let Ok(url) = url::Url::parse(core) else {
159 return raw.to_string();
160 };
161 format!("{}{trailing}", sanitize_parsed_url(url))
162}
163
164pub fn redact_json(value: &mut serde_json::Value) {
166 match value {
167 serde_json::Value::String(string) => {
168 let redacted = if url::Url::parse(string)
169 .is_ok_and(|url| matches!(url.scheme(), "http" | "https"))
170 {
171 sanitize_url_for_display(string)
172 } else {
173 redact_secrets(string)
174 };
175 if &redacted != string {
176 *string = redacted;
177 }
178 },
179 serde_json::Value::Array(items) => items.iter_mut().for_each(redact_json),
180 serde_json::Value::Object(map) => {
181 for (key, value) in map {
182 if key_names_credential(key) && !value.is_null() {
183 *value = serde_json::Value::String("[REDACTED]".to_string());
184 } else {
185 redact_json(value);
186 }
187 }
188 },
189 _ => {},
190 }
191}
192
193pub fn redact_json_text(input: &str) -> String {
196 match serde_json::from_str::<serde_json::Value>(input) {
197 Ok(mut value) => {
198 redact_json(&mut value);
199 serde_json::to_string(&value).unwrap_or_else(|_| redact_secrets(input))
200 },
201 Err(_) => redact_secrets(input),
202 }
203}
204
205pub fn sanitize_url_for_display(input: &str) -> String {
207 let Ok(url) = url::Url::parse(input) else {
208 return redact_secrets(input);
209 };
210 sanitize_parsed_url(url)
211}
212
213fn sanitize_parsed_url(mut url: url::Url) -> String {
214 let _ = url.set_username("");
215 let _ = url.set_password(None);
216 url.set_fragment(None);
217
218 let path = url.path().to_string();
219 let redacted_path = redact_secret_shapes(&path);
220 if redacted_path != path {
221 url.set_path(&redacted_path);
222 }
223
224 if url.query().is_some() {
225 let pairs: Vec<(String, String)> = url
226 .query_pairs()
227 .map(|(key, value)| {
228 let value = if sensitive_query_key(&key) || redact_secrets(&value) != value {
229 "[REDACTED]".to_string()
230 } else {
231 value.into_owned()
232 };
233 (key.into_owned(), value)
234 })
235 .collect();
236 url.set_query(None);
237 if !pairs.is_empty() {
238 url.query_pairs_mut().extend_pairs(pairs);
239 }
240 }
241
242 url.to_string()
243}
244
245fn sensitive_query_key(key: &str) -> bool {
246 let normalized = normalize_credential_key(key);
247 key_names_credential(key)
248 || matches!(
249 normalized.as_str(),
250 "auth"
251 | "authorization"
252 | "code"
253 | "googleaccessid"
254 | "google_access_id"
255 | "jwt"
256 | "key"
257 | "key_pair_id"
258 | "policy"
259 | "sig"
260 | "signature"
261 )
262 || normalized.ends_with("_signature")
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn serialized_payload_redaction_covers_signed_urls_and_fetched_secrets() {
271 let input = serde_json::json!({
272 "url": "https://user:password@example.test/a?GoogleAccessId=opaque-id&Signature=opaque-signature#fragment",
273 "model_content": "OPENAI_API_KEY=sk-abcdefghijklmnop1234; see (https://alice:hunter2@example.test/page?q=1#private-state).",
274 "authorization": "opaque-bearer-value"
275 })
276 .to_string();
277 let redacted = redact_json_text(&input);
278 for secret in [
279 "user",
280 "password",
281 "opaque-id",
282 "opaque-signature",
283 "fragment",
284 "sk-abcdefghijklmnop1234",
285 "opaque-bearer-value",
286 "alice",
287 "hunter2",
288 "private-state",
289 ] {
290 assert!(!redacted.contains(secret), "leaked {secret}: {redacted}");
291 }
292 assert!(redacted.contains("REDACTED"));
293 }
294
295 #[test]
296 fn structured_credentials_are_redacted_regardless_of_value_type_or_length() {
297 let mut value = serde_json::json!({
298 "password": "abc",
299 "token": 12345,
300 "nested": {
301 "client_secret": true,
302 "accessToken": 67890,
303 "apiKey": false
304 },
305 "nullable_token": null,
306 "ordinary": 12345,
307 "max_tokens": 4096,
308 "promptTokens": 2048,
309 "token_count": 3,
310 "key": "Enter"
311 });
312 redact_json(&mut value);
313 assert_eq!(value["password"], "[REDACTED]");
314 assert_eq!(value["token"], "[REDACTED]");
315 assert_eq!(value["nested"]["client_secret"], "[REDACTED]");
316 assert_eq!(value["nested"]["accessToken"], "[REDACTED]");
317 assert_eq!(value["nested"]["apiKey"], "[REDACTED]");
318 assert!(value["nullable_token"].is_null());
319 assert_eq!(value["ordinary"], 12345);
320 assert_eq!(value["max_tokens"], 4096);
321 assert_eq!(value["promptTokens"], 2048);
322 assert_eq!(value["token_count"], 3);
323 assert_eq!(value["key"], "Enter");
324 }
325
326 #[test]
327 fn embedded_short_basic_and_private_key_secrets_are_fully_redacted() {
328 let input = "password=abc\nAuthorization: Basic dXNlcjphYmM=\n\
329 -----BEGIN PRIVATE KEY-----\n\
330 cHJpdmF0ZS1tYXRlcmlhbA==\n\
331 -----END PRIVATE KEY-----";
332 let redacted = redact_secrets(input);
333 for secret in ["password=abc", "dXNlcjphYmM=", "cHJpdmF0ZS1tYXRlcmlhbA=="] {
334 assert!(!redacted.contains(secret), "leaked {secret}: {redacted}");
335 }
336 assert!(redacted.contains("password=[REDACTED]"));
337 assert!(redacted.contains("Authorization: Basic [REDACTED]"));
338 assert!(redacted.contains("[REDACTED PRIVATE KEY]"));
339 assert!(!redacted.contains("-----END PRIVATE KEY-----"));
340
341 let truncated =
342 redact_secrets("before\n-----BEGIN EC PRIVATE KEY-----\ncHJpdmF0ZS1tYXRlcmlhbA==");
343 assert_eq!(truncated, "before\n[REDACTED PRIVATE KEY]");
344
345 let uppercase_url =
346 redact_secrets("see HTTPS://alice:pw@example.test/path?token=abc#private-state");
347 for secret in ["alice", "pw", "abc", "private-state"] {
348 assert!(
349 !uppercase_url.contains(secret),
350 "uppercase URL leaked {secret}: {uppercase_url}"
351 );
352 }
353 }
354
355 #[test]
356 fn quoted_assignments_and_authorization_schemes_are_fully_redacted() {
357 let input = "password=\"alpha beta\"\n\
358 client_secret='gamma delta'\n\
359 Authorization: Digest username=\"alice\", response=\"digest-secret\"\n\
360 Proxy-Authorization: Custom opaque proxy credential\n\
361 Authorization is required for this operation.";
362 let redacted = redact_secrets(input);
363
364 for secret in [
365 "alpha beta",
366 "gamma delta",
367 "alice",
368 "digest-secret",
369 "opaque proxy credential",
370 ] {
371 assert!(!redacted.contains(secret), "leaked {secret}: {redacted}");
372 }
373 assert!(redacted.contains("password=[REDACTED]"));
374 assert!(redacted.contains("client_secret=[REDACTED]"));
375 assert!(redacted.contains("Authorization: Digest [REDACTED]"));
376 assert!(redacted.contains("Proxy-Authorization: Custom [REDACTED]"));
377 assert!(redacted.contains("Authorization is required for this operation."));
378 }
379
380 #[test]
381 fn provider_signed_url_credentials_are_sanitized() {
382 for url in [
383 "https://storage.example/object?X-Goog-Credential=acct%2Fscope&X-Goog-Signature=abcdef&X-Goog-Date=20260721T000000Z",
384 "https://s3.example/object?X-Amz-Credential=acct%2Fscope&X-Amz-Signature=abcdef&X-Amz-Security-Token=session-token&response-content-type=text%2Fplain",
385 ] {
386 let sanitized = sanitize_url_for_display(url);
387 assert!(!sanitized.contains("acct"), "{sanitized}");
388 assert!(!sanitized.contains("abcdef"), "{sanitized}");
389 assert!(!sanitized.contains("session-token"), "{sanitized}");
390 assert!(sanitized.contains("REDACTED"), "{sanitized}");
391 }
392 }
393
394 #[test]
395 fn camel_case_query_credentials_are_sanitized_without_hiding_ordinary_values() {
396 let sanitized = sanitize_url_for_display(
397 "https://example.test/callback?accessToken=opaque-access&clientSecret=opaque-client&ordinary=visible",
398 );
399
400 assert!(!sanitized.contains("opaque-access"), "{sanitized}");
401 assert!(!sanitized.contains("opaque-client"), "{sanitized}");
402 assert!(sanitized.contains("ordinary=visible"), "{sanitized}");
403 }
404
405 #[test]
406 fn exact_url_paths_and_short_bearer_values_are_shape_redacted() {
407 let token = "sk-abcdefghijklmnop1234";
408 let sanitized = sanitize_url_for_display(&format!("https://example.test/download/{token}"));
409 assert!(!sanitized.contains(token), "URL path leaked: {sanitized}");
410 assert!(sanitized.contains("REDACTED"));
411 assert_eq!(
412 redact_secrets("Authorization: Bearer abc"),
413 "Authorization: Bearer [REDACTED]"
414 );
415 }
416}