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

nordisk/heimdall

crates/heimdall-broker/src/filter.rs

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB

//! The broker's seccomp filter, built with heimdall-seccomp's BPF builder:
//! the brokered calls return `SECCOMP_RET_USER_NOTIF` (or
//! `SECCOMP_RET_ERRNO(EACCES)` in the deny-only fallback), every other call
//! `SECCOMP_RET_ALLOW` (foreign arches and x32 calls are killed, as in every
//! heimdall-seccomp program).
//!
//! Heimdall is a defensive sandbox. This filter is stacked on the job's
//! allowlist (heimdall-container's fixed profile, or a generated profile):
//! the kernel runs every filter and takes the strictest answer, so `KILL` or
//! `ERRNO` from the allowlist still win over `USER_NOTIF` here, and `ALLOW`
//! here never widens the allowlist.
//!
//! Also routed to the broker (always refused there) are the calls that would
//! open, connect or listen around it: `bind`, `listen`, `accept`/`accept4`,
//! `io_uring_setup` (io_uring opens and connects without a system call), and
//! `sendto`/`sendmsg`/`sendmmsg` with `MSG_FASTOPEN` (a TCP connect without
//! `connect`).

use heimdall_seccomp::bpf::{build_rules, BuildError, Program};
use heimdall_seccomp::{Action, Arch, ArgCmp, Rule, Width};

use crate::policy::ExecPolicy;

/// The open calls of this arch (the path is an argument the broker reads).
#[cfg(target_arch = "x86_64")]
pub const OPEN_CALLS: &[&str] = &["openat", "openat2", "open", "creat"];
#[cfg(not(target_arch = "x86_64"))]
pub const OPEN_CALLS: &[&str] = &["openat", "openat2"];

/// The socket calls the broker decides (or always refuses).
#[cfg(target_arch = "x86_64")]
pub const NET_CALLS: &[&str] = &["socket", "connect", "bind", "listen", "accept", "accept4"];
#[cfg(not(target_arch = "x86_64"))]
pub const NET_CALLS: &[&str] = &["socket", "connect", "bind", "listen", "accept4"];

/// The send calls that are brokered only with `MSG_FASTOPEN` set (flags
/// argument index).
pub const FASTOPEN_CALLS: &[(&str, u8)] = &[("sendto", 3), ("sendmsg", 2), ("sendmmsg", 3)];

/// The exec calls.
pub const EXEC_CALLS: &[&str] = &["execve", "execveat"];

/// What brokered calls return.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FilterMode {
    /// `SECCOMP_RET_USER_NOTIF`: the broker decides.
    Notify,
    /// `SECCOMP_RET_ERRNO(EACCES)`: the kernel has no USER_NOTIF (spec §10).
    Deny,
}

/// The arch this process runs on.
pub fn arch() -> Arch {
    Arch::native().expect("heimdall-broker runs on x86_64 or aarch64")
}

/// The filter's rules. Exec is notified in [`FilterMode::Notify`] (the
/// broker logs it and decides by [`ExecPolicy`]); in [`FilterMode::Deny`]
/// it is refused only under [`ExecPolicy::Deny`], so a fail-closed job can
/// still start its program.
pub fn rules(mode: FilterMode, exec: ExecPolicy) -> Vec<Rule> {
    let action = match mode {
        FilterMode::Notify => Action::UserNotif,
        FilterMode::Deny => Action::Errno(libc::EACCES as u16),
    };
    let mut v: Vec<Rule> = OPEN_CALLS
        .iter()
        .chain(NET_CALLS)
        .chain(["io_uring_setup"].iter())
        .map(|c| Rule::bare(c, action))
        .collect();
    for (c, arg) in FASTOPEN_CALLS {
        v.push(Rule {
            syscall: (*c).into(),
            action,
            when: vec![vec![ArgCmp {
                arg: *arg,
                width: Width::W32,
                mask: libc::MSG_FASTOPEN as u64,
                value: libc::MSG_FASTOPEN as u64,
            }]],
        });
    }
    let exec_action = match (mode, exec) {
        (FilterMode::Notify, _) => Some(Action::UserNotif),
        (FilterMode::Deny, ExecPolicy::Deny) => Some(action),
        (FilterMode::Deny, _) => None,
    };
    if let Some(a) = exec_action {
        v.extend(EXEC_CALLS.iter().map(|c| Rule::bare(c, a)));
    }
    v
}

