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

nordisk/heimdall

crates/heimdall-container/src/kernel.rs

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

//! The kernel features a runner actually gets (spec §10), probed at start.
//!
//! Heimdall is a defensive sandbox. | feature | kernel | if missing |:
//! seccomp-bpf (3.5) refuses to start; USER_NOTIF (5.0) and NOTIF_ADDFD
//! (5.9) are the broker's (its mode follows them, [`crate::brokered::mode_for`]),
//! and both come from the broker's own probe ([`heimdall_broker::Support`]),
//! which asks the kernel: there is ONE kernel probe for them; Landlock ABI 1 / 4
//! (5.13 / 6.7) logs and continues without it, or refuses in strict mode;
//! cgroup v2 with the memory, pids and cpu controllers refuses in strict
//! mode (io is applied when delegated and reported either way); user
//! namespaces refuse in strict mode.

use std::path::Path;

use serde::{Deserialize, Serialize};

/// The controllers strict mode requires for the jobs' cgroups.
pub const REQUIRED_CONTROLLERS: &[&str] = &["memory", "pids", "cpu"];

/// What the kernel gives this runner.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct KernelReport {
    /// `uname -r`.
    pub release: String,
    /// seccomp filter mode with `SECCOMP_RET_KILL_PROCESS` (3.5 / 4.14).
    pub seccomp_filter: bool,
    /// `SECCOMP_RET_USER_NOTIF` (5.0), seen working by the broker's probe.
    pub user_notif: bool,
    /// `SECCOMP_IOCTL_NOTIF_ADDFD` (5.9), seen working by the broker's probe.
    pub notif_addfd: bool,
    /// The kernel's Landlock ABI, `None` when Landlock is absent or disabled.
    pub landlock_abi: Option<u32>,
    /// cgroup v2 at the configured cgroup root.
    pub cgroup_v2: bool,
    /// Controllers the jobs' cgroups can get.
    pub cgroup_controllers: Vec<String>,
    /// Unprivileged user namespaces are possible.
    pub user_namespaces: bool,
}

/// `heimdall kernel needs --json` (KTOOL fix 7) is `nornir.kernel-runtime-needs/1`,
/// whose ONE definition and validator live in the shared MIT crate
/// `nordisk-kernel-evidence` (kernel tooling fix 20): the kconfig lines a runner
/// needs, each with its reason. Build Thing reads the same type. Heimdall's
/// names for it stay.
pub use nordisk_kernel_evidence::runtime_needs::{
    NeedOp, NeededBy, RuntimeNeed as KconfigNeed, RuntimeNeeds as KernelNeeds,
    RUNTIME_NEEDS_SCHEMA as KERNEL_NEEDS_SCHEMA,
};

