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 = "Found key: sk-test1234567890abcdefghij".to_string();
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 input = " creds: AKIAIOSFODNN7EXAMPLE ".to_string();
152 let output = redact_secrets(input);
153 assert_eq!(output, " creds: [REDACTED_SECRET] ");
154 }
155
156 #[test]
157 fn redacts_bearer_token() {
158 let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9".to_string();
159 let output = redact_secrets(input);
160 assert_eq!(output, "Authorization: Bearer [REDACTED_SECRET]");
161 }
162
163 #[test]
164 fn redacts_api_key_assignment() {
165 let input = "api_key=sk-test12345678".to_string();
166 let output = redact_secrets(input);
167 assert_eq!(output, "api_key=[REDACTED_SECRET]");
168 }
169
170 #[test]
171 fn redacts_password_assignment() {
172 let input = "password: mysecretvalue".to_string();
173 let output = redact_secrets(input);
174 assert_eq!(output, "password: [REDACTED_SECRET]");
175 }
176
177 #[test]
178 fn redacts_token_in_quotes() {
179 let input = r#"token="abc123xyz789abcdef""#.to_string();
180 let output = redact_secrets(input);
181 assert_eq!(output, r#"token="[REDACTED_SECRET]""#);
182 }
183
184 #[test]
185 fn preserves_short_values() {
186 let input = "password: short".to_string();
188 let output = redact_secrets(input);
189 assert_eq!(output, "password: short");
190 }
191
192 #[test]
193 fn redacts_multiple_secrets() {
194 let input = "Keys: sk-test1234567890abcdefghij and AKIAIOSFODNN7EXAMPLE".to_string();
195 let output = redact_secrets(input);
196 assert!(output.contains("[REDACTED_SECRET]"));
198 assert!(!output.contains("AKIAIOSFODNN7EXAMPLE"));
199 assert!(!output.contains("sk-test1234567890abcdefghij"));
200 }
201
202 #[test]
203 fn preserves_non_secret_text() {
204 let input = "Hello world, this is normal text".to_string();
205 let output = redact_secrets(input);
206 assert_eq!(output, "Hello world, this is normal text");
207 }
208
209 #[test]
210 fn redacts_secrets_split_across_stream_chunks() {
211 let mut redactor = StreamingSecretRedactor::default();
212 let mut output = redactor.push("password=superse");
213 output.push_str(&redactor.push("cretvalue\n"));
214 output.push_str(&redactor.finish());
215
216 assert_eq!(output, "password=[REDACTED_SECRET]\n");
217 assert!(!output.contains("supersecretvalue"));
218 }
219
220 #[test]
221 fn provider_diagnostic_is_bounded_utf8_safe_and_redacted() {
222 let mut input = b"api_key=diagnostic-secret-value Bearer abcdefghijklmnop ".to_vec();
223 input.extend(std::iter::repeat_n(b'x', 20_000));
224 input.extend_from_slice("終端".as_bytes());
225 input.push(0xff);
226
227 let output = sanitize_provider_diagnostic(input);
228
229 assert!(output.len() <= PROVIDER_DIAGNOSTIC_MAX_BYTES);
230 assert!(output.is_char_boundary(output.len()));
231 assert!(!output.contains("diagnostic-secret-value"));
232 assert!(!output.contains("abcdefghijklmnop"));
233 }
234}