From cb2f106239a95d840c2ec20426ffcf3577f8d9d1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 2 May 2025 09:05:32 +0300 Subject: [PATCH] eris: support regex patterns; add TOML config support --- Cargo.lock | 64 +++++++++++++- Cargo.toml | 8 +- src/main.rs | 235 ++++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 275 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e3eb40..fe88725 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -414,9 +414,7 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", - "js-sys", "num-traits", - "wasm-bindgen", "windows-link", ] @@ -442,7 +440,6 @@ version = "4.5.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" dependencies = [ - "anstream", "anstyle", "clap_lex", "strsim", @@ -633,11 +630,13 @@ dependencies = [ "prometheus 0.14.0", "prometheus_exporter", "rand", + "regex", "rlua", "serde", "serde_json", "tempfile", "tokio", + "toml", ] [[package]] @@ -1640,6 +1639,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1896,6 +1904,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" + [[package]] name = "tracing" version = "0.1.41" @@ -2207,6 +2256,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e27d6ad3dac991091e4d35de9ba2d2d00647c5d0fc26c5496dee55984ae111b" +dependencies = [ + "memchr", +] + [[package]] name = "winsafe" version = "0.0.19" diff --git a/Cargo.toml b/Cargo.toml index c02e277..7efec6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] -actix-web = "4.3.1" -clap = { version = "4.3", features = ["derive"] } -chrono = "0.4.24" +actix-web = { version = "4.3.1" } +chrono = { version = "0.4.41", default-features = false, features = ["std", "clock"] } +clap = { version = "4.5", default-features = false, features = ["std", "derive", "help", "usage", "suggestions"] } futures = "0.3.28" ipnetwork = "0.21.1" lazy_static = "1.4.0" @@ -20,3 +20,5 @@ tokio = { version = "1.28.0", features = ["full"] } log = "0.4.27" env_logger = "0.11.8" tempfile = "3.19.1" +regex = "1.11.1" +toml = "0.8.22" diff --git a/src/main.rs b/src/main.rs index 387e886..185c258 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use actix_web::{App, HttpResponse, HttpServer, web}; use clap::Parser; use ipnetwork::IpNetwork; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::env; @@ -96,7 +97,7 @@ struct Args { #[clap( long, - help = "Path to JSON configuration file (overrides command line options)" + help = "Path to configuration file (JSON or TOML, overrides command line options)" )] config_file: Option, @@ -108,6 +109,44 @@ struct Args { log_level: String, } +// Trap pattern structure that can be either a plain string or regex +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +enum TrapPattern { + Plain(String), + Regex { pattern: String, regex: bool }, +} + +impl TrapPattern { + fn as_plain(value: &str) -> Self { + Self::Plain(value.to_string()) + } + + fn as_regex(value: &str) -> Self { + Self::Regex { + pattern: value.to_string(), + regex: true, + } + } + + fn matches(&self, path: &str) -> bool { + match self { + Self::Plain(pattern) => path.contains(pattern), + Self::Regex { + pattern, + regex: true, + } => { + if let Ok(re) = Regex::new(pattern) { + re.is_match(path) + } else { + false + } + } + _ => false, + } + } +} + // Configuration structure #[derive(Clone, Debug, Deserialize, Serialize)] struct Config { @@ -119,7 +158,7 @@ struct Config { max_delay: u64, max_tarpit_time: u64, block_threshold: u32, - trap_patterns: Vec, + trap_patterns: Vec, whitelist_networks: Vec, markov_corpora_dir: String, lua_scripts_dir: String, @@ -140,17 +179,37 @@ impl Default for Config { max_tarpit_time: 600, block_threshold: 3, trap_patterns: vec![ - "/vendor/phpunit".to_string(), - "eval-stdin.php".to_string(), - "/wp-admin".to_string(), - "/wp-login.php".to_string(), - "/xmlrpc.php".to_string(), - "/phpMyAdmin".to_string(), - "/solr/".to_string(), - "/.env".to_string(), - "/config".to_string(), - "/api/".to_string(), - "/actuator/".to_string(), + // Basic attack patterns as plain strings + TrapPattern::as_plain("/vendor/phpunit"), + TrapPattern::as_plain("eval-stdin.php"), + TrapPattern::as_plain("/wp-admin"), + TrapPattern::as_plain("/wp-login.php"), + TrapPattern::as_plain("/xmlrpc.php"), + TrapPattern::as_plain("/phpMyAdmin"), + TrapPattern::as_plain("/solr/"), + TrapPattern::as_plain("/.env"), + TrapPattern::as_plain("/config"), + TrapPattern::as_plain("/actuator/"), + // More aggressive patterns for various PHP exploits + TrapPattern::as_regex(r"/.*phpunit.*eval-stdin\.php"), + TrapPattern::as_regex(r"/index\.php\?s=/index/\\think\\app/invokefunction"), + TrapPattern::as_regex(r".*%ADd\+auto_prepend_file%3dphp://input.*"), + TrapPattern::as_regex(r".*%ADd\+allow_url_include%3d1.*"), + TrapPattern::as_regex(r".*/wp-content/plugins/.*\.php"), + TrapPattern::as_regex(r".*/wp-content/themes/.*\.php"), + TrapPattern::as_regex(r".*eval\(.*\).*"), + TrapPattern::as_regex(r".*/adminer\.php.*"), + TrapPattern::as_regex(r".*/admin\.php.*"), + TrapPattern::as_regex(r".*/administrator/.*"), + TrapPattern::as_regex(r".*/wp-json/.*"), + TrapPattern::as_regex(r".*/api/.*\.php.*"), + TrapPattern::as_regex(r".*/cgi-bin/.*"), + TrapPattern::as_regex(r".*/owa/.*"), + TrapPattern::as_regex(r".*/ecp/.*"), + TrapPattern::as_regex(r".*/webshell\.php.*"), + TrapPattern::as_regex(r".*/shell\.php.*"), + TrapPattern::as_regex(r".*/cmd\.php.*"), + TrapPattern::as_regex(r".*/struts.*"), ], whitelist_networks: vec![ "192.168.0.0/16".to_string(), @@ -234,19 +293,65 @@ impl Config { } } - // Load configuration from a JSON file + // Load configuration from a file (JSON or TOML) fn load_from_file(path: &Path) -> std::io::Result { let content = fs::read_to_string(path)?; - let config = serde_json::from_str(&content)?; + + let extension = path + .extension() + .map(|ext| ext.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + + let config = match extension.as_str() { + "toml" => toml::from_str(&content).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Failed to parse TOML: {e}"), + ) + })?, + _ => { + // Default to JSON for any other extension + serde_json::from_str(&content).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Failed to parse JSON: {e}"), + ) + })? + } + }; + Ok(config) } - // Save configuration to a JSON file + // Save configuration to a file (JSON or TOML) fn save_to_file(&self, path: &Path) -> std::io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let content = serde_json::to_string_pretty(self)?; + + let extension = path + .extension() + .map(|ext| ext.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + + let content = match extension.as_str() { + "toml" => toml::to_string_pretty(self).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Failed to serialize to TOML: {e}"), + ) + })?, + _ => { + // Default to JSON for any other extension + serde_json::to_string_pretty(self).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Failed to serialize to JSON: {e}"), + ) + })? + } + }; + fs::write(path, content)?; Ok(()) } @@ -394,7 +499,7 @@ fn extract_path_from_request(data: &[u8]) -> Option<&str> { .next() .filter(|line| !line.is_empty())?; - // Split by spaces and ensure we have at least 3 parts + // Split by spaces and ensure we have at least 3 parts (METHOD PATH VERSION) let parts: Vec<&[u8]> = first_line.split(|&b| b == b' ').collect(); if parts.len() < 3 || !parts[2].starts_with(b"HTTP/") { return None; @@ -886,10 +991,9 @@ async fn should_tarpit(path: &str, ip: &IpAddr, config: &Config) -> bool { } } - // Use a more efficient pattern matching approach - let path_lower = path.to_lowercase(); + // Use pattern matching based on the trap pattern type (plain string or regex) for pattern in &config.trap_patterns { - if path_lower.contains(pattern) { + if pattern.matches(path) { return true; } } @@ -1092,12 +1196,23 @@ async fn main() -> std::io::Result<()> { if let Err(e) = fs::create_dir_all(&config.config_dir) { log::warn!("Failed to create config directory: {e}"); } else { - let config_path = Path::new(&config.config_dir).join("config.json"); - if !config_path.exists() { - if let Err(e) = config.save_to_file(&config_path) { - log::warn!("Failed to save default configuration: {e}"); + // Save both JSON and TOML versions of the config for user reference + let config_path_json = Path::new(&config.config_dir).join("config.json"); + let config_path_toml = Path::new(&config.config_dir).join("config.toml"); + + if !config_path_json.exists() { + if let Err(e) = config.save_to_file(&config_path_json) { + log::warn!("Failed to save JSON configuration: {e}"); } else { - log::info!("Saved default configuration to {config_path:?}"); + log::info!("Saved JSON configuration to {config_path_json:?}"); + } + } + + if !config_path_toml.exists() { + if let Err(e) = config.save_to_file(&config_path_toml) { + log::warn!("Failed to save TOML configuration: {e}"); + } else { + log::info!("Saved TOML configuration to {config_path_toml:?}"); } } } @@ -1318,6 +1433,27 @@ mod tests { assert_eq!(config.cache_dir, "/tmp/eris/cache"); } + #[test] + fn test_trap_pattern_matching() { + // Test plain string pattern + let plain = TrapPattern::as_plain("phpunit"); + assert!(plain.matches("path/to/phpunit/test")); + assert!(!plain.matches("path/to/something/else")); + + // Test regex pattern + let regex = TrapPattern::as_regex(r".*eval-stdin\.php.*"); + assert!(regex.matches("/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php")); + assert!(regex.matches("/tests/eval-stdin.php?param")); + assert!(!regex.matches("/normal/path")); + + // Test invalid regex pattern (should return false) + let invalid = TrapPattern::Regex { + pattern: "(invalid[regex".to_string(), + regex: true, + }; + assert!(!invalid.matches("anything")); + } + #[tokio::test] async fn test_should_tarpit() { let config = Config::default(); @@ -1376,6 +1512,25 @@ mod tests { ) .await ); + + // Test regex patterns + assert!( + should_tarpit( + "/index.php?s=/index/\\think\\app/invokefunction&function=call_user_func_array&vars[0]=md5&vars[1][]=Hello", + &IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + &config + ) + .await + ); + + assert!( + should_tarpit( + "/hello.world?%ADd+allow_url_include%3d1+%ADd+auto_prepend_file%3dphp://input", + &IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + &config + ) + .await + ); } #[tokio::test] @@ -1403,6 +1558,7 @@ mod tests { state.blocked.insert(ip1); assert!(state.blocked.contains(&ip1)); assert!(!state.blocked.contains(&ip2)); + drop(state); } // Test active connections @@ -1416,6 +1572,7 @@ mod tests { assert_eq!(state.active_connections.len(), 1); assert!(!state.active_connections.contains(&ip1)); assert!(state.active_connections.contains(&ip2)); + drop(state); } } @@ -1477,4 +1634,30 @@ mod tests { assert_eq!(choose_response_type("/api/v1/users"), "api"); assert_eq!(choose_response_type("/index.html"), "generic"); } + + #[test] + fn test_config_file_formats() { + // Create temporary JSON config file + let temp_dir = std::env::temp_dir(); + let json_path = temp_dir.join("temp_config.json"); + let toml_path = temp_dir.join("temp_config.toml"); + + let config = Config::default(); + + // Test JSON serialization and deserialization + config.save_to_file(&json_path).unwrap(); + let loaded_json = Config::load_from_file(&json_path).unwrap(); + assert_eq!(loaded_json.listen_addr, config.listen_addr); + assert_eq!(loaded_json.min_delay, config.min_delay); + + // Test TOML serialization and deserialization + config.save_to_file(&toml_path).unwrap(); + let loaded_toml = Config::load_from_file(&toml_path).unwrap(); + assert_eq!(loaded_toml.listen_addr, config.listen_addr); + assert_eq!(loaded_toml.min_delay, config.min_delay); + + // Clean up + let _ = std::fs::remove_file(json_path); + let _ = std::fs::remove_file(toml_path); + } }