diff --git a/.env.sample b/.env.sample index e96469a..075682e 100644 --- a/.env.sample +++ b/.env.sample @@ -78,6 +78,26 @@ RUST_BACKTRACE=full # traffic heuristic alone. #STACKDOG_MAIL_GUARD_REQUIRE_SMTP=true +# PHP shell detection. Scans PHP files in web roots for webshell code and +# watches access logs for requests to them. A flagged file is an alert; a +# request that drives a shell (POST to PHP under an uploads/ URL, parameters +# sent to a flagged file) feeds the IP ban like any other detector finding. +# false turns off both the scan and the access-log detector. +#STACKDOG_PHPSHELL_ENABLED=true +# Host web roots to scan. Containers are found on their own (see below). +#STACKDOG_PHPSHELL_PATHS=/var/www +# Container name/image fragments that mark a PHP workload. Any container with +# something mounted at /var/www, /srv, /app or /usr/share/nginx/html is scanned +# as well, whatever its name. +#STACKDOG_PHPSHELL_CONTAINERS=php,wordpress,drupal,joomla,laravel,magento,apache,httpd +# Where the host filesystem is mounted when Stackdog runs in a container +# (- /:/host:ro). Needed to read host paths and container docroots. +#STACKDOG_HOST_ROOT=/host +#STACKDOG_PHPSHELL_SKIP_DIRS=vendor,node_modules,.git +#STACKDOG_PHPSHELL_MAX_FILE_BYTES=2097152 +# Extra signatures, JSON: [{"id","description","pattern","weight":"strong|medium|weak"}] +#STACKDOG_PHPSHELL_SIGNATURES=/etc/stackdog/php-shell-signatures.json + # --------------------------------------------------------------------------- # Dashboard container (docker-compose.yml / docker-compose.app.yml) # --------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 272ff92..ee5abc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- PHP shell detection. Each sniff pass scores the PHP files in host web roots + (`STACKDOG_PHPSHELL_PATHS`) and in the docroots of PHP containers for webshell + code, and alerts on new or changed files that score as one (`php.shell-file`). + Requests that drive a shell — any request to a flagged upload through an + `uploads/` URL, parameters sent to a flagged file that is not a common entry + point, a POST to PHP under an `uploads/` URL (PATH_INFO and `//`, `.`, `..` + resolved) — are banned like other detector findings (`web.php-shell-access`). + A plain visit to an infected page is an alert only (`php.shell-request`). + Running in Docker, mount the host read-only and set `STACKDOG_HOST_ROOT`. + Extra signatures load from JSON (`STACKDOG_PHPSHELL_SIGNATURES`). + ## [0.2.6] - 2026-10-27 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index c1540a8..9e87bd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ bollard = "0.16" # HTTP client (for LLM API) reqwest = { version = "0.12.24", default-features = false, features = ["json", "blocking", "rustls-tls"] } sha2 = "0.10" +regex = "1" # Compression zstd = "0.13" diff --git a/README.md b/README.md index d5eaf69..483131a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - **📊 Real-time Monitoring** — eBPF-based syscall monitoring with minimal overhead (<5% CPU) - **🔍 Log Sniffing** — Discover, read, and AI-summarize logs from containers and system files +- **🐘 PHP Shell Detection** — Finds webshells in host and container web roots and bans the clients driving them - **🧭 Detector Framework** — Rust-native detector registry for web attack heuristics and outbound exfiltration indicators - **🤖 AI/ML Detection** — Candle-powered anomaly detection + OpenAI/Ollama log analysis - **🚨 Alert System** — Multi-channel notifications (Slack, email, webhook) @@ -566,6 +567,53 @@ curl -X POST http://localhost:5000/api/logs/sources \ curl http://localhost:5000/api/logs/summaries?source_id=myapp ``` +### 8. PHP Shell Detection + +Every sniff pass reads the PHP files in web roots and scores them for webshell +code: request input reaching `eval`/`system`/`assert`, `eval` of decoded data, +`preg_replace` with `/e`, request input called as a function, and markers of +known families (c99, r57, WSO, b374k, p0wny, China Chopper). One keyword never +flags a file — WordPress calls `base64_decode`, Composer calls `proc_open` — the +score does. PHP in an upload directory, and PHP hidden in an image there, add to +it. + +**Where it looks:** +- host paths in `STACKDOG_PHPSHELL_PATHS`; +- PHP containers, found on their own: by name or image + (`STACKDOG_PHPSHELL_CONTAINERS`), or by a mount at `/var/www`, `/srv`, `/app` + or `/usr/share/nginx/html`. Mounted docroots are read at their host source, + docroots baked into the image through the overlay `MergedDir`. + +Running in Docker, Stackdog reads both through a read-only mount of the host: + +```yaml +environment: + STACKDOG_HOST_ROOT: /host + STACKDOG_PHPSHELL_PATHS: /var/www +volumes: + - /:/host:ro +``` + +**What it does:** + +| Finding | When | Action | +|---|---|---| +| `php.shell-file` | a new or changed file scores as a shell | alert (a file names no attacker) | +| `web.php-shell-access` | any request to a flagged upload whose URL goes through `uploads/`; a request with parameters to a flagged file that is not a common entry point; a POST to PHP under an `uploads/` URL (`/images/`, `/files/`, `/tmp/` URLs never ban on their own) | alert + IP ban | +| `php.shell-request` | a plain visit to a flagged page such as `index.php` | alert only, never a ban — banning visitors of an infected page would ban its audience | + +Bans go through the same gate as every other ban: `STACKDOG_IP_BAN_ALLOWLIST`, +protected local addresses and the per-minute ceiling all apply. Stackdog never +deletes or moves the file; removing it is your call. + +Add signatures without a rebuild by pointing `STACKDOG_PHPSHELL_SIGNATURES` at +a JSON file: + +```json +[{"id": "acme-shell", "description": "ACME shell marker", + "pattern": "acme_shell_v\\d", "weight": "strong"}] +``` + --- ## 📦 Installation diff --git a/docker-compose.yml b/docker-compose.yml index dcf7f01..44083c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,10 @@ services: STACKDOG_AI_API_KEY: STACKDOG_SLACK_WEBHOOK_URL: Result<()> { [], ); + conn.execute( + "CREATE TABLE IF NOT EXISTS php_shell_scans ( + path TEXT PRIMARY KEY, + owner TEXT NOT NULL, + logical_path TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + sha256 TEXT NOT NULL, + score INTEGER NOT NULL, + rules TEXT NOT NULL, + in_upload_dir INTEGER NOT NULL, + scanned_at TEXT NOT NULL + )", + [], + )?; + conn.execute( "CREATE TABLE IF NOT EXISTS ip_offenses ( id TEXT PRIMARY KEY, diff --git a/src/detectors/http_request.rs b/src/detectors/http_request.rs new file mode 100644 index 0000000..818c103 --- /dev/null +++ b/src/detectors/http_request.rs @@ -0,0 +1,179 @@ +//! Extract the HTTP request a log line records. +//! +//! Web detectors used to match their indicators anywhere in a line. An +//! application that logs whole requests — headers, bodies, its own +//! middleware names — then trips them on its own traffic: stacker's agent +//! long-poll lines carried enough header text to read as webshell probing. +//! Detectors that are about the URL should look at the URL only. + +use std::sync::OnceLock; + +use regex::Regex; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HttpRequest { + pub method: String, + /// Percent-decoded, lowercased path without the query string. + pub path: String, + /// Percent-decoded, lowercased query string without the leading `?`. + pub query: String, +} + +impl HttpRequest { + pub fn has_parameters(&self) -> bool { + !self.query.is_empty() || self.method == "POST" + } +} + +const METHODS: &str = "GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS"; + +fn combined_format() -> &'static Regex { + // nginx/apache combined and common formats: "GET /path?q HTTP/1.1" + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(&format!(r#""({METHODS}) (\S+) HTTP/[0-9.]+""#)).expect("valid regex") + }) +} + +fn method_colon_format() -> &'static Regex { + // actix ServiceRequest debug output, as stacker logs it: "GET:/api/v1/..." + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(&format!(r#"\b({METHODS}):(/[^\s"\\]*)"#)).expect("valid regex")) +} + +fn bare_format() -> &'static Regex { + // Plain "GET /path" without quotes, as many app loggers write it. + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(&format!(r#"(?:^|[\s\[\]|])({METHODS}) (/[^\s"\\]*)"#)).expect("valid regex") + }) +} + +/// The request a log line records, if it records one. +pub fn extract_request(line: &str) -> Option { + let captures = combined_format() + .captures(line) + .or_else(|| method_colon_format().captures(line)) + .or_else(|| bare_format().captures(line))?; + + let method = captures.get(1)?.as_str().to_ascii_uppercase(); + let raw_target = captures.get(2)?.as_str(); + let raw_target = strip_scheme_and_host(raw_target); + + let (raw_path, raw_query) = match raw_target.split_once('?') { + Some((path, query)) => (path, query), + None => (raw_target, ""), + }; + + Some(HttpRequest { + method, + path: percent_decode(raw_path).to_ascii_lowercase(), + query: percent_decode(raw_query).to_ascii_lowercase(), + }) +} + +/// A proxy log can record an absolute URI: "GET http://host/path HTTP/1.1". +fn strip_scheme_and_host(target: &str) -> &str { + for scheme in ["http://", "https://"] { + if let Some(rest) = target.strip_prefix(scheme) { + return rest.find('/').map(|index| &rest[index..]).unwrap_or("/"); + } + } + target +} + +/// Decode `%XX` escapes and `+` once. Attackers encode `cmd%3Did`; a second +/// pass would turn literal `%25` in a legitimate URL into something it isn't. +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + + while index < bytes.len() { + match bytes[index] { + b'%' if index + 2 < bytes.len() => { + match (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) { + (Some(high), Some(low)) => { + decoded.push(high << 4 | low); + index += 3; + } + _ => { + decoded.push(b'%'); + index += 1; + } + } + } + b'+' => { + decoded.push(b' '); + index += 1; + } + byte => { + decoded.push(byte); + index += 1; + } + } + } + + String::from_utf8_lossy(&decoded).into_owned() +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extracts_combined_log_format() { + let request = extract_request( + r#"203.0.113.9 - - [31/Oct/2026:10:00:00 +0000] "GET /wp-content/uploads/x.php?cmd=id HTTP/1.1" 200 12 "-" "curl/8""#, + ) + .unwrap(); + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/wp-content/uploads/x.php"); + assert_eq!(request.query, "cmd=id"); + } + + #[test] + fn test_extracts_actix_service_request_format() { + let request = extract_request( + r#"{"req":"\nServiceRequest HTTP/1.1 GET:/api/v1/agent/commands/wait/deployment_1\n query: ?\"timeout=10\"\n"}"#, + ) + .unwrap(); + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/api/v1/agent/commands/wait/deployment_1"); + assert_eq!(request.query, ""); + } + + #[test] + fn test_decodes_percent_escapes_once() { + let request = + extract_request(r#""POST /shell.php?c%3Dwhoami%26x=%2541 HTTP/1.1""#).unwrap(); + assert_eq!(request.query, "c=whoami&x=%41"); + } + + #[test] + fn test_strips_absolute_uri() { + let request = extract_request(r#""GET http://example.com/a.php?x=1 HTTP/1.1""#).unwrap(); + assert_eq!(request.path, "/a.php"); + } + + #[test] + fn test_line_without_request_yields_none() { + assert!(extract_request("sshd[1]: Failed password for root from 203.0.113.9").is_none()); + assert!(extract_request("worker: ran /bin/sh -c cleanup").is_none()); + } + + #[test] + fn test_truncated_escape_is_kept_literally() { + let request = extract_request(r#""GET /a?b=%4 HTTP/1.1""#).unwrap(); + assert_eq!(request.query, "b=%4"); + } +} diff --git a/src/detectors/mod.rs b/src/detectors/mod.rs index d248dfd..0239ed4 100644 --- a/src/detectors/mod.rs +++ b/src/detectors/mod.rs @@ -5,8 +5,10 @@ //! anomalies that flow through the existing sniff/reporting pipeline. pub mod audits; +pub(crate) mod http_request; mod integrity; mod ml; +pub mod php_shell; use std::collections::HashSet; @@ -18,6 +20,7 @@ pub use self::audits::ContainerPosture; use self::audits::{ConfigAssessmentMonitor, DockerPostureMonitor, PackageInventoryMonitor}; use self::integrity::FileIntegrityMonitor; use self::ml::MlBehavioralDetector; +use self::php_shell::{PhpShellAccessDetector, PhpShellOptions, PhpShellScanner, ScanRoot}; use crate::database::connection::DbPool; use crate::sniff::analyzer::{AnomalySeverity, LogAnomaly}; use crate::sniff::reader::LogEntry; @@ -93,6 +96,7 @@ pub struct DetectorRegistry { config_assessment_monitor: ConfigAssessmentMonitor, package_inventory_monitor: PackageInventoryMonitor, docker_posture_monitor: DockerPostureMonitor, + php_shell_scanner: PhpShellScanner, } impl DetectorRegistry { @@ -103,6 +107,7 @@ impl DetectorRegistry { config_assessment_monitor: ConfigAssessmentMonitor, package_inventory_monitor: PackageInventoryMonitor, docker_posture_monitor: DockerPostureMonitor, + php_shell_scanner: PhpShellScanner::new(), } } @@ -113,6 +118,10 @@ impl DetectorRegistry { self.detectors.push(Box::new(detector)); } + pub fn remove_detector(&mut self, id: &str) { + self.detectors.retain(|detector| detector.id() != id); + } + pub fn register_builtin_log_detectors(&mut self) { self.register(SqlInjectionProbeDetector); self.register(PathTraversalDetector); @@ -126,6 +135,9 @@ impl DetectorRegistry { self.register(SecretLeakageDetector); self.register(WebArchiveProbeDetector); self.register(MlBehavioralDetector::new()); + self.register(PhpShellAccessDetector::new( + self.php_shell_scanner.flagged_files(), + )); } pub fn detect_log_anomalies(&self, entries: &[LogEntry]) -> Vec { @@ -160,6 +172,20 @@ impl DetectorRegistry { .collect()) } + pub fn detect_php_shell_anomalies( + &self, + pool: &DbPool, + roots: &[ScanRoot], + options: &PhpShellOptions, + ) -> Result> { + Ok(self + .php_shell_scanner + .detect(pool, roots, options)? + .into_iter() + .map(|finding| finding.to_log_anomaly()) + .collect()) + } + pub fn detect_config_assessment_anomalies(&self, paths: &[String]) -> Result> { Ok(self .config_assessment_monitor diff --git a/src/detectors/php_shell/access.rs b/src/detectors/php_shell/access.rs new file mode 100644 index 0000000..e8e0f97 --- /dev/null +++ b/src/detectors/php_shell/access.rs @@ -0,0 +1,417 @@ +//! Requests to PHP shells, read from access logs. +//! +//! Banning a visitor of an infected *legitimate* page would ban the site's +//! audience: a backdoor injected into `index.php` is fetched by everyone. So a +//! request is grounds for a ban only when it can come from nobody but the +//! attacker: +//! +//! - any request to a flagged file in an upload directory, with the upload +//! directory in the URL — no visitor has a reason to execute an upload; +//! - a request with parameters (query string or POST) to a flagged file that +//! is not a common entry point — that is someone driving the shell; +//! - a POST to any PHP file under an `uploads/` URL, flagged or not yet. +//! +//! The URL side of "upload directory" is only `uploads/`: `/images/`, +//! `/files/` and `/tmp/` are ordinary URL trees with real PHP endpoints. +//! +//! Other requests to flagged files raise an alert-only finding. + +use std::collections::HashSet; + +use crate::detectors::http_request::{extract_request, HttpRequest}; +use crate::detectors::{DetectorFamily, DetectorFinding, LogDetector}; +use crate::ip_ban::engine::IpBanEngine; +use crate::sniff::analyzer::AnomalySeverity; +use crate::sniff::reader::LogEntry; + +use super::{FlaggedFile, FlaggedFiles, PHP_EXTENSIONS}; + +pub const ACCESS_DETECTOR_ID: &str = "web.php-shell-access"; +/// Alert-only: a flagged file was requested in a way visitors also do. +pub const REQUEST_DETECTOR_ID: &str = "php.shell-request"; + +/// One finding per client per pass is enough to count an offense; more only +/// floods the alert channel. +const MAX_FINDINGS: usize = 20; + +/// URL segments that mark an upload directory. Narrower than the scanner's +/// list, because a ban rests on it. +const URL_UPLOAD_DIR_SEGMENTS: &[&str] = &["uploads"]; + +/// Files every visitor of a PHP site requests, with parameters. +const COMMON_ENTRY_POINTS: &[&str] = &[ + "index.php", + "wp-login.php", + "wp-cron.php", + "xmlrpc.php", + "admin-ajax.php", + "admin-post.php", + "admin.php", + "login.php", + "wp-comments-post.php", +]; + +pub struct PhpShellAccessDetector { + flagged: FlaggedFiles, +} + +impl PhpShellAccessDetector { + pub fn new(flagged: FlaggedFiles) -> Self { + Self { flagged } + } +} + +enum Verdict { + Ban(&'static str), + Alert, +} + +impl LogDetector for PhpShellAccessDetector { + fn id(&self) -> &'static str { + ACCESS_DETECTOR_ID + } + + fn family(&self) -> DetectorFamily { + DetectorFamily::Web + } + + fn detect(&self, entries: &[LogEntry]) -> Vec { + let flagged = match self.flagged.read() { + Ok(flagged) => flagged.clone(), + Err(_) => return Vec::new(), + }; + + let mut seen = HashSet::new(); + let mut findings = Vec::new(); + + for entry in entries { + if findings.len() >= MAX_FINDINGS { + break; + } + let Some(request) = extract_request(&entry.line) else { + continue; + }; + let Some(verdict) = judge(&request, &flagged) else { + continue; + }; + + let client = IpBanEngine::extract_ip_candidates(&entry.line) + .into_iter() + .next() + .unwrap_or_default(); + let detector_id = match verdict { + Verdict::Ban(_) => ACCESS_DETECTOR_ID, + Verdict::Alert => REQUEST_DETECTOR_ID, + }; + if !seen.insert((detector_id, client)) { + continue; + } + + findings.push(match verdict { + Verdict::Ban(reason) => DetectorFinding { + detector_id: ACCESS_DETECTOR_ID.into(), + family: DetectorFamily::Web, + description: format!( + "PHP shell access: {} {} ({})", + request.method, request.path, reason + ), + severity: AnomalySeverity::Critical, + confidence: 92, + sample_line: entry.line.clone(), + }, + Verdict::Alert => DetectorFinding { + detector_id: REQUEST_DETECTOR_ID.into(), + family: DetectorFamily::Web, + description: format!( + "Request to a file flagged as a PHP shell: {} {}", + request.method, request.path + ), + severity: AnomalySeverity::High, + confidence: 70, + sample_line: entry.line.clone(), + }, + }); + } + + findings + } +} + +fn judge(request: &HttpRequest, flagged: &[FlaggedFile]) -> Option { + let path = script_path(&request.path); + let file_name = path.rsplit('/').next().unwrap_or_default(); + if file_name.is_empty() { + return None; + } + let url_in_uploads = in_url_upload_dir(&path); + + if let Some(file) = flagged + .iter() + .find(|file| same_file(&file.logical_path, &path)) + { + // The URL must carry the upload directory itself: a suffix match of + // `/index.php` against `.../uploads/index.php` is the front page. + if file.in_upload_dir && url_in_uploads { + return Some(Verdict::Ban("flagged file in an upload directory")); + } + if request.has_parameters() && !COMMON_ENTRY_POINTS.contains(&file_name) { + return Some(Verdict::Ban("parameters sent to a flagged file")); + } + return Some(Verdict::Alert); + } + + if request.method == "POST" && is_php(file_name) && url_in_uploads { + return Some(Verdict::Ban("POST to PHP in an upload directory")); + } + + None +} + +/// The script a request executes. Segments are normalised the way the web +/// server resolves them (`//`, `.`, `..`), and anything after the first PHP +/// segment is PATH_INFO: `/up/x.php/cmd` and `/up/x.php/` both run `x.php`. +fn script_path(request_path: &str) -> String { + let mut segments: Vec<&str> = Vec::new(); + for segment in request_path.split('/') { + match segment { + "" | "." => {} + ".." => { + segments.pop(); + } + segment => { + segments.push(segment); + if is_php(segment) { + break; + } + } + } + } + format!("/{}", segments.join("/")) +} + +fn in_url_upload_dir(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower + .split('/') + .rev() + .skip(1) // the file name itself + .any(|segment| URL_UPLOAD_DIR_SEGMENTS.contains(&segment)) +} + +/// The file at `logical_path` (`/var/www/html/up/x.php`) is what a request +/// for `request_path` (`/up/x.php`) executes. The docroot is not known, so +/// the URL must be a suffix of the file path on a segment boundary. A suffix +/// that leaves an upload directory out (`/x.php` against +/// `.../uploads/2026/x.php`) names some other file: a docroot does not sit +/// inside `uploads/`. +fn same_file(logical_path: &str, request_path: &str) -> bool { + let request_path = request_path.trim_end_matches('/'); + if request_path.is_empty() { + return false; + } + let request_path = if request_path.starts_with('/') { + request_path.to_string() + } else { + format!("/{}", request_path) + }; + let Some(docroot) = logical_path.strip_suffix(request_path.as_str()) else { + return false; + }; + !docroot + .to_ascii_lowercase() + .split('/') + .any(|segment| URL_UPLOAD_DIR_SEGMENTS.contains(&segment)) +} + +fn is_php(file_name: &str) -> bool { + file_name + .rsplit_once('.') + .map(|(_, ext)| PHP_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use std::collections::HashMap; + use std::sync::{Arc, RwLock}; + + fn entries(lines: &[&str]) -> Vec { + lines + .iter() + .map(|line| LogEntry { + source_id: "nginx".into(), + timestamp: Utc::now(), + line: (*line).into(), + metadata: HashMap::new(), + }) + .collect() + } + + fn detector(files: &[(&str, bool)]) -> PhpShellAccessDetector { + PhpShellAccessDetector::new(Arc::new(RwLock::new( + files + .iter() + .map(|(path, upload)| FlaggedFile { + logical_path: (*path).into(), + in_upload_dir: *upload, + }) + .collect(), + ))) + } + + fn line(ip: &str, method: &str, target: &str) -> String { + format!( + r#"{ip} - - [31/Oct/2026:10:00:00 +0000] "{method} {target} HTTP/1.1" 200 5 "-" "x""# + ) + } + + #[test] + fn test_any_request_to_flagged_upload_bans() { + let detector = detector(&[("/var/www/html/wp-content/uploads/x.php", true)]); + let findings = detector.detect(&entries(&[&line( + "203.0.113.9", + "GET", + "/wp-content/uploads/x.php", + )])); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].detector_id, ACCESS_DETECTOR_ID); + } + + #[test] + fn test_parameters_to_flagged_file_ban() { + let detector = detector(&[("/var/www/html/shell.php", false)]); + let found = detector.detect(&entries(&[&line("203.0.113.9", "GET", "/shell.php?c=id")])); + assert_eq!(found[0].detector_id, ACCESS_DETECTOR_ID); + + let found = detector.detect(&entries(&[&line("203.0.113.9", "GET", "/shell.php")])); + assert_eq!(found[0].detector_id, REQUEST_DETECTOR_ID); + } + + #[test] + fn test_visitors_of_infected_entry_point_are_not_banned() { + let detector = detector(&[("/var/www/html/index.php", false)]); + let found = detector.detect(&entries(&[&line("198.51.100.7", "GET", "/index.php?p=42")])); + assert_eq!(found.len(), 1); + assert_eq!(found[0].detector_id, REQUEST_DETECTOR_ID); + assert!(!found[0] + .description + .to_ascii_lowercase() + .contains("webshell")); + } + + #[test] + fn test_post_to_php_in_uploads_bans_without_scan() { + let detector = detector(&[]); + let found = detector.detect(&entries(&[&line( + "203.0.113.9", + "POST", + "/uploads/2026/a.php", + )])); + assert_eq!(found[0].detector_id, ACCESS_DETECTOR_ID); + + assert!(detector + .detect(&entries(&[&line( + "203.0.113.9", + "GET", + "/uploads/2026/a.php" + )])) + .is_empty()); + assert!(detector + .detect(&entries(&[&line( + "203.0.113.9", + "POST", + "/uploads/photo.jpg" + )])) + .is_empty()); + } + + #[test] + fn test_flagged_upload_does_not_ban_a_same_named_docroot_file() { + let detector = detector(&[ + ("/var/www/html/wp-content/uploads/index.php", true), + ("/var/www/html/wp-content/uploads/2026/x.php", true), + ]); + for target in ["/index.php", "/x.php", "/x.php?a=1", "/index.php?p=42"] { + let found = detector.detect(&entries(&[&line("198.51.100.7", "GET", target)])); + assert!(found.is_empty(), "{target} is another file"); + } + let found = detector.detect(&entries(&[&line( + "203.0.113.9", + "GET", + "/wp-content/uploads/index.php", + )])); + assert_eq!(found[0].detector_id, ACCESS_DETECTOR_ID); + } + + #[test] + fn test_post_to_php_under_ordinary_url_trees_does_not_ban() { + let detector = detector(&[]); + for target in [ + "/images/thumb.php", + "/files/download.php", + "/tmp/x.php", + "/upload/index.php", + ] { + assert!( + detector + .detect(&entries(&[&line("198.51.100.7", "POST", target)])) + .is_empty(), + "{target} must not ban" + ); + } + } + + #[test] + fn test_path_info_and_trailing_slash_reach_the_script() { + let detector = detector(&[("/var/www/html/uploads/x.php", true)]); + for (method, target) in [ + ("GET", "/uploads/x.php/"), + ("POST", "/uploads/x.php/cmd"), + ("GET", "/uploads//./x.php"), + ("GET", "/static/../uploads/x.php"), + ] { + let found = detector.detect(&entries(&[&line("203.0.113.9", method, target)])); + assert_eq!(found.len(), 1, "{method} {target}"); + assert_eq!( + found[0].detector_id, ACCESS_DETECTOR_ID, + "{method} {target}" + ); + } + + let found = self::detector(&[]).detect(&entries(&[&line( + "203.0.113.9", + "POST", + "/uploads/y.PHP/z", + )])); + assert_eq!(found[0].detector_id, ACCESS_DETECTOR_ID); + } + + #[test] + fn test_suffix_match_respects_segments() { + assert!(same_file("/var/www/html/up/x.php", "/up/x.php")); + assert!(!same_file("/var/www/html/backup/x.php", "/up/x.php")); + assert!(!same_file("/var/www/html/up/x.php", "/")); + assert!(same_file("/var/www/html/uploads/x.php", "/uploads/x.php")); + assert!(!same_file("/var/www/html/uploads/x.php", "/x.php")); + } + + #[test] + fn test_one_finding_per_client() { + let detector = detector(&[("/var/www/html/uploads/x.php", true)]); + let a = line("203.0.113.9", "POST", "/uploads/x.php"); + let b = line("203.0.113.10", "POST", "/uploads/x.php"); + let found = detector.detect(&entries(&[&a, &a, &b])); + assert_eq!(found.len(), 2); + } + + #[test] + fn test_stacker_agent_poll_is_ignored() { + let detector = detector(&[("/var/www/html/uploads/x.php", true)]); + let found = detector.detect(&entries(&[ + r#"{"msg":"[AUTHENTICATE AGENT VIA X-AGENT-ID AND BEARER TOKEN - START]","req":"\nServiceRequest HTTP/1.1 GET:/api/v1/agent/commands/wait/deployment_1\n query: ?\"timeout=10\"\n"}"#, + ])); + assert!(found.is_empty()); + } +} diff --git a/src/detectors/php_shell/mod.rs b/src/detectors/php_shell/mod.rs new file mode 100644 index 0000000..b5a9c6d --- /dev/null +++ b/src/detectors/php_shell/mod.rs @@ -0,0 +1,901 @@ +//! PHP webshell detection. +//! +//! Two halves that share one list of flagged files: +//! +//! - [`PhpShellScanner`] reads PHP files under web roots — host paths and the +//! docroots of PHP containers — and flags those whose content scores as a +//! webshell. A file names no attacker, so this alerts and never bans. +//! - [`PhpShellAccessDetector`] watches access logs for requests to flagged +//! files and for POSTs to PHP files in upload directories. Those lines carry +//! the client address, so they feed the IP ban engine. + +mod access; +pub mod signatures; + +use std::collections::HashMap; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::UNIX_EPOCH; + +use anyhow::Result; +use chrono::Utc; +use rusqlite::params; +use sha2::{Digest, Sha256}; + +use crate::database::connection::DbPool; +use crate::sniff::analyzer::AnomalySeverity; + +use self::signatures::{SignatureSet, Weight}; +use super::{DetectorFamily, DetectorFinding}; + +pub use self::access::{PhpShellAccessDetector, ACCESS_DETECTOR_ID}; + +pub const FILE_DETECTOR_ID: &str = "php.shell-file"; + +/// Score at which a file is reported. One strong signal, or a medium plus a +/// weak one; a PHP file in an upload directory alone (medium) is not enough, +/// because plugins drop `, + pub signatures_path: Option, +} + +impl Default for PhpShellOptions { + fn default() -> Self { + Self { + max_file_bytes: DEFAULT_MAX_FILE_BYTES, + skip_dirs: DEFAULT_SKIP_DIRS + .iter() + .map(|dir| dir.to_string()) + .collect(), + signatures_path: None, + } + } +} + +/// What a running container exposes to the scanner. +#[derive(Debug, Clone, Default)] +pub struct ContainerFilesystem { + pub id: String, + pub name: String, + pub image: String, + /// `(source on the host, destination in the container)`. + pub mounts: Vec<(String, String)>, + /// overlay2 `MergedDir`: the container's root filesystem on the host. + pub merged_dir: Option, +} + +/// A file the scanner flagged, as the access detector needs to know it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FlaggedFile { + /// Path as the workload sees it, lowercased: `/var/www/html/up/x.php`. + pub logical_path: String, + pub in_upload_dir: bool, +} + +pub type FlaggedFiles = Arc>>; + +#[derive(Debug, Clone)] +struct CachedScan { + size_bytes: u64, + modified_at: i64, + score: u32, + logical_path: String, + in_upload_dir: bool, +} + +#[derive(Debug, Default)] +pub struct PhpShellScanner { + flagged: FlaggedFiles, + signatures: Mutex, Arc)>>, +} + +impl PhpShellScanner { + pub fn new() -> Self { + Self::default() + } + + /// Shared handle the access detector reads. + pub fn flagged_files(&self) -> FlaggedFiles { + Arc::clone(&self.flagged) + } + + /// Scan `roots`, report files that are new or changed since the last pass + /// and score as a webshell, and refresh the flagged list. + pub fn detect( + &self, + pool: &DbPool, + roots: &[ScanRoot], + options: &PhpShellOptions, + ) -> Result> { + if roots.is_empty() { + self.set_flagged(Vec::new()); + return Ok(Vec::new()); + } + + let signatures = self.signature_set(options); + let cached = load_cache(pool)?; + let mut current: HashMap = HashMap::new(); + let mut findings = Vec::new(); + let conn = pool.get()?; + + for root in roots { + let mut candidates = Vec::new(); + collect_candidates(root, &root.fs_path, options, &mut candidates); + if candidates.len() > MAX_FILES_PER_ROOT { + log::warn!( + "PHP shell scan: {} has {} candidate files, scanning the first {}", + root.fs_path.display(), + candidates.len(), + MAX_FILES_PER_ROOT + ); + candidates.truncate(MAX_FILES_PER_ROOT); + } + + for (path, metadata) in candidates { + let key = path.to_string_lossy().into_owned(); + let size_bytes = metadata.len(); + let modified_at = modified_secs(&metadata); + + if let Some(hit) = cached.get(&key) { + if hit.size_bytes == size_bytes && hit.modified_at == modified_at { + current.insert(key, hit.clone()); + continue; + } + } + + let Some(content) = read_bounded(&path, options.max_file_bytes) else { + continue; + }; + let logical_path = logical_path(root, &path); + let in_upload_dir = in_upload_dir_under(root, &path); + let verdict = score_file(&signatures, &path, &content, in_upload_dir); + let sha256 = format!("{:x}", Sha256::digest(&content)); + + conn.execute( + "INSERT INTO php_shell_scans ( + path, owner, logical_path, size_bytes, modified_at, sha256, + score, rules, in_upload_dir, scanned_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(path) DO UPDATE SET + owner = excluded.owner, + logical_path = excluded.logical_path, + size_bytes = excluded.size_bytes, + modified_at = excluded.modified_at, + sha256 = excluded.sha256, + score = excluded.score, + rules = excluded.rules, + in_upload_dir = excluded.in_upload_dir, + scanned_at = excluded.scanned_at", + params![ + &key, + &root.owner, + &logical_path, + size_bytes as i64, + modified_at, + &sha256, + verdict.score as i64, + verdict.rules.join(","), + in_upload_dir as i64, + Utc::now().to_rfc3339(), + ], + )?; + + if verdict.score >= FLAG_THRESHOLD { + findings.push(file_finding(root, &logical_path, &sha256, &verdict)); + } + + current.insert( + key, + CachedScan { + size_bytes, + modified_at, + score: verdict.score, + logical_path, + in_upload_dir, + }, + ); + } + } + + // A file that is gone, or a root that is no longer scanned, leaves the + // cache: if it comes back it is scanned — and reported — again. + for path in cached.keys() { + if !current.contains_key(path) { + conn.execute("DELETE FROM php_shell_scans WHERE path = ?1", params![path])?; + } + } + + self.set_flagged( + current + .values() + .filter(|scan| scan.score >= FLAG_THRESHOLD) + .map(|scan| FlaggedFile { + logical_path: scan.logical_path.to_ascii_lowercase(), + in_upload_dir: scan.in_upload_dir, + }) + .collect(), + ); + + findings.sort_by(|left, right| left.sample_line.cmp(&right.sample_line)); + Ok(findings) + } + + fn set_flagged(&self, files: Vec) { + if let Ok(mut flagged) = self.flagged.write() { + *flagged = files; + } + } + + fn signature_set(&self, options: &PhpShellOptions) -> Arc { + let mut slot = self + .signatures + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if let Some((path, set)) = slot.as_ref() { + if path == &options.signatures_path { + return Arc::clone(set); + } + } + + let set = match options.signatures_path.as_deref() { + Some(path) => SignatureSet::with_file(path).unwrap_or_else(|err| { + log::warn!("{:#}; using built-in PHP shell signatures only", err); + SignatureSet::builtin() + }), + None => SignatureSet::builtin(), + }; + let set = Arc::new(set); + *slot = Some((options.signatures_path.clone(), Arc::clone(&set))); + set + } +} + +struct Verdict { + score: u32, + rules: Vec, +} + +fn score_file(signatures: &SignatureSet, path: &Path, content: &[u8], upload_dir: bool) -> Verdict { + let text = String::from_utf8_lossy(content); + let mut score = 0; + let mut rules = Vec::new(); + + for signature in signatures.matches(&text) { + score += signature.weight.points(); + rules.push(signature.id.clone()); + } + + if upload_dir { + if has_php_extension(path) { + score += Weight::Medium.points(); + rules.push("php-in-upload-dir".into()); + } else if contains_php_open_tag(&text) { + // An "image" in an upload directory that carries PHP is a polyglot + // waiting for an include(). Weak on its own: config files and docs + // mention " DetectorFinding { + DetectorFinding { + detector_id: FILE_DETECTOR_ID.into(), + family: DetectorFamily::Web, + description: format!( + "PHP shell indicators in {} on {} (score {}: {}; sha256 {})", + logical_path, + root.owner, + verdict.score, + verdict.rules.join(", "), + &sha256[..16.min(sha256.len())] + ), + severity: if verdict.score >= CRITICAL_THRESHOLD { + AnomalySeverity::Critical + } else { + AnomalySeverity::High + }, + confidence: (60 + verdict.score * 5).min(97) as u8, + sample_line: format!("{}:{}", root.owner, logical_path), + } +} + +fn load_cache(pool: &DbPool) -> Result> { + let conn = pool.get()?; + let mut stmt = conn.prepare( + "SELECT path, size_bytes, modified_at, score, logical_path, in_upload_dir + FROM php_shell_scans", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + CachedScan { + size_bytes: row.get::<_, i64>(1)? as u64, + modified_at: row.get(2)?, + score: row.get::<_, i64>(3)? as u32, + logical_path: row.get(4)?, + in_upload_dir: row.get::<_, i64>(5)? != 0, + }, + )) + })?; + + let mut cache = HashMap::new(); + for row in rows { + let (path, scan) = row?; + cache.insert(path, scan); + } + Ok(cache) +} + +/// Walk `dir` for files worth reading: PHP by extension anywhere, and any +/// file inside an upload directory. Unreadable entries are skipped — a web +/// root routinely holds files Stackdog may not open, and one of them must not +/// stop the scan of the rest. +fn collect_candidates( + root: &ScanRoot, + dir: &Path, + options: &PhpShellOptions, + out: &mut Vec<(PathBuf, fs::Metadata)>, +) { + let metadata = match fs::symlink_metadata(dir) { + Ok(metadata) => metadata, + Err(err) => { + log::debug!("PHP shell scan: skipping {}: {}", dir.display(), err); + return; + } + }; + + if metadata.file_type().is_symlink() { + return; + } + + if metadata.is_file() { + if has_php_extension(dir) || in_upload_dir_under(root, dir) { + out.push((dir.to_path_buf(), metadata)); + } + return; + } + + if !metadata.is_dir() || out.len() > MAX_FILES_PER_ROOT { + return; + } + + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(err) => { + log::debug!("PHP shell scan: cannot read {}: {}", dir.display(), err); + return; + } + }; + + let mut paths: Vec = entries + .filter_map(|entry| entry.ok().map(|e| e.path())) + .collect(); + paths.sort(); + + for path in paths { + let skipped = path + .file_name() + .map(|name| options.skip_dirs.iter().any(|skip| name == skip.as_str())) + .unwrap_or(false); + if skipped && path.is_dir() { + continue; + } + collect_candidates(root, &path, options, out); + } +} + +fn read_bounded(path: &Path, max_bytes: u64) -> Option> { + let file = match fs::File::open(path) { + Ok(file) => file, + Err(err) => { + log::debug!("PHP shell scan: cannot open {}: {}", path.display(), err); + return None; + } + }; + let mut content = Vec::new(); + file.take(max_bytes).read_to_end(&mut content).ok()?; + Some(content) +} + +fn modified_secs(metadata: &fs::Metadata) -> i64 { + metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0) +} + +fn logical_path(root: &ScanRoot, path: &Path) -> String { + let relative = path.strip_prefix(&root.fs_path).unwrap_or(path); + let base = root.logical_path.trim_end_matches('/'); + let relative = relative.to_string_lossy(); + if relative.is_empty() { + base.to_string() + } else { + format!("{}/{}", base, relative.trim_start_matches('/')) + } +} + +fn has_php_extension(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| PHP_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())) + .unwrap_or(false) +} + +/// Upload directory, judged inside the root only. Where the root itself sits +/// says nothing: a site deployed under `/srv/files/` or a temp checkout would +/// otherwise make every file in it an "upload". The root's own name does +/// count, for a volume mounted straight at `.../wp-content/uploads`. +fn in_upload_dir_under(root: &ScanRoot, path: &Path) -> bool { + let relative = path.strip_prefix(&root.fs_path).unwrap_or(path); + let root_name = root + .logical_path + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or_default(); + in_upload_dir(&format!("/{}/{}", root_name, relative.to_string_lossy())) +} + +pub(crate) fn in_upload_dir(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower + .split('/') + .rev() + .skip(1) // the file name itself + .any(|segment| UPLOAD_DIR_SEGMENTS.contains(&segment)) +} + +fn contains_php_open_tag(text: &str) -> bool { + text.to_ascii_lowercase().contains(" bool { + let mut run = 0; + for byte in text.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=') { + run += 1; + if run >= ENCODED_BLOB_MIN_LEN { + return true; + } + } else { + run = 0; + } + } + false +} + +/// Join a host path onto the host-root mount (`/host` when Stackdog runs in a +/// container with `/:/host:ro`; empty when it runs on the host). +pub fn under_host_root(host_root: Option<&str>, path: &str) -> PathBuf { + match host_root + .map(|root| root.trim_end_matches('/')) + .filter(|root| !root.is_empty()) + { + Some(root) => PathBuf::from(format!("{}/{}", root, path.trim_start_matches('/'))), + None => PathBuf::from(path), + } +} + +/// Roots for paths configured on the host. +pub fn host_roots(paths: &[String], host_root: Option<&str>) -> Vec { + paths + .iter() + .map(|path| path.trim()) + .filter(|path| !path.is_empty()) + .map(|path| ScanRoot { + fs_path: under_host_root(host_root, path), + logical_path: path.to_string(), + owner: "host".into(), + }) + .collect() +} + +/// Docroots of PHP containers. +/// +/// A container counts as PHP when its name or image matches `patterns`, or +/// when something is mounted at a docroot. Mounted docroots are read at their +/// host source; docroots baked into the image are read through the overlay +/// `MergedDir`. Both live on the host, so both go through `host_root`. +pub fn container_roots( + containers: &[ContainerFilesystem], + patterns: &[String], + host_root: Option<&str>, +) -> Vec { + let mut roots = Vec::new(); + + for container in containers { + let docroot_mounts: Vec<&(String, String)> = container + .mounts + .iter() + .filter(|(_, destination)| is_docroot(destination)) + .collect(); + + let identity = format!("{} {}", container.name, container.image).to_ascii_lowercase(); + let looks_php = patterns + .iter() + .any(|pattern| identity.contains(pattern.as_str())); + if !looks_php && docroot_mounts.is_empty() { + continue; + } + + for (source, destination) in &docroot_mounts { + roots.push(ScanRoot { + fs_path: under_host_root(host_root, source), + logical_path: destination.clone(), + owner: container.name.clone(), + }); + } + + if !looks_php { + continue; + } + let Some(merged) = container.merged_dir.as_deref() else { + continue; + }; + for prefix in DOCROOT_PREFIXES { + // Covered by a mount already: the overlay shows nothing there. + if docroot_mounts + .iter() + .any(|(_, destination)| path_within(prefix, destination)) + { + continue; + } + let fs_path = under_host_root(host_root, &format!("{}{}", merged, prefix)); + if fs_path.is_dir() { + roots.push(ScanRoot { + fs_path, + logical_path: (*prefix).to_string(), + owner: container.name.clone(), + }); + } + } + } + + roots +} + +fn is_docroot(destination: &str) -> bool { + DOCROOT_PREFIXES + .iter() + .any(|prefix| path_within(destination, prefix)) +} + +/// `path` is `base` or below it. +fn path_within(path: &str, base: &str) -> bool { + let base = base.trim_end_matches('/'); + path == base || path.starts_with(&format!("{}/", base)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::connection::{create_pool, init_database}; + + const SHELL: &str = ""; + + fn pool() -> DbPool { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + pool + } + + fn root(dir: &Path) -> ScanRoot { + ScanRoot { + fs_path: dir.to_path_buf(), + logical_path: "/var/www/html".into(), + owner: "host".into(), + } + } + + #[test] + fn test_flags_dropped_shell_and_reports_it_once() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("wp-content/uploads/2026")).unwrap(); + fs::write(dir.path().join("wp-content/uploads/2026/x.php"), SHELL).unwrap(); + fs::write( + dir.path().join("index.php"), + ""); + fs::write(dir.path().join("uploads/avatar.gif"), gif).unwrap(); + + let findings = PhpShellScanner::new() + .detect(&pool(), &[root(dir.path())], &PhpShellOptions::default()) + .unwrap(); + assert_eq!(findings.len(), 1); + assert!(findings[0].description.contains("php-in-non-php-file")); + } + + #[test] + fn test_skip_dirs_are_not_scanned() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("vendor/acme")).unwrap(); + fs::write(dir.path().join("vendor/acme/shell.php"), SHELL).unwrap(); + + let findings = PhpShellScanner::new() + .detect(&pool(), &[root(dir.path())], &PhpShellOptions::default()) + .unwrap(); + assert!(findings.is_empty()); + } + + #[test] + fn test_removed_shell_leaves_flagged_list() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("x.php"); + fs::write(&file, SHELL).unwrap(); + + let pool = pool(); + let scanner = PhpShellScanner::new(); + let roots = vec![root(dir.path())]; + let options = PhpShellOptions::default(); + assert_eq!(scanner.detect(&pool, &roots, &options).unwrap().len(), 1); + + fs::remove_file(&file).unwrap(); + scanner.detect(&pool, &roots, &options).unwrap(); + assert!(scanner.flagged_files().read().unwrap().is_empty()); + } + + #[test] + fn test_upload_dir_is_judged_inside_the_root() { + let site = ScanRoot { + fs_path: PathBuf::from("/srv/files/site"), + logical_path: "/srv/files/site".into(), + owner: "host".into(), + }; + assert!(!in_upload_dir_under( + &site, + Path::new("/srv/files/site/index.php") + )); + assert!(in_upload_dir_under( + &site, + Path::new("/srv/files/site/uploads/x.php") + )); + + let uploads_volume = ScanRoot { + fs_path: PathBuf::from("/host/var/lib/docker/volumes/up/_data"), + logical_path: "/var/www/html/wp-content/uploads".into(), + owner: "blog".into(), + }; + assert!(in_upload_dir_under( + &uploads_volume, + Path::new("/host/var/lib/docker/volumes/up/_data/2026/x.php") + )); + } + + #[test] + fn test_in_upload_dir_ignores_file_name() { + assert!(in_upload_dir("/var/www/html/wp-content/uploads/x.php")); + assert!(!in_upload_dir("/var/www/html/uploads.php")); + assert!(!in_upload_dir("/var/www/html/index.php")); + } + + #[test] + fn test_host_roots_go_under_host_root() { + let roots = host_roots(&["/var/www".into()], Some("/host")); + assert_eq!(roots[0].fs_path, PathBuf::from("/host/var/www")); + assert_eq!(roots[0].logical_path, "/var/www"); + + let roots = host_roots(&["/var/www".into()], None); + assert_eq!(roots[0].fs_path, PathBuf::from("/var/www")); + } + + #[test] + fn test_container_roots_from_mounts_and_name() { + let patterns: Vec = DEFAULT_CONTAINER_PATTERNS + .iter() + .map(|p| p.to_string()) + .collect(); + let containers = vec![ + ContainerFilesystem { + name: "blog".into(), + image: "wordpress:6".into(), + mounts: vec![("/srv/blog".into(), "/var/www/html".into())], + ..Default::default() + }, + ContainerFilesystem { + name: "custom-app".into(), + image: "acme/site".into(), + mounts: vec![("/data/site".into(), "/var/www".into())], + ..Default::default() + }, + ContainerFilesystem { + name: "postgres".into(), + image: "postgres:16".into(), + mounts: vec![("/var/lib/pg".into(), "/var/lib/postgresql/data".into())], + ..Default::default() + }, + ]; + + let roots = container_roots(&containers, &patterns, Some("/host")); + assert_eq!(roots.len(), 2); + assert_eq!(roots[0].fs_path, PathBuf::from("/host/srv/blog")); + assert_eq!(roots[0].logical_path, "/var/www/html"); + assert_eq!(roots[0].owner, "blog"); + assert_eq!(roots[1].owner, "custom-app"); + } + + #[test] + fn test_container_roots_read_image_docroot_through_merged_dir() { + let host = tempfile::tempdir().unwrap(); + fs::create_dir_all( + host.path() + .join("var/lib/docker/overlay2/abc/merged/var/www/html"), + ) + .unwrap(); + + let containers = vec![ContainerFilesystem { + name: "php-site".into(), + image: "php:8.3-apache".into(), + merged_dir: Some("/var/lib/docker/overlay2/abc/merged".into()), + ..Default::default() + }]; + let patterns = vec!["php".to_string()]; + + let roots = container_roots(&containers, &patterns, host.path().to_str()); + assert_eq!(roots.len(), 1); + assert_eq!(roots[0].logical_path, "/var/www"); + assert!(roots[0].fs_path.ends_with("merged/var/www")); + } + + /// False-positive check against real code, vendor directories included: + /// `STACKDOG_PHPSHELL_CORPUS=/path/to/wordpress cargo test --lib -- --ignored php_shell_corpus` + #[test] + #[ignore] + fn test_php_shell_corpus_has_no_findings() { + let Ok(corpus) = std::env::var("STACKDOG_PHPSHELL_CORPUS") else { + return; + }; + let options = PhpShellOptions { + skip_dirs: Vec::new(), + ..PhpShellOptions::default() + }; + let roots: Vec = corpus + .split(',') + .map(|path| ScanRoot { + fs_path: PathBuf::from(path), + logical_path: path.to_string(), + owner: "corpus".into(), + }) + .collect(); + + let findings = PhpShellScanner::new() + .detect(&pool(), &roots, &options) + .unwrap(); + for finding in &findings { + eprintln!("{}", finding.description); + } + assert!(findings.is_empty(), "{} files flagged", findings.len()); + } +} diff --git a/src/detectors/php_shell/signatures.rs b/src/detectors/php_shell/signatures.rs new file mode 100644 index 0000000..ba70872 --- /dev/null +++ b/src/detectors/php_shell/signatures.rs @@ -0,0 +1,280 @@ +//! PHP webshell content signatures. +//! +//! No single keyword marks a webshell: WordPress core calls `base64_decode`, +//! Composer calls `proc_open`, and test suites call `assert`. What marks one is +//! user input reaching code execution, or code that hides what it executes. +//! Signatures carry a weight and a file is flagged on the sum. +//! +//! Extra signatures load from a JSON file (`STACKDOG_PHPSHELL_SIGNATURES`) so +//! a new shell family can be covered without a rebuild: +//! +//! ```json +//! [{"id": "acme-shell", "description": "ACME shell marker", +//! "pattern": "acme_shell_v\\d", "weight": "strong"}] +//! ``` + +use anyhow::{Context, Result}; +use regex::{Regex, RegexBuilder}; +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Weight { + Strong, + Medium, + Weak, +} + +impl Weight { + pub fn points(self) -> u32 { + match self { + Weight::Strong => 3, + Weight::Medium => 2, + Weight::Weak => 1, + } + } +} + +#[derive(Debug, Clone)] +pub struct Signature { + pub id: String, + pub description: String, + pub pattern: Regex, + pub weight: Weight, +} + +#[derive(Debug, Deserialize)] +struct SignatureSpec { + id: String, + description: String, + pattern: String, + weight: Weight, +} + +const BUILTIN: &[(&str, &str, &str, Weight)] = &[ + ( + "superglobal-exec", + "request input passed straight to a code or command execution function", + r"\b(eval|assert|system|passthru|shell_exec|exec|popen|proc_open|pcntl_exec|create_function)\s*\(\s*@?\s*(\$_(get|post|request|cookie|server|files)\b|stripslashes\s*\(\s*\$_|base64_decode\s*\(\s*\$_)", + Weight::Strong, + ), + ( + "eval-decode-chain", + "eval of decoded or deobfuscated data", + r"\b(eval|assert)\s*\(\s*@?\s*(base64_decode|gzinflate|gzuncompress|gzdecode|str_rot13|strrev|hex2bin|convert_uudecode)\s*\(", + Weight::Strong, + ), + ( + "superglobal-callable", + "request input called as a function", + r"\$_(get|post|request|cookie)\s*\[[^\]]{1,40}\]\s*\(", + Weight::Strong, + ), + ( + "preg-replace-eval", + "preg_replace with the /e modifier (executes the replacement)", + r#"preg_replace\s*\(\s*['"]/[^'"]*/[a-df-z]*e[a-z]*['"]"#, + Weight::Strong, + ), + ( + "backtick-superglobal", + "shell backticks around request input", + r"`[^`\n]{0,80}\$_(get|post|request|cookie)", + Weight::Strong, + ), + ( + "known-family", + "marker of a known webshell family", + r"(c99shell|c99_buff_prepare|r57shell|wso_version|\bfilesman\b|b374k|p0wny|indoxploit|alfa_data|alfashell|weevely|\bphpspy\b|mini shell|anonsec shell)", + Weight::Strong, + ), + ( + "nested-decode", + "decode functions nested inside each other", + r"\b(base64_decode|gzinflate|gzuncompress|str_rot13|strrev)\s*\(\s*(base64_decode|gzinflate|gzuncompress|str_rot13|strrev)\s*\(", + Weight::Medium, + ), + ( + "write-request-input", + "request input written to a file", + r"\b(file_put_contents|fwrite|fputs)\s*\([^;]{0,120}\$_(get|post|request|cookie)", + Weight::Medium, + ), + ( + "silenced-errors", + "error reporting switched off", + r"\berror_reporting\s*\(\s*0\s*\)", + Weight::Weak, + ), + ( + "no-time-limit", + "execution time limit removed", + r"\bset_time_limit\s*\(\s*0\s*\)", + Weight::Weak, + ), + ( + "command-with-variable", + "shell command built from a variable", + r"\b(system|passthru|shell_exec|popen|proc_open)\s*\(\s*\$", + Weight::Weak, + ), +]; + +#[derive(Debug, Clone)] +pub struct SignatureSet { + signatures: Vec, +} + +impl SignatureSet { + pub fn builtin() -> Self { + let signatures = BUILTIN + .iter() + .map(|(id, description, pattern, weight)| Signature { + id: (*id).to_string(), + description: (*description).to_string(), + pattern: compile(pattern).expect("built-in PHP shell signature compiles"), + weight: *weight, + }) + .collect(); + Self { signatures } + } + + /// Built-ins plus the signatures in `path`. + pub fn with_file(path: &str) -> Result { + let mut set = Self::builtin(); + let contents = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read PHP shell signatures {}", path))?; + let specs: Vec = serde_json::from_str(&contents) + .with_context(|| format!("Failed to parse PHP shell signatures {}", path))?; + + for spec in specs { + let pattern = compile(&spec.pattern) + .with_context(|| format!("Invalid pattern in PHP shell signature {}", spec.id))?; + set.signatures.push(Signature { + id: spec.id, + description: spec.description, + pattern, + weight: spec.weight, + }); + } + + Ok(set) + } + + pub fn len(&self) -> usize { + self.signatures.len() + } + + pub fn is_empty(&self) -> bool { + self.signatures.is_empty() + } + + /// Signatures that match `content`, each counted once. + pub fn matches<'a>(&'a self, content: &str) -> Vec<&'a Signature> { + self.signatures + .iter() + .filter(|signature| signature.pattern.is_match(content)) + .collect() + } +} + +fn compile(pattern: &str) -> Result { + RegexBuilder::new(pattern) + .case_insensitive(true) + .size_limit(1 << 20) + .build() + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(set: &SignatureSet, content: &str) -> Vec { + set.matches(content) + .into_iter() + .map(|signature| signature.id.clone()) + .collect() + } + + #[test] + fn test_builtin_signatures_compile() { + assert_eq!(SignatureSet::builtin().len(), BUILTIN.len()); + } + + #[test] + fn test_china_chopper_one_liner() { + let set = SignatureSet::builtin(); + assert!(ids(&set, r#""#).contains(&"superglobal-exec".into())); + } + + #[test] + fn test_variable_function_shell() { + let set = SignatureSet::builtin(); + assert!(ids(&set, r#""#) + .contains(&"superglobal-callable".into())); + } + + #[test] + fn test_obfuscated_eval() { + let set = SignatureSet::builtin(); + let found = ids( + &set, + r#""#, + ); + assert!(found.contains(&"eval-decode-chain".into())); + assert!(found.contains(&"nested-decode".into())); + } + + #[test] + fn test_preg_replace_e_modifier_but_not_other_modifiers() { + let set = SignatureSet::builtin(); + assert!(ids(&set, r#"preg_replace("/.*/e", $_POST['c'], '');"#) + .contains(&"preg-replace-eval".into())); + assert!(!ids(&set, r#"preg_replace('/\s+/u', ' ', $title);"#) + .contains(&"preg-replace-eval".into())); + } + + #[test] + fn test_ordinary_php_matches_nothing_strong() { + let set = SignatureSet::builtin(); + let ordinary = r#"prepare('SELECT * FROM users WHERE id = ?'); + $stmt->execute([$_GET['id']]); + assert($count > 0); + echo htmlspecialchars($_POST['name']); + "#; + assert!(set + .matches(ordinary) + .iter() + .all(|signature| signature.weight != Weight::Strong)); + } + + #[test] + fn test_signature_file_extends_builtins() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("signatures.json"); + std::fs::write( + &path, + r#"[{"id":"acme","description":"ACME marker","pattern":"acme_shell_v\\d","weight":"strong"}]"#, + ) + .unwrap(); + + let set = SignatureSet::with_file(path.to_str().unwrap()).unwrap(); + assert_eq!(set.len(), BUILTIN.len() + 1); + assert_eq!(ids(&set, "// ACME_SHELL_V2"), vec!["acme".to_string()]); + } + + #[test] + fn test_invalid_signature_file_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("signatures.json"); + std::fs::write( + &path, + r#"[{"id":"bad","description":"x","pattern":"(","weight":"strong"}]"#, + ) + .unwrap(); + assert!(SignatureSet::with_file(path.to_str().unwrap()).is_err()); + } +} diff --git a/src/docker/client.rs b/src/docker/client.rs index 67cda0d..cafcff5 100644 --- a/src/docker/client.rs +++ b/src/docker/client.rs @@ -132,6 +132,63 @@ impl DockerClient { }) } + /// Mounts and root filesystem of each running container, for the PHP + /// shell scanner to find docroots on the host. + pub async fn list_container_filesystems( + &self, + ) -> Result> { + let containers = self + .client + .list_containers(None::>) + .await + .context("Failed to list containers for PHP shell scan")?; + + let mut result = Vec::new(); + for container in containers { + let Some(id) = container.id else { + continue; + }; + let inspect = match self + .client + .inspect_container(&id, None::) + .await + { + Ok(inspect) => inspect, + Err(err) => { + log::debug!("Skipping container {} in PHP shell scan: {}", id, err); + continue; + } + }; + + let mounts = inspect + .mounts + .unwrap_or_default() + .into_iter() + .filter_map(|mount| Some((mount.source?, mount.destination?))) + .collect(); + let merged_dir = inspect + .graph_driver + .and_then(|driver| driver.data.get("MergedDir").cloned()); + + result.push(crate::detectors::php_shell::ContainerFilesystem { + name: inspect + .name + .unwrap_or_else(|| id.chars().take(12).collect()) + .trim_start_matches('/') + .to_string(), + image: inspect + .config + .and_then(|config| config.image) + .unwrap_or_default(), + id, + mounts, + merged_dir, + }); + } + + Ok(result) + } + /// List container posture information for detector-backed audits pub async fn list_container_postures( &self, diff --git a/src/sniff/config.rs b/src/sniff/config.rs index 10f704b..a79ace0 100644 --- a/src/sniff/config.rs +++ b/src/sniff/config.rs @@ -3,6 +3,10 @@ use std::env; use std::path::PathBuf; +use crate::detectors::php_shell::{ + PhpShellOptions, DEFAULT_CONTAINER_PATTERNS, DEFAULT_MAX_FILE_BYTES, DEFAULT_SKIP_DIRS, +}; + /// Default AI request timeout. Generous because local models on modest /// hardware can take minutes per completion, but bounded so a wedged /// inference host cannot stall the sniff loop indefinitely. @@ -89,6 +93,16 @@ pub struct SniffConfig { pub trusted_containers: Vec, /// Enable AI tool-use (function calling) during analysis pub ai_tools_enabled: bool, + /// Scan web roots for PHP shells + pub phpshell_enabled: bool, + /// Host directories to scan for PHP shells + pub phpshell_paths: Vec, + /// Container name/image fragments that mark a PHP workload + pub phpshell_container_patterns: Vec, + /// Where the host filesystem is mounted when Stackdog runs in a container + pub host_root: Option, + /// File size cap, skipped directories and extra signatures for the scanner + pub phpshell_options: PhpShellOptions, } /// Arguments for building a SniffConfig @@ -280,10 +294,60 @@ impl SniffConfig { _ => None, }) .unwrap_or(true), + phpshell_enabled: env::var("STACKDOG_PHPSHELL_ENABLED") + .ok() + .and_then(|v| match v.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + }) + .unwrap_or(true), + phpshell_paths: env_list("STACKDOG_PHPSHELL_PATHS").unwrap_or_default(), + phpshell_container_patterns: env_list("STACKDOG_PHPSHELL_CONTAINERS") + .map(|patterns| { + patterns + .into_iter() + .map(|p| p.to_ascii_lowercase()) + .collect() + }) + .unwrap_or_else(|| { + DEFAULT_CONTAINER_PATTERNS + .iter() + .map(|p| p.to_string()) + .collect() + }), + host_root: env::var("STACKDOG_HOST_ROOT") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()), + phpshell_options: PhpShellOptions { + max_file_bytes: env::var("STACKDOG_PHPSHELL_MAX_FILE_BYTES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_MAX_FILE_BYTES), + skip_dirs: env_list("STACKDOG_PHPSHELL_SKIP_DIRS") + .unwrap_or_else(|| DEFAULT_SKIP_DIRS.iter().map(|d| d.to_string()).collect()), + signatures_path: env::var("STACKDOG_PHPSHELL_SIGNATURES") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()), + }, } } } +/// A comma-separated variable, `None` when unset so callers can tell "unset" +/// (use the defaults) from "set to nothing". +fn env_list(name: &str) -> Option> { + env::var(name).ok().map(|value| { + value + .split(',') + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect() + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/sniff/mod.rs b/src/sniff/mod.rs index b3858da..8980554 100644 --- a/src/sniff/mod.rs +++ b/src/sniff/mod.rs @@ -13,6 +13,7 @@ pub mod reporter; use crate::alerting::{notifications::NotificationConfig, AlertSeverity}; use crate::database::connection::{create_pool, init_database, DbPool}; use crate::database::repositories::log_sources as log_sources_repo; +use crate::detectors::php_shell; use crate::detectors::DetectorRegistry; use crate::docker::DockerClient; use crate::ip_ban::{gate, IpBanConfig, IpBanEngine, OffenseInput}; @@ -75,16 +76,21 @@ impl SniffOrchestrator { .enabled .then(|| IpBanEngine::new(pool.clone(), ip_ban_config)); - let tool_registry = ToolRegistry::new( - pool.clone(), - IpBanConfig::from_env(), - DetectorRegistry::default(), - ); + let detector_registry = || { + let mut registry = DetectorRegistry::default(); + if !config.phpshell_enabled { + registry.remove_detector(php_shell::ACCESS_DETECTOR_ID); + } + registry + }; + let tool_registry = + ToolRegistry::new(pool.clone(), IpBanConfig::from_env(), detector_registry()); + let detectors = detector_registry(); Ok(Self { config, pool, - detectors: DetectorRegistry::default(), + detectors, reporter, ip_ban, tool_registry, @@ -181,8 +187,15 @@ impl SniffOrchestrator { ) .await?; + let mut container_filesystems = Vec::new(); match DockerClient::new().await { Ok(docker) => { + if self.config.phpshell_enabled { + match docker.list_container_filesystems().await { + Ok(filesystems) => container_filesystems = filesystems, + Err(err) => log::debug!("Skipping container PHP shell roots: {}", err), + } + } let postures = docker.list_container_postures(true).await?; // Update tool registry with all postures (before filtering) self.tool_registry.set_postures(postures.clone()); @@ -202,6 +215,30 @@ impl SniffOrchestrator { Err(err) => log::debug!("Skipping Docker posture audit: {}", err), } + // Before the logs: a shell found in this pass is matched against the + // access logs read in this same pass. + if self.config.phpshell_enabled { + let host_root = self.config.host_root.as_deref(); + let mut roots = php_shell::host_roots(&self.config.phpshell_paths, host_root); + roots.extend(php_shell::container_roots( + &container_filesystems, + &self.config.phpshell_container_patterns, + host_root, + )); + self.report_detector_batch( + &mut result, + "php-shell", + roots.len(), + "PHP shell scan", + self.detectors.detect_php_shell_anomalies( + &self.pool, + &roots, + &self.config.phpshell_options, + )?, + ) + .await?; + } + // 1. Discover sources log::debug!("Step 1: discovering log sources..."); let sources = discovery::discover_all(&self.config.extra_sources).await?; @@ -584,11 +621,18 @@ fn should_auto_ban(anomaly: &analyzer::LogAnomaly) -> bool { | "web.archive-probe" | "web.sqli-probe" | "web.webshell-probe" + | "web.php-shell-access" | "file.sensitive-access" | "cloud.metadata-ssrf" ) { return true; } + // Alert-only by policy. Their descriptions carry file and URL paths, + // and a shell named `webshell.php` would otherwise pass the phrase + // check below. + if matches!(detector_id, "php.shell-file" | "php.shell-request") { + return false; + } } if !crate::alerting::notifications::env_flag_enabled("STACKDOG_IP_BAN_FROM_AI_FINDINGS", true) { @@ -1482,6 +1526,132 @@ mod tests { ); } + fn php_shell_anomalies(lines: &[&str]) -> Vec { + use crate::detectors::php_shell::{FlaggedFile, PhpShellAccessDetector}; + use crate::detectors::LogDetector; + + let detector = + PhpShellAccessDetector::new(std::sync::Arc::new(std::sync::RwLock::new(vec![ + FlaggedFile { + logical_path: "/var/www/html/wp-content/uploads/x.php".into(), + in_upload_dir: true, + }, + FlaggedFile { + logical_path: "/var/www/html/index.php".into(), + in_upload_dir: false, + }, + ]))); + let entries: Vec = lines + .iter() + .map(|line| reader::LogEntry { + source_id: "nginx".into(), + timestamp: Utc::now(), + line: (*line).into(), + metadata: std::collections::HashMap::new(), + }) + .collect(); + detector + .detect(&entries) + .iter() + .map(|finding| finding.to_log_anomaly()) + .collect() + } + + #[test] + fn test_php_shell_access_bans_but_visits_to_infected_pages_do_not() { + let shell = php_shell_anomalies(&[ + r#"95.163.183.216 - - [31/Oct/2026:10:00:00 +0000] "POST /wp-content/uploads/x.php HTTP/1.1" 200 5 "-" "x""#, + ]); + assert_eq!(shell.len(), 1); + assert!(should_auto_ban(&shell[0])); + + // AI-finding phrases are on by default: the alert-only finding must + // not read as one either. + let visit = php_shell_anomalies(&[ + r#"198.51.100.7 - - [31/Oct/2026:10:00:00 +0000] "GET /index.php?p=42 HTTP/1.1" 200 5 "-" "x""#, + ]); + assert_eq!(visit.len(), 1); + assert_eq!(visit[0].detector_id.as_deref(), Some("php.shell-request")); + assert!(!should_auto_ban(&visit[0])); + } + + #[test] + fn test_php_alert_only_findings_never_ban_whatever_the_wording() { + for detector_id in ["php.shell-file", "php.shell-request"] { + let anomaly = LogAnomaly { + description: "Request to a file flagged as a PHP shell: GET /webshell.php".into(), + severity: AnomalySeverity::High, + sample_line: r#"203.0.113.9 - - [31/Oct/2026:10:00:00 +0000] "GET /webshell.php HTTP/1.1" 200 5 "-" "x""#.into(), + detector_id: Some(detector_id.into()), + detector_family: None, + confidence: Some(70), + suggested_action: None, + }; + assert!(!should_auto_ban(&anomaly), "{detector_id}"); + } + } + + #[test] + fn test_phpshell_disabled_removes_the_access_detector() { + let mut config = memory_sniff_config(); + config.phpshell_enabled = false; + let orchestrator = SniffOrchestrator::new(config).unwrap(); + let entries = vec![reader::LogEntry { + source_id: "nginx".into(), + timestamp: Utc::now(), + line: r#"203.0.113.9 - - [31/Oct/2026:10:00:00 +0000] "POST /uploads/a.php HTTP/1.1" 200 5 "-" "x""#.into(), + metadata: std::collections::HashMap::new(), + }]; + assert!(orchestrator + .detectors + .detect_log_anomalies(&entries) + .iter() + .all(|anomaly| anomaly.detector_id.as_deref() != Some("web.php-shell-access"))); + } + + #[actix_rt::test] + async fn test_php_shell_access_records_offense_except_for_allowlisted() { + let orchestrator = SniffOrchestrator::new(memory_sniff_config()).unwrap(); + let engine = IpBanEngine::new( + orchestrator.pool.clone(), + IpBanConfig { + enabled: true, + max_retries: 5, + find_time_secs: 300, + ban_time_secs: 60, + unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: vec![("95.163.183.217".parse().unwrap(), 32)], + max_bans_per_minute: 0, + protect_local_ips: true, + }, + ); + let mut summary = make_summary("unused", AnomalySeverity::Critical); + summary.anomalies = php_shell_anomalies(&[ + r#"95.163.183.216 - - [31/Oct/2026:10:00:00 +0000] "POST /wp-content/uploads/x.php HTTP/1.1" 200 5 "-" "x""#, + r#"95.163.183.217 - - [31/Oct/2026:10:00:01 +0000] "POST /wp-content/uploads/x.php HTTP/1.1" 200 5 "-" "x""#, + ]); + assert_eq!(summary.anomalies.len(), 2); + + orchestrator + .apply_ip_ban(&[], &test_log_source(), &summary, &engine) + .await + .unwrap(); + + let since = Utc::now() - chrono::Duration::minutes(5); + assert_eq!( + find_recent_offenses(&orchestrator.pool, "95.163.183.216", "sniff", since) + .unwrap() + .len(), + 1 + ); + assert!( + find_recent_offenses(&orchestrator.pool, "95.163.183.217", "sniff", since) + .unwrap() + .is_empty() + ); + } + #[actix_rt::test] async fn test_apply_ip_ban_skips_non_bannable_anomalies() { let orchestrator = SniffOrchestrator::new(memory_sniff_config()).unwrap(); diff --git a/website/app/docs/page.tsx b/website/app/docs/page.tsx index 2ec4f87..5db8e1c 100644 --- a/website/app/docs/page.tsx +++ b/website/app/docs/page.tsx @@ -100,7 +100,14 @@ const envRows = [ ['STACKDOG_IP_BAN_UNBAN_CHECK_INTERVAL_SECS', '60', 'How often expired bans are checked and released.'], ['STACKDOG_MAIL_GUARD_ENABLED', 'true', 'Watch web containers for outbound spam bursts and quarantine offenders.'], ['STACKDOG_MAIL_GUARD_TARGETS', 'wordpress,php,apache', 'Container name patterns the mail guard watches.'], - ['STACKDOG_MAIL_GUARD_ALLOWLIST', 'postfix,mailu', 'Container name patterns exempt from the mail guard.'] + ['STACKDOG_MAIL_GUARD_ALLOWLIST', 'postfix,mailu', 'Container name patterns exempt from the mail guard.'], + ['STACKDOG_PHPSHELL_ENABLED', 'true', 'Scan web roots for PHP shells. See the PHP Shell Detection page.'], + ['STACKDOG_PHPSHELL_PATHS', '/var/www', 'Host web roots scanned for PHP shells. PHP containers are found on their own.'], + ['STACKDOG_PHPSHELL_CONTAINERS', 'php,wordpress,drupal,laravel', 'Name or image fragments that mark a PHP container.'], + ['STACKDOG_HOST_ROOT', '/host', 'Host filesystem mount point when Stackdog runs in Docker (/:/host:ro).'], + ['STACKDOG_PHPSHELL_SKIP_DIRS', 'vendor,node_modules,.git', 'Directory names the PHP shell scan never enters.'], + ['STACKDOG_PHPSHELL_MAX_FILE_BYTES', '2097152', 'Bytes read per file by the PHP shell scan.'], + ['STACKDOG_PHPSHELL_SIGNATURES', '/etc/stackdog/php-shell-signatures.json', 'Extra PHP shell signatures in JSON.'] ] as const; export const metadata: Metadata = { diff --git a/website/app/page.tsx b/website/app/page.tsx index 0dac206..167f8d0 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -7,6 +7,7 @@ import { Boxes, Cpu, Network, + FileCode2, FileSearch, ShieldAlert, ShieldCheck, @@ -67,6 +68,13 @@ const features = [ 'Track file integrity against SQLite baselines, audit SSH, sudoers, and Docker daemon settings, and flag privileged containers or exposed Docker sockets.', icon: FileSearch }, + { + name: 'PHP Webshell Detection', + description: + 'Scan host and container web roots for PHP shells, correlate them with access logs, and ban the clients driving them.', + icon: FileCode2, + href: '/php-shell-detection' + }, { name: 'Multi-Channel Alerts', description: @@ -142,6 +150,11 @@ const faqEntries = [ answer: 'Alerts are filtered by a minimum severity you set, and repeats of the same finding are suppressed for a configurable window (six hours by default). Standing misconfigurations are re-detected on every pass, so without this they would notify all day. Each alert also names the real source, such as the container or log file it came from.' }, + { + question: 'Can Stackdog detect PHP webshells?', + answer: + 'Yes. Stackdog scans PHP files in host web roots and in the docroots of PHP containers, flags files that execute request input or hide what they execute, and alerts on them. Requests that drive a flagged shell are read from access logs and the client address is banned. Files are never deleted automatically.' + }, { question: 'Is Stackdog open source?', answer: @@ -290,6 +303,15 @@ export default function HomePage() {

