turbomcp_server/
error_sanitization.rs1use regex::Regex;
39use std::sync::OnceLock;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum DisplayMode {
44 #[default]
46 Production,
47 Development,
49}
50
51#[derive(Debug)]
53pub struct SanitizedError<E> {
54 error: E,
55 mode: DisplayMode,
56}
57
58impl<E> SanitizedError<E> {
59 pub fn new(error: E, mode: DisplayMode) -> Self {
61 Self { error, mode }
62 }
63
64 pub fn production(error: E) -> Self {
66 Self::new(error, DisplayMode::Production)
67 }
68
69 pub fn development(error: E) -> Self {
71 Self::new(error, DisplayMode::Development)
72 }
73
74 pub fn into_inner(self) -> E {
76 self.error
77 }
78
79 pub fn inner(&self) -> &E {
81 &self.error
82 }
83}
84
85impl<E: std::fmt::Display> std::fmt::Display for SanitizedError<E> {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 match self.mode {
88 DisplayMode::Development => write!(f, "{}", self.error),
89 DisplayMode::Production => {
90 let message = self.error.to_string();
91 write!(f, "{}", sanitize_error_message(&message))
92 }
93 }
94 }
95}
96
97impl<E: std::error::Error> std::error::Error for SanitizedError<E> {
98 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
99 self.error.source()
100 }
101}
102
103pub fn sanitize_error_message(message: &str) -> String {
130 let mut sanitized = message.to_string();
131
132 sanitized = sanitize_connection_strings(&sanitized);
137
138 sanitized = sanitize_urls(&sanitized);
140
141 sanitized = sanitize_secrets(&sanitized);
143
144 sanitized = sanitize_ip_addresses(&sanitized);
146
147 sanitized = sanitize_file_paths(&sanitized);
149
150 sanitized = sanitize_email_addresses(&sanitized);
152
153 sanitized
154}
155
156fn sanitize_file_paths(message: &str) -> String {
158 static UNIX_PATH_RE: OnceLock<Regex> = OnceLock::new();
159 static WINDOWS_PATH_RE: OnceLock<Regex> = OnceLock::new();
160
161 let unix_re = UNIX_PATH_RE.get_or_init(|| Regex::new(r"(?:/|\./)[\w\-./]+(?:\.\w+)?").unwrap());
163
164 let windows_re = WINDOWS_PATH_RE
166 .get_or_init(|| Regex::new(r"(?:[A-Za-z]:\\|\\\\)[\w\-\\/.]+(?:\.\w+)?").unwrap());
167
168 let mut sanitized = unix_re.replace_all(message, "[PATH]").to_string();
169 sanitized = windows_re.replace_all(&sanitized, "[PATH]").to_string();
170
171 sanitized
172}
173
174fn sanitize_ip_addresses(message: &str) -> String {
176 static IPV4_RE: OnceLock<Regex> = OnceLock::new();
177 static IPV6_RE: OnceLock<Regex> = OnceLock::new();
178
179 let ipv4_re = IPV4_RE.get_or_init(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").unwrap());
181
182 let ipv6_re = IPV6_RE
184 .get_or_init(|| Regex::new(r"\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b").unwrap());
185
186 let mut sanitized = ipv4_re.replace_all(message, "[IP]").to_string();
187 sanitized = ipv6_re.replace_all(&sanitized, "[IP]").to_string();
188
189 sanitized
190}
191
192fn sanitize_connection_strings(message: &str) -> String {
194 static CONN_STRING_RE: OnceLock<Regex> = OnceLock::new();
195
196 let conn_re = CONN_STRING_RE.get_or_init(|| {
198 Regex::new(r"\b(?:postgres|mysql|mongodb|redis|amqp|kafka)://[^\s]+").unwrap()
199 });
200
201 conn_re.replace_all(message, "[CONNECTION]").to_string()
202}
203
204fn sanitize_secrets(message: &str) -> String {
206 static SECRET_RE: OnceLock<Regex> = OnceLock::new();
207
208 let secret_re = SECRET_RE.get_or_init(|| {
213 Regex::new(r"(?i)\b(api[_-]?key|token|password|secret|bearer)(\s*[=:]?\s*)([^\s,;)]+)")
214 .unwrap()
215 });
216
217 secret_re
219 .replace_all(message, |caps: ®ex::Captures| {
220 format!("{}=[REDACTED]", caps[1].to_lowercase())
221 })
222 .to_string()
223}
224
225fn sanitize_email_addresses(message: &str) -> String {
227 static EMAIL_RE: OnceLock<Regex> = OnceLock::new();
228
229 let email_re = EMAIL_RE.get_or_init(|| {
231 Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap()
232 });
233
234 email_re.replace_all(message, "[EMAIL]").to_string()
235}
236
237fn sanitize_urls(message: &str) -> String {
239 static URL_RE: OnceLock<Regex> = OnceLock::new();
240
241 let url_re = URL_RE.get_or_init(|| Regex::new(r"\b(?:https?|ftp)://[^\s]+").unwrap());
243
244 url_re.replace_all(message, "[URL]").to_string()
245}
246
247pub const GENERIC_ERROR_MESSAGE: &str = "An error occurred. Please try again or contact support.";
251
252pub fn generic_error() -> String {
254 GENERIC_ERROR_MESSAGE.to_string()
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_sanitize_unix_paths() {
263 assert_eq!(
264 sanitize_file_paths("File not found: /etc/secrets/key.txt"),
265 "File not found: [PATH]"
266 );
267 assert_eq!(
268 sanitize_file_paths("Error reading ./config/database.yml"),
269 "Error reading [PATH]"
270 );
271 assert_eq!(
272 sanitize_file_paths("Failed: /home/user/.ssh/id_rsa"),
273 "Failed: [PATH]"
274 );
275 }
276
277 #[test]
278 fn test_sanitize_windows_paths() {
279 assert_eq!(
280 sanitize_file_paths("File not found: C:\\Windows\\System32\\config.sys"),
281 "File not found: [PATH]"
282 );
283 assert_eq!(
284 sanitize_file_paths("Error: \\\\server\\share\\data.txt"),
285 "Error: [PATH]"
286 );
287 }
288
289 #[test]
290 fn test_sanitize_ipv4_addresses() {
291 assert_eq!(
292 sanitize_ip_addresses("Connection to 192.168.1.100 failed"),
293 "Connection to [IP] failed"
294 );
295 assert_eq!(
296 sanitize_ip_addresses("Server: 10.0.0.1:8080"),
297 "Server: [IP]:8080"
298 );
299 }
300
301 #[test]
302 fn test_sanitize_ipv6_addresses() {
303 assert_eq!(
304 sanitize_ip_addresses("Failed: 2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
305 "Failed: [IP]"
306 );
307 }
308
309 #[test]
310 fn test_sanitize_connection_strings() {
311 assert_eq!(
312 sanitize_connection_strings("Connect failed: postgres://user:pass@localhost:5432/db"),
313 "Connect failed: [CONNECTION]"
314 );
315 assert_eq!(
316 sanitize_connection_strings("Error: mongodb://admin:secret@cluster.example.com/mydb"),
317 "Error: [CONNECTION]"
318 );
319 }
320
321 #[test]
322 fn test_sanitize_secrets() {
323 assert_eq!(
324 sanitize_secrets("API key: api_key=sk_test_1234567890abcdef"),
325 "API key: api_key=[REDACTED]"
326 );
327 assert_eq!(
328 sanitize_secrets("Auth failed: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"),
329 "Auth failed: token=[REDACTED]"
330 );
331 assert_eq!(
332 sanitize_secrets("Login: password=MySecretPass123"),
333 "Login: password=[REDACTED]"
334 );
335 assert_eq!(
336 sanitize_secrets("Header: Authorization: Bearer abc123"),
337 "Header: Authorization: bearer=[REDACTED]"
338 );
339 }
340
341 #[test]
342 fn test_sanitize_email_addresses() {
343 assert_eq!(
344 sanitize_email_addresses("User: admin@example.com"),
345 "User: [EMAIL]"
346 );
347 assert_eq!(
348 sanitize_email_addresses("Contact: support@company.org"),
349 "Contact: [EMAIL]"
350 );
351 }
352
353 #[test]
354 fn test_sanitize_urls() {
355 assert_eq!(
356 sanitize_urls("Failed to fetch: https://api.example.com/v1/users"),
357 "Failed to fetch: [URL]"
358 );
359 assert_eq!(
360 sanitize_urls("Error: http://internal-service.local/health"),
361 "Error: [URL]"
362 );
363 }
364
365 #[test]
366 fn test_full_sanitization() {
367 let message = "Connection to postgres://admin:pass@192.168.1.100:5432/db failed. \
368 Check /etc/database/config.yml and contact support@company.com. \
369 API key: api_key=sk_live_abc123";
370
371 let sanitized = sanitize_error_message(message);
372
373 assert!(!sanitized.contains("postgres://"));
375 assert!(!sanitized.contains("admin:pass"));
376 assert!(!sanitized.contains("192.168.1.100"));
377 assert!(!sanitized.contains("/etc/database"));
378 assert!(!sanitized.contains("support@company.com"));
379 assert!(!sanitized.contains("sk_live_abc123"));
380
381 assert!(sanitized.contains("[CONNECTION]"));
383 assert!(sanitized.contains("[PATH]"));
384 assert!(sanitized.contains("[EMAIL]"));
385 assert!(sanitized.contains("[REDACTED]"));
386 }
387
388 #[test]
389 fn test_sanitized_error_production_mode() {
390 let error = std::io::Error::new(
391 std::io::ErrorKind::NotFound,
392 "File not found: /etc/secrets/api_key.txt",
393 );
394
395 let sanitized = SanitizedError::production(error);
396 let display = format!("{}", sanitized);
397
398 assert!(!display.contains("/etc/secrets"));
399 assert!(display.contains("[PATH]"));
400 }
401
402 #[test]
403 fn test_sanitized_error_development_mode() {
404 let error = std::io::Error::new(
405 std::io::ErrorKind::NotFound,
406 "File not found: /etc/secrets/api_key.txt",
407 );
408
409 let sanitized = SanitizedError::development(error);
410 let display = format!("{}", sanitized);
411
412 assert!(display.contains("/etc/secrets/api_key.txt"));
414 }
415
416 #[test]
417 fn test_display_mode_default() {
418 assert_eq!(DisplayMode::default(), DisplayMode::Production);
420 }
421
422 #[test]
423 fn test_generic_error_message() {
424 let msg = generic_error();
425 assert_eq!(msg, GENERIC_ERROR_MESSAGE);
426 assert!(msg.contains("An error occurred"));
427 }
428
429 #[test]
430 fn test_no_false_positives() {
431 let message = "User 123 requested tool list";
433 assert_eq!(sanitize_error_message(message), message);
434
435 let message = "Server running on port 8080";
437 assert_eq!(sanitize_error_message(message), message);
438 }
439}