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

nordisk/heimdall

crates/heimdall-probe/tests/kernel.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! Kernel tests of the probe: this test binary is also a small worker
//! (`kernel worker <file>`) that is probed in learning mode, generated,
//! checked and enforced. Heimdall is a defensive sandbox; the worker attacks
//! only its own filter.
//!
//! Run only with `HEIMDALL_KERNEL_TESTS=1` (scripts/seccomp-tests.sh, a
//! privileged rootless podman container). `harness = false`: the worker must
//! not start libtest's threads.

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};

use heimdall_probe::codegen::files;
use heimdall_probe::{
    check_workload, gen_and_write, load_generated, probe_and_write, ProbeError, ProbeOptions,
    Workload,
};
use heimdall_seccomp::bpf::Mode;
use heimdall_seccomp::launch::{Exit, Launch, Stdio};
use heimdall_seccomp::{Action, Arch};

// ---------------------------------------------------------------- worker

/// The worker: a fixed set of calls with interesting arguments, plus
/// switches from the environment (so the same test name makes new calls).
fn worker(file: &str) -> i32 {
    let env = |k: &str| std::env::var_os(k).is_some();
    // openat(O_RDONLY|O_CLOEXEC) + read
    let data = std::fs::read(file).unwrap_or_default();
    // SAFETY: plain syscalls on local data.
    unsafe {
        let s = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0);
        libc::close(s);
        let mut ws: libc::winsize = std::mem::zeroed();
        libc::ioctl(0, libc::TIOCGWINSZ, &mut ws);
        libc::fcntl(1, libc::F_GETFD);
        let mut name = [0u8; 16];
        libc::prctl(libc::PR_GET_NAME, name.as_mut_ptr());
        let p = libc::mmap(
            std::ptr::null_mut(),
            4096,
            libc::PROT_READ | libc::PROT_WRITE,
            libc::MAP_PRIVATE | libc::MAP_ANONYMOUS,
            -1,
            0,
        );
        libc::mprotect(p, 4096, libc::PROT_READ);
        libc::munmap(p, 4096);
        if env("WORKER_EXTRA") {
            let mut si: libc::sysinfo = std::mem::zeroed();
            libc::sysinfo(&mut si);
        }
        if env("WORKER_ERROR") {
            let mut ru: libc::rusage = std::mem::zeroed();
            libc::getrusage(libc::RUSAGE_SELF, &mut ru);
        }
        if env("WORKER_WX") {
            let p = libc::mmap(
                std::ptr::null_mut(),
                4096,
                libc::PROT_READ | libc::PROT_WRITE | libc::PROT_EXEC,
                libc::MAP_PRIVATE | libc::MAP_ANONYMOUS,
                -1,
                0,
            );
            if p == libc::MAP_FAILED {
                return 3;
            }
        }
        if env("WORKER_HANG") {
            libc::sleep(30);
        }
    }
    // The futex calls a thread join may or may not make, by timing, are made always:
    // a FUTEX_WAKE on a word nobody waits on (so a probe of a fast run sees them too).
    let word: u32 = 0;
    // SAFETY: a wake on a local word; no waiter, no side effect.
    unsafe {
        libc::syscall(
            libc::SYS_futex,
            &word as *const u32,
            libc::FUTEX_WAKE | libc::FUTEX_PRIVATE_FLAG,
            1,
            std::ptr::null::<libc::timespec>(),
        );
    }
    // A thread: clone3 is answered ENOSYS, so glibc falls back to clone.
    let slow = env("WORKER_SLOW_THREAD");
    let t = std::thread::spawn(move || {
        if slow {
            // the join below must wait for this thread (a futex wait in glibc)
            std::thread::sleep(std::time::Duration::from_millis(200));
        }
        data.len()
    });
    std::thread::sleep(std::time::Duration::from_millis(30));
    let n = t.join().unwrap();
    println!("read {n} bytes");
    if env("WORKER_EXIT3") {
        return 3;
    }
    0
}

