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()
);
}
}
}