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

nordisk/heimdall

crates/heimdall-cli/tests/probe_container.rs

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

//! `heimdall profile probe-container`: a real program learned inside
//! Heimdall's own protected container while this test drives it from
//! outside. Heimdall is a defensive sandbox; every program here talks only to
//! this test.
//!
//! Kernel tests: they need user namespaces, delegated cgroup v2, Landlock,
//! seccomp USER_NOTIF, a static busybox, rustc with the musl target and the
//! heimdall-container shim (HEIMDALL_CONTAINER_SHIM), so they are
//! `#[ignore]`d in plain CI; `scripts/container-tests.sh` runs them.

#[path = "../../heimdall-plain/tests/common/mod.rs"]
mod common;

use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::os::unix::process::ExitStatusExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};

use common::*;
use heimdall_core::ImageRef;
use heimdall_probe::observe::RunInfo;
use heimdall_probe::Observations;

/// The static musl test program (tests/fixtures/chan_echo.rs).
fn chan_echo(base: &Path) -> Vec<u8> {
    let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/chan_echo.rs");
    let out = base.join("chan-echo");
    let target = format!("{}-unknown-linux-musl", std::env::consts::ARCH);
    let st = Command::new(std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()))
        .args([
            "--edition",
            "2021",
            "-C",
            "opt-level=1",
            "--target",
            &target,
            "-o",
        ])
        .arg(&out)
        .arg(&src)
        .status()
        .expect("rustc");
    assert!(st.success(), "building the musl test program");
    std::fs::read(out).unwrap()
}

/// busybox (static) + /bin/chan-echo (static musl).
fn image(base: &Path, layout: &Layout) -> ImageRef {
    let bb = std::fs::read(busybox().expect("a static busybox")).unwrap();
    let mut entries = vec![
        Entry::Dir("bin"),
        Entry::Dir("tmp"),
        Entry::Dir("in"),
        Entry::Dir("out"),
        Entry::Dir("proc"),
        Entry::Dir("dev"),
        Entry::Dir("etc"),
        Entry::File("bin/busybox".into(), bb, 0o755),
        Entry::File("bin/chan-echo".into(), chan_echo(base), 0o755),
    ];
    for a in APPLETS {
        entries.push(Entry::Symlink(format!("bin/{a}"), "busybox".into()));
    }
    layout.image(&[(Layout::tar(&entries), true)], &["PATH=/bin"])
}

struct Fx {
    base: PathBuf,
    layout: Layout,
    image: ImageRef,
}

fn fx(name: &str) -> Fx {
    let base = scratch(&format!("pc-{name}"));
    let layout = Layout::new(&base.join("layout"));
    let image = image(&base, &layout);
    Fx {
        base,
        layout,
        image,
    }
}

/// A running `heimdall profile probe-container` with its stdout lines.
struct Probe {
    child: Child,
    lines: mpsc::Receiver<String>,
}

impl Fx {
    fn out(&self) -> PathBuf {
        self.base.join("evidence").join("x86_64-glibc.probe.json")
    }
    fn log(&self) -> PathBuf {
        self.base.join("program.log")
    }
    fn args(&self, run: &str) -> Vec<String> {
        std::fs::create_dir_all(self.base.join("evidence")).unwrap();
        vec![
            "profile".into(),
            "probe-container".into(),
            "--workload".into(),
            "chan-echo".into(),
            "--run".into(),
            run.into(),
            "--out".into(),
            self.out().to_string_lossy().into_owned(),
            "--image".into(),
            self.layout.dir.to_string_lossy().into_owned(),
            "--digest".into(),
            self.image.digest.clone(),
            "--work-dir".into(),
            self.base.join("work").to_string_lossy().into_owned(),
            "--log".into(),
            self.log().to_string_lossy().into_owned(),
            "--flush-secs".into(),
            "1".into(),
            "--wall-secs".into(),
            "120".into(),
            "--memory-mib".into(),
            "128".into(),
            "--pids".into(),
            "32".into(),
        ]
    }
    fn spawn(&self, args: Vec<String>, argv: &[&str]) -> Probe {
        let mut child = Command::new(env!("CARGO_BIN_EXE_heimdall"))
            .args(args)
            .arg("--")
            .args(argv)
            .stdout(Stdio::piped())
            .spawn()
            .unwrap();
        let out = child.stdout.take().unwrap();
        let (tx, lines) = mpsc::channel();
        std::thread::spawn(move || {
            for l in BufReader::new(out).lines().map_while(Result::ok) {
                let _ = tx.send(l);
            }
        });
        Probe { child, lines }
    }
}