// ---------------------------------------------------------------- helpers

static N: AtomicUsize = AtomicUsize::new(0);

fn me() -> PathBuf {
    std::env::current_exe().unwrap()
}

/// A fresh profiles dir with workload `w`: corpus file + manifest.
fn profiles(runs: &str, extra: &str) -> PathBuf {
    let n = N.fetch_add(1, Ordering::SeqCst);
    let root = std::env::temp_dir().join(format!("heim-probe-k-{}-{n}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    let dir = root.join("profiles/w");
    std::fs::create_dir_all(dir.join("corpus")).unwrap();
    std::fs::write(dir.join("corpus/input.txt"), b"probe corpus input\n").unwrap();
    std::fs::write(
        dir.join("workload.toml"),
        format!(
            "workload = \"w\"\ntimeout_secs = 20\n{extra}\n[binary]\nglibc = {:?}\nmusl = {:?}\n{runs}",
            me().display().to_string(),
            me().display().to_string()
        ),
    )
    .unwrap();
    root.join("profiles")
}

const BASE: &str = r#"
[[run]]
name = "basic_run"
args = ["worker", "{corpus}/input.txt"]
expect_exit = 0
"#;

fn with_env(runs: &str, k: &str) -> String {
    runs.replace(
        "expect_exit = 0",
        &format!("expect_exit = 0\nenv = {{ {k} = \"1\" }}"),
    )
}

fn workload(p: &Path) -> Workload {
    Workload::load(p, "w").unwrap()
}

fn opts() -> ProbeOptions {
    ProbeOptions::default()
}

fn probe_gen(p: &Path) -> (Workload, heimdall_probe::Generated) {
    let w = workload(p);
    let (obs, _) = probe_and_write(&w, &opts()).unwrap();
    let (g, _) = gen_and_write(&w, obs.arch, obs.libc).unwrap();
    (w, g)
}

fn enforce(g: &heimdall_probe::Generated, env: &[&str]) -> Exit {
    let input = std::env::temp_dir().join(format!("heim-probe-enf-{}", std::process::id()));
    std::fs::write(&input, b"x").unwrap();
    let mut l = Launch::new(me())
        .arg("worker")
        .arg(&input)
        .env_clear()
        .env("PATH", "/usr/bin:/bin")
        .stdin(Stdio::Null)
        .stdout(Stdio::Null)
        .stderr(Stdio::Null)
        .profile(&g.profile, &Mode::Enforce);
    for k in env {
        l = l.env(k, "1");
    }
    l.spawn().unwrap().wait().unwrap()
}

fn rule<'a>(g: &'a heimdall_probe::Generated, s: &str) -> &'a heimdall_seccomp::Rule {
    g.profile
        .rules
        .iter()
        .find(|r| r.syscall == s)
        .unwrap_or_else(|| panic!("no rule for {s}: {:?}", g.profile.syscalls()))
}

// ---------------------------------------------------------------- tests

fn the_learning_filter_records_the_argument_facts() {
    let w = workload(&profiles(BASE, ""));
    let obs = w.probe(&opts()).unwrap();
    let has = |s: &str, i: u8, v: u64| {
        obs.calls
            .keys()
            .any(|c| c.syscall == s && c.arg(i) == Some(v))
    };
    assert!(has("socket", 0, libc::AF_UNIX as u64), "socket domain");
    assert!(
        has("socket", 1, (libc::SOCK_STREAM | libc::SOCK_CLOEXEC) as u64),
        "socket type"
    );
    assert!(has("ioctl", 1, libc::TIOCGWINSZ), "ioctl request");
    assert!(has("fcntl", 1, libc::F_GETFD as u64), "fcntl cmd");
    assert!(has("prctl", 0, libc::PR_GET_NAME as u64), "prctl option");
    assert!(has("mprotect", 2, libc::PROT_READ as u64), "mprotect prot");
    assert!(
        obs.calls
            .keys()
            .any(|c| c.syscall == "openat"
                && c.arg(2).is_some_and(|f| f & libc::O_CLOEXEC as u64 != 0)),
        "openat flags"
    );
    assert!(
        obs.calls.keys().any(|c| c.syscall == "clone"
            && c.arg(0).is_some_and(|f| f & libc::CLONE_THREAD as u64 != 0)),
        "clone flags of the thread"
    );
    assert!(
        obs.calls.keys().all(|c| c.syscall != "clone3"),
        "clone3 is answered by the filter"
    );
    assert!(obs.unknown.is_empty(), "{:?}", obs.unknown);
    assert!(obs.calls.values().all(|r| r.contains("basic_run")));
}

