From 4fb451ff236d9d492566a67aa7e37bbc95670a71 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sat, 31 Oct 2026 17:42:21 +0200 Subject: [PATCH 1/2] feat: flag PHP builds exposed to PHP2Shell and alert on PHP worker crashes PHP2Shell (CVE-2026-109618, CVE-2026-109619) is unauthenticated RCE in the PHP interpreter; the fix is upgrading PHP. Each sniff pass now reads the PHP version on the host and in running containers (dpkg/apk package database through MergedDir, and PHP_VERSION from official images) and alerts on builds older than 8.2.35, 8.3.36, 8.4.27 or 8.5.12 (vuln.php2shell). Upstream builds get a Critical alert. Distribution packages get a Medium "check your vendor" alert, because backports keep the old version number. Alpine's version is read from php8x-common, which every SAPI depends on. php.worker-crash alerts when 3 or more php-fpm or Apache workers die on SIGSEGV/SIGBUS/SIGABRT/SIGILL in one log source within one pass. Log formats are taken from php-src fpm_children.c and httpd unixd.c. Neither finding names a client, so both are on the never-ban list. Also fixes alert dedup for scan findings. They had no log source, so every finding of one detector shared a key and only the first of a pass alerted. A second exposed install, a second shell file or a second integrity change was silently dropped. Scan findings now key on their sample line, which names what was found. Log findings are unchanged. Co-Authored-By: Claude Opus 5.5 --- .env.sample | 3 + CHANGELOG.md | 19 + README.md | 22 + src/detectors/audits.rs | 4 +- src/detectors/mod.rs | 21 +- src/detectors/php2shell.rs | 542 ++++++++++++++++++++++ src/detectors/php_shell/mod.rs | 2 + src/docker/client.rs | 11 +- src/sniff/config.rs | 10 + src/sniff/mod.rs | 33 +- src/sniff/reporter.rs | 83 +++- website/app/docs/page.tsx | 3 +- website/app/php-shell-detection/page.tsx | 49 +- website/app/php-shell-quickstart/page.tsx | 7 +- 14 files changed, 785 insertions(+), 24 deletions(-) create mode 100644 src/detectors/php2shell.rs diff --git a/.env.sample b/.env.sample index 2ccb888..907f5d5 100644 --- a/.env.sample +++ b/.env.sample @@ -97,6 +97,9 @@ RUST_BACKTRACE=full #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 +# PHP2Shell (CVE-2026-109618, CVE-2026-109619): alert on PHP builds older than +# the fixed releases, on the host and in running containers. Alert only. +#STACKDOG_PHP_VERSION_CHECK_ENABLED=true # --------------------------------------------------------------------------- # Dashboard container (docker-compose.yml / docker-compose.app.yml) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6f6ea..ada08cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- PHP2Shell exposure check (CVE-2026-109618, CVE-2026-109619). Each sniff pass + reads the PHP version on the host and in running containers (package + database, and `PHP_VERSION` for official images) and alerts on builds older + than 8.2.35, 8.3.36, 8.4.27 or 8.5.12 (`vuln.php2shell`). Distribution + packages get a "check your vendor" alert instead, because backports keep the + old version number. `STACKDOG_PHP_VERSION_CHECK_ENABLED=false` turns it off. +- `php.worker-crash`: an alert when php-fpm or Apache workers keep dying on + memory faults, which is what attempts against PHP memory bugs leave behind. + Neither finding bans. + +### Fixed + +- Scan findings (file integrity, PHP shell files, package and posture checks) + shared one dedup key per detector, so when a pass found several distinct + things, only the first alerted and the rest were dropped. They now key on + what they found; log findings still key on the detector alone. + ## [0.2.7] - 2026-10-31 ### Added diff --git a/README.md b/README.md index 2f78701..425eadb 100644 --- a/README.md +++ b/README.md @@ -614,6 +614,28 @@ a JSON file: "pattern": "acme_shell_v\\d", "weight": "strong"}] ``` +#### PHP2Shell exposure (CVE-2026-109618, CVE-2026-109619) + +PHP2Shell is unauthenticated remote code execution in the PHP interpreter. The +fix is upgrading PHP; Stackdog tells you where you still have to. + +| Finding | When | Action | +|---|---|---| +| `vuln.php2shell` | a PHP build older than 8.2.35, 8.3.36, 8.4.27 or 8.5.12, or any 8.1 and older | Critical alert for upstream builds (official `php` images, read from `PHP_VERSION`); Medium "check your vendor" alert for distribution packages, which backport fixes without changing the version number | +| `php.worker-crash` | 3 or more php-fpm or Apache workers dying on SIGSEGV, SIGBUS, SIGABRT or SIGILL in one log source within one pass; 1–2 per pass never alert, so one flaky extension stays quiet | alert; repeated crashes are what attempts against PHP memory bugs leave behind | + +The version check reads `php8.x-common` (Debian, Ubuntu) and `php8x-common` or `php8x` (Alpine) +from the package database on the host and inside each running container, and +`PHP_VERSION` from container environments. RPM-based hosts are not covered. +Neither finding names a client, so neither bans. Turn the version check off +with `STACKDOG_PHP_VERSION_CHECK_ENABLED=false`. + +Crash lines come from php-fpm and Apache error logs. Container logs are read +on their own (official images send php-fpm errors to stderr); add host logs +such as `/var/log/php8.3-fpm.log` to `STACKDOG_LOG_SOURCES`. + +Stackdog does not detect the exploit request itself and cannot patch PHP. + --- ## 📦 Installation diff --git a/src/detectors/audits.rs b/src/detectors/audits.rs index 78758da..cf8e778 100644 --- a/src/detectors/audits.rs +++ b/src/detectors/audits.rs @@ -318,7 +318,7 @@ fn uncommented_lines(content: &str) -> Vec { .collect() } -fn parse_dpkg_status(content: &str) -> Vec<(String, String)> { +pub(crate) fn parse_dpkg_status(content: &str) -> Vec<(String, String)> { let mut packages = Vec::new(); for stanza in content.split("\n\n") { @@ -341,7 +341,7 @@ fn parse_dpkg_status(content: &str) -> Vec<(String, String)> { packages } -fn parse_apk_installed(content: &str) -> Vec<(String, String)> { +pub(crate) fn parse_apk_installed(content: &str) -> Vec<(String, String)> { let mut packages = Vec::new(); let mut package = None; let mut version = None; diff --git a/src/detectors/mod.rs b/src/detectors/mod.rs index 32bcce0..e352358 100644 --- a/src/detectors/mod.rs +++ b/src/detectors/mod.rs @@ -8,6 +8,7 @@ pub mod audits; pub(crate) mod http_request; mod integrity; mod ml; +pub mod php2shell; pub mod php_shell; use std::collections::HashSet; @@ -21,7 +22,10 @@ use self::audits::{ConfigAssessmentMonitor, DockerPostureMonitor, PackageInvento use self::http_request::{extract_request, HttpRequest}; use self::integrity::FileIntegrityMonitor; use self::ml::MlBehavioralDetector; -use self::php_shell::{PhpShellAccessDetector, PhpShellOptions, PhpShellScanner, ScanRoot}; +use self::php2shell::{PhpVersionMonitor, PhpWorkerCrashDetector}; +use self::php_shell::{ + ContainerFilesystem, PhpShellAccessDetector, PhpShellOptions, PhpShellScanner, ScanRoot, +}; use crate::database::connection::DbPool; use crate::sniff::analyzer::{AnomalySeverity, LogAnomaly}; use crate::sniff::reader::LogEntry; @@ -98,6 +102,7 @@ pub struct DetectorRegistry { package_inventory_monitor: PackageInventoryMonitor, docker_posture_monitor: DockerPostureMonitor, php_shell_scanner: PhpShellScanner, + php_version_monitor: PhpVersionMonitor, } impl DetectorRegistry { @@ -109,6 +114,7 @@ impl DetectorRegistry { package_inventory_monitor: PackageInventoryMonitor, docker_posture_monitor: DockerPostureMonitor, php_shell_scanner: PhpShellScanner::new(), + php_version_monitor: PhpVersionMonitor::new(), } } @@ -139,6 +145,7 @@ impl DetectorRegistry { self.register(PhpShellAccessDetector::new( self.php_shell_scanner.flagged_files(), )); + self.register(PhpWorkerCrashDetector); } pub fn detect_log_anomalies(&self, entries: &[LogEntry]) -> Vec { @@ -187,6 +194,18 @@ impl DetectorRegistry { .collect()) } + pub fn detect_php_version_anomalies( + &self, + containers: &[ContainerFilesystem], + host_root: Option<&str>, + ) -> Vec { + self.php_version_monitor + .detect(containers, host_root) + .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/php2shell.rs b/src/detectors/php2shell.rs new file mode 100644 index 0000000..6828ec1 --- /dev/null +++ b/src/detectors/php2shell.rs @@ -0,0 +1,542 @@ +//! PHP2Shell exposure and PHP worker crashes. +//! +//! PHP2Shell (CVE-2026-109618, CVE-2026-109619) is unauthenticated remote code +//! execution in the PHP interpreter itself. Nothing in a log line or a file on +//! disk marks the attack reliably, so this module answers two narrower +//! questions: +//! +//! - which PHP builds on the host and in running containers predate the fixed +//! releases (`vuln.php2shell`); +//! - whether PHP workers keep crashing, which is what memory-corruption +//! attempts tend to leave behind (`php.worker-crash`). +//! +//! Neither names a client address, so neither ever bans. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::SystemTime; + +use crate::detectors::audits::{parse_apk_installed, parse_dpkg_status}; +use crate::detectors::php_shell::{under_host_root, ContainerFilesystem}; +use crate::detectors::{DetectorFamily, DetectorFinding, LogDetector}; +use crate::sniff::analyzer::AnomalySeverity; +use crate::sniff::reader::LogEntry; + +pub const VERSION_DETECTOR_ID: &str = "vuln.php2shell"; +pub const CRASH_DETECTOR_ID: &str = "php.worker-crash"; + +const CVES: &str = "CVE-2026-109618, CVE-2026-109619"; + +/// First fixed release per supported branch, published 2026-10-21. Branches +/// older than the oldest listed one get no fix from the PHP project. +const FIXED_RELEASES: &[(u32, u32, u32)] = &[(8, 5, 12), (8, 4, 27), (8, 3, 36), (8, 2, 35)]; + +/// Package databases, relative to a filesystem root. +const PACKAGE_DATABASES: &[&str] = &["var/lib/dpkg/status", "lib/apk/db/installed"]; + +/// Crashes in one batch of log lines before it is worth an alert. A single +/// segfault is usually a buggy extension; a run of them is not. +const CRASH_THRESHOLD: usize = 3; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Exposure { + /// An upstream build older than the fix for its branch. + Vulnerable { + fixed_in: Option, + }, + /// A distribution package older than the upstream fix. Distributions + /// backport fixes without changing the upstream number, so only the + /// vendor's advisory can say. + CheckVendor { + fixed_in: Option, + }, + Patched, +} + +/// Where a PHP version was read from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VersionSource { + /// `PHP_VERSION` in a container's environment: official images build + /// PHP from upstream source. + ImageEnv, + /// A distribution package (`php8.2-common` 8.2.20-1~deb12u1). + Package { name: String }, +} + +/// The PHP2Shell exposure of `version`, or None if it is not a PHP version. +pub fn exposure(version: &str, from_distro_package: bool) -> Option { + let (major, minor, patch) = upstream_version(version)?; + + if (major, minor) > (8, 5) { + return Some(Exposure::Patched); + } + + let fixed = FIXED_RELEASES + .iter() + .find(|(fixed_major, fixed_minor, _)| (*fixed_major, *fixed_minor) == (major, minor)); + + match fixed { + Some(&(_, _, fixed_patch)) if patch >= fixed_patch => Some(Exposure::Patched), + Some(&(fixed_major, fixed_minor, fixed_patch)) => { + let fixed_in = Some(format!("{}.{}.{}", fixed_major, fixed_minor, fixed_patch)); + Some(if from_distro_package { + Exposure::CheckVendor { fixed_in } + } else { + Exposure::Vulnerable { fixed_in } + }) + } + None => Some(if from_distro_package { + Exposure::CheckVendor { fixed_in: None } + } else { + Exposure::Vulnerable { fixed_in: None } + }), + } +} + +/// `8.3.30` out of `8.3.30`, `1:8.2.20-1~deb12u1` or `8.3.12-1+ubuntu22.04.1+deb.sury.org+1`. +fn upstream_version(version: &str) -> Option<(u32, u32, u32)> { + let without_epoch = version.split_once(':').map_or(version, |(_, rest)| rest); + let numeric: String = without_epoch + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + let mut parts = numeric.split('.').filter(|part| !part.is_empty()); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next().and_then(|part| part.parse().ok()).unwrap_or(0); + Some((major, minor, patch)) +} + +/// The package that carries the PHP core version: `php8.2-common` (Debian, +/// Ubuntu), `php83-common` or `php83` (Alpine). On Alpine every SAPI and +/// extension depends on `php83-common`, not on `php83`, so `apk add php83-fpm` +/// installs no `php83`. Meta-packages such as `php` carry the distribution's +/// own numbering, not PHP's. +fn is_php_core_package(name: &str) -> bool { + if let Some(branch) = name + .strip_prefix("php") + .and_then(|rest| rest.strip_suffix("-common")) + { + return is_branch(branch, true) || is_branch(branch, false); + } + name.strip_prefix("php") + .map(|branch| is_branch(branch, false)) + .unwrap_or(false) +} + +fn is_branch(text: &str, dotted: bool) -> bool { + if dotted { + matches!(text.split_once('.'), Some((major, minor)) + if !major.is_empty() && !minor.is_empty() + && major.chars().all(|c| c.is_ascii_digit()) + && minor.chars().all(|c| c.is_ascii_digit())) + } else { + text.len() >= 2 && text.chars().all(|c| c.is_ascii_digit()) + } +} + +/// A PHP installation found on the host or in a container. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PhpInstall { + /// `host` or the container name. + pub owner: String, + pub version: String, + pub source: VersionSource, +} + +/// A parsed package database and the file state it was parsed from. +struct CachedPackages { + modified: SystemTime, + size: u64, + packages: Vec<(String, String)>, +} + +#[derive(Default)] +pub struct PhpVersionMonitor { + /// Parsed package databases keyed by path, reused while the file's + /// modification time and size stay the same. + cache: Mutex>, +} + +impl PhpVersionMonitor { + pub fn new() -> Self { + Self::default() + } + + pub fn detect( + &self, + containers: &[ContainerFilesystem], + host_root: Option<&str>, + ) -> Vec { + let mut installs = self.installs_under("host", &under_host_root(host_root, "/")); + + for container in containers { + if let Some(version) = &container.php_version { + installs.push(PhpInstall { + owner: container.name.clone(), + version: version.clone(), + source: VersionSource::ImageEnv, + }); + } + if let Some(merged_dir) = &container.merged_dir { + installs.extend( + self.installs_under(&container.name, &under_host_root(host_root, merged_dir)), + ); + } + } + + findings(&installs) + } + + fn installs_under(&self, owner: &str, root: &Path) -> Vec { + PACKAGE_DATABASES + .iter() + .flat_map(|database| self.packages(&root.join(database))) + .filter(|(name, _)| is_php_core_package(name)) + .map(|(name, version)| PhpInstall { + owner: owner.to_string(), + version, + source: VersionSource::Package { name }, + }) + .collect() + } + + fn packages(&self, path: &Path) -> Vec<(String, String)> { + let Ok(metadata) = std::fs::metadata(path) else { + return Vec::new(); + }; + let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH); + let size = metadata.len(); + + if let Ok(cache) = self.cache.lock() { + if let Some(cached) = cache.get(path) { + if cached.modified == modified && cached.size == size { + return cached.packages.clone(); + } + } + } + + let Ok(content) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + let packages = if path.ends_with("installed") { + parse_apk_installed(&content) + } else { + parse_dpkg_status(&content) + }; + + if let Ok(mut cache) = self.cache.lock() { + cache.insert( + path.to_path_buf(), + CachedPackages { + modified, + size, + packages: packages.clone(), + }, + ); + } + packages + } +} + +/// One finding per exposed `(owner, version)`. +pub fn findings(installs: &[PhpInstall]) -> Vec { + let mut seen = HashSet::new(); + let mut findings = Vec::new(); + + for install in installs { + if !seen.insert((install.owner.as_str(), install.version.as_str())) { + continue; + } + let from_package = matches!(install.source, VersionSource::Package { .. }); + let Some(exposure) = exposure(&install.version, from_package) else { + continue; + }; + let source = match &install.source { + VersionSource::ImageEnv => "image PHP_VERSION".to_string(), + VersionSource::Package { name } => format!("package {}", name), + }; + let upgrade = |fixed_in: &Option| { + match fixed_in { + Some(fixed) => format!("upgrade to {} or later", fixed), + None => "this branch gets no fix from the PHP project; move to 8.2.35, 8.3.36, 8.4.27 or 8.5.12 or later".to_string(), + } + }; + + let (description, severity, confidence) = match &exposure { + Exposure::Patched => continue, + Exposure::Vulnerable { fixed_in } => ( + format!( + "PHP {} on {} is exposed to PHP2Shell unauthenticated remote code execution ({}): {}", + install.version, install.owner, CVES, upgrade(fixed_in) + ), + AnomalySeverity::Critical, + 95, + ), + Exposure::CheckVendor { fixed_in } => ( + format!( + "PHP {} on {} predates the upstream PHP2Shell fixes ({}). Distribution packages may carry a backported fix: check your vendor's advisory, or {}", + install.version, install.owner, CVES, upgrade(fixed_in) + ), + AnomalySeverity::Medium, + 60, + ), + }; + + findings.push(DetectorFinding { + detector_id: VERSION_DETECTOR_ID.into(), + family: DetectorFamily::Vulnerability, + description, + severity, + confidence, + sample_line: format!("{} {} {}", install.owner, source, install.version), + }); + } + + findings +} + +/// PHP workers dying on memory faults, from php-fpm and Apache error logs. +pub struct PhpWorkerCrashDetector; + +impl LogDetector for PhpWorkerCrashDetector { + fn id(&self) -> &'static str { + CRASH_DETECTOR_ID + } + + fn family(&self) -> DetectorFamily { + DetectorFamily::Execution + } + + fn detect(&self, entries: &[LogEntry]) -> Vec { + let crashes: Vec<&LogEntry> = entries + .iter() + .filter(|entry| is_worker_crash(&entry.line)) + .collect(); + + if crashes.len() < CRASH_THRESHOLD { + return Vec::new(); + } + + vec![DetectorFinding { + detector_id: CRASH_DETECTOR_ID.into(), + family: DetectorFamily::Execution, + description: format!( + "PHP worker processes crashed {} times on memory faults. Repeated crashes are what attempts against PHP memory bugs such as PHP2Shell ({}) leave behind; check the PHP version and the requests at those times", + crashes.len(), + CVES + ), + severity: if crashes.len() >= 10 { + AnomalySeverity::High + } else { + AnomalySeverity::Medium + }, + confidence: 65, + sample_line: crashes[0].line.clone(), + }] + } +} + +/// php-fpm: `[pool www] child 123 exited on signal 11 (SIGSEGV) after ...` +/// (php-src sapi/fpm/fpm/fpm_children.c). Apache: `child pid 123 exit signal +/// Segmentation fault (11)` (httpd os/unix/unixd.c, AH00051/AH00052). +fn is_worker_crash(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + let fpm = lower.contains("child ") + && lower.contains("exited on signal") + && ["(sigsegv", "(sigbus", "(sigabrt", "(sigill"] + .iter() + .any(|signal| lower.contains(signal)); + let apache = lower.contains("child pid") + && [ + "exit signal segmentation fault", + "exit signal bus error", + "exit signal aborted", + "exit signal illegal instruction", + ] + .iter() + .any(|signal| lower.contains(signal)); + fpm || apache +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + #[test] + fn test_upstream_builds_below_the_fix_are_vulnerable() { + assert_eq!( + exposure("8.3.30", false), + Some(Exposure::Vulnerable { + fixed_in: Some("8.3.36".into()) + }) + ); + assert_eq!(exposure("8.3.36", false), Some(Exposure::Patched)); + assert_eq!(exposure("8.4.27", false), Some(Exposure::Patched)); + assert_eq!( + exposure("8.5.11", false), + Some(Exposure::Vulnerable { + fixed_in: Some("8.5.12".into()) + }) + ); + assert_eq!(exposure("8.6.0", false), Some(Exposure::Patched)); + assert_eq!( + exposure("8.1.31", false), + Some(Exposure::Vulnerable { fixed_in: None }) + ); + assert_eq!( + exposure("7.4.33", false), + Some(Exposure::Vulnerable { fixed_in: None }) + ); + assert_eq!(exposure("not-a-version", false), None); + } + + #[test] + fn test_distro_packages_below_the_fix_need_the_vendor_advisory() { + assert_eq!( + exposure("8.2.20-1~deb12u1", true), + Some(Exposure::CheckVendor { + fixed_in: Some("8.2.35".into()) + }) + ); + assert_eq!( + exposure("8.3.36-1+ubuntu24.04.1+deb.sury.org+1", true), + Some(Exposure::Patched) + ); + assert_eq!( + exposure("1:8.1.2-1ubuntu2.20", true), + Some(Exposure::CheckVendor { fixed_in: None }) + ); + } + + #[test] + fn test_only_core_packages_carry_the_php_version() { + assert!(is_php_core_package("php8.2-common")); + assert!(is_php_core_package("php83")); + assert!(is_php_core_package("php83-common")); + assert!(!is_php_core_package("php")); + assert!(!is_php_core_package("php-common")); + assert!(!is_php_core_package("php8.2-mysql")); + assert!(!is_php_core_package("php83-fpm")); + assert!(!is_php_core_package("phpmyadmin")); + } + + #[test] + fn test_findings_per_owner_and_version() { + let installs = vec![ + PhpInstall { + owner: "wordpress".into(), + version: "8.3.30".into(), + source: VersionSource::ImageEnv, + }, + PhpInstall { + owner: "wordpress".into(), + version: "8.3.30".into(), + source: VersionSource::ImageEnv, + }, + PhpInstall { + owner: "host".into(), + version: "8.2.20-1~deb12u1".into(), + source: VersionSource::Package { + name: "php8.2-common".into(), + }, + }, + PhpInstall { + owner: "shop".into(), + version: "8.4.27".into(), + source: VersionSource::ImageEnv, + }, + ]; + + let found = findings(&installs); + assert_eq!(found.len(), 2); + assert_eq!(found[0].severity, AnomalySeverity::Critical); + assert!(found[0].description.contains("upgrade to 8.3.36")); + assert_eq!(found[1].severity, AnomalySeverity::Medium); + assert!(found[1].description.contains("vendor")); + assert!(found + .iter() + .all(|finding| !crate::ip_ban::gate::names_confirmed_attack(&finding.description))); + } + + #[test] + fn test_monitor_reads_package_databases_under_a_root() { + // The host root is a temp dir; the container's MergedDir sits under it, + // as it does when Stackdog runs with the host mounted at /host. + let host = tempfile::tempdir().unwrap(); + let dpkg = host.path().join("overlay/abc/merged/var/lib/dpkg"); + std::fs::create_dir_all(&dpkg).unwrap(); + std::fs::write( + dpkg.join("status"), + "Package: php\nVersion: 2:8.2+93\n\nPackage: php8.2-common\nVersion: 8.2.20-1~deb12u1\n\nPackage: php8.2-fpm\nVersion: 8.2.20-1~deb12u1\n", + ) + .unwrap(); + + let container = ContainerFilesystem { + name: "legacy".into(), + merged_dir: Some("/overlay/abc/merged".into()), + php_version: Some("8.4.20".into()), + ..Default::default() + }; + let monitor = PhpVersionMonitor::new(); + let found = monitor.detect(&[container], host.path().to_str()); + + assert_eq!(found.len(), 2, "{found:#?}"); + assert!(found + .iter() + .any(|f| f.description.contains("PHP 8.4.20 on legacy"))); + assert!(found + .iter() + .any(|f| f.description.contains("PHP 8.2.20-1~deb12u1 on legacy"))); + } + + #[test] + fn test_alpine_common_package_alone_is_read() { + // `apk add php83-fpm php83-mysqli` installs php83-common, not php83. + let host = tempfile::tempdir().unwrap(); + let apk = host.path().join("lib/apk/db"); + std::fs::create_dir_all(&apk).unwrap(); + std::fs::write( + apk.join("installed"), + "P:php83-common\nV:8.3.13-r0\n\nP:php83-fpm\nV:8.3.13-r0\n\nP:php83-pecl-redis\nV:6.1.0-r0\n", + ) + .unwrap(); + + let found = PhpVersionMonitor::new().detect(&[], host.path().to_str()); + assert_eq!(found.len(), 1, "{found:#?}"); + assert!(found[0].description.contains("PHP 8.3.13-r0 on host")); + assert!(found[0].description.contains("8.3.36")); + } + + fn crash_entries(lines: &[&str]) -> Vec { + lines + .iter() + .map(|line| LogEntry { + source_id: "php-fpm".into(), + timestamp: Utc::now(), + line: (*line).into(), + metadata: HashMap::new(), + }) + .collect() + } + + #[test] + fn test_worker_crashes_need_a_run() { + let fpm = "[31-Oct-2026 10:00:00] WARNING: [pool www] child 4121 exited on signal 11 (SIGSEGV - core dumped) after 0.412000 seconds from start"; + let apache = "[Sat Oct 31 10:00:00.123 2026] [core:notice] [pid 1:tid 1] AH00052: child pid 4122 exit signal Segmentation fault (11)"; + let graceful = "[31-Oct-2026 10:00:00] NOTICE: [pool www] child 4123 exited on signal 3 (SIGQUIT) after 30.0 seconds from start"; + + assert!(PhpWorkerCrashDetector + .detect(&crash_entries(&[fpm, apache, graceful])) + .is_empty()); + + let found = PhpWorkerCrashDetector.detect(&crash_entries(&[fpm, apache, fpm, graceful])); + assert_eq!(found.len(), 1); + assert_eq!(found[0].detector_id, CRASH_DETECTOR_ID); + assert!(!crate::ip_ban::gate::names_confirmed_attack( + &found[0].description + )); + } +} diff --git a/src/detectors/php_shell/mod.rs b/src/detectors/php_shell/mod.rs index b5a9c6d..e1d160a 100644 --- a/src/detectors/php_shell/mod.rs +++ b/src/detectors/php_shell/mod.rs @@ -110,6 +110,8 @@ pub struct ContainerFilesystem { pub mounts: Vec<(String, String)>, /// overlay2 `MergedDir`: the container's root filesystem on the host. pub merged_dir: Option, + /// `PHP_VERSION` from the container environment, set by official PHP images. + pub php_version: Option, } /// A file the scanner flagged, as the access detector needs to know it. diff --git a/src/docker/client.rs b/src/docker/client.rs index cafcff5..46ba2dd 100644 --- a/src/docker/client.rs +++ b/src/docker/client.rs @@ -169,6 +169,11 @@ impl DockerClient { let merged_dir = inspect .graph_driver .and_then(|driver| driver.data.get("MergedDir").cloned()); + let config = inspect.config.unwrap_or_default(); + let php_version = config.env.unwrap_or_default().into_iter().find_map(|var| { + var.strip_prefix("PHP_VERSION=") + .map(|version| version.trim().to_string()) + }); result.push(crate::detectors::php_shell::ContainerFilesystem { name: inspect @@ -176,13 +181,11 @@ impl DockerClient { .unwrap_or_else(|| id.chars().take(12).collect()) .trim_start_matches('/') .to_string(), - image: inspect - .config - .and_then(|config| config.image) - .unwrap_or_default(), + image: config.image.unwrap_or_default(), id, mounts, merged_dir, + php_version, }); } diff --git a/src/sniff/config.rs b/src/sniff/config.rs index a79ace0..1498916 100644 --- a/src/sniff/config.rs +++ b/src/sniff/config.rs @@ -95,6 +95,8 @@ pub struct SniffConfig { pub ai_tools_enabled: bool, /// Scan web roots for PHP shells pub phpshell_enabled: bool, + /// Flag PHP builds older than the PHP2Shell fixes, on the host and in containers + pub php_version_check_enabled: bool, /// Host directories to scan for PHP shells pub phpshell_paths: Vec, /// Container name/image fragments that mark a PHP workload @@ -302,6 +304,14 @@ impl SniffConfig { _ => None, }) .unwrap_or(true), + php_version_check_enabled: env::var("STACKDOG_PHP_VERSION_CHECK_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| { diff --git a/src/sniff/mod.rs b/src/sniff/mod.rs index 8980554..65cd265 100644 --- a/src/sniff/mod.rs +++ b/src/sniff/mod.rs @@ -190,10 +190,10 @@ impl SniffOrchestrator { let mut container_filesystems = Vec::new(); match DockerClient::new().await { Ok(docker) => { - if self.config.phpshell_enabled { + if self.config.phpshell_enabled || self.config.php_version_check_enabled { match docker.list_container_filesystems().await { Ok(filesystems) => container_filesystems = filesystems, - Err(err) => log::debug!("Skipping container PHP shell roots: {}", err), + Err(err) => log::debug!("Skipping container PHP checks: {}", err), } } let postures = docker.list_container_postures(true).await?; @@ -239,6 +239,21 @@ impl SniffOrchestrator { .await?; } + if self.config.php_version_check_enabled { + let anomalies = self.detectors.detect_php_version_anomalies( + &container_filesystems, + self.config.host_root.as_deref(), + ); + self.report_detector_batch( + &mut result, + "php-version", + container_filesystems.len() + 1, + "PHP2Shell version check", + anomalies, + ) + .await?; + } + // 1. Discover sources log::debug!("Step 1: discovering log sources..."); let sources = discovery::discover_all(&self.config.extra_sources).await?; @@ -629,8 +644,11 @@ fn should_auto_ban(anomaly: &analyzer::LogAnomaly) -> bool { } // 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") { + // check below. The PHP2Shell findings name no client at all. + if matches!( + detector_id, + "php.shell-file" | "php.shell-request" | "vuln.php2shell" | "php.worker-crash" + ) { return false; } } @@ -1577,7 +1595,12 @@ mod tests { #[test] fn test_php_alert_only_findings_never_ban_whatever_the_wording() { - for detector_id in ["php.shell-file", "php.shell-request"] { + for detector_id in [ + "php.shell-file", + "php.shell-request", + "vuln.php2shell", + "php.worker-crash", + ] { let anomaly = LogAnomaly { description: "Request to a file flagged as a PHP shell: GET /webshell.php".into(), severity: AnomalySeverity::High, diff --git a/src/sniff/reporter.rs b/src/sniff/reporter.rs index 2b38d98..90d76a0 100644 --- a/src/sniff/reporter.rs +++ b/src/sniff/reporter.rs @@ -209,10 +209,17 @@ fn description_signature(description: &str) -> String { /// differently by the model, and that alone should not re-alert. Detector-backed /// findings key on their stable `detector_id`; AI-written ones fall back to a /// signature of the description. +/// +/// Findings with no log source come from scans (files, packages, containers), +/// where one pass reports several distinct things under one detector. Their +/// `sample_line` names the thing (`host:/var/www/x.php`, `shop image +/// PHP_VERSION 8.3.30`), so it joins the key. Log findings keep the detector +/// id alone: their sample is a raw line with a timestamp. fn dedup_key(anomaly: &crate::sniff::analyzer::LogAnomaly, source: Option<&LogSource>) -> String { - let finding = match &anomaly.detector_id { - Some(detector_id) => detector_id.clone(), - None => description_signature(&anomaly.description), + let finding = match (&anomaly.detector_id, source) { + (Some(detector_id), Some(_)) => detector_id.clone(), + (Some(detector_id), None) => format!("{}:{}", detector_id, anomaly.sample_line), + (None, _) => description_signature(&anomaly.description), }; let source_key = source .map(|source| source.path_or_id.as_str()) @@ -393,6 +400,76 @@ mod tests { assert_eq!(describe_source("file-integrity", None), "file-integrity"); } + fn version_finding(owner: &str) -> LogAnomaly { + LogAnomaly { + description: format!("PHP 8.3.30 on {} is exposed to PHP2Shell", owner), + severity: AnomalySeverity::Critical, + sample_line: format!("{} image PHP_VERSION 8.3.30", owner), + detector_id: Some("vuln.php2shell".into()), + detector_family: Some("Vulnerability".into()), + confidence: Some(95), + suggested_action: None, + } + } + + #[tokio::test] + async fn test_scan_findings_for_different_things_each_alert_once() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + let reporter = Reporter::new(NotificationConfig::default(), 300); + + // A scan batch has no log source: three installs, three alerts. + let summary = make_summary(vec![ + version_finding("host"), + version_finding("blog"), + version_finding("shop"), + ]); + reporter.report(&summary, Some(&pool), None).await.unwrap(); + assert_eq!( + list_alerts(&pool, AlertFilter::default()) + .await + .unwrap() + .len(), + 3 + ); + + // The next pass finds the same three: nothing new. + reporter.report(&summary, Some(&pool), None).await.unwrap(); + assert_eq!( + list_alerts(&pool, AlertFilter::default()) + .await + .unwrap() + .len(), + 3 + ); + } + + #[test] + fn test_log_findings_ignore_the_sample_line() { + let source = LogSource::new( + LogSourceType::SystemLog, + "/var/log/nginx/access.log".into(), + "access.log".into(), + ); + let first = LogAnomaly { + description: "Potential SQL injection probing detected".into(), + severity: AnomalySeverity::High, + sample_line: "10:00:01 GET /search?q=UNION%20SELECT".into(), + detector_id: Some("web.sqli-probe".into()), + detector_family: None, + confidence: None, + suggested_action: None, + }; + let next = LogAnomaly { + sample_line: "10:00:31 GET /search?q=UNION%20SELECT".into(), + ..first.clone() + }; + assert_eq!( + dedup_key(&first, Some(&source)), + dedup_key(&next, Some(&source)) + ); + } + #[test] fn test_dedup_key_survives_ai_wording_drift() { let source = LogSource::new( diff --git a/website/app/docs/page.tsx b/website/app/docs/page.tsx index 991d1b2..d1cc64a 100644 --- a/website/app/docs/page.tsx +++ b/website/app/docs/page.tsx @@ -107,7 +107,8 @@ const envRows = [ ['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.'] + ['STACKDOG_PHPSHELL_SIGNATURES', '/etc/stackdog/php-shell-signatures.json', 'Extra PHP shell signatures in JSON.'], + ['STACKDOG_PHP_VERSION_CHECK_ENABLED', 'true', 'Alert on PHP builds older than the PHP2Shell fixes, on the host and in containers.'] ] as const; export const metadata: Metadata = { diff --git a/website/app/php-shell-detection/page.tsx b/website/app/php-shell-detection/page.tsx index 4132208..67036fc 100644 --- a/website/app/php-shell-detection/page.tsx +++ b/website/app/php-shell-detection/page.tsx @@ -60,6 +60,19 @@ const responseRows = [ ] ] as const; +const php2shellRows = [ + [ + 'vuln.php2shell', + 'A PHP build older than 8.2.35, 8.3.36, 8.4.27 or 8.5.12, or any 8.1 and older, on the host or in a running container', + 'Critical alert for upstream builds such as official php images. Medium "check your vendor" alert for distribution packages, which backport fixes without changing the version number.' + ], + [ + 'php.worker-crash', + 'Three or more php-fpm or Apache workers dying on SIGSEGV, SIGBUS, SIGABRT or SIGILL in one log source within one pass. One or two per pass never alert, so a single flaky extension stays quiet.', + 'Alert. Repeated crashes are what attempts against PHP memory bugs leave behind.' + ] +] as const; + const envRows = [ ['STACKDOG_PHPSHELL_ENABLED', 'true', 'Turn PHP shell detection on or off: both the file scan and the access-log detector.'], ['STACKDOG_PHPSHELL_PATHS', '/var/www', 'Host web roots to scan, comma-separated. Containers are found on their own.'], @@ -71,7 +84,8 @@ const envRows = [ ['STACKDOG_HOST_ROOT', '/host', 'Where the host filesystem is mounted when Stackdog runs in Docker. Needed for host paths and container docroots.'], ['STACKDOG_PHPSHELL_SKIP_DIRS', 'vendor,node_modules,.git', 'Directory names never descended into.'], ['STACKDOG_PHPSHELL_MAX_FILE_BYTES', '2097152', 'Bytes read per file. Larger files are scored on their first 2 MB.'], - ['STACKDOG_PHPSHELL_SIGNATURES', '/etc/stackdog/php-shell-signatures.json', 'Extra signatures in JSON, added to the built-in ones.'] + ['STACKDOG_PHPSHELL_SIGNATURES', '/etc/stackdog/php-shell-signatures.json', 'Extra signatures in JSON, added to the built-in ones.'], + ['STACKDOG_PHP_VERSION_CHECK_ENABLED', 'true', 'Alert on PHP builds older than the PHP2Shell fixes, on the host and in containers.'] ] as const; const composeSnippet = `services: @@ -246,7 +260,7 @@ export default function PhpShellDetectionPage() { 'How Stackdog finds PHP webshells in host and container web roots and bans the clients driving them.', url: getSiteUrl() + PAGE_PATH, about: ['PHP webshells', 'Web server security', 'Docker security', 'Automated IP banning'], - sections: ['How it works', 'Signals', 'Response', 'Setup', 'Configuration', 'Incident runbook'] + sections: ['How it works', 'Signals', 'Response', 'PHP2Shell', 'Setup', 'Configuration', 'Incident runbook'] }) ) }} @@ -350,7 +364,30 @@ export default function PhpShellDetectionPage() { -
+
+
+ PHP2Shell +