impl Probe {
    fn event(&self, what: &str, within: Duration) -> serde_json::Value {
        let deadline = Instant::now() + within;
        loop {
            let left = deadline.saturating_duration_since(Instant::now());
            let l = self
                .lines
                .recv_timeout(left)
                .unwrap_or_else(|_| panic!("no {what} event within {within:?}"));
            let v: serde_json::Value =
                serde_json::from_str(&l).unwrap_or_else(|e| panic!("stdout line {l:?}: {e}"));
            if v["event"] == what {
                return v;
            }
        }
    }
    fn signal(&self, sig: i32) {
        // SAFETY: kill on our own child.
        assert_eq!(unsafe { libc::kill(self.child.id() as i32, sig) }, 0);
    }
    fn wait(&mut self, within: Duration) -> std::process::ExitStatus {
        let deadline = Instant::now() + within;
        loop {
            if let Some(s) = self.child.try_wait().unwrap() {
                return s;
            }
            if Instant::now() > deadline {
                let _ = self.child.kill();
                panic!("heimdall did not exit within {within:?}");
            }
            std::thread::sleep(Duration::from_millis(50));
        }
    }
}

fn wait_for(what: &str, within: Duration, mut f: impl FnMut() -> bool) {
    let deadline = Instant::now() + within;
    while !f() {
        assert!(Instant::now() < deadline, "{what}: not within {within:?}");
        std::thread::sleep(Duration::from_millis(100));
    }
}

fn read(p: &Path) -> String {
    std::fs::read_to_string(p).unwrap_or_default()
}

fn evidence(p: &Path) -> Observations {
    Observations::from_json(&read(p)).unwrap()
}

fn has_call(o: &Observations, name: &str) -> bool {
    o.calls.keys().any(|c| c.syscall == name)
}

/// Mount points whose per-mount options are read-write, from mountinfo.
fn rw_mounts(mountinfo: &str) -> Vec<String> {
    mountinfo
        .lines()
        .filter_map(|l| {
            let f: Vec<&str> = l.split_whitespace().collect();
            (f.len() > 5 && f[5].split(',').any(|o| o == "rw")).then(|| f[4].to_string())
        })
        .collect()
}

#[test]
#[ignore = "kernel test: scripts/container-tests.sh"]
fn a_program_in_the_container_is_learned_driven_through_the_channel_and_stopped_by_sigterm() {
    let f = fx("chan");
    let chan = f.base.join("chan");
    std::fs::create_dir_all(&chan).unwrap();
    let mut args = f.args("kernel:chan");
    args.extend(["--channel".into(), chan.to_string_lossy().into_owned()]);
    args.extend(["--env".into(), "GREETING=hello-from-env".into()]);
    let mut p = f.spawn(
        args,
        &[
            "/bin/sh",
            "-c",
            "trap 'echo got-term; exit 7' TERM; echo $GREETING; cat /proc/self/mountinfo; \
             /bin/chan-echo /run/heimdall-channel/echo.sock & wait",
        ],
    );
    let started = p.event("started", Duration::from_secs(60));
    assert!(started["pid"].as_i64().unwrap() > 1, "{started}");
    assert_eq!(started["seccomp"], "learning", "{started}");
    assert_eq!(started["channel"], "/run/heimdall-channel", "{started}");
    let bb = std::fs::read(busybox().unwrap()).unwrap();
    assert_eq!(
        started["libc"],
        heimdall_seccomp::Libc::detect(&bb).unwrap().as_str(),
        "the libc of /bin/sh inside the image"
    );

    // The driver, outside the program's process tree, reaches its socket.
    let sock = chan.join("echo.sock");
    wait_for(
        "the program's socket in the channel",
        Duration::from_secs(30),
        || sock.exists(),
    );
    let mut s = UnixStream::connect(&sock).expect("connect from outside");
    s.write_all(b"ping\n").unwrap();
    let mut answer = String::new();
    BufReader::new(&mut s).read_line(&mut answer).unwrap();
    assert_eq!(answer.trim(), "pong ping");

    // The evidence is current while the program still runs.
    wait_for(
        "accept4 in the evidence while running",
        Duration::from_secs(20),
        || {
            let t = read(&f.out());
            t.contains("accept4") && t.contains("running")
        },
    );

    p.signal(libc::SIGTERM);
    let st = p.wait(Duration::from_secs(30));
    assert_eq!(
        st.code(),
        Some(7),
        "the program's exit is heimdall's: {st:?}"
    );
    let exited = p.event("exited", Duration::from_secs(5));
    assert_eq!(exited["exit"], "exit 7", "{exited}");
    assert_eq!(exited["out"], f.out().to_string_lossy().as_ref());
    assert!(exited["calls"].as_u64().unwrap() > 10, "{exited}");

    let o = evidence(&f.out());
    for c in ["bind", "listen", "accept4", "execve", "wait4"] {
        assert!(has_call(&o, c), "{c} recorded");
    }
    assert!(
        o.calls
            .keys()
            .any(|c| c.syscall == "socket" && c.arg(0) == Some(libc::AF_UNIX as u64)),
        "an AF_UNIX socket recorded with its domain"
    );
    assert!(o.runs.contains(&RunInfo {
        name: "kernel:chan".into(),
        error_path: false,
        exit: "exit 7".into(),
    }));
    assert!(!o.runs.iter().any(|r| r.exit == "running"), "{:?}", o.runs);

    let log = read(&f.log());
    assert!(
        log.contains("hello-from-env"),
        "--env reaches the program: {log}"
    );
    assert!(
        log.contains("got-term"),
        "SIGTERM reached the program: {log}"
    );
    let rw = rw_mounts(&log);
    assert!(rw.iter().any(|m| m == "/run/heimdall-channel"), "{rw:?}");

    // Mergeable with the other evidence verbs.
    let merged = f.base.join("merged.json");
    let st = Command::new(env!("CARGO_BIN_EXE_heimdall"))
        .args(["profile", "merge", "--out"])
        .arg(&merged)
        .arg(f.out())
        .status()
        .unwrap();
    assert!(st.success());
    assert_eq!(evidence(&merged).calls, o.calls);
    let _ = std::fs::remove_dir_all(&f.base);
}

