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-3.md

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

4. The worked example: A { B C D }* F

The program is the graph plan's own fixture (plan §5), on one tokio thread: A opens a file (openat), then a loop: B (getpid), then join! of two futures C (getuid) and D (getgid), whose order varies; then F closes the file (close). A real run prints A (B C D | B D C)* F.

In static mode. The profiler's runs give M ⊇ {openat, getpid, getuid, getgid, close} plus the runtime's calls. The cross-check confirms all five. There is one position, and decide allows I ∪ F from the first instruction to the last. The order of C and D is invisible and does not matter. An attacker who takes over in the loop can call openat again, any number of times, on any path Landlock allows.

In graph mode. The graph (plan §5):

First(main) = {A}
Next = { A→B, A→F,  B→C, B→D,  C→D, D→C,  C→B, D→B,  C→F, D→F,  B→F }
regions:  setup {A}    serve {B, C, D} (one cycle)    shutdown {F}
call position after allowed next (besides I and the same call again)
(start) {ε} openat
A openat {A} getpid, close
B getpid {B} getuid, getgid, close
D getgid {D} getuid, getpid, close
C getuid {C} getgid, getpid, close
F close {F} nothing: the thread can only end

A lies on no cycle, so once the thread has moved on, a second openat is an invalid step: a budget of one, with no counter. An openat injected in the loop is an invalid step too, because no position of the loop allows it.

What it costs here. The floor answers all five calls in BPF (openat goes to the broker where the job has one). The follower then checks each call in the kernel, in the calling thread. No call of the loop leaves the kernel for the check, and the order of B, C and D is enforced. The price is one small program run per call (section 8).

The profiler finding "C and D were seen in another order". Suppose an early analyzer models join! as "C, then D". Its graph is Next₁ = { A→B, A→F, B→C, C→D, D→B, D→F }. One profiled run is A B C D B D C B C D F:

Obs₂ = { (A,B), (B,C), (C,D), (D,B), (B,D), (D,C), (C,B), (D,F) }           8 pairs
nodes:  S = 5/5 = 1.0     P = 5/5 = 1.0
pairs:  S₂ = |Obs₂ ∩ Next₁| / |Obs₂| = 5/8 = 0.625     findings: (B,D), (D,C), (C,B)
        P₂ = 5/6                                        never observed: A→F

S₂ < 1, so this graph is not enforced. The three findings have one cause: both futures are polled inside one poll, and each can be pending, so either can call first. That is in the code, so the graph must say it. The resolution is an analyzer fix, with this fixture as its test (plan §7 lists it). Nobody declares "C and D are unordered" by hand. With the fixed graph (Next above):

S₂ = 8/8 = 1.0        P₂ = 8/11       never observed: A→F, C→F, B→F

A→F needs a run with zero rounds, and C→F a run whose last round is B D C: two runs to add. B→F cannot happen in any run (the join always makes both calls). It is analyzer imprecision and stays listed until the analysis is sharper.

5. The trait

Where it sits. There is no trait today, but the code has one choke point too: every BPF program Heimdall builds goes through heimdall_seccomp::bpf::build_rules (crates/heimdall-seccomp/src/bpf.rs:176). Its callers assemble their rules themselves:

caller file:line builds
generated profile, check at resolve crates/heimdall-container/src/genprof.rs:181-184 prelude + report rule + profile, default KILL
generated profile, install crates/heimdall-seccomp/src/launch.rs:688-691 (from shim.rs:368) the same, then TSYNC and execveat
wasm host worker, learning filters crates/heimdall-seccomp/src/launch.rs:264-266 prelude + rules, any default
broker filter crates/heimdall-broker/src/filter.rs:99-101 brokered calls → USER_NOTIF, default allow

