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

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

//! Landlock for one job: the plan (computed by the runner from the verified
//! image) and its application (inside the job, right before `execve`).
//!
//! Heimdall is a defensive sandbox. Every Landlock right is handled, so
//! whatever the plan does not list is refused, whatever the mounts allow:
//!
//! | where | rights |
//! |---|---|
//! | the image's top-level entries (not `/` itself) | read, read dir, execute |
//! | `/in`, `/proc`, `policy.read` | read, read dir |
//! | `/out` | write only: create, write, truncate, remove (no read, no execute) |
//! | `/tmp`, `policy.write`, the job workspace `/work` | read and write, no execute |
//! | the workspace with `exec` (runner must allow it) | read, write and execute |
//! | `/dev/null` | read, write, truncate |
//! | `/dev/{zero,full,random,urandom}` | read |
//! | TCP (ABI 4) | no bind, no connect |
//! | scopes (ABI 6) | no signals, no abstract unix sockets outside the job |
//! | the learning channel (`probe-container` only, [`crate::learn`]) | `/tmp`'s rights plus making unix sockets |

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

/// Top-level names of the image that never get the image's rights.
pub const NOT_IMAGE: &[&str] = &["in", "out", "tmp", "proc", "dev", "sys"];

/// The Landlock plan of one job (paths inside the container).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LandlockPlan {
    pub read_exec: Vec<PathBuf>,
    pub read: Vec<PathBuf>,
    pub read_write: Vec<PathBuf>,
    /// An executable workspace: read, write and execute.
    #[serde(default)]
    pub read_write_exec: Vec<PathBuf>,
    pub write_only: Vec<PathBuf>,
    pub dev_rw: Vec<PathBuf>,
    pub dev_read: Vec<PathBuf>,
    /// The learning run's channel ([`crate::learn::CHANNEL_DIR`]): read,
    /// write and make unix sockets ([`rights::channel`]); empty for every
    /// normal job.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub channel: Vec<PathBuf>,
}

/// How much of the plan the kernel enforced.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LandlockApplied {
    /// Every handled right of the ABI Heimdall asks for.
    Full,
    /// An older kernel: the rights of its ABI (still deny by default).
    Partial,
}

/// The rights of each plan group, as Landlock access bits (`AccessFs`).
pub mod rights {
    use landlock::{make_bitflags, AccessFs, BitFlags};

    /// The image: read, list and execute.
    pub fn image() -> BitFlags<AccessFs> {
        make_bitflags!(AccessFs::{ReadFile | ReadDir | Execute})
    }
    /// `/in`, `/proc` and `policy.read`: read and list.
    pub fn read() -> BitFlags<AccessFs> {
        make_bitflags!(AccessFs::{ReadFile | ReadDir})
    }
    /// `/tmp` and `policy.write`: read and write, never execute or devices.
    pub fn read_write() -> BitFlags<AccessFs> {
        read() | write_only()
    }
    /// An executable workspace: [`read_write`] and execute.
    pub fn read_write_exec() -> BitFlags<AccessFs> {
        read_write() | make_bitflags!(AccessFs::{Execute})
    }
    /// `/out`: create, write, truncate and remove; no read, no execute. Dirs,
    /// symlinks and fifos may be made, and the output scan refuses them
    /// (`heimdall-out-1`).
    pub fn write_only() -> BitFlags<AccessFs> {
        make_bitflags!(AccessFs::{
            WriteFile | Truncate | MakeReg | MakeDir | MakeSym | MakeFifo
                | RemoveFile | RemoveDir | Refer
        })
    }
    /// The learning channel: `/tmp`'s rights plus making unix sockets
    /// (`MakeSock`), so a driver outside reaches the program's sockets there.
    pub fn channel() -> BitFlags<AccessFs> {
        read_write() | AccessFs::MakeSock
    }
    /// `/dev/null`.
    pub fn dev_rw() -> BitFlags<AccessFs> {
        make_bitflags!(AccessFs::{ReadFile | WriteFile | Truncate})
    }
    /// `/dev/{zero,full,random,urandom}`.
    pub fn dev_read() -> BitFlags<AccessFs> {
        make_bitflags!(AccessFs::{ReadFile})
    }
    /// The rights the kernel accepts on a regular (non-directory) file.
    pub fn file_only() -> BitFlags<AccessFs> {
        make_bitflags!(AccessFs::{ReadFile | WriteFile | Execute | Truncate | IoctlDev})
    }
}

fn under_excluded(target: &Path) -> bool {
    use std::path::Component;
    let mut depth: i32 = 0;
    let mut first: Option<String> = None;
    for c in target.components() {
        match c {
            Component::RootDir | Component::Prefix(_) => depth = 0,
            Component::CurDir => {}
            Component::ParentDir => return true,
            Component::Normal(n) => {
                if depth == 0 {
                    first = Some(n.to_string_lossy().into_owned());
                }
                depth += 1;
            }
        }
    }
    match first {
        // `/`, `.` or empty: the whole root.
        None => true,
        Some(f) => NOT_IMAGE.contains(&f.as_str()),
    }
}

