crates/heimdall-container/src/profile.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The fixed, hand-reviewed seccomp profile of heimdall-container (x86_64),
//! reported as [`heimdall_core::Protection::Default`].
//!
//! Heimdall is a defensive sandbox. The profile starts from heimdall-plain's
//! (moby's default reduced to no capabilities) and is STRICTER: the calls in
//! [`REMOVED`] are refused with EPERM, the calls in [`KILL`] kill the whole
//! job (`SCMP_ACT_KILL_PROCESS`), and `socket` is allowed only for
//! AF_UNIX, AF_INET and AF_INET6. Every removal states why. The generated
//! per-workload profiles (heimdall-seccomp / heimdall-probe) replace this
//! one later.
use serde_json::Value;
use heimdall_plain::default_profile as moby;
use serde_json::json;
/// Calls heimdall-plain allows that this profile refuses (EPERM), and why.
pub const REMOVED: &[(&str, &str)] = &[
// Other processes' memory: kill instead (see KILL).
("ptrace", "tracing other processes; moved to KILL"),
(
"process_vm_readv",
"reads another process's memory; moved to KILL",
),
(
"process_vm_writev",
"writes another process's memory; moved to KILL",
),
("process_mrelease", "reaps another process's memory"),
// Legacy AIO: a large, rarely needed kernel surface.
("io_setup", "legacy AIO, kernel surface no parser needs"),
("io_submit", "legacy AIO, kernel surface no parser needs"),
("io_cancel", "legacy AIO, kernel surface no parser needs"),
("io_destroy", "legacy AIO, kernel surface no parser needs"),
("io_getevents", "legacy AIO, kernel surface no parser needs"),
(
"io_pgetevents",
"legacy AIO, kernel surface no parser needs",
),
(
"io_pgetevents_time64",
"legacy AIO (32-bit name), kernel surface",
),
// SysV IPC and POSIX message queues: jobs talk to nobody.
("ipc", "SysV IPC multiplexer (32-bit), jobs talk to nobody"),
("msgctl", "SysV message queues, jobs talk to nobody"),
("msgget", "SysV message queues, jobs talk to nobody"),
("msgrcv", "SysV message queues, jobs talk to nobody"),
("msgsnd", "SysV message queues, jobs talk to nobody"),
("semctl", "SysV semaphores, jobs talk to nobody"),
("semget", "SysV semaphores, jobs talk to nobody"),
("semop", "SysV semaphores, jobs talk to nobody"),
("semtimedop", "SysV semaphores, jobs talk to nobody"),
("semtimedop_time64", "SysV semaphores (32-bit name)"),
("shmat", "SysV shared memory, jobs talk to nobody"),
("shmctl", "SysV shared memory, jobs talk to nobody"),
("shmdt", "SysV shared memory, jobs talk to nobody"),
("shmget", "SysV shared memory, jobs talk to nobody"),
(
"mq_getsetattr",
"POSIX message queues (no /dev/mqueue either)",
),
("mq_notify", "POSIX message queues (no /dev/mqueue either)"),
("mq_open", "POSIX message queues (no /dev/mqueue either)"),
(
"mq_timedreceive",
"POSIX message queues (no /dev/mqueue either)",
),
(
"mq_timedreceive_time64",
"POSIX message queues (32-bit name)",
),
(
"mq_timedsend",
"POSIX message queues (no /dev/mqueue either)",
),
("mq_timedsend_time64", "POSIX message queues (32-bit name)"),
("mq_unlink", "POSIX message queues (no /dev/mqueue either)"),
// Fileless code and odd memory tricks.
(
"memfd_create",
"anonymous files that can be executed past Landlock",
),
("memfd_secret", "memory hidden from the kernel's own checks"),
("execveat", "exec by fd (memfd, O_PATH); execve is enough"),
("modify_ldt", "x86 LDT, a history of kernel bugs"),
(
"remap_file_pages",
"deprecated, emulated, a history of bugs",
),
(
"vmsplice",
"page-pinning into pipes, a history of kernel bugs",
),
("mlock", "pins memory; the memory limit is the budget"),
("mlock2", "pins memory; the memory limit is the budget"),
("mlockall", "pins memory; the memory limit is the budget"),
("munlock", "goes with mlock"),
("munlockall", "goes with mlockall"),
// Host-wide effects or information.
(
"sync",
"flushes every filesystem of the host (noisy neighbour)",
),
("name_to_handle_at", "file handles name host inodes"),
(
"fanotify_mark",
"filesystem-wide watches; fanotify_init is refused anyway",
),
("listmount", "lists mounts (new in 6.8): host layout"),
("statmount", "mount details (new in 6.8): host layout"),
(
"adjtimex",
"clock tuning: jobs read clocks, never tune them",
),
(
"clock_adjtime",
"clock tuning: jobs read clocks, never tune them",
),
("clock_adjtime64", "clock tuning (32-bit name)"),
// Scheduling and io priority: the cgroup decides.
(
"sched_setattr",
"scheduling policy changes: the cgroup decides",
),
(
"sched_setparam",
"scheduling policy changes: the cgroup decides",
),
(
"sched_setscheduler",
"scheduling policy changes: the cgroup decides",
),
("ioprio_set", "io priority changes: the cgroup decides"),
// Extended attributes: outputs are names and bytes only.
("setxattr", "outputs carry no extended attributes"),
("lsetxattr", "outputs carry no extended attributes"),
("fsetxattr", "outputs carry no extended attributes"),
("setxattrat", "outputs carry no extended attributes"),
("removexattr", "outputs carry no extended attributes"),
("lremovexattr", "outputs carry no extended attributes"),
("fremovexattr", "outputs carry no extended attributes"),
("removexattrat", "outputs carry no extended attributes"),
// Other architectures' or multiplexer names.
(
"socketcall",
"socket multiplexer (32-bit): bypasses socket() rules",
),
("riscv_hwprobe", "RISC-V only"),
];
/// Calls that kill the whole job: never legitimate in a parsing job, and
/// historically the big kernel attack surface. `io_uring_setup` is not here:
/// node 22's libuv calls it at start whatever `UV_USE_IO_URING` says, so it
/// is refused by the default action (EPERM) and the program carries on
/// without a ring; with no ring fd, `io_uring_enter` and `io_uring_register`
/// still kill.
pub const KILL: &[&str] = &[
"ptrace",
"process_vm_readv",
"process_vm_writev",
"bpf",
"perf_event_open",
"userfaultfd",
"io_uring_enter",
"io_uring_register",
"keyctl",
"add_key",
"request_key",
"mount",
"umount2",
"pivot_root",
"chroot",
"unshare",
"setns",
"fsopen",
"fsconfig",
"fsmount",
"fspick",
"open_tree",
"move_mount",
"mount_setattr",
"open_by_handle_at",
"init_module",
"finit_module",
"delete_module",
"kexec_load",
"kexec_file_load",
"reboot",
"swapon",
"swapoff",
"iopl",
"ioperm",
"acct",
"quotactl",
"quotactl_fd",
"syslog",
"vhangup",
"lookup_dcookie",
"fanotify_init",
];
/// `socket(domain, ..)` is allowed only for these domains: AF_UNIX, AF_INET,
/// AF_INET6 (the job's netns is empty and Landlock refuses TCP anyway).
pub const SOCKET_DOMAINS: &[u64] = &[1, 2, 10];
/// The calls allowed with any arguments.
pub fn allowed() -> Vec<&'static str> {
moby::ALLOW
.iter()
.copied()
.filter(|n| !REMOVED.iter().any(|(r, _)| r == n))
.collect()
}
/// The profile as an OCI `linux.seccomp` object.
pub fn seccomp_json() -> Value {
let mut rules = vec![
json!({"names": allowed(), "action": "SCMP_ACT_ALLOW"}),
json!({"names": KILL, "action": "SCMP_ACT_KILL_PROCESS"}),
];
for d in SOCKET_DOMAINS {
rules.push(json!({
"names": ["socket"],
"action": "SCMP_ACT_ALLOW",
"args": [{"index": 0, "value": d, "op": "SCMP_CMP_EQ"}],
}));
}
for v in moby::PERSONALITIES {
rules.push(json!({
"names": ["personality"],
"action": "SCMP_ACT_ALLOW",
"args": [{"index": 0, "value": v, "op": "SCMP_CMP_EQ"}],
}));
}
rules.push(json!({
"names": ["clone"],
"action": "SCMP_ACT_ALLOW",
"args": [{"index": 0, "value": moby::CLONE_NS_MASK, "valueTwo": 0, "op": "SCMP_CMP_MASKED_EQ"}],
}));
rules.push(json!({
"names": ["clone3"],
"action": "SCMP_ACT_ERRNO",
"errnoRet": moby::CLONE3_ERRNO,
}));
json!({
"defaultAction": "SCMP_ACT_ERRNO",
"defaultErrnoRet": moby::DEFAULT_ERRNO,
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": rules,
})
}