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

nordisk/heimdall

docs/SECCOMP.md

Seccomp profiles: heimdall-seccomp and heimdall-probe

Heimdall is a defensive sandbox. This document covers milestone 3 (spec §3): the profile types and BPF builder (heimdall-seccomp), the learning-mode probe and generator (heimdall-probe), and heimdall profile probe|gen|check.

heimdall-seccomp

Part What it does
Profile / StaticProfile The allowlist of one workload, arch (x86_64, aarch64) and libc (glibc, musl). StaticProfile is the const form a generated .rs holds.
bpf::build A pure-Rust classic-BPF builder (no libseccomp, no C).
Mode::Enforce A call outside the profile is SECCOMP_RET_KILL_PROCESS. The kernel writes an audit record (type=1326 … sig=31 syscall=<nr>, seen on oden); the launcher reports the child as killed by seccomp (SIGSYS).
Mode::Audit(TestBox) A call outside the profile is SECCOMP_RET_LOG (it runs and is logged). Only for staging on test boxes: a TestBox exists only when HEIMDALL_SECCOMP_AUDIT_TEST_BOX=1.
notif The USER_NOTIF API the broker and the probe are built on (below).
launch Starts a program with a filter installed just before exec.
interp A classic-BPF interpreter, so a program is checked offline against Profile::evaluate.
oci The same profile as OCI seccomp JSON (seccompProfile: Localhost).

Rule semantics (the same in Profile::evaluate, the BPF program and the interpreter): the rules of one syscall are tried in order; a rule matches when its when is empty or any alternative matches; an alternative matches when every ArgCmp holds: (arg & mask) == value, on the low 32 bits for W32 (int parameters, whose upper half the kernel ignores). The first match wins; no match gives the mode's default. In every mode, a foreign arch and (on x86_64) every x32 call (nr >= 0x40000000) are killed.