The trait replaces the enforcing assemblies (the first two rows, and the worker's use of the third). The broker filter and the learning filters stay outside: they broker and record.

The trait is the model, in a new module heimdall_seccomp::filter:

pub trait CallFilter: Send + Sync {
    fn name(&self) -> &'static str;                             // "static" | "graph"
    fn start(&self, entry: EntryId) -> Position;                // static: the one position; graph: a stack
    fn decide(&self, at: &Position, event: &Event) -> Decision; // Valid(next) | Invalid
    fn floor(&self) -> &[Rule];                                 // the floor's rules: static mode's profile
    fn digests(&self) -> FilterDigests;                         // what the signed record names
}

/// The pipeline, OUTSIDE the trait. It builds the floor as ONE program with build_rules, so
/// the arch check, the x32 kill and the base set are outside every implementation. For a
/// graph filter it also builds the follower's tables: the states and the (state, class) map.
pub fn compile(f: &dyn CallFilter, arch: Arch) -> Result<Compiled, FilterError>;
pub struct Compiled { pub floor: Program, pub wants_listener: bool,
                      pub follower: Option<FollowerTables>, pub digests: FilterDigests }

decide in Rust is the reference. The eBPF follower must agree with it on every run of the tests.

StaticFilter GraphFilter
positions one a stack of positions per thread, at most 8 frames
must provide the profile's rules the same rules as the floor, and the graph (exec.nodes, exec.edges)
in the kernel the floor the floor, and the follower with its maps
in user space the broker, where the job has one the same, and the loader that attaches the follower and reads its violations
signed Generated { profile_digest }, as today mode, Enforce or Audit, floor digest, graph digest, violation count

Names, against the code. heimdall_core::Protection already exists. It is the signed statement of what protected a run: None | Default | Generated { profile_digest } (crates/heimdall-core/src/types.rs:757-765). Mode and FilterMode are taken too (crates/heimdall-seccomp/src/bpf.rs:141, crates/heimdall-broker/src/filter.rs:48). So:

The signed record says: the mode (static or graph), Enforce or Audit, the digest of the floor's program, the digest of the graph, and the number of invalid steps. The digests are SHA-256, the hash the code already uses for profiles and outputs (Sha256Digest::of, crates/heimdall-core/src/types.rs:108-113). The graph digest is over the bytes of exec.nodes and then exec.edges. The mode and the digests are known at start; the violation count is in the result. A service has no signed record after its start today (crates/heimdall-core/src/service.rs:250-276), so its end record is new work. A consumer should give full trust only to Enforce with 0 violations. That is the consumer's policy; the record only states the facts. A signature proves what Heimdall installed and saw, not that the kernel held.

The first build step is static mode behind the trait, as a pure refactor. Its test: for every profile in heimdall_profiles::ALL (profiles/src/lib.rs:31), the program from compile(StaticFilter) equals today's program instruction for instruction, and the signed result and signed start bytes are unchanged. This step does not need the graph.

6. How it plugs into a container runtime (youki)

A container runtime compiles the OCI seccomp section into one program and loads it. The runtime-side trait gives the user the choice of who does that: libseccomp (the default), Firecracker's seccompiler, or Heimdall. Whoever picks Heimdall replaces the default fully. The trait is small, and it stays small:

pub trait SeccompBackend: Send + Sync {
    fn name(&self) -> &'static str;                 // "libseccomp" | "seccompiler" | "heimdall"
    fn compile(&self, spec: &LinuxSeccomp, arch: Arch) -> Result<CompiledSeccomp>;
}
pub struct CompiledSeccomp { pub program: Vec<sock_filter>, pub flags: SeccompFlags, pub wants_listener: bool }

Graph mode uses no filter code of youki, libseccomp or Firecracker. The runtime only loads the program and passes the listener.

7. What the profiler must record for graph mode

Today it keeps call → runs (crates/heimdall-probe/src/observe.rs:56). The thread id arrives in every notification (crates/heimdall-seccomp/src/notif.rs:67-69), but call_of drops it (crates/heimdall-probe/src/learn.rs:69-94), and the live probe uses it only to skip other processes (live.rs:130). Graph mode needs, per run:

per thread, in memory only (tids differ between runs):   last = the class of its previous call
pairs[(a, b)]   count of "b is the next call after a in one thread"     → Obs₂, S₂, P₂
count[c]        calls per class                              → Obs, and how hot a class is
thread starts   which entry a new thread belongs to, as far as the profiler can tell

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