EDR: XOR-on-the-wire, DLL via rundll32, Whiskers page, kill-detection fix

This commit is contained in:
BlackSnufkin
2026-04-30 01:27:51 -07:00
parent 36eab29536
commit 966d14104c
15 changed files with 609 additions and 84 deletions
+62 -28
View File
@@ -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 <dll>,<entry-point> [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: <ExportedFunction> [args...])".into(),
),
})));
}
}
// Split the args: the first token is the export name (becomes
// `<dll>,<export>`), 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}")),
})));
}
};
+16
View File
@@ -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 <dll-path>,<entry>`). 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,
}}},
],
}})
+69 -22
View File
@@ -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,
+6 -1
View File
@@ -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.) —
+30 -7
View File
@@ -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 <dll>,<entry>` — 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: <ExportName> "
"[args...])"
)
entry, *extra = cmd_args
# rundll32.exe expects: <dll>,<entry> [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
+25 -2
View File
@@ -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/<profile>/<target>', 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)
+74
View File
@@ -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/<target>/edr/<profile>', methods=['GET'])
@error_handler
def api_edr_results(target, profile):
+127
View File
@@ -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);
});
+2 -4
View File
@@ -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) {
+13 -11
View File
@@ -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/<hash>/edr/<profile> 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/<hash>/edr/<profile> 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 = `
<div class="lb-empty" style="flex-direction: column; padding: 24px 16px; gap: 8px; align-items: flex-start;">
+3 -6
View File
@@ -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;
+8 -2
View File
@@ -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:<profile_name>"
// Server-side route is /analyze/edr/<profile>/<hash>.
// EDR profile dispatch: type is "edr:<profile_name>".
// Each profile body has its own args input (id =
// edrArgs-<profile>); 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
+159
View File
@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block breadcrumb %}Whiskers{% endblock %}
{% set active_nav = 'whiskers' %}
{% block content %}
{% if not edr_profiles %}
<!-- No profiles registered — point the operator at the docs. -->
<div class="lb-panel">
<div class="lb-panel-hdr"><span class="lb-glyph"></span>Agents</div>
<div class="lb-panel-body">
<div class="lb-empty" style="flex-direction: column; padding: 24px; gap: 8px;">
<div class="lb-strong">No EDR profiles registered</div>
<div class="lb-muted" style="font-size: 12px; text-align: center; max-width: 600px;">
Drop a YAML at <span class="lb-mono">Config/edr_profiles/&lt;name&gt;.yml</span>
(copy from <span class="lb-mono">elastic.yml.example</span>) and restart LitterBox.
See the <span class="lb-mono">README.md</span> "Elastic EDR Setup" section
for the full deployment guide.
</div>
</div>
</div>
</div>
{% else %}
<div class="lb-panel">
<div class="lb-panel-hdr">
<span class="lb-glyph"></span>EDR Agents
<span id="agentsCount" class="lb-tag muted" style="margin-left: 12px;">{{ edr_profiles|length }} registered</span>
<button id="agentsRefreshBtn" onclick="refreshAgents()" class="lb-btn lb-btn-ghost"
style="margin-left: auto; padding: 2px 10px; font-size: 12px;">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M4 4v6h6M20 20v-6h-6M4 10a8 8 0 0114-5M20 14a8 8 0 01-14 5"/>
</svg>
Refresh
</button>
</div>
<div class="lb-panel-body">
<p class="lb-muted" style="font-size: 12px; margin: 0 0 4px 0;">
One card per profile registered under
<span class="lb-mono">Config/edr_profiles/</span>. The Whiskers agent + Elastic stack
are probed in parallel; status refreshes every minute.
</p>
</div>
</div>
<div id="agentsGrid" class="lb-grid-2" style="margin-top: 12px;">
{% for profile in edr_profiles %}
<div class="lb-agent-card" data-profile="{{ profile.name }}">
<div class="lb-agent-card-hdr">
<div class="lb-agent-card-title">
<span id="agentDot-{{ profile.name }}" class="lb-agent-dot lb-agent-dot--unknown" title="Probing…"></span>
<span class="lb-strong" style="font-size: 14px;">{{ profile.display_name }}</span>
<span class="lb-tag info" style="margin-left: 8px;">EDR Agent</span>
</div>
<div class="lb-muted" style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em;">
<span id="agentType-{{ profile.name }}">elastic-defend</span>
</div>
</div>
<div class="lb-agent-card-body">
<div class="lb-agent-row">
<span class="lb-eyebrow">Status</span>
<span id="agentStatus-{{ profile.name }}" class="lb-muted">Probing…</span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Hostname</span>
<span id="agentHostname-{{ profile.name }}" class="lb-mono lb-muted"></span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">OS</span>
<span id="agentOs-{{ profile.name }}" class="lb-mono lb-muted"></span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Agent Version</span>
<span id="agentVersion-{{ profile.name }}" class="lb-mono lb-muted"></span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Lock</span>
<span id="agentLock-{{ profile.name }}" class="lb-muted"></span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Agent URL</span>
<span class="lb-mono" style="font-size: 11px;">{{ profile.agent_url }}</span>
</div>
<div class="lb-agent-divider"></div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Elastic</span>
<span id="agentElastic-{{ profile.name }}" class="lb-muted">Probing…</span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Cluster</span>
<span id="agentCluster-{{ profile.name }}" class="lb-mono lb-muted"></span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Elastic URL</span>
<span class="lb-mono" style="font-size: 11px; word-break: break-all;">{{ profile.elastic_url }}</span>
</div>
<div id="agentError-{{ profile.name }}" class="lb-agent-error hidden"></div>
</div>
</div>
{% endfor %}
</div>
<style>
.lb-agent-card {
display: flex; flex-direction: column;
border: 1px solid var(--lb-border-hi);
background: var(--lb-panel);
}
.lb-agent-card-hdr {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: var(--lb-bg-soft);
border-bottom: 1px solid var(--lb-border);
}
.lb-agent-card-title { display: flex; align-items: center; gap: 8px; }
.lb-agent-card-body {
display: flex; flex-direction: column; gap: 6px;
padding: 12px 14px;
}
.lb-agent-row {
display: flex; align-items: baseline; justify-content: space-between;
gap: 12px; min-height: 18px;
}
.lb-agent-row .lb-eyebrow { flex: 0 0 auto; }
.lb-agent-row > :last-child {
text-align: right; font-size: 12px; word-break: break-all;
}
.lb-agent-divider {
border-top: 1px solid var(--lb-border);
margin: 4px -14px;
}
.lb-agent-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0;
}
.lb-agent-dot--ok { background: var(--lb-sev-low); box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.12); }
.lb-agent-dot--down { background: var(--lb-accent); box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.12); }
.lb-agent-dot--partial { background: var(--lb-sev-medium); box-shadow: 0 0 0 3px rgba(250, 204, 21, 0.12); }
.lb-agent-dot--unknown { background: var(--lb-text-mute); }
.lb-agent-error {
margin-top: 6px; padding: 8px 10px;
background: rgba(248, 113, 113, 0.06);
border-left: 2px solid var(--lb-accent);
font-size: 11px; color: var(--lb-accent-soft);
word-break: break-all;
}
</style>
{% endif %}
{% endblock %}
{% block scripts %}
<script type="module" src="{{ url_for('static', filename='js/agents/core.js') }}"></script>
{% endblock %}
+8
View File
@@ -89,6 +89,14 @@
<span class="lb-nav-text">Summary</span>
</button>
<a href="/whiskers" class="lb-nav-item{% if active_nav == 'whiskers' %} active{% endif %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17a4 4 0 008 0M5 17a8 8 0 0116 0H5z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 13V3M9 6l3-3 3 3"/>
</svg>
<span class="lb-nav-text">Whiskers</span>
</a>
<button onclick="cleanupSystem()" class="lb-nav-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
+7 -1
View File
@@ -134,7 +134,7 @@
<div id="argsInputContainer">
<label for="analysisArgs" class="lb-eyebrow" style="display: block; margin-bottom: 6px;">Command-line Arguments</label>
<input type="text" id="analysisArgs" placeholder="Enter arguments separated by spaces" class="lb-input lb-mono"/>
<p class="lb-muted" style="margin-top: 6px; font-size: 12px;">Arguments passed to the payload at execution time.</p>
<p class="lb-muted" style="margin-top: 6px; font-size: 12px;">Arguments passed to the payload at execution time. <span class="lb-strong">For DLL files</span> the first argument is the exported entry point (e.g. <span class="lb-mono">DllMain</span>) — passed to <span class="lb-mono">rundll32.exe</span>.</p>
</div>
<button type="button" onclick="selectAnalysisType('dynamic')" class="lb-mode-cta">
Run Dynamic Scan
@@ -154,6 +154,12 @@
<li class="lb-ok">EDR Alert Correlation</li>
<li class="lb-ok">Execution Log Capture</li>
</ul>
<div class="edr-args-container">
<label for="edrArgs-{{ profile.name }}" class="lb-eyebrow" style="display: block; margin-bottom: 6px;">Command-line Arguments</label>
<input type="text" id="edrArgs-{{ profile.name }}" data-edr-args="{{ profile.name }}"
placeholder="Enter arguments separated by spaces" class="lb-input lb-mono"/>
<p class="lb-muted" style="margin-top: 6px; font-size: 12px;">Forwarded to the spawned process on the EDR VM. <span class="lb-strong">For DLL files</span> the first argument is the exported entry point (e.g. <span class="lb-mono">DllMain</span>) — passed to <span class="lb-mono">rundll32.exe</span>.</p>
</div>
<button type="button" onclick="selectAnalysisType('edr:{{ profile.name }}')" class="lb-mode-cta">
Run with {{ profile.display_name }}
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>