Heimdall job API (v0.2.0)
Heimdall is a defensive sandbox. It runs untrusted parsing, verification and scanning jobs so that a compromised job can reach only its own input and output. Its tests only exercise its own containment.
This is the stable job API every runner implements (milestone 0, Holger's demand). It follows semver: within a release line (0.2.x) it changes by additions only; a new 0.x line may break, and lists every break below and in CHANGELOG.md.
# The crates.io packages are named nordisk-heimdall-* (heimdall-core is an
# unrelated crate there); the library names stay heimdall_core, heimdall_plain,
# so `use heimdall_core::…` is unchanged.
heimdall-core = { package = "nordisk-heimdall-core", version = "0.2.0" }
heimdall-plain = { package = "nordisk-heimdall-plain", version = "0.2.0" }
heimdall-container = { package = "nordisk-heimdall-container", version = "0.2.0" }
# also 0.2.0: nordisk-heimdall-wasm, -run, -cli, -seccomp, -probe
Upgrading from 0.1
Change every version = "0.1" / "0.1.0" of a nordisk-heimdall-* crate to "0.2.0"; all crates move together (heimdall-run 0.2 needs heimdall-core 0.2). What breaks:
| Where | 0.1 | 0.2 | What to write |
JobKind::Wasm |
Wasm { module } |
Wasm { module, decoder: Option<GuestRef> } |
match Wasm { module, .. }; build JobKind::wasm(module) (or decoder: None); a split guest: JobKind::wasm_split(verifier, decoder) |
Limits |
13 fields | + decoders: u32 |
build with ..Limits::default() (as before) |
JobResult |
ends with events |
+ kernel: Option<KernelFeatures> |
a runner fills kernel: None unless it probed the kernel; readers may show it |
| signed wire | version 1, DOMAIN_V1 |
version 2, DOMAIN_V2 |
nothing for callers of verify() / Verifier; a 0.1 result (v1) is refused as UnsupportedVersion(1), so producer and verifier must both be 0.2 (results are per boot, there is nothing old to keep) |
heimdall-run RunLimits |
12 fields, LIMIT_KEYS: [_; 12] |
+ decoders, [_; 13] |
build with ..RunLimits::default() |
heimdall-run WasmLoadError |
3 variants | + Decoder(LoadRefusal) |
add an arm, or _ |
JSON stays compatible both ways for jobs: a 0.1 job's JSON reads with decoder: None and decoders defaulted, and a 0.2 job without a decoder writes no decoder key. A 0.1 JobResult JSON reads with kernel: None.
Holger 3.0 pins 0.1; 0.1.x stays supported with security fixes only (SECURITY.md).
The flow
use heimdall_core::*;
use heimdall_plain::{PlainConfig, PlainYoukiRunner};
let runner: Box<dyn JobRunner> =
Box::new(PlainYoukiRunner::new(PlainConfig::new("/var/lib/holger/oci", "/var/lib/holger/heimdall")));
let pinned = runner.verifying_key(); // per boot; pin it
let job = Job::new(
JobKind::Native {
image: ImageRef { name: "docker.io/library/busybox".into(), digest: "sha256:…".into() },
argv: vec!["/bin/cp".into(), "/in/a".into(), "/out/a".into()],
},
input_dir, output_dir, // output_dir: existing, empty
serde_json::Value::Null,
Policy::default(),
);
let signed = runner.run(job)?; // blocking; put it on a job queue
let result = verify(&signed, &pinned)?; // signature, key id, well-formed
check_outputs(result, &output_dir)?; // every listed file, hash checked, no extras
match &result.verdict {
Verdict::Accepted => { /* use the outputs */ }
Verdict::Refused { code, reason } => { /* code is stable, reason is for people */ }
}
Later, the protected Heimdall runner replaces PlainYoukiRunner::new(…); nothing else moves.
heimdall-core: the names
| Item | Shape |
Job |
id: JobId, nonce: Nonce, kind: JobKind, input_dir: PathBuf, output_dir: PathBuf, params: serde_json::Value, policy: Policy |
JobKind |
Native { image: ImageRef, argv: Vec<String> } | Wasm { module: GuestRef, decoder: Option<GuestRef> } (decoder: the decoder module of a split guest, module is then its verifier; serde default None, not serialized when None; JobKind::wasm(m) / JobKind::wasm_split(m, d) build them) |
ImageRef |
name: String, digest: String (sha256: + 64 lower-case hex); ImageRef::sha256() parses it |
GuestRef |
path: PathBuf, blake3: String |
Policy |
read: Vec<PathBuf>, write: Vec<PathBuf>, net: Option<NetAllow>, limits: Limits |
NetAllow |
host: String, port: u16 (exactly one peer) |
Limits |
wall: Duration, memory_bytes: u64, cpus: f32, pids: u32 + wasm/output limits fuel, table_elements, instances, tables, memories, stack_bytes, max_output_files, max_output_file_bytes, max_output_total_bytes, decoders (decoders: decoder instances a split wasm guest runs at once, default default_decoders() = min(cores, 8); see docs/GATLING-WASM.md); build with ..Limits::default() |
Verdict |
Accepted | Refused { code: String, reason: String } |
Output |
name: String, sha256: Sha256Digest, bytes: u64 |
ExitInfo |
code: Option<i32>, signal: Option<i32>, killed: Option<KillReason> |
KillReason |
Wall | Memory | Pids | Fuel |
JobResult |
job_id, nonce, verdict, outputs: Vec<Output>, exit: ExitInfo, started_unix: i64, finished_unix: i64, runner: Features, events: Vec<BrokerEvent>, kernel: Option<KernelFeatures> (kernel: what the runner got from the kernel, spec §10; None for the wasm and plain runners; serde default None) |
KernelFeatures |
release: String, seccomp_filter, user_notif, notif_addfd: bool, landlock_abi: Option<u32>, cgroup_v2: bool, cgroup_controllers: Vec<String>, user_namespaces: bool |
SignedResult |
version: u16, key_id: KeyId, boot_id: BootId, result: JobResult, sig: Ed25519Signature |
Features |
runner: String, container, seccomp: Protection, landlock, broker, wasm, image_verified_each_start (bools) |
Protection |
None | Default | Generated { profile_digest: String } |
trait JobRunner: Send + Sync |
run(&self, Job) -> Result<SignedResult, RunError>, verifying_key(&self) -> VerifyingKey, features(&self) -> Features |
RunError |
Unsupported { what }, InvalidJob { reason }, Image { reason }, Setup { reason }, Io { reason }, Sign(ResultError) — nothing was signed |
verify |
fn verify(&SignedResult, &VerifyingKey) -> Result<&JobResult, VerifyError> |
check_outputs |
fn check_outputs(&JobResult, &Path) -> Result<(), VerifyError> (VerifyError::Output(OutputError::…) names the failure) |
VerifyingKey |
re-export of ed25519_dalek::VerifyingKey (also heimdall_core::ed25519_dalek) |
Everything public derives serde. heimdall-core is #![forbid(unsafe_code)] and depends on neither youki nor wasmtime.
Additions to the demand's shapes: Job.nonce and JobResult.nonce (bind a result to one request; Job::new fills it), JobResult.events (broker decisions, empty without a broker), SignedResult.version and boot_id, the KillReason and codes items, and Verifier/ReplayGuard/PinnedKey for callers that pin a key record and want replay detection. Output.sha256 is a typed Sha256Digest (its serde form is the 64-hex string), key_id a typed KeyId and sig an Ed25519Signature (64 bytes) rather than raw strings and arrays.
Refusal codes (heimdall_core::codes)
| Code | Meaning |
heimdall-out-1 |
output_dir holds an extra: a directory, symlink, fifo, device, bad name, or more files than max_output_files |
heimdall-out-2 |
an output over max_output_file_bytes, or all over max_output_total_bytes |
heimdall-out-3 / -4 |
(wasm) an emitted file was refused / the output audit failed |
heimdall-limit-wall |
killed at the wall-clock limit |
heimdall-limit-memory |
killed at the memory limit |
heimdall-limit-pids |
the job hit the process limit (a fork was refused), whatever its exit |
heimdall-limit-workspace |
killed: its host-folder workspace went over its cap (exit.killed = Workspace) |
heimdall-exit-1 |
the job exited non-zero (its outputs are still listed) |
heimdall-exit-2 |
the job died from a signal |
heimdall-seccomp-1 |
the job (or heimdall run's wasm worker) was killed by its seccomp profile (SIGSYS) |
heimdall-wasm-<n> |
a wasm guest trapped or hit a wasm limit (heimdall-wasm) |
guest-refused |
a wasm guest itself refused the input; the reason is the guest's |
The signed bytes: canonical binary wire v2 (not JSON)
The demand asked for Ed25519 over canonical JSON. Heimdall signs its canonical binary encoding v2 instead (0.1 signed v1; v2 = v1 + the trailing kernel, and a v1 result is refused as an unsupported version): it is deterministic by construction (one value has exactly one encoding, the decoder accepts nothing else), domain-separated, length-prefixed, has no floats, and its decoder is fuzzed. Callers never touch the bytes: verify() re-encodes the result and checks the signature. JSON stays available for transport (SignedResult is serde) and still verifies after a JSON round trip.
wire = "HMDL-SR\0" | version u16 = 2 | key_id[16] | boot_id[16]
| result_len u32 | JobResult v2 | signature[64]
signature = Ed25519 over "heimdall.signed-result.v2\0" || (wire bytes between magic and signature)
key_id = first 16 bytes of sha256("heimdall.key-id.v1\0" || public key)
JobResult v2 =
job_id[16] nonce[32]
verdict: u8 (0 Accepted | 1 Refused, then code: str, reason: str)
outputs: u32 n × (name: str, sha256[32], bytes: u64)
exit: code: opt i32, signal: opt i32, killed: u8 (0 none, 1 Wall, 2 Memory, 3 Pids, 4 Fuel)
started_unix: i64, finished_unix: i64
runner: runner: str, container: bool, seccomp: u8 (0 None, 1 Default, 2 Generated + profile_digest: str),
landlock: bool, broker: bool, wasm: bool, image_verified_each_start: bool
events: u32 n × (at_ms: u64, call: str, args: u32 m × str, decision: u8)
kernel: opt (release: str, seccomp_filter: bool, user_notif: bool, notif_addfd: bool,
landlock_abi: opt u32, cgroup_v2: bool, cgroup_controllers: u32 n × str, user_namespaces: bool)
str = u32 length + UTF-8; opt T = u8 0 | 1 + T; bool = u8 0 | 1; integers big-endian
Limits (and so cpus, the only float) is never part of a result.
heimdall-plain: PlainYoukiRunner (interim, NOT Heimdall)
An ordinary youki container per job (libcontainer pinned at =0.7.0), with no Heimdall protections. Its features(), and every result's runner:
runner "youki-plain", container true, seccomp Default, landlock false,
broker false, wasm false, image_verified_each_start true
- Namespaces: user (container root = the runner's uid, one id mapped; or a host id range with
with_host_ids, see below), mount, pid, ipc, uts, cgroup, and network (empty: only a down loopback). - Root: the pinned image, read-only, unpacked fresh for every job from a local OCI image layout after checking the manifest, config and every layer against their sha256 digests and sizes (so
image_verified_each_startis true)./tmpis a 64 MiB tmpfs. No/sys. input_dirat/inread-only,output_dirat/outread-write; extrapolicy.read/policy.writepaths at the same path, ro / rw.- All capabilities dropped, no-new-privs,
RLIMIT_CORE0,RLIMIT_NOFILE1024. - Seccomp
Default: youki ships no default profile, so heimdall-plain ships one fixed profile for every job: moby's default profile (moby/profiles 4b8214fc, Apache-2.0) reduced to x86_64 with no capabilities — deny by default with EPERM, nomount/unshare/setns/bpf/keyctl/module calls,clonewithout namespace flags,clone3ENOSYS. - cgroup v2:
memory.max=memory_byteswith no swap,pids.max=pids,cpu.maxfromcpus; the shim kills the whole job atwall. - Wasm jobs:
RunError::Unsupported;policy.net: see "Fetch jobs" below. - After the job the runner lists every regular file in
output_dir(sorted, sha256, size); anything else there refuses the run withheimdall-out-1. - The per-boot Ed25519 key lives only in the runner process. Each job runs through
heimdall-plain-shim, a separate process that drives libcontainer (the runner never forks itself) and never receives the key; the job's stdio is/dev/null.
Building: the shim needs the cargo feature youki (libseccomp-dev): cargo build -p nordisk-heimdall-plain --features youki --bin heimdall-plain-shim. The runner finds it via HEIMDALL_PLAIN_SHIM, next to the current executable, or PlainConfig::shim. A host binary can also serve as the shim: call heimdall_plain::shim::run_if_shim() first in main and use ShimCommand::current_exe().
Host requirements: user namespaces, cgroup v2 with the memory, pids and cpu controllers delegated to the runner's cgroup, and a cgroup dir the runner can create heimdall/ in (PlainConfig::cgroup_root, cgroup_parent). scripts/plain-tests.sh shows a working setup in a privileged rootless podman container.
heimdall-container: HeimdallRunner (the protected native runner)
Milestone 2 (spec §5, §10). The same trait, so it replaces PlainYoukiRunner::new(…) and nothing else moves:
use heimdall_container::{HeimdallConfig, HeimdallRunner};
let runner = HeimdallRunner::new(HeimdallConfig::new("/var/lib/holger/oci", "/var/lib/holger/heimdall"))?;
// Err(RunError::Unsupported { what }) when the kernel lacks a feature (strict mode).
eprintln!("{:?}", runner.kernel()); // the kernel features it got (spec §10)
Its features(), and every result's runner (where landlock is what the executor reported from inside that job, right before execve):
runner "heimdall", container true, seccomp Default, landlock true,
broker true, wasm false, image_verified_each_start true
- Everything heimdall-plain does (namespaces incl. an empty netns, the pinned image verified and unpacked fresh at every start, read-only root, tmpfs
/tmp,/inread-only,/out, caps dropped, no-new-privs, cgroup v2 memory withmemory.swap.max0 / pids / cpu, the wall clock, the output scan, the key only in the runner), plus: - Landlock (crate
landlock, ABI 6 asked, best effort down to ABI 1), applied by Heimdall's libcontainer executor inside the job after pivot_root and seccomp, right beforeexecve: read + execute the image's top-level entries (never/itself), read/in,/procandpolicy.read, write-only/out(the job cannot read back or list it), read-write no-exec/tmpandpolicy.write,/dev/nullrw, the random and zero devices read; no TCP bind/connect (ABI 4); signal and abstract unix socket scopes (ABI 6). Anything else is refused whatever the mounts allow. - seccomp
Default: a fixed hand-reviewed profile stricter than heimdall-plain's moby profile: 66 more calls refused with EPERM (AIO, SysV IPC, mqueues, memfd/execveat, mlock, sync, xattr writes, clock tuning, …), 42 calls kill the whole job (ptrace, process_vm_*, bpf, perf, userfaultfd, io_uring, keys, mount family, unshare/setns, modules, kexec, …),socketonly for AF_UNIX/AF_INET/AF_INET6 (heimdall_container::profile). - seccomp
Generated { profile_digest }: when the job'spolicy.seccomp(orHeimdallConfig::seccompfor every job) names a GENERATED profile, see "Generated seccomp profiles" below. - The syscall broker (heimdall-broker, spec §4, milestone 4): see "The syscall broker" below.
- rlimits: nofile 1024, nproc =
pids+ 1 (a backstop one above the cgroup'spids.max, so the cgroup refuses a fork first and counts it), fsize =max_output_file_bytes, core 0.io.weight50 when the io controller is delegated (written by the shim, read back).io.maxthrottles per disk when the config sets them (HeimdallConfig::io_max). No/dev/mqueue. - Fail closed: a job whose executor did not report seccomp filter mode, no-new-privs and (when promised) Landlock, whose swap is not 0, or whose
io.weight/io.maxread back from its cgroup is not what the config asks, is aRunError::Setup; nothing is signed. - Kernel report and strict mode (
HeimdallConfig::strict, default true): seccomp filters are always required; strict mode also requires Landlock, cgroup v2 with memory/pids/cpu and user namespaces, and refusesLandlockMode::Off. Not strict: it starts and sayslandlock: false. The broker does not make strict mode refuse a kernel: without ADDFD or USER_NOTIF it falls back (spec §10, below) and saysbroker: false. policy.net = Some(..)and wasm jobs:RunError::Unsupported. A policy path may not be/or under/in,/out,/proc,/devor/sys.
Building: cargo build -p nordisk-heimdall-container --features youki --bin heimdall-container-shim (libseccomp-dev); found via HEIMDALL_CONTAINER_SHIM or next to the executable. scripts/container-tests.sh runs the kernel tests (the broker's own, then every heimdall-plain container test again plus Heimdall's own) in privileged rootless podman.
The syscall broker
Every job runs under the broker unless HeimdallConfig::broker is BrokerSetting::Off. The job's executor installs heimdall-broker's seccomp filter inside the container after Landlock, right before execve (and before a generated profile), and sends its USER_NOTIF listener to the shim, which serves it for the whole job. The job's openat, openat2, open, creat, socket, connect, bind, listen, accept, io_uring_setup, MSG_FASTOPEN sends, execve and execveat pause; the broker copies the arguments once, checks NOTIF_ID_VALID, decides by the job's policy and then opens the file itself (openat2 beneath the prefix it holds open, no magic links, no mount crossing) and injects the fd with SECCOMP_IOCTL_NOTIF_ADDFD (FLAG_SEND). The job's own call never runs on a path or sockaddr it could still change. A refused call fails with EACCES.
The policy (heimdall_container::brokered::policy) is never wider than the job's Landlock plan, because the broker's own opens are not under Landlock:
| Where (as the job sees it) | Broker access | Resolution |
/ (the image; never / itself) |
read | in-root, no mount crossing (so not into /in, /out, /tmp, /proc, /dev or any other mount) |
/in, policy.read |
read | beneath the prefix (a symlink may not leave it) |
/out |
write only: O_WRONLY, no read-back, no listing, no O_PATH |
no symlinks at all |
/tmp, /work (the job workspace), policy.write |
read and write | beneath (exec of /work stays the mount's and Landlock's decision) |
/proc |
read | the caller's own self, magic links (/proc/self/fd/N, /proc/self/root) refused |
/dev/null |
read and write | exact path, character device only |
/dev/{zero,full,random,urandom} |
read | exact path, character device only |
| sockets | refused, except a fetch job's one TCP peer (below) | |
bind, listen, accept, MSG_FASTOPEN, io_uring_setup |
always refused | |
| exec | allowed and logged; Landlock decides what executes (only the image) |
.. anywhere in a path is refused. A relative path is resolved against the caller's cwd or dirfd (/proc/<tid>/cwd, /proc/<tid>/fd/<n>) and checked as the absolute path. openat2 RESOLVE_* flags from the job only add strictness (RESOLVE_BENEATH / RESOLVE_IN_ROOT are refused). O_PATH is opened for reading (the kernel's ADDFD cannot inject an O_PATH file). When the job is out of fds, the call fails with EMFILE.
Events. JobResult.events starts with broker ["mode=full"] (check-only and deny-only add a warning: … argument), then one BrokerEvent per decision (openat with path=, flags= and fd= or refused: <why>, execve with path=, socket with its arguments, …), at most 4096, then a broker ["dropped=N"] event. They are inside the signature.
Kernel fallbacks (spec §10), from the runner's kernel report:
| Kernel | Mode | Features.broker |
Effect |
| USER_NOTIF + ADDFD (5.9; 5.14 for the atomic answer) | full | true | as above |
| USER_NOTIF, no ADDFD | check-only | false | the broker checks, then lets the job's own call proceed (not TOCTOU-safe); a warning in the mode event |
| no USER_NOTIF | deny-only | false | every brokered call fails with EACCES (fail closed); exec still runs |
A job whose executor did not report the promised broker filter, or whose broker never served or ran in a weaker mode than promised, is a RunError::Setup; nothing is signed.
Fetch jobs (policy.net)
Policy.net = Some(NetAllow { peers }) makes a native job a fetch job with a small DECLARED set of TCP peers, each NetPeer { host, addr, port }:
hostis the name the job talks to: it sends it as TLS SNI and checks the server certificate against it, never against the IP. Heimdall never resolves it.addris the IP literal the caller resolvedhostto (10.1.2.3,2001:db8::7or[2001:db8::7]), outside the sandbox. Real sources need several peers (PyPI:pypi.org+files.pythonhosted.org; crates:index.crates.io+static.crates.io); two names on one CDN address and port are two peers.NetAllow::checkrefuses, by name, asRunError::InvalidJob: no peers, more thanMAX_NET_PEERS(16), a peer declared twice (same host, address and port), ahostthat is not a host name, anaddrthat is not an IP literal, port 0.- The broker must run in full mode: on a kernel without ADDFD (check-only) or USER_NOTIF (deny-only), or with
BrokerSetting::Off, a fetch job isRunError::Unsupportedbefore anything runs. - The job learns its peers from the environment variable
HEIMDALL_NET_PEERS: the declared peers as a JSON array,[{"host":"pypi.org","addr":"151.101.0.223","port":443}, …], in the declared order. Connect toaddr:port; usehostfor SNI and the certificate check. - The job's own network namespace stays empty (only a down loopback). Its
socket(AF_INET or AF_INET6, SOCK_STREAM, 0 or IPPROTO_TCP)(a family one of the peers has) is created by the broker in the runner's network namespace and injected (ADDFD); itsconnectis done by the broker (pidfd_getfdof the job's socket) to the address the broker checked, and only when that address and port is one of the declared pairs, never through the job's pointer. Non-blocking sockets getEINPROGRESSas usual. - Every other socket (UDP, raw, unix, netlink, a family no peer has), every
connectto an undeclared address or port, and everybind,listen,acceptandMSG_FASTOPENsend is refused withEACCESand recorded as aBrokerEvent(refused: address not allowed,only TCP stream sockets,bind is never allowed, …); an allowed connect is recorded withaddr=<ip:port>andresult=ok(or the errno). - Redirects are not Heimdall's business. A fetch job that gets a redirect to a peer it did not declare returns the redirect target as its answer (an output file); the caller (Holger) resolves the new name and starts a new job with that peer declared.
- Landlock ABI 4 keeps refusing every TCP connect and bind the job makes itself; the allowed connects are the broker's, outside the job's Landlock domain.
Scan-worker job contract (HeimdallRunner)
This is what a scan worker (Holger's scan step, or any other native job) gets from heimdall-container 0.2.0, and what Holger's caller code must do with the answer. The protections themselves are listed in heimdall-container: HeimdallRunner and are not repeated here. Everything below is read from crates/heimdall-container/src/{lib,spec,shim,confine,kernel}.rs, the heimdall-plain parts they reuse (image.rs, spec.rs, outputs.rs) and their tests; it describes what is there, not plans.
The job input
| What | How it is implemented |
| Where | The whole Job.input_dir is bind-mounted at /in (rbind, ro, nosuid, nodev). There is no single input file: the caller puts the artifact (or several files) into input_dir before run, and the worker reads /in/<name>. |
| Read-only | Yes, three times: the mount is read-only, Landlock gives /in only read file + read dir (no write, no execute), and the broker opens /in for reading only. A write into /in fails and the host files are unchanged (test writing_into_the_input_fails_and_the_input_is_unchanged); Landlock alone refuses it even through a writable /in mount (landlock_alone_refuses_writing_into_in_even_through_a_writable_in_mount). |
| Checks | input_dir must exist and be a directory (it is canonicalized first), else RunError::InvalidJob. Its contents are not inspected or hashed. |
| Extra inputs | policy.read paths are bound read-only at the same path inside; policy.write paths read-write (no execute). Each must be absolute, without .., exist on the host, and not be / or under /in, /out, /proc, /dev or /sys. |
Job.params |
Not implemented: HeimdallRunner ignores params; the worker never sees it. Pass parameters in argv or as a file in input_dir. |
| stdin | /dev/null. |
The answer: /out and the output list
| What | How it is implemented |
| Where | Job.output_dir is bind-mounted read-write at /out. It must exist, be a directory and be empty when run starts (else InvalidJob). |
| Write-only | Landlock gives /out write file, truncate, make regular file / dir / symlink / fifo, remove and refer, but no read and no execute, and the broker opens /out files O_WRONLY only: the worker can create and write files (>, >> work) but cannot read them back or list /out, also not through /proc/self/fd/N or a symlink in /in. Landlock alone refuses read-back and listing although the mount allows both (landlock_alone_refuses_reading_back_and_listing_out_which_the_mount_allows). Write each answer in one pass. |
| Format | Heimdall imposes none. An output is any regular file directly in /out; its name is its output name. The worker and Holger agree on names and contents (for example one JSON report file). |
| Names | One plain file name: non-empty, at most 255 bytes, not . or .., no / or \, no NUL or control characters, valid UTF-8 (heimdall_core::validate_output_name). |
| stdout / stderr | /dev/null (the shim's stderr only with HEIMDALL_CONTAINER_DEBUG set). Nothing the worker prints reaches the caller; the answer must be a file. |
| Collection | After the job ends, the runner lists output_dir itself (heimdall_plain::outputs::scan): sorted by name, each file opened with O_NOFOLLOW, SHA-256 and byte length. The list becomes JobResult.outputs (Output { name, sha256, bytes }). |
| Caps | limits.max_output_files (default 64), limits.max_output_file_bytes (default 64 MiB), limits.max_output_total_bytes (default 256 MiB). max_output_file_bytes is also RLIMIT_FSIZE, so it caps every file the worker writes, in /tmp too. |
| Refused outputs | A directory, symlink, fifo, socket or device in /out, a bad or non-UTF-8 name, more than max_output_files files, or an unreadable entry: heimdall-out-1. A file over max_output_file_bytes, or the sum over max_output_total_bytes: heimdall-out-2. On either refusal outputs is empty and HeimdallRunner empties output_dir (never following a symlink), so the signed empty list and the directory agree and check_outputs passes. If emptying fails the run is RunError::Io, nothing signed. |
| Non-accepted runs | On heimdall-exit-1, heimdall-exit-2, heimdall-limit-wall and heimdall-limit-memory the outputs are still scanned and listed (unless the scan itself refuses). |
The image
| What | How it is implemented |
| Layout | HeimdallConfig::image_layout is a local OCI image layout directory. Only blobs/sha256/<hex> is read; oci-layout and index.json are not read. |
| Reference | JobKind::Native { image: ImageRef { name, digest }, argv }. Only digest is used: it must be sha256: + 64 lower-case hex (else InvalidJob), and the manifest is the blob with that digest. ImageRef.name is ignored. |
| Pinning | At every start the runner reads the manifest blob by the pinned digest and checks its SHA-256, then the config blob and every layer blob against the digests and sizes the manifest lists, while unpacking them into a fresh rootfs/ for this job only. Nothing is cached between jobs. A changed byte is refused at the next start (test a_changed_image_byte_is_refused_at_the_next_start). |
| Accepted | An OCI image manifest or Docker v2 manifest; an OCI image index or Docker manifest list whose entry for linux/<host arch> is then used (one level only). Layers: OCI tar, tar+gzip, Docker rootfs.diff.tar and rootfs.diff.tar.gzip. |
Refused (RunError::Image) |
a missing blob, a digest or size mismatch, a manifest or config blob over 4 MiB, a nested index, an index without linux/<arch>, another manifest media type, a manifest without config, another layer media type (for example zstd), and a layer entry that would leave the root (absolute path, .., a symlinked parent, a hard link outside). Device nodes and fifos in layers are skipped, ownership and xattrs are not applied, and OCI whiteouts are applied. |
| Used from the config | Only config.Env. Entrypoint, Cmd, WorkingDir, User, ExposedPorts and the rest are ignored. |
argv, env, user and working directory
- argv: exactly
JobKind::Native.argv, as the OCIprocess.args. It must be non-empty with a non-emptyargv[0](elseInvalidJob). Nothing is prepended (no entrypoint) and nothing is added. The tests call workers as["/bin/sh", "-c", "…"]; giveargv[0]as an absolute path in the image. - env: exactly the image config's
Env. When the config has noEnv(or the verified config blob does not parse as an image config), the env is onlyPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin. Nothing of the runner's or the shim's environment is passed (testthe_image_env_is_the_job_env_and_nothing_of_the_host_leaks). There is no way to add an env variable per job. - user: uid 0 / gid 0 inside, mapped to the runner's own uid / gid (one id each); every capability dropped, no-new-privs. A runner that runs as host root (e.g. PID 1 of an appliance) should set a host id range (since 0.2.1):
HeimdallRunner::new(cfg)?.with_host_ids(100000, 65536)?(alsoPlainYoukiRunner::with_host_ids). Container ids0..countthen map to host idsbase..base + count(one line per map, written by libcontainer straight intouid_map/gid_map, no newuidmap), so the job's root is host uidbase, not host root. Before the job starts the unpacked root is shifted into the range (ownerubecomesbase + uwhenu < count) andinput_dirandoutput_dirwith their contents are given tobase:base(never following a symlink); the job's output files belong tobase. Extra policy read/write paths are not changed. Refused asRunError::Unsupportednaminghost_idswhencountis 0,baseis 0, the range runs past the last id, the runner is not root, or the range holds the runner's own uid or gid. Unset (the default): the 0.2.0 behaviour above. It is a method on the runner, not a config field, so 0.2.x code that buildsHeimdallConfig/PlainConfigkeeps compiling. - cwd
/, hostnameheimdall-job, no terminal. - Filesystem: the image read-only (read + execute),
/tmpa 64 MiB tmpfs (read-write, no execute, counted against the memory limit),/procread./dev/nullread-write,/dev/{zero,full,random,urandom}read. Nothing else is reachable through Landlock, whatever the mounts allow. No network: an empty network namespace, and Landlock refuses TCP bind and connect (ABI 4).
Limits
All per-job limits come from Job.policy.limits (heimdall_core::Limits); the defaults are Limits::default() in crates/heimdall-core/src/types.rs.
| Limit | Default | Enforced by | When hit |
wall |
10 s | the shim: SIGKILL to the whole container at wall, counted from start |
heimdall-limit-wall, exit.killed = Wall |
memory_bytes |
256 MiB (must be ≥ 4 MiB) | cgroup v2 memory.max, memory.swap.max = 0 (written by the shim and read back) |
any oom_kill in the job's memory.events: heimdall-limit-memory, exit.killed = Memory, even if the job then exited 0 |
pids |
64 (must be > 0) | cgroup v2 pids.max (and RLIMIT_NPROC = pids + 1 as a backstop) |
fork fails inside; any max in the job's pids.events: heimdall-limit-pids, even if the job then exited 0 (exit.killed stays None: the runner did not kill it) |
cpus |
1.0 (must be in (0, 1024]) | cgroup v2 cpu.max = cpus × 100 ms per 100 ms (at least 1 ms) |
throttled, never refused |
max_output_file_bytes |
64 MiB | RLIMIT_FSIZE and the output scan |
SIGXFSZ: heimdall-exit-2 with "(a file over the size limit …)"; or heimdall-out-2 |
max_output_files, max_output_total_bytes |
64, 256 MiB | the output scan | heimdall-out-1, heimdall-out-2 |
| open files | 1024 | RLIMIT_NOFILE (spec::NOFILE, fixed) |
EMFILE inside |
| core dumps | 0 | RLIMIT_CORE (fixed) |
none written |
| io weight | io.weight 50 (1..=10000) |
HeimdallConfig::io_weight (DEFAULT_IO_WEIGHT), only when the io controller is delegated; the shim writes io.weight itself (libcgroups 0.7.0 overflows u16 converting an OCI blockIO.weight) and reads it back |
never refused |
| io rates | none | HeimdallConfig::io_max: IoMax { device, rates }, device = IoDevice::Number(DevNum) or IoDevice::Holding(path) (the disk of that path's file system), resolved to the WHOLE disk at start (sysfs at HeimdallConfig::sysfs_root); rates = rbps, wbps, riops, wiops (None = max, 0 refused) → OCI blockIO.throttle*Device → cgroup v2 io.max, read back by the shim before the job starts |
the job is throttled, never refused; io limits without the io controller refuse to START (strict or not), and so does a path whose file system has no block device (tmpfs, overlay, a btrfs subvolume) |
| the shim itself | wall + 30 s |
the runner (SHIM_GRACE): kills the shim, then writes cgroup.kill |
RunError::Setup "the shim did not finish in time"; nothing signed |
Not implemented for this runner: the wasm limits fuel, table_elements, instances, tables, memories, stack_bytes and decoders are ignored. A job that hits the pids limit is refused with heimdall-limit-pids whatever its exit (a job that survives a failed fork and exits 0 is NOT accepted: its work may be incomplete); the reason names the limit, the refused forks and how the job ended. Its own outputs stay listed, like after a non-zero exit.
The job workspace (Policy::workspace)
A job that needs room to work (unpack, build, trace an install) asks for a writable scratch area: policy.workspace = Some(Workspace { bytes, exec }). It is mounted at /work (WORKSPACE_PATH) INSIDE the job's container only, and it is gone when the job ends. Only what the job writes to /out leaves it, through the usual output scan; a job copies its artifacts there itself.
bytes <= 1 GiB (WORKSPACE_TMPFS_MAX) |
bytes > 1 GiB |
|
kind (Workspace::kind) |
Tmpfs: a tmpfs in the container's mount namespace |
HostDir: <work_dir>/workspaces/<job> (mode 0700) on the host disk, bind-mounted into that one container |
| cap | size=bytes: a write past it fails with ENOSPC; the pages count against memory_bytes, so give the job at least that much memory |
the shim measures the folder (allocated blocks) every 200 ms and at the end; over the cap = SIGKILL, refused heimdall-limit-workspace, exit.killed = Workspace |
| deleted | with the container | by the runner after the job, whatever its end |
Both kinds are nosuid,nodev,noexec, and Landlock gives /work read and write without execute. exec: true drops noexec and adds Landlock's execute right; it is for build and trace jobs only, and a runner refuses it (RunError::Unsupported) unless HeimdallConfig::allow_workspace_exec is set, so a scanner runner never gets exec. RLIMIT_FSIZE becomes the larger of max_output_file_bytes and bytes (a workspace file may be large; /out is still held to max_output_file_bytes by the output scan). Seccomp, the empty network namespace, the cgroup limits and the signing do not change. bytes = 0 and policy paths under /work are InvalidJob; heimdall-plain and the wasm runner refuse a workspace (Unsupported).
Measured on oden (kernel 7.0): buffered writes of more than the memory limit into a host-folder workspace can be OOM-killed (heimdall-limit-memory) before the cap is reached, because dirty page cache counts against memory.max; size memory_bytes for the job's write rate, or write with O_DIRECT.
features() and the kernel report
features() is fixed when the runner starts (admit in lib.rs):
runner "heimdall", container true, seccomp Default, landlock <L>,
broker <B>, wasm false, image_verified_each_start true
<B> is true when HeimdallConfig::broker is BrokerSetting::Auto and the kernel has USER_NOTIF and NOTIF_ADDFD (the broker's full mode). <L> is true when HeimdallConfig::landlock is LandlockMode::Auto and the kernel has Landlock. Each result's runner is the same, except that landlock is what the executor reported from inside that job, right before execve (fully or partially enforced both count as true). events holds the broker's decisions (see "The syscall broker").
HeimdallRunner::new probes the kernel (KernelReport::probe) and runner.kernel() returns it. Every signed result carries it as JobResult.kernel = Some(KernelFeatures { release, seccomp_filter, user_notif, notif_addfd, landlock_abi, cgroup_v2, cgroup_controllers, user_namespaces }), the same for every job of one runner (it is not re-probed per job). cgroup_controllers is what the jobs' parent cgroup offers. user_notif and notif_addfd choose the broker's mode.
| Kernel / config | strict = true (default) |
strict = false |
no seccomp kill_process action |
refuses to start | refuses to start |
| no Landlock | refuses to start | starts, landlock: false |
LandlockMode::Off |
refuses to start | starts, landlock: false |
| no cgroup v2, or memory / pids / cpu not delegated | refuses to start | starts |
| no unprivileged user namespaces | refuses to start | starts |
A refusal to start is RunError::Unsupported { what }, where what names the kernel release and every missing feature at once.
Generated seccomp profiles
Policy.seccomp: Option<SeccompProfileRef> (serde default None, not serialized when None) names the profile heimdall profile gen wrote for the job's workload, pinned by sha256 = sha256:<hex> of its OCI seccomp JSON (profiles/<workload>/<arch>-<libc>.oci.json):
policy.seccomp = Some(SeccompProfileRef {
source: ProfileSource::OciFile("/etc/heimdall/profiles/clamav/x86_64-glibc.oci.json".into()),
// or ProfileSource::Workload("heimdall-wasm-host".into()): checked in, compiled into Heimdall
sha256: "sha256:…".into(),
fallback_to_default: false,
});
OciFile: read by the runner (absolute path, no symlink, at most 1 MiB), never by the job;Workload: the checked-in profiles of the cratenordisk-heimdall-profiles, the one for the runner's arch whose digest matches.- Missing, another digest, another arch, not enforcing, or not buildable:
RunError::InvalidJob, nothing runs; withfallback_to_default: truea missing or tampered profile runs the job under the fixed profile and the result saysseccomp: Default. HeimdallConfig::seccompgives every job without its own reference that profile; it is resolved at start (a missing or tampered one refuses the start) andfeatures().seccompthen saysGenerated { profile_digest }.- How it is applied: youki installs the fixed profile plus one rule (
execveatonly on fd 1022 withAT_EMPTY_PATH); the executor, inside the job after Landlock, installs the generated profile on top (heimdall-seccomp's BPF builder, unknown call =KILL_PROCESS,TSYNC) and starts the job's program withexecveat(1022, "", argv, env, AT_EMPTY_PATH), the same start the probe saw. The kernel applies the stricter of the two filters to every call. After the install the executor writes the digest on its report fd (fd 1020, the only extra call the generated filter allows, close-on-exec); a job without that confirmation is aRunError::Setup, never signed. - Each result's
runner.seccompisGenerated { profile_digest }only when that job's executor confirmed it; a job that falls back saysDefault. A call outside the profile kills the job: signed Refusedheimdall-seccomp-1. WasmJobRunner(in-process) refuses a job naming a profile (Unsupported): a filter there would filter the caller.heimdall run --job wasmapplies the generatedheimdall-wasm-hostprofile to its own worker process (CLI.md, SECCOMP.md).
Errors and refusal codes
A signed refusal is a SignedResult with Verdict::Refused { code, reason }. HeimdallRunner produces exactly these codes, checked in this order:
| Code | When |
heimdall-limit-wall |
the shim killed the job at wall |
heimdall-limit-memory |
the job's cgroup counted an oom_kill |
heimdall-limit-pids |
the job's cgroup counted a refused fork (pids.events max), unless it died from SIGSYS |
heimdall-out-1 |
the output scan found an extra (see above); output_dir is emptied |
heimdall-out-2 |
an output or the total is over its cap; output_dir is emptied |
heimdall-exit-1 |
the job exited with a non-zero code |
heimdall-seccomp-1 |
the job died from SIGSYS: a call its seccomp profile kills (the reason names the profile: fixed, or generated with its digest) |
heimdall-exit-2 |
the job died from another signal (the reason names SIGXFSZ as the file size limit), or left no exit status |
Otherwise (exit code 0) the verdict is Accepted. It never produces heimdall-out-3, heimdall-out-4, heimdall-wasm-<n> or guest-refused (those are the wasm runner's: an emitted file refused / the output audit failed, a guest trap or wasm limit, the guest's own refusal).
Self-consistency rule: every signed result's outputs is exactly what is in output_dir when run returns. Whenever the output scan refuses the directory (out-1, out-2, also when a limit code wins) the list is empty and the directory is emptied; otherwise every file the job left is listed.
An error is Err(RunError) from run; nothing is signed and there is no result to verify:
RunError |
When |
Unsupported |
a wasm job; policy.net = Some(..); at start: the kernel table above |
InvalidJob |
policy.seccomp names a profile that is missing, has another digest, is for another arch, is not enforcing (default not SCMP_ACT_KILL_PROCESS) or does not build, and does not allow the fallback (the reason starts seccomp profile (); bad image digest; empty argv; wall zero; cpus outside (0, 1024]; pids zero; memory_bytes under 4 MiB; a bad policy path or one that does not exist; input_dir / output_dir missing or not a directory; output_dir not empty; a job id already used on this runner (its bundle dir exists) |
Image |
the image is refused (see the image table) |
Setup |
the shim cannot start, fails, times out or answers garbage; the container could not be created or started; no report from inside the job; an executor error before execve (also Landlock failing to apply); no-new-privs not set; seccomp not in filter mode; Landlock promised but not applied; memory.swap.max not 0 |
Io |
creating the bundle, writing config.json, planning Landlock, reading output_dir |
Sign |
the result failed validation before signing |
Each job gets its own bundle <work_dir>/jobs/<job id hex>/, removed after the run whatever the outcome; its container id is heimdall-<job id> and its cgroup <cgroup_parent>/job-<job id>.
Verifying the answer (the caller side)
let pinned = runner.verifying_key(); // once per runner (per boot)
let ticket = job.ticket(); // before run(job)
let signed = runner.run(job)?; // Err: nothing signed, no outputs to trust
let result = heimdall_core::verify(&signed, &pinned)?;
if result.job_id != ticket.job_id || result.nonce != ticket.nonce { /* refuse */ }
heimdall_core::check_outputs(result, &output_dir)?;
verify(heimdall-core/src/signed.rs) checks, in order: the format version is 2 (a 0.1 result isDecodeError::UnsupportedVersion(1)); the key is not a weak key (WeakKey);key_idequals the first 16 bytes ofsha256("heimdall.key-id.v1\0" || public key)(WrongKey); the Ed25519 signature verifies strictly over"heimdall.signed-result.v2\0"and the canonical wire v2 bytes (BadSignature); the result is well formed (valid, unique output names,finished_unix ≥ started_unix). It is stateless: it does not compare the job id or nonce and does not detect replays.- For that, pin
runner.pinned()(PinnedKey: key id, boot id, public key; text formheimdall-boot-key v1 <key_id> <boot_id> <public_key>) in aVerifierand callVerifier::accept(&signed.to_bytes(), &ticket): it also checks the boot id (WrongBoot), the job id (JobMismatch), the nonce (NonceMismatch) and refuses a repeat (Replayed). check_outputs(result, output_dir)then checks the directory againstresult.outputs: every listed file present, a regular file (not followed through a symlink), with the listed length and SHA-256, and no unlisted entry (OutputError::Unlisted). Use only files that passed it. After a refusal withheimdall-out-1or-2the list is empty and so is the directory (the self-consistency rule above):check_outputspasses.started_unix/finished_unixare whole seconds around the shim call, not the job's exact run time.
Dynamic-trace jobs (heimdall-trace)
Heimdall is a defensive sandbox. A dynamic-trace job runs a package's install or import (pip install --no-index of a wheel or sdist from /in, npm install of a tarball with its scripts on, python3 -c 'import x') inside a HeimdallRunner job with no network, and returns a signed trace of what it did, so the caller can diff it against a baseline (a clean package's trace) and hold a package on new behaviour. It fails closed: a trace that could not be taken is TraceOutcome::Unknown, never clean.
heimdall-trace = { package = "nordisk-heimdall-trace", version = "0.3.0" } # new crate, unreleased
use heimdall_trace::{run_trace, diff, TraceJob, TraceOutcome};
// once: the worker image (python3, pip, setuptools, node, npm) + the static tracer
let image = heimdall_trace::image::with_tracer(&layout, &worker_image, Path::new("heimdall-trace"))?;
let mut tj = TraceJob::new(image, vec!["/usr/bin/python3".into(), "-m".into(), "pip".into(),
"install".into(), "--no-index".into(), "--no-deps".into(), "--no-build-isolation".into(),
"--break-system-packages".into(), "--target".into(), "/tmp/site".into(), "/in/pkg.whl".into()]);
let (signed, outcome) = run_trace(&runner, &tj, input_dir, output_dir); // runner: HeimdallRunner
match outcome {
TraceOutcome::Traced { trace, job_code } => {
let d = diff(&baseline, &trace);
if !d.is_clean() { /* hold the package: d names the new behaviour */ }
}
TraceOutcome::Unknown { reason } => { /* hold: no trace to trust */ }
}
No new job kind or policy flag. A trace job is an ordinary JobKind::Native job whose argv starts the tracer (/heimdall/heimdall-trace --out /out/trace.json ... -- <command>) in a trace image; its one output is trace.json, listed with its SHA-256 in the SignedResult. Every runner, the signed wire and verify stay as they are. TraceJob::job() builds the Job (call ticket() on it before run), or run_trace does it all; verify_trace(signed, key, ticket, output_dir, max_bytes) checks a result from elsewhere.
The tracer (inside the job)
The static heimdall-trace binary (musl) is PID 1 of the job. It starts the command under heimdall-probe's learning filter (seccomp USER_NOTIF for every call, heimdall-seccomp's Launch with exec_by_path: a plain execve, since the job's fixed profile refuses execveat) and answers every call itself:
| Calls | Recorded | Answer |
| every call | per program (/proc/<tid>/exe): the call with heimdall-probe's argument facts (socket domain/type/protocol, open flags, ioctl, mmap/mprotect prot, clone flags, prctl, fcntl) |
as below, else continue |
execve, execveat |
the caller, the path (absolute), argv (64 args × 1024 bytes at most) | continue |
connect, bind, sendto/sendmsg with an address |
op, family (inet, inet6, unix, netlink, ...), address |
EACCES for every family but AF_UNIX (and a unix bind outside the writable paths); continue for AF_UNIX |
file calls: open*, creat, mkdir*, unlink*, rmdir, rename*, link*, symlink*, chmod/fchmodat*, chown/fchownat, truncate, mknod* |
op, path (absolute, normalised; a write's parent symlinks resolved), open flags, write, outside_tmp |
EACCES for a write outside the writable paths (/tmp by default, and /dev/null), into /out, or with an unreadable path; continue otherwise |
| a foreign arch or an unknown number | in unknown |
ENOSYS |
When the command ends, or at the tracer's deadline (the job's wall limit less 5 s), the tracer kills every process left (kill(-1, SIGKILL)), reaps them all, and only then writes the trace into the file it created before the command started. As PID 1 it cannot be signalled from inside the job, and nothing of the package runs while it writes. The tracer exits 0 whenever it wrote a document (an incomplete one says why); 71 when it could write none.
Not seen: calls the job's fixed profile answers before the listener (EPERM or kill: the stricter seccomp action wins), the vDSO time calls, a sendmsg on fd 1021 (the launch prelude's listener hand-off; closed at exec). Paths and addresses are read from the caller's memory when the call stops; another thread can change them before the kernel reads them again (USER_NOTIF Continue). Such a race can make a recorded path differ from the one the kernel used, never widen what the job may do: Landlock and the read-only root still decide, and a write that lands in /out makes the result Unknown (an extra output). The TOCTOU-safe way is the broker's (open on the caller's behalf, NOTIF_ADDFD).
A trace job runs without the broker. The kernel allows one seccomp listener per filter chain (a second NEW_LISTENER below one is EBUSY), and the tracer holds it: the trace says listener: "heimdall-trace", the result says broker: false, and file access stays enforced by the mounts and Landlock. A broker and a trace together are refused by name (heimdall_trace::job::BROKER_REFUSAL): run_trace on a runner whose features say broker: true signs nothing and is Unknown, and so is verify_trace of a result whose runner had a broker. The way to both is an observer hook in the broker (one listener for brokering and tracing; lane broker, later).
Node: node 22's libuv calls io_uring_setup at start (its epoll ring) whatever UV_USE_IO_URING says; the fixed profile refuses it with EPERM (not kill), and node carries on without a ring. io_uring_enter and io_uring_register still kill.
The trace document (heimdall.trace.v1)
Canonical JSON: compact, fields in this order, every list a sorted set without duplicates; Trace::from_canonical re-encodes and refuses any other encoding, a foreign format, unknown fields, contradicting fields, or a document over its own max_bytes or the reader's bound. No pid, time or per-entry count, so two runs of one package give the same sets.
{ format: "heimdall.trace.v1", argv: [str], status: "complete"|"truncated"|"incomplete",
incomplete: str, exit: str ("exit 0", "killed by signal 9", "killed by seccomp (SIGSYS)", "timed out"),
timed_out: bool, leftover_killed: u64, notifications: u64, dropped: u64, max_bytes: u64,
listener: "heimdall-trace",
exec: [{program, path, argv: [str], argv_cut: bool}],
net: [{program, op, family, address, refused: bool}],
files: [{program, op, path, flags: "0x..", write: bool, outside_tmp: bool, refused: bool}],
syscalls: {program: [{syscall, args: {index: "0x.."}}]},
unknown: [str] }
The size bound (TraceJob::max_bytes, default 4 MiB, at least 4096) holds for the whole document: once full, new entries are counted in dropped and the status is truncated (never silently complete).
The outcome and the diff
TraceOutcome |
When |
Traced { trace, job_code: None } |
the result verifies (key, ticket), the outputs are exactly trace.json with the signed hash, the job was Accepted, the trace is canonical, within the bound and complete or truncated |
Traced { trace, job_code: Some(code) } |
the same, but the job was refused heimdall-limit-pids or heimdall-limit-memory (a fork bomb): the tracer survived and the trace is still signed |
Unknown { reason } |
anything else: the runner signed nothing, a bad signature/key/ticket, a changed or extra output, any other refusal (wall limit, exit, seccomp), a trace over the bound, not canonical, or incomplete |
diff(baseline, trace) lists what the trace does that the baseline does not: exec by (program, path) (argv shown, not compared: temp names), net by (op, family, address, refused), files by (op, path, write, refused) leaving out non-refused calls inside /tmp, syscalls by (program, call), unknown calls. TraceDiff::is_clean() is false on anything new and whenever either trace is not complete. Holger holds a package unless the outcome is Traced and the diff against its baseline is clean.
CLI
`heimdall trace run --layout --image sha256:.. [--tracer ] --in
Wasm behind the trait
heimdall_run::WasmJobRunner runs JobKind::Wasm jobs through the same trait (signed guests verified and blake3-pinned on every job, no WASI); runner: "heimdall-wasm", wasm: true, container: false, seccomp: None.
Static-scanner guests (heimdall-scan, guests/static-scan)
Heimdall is a defensive sandbox. A static scanner is a signed wasm guest that judges one package artifact without running it: Holger's rule "static parsers run as Heimdall wasm guests". The contract is the crate nordisk-heimdall-scan (lib heimdall_scan, only serde; it builds for wasm32 and natively), shared by the guest and the caller. The first guest, guests/static-scan, runs five checks with modgunn's own code (modgunn 0.0.8 from crates.io, features scan + scip, unchanged):
| Check | What it finds | modgunn code | Data it needs |
behavior |
install hooks, network at install, URLs, high-entropy or hex payloads, sensitive APIs (npm package.json; PyPI setup.py, pyproject.toml, setup.cfg; a wheel's top-level .pth import lines), plus the archive's own shape (below) |
behavior::parse_npm_package, parse_pypi_setup, parse_pyproject_toml, parse_setup_cfg |
the artifact |
typosquat |
a name one edit from a popular name (PyPI names PEP 503-normalized first) | scip::typosquat_findings |
popular-names |
dependency-confusion |
an internal-only name resolved from a public registry | scip::typosquat_findings |
internal-namespaces + param origin |
registry |
a yanked (crates.io, PyPI), deprecated (npm) or retracted (Go) version | registry::parse_crates_yanks, parse_npm_deprecations, parse_pypi_yanks, parse_go_retractions |
registry |
malicious |
the package on the OpenSSF malicious-packages feed (MAL-…) |
feeds::parse_malicious |
malicious |
The job
A plain JobKind::Wasm job (WasmJobRunner / heimdall run --job wasm): the input dir holds one file, the scan-input envelope; Job.params is a JSON object of strings.
magic "heimdall-scan-input v1\n"
count u32 big endian (1 ..= 16)
section name_len u8 | name | body_len u64 big endian | body (count times)
| Section | Body |
artifact (required) |
the package file as the registry served it: npm .tgz, PyPI sdist (.tar.gz or .zip) or wheel, .crate, … (the kind is read from the magic bytes, never from a name) |
popular-names |
popular names of the purl's ecosystem, UTF-8, one per line, # comments and blank lines skipped |
internal-namespaces |
internal name prefixes (@acme/, acme-), same syntax; an empty body means "none" |
registry |
the registry's document for this package, verbatim: crates.io /api/v1/crates/<name>, the npm packument, PyPI /pypi/<name>/json, or the module's go.mod |
malicious |
OSV MAL- records, one record or a JSON array ([] is a real "none") |
Unknown or repeated sections, a bad magic, a short body and trailing bytes are refused. Build it with heimdall_scan::ScanInput::new(artifact).with(DataKind::Registry, doc).encode(). There is no network in a guest: every snapshot comes in here, and the report names each by SHA-256.
| Param | Value | Default |
purl |
the package URL (required; pkg:npm/%40acme/x@1.0.0, pkg:pypi/pyyaml@5.3, pkg:cargo/smallvec@1.6.0, pkg:golang/github.com/a/b@v1.2.0) |
— |
checks |
comma list of check names | all five |
origin |
public or internal: where the caller resolved the package |
none (dependency-confusion is then unknown) |
max-unpacked-bytes |
bytes the archive may unpack to (at most 1 GiB) | 64 MiB |
max-entries |
archive members | 100 000 |
max-manifest-bytes |
one manifest read into memory | 1 MiB |
max-depth |
path components of a member | 32 |
An unknown key is refused, never ignored.
The answer
An accepted job emits exactly one file, findings.json (heimdall_scan::REPORT_NAME): the canonical findings document (heimdall_scan::Report, compact JSON, fields in declared order, no floats).
{"scanner":{"name":"heimdall-static-scan","version":"0.1.0","modgunn":"0.0.8"},
"subject":{"purl":"pkg:npm/evil-pkg@1.0.0","ecosystem":"npm","name":"evil-pkg","version":"1.0.0","sha256":"…","bytes":231},
"data":[{"name":"popular-names","sha256":"…","bytes":31}],
"checks":[{"check":"behavior","verdict":"findings","findings":[{"id":"MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL","severity":"high","summary":"…","path":"package/package.json"}]},
{"check":"typosquat","verdict":"clean","findings":[]},
{"check":"registry","verdict":"unknown","findings":[],"unknown":{"code":"data-missing","detail":"no registry snapshot"}}],
"verdict":"findings"}
- Per check:
findingswhen it found anything (anunknownreason may sit beside it when it could not finish), elseunknownwhen it could not judge, elseclean. Overall:findingsoverunknownoverclean. Fail closed: hold the package on anything butclean. unknown.codeis stable:data-missing,data-invalid(unreadable, empty where it may not be, or about another package or a version it does not list),unsupported-ecosystem,archive-unreadable,archive-over-limits,no-manifest,no-version,origin-missing.- Archive-shape findings (
behavior):HEIMDALL-SCAN-ARCHIVE-PATH-TRAVERSAL(absolute,.., backslash, drive; High),-LINK-ESCAPE(High),-ZIP-OVERLAP(the overlapping-member zip bomb; High, and the archive is not judged further),-NAME-NOT-UTF8,-NAME-CONTROL,-DEEP-NESTING,-SPECIAL-FILE,-DUPLICATE-NAME(every copy of a manifest is judged),-ZIP-NAME-MISMATCH(Medium);HEIMDALL-SCAN-WHEEL-PTH-EXEC(Medium). Gzip CRC and length, zip CRC-32, sizes and the central directory's count are all checked, so a corrupted package never reads as clean.
A refusal (heimdall-wasm-1) means the guest could not read the job at all; no document is emitted. The reason starts with a bracketed code (heimdall_scan::codes): [heimdall-scan-1] bad envelope, [heimdall-scan-2] bad parameter, [heimdall-scan-3] missing or bad purl, [heimdall-scan-4] unknown check. Traps and limits are the host's heimdall-wasm-2 … -13. Treat every refusal as a hold too.
The caller (Holger)
use heimdall_scan::{in_toto_statement, DataKind, Report, ScanInput, Verdict, REPORT_NAME};
let input = ScanInput::new(artifact_bytes)
.with(DataKind::PopularNames, popular) // from Holger's own snapshot store
.with(DataKind::Registry, registry_doc)
.with(DataKind::Malicious, mal_feed)
.with(DataKind::InternalNamespaces, prefixes)
.encode();
std::fs::write(input_dir.join("scan-input"), &input)?;
let params = serde_json::json!({ "purl": purl, "origin": "public" });
// … run the signed guest as a JobKind::Wasm job, verify the SignedResult,
// check_outputs, then:
let report = Report::from_canonical_json(&std::fs::read(out_files.join(REPORT_NAME))?)?;
report.check_subject(&purl, &sha256_hex(&artifact_bytes))?;
let statement = in_toto_statement(&report); // in-toto Statement v1
// sign `statement` (DSSE) into the evidence; hold unless report.verdict == Verdict::Clean
PREDICATE_TYPE is https://gunnar.rs/nordisk/heimdall/static-scan/v1; the statement's subject is the purl with the artifact's SHA-256. Report::from_canonical_json refuses a document that is not canonical, has a verdict that does not follow from its contents, or an unknown code.