{feature.name}

{feature.description}

+ {'href' in feature && feature.href ? ( + + Learn more + + + ) : null} ); })} diff --git a/website/app/php-shell-detection/page.tsx b/website/app/php-shell-detection/page.tsx new file mode 100644 index 0000000..01893ea --- /dev/null +++ b/website/app/php-shell-detection/page.tsx @@ -0,0 +1,437 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { ArrowRight, FileCode2, Radar, ScrollText, TerminalSquare } from 'lucide-react'; +import CopyButton from '@/components/CopyButton'; +import { getSiteUrl } from '@/lib/config'; +import { buildDocsStructuredData, toJsonLd } from '@/lib/structured-data'; + +const PAGE_PATH = '/php-shell-detection'; + +const layers = [ + { + name: 'File content scan', + status: 'Available', + description: + 'Every sniff pass reads PHP files in your web roots and scores them for webshell code. Only new or changed files are read; the rest are checked by size and modification time.', + icon: FileCode2 + }, + { + name: 'Access-log correlation', + status: 'Available', + description: + 'Requests to flagged files, and POSTs to PHP under uploads/ URLs, are picked out of nginx, Apache and application access logs — with the client address that sent them.', + icon: ScrollText + }, + { + name: 'Process execution (eBPF)', + status: 'Planned', + description: + 'Catching php-fpm or Apache spawning sh, curl or nc at the kernel level. This catches shells no signature knows. It is not shipped yet.', + icon: Radar + } +]; + +const signalRows = [ + ['Request input reaches execution', 'Strong', 'eval($_POST[...]), system($_GET[...]), assert($_REQUEST[...])'], + ['eval of decoded data', 'Strong', 'eval(base64_decode(...)), eval(gzinflate(...))'], + ['Request input called as a function', 'Strong', '$_GET[\'f\']($_GET[\'c\'])'], + ['preg_replace with /e', 'Strong', 'preg_replace("/.*/e", $_POST[\'c\'], \'\')'], + ['Backticks around request input', 'Strong', '`$_GET[cmd]`'], + ['Known shell family', 'Strong', 'c99, r57, WSO, b374k, p0wny, FilesMan, Weevely, IndoXploit'], + ['PHP in an upload directory', 'Medium', 'wp-content/uploads/2026/10/x.php'], + ['Nested decoders', 'Medium', 'base64_decode(str_rot13(...))'], + ['Request input written to a file', 'Medium', 'file_put_contents($f, $_POST[\'d\'])'], + ['PHP inside a non-PHP upload', 'Weak', 'GIF89a releases it, and STACKDOG_IP_BAN_ALLOWLIST keeps that address out of every future ban.' + } +]; + +const faqEntries = [ + { + question: 'Will it flag WordPress, Laravel or phpMyAdmin?', + answer: + 'It should not. One keyword never flags a file: WordPress calls base64_decode and Composer calls proc_open. A file is flagged on the sum of its signals, and a strong signal means request input reaching code execution. vendor/ and node_modules/ are skipped by default.' + }, + { + question: 'Does Stackdog delete the shell?', + answer: + 'No. Detection raises an alert and bans the clients driving the shell. Removing the file stays your decision, because a false positive that deleted a page would take part of a site down.' + }, + { + question: 'How fast is a ban?', + answer: + 'Each sniff pass (30 seconds by default) records one offense per client. With the default threshold of 5 offenses in 5 minutes, an attacker who keeps using the shell is banned within a few minutes. The ban threshold, window and duration are the same STACKDOG_IP_BAN_* settings every other detector uses.' + }, + { + question: 'Which logs does it read?', + answer: + 'Any access log Stackdog already sniffs: nginx and Apache combined format, plain "GET /path" lines, and actix-style "GET:/path" request dumps. Add your access log to STACKDOG_LOG_SOURCES if it is not discovered automatically.' + }, + { + question: 'What about shells baked into a container image?', + answer: + 'For PHP containers, Stackdog also reads the docroot through the container overlay filesystem (overlay2 MergedDir) under the host mount, so files that are not on a volume are covered too.' + } +]; + +export const metadata: Metadata = { + title: 'PHP Webshell Detection', + description: + 'Detect PHP webshells in host and Docker web roots, correlate them with access logs, and automatically ban the clients that drive them.', + keywords: [ + 'PHP webshell detection', + 'PHP shell scanner', + 'WordPress webshell', + 'c99 r57 WSO shell', + 'Docker PHP security', + 'Stackdog Security' + ], + alternates: { + canonical: PAGE_PATH + }, + openGraph: { + title: 'PHP Webshell Detection with Stackdog', + description: + 'Find PHP webshells in host and container web roots and ban the clients driving them.', + url: getSiteUrl() + PAGE_PATH + }, + twitter: { + title: 'PHP Webshell Detection with Stackdog', + description: + 'Find PHP webshells in host and container web roots and ban the clients driving them.' + } +}; + +function DataTable({ + headers, + rows +}: { + headers: readonly string[]; + rows: readonly (readonly string[])[]; +}) { + return ( +
+
+ + + + {headers.map((header) => ( + + ))} + + + + {rows.map((row) => ( + + {row.map((cell, index) => ( + + ))} + + ))} + +
+ {header} +
+ {cell} +
+
+
+ ); +} + +function CodeSample({ code, label }: { code: string; label: string }) { + return ( +
+
+

{label}

+ +
+
+        {code}
+      
+
+ ); +} + +export default function PhpShellDetectionPage() { + return ( +
+