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:
BlackSnufkin
2026-04-30 03:37:14 -07:00
parent 96ced766e6
commit 023b5197a0
14 changed files with 798 additions and 61 deletions
+25 -35
View File
@@ -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
+9 -11
View File
@@ -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
View File
@@ -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",
))
}
+6 -1
View File
@@ -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,
})
}
}