use regex::{Regex, RegexSet};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TrapConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_paths")]
pub paths: Vec<String>,
#[serde(default = "default_apply_max_risk")]
pub apply_max_risk: bool,
#[serde(default)]
pub extended_tarpit_ms: Option<u64>,
#[serde(default = "default_alert_telemetry")]
pub alert_telemetry: bool,
}
fn default_enabled() -> bool {
true
}
fn default_apply_max_risk() -> bool {
true
}
fn default_alert_telemetry() -> bool {
true
}
fn default_paths() -> Vec<String> {
vec![
"/.git/*".to_string(),
"/.env".to_string(),
"/.env.*".to_string(),
"/admin/backup*".to_string(),
"/wp-admin/*".to_string(),
"/phpmyadmin/*".to_string(),
"/.svn/*".to_string(),
"/.htaccess".to_string(),
"/web.config".to_string(),
"/config.php".to_string(),
]
}
impl Default for TrapConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
paths: default_paths(),
apply_max_risk: default_apply_max_risk(),
extended_tarpit_ms: Some(5000),
alert_telemetry: default_alert_telemetry(),
}
}
}
pub struct TrapMatcher {
pattern_set: RegexSet,
config: TrapConfig,
}
impl TrapMatcher {
pub fn new(config: TrapConfig) -> Result<Self, regex::Error> {
let regex_strings: Vec<String> = config
.paths
.iter()
.map(|p| glob_to_regex_string(p))
.collect();
let pattern_set = RegexSet::new(®ex_strings)?;
Ok(Self {
pattern_set,
config,
})
}
#[inline]
#[must_use]
pub fn is_trap(&self, path: &str) -> bool {
if !self.config.enabled {
return false;
}
let path_only = path.split('?').next().unwrap_or(path);
self.pattern_set.is_match(path_only)
}
pub fn config(&self) -> &TrapConfig {
&self.config
}
#[must_use]
pub fn matched_pattern(&self, path: &str) -> Option<&str> {
let path_only = path.split('?').next().unwrap_or(path);
self.pattern_set
.matches(path_only)
.iter()
.next()
.map(|i| self.config.paths[i].as_str())
}
}
fn glob_to_regex_string(glob: &str) -> String {
let mut regex_str = String::with_capacity(glob.len() * 2);
regex_str.push('^');
let mut chars = glob.chars().peekable();
while let Some(c) = chars.next() {
match c {
'*' => {
if chars.peek() == Some(&'*') {
chars.next(); regex_str.push_str(".*"); } else {
regex_str.push_str("[^/]*"); }
}
'?' => regex_str.push('.'),
'.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
regex_str.push('\\');
regex_str.push(c);
}
_ => regex_str.push(c),
}
}
regex_str.push('$');
regex_str
}
#[allow(dead_code)]
fn glob_to_regex(glob: &str) -> Result<Regex, regex::Error> {
Regex::new(&glob_to_regex_string(glob))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_glob_to_regex_exact() {
let re = glob_to_regex("/.env").unwrap();
assert!(re.is_match("/.env"));
assert!(!re.is_match("/.env.local"));
assert!(!re.is_match("/api/.env"));
}
#[test]
fn test_glob_to_regex_wildcard() {
let re = glob_to_regex("/.git/*").unwrap();
assert!(re.is_match("/.git/config"));
assert!(re.is_match("/.git/HEAD"));
assert!(!re.is_match("/.git"));
assert!(!re.is_match("/.git/objects/pack/file"));
}
#[test]
fn test_glob_to_regex_double_star() {
let re = glob_to_regex("/admin/**").unwrap();
assert!(re.is_match("/admin/backup"));
assert!(re.is_match("/admin/backup/db.sql"));
assert!(re.is_match("/admin/users/edit/1"));
}
#[test]
fn test_glob_to_regex_prefix() {
let re = glob_to_regex("/admin/backup*").unwrap();
assert!(re.is_match("/admin/backup"));
assert!(re.is_match("/admin/backup.sql"));
assert!(re.is_match("/admin/backup_2024.tar.gz"));
assert!(!re.is_match("/admin/backups/file"));
}
#[test]
fn test_trap_matcher_basic() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
assert!(matcher.is_trap("/.git/config"));
assert!(matcher.is_trap("/.env"));
assert!(matcher.is_trap("/wp-admin/install.php"));
assert!(!matcher.is_trap("/api/users"));
assert!(!matcher.is_trap("/"));
assert!(!matcher.is_trap("/health"));
}
#[test]
fn test_trap_matcher_disabled() {
let config = TrapConfig {
enabled: false,
..Default::default()
};
let matcher = TrapMatcher::new(config).unwrap();
assert!(!matcher.is_trap("/.git/config"));
}
#[test]
fn test_trap_matcher_strips_query() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
assert!(matcher.is_trap("/.env?foo=bar"));
assert!(matcher.is_trap("/.git/config?ref=main"));
}
#[test]
fn test_matched_pattern() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
assert_eq!(matcher.matched_pattern("/.git/config"), Some("/.git/*"));
assert_eq!(matcher.matched_pattern("/.env"), Some("/.env"));
assert_eq!(matcher.matched_pattern("/api/users"), None);
}
#[test]
fn test_default_config() {
let config = TrapConfig::default();
assert!(config.enabled);
assert!(config.apply_max_risk);
assert!(config.alert_telemetry);
assert_eq!(config.extended_tarpit_ms, Some(5000));
assert!(!config.paths.is_empty());
}
#[test]
fn test_custom_paths() {
let config = TrapConfig {
enabled: true,
paths: vec!["/secret/*".to_string(), "/internal/**".to_string()],
apply_max_risk: true,
extended_tarpit_ms: None,
alert_telemetry: false,
};
let matcher = TrapMatcher::new(config).unwrap();
assert!(matcher.is_trap("/secret/data"));
assert!(matcher.is_trap("/internal/deep/path/file"));
assert!(!matcher.is_trap("/.git/config")); }
#[test]
fn test_env_variations() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
assert!(matcher.is_trap("/.env"));
assert!(matcher.is_trap("/.env.local"));
assert!(matcher.is_trap("/.env.production"));
assert!(matcher.is_trap("/.env.backup"));
}
#[test]
fn test_special_regex_chars() {
let config = TrapConfig {
enabled: true,
paths: vec!["/test.php".to_string(), "/api/v1.0/*".to_string()],
..Default::default()
};
let matcher = TrapMatcher::new(config).unwrap();
assert!(matcher.is_trap("/test.php"));
assert!(!matcher.is_trap("/testXphp")); assert!(matcher.is_trap("/api/v1.0/users"));
}
#[test]
fn test_double_slash_normalization_not_matched() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
assert!(!matcher.is_trap("//.git/config"));
assert!(!matcher.is_trap("///.git/config"));
assert!(!matcher.is_trap("/.git//config"));
assert!(!matcher.is_trap("//.env"));
}
#[test]
fn test_dot_segment_traversal_attacks() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
assert!(!matcher.is_trap("/foo/../.git/config"));
assert!(!matcher.is_trap("/api/../../.env"));
assert!(!matcher.is_trap("/./admin/backup"));
assert!(!matcher.is_trap("/foo/.git/config"));
}
#[test]
fn test_unicode_path_variations() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
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"));
assert!(matcher.is_trap("/.env"));
}
#[test]
fn test_very_long_paths() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
let long_path = format!("/api/{}/data", "a".repeat(10000));
assert!(!matcher.is_trap(&long_path));
let long_trap_path = format!("/api/{}/.git/config", "a".repeat(10000));
assert!(!matcher.is_trap(&long_trap_path));
let trap_with_long_suffix = format!("/.git/{}", "a".repeat(10000));
assert!(matcher.is_trap(&trap_with_long_suffix));
}
#[test]
fn test_case_sensitivity_variations() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
assert!(matcher.is_trap("/.git/config"));
assert!(!matcher.is_trap("/.GIT/config"));
assert!(!matcher.is_trap("/.Git/Config"));
assert!(!matcher.is_trap("/.GIT/CONFIG"));
assert!(matcher.is_trap("/.env"));
assert!(!matcher.is_trap("/.ENV"));
assert!(!matcher.is_trap("/.Env"));
assert!(matcher.is_trap("/wp-admin/index.php"));
assert!(!matcher.is_trap("/WP-ADMIN/index.php"));
assert!(!matcher.is_trap("/Wp-Admin/index.php"));
}
#[test]
fn test_null_byte_injection() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
assert!(matcher.is_trap("/.git/config\x00.txt"));
assert!(!matcher.is_trap("/.env\x00.bak"));
assert!(!matcher.is_trap("/.env\x00.bak"));
assert!(!matcher.is_trap("/foo\x00/.git/config"));
}
#[test]
fn test_url_encoded_in_path() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
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"));
}
#[test]
fn test_backslash_path_separators() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
assert!(!matcher.is_trap("\\.git\\config"));
assert!(!matcher.is_trap("\\.env"));
assert!(!matcher.is_trap("\\wp-admin\\index.php"));
assert!(!matcher.is_trap("/.git\\config"));
assert!(!matcher.is_trap("\\.git/config"));
}
#[test]
fn test_empty_and_minimal_paths() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
assert!(!matcher.is_trap(""));
assert!(!matcher.is_trap("/"));
assert!(!matcher.is_trap("/."));
assert!(!matcher.is_trap("/.."));
assert!(!matcher.is_trap(".env"));
assert!(!matcher.is_trap(".git"));
}
#[test]
fn test_multiple_query_strings() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
assert!(matcher.is_trap("/.env?foo=bar?baz=qux"));
assert!(matcher.is_trap("/.git/config?a=1&b=2"));
assert!(!matcher.is_trap("/api?path=/.git/config"));
assert!(!matcher.is_trap("/.env#section"));
}
#[test]
fn test_question_mark_glob_pattern() {
let config = TrapConfig {
enabled: true,
paths: vec!["/secret?.txt".to_string(), "/admin?/*".to_string()],
..Default::default()
};
let matcher = TrapMatcher::new(config).unwrap();
assert!(matcher.is_trap("/secret1.txt"));
assert!(matcher.is_trap("/secretX.txt"));
assert!(!matcher.is_trap("/secret.txt")); assert!(!matcher.is_trap("/secret12.txt"));
assert!(matcher.is_trap("/admin1/file"));
assert!(matcher.is_trap("/adminX/file"));
assert!(!matcher.is_trap("/admin/file")); assert!(!matcher.is_trap("/admin12/file")); }
#[test]
fn test_nested_trap_patterns() {
let config = TrapConfig {
enabled: true,
paths: vec!["/deep/**/secret/*".to_string(), "/a/**/b/**/c".to_string()],
..Default::default()
};
let matcher = TrapMatcher::new(config).unwrap();
assert!(matcher.is_trap("/deep/any/path/here/secret/file"));
assert!(matcher.is_trap("/deep/x/secret/file"));
assert!(matcher.is_trap("/a/x/b/y/c"));
assert!(matcher.is_trap("/a/foo/bar/b/baz/c"));
assert!(!matcher.is_trap("/a/b/c"));
assert!(!matcher.is_trap("/deep/secret/file"));
}
#[test]
fn test_special_characters_in_custom_paths() {
let config = TrapConfig {
enabled: true,
paths: vec![
"/file+name.php".to_string(),
"/path(with)parens/*".to_string(),
"/regex[chars]test".to_string(),
"/dollar$sign.txt".to_string(),
"/caret^file.txt".to_string(),
"/pipe|char.txt".to_string(),
"/brace{test}end".to_string(),
],
..Default::default()
};
let matcher = TrapMatcher::new(config).unwrap();
assert!(matcher.is_trap("/file+name.php"));
assert!(matcher.is_trap("/path(with)parens/anything"));
assert!(matcher.is_trap("/regex[chars]test"));
assert!(matcher.is_trap("/dollar$sign.txt"));
assert!(matcher.is_trap("/caret^file.txt"));
assert!(matcher.is_trap("/pipe|char.txt"));
assert!(matcher.is_trap("/brace{test}end"));
assert!(!matcher.is_trap("/filename.php")); assert!(!matcher.is_trap("/pathwithparens/test")); }
#[test]
fn test_whitespace_in_paths() {
let config = TrapConfig::default();
let matcher = TrapMatcher::new(config).unwrap();
assert!(!matcher.is_trap(" /.git/config"));
assert!(!matcher.is_trap(" /.env "));
assert!(matcher.is_trap("/.git/config "));
assert!(!matcher.is_trap("/. git/config"));
assert!(!matcher.is_trap("/ .env"));
assert!(!matcher.is_trap("/\t.git/config"));
assert!(matcher.is_trap("/.git/\tconfig"));
}
#[test]
fn test_config_getter() {
let custom_config = TrapConfig {
enabled: true,
paths: vec!["/custom/*".to_string()],
apply_max_risk: false,
extended_tarpit_ms: Some(10000),
alert_telemetry: false,
};
let matcher = TrapMatcher::new(custom_config.clone()).unwrap();
let config = matcher.config();
assert!(config.enabled);
assert!(!config.apply_max_risk);
assert_eq!(config.extended_tarpit_ms, Some(10000));
assert!(!config.alert_telemetry);
assert_eq!(config.paths.len(), 1);
}
#[test]
fn test_empty_paths_config() {
let config = TrapConfig {
enabled: true,
paths: vec![],
..Default::default()
};
let matcher = TrapMatcher::new(config).unwrap();
assert!(!matcher.is_trap("/.git/config"));
assert!(!matcher.is_trap("/.env"));
assert!(!matcher.is_trap("/anything"));
}
#[test]
fn test_matched_pattern_returns_correct_pattern() {
let config = TrapConfig {
enabled: true,
paths: vec![
"/first/*".to_string(),
"/second/**".to_string(),
"/third".to_string(),
],
..Default::default()
};
let matcher = TrapMatcher::new(config).unwrap();
assert_eq!(matcher.matched_pattern("/first/file"), Some("/first/*"));
assert_eq!(
matcher.matched_pattern("/second/deep/path"),
Some("/second/**")
);
assert_eq!(matcher.matched_pattern("/third"), Some("/third"));
assert_eq!(matcher.matched_pattern("/nonexistent"), None);
}
#[test]
fn test_glob_to_regex_edge_cases() {
let re = glob_to_regex("").unwrap();
assert!(re.is_match(""));
assert!(!re.is_match("something"));
let re_star = glob_to_regex("*").unwrap();
assert!(re_star.is_match("anything"));
assert!(re_star.is_match(""));
assert!(!re_star.is_match("with/slash"));
let re_double_star = glob_to_regex("**").unwrap();
assert!(re_double_star.is_match("anything"));
assert!(re_double_star.is_match("with/slash/deep"));
assert!(re_double_star.is_match(""));
let re_mixed = glob_to_regex("**/file_*_?.txt").unwrap();
assert!(re_mixed.is_match("path/to/file_test_1.txt"));
assert!(re_mixed.is_match("dir/file_abc_X.txt")); assert!(!re_mixed.is_match("file_test_12.txt")); }
}