Skip to main content

vtcode_commons/
sanitizer.rs

1//! Secret sanitization utilities for redacting sensitive information.
2//!
3//! Provides regex-based secret redaction for:
4//! - OpenAI API keys (`sk-...`)
5//! - AWS Access Key IDs (`AKIA...`)
6//! - GitHub App installation tokens (`ghs_...`, including ~520-char stateless JWTs)
7//! - Other GitHub token prefixes (`ghp_`/`gho_`/`ghu_`/`ghr_`/`github_pat_...`)
8//! - Bearer tokens (`Bearer ...`)
9//! - Generic secret assignments (`api_key=...`, `password:...`, etc.)
10//!
11//! Use this module to sanitize text before logging, displaying in UI,
12//! or storing in session archives.
13//!
14//! GitHub App installation tokens are treated as opaque strings per
15//! <https://github.blog/changelog/2026-05-15-github-app-installation-tokens-per-request-override-header/>
16//! and <https://github.blog/changelog/2026-10-02-stateless-github-app-installation-tokens-rolled-out>:
17//! `ghs_`-prefixed JWTs (~520 chars, two dots) must not be subject to length
18//! assumptions and must be redacted even without a `Bearer` prefix.
19
20use regex::Regex;
21use std::sync::LazyLock;
22
23/// OpenAI API key pattern: sk- followed by alphanumeric characters
24static OPENAI_KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"sk-[A-Za-z0-9_-]{16,}"));
25
26/// AWS Access Key ID pattern: AKIA followed by 16 alphanumeric characters
27static AWS_ACCESS_KEY_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"\bAKIA[0-9A-Z]{16}\b"));
28
29/// GitHub App installation token pattern (recommended by GitHub):
30/// `ghs_[A-Za-z0-9\.\-_]{36,}` matches both legacy opaque tokens (~40 chars)
31/// and stateless JWT-format tokens (~520 chars, two dots).
32static GITHUB_APP_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"ghs_[A-Za-z0-9.\-_]{36,}"));
33
34/// Remaining GitHub token family: personal (`ghp_`), OAuth (`gho_`),
35/// user-to-server (`ghu_`), and refresh (`ghr_`) tokens share the classic
36/// 36+ char suffix shape; fine-grained PATs use the distinctive
37/// `github_pat_` prefix with a 22+ char suffix. Same opaque-string
38/// treatment as `ghs_`: redact on prefix alone, without length oracles.
39static 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
42/// Bearer token pattern: "Bearer " followed by token characters
43static BEARER_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"(?i)\bBearer\s+[A-Za-z0-9.\-_]{16,}\b"));
44
45/// Generic secret assignment pattern: key=value or key: value format
46/// Matches common secret key names like api_key, token, secret, password
47static 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
53/// Maximum serialized size of a provider diagnostic after redaction.
54pub const PROVIDER_DIAGNOSTIC_MAX_BYTES: usize = 8 * 1024;
55const PROVIDER_DIAGNOSTIC_TRUNCATION_MARKER: &str = "… [diagnostic truncated]";
56
57/// Replacement marker for control characters in terminal-bound text.
58const TERMINAL_CONTROL_REPLACEMENT: char = '\u{FFFD}';
59
60/// Bidi controls that reorder terminal output while occupying no visible space.
61///
62/// A crafted override (U+202E) in a displayed URI or domain can visually spoof a
63/// trusted value, so these are neutralized like control characters. Joiners and
64/// other format characters stay intact: legitimate names may contain emoji.
65fn 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
76/// Neutralize control and bidi-reordering characters and bound length for terminal display.
77///
78/// Server-originated strings (resource URIs, domains, names) must pass through
79/// here before reaching terminal output: raw newlines split rows, BEL/CR/ESC
80/// enable terminal mangling, bidi controls visually reorder text, and unbounded
81/// values blow up layouts. Shell metacharacters (`$`, backticks) are inert in
82/// terminal output and preserved.
83pub 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
100/// Join disclosed entries with per-item sanitizing and a count-capped tail.
101///
102/// Each item is neutralized via [`sanitize_terminal_text`] with `max_item_chars`;
103/// at most `max_items` are shown before the remainder folds into a count.
104pub 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
113/// Redact secrets and sensitive keys from a string.
114///
115/// This is a best-effort operation using well-known regex patterns.
116/// Redacted values are replaced with `[REDACTED_SECRET]`.
117///
118/// # Examples
119///
120/// ```
121/// use vtcode_commons::sanitizer::redact_secrets;
122///
123/// let input = format!("Found key: {}", concat!("sk-", "test1234567890abcdef"));
124/// let output = redact_secrets(input);
125/// assert_eq!(output, "Found key: [REDACTED_SECRET]");
126/// ```
127pub 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    // `into_owned` clones only when the final result is `Borrowed` (no regex
135    // matched at all); when any redaction occurred it moves the owned string
136    // without an extra allocation. Do NOT short-circuit on `Cow::Borrowed` —
137    // the final Cow is `Borrowed` whenever the *last* regex doesn't match,
138    // even if earlier regexes did, which would silently discard redactions.
139    r6.into_owned()
140}
141
142/// Redact secrets and return a bounded, UTF-8-safe provider diagnostic.
143///
144/// The input is sampled with a carry window so a secret beginning near the
145/// output boundary is still redacted before the final size limit is applied.
146pub 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/// Incrementally redact streamed output without retaining the full stream.
161///
162/// A bounded suffix is held between chunks so a secret split at an IO
163/// boundary is still matched by the same redaction rules as a complete line.
164#[derive(Debug, Default)]
165pub struct StreamingSecretRedactor {
166    pending: String,
167}
168
169const STREAMING_REDACTION_CARRY_BYTES: usize = 1_024;
170
171impl StreamingSecretRedactor {
172    /// Redact and return the safe prefix of `chunk`. The returned string may
173    /// be empty while the bounded carry window is being filled.
174    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    /// Redact and return the final carried suffix.
193    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        // Panic is acceptable thanks to the `load_regex` test
206        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        // Verify all regex patterns compile without panicking
217        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        // Assemble the documentation fixture at runtime so repository scans do not
230        // mistake it for a live credential.
231        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        // Legacy opaque format (~40 chars, no dots) and stateless JWT format
247        // (~520 chars, two dots) per May/Oct 2026 changelogs. Both must be
248        // treated as opaque and redacted even without a Bearer prefix.
249        // Fixtures are assembled at runtime so scans do not flag them.
250        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        // Locks the GitHub-recommended `ghs_[A-Za-z0-9.\-_]{36,}` boundary:
271        // 35 chars must not redact on its own, 36 chars must.
272        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        // Fixtures are assembled at runtime so scans do not flag them.
281        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        // `gh?_` prefixes need a 36+ char suffix, `github_pat_` needs 22+;
296        // shorter lookalikes (docs, placeholders) must survive.
297        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        // A ~526-char stateless token fits in the 1_024-byte carry window, so
310        // halves split at an IO boundary must still redact on finish.
311        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        // Values under 8 characters should not be redacted
346        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        // Verify both secrets are redacted
358        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        // Zero-width joiners keep emoji sequences intact.
409        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        // Per-item caps apply before joining; controls in any item are neutralized.
421        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}