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

nordisk/heimdall

crates/heimdall-seccomp/src/profile.rs

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

//! Profile types (spec §3.1 step 4). Heimdall is a defensive sandbox: a
//! profile is the complete list of calls one workload may make; everything
//! else gets the mode's default action (kill in production).
//!
//! Two forms: [`StaticProfile`] is the `const` table a generated
//! `profiles/<workload>/<arch>-<libc>.rs` holds; [`Profile`] is the owned
//! form the builder, the probe and the JSON use. `Profile::from(&STATIC)`
//! converts.

use std::fmt;

use serde::{Deserialize, Serialize};

use crate::arch::{Arch, Libc};

/// What the filter does with a call.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
    /// `SECCOMP_RET_ALLOW`.
    Allow,
    /// `SECCOMP_RET_LOG`: allow after a kernel audit log entry (audit mode).
    Log,
    /// `SECCOMP_RET_ERRNO`: fail the call with this errno, do not run it.
    Errno(u16),
    /// `SECCOMP_RET_USER_NOTIF`: pause the caller and ask the supervisor
    /// (the broker, or the probe in learning mode).
    UserNotif,
    /// `SECCOMP_RET_TRAP`: send SIGSYS to the thread.
    Trap,
    /// `SECCOMP_RET_KILL_THREAD`.
    KillThread,
    /// `SECCOMP_RET_KILL_PROCESS`: the enforcing default.
    KillProcess,
}

/// `SECCOMP_RET_KILL_PROCESS`.
pub const RET_KILL_PROCESS: u32 = 0x8000_0000;
/// `SECCOMP_RET_KILL_THREAD`.
pub const RET_KILL_THREAD: u32 = 0x0000_0000;
/// `SECCOMP_RET_TRAP`.
pub const RET_TRAP: u32 = 0x0003_0000;
/// `SECCOMP_RET_ERRNO`.
pub const RET_ERRNO: u32 = 0x0005_0000;
/// `SECCOMP_RET_USER_NOTIF`.
pub const RET_USER_NOTIF: u32 = 0x7fc0_0000;
/// `SECCOMP_RET_LOG`.
pub const RET_LOG: u32 = 0x7ffc_0000;
/// `SECCOMP_RET_ALLOW`.
pub const RET_ALLOW: u32 = 0x7fff_0000;

impl Action {
    /// The filter return value.
    pub const fn ret(self) -> u32 {
        match self {
            Action::Allow => RET_ALLOW,
            Action::Log => RET_LOG,
            Action::Errno(e) => RET_ERRNO | e as u32,
            Action::UserNotif => RET_USER_NOTIF,
            Action::Trap => RET_TRAP,
            Action::KillThread => RET_KILL_THREAD,
            Action::KillProcess => RET_KILL_PROCESS,
        }
    }

    /// The action of a filter return value (the data bits matter only for
    /// `ERRNO`; `TRACE` and unknown values give `None`).
    pub fn from_ret(ret: u32) -> Option<Action> {
        match ret & 0xffff_0000 {
            RET_ALLOW => Some(Action::Allow),
            RET_LOG => Some(Action::Log),
            RET_ERRNO => Some(Action::Errno((ret & 0xffff) as u16)),
            RET_USER_NOTIF => Some(Action::UserNotif),
            RET_TRAP => Some(Action::Trap),
            RET_KILL_THREAD => Some(Action::KillThread),
            RET_KILL_PROCESS => Some(Action::KillProcess),
            _ => None,
        }
    }
}

impl fmt::Display for Action {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Action::Allow => f.write_str("allow"),
            Action::Log => f.write_str("log"),
            Action::Errno(e) => write!(f, "errno {e}"),
            Action::UserNotif => f.write_str("user_notif"),
            Action::Trap => f.write_str("trap"),
            Action::KillThread => f.write_str("kill_thread"),
            Action::KillProcess => f.write_str("kill_process"),
        }
    }
}

/// How much of a 64-bit argument register a comparison looks at.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Width {
    /// The low 32 bits only: for `int`/`unsigned int` parameters, whose upper
    /// half the kernel ignores (and userspace may leave dirty).
    W32,
    /// All 64 bits: for `long`/pointer-sized parameters (e.g. `clone` flags).
    W64,
}

