1use regex::{Regex, RegexSet};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
12pub struct TrapConfig {
13 #[serde(default = "default_enabled")]
15 pub enabled: bool,
16 #[serde(default = "default_paths")]
18 pub paths: Vec<String>,
19 #[serde(default = "default_apply_max_risk")]
21 pub apply_max_risk: bool,
22 #[serde(default)]
24 pub extended_tarpit_ms: Option<u64>,
25 #[serde(default = "default_alert_telemetry")]
27 pub alert_telemetry: bool,
28}
29
30fn default_enabled() -> bool {
31 true
32}
33fn default_apply_max_risk() -> bool {
34 true
35}
36fn default_alert_telemetry() -> bool {
37 true
38}
39fn default_paths() -> Vec<String> {
40 vec![
41 "/.git/*".to_string(),
42 "/.env".to_string(),
43 "/.env.*".to_string(),
44 "/admin/backup*".to_string(),
45 "/wp-admin/*".to_string(),
46 "/phpmyadmin/*".to_string(),
47 "/.svn/*".to_string(),
48 "/.htaccess".to_string(),
49 "/web.config".to_string(),
50 "/config.php".to_string(),
51 ]
52}
53
54impl Default for TrapConfig {
55 fn default() -> Self {
56 Self {
57 enabled: default_enabled(),
58 paths: default_paths(),
59 apply_max_risk: default_apply_max_risk(),
60 extended_tarpit_ms: Some(5000),
61 alert_telemetry: default_alert_telemetry(),
62 }
63 }
64}
65
66pub struct TrapMatcher {
71 pattern_set: RegexSet,
73 config: TrapConfig,
74}
75
76impl TrapMatcher {
77 pub fn new(config: TrapConfig) -> Result<Self, regex::Error> {
79 let regex_strings: Vec<String> = config
81 .paths
82 .iter()
83 .map(|p| glob_to_regex_string(p))
84 .collect();
85
86 let pattern_set = RegexSet::new(®ex_strings)?;
88
89 Ok(Self {
90 pattern_set,
91 config,
92 })
93 }
94
95 #[inline]
100 #[must_use]
101 pub fn is_trap(&self, path: &str) -> bool {
102 if !self.config.enabled {
103 return false;
104 }
105 let path_only = path.split('?').next().unwrap_or(path);
107 self.pattern_set.is_match(path_only)
109 }
110
111 pub fn config(&self) -> &TrapConfig {
113 &self.config
114 }
115
116 #[must_use]
121 pub fn matched_pattern(&self, path: &str) -> Option<&str> {
122 let path_only = path.split('?').next().unwrap_or(path);
123 self.pattern_set
125 .matches(path_only)
126 .iter()
127 .next()
128 .map(|i| self.config.paths[i].as_str())
129 }
130}
131
132fn glob_to_regex_string(glob: &str) -> String {
134 let mut regex_str = String::with_capacity(glob.len() * 2);
135 regex_str.push('^');
136
137 let mut chars = glob.chars().peekable();
138 while let Some(c) = chars.next() {
139 match c {
140 '*' => {
141 if chars.peek() == Some(&'*') {
142 chars.next(); regex_str.push_str(".*"); } else {
145 regex_str.push_str("[^/]*"); }
147 }
148 '?' => regex_str.push('.'),
149 '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
150 regex_str.push('\\');
151 regex_str.push(c);
152 }
153 _ => regex_str.push(c),
154 }
155 }
156 regex_str.push('$');
157 regex_str
158}
159
160#[allow(dead_code)]
162fn glob_to_regex(glob: &str) -> Result<Regex, regex::Error> {
163 Regex::new(&glob_to_regex_string(glob))
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn test_glob_to_regex_exact() {
172 let re = glob_to_regex("/.env").unwrap();
173 assert!(re.is_match("/.env"));
174 assert!(!re.is_match("/.env.local"));
175 assert!(!re.is_match("/api/.env"));
176 }
177
178 #[test]
179 fn test_glob_to_regex_wildcard() {
180 let re = glob_to_regex("/.git/*").unwrap();
181 assert!(re.is_match("/.git/config"));
182 assert!(re.is_match("/.git/HEAD"));
183 assert!(!re.is_match("/.git"));
184 assert!(!re.is_match("/.git/objects/pack/file"));
185 }
186
187 #[test]
188 fn test_glob_to_regex_double_star() {
189 let re = glob_to_regex("/admin/**").unwrap();
190 assert!(re.is_match("/admin/backup"));
191 assert!(re.is_match("/admin/backup/db.sql"));
192 assert!(re.is_match("/admin/users/edit/1"));
193 }
194
195 #[test]
196 fn test_glob_to_regex_prefix() {
197 let re = glob_to_regex("/admin/backup*").unwrap();
198 assert!(re.is_match("/admin/backup"));
199 assert!(re.is_match("/admin/backup.sql"));
200 assert!(re.is_match("/admin/backup_2024.tar.gz"));
201 assert!(!re.is_match("/admin/backups/file"));
202 }
203
204 #[test]
205 fn test_trap_matcher_basic() {
206 let config = TrapConfig::default();
207 let matcher = TrapMatcher::new(config).unwrap();
208
209 assert!(matcher.is_trap("/.git/config"));
211 assert!(matcher.is_trap("/.env"));
212 assert!(matcher.is_trap("/wp-admin/install.php"));
213
214 assert!(!matcher.is_trap("/api/users"));
216 assert!(!matcher.is_trap("/"));
217 assert!(!matcher.is_trap("/health"));
218 }
219
220 #[test]
221 fn test_trap_matcher_disabled() {
222 let config = TrapConfig {
223 enabled: false,
224 ..Default::default()
225 };
226 let matcher = TrapMatcher::new(config).unwrap();
227
228 assert!(!matcher.is_trap("/.git/config"));
230 }
231
232 #[test]
233 fn test_trap_matcher_strips_query() {
234 let config = TrapConfig::default();
235 let matcher = TrapMatcher::new(config).unwrap();
236
237 assert!(matcher.is_trap("/.env?foo=bar"));
239 assert!(matcher.is_trap("/.git/config?ref=main"));
240 }
241
242 #[test]
243 fn test_matched_pattern() {
244 let config = TrapConfig::default();
245 let matcher = TrapMatcher::new(config).unwrap();
246
247 assert_eq!(matcher.matched_pattern("/.git/config"), Some("/.git/*"));
248 assert_eq!(matcher.matched_pattern("/.env"), Some("/.env"));
249 assert_eq!(matcher.matched_pattern("/api/users"), None);
250 }
251
252 #[test]
253 fn test_default_config() {
254 let config = TrapConfig::default();
255 assert!(config.enabled);
256 assert!(config.apply_max_risk);
257 assert!(config.alert_telemetry);
258 assert_eq!(config.extended_tarpit_ms, Some(5000));
259 assert!(!config.paths.is_empty());
260 }
261
262 #[test]
263 fn test_custom_paths() {
264 let config = TrapConfig {
265 enabled: true,
266 paths: vec!["/secret/*".to_string(), "/internal/**".to_string()],
267 apply_max_risk: true,
268 extended_tarpit_ms: None,
269 alert_telemetry: false,
270 };
271 let matcher = TrapMatcher::new(config).unwrap();
272
273 assert!(matcher.is_trap("/secret/data"));
274 assert!(matcher.is_trap("/internal/deep/path/file"));
275 assert!(!matcher.is_trap("/.git/config")); }
277
278 #[test]
279 fn test_env_variations() {
280 let config = TrapConfig::default();
281 let matcher = TrapMatcher::new(config).unwrap();
282
283 assert!(matcher.is_trap("/.env"));
285 assert!(matcher.is_trap("/.env.local"));
287 assert!(matcher.is_trap("/.env.production"));
288 assert!(matcher.is_trap("/.env.backup"));
289 }
290
291 #[test]
292 fn test_special_regex_chars() {
293 let config = TrapConfig {
295 enabled: true,
296 paths: vec!["/test.php".to_string(), "/api/v1.0/*".to_string()],
297 ..Default::default()
298 };
299 let matcher = TrapMatcher::new(config).unwrap();
300
301 assert!(matcher.is_trap("/test.php"));
302 assert!(!matcher.is_trap("/testXphp")); assert!(matcher.is_trap("/api/v1.0/users"));
304 }
305
306 #[test]
309 fn test_double_slash_normalization_not_matched() {
310 let config = TrapConfig::default();
313 let matcher = TrapMatcher::new(config).unwrap();
314
315 assert!(!matcher.is_trap("//.git/config"));
317 assert!(!matcher.is_trap("///.git/config"));
318 assert!(!matcher.is_trap("/.git//config"));
319 assert!(!matcher.is_trap("//.env"));
320 }
321
322 #[test]
323 fn test_dot_segment_traversal_attacks() {
324 let config = TrapConfig::default();
326 let matcher = TrapMatcher::new(config).unwrap();
327
328 assert!(!matcher.is_trap("/foo/../.git/config"));
331 assert!(!matcher.is_trap("/api/../../.env"));
332 assert!(!matcher.is_trap("/./admin/backup"));
333
334 assert!(!matcher.is_trap("/foo/.git/config"));
337 }
338
339 #[test]
340 fn test_unicode_path_variations() {
341 let config = TrapConfig::default();
343 let matcher = TrapMatcher::new(config).unwrap();
344
345 assert!(!matcher.is_trap("/\u{FF0E}git/config")); assert!(!matcher.is_trap("/\u{FF0E}env")); assert!(!matcher.is_trap("\u{2215}.git/config")); assert!(!matcher.is_trap("\u{2044}.env")); assert!(matcher.is_trap("/.git/config"));
355 assert!(matcher.is_trap("/.env"));
356 }
357
358 #[test]
359 fn test_very_long_paths() {
360 let config = TrapConfig::default();
361 let matcher = TrapMatcher::new(config).unwrap();
362
363 let long_path = format!("/api/{}/data", "a".repeat(10000));
365 assert!(!matcher.is_trap(&long_path));
366
367 let long_trap_path = format!("/api/{}/.git/config", "a".repeat(10000));
369 assert!(!matcher.is_trap(&long_trap_path));
371
372 let trap_with_long_suffix = format!("/.git/{}", "a".repeat(10000));
374 assert!(matcher.is_trap(&trap_with_long_suffix));
375 }
376
377 #[test]
378 fn test_case_sensitivity_variations() {
379 let config = TrapConfig::default();
380 let matcher = TrapMatcher::new(config).unwrap();
381
382 assert!(matcher.is_trap("/.git/config"));
384 assert!(!matcher.is_trap("/.GIT/config"));
385 assert!(!matcher.is_trap("/.Git/Config"));
386 assert!(!matcher.is_trap("/.GIT/CONFIG"));
387
388 assert!(matcher.is_trap("/.env"));
389 assert!(!matcher.is_trap("/.ENV"));
390 assert!(!matcher.is_trap("/.Env"));
391
392 assert!(matcher.is_trap("/wp-admin/index.php"));
394 assert!(!matcher.is_trap("/WP-ADMIN/index.php"));
395 assert!(!matcher.is_trap("/Wp-Admin/index.php"));
396 }
397
398 #[test]
399 fn test_null_byte_injection() {
400 let config = TrapConfig::default();
401 let matcher = TrapMatcher::new(config).unwrap();
402
403 assert!(matcher.is_trap("/.git/config\x00.txt"));
406
407 assert!(!matcher.is_trap("/.env\x00.bak"));
410
411 assert!(!matcher.is_trap("/.env\x00.bak"));
414
415 assert!(!matcher.is_trap("/foo\x00/.git/config"));
417 }
418
419 #[test]
420 fn test_url_encoded_in_path() {
421 let config = TrapConfig::default();
422 let matcher = TrapMatcher::new(config).unwrap();
423
424 assert!(!matcher.is_trap("/%2egit/config")); assert!(!matcher.is_trap("/.git%2fconfig")); assert!(!matcher.is_trap("/%2eenv")); assert!(!matcher.is_trap("/%252egit/config")); assert!(matcher.is_trap("/.git/config"));
433 }
434
435 #[test]
436 fn test_backslash_path_separators() {
437 let config = TrapConfig::default();
438 let matcher = TrapMatcher::new(config).unwrap();
439
440 assert!(!matcher.is_trap("\\.git\\config"));
442 assert!(!matcher.is_trap("\\.env"));
443 assert!(!matcher.is_trap("\\wp-admin\\index.php"));
444
445 assert!(!matcher.is_trap("/.git\\config"));
447 assert!(!matcher.is_trap("\\.git/config"));
448 }
449
450 #[test]
451 fn test_empty_and_minimal_paths() {
452 let config = TrapConfig::default();
453 let matcher = TrapMatcher::new(config).unwrap();
454
455 assert!(!matcher.is_trap(""));
457
458 assert!(!matcher.is_trap("/"));
460
461 assert!(!matcher.is_trap("/."));
463 assert!(!matcher.is_trap("/.."));
464
465 assert!(!matcher.is_trap(".env"));
467 assert!(!matcher.is_trap(".git"));
468 }
469
470 #[test]
471 fn test_multiple_query_strings() {
472 let config = TrapConfig::default();
473 let matcher = TrapMatcher::new(config).unwrap();
474
475 assert!(matcher.is_trap("/.env?foo=bar?baz=qux"));
477 assert!(matcher.is_trap("/.git/config?a=1&b=2"));
478
479 assert!(!matcher.is_trap("/api?path=/.git/config"));
481
482 assert!(!matcher.is_trap("/.env#section"));
485 }
486
487 #[test]
488 fn test_question_mark_glob_pattern() {
489 let config = TrapConfig {
491 enabled: true,
492 paths: vec!["/secret?.txt".to_string(), "/admin?/*".to_string()],
493 ..Default::default()
494 };
495 let matcher = TrapMatcher::new(config).unwrap();
496
497 assert!(matcher.is_trap("/secret1.txt"));
499 assert!(matcher.is_trap("/secretX.txt"));
500 assert!(!matcher.is_trap("/secret.txt")); assert!(!matcher.is_trap("/secret12.txt")); assert!(matcher.is_trap("/admin1/file"));
504 assert!(matcher.is_trap("/adminX/file"));
505 assert!(!matcher.is_trap("/admin/file")); assert!(!matcher.is_trap("/admin12/file")); }
508
509 #[test]
510 fn test_nested_trap_patterns() {
511 let config = TrapConfig {
512 enabled: true,
513 paths: vec!["/deep/**/secret/*".to_string(), "/a/**/b/**/c".to_string()],
514 ..Default::default()
515 };
516 let matcher = TrapMatcher::new(config).unwrap();
517
518 assert!(matcher.is_trap("/deep/any/path/here/secret/file"));
522 assert!(matcher.is_trap("/deep/x/secret/file")); assert!(matcher.is_trap("/a/x/b/y/c"));
527 assert!(matcher.is_trap("/a/foo/bar/b/baz/c"));
528
529 assert!(!matcher.is_trap("/a/b/c"));
533
534 assert!(!matcher.is_trap("/deep/secret/file"));
536 }
537
538 #[test]
539 fn test_special_characters_in_custom_paths() {
540 let config = TrapConfig {
542 enabled: true,
543 paths: vec![
544 "/file+name.php".to_string(),
545 "/path(with)parens/*".to_string(),
546 "/regex[chars]test".to_string(),
547 "/dollar$sign.txt".to_string(),
548 "/caret^file.txt".to_string(),
549 "/pipe|char.txt".to_string(),
550 "/brace{test}end".to_string(),
551 ],
552 ..Default::default()
553 };
554 let matcher = TrapMatcher::new(config).unwrap();
555
556 assert!(matcher.is_trap("/file+name.php"));
558 assert!(matcher.is_trap("/path(with)parens/anything"));
559 assert!(matcher.is_trap("/regex[chars]test"));
560 assert!(matcher.is_trap("/dollar$sign.txt"));
561 assert!(matcher.is_trap("/caret^file.txt"));
562 assert!(matcher.is_trap("/pipe|char.txt"));
563 assert!(matcher.is_trap("/brace{test}end"));
564
565 assert!(!matcher.is_trap("/filename.php")); assert!(!matcher.is_trap("/pathwithparens/test")); }
569
570 #[test]
571 fn test_whitespace_in_paths() {
572 let config = TrapConfig::default();
573 let matcher = TrapMatcher::new(config).unwrap();
574
575 assert!(!matcher.is_trap(" /.git/config"));
577 assert!(!matcher.is_trap(" /.env "));
578
579 assert!(matcher.is_trap("/.git/config "));
581
582 assert!(!matcher.is_trap("/. git/config"));
584 assert!(!matcher.is_trap("/ .env"));
585
586 assert!(!matcher.is_trap("/\t.git/config"));
588 assert!(matcher.is_trap("/.git/\tconfig"));
590 }
591
592 #[test]
593 fn test_config_getter() {
594 let custom_config = TrapConfig {
595 enabled: true,
596 paths: vec!["/custom/*".to_string()],
597 apply_max_risk: false,
598 extended_tarpit_ms: Some(10000),
599 alert_telemetry: false,
600 };
601 let matcher = TrapMatcher::new(custom_config.clone()).unwrap();
602
603 let config = matcher.config();
604 assert!(config.enabled);
605 assert!(!config.apply_max_risk);
606 assert_eq!(config.extended_tarpit_ms, Some(10000));
607 assert!(!config.alert_telemetry);
608 assert_eq!(config.paths.len(), 1);
609 }
610
611 #[test]
612 fn test_empty_paths_config() {
613 let config = TrapConfig {
614 enabled: true,
615 paths: vec![],
616 ..Default::default()
617 };
618 let matcher = TrapMatcher::new(config).unwrap();
619
620 assert!(!matcher.is_trap("/.git/config"));
622 assert!(!matcher.is_trap("/.env"));
623 assert!(!matcher.is_trap("/anything"));
624 }
625
626 #[test]
627 fn test_matched_pattern_returns_correct_pattern() {
628 let config = TrapConfig {
629 enabled: true,
630 paths: vec![
631 "/first/*".to_string(),
632 "/second/**".to_string(),
633 "/third".to_string(),
634 ],
635 ..Default::default()
636 };
637 let matcher = TrapMatcher::new(config).unwrap();
638
639 assert_eq!(matcher.matched_pattern("/first/file"), Some("/first/*"));
641 assert_eq!(
642 matcher.matched_pattern("/second/deep/path"),
643 Some("/second/**")
644 );
645 assert_eq!(matcher.matched_pattern("/third"), Some("/third"));
646 assert_eq!(matcher.matched_pattern("/nonexistent"), None);
647 }
648
649 #[test]
650 fn test_glob_to_regex_edge_cases() {
651 let re = glob_to_regex("").unwrap();
653 assert!(re.is_match(""));
654 assert!(!re.is_match("something"));
655
656 let re_star = glob_to_regex("*").unwrap();
658 assert!(re_star.is_match("anything"));
659 assert!(re_star.is_match(""));
660 assert!(!re_star.is_match("with/slash"));
661
662 let re_double_star = glob_to_regex("**").unwrap();
664 assert!(re_double_star.is_match("anything"));
665 assert!(re_double_star.is_match("with/slash/deep"));
666 assert!(re_double_star.is_match(""));
667
668 let re_mixed = glob_to_regex("**/file_*_?.txt").unwrap();
672
673 assert!(re_mixed.is_match("path/to/file_test_1.txt"));
675
676 assert!(re_mixed.is_match("dir/file_abc_X.txt")); assert!(!re_mixed.is_match("file_test_12.txt")); }
690}