/// **The kconfig lines a strict runner needs** (with the broker on, the
/// default), each with its reason.
///
/// Each line serves one check of [`KernelReport::missing_for_strict`]
/// (`needed_by = strict`), the namespaces and mounts every job's container
/// asks for (`job`, heimdall-plain `spec.rs`), or the broker's full mode
/// (`broker`). A test holds every strict check to its lines.
pub fn strict_kconfig_needs() -> Vec<KconfigNeed> {
    use NeedOp::{ListContains, Set};
    use NeededBy::{Broker, Job, Strict};
    let n = |symbol: &str, value: &str, op, needed_by, feature: &str, reason: &str| KconfigNeed {
        symbol: symbol.into(),
        value: value.into(),
        op,
        needed_by,
        feature: feature.into(),
        reason: reason.into(),
    };
    vec![
        n("CONFIG_SECCOMP", "y", Set, Strict, "seccomp_filter",
          "Heimdall strict: every job runs under a seccomp filter (SECCOMP_RET_KILL_PROCESS); without it the runner refuses to start"),
        n("CONFIG_SECCOMP_FILTER", "y", Set, Strict, "seccomp_filter",
          "Heimdall strict: the job's profile is a BPF filter (SECCOMP_SET_MODE_FILTER), and the broker's USER_NOTIF listener hangs off it"),
        n("CONFIG_MULTIUSER", "y", Set, Strict, "landlock",
          "Heimdall strict: SECURITY and NAMESPACES depend on MULTIUSER (security/Kconfig, init/Kconfig)"),
        n("CONFIG_SECURITY", "y", Set, Strict, "landlock",
          "Heimdall strict: the LSM menu Landlock hangs off (security/Kconfig)"),
        n("CONFIG_SECURITY_LANDLOCK", "y", Set, Strict, "landlock",
          "Heimdall strict: every job is restricted with Landlock (filesystem and network rules no root inside the job can widen); without it the runner refuses to start"),
        n("CONFIG_LSM", "landlock", ListContains, Strict, "landlock",
          "Heimdall strict: Landlock must be in the LSM boot order by name (`landlock` in CONFIG_LSM or lsm=), or the kernel has it built and never starts it"),
        n("CONFIG_CGROUPS", "y", Set, Strict, "cgroup_v2",
          "Heimdall strict: every job's limits are a cgroup v2 cgroup under the runner's parent"),
        n("CONFIG_MEMCG", "y", Set, Strict, "cgroup.memory",
          "Heimdall strict: memory.max and memory.swap.max, the job's memory limit"),
        n("CONFIG_CGROUP_PIDS", "y", Set, Strict, "cgroup.pids",
          "Heimdall strict: pids.max, the job's process limit (a fork bomb ends there)"),
        n("CONFIG_CGROUP_SCHED", "y", Set, Strict, "cgroup.cpu",
          "Heimdall strict: the cpu controller cpu.max hangs off"),
        n("CONFIG_FAIR_GROUP_SCHED", "y", Set, Strict, "cgroup.cpu",
          "Heimdall strict: the CFS group the job's cpu quota is accounted on"),
        n("CONFIG_CFS_BANDWIDTH", "y", Set, Strict, "cgroup.cpu",
          "Heimdall strict: cpu.max, the job's cpu quota"),
        n("CONFIG_NAMESPACES", "y", Set, Strict, "user_namespaces",
          "Heimdall strict: every job is a container in its own namespaces"),
        n("CONFIG_USER_NS", "y", Set, Strict, "user_namespaces",
          "Heimdall strict: the job's root maps to one unprivileged host id; without user namespaces the runner refuses to start"),
        n("CONFIG_PID_NS", "y", Set, Job, "job.namespaces",
          "every job's container has its own pid namespace (heimdall-plain spec.rs): it sees only itself, a kill reaches all of it"),
        n("CONFIG_SYSVIPC", "y", Set, Job, "job.namespaces",
          "IPC_NS depends on SYSVIPC or POSIX_MQUEUE (init/Kconfig)"),
        n("CONFIG_IPC_NS", "y", Set, Job, "job.namespaces",
          "every job's container has its own ipc namespace (heimdall-plain spec.rs)"),
        n("CONFIG_UTS_NS", "y", Set, Job, "job.namespaces",
          "every job's container has its own uts namespace (heimdall-plain spec.rs)"),
        n("CONFIG_NET", "y", Set, Job, "job.namespaces",
          "NET_NS depends on NET (net/Kconfig)"),
        n("CONFIG_NET_NS", "y", Set, Job, "job.namespaces",
          "every job's container has its own, empty network namespace (heimdall-plain spec.rs)"),
        n("CONFIG_TMPFS", "y", Set, Job, "job.mounts",
          "every job's /tmp is a capped tmpfs (heimdall-plain spec.rs)"),
        n("CONFIG_CROSS_MEMORY_ATTACH", "y", Set, Broker, "broker.read_args",
          "the broker reads a job's call arguments with process_vm_readv(2) (heimdall-broker sys.rs); tinyconfig leaves it off"),
    ]
}

/// The strict runner's document (`heimdall kernel needs --json`).
pub fn strict() -> KernelNeeds {
    KernelNeeds {
        schema: KERNEL_NEEDS_SCHEMA.into(),
        producer: format!("heimdall {}", env!("CARGO_PKG_VERSION")),
        mode: "strict".into(),
        needs: strict_kconfig_needs(),
    }
}

/// One missing feature, named for people.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Missing(pub String);

impl KernelReport {
    /// Probe this kernel; `cgroup_root`/`cgroup_parent` as in the config.
    pub fn probe(cgroup_root: &Path, cgroup_parent: &str) -> KernelReport {
        Self::probe_with(
            cgroup_root,
            cgroup_parent,
            heimdall_broker::Support::probe(),
        )
    }

