EDR: XOR-on-the-wire, DLL via rundll32, Whiskers page, kill-detection fix
This commit is contained in:
+62
-28
@@ -91,33 +91,69 @@ pub async fn exec(
|
|||||||
// Distinguish if we can; otherwise treat as generic write failure.
|
// Distinguish if we can; otherwise treat as generic write failure.
|
||||||
tracing::error!(error = %err, "Failed to write payload");
|
tracing::error!(error = %err, "Failed to write payload");
|
||||||
let virus_signaled = is_likely_av_block(&err);
|
let virus_signaled = is_likely_av_block(&err);
|
||||||
let resp = if virus_signaled {
|
if virus_signaled {
|
||||||
ExecResponse {
|
// 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",
|
status: "virus",
|
||||||
pid: None,
|
pid: None,
|
||||||
message: Some(format!("Antivirus blocked write: {err}")),
|
message: Some(format!("Antivirus blocked write: {err}")),
|
||||||
}
|
}));
|
||||||
} else {
|
}
|
||||||
ExecResponse {
|
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ExecResponse {
|
||||||
status: "error",
|
status: "error",
|
||||||
pid: None,
|
pid: None,
|
||||||
message: Some(format!("Failed to write file: {err}")),
|
message: Some(format!("Failed to write file: {err}")),
|
||||||
}
|
})));
|
||||||
};
|
|
||||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(resp)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill any previous run that's still hanging around (defensive — orchestrator
|
// Kill any previous run that's still hanging around (defensive — orchestrator
|
||||||
// should have called kill, but if it didn't, don't leave orphans).
|
// should have called kill, but if it didn't, don't leave orphans).
|
||||||
take_previous_run_for_cleanup(&state).await;
|
take_previous_run_for_cleanup(&state).await;
|
||||||
|
|
||||||
// Spawn the payload.
|
// Spawn the payload. Windows can't directly exec a `.dll` — those need
|
||||||
let mut command = Command::new(&file_path);
|
// to go through `rundll32.exe <dll>,<entry-point> [args...]`. The
|
||||||
if let Some(args) = form.executable_args.as_ref() {
|
// entry point is required and comes from `executable_args`; if it's
|
||||||
if !args.is_empty() {
|
// missing we bail with a clear error instead of letting Windows
|
||||||
command.args(parse_args(args));
|
// 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
|
command
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
@@ -132,20 +168,18 @@ pub async fn exec(
|
|||||||
let virus_signaled = is_likely_av_block(&err);
|
let virus_signaled = is_likely_av_block(&err);
|
||||||
// Best-effort cleanup of the dropper.
|
// Best-effort cleanup of the dropper.
|
||||||
let _ = tokio::fs::remove_file(&file_path).await;
|
let _ = tokio::fs::remove_file(&file_path).await;
|
||||||
let resp = if virus_signaled {
|
if virus_signaled {
|
||||||
ExecResponse {
|
return Ok(Json(ExecResponse {
|
||||||
status: "virus",
|
status: "virus",
|
||||||
pid: None,
|
pid: None,
|
||||||
message: Some(format!("Antivirus blocked spawn: {err}")),
|
message: Some(format!("Antivirus blocked spawn: {err}")),
|
||||||
}
|
}));
|
||||||
} else {
|
}
|
||||||
ExecResponse {
|
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ExecResponse {
|
||||||
status: "error",
|
status: "error",
|
||||||
pid: None,
|
pid: None,
|
||||||
message: Some(format!("Failed to spawn process: {err}")),
|
message: Some(format!("Failed to spawn process: {err}")),
|
||||||
}
|
})));
|
||||||
};
|
|
||||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(resp)));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,14 @@ class ElasticClient:
|
|||||||
# Match alerts touching THIS specific payload across all the
|
# Match alerts touching THIS specific payload across all the
|
||||||
# places Elastic Defend records the filename. The MD5 prefix
|
# places Elastic Defend records the filename. The MD5 prefix
|
||||||
# in the filename ensures uniqueness across uploads.
|
# 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": {
|
filters.append({"bool": {
|
||||||
"minimum_should_match": 1,
|
"minimum_should_match": 1,
|
||||||
"should": [
|
"should": [
|
||||||
@@ -185,6 +193,14 @@ class ElasticClient:
|
|||||||
"value": f"*{file_name}",
|
"value": f"*{file_name}",
|
||||||
"case_insensitive": True,
|
"case_insensitive": True,
|
||||||
}}},
|
}}},
|
||||||
|
{"wildcard": {"process.command_line": {
|
||||||
|
"value": f"*{file_name}*",
|
||||||
|
"case_insensitive": True,
|
||||||
|
}}},
|
||||||
|
{"wildcard": {"process.args": {
|
||||||
|
"value": f"*{file_name}*",
|
||||||
|
"case_insensitive": True,
|
||||||
|
}}},
|
||||||
],
|
],
|
||||||
}})
|
}})
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Per-payload flow (matches ROADMAP.md Phase L4):
|
|||||||
|
|
||||||
1. AgentClient.get_info -> learn the EDR VM's hostname
|
1. AgentClient.get_info -> learn the EDR VM's hostname
|
||||||
2. AgentClient.lock_acquire
|
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
|
4. wait for exec to exit OR exec_timeout_seconds
|
||||||
5. AgentClient.kill (idempotent if already exited)
|
5. AgentClient.kill (idempotent if already exited)
|
||||||
6. AgentClient.get_execution_logs -> stdout/stderr/exit_code
|
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
|
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
|
fails, the lock is released in `finally` so the agent doesn't get stuck
|
||||||
in a busy state.
|
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 logging
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
@@ -241,20 +250,27 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
|
|||||||
# Build the phase-1 snapshot the UI shows immediately. It's a
|
# Build the phase-1 snapshot the UI shows immediately. It's a
|
||||||
# complete EDR-result dict with status="polling_alerts" and an
|
# complete EDR-result dict with status="polling_alerts" and an
|
||||||
# empty alerts array; Phase 2 will overwrite it on disk when the
|
# 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"]
|
kind = exec_outcome["kind"]
|
||||||
is_blocked = (kind == "virus")
|
is_blocked = (kind == "virus")
|
||||||
phase_1_status = "blocked_polling_alerts" if is_blocked else "polling_alerts"
|
phase_1_status = "polling_alerts"
|
||||||
max_wait = (
|
max_wait = (
|
||||||
self.profile.av_block_wait_seconds if is_blocked
|
self.profile.av_block_wait_seconds if is_blocked
|
||||||
else self.profile.wait_seconds_for_alerts
|
else self.profile.wait_seconds_for_alerts
|
||||||
)
|
)
|
||||||
exec_logs = exec_outcome.get("exec_logs", {})
|
exec_logs = exec_outcome.get("exec_logs", {})
|
||||||
# For the AV-block path the process never ran, so kill
|
# For the AV-block path the process never ran, so kill
|
||||||
# classification doesn't apply. For successful spawns we already
|
# classification doesn't apply. For .exe successful spawns we can
|
||||||
# know if the EDR terminated it externally — surface it now so
|
# rely on the heuristic in Phase 1; for .dll spawns the heuristic
|
||||||
# the operator doesn't have to wait for Phase 2 to see it.
|
# produces false positives (rundll32 exits 1 on bad-export, etc.)
|
||||||
killed_by_edr = False if is_blocked else self._classify_kill(exec_logs)
|
# 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")
|
raw_exec_status = exec_logs.get("status")
|
||||||
exec_status_label = (
|
exec_status_label = (
|
||||||
"virus" if is_blocked
|
"virus" if is_blocked
|
||||||
@@ -284,7 +300,6 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
|
|||||||
"run_start": run_start.isoformat(),
|
"run_start": run_start.isoformat(),
|
||||||
"run_end": None,
|
"run_end": None,
|
||||||
"wait_seconds_for_alerts": max_wait,
|
"wait_seconds_for_alerts": max_wait,
|
||||||
"phase": phase_1_status,
|
|
||||||
"blocked_by_av": is_blocked,
|
"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
|
alerts fire in real-time — we don't need to hold the lock through the
|
||||||
post-exec wait window just to query Elastic afterwards.
|
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:
|
try:
|
||||||
exec_resp = self.agent.exec(
|
exec_resp = self.agent.exec(
|
||||||
file_bytes=file_bytes,
|
file_bytes=xored,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
drop_path=self.profile.drop_path,
|
drop_path=self.profile.drop_path,
|
||||||
executable_args=executable_args,
|
executable_args=executable_args,
|
||||||
|
xor_key=xor_key,
|
||||||
)
|
)
|
||||||
except AgentUnreachable as exc:
|
except AgentUnreachable as exc:
|
||||||
return {**self._unreachable_result(exc), "_final": True}
|
return {**self._unreachable_result(exc), "_final": True}
|
||||||
@@ -444,6 +468,7 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
|
|||||||
return self._success_result(
|
return self._success_result(
|
||||||
agent_info, outcome["exec_logs"], outcome["pid"],
|
agent_info, outcome["exec_logs"], outcome["pid"],
|
||||||
run_start, run_end, hostname, alerts,
|
run_start, run_end, hostname, alerts,
|
||||||
|
file_name=file_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _poll_alerts(
|
def _poll_alerts(
|
||||||
@@ -526,24 +551,40 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
|
|||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
@classmethod
|
@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
|
"""If Whiskers didn't issue a kill and the process didn't exit
|
||||||
cleanly, something external terminated it. In a LitterBox dispatch
|
cleanly, something external terminated it. For .exe payloads on
|
||||||
the EDR is the only thing on the VM that does that — so we label
|
an EDR-instrumented host, "something external" is overwhelmingly
|
||||||
it as a behavior-protection kill.
|
the EDR — the heuristic alone is reliable.
|
||||||
|
|
||||||
Edge case: a payload that crashes on its own with a non-zero exit
|
For .dll payloads spawned via rundll32, exit_code != 0 is much
|
||||||
code will get the same label. That's an acceptable false positive
|
noisier: GetProcAddress failure on a missing export, DllMain
|
||||||
— when you're running suspect binaries on an EDR-instrumented host,
|
returning FALSE, even a malformed DLL all produce non-zero exits
|
||||||
a non-zero exit you didn't ask for is overwhelmingly the EDR.
|
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()
|
raw_status = (exec_logs.get("status") or "").lower()
|
||||||
if raw_status == "killed":
|
if raw_status == "killed":
|
||||||
# The agent issued the kill itself (orchestrator timeout). Not
|
# The agent issued the kill itself (orchestrator timeout).
|
||||||
# an external termination.
|
|
||||||
return False
|
return False
|
||||||
exit_code = exec_logs.get("exit_code")
|
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(
|
def _success_result(
|
||||||
self,
|
self,
|
||||||
@@ -554,10 +595,15 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
|
|||||||
run_end: datetime,
|
run_end: datetime,
|
||||||
hostname: str,
|
hostname: str,
|
||||||
alerts: List[Alert],
|
alerts: List[Alert],
|
||||||
|
file_name: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
alert_dicts = [a.to_dict() for a in alerts]
|
alert_dicts = [a.to_dict() for a in alerts]
|
||||||
high_severity_count = sum(1 for a in alerts if a.severity in HIGH_SEVERITY)
|
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
|
# Surface "killed_by_edr" as the user-facing exec_status when we
|
||||||
# have evidence — the raw "exited" label is technically right but
|
# have evidence — the raw "exited" label is technically right but
|
||||||
# actively misleading when behavior protection was the cause.
|
# actively misleading when behavior protection was the cause.
|
||||||
@@ -584,6 +630,7 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
|
|||||||
"total_alerts": len(alert_dicts),
|
"total_alerts": len(alert_dicts),
|
||||||
"high_severity_alerts": high_severity_count,
|
"high_severity_alerts": high_severity_count,
|
||||||
"killed_by_edr": killed_by_edr,
|
"killed_by_edr": killed_by_edr,
|
||||||
|
"blocked_by_av": False,
|
||||||
"run_start": run_start.isoformat(),
|
"run_start": run_start.isoformat(),
|
||||||
"run_end": run_end.isoformat(),
|
"run_end": run_end.isoformat(),
|
||||||
"wait_seconds_for_alerts": self.profile.wait_seconds_for_alerts,
|
"wait_seconds_for_alerts": self.profile.wait_seconds_for_alerts,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ def dispatch_split(
|
|||||||
payload_path: str,
|
payload_path: str,
|
||||||
config: dict,
|
config: dict,
|
||||||
on_phase_2_done: Callable[[dict], None],
|
on_phase_2_done: Callable[[dict], None],
|
||||||
|
executable_args: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Split-phase dispatch.
|
"""Split-phase dispatch.
|
||||||
|
|
||||||
@@ -103,6 +104,10 @@ def dispatch_split(
|
|||||||
is called with the final findings dict. The callback is responsible
|
is called with the final findings dict. The callback is responsible
|
||||||
for persisting the updated result.
|
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
|
Phase 2 errors are swallowed and surfaced to the callback as a
|
||||||
`status: 'error'` dict — the thread never raises into nothing.
|
`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}")
|
raise KeyError(f"unknown EDR profile: {profile_name!r}")
|
||||||
|
|
||||||
analyzer = ElasticEdrAnalyzer(config, profile)
|
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:
|
if continuation is None:
|
||||||
# Terminal failure (busy, agent unreachable, missing file, etc.) —
|
# Terminal failure (busy, agent unreachable, missing file, etc.) —
|
||||||
|
|||||||
@@ -251,7 +251,14 @@ class AnalysisManager:
|
|||||||
|
|
||||||
self.logger.debug("Initializing RedEdr analyzer")
|
self.logger.debug("Initializing RedEdr analyzer")
|
||||||
try:
|
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)
|
rededr = RedEdrAnalyzer(self.config)
|
||||||
if not rededr.start_tool(target_name):
|
if not rededr.start_tool(target_name):
|
||||||
self.logger.error("Failed to start RedEdr")
|
self.logger.error("Failed to start RedEdr")
|
||||||
@@ -389,12 +396,28 @@ class AnalysisManager:
|
|||||||
raise Exception(f"Invalid or non-existent PID: {e}")
|
raise Exception(f"Invalid or non-existent PID: {e}")
|
||||||
|
|
||||||
def _create_new_process(self, target: str, cmd_args: list) -> Tuple[subprocess.Popen, int]:
|
def _create_new_process(self, target: str, cmd_args: list) -> Tuple[subprocess.Popen, int]:
|
||||||
"""Create and validate new process"""
|
"""Create and validate new process. DLL targets are wrapped with
|
||||||
command = [target]
|
rundll32.exe — Windows can't directly Popen a .dll, and the
|
||||||
if cmd_args:
|
operator's first cmd_arg is treated as the exported entry point
|
||||||
command.extend(cmd_args)
|
(mandatory for DLLs)."""
|
||||||
|
if target.lower().endswith('.dll'):
|
||||||
self.logger.debug(f"Starting new process: {command}")
|
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 = subprocess.STARTUPINFO()
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||||
|
|||||||
@@ -166,6 +166,18 @@ def _handle_analysis_results(results, result_path, results_filename):
|
|||||||
return jsonify({'status': 'success', 'results': results})
|
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'])
|
@analysis_bp.route('/analyze/edr/<profile>/<target>', methods=['GET', 'POST'])
|
||||||
@error_handler
|
@error_handler
|
||||||
def analyze_edr(profile, target):
|
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}")
|
app.logger.warning(f"Result path not found for hash: {target}")
|
||||||
return jsonify({'error': 'Result path not found'}), 404
|
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'
|
results_filename = f'edr_{profile}_results.json'
|
||||||
|
|
||||||
# Phase 2 callback — runs on a background thread when alerts arrive.
|
# Phase 2 callback — runs on a background thread when alerts arrive.
|
||||||
@@ -232,7 +254,8 @@ def analyze_edr(profile, target):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
results = deps.edr_registry.dispatch_split(
|
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:
|
except Exception as e:
|
||||||
app.logger.error(f"EDR dispatch failed: {e}", exc_info=True)
|
app.logger.error(f"EDR dispatch failed: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -111,6 +111,80 @@ def api_edr_profiles():
|
|||||||
return jsonify({'profiles': deps.edr_registry.list_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'])
|
@api_bp.route('/api/results/<target>/edr/<profile>', methods=['GET'])
|
||||||
@error_handler
|
@error_handler
|
||||||
def api_edr_results(target, profile):
|
def api_edr_results(target, profile):
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -124,10 +124,8 @@ class AnalysisCore {
|
|||||||
// the duration timer running and don't flip the status to
|
// the duration timer running and don't flip the status to
|
||||||
// "Analysis completed". The EDR poll handler will stop the
|
// "Analysis completed". The EDR poll handler will stop the
|
||||||
// timer + flip status when Phase 2 lands a terminal result.
|
// timer + flip status when Phase 2 lands a terminal result.
|
||||||
const edrPolling = !!(data.results && data.results.edr && (
|
const edrPolling = !!(data.results && data.results.edr &&
|
||||||
data.results.edr.status === 'polling_alerts' ||
|
data.results.edr.status === 'polling_alerts');
|
||||||
data.results.edr.status === 'blocked_polling_alerts'
|
|
||||||
));
|
|
||||||
|
|
||||||
this.updateTimer();
|
this.updateTimer();
|
||||||
if (!edrPolling) {
|
if (!edrPolling) {
|
||||||
|
|||||||
@@ -4,15 +4,18 @@
|
|||||||
//
|
//
|
||||||
// The orchestrator runs in two phases:
|
// The orchestrator runs in two phases:
|
||||||
// Phase 1 (exec) — sync over HTTP; returns when the agent finishes
|
// 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);
|
// Phase 2 (correlation) — async on the server (background thread);
|
||||||
// polls Elastic for alerts, overwrites the saved
|
// polls Elastic for alerts, overwrites the saved
|
||||||
// findings JSON when done.
|
// findings JSON when done.
|
||||||
//
|
//
|
||||||
// On the initial POST response we render whatever Phase 1 produced. If
|
// On the initial POST response we render whatever Phase 1 produced. If
|
||||||
// the status is `polling_alerts` or `blocked_polling_alerts`, we kick
|
// the status is `polling_alerts`, we kick off a foreground poll of GET
|
||||||
// off a foreground poll of GET /api/results/<hash>/edr/<profile> so the
|
// /api/results/<hash>/edr/<profile> so the alerts pane and summary chip
|
||||||
// alerts pane and summary chip reflect Phase 2 progress in real time.
|
// 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:
|
// Targets:
|
||||||
// - #edrSummary (summary tab)
|
// - #edrSummary (summary tab)
|
||||||
@@ -25,7 +28,7 @@ import { errorPanel, cleanState, threatState, statRow, panel, kvGrid, codeBlock,
|
|||||||
import summaryTool from './summary.js';
|
import summaryTool from './summary.js';
|
||||||
|
|
||||||
const HIGH_SEVERITY = new Set(['high', 'critical']);
|
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;
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
// Module-level handle so a re-render with a new payload aborts the old
|
// Module-level handle so a re-render with a new payload aborts the old
|
||||||
@@ -40,7 +43,7 @@ function clearPoll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isPolling(results) {
|
function isPolling(results) {
|
||||||
return POLLING_STATUSES.has(results?.status);
|
return results?.status === POLLING_STATUS;
|
||||||
}
|
}
|
||||||
|
|
||||||
function severityRank(s) {
|
function severityRank(s) {
|
||||||
@@ -110,8 +113,7 @@ function renderAlerts(results) {
|
|||||||
// the stat chip stays the same width as the numeric ones beside it.
|
// the stat chip stays the same width as the numeric ones beside it.
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
'completed': 'Completed',
|
'completed': 'Completed',
|
||||||
'blocked_by_av': 'AV Block',
|
'blocked_by_av': 'EDR Block',
|
||||||
'blocked_polling_alerts': 'AV Block',
|
|
||||||
'polling_alerts': 'Polling…',
|
'polling_alerts': 'Polling…',
|
||||||
'partial': 'Partial',
|
'partial': 'Partial',
|
||||||
'busy': 'Busy',
|
'busy': 'Busy',
|
||||||
@@ -122,7 +124,7 @@ function renderAlerts(results) {
|
|||||||
const statusSeverity = (
|
const statusSeverity = (
|
||||||
status === 'completed' && totalAlerts === 0 ? 'clean' :
|
status === 'completed' && totalAlerts === 0 ? 'clean' :
|
||||||
status === 'completed' ? 'critical' :
|
status === 'completed' ? 'critical' :
|
||||||
status === 'blocked_by_av' || status === 'blocked_polling_alerts' ? 'critical' :
|
status === 'blocked_by_av' ? 'critical' :
|
||||||
status === 'polling_alerts' ? 'info' :
|
status === 'polling_alerts' ? 'info' :
|
||||||
status === 'partial' ? 'medium' :
|
status === 'partial' ? 'medium' :
|
||||||
'critical'
|
'critical'
|
||||||
@@ -158,8 +160,8 @@ function renderAlerts(results) {
|
|||||||
// Phase 2 in flight on the server — show a busy indicator in the
|
// Phase 2 in flight on the server — show a busy indicator in the
|
||||||
// alerts pane until the next poll updates this state.
|
// alerts pane until the next poll updates this state.
|
||||||
const max = summary.wait_seconds_for_alerts || '?';
|
const max = summary.wait_seconds_for_alerts || '?';
|
||||||
const blockedHint = status === 'blocked_polling_alerts'
|
const blockedHint = summary.blocked_by_av
|
||||||
? ' The AV blocked the spawn; we are correlating against the prevention alert.'
|
? ' The EDR blocked the spawn; we are correlating against the prevention alert.'
|
||||||
: '';
|
: '';
|
||||||
target.innerHTML = `
|
target.innerHTML = `
|
||||||
<div class="lb-empty" style="flex-direction: column; padding: 24px 16px; gap: 8px; align-items: flex-start;">
|
<div class="lb-empty" style="flex-direction: column; padding: 24px 16px; gap: 8px; align-items: flex-start;">
|
||||||
|
|||||||
@@ -159,10 +159,10 @@ export default {
|
|||||||
else if (status === 'busy') { detail = 'Agent busy with another run'; isFailureState = true; }
|
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 === 'partial') { detail = 'Run completed but Elastic query failed'; isFailureState = true; }
|
||||||
else if (status === 'error') { detail = `Error: ${r.error || 'unknown'}`; 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' && killedByEdr) { detail = 'Killed by EDR — correlating alerts…'; isPolling = true; }
|
||||||
else if (status === 'polling_alerts') { detail = 'Exec finished — 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 EDR before execution';
|
||||||
else if (status === 'blocked_by_av') detail = 'Blocked by AV before execution';
|
|
||||||
else if (killedByEdr && totalAlerts > 0) detail = `Killed by EDR · ${totalAlerts} alert${totalAlerts === 1 ? '' : 's'} raised`;
|
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 if (totalAlerts > 0) detail = `${totalAlerts} alert${totalAlerts === 1 ? '' : 's'} raised`;
|
||||||
else detail = 'No alerts raised';
|
else detail = 'No alerts raised';
|
||||||
@@ -178,10 +178,7 @@ export default {
|
|||||||
// poll window haven't finished correlating yet — show that
|
// poll window haven't finished correlating yet — show that
|
||||||
// explicitly instead of a misleading "Clean" green when the
|
// explicitly instead of a misleading "Clean" green when the
|
||||||
// count happens to be 0.
|
// count happens to be 0.
|
||||||
const edrPolling = !!(results.edr && (
|
const edrPolling = !!(results.edr && results.edr.status === 'polling_alerts');
|
||||||
results.edr.status === 'polling_alerts' ||
|
|
||||||
results.edr.status === 'blocked_polling_alerts'
|
|
||||||
));
|
|
||||||
const totalEl = document.getElementById('totalDetections');
|
const totalEl = document.getElementById('totalDetections');
|
||||||
const overEl = document.getElementById('overallStatus');
|
const overEl = document.getElementById('overallStatus');
|
||||||
if (totalEl) totalEl.textContent = totalDetections;
|
if (totalEl) totalEl.textContent = totalDetections;
|
||||||
|
|||||||
@@ -668,9 +668,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Navigate to dynamic analysis
|
// Navigate to dynamic analysis
|
||||||
window.location.href = `/analyze/${type}/${currentFileHash}`;
|
window.location.href = `/analyze/${type}/${currentFileHash}`;
|
||||||
} else if (type.startsWith('edr:')) {
|
} else if (type.startsWith('edr:')) {
|
||||||
// EDR profile dispatch: type is "edr:<profile_name>"
|
// EDR profile dispatch: type is "edr:<profile_name>".
|
||||||
// Server-side route is /analyze/edr/<profile>/<hash>.
|
// 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 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}`;
|
window.location.href = `/analyze/edr/${encodeURIComponent(profile)}/${currentFileHash}`;
|
||||||
} else {
|
} else {
|
||||||
// Navigate to static analysis
|
// Navigate to static analysis
|
||||||
|
|||||||
@@ -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/<name>.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 %}
|
||||||
@@ -89,6 +89,14 @@
|
|||||||
<span class="lb-nav-text">Summary</span>
|
<span class="lb-nav-text">Summary</span>
|
||||||
</button>
|
</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">
|
<button onclick="cleanupSystem()" class="lb-nav-item">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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"/>
|
<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"/>
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
<div id="argsInputContainer">
|
<div id="argsInputContainer">
|
||||||
<label for="analysisArgs" class="lb-eyebrow" style="display: block; margin-bottom: 6px;">Command-line Arguments</label>
|
<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"/>
|
<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>
|
</div>
|
||||||
<button type="button" onclick="selectAnalysisType('dynamic')" class="lb-mode-cta">
|
<button type="button" onclick="selectAnalysisType('dynamic')" class="lb-mode-cta">
|
||||||
Run Dynamic Scan
|
Run Dynamic Scan
|
||||||
@@ -154,6 +154,12 @@
|
|||||||
<li class="lb-ok">EDR Alert Correlation</li>
|
<li class="lb-ok">EDR Alert Correlation</li>
|
||||||
<li class="lb-ok">Execution Log Capture</li>
|
<li class="lb-ok">Execution Log Capture</li>
|
||||||
</ul>
|
</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">
|
<button type="button" onclick="selectAnalysisType('edr:{{ profile.name }}')" class="lb-mode-cta">
|
||||||
Run with {{ profile.display_name }}
|
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>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user