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/oci.rs

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

//! The profile as a standard OCI runtime-spec seccomp JSON, for a pod's
//! `securityContext.seccompProfile: Localhost` (docs/BUILD-THING.md).
//! Heimdall is a defensive sandbox; the OCI form carries the same rules.
//!
//! Mapping: one OCI syscall entry per alternative (entries are OR-ed, the
//! `args` of one entry AND-ed); every comparison is `SCMP_CMP_MASKED_EQ`
//! (`(arg & value) == valueTwo`). OCI runtimes compare all 64 bits, so a
//! [`Width::W32`] comparison is written with its 32-bit mask, which ignores
//! the upper half exactly as Heimdall does.

use serde_json::{json, Value};

use crate::arch::{Arch, Libc};
use crate::bpf::Mode;
use crate::profile::{Action, ArgCmp, Profile, ProfileError, Rule, Width};

fn act(a: Action) -> (&'static str, Option<u16>) {
    match a {
        Action::Allow => ("SCMP_ACT_ALLOW", None),
        Action::Log => ("SCMP_ACT_LOG", None),
        Action::Errno(e) => ("SCMP_ACT_ERRNO", Some(e)),
        Action::UserNotif => ("SCMP_ACT_NOTIFY", None),
        Action::Trap => ("SCMP_ACT_TRAP", None),
        Action::KillThread => ("SCMP_ACT_KILL_THREAD", None),
        Action::KillProcess => ("SCMP_ACT_KILL_PROCESS", None),
    }
}

/// The OCI seccomp object (`linux.seccomp`) for a profile in a mode.
pub fn to_oci(p: &Profile, mode: &Mode) -> Value {
    let arch = match p.arch {
        Arch::X86_64 => "SCMP_ARCH_X86_64",
        Arch::Aarch64 => "SCMP_ARCH_AARCH64",
    };
    let (default, _) = act(mode.default_action());
    let mut syscalls = Vec::new();
    for r in &p.rules {
        let (action, errno) = act(r.action);
        let alts: Vec<Vec<Value>> = if r.when.is_empty() {
            vec![Vec::new()]
        } else {
            r.when
                .iter()
                .map(|alt| {
                    alt.iter()
                        .map(|c| {
                            json!({
                                "index": c.arg,
                                "value": c.mask,
                                "valueTwo": c.value,
                                "op": "SCMP_CMP_MASKED_EQ",
                            })
                        })
                        .collect()
                })
                .collect()
        };
        for args in alts {
            let mut e = json!({ "names": [r.syscall], "action": action });
            if let Some(n) = errno {
                e["errnoRet"] = json!(n);
            }
            if !args.is_empty() {
                e["args"] = Value::Array(args);
            }
            syscalls.push(e);
        }
    }
    json!({
        "defaultAction": default,
        "architectures": [arch],
        "syscalls": syscalls,
    })
}

