1use regex::Regex;
21use std::sync::LazyLock;
22
23static OPENAI_KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"sk-[A-Za-z0-9_-]{16,}"));
25
26static AWS_ACCESS_KEY_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"\bAKIA[0-9A-Z]{16}\b"));
28
29static GITHUB_APP_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"ghs_[A-Za-z0-9.\-_]{36,}"));
33
34static GITHUB_TOKEN_REGEX: LazyLock<Regex> =
40 LazyLock::new(|| compile_regex(r"(?:gh[pour]_[A-Za-z0-9.\-_]{36,}|github_pat_[A-Za-z0-9.\-_]{22,})"));
41
42static BEARER_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"(?i)\bBearer\s+[A-Za-z0-9.\-_]{16,}\b"));
44
45static SECRET_ASSIGNMENT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
48 compile_regex(
49 r#"(?i)\b((?:[a-z0-9][a-z0-9_-]*?)?(?:api[\-_]?key|access[\-_]?key|client[\-_]?secret|credential|private[\-_]?key|token|secret|password|auth)[a-z0-9_-]*)\b(\s*[:=]\s*)(["']?)[^\s"']{8,}"#,
50 )
51});
52
53pub const PROVIDER_DIAGNOSTIC_MAX_BYTES: usize = 8 * 1024;
55const PROVIDER_DIAGNOSTIC_TRUNCATION_MARKER: &str = "… [diagnostic truncated]";
56
57const TERMINAL_CONTROL_REPLACEMENT: char = '\u{FFFD}';
59
60fn is_bidi_control(character: char) -> bool {
66 matches!(
67 character,
68 '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
69 )
70}
71
72fn is_neutralized_terminal_char(character: char) -> bool {
73 character.is_control() || is_bidi_control(character)
74}
75
76pub fn sanitize_terminal_text(input: &str, max_chars: usize) -> String {
84 if input.chars().all(|character| !is_neutralized_terminal_char(character)) {
85 return crate::formatting::truncate_text(input, max_chars, "…");
86 }
87 let neutralized: String = input
88 .chars()
89 .map(|character| {
90 if is_neutralized_terminal_char(character) {
91 TERMINAL_CONTROL_REPLACEMENT
92 } else {
93 character
94 }
95 })
96 .collect();
97 crate::formatting::truncate_text(&neutralized, max_chars, "…")
98}
99
100pub fn join_sanitized_truncated(items: &[String], max_items: usize, max_item_chars: usize) -> String {
105 let sanitized: Vec<String> = items.iter().map(|item| sanitize_terminal_text(item, max_item_chars)).collect();
106 if sanitized.len() <= max_items {
107 return sanitized.join(", ");
108 }
109 let shown: Vec<&str> = sanitized.iter().take(max_items).map(String::as_str).collect();
110 format!("{} … +{} more", shown.join(", "), sanitized.len() - max_items)
111}
112
113pub fn redact_secrets(input: String) -> String {
128 let r1 = OPENAI_KEY_REGEX.replace_all(&input, "[REDACTED_SECRET]");
129 let r2 = AWS_ACCESS_KEY_ID_REGEX.replace_all(&r1, "[REDACTED_SECRET]");
130 let r3 = GITHUB_APP_TOKEN_REGEX.replace_all(&r2, "[REDACTED_SECRET]");
131 let r4 = GITHUB_TOKEN_REGEX.replace_all(&r3, "[REDACTED_SECRET]");
132 let r5 = BEARER_TOKEN_REGEX.replace_all(&r4, "Bearer [REDACTED_SECRET]");
133 let r6 = SECRET_ASSIGNMENT_REGEX.replace_all(&r5, "$1$2$3[REDACTED_SECRET]");
134 r6.into_owned()
140}
141
142pub fn sanitize_provider_diagnostic(input: impl AsRef<[u8]>) -> String {
147 let input = input.as_ref();
148 let sample_len = input.len().min(PROVIDER_DIAGNOSTIC_MAX_BYTES + STREAMING_REDACTION_CARRY_BYTES);
149 let sample = String::from_utf8_lossy(input.get(..sample_len).unwrap_or(input));
150 let redacted = redact_secrets(sample.into_owned());
151 if redacted.len() <= PROVIDER_DIAGNOSTIC_MAX_BYTES {
152 return redacted;
153 }
154
155 let content_limit = PROVIDER_DIAGNOSTIC_MAX_BYTES.saturating_sub(PROVIDER_DIAGNOSTIC_TRUNCATION_MARKER.len());
156 let end = redacted.floor_char_boundary(content_limit);
157 format!("{}{}", redacted.get(..end).unwrap_or(&redacted), PROVIDER_DIAGNOSTIC_TRUNCATION_MARKER)
158}
159
160#[derive(Debug, Default)]
165pub struct StreamingSecretRedactor {
166 pending: String,
167}
168
169const STREAMING_REDACTION_CARRY_BYTES: usize = 1_024;
170
171impl StreamingSecretRedactor {
172 pub fn push(&mut self, chunk: &str) -> String {
175 self.pending.push_str(chunk);
176 if self.pending.len() <= STREAMING_REDACTION_CARRY_BYTES {
177 if !self.pending.contains('\n') {
178 return String::new();
179 }
180 }
181
182 let carry_split = self.pending.len().saturating_sub(STREAMING_REDACTION_CARRY_BYTES);
183 let line_split = self.pending.rfind('\n').map(|index| index + 1).unwrap_or(0);
184 let mut split_at = carry_split.max(line_split);
185 while split_at > 0 && !self.pending.is_char_boundary(split_at) {
186 split_at -= 1;
187 }
188 let prefix: String = self.pending.drain(..split_at).collect();
189 redact_secrets(prefix)
190 }
191
192 pub fn finish(self) -> String {
194 redact_secrets(self.pending)
195 }
196}
197
198#[allow(
199 clippy::panic,
200 reason = "Intentional compatibility, platform, or test-only suppression."
201)]
202fn compile_regex(pattern: &str) -> Regex {
203 match Regex::new(pattern) {
204 Ok(regex) => regex,
205 Err(err) => panic!("invalid regex pattern `{pattern}`: {err}"),
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn load_regex() {
216 let _ = redact_secrets("test".to_string());
218 }
219
220 #[test]
221 fn redacts_openai_key() {
222 let input = format!("Found key: {}", concat!("sk-", "test1234567890abcdef"));
223 let output = redact_secrets(input);
224 assert_eq!(output, "Found key: [REDACTED_SECRET]");
225 }
226
227 #[test]
228 fn redacts_aws_access_key() {
229 let aws_key = concat!("AKIA", "IOSFODNN7EXAMPLE");
232 let input = format!(" creds: {aws_key} ");
233 let output = redact_secrets(input);
234 assert_eq!(output, " creds: [REDACTED_SECRET] ");
235 }
236
237 #[test]
238 fn redacts_bearer_token() {
239 let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9".to_string();
240 let output = redact_secrets(input);
241 assert_eq!(output, "Authorization: Bearer [REDACTED_SECRET]");
242 }
243
244 #[test]
245 fn redacts_github_app_installation_tokens() {
246 let legacy = format!("ghs_{}", "a".repeat(36));
251 let header = format!("ghs_{}", "b".repeat(20));
252 let payload = format!("{}.", "c".repeat(250));
253 let signature = "d".repeat(250);
254 let stateless = format!("{header}.{payload}{signature}");
255 assert!(stateless.len() >= 500, "fixture should model ~520-char token");
256 assert_eq!(stateless.matches('.').count(), 2);
257
258 for token in [&legacy, &stateless] {
259 let bare = redact_secrets(format!("saw token {token} in logs"));
260 assert_eq!(bare, "saw token [REDACTED_SECRET] in logs");
261 assert!(!bare.contains(token.as_str()));
262
263 let bearer = redact_secrets(format!("Authorization: Bearer {token}"));
264 assert!(!bearer.contains(token.as_str()));
265 }
266 }
267
268 #[test]
269 fn github_app_token_threshold_is_36_chars() {
270 let short = format!("ghs_{}", "a".repeat(35));
273 let exact = format!("ghs_{}", "a".repeat(36));
274 assert_eq!(redact_secrets(format!("saw {short} here")), format!("saw {short} here"));
275 assert_eq!(redact_secrets(format!("saw {exact} here")), "saw [REDACTED_SECRET] here");
276 }
277
278 #[test]
279 fn redacts_github_token_family_besides_app_tokens() {
280 for prefix in ["ghp_", "gho_", "ghu_", "ghr_"] {
282 let token = format!("{prefix}{}", "e".repeat(36));
283 let output = redact_secrets(format!("pasted {token} in chat"));
284 assert_eq!(output, "pasted [REDACTED_SECRET] in chat", "{prefix}");
285 assert!(!output.contains(token.as_str()));
286 }
287 let fine_grained = format!("github_pat_{}", "f".repeat(22));
288 let output = redact_secrets(format!("pasted {fine_grained} in chat"));
289 assert_eq!(output, "pasted [REDACTED_SECRET] in chat");
290 assert!(!output.contains(fine_grained.as_str()));
291 }
292
293 #[test]
294 fn github_token_family_thresholds_match_app_token_policy() {
295 let short_classic = format!("ghp_{}", "a".repeat(35));
298 let exact_classic = format!("ghp_{}", "a".repeat(36));
299 assert_eq!(redact_secrets(format!("saw {short_classic} here")), format!("saw {short_classic} here"));
300 assert_eq!(redact_secrets(format!("saw {exact_classic} here")), "saw [REDACTED_SECRET] here");
301 let short_pat = format!("github_pat_{}", "a".repeat(21));
302 let exact_pat = format!("github_pat_{}", "a".repeat(22));
303 assert_eq!(redact_secrets(format!("saw {short_pat} here")), format!("saw {short_pat} here"));
304 assert_eq!(redact_secrets(format!("saw {exact_pat} here")), "saw [REDACTED_SECRET] here");
305 }
306
307 #[test]
308 fn redacts_github_app_token_split_across_stream_chunks() {
309 let token = format!("ghs_{}.{}.{}", "b".repeat(20), "c".repeat(250), "d".repeat(250));
312 let mid = token.floor_char_boundary(token.len() / 2);
313 let (first, second) = token.split_at(mid);
314 let mut redactor = StreamingSecretRedactor::default();
315 let mut output = redactor.push(first);
316 output.push_str(&redactor.push(&format!("{second}\n")));
317 output.push_str(&redactor.finish());
318 assert!(!output.contains(token.as_str()));
319 assert!(output.contains("[REDACTED_SECRET]"));
320 }
321
322 #[test]
323 fn redacts_api_key_assignment() {
324 let input = "api_key=sk-test12345678".to_string();
325 let output = redact_secrets(input);
326 assert_eq!(output, "api_key=[REDACTED_SECRET]");
327 }
328
329 #[test]
330 fn redacts_password_assignment() {
331 let input = "password: mysecretvalue".to_string();
332 let output = redact_secrets(input);
333 assert_eq!(output, "password: [REDACTED_SECRET]");
334 }
335
336 #[test]
337 fn redacts_token_in_quotes() {
338 let input = r#"token="abc123xyz789abcdef""#.to_string();
339 let output = redact_secrets(input);
340 assert_eq!(output, r#"token="[REDACTED_SECRET]""#);
341 }
342
343 #[test]
344 fn preserves_short_values() {
345 let input = "password: short".to_string();
347 let output = redact_secrets(input);
348 assert_eq!(output, "password: short");
349 }
350
351 #[test]
352 fn redacts_multiple_secrets() {
353 let openai_key = concat!("sk-", "test1234567890abcdef");
354 let aws_key = concat!("AKIA", "IOSFODNN7EXAMPLE");
355 let input = format!("Keys: {openai_key} and {aws_key}");
356 let output = redact_secrets(input);
357 assert!(output.contains("[REDACTED_SECRET]"));
359 assert!(!output.contains(openai_key));
360 assert!(!output.contains(aws_key));
361 }
362
363 #[test]
364 fn preserves_non_secret_text() {
365 let input = "Hello world, this is normal text".to_string();
366 let output = redact_secrets(input);
367 assert_eq!(output, "Hello world, this is normal text");
368 }
369
370 #[test]
371 fn redacts_secrets_split_across_stream_chunks() {
372 let mut redactor = StreamingSecretRedactor::default();
373 let mut output = redactor.push("password=superse");
374 output.push_str(&redactor.push("cretvalue\n"));
375 output.push_str(&redactor.finish());
376
377 assert_eq!(output, "password=[REDACTED_SECRET]\n");
378 assert!(!output.contains("supersecretvalue"));
379 }
380
381 #[test]
382 fn terminal_text_neutralizes_controls_and_bounds_length() {
383 assert_eq!(sanitize_terminal_text("", 8), "");
384 assert_eq!(sanitize_terminal_text("abc", 8), "abc");
385 assert_eq!(sanitize_terminal_text("abcdefgh", 8), "abcdefgh");
386 assert_eq!(sanitize_terminal_text("abcdefghi", 8), "abcdefgh…");
387 assert_eq!(sanitize_terminal_text("ab", 0), "…");
388 }
389
390 #[test]
391 fn terminal_text_replaces_controls_but_preserves_shell_metachars() {
392 assert_eq!(sanitize_terminal_text("a\nb\rc\td\0e\x07f\x1bg", 64), "a�b�c�d�e�f�g");
393 assert_eq!(sanitize_terminal_text("price is $5 `code` ~user", 64), "price is $5 `code` ~user");
394 assert_eq!(sanitize_terminal_text("ui://x/\u{7f}app", 64), "ui://x/�app");
395 }
396
397 #[test]
398 fn terminal_text_truncates_on_char_boundaries() {
399 assert_eq!(sanitize_terminal_text("日本語テスト", 4), "日本語テ…");
400 assert_eq!(sanitize_terminal_text("日本語", 4), "日本語");
401 }
402
403 #[test]
404 fn terminal_text_neutralizes_bidi_controls_but_preserves_joiners() {
405 assert_eq!(sanitize_terminal_text("ui://\u{202e}evil/app", 64), "ui://�evil/app");
406 assert_eq!(sanitize_terminal_text("a\u{2066}b\u{2069}c", 64), "a�b�c");
407 assert_eq!(sanitize_terminal_text("x\u{200e}y\u{200f}z", 64), "x�y�z");
408 assert_eq!(sanitize_terminal_text("fam 👨\u{200d}👩\u{200d}👧 ok", 64), "fam 👨\u{200d}👩\u{200d}👧 ok");
410 }
411
412 #[test]
413 fn join_sanitized_truncated_folds_only_past_max() {
414 let items: Vec<String> = (0..8).map(|n| format!("d{n}")).collect();
415 assert_eq!(join_sanitized_truncated(&items, 8, usize::MAX), "d0, d1, d2, d3, d4, d5, d6, d7");
416 let items: Vec<String> = (0..9).map(|n| format!("d{n}")).collect();
417 assert_eq!(join_sanitized_truncated(&items, 8, usize::MAX), "d0, d1, d2, d3, d4, d5, d6, d7 … +1 more");
418 let items: Vec<String> = (0..11).map(|n| format!("d{n}")).collect();
419 assert_eq!(join_sanitized_truncated(&items, 8, usize::MAX), "d0, d1, d2, d3, d4, d5, d6, d7 … +3 more");
420 let items = vec!["abcdefghij".to_string(), "x\ny".to_string()];
422 assert_eq!(join_sanitized_truncated(&items, 8, 4), "abcd…, x�y");
423 assert!(join_sanitized_truncated(&[], 8, usize::MAX).is_empty());
424 }
425
426 #[test]
427 fn provider_diagnostic_is_bounded_utf8_safe_and_redacted() {
428 let mut input = b"api_key=diagnostic-secret-value Bearer abcdefghijklmnop ".to_vec();
429 input.extend(std::iter::repeat_n(b'x', 20_000));
430 input.extend_from_slice("終端".as_bytes());
431 input.push(0xff);
432
433 let output = sanitize_provider_diagnostic(input);
434
435 assert!(output.len() <= PROVIDER_DIAGNOSTIC_MAX_BYTES);
436 assert!(output.is_char_boundary(output.len()));
437 assert!(!output.contains("diagnostic-secret-value"));
438 assert!(!output.contains("abcdefghijklmnop"));
439 }
440}