diff --git a/Whiskers/src/api/execute.rs b/Whiskers/src/api/execute.rs index cdf4391..b6f16bf 100644 --- a/Whiskers/src/api/execute.rs +++ b/Whiskers/src/api/execute.rs @@ -91,33 +91,69 @@ pub async fn exec( // Distinguish if we can; otherwise treat as generic write failure. tracing::error!(error = %err, "Failed to write payload"); let virus_signaled = is_likely_av_block(&err); - let resp = if virus_signaled { - ExecResponse { + if virus_signaled { + // 200 OK — the agent successfully detected an AV intercept. + // It's a real outcome of the run, not a transport-level error. + return Ok(Json(ExecResponse { status: "virus", pid: None, message: Some(format!("Antivirus blocked write: {err}")), - } - } else { - ExecResponse { - status: "error", - pid: None, - message: Some(format!("Failed to write file: {err}")), - } - }; - return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(resp))); + })); + } + return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ExecResponse { + status: "error", + pid: None, + message: Some(format!("Failed to write file: {err}")), + }))); } // Kill any previous run that's still hanging around (defensive — orchestrator // should have called kill, but if it didn't, don't leave orphans). take_previous_run_for_cleanup(&state).await; - // Spawn the payload. - let mut command = Command::new(&file_path); - if let Some(args) = form.executable_args.as_ref() { - if !args.is_empty() { - command.args(parse_args(args)); + // Spawn the payload. Windows can't directly exec a `.dll` — those need + // to go through `rundll32.exe , [args...]`. The + // entry point is required and comes from `executable_args`; if it's + // missing we bail with a clear error instead of letting Windows + // return ERROR_BAD_EXE_FORMAT. + let is_dll = file_path + .extension() + .and_then(|s| s.to_str()) + .map(|s| s.eq_ignore_ascii_case("dll")) + .unwrap_or(false); + let executable_args = form.executable_args.as_deref().unwrap_or("").trim(); + + let mut command = if is_dll { + if executable_args.is_empty() { + tracing::error!("DLL spawn rejected: no entry point provided in executable_args"); + let _ = tokio::fs::remove_file(&file_path).await; + return Err((StatusCode::BAD_REQUEST, Json(ExecResponse { + status: "error", + pid: None, + message: Some( + "DLL execution requires an entry point in executable_args \ + (rundll32 syntax: [args...])".into(), + ), + }))); } - } + // Split the args: the first token is the export name (becomes + // `,`), everything after is forwarded to rundll32. + let mut tokens = executable_args.split_whitespace(); + let entry = tokens.next().unwrap(); // checked non-empty above + let rest: Vec<&str> = tokens.collect(); + let dll_target = format!("{},{}", file_path.display(), entry); + tracing::info!(dll = %file_path.display(), entry, "Spawning DLL via rundll32"); + let mut c = Command::new("rundll32.exe"); + c.arg(dll_target); + for r in rest { c.arg(r); } + c + } else { + let mut c = Command::new(&file_path); + if !executable_args.is_empty() { + c.args(parse_args(executable_args)); + } + c + }; command .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -132,20 +168,18 @@ pub async fn exec( let virus_signaled = is_likely_av_block(&err); // Best-effort cleanup of the dropper. let _ = tokio::fs::remove_file(&file_path).await; - let resp = if virus_signaled { - ExecResponse { + if virus_signaled { + return Ok(Json(ExecResponse { status: "virus", pid: None, message: Some(format!("Antivirus blocked spawn: {err}")), - } - } else { - ExecResponse { - status: "error", - pid: None, - message: Some(format!("Failed to spawn process: {err}")), - } - }; - return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(resp))); + })); + } + return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ExecResponse { + status: "error", + pid: None, + message: Some(format!("Failed to spawn process: {err}")), + }))); } }; diff --git a/app/analyzers/edr/elastic_client.py b/app/analyzers/edr/elastic_client.py index 92f35a7..d6ed450 100644 --- a/app/analyzers/edr/elastic_client.py +++ b/app/analyzers/edr/elastic_client.py @@ -166,6 +166,14 @@ class ElasticClient: # Match alerts touching THIS specific payload across all the # places Elastic Defend records the filename. The MD5 prefix # in the filename ensures uniqueness across uploads. + # + # The command-line / args matches are critical for DLL + # payloads spawned via rundll32: the running process is + # rundll32.exe (so file.name / process.name / file.path / + # process.executable all point at the system rundll32), + # and the DLL's path only appears inside the command line + # (`rundll32.exe ,`). Same pattern for any + # other launcher-hosted payload. filters.append({"bool": { "minimum_should_match": 1, "should": [ @@ -185,6 +193,14 @@ class ElasticClient: "value": f"*{file_name}", "case_insensitive": True, }}}, + {"wildcard": {"process.command_line": { + "value": f"*{file_name}*", + "case_insensitive": True, + }}}, + {"wildcard": {"process.args": { + "value": f"*{file_name}*", + "case_insensitive": True, + }}}, ], }}) diff --git a/app/analyzers/edr/elastic_edr_analyzer.py b/app/analyzers/edr/elastic_edr_analyzer.py index 723b2e7..cfdc257 100644 --- a/app/analyzers/edr/elastic_edr_analyzer.py +++ b/app/analyzers/edr/elastic_edr_analyzer.py @@ -4,7 +4,7 @@ Per-payload flow (matches ROADMAP.md Phase L4): 1. AgentClient.get_info -> learn the EDR VM's hostname 2. AgentClient.lock_acquire - 3. AgentClient.exec(payload, args) + 3. AgentClient.exec(payload, args) -- XOR-encoded on the wire 4. wait for exec to exit OR exec_timeout_seconds 5. AgentClient.kill (idempotent if already exited) 6. AgentClient.get_execution_logs -> stdout/stderr/exit_code @@ -17,10 +17,19 @@ Failure model: if the agent is unreachable in step 1, mark the profile unavailable and bail. If anything between lock_acquire and lock_release fails, the lock is released in `finally` so the agent doesn't get stuck in a busy state. + +XOR-on-the-wire: every dispatch picks a random byte 0-255, XORs the +payload with it before multipart upload, and tells Whiskers to reverse +the XOR while writing byte-by-byte. This avoids leaving an unencrypted +copy of the payload in HTTP buffers / OS network stacks / the agent's +request memory — Defender's network inspection on the EDR VM otherwise +sees the cleartext payload before WriteAsync runs and can flag it +before our multipart parser even completes. """ import logging import os +import secrets import time from datetime import datetime, timezone from typing import List, Optional @@ -241,20 +250,27 @@ class ElasticEdrAnalyzer(BaseAnalyzer): # Build the phase-1 snapshot the UI shows immediately. It's a # complete EDR-result dict with status="polling_alerts" and an # empty alerts array; Phase 2 will overwrite it on disk when the - # alerts arrive. + # alerts arrive. The AV-block-vs-clean-exec distinction lives in + # `summary.blocked_by_av` — we don't fork the polling status for + # that because the polling itself happens entirely between + # LitterBox and Elastic, regardless of what the EDR VM did. kind = exec_outcome["kind"] is_blocked = (kind == "virus") - phase_1_status = "blocked_polling_alerts" if is_blocked else "polling_alerts" + phase_1_status = "polling_alerts" max_wait = ( self.profile.av_block_wait_seconds if is_blocked else self.profile.wait_seconds_for_alerts ) exec_logs = exec_outcome.get("exec_logs", {}) # For the AV-block path the process never ran, so kill - # classification doesn't apply. For successful spawns we already - # know if the EDR terminated it externally — surface it now so - # the operator doesn't have to wait for Phase 2 to see it. - killed_by_edr = False if is_blocked else self._classify_kill(exec_logs) + # classification doesn't apply. For .exe successful spawns we can + # rely on the heuristic in Phase 1; for .dll spawns the heuristic + # produces false positives (rundll32 exits 1 on bad-export, etc.) + # so we defer to Phase 2 which has alert evidence. + killed_by_edr = ( + False if is_blocked + else self._classify_kill(exec_logs, filename=filename) + ) raw_exec_status = exec_logs.get("status") exec_status_label = ( "virus" if is_blocked @@ -284,7 +300,6 @@ class ElasticEdrAnalyzer(BaseAnalyzer): "run_start": run_start.isoformat(), "run_end": None, "wait_seconds_for_alerts": max_wait, - "phase": phase_1_status, "blocked_by_av": is_blocked, }, } @@ -321,13 +336,22 @@ class ElasticEdrAnalyzer(BaseAnalyzer): alerts fire in real-time — we don't need to hold the lock through the post-exec wait window just to query Elastic afterwards. """ - # Step 3 — exec. + # Step 3 — exec. The payload travels XOR-encoded with a random + # per-dispatch byte; Whiskers reverses the XOR byte-by-byte while + # writing to disk. Avoids cleartext payload sitting in HTTP buffers + # where Defender's network inspection might flag it pre-write. + # `bytes.translate` is C-implemented; a generator over file_bytes + # would take seconds on a 12 MB sample. + xor_key = secrets.randbelow(256) + xor_table = bytes(b ^ xor_key for b in range(256)) + xored = file_bytes.translate(xor_table) try: exec_resp = self.agent.exec( - file_bytes=file_bytes, + file_bytes=xored, filename=filename, drop_path=self.profile.drop_path, executable_args=executable_args, + xor_key=xor_key, ) except AgentUnreachable as exc: return {**self._unreachable_result(exc), "_final": True} @@ -444,6 +468,7 @@ class ElasticEdrAnalyzer(BaseAnalyzer): return self._success_result( agent_info, outcome["exec_logs"], outcome["pid"], run_start, run_end, hostname, alerts, + file_name=file_name, ) def _poll_alerts( @@ -526,24 +551,40 @@ class ElasticEdrAnalyzer(BaseAnalyzer): time.sleep(poll_interval) @classmethod - def _classify_kill(cls, exec_logs: dict) -> bool: + def _classify_kill( + cls, + exec_logs: dict, + *, + filename: Optional[str] = None, + alerts: Optional[list] = None, + ) -> bool: """If Whiskers didn't issue a kill and the process didn't exit - cleanly, something external terminated it. In a LitterBox dispatch - the EDR is the only thing on the VM that does that — so we label - it as a behavior-protection kill. + cleanly, something external terminated it. For .exe payloads on + an EDR-instrumented host, "something external" is overwhelmingly + the EDR — the heuristic alone is reliable. - Edge case: a payload that crashes on its own with a non-zero exit - code will get the same label. That's an acceptable false positive - — when you're running suspect binaries on an EDR-instrumented host, - a non-zero exit you didn't ask for is overwhelmingly the EDR. + For .dll payloads spawned via rundll32, exit_code != 0 is much + noisier: GetProcAddress failure on a missing export, DllMain + returning FALSE, even a malformed DLL all produce non-zero exits + with no EDR involvement. So for DLLs we additionally require at + least one Elastic alert as evidence before flagging killed_by_edr. + + Phase 1 has no alerts yet, so for DLLs Phase 1 always returns + False; Phase 2 re-evaluates after correlation and may flip True. """ raw_status = (exec_logs.get("status") or "").lower() if raw_status == "killed": - # The agent issued the kill itself (orchestrator timeout). Not - # an external termination. + # The agent issued the kill itself (orchestrator timeout). return False exit_code = exec_logs.get("exit_code") - return exit_code not in (0, None) + if exit_code in (0, None): + return False + + is_dll = bool(filename and filename.lower().endswith(".dll")) + if is_dll: + # DLL exit codes are unreliable — require alert evidence. + return bool(alerts) + return True def _success_result( self, @@ -554,10 +595,15 @@ class ElasticEdrAnalyzer(BaseAnalyzer): run_end: datetime, hostname: str, alerts: List[Alert], + file_name: Optional[str] = None, ) -> dict: alert_dicts = [a.to_dict() for a in alerts] high_severity_count = sum(1 for a in alerts if a.severity in HIGH_SEVERITY) - killed_by_edr = self._classify_kill(exec_logs) + # Phase 2 has alerts in hand — feed them in so the .dll-via-rundll32 + # case can confirm killed_by_edr only when alerts back it up. + killed_by_edr = self._classify_kill( + exec_logs, filename=file_name, alerts=alerts, + ) # Surface "killed_by_edr" as the user-facing exec_status when we # have evidence — the raw "exited" label is technically right but # actively misleading when behavior protection was the cause. @@ -584,6 +630,7 @@ class ElasticEdrAnalyzer(BaseAnalyzer): "total_alerts": len(alert_dicts), "high_severity_alerts": high_severity_count, "killed_by_edr": killed_by_edr, + "blocked_by_av": False, "run_start": run_start.isoformat(), "run_end": run_end.isoformat(), "wait_seconds_for_alerts": self.profile.wait_seconds_for_alerts, diff --git a/app/analyzers/edr/registry.py b/app/analyzers/edr/registry.py index 219bd71..e2a435d 100644 --- a/app/analyzers/edr/registry.py +++ b/app/analyzers/edr/registry.py @@ -93,6 +93,7 @@ def dispatch_split( payload_path: str, config: dict, on_phase_2_done: Callable[[dict], None], + executable_args: Optional[str] = None, ) -> dict: """Split-phase dispatch. @@ -103,6 +104,10 @@ def dispatch_split( is called with the final findings dict. The callback is responsible for persisting the updated result. + `executable_args` is forwarded to the agent's exec endpoint as a + single space-separated string. For DLL payloads the first token is + the exported entry point (rundll32 wraps it server-side). + Phase 2 errors are swallowed and surfaced to the callback as a `status: 'error'` dict — the thread never raises into nothing. """ @@ -111,7 +116,7 @@ def dispatch_split( raise KeyError(f"unknown EDR profile: {profile_name!r}") analyzer = ElasticEdrAnalyzer(config, profile) - phase_1, continuation = analyzer.run_exec(payload_path) + phase_1, continuation = analyzer.run_exec(payload_path, executable_args) if continuation is None: # Terminal failure (busy, agent unreachable, missing file, etc.) — diff --git a/app/analyzers/manager.py b/app/analyzers/manager.py index 480b6a7..237159a 100644 --- a/app/analyzers/manager.py +++ b/app/analyzers/manager.py @@ -251,7 +251,14 @@ class AnalysisManager: self.logger.debug("Initializing RedEdr analyzer") try: - target_name = target.split('\\')[-1] + # For DLL targets we spawn `rundll32.exe ,` — the + # actual running process is rundll32, not the DLL. RedEdr's + # --trace filter takes a process name, so point it at + # rundll32.exe to capture ETW from the DLL's host process. + if target.lower().endswith('.dll'): + target_name = 'rundll32.exe' + else: + target_name = target.split('\\')[-1] rededr = RedEdrAnalyzer(self.config) if not rededr.start_tool(target_name): self.logger.error("Failed to start RedEdr") @@ -389,12 +396,28 @@ class AnalysisManager: raise Exception(f"Invalid or non-existent PID: {e}") def _create_new_process(self, target: str, cmd_args: list) -> Tuple[subprocess.Popen, int]: - """Create and validate new process""" - command = [target] - if cmd_args: - command.extend(cmd_args) - - self.logger.debug(f"Starting new process: {command}") + """Create and validate new process. DLL targets are wrapped with + rundll32.exe — Windows can't directly Popen a .dll, and the + operator's first cmd_arg is treated as the exported entry point + (mandatory for DLLs).""" + if target.lower().endswith('.dll'): + if not cmd_args: + raise Exception( + "DLL execution requires an entry point as the first " + "command-line argument (rundll32 syntax: " + "[args...])" + ) + entry, *extra = cmd_args + # rundll32.exe expects: , [args...] + # The dll path and entry name are joined with a comma into a + # single argv slot so rundll32 parses them as one target spec. + command = ['rundll32.exe', f'{target},{entry}', *extra] + self.logger.debug(f"DLL target — wrapping with rundll32: {command}") + else: + command = [target] + if cmd_args: + command.extend(cmd_args) + self.logger.debug(f"Starting new process: {command}") startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW diff --git a/app/blueprints/analysis.py b/app/blueprints/analysis.py index d1ffee4..52851ce 100644 --- a/app/blueprints/analysis.py +++ b/app/blueprints/analysis.py @@ -166,6 +166,18 @@ def _handle_analysis_results(results, result_path, results_filename): return jsonify({'status': 'success', 'results': results}) +@analysis_bp.route('/whiskers', methods=['GET']) +def whiskers_page(): + """Render the Whiskers (EDR agents) inventory page. Live status data + is fetched async by the page's JS via /api/edr/agents/status.""" + deps = current_app.extensions['litterbox'] + return render_template( + 'agents.html', + config=current_app.config, + edr_profiles=deps.edr_registry.list_profiles(), + ) + + @analysis_bp.route('/analyze/edr//', methods=['GET', 'POST']) @error_handler def analyze_edr(profile, target): @@ -205,7 +217,17 @@ def analyze_edr(profile, target): app.logger.warning(f"Result path not found for hash: {target}") return jsonify({'error': 'Result path not found'}), 404 - app.logger.debug(f"Dispatching to EDR profile {profile!r} with payload {file_path}") + # Pull cmd args from the POST body (validated/sanitized like the + # dynamic-analysis route does) and join into the single string + # AgentClient.exec expects. For DLL targets the first token is the + # exported entry point — Whiskers wraps with rundll32 server-side. + cmd_args = _extract_and_validate_args(request, app.logger) + executable_args = ' '.join(cmd_args) if cmd_args else None + + app.logger.debug( + f"Dispatching to EDR profile {profile!r} with payload {file_path} " + f"args={executable_args!r}" + ) results_filename = f'edr_{profile}_results.json' # Phase 2 callback — runs on a background thread when alerts arrive. @@ -232,7 +254,8 @@ def analyze_edr(profile, target): try: results = deps.edr_registry.dispatch_split( - profile, file_path, app.config, _on_phase_2_done + profile, file_path, app.config, _on_phase_2_done, + executable_args=executable_args, ) except Exception as e: app.logger.error(f"EDR dispatch failed: {e}", exc_info=True) diff --git a/app/blueprints/api.py b/app/blueprints/api.py index 2b04525..789fe4d 100644 --- a/app/blueprints/api.py +++ b/app/blueprints/api.py @@ -111,6 +111,80 @@ def api_edr_profiles(): return jsonify({'profiles': deps.edr_registry.list_profiles()}) +@api_bp.route('/api/edr/agents/status', methods=['GET']) +@error_handler +def api_edr_agents_status(): + """Live probe of every registered EDR profile. + + For each profile we hit the agent's /api/info + /api/lock/status and + the Elastic stack's `GET /` ping in parallel. Each probe is bounded + by a tight timeout so an unreachable agent doesn't stall the whole + page. Used by /whiskers to render the inventory + live status. + """ + from concurrent.futures import ThreadPoolExecutor + from ..analyzers.edr.agent_client import AgentClient, AgentError, AgentUnreachable + from ..analyzers.edr.elastic_client import ElasticClient, ElasticError, ElasticUnreachable + + deps = _deps() + profiles = list(deps.edr_registry._PROFILES.values()) # internal accessor — same module + + def probe(p): + agent = AgentClient(p.agent_url, timeout=4) + elastic = ElasticClient( + p.elastic_url, p.elastic_apikey, + verify_tls=p.elastic_verify_tls, timeout=5, + ) + agent_info, agent_err, lock = None, None, None + try: + agent_info = agent.get_info() + try: + lock = agent.lock_status() + except (AgentUnreachable, AgentError): + pass + except AgentUnreachable as e: + agent_err = f"unreachable: {e}" + except AgentError as e: + agent_err = f"error: {e}" + + elastic_info, elastic_err = None, None + try: + elastic_info = elastic.ping() + except ElasticUnreachable as e: + elastic_err = f"unreachable: {e}" + except ElasticError as e: + elastic_err = f"error: {e}" + + return { + "name": p.name, + "display_name": p.display_name, + "type": "elastic-defend", + "agent_url": p.agent_url, + "elastic_url": p.elastic_url, + "agent": { + "reachable": agent_info is not None, + "error": agent_err, + "hostname": (agent_info or {}).get("hostname"), + "os_version": (agent_info or {}).get("os_version"), + "agent_version": (agent_info or {}).get("agent_version"), + }, + "lock": lock, + "elastic": { + "reachable": elastic_info is not None, + "error": elastic_err, + "cluster_name": (elastic_info or {}).get("cluster_name"), + "version": ((elastic_info or {}).get("version") or {}).get("number"), + }, + } + + if not profiles: + return jsonify({"agents": []}) + + # Probe in parallel — total wall time is the slowest probe, not sum of all. + with ThreadPoolExecutor(max_workers=min(8, len(profiles))) as pool: + results = list(pool.map(probe, profiles)) + return jsonify({"agents": results}) + + @api_bp.route('/api/results//edr/', methods=['GET']) @error_handler def api_edr_results(target, profile): diff --git a/app/static/js/agents/core.js b/app/static/js/agents/core.js new file mode 100644 index 0000000..290f7be --- /dev/null +++ b/app/static/js/agents/core.js @@ -0,0 +1,127 @@ +// app/static/js/agents/core.js +// +// Drives the /agents inventory page. Hits /api/edr/agents/status to probe +// each registered EDR profile in parallel (server-side ThreadPool), then +// fills the per-card status fields. Refreshes every 15s; the manual +// Refresh button forces an immediate poll. + +const REFRESH_MS = 60000; +let _refreshTimer = null; +let _inFlight = false; + +function setText(id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; +} + +function setColor(id, color) { + const el = document.getElementById(id); + if (el) el.style.color = color; +} + +function setDot(profile, kind /* 'ok' | 'down' | 'partial' | 'unknown' */, title) { + const el = document.getElementById(`agentDot-${profile}`); + if (!el) return; + el.className = `lb-agent-dot lb-agent-dot--${kind}`; + if (title) el.title = title; +} + +function applyStatus(agent) { + const p = agent.name; + + // Type badge — for now everything is `elastic-defend`, but the field + // is server-driven so future agent types render automatically. + setText(`agentType-${p}`, agent.type || 'unknown'); + + const a = agent.agent || {}; + const e = agent.elastic || {}; + + // Aggregate dot: green if both sides reachable, yellow if only one, + // red if neither. + 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'); + + // Agent side + if (a.reachable) { + setText(`agentStatus-${p}`, 'Online'); + setColor(`agentStatus-${p}`, 'var(--lb-sev-low)'); + setText(`agentHostname-${p}`, a.hostname || '—'); + setText(`agentOs-${p}`, a.os_version || '—'); + setText(`agentVersion-${p}`, a.agent_version || '—'); + } else { + setText(`agentStatus-${p}`, 'Offline'); + setColor(`agentStatus-${p}`, 'var(--lb-accent-soft)'); + setText(`agentHostname-${p}`, '—'); + setText(`agentOs-${p}`, '—'); + setText(`agentVersion-${p}`, '—'); + } + + // Lock + if (agent.lock) { + const inUse = !!agent.lock.in_use; + setText(`agentLock-${p}`, inUse ? 'Busy (run in progress)' : 'Idle'); + setColor(`agentLock-${p}`, inUse ? 'var(--lb-sev-medium)' : 'var(--lb-sev-low)'); + } else { + setText(`agentLock-${p}`, '—'); + setColor(`agentLock-${p}`, 'var(--lb-text-mute)'); + } + + // Elastic side + if (e.reachable) { + const v = e.version ? ` v${e.version}` : ''; + setText(`agentElastic-${p}`, `Reachable${v}`); + setColor(`agentElastic-${p}`, 'var(--lb-sev-low)'); + setText(`agentCluster-${p}`, e.cluster_name || '—'); + } else { + setText(`agentElastic-${p}`, 'Unreachable'); + setColor(`agentElastic-${p}`, 'var(--lb-accent-soft)'); + setText(`agentCluster-${p}`, '—'); + } + + // Combined error block (shows if anything failed) + const errEl = document.getElementById(`agentError-${p}`); + if (errEl) { + const errs = []; + if (a.error) errs.push(`Agent: ${a.error}`); + if (e.error) errs.push(`Elastic: ${e.error}`); + if (errs.length) { + errEl.textContent = errs.join(' · '); + errEl.classList.remove('hidden'); + } else { + errEl.classList.add('hidden'); + } + } +} + +async function refreshAgents() { + if (_inFlight) return; + _inFlight = true; + const btn = document.getElementById('agentsRefreshBtn'); + if (btn) btn.disabled = true; + + try { + const resp = await fetch('/api/edr/agents/status', { cache: 'no-store' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + for (const agent of (data.agents || [])) applyStatus(agent); + } catch (err) { + console.error('[agents] refresh failed:', err); + } finally { + _inFlight = false; + if (btn) btn.disabled = false; + } +} + +// Expose for the inline onclick on the Refresh button. +window.refreshAgents = refreshAgents; + +document.addEventListener('DOMContentLoaded', () => { + refreshAgents(); + _refreshTimer = setInterval(refreshAgents, REFRESH_MS); +}); + +window.addEventListener('beforeunload', () => { + if (_refreshTimer) clearInterval(_refreshTimer); +}); diff --git a/app/static/js/results/core.js b/app/static/js/results/core.js index 59140dc..d3e93d1 100644 --- a/app/static/js/results/core.js +++ b/app/static/js/results/core.js @@ -124,10 +124,8 @@ class AnalysisCore { // the duration timer running and don't flip the status to // "Analysis completed". The EDR poll handler will stop the // timer + flip status when Phase 2 lands a terminal result. - const edrPolling = !!(data.results && data.results.edr && ( - data.results.edr.status === 'polling_alerts' || - data.results.edr.status === 'blocked_polling_alerts' - )); + const edrPolling = !!(data.results && data.results.edr && + data.results.edr.status === 'polling_alerts'); this.updateTimer(); if (!edrPolling) { diff --git a/app/static/js/results/tools/edr.js b/app/static/js/results/tools/edr.js index da3c25d..a7fc177 100644 --- a/app/static/js/results/tools/edr.js +++ b/app/static/js/results/tools/edr.js @@ -4,15 +4,18 @@ // // The orchestrator runs in two phases: // Phase 1 (exec) — sync over HTTP; returns when the agent finishes -// spawning the payload (success or AV block) +// spawning the payload (success or EDR block) // Phase 2 (correlation) — async on the server (background thread); // polls Elastic for alerts, overwrites the saved // findings JSON when done. // // On the initial POST response we render whatever Phase 1 produced. If -// the status is `polling_alerts` or `blocked_polling_alerts`, we kick -// off a foreground poll of GET /api/results//edr/ so the -// alerts pane and summary chip reflect Phase 2 progress in real time. +// the status is `polling_alerts`, we kick off a foreground poll of GET +// /api/results//edr/ so the alerts pane and summary chip +// reflect Phase 2 progress in real time. The block-vs-clean-exec +// distinction is carried in `summary.blocked_by_av` — Phase 2 doesn't +// fork its status on it because the polling itself is purely between +// LitterBox and Elastic, regardless of what the EDR VM did. // // Targets: // - #edrSummary (summary tab) @@ -25,7 +28,7 @@ import { errorPanel, cleanState, threatState, statRow, panel, kvGrid, codeBlock, import summaryTool from './summary.js'; const HIGH_SEVERITY = new Set(['high', 'critical']); -const POLLING_STATUSES = new Set(['polling_alerts', 'blocked_polling_alerts']); +const POLLING_STATUS = 'polling_alerts'; const POLL_INTERVAL_MS = 3000; // Module-level handle so a re-render with a new payload aborts the old @@ -40,7 +43,7 @@ function clearPoll() { } function isPolling(results) { - return POLLING_STATUSES.has(results?.status); + return results?.status === POLLING_STATUS; } function severityRank(s) { @@ -110,8 +113,7 @@ function renderAlerts(results) { // the stat chip stays the same width as the numeric ones beside it. const STATUS_LABELS = { 'completed': 'Completed', - 'blocked_by_av': 'AV Block', - 'blocked_polling_alerts': 'AV Block', + 'blocked_by_av': 'EDR Block', 'polling_alerts': 'Polling…', 'partial': 'Partial', 'busy': 'Busy', @@ -122,7 +124,7 @@ function renderAlerts(results) { const statusSeverity = ( status === 'completed' && totalAlerts === 0 ? 'clean' : status === 'completed' ? 'critical' : - status === 'blocked_by_av' || status === 'blocked_polling_alerts' ? 'critical' : + status === 'blocked_by_av' ? 'critical' : status === 'polling_alerts' ? 'info' : status === 'partial' ? 'medium' : 'critical' @@ -158,8 +160,8 @@ function renderAlerts(results) { // Phase 2 in flight on the server — show a busy indicator in the // alerts pane until the next poll updates this state. const max = summary.wait_seconds_for_alerts || '?'; - const blockedHint = status === 'blocked_polling_alerts' - ? ' The AV blocked the spawn; we are correlating against the prevention alert.' + const blockedHint = summary.blocked_by_av + ? ' The EDR blocked the spawn; we are correlating against the prevention alert.' : ''; target.innerHTML = `
diff --git a/app/static/js/results/tools/summary.js b/app/static/js/results/tools/summary.js index 832e8ae..c840f00 100644 --- a/app/static/js/results/tools/summary.js +++ b/app/static/js/results/tools/summary.js @@ -159,10 +159,10 @@ export default { else if (status === 'busy') { detail = 'Agent busy with another run'; isFailureState = true; } else if (status === 'partial') { detail = 'Run completed but Elastic query failed'; isFailureState = true; } else if (status === 'error') { detail = `Error: ${r.error || 'unknown'}`; isFailureState = true; } + else if (status === 'polling_alerts' && summary.blocked_by_av) { detail = 'EDR blocked spawn — correlating alerts…'; isPolling = true; } else if (status === 'polling_alerts' && killedByEdr) { detail = 'Killed by EDR — correlating alerts…'; isPolling = true; } else if (status === 'polling_alerts') { detail = 'Exec finished — correlating alerts…'; isPolling = true; } - else if (status === 'blocked_polling_alerts'){ detail = 'AV blocked spawn — correlating alerts…'; isPolling = true; } - else if (status === 'blocked_by_av') detail = 'Blocked by AV before execution'; + else if (status === 'blocked_by_av') detail = 'Blocked by EDR before execution'; else if (killedByEdr && totalAlerts > 0) detail = `Killed by EDR · ${totalAlerts} alert${totalAlerts === 1 ? '' : 's'} raised`; else if (totalAlerts > 0) detail = `${totalAlerts} alert${totalAlerts === 1 ? '' : 's'} raised`; else detail = 'No alerts raised'; @@ -178,10 +178,7 @@ export default { // poll window haven't finished correlating yet — show that // explicitly instead of a misleading "Clean" green when the // count happens to be 0. - const edrPolling = !!(results.edr && ( - results.edr.status === 'polling_alerts' || - results.edr.status === 'blocked_polling_alerts' - )); + const edrPolling = !!(results.edr && results.edr.status === 'polling_alerts'); const totalEl = document.getElementById('totalDetections'); const overEl = document.getElementById('overallStatus'); if (totalEl) totalEl.textContent = totalDetections; diff --git a/app/static/js/upload/core.js b/app/static/js/upload/core.js index a5c30c4..2b4ec4b 100644 --- a/app/static/js/upload/core.js +++ b/app/static/js/upload/core.js @@ -668,9 +668,15 @@ document.addEventListener('DOMContentLoaded', function() { // Navigate to dynamic analysis window.location.href = `/analyze/${type}/${currentFileHash}`; } else if (type.startsWith('edr:')) { - // EDR profile dispatch: type is "edr:" - // Server-side route is /analyze/edr//. + // EDR profile dispatch: type is "edr:". + // Each profile body has its own args input (id = + // edrArgs-); read it, persist to localStorage so + // the results page's POST forwards it to Whiskers. const profile = type.slice(4); + const argsInput = document.getElementById(`edrArgs-${profile}`); + const argsValue = argsInput ? argsInput.value : ''; + const args = argsValue.split(' ').filter(arg => arg.trim() !== ''); + localStorage.setItem('analysisArgs', JSON.stringify(args)); window.location.href = `/analyze/edr/${encodeURIComponent(profile)}/${currentFileHash}`; } else { // Navigate to static analysis diff --git a/app/templates/agents.html b/app/templates/agents.html new file mode 100644 index 0000000..7fef981 --- /dev/null +++ b/app/templates/agents.html @@ -0,0 +1,159 @@ +{% extends "base.html" %} + +{% block breadcrumb %}Whiskers{% endblock %} + +{% set active_nav = 'whiskers' %} + +{% block content %} + +{% if not edr_profiles %} + +
+
Agents
+
+
+
No EDR profiles registered
+
+ Drop a YAML at Config/edr_profiles/<name>.yml + (copy from elastic.yml.example) and restart LitterBox. + See the README.md "Elastic EDR Setup" section + for the full deployment guide. +
+
+
+
+{% else %} + +
+
+ EDR Agents + {{ edr_profiles|length }} registered + +
+
+

+ One card per profile registered under + Config/edr_profiles/. The Whiskers agent + Elastic stack + are probed in parallel; status refreshes every minute. +

+
+
+ +
+ {% for profile in edr_profiles %} +
+
+
+ + {{ profile.display_name }} + EDR Agent +
+
+ elastic-defend +
+
+
+
+ Status + Probing… +
+
+ Hostname + +
+
+ OS + +
+
+ Agent Version + +
+
+ Lock + +
+
+ Agent URL + {{ profile.agent_url }} +
+
+
+ Elastic + Probing… +
+
+ Cluster + +
+
+ Elastic URL + {{ profile.elastic_url }} +
+ +
+
+ {% endfor %} +
+ + + +{% endif %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 75cb910..a447c92 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -89,6 +89,14 @@ Summary + + + + + + Whiskers + +