diff --git a/CHANGELOG.md b/CHANGELOG.md index 00465bc..ff0850e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,14 +11,29 @@ All notable changes to this project will be documented in this file. - RedEdr now captures Microsoft-Windows-Kernel-File / -Network / -Audit-API-Calls / Antimalware-Engine ETW events; new tabs surface File Ops / Network / Audit API / Defender with Process Tree panel and ETW Provider Diagnostics - Defender threat verdicts at runtime contribute +50 to the Detection Score (only verdicts; scan activity stays descriptive) - Whiskers — single-binary Rust HTTP agent (`Whiskers/`) for dispatching payloads to a separate EDR-instrumented Windows VM +- Whiskers `--install` / `--uninstall` flags register an `ONLOGON` Windows scheduled task so the agent auto-starts at user logon (no UAC, runs as the invoking user) +- Whiskers `--samples-dir` flag; default drop path is now `/samples/` (auto-created on first write) instead of `C:\Users\Public\Downloads\` +- Whiskers chunked-XOR write (64 KiB working buffer) — multi-MB payloads finish in milliseconds instead of the 10+ seconds the byte-by-byte loop took, which had been timing out the orchestrator - Elastic EDR integration via per-profile YAMLs under `Config/edr_profiles/`; each profile gets a "Run with X" tab on the upload page - Two-phase EDR orchestration: Phase 1 (exec) returns sync, Phase 2 (Elastic alert poll) runs in a background thread and updates the saved JSON when done -- Per-payload alert correlation — query scoped by `host.name` + filename across `file.name`/`process.name`/`file.path`/`process.executable` +- Per-payload alert correlation — query scoped by `host.name` + filename across `file.name`/`process.name`/`file.path`/`process.executable`/`process.command_line`/`process.args` (the `command_line` / `args` clauses pull in alerts on rundll32-launched DLLs, which only carry the DLL name in the parent's command line) - AV-block detection from Whiskers (`status:"virus"` on Windows errno 225/995/1234) surfaces as `blocked_by_av` -- EDR-kill detection — non-zero exit without an agent-issued kill is labeled "killed by EDR behavior protection" +- EDR-kill detection — non-zero exit without an agent-issued kill is labeled "killed by EDR behavior protection". For DLLs, kill classification additionally requires alert evidence (DLL hosts can exit non-zero for benign reasons) - Per-alert detail panel: Reason, Rule Description, MITRE chips, Triggering API, Memory Region, Call Stack with module provenance, Final User Module, Process / Parent / EDR Response cards - High/critical EDR alerts contribute up to +50 to the Detection Score (AV blocks +35; multi-profile takes the max) -- New endpoints: `GET /api/edr/profiles`, `GET /api/results//edr[/]`, `GET/POST /analyze/edr//` +- New endpoints: `GET /api/edr/profiles`, `GET /api/edr/agents/status`, `GET /api/system/scanners`, `GET /api/results/edr/[/]`, `GET/POST /analyze/edr//` +- New pages: system dashboard at `/` (live scanner availability + EDR agent reachability, polls every minute), `/whiskers` agent inventory, `/analyze/all/` "All" pipeline coordinator, `/results/edr//` saved-view (rich detail — MITRE chips, call stack, expandable per-alert detail, raw `_source` — backed by the same renderer as the live scan view) +- "All" analysis mode: client-side coordinator runs Static + every EDR profile in parallel; Dynamic waits only for Static (EDR is on a remote VM, no local resource contention) +- DLL execution support — payloads ending in `.dll` spawn via `rundll32.exe , [args...]` in both the Whiskers agent and the local Dynamic analyzer; entry point comes from the executable-args field +- GrumpyCats client gained EDR + scanner-health methods (`analyze_edr`, `get_edr_results`, `get_edr_index`, `list_edr_profiles`, `get_edr_agents_status`, `get_scanners_status`, `wait_for_edr_completion`); CLI subcommands `edr-run` / `edr-results` / `edr-profiles` / `edr-status` / `scanners`; matching MCP tools surface the same to LLM clients + +### Changed +- Drop-zone moved from `/` to `/upload` (GET); `/` is now the system dashboard. Existing POST `/upload` for file uploads is unchanged. +- URL convention unified — analysis type / qualifier always comes BEFORE the target hash: `/results//`, `/api/results//`, `/api/results/edr//`. Matches `/analyze/edr//`. +- AgentClient gains a separate `exec_timeout` (180s) for the multipart upload + agent-side write path; the cheap endpoints stay at the 10s default. +- Status flow simplified — `blocked_polling_alerts` collapsed into `polling_alerts` plus an orthogonal `summary.blocked_by_av` flag (the polling itself is purely LitterBox→Elastic, so the EDR-VM blocked/clean state shouldn't affect the polling status). +- Sidebar nav: added Dashboard entry; Upload nav points to `/upload`; "Agents" renamed to "Whiskers" (route also at `/whiskers`). +- File-info detection score no longer folds in EDR results (EDR is its own analysis type with its own page; it shouldn't bleed into the static + dynamic + PE score). ### Changed - Backend split into Flask blueprints, services, and a `utils/` package; subprocess analyzers consolidated under `BaseSubprocessAnalyzer` diff --git a/GrumpyCats/LitterBoxMCP.py b/GrumpyCats/LitterBoxMCP.py index 056996c..a6e45c1 100644 --- a/GrumpyCats/LitterBoxMCP.py +++ b/GrumpyCats/LitterBoxMCP.py @@ -35,10 +35,11 @@ mcp = FastMCP( name="LitterBox", instructions=( "Tools for the LitterBox payload-analysis sandbox: upload payloads / drivers, " - "run static or dynamic analysis, retrieve results, and generate reports. " - "Use the prompts for OPSEC review of analysis output. " - "Tool exceptions are surfaced to the client by FastMCP automatically — " - "do not wrap returns in success / error envelopes." + "run static / dynamic / EDR (Whiskers + Elastic Defend) analysis, retrieve " + "results, and generate reports. Also exposes system health (registered EDR " + "agent reachability, configured scanner inventory). Use the prompts for " + "OPSEC review of analysis output. Tool exceptions are surfaced to the client " + "by FastMCP automatically — do not wrap returns in success / error envelopes." ), ) @@ -178,6 +179,76 @@ async def download_report( return {"saved_to": saved} +# ============================================================================= +# EDR — Whiskers agent + Elastic Defend correlation +# ============================================================================= + +@mcp.tool() +async def list_edr_profiles() -> dict: + """List EDR profiles registered under Config/edr_profiles/.""" + return await _call(client.list_edr_profiles) + + +@mcp.tool() +async def get_edr_agents_status() -> dict: + """Live probe of every EDR profile (Whiskers agent + Elastic stack reachability, + hostname, agent version, lock state, cluster info).""" + return await _call(client.get_edr_agents_status) + + +@mcp.tool() +async def analyze_edr( + file_hash: Annotated[str, Field(description="MD5 hash of an uploaded file.")], + profile: Annotated[str, Field(description="EDR profile name (matches Config/edr_profiles/.yml).")], + cmd_args: Annotated[Optional[List[str]], Field(description="Command-line arguments passed to the payload.")] = None, + xor_key: Annotated[Optional[int], Field(description="Single byte (0-255) to XOR-encode the payload in transit (anti-AV).", ge=0, le=255)] = None, + wait: Annotated[bool, Field(description="Block until Phase-2 (Elastic alert correlation) settles.")] = True, + timeout: Annotated[float, Field(description="Phase-2 wait timeout in seconds.", ge=10, le=600)] = 180.0, +) -> dict: + """Dispatch a payload to a registered EDR profile (Whiskers agent + Elastic Defend). + + Note: this EXECUTES the payload on the EDR VM. Confirm with the user first. + + Two-phase: Phase-1 (synchronous) handles agent dispatch + lock + execution; Phase-2 + (server-side daemon) correlates Elastic alerts. With wait=True, the server returns + once Phase-2 settles or `timeout` elapses. + """ + phase1 = await _call(client.analyze_edr, file_hash, profile, cmd_args=cmd_args, xor_key=xor_key) + if not wait or (phase1 or {}).get('status') != 'polling_alerts': + return {'phase_1': phase1, 'phase_2': None} + phase2 = await _call(client.wait_for_edr_completion, file_hash, profile, 3.0, timeout) + return {'phase_1': phase1, 'phase_2': phase2} + + +@mcp.tool() +async def get_edr_results( + file_hash: Annotated[str, Field(description="MD5 hash of the analyzed payload.")], + profile: Annotated[str, Field(description="EDR profile name.")], +) -> dict: + """Read saved EDR findings (alerts + execution logs + summary) for one profile.""" + return await _call(client.get_edr_results, file_hash, profile) + + +@mcp.tool() +async def get_edr_index( + file_hash: Annotated[str, Field(description="MD5 hash of the analyzed payload.")], +) -> dict: + """Index of every saved EDR run for a target (one entry per profile that has data).""" + return await _call(client.get_edr_index, file_hash) + + +# ============================================================================= +# System health — local scanner inventory +# ============================================================================= + +@mcp.tool() +async def get_scanners_status() -> dict: + """Inventory of configured local analyzers (static + dynamic + holygrail) and + whether their binaries are present on disk. Drives the dashboard panel and + is the right thing to call before a run if a scanner has been failing.""" + return await _call(client.get_scanners_status) + + # ============================================================================= # Doppelganger — comparison against host snapshot or fuzzy-hash baseline # ============================================================================= diff --git a/GrumpyCats/grumpycat.py b/GrumpyCats/grumpycat.py index 500fb8f..029c886 100644 --- a/GrumpyCats/grumpycat.py +++ b/GrumpyCats/grumpycat.py @@ -344,6 +344,93 @@ class LitterBoxClient: response = self._make_request('GET', f'/api/results/risk/{target}') return response.json() + # ============================================================================= + # EDR (Whiskers + Elastic Defend) OPERATIONS + # ============================================================================= + + def list_edr_profiles(self) -> Dict: + """List EDR profiles registered under Config/edr_profiles/.""" + response = self._make_request('GET', '/api/edr/profiles') + return response.json() + + def get_edr_agents_status(self) -> Dict: + """Probe each registered EDR profile's Whiskers agent + Elastic stack. + + Returns reachability, hostname, agent version, lock state, cluster + info etc. The server fans out probes in parallel; total wall time + is the slowest probe.""" + response = self._make_request('GET', '/api/edr/agents/status') + return response.json() + + def analyze_edr(self, file_hash: str, profile: str, + cmd_args: Optional[List[str]] = None, + xor_key: Optional[int] = None) -> Dict: + """Dispatch a payload to a registered EDR profile. + + Returns the Phase-1 result immediately (status='polling_alerts' + when execution succeeded; 'blocked_by_av' / 'agent_unreachable' / + 'busy' / 'error' otherwise). Phase-2 (Elastic alert correlation) + runs in a server-side daemon thread; poll + `get_edr_results(file_hash, profile)` until status is no longer + 'polling_alerts'. + """ + data = {} + if cmd_args: + data.update(self._validate_command_args(cmd_args)) + if xor_key is not None: + if not 0 <= xor_key <= 255: + raise ValueError(f"xor_key must be 0-255, got {xor_key}") + data['xor_key'] = xor_key + + response = self._make_request( + 'POST', f'/analyze/edr/{profile}/{file_hash}', json=data, + ) + return response.json() + + def get_edr_results(self, file_hash: str, profile: str) -> Dict: + """Fetch the saved findings for a specific EDR profile run.""" + response = self._make_request( + 'GET', f'/api/results/edr/{profile}/{file_hash}', + ) + return response.json() + + def get_edr_index(self, file_hash: str) -> Dict: + """Fetch every saved EDR run for a target (one entry per profile).""" + response = self._make_request('GET', f'/api/results/edr/{file_hash}') + return response.json() + + def wait_for_edr_completion(self, file_hash: str, profile: str, + interval: float = 3.0, + timeout: float = 180.0) -> Dict: + """Block until Phase-2 settles (status leaves 'polling_alerts'), + the saved JSON appears for the first time, or `timeout` seconds + elapse. Returns the last-seen findings dict (may still be + 'polling_alerts' on timeout — caller decides what to do).""" + import time + deadline = time.monotonic() + timeout + last = None + while time.monotonic() < deadline: + try: + last = self.get_edr_results(file_hash, profile) + if (last or {}).get('status') and last.get('status') != 'polling_alerts': + return last + except LitterBoxAPIError as e: + # 404 just means Phase-1 hasn't been kicked off yet — keep polling. + if e.status_code != 404: + raise + time.sleep(interval) + return last or {'status': 'timeout', 'error': f'Phase-2 timeout after {timeout}s'} + + # ============================================================================= + # SYSTEM HEALTH + # ============================================================================= + + def get_scanners_status(self) -> Dict: + """Inventory of configured analyzers and whether their binaries + exist on disk (drives the dashboard scanner panel).""" + response = self._make_request('GET', '/api/system/scanners') + return response.json() + def get_files_summary(self) -> Dict: """Get summary of all analyzed files and processes.""" response = self._make_request('GET', '/files') @@ -352,15 +439,17 @@ class LitterBoxClient: def get_comprehensive_results(self, target: str) -> Dict: """Get all available results for a target in one call. - The four GETs are independent, so they fan out across a small + The five GETs are independent, so they fan out across a small thread pool — wall time on a populated target drops from - sequential (~4×) to roughly the slowest single response. + sequential (~5×) to roughly the slowest single response. + Includes EDR runs (across every profile) when present. """ fetchers = [ ('file_info', self.get_file_info), ('static_results', self.get_static_results), ('dynamic_results', self.get_dynamic_results), ('holygrail_results', self.get_holygrail_results), + ('edr_index', self.get_edr_index), ] def _safe_fetch(method): @@ -545,8 +634,15 @@ Examples: %(prog)s doppelganger-scan --type blender %(prog)s doppelganger-analyze abc123def --type fuzzy --threshold 85 + # EDR (Whiskers + Elastic Defend) + %(prog)s edr-profiles + %(prog)s edr-status + %(prog)s edr-run abc123def --profile elastic --wait + %(prog)s edr-results abc123def --profile elastic + # System operations %(prog)s status --full + %(prog)s scanners %(prog)s cleanup --all # Report operations @@ -592,6 +688,23 @@ Examples: results_parser.add_argument('--comprehensive', action='store_true', help='Get all available results') + # EDR commands + edr_run_parser = subparsers.add_parser('edr-run', help='Dispatch a payload to an EDR profile (Whiskers + Elastic)') + edr_run_parser.add_argument('hash', help='File hash returned by upload') + edr_run_parser.add_argument('--profile', required=True, help='EDR profile name (Config/edr_profiles/.yml)') + edr_run_parser.add_argument('--args', nargs='+', help='Command-line arguments passed to the payload') + edr_run_parser.add_argument('--xor-key', type=int, help='Single byte (0-255) used to XOR-encode the payload in transit') + edr_run_parser.add_argument('--wait', action='store_true', help='Poll until Phase-2 (Elastic alert correlation) settles') + edr_run_parser.add_argument('--timeout', type=float, default=180.0, help='Phase-2 wait timeout in seconds (default 180)') + + edr_results_parser = subparsers.add_parser('edr-results', help='Read saved EDR findings for a target') + edr_results_parser.add_argument('hash', help='File hash') + edr_results_parser.add_argument('--profile', help='Specific profile (omit to get the index across every profile)') + + subparsers.add_parser('edr-profiles', help='List registered EDR profiles') + subparsers.add_parser('edr-status', help='Live probe of every EDR profile (Whiskers + Elastic reachability)') + subparsers.add_parser('scanners', help='Inventory of configured local analyzers and whether their binaries exist') + # Doppelganger scan command doppelganger_scan_parser = subparsers.add_parser('doppelganger-scan', help='Run doppelganger scan') doppelganger_scan_parser.add_argument('--type', choices=['blender'], default='blender', @@ -733,6 +846,40 @@ def _cmd_results(client: LitterBoxClient, args): print(json.dumps(result, indent=2)) +def _cmd_edr_run(client: LitterBoxClient, args): + print(f"Dispatching {args.hash} to EDR profile '{args.profile}'...") + phase1 = client.analyze_edr(args.hash, args.profile, + cmd_args=args.args, xor_key=args.xor_key) + print("Phase-1 result:") + print(json.dumps(phase1, indent=2)) + + if args.wait and (phase1 or {}).get('status') == 'polling_alerts': + print(f"\nPolling for Phase-2 settle (timeout {args.timeout}s)...") + final = client.wait_for_edr_completion(args.hash, args.profile, timeout=args.timeout) + print("Phase-2 result:") + print(json.dumps(final, indent=2)) + + +def _cmd_edr_results(client: LitterBoxClient, args): + if args.profile: + result = client.get_edr_results(args.hash, args.profile) + else: + result = client.get_edr_index(args.hash) + print(json.dumps(result, indent=2)) + + +def _cmd_edr_profiles(client: LitterBoxClient, args): + print(json.dumps(client.list_edr_profiles(), indent=2)) + + +def _cmd_edr_status(client: LitterBoxClient, args): + print(json.dumps(client.get_edr_agents_status(), indent=2)) + + +def _cmd_scanners(client: LitterBoxClient, args): + print(json.dumps(client.get_scanners_status(), indent=2)) + + def _cmd_doppelganger_scan(client: LitterBoxClient, args): print(f"Running doppelganger scan with type: {args.type}") print(json.dumps(client.run_blender_scan(), indent=2)) @@ -809,6 +956,11 @@ COMMAND_HANDLERS = { 'upload-driver': _cmd_upload_driver, 'analyze-pid': _cmd_analyze_pid, 'results': _cmd_results, + 'edr-run': _cmd_edr_run, + 'edr-results': _cmd_edr_results, + 'edr-profiles': _cmd_edr_profiles, + 'edr-status': _cmd_edr_status, + 'scanners': _cmd_scanners, 'doppelganger-scan': _cmd_doppelganger_scan, 'doppelganger-analyze': _cmd_doppelganger_analyze, 'doppelganger-db': _cmd_doppelganger_db, diff --git a/Whiskers/README.md b/Whiskers/README.md index 718c8c2..861c359 100644 --- a/Whiskers/README.md +++ b/Whiskers/README.md @@ -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 + `\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://:8080/api/info |---|---|---| | `--port ` | `8080` | TCP port to listen on | | `--bind ` | `0.0.0.0` | Bind address. Set `127.0.0.1` for loopback-only testing | +| `--max-payload-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 ` | `\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 `\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 diff --git a/Whiskers/src/api/execute.rs b/Whiskers/src/api/execute.rs index b6f16bf..78c6fb1 100644 --- a/Whiskers/src/api/execute.rs +++ b/Whiskers/src/api/execute.rs @@ -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: `/`. 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: `/`. 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('\\'); } diff --git a/Whiskers/src/main.rs b/Whiskers/src/main.rs index f6bad9c..0eae0b6 100644 --- a/Whiskers/src/main.rs +++ b/Whiskers/src/main.rs @@ -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 + /// `/samples`. The directory is created on first write. + #[arg(long)] + samples_dir: Option, + + /// 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 `/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 { + 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", + )) +} diff --git a/Whiskers/src/state.rs b/Whiskers/src/state.rs index 2ff9756..be3d065 100644 --- a/Whiskers/src/state.rs +++ b/Whiskers/src/state.rs @@ -17,14 +17,19 @@ pub struct AppState { pub lock: Mutex, pub run: Mutex>, pub agent_log: Arc, + /// Directory where incoming payloads land when the orchestrator does + /// not specify a per-request `drop_path`. Set at startup from CLI flags + /// or `/samples`. + pub default_drop_path: PathBuf, } impl AppState { - pub fn new(agent_log: Arc) -> Arc { + pub fn new(agent_log: Arc, default_drop_path: PathBuf) -> Arc { Arc::new(AppState { lock: Mutex::new(LockState::default()), run: Mutex::new(None), agent_log, + default_drop_path, }) } } diff --git a/app/blueprints/api.py b/app/blueprints/api.py index 4c19a14..d0b99a4 100644 --- a/app/blueprints/api.py +++ b/app/blueprints/api.py @@ -111,6 +111,53 @@ def api_edr_profiles(): return jsonify({'profiles': deps.edr_registry.list_profiles()}) +@api_bp.route('/api/system/scanners', methods=['GET']) +@error_handler +def api_system_scanners(): + """Inventory of configured analyzers and whether their binaries exist. + + Walks the static + dynamic + holygrail sections of analysis config and + reports per-scanner: enabled flag, configured tool path, whether the + file is present on disk. Used by the dashboard to flag missing tools.""" + cfg = current_app.config.get('analysis', {}) or {} + + def _row(group, name, scanner_cfg): + tool_path = (scanner_cfg or {}).get('tool_path', '').strip() + enabled = bool((scanner_cfg or {}).get('enabled', False)) + exists = bool(tool_path) and os.path.isfile(tool_path) + return { + 'group': group, + 'name': name, + 'enabled': enabled, + 'tool_path': tool_path, + 'exists': exists, + 'status': ( + 'ok' if enabled and exists else + 'missing' if enabled and not exists else + 'disabled' + ), + } + + rows = [] + for group_key in ('static', 'dynamic'): + group_cfg = cfg.get(group_key) or {} + for scanner_name, scanner_cfg in group_cfg.items(): + if isinstance(scanner_cfg, dict): + rows.append(_row(group_key, scanner_name, scanner_cfg)) + + holygrail = cfg.get('holygrail') + if isinstance(holygrail, dict): + rows.append(_row('holygrail', 'holygrail', holygrail)) + + counts = { + 'total': len(rows), + 'ok': sum(1 for r in rows if r['status'] == 'ok'), + 'missing': sum(1 for r in rows if r['status'] == 'missing'), + 'disabled': sum(1 for r in rows if r['status'] == 'disabled'), + } + return jsonify({'scanners': rows, 'counts': counts}) + + @api_bp.route('/api/edr/agents/status', methods=['GET']) @error_handler def api_edr_agents_status(): diff --git a/app/blueprints/upload.py b/app/blueprints/upload.py index 35c6412..47d5b10 100644 --- a/app/blueprints/upload.py +++ b/app/blueprints/upload.py @@ -1,5 +1,5 @@ # app/blueprints/upload.py -"""Static index page and generic file uploads.""" +"""Index dashboard, upload drop-zone, and generic file uploads.""" from flask import Blueprint, current_app, jsonify, render_template, request from ..services.error_handling import error_handler @@ -10,6 +10,20 @@ upload_bp = Blueprint('upload', __name__) @upload_bp.route('/') def index(): + """System health dashboard: agents + scanner availability. + Live data is fetched async by the page's JS via /api/edr/agents/status + and /api/system/scanners.""" + deps = current_app.extensions['litterbox'] + return render_template( + 'dashboard.html', + config=current_app.config, + edr_profiles=deps.edr_registry.list_profiles(), + ) + + +@upload_bp.route('/upload', methods=['GET']) +def upload_page(): + """Upload drop-zone — renders the analysis-mode picker.""" deps = current_app.extensions['litterbox'] return render_template( 'upload.html', diff --git a/app/static/js/dashboard/core.js b/app/static/js/dashboard/core.js new file mode 100644 index 0000000..6ffd14d --- /dev/null +++ b/app/static/js/dashboard/core.js @@ -0,0 +1,157 @@ +// app/static/js/dashboard/core.js +// +// Drives the index dashboard. Polls /api/system/scanners for analyzer +// availability and /api/edr/agents/status for live agent + Elastic state. +// Auto-refreshes every 60s; the manual Refresh button forces an immediate +// poll. Both fetches are kicked off in parallel. + +const REFRESH_MS = 60000; +let _refreshTimer = null; +let _inFlight = false; + +function setText(id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; +} + +// ---- Scanners ----------------------------------------------------------- +function statusTagClass(status) { + return ( + status === 'ok' ? 'low' : + status === 'missing' ? 'high' : + status === 'disabled' ? 'muted' : + 'muted' + ); +} + +function renderScanners(payload) { + const host = document.getElementById('scannersTable'); + if (!host) return; + const scanners = (payload && payload.scanners) || []; + const counts = (payload && payload.counts) || {}; + setText('scannersCount', + `${counts.ok ?? 0} ok · ${counts.missing ?? 0} missing · ${counts.disabled ?? 0} disabled`); + + if (!scanners.length) { + host.innerHTML = '
No analyzers configured.
'; + return; + } + + // Group by `group` (static / dynamic / holygrail), preserving config order. + const groups = new Map(); + for (const s of scanners) { + if (!groups.has(s.group)) groups.set(s.group, []); + groups.get(s.group).push(s); + } + + const parts = []; + for (const [group, rows] of groups) { + parts.push(`
${escapeHtml(group)}
`); + for (const r of rows) { + const tag = statusTagClass(r.status); + parts.push(` +
+ ${escapeHtml(capitalize(r.name))} + ${escapeHtml(r.tool_path || '—')} + ${escapeHtml(r.status)} +
+ `); + } + } + host.innerHTML = parts.join(''); +} + +// ---- Agents ------------------------------------------------------------- +function setDot(profile, kind, title) { + const el = document.getElementById(`dashAgentDot-${profile}`); + if (!el) return; + el.className = `lb-agent-dot lb-agent-dot--${kind}`; + if (title) el.title = title; +} + +function setTag(id, kind, text) { + const el = document.getElementById(id); + if (!el) return; + el.className = `lb-tag ${kind}`; + el.textContent = text; +} + +function applyAgentRow(agent) { + const p = agent.name; + const a = agent.agent || {}; + const e = agent.elastic || {}; + + const reachable = (a.reachable ? 1 : 0) + (e.reachable ? 1 : 0); + if (reachable === 2) setDot(p, 'ok', 'Agent + Elastic reachable'); + else if (reachable === 1) setDot(p, 'partial', 'Partial — see status fields'); + else setDot(p, 'down', 'Agent + Elastic unreachable'); + + if (a.reachable) { + const v = a.agent_version ? ` v${a.agent_version}` : ''; + setTag(`dashAgentSide-${p}`, 'low', `agent${v}`); + } else { + setTag(`dashAgentSide-${p}`, 'high', 'agent down'); + } + + if (e.reachable) { + const v = e.version ? ` v${e.version}` : ''; + setTag(`dashElasticSide-${p}`, 'low', `elastic${v}`); + } else { + setTag(`dashElasticSide-${p}`, 'high', 'elastic down'); + } +} + +// ---- Drive -------------------------------------------------------------- +async function refreshDashboard() { + if (_inFlight) return; + _inFlight = true; + const btn = document.getElementById('dashRefreshBtn'); + if (btn) btn.disabled = true; + + try { + const [scannersResp, agentsResp] = await Promise.all([ + fetch('/api/system/scanners', { cache: 'no-store' }), + fetch('/api/edr/agents/status', { cache: 'no-store' }), + ]); + + if (scannersResp.ok) { + renderScanners(await scannersResp.json()); + } else { + const host = document.getElementById('scannersTable'); + if (host) host.innerHTML = + `
Failed to load scanners (HTTP ${scannersResp.status}).
`; + } + + if (agentsResp.ok) { + const data = await agentsResp.json(); + for (const agent of (data.agents || [])) applyAgentRow(agent); + } + } catch (err) { + console.error('[dashboard] refresh failed:', err); + } finally { + _inFlight = false; + if (btn) btn.disabled = false; + } +} + +function capitalize(s) { + const str = String(s ?? ''); + return str ? str[0].toUpperCase() + str.slice(1) : ''; +} + +function escapeHtml(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); +} + +window.refreshDashboard = refreshDashboard; + +document.addEventListener('DOMContentLoaded', () => { + refreshDashboard(); + _refreshTimer = setInterval(refreshDashboard, REFRESH_MS); +}); + +window.addEventListener('beforeunload', () => { + if (_refreshTimer) clearInterval(_refreshTimer); +}); diff --git a/app/templates/base.html b/app/templates/base.html index a447c92..6c5a639 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -43,7 +43,14 @@
Analyse
- + + + + + Dashboard + + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..c410dc7 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} + +{% block breadcrumb %}Dashboard{% endblock %} + +{% set active_nav = 'dashboard' %} + +{% block content %} + + +
+ +
+

+ Live readout of LitterBox's pieces — local scanner binaries and remote EDR agents. + Auto-refreshes every minute; click Refresh for an immediate poll. +

+
+
+ + +
+ + +
+
+ Scanners + +
+
+

+ Configured analyzers. ok = enabled and binary on disk; + missing = enabled but tool path can't be resolved; + disabled = turned off in config. +

+
+
Loading…
+
+
+
+ + +
+
+ EDR Agents + {{ edr_profiles|length }} registered + Open Whiskers → +
+
+ {% if not edr_profiles %} +
+ No EDR profiles registered. Drop a YAML at + Config/edr_profiles/<name>.yml and restart. +
+ {% else %} +

+ Per-profile agent + Elastic reachability. Click a row to jump to the full Whiskers detail card. +

+
+ {% for profile in edr_profiles %} +
+ +
+
{{ profile.display_name }}
+
{{ profile.agent_url }}
+
+
+ agent — + elastic — +
+
+ {% endfor %} +
+ {% endif %} +
+
+ +
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/results.html b/app/templates/results.html index 13bc39d..4f15ccd 100644 --- a/app/templates/results.html +++ b/app/templates/results.html @@ -94,7 +94,7 @@ Run Static {% endif %} - +
diff --git a/app/templates/summary.html b/app/templates/summary.html index b58964b..21a0619 100644 --- a/app/templates/summary.html +++ b/app/templates/summary.html @@ -13,7 +13,7 @@
Results Summary
-