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

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

//! The syscall broker (layer 2, spec §4) for one protected job: which mode
//! this kernel allows (spec §10), the job's [`BrokerPolicy`], and the
//! decisions as signed [`BrokerEvent`]s.
//!
//! Heimdall is a defensive sandbox. The job's executor installs the broker
//! filter inside the container, right after Landlock and before `execve`
//! (and before a generated profile), and hands its USER_NOTIF listener to
//! the shim, which serves it with [`heimdall_broker::Broker`]. The broker
//! opens files ITSELF, outside Landlock, so its policy is never wider than
//! the job's Landlock plan ([`crate::confine`]):
//!
//! | where (as the job sees it) | broker access | resolve |
//! |---|---|---|
//! | `/` (the image; not `/` itself) | read | in-root, no mount crossing |
//! | `/in`, `policy.read` | read | beneath |
//! | `/out` | write only (no read-back, no listing) | no symlinks |
//! | `/tmp`, `/work` (the job workspace), `policy.write` | read and write | beneath |
//! | `/proc` | read | the caller's own `self` |
//! | `/dev/null` | read and write | exact path, character device |
//! | `/dev/{zero,full,random,urandom}` | read | exact path, character device |
//! | sockets | none; a fetch job (`policy.net`): TCP to its declared IP:port peers, created and connected by the broker in the runner's network namespace | |
//! | exec | allowed and logged (Landlock: only the image executes) | |

use std::net::SocketAddr;
use std::path::PathBuf;

use heimdall_broker::{Access, BrokerPolicy, BrokerReport, ExecPolicy, Mode, Resolve};
use heimdall_core::{BrokerEvent, Decision, JobId, NetAllow};
use serde::{Deserialize, Serialize};

use crate::kernel::KernelReport;

/// Whether jobs get the broker.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BrokerSetting {
    /// The broker in the best mode this kernel allows (spec §10): full with
    /// USER_NOTIF + ADDFD, check-only without ADDFD, deny-only without
    /// USER_NOTIF.
    Auto,
    /// No broker filter at all (tests and diagnosis: proves what Landlock
    /// alone refuses). `Features.broker` is false.
    Off,
}

/// What the runner asks the shim for.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrokerRequest {
    pub mode: Mode,
    pub policy: BrokerPolicy,
    /// Stream the job's network events ([`heimdall_core::NetEvent`]) as JSON
    /// lines on the shim's stdout, before its response (the runner has an
    /// observer, 0.4).
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub observe: bool,
}

/// The mode a runner with this setting gives jobs on this kernel.
pub fn mode_for(setting: BrokerSetting, k: &KernelReport) -> Option<Mode> {
    match setting {
        BrokerSetting::Off => None,
        BrokerSetting::Auto if !k.user_notif => Some(Mode::DenyOnly),
        BrokerSetting::Auto if !k.notif_addfd => Some(Mode::CheckOnly),
        BrokerSetting::Auto => Some(Mode::Full),
    }
}

/// The devices a job may open, as in the Landlock plan.
pub const DEV_RW: &[&str] = &["/dev/null"];
pub const DEV_READ: &[&str] = &["/dev/zero", "/dev/full", "/dev/random", "/dev/urandom"];

/// Events kept per job (the rest are counted): they travel in the shim's
/// response and the signed result.
pub const MAX_EVENTS: usize = 4096;

/// The environment variable that hands a fetch job its declared peers.
pub const PEERS_ENV: &str = "HEIMDALL_NET_PEERS";

/// `HEIMDALL_NET_PEERS=<the declared peers as a JSON array of {host, addr,
/// port}>`: the job sends SNI and checks certificates against `host`, and
/// connects to `addr:port` (the only addresses the broker connects).
pub fn peers_env(n: &NetAllow) -> String {
    format!(
        "{PEERS_ENV}={}",
        serde_json::to_string(&n.peers).expect("peers serialize")
    )
}

