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
4 changes: 2 additions & 2 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "stackdog"
version = "0.2.6"
version = "0.2.7"
authors = ["Vasili Pascal <info@try.direct>"]
edition = "2021"
description = "Security platform for Docker containers and Linux servers"
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion VERSION.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.6
0.2.7
2 changes: 1 addition & 1 deletion docs/INDEX.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Stackdog Security - Documentation Index

**Version:** 0.2.6
**Version:** 0.2.7
**Last Updated:** 2026-04-07

---
Expand Down
8 changes: 4 additions & 4 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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//')"
Expand Down Expand Up @@ -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
;;
Expand All @@ -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 ""
}

Expand Down
2 changes: 1 addition & 1 deletion src/alerting/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
87 changes: 73 additions & 14 deletions src/detectors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -362,20 +363,17 @@ impl LogDetector for WebshellProbeDetector {
}

fn detect(&self, entries: &[LogEntry]) -> Vec<DetectorFinding> {
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();
Expand All @@ -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"
Expand Down Expand Up @@ -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<DetectorFinding> {
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}");
}
}
}
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion website/.env.local.example
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions website/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 \\
Expand Down Expand Up @@ -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.'],
Expand Down
2 changes: 1 addition & 1 deletion website/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export default function HomePage() {
<div className="site-container">
<div className="grid items-center gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<div>
<span className="eyebrow">Stackdog Security v0.2.6</span>
<span className="eyebrow">Stackdog Security v0.2.7</span>
<h1 className="mt-6 max-w-4xl text-balance text-4xl font-semibold tracking-tight text-white sm:text-5xl lg:text-6xl">
AI-Powered Security for Docker Containers &amp; Linux Servers
</h1>
Expand Down
12 changes: 7 additions & 5 deletions website/app/php-shell-detection/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,13 +267,13 @@ export default function PhpShellDetectionPage() {
the addresses that use them.
</p>
<div className="mt-8 flex flex-wrap items-center gap-4">
<a
href="#setup"
<Link
href="/php-shell-quickstart"
className="inline-flex items-center gap-2 rounded-full bg-cyan-500 px-6 py-3 text-sm font-semibold text-slate-950 transition hover:bg-cyan-400"
>
Set it up
Quick start
<ArrowRight className="h-4 w-4" />
</a>
</Link>
<Link
href="/docs"
className="inline-flex items-center gap-2 rounded-full border border-slate-700 px-6 py-3 text-sm font-semibold text-slate-200 transition hover:border-cyan-400/50 hover:text-white"
Expand Down Expand Up @@ -358,7 +358,9 @@ export default function PhpShellDetectionPage() {
Host paths come from <code>STACKDOG_PHPSHELL_PATHS</code>. 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 <Link href="/php-shell-quickstart" className="text-cyan-300 hover:text-cyan-200">quick start</Link>{' '}
has the install command, recommended settings and a systemd unit.
</p>
<div className="mt-10 grid grid-cols-1 gap-6 lg:grid-cols-2">
<CodeSample code={composeSnippet} label="Docker Compose" />
Expand Down
Loading
Loading