crates/heimdall-seccomp/src/bpf.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The classic-BPF builder: pure Rust, no libseccomp. Heimdall is a
//! defensive sandbox; the program it builds is the whole allowlist.
//!
//! Layout:
//!
//! ```text
//! ld [arch] ; foreign arch -> KILL_PROCESS (every mode)
//! jeq AUDIT_ARCH, +1
//! ret KILL_PROCESS
//! ld [nr]
//! jge 0x40000000, ... ; x86_64 only: x32 calls -> KILL_PROCESS (every mode)
//! ; per syscall, in first-appearance order of its rules:
//! jeq NR, +1 ; not this call: jump over its block
//! ja <block len>
//! ; per rule, per alternative: every comparison, 32 bits at a time
//! ld [args[i] hi] ; and MASK_HI ; jeq VALUE_HI, +1 ; ja <next alternative>
//! ld [args[i] lo] ; and MASK_LO ; jeq VALUE_LO, +1 ; ja <next alternative>
//! ret ACTION
//! ret DEFAULT ; the call's rules did not match
//! ...
//! ret DEFAULT ; a call with no rules
//! ```
//!
//! Every forward jump past more than one instruction is a `ja` (32-bit
//! offset), so blocks of any length work; the only limit is the kernel's
//! 4096 instructions.
use crate::arch::{Arch, X32_SYSCALL_BIT};
use crate::profile::{validate_rules, Action, Profile, ProfileError, Rule, Width};
/// `struct sock_filter`.
#[repr(C)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct SockFilter {
pub code: u16,
pub jt: u8,
pub jf: u8,
pub k: u32,
}
/// `BPF_LD | BPF_W | BPF_ABS`.
pub const LD_W_ABS: u16 = 0x20;
/// `BPF_ALU | BPF_AND | BPF_K`.
pub const ALU_AND_K: u16 = 0x54;
/// `BPF_JMP | BPF_JA`.
pub const JMP_JA: u16 = 0x05;
/// `BPF_JMP | BPF_JEQ | BPF_K`.
pub const JMP_JEQ_K: u16 = 0x15;
/// `BPF_JMP | BPF_JGT | BPF_K`.
pub const JMP_JGT_K: u16 = 0x25;
/// `BPF_JMP | BPF_JGE | BPF_K`.
pub const JMP_JGE_K: u16 = 0x35;
/// `BPF_JMP | BPF_JSET | BPF_K`.
pub const JMP_JSET_K: u16 = 0x45;
/// `BPF_RET | BPF_K`.
pub const RET_K: u16 = 0x06;
/// The kernel's `BPF_MAXINSNS`.
pub const MAX_INSNS: usize = 4096;
/// Offsets in `struct seccomp_data`.
const OFF_NR: u32 = 0;
const OFF_ARCH: u32 = 4;
const fn off_arg_lo(i: u8) -> u32 {
16 + 8 * i as u32
}
const fn off_arg_hi(i: u8) -> u32 {
16 + 8 * i as u32 + 4
}
const fn stmt(code: u16, k: u32) -> SockFilter {
SockFilter {
code,
jt: 0,
jf: 0,
k,
}
}
const fn jump(code: u16, k: u32, jt: u8, jf: u8) -> SockFilter {
SockFilter { code, jt, jf, k }
}
/// A built filter for one arch.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Program {
arch: Arch,
insns: Vec<SockFilter>,
}
impl Program {
/// The arch the program checks for.
pub fn arch(&self) -> Arch {
self.arch
}
/// The instructions.
pub fn insns(&self) -> &[SockFilter] {
&self.insns
}
/// The instruction count.
pub fn len(&self) -> usize {
self.insns.len()
}
/// Never true for a built program.
pub fn is_empty(&self) -> bool {
self.insns.is_empty()
}
}
/// The token that allows audit mode. Heimdall is a defensive sandbox: audit
/// mode lets unknown calls RUN (logged), so it exists for staging runs on
/// test boxes only (spec §3.3), never in production.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TestBox(());
/// The environment variable a test box sets to `1` to allow audit mode.
pub const TEST_BOX_ENV: &str = "HEIMDALL_SECCOMP_AUDIT_TEST_BOX";
/// Audit mode was asked for on a box that is not marked a test box.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("seccomp audit mode is for test boxes only: set {TEST_BOX_ENV}=1 on a test box (never in production)")]
pub struct NotATestBox;
impl TestBox {
/// A token when this box is marked a test box (`HEIMDALL_SECCOMP_AUDIT_TEST_BOX=1`).
pub fn from_env() -> Result<TestBox, NotATestBox> {
match std::env::var_os(TEST_BOX_ENV) {
Some(v) if v == "1" => Ok(TestBox(())),
_ => Err(NotATestBox),
}
}
}
/// Enforcing or audit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mode {
/// Production: a call outside the profile is `SECCOMP_RET_KILL_PROCESS`
/// (the kernel writes an audit record for it).
Enforce,
/// Staging on test boxes: a call outside the profile is
/// `SECCOMP_RET_LOG` (runs, and is logged).
Audit(TestBox),
}
impl Mode {
/// The action for a call outside the profile.
pub fn default_action(&self) -> Action {
match self {
Mode::Enforce => Action::KillProcess,
Mode::Audit(_) => Action::Log,
}
}
}
/// Why a program could not be built.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum BuildError {
#[error(transparent)]
Profile(#[from] ProfileError),
#[error("the filter needs {0} instructions; the kernel takes at most {MAX_INSNS}")]
TooLong(usize),
}
/// Build the filter for a profile in a mode.
pub fn build(profile: &Profile, mode: &Mode) -> Result<Program, BuildError> {
build_rules(profile.arch, &profile.rules, mode.default_action())
}
/// Build a filter from rules with any default action (the probe's learning
/// filter uses `UserNotif`). Rules are validated first.
pub fn build_rules(arch: Arch, rules: &[Rule], default: Action) -> Result<Program, BuildError> {
validate_rules(arch, rules)?;
let mut p = vec![
stmt(LD_W_ABS, OFF_ARCH),
jump(JMP_JEQ_K, arch.audit_arch(), 1, 0),
stmt(RET_K, Action::KillProcess.ret()),
stmt(LD_W_ABS, OFF_NR),
];
if arch == Arch::X86_64 {
p.push(jump(JMP_JGE_K, X32_SYSCALL_BIT, 0, 1));
p.push(stmt(RET_K, Action::KillProcess.ret()));
}
// Group the rules by syscall, in first-appearance order.
let mut groups: Vec<(u32, Vec<&Rule>)> = Vec::new();
for r in rules {
let nr = arch.syscall_nr(&r.syscall).expect("validated");
match groups.iter_mut().find(|(n, _)| *n == nr) {
Some((_, v)) => v.push(r),
None => groups.push((nr, vec![r])),
}
}
for (nr, group) in groups {
let block = syscall_block(&group, default);
p.push(jump(JMP_JEQ_K, nr, 1, 0));
p.push(stmt(JMP_JA, block.len() as u32));
p.extend(block);
}
p.push(stmt(RET_K, default.ret()));
if p.len() > MAX_INSNS {
return Err(BuildError::TooLong(p.len()));
}
Ok(Program { arch, insns: p })
}
/// The block of one syscall: its rules in order, then `ret default`.
fn syscall_block(rules: &[&Rule], default: Action) -> Vec<SockFilter> {
let mut b = Vec::new();
for r in rules {
if r.when.is_empty() {
// Matches always: later rules of this call are unreachable.
b.push(stmt(RET_K, r.action.ret()));
return b;
}
for alt in &r.when {
b.extend(alternative(alt, r.action));
}
}
b.push(stmt(RET_K, default.ret()));
b
}
/// One alternative: every comparison must hold, then `ret action`; any
/// failing comparison jumps to the instruction after the `ret`.
fn alternative(cmps: &[crate::profile::ArgCmp], action: Action) -> Vec<SockFilter> {
// Each check is (offset, mask, value) on one 32-bit half.
let mut checks = Vec::new();
for c in cmps {
let halves: Vec<(u32, u32, u32)> = match c.width {
Width::W32 => vec![(off_arg_lo(c.arg), c.mask as u32, c.value as u32)],
Width::W64 => vec![
(
off_arg_hi(c.arg),
(c.mask >> 32) as u32,
(c.value >> 32) as u32,
),
(off_arg_lo(c.arg), c.mask as u32, c.value as u32),
],
};
for (off, mask, value) in halves {
if mask == 0 {
// (x & 0) == value: value is 0 (validated), always true.
continue;
}
checks.push((off, mask, value));
}
}
// Emit, then patch the `ja` offsets to land just past the final `ret`.
let mut out: Vec<SockFilter> = Vec::new();
let mut jas = Vec::new();
for (off, mask, value) in checks {
out.push(stmt(LD_W_ABS, off));
if mask != u32::MAX {
out.push(stmt(ALU_AND_K, mask));
}
out.push(jump(JMP_JEQ_K, value, 1, 0));
jas.push(out.len());
out.push(stmt(JMP_JA, 0));
}
out.push(stmt(RET_K, action.ret()));
let end = out.len();
for i in jas {
out[i].k = (end - i - 1) as u32;
}
out
}