Add system dashboard, scanner-health API, Whiskers --install + samples dir
- New system dashboard at / (live scanner availability + EDR agent reachability, refreshes every minute). Drop-zone moved to /upload. - New GET /api/system/scanners endpoint inventories static + dynamic + holygrail analyzers and reports whether each tool's binary exists. - Whiskers --install / --uninstall register an ONLOGON Windows scheduled task so the agent auto-starts at user logon (no UAC, runs as the invoking user). Forwards non-default flags into the task. - Whiskers --samples-dir; default drop path is now <exe_dir>/samples/ (auto-created) instead of C:\Users\Public\Downloads\. - GrumpyCats client + MCP tools picked up the new EDR endpoints (analyze_edr, get_edr_results/index, list_edr_profiles, get_edr_agents_status) plus get_scanners_status; new CLI subcommands edr-run / edr-results / edr-profiles / edr-status / scanners. - 'New Analysis' / 'Upload New' buttons in summary + results pages point at /upload now that / is the dashboard. - CHANGELOG + Whiskers/README updated for the new flags + auto-start.
This commit is contained in:
+25
-35
@@ -25,14 +25,17 @@ library is `GrumpyCats`.
|
||||
1. Get the binary
|
||||
- Download `Whiskers.exe` from the LitterBox release page, OR
|
||||
- Build from source — see [`BUILD.md`](BUILD.md)
|
||||
2. Drop it anywhere on the VM (e.g. `C:\Tools\Whiskers.exe`)
|
||||
2. Drop it anywhere on the VM (e.g. `C:\Tools\Whiskers.exe`). The folder you
|
||||
put it in becomes the agent home — payloads land in
|
||||
`<that folder>\samples\` by default and the directory is created on first
|
||||
write.
|
||||
3. Allow inbound TCP 8080 in Windows Firewall:
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "Whiskers" `
|
||||
-Direction Inbound -Protocol TCP -LocalPort 8080 `
|
||||
-Action Allow
|
||||
```
|
||||
4. Run it:
|
||||
4. Run it once to verify:
|
||||
```powershell
|
||||
.\Whiskers.exe --port 8080
|
||||
```
|
||||
@@ -40,6 +43,14 @@ library is `GrumpyCats`.
|
||||
```
|
||||
2026-04-29T13:30:12Z INFO whiskers ready version=0.1.0 listen=0.0.0.0:8080
|
||||
```
|
||||
5. (Optional, recommended) Register it to auto-start on user logon so you
|
||||
don't have to launch it manually every session:
|
||||
```powershell
|
||||
.\Whiskers.exe --install
|
||||
```
|
||||
This creates an `ONLOGON` Windows scheduled task named `Whiskers` running
|
||||
as the current user (no UAC prompt). Log out and back in to confirm.
|
||||
To remove the task: `.\Whiskers.exe --uninstall`.
|
||||
|
||||
## Verify
|
||||
|
||||
@@ -56,35 +67,13 @@ curl http://<edr-vm-ip>:8080/api/info
|
||||
|---|---|---|
|
||||
| `--port <PORT>` | `8080` | TCP port to listen on |
|
||||
| `--bind <ADDR>` | `0.0.0.0` | Bind address. Set `127.0.0.1` for loopback-only testing |
|
||||
| `--max-payload-mb <MB>` | `200` | Multipart upload cap on `/api/execute/exec`. LitterBox's own upload cap is 100 MB; this leaves headroom for the multipart envelope |
|
||||
| `--samples-dir <PATH>` | `<exe_dir>\samples` | Where payloads land when the orchestrator doesn't supply a per-request `drop_path`. Auto-created on first write |
|
||||
| `--install` | — | Register Whiskers as an `ONLOGON` Windows scheduled task (no UAC, runs as the invoking user). Forwards any non-default flags from the current invocation into the task. Exits without starting the server |
|
||||
| `--uninstall` | — | Remove the previously installed scheduled task. Exits |
|
||||
|
||||
The binary also accepts `--help` and `--version`.
|
||||
|
||||
## Run as a Windows service (optional)
|
||||
|
||||
For a VM where you want Whiskers up automatically after every reboot.
|
||||
Easiest path is `sc.exe`:
|
||||
|
||||
```powershell
|
||||
# Run elevated PowerShell:
|
||||
sc.exe create Whiskers binPath= "\"C:\Tools\Whiskers.exe\" --port 8080" `
|
||||
start= auto DisplayName= "Whiskers (LitterBox sensor agent)"
|
||||
|
||||
sc.exe description Whiskers "LitterBox sensor agent — payload execution runner"
|
||||
|
||||
sc.exe start Whiskers
|
||||
```
|
||||
|
||||
Stop / remove later with:
|
||||
```powershell
|
||||
sc.exe stop Whiskers
|
||||
sc.exe delete Whiskers
|
||||
```
|
||||
|
||||
> Whiskers isn't a "real" Windows service (no SCM lifecycle handling) so
|
||||
> `sc create` runs it as a console app under the service host. For most
|
||||
> sandbox use that's fine; for a hardened production deploy wrap it in
|
||||
> [NSSM](https://nssm.cc/) which handles SCM properly.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
@@ -113,13 +102,14 @@ a crashed orchestrator stranding Whiskers.
|
||||
- Whiskers has **no authentication**. It's designed to run on a VM that
|
||||
only LitterBox should be able to reach (private network, VPN, or
|
||||
loopback). Don't expose port 8080 to the internet.
|
||||
- Payloads land in `C:\Users\Public\Downloads\` by default unless the
|
||||
caller specifies `drop_path`. That folder is auto-cleaned after each
|
||||
run, but Whiskers never reaches outside the supplied `drop_path`.
|
||||
- Execution runs as the same user the Whiskers process is running as. If
|
||||
you installed the service as `LocalSystem` (sc.exe default), payloads
|
||||
run with SYSTEM privileges — fine for sandbox use, intentional for
|
||||
evaluating SYSTEM-context detections, but be aware.
|
||||
- Payloads land in `<exe_dir>\samples\` by default (override with
|
||||
`--samples-dir` or per-request via the multipart `drop_path` field).
|
||||
The drop is auto-cleaned after each run, but Whiskers never reaches
|
||||
outside the supplied path.
|
||||
- Execution runs as the same user the Whiskers process is running as.
|
||||
`--install` registers the task as `ONLOGON` running as the invoking
|
||||
user — payloads run unelevated unless you launched the install from an
|
||||
elevated shell.
|
||||
- The XOR option on `/api/execute/exec` keeps the payload encrypted in
|
||||
transit and during the in-memory copy on the agent — useful when your
|
||||
EDR's behavioral monitor would match a plaintext known-bad sample
|
||||
|
||||
@@ -27,8 +27,6 @@ use tokio::sync::oneshot;
|
||||
use crate::file_writer;
|
||||
use crate::state::{AppState, ExecStatus, RunState};
|
||||
|
||||
const DEFAULT_DROP_PATH: &str = r"C:\Users\Public\Downloads\";
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ExecResponse {
|
||||
pub status: &'static str,
|
||||
@@ -83,7 +81,7 @@ pub async fn exec(
|
||||
));
|
||||
}
|
||||
|
||||
let file_path = build_drop_path(&form.drop_path, &form.file_name);
|
||||
let file_path = build_drop_path(&form.drop_path, &form.file_name, &state.default_drop_path);
|
||||
tracing::info!(path = %file_path.display(), xor = form.xor_key.is_some(), "Writing payload");
|
||||
|
||||
if let Err(err) = file_writer::write(&file_path, &form.file_bytes, form.xor_key).await {
|
||||
@@ -391,14 +389,14 @@ fn is_likely_av_block(err: &std::io::Error) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the final on-disk path: `<drop_path>/<file_name>`. Defaults
|
||||
/// drop_path to `C:\Users\Public\Downloads\` if not provided.
|
||||
fn build_drop_path(drop_path: &str, file_name: &str) -> PathBuf {
|
||||
let mut base = if drop_path.trim().is_empty() {
|
||||
DEFAULT_DROP_PATH.to_string()
|
||||
} else {
|
||||
drop_path.to_string()
|
||||
};
|
||||
/// Build the final on-disk path: `<drop_path>/<file_name>`. The orchestrator
|
||||
/// can override per-request via the multipart `drop_path` field; otherwise
|
||||
/// we drop into the agent's own samples dir (resolved at startup).
|
||||
fn build_drop_path(drop_path: &str, file_name: &str, default_dir: &Path) -> PathBuf {
|
||||
if drop_path.trim().is_empty() {
|
||||
return default_dir.join(file_name);
|
||||
}
|
||||
let mut base = drop_path.to_string();
|
||||
if !base.ends_with('\\') && !base.ends_with('/') {
|
||||
base.push('\\');
|
||||
}
|
||||
|
||||
+139
-1
@@ -8,6 +8,7 @@
|
||||
//! LitterBox queries the EDR's backend separately for the verdict.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{delete, get, post};
|
||||
@@ -25,6 +26,8 @@ use crate::agent_log::{AgentLog, AgentLogLayer};
|
||||
use crate::state::AppState;
|
||||
|
||||
const AGENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
/// Name registered with Windows Task Scheduler for `--install` / `--uninstall`.
|
||||
const SCHEDULED_TASK_NAME: &str = "Whiskers";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "whiskers", version, about = "Whiskers — LitterBox's sensor agent")]
|
||||
@@ -44,6 +47,23 @@ struct Cli {
|
||||
/// dispatches larger samples.
|
||||
#[arg(long, default_value_t = 200)]
|
||||
max_payload_mb: usize,
|
||||
|
||||
/// Directory where incoming payloads are written when the orchestrator
|
||||
/// does not specify a per-request drop path. Defaults to
|
||||
/// `<agent-exe-dir>/samples`. The directory is created on first write.
|
||||
#[arg(long)]
|
||||
samples_dir: Option<PathBuf>,
|
||||
|
||||
/// Register Whiskers with Windows Task Scheduler so it auto-starts at
|
||||
/// user logon (no admin elevation required). Forwards the current
|
||||
/// invocation's flags into the task's command line, then exits without
|
||||
/// starting the server. Use `--uninstall` to remove.
|
||||
#[arg(long, conflicts_with = "uninstall")]
|
||||
install: bool,
|
||||
|
||||
/// Remove the previously installed scheduled task and exit.
|
||||
#[arg(long, conflicts_with = "install")]
|
||||
uninstall: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -64,10 +84,33 @@ async fn main() {
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.install {
|
||||
match install_scheduled_task(&cli) {
|
||||
Ok(()) => return,
|
||||
Err(e) => {
|
||||
eprintln!("install failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if cli.uninstall {
|
||||
match uninstall_scheduled_task() {
|
||||
Ok(()) => return,
|
||||
Err(e) => {
|
||||
eprintln!("uninstall failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let addr = SocketAddr::new(cli.bind, cli.port);
|
||||
let max_payload_bytes = cli.max_payload_mb.saturating_mul(1024 * 1024);
|
||||
let samples_dir = resolve_samples_dir(cli.samples_dir.clone());
|
||||
|
||||
let state = AppState::new(agent_log);
|
||||
tracing::info!(samples_dir = %samples_dir.display(), "Default drop path");
|
||||
|
||||
let state = AppState::new(agent_log, samples_dir);
|
||||
|
||||
// axum's default multipart body limit is 2 MiB — too small for real
|
||||
// payloads. Disable the route-level cap and re-impose our own via
|
||||
@@ -131,3 +174,98 @@ async fn shutdown_signal() {
|
||||
|
||||
tracing::info!("shutdown signal received");
|
||||
}
|
||||
|
||||
/// Pick the directory where payloads land by default.
|
||||
///
|
||||
/// CLI override (`--samples-dir`) wins; otherwise we resolve `<exe_dir>/samples`
|
||||
/// from `current_exe()`. If both lookups fail (extremely unusual), fall back
|
||||
/// to `./samples` relative to the working directory.
|
||||
fn resolve_samples_dir(cli_override: Option<PathBuf>) -> PathBuf {
|
||||
if let Some(p) = cli_override {
|
||||
return p;
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(parent) = exe.parent() {
|
||||
return parent.join("samples");
|
||||
}
|
||||
}
|
||||
PathBuf::from("samples")
|
||||
}
|
||||
|
||||
/// Register Whiskers as an "at logon" scheduled task so it comes up
|
||||
/// automatically when the user logs into the EDR VM. We forward only the
|
||||
/// flags that actually differ from defaults — the task command line stays
|
||||
/// readable when inspected via `schtasks /Query`.
|
||||
///
|
||||
/// Runs as the invoking user, no elevation: we want spawned payloads to
|
||||
/// inherit the same privilege Whiskers has now (matches the operator's
|
||||
/// manual-launch behavior).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn install_scheduled_task(cli: &Cli) -> std::io::Result<()> {
|
||||
let exe = std::env::current_exe()?;
|
||||
let mut tr = format!("\"{}\"", exe.display());
|
||||
if cli.port != 8080 {
|
||||
tr.push_str(&format!(" --port {}", cli.port));
|
||||
}
|
||||
if cli.bind.to_string() != "0.0.0.0" {
|
||||
tr.push_str(&format!(" --bind {}", cli.bind));
|
||||
}
|
||||
if cli.max_payload_mb != 200 {
|
||||
tr.push_str(&format!(" --max-payload-mb {}", cli.max_payload_mb));
|
||||
}
|
||||
if let Some(d) = &cli.samples_dir {
|
||||
tr.push_str(&format!(" --samples-dir \"{}\"", d.display()));
|
||||
}
|
||||
|
||||
let status = std::process::Command::new("schtasks.exe")
|
||||
.args([
|
||||
"/Create", "/F",
|
||||
"/TN", SCHEDULED_TASK_NAME,
|
||||
"/SC", "ONLOGON",
|
||||
"/TR", &tr,
|
||||
])
|
||||
.status()?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"schtasks /Create exited with {:?}",
|
||||
status.code()
|
||||
)));
|
||||
}
|
||||
println!(
|
||||
"Installed scheduled task '{name}'. Whiskers will auto-start on the next user logon.\n Command: {tr}\n To remove: {exe} --uninstall",
|
||||
name = SCHEDULED_TASK_NAME,
|
||||
tr = tr,
|
||||
exe = exe.display(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn uninstall_scheduled_task() -> std::io::Result<()> {
|
||||
let status = std::process::Command::new("schtasks.exe")
|
||||
.args(["/Delete", "/F", "/TN", SCHEDULED_TASK_NAME])
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"schtasks /Delete exited with {:?} (task may not exist)",
|
||||
status.code()
|
||||
)));
|
||||
}
|
||||
println!("Removed scheduled task '{}'.", SCHEDULED_TASK_NAME);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn install_scheduled_task(_cli: &Cli) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other(
|
||||
"--install is only supported on Windows (uses schtasks.exe)",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn uninstall_scheduled_task() -> std::io::Result<()> {
|
||||
Err(std::io::Error::other(
|
||||
"--uninstall is only supported on Windows",
|
||||
))
|
||||
}
|
||||
|
||||
@@ -17,14 +17,19 @@ pub struct AppState {
|
||||
pub lock: Mutex<LockState>,
|
||||
pub run: Mutex<Option<RunState>>,
|
||||
pub agent_log: Arc<AgentLog>,
|
||||
/// Directory where incoming payloads land when the orchestrator does
|
||||
/// not specify a per-request `drop_path`. Set at startup from CLI flags
|
||||
/// or `<exe_dir>/samples`.
|
||||
pub default_drop_path: PathBuf,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(agent_log: Arc<AgentLog>) -> Arc<Self> {
|
||||
pub fn new(agent_log: Arc<AgentLog>, default_drop_path: PathBuf) -> Arc<Self> {
|
||||
Arc::new(AppState {
|
||||
lock: Mutex::new(LockState::default()),
|
||||
run: Mutex::new(None),
|
||||
agent_log,
|
||||
default_drop_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user