Repository navigation
Architecture
provctl is one capture engine with several query views on top of it. The design goal: the kernel side stays dumb (capture + tag with identity, hand to userspace), and every interesting question — "where did this file come from", "what did this process do" — is a query over one durable event log, not a separate subsystem.
kernel userspace
┌─────────────────────────────┐ ┌──────────────────────────────┐
│ tp/sched_process_fork │ │ │
│ tp/sched_process_exec │ │ engine.Engine │
│ tp/sched_process_exit │──▶│ - loads probes (bpf2go) │
│ fentry/security_file_open │ │ - attaches all hooks │
│ fentry/tcp_v4_connect │ │ - reads BPF ring buffer │
│ fentry/tcp_v6_connect │ │ - decodes -> model.Event │
└──────────────┬────────────────┘ └───────────────┬──────────────┘
│ BPF ring buffer │
│ (struct event, provctl.h) ▼
│ ┌──────────────────────┐
│ │ store.Store (SQLite) │
└─────────────────────────▶│ processes │
│ file_events │
│ net_events │
│ activity_log │
└──────────┬────────────┘
│
┌──────────────────┬───────────────┼───────────────────┬──────────────────┐
▼ ▼ ▼ ▼
`provctl trace` `provctl timeline` `provctl ps` `provctl top`
file provenance process flight process tree live TUI (separate
recorder process, polls the
same store)
See BPF Hooks Reference for what each hook attaches to and why.
The object is compiled against one machine's /sys/kernel/btf/vmlinux, but
field-offset relocations are resolved at load time against whatever
kernel provctl actually runs on. That's what lets a single compiled binary
run across kernel versions that expose the same fields, without
recompiling per-target.
- At startup,
watchsnapshots/proc(internal/procscan) and seeds the store with one synthetic exec record per already-running process, using each process's real start time (btime+starttime). Without this, the first events a fresh daemon sees come from pids it never watched start — sotracecould report that a path was opened but not by whom, which is the whole question it exists to answer. Most activity right after startup comes from long-lived processes (shells, browsers, desktop apps). - Each hook fills a
struct event(internal/bpf/provctl.h) with a timestamp, identity (pid/ppid/uid/gid/comm), and a type-specific payload (exec path, opened path, or destination address), and submits it to aBPF_RINGBUFmap. -
engine.Engine(Go, via cilium/ebpf) reads the ring buffer and decodes each record by explicit byte offset — deliberately not relying on Go struct layout matching C struct layout, so the two representations can't silently drift apart without a hard decode error. -
store.Storepersists every event to SQLite: oneprocessesrow per process lifetime (keyed by(pid, started_ns)since pids get reused), plus append-onlyfile_eventsandnet_eventslogs. The database runs in WAL mode withsynchronous=NORMAL, andwatchbatches events into one transaction per 150ms (or 256 events, whichever comes first) — during testing a singleexecve()produced dozens of file-open events within the same millisecond, and per-event fsync would otherwise make the store the bottleneck. - The CLI subcommands are pure queries over that store:
-
trace <path>— find the earliest open of the path, the process that did it, and what that process connected to just before (the likely download source); then follow later opens (copy/extract) and any execution of the path, including what that process connected to or spawned. -
timeline <pid>— merge that process's own start/exit with every file it opened, every connection it made, and every child it spawned, sorted by time. -
ps— the process tree reconstructed fromprocesses.ppid. -
top— a live TUI (bubbletea/lipgloss) that polls the store from a separate process whilewatchwrites to it: a process tree (sharingps's tree-rendering code) alongside a scrollingactivity_logfeed.activity_logis a plain append-only table — one rendered line per live-observed event, via the samemodel.Event.Describe()watch's own output uses — kept separate from the other tables specifically sotopdoesn't have to re-derive a chronological feed from rows that mutate in place (a process's row is updated by EXEC, then again by EXIT). The/procstartup backfill does not write toactivity_log(Store.SeedvsStore.Apply) — those are synthetic, not live-observed, and logging hundreds of them at everywatchstartup would flood the feed. The incremental poll query is also intentionally not capped at the same small limit as what's displayed: a single chatty process (a browser polling/proc, observed generating 1000+ events well under a second) can otherwise outpace a tightly-bounded fetch, permanently widening the gap between the feed and "now" instead of catching up between bursts.
-
For what this doesn't yet handle, see Known Limitations & Roadmap.