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//! - Bearer tokens (`Bearer ...`)
7//! - Generic secret assignments (`api_key=...`, `password:...`, etc.)
8//!
9//! Use this module to sanitize text before logging, displaying in UI,
10//! or storing in session archives.
11
12use regex::Regex;
13use std::sync::LazyLock;
14
15/// OpenAI API key pattern: sk- followed by alphanumeric characters
16static OPENAI_KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"sk-[A-Za-z0-9_-]{16,}"));
17
18/// AWS Access Key ID pattern: AKIA followed by 16 alphanumeric characters
19static AWS_ACCESS_KEY_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"\bAKIA[0-9A-Z]{16}\b"));
20
21/// Bearer token pattern: "Bearer " followed by token characters
22static BEARER_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"(?i)\bBearer\s+[A-Za-z0-9.\-_]{16,}\b"));
23
24/// Generic secret assignment pattern: key=value or key: value format
25/// Matches common secret key names like api_key, token, secret, password
26static SECRET_ASSIGNMENT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
27    compile_regex(
28        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,}"#,
29    )
30});
31
32/// Maximum serialized size of a provider diagnostic after redaction.
33pub const PROVIDER_DIAGNOSTIC_MAX_BYTES: usize = 8 * 1024;
34const PROVIDER_DIAGNOSTIC_TRUNCATION_MARKER: &str = "… [diagnostic truncated]";
35
36/// Redact secrets and sensitive keys from a string.
37///
38/// This is a best-effort operation using well-known regex patterns.
39/// Redacted values are replaced with `[REDACTED_SECRET]`.
40///
41/// # Examples
42///
43/// ```
44/// use vtcode_commons::sanitizer::redact_secrets;
45///
46/// let input = format!("Found key: {}", concat!("sk-", "test1234567890abcdef"));
47/// let output = redact_secrets(input);
48/// assert_eq!(output, "Found key: [REDACTED_SECRET]");
49/// ```
50pub fn redact_secrets(input: String) -> String {
51    let r1 = OPENAI_KEY_REGEX.replace_all(&input, "[REDACTED_SECRET]");
52    let r2 = AWS_ACCESS_KEY_ID_REGEX.replace_all(&r1, "[REDACTED_SECRET]");
53    let r3 = BEARER_TOKEN_REGEX.replace_all(&r2, "Bearer [REDACTED_SECRET]");
54    let r4 = SECRET_ASSIGNMENT_REGEX.replace_all(&r3, "$1$2$3[REDACTED_SECRET]");
55    // `into_owned` clones only when the final result is `Borrowed` (no regex
56    // matched at all); when any redaction occurred it moves the owned string
57    // without an extra allocation. Do NOT short-circuit on `Cow::Borrowed` —
58    // the final Cow is `Borrowed` whenever the *last* regex doesn't match,
59    // even if earlier regexes did, which would silently discard redactions.
60    r4.into_owned()
61}
62
63/// Redact secrets and return a bounded, UTF-8-safe provider diagnostic.
64///
65/// The input is sampled with a carry window so a secret beginning near the
66/// output boundary is still redacted before the final size limit is applied.
67pub fn sanitize_provider_diagnostic(input: impl AsRef<[u8]>) -> String {
68    let input = input.as_ref();
69    let sample_len = input.len().min(PROVIDER_DIAGNOSTIC_MAX_BYTES + STREAMING_REDACTION_CARRY_BYTES);
70    let sample = String::from_utf8_lossy(input.get(..sample_len).unwrap_or(input));
71    let redacted = redact_secrets(sample.into_owned());
72    if redacted.len() <= PROVIDER_DIAGNOSTIC_MAX_BYTES {
73        return redacted;
74    }
75
76    let content_limit = PROVIDER_DIAGNOSTIC_MAX_BYTES.saturating_sub(PROVIDER_DIAGNOSTIC_TRUNCATION_MARKER.len());
77    let end = redacted.floor_char_boundary(content_limit);
78    format!("{}{}", redacted.get(..end).unwrap_or(&redacted), PROVIDER_DIAGNOSTIC_TRUNCATION_MARKER)
79}
80
81/// Incrementally redact streamed output without retaining the full stream.
82///
83/// A bounded suffix is held between chunks so a secret split at an IO
84/// boundary is still matched by the same redaction rules as a complete line.
85#[derive(Debug, Default)]
86pub struct StreamingSecretRedactor {
87    pending: String,
88}
89
90const STREAMING_REDACTION_CARRY_BYTES: usize = 1_024;
91
92impl StreamingSecretRedactor {
93    /// Redact and return the safe prefix of `chunk`. The returned string may
94    /// be empty while the bounded carry window is being filled.
95    pub fn push(&mut self, chunk: &str) -> String {
96        self.pending.push_str(chunk);
97        if self.pending.len() <= STREAMING_REDACTION_CARRY_BYTES {
98            if !self.pending.contains('\n') {
99                return String::new();
100            }
101        }
102
103        let carry_split = self.pending.len().saturating_sub(STREAMING_REDACTION_CARRY_BYTES);
104        let line_split = self.pending.rfind('\n').map(|index| index + 1).unwrap_or(0);
105        let mut split_at = carry_split.max(line_split);
106        while split_at > 0 && !self.pending.is_char_boundary(split_at) {
107            split_at -= 1;
108        }
109        let prefix: String = self.pending.drain(..split_at).collect();
110        redact_secrets(prefix)
111    }
112
113    /// Redact and return the final carried suffix.
114    pub fn finish(self) -> String {
115        redact_secrets(self.pending)
116    }
117}
118
119#[allow(
120    clippy::panic,
121    reason = "Intentional compatibility, platform, or test-only suppression."
122)]
123fn compile_regex(pattern: &str) -> Regex {
124    match Regex::new(pattern) {
125        Ok(regex) => regex,
126        // Panic is acceptable thanks to the `load_regex` test
127        Err(err) => panic!("invalid regex pattern `{pattern}`: {err}"),
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn load_regex() {
137        // Verify all regex patterns compile without panicking
138        let _ = redact_secrets("test".to_string());
139    }
140
141    #[test]
142    fn redacts_openai_key() {
143        let input = format!("Found key: {}", concat!("sk-", "test1234567890abcdef"));
144        let output = redact_secrets(input);
145        assert_eq!(output, "Found key: [REDACTED_SECRET]");
146    }
147
148    #[test]
149    fn redacts_aws_access_key() {
150        // Assemble the documentation fixture at runtime so repository scans do not
151        // mistake it for a live credential.
152        let aws_key = concat!("AKIA", "IOSFODNN7EXAMPLE");
153        let input = format!(" creds: {aws_key} ");
154        let output = redact_secrets(input);
155        assert_eq!(output, " creds: [REDACTED_SECRET] ");
156    }
157
158    #[test]
159    fn redacts_bearer_token() {
160        let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9".to_string();
161        let output = redact_secrets(input);
162        assert_eq!(output, "Authorization: Bearer [REDACTED_SECRET]");
163    }
164
165    #[test]
166    fn redacts_api_key_assignment() {
167        let input = "api_key=sk-test12345678".to_string();
168        let output = redact_secrets(input);
169        assert_eq!(output, "api_key=[REDACTED_SECRET]");
170    }
171
172    #[test]
173    fn redacts_password_assignment() {
174        let input = "password: mysecretvalue".to_string();
175        let output = redact_secrets(input);
176        assert_eq!(output, "password: [REDACTED_SECRET]");
177    }
178
179    #[test]
180    fn redacts_token_in_quotes() {
181        let input = r#"token="abc123xyz789abcdef""#.to_string();
182        let output = redact_secrets(input);
183        assert_eq!(output, r#"token="[REDACTED_SECRET]""#);
184    }
185
186    #[test]
187    fn preserves_short_values() {
188        // Values under 8 characters should not be redacted
189        let input = "password: short".to_string();
190        let output = redact_secrets(input);
191        assert_eq!(output, "password: short");
192    }
193
194    #[test]
195    fn redacts_multiple_secrets() {
196        let openai_key = concat!("sk-", "test1234567890abcdef");
197        let aws_key = concat!("AKIA", "IOSFODNN7EXAMPLE");
198        let input = format!("Keys: {openai_key} and {aws_key}");
199        let output = redact_secrets(input);
200        // Verify both secrets are redacted
201        assert!(output.contains("[REDACTED_SECRET]"));
202        assert!(!output.contains(openai_key));
203        assert!(!output.contains(aws_key));
204    }
205
206    #[test]
207    fn preserves_non_secret_text() {
208        let input = "Hello world, this is normal text".to_string();
209        let output = redact_secrets(input);
210        assert_eq!(output, "Hello world, this is normal text");
211    }
212
213    #[test]
214    fn redacts_secrets_split_across_stream_chunks() {
215        let mut redactor = StreamingSecretRedactor::default();
216        let mut output = redactor.push("password=superse");
217        output.push_str(&redactor.push("cretvalue\n"));
218        output.push_str(&redactor.finish());
219
220        assert_eq!(output, "password=[REDACTED_SECRET]\n");
221        assert!(!output.contains("supersecretvalue"));
222    }
223
224    #[test]
225    fn provider_diagnostic_is_bounded_utf8_safe_and_redacted() {
226        let mut input = b"api_key=diagnostic-secret-value Bearer abcdefghijklmnop ".to_vec();
227        input.extend(std::iter::repeat_n(b'x', 20_000));
228        input.extend_from_slice("終端".as_bytes());
229        input.push(0xff);
230
231        let output = sanitize_provider_diagnostic(input);
232
233        assert!(output.len() <= PROVIDER_DIAGNOSTIC_MAX_BYTES);
234        assert!(output.is_char_boundary(output.len()));
235        assert!(!output.contains("diagnostic-secret-value"));
236        assert!(!output.contains("abcdefghijklmnop"));
237    }
238}