EDR: XOR-on-the-wire, DLL via rundll32, Whiskers page, kill-detection fix
This commit is contained in:
+53
-19
@@ -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 {
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ExecResponse {
|
||||
status: "error",
|
||||
pid: None,
|
||||
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
|
||||
// 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 {
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ExecResponse {
|
||||
status: "error",
|
||||
pid: None,
|
||||
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
|
||||
# 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,
|
||||
}}},
|
||||
],
|
||||
}})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.) —
|
||||
|
||||
@@ -251,6 +251,13 @@ class AnalysisManager:
|
||||
|
||||
self.logger.debug("Initializing RedEdr analyzer")
|
||||
try:
|
||||
# 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):
|
||||
@@ -389,11 +396,27 @@ 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"""
|
||||
"""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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
// "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) {
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user