    /// [`probe`](Self::probe) with the broker's kernel probe given: the
    /// seccomp notifier features come from it.
    pub fn probe_with(
        cgroup_root: &Path,
        cgroup_parent: &str,
        broker: heimdall_broker::Support,
    ) -> KernelReport {
        let read = |p: &str| std::fs::read_to_string(p).unwrap_or_default();
        let release = read("/proc/sys/kernel/osrelease").trim().to_string();
        let (kill_process, _) = parse_actions(&read("/proc/sys/kernel/seccomp/actions_avail"));
        // USER_NOTIF and ADDFD: the broker's probe, never the release string.
        let user_notif = broker.user_notif;
        let cgroup_v2 = cgroup_root.join("cgroup.controllers").is_file();
        // The jobs' cgroups get what their parent's subtree enables, or (when
        // the parent does not exist yet) what the root offers its children.
        let parent = cgroup_root.join(cgroup_parent);
        let controllers_file = if parent.join("cgroup.controllers").is_file() {
            parent.join("cgroup.controllers")
        } else {
            cgroup_root.join("cgroup.subtree_control")
        };
        let mut cgroup_controllers: Vec<String> = std::fs::read_to_string(controllers_file)
            .unwrap_or_default()
            .split_whitespace()
            .map(String::from)
            .collect();
        cgroup_controllers.sort();
        let max_userns: u64 = read("/proc/sys/user/max_user_namespaces")
            .trim()
            .parse()
            .unwrap_or(0);
        // Debian's knob; absent elsewhere (then only the max counts).
        let unpriv = read("/proc/sys/kernel/unprivileged_userns_clone");
        let user_namespaces = max_userns > 0 && unpriv.trim() != "0";
        KernelReport {
            release,
            seccomp_filter: kill_process,
            user_notif,
            notif_addfd: user_notif && broker.addfd,
            landlock_abi: crate::sys::landlock_abi(),
            cgroup_v2,
            cgroup_controllers: if cgroup_v2 {
                cgroup_controllers
            } else {
                vec![]
            },
            user_namespaces,
        }
    }

    /// This report as heimdall-core's [`heimdall_core::KernelFeatures`], the
    /// form every signed result names (spec §10).
    pub fn features(&self) -> heimdall_core::KernelFeatures {
        heimdall_core::KernelFeatures {
            release: self.release.clone(),
            seccomp_filter: self.seccomp_filter,
            user_notif: self.user_notif,
            notif_addfd: self.notif_addfd,
            landlock_abi: self.landlock_abi,
            cgroup_v2: self.cgroup_v2,
            cgroup_controllers: self.cgroup_controllers.clone(),
            user_namespaces: self.user_namespaces,
        }
    }

    /// Landlock's TCP port rules (ABI 4) are there.
    pub fn landlock_net(&self) -> bool {
        self.landlock_abi.is_some_and(|a| a >= 4)
    }

    pub fn has_controller(&self, c: &str) -> bool {
        self.cgroup_controllers.iter().any(|x| x == c)
    }

    /// What is missing that strict mode needs (seccomp included, which every
    /// mode needs).
    pub fn missing_for_strict(&self) -> Vec<Missing> {
        let mut m = Vec::new();
        if !self.seccomp_filter {
            m.push(Missing(
                "seccomp filters with SECCOMP_RET_KILL_PROCESS (kernel 4.14)".into(),
            ));
        }
        if self.landlock_abi.is_none() {
            m.push(Missing(
                "Landlock (kernel 5.13, CONFIG_SECURITY_LANDLOCK and `landlock` in the lsm= list)"
                    .into(),
            ));
        }
        if !self.cgroup_v2 {
            m.push(Missing("cgroup v2 at the cgroup root".into()));
        } else {
            for c in REQUIRED_CONTROLLERS {
                if !self.has_controller(c) {
                    m.push(Missing(format!(
                        "the cgroup v2 {c} controller delegated to the jobs' parent cgroup"
                    )));
                }
            }
        }
        if !self.user_namespaces {
            m.push(Missing("unprivileged user namespaces".into()));
        }
        m
    }
}

/// `major.minor` of a release string like `7.0.0-38-generic`.
pub fn parse_release(s: &str) -> Option<(u32, u32)> {
    let mut it = s.trim().split(|c: char| !c.is_ascii_digit());
    let major = it.next()?.parse().ok()?;
    let minor = it.next()?.parse().ok()?;
    Some((major, minor))
}

/// `/proc/sys/kernel/seccomp/actions_avail` → (kill_process, user_notif).
pub fn parse_actions(s: &str) -> (bool, bool) {
    let has = |a: &str| s.split_whitespace().any(|x| x == a);
    (has("kill_process"), has("user_notif"))
}