vtcode_commons/
sanitizer.rs1use regex::Regex;
13use std::sync::LazyLock;
14
15static OPENAI_KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"sk-[A-Za-z0-9_-]{16,}"));
17
18static AWS_ACCESS_KEY_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"\bAKIA[0-9A-Z]{16}\b"));
20
21static BEARER_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| compile_regex(r"(?i)\bBearer\s+[A-Za-z0-9.\-_]{16,}\b"));
23
24static 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
32pub const PROVIDER_DIAGNOSTIC_MAX_BYTES: usize = 8 * 1024;
34const PROVIDER_DIAGNOSTIC_TRUNCATION_MARKER: &str = "… [diagnostic truncated]";
35
36pub 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 r4.into_owned()
61}
62
63pub 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#[derive(Debug, Default)]
86pub struct StreamingSecretRedactor {
87 pending: String,
88}
89
90const STREAMING_REDACTION_CARRY_BYTES: usize = 1_024;
91
92impl StreamingSecretRedactor {
93 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 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 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 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 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 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 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}