diff --git a/README.md b/README.md index d260926..16d93be 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ exploitation*, not the *identity of the bug*. > Built as the runtime-defence companion to [`ghost`](https://github.com/pandaadir05/ghost). > `ghost` finds weaknesses; `wraith` catches them being used. +![Wraith's live scan dashboard: three clean workers and one process caught mid-exploitation](docs/scan-demo.svg) + +

wraith scan --ui --match netd — one row per process with live syscall/event +counters, and a correlated exploitation verdict the instant injected code issues a syscall.

+ --- ## The idea @@ -115,6 +120,7 @@ Tuning: ``` --jit-critical treat anonymous-exec pages as HIGH (targets that never JIT) --trust-region A-B treat the hex range [A,B) as legitimate JIT (repeatable) +--ui live full-screen dashboard instead of the log stream --no-stack-pivot disable the ROP stack-pivot heuristic --audit-sensitive log sensitive syscalls from legitimate code too --min floor: info|warn|high|critical (default warn) @@ -194,6 +200,27 @@ target. Caveats worth knowing: `PTRACE_O_EXITKILL`: stopping Wraith leaves every scanned process running. - **Post-attach threads only.** As with `attach`, sibling threads that already existed before Wraith attached aren't picked up automatically (see below). +- **Never traces itself.** `scan` excludes its own process and its whole + ancestor chain (the shell/terminal that launched it), so a broad `--match` + can't accidentally attach to — and hang on — the tool that started it. + +### Live dashboard (`--ui`) + +Add `--ui` to any mode for a full-screen terminal dashboard instead of the +scrolling log — the picture at the top of this README is exactly that, on the +scan flow: + +```bash +sudo wraith scan --ui --match nginx +``` + +One row per traced process with live syscall/event counters and a colour-coded +verdict (`clean` → `suspicious` → `EXPLOITATION`), above a feed of the most +recent detections and a status bar carrying the aggregate verdict. It repaints +on every detection and at ~20 fps otherwise, restores the terminal cleanly on +exit or Ctrl-C, and still honours `--json` (the event stream is written to the +sink underneath the UI). The dashboard is hand-rolled ANSI — no TUI dependency +— so the sensor's supply chain stays `nix` + `libc` only. --- @@ -211,6 +238,7 @@ carry the smallest supply chain you can manage. The engine links only `nix` and ├─ detect.rs the invariants + the exploitation-chain correlator ├─ event.rs detection events + their JSONL form ├─ tracer.rs the ptrace engine (spawn/attach/scan, thread-following, enforcement) + ├─ ui.rs the live terminal dashboard (--ui), hand-rolled ANSI └─ bin/ ├─ wraith.rs the CLI sensor ├─ benign.rs false-positive control target @@ -275,7 +303,7 @@ ranges). ```bash cargo build --release -cargo test # 36 unit + 9 end-to-end tests +cargo test # 42 unit + 10 end-to-end tests cargo clippy --all-targets ./demo.sh # side-by-side benign vs. exploitation run ``` diff --git a/docs/scan-demo.svg b/docs/scan-demo.svg new file mode 100644 index 0000000..efd72fe --- /dev/null +++ b/docs/scan-demo.svg @@ -0,0 +1,38 @@ + + + + + + + +wraith — live scan + + WRAITH — runtime exploitation sensor 00:00 + + scan --match netd · enforce: observe · 4 proc · 6 syscalls · 3 events + + PID PROCESS SYSCALLS EVENTS VERDICT + + 17199 netd-cache 1 0 +● clean + + 17201 netd-auth 1 0 +● clean + + 17203 netd-log 1 0 +● clean + + 17210 netd-api 3 3 +● EXPLOITATION + +── recent detections ─────────────────────────────────────────────────────────────────────────────────── + + HIGH + wx_violation mmap @ 0x7f7e1812534a [0x0] `mmap` requests writable+executable memory — classi… + CRITICAL + foreign_origin_syscall socket @ 0x7f7e183a1011 [anon] sensitive syscall `socket` issued from… + CRITICAL + exploitation_chain socket @ 0x7f7e183a1011 [correlated] EXPLOITATION CHAIN: executable paylo… + + VERDICT: EXPLOITATION 6 syscalls · 3 events + \ No newline at end of file diff --git a/src/bin/wraith.rs b/src/bin/wraith.rs index 191733e..f9053ea 100644 --- a/src/bin/wraith.rs +++ b/src/bin/wraith.rs @@ -14,6 +14,7 @@ //! --kill SIGKILL the traced tree on detection //! --match (scan) attach to processes whose name/cmdline matches //! --all (scan) attach to every process we're allowed to trace +//! --ui live full-screen dashboard instead of the log stream //! --no-stack-pivot disable the ROP stack-pivot heuristic //! --audit-sensitive also log sensitive syscalls from legitimate code //! --quiet suppress the human event stream (use with --json) @@ -26,6 +27,7 @@ use std::process::ExitCode; use wraith::detect::{Config, Enforcement}; use wraith::event::{Event, Severity}; use wraith::tracer::Tracer; +use wraith::ui::{Dashboard, TerminalGuard}; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); @@ -42,6 +44,7 @@ struct Opts { json: Option, min: Severity, quiet: bool, + ui: bool, cfg: Config, } @@ -58,6 +61,7 @@ fn run(args: Vec) -> io::Result { json: None, min: Severity::Warn, quiet: false, + ui: false, cfg: Config::default(), }; @@ -98,6 +102,7 @@ fn run(args: Vec) -> io::Result { scan_matches.push(v.clone()); } "--all" => scan_all = true, + "--ui" | "--dashboard" => opts.ui = true, "--no-stack-pivot" => opts.cfg.detect_stack_pivot = false, "--audit-sensitive" => opts.cfg.audit_sensitive = true, "--quiet" => opts.quiet = true, @@ -114,45 +119,52 @@ fn run(args: Vec) -> io::Result { // Set up output sinks. let color = io::stderr().is_terminal(); - let mut json_sink: Option> = match opts.json.as_deref() { + let json_sink: Option> = match opts.json.as_deref() { None => None, Some("-") => Some(Box::new(io::stdout())), Some(path) => Some(Box::new(File::create(path)?)), }; let min = opts.min; let quiet = opts.quiet; + let ui = opts.ui; + let enforcement = opts.cfg.enforcement; - let mut on_event = |ev: &Event| { - if ev.severity >= min { - if !quiet { - let _ = writeln!(io::stderr(), "{}", ev.to_line(color)); - } - if let Some(sink) = json_sink.as_mut() { - let _ = writeln!(sink, "{}", ev.to_json()); - } - } - }; + if ui && !io::stderr().is_terminal() { + return Err(bad( + "--ui needs an interactive terminal on stderr; drop --ui, or use --json for a stream", + )); + } - let enforce_note = match opts.cfg.enforcement { + let enforce_note = match enforcement { Enforcement::Observe => "", Enforcement::Block => " [enforcing: block]", Enforcement::Kill => " [enforcing: kill]", }; + // A short label for the run, used by the dashboard header. Assigned by + // every non-returning arm below. + let ui_label; + let tracer = match mode.as_str() { "run" => { if target.is_empty() { return Err(bad("no program to run; use: wraith run -- [args...]")); } - eprintln!( - "wraith: monitoring `{}` (provenance mode){enforce_note}", - target.join(" ") - ); + ui_label = format!("run — {}", target.join(" ")); + if !ui { + eprintln!( + "wraith: monitoring `{}` (provenance mode){enforce_note}", + target.join(" ") + ); + } Tracer::spawn(&target, opts.cfg)? } "attach" => { let pid = attach_pid.ok_or_else(|| bad("attach needs a pid"))?; - eprintln!("wraith: attaching to pid {pid}{enforce_note}"); + ui_label = format!("attach — pid {pid}"); + if !ui { + eprintln!("wraith: attaching to pid {pid}{enforce_note}"); + } Tracer::attach(pid, opts.cfg)? } "scan" => { @@ -165,15 +177,18 @@ fn run(args: Vec) -> io::Result { if pids.is_empty() { return Err(bad("scan matched no running processes")); } - eprintln!( - "wraith: scanning {} process(es){}{enforce_note}", - pids.len(), - if scan_all { - " (--all)".to_string() - } else { - format!(" matching {scan_matches:?}") - }, - ); + let filter = if scan_all { + "--all".to_string() + } else { + format!("--match {}", scan_matches.join(",")) + }; + ui_label = format!("scan {filter}"); + if !ui { + eprintln!( + "wraith: scanning {} process(es) {filter}{enforce_note}", + pids.len(), + ); + } Tracer::attach_many(&pids, opts.cfg)? } other => { @@ -183,7 +198,27 @@ fn run(args: Vec) -> io::Result { } }; - let summary = tracer.run(&mut on_event)?; + let summary = if ui { + // Live dashboard: the tracer drives a Dashboard reporter inside a guard + // that restores the terminal on exit (and on Ctrl-C via a signal handler). + let dash = Dashboard::new(ui_label, enforcement, min, json_sink); + let _guard = TerminalGuard::enter()?; + tracer.run_with(dash)? + } else { + // Plain stream: colored log lines to stderr, optional JSONL to the sink. + let mut json_sink = json_sink; + let mut on_event = |ev: &Event| { + if ev.severity >= min { + if !quiet { + let _ = writeln!(io::stderr(), "{}", ev.to_line(color)); + } + if let Some(sink) = json_sink.as_mut() { + let _ = writeln!(sink, "{}", ev.to_json()); + } + } + }; + tracer.run(&mut on_event)? + }; // A short verdict on stderr so a human sees the bottom line. eprintln!( @@ -212,11 +247,13 @@ fn verdict(sev: Option) -> &'static str { } /// Walk `/proc` and return the PIDs to scan. A process is selected when `all` -/// is set, or when any `needle` is a substring of its `comm` or `cmdline`. The -/// scanner's own PID and PID 1 are always excluded; the attach itself (in -/// [`Tracer::attach_many`]) skips anything we lack permission to trace. +/// is set, or when any `needle` is a substring of its `comm` or `cmdline`. Our +/// own process, its whole ancestor chain (the shell/terminal that launched us), +/// and PID 1 are excluded — a `scan` should watch its targets, never the tools +/// that started it. The attach itself (in [`Tracer::attach_many`]) then skips +/// anything we lack permission to trace. fn enumerate_scan_pids(needles: &[String], all: bool) -> Vec { - let self_pid = std::process::id() as i32; + let excluded = ancestor_pids(); let mut out = Vec::new(); let Ok(entries) = std::fs::read_dir("/proc") else { return out; @@ -226,7 +263,7 @@ fn enumerate_scan_pids(needles: &[String], all: bool) -> Vec { let Some(pid) = name.to_str().and_then(|n| n.parse::().ok()) else { continue; }; - if pid == self_pid || pid == 1 { + if pid == 1 || excluded.contains(&pid) { continue; } let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default(); @@ -241,6 +278,37 @@ fn enumerate_scan_pids(needles: &[String], all: bool) -> Vec { out } +/// The set of PIDs from us up to the root of the process tree — our own PID and +/// every ancestor. Used to keep `scan` from attaching to the shell, terminal, +/// or supervisor that launched it (which would otherwise match a broad filter +/// and, being long-lived, keep the trace running forever). +fn ancestor_pids() -> std::collections::HashSet { + let mut set = std::collections::HashSet::new(); + let mut pid = std::process::id() as i32; + // Bounded walk: real trees are shallow, and this guards against a cycle. + for _ in 0..128 { + if !set.insert(pid) { + break; + } + match read_ppid(pid) { + Some(ppid) if ppid > 1 => pid = ppid, + _ => break, + } + } + set +} + +/// The parent PID of `pid` from `/proc//status`, if readable. +fn read_ppid(pid: i32) -> Option { + let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?; + for line in status.lines() { + if let Some(rest) = line.strip_prefix("PPid:") { + return rest.trim().parse().ok(); + } + } + None +} + /// Pure predicate: does a process with this `comm`/`cmdline` pass the filter? /// Split out from the `/proc` walk so it can be unit-tested without a live /// process table. @@ -298,6 +366,7 @@ OPTIONS:\n \ --kill SIGKILL the traced tree on exploitation (CRITICAL)\n \ --match (scan) attach to processes whose name/cmdline matches (repeatable)\n \ --all (scan) attach to every process we're allowed to trace\n \ +--ui live full-screen dashboard (per-process rows + event feed)\n \ --no-stack-pivot disable the ROP stack-pivot heuristic\n \ --audit-sensitive also log sensitive syscalls from legitimate code\n \ --quiet suppress the human stream (pair with --json)\n \ diff --git a/src/lib.rs b/src/lib.rs index 71cb30c..b6bd5d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,7 @@ //! - [`detect`] — the rules and the exploitation-chain correlator. //! - [`event`] — detection events and their JSON form. //! - [`tracer`] — the `ptrace` engine that drives a target. +//! - [`ui`] — the live terminal dashboard (`--ui`). pub mod detect; pub mod event; @@ -45,7 +46,9 @@ pub mod maps; pub mod provenance; pub mod syscalls; pub mod tracer; +pub mod ui; -pub use detect::{Config, Detector, SyscallCtx}; +pub use detect::{Config, Detector, Enforcement, SyscallCtx}; pub use event::{Event, Kind, Severity}; -pub use tracer::{Summary, Tracer}; +pub use tracer::{ProcStat, Reporter, Summary, Tracer}; +pub use ui::{Dashboard, TerminalGuard}; diff --git a/src/tracer.rs b/src/tracer.rs index 766ceb4..c6edc6a 100644 --- a/src/tracer.rs +++ b/src/tracer.rs @@ -22,6 +22,7 @@ use std::collections::HashMap; use std::ffi::CString; use std::fs; use std::io; +use std::time::{Duration, Instant}; use nix::sys::ptrace; use nix::sys::signal::{kill, Signal}; @@ -84,6 +85,70 @@ impl Summary { } } +/// Live, per-process statistics surfaced to a [`Reporter`] during a run, so a +/// UI can show what each traced process is doing in real time. Keyed by +/// thread-group id — one entry per address space, matching how detection is +/// scoped — so every thread of a process rolls up into one row. +#[derive(Debug, Clone)] +pub struct ProcStat { + pub tgid: i32, + pub name: String, + pub syscalls: u64, + pub events: u64, + pub max_severity: Option, + pub alive: bool, +} + +impl ProcStat { + fn new(tgid: i32) -> Self { + ProcStat { + tgid, + name: read_comm(tgid), + syscalls: 0, + events: 0, + max_severity: None, + alive: true, + } + } + + fn record(&mut self, ev: &Event) { + self.events += 1; + self.max_severity = Some(match self.max_severity { + Some(cur) => cur.max(ev.severity), + None => ev.severity, + }); + } +} + +/// The sink for a run's output. Detection events arrive via [`Reporter::event`]; +/// an implementor that also wants live progress overrides [`Reporter::wants_refresh`] +/// to return `true` and paints in [`Reporter::refresh`]. The defaults make a +/// plain event-only consumer (a logging closure, the test harness) pay nothing +/// for progress machinery it doesn't use — the engine skips building snapshots +/// entirely when no one is watching. +pub trait Reporter { + /// One detection fired. + fn event(&mut self, ev: &Event); + /// Whether this reporter wants periodic [`refresh`](Reporter::refresh) + /// snapshots. Default `false`. + fn wants_refresh(&self) -> bool { + false + } + /// A periodic snapshot of every traced process and the running aggregate. + /// Only called when [`wants_refresh`](Reporter::wants_refresh) is `true`. + fn refresh(&mut self, _stats: &[ProcStat], _summary: &Summary) {} +} + +/// Adapts a plain `FnMut(&Event)` closure into a [`Reporter`], so the common +/// "just hand me events" callers keep working unchanged through [`Tracer::run`]. +struct FnReporter(F); + +impl Reporter for FnReporter { + fn event(&mut self, ev: &Event) { + (self.0)(ev) + } +} + /// How the tracee(s) were obtained, so `run` knows how to seed and resume them. enum Target { /// We forked and exec'd it; it is stopped at the post-exec SIGTRAP. We own @@ -223,10 +288,22 @@ impl Tracer { /// Run the trace to completion — following every thread and child the /// target(s) spawn — invoking `on_event` for every detection. The trace - /// ends once the last tracee has exited. - pub fn run(mut self, mut on_event: F) -> io::Result + /// ends once the last tracee has exited. This is the plain, event-only + /// entry point; for a live progress UI, see [`Tracer::run_with`]. + pub fn run(self, on_event: F) -> io::Result where F: FnMut(&Event), + { + self.run_with(FnReporter(on_event)) + } + + /// Run the trace to completion, driving an arbitrary [`Reporter`]. The + /// engine feeds it every detection and — when it asks via + /// [`Reporter::wants_refresh`] — periodic per-process snapshots for a live + /// display. The trace ends once the last tracee has exited. + pub fn run_with(mut self, mut reporter: R) -> io::Result + where + R: Reporter, { let mut summary = Summary::default(); @@ -238,10 +315,13 @@ impl Tracer { Target::ScanAttached(pids) => (pids.clone(), None), }; - // Per-thread phase, and per-address-space (tgid) cached maps. Threads - // that share memory share an `AddrSpace` entry. + // Per-thread phase, per-address-space (tgid) cached maps, and per-process + // live stats. Threads that share memory share an `AddrSpace` and a + // `ProcStat` entry. let mut threads: HashMap = HashMap::new(); let mut spaces: HashMap = HashMap::new(); + let mut stats: HashMap = HashMap::new(); + let mut last_refresh = Instant::now(); // Seed every initial tracee and kick each toward its first syscall stop. for p in &initial { @@ -249,8 +329,10 @@ impl Tracer { let tgid = read_tgid(raw); threads.insert(raw, ThreadState { at_entry: true, tgid }); spaces.entry(tgid).or_insert_with(AddrSpace::new); + stats.entry(tgid).or_insert_with(|| ProcStat::new(tgid)); ptrace::syscall(*p, None).map_err(nix_err)?; } + refresh(&mut reporter, &stats, &summary, &mut last_refresh, true); loop { // Reap any tracee. `ECHILD` means every thread and child has gone. @@ -271,27 +353,35 @@ impl Tracer { let tgid = read_tgid(raw); slot.insert(ThreadState { at_entry: true, tgid }); spaces.entry(tgid).or_insert_with(AddrSpace::new); + stats.entry(tgid).or_insert_with(|| ProcStat::new(tgid)); // Consume this initial stop and let the new tracee run; the // creation SIGSTOP must not be forwarded. let _ = ptrace::syscall(who, None); + refresh(&mut reporter, &stats, &summary, &mut last_refresh, true); continue; } match status { WaitStatus::Exited(_, code) => { + let tgid = threads.get(&raw).map(|t| t.tgid); threads.remove(&raw); if Some(raw) == root_pid { summary.exit_code = Some(code); } + mark_dead_if_last(&mut stats, &threads, tgid); + refresh(&mut reporter, &stats, &summary, &mut last_refresh, true); if threads.is_empty() { break; } } WaitStatus::Signaled(_, sig, _) => { + let tgid = threads.get(&raw).map(|t| t.tgid); threads.remove(&raw); if Some(raw) == root_pid { summary.term_signal = Some(sig as i32); } + mark_dead_if_last(&mut stats, &threads, tgid); + refresh(&mut reporter, &stats, &summary, &mut last_refresh, true); if threads.is_empty() { break; } @@ -299,29 +389,33 @@ impl Tracer { WaitStatus::PtraceSyscall(_) => { let tgid = threads[&raw].tgid; let mut killed = false; + let mut event_fired = false; if threads[&raw].at_entry { summary.syscalls_seen += 1; let space = spaces.entry(tgid).or_insert_with(AddrSpace::new); - let step = self.inspect(who, tgid, space, &mut summary, &mut on_event); - if let Some(nr) = step.nr { + let stat = stats.entry(tgid).or_insert_with(|| ProcStat::new(tgid)); + stat.syscalls += 1; + let step = self.inspect(who, tgid, space, &mut summary, stat, &mut reporter); + event_fired = step.max_severity.is_some(); + let is_mem = step.nr.map(syscalls::is_memory_op).unwrap_or(false); + let critical = step.max_severity == Some(Severity::Critical); + // `space` and `stat` borrows end above; safe to re-borrow. + if is_mem { // A memory op by any thread can change the shared // address space; invalidate the whole group's map. - if syscalls::is_memory_op(nr) { - spaces.get_mut(&tgid).unwrap().dirty = true; - } + spaces.get_mut(&tgid).unwrap().dirty = true; } // Enforce only on a confirmed exploitation (CRITICAL), // and only at the syscall's entry stop — the one moment // the offending syscall has not yet run. - if self.enforcement != Enforcement::Observe - && step.max_severity == Some(Severity::Critical) - { + if self.enforcement != Enforcement::Observe && critical { + let stat = stats.get_mut(&tgid).unwrap(); match self.enforcement { Enforcement::Block => { - self.block_syscall(who, &mut summary, &mut on_event) + self.block_syscall(who, &mut summary, stat, &mut reporter) } Enforcement::Kill => { - self.kill_tree(who, &threads, &mut summary, &mut on_event); + self.kill_tree(who, &threads, &mut summary, stat, &mut reporter); killed = true; } Enforcement::Observe => {} @@ -339,13 +433,18 @@ impl Tracer { } else { ptrace::syscall(who, None).map_err(nix_err)?; } + // Repaint immediately on a detection, else at a throttled rate. + refresh(&mut reporter, &stats, &summary, &mut last_refresh, event_fired); } WaitStatus::Stopped(_, sig) => { // A real signal was delivered to the tracee (not a syscall // stop). Fatal memory-safety signals are worth surfacing as // a possible failed exploit, then we forward the signal. - self.on_signal(who, sig, &mut summary, &mut on_event); + let tgid = threads[&raw].tgid; + let stat = stats.entry(tgid).or_insert_with(|| ProcStat::new(tgid)); + let fired = self.on_signal(who, sig, &mut summary, stat, &mut reporter); ptrace::syscall(who, Some(sig)).map_err(nix_err)?; + refresh(&mut reporter, &stats, &summary, &mut last_refresh, fired); } WaitStatus::PtraceEvent(_, _, _) => { // Clone/fork/exec notification for a tracee we already know; @@ -356,22 +455,25 @@ impl Tracer { WaitStatus::StillAlive => {} } } + // A final frame so the last state is on screen before we return. + refresh(&mut reporter, &stats, &summary, &mut last_refresh, true); Ok(summary) } /// Inspect a single syscall-entry stop for thread `who` in address space /// `space`, reporting the syscall number and the highest severity it /// raised so the caller can decide whether to enforce. - fn inspect( + fn inspect( &mut self, who: Pid, tgid: i32, space: &mut AddrSpace, summary: &mut Summary, - on_event: &mut F, + stat: &mut ProcStat, + reporter: &mut R, ) -> Inspection where - F: FnMut(&Event), + R: Reporter, { let Ok(regs) = ptrace::getregs(who) else { return Inspection { nr: None, max_severity: None }; @@ -404,7 +506,8 @@ impl Tracer { for ev in self.detector.on_syscall(tgid, &ctx, current) { max_severity = Some(max_severity.map_or(ev.severity, |cur: Severity| cur.max(ev.severity))); summary.record(&ev); - on_event(&ev); + stat.record(&ev); + reporter.event(&ev); } Inspection { nr: Some(nr), max_severity } } @@ -413,9 +516,9 @@ impl Tracer { /// syscall number with an invalid value: the kernel then skips the call and /// returns `-ENOSYS`, so the injected code's action never takes effect. The /// tracee lives on, which is what `--block` is for. - fn block_syscall(&self, who: Pid, summary: &mut Summary, on_event: &mut F) + fn block_syscall(&self, who: Pid, summary: &mut Summary, stat: &mut ProcStat, reporter: &mut R) where - F: FnMut(&Event), + R: Reporter, { let Ok(mut regs) = ptrace::getregs(who) else { return }; let syscall = syscalls::name(regs.orig_rax); @@ -438,21 +541,23 @@ impl Tracer { format!("neutralised `{syscall}` from injected code before it executed (--block)"), ); summary.record(&ev); - on_event(&ev); + stat.record(&ev); + reporter.event(&ev); } /// `SIGKILL` every thread-group under trace. Sending the signal to a group /// leader (a tgid) tears down all of its threads at once, so the offending /// process — and every sibling we are following — dies before the syscall /// we stopped at can run. - fn kill_tree( + fn kill_tree( &self, who: Pid, threads: &HashMap, summary: &mut Summary, - on_event: &mut F, + stat: &mut ProcStat, + reporter: &mut R, ) where - F: FnMut(&Event), + R: Reporter, { let (syscall, rip, rsp) = ptrace::getregs(who) .map(|r| (syscalls::name(r.orig_rax), r.rip.wrapping_sub(2), r.rsp)) @@ -478,19 +583,29 @@ impl Tracer { format!("killed traced process tree on `{syscall}` from injected code (--kill)"), ); summary.record(&ev); - on_event(&ev); + stat.record(&ev); + reporter.event(&ev); } - fn on_signal(&self, pid: Pid, sig: Signal, summary: &mut Summary, on_event: &mut F) + /// Surface a fatal memory-safety signal as a possible failed exploit. + /// Returns whether an event was emitted (so the caller can force a repaint). + fn on_signal( + &self, + pid: Pid, + sig: Signal, + summary: &mut Summary, + stat: &mut ProcStat, + reporter: &mut R, + ) -> bool where - F: FnMut(&Event), + R: Reporter, { let fatal = matches!( sig, Signal::SIGSEGV | Signal::SIGILL | Signal::SIGBUS | Signal::SIGABRT ); if !fatal { - return; + return false; } let (rip, rsp) = ptrace::getregs(pid) .map(|r| (r.rip, r.rsp)) @@ -508,7 +623,9 @@ impl Tracer { ), ); summary.record(&ev); - on_event(&ev); + stat.record(&ev); + reporter.event(&ev); + true } } @@ -544,6 +661,57 @@ fn status_pid(status: &WaitStatus) -> Option { } } +/// The short `comm` name of a process (e.g. `nginx`), read once. Falls back to +/// the pid rendered as a string when `/proc//comm` is unreadable. +fn read_comm(pid: i32) -> String { + match fs::read_to_string(format!("/proc/{pid}/comm")) { + Ok(s) if !s.trim().is_empty() => s.trim().to_string(), + _ => pid.to_string(), + } +} + +/// Push a live snapshot to the reporter. When `force` is false the paint is +/// throttled to ~20 fps so a syscall-heavy target doesn't spend its time +/// redrawing; `force` (a detection, a process lifecycle change, the final +/// frame) always paints. Skipped entirely — no snapshot built — when the +/// reporter doesn't want progress, so the plain event path costs nothing. +fn refresh( + reporter: &mut R, + stats: &HashMap, + summary: &Summary, + last: &mut Instant, + force: bool, +) { + if !reporter.wants_refresh() { + return; + } + let now = Instant::now(); + if !force && now.duration_since(*last) < Duration::from_millis(50) { + return; + } + *last = now; + let mut snap: Vec = stats.values().cloned().collect(); + snap.sort_by_key(|s| s.tgid); + reporter.refresh(&snap, summary); +} + +/// Mark a process dead once its last thread has gone. `tgid` is the group the +/// just-exited thread belonged to; if no surviving thread shares it, the +/// process is finished and its row flips to "exited". +fn mark_dead_if_last( + stats: &mut HashMap, + threads: &HashMap, + tgid: Option, +) { + if let Some(tgid) = tgid { + if !threads.values().any(|t| t.tgid == tgid) { + if let Some(s) = stats.get_mut(&tgid) { + s.alive = false; + } + } + } +} + /// The thread-group id of a thread, read once from `/proc//status`. /// Threads of a process share a tgid (and their address space); a `fork`ed /// child gets its own. Falls back to the tid itself if status is unreadable. diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..8c0791c --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,551 @@ +//! A live, full-screen terminal dashboard for a running trace. +//! +//! This is the "advanced" console UI: a [`Dashboard`] that implements +//! [`Reporter`](crate::tracer::Reporter), so the tracer feeds it detections and +//! periodic per-process snapshots and it paints them into an alternate-screen +//! TUI — one row per traced process with live syscall/event counters and a +//! colour-coded verdict, above a scrolling feed of recent detections and an +//! aggregate status bar. +//! +//! In keeping with the rest of Wraith it pulls in no TUI framework: the whole +//! thing is hand-rolled ANSI, the same way events are serialized to JSON by +//! hand. Terminal size comes from a `TIOCGWINSZ` ioctl (via `libc`), and a +//! [`TerminalGuard`] plus a `SIGINT`/`SIGTERM` handler make sure the terminal +//! is always restored — alternate screen left, cursor shown — even on Ctrl-C. + +use std::collections::VecDeque; +use std::io::{self, Write}; +use std::time::Instant; + +use nix::sys::signal::{sigaction, SaFlags, SigAction, SigHandler, SigSet, Signal}; + +use crate::detect::Enforcement; +use crate::event::{Event, Severity}; +use crate::tracer::{ProcStat, Reporter, Summary}; + +/// How many recent detections to retain for the feed (only the tail is drawn). +const FEED_CAP: usize = 512; + +/// The live dashboard. Construct one, hand it to +/// [`Tracer::run_with`](crate::tracer::Tracer::run_with) inside a +/// [`TerminalGuard`] scope, and it renders until the trace ends. +pub struct Dashboard { + mode: String, + enforcement: Enforcement, + min: Severity, + started: Instant, + feed: VecDeque, + json: Option>, +} + +impl Dashboard { + /// `mode` is a short human label for the run (e.g. `scan --match nginx`); + /// `min` gates which detections reach the feed and the JSON sink, matching + /// the plain output's `--min`. An optional `json` sink receives every + /// reported event as JSONL, exactly as in non-UI mode. + pub fn new( + mode: String, + enforcement: Enforcement, + min: Severity, + json: Option>, + ) -> Self { + Dashboard { + mode, + enforcement, + min, + started: Instant::now(), + feed: VecDeque::new(), + json, + } + } + + /// Build the frame as a vector of already-styled lines, each with a visible + /// width no greater than `cols`. Split out from painting so it can be unit + /// tested without a terminal. + fn frame(&self, stats: &[ProcStat], summary: &Summary, cols: usize, rows: usize) -> Vec { + let cols = cols.max(20); + let elapsed = self.elapsed(); + let mut out = Vec::new(); + + // --- title bar ----------------------------------------------------- + out.push(bar( + " WRAITH — runtime exploitation sensor", + &format!("{elapsed} "), + cols, + )); + + // --- subtitle: mode / enforcement / totals ------------------------- + let enforce = match self.enforcement { + Enforcement::Observe => "observe", + Enforcement::Block => "block", + Enforcement::Kill => "kill", + }; + let subtitle = format!( + " {} · enforce: {} · {} proc · {} syscalls · {} events", + self.mode, + enforce, + stats.len(), + human(summary.syscalls_seen), + human(summary.events), + ); + out.push(dim(&clip(&subtitle, cols))); + + // Tiny terminals: stop after the header so we never overflow. + if rows < 8 { + out.push(footer(summary, cols)); + out.truncate(rows.max(1)); + return out; + } + + // --- process table ------------------------------------------------- + // Fixed columns (pid, syscalls, events, verdict + separators) take 41 + // cells; the process name gets whatever is left. + let name_w = cols.saturating_sub(41); + out.push(dim(&padr( + &format!( + " {:>6} {:8} {:>6} {}", + "PID", "PROCESS", "SYSCALLS", "EVENTS", "VERDICT", + name_w = name_w + ), + cols, + ))); + + // Budget the remaining rows between the process table and the feed. + let body = rows.saturating_sub(4); // title, subtitle, header, footer + let list = body.saturating_sub(1); // one line for the feed divider + let proc_cap = (list * 3 / 5).max(1); + let proc_show = stats.len().min(proc_cap).max(1); + let event_rows = list.saturating_sub(proc_show); + + if stats.len() > proc_show { + // Leave the last slot for a "+N more" marker. + for s in stats.iter().take(proc_show - 1) { + out.push(proc_row(s, name_w, cols)); + } + out.push(dim(&clip( + &format!(" … and {} more process(es)", stats.len() - (proc_show - 1)), + cols, + ))); + } else { + for s in stats.iter().take(proc_show) { + out.push(proc_row(s, name_w, cols)); + } + } + + // --- recent-events feed -------------------------------------------- + out.push(dim(&rule("recent detections", cols))); + if self.feed.is_empty() { + out.push(dim(" (no detections yet)")); + } else { + let shown: Vec<&Event> = self.feed.iter().rev().take(event_rows).collect(); + for ev in shown.iter().rev() { + out.push(event_row(ev, cols)); + } + } + + // --- status bar ---------------------------------------------------- + // Pad up to the footer row so the bar sits at the bottom edge. + while out.len() < rows.saturating_sub(1) { + out.push(String::new()); + } + out.push(footer(summary, cols)); + out.truncate(rows); + out + } + + fn elapsed(&self) -> String { + let s = self.started.elapsed().as_secs(); + format!("{:02}:{:02}", s / 60, s % 60) + } + + /// Repaint the whole screen from the current snapshot. + fn paint(&mut self, stats: &[ProcStat], summary: &Summary) { + let (cols, rows) = term_size(); + let lines = self.frame(stats, summary, cols as usize, rows as usize); + // Home the cursor, redraw each line clearing to end-of-line, then clear + // everything below — one write, so the frame updates without flicker. + let mut buf = String::from("\x1b[H"); + for (i, line) in lines.iter().enumerate() { + if i > 0 { + buf.push_str("\r\n"); + } + buf.push_str(line); + buf.push_str("\x1b[K"); + } + buf.push_str("\x1b[J"); + let mut err = io::stderr(); + let _ = err.write_all(buf.as_bytes()); + let _ = err.flush(); + } +} + +impl Reporter for Dashboard { + fn event(&mut self, ev: &Event) { + if ev.severity < self.min { + return; + } + if let Some(sink) = self.json.as_mut() { + let _ = writeln!(sink, "{}", ev.to_json()); + } + self.feed.push_back(ev.clone()); + if self.feed.len() > FEED_CAP { + self.feed.pop_front(); + } + } + + fn wants_refresh(&self) -> bool { + true + } + + fn refresh(&mut self, stats: &[ProcStat], summary: &Summary) { + self.paint(stats, summary); + } +} + +// --------------------------------------------------------------------------- +// Line builders +// --------------------------------------------------------------------------- + +fn proc_row(s: &ProcStat, name_w: usize, cols: usize) -> String { + let (color, label) = verdict_style(s.max_severity); + let dot = if s.alive { '●' } else { '○' }; + let verd_plain = padr(&format!("{dot} {label}"), 14); + let prefix = format!( + " {:>6} {} {:>8} {:>6} ", + s.tgid, + padr(&s.name, name_w), + clip(&human(s.syscalls), 8), + clip(&human(s.events), 6), + ); + // Colour the verdict inline when the whole row fits; on a terminal too + // narrow for that, fall back to a plain clip so no ANSI code is cut. + if prefix.chars().count() + 14 <= cols { + format!("{prefix}\x1b[{color}m{verd_plain}\x1b[0m") + } else { + clip(&format!("{prefix}{verd_plain}"), cols) + } +} + +fn event_row(ev: &Event, cols: usize) -> String { + let color = sev_color(ev.severity); + let head = format!("\x1b[{color}m{:>9}\x1b[0m", ev.severity.as_str()); + let rest = format!( + " {} {} @ {:#x} [{}] {}", + ev.kind.as_str(), + ev.syscall, + ev.rip, + ev.origin, + ev.detail, + ); + format!("{head}{}", clip(&rest, cols.saturating_sub(9))) +} + +fn footer(summary: &Summary, cols: usize) -> String { + let (color, label) = verdict_style(summary.max_severity); + let left = format!(" VERDICT: {label}"); + let right = format!( + "{} syscalls · {} events ", + human(summary.syscalls_seen), + human(summary.events) + ); + // Colour the whole bar by the current verdict for an at-a-glance read. + let plain = bar_plain(&left, &right, cols); + format!("\x1b[7;{color}m{plain}\x1b[0m") +} + +/// A reverse-video bar with `left` flushed left and `right` flushed right. +fn bar(left: &str, right: &str, cols: usize) -> String { + format!("\x1b[7m{}\x1b[0m", bar_plain(left, right, cols)) +} + +fn bar_plain(left: &str, right: &str, cols: usize) -> String { + let l = clip(left, cols); + let ll = l.chars().count(); + let rem = cols - ll; + let r = clip(right, rem); + let gap = rem - r.chars().count(); + let mut s = String::with_capacity(cols); + s.push_str(&l); + for _ in 0..gap { + s.push(' '); + } + s.push_str(&r); + s +} + +/// A dim horizontal rule with an inline label: `── recent detections ──────`. +fn rule(label: &str, cols: usize) -> String { + let head = format!("── {label} "); + let n = head.chars().count(); + let mut s = head; + for _ in n..cols { + s.push('─'); + } + clip(&s, cols) +} + +// --------------------------------------------------------------------------- +// Styling helpers +// --------------------------------------------------------------------------- + +fn dim(s: &str) -> String { + format!("\x1b[2m{s}\x1b[0m") +} + +/// The ANSI colour and short label for a verdict derived from a max severity. +fn verdict_style(sev: Option) -> (&'static str, &'static str) { + match sev { + None => ("2", "clean"), + Some(Severity::Info) => ("36", "info"), + Some(Severity::Warn) => ("33", "minor"), + Some(Severity::High) => ("35", "suspicious"), + Some(Severity::Critical) => ("1;31", "EXPLOITATION"), + } +} + +fn sev_color(sev: Severity) -> &'static str { + match sev { + Severity::Info => "36", + Severity::Warn => "33", + Severity::High => "35", + Severity::Critical => "1;31", + } +} + +/// Compact a count: `950`, `1.2k`, `3.4M`, `1.1G`. +fn human(n: u64) -> String { + const UNITS: [(&str, f64); 4] = [("T", 1e12), ("G", 1e9), ("M", 1e6), ("k", 1e3)]; + for (suffix, scale) in UNITS { + if n as f64 >= scale { + return format!("{:.1}{}", n as f64 / scale, suffix); + } + } + n.to_string() +} + +/// Truncate `s` to at most `w` visible columns, marking a cut with `…`. +fn clip(s: &str, w: usize) -> String { + let n = s.chars().count(); + if n <= w { + return s.to_string(); + } + match w { + 0 => String::new(), + 1 => "…".to_string(), + _ => { + let mut out: String = s.chars().take(w - 1).collect(); + out.push('…'); + out + } + } +} + +/// Left-align `s` in a field of exactly `w` columns (clipping if longer). +fn padr(s: &str, w: usize) -> String { + let c = clip(s, w); + let n = c.chars().count(); + let mut out = c; + for _ in n..w { + out.push(' '); + } + out +} + +// --------------------------------------------------------------------------- +// Terminal control +// --------------------------------------------------------------------------- + +/// The terminal's `(cols, rows)`, or a sane default if it can't be queried. +fn term_size() -> (u16, u16) { + unsafe { + let mut ws: libc::winsize = std::mem::zeroed(); + if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut ws) == 0 + && ws.ws_row > 0 + && ws.ws_col > 0 + { + (ws.ws_col, ws.ws_row) + } else { + (80, 24) + } + } +} + +/// Bytes that restore the terminal: show the cursor, leave the alternate +/// screen. Written both by [`TerminalGuard::drop`] and, for Ctrl-C, by the +/// signal handler (where only async-signal-safe work is allowed). +const RESTORE: &[u8] = b"\x1b[?25h\x1b[?1049l"; + +/// Enters the alternate screen and hides the cursor on construction; restores +/// both on drop. Hold it for the lifetime of the dashboard. +pub struct TerminalGuard; + +impl TerminalGuard { + /// Switch to the alternate screen, hide the cursor, and arm a signal + /// handler that restores the terminal if the process is interrupted. + pub fn enter() -> io::Result { + install_restore_handler(); + let mut err = io::stderr(); + // Enter alternate screen, hide cursor, clear it. + err.write_all(b"\x1b[?1049h\x1b[?25l\x1b[2J")?; + err.flush()?; + Ok(TerminalGuard) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let mut err = io::stderr(); + let _ = err.write_all(RESTORE); + let _ = err.flush(); + } +} + +extern "C" fn on_term_signal(_sig: i32) { + // Only async-signal-safe calls here: a raw write of a constant, then _exit. + unsafe { + let _ = libc::write( + libc::STDERR_FILENO, + RESTORE.as_ptr() as *const libc::c_void, + RESTORE.len(), + ); + libc::_exit(130); + } +} + +fn install_restore_handler() { + let action = SigAction::new( + SigHandler::Handler(on_term_signal), + SaFlags::empty(), + SigSet::empty(), + ); + // Best-effort: a failure to install just means Ctrl-C falls back to the + // default (terminal not restored), which the Drop guard still handles on a + // normal return. + unsafe { + let _ = sigaction(Signal::SIGINT, &action); + let _ = sigaction(Signal::SIGTERM, &action); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::{Event, Kind}; + + fn strip_ansi(s: &str) -> String { + let mut out = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' { + // Skip until the terminating letter of the escape sequence. + for e in chars.by_ref() { + if e.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out + } + + fn stat(tgid: i32, name: &str, sys: u64, ev: u64, sev: Option, alive: bool) -> ProcStat { + ProcStat { + tgid, + name: name.to_string(), + syscalls: sys, + events: ev, + max_severity: sev, + alive, + } + } + + fn dash() -> Dashboard { + Dashboard::new("scan --match nginx".into(), Enforcement::Kill, Severity::Warn, None) + } + + #[test] + fn human_scales() { + assert_eq!(human(0), "0"); + assert_eq!(human(999), "999"); + assert_eq!(human(1_500), "1.5k"); + assert_eq!(human(2_400_000), "2.4M"); + assert_eq!(human(3_000_000_000), "3.0G"); + } + + #[test] + fn clip_and_pad_respect_width() { + assert_eq!(clip("hello", 10), "hello"); + assert_eq!(clip("hello", 4), "hel…"); + assert_eq!(clip("hello", 1), "…"); + assert_eq!(padr("hi", 5).chars().count(), 5); + assert_eq!(padr("toolongname", 4), "too…"); + } + + #[test] + fn every_frame_line_fits_width() { + let d = dash(); + let stats = vec![ + stat(100, "nginx", 1200, 0, None, true), + stat(101, "nginx: worker", 3400, 3, Some(Severity::Critical), true), + stat(102, "redis-server", 890, 0, Some(Severity::Warn), false), + ]; + let summary = Summary { + syscalls_seen: 5490, + events: 3, + max_severity: Some(Severity::Critical), + ..Summary::default() + }; + for (cols, rows) in [(80usize, 24usize), (120, 40), (40, 12), (200, 60)] { + let frame = d.frame(&stats, &summary, cols, rows); + assert!(frame.len() <= rows, "frame taller than terminal"); + for line in &frame { + let w = strip_ansi(line).chars().count(); + assert!(w <= cols, "line {w} cols > {cols}: {:?}", strip_ansi(line)); + } + } + } + + #[test] + fn frame_shows_processes_and_verdict() { + let d = dash(); + let stats = vec![stat(4242, "nginx", 1200, 3, Some(Severity::Critical), true)]; + let summary = Summary { + syscalls_seen: 1200, + events: 3, + max_severity: Some(Severity::Critical), + ..Summary::default() + }; + let text = d + .frame(&stats, &summary, 100, 24) + .iter() + .map(|l| strip_ansi(l)) + .collect::>() + .join("\n"); + assert!(text.contains("nginx"), "process name missing:\n{text}"); + assert!(text.contains("4242"), "pid missing"); + assert!(text.contains("EXPLOITATION"), "critical verdict missing:\n{text}"); + assert!(text.contains("scan --match nginx"), "mode label missing"); + } + + #[test] + fn feed_records_and_caps() { + let mut d = dash(); + // Below-min events are dropped from the feed. + d.event(&Event::now(1, Severity::Info, Kind::SensitiveCall, "read", 0, 0, "libc", "info")); + assert!(d.feed.is_empty(), "info event below warn floor must not be fed"); + for i in 0..(FEED_CAP + 50) { + d.event(&Event::now(1, Severity::High, Kind::WxViolation, "mmap", i as u64, 0, "anon", "x")); + } + assert_eq!(d.feed.len(), FEED_CAP, "feed must be capped"); + } + + #[test] + fn exited_process_shows_hollow_dot() { + let alive = proc_row(&stat(1, "x", 0, 0, None, true), 10, 80); + let dead = proc_row(&stat(1, "x", 0, 0, None, false), 10, 80); + assert!(alive.contains('●')); + assert!(dead.contains('○')); + } +} diff --git a/tests/integration.rs b/tests/integration.rs index 839baf0..4a695dd 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -16,7 +16,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use wraith::detect::Config; use wraith::event::{Event, Kind, Severity}; -use wraith::tracer::{Summary, Tracer}; +use wraith::tracer::{ProcStat, Reporter, Summary, Tracer}; /// The tracer reaps its whole process tree with `waitpid(-1)`, which is exactly /// right for the real sensor (a dedicated process with a single tracer) but @@ -251,3 +251,57 @@ fn observe_mode_never_intervenes() { "observe mode must not enforce" ); } + +/// A [`Reporter`] that records what the live-UI path would see, so we can test +/// the per-process stats plumbing that feeds the dashboard. +#[derive(Clone)] +struct Recorder { + events: Arc>, + refreshes: Arc>, + snapshot: Arc>>, +} + +impl Reporter for Recorder { + fn event(&mut self, _ev: &Event) { + *self.events.lock().unwrap() += 1; + } + fn wants_refresh(&self) -> bool { + true + } + fn refresh(&mut self, stats: &[ProcStat], _summary: &Summary) { + *self.refreshes.lock().unwrap() += 1; + *self.snapshot.lock().unwrap() = stats.to_vec(); + } +} + +#[test] +fn run_with_surfaces_per_process_stats() { + // The live-UI path (run_with + a refreshing Reporter) must accumulate + // per-process stats: the shellcode simulator should show syscalls, events, + // and a CRITICAL max-severity for its single process. + let _guard = trace_lock().lock().unwrap_or_else(|e| e.into_inner()); + + let bin = env!("CARGO_BIN_EXE_shellcode-sim"); + let rec = Recorder { + events: Arc::new(Mutex::new(0)), + refreshes: Arc::new(Mutex::new(0)), + snapshot: Arc::new(Mutex::new(Vec::new())), + }; + let tracer = match Tracer::spawn(&[bin.to_string()], Config::default()) { + Ok(t) => t, + Err(_) => return, // ptrace unavailable — skip + }; + let summary = tracer.run_with(rec.clone()).expect("run_with failed"); + + assert!(*rec.refreshes.lock().unwrap() > 0, "reporter was never refreshed"); + assert!(*rec.events.lock().unwrap() > 0, "reporter saw no events"); + + let snap = rec.snapshot.lock().unwrap(); + assert_eq!(snap.len(), 1, "expected exactly one traced process"); + let p = &snap[0]; + assert!(p.syscalls > 0, "process should have observed syscalls"); + assert!(p.events > 0, "process should have accumulated events"); + assert_eq!(p.max_severity, Some(Severity::Critical)); + assert!(!p.alive, "process should be marked exited by the final frame"); + assert_eq!(summary.max_severity, Some(Severity::Critical)); +}