fn probing_the_worker_twice_gives_the_same_profile() {
    let a = profiles(BASE, "");
    let b = profiles(BASE, "");
    let (wa, ga) = probe_gen(&a);
    let (wb, gb) = probe_gen(&b);
    assert_eq!(
        files(&ga),
        files(&gb),
        "generated files differ between two probes"
    );
    let pa = wa.paths(ga.profile.arch, ga.profile.libc);
    let pb = wb.paths(gb.profile.arch, gb.profile.libc);
    for (x, y) in [
        (&pa.probe, &pb.probe),
        (&pa.rs, &pb.rs),
        (&pa.json, &pb.json),
        (&pa.oci, &pb.oci),
    ] {
        assert_eq!(
            std::fs::read(x).unwrap(),
            std::fs::read(y).unwrap(),
            "{}",
            x.display()
        );
    }
    eprintln!(
        "    {} rules, {}",
        ga.profile.rules.len(),
        ga.profile.syscalls().join(" ")
    );
}

fn adding_a_syscall_to_the_worker_fails_check_and_names_it_and_the_test() {
    let p = profiles(BASE, "");
    let (w, _) = probe_gen(&p);
    let r = check_workload(&w, &opts()).unwrap();
    assert!(
        r.ok(),
        "unchanged worker: {:?} {:?}",
        r.violations,
        r.out_of_sync
    );
    // The worker changes: the same test now also calls sysinfo.
    std::fs::write(
        w.dir.join("workload.toml"),
        std::fs::read_to_string(w.dir.join("workload.toml"))
            .unwrap()
            .replace(BASE, &with_env(BASE, "WORKER_EXTRA")),
    )
    .unwrap();
    let w = workload(&p);
    let r = check_workload(&w, &opts()).unwrap();
    assert!(!r.ok());
    assert_eq!(r.violations.len(), 1, "{:?}", r.violations);
    let v = &r.violations[0];
    assert_eq!(v.syscall, "sysinfo");
    assert_eq!(v.runs, vec!["basic_run"]);
    assert_eq!(
        v.to_string(),
        "new call sysinfo (kill_process), made by test basic_run"
    );
}

/// The worker's calls do not depend on timing: a run whose thread finishes after the join
/// started (the join then waits on a futex) makes no call the probe of a fast run missed.
/// (Lane prof saw `futex` beside `sysinfo` once under load: 2 violations instead of 1.)
fn a_slow_thread_join_is_no_drift() {
    let p = profiles(BASE, "");
    let (w, _) = probe_gen(&p);
    std::fs::write(
        w.dir.join("workload.toml"),
        std::fs::read_to_string(w.dir.join("workload.toml"))
            .unwrap()
            .replace(BASE, &with_env(BASE, "WORKER_SLOW_THREAD")),
    )
    .unwrap();
    let w = workload(&p);
    let r = check_workload(&w, &opts()).unwrap();
    assert!(r.ok(), "timing made new calls: {:?}", r.violations);
}

fn check_flags_a_hand_edited_rust_file() {
    let p = profiles(BASE, "");
    let (w, g) = probe_gen(&p);
    let paths = w.paths(g.profile.arch, g.profile.libc);
    let rs = std::fs::read_to_string(&paths.rs).unwrap();
    std::fs::write(
        &paths.rs,
        rs.replace("syscall: \"read\"", "syscall: \"ptrace\""),
    )
    .unwrap();
    let r = check_workload(&w, &opts()).unwrap();
    assert!(r.violations.is_empty());
    assert_eq!(r.out_of_sync, vec![paths.rs]);
}

