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

nordisk/heimdall

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