Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ services:
STACKDOG_AI_API_KEY: <yourkeyhere>
STACKDOG_SLACK_WEBHOOK_URL: <yourslackchannelwebhook?
STACKDOG_LOG_SOURCES: /var/log/syslog,/var/log/auth.log,/var/log/nginx/access.log
# PHP shell detection reads web roots on the host through the /host mount
# below. Uncomment both to scan host paths and PHP container docroots.
# STACKDOG_HOST_ROOT: /host
# STACKDOG_PHPSHELL_PATHS: /var/www
restart: unless-stopped
volumes:
- source: stackdog-data
Expand All @@ -27,6 +31,10 @@ services:
- source: /var/run/docker.sock
target: /var/run/docker.sock
type: bind
# - source: /
# target: /host
# type: bind
# read_only: true
command: serve
stackdog-ui:
image: trydirect/stackdog-ui:latest
Expand Down
16 changes: 16 additions & 0 deletions src/database/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,22 @@ pub fn init_database(pool: &DbPool) -> 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,
Expand Down
179 changes: 179 additions & 0 deletions src/detectors/http_request.rs
Original file line number Diff line number Diff line change
@@ -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<Regex> = 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<Regex> = 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<Regex> = 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<HttpRequest> {
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<u8> {
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");
}
}
Loading
Loading