Skip to content

Commit f26f152

Browse files
authored
Merge pull request #4 from grloper/claude/wraith-security-sensor-0pjajs
Add live TUI dashboard (--ui) with a scan-flow screenshot
2 parents 8e5642f + 3e6d559 commit f26f152

7 files changed

Lines changed: 977 additions & 66 deletions

File tree

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ exploitation*, not the *identity of the bug*.
1111
> Built as the runtime-defence companion to [`ghost`](https://github.com/pandaadir05/ghost).
1212
> `ghost` finds weaknesses; `wraith` catches them being used.
1313
14+
![Wraith's live scan dashboard: three clean workers and one process caught mid-exploitation](docs/scan-demo.svg)
15+
16+
<p align="center"><em><code>wraith scan --ui --match netd</code> — one row per process with live syscall/event
17+
counters, and a correlated exploitation verdict the instant injected code issues a syscall.</em></p>
18+
1419
---
1520

1621
## The idea
@@ -115,6 +120,7 @@ Tuning:
115120
```
116121
--jit-critical treat anonymous-exec pages as HIGH (targets that never JIT)
117122
--trust-region A-B treat the hex range [A,B) as legitimate JIT (repeatable)
123+
--ui live full-screen dashboard instead of the log stream
118124
--no-stack-pivot disable the ROP stack-pivot heuristic
119125
--audit-sensitive log sensitive syscalls from legitimate code too
120126
--min <sev> floor: info|warn|high|critical (default warn)
@@ -194,6 +200,27 @@ target. Caveats worth knowing:
194200
`PTRACE_O_EXITKILL`: stopping Wraith leaves every scanned process running.
195201
- **Post-attach threads only.** As with `attach`, sibling threads that already
196202
existed before Wraith attached aren't picked up automatically (see below).
203+
- **Never traces itself.** `scan` excludes its own process and its whole
204+
ancestor chain (the shell/terminal that launched it), so a broad `--match`
205+
can't accidentally attach to — and hang on — the tool that started it.
206+
207+
### Live dashboard (`--ui`)
208+
209+
Add `--ui` to any mode for a full-screen terminal dashboard instead of the
210+
scrolling log — the picture at the top of this README is exactly that, on the
211+
scan flow:
212+
213+
```bash
214+
sudo wraith scan --ui --match nginx
215+
```
216+
217+
One row per traced process with live syscall/event counters and a colour-coded
218+
verdict (`clean``suspicious``EXPLOITATION`), above a feed of the most
219+
recent detections and a status bar carrying the aggregate verdict. It repaints
220+
on every detection and at ~20 fps otherwise, restores the terminal cleanly on
221+
exit or Ctrl-C, and still honours `--json` (the event stream is written to the
222+
sink underneath the UI). The dashboard is hand-rolled ANSI — no TUI dependency
223+
— so the sensor's supply chain stays `nix` + `libc` only.
197224

198225
---
199226

@@ -211,6 +238,7 @@ carry the smallest supply chain you can manage. The engine links only `nix` and
211238
├─ detect.rs the invariants + the exploitation-chain correlator
212239
├─ event.rs detection events + their JSONL form
213240
├─ tracer.rs the ptrace engine (spawn/attach/scan, thread-following, enforcement)
241+
├─ ui.rs the live terminal dashboard (--ui), hand-rolled ANSI
214242
└─ bin/
215243
├─ wraith.rs the CLI sensor
216244
├─ benign.rs false-positive control target
@@ -275,7 +303,7 @@ ranges).
275303

276304
```bash
277305
cargo build --release
278-
cargo test # 36 unit + 9 end-to-end tests
306+
cargo test # 42 unit + 10 end-to-end tests
279307
cargo clippy --all-targets
280308
./demo.sh # side-by-side benign vs. exploitation run
281309
```

docs/scan-demo.svg

Lines changed: 38 additions & 0 deletions
Loading

src/bin/wraith.rs

Lines changed: 101 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
//! --kill SIGKILL the traced tree on detection
1515
//! --match <substr> (scan) attach to processes whose name/cmdline matches
1616
//! --all (scan) attach to every process we're allowed to trace
17+
//! --ui live full-screen dashboard instead of the log stream
1718
//! --no-stack-pivot disable the ROP stack-pivot heuristic
1819
//! --audit-sensitive also log sensitive syscalls from legitimate code
1920
//! --quiet suppress the human event stream (use with --json)
@@ -26,6 +27,7 @@ use std::process::ExitCode;
2627
use wraith::detect::{Config, Enforcement};
2728
use wraith::event::{Event, Severity};
2829
use wraith::tracer::Tracer;
30+
use wraith::ui::{Dashboard, TerminalGuard};
2931

3032
fn main() -> ExitCode {
3133
let args: Vec<String> = std::env::args().skip(1).collect();
@@ -42,6 +44,7 @@ struct Opts {
4244
json: Option<String>,
4345
min: Severity,
4446
quiet: bool,
47+
ui: bool,
4548
cfg: Config,
4649
}
4750

@@ -58,6 +61,7 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
5861
json: None,
5962
min: Severity::Warn,
6063
quiet: false,
64+
ui: false,
6165
cfg: Config::default(),
6266
};
6367

@@ -98,6 +102,7 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
98102
scan_matches.push(v.clone());
99103
}
100104
"--all" => scan_all = true,
105+
"--ui" | "--dashboard" => opts.ui = true,
101106
"--no-stack-pivot" => opts.cfg.detect_stack_pivot = false,
102107
"--audit-sensitive" => opts.cfg.audit_sensitive = true,
103108
"--quiet" => opts.quiet = true,
@@ -114,45 +119,52 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
114119

115120
// Set up output sinks.
116121
let color = io::stderr().is_terminal();
117-
let mut json_sink: Option<Box<dyn Write>> = match opts.json.as_deref() {
122+
let json_sink: Option<Box<dyn Write>> = match opts.json.as_deref() {
118123
None => None,
119124
Some("-") => Some(Box::new(io::stdout())),
120125
Some(path) => Some(Box::new(File::create(path)?)),
121126
};
122127
let min = opts.min;
123128
let quiet = opts.quiet;
129+
let ui = opts.ui;
130+
let enforcement = opts.cfg.enforcement;
124131

125-
let mut on_event = |ev: &Event| {
126-
if ev.severity >= min {
127-
if !quiet {
128-
let _ = writeln!(io::stderr(), "{}", ev.to_line(color));
129-
}
130-
if let Some(sink) = json_sink.as_mut() {
131-
let _ = writeln!(sink, "{}", ev.to_json());
132-
}
133-
}
134-
};
132+
if ui && !io::stderr().is_terminal() {
133+
return Err(bad(
134+
"--ui needs an interactive terminal on stderr; drop --ui, or use --json for a stream",
135+
));
136+
}
135137

136-
let enforce_note = match opts.cfg.enforcement {
138+
let enforce_note = match enforcement {
137139
Enforcement::Observe => "",
138140
Enforcement::Block => " [enforcing: block]",
139141
Enforcement::Kill => " [enforcing: kill]",
140142
};
141143

144+
// A short label for the run, used by the dashboard header. Assigned by
145+
// every non-returning arm below.
146+
let ui_label;
147+
142148
let tracer = match mode.as_str() {
143149
"run" => {
144150
if target.is_empty() {
145151
return Err(bad("no program to run; use: wraith run -- <program> [args...]"));
146152
}
147-
eprintln!(
148-
"wraith: monitoring `{}` (provenance mode){enforce_note}",
149-
target.join(" ")
150-
);
153+
ui_label = format!("run — {}", target.join(" "));
154+
if !ui {
155+
eprintln!(
156+
"wraith: monitoring `{}` (provenance mode){enforce_note}",
157+
target.join(" ")
158+
);
159+
}
151160
Tracer::spawn(&target, opts.cfg)?
152161
}
153162
"attach" => {
154163
let pid = attach_pid.ok_or_else(|| bad("attach needs a pid"))?;
155-
eprintln!("wraith: attaching to pid {pid}{enforce_note}");
164+
ui_label = format!("attach — pid {pid}");
165+
if !ui {
166+
eprintln!("wraith: attaching to pid {pid}{enforce_note}");
167+
}
156168
Tracer::attach(pid, opts.cfg)?
157169
}
158170
"scan" => {
@@ -165,15 +177,18 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
165177
if pids.is_empty() {
166178
return Err(bad("scan matched no running processes"));
167179
}
168-
eprintln!(
169-
"wraith: scanning {} process(es){}{enforce_note}",
170-
pids.len(),
171-
if scan_all {
172-
" (--all)".to_string()
173-
} else {
174-
format!(" matching {scan_matches:?}")
175-
},
176-
);
180+
let filter = if scan_all {
181+
"--all".to_string()
182+
} else {
183+
format!("--match {}", scan_matches.join(","))
184+
};
185+
ui_label = format!("scan {filter}");
186+
if !ui {
187+
eprintln!(
188+
"wraith: scanning {} process(es) {filter}{enforce_note}",
189+
pids.len(),
190+
);
191+
}
177192
Tracer::attach_many(&pids, opts.cfg)?
178193
}
179194
other => {
@@ -183,7 +198,27 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
183198
}
184199
};
185200

186-
let summary = tracer.run(&mut on_event)?;
201+
let summary = if ui {
202+
// Live dashboard: the tracer drives a Dashboard reporter inside a guard
203+
// that restores the terminal on exit (and on Ctrl-C via a signal handler).
204+
let dash = Dashboard::new(ui_label, enforcement, min, json_sink);
205+
let _guard = TerminalGuard::enter()?;
206+
tracer.run_with(dash)?
207+
} else {
208+
// Plain stream: colored log lines to stderr, optional JSONL to the sink.
209+
let mut json_sink = json_sink;
210+
let mut on_event = |ev: &Event| {
211+
if ev.severity >= min {
212+
if !quiet {
213+
let _ = writeln!(io::stderr(), "{}", ev.to_line(color));
214+
}
215+
if let Some(sink) = json_sink.as_mut() {
216+
let _ = writeln!(sink, "{}", ev.to_json());
217+
}
218+
}
219+
};
220+
tracer.run(&mut on_event)?
221+
};
187222

188223
// A short verdict on stderr so a human sees the bottom line.
189224
eprintln!(
@@ -212,11 +247,13 @@ fn verdict(sev: Option<Severity>) -> &'static str {
212247
}
213248

214249
/// Walk `/proc` and return the PIDs to scan. A process is selected when `all`
215-
/// is set, or when any `needle` is a substring of its `comm` or `cmdline`. The
216-
/// scanner's own PID and PID 1 are always excluded; the attach itself (in
217-
/// [`Tracer::attach_many`]) skips anything we lack permission to trace.
250+
/// is set, or when any `needle` is a substring of its `comm` or `cmdline`. Our
251+
/// own process, its whole ancestor chain (the shell/terminal that launched us),
252+
/// and PID 1 are excluded — a `scan` should watch its targets, never the tools
253+
/// that started it. The attach itself (in [`Tracer::attach_many`]) then skips
254+
/// anything we lack permission to trace.
218255
fn enumerate_scan_pids(needles: &[String], all: bool) -> Vec<i32> {
219-
let self_pid = std::process::id() as i32;
256+
let excluded = ancestor_pids();
220257
let mut out = Vec::new();
221258
let Ok(entries) = std::fs::read_dir("/proc") else {
222259
return out;
@@ -226,7 +263,7 @@ fn enumerate_scan_pids(needles: &[String], all: bool) -> Vec<i32> {
226263
let Some(pid) = name.to_str().and_then(|n| n.parse::<i32>().ok()) else {
227264
continue;
228265
};
229-
if pid == self_pid || pid == 1 {
266+
if pid == 1 || excluded.contains(&pid) {
230267
continue;
231268
}
232269
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<i32> {
241278
out
242279
}
243280

281+
/// The set of PIDs from us up to the root of the process tree — our own PID and
282+
/// every ancestor. Used to keep `scan` from attaching to the shell, terminal,
283+
/// or supervisor that launched it (which would otherwise match a broad filter
284+
/// and, being long-lived, keep the trace running forever).
285+
fn ancestor_pids() -> std::collections::HashSet<i32> {
286+
let mut set = std::collections::HashSet::new();
287+
let mut pid = std::process::id() as i32;
288+
// Bounded walk: real trees are shallow, and this guards against a cycle.
289+
for _ in 0..128 {
290+
if !set.insert(pid) {
291+
break;
292+
}
293+
match read_ppid(pid) {
294+
Some(ppid) if ppid > 1 => pid = ppid,
295+
_ => break,
296+
}
297+
}
298+
set
299+
}
300+
301+
/// The parent PID of `pid` from `/proc/<pid>/status`, if readable.
302+
fn read_ppid(pid: i32) -> Option<i32> {
303+
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
304+
for line in status.lines() {
305+
if let Some(rest) = line.strip_prefix("PPid:") {
306+
return rest.trim().parse().ok();
307+
}
308+
}
309+
None
310+
}
311+
244312
/// Pure predicate: does a process with this `comm`/`cmdline` pass the filter?
245313
/// Split out from the `/proc` walk so it can be unit-tested without a live
246314
/// process table.
@@ -298,6 +366,7 @@ OPTIONS:\n \
298366
--kill SIGKILL the traced tree on exploitation (CRITICAL)\n \
299367
--match <substr> (scan) attach to processes whose name/cmdline matches (repeatable)\n \
300368
--all (scan) attach to every process we're allowed to trace\n \
369+
--ui live full-screen dashboard (per-process rows + event feed)\n \
301370
--no-stack-pivot disable the ROP stack-pivot heuristic\n \
302371
--audit-sensitive also log sensitive syscalls from legitimate code\n \
303372
--quiet suppress the human stream (pair with --json)\n \

src/lib.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,17 @@
3838
//! - [`detect`] — the rules and the exploitation-chain correlator.
3939
//! - [`event`] — detection events and their JSON form.
4040
//! - [`tracer`] — the `ptrace` engine that drives a target.
41+
//! - [`ui`] — the live terminal dashboard (`--ui`).
4142
4243
pub mod detect;
4344
pub mod event;
4445
pub mod maps;
4546
pub mod provenance;
4647
pub mod syscalls;
4748
pub mod tracer;
49+
pub mod ui;
4850

49-
pub use detect::{Config, Detector, SyscallCtx};
51+
pub use detect::{Config, Detector, Enforcement, SyscallCtx};
5052
pub use event::{Event, Kind, Severity};
51-
pub use tracer::{Summary, Tracer};
53+
pub use tracer::{ProcStat, Reporter, Summary, Tracer};
54+
pub use ui::{Dashboard, TerminalGuard};

0 commit comments

Comments
 (0)