/// The broker filter program for this arch (default: allow).
pub fn program(mode: FilterMode, exec: ExecPolicy) -> Result<Program, BuildError> {
    build_rules(arch(), &rules(mode, exec), Action::Allow)
}

/// The name of a brokered call number on this arch.
pub fn name_of(nr: i32) -> &'static str {
    u32::try_from(nr)
        .ok()
        .and_then(|n| arch().syscall_name(n))
        .unwrap_or("unknown")
}

/// Does the kernel know `SECCOMP_RET_USER_NOTIF`?
pub fn user_notif_available() -> bool {
    let action: u32 = Action::UserNotif.ret();
    // SAFETY: SECCOMP_GET_ACTION_AVAIL (2) reads one u32.
    unsafe {
        libc::syscall(
            libc::SYS_seccomp,
            2 as libc::c_ulong,
            0 as libc::c_ulong,
            &action as *const u32,
        ) == 0
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use heimdall_seccomp::interp;
    use heimdall_seccomp::notif::SeccompData;

    fn eval(p: &Program, name: &str, args: [u64; 6]) -> u32 {
        let nr = arch().syscall_nr(name).unwrap() as i32;
        let d = SeccompData {
            nr,
            arch: arch().audit_arch(),
            instruction_pointer: 0,
            args,
        };
        interp::run(p.insns(), &d).unwrap()
    }

    #[test]
    fn every_open_and_net_call_is_notified_and_ordinary_calls_are_allowed() {
        let p = program(FilterMode::Notify, ExecPolicy::Allow).unwrap();
        for c in OPEN_CALLS.iter().chain(NET_CALLS).chain(EXEC_CALLS) {
            assert_eq!(eval(&p, c, [0; 6]), Action::UserNotif.ret(), "{c}");
        }
        for c in [
            "read", "write", "mmap", "close", "fstat", "sendmsg", "sendto",
        ] {
            assert_eq!(eval(&p, c, [0; 6]), Action::Allow.ret(), "{c}");
        }
    }

    #[test]
    fn a_send_with_msg_fastopen_goes_to_the_broker_and_a_plain_send_does_not() {
        let p = program(FilterMode::Notify, ExecPolicy::Allow).unwrap();
        let fo = libc::MSG_FASTOPEN as u64;
        assert_eq!(
            eval(&p, "sendto", [3, 0, 0, fo, 0, 0]),
            Action::UserNotif.ret()
        );
        assert_eq!(
            eval(&p, "sendmsg", [3, 0, fo | 0x40, 0, 0, 0]),
            Action::UserNotif.ret()
        );
        assert_eq!(
            eval(&p, "sendmmsg", [3, 0, 0, fo, 0, 0]),
            Action::UserNotif.ret()
        );
        assert_eq!(
            eval(&p, "sendto", [3, 0, 0, 0x40, 0, 0]),
            Action::Allow.ret()
        );
        // Dirty upper bits do not hide the flag (32-bit flags argument).
        assert_eq!(
            eval(&p, "sendto", [3, 0, 0, fo | 0xffff_0000_0000_0000, 0, 0]),
            Action::UserNotif.ret()
        );
    }

    #[test]
    fn deny_mode_fails_brokered_calls_with_eacces_and_leaves_exec_unless_exec_is_denied() {
        let errno = Action::Errno(libc::EACCES as u16).ret();
        let p = program(FilterMode::Deny, ExecPolicy::Allow).unwrap();
        assert_eq!(eval(&p, "openat", [0; 6]), errno);
        assert_eq!(eval(&p, "connect", [0; 6]), errno);
        assert_eq!(eval(&p, "execve", [0; 6]), Action::Allow.ret());
        let p = program(FilterMode::Deny, ExecPolicy::Deny).unwrap();
        assert_eq!(eval(&p, "execve", [0; 6]), errno);
    }

    #[test]
    fn a_foreign_arch_and_an_x32_call_are_killed() {
        let p = program(FilterMode::Notify, ExecPolicy::Allow).unwrap();
        let d = SeccompData {
            nr: 0,
            arch: 0x4000_0003,
            instruction_pointer: 0,
            args: [0; 6],
        };
        assert_eq!(
            interp::run(p.insns(), &d).unwrap(),
            Action::KillProcess.ret()
        );
        if arch() == Arch::X86_64 {
            let d = SeccompData {
                nr: 0x4000_0000 | 257,
                arch: arch().audit_arch(),
                instruction_pointer: 0,
                args: [0; 6],
            };
            assert_eq!(
                interp::run(p.insns(), &d).unwrap(),
                Action::KillProcess.ret()
            );
        }
    }
}