/// One argument test: matches when `(args[arg] & mask) == value`.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ArgCmp {
    /// The argument index, 0..=5.
    pub arg: u8,
    pub width: Width,
    #[serde(with = "hex_u64")]
    pub mask: u64,
    #[serde(with = "hex_u64")]
    pub value: u64,
}

impl ArgCmp {
    /// `args[arg] as u32 == value`.
    pub const fn eq32(arg: u8, value: u32) -> ArgCmp {
        ArgCmp {
            arg,
            width: Width::W32,
            mask: 0xffff_ffff,
            value: value as u64,
        }
    }

    /// `args[arg] == value`.
    pub const fn eq64(arg: u8, value: u64) -> ArgCmp {
        ArgCmp {
            arg,
            width: Width::W64,
            mask: u64::MAX,
            value,
        }
    }

    /// No bit outside `allowed` is set in the low 32 bits (a flag field).
    pub const fn only_bits32(arg: u8, allowed: u32) -> ArgCmp {
        ArgCmp {
            arg,
            width: Width::W32,
            mask: (!allowed) as u64,
            value: 0,
        }
    }

    /// No bit outside `allowed` is set (a 64-bit flag field).
    pub const fn only_bits64(arg: u8, allowed: u64) -> ArgCmp {
        ArgCmp {
            arg,
            width: Width::W64,
            mask: !allowed,
            value: 0,
        }
    }

    /// Whether the comparison holds for these argument registers.
    pub fn matches(&self, args: &[u64; 6]) -> bool {
        let a = args[usize::from(self.arg)];
        let a = match self.width {
            Width::W32 => a & 0xffff_ffff,
            Width::W64 => a,
        };
        a & self.mask == self.value
    }
}

/// One rule of a syscall.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Rule {
    /// The syscall name, as the arch's table spells it (`openat`, `mmap`).
    pub syscall: String,
    pub action: Action,
    /// Empty: any arguments. Otherwise the rule matches when ANY of these
    /// alternatives matches, and an alternative matches when ALL its
    /// comparisons do.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub when: Vec<Vec<ArgCmp>>,
}

impl Rule {
    /// A bare rule: `syscall` with any arguments.
    pub fn bare(syscall: &str, action: Action) -> Rule {
        Rule {
            syscall: syscall.to_string(),
            action,
            when: Vec::new(),
        }
    }

    /// Whether this rule matches these argument registers.
    pub fn matches(&self, args: &[u64; 6]) -> bool {
        self.when.is_empty()
            || self
                .when
                .iter()
                .any(|alt| alt.iter().all(|c| c.matches(args)))
    }
}

/// A profile: one workload, one arch, one libc.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Profile {
    pub workload: String,
    pub arch: Arch,
    pub libc: Libc,
    pub rules: Vec<Rule>,
}

/// Why a profile cannot be built.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ProfileError {
    #[error("{arch} has no syscall named {name:?}")]
    UnknownSyscall { arch: Arch, name: String },
    #[error("rule for {syscall}: argument index {arg} is not 0..=5")]
    BadArgIndex { syscall: String, arg: u8 },
    #[error("rule for {syscall}: a 32-bit comparison on arg {arg} has bits above 32 (mask {mask:#x}, value {value:#x})")]
    WideW32 {
        syscall: String,
        arg: u8,
        mask: u64,
        value: u64,
    },
    #[error("rule for {syscall}: arg {arg} value {value:#x} has bits outside its mask {mask:#x}, so it never matches")]
    NeverMatches {
        syscall: String,
        arg: u8,
        mask: u64,
        value: u64,
    },
    #[error(
        "rule for {syscall}: an empty alternative (it would match everything; use an empty `when`)"
    )]
    EmptyAlternative { syscall: String },
    #[error("profile JSON: {0}")]
    Json(String),
    #[error("OCI seccomp JSON: {0}")]
    Oci(String),
}

impl Profile {
    /// Check every rule against the arch: known syscall names, argument
    /// indexes 0..=5, 32-bit comparisons within 32 bits, values within masks.
    pub fn validate(&self) -> Result<(), ProfileError> {
        validate_rules(self.arch, &self.rules)
    }

