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]{20,}"));
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> =
27 LazyLock::new(|| compile_regex(r#"(?i)\b(api[\-_]?key|token|secret|password)\b(\s*[:=]\s*)(["']?)[^\s"']{8,}"#));
28
29pub 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 r4.into_owned()
54}
55
56#[derive(Debug, Default)]
61pub struct StreamingSecretRedactor {
62 pending: String,
63}
64
65const STREAMING_REDACTION_CARRY_BYTES: usize = 1_024;
66
67impl StreamingSecretRedactor {
68 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 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 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 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 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 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 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}