The launcher. After fork the child puts the program's O_PATH fd at fd 1022 and (with a listener) a socket at fd 1021, both close-on-exec, sets no_new_privs, installs the filter, sends the listener over fd 1021 (SCM_RIGHTS) and calls execveat(1022, "", argv, envp, AT_EMPTY_PATH). Every filter built by the launcher starts with a two-rule prelude that allows exactly that execveat and that sendmsg; a worker's profile needs no bare execve (a test shows the worker's own execve is killed).

The USER_NOTIF API (for heimdall-broker)

use heimdall_seccomp::notif::{AddFd, Listener, Response};
let mut child = Launch::new(prog).profile(&p, &Mode::Enforce).listener(true).spawn()?;
let l: Listener = child.take_listener().unwrap();
Listener::check_kernel()?;                       // struct sizes match this crate
loop {
    match l.wait(None)? { Readiness::Hangup => break, _ => {} }
    let n = l.recv()?;                           // n.id, n.pid, n.data.{nr, arch, args}
    // read the target's memory, decide, open the file yourself...
    if !l.id_valid(n.id)? { continue; }          // the target died / the call was interrupted
    l.add_fd(n.id, file.as_fd(), AddFd { cloexec, ..AddFd::send() })?;  // inject + answer
    // or: l.respond(n.id, Response::Errno(libc::EACCES))?;
}

heimdall-probe

  1. Learning mode (learn): the filter's default is SECCOMP_RET_USER_NOTIF; the probe records every call and answers CONTINUE. No strace, no ptrace (no ptrace fallback is built: every kernel Heimdall targets has 5.5). clone3 is answered ENOSYS by the filter itself, so the libc falls back to clone, whose flags a filter can check; generated profiles keep that rule.
  2. Argument facts (facts::SPECS): socket/socketpair domain, type and protocol; open/openat flags; ioctl request; mmap/mprotect/pkey_mprotect prot; clone flags; prctl option; fcntl command. Everything else is recorded as the bare call.
  3. Merge and minimise (generate): exact tuples while at most exact_limit (16) distinct, else a reported bare allow; flag fields (openat, clone) become "no bit outside the observed union"; prot values are exact, and PROT_WRITE|PROT_EXEC together is reported and left out unless the workload sets allow_wx.
  4. Review: near-miss calls (seen only in error_path runs; kept in the profile, listed), W|X findings, bare allows, unknown calls, fixed rules, the vDSO note. In the .json (review) and the .rs header.
  5. Codegen: profiles/<workload>/<arch>-<libc>.rs (a const StaticProfile, fully qualified paths, include!-able), .json, .oci.json. No dates; sorted; byte-identical for the same observations.
  6. Drift check (check): a fresh probe must be a subset of the checked-in .json; each failure reads new call sysinfo (kill_process), made by test basic_run. .rs and .oci.json must match what .json generates.

workload.toml

workload = "heimdall-wasm-host"
timeout_secs = 60     # per run
repeat = 3            # every run this many times; the union is kept
exact_limit = 16
allow_wx = false
[binary]
glibc = "{target}/release/heimdall"
musl = "{target}/x86_64-unknown-linux-musl/release/heimdall"
[[run]]
name = "good_input_is_accepted"         # the test name check reports
args = ["run", "--job", "wasm", "--module", "{corpus}/good.wasm", "--out", "{tmp}/out"]
expect_exit = 0                         # a broken corpus fails the probe
[[run]]
name = "tampered_input_is_refused"
error_path = true
args = ["..."]
expect_exit = 4

Placeholders: {dir}, {corpus} (<dir>/corpus), {tmp} (a fresh dir per run, also its cwd), {target} ($CARGO_TARGET_DIR or <profiles>/../target). Runs start from an empty environment plus PATH=/usr/bin:/bin and [env].

Tests and where they run

Checked-in profiles

Workload Arch / libc Rules Notes
heimdall-wasm-host (heimdall run --job wasm, the verify-bundle job's worker) x86_64 glibc 39 no socket, no execve, no memfd, no W|X; near-miss rt_sigreturn (stack overflow, trap); sha256:bd1a5bcd…2816272
heimdall-wasm-host x86_64 musl (static release) 35 same; sha256:8369f2ec…5642d23

Profiles a program's own repository checks in (measured with heimdall profile probe-exec where the program really runs, generated with heimdall profile gen, MIT-0; lane prof, 2026-11-01, oden kernel 7.0):

Program Where Arch / libc Rules Digest Enforced by
Holger's scan worker (holger-scan-worker, static): verify-bundle native + split guest, scans of 13 real packages (pip, maven, rust, npm, helm, debian, rpm, oci, raw) and made-up subjects holger jobs/runner/seccomp/holger-scan-worker/ x86_64 musl 32 sha256:6d802b5e…ba752a HeimdallRunner, Policy.seccomp = OciFile pinned (every job but Holger's probe)
clamd (ClamAV 1.5.4): PING, INSTREAM of EICAR and real packages, RELOAD, bad clients, SelfCheck holger server/sandbox/seccomp/clamd/ x86_64 musl 58 sha256:4a714c3d…19f53 Holger's crun sandbox (linux.seccomp, plus crun's own close_range and execve after the filter)

Both were run green under their profile on a real kernel, and a call outside each was killed (the scan worker: signed heimdall-seccomp-1; clamd: SIGSYS, exit 159, clamd still serving); the READMEs there list the corpus and how to measure again.

The crate nordisk-heimdall-profiles (profiles/; publishable from 0.3.0, because heimdall-container and heimdall-cli enforce its tables at runtime; the package holds only the generated .rs/.json/.oci.json and workload.toml, not the corpus) compiles every .rs and holds it to its .json and .oci.json; its enforce test runs each profile in enforcing mode on the real release binary over the whole corpus. The corpus guests are written by crates/heimdall-cli/tests/profile_corpus.rs (small WAT guests, corpus/) and guests/verify-bundle/tests/probe_corpus.rs (the REAL verify-bundle guest, single and split with N = 1 and N = 8 decoder instances, over good stored, good OpenZL, many-chunk OpenZL, tampered and two bomb bundles: verify-bundle/ and the marked block of workload.toml). With the real guest the rule count stayed the same: wasmtime's own threads already needed every clone, futex, madvise and mmap tuple the decoder workers use; mremap moved from near-miss to a normal path (a good bundle grows the guest's memory).

A generated .rs starts with // SPDX-License-Identifier: MIT-0 and says it is generated output of Heimdall, not covered by Heimdall's AGPL: a profile is data about the profiled program, so MIT, Apache and proprietary repos can check it in. MIT-0 rather than CC0-1.0: it is OSI-approved and accepted where code licences are checked (some policies refuse CC0 for code, for its patent clause); it asks for no attribution. The .json forms carry no licence text.

Enforcing the profiles at runtime

What is not built