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 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 @@
+
\ 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));
+}