/// Read an ENFORCING OCI seccomp JSON back into a [`Profile`] (the inverse of
/// [`to_oci`] with [`Mode::Enforce`]), so a runner can build and install it
/// with Heimdall's own BPF builder. Heimdall is a defensive sandbox: this
/// fails closed on anything it cannot represent exactly.
///
/// Refused: a `defaultAction` other than `SCMP_ACT_KILL_PROCESS` (a profile
/// that lets unknown calls through protects nothing), not exactly one
/// architecture or one other than `arch`'s, `SCMP_ACT_NOTIFY` (nobody would
/// answer) or `SCMP_ACT_TRACE`, any comparison other than
/// `SCMP_CMP_MASKED_EQ` / `SCMP_CMP_EQ`, docker's `includes`/`excludes`,
/// unknown syscall names. Consecutive entries of one syscall with one action
/// and arguments become one [`Rule`] with several alternatives; a comparison
/// whose mask and value fit in 32 bits reads as [`Width::W32`] (the same
/// test). `workload` and `libc` are not in the OCI form and are given.
pub fn from_oci(json: &str, workload: &str, libc: Libc) -> Result<Profile, ProfileError> {
    let bad = |m: String| ProfileError::Oci(m);
    let v: Value = serde_json::from_str(json).map_err(|e| bad(e.to_string()))?;
    let obj = v
        .as_object()
        .ok_or_else(|| bad("not a JSON object".into()))?;
    match obj.get("defaultAction").and_then(Value::as_str) {
        Some("SCMP_ACT_KILL_PROCESS") => {}
        other => {
            return Err(bad(format!(
                "defaultAction {other:?} is not SCMP_ACT_KILL_PROCESS: only enforcing profiles are applied"
            )))
        }
    }
    let arches: Vec<&str> = obj
        .get("architectures")
        .and_then(Value::as_array)
        .map(|a| a.iter().filter_map(Value::as_str).collect())
        .unwrap_or_default();
    let arch = match arches.as_slice() {
        ["SCMP_ARCH_X86_64"] => Arch::X86_64,
        ["SCMP_ARCH_AARCH64"] => Arch::Aarch64,
        other => {
            return Err(bad(format!(
                "architectures {other:?}: exactly one of SCMP_ARCH_X86_64, SCMP_ARCH_AARCH64"
            )))
        }
    };
    let mut rules: Vec<Rule> = Vec::new();
    let entries = obj
        .get("syscalls")
        .and_then(Value::as_array)
        .ok_or_else(|| bad("no syscalls array".into()))?;
    for (i, e) in entries.iter().enumerate() {
        let eo = e
            .as_object()
            .ok_or_else(|| bad(format!("syscalls[{i}] is not an object")))?;
        for k in eo.keys() {
            if !matches!(
                k.as_str(),
                "names" | "action" | "errnoRet" | "args" | "comment"
            ) {
                return Err(bad(format!("syscalls[{i}]: unsupported key {k:?}")));
            }
        }
        let action = match eo.get("action").and_then(Value::as_str) {
            Some("SCMP_ACT_ALLOW") => Action::Allow,
            Some("SCMP_ACT_LOG") => Action::Log,
            Some("SCMP_ACT_TRAP") => Action::Trap,
            Some("SCMP_ACT_KILL_THREAD") | Some("SCMP_ACT_KILL") => Action::KillThread,
            Some("SCMP_ACT_KILL_PROCESS") => Action::KillProcess,
            Some("SCMP_ACT_ERRNO") => {
                let n = eo.get("errnoRet").and_then(Value::as_u64).ok_or_else(|| {
                    bad(format!("syscalls[{i}]: SCMP_ACT_ERRNO without errnoRet"))
                })?;
                Action::Errno(
                    u16::try_from(n).map_err(|_| bad(format!("syscalls[{i}]: errnoRet {n}")))?,
                )
            }
            other => return Err(bad(format!("syscalls[{i}]: unsupported action {other:?}"))),
        };
        let mut alt = Vec::new();
        for (j, a) in eo
            .get("args")
            .and_then(Value::as_array)
            .map(Vec::as_slice)
            .unwrap_or_default()
            .iter()
            .enumerate()
        {
            let num = |k: &str| a.get(k).and_then(Value::as_u64);
            let at = || format!("syscalls[{i}].args[{j}]");
            let arg = num("index")
                .filter(|x| *x <= 5)
                .ok_or_else(|| bad(format!("{}: index is not 0..=5", at())))?
                as u8;
            let (mask, value) = match a.get("op").and_then(Value::as_str) {
                Some("SCMP_CMP_MASKED_EQ") => (
                    num("value").ok_or_else(|| bad(format!("{}: no value", at())))?,
                    num("valueTwo").unwrap_or(0),
                ),
                Some("SCMP_CMP_EQ") => (
                    u64::MAX,
                    num("value").ok_or_else(|| bad(format!("{}: no value", at())))?,
                ),
                other => return Err(bad(format!("{}: unsupported op {other:?}", at()))),
            };
            let width = if mask >> 32 == 0 && value >> 32 == 0 {
                Width::W32
            } else {
                Width::W64
            };
            alt.push(ArgCmp {
                arg,
                width,
                mask,
                value,
            });
        }
        let names = eo
            .get("names")
            .and_then(Value::as_array)
            .filter(|n| !n.is_empty())
            .ok_or_else(|| bad(format!("syscalls[{i}]: no names")))?;
        for n in names {
            let name = n
                .as_str()
                .ok_or_else(|| bad(format!("syscalls[{i}]: a name is not a string")))?;
            // One rule per syscall and action: consecutive entries with
            // arguments are alternatives of the rule before them.
            match rules.last_mut() {
                Some(last)
                    if !alt.is_empty()
                        && !last.when.is_empty()
                        && last.syscall == name
                        && last.action == action =>
                {
                    last.when.push(alt.clone())
                }
                _ => rules.push(Rule {
                    syscall: name.to_string(),
                    action,
                    when: if alt.is_empty() {
                        vec![]
                    } else {
                        vec![alt.clone()]
                    },
                }),
            }
        }
    }
    let p = Profile {
        workload: workload.to_string(),
        arch,
        libc,
        rules,
    };
    p.validate()?;
    Ok(p)
}