fn an_enforced_generated_profile_runs_the_worker_and_kills_a_call_outside_it() {
    let (_, g) = probe_gen(&profiles(BASE, ""));
    assert_eq!(
        enforce(&g, &[]),
        Exit::Code(0),
        "the probed worker runs under its profile"
    );
    let e = enforce(&g, &["WORKER_EXTRA"]);
    assert!(e.killed_by_seccomp(), "sysinfo is outside the profile: {e}");
}

fn clone3_is_answered_enosys_and_kept_as_a_fixed_rule() {
    let (_, g) = probe_gen(&profiles(BASE, ""));
    assert_eq!(
        rule(&g, "clone3").action,
        Action::Errno(libc::ENOSYS as u16)
    );
    assert!(!rule(&g, "clone").when.is_empty(), "clone has a flag mask");
}

fn near_miss_lists_calls_seen_only_on_error_paths() {
    let runs = format!(
        "{BASE}\n[[run]]\nname = \"error_path_run\"\nerror_path = true\nargs = [\"worker\", \"{{corpus}}/input.txt\"]\nexpect_exit = 0\nenv = {{ WORKER_ERROR = \"1\" }}\n"
    );
    let (_, g) = probe_gen(&profiles(&runs, ""));
    let nm: Vec<&str> = g.review.near_miss.iter().map(|f| f.call.as_str()).collect();
    assert!(nm.contains(&"getrusage"), "{nm:?}");
    let f = g
        .review
        .near_miss
        .iter()
        .find(|f| f.call == "getrusage")
        .unwrap();
    assert_eq!(f.runs, vec!["error_path_run"]);
    rule(&g, "getrusage");
    assert!(!nm.contains(&"read"), "read is on the happy path too");
}

fn a_write_exec_mapping_is_flagged_left_out_and_killed_when_enforced() {
    let (_, g) = probe_gen(&profiles(&with_env(BASE, "WORKER_WX"), ""));
    assert!(
        g.review.wx.iter().any(|f| f.call
            == format!(
                "mmap arg2={:#x}",
                libc::PROT_READ | libc::PROT_WRITE | libc::PROT_EXEC
            )),
        "{:?}",
        g.review.wx
    );
    let e = enforce(&g, &["WORKER_WX"]);
    assert!(e.killed_by_seccomp(), "{e}");
    assert_eq!(enforce(&g, &[]), Exit::Code(0));
}

fn a_run_with_the_wrong_exit_fails_the_probe_naming_it() {
    let w = workload(&profiles(&with_env(BASE, "WORKER_EXIT3"), ""));
    match w.probe(&opts()) {
        Err(ProbeError::Run { run, msg }) => {
            assert_eq!(run, "basic_run");
            assert!(msg.starts_with("expected exit 0, got exit 3"), "{msg}");
        }
        other => panic!("{other:?}"),
    }
}

fn a_run_that_hangs_times_out() {
    let w = workload(&profiles(&with_env(BASE, "WORKER_HANG"), "").to_path_buf());
    let mut w = w;
    w.manifest.timeout_secs = 1;
    match w.probe(&opts()) {
        Err(ProbeError::Run { run, msg }) => {
            assert_eq!(run, "basic_run");
            assert!(msg.starts_with("timed out"), "{msg}");
        }
        other => panic!("{other:?}"),
    }
}