/// The plan for a job whose unpacked root is `rootfs`, with the policy's
/// extra read and write paths.
pub fn plan(
    rootfs: &Path,
    policy_read: &[PathBuf],
    policy_write: &[PathBuf],
) -> std::io::Result<LandlockPlan> {
    let mut read_exec = Vec::new();
    for e in std::fs::read_dir(rootfs)? {
        let e = e?;
        let name = e.file_name();
        let Some(name) = name.to_str() else { continue };
        if NOT_IMAGE.contains(&name) {
            continue;
        }
        if e.file_type()?.is_symlink() {
            // A top-level link must stay inside the image and away from the
            // places Landlock treats on their own (`/`, `..`, /proc, /out, ..).
            let t = std::fs::read_link(e.path())?;
            if under_excluded(&t) {
                continue;
            }
        }
        read_exec.push(Path::new("/").join(name));
    }
    read_exec.sort();
    let mut read: Vec<PathBuf> = vec!["/in".into(), "/proc".into()];
    read.extend(policy_read.iter().cloned());
    let mut read_write: Vec<PathBuf> = vec!["/tmp".into()];
    read_write.extend(policy_write.iter().cloned());
    Ok(LandlockPlan {
        read_exec,
        read,
        read_write,
        read_write_exec: vec![],
        write_only: vec!["/out".into()],
        dev_rw: vec!["/dev/null".into()],
        channel: vec![],
        dev_read: ["/dev/zero", "/dev/full", "/dev/random", "/dev/urandom"]
            .iter()
            .map(PathBuf::from)
            .collect(),
    })
}

/// [`plan`] with the job's workspace.
pub fn plan_with_workspace(
    rootfs: &Path,
    policy_read: &[PathBuf],
    policy_write: &[PathBuf],
    workspace: Option<&heimdall_core::Workspace>,
) -> std::io::Result<LandlockPlan> {
    let mut p = plan(rootfs, policy_read, policy_write)?;
    if let Some(ws) = workspace {
        let at = PathBuf::from(heimdall_core::WORKSPACE_PATH);
        // The workspace mount covers the image's own entry of that name.
        p.read_exec.retain(|x| x != &at);
        if ws.exec {
            p.read_write_exec.push(at);
        } else {
            p.read_write.push(at);
        }
    }
    Ok(p)
}

/// The Landlock ABI Heimdall asks for (fixed in code, as the landlock crate
/// recommends): filesystem rights up to ABI 5 (ioctl on devices), TCP bind
/// and connect (ABI 4), signal and abstract-unix-socket scopes (ABI 6). A
/// kernel with an older ABI enforces its subset ("partial").
pub const ABI: landlock::ABI = landlock::ABI::V6;

/// Apply `plan` to the calling (single-threaded) process: inside the job,
/// after pivot_root and seccomp, right before `execve`. Fails closed: any
/// error, or a kernel that enforces nothing, is an `Err`.
pub fn apply(plan: &LandlockPlan) -> Result<LandlockApplied, String> {
    use landlock::{
        Access, AccessFs, AccessNet, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset,
        RulesetAttr, RulesetCreatedAttr, RulesetStatus, Scope,
    };
    let e = |what: &str, err: &dyn std::fmt::Display| format!("{what}: {err}");
    let mut rs = Ruleset::default()
        .set_compatibility(CompatLevel::BestEffort)
        .handle_access(AccessFs::from_all(ABI))
        .map_err(|x| e("handle fs", &x))?
        .handle_access(AccessNet::from_all(ABI))
        .map_err(|x| e("handle net", &x))?
        .scope(Scope::from_all(ABI))
        .map_err(|x| e("scope", &x))?
        .create()
        .map_err(|x| e("create ruleset", &x))?;
    let groups: [(&[PathBuf], landlock::BitFlags<AccessFs>, bool); 8] = [
        (&plan.read_exec, rights::image(), true),
        (&plan.read, rights::read(), true),
        (&plan.read_write, rights::read_write(), true),
        (&plan.read_write_exec, rights::read_write_exec(), true),
        (&plan.write_only, rights::write_only(), true),
        (&plan.dev_rw, rights::dev_rw(), false),
        (&plan.dev_read, rights::dev_read(), false),
        (&plan.channel, rights::channel(), true),
    ];
    for (paths, access, required) in groups {
        for p in paths {
            let fd = match PathFd::new(p) {
                Ok(fd) => fd,
                Err(_) if !required => continue,
                Err(x) => return Err(e(&format!("open {}", p.display()), &x)),
            };
            let is_dir = std::fs::metadata(p).map(|m| m.is_dir()).unwrap_or(true);
            let a = if is_dir {
                access
            } else {
                access & rights::file_only()
            };
            rs = rs
                .add_rule(PathBeneath::new(fd, a))
                .map_err(|x| e(&format!("rule {}", p.display()), &x))?;
        }
    }
    let st = rs.restrict_self().map_err(|x| e("restrict_self", &x))?;
    match st.ruleset {
        RulesetStatus::FullyEnforced => Ok(LandlockApplied::Full),
        RulesetStatus::PartiallyEnforced => Ok(LandlockApplied::Partial),
        RulesetStatus::NotEnforced => Err("the kernel enforces no Landlock rule".into()),
    }
}