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);
}
}