fn live(run: &str, out: &Path, env: &[(&str, &str)]) -> Exit {
    let file = out.with_file_name("live-input.txt");
    std::fs::write(&file, b"live probe input\n").unwrap();
    heimdall_probe::learn_live(&heimdall_probe::LiveRun {
        workload: "w".into(),
        run: run.into(),
        error_path: false,
        program: me(),
        arg0: Some("live-worker".into()),
        args: vec!["worker".into(), file.into_os_string()],
        env: env
            .iter()
            .map(|(k, v)| ((*k).into(), (*v).into()))
            .collect(),
        libc: if cfg!(target_env = "musl") {
            heimdall_seccomp::Libc::Musl
        } else {
            heimdall_seccomp::Libc::Glibc
        },
        out: out.to_path_buf(),
        flush: std::time::Duration::from_millis(50),
        self_only: env.iter().any(|(k, _)| *k == "SELF_ONLY"),
    })
    .unwrap()
}

/// **The live probe** (lane layers): around a program where it really runs,
/// its exit passed through, every run merged into one evidence file.
fn a_live_probe_records_merges_and_passes_the_exit_through() {
    let dir = std::env::temp_dir().join(format!("heim-live-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let out = dir.join("w.probe.json");
    assert_eq!(live("node:first", &out, &[]), Exit::Code(0));
    let o =
        heimdall_probe::Observations::from_json(&std::fs::read_to_string(&out).unwrap()).unwrap();
    let names: Vec<&str> = o.calls.keys().map(|c| c.syscall.as_str()).collect();
    for c in ["socket", "openat", "prctl", "mmap"] {
        assert!(names.contains(&c), "{c} in {names:?}");
    }
    assert!(!names.contains(&"sysinfo"));
    assert!(
        o.runs
            .iter()
            .any(|r| r.name == "node:first" && r.exit == "exit 0"),
        "{:?}",
        o.runs
    );
    // A second run (another invocation) merges in, with its own calls and exit.
    assert_eq!(
        live(
            "node:second",
            &out,
            &[("WORKER_EXTRA", "1"), ("WORKER_EXIT3", "1")]
        ),
        Exit::Code(3)
    );
    let o =
        heimdall_probe::Observations::from_json(&std::fs::read_to_string(&out).unwrap()).unwrap();
    let sysinfo = o
        .calls
        .iter()
        .find(|(c, _)| c.syscall == "sysinfo")
        .map(|(_, r)| r.clone())
        .expect("sysinfo merged in");
    assert_eq!(
        sysinfo.into_iter().collect::<Vec<_>>(),
        vec!["node:second".to_string()]
    );
    assert!(
        o.runs.iter().any(|r| r.name == "node:first"),
        "the first run kept: {:?}",
        o.runs
    );
    assert!(
        o.runs
            .iter()
            .any(|r| r.name == "node:second" && r.exit == "exit 3"),
        "{:?}",
        o.runs
    );
    assert!(
        !o.runs.iter().any(|r| r.exit == "running"),
        "a finished run is not left running: {:?}",
        o.runs
    );
    // --self-only: the program's own calls, not those of the processes it starts
    let sh = dir.join("sh.probe.json");
    let e = heimdall_probe::learn_live(&heimdall_probe::LiveRun {
        workload: "sh".into(),
        run: "self".into(),
        error_path: false,
        program: "/bin/sh".into(),
        arg0: None,
        args: vec![
            "-c".into(),
            format!(
                "{} worker {}; exit 0",
                me().display(),
                dir.join("live-input.txt").display()
            )
            .into(),
        ],
        env: vec![("WORKER_EXTRA".into(), "1".into())],
        libc: if cfg!(target_env = "musl") {
            heimdall_seccomp::Libc::Musl
        } else {
            heimdall_seccomp::Libc::Glibc
        },
        out: sh.clone(),
        flush: std::time::Duration::from_millis(50),
        self_only: true,
    })
    .unwrap();
    assert_eq!(e, Exit::Code(0));
    let so =
        heimdall_probe::Observations::from_json(&std::fs::read_to_string(&sh).unwrap()).unwrap();
    assert!(
        so.calls.keys().any(|c| c.syscall == "wait4"),
        "the shell's own wait is recorded"
    );
    assert!(
        !so.calls.keys().any(|c| c.syscall == "sysinfo"),
        "the child's calls are not: {:?}",
        so.calls.keys().map(|c| &c.syscall).collect::<Vec<_>>()
    );
    // the generator takes the merged evidence like any probe file
    let g = heimdall_probe::generate(&o, heimdall_probe::GenOptions::default());
    assert!(g.profile.rules.iter().any(|r| r.syscall == "sysinfo"));
}

fn the_checked_in_json_loads_back_as_the_generated_profile() {
    let p = profiles(BASE, "");
    let (w, g) = probe_gen(&p);
    assert_eq!(
        load_generated(&w, g.profile.arch, g.profile.libc).unwrap(),
        g
    );
    assert_eq!(g.profile.arch, Arch::native().unwrap());
}

// ---------------------------------------------------------------- runner

type Test = (&'static str, fn());

const TESTS: &[Test] = &[
    (
        "the_learning_filter_records_the_argument_facts",
        the_learning_filter_records_the_argument_facts,
    ),
    (
        "probing_the_worker_twice_gives_the_same_profile",
        probing_the_worker_twice_gives_the_same_profile,
    ),
    (
        "adding_a_syscall_to_the_worker_fails_check_and_names_it_and_the_test",
        adding_a_syscall_to_the_worker_fails_check_and_names_it_and_the_test,
    ),
    (
        "a_slow_thread_join_is_no_drift",
        a_slow_thread_join_is_no_drift,
    ),
    (
        "check_flags_a_hand_edited_rust_file",
        check_flags_a_hand_edited_rust_file,
    ),
    (
        "an_enforced_generated_profile_runs_the_worker_and_kills_a_call_outside_it",
        an_enforced_generated_profile_runs_the_worker_and_kills_a_call_outside_it,
    ),
    (
        "clone3_is_answered_enosys_and_kept_as_a_fixed_rule",
        clone3_is_answered_enosys_and_kept_as_a_fixed_rule,
    ),
    (
        "near_miss_lists_calls_seen_only_on_error_paths",
        near_miss_lists_calls_seen_only_on_error_paths,
    ),
    (
        "a_write_exec_mapping_is_flagged_left_out_and_killed_when_enforced",
        a_write_exec_mapping_is_flagged_left_out_and_killed_when_enforced,
    ),
    (
        "a_run_with_the_wrong_exit_fails_the_probe_naming_it",
        a_run_with_the_wrong_exit_fails_the_probe_naming_it,
    ),
    ("a_run_that_hangs_times_out", a_run_that_hangs_times_out),
    (
        "a_live_probe_records_merges_and_passes_the_exit_through",
        a_live_probe_records_merges_and_passes_the_exit_through,
    ),
    (
        "the_checked_in_json_loads_back_as_the_generated_profile",
        the_checked_in_json_loads_back_as_the_generated_profile,
    ),
];

fn main() {
    let args: Vec<String> = std::env::args().collect();
    if args.get(1).map(String::as_str) == Some("worker") {
        std::process::exit(worker(&args[2]));
    }
    if std::env::var_os("HEIMDALL_KERNEL_TESTS").is_none_or(|v| v != "1") {
        println!("kernel tests skipped: set HEIMDALL_KERNEL_TESTS=1 (scripts/seccomp-tests.sh)");
        return;
    }
    let filter: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with('-')).collect();
    let mut failed = Vec::new();
    let mut ran = 0;
    for (name, f) in TESTS {
        if !filter.is_empty() && !filter.iter().any(|x| name.contains(x.as_str())) {
            continue;
        }
        ran += 1;
        match std::panic::catch_unwind(f) {
            Ok(()) => println!("test {name} ... ok"),
            Err(_) => {
                println!("test {name} ... FAILED");
                failed.push(*name);
            }
        }
    }
    println!(
        "\nkernel tests: {} passed; {} failed",
        ran - failed.len(),
        failed.len()
    );
    if !failed.is_empty() {
        std::process::exit(1);
    }
}