crates/heimdall-seccomp/tests/profile.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! Profile types: validation, rule semantics, JSON. Heimdall is a defensive
//! sandbox; a profile is the whole allowlist of one workload.
use heimdall_seccomp::profile::ProfileError;
use heimdall_seccomp::{
Action, Arch, ArgCmp, Libc, Profile, Rule, StaticProfile, StaticRule, Width,
};
fn p(rules: Vec<Rule>) -> Profile {
Profile {
workload: "t".into(),
arch: Arch::X86_64,
libc: Libc::Glibc,
rules,
}
}
fn nr(name: &str) -> u32 {
Arch::X86_64.syscall_nr(name).unwrap()
}
const KILL: Action = Action::KillProcess;
#[test]
fn a_call_with_no_rule_gets_the_default_action() {
let prof = p(vec![Rule::bare("read", Action::Allow)]);
assert_eq!(prof.evaluate(nr("write"), &[0; 6], KILL), KILL);
assert_eq!(prof.evaluate(nr("read"), &[7; 6], KILL), Action::Allow);
}
#[test]
fn an_unknown_number_gets_the_default_action() {
let prof = p(vec![Rule::bare("read", Action::Allow)]);
assert_eq!(prof.evaluate(9999, &[0; 6], Action::Log), Action::Log);
}
#[test]
fn the_first_matching_rule_of_a_call_wins() {
let prof = p(vec![
Rule {
syscall: "openat".into(),
action: Action::Allow,
when: vec![vec![ArgCmp::only_bits32(2, libc::O_CLOEXEC as u32)]],
},
Rule::bare("openat", Action::UserNotif),
]);
let ro = [0, 0, libc::O_CLOEXEC as u64, 0, 0, 0];
let wr = [0, 0, (libc::O_WRONLY | libc::O_CREAT) as u64, 0, 0, 0];
assert_eq!(prof.evaluate(nr("openat"), &ro, KILL), Action::Allow);
assert_eq!(prof.evaluate(nr("openat"), &wr, KILL), Action::UserNotif);
}
#[test]
fn alternatives_are_or_and_comparisons_inside_one_are_and() {
let prof = p(vec![Rule {
syscall: "socket".into(),
action: Action::Allow,
when: vec![
vec![ArgCmp::eq32(0, 1), ArgCmp::eq32(1, 1)],
vec![ArgCmp::eq32(0, 2), ArgCmp::eq32(1, 2)],
],
}]);
let s = nr("socket");
assert_eq!(prof.evaluate(s, &[1, 1, 0, 0, 0, 0], KILL), Action::Allow);
assert_eq!(prof.evaluate(s, &[2, 2, 0, 0, 0, 0], KILL), Action::Allow);
assert_eq!(prof.evaluate(s, &[1, 2, 0, 0, 0, 0], KILL), KILL);
assert_eq!(prof.evaluate(s, &[2, 1, 0, 0, 0, 0], KILL), KILL);
}
#[test]
fn a_32_bit_comparison_ignores_the_upper_half_and_a_64_bit_one_does_not() {
let w32 = ArgCmp::eq32(0, 5);
let w64 = ArgCmp::eq64(0, 5);
let dirty = [0xdead_beef_0000_0005, 0, 0, 0, 0, 0];
assert!(w32.matches(&dirty));
assert!(!w64.matches(&dirty));
assert!(w64.matches(&[5, 0, 0, 0, 0, 0]));
}
#[test]
fn only_bits_allows_subsets_of_the_mask_and_nothing_else() {
let c = ArgCmp::only_bits64(0, 0b1010);
assert!(c.matches(&[0, 0, 0, 0, 0, 0]));
assert!(c.matches(&[0b1000, 0, 0, 0, 0, 0]));
assert!(c.matches(&[0b1010, 0, 0, 0, 0, 0]));
assert!(!c.matches(&[0b0001, 0, 0, 0, 0, 0]));
assert!(!c.matches(&[1 << 40, 0, 0, 0, 0, 0]));
}
#[test]
fn validate_refuses_a_syscall_the_arch_does_not_have() {
let mut prof = p(vec![Rule::bare("open", Action::Allow)]);
assert!(prof.validate().is_ok());
prof.arch = Arch::Aarch64;
assert_eq!(
prof.validate(),
Err(ProfileError::UnknownSyscall {
arch: Arch::Aarch64,
name: "open".into()
})
);
}
#[test]
fn validate_refuses_bad_comparisons() {
let r = |c: ArgCmp| {
p(vec![Rule {
syscall: "read".into(),
action: Action::Allow,
when: vec![vec![c]],
}])
.validate()
};
assert!(matches!(
r(ArgCmp {
arg: 6,
width: Width::W64,
mask: 1,
value: 1
}),
Err(ProfileError::BadArgIndex { .. })
));
assert!(matches!(
r(ArgCmp {
arg: 0,
width: Width::W32,
mask: 1 << 40,
value: 0
}),
Err(ProfileError::WideW32 { .. })
));
assert!(matches!(
r(ArgCmp {
arg: 0,
width: Width::W64,
mask: 0xf0,
value: 0x0f
}),
Err(ProfileError::NeverMatches { .. })
));
let empty_alt = p(vec![Rule {
syscall: "read".into(),
action: Action::Allow,
when: vec![vec![]],
}]);
assert!(matches!(
empty_alt.validate(),
Err(ProfileError::EmptyAlternative { .. })
));
}
#[test]
fn json_round_trips_and_writes_masks_in_hex() {
let prof = p(vec![
Rule {
syscall: "mprotect".into(),
action: Action::Allow,
when: vec![vec![ArgCmp::eq32(2, 3)], vec![ArgCmp::eq32(2, 5)]],
},
Rule::bare("clone3", Action::Errno(38)),
]);
let j = prof.to_json();
assert!(j.contains("\"0xffffffff\""), "{j}");
assert!(j.contains("\"errno\": 38"), "{j}");
assert_eq!(Profile::from_json(&j).unwrap(), prof);
assert_eq!(prof.to_json(), j, "to_json is deterministic");
}
#[test]
fn from_json_ignores_extra_review_sections_but_validates() {
let j = r#"{"workload":"w","arch":"aarch64","libc":"musl","rules":[{"syscall":"read","action":"allow"}],"review":{"near_miss":[]}}"#;
let prof = Profile::from_json(j).unwrap();
assert_eq!(prof.arch, Arch::Aarch64);
let bad = r#"{"workload":"w","arch":"aarch64","libc":"musl","rules":[{"syscall":"open","action":"allow"}]}"#;
assert!(Profile::from_json(bad).is_err());
}
const STATIC: StaticProfile = StaticProfile {
workload: "s",
arch: Arch::X86_64,
libc: Libc::Musl,
rules: &[
StaticRule {
syscall: "read",
action: Action::Allow,
when: &[],
},
StaticRule {
syscall: "ioctl",
action: Action::Allow,
when: &[&[ArgCmp::eq32(1, 0x5401)]],
},
],
};
#[test]
fn a_static_profile_converts_to_the_same_owned_profile() {
let prof = Profile::from(&STATIC);
assert_eq!(prof.workload, "s");
assert_eq!(prof.rules.len(), 2);
assert_eq!(prof.rules[1].when, vec![vec![ArgCmp::eq32(1, 0x5401)]]);
assert!(prof.validate().is_ok());
assert_eq!(prof.syscalls(), vec!["ioctl", "read"]);
}
#[test]
fn action_return_values_round_trip() {
for a in [
Action::Allow,
Action::Log,
Action::Errno(1),
Action::Errno(38),
Action::UserNotif,
Action::Trap,
Action::KillThread,
Action::KillProcess,
] {
assert_eq!(Action::from_ret(a.ret()), Some(a));
}
assert_eq!(Action::KillProcess.ret(), 0x8000_0000);
assert_eq!(Action::Allow.ret(), 0x7fff_0000);
assert_eq!(Action::UserNotif.ret(), 0x7fc0_0000);
assert_eq!(Action::Log.ret(), 0x7ffc_0000);
}