/// The broker policy of one job (see the module docs). `fetch`: a fetch
/// job's declared TCP peers (from [`NetAllow::check`]). The job's network
/// namespace stays empty: the broker creates the socket in ITS network
/// namespace (the runner's), connects it to one of exactly these
/// addresses and injects it; any other socket, connect, bind or listen is
/// refused. Landlock (ABI 4) keeps refusing every TCP connect and
/// bind the job makes itself.
pub fn policy(
    job_id: JobId,
    read: &[PathBuf],
    write: &[PathBuf],
    fetch: &[SocketAddr],
) -> BrokerPolicy {
    let mut p = BrokerPolicy::empty(job_id)
        .with_exec(ExecPolicy::Allow)
        .with_prefix("/", Access::Read, Resolve::InRoot)
        .with_prefix("/in", Access::Read, Resolve::Beneath)
        .with_prefix("/out", Access::WriteOnly, Resolve::NoSymlinks)
        .with_prefix("/tmp", Access::ReadWrite, Resolve::Beneath)
        // The job workspace (its own mount, when the job has one): like /tmp;
        // whether it executes is the mount's and Landlock's decision.
        .with_prefix(
            heimdall_core::WORKSPACE_PATH,
            Access::ReadWrite,
            Resolve::Beneath,
        )
        .with_prefix("/proc", Access::Read, Resolve::Proc);
    for d in DEV_RW {
        p = p.with_prefix(*d, Access::ReadWrite, Resolve::Device);
    }
    for d in DEV_READ {
        p = p.with_prefix(*d, Access::Read, Resolve::Device);
    }
    for r in read {
        p = p.with_prefix(r, Access::Read, Resolve::Beneath);
    }
    for w in write {
        p = p.with_prefix(w, Access::ReadWrite, Resolve::Beneath);
    }
    p.max_events = MAX_EVENTS;
    p.fetch = fetch.to_vec();
    p
}

/// The broker policy of `job`: [`policy`] with its declared peers named
/// (host, rule) and its `job_ref` for the network events.
pub fn job_policy(job: &heimdall_core::Job) -> BrokerPolicy {
    let p = &job.policy;
    let mut bp = policy(job.id, &p.read, &p.write, &[]);
    if let Some(n) = &p.net {
        if let Ok(addrs) = n.check() {
            for (i, (peer, addr)) in n.peers.iter().zip(addrs).enumerate() {
                bp = bp.with_peer(addr, &peer.host, &peer.rule_name(i));
            }
        }
    }
    bp.event_job_id = Some(p.event_job_id(&job.id));
    bp
}

/// The name of a mode in events.
pub fn mode_name(m: Mode) -> &'static str {
    match m {
        Mode::Full => "full",
        Mode::CheckOnly => "check-only",
        Mode::DenyOnly => "deny-only",
    }
}

/// The job's signed events: first a `broker` event naming the mode (and
/// every warning), then each decision, then a count of events not kept.
pub fn events(mode: Mode, report: Option<&BrokerReport>) -> Vec<BrokerEvent> {
    let mut args = vec![format!("mode={}", mode_name(mode))];
    match mode {
        Mode::Full => {}
        Mode::CheckOnly => args.push(
            "warning: no SECCOMP_IOCTL_NOTIF_ADDFD: calls are checked and then proceed \
             (allow/deny only, not TOCTOU-safe)"
                .into(),
        ),
        Mode::DenyOnly => args.push(
            "warning: no SECCOMP_RET_USER_NOTIF: every brokered call is refused (fail closed)"
                .into(),
        ),
    }
    if let Some(r) = report {
        args.extend(r.warnings.iter().map(|w| format!("warning: {w}")));
    }
    let mut v = vec![BrokerEvent {
        at_ms: 0,
        call: "broker".into(),
        args,
        decision: Decision::Allowed,
    }];
    if let Some(r) = report {
        v.extend(r.events.iter().cloned());
        if r.dropped_events > 0 {
            v.push(BrokerEvent {
                at_ms: r.events.last().map_or(0, |e| e.at_ms),
                call: "broker".into(),
                args: vec![format!("dropped={}", r.dropped_events)],
                decision: Decision::Allowed,
            });
        }
    }
    v
}