    /// The action for one call, with `default` when no rule matches. This is
    /// the reference the BPF program is tested against.
    pub fn evaluate(&self, nr: u32, args: &[u64; 6], default: Action) -> Action {
        let Some(name) = self.arch.syscall_name(nr) else {
            return default;
        };
        self.rules
            .iter()
            .filter(|r| r.syscall == name)
            .find(|r| r.matches(args))
            .map(|r| r.action)
            .unwrap_or(default)
    }

    /// The syscall names this profile has rules for, sorted, once each.
    pub fn syscalls(&self) -> Vec<&str> {
        let mut v: Vec<&str> = self.rules.iter().map(|r| r.syscall.as_str()).collect();
        v.sort_unstable();
        v.dedup();
        v
    }

    /// Pretty JSON with a fixed field order (diff-friendly, deterministic).
    pub fn to_json(&self) -> String {
        serde_json::to_string_pretty(self).expect("a profile always serializes") + "\n"
    }

    /// Read a profile from JSON. Unknown fields (e.g. the probe's `review`
    /// section) are ignored; the result is validated.
    pub fn from_json(s: &str) -> Result<Profile, ProfileError> {
        let p: Profile = serde_json::from_str(s).map_err(|e| ProfileError::Json(e.to_string()))?;
        p.validate()?;
        Ok(p)
    }
}

pub(crate) fn validate_rules(arch: Arch, rules: &[Rule]) -> Result<(), ProfileError> {
    for r in rules {
        if arch.syscall_nr(&r.syscall).is_none() {
            return Err(ProfileError::UnknownSyscall {
                arch,
                name: r.syscall.clone(),
            });
        }
        for alt in &r.when {
            if alt.is_empty() {
                return Err(ProfileError::EmptyAlternative {
                    syscall: r.syscall.clone(),
                });
            }
            for c in alt {
                if c.arg > 5 {
                    return Err(ProfileError::BadArgIndex {
                        syscall: r.syscall.clone(),
                        arg: c.arg,
                    });
                }
                if c.width == Width::W32 && (c.mask >> 32 != 0 || c.value >> 32 != 0) {
                    return Err(ProfileError::WideW32 {
                        syscall: r.syscall.clone(),
                        arg: c.arg,
                        mask: c.mask,
                        value: c.value,
                    });
                }
                if c.value & !c.mask != 0 {
                    return Err(ProfileError::NeverMatches {
                        syscall: r.syscall.clone(),
                        arg: c.arg,
                        mask: c.mask,
                        value: c.value,
                    });
                }
            }
        }
    }
    Ok(())
}

/// The `const` form of a [`Rule`].
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct StaticRule {
    pub syscall: &'static str,
    pub action: Action,
    pub when: &'static [&'static [ArgCmp]],
}

/// The `const` form of a [`Profile`], as generated `.rs` files hold it.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct StaticProfile {
    pub workload: &'static str,
    pub arch: Arch,
    pub libc: Libc,
    pub rules: &'static [StaticRule],
}

impl From<&StaticProfile> for Profile {
    fn from(s: &StaticProfile) -> Profile {
        Profile {
            workload: s.workload.to_string(),
            arch: s.arch,
            libc: s.libc,
            rules: s
                .rules
                .iter()
                .map(|r| Rule {
                    syscall: r.syscall.to_string(),
                    action: r.action,
                    when: r.when.iter().map(|alt| alt.to_vec()).collect(),
                })
                .collect(),
        }
    }
}

/// u64 as a `0x…` hex string in JSON (masks and flags read better in hex).
mod hex_u64 {
    use serde::{Deserialize, Deserializer, Serializer};

    pub fn serialize<S: Serializer>(v: &u64, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&format!("{v:#x}"))
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
        let s = String::deserialize(d)?;
        let h = s
            .strip_prefix("0x")
            .ok_or_else(|| serde::de::Error::custom(format!("{s:?} is not 0x-hex")))?;
        u64::from_str_radix(h, 16).map_err(serde::de::Error::custom)
    }
}