Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

docs/v2/graph-filter-parts/part-4.md

Part 4 of 5 of docs/v2/graph-filter.md (generated; edit the source)

8. The decision: where the position lives, and why

Decided by the owner, with the outside review: the three parts of section 1. The position lives in the kernel, in the eBPF graph follower. This section gives the reasons, the invariants, what a host must have, and the fallback.

Why three parts.

The invariants.

  1. Enforce or Audit, a field of the policy. Enforce: an invalid step kills. Audit: it is logged and counted and the call runs; Audit never returns an error.
  2. The kill is late by at most one call on the tracepoint path, and the floor allows that call anyway. Where an LSM hook exists for the operation, the refusal is before it.
  3. Thread creation. A new thread starts on the entry of its spawn site. A spawn the graph does not have is an invalid step.
  4. Async. Per kernel thread, the union over the tasks that can run. Sound; it costs precision. Task tags written by the program are not used.
  5. Widening. dyn and fn-pointer sites only, limited to what the reached code creates. A C function without a map entry blocks graph mode for that program.
  6. The signed record names the mode, Enforce or Audit, the floor's program digest, the graph digest (SHA-256) and the violation count (section 5).

What it needs.

The fallback, plainly. Graph mode does not work out of the box. Not every host has these kernel options, and a rootless runner cannot load eBPF. There the workload runs in static mode, and the record says static.

One slow variant stays documented for hosts without eBPF: positions held by the supervisor. It is never chosen by default, only by name in the policy, and the record names it. The seccomp program then has three answers: never → kill; a call that is allowed at every position → ALLOW; a call whose answer depends on the position → USER_NOTIF, and the supervisor answers CONTINUE or an errno before the call runs. Its limits:

Cost: to measure. None of these numbers is measured for Heimdall yet. Build step 10 measures them.

followed workload, per call:    t_bpf (the floor, as today)  +  t_follow (one eBPF run, one map lookup)
other processes on the host:    t_idle per call  (the tracepoint is host-wide; a thread with no stack
                                                  returns at once)
supervisor-held variant:        t_rt per position-dependent call  (a notify round trip)

Kernel facts, with sources.

9. Against Firecracker's seccompiler

seccompiler compiles hand-written JSON rules into BPF at build time. It is a good compiler, long in production, and it is not a way to find the rules.

Firecracker seccompiler Heimdall static mode (0.4.2) Heimdall graph mode (design)
where the rules come from written by hand measured by the profiler, reviewed, never edited by hand the code's graph; the profiler confirms it
checked against the code no cross-check with the possible set, as a guide the graph is the source; S = 1.0 required
drift the authors' own tests profile check names the call and the test the same, plus pair findings
order and counts none none a stack of positions per thread; once-only sites
per thread yes: one hand-written filter per thread role no: one filter per process (TSYNC) yes, from the graph
argument conditions eq, ne, lt, le, gt, ge, masked eq (arg & mask) == value only the same, plus values the code fixes
kernel-implied calls what the author lists the base set, first in every program the same
signed evidence of what ran not its job profile digest in the signed result and start plus graph digest, Enforce or Audit, violations
cost per call one BPF program one BPF program one BPF program and one eBPF program
needs any seccomp kernel any seccomp kernel eBPF on the host, with privileges
state today in production for years released, small not built

seccompiler is ahead today on two rows: comparison operators and per-thread filters.

10. Limits

11. Prior art

12. Build order

Test-first: each step is a branch whose tests are red first, then green.

  1. Static mode behind the trait (section 5). Needs no graph and can start now. Byte-identical programs and records. The runtime backend of section 6 builds on it.

The rest starts only when the 1:1 graph exists: the graph plan's fixtures are green and exec.nodes / exec.edges are written for a real program.

  1. Graph import, pinned by its SHA-256. Tests: a round trip; an unknown schema or call name is refused by name; a C function without a map entry blocks graph mode.
  2. The pair recorder (section 7). Tests on the A { B C D }* F fixture: exactly the pairs of section 4; probe.json byte-identical.
  3. The graph check. Tests: section 4's numbers, 0.625 on the wrong graph and 1.0 on the right one; an empty denominator prints "no data".
  4. decide in Rust, the reference, no kernel. Property tests for G1 to G4, with handlers that start anywhere, nest and return, restarted calls, and spawns.
  5. The follower's tables. The loader makes the graph deterministic. Tests: the tables agree with decide on random runs; the state count is reported; a graph past the map's size is refused.
  6. The follower: the aya program and its maps, on the entry, exit, signal-delivery and thread-creation tracepoints. Audit first. Tests in a privileged test machine: the fixture runs with 0 violations; an openat injected in the loop is counted; a signal handler runs between two calls of the fixture and the run is still valid; a call that is in no handler and not next is still invalid; a restarted call is valid; a ninth frame is invalid. The kernel facts marked "to verify" are checked here.
  7. The loader on the runner's host side: attach by cgroup at start, detach at the end. Tests: a host without the kernel options or the privileges gets static mode, and the record says so. The options become rows of heimdall kernel needs.
  8. Enforce. Tests: an invalid step kills; the invalid call is the last one the thread makes; with BPF LSM and no broker, an invalid openat or connect is refused before it runs; Audit returns no error on the same runs.
  9. Records: Protection::Graph and the end record. Old results still verify.
  10. Measure t_bpf, t_follow and t_idle, on the fixture and on the wasm host worker, and the round trip of the supervisor-held variant on the same runs. The numbers go into section 8 with date and kernel.
  11. Real programs, in Audit first and then Enforce: the wasm host worker (our own code), then Holger's scan worker.

Next: part 5 of 5, raw: https://www.gunnar.rs/nordisk/heimdall/raw/docs/v2/graph-filter-parts/part-5.md