#[test]
#[ignore = "kernel test: scripts/container-tests.sh"]
fn without_a_channel_there_is_no_read_write_mount_and_a_program_deaf_to_sigterm_is_killed_after_the_grace(
) {
    let f = fx("nochan");
    let mut args = f.args("kernel:nochan");
    args.extend([
        "--libc".into(),
        "glibc".into(),
        "--term-grace-secs".into(),
        "1".into(),
    ]);
    // `exec sleep`: pid 1 of its namespace with no handler, SIGTERM never lands.
    let mut p = f.spawn(
        args,
        &[
            "/bin/sh",
            "-c",
            "cat /proc/self/mountinfo; ls -d /run 2>&1; echo ready; exec sleep 600",
        ],
    );
    let started = p.event("started", Duration::from_secs(60));
    assert!(started["channel"].is_null(), "{started}");
    wait_for("the program's output", Duration::from_secs(30), || {
        read(&f.log()).contains("ready")
    });
    let t0 = Instant::now();
    p.signal(libc::SIGTERM);
    let st = p.wait(Duration::from_secs(30));
    assert!(
        t0.elapsed() >= Duration::from_millis(900),
        "the grace was kept"
    );
    assert_eq!(
        st.signal(),
        Some(libc::SIGKILL),
        "killed after the grace; heimdall ends the way the program did: {st:?}"
    );
    let exited = p.event("exited", Duration::from_secs(5));
    assert_eq!(exited["exit"], "killed by signal 9", "{exited}");
    assert_eq!(exited["term_killed"], true, "{exited}");

    let log = read(&f.log());
    assert!(!log.contains("heimdall-channel"), "{log}");
    assert!(
        log.contains("No such file"),
        "no /run made without a channel: {log}"
    );
    // A normal job's: /out, /tmp, and the runtime's /proc and /dev (with its
    // device nodes and the masked /proc paths, bound from /dev/null).
    let rw = rw_mounts(&log);
    assert!(
        rw.iter().all(|m| m == "/out"
            || m == "/tmp"
            || m == "/proc"
            || m.starts_with("/proc/")
            || m == "/dev"
            || m.starts_with("/dev/")),
        "only the normal job's writable mounts: {rw:?}"
    );
    let o = evidence(&f.out());
    assert!(has_call(&o, "nanosleep") || has_call(&o, "clock_nanosleep"));
    assert!(o
        .runs
        .iter()
        .any(|r| r.name == "kernel:nochan" && r.exit == "killed by signal 9"));
    let _ = std::fs::remove_dir_all(&f.base);
}

#[test]
#[ignore = "kernel test: scripts/container-tests.sh"]
fn the_program_may_run_as_another_uid_and_an_image_off_its_digest_never_starts() {
    let f = fx("uid");
    let mut args = f.args("kernel:uid");
    args.extend(["--libc".into(), "glibc".into(), "--uid".into(), "70".into()]);
    let mut p = f.spawn(
        args,
        &[
            "/bin/sh",
            "-c",
            "id -u; id -g; grep -E '^Cap(Inh|Prm|Eff|Bnd|Amb)' /proc/self/status",
        ],
    );
    p.event("started", Duration::from_secs(60));
    let st = p.wait(Duration::from_secs(30));
    assert_eq!(st.code(), Some(0), "{st:?}");
    let log = read(&f.log());
    let lines: Vec<&str> = log.lines().collect();
    assert_eq!(lines[..2], ["70", "70"], "{log}");
    // The CAP_SETFCAP the maps needed is gone: the program holds nothing.
    assert_eq!(lines.len(), 7, "{log}");
    for l in &lines[2..] {
        assert!(l.ends_with("0000000000000000"), "no capability: {l}");
    }

    let mut args = f.args("kernel:baddigest");
    let i = args.iter().position(|a| a == "--digest").unwrap();
    args[i + 1] = format!("sha256:{}", "0".repeat(64));
    args.extend(["--libc".into(), "glibc".into()]);
    let before = read(&f.out());
    let mut p = f.spawn(args, &["/bin/true"]);
    let st = p.wait(Duration::from_secs(30));
    assert_eq!(
        st.code(),
        Some(3),
        "a host error, not the program's exit: {st:?}"
    );
    let mut rest = String::new();
    while let Ok(l) = p.lines.recv_timeout(Duration::from_millis(200)) {
        rest.push_str(&l);
    }
    assert!(!rest.contains("started"), "{rest}");
    assert_eq!(read(&f.out()), before, "no evidence written");
    let _ = std::fs::remove_dir_all(&f.base);
}