CVE-2026-109618 and CVE-2026-109619: find what still needs upgrading

+

+ PHP2Shell is unauthenticated remote code execution in the PHP interpreter itself, so no + file scan or log rule can fix it: the fix is upgrading PHP. Stackdog reads the PHP version + on the host and in every running container, from the package database and from the{' '} + PHP_VERSION variable of official images, and tells you which ones are behind. + It also watches for PHP workers that keep crashing. Neither finding names an attacker, so + neither bans, and Stackdog does not detect the exploit request itself. +

+
+ +
+

+ Covered: Debian and Ubuntu (php8.x-common), Alpine (php8x-common) and + official php images. RPM-based hosts are not read yet. A shell an attacker + drops after getting in is still caught by the file scan above. +

+
+
+ +
Setup

Host web roots and PHP containers

@@ -382,7 +419,7 @@ export default function PhpShellDetectionPage() {
-
+
Configuration

Settings

@@ -404,7 +441,7 @@ export default function PhpShellDetectionPage() {
-
+
Incident runbook

When a shell is found

@@ -420,7 +457,7 @@ export default function PhpShellDetectionPage() {
-
+
FAQ

Questions operators ask

diff --git a/website/app/php-shell-quickstart/page.tsx b/website/app/php-shell-quickstart/page.tsx index f6ed40e..fd2f334 100644 --- a/website/app/php-shell-quickstart/page.tsx +++ b/website/app/php-shell-quickstart/page.tsx @@ -20,8 +20,11 @@ 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 +# PHP2Shell: alert on PHP builds older than the fixed releases +STACKDOG_PHP_VERSION_CHECK_ENABLED=true +# Access logs the bans are read from, and PHP error logs for worker crashes. +# 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,/var/log/apache2/error.log,/var/log/php8.3-fpm.log # Bans: 5 offenses in 5 minutes, banned for 30 minutes STACKDOG_IP_BAN_ENABLED=true From 87308cbdfd640519dfe5b095d61059c854d5079b Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sat, 31 Oct 2026 17:43:42 +0200 Subject: [PATCH 2/2] chore: bump version to v0.2.8 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ Cargo.toml | 2 +- DEVELOPMENT.md | 2 +- README.md | 4 ++-- VERSION.md | 2 +- docs/INDEX.md | 2 +- install.sh | 6 +++--- web/package.json | 2 +- website/app/docs/page.tsx | 2 +- website/app/page.tsx | 2 +- website/app/php-shell-quickstart/page.tsx | 2 +- website/components/Footer.tsx | 2 +- website/lib/structured-data.ts | 2 +- 13 files changed, 17 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ada08cf..06bad5f 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.8] - 2026-10-31 + ### Added - PHP2Shell exposure check (CVE-2026-109618, CVE-2026-109619). Each sniff pass diff --git a/Cargo.toml b/Cargo.toml index 40a7cf0..c525f98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stackdog" -version = "0.2.7" +version = "0.2.8" authors = ["Vasili Pascal "] edition = "2021" description = "Security platform for Docker containers and Linux servers" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7c608fb..b32936b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,7 +1,7 @@ # Stackdog Security - Development Plan **Last Updated:** 2026-04-07 -**Current Version:** 0.2.7 +**Current Version:** 0.2.8 **Status:** Phase 2 In Progress ## Project Vision diff --git a/README.md b/README.md index 425eadb..ec05e19 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Stackdog Security -![Version](https://img.shields.io/badge/version-0.2.7-blue.svg) +![Version](https://img.shields.io/badge/version-0.2.8-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.7 +curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.8 ``` If your repository has no published stable release yet, use `--version` explicitly. diff --git a/VERSION.md b/VERSION.md index b003284..a45be46 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -0.2.7 +0.2.8 diff --git a/docs/INDEX.md b/docs/INDEX.md index d730515..9c761ec 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,6 +1,6 @@ # Stackdog Security - Documentation Index -**Version:** 0.2.7 +**Version:** 0.2.8 **Last Updated:** 2026-04-07 --- diff --git a/install.sh b/install.sh index 2936046..1fab145 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.7 +# curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.8 # # 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.7)." + error "Could not determine latest release. Create a GitHub release, or specify one with --version (e.g. --version v0.2.8)." 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.7)" + echo " --version VERSION Install a specific version (e.g. v0.2.8)" echo " --help Show this help" exit 0 ;; diff --git a/web/package.json b/web/package.json index e6007cd..f4571c8 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "stackdog-web", "description": "Stackdog Security Web Dashboard", - "version": "0.2.7", + "version": "0.2.8", "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/app/docs/page.tsx b/website/app/docs/page.tsx index d1cc64a..b2bb6c5 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.7'; + 'curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.8'; const installDocker = `docker run --rm -it \\ --name stackdog \\ --network host \\ diff --git a/website/app/page.tsx b/website/app/page.tsx index e3dff1b..4cb2976 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -212,7 +212,7 @@ export default function HomePage() {
- Stackdog Security v0.2.7 + Stackdog Security v0.2.8

AI-Powered Security for Docker Containers & Linux Servers

diff --git a/website/app/php-shell-quickstart/page.tsx b/website/app/php-shell-quickstart/page.tsx index fd2f334..8d781d0 100644 --- a/website/app/php-shell-quickstart/page.tsx +++ b/website/app/php-shell-quickstart/page.tsx @@ -109,7 +109,7 @@ const faqEntries = [ { question: 'Which release do I need?', answer: - 'v0.2.7 or later. The installer picks the latest release by default.' + 'v0.2.7 or later for PHP shell detection, v0.2.8 or later for the PHP2Shell version check. The installer picks the latest release by default.' } ]; diff --git a/website/components/Footer.tsx b/website/components/Footer.tsx index 52b43fd..8b7d5ae 100644 --- a/website/components/Footer.tsx +++ b/website/components/Footer.tsx @@ -92,7 +92,7 @@ export default function Footer(_: FooterProps) {

Release

    -
  • Version 0.2.7
  • +
  • Version 0.2.8
  • MIT Licensed