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]{20,}"));
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> =
27    LazyLock::new(|| compile_regex(r#"(?i)\b(api[\-_]?key|token|secret|password)\b(\s*[:=]\s*)(["']?)[^\s"']{8,}"#));
28
29/// Redact secrets and sensitive keys from a string.
30///
31/// This is a best-effort operation using well-known regex patterns.
32/// Redacted values are replaced with `[REDACTED_SECRET]`.
33///
34/// # Examples
35///
36/// ```
37/// use vtcode_commons::sanitizer::redact_secrets;
38///
39/// let input = "Found key: sk-test1234567890abcdefghij".to_string();
40/// let output = redact_secrets(input);
41/// assert_eq!(output, "Found key: [REDACTED_SECRET]");
42/// ```
43pub fn redact_secrets(input: String) -> String {
44    let r1 = OPENAI_KEY_REGEX.replace_all(&input, "[REDACTED_SECRET]");
45    let r2 = AWS_ACCESS_KEY_ID_REGEX.replace_all(&r1, "[REDACTED_SECRET]");
46    let r3 = BEARER_TOKEN_REGEX.replace_all(&r2, "Bearer [REDACTED_SECRET]");
47    let r4 = SECRET_ASSIGNMENT_REGEX.replace_all(&r3, "$1$2$3[REDACTED_SECRET]");
48    // `into_owned` clones only when the final result is `Borrowed` (no regex
49    // matched at all); when any redaction occurred it moves the owned string
50    // without an extra allocation. Do NOT short-circuit on `Cow::Borrowed` —
51    // the final Cow is `Borrowed` whenever the *last* regex doesn't match,
52    // even if earlier regexes did, which would silently discard redactions.
53    r4.into_owned()
54}
55
56/// Incrementally redact streamed output without retaining the full stream.
57///
58/// A bounded suffix is held between chunks so a secret split at an IO
59/// boundary is still matched by the same redaction rules as a complete line.
60#[derive(Debug, Default)]
61pub struct StreamingSecretRedactor {
62    pending: String,
63}
64
65const STREAMING_REDACTION_CARRY_BYTES: usize = 1_024;
66
67impl StreamingSecretRedactor {
68    /// Redact and return the safe prefix of `chunk`. The returned string may
69    /// be empty while the bounded carry window is being filled.
70    pub fn push(&mut self, chunk: &str) -> String {
71        self.pending.push_str(chunk);
72        if self.pending.len() <= STREAMING_REDACTION_CARRY_BYTES {
73            if !self.pending.contains('\n') {
74                return String::new();
75            }
76        }
77
78        let carry_split = self.pending.len().saturating_sub(STREAMING_REDACTION_CARRY_BYTES);
79        let line_split = self.pending.rfind('\n').map(|index| index + 1).unwrap_or(0);
80        let mut split_at = carry_split.max(line_split);
81        while split_at > 0 && !self.pending.is_char_boundary(split_at) {
82            split_at -= 1;
83        }
84        let prefix: String = self.pending.drain(..split_at).collect();
85        redact_secrets(prefix)
86    }
87
88    /// Redact and return the final carried suffix.
89    pub fn finish(self) -> String {
90        redact_secrets(self.pending)
91    }
92}
93
94#[allow(clippy::panic)]
95fn compile_regex(pattern: &str) -> Regex {
96    match Regex::new(pattern) {
97        Ok(regex) => regex,
98        // Panic is acceptable thanks to the `load_regex` test
99        Err(err) => panic!("invalid regex pattern `{pattern}`: {err}"),
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn load_regex() {
109        // Verify all regex patterns compile without panicking
110        let _ = redact_secrets("test".to_string());
111    }
112
113    #[test]
114    fn redacts_openai_key() {
115        let input = "Found key: sk-test1234567890abcdefghij".to_string();
116        let output = redact_secrets(input);
117        assert_eq!(output, "Found key: [REDACTED_SECRET]");
118    }
119
120    #[test]
121    fn redacts_aws_access_key() {
122        // AKIAIOSFODNN7EXAMPLE is AWS's well-known documentation example key.
123        let input = " creds: AKIAIOSFODNN7EXAMPLE ".to_string();
124        let output = redact_secrets(input);
125        assert_eq!(output, " creds: [REDACTED_SECRET] ");
126    }
127
128    #[test]
129    fn redacts_bearer_token() {
130        let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9".to_string();
131        let output = redact_secrets(input);
132        assert_eq!(output, "Authorization: Bearer [REDACTED_SECRET]");
133    }
134
135    #[test]
136    fn redacts_api_key_assignment() {
137        let input = "api_key=sk-test12345678".to_string();
138        let output = redact_secrets(input);
139        assert_eq!(output, "api_key=[REDACTED_SECRET]");
140    }
141
142    #[test]
143    fn redacts_password_assignment() {
144        let input = "password: mysecretvalue".to_string();
145        let output = redact_secrets(input);
146        assert_eq!(output, "password: [REDACTED_SECRET]");
147    }
148
149    #[test]
150    fn redacts_token_in_quotes() {
151        let input = r#"token="abc123xyz789abcdef""#.to_string();
152        let output = redact_secrets(input);
153        assert_eq!(output, r#"token="[REDACTED_SECRET]""#);
154    }
155
156    #[test]
157    fn preserves_short_values() {
158        // Values under 8 characters should not be redacted
159        let input = "password: short".to_string();
160        let output = redact_secrets(input);
161        assert_eq!(output, "password: short");
162    }
163
164    #[test]
165    fn redacts_multiple_secrets() {
166        let input = "Keys: sk-test1234567890abcdefghij and AKIAIOSFODNN7EXAMPLE".to_string();
167        let output = redact_secrets(input);
168        // Verify both secrets are redacted
169        assert!(output.contains("[REDACTED_SECRET]"));
170        assert!(!output.contains("AKIAIOSFODNN7EXAMPLE"));
171        assert!(!output.contains("sk-test1234567890abcdefghij"));
172    }
173
174    #[test]
175    fn preserves_non_secret_text() {
176        let input = "Hello world, this is normal text".to_string();
177        let output = redact_secrets(input);
178        assert_eq!(output, "Hello world, this is normal text");
179    }
180
181    #[test]
182    fn redacts_secrets_split_across_stream_chunks() {
183        let mut redactor = StreamingSecretRedactor::default();
184        let mut output = redactor.push("password=superse");
185        output.push_str(&redactor.push("cretvalue\n"));
186        output.push_str(&redactor.finish());
187
188        assert_eq!(output, "password=[REDACTED_SECRET]\n");
189        assert!(!output.contains("supersecretvalue"));
190    }
191}