fb52b1432e
Fibratus EDR profile (kind: fibratus). Pull-from-event-log model, same
shape DetonatorAgent's FibratusEdrPlugin.cs uses: operator configures
Fibratus on the EDR VM with alertsenders.eventlog: {enabled: true,
format: json}; rule matches land in the Application log. Whiskers gains
GET /api/alerts/fibratus/since which wevtutil-queries the log,
extracts <TimeCreated SystemTime> + <EventID> + <Data>, ships the raw
JSON blobs back. The new FibratusEdrAnalyzer mirrors Elastic's
two-phase shape — Phase 1 exec, Phase 2 polls Whiskers — and normalizes
Fibratus's actual schema (events[].proc.{name,exe,cmdline,parent_name,
parent_cmdline,ancestors} + bare tactic.id/technique.id/subtechnique.id
labels) into the saved-view renderer's dict.
Whiskers /api/info now reports telemetry_sources: ['fibratus'] when
fibratus.exe is at C:\Program Files\Fibratus\Bin\, so the
orchestrator can preflight before dispatching. wevtutil's single-quoted
attribute output is parsed correctly.
Dashboard reachability cache (services.edr_health). 30s TTL +
background poller every 15s. Per-probe timeouts dropped 4s/5s -> 2s.
First load post-boot waits at most one probe cycle; every subsequent
load <5ms (cache hit).
GrumpyCats package split: 1085-line monolith into:
grumpycat.py — orchestrator (14 lines)
cli/ — parser, handlers, runner
litterbox_client/ — base + per-domain mixins (files, analysis,
doppelganger, results, edr, reports, system)
composed into LitterBoxClient.
LitterBoxMCP.py rewires its one import. New CLI subcommand
fibratus-alerts and matching MCP tool fibratus_alerts_since pull
Fibratus alerts via a LitterBox passthrough endpoint
(/api/edr/fibratus/<profile>/alerts/since) for wire-checking the agent
without dispatching a payload.
CHANGELOG updated.
273 lines
9.2 KiB
Rust
273 lines
9.2 KiB
Rust
//! Whiskers — LitterBox's sensor agent. HTTP execution runner deployed on
|
|
//! the user's EDR VM.
|
|
//!
|
|
//! Single-binary, single-occupancy. Receives a payload via HTTP, executes
|
|
//! it on the host, reports stdout/stderr/PID/exit_code back to LitterBox.
|
|
//! Does not read local EDR alerts — telemetry comes from whatever EDR agent
|
|
//! (e.g. Elastic Defend) the user installed on the same VM independently.
|
|
//! LitterBox queries the EDR's backend separately for the verdict.
|
|
|
|
use std::net::{IpAddr, SocketAddr};
|
|
use std::path::PathBuf;
|
|
|
|
use axum::extract::DefaultBodyLimit;
|
|
use axum::routing::{delete, get, post};
|
|
use axum::Router;
|
|
use clap::Parser;
|
|
use tracing_subscriber::layer::SubscriberExt;
|
|
use tracing_subscriber::util::SubscriberInitExt;
|
|
|
|
mod agent_log;
|
|
mod api;
|
|
mod file_writer;
|
|
mod state;
|
|
|
|
use crate::agent_log::{AgentLog, AgentLogLayer};
|
|
use crate::state::AppState;
|
|
|
|
const AGENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
/// Name registered with Windows Task Scheduler for `--install` / `--uninstall`.
|
|
const SCHEDULED_TASK_NAME: &str = "Whiskers";
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(name = "whiskers", version, about = "Whiskers — LitterBox's sensor agent")]
|
|
struct Cli {
|
|
/// TCP port to listen on. LitterBox connects to this from its profile YAML.
|
|
#[arg(long, default_value_t = 8080)]
|
|
port: u16,
|
|
|
|
/// Bind address. Default 0.0.0.0 so the orchestrator on a different host
|
|
/// can reach the agent. Set to 127.0.0.1 for loopback-only testing.
|
|
#[arg(long, default_value = "0.0.0.0")]
|
|
bind: IpAddr,
|
|
|
|
/// Max payload size in megabytes for /api/execute/exec multipart uploads.
|
|
/// LitterBox's default upload cap is 100 MB; we mirror that with a small
|
|
/// margin for the multipart envelope. Override if your environment
|
|
/// dispatches larger samples.
|
|
#[arg(long, default_value_t = 200)]
|
|
max_payload_mb: usize,
|
|
|
|
/// Directory where incoming payloads are written when the orchestrator
|
|
/// does not specify a per-request drop path. Defaults to
|
|
/// `<agent-exe-dir>/samples`. The directory is created on first write.
|
|
#[arg(long)]
|
|
samples_dir: Option<PathBuf>,
|
|
|
|
/// Register Whiskers with Windows Task Scheduler so it auto-starts at
|
|
/// user logon (no admin elevation required). Forwards the current
|
|
/// invocation's flags into the task's command line, then exits without
|
|
/// starting the server. Use `--uninstall` to remove.
|
|
#[arg(long, conflicts_with = "uninstall")]
|
|
install: bool,
|
|
|
|
/// Remove the previously installed scheduled task and exit.
|
|
#[arg(long, conflicts_with = "install")]
|
|
uninstall: bool,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
// Build the agent log buffer first; we'll install it as a tracing layer
|
|
// so every log line gets mirrored into it AND printed to stdout.
|
|
let agent_log = std::sync::Arc::new(AgentLog::new());
|
|
|
|
let stdout_layer = tracing_subscriber::fmt::layer().with_target(false);
|
|
let log_buffer_layer = AgentLogLayer::new(std::sync::Arc::clone(&agent_log));
|
|
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| "info".into());
|
|
|
|
tracing_subscriber::registry()
|
|
.with(env_filter)
|
|
.with(stdout_layer)
|
|
.with(log_buffer_layer)
|
|
.init();
|
|
|
|
let cli = Cli::parse();
|
|
|
|
if cli.install {
|
|
match install_scheduled_task(&cli) {
|
|
Ok(()) => return,
|
|
Err(e) => {
|
|
eprintln!("install failed: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
if cli.uninstall {
|
|
match uninstall_scheduled_task() {
|
|
Ok(()) => return,
|
|
Err(e) => {
|
|
eprintln!("uninstall failed: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
let addr = SocketAddr::new(cli.bind, cli.port);
|
|
let max_payload_bytes = cli.max_payload_mb.saturating_mul(1024 * 1024);
|
|
let samples_dir = resolve_samples_dir(cli.samples_dir.clone());
|
|
|
|
tracing::info!(samples_dir = %samples_dir.display(), "Default drop path");
|
|
|
|
let state = AppState::new(agent_log, samples_dir);
|
|
|
|
// axum's default multipart body limit is 2 MiB — too small for real
|
|
// payloads. Disable the route-level cap and re-impose our own via
|
|
// RequestBodyLimitLayer so we can configure it from the CLI.
|
|
let exec_router = Router::new()
|
|
.route("/api/execute/exec", post(api::execute::exec))
|
|
.layer(DefaultBodyLimit::max(max_payload_bytes));
|
|
|
|
let app = Router::new()
|
|
.route("/api/info", get(api::info::get_info))
|
|
.route("/api/lock/acquire", post(api::lock::acquire))
|
|
.route("/api/lock/release", post(api::lock::release))
|
|
.route("/api/lock/status", get(api::lock::status))
|
|
.merge(exec_router)
|
|
.route("/api/execute/kill", post(api::execute::kill))
|
|
.route("/api/logs/execution", get(api::logs::execution))
|
|
.route("/api/logs/agent", get(api::logs::agent_logs))
|
|
.route("/api/logs/agent", delete(api::logs::clear_agent_logs))
|
|
.route("/api/alerts/fibratus/since", get(api::alerts::since))
|
|
.with_state(state);
|
|
|
|
let listener = tokio::net::TcpListener::bind(addr)
|
|
.await
|
|
.expect("failed to bind listen address");
|
|
|
|
tracing::info!(
|
|
version = AGENT_VERSION,
|
|
listen = %addr,
|
|
max_payload_mb = cli.max_payload_mb,
|
|
"Whiskers ready"
|
|
);
|
|
|
|
axum::serve(listener, app)
|
|
.with_graceful_shutdown(shutdown_signal())
|
|
.await
|
|
.expect("server error");
|
|
}
|
|
|
|
/// Shut down cleanly on Ctrl-C / SIGTERM.
|
|
async fn shutdown_signal() {
|
|
let ctrl_c = async {
|
|
tokio::signal::ctrl_c()
|
|
.await
|
|
.expect("failed to install Ctrl-C handler");
|
|
};
|
|
|
|
#[cfg(unix)]
|
|
let terminate = async {
|
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
|
.expect("failed to install SIGTERM handler")
|
|
.recv()
|
|
.await;
|
|
};
|
|
|
|
#[cfg(not(unix))]
|
|
let terminate = std::future::pending::<()>();
|
|
|
|
tokio::select! {
|
|
_ = ctrl_c => {},
|
|
_ = terminate => {},
|
|
}
|
|
|
|
tracing::info!("shutdown signal received");
|
|
}
|
|
|
|
/// Pick the directory where payloads land by default.
|
|
///
|
|
/// CLI override (`--samples-dir`) wins; otherwise we resolve `<exe_dir>/samples`
|
|
/// from `current_exe()`. If both lookups fail (extremely unusual), fall back
|
|
/// to `./samples` relative to the working directory.
|
|
fn resolve_samples_dir(cli_override: Option<PathBuf>) -> PathBuf {
|
|
if let Some(p) = cli_override {
|
|
return p;
|
|
}
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
if let Some(parent) = exe.parent() {
|
|
return parent.join("samples");
|
|
}
|
|
}
|
|
PathBuf::from("samples")
|
|
}
|
|
|
|
/// Register Whiskers as an "at logon" scheduled task so it comes up
|
|
/// automatically when the user logs into the EDR VM. We forward only the
|
|
/// flags that actually differ from defaults — the task command line stays
|
|
/// readable when inspected via `schtasks /Query`.
|
|
///
|
|
/// Runs as the invoking user, no elevation: we want spawned payloads to
|
|
/// inherit the same privilege Whiskers has now (matches the operator's
|
|
/// manual-launch behavior).
|
|
#[cfg(target_os = "windows")]
|
|
fn install_scheduled_task(cli: &Cli) -> std::io::Result<()> {
|
|
let exe = std::env::current_exe()?;
|
|
let mut tr = format!("\"{}\"", exe.display());
|
|
if cli.port != 8080 {
|
|
tr.push_str(&format!(" --port {}", cli.port));
|
|
}
|
|
if cli.bind.to_string() != "0.0.0.0" {
|
|
tr.push_str(&format!(" --bind {}", cli.bind));
|
|
}
|
|
if cli.max_payload_mb != 200 {
|
|
tr.push_str(&format!(" --max-payload-mb {}", cli.max_payload_mb));
|
|
}
|
|
if let Some(d) = &cli.samples_dir {
|
|
tr.push_str(&format!(" --samples-dir \"{}\"", d.display()));
|
|
}
|
|
|
|
let status = std::process::Command::new("schtasks.exe")
|
|
.args([
|
|
"/Create", "/F",
|
|
"/TN", SCHEDULED_TASK_NAME,
|
|
"/SC", "ONLOGON",
|
|
"/TR", &tr,
|
|
])
|
|
.status()?;
|
|
|
|
if !status.success() {
|
|
return Err(std::io::Error::other(format!(
|
|
"schtasks /Create exited with {:?}",
|
|
status.code()
|
|
)));
|
|
}
|
|
println!(
|
|
"Installed scheduled task '{name}'. Whiskers will auto-start on the next user logon.\n Command: {tr}\n To remove: {exe} --uninstall",
|
|
name = SCHEDULED_TASK_NAME,
|
|
tr = tr,
|
|
exe = exe.display(),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn uninstall_scheduled_task() -> std::io::Result<()> {
|
|
let status = std::process::Command::new("schtasks.exe")
|
|
.args(["/Delete", "/F", "/TN", SCHEDULED_TASK_NAME])
|
|
.status()?;
|
|
if !status.success() {
|
|
return Err(std::io::Error::other(format!(
|
|
"schtasks /Delete exited with {:?} (task may not exist)",
|
|
status.code()
|
|
)));
|
|
}
|
|
println!("Removed scheduled task '{}'.", SCHEDULED_TASK_NAME);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
fn install_scheduled_task(_cli: &Cli) -> std::io::Result<()> {
|
|
Err(std::io::Error::other(
|
|
"--install is only supported on Windows (uses schtasks.exe)",
|
|
))
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
fn uninstall_scheduled_task() -> std::io::Result<()> {
|
|
Err(std::io::Error::other(
|
|
"--uninstall is only supported on Windows",
|
|
))
|
|
}
|