Apply a seccomp-BPF syscall allowlist by default

Derived from Podman's default profile, stripped of capability-conditional
rules (we never grant capabilities), argument filters, and the explicit
EPERM block. Dangerous syscalls (mount, unshare, ptrace, bpf,
perf_event_open, io_uring_*, keyctl, kexec_*, ...) fall through to the
default ENOSYS action, which also keeps glibc's clone3 -> clone fallback
working. x86_64 and aarch64 are supported; other archs error out.

Toggle with --seccomp / --no-seccomp or seccomp = <bool> in config.
This commit is contained in:
2026-04-08 08:34:34 +02:00
parent 5f3b139457
commit 12644ae31e
11 changed files with 772 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
//! seccomp BPF allowlist for sandboxed processes.
//!
//! Derived from Podman's containers/common default profile:
//! <https://github.com/containers/common/blob/main/pkg/seccomp/seccomp.json>
//!
//! Simplifications vs upstream (intentional):
//! - No capability-conditional rules (we never grant capabilities, so all
//! of Podman's `caps` blocks collapse to "deny" — we just omit them).
//! - No argument filters. `personality` stays out of the allowlist; `socket`
//! is allowed unconditionally rather than gated on AF_VSOCK.
//! - No explicit-EPERM list — anything outside the allowlist returns ENOSYS
//! via the default action.
//! - x86_64 and aarch64 only.
//!
//! Default action is ENOSYS (errno 38), not EPERM. This matches Podman's
//! stance and is critical for the glibc clone3 -> clone fallback path.
//!
//! Syscall name -> number resolution uses the `syscalls` crate. Names that
//! don't exist on the host architecture (e.g. legacy 32-bit aliases like
//! `_llseek`, or aarch64-only `set_tls` when building on x86_64) are silently
//! skipped — they would just return ENOSYS anyway under the default action.
use std::collections::BTreeMap;
use std::io::{Seek, SeekFrom, Write};
use std::os::fd::{FromRawFd, IntoRawFd, RawFd};
use std::str::FromStr;
use seccompiler::{
BackendError, BpfProgram, SeccompAction, SeccompFilter, SeccompRule, TargetArch, sock_filter,
};
use syscalls::Sysno;
use crate::SandboxError;
/// Syscall allowlist. Includes Podman's unconditional allow set (minus syscalls
/// we deny on top, see module docs) plus arch-specific syscalls for the targets
/// we support. Names absent from the host arch are skipped at filter-build time.
const ALLOWED_SYSCALLS: &[&str] = &include!("seccomp_allowlist.in");
/// Build a seccomp BPF program, write it to an anonymous in-memory file, and
/// return the raw fd. The fd is intentionally leaked from Rust's ownership: it
/// is created without `MFD_CLOEXEC` so it survives `exec` into bwrap, which
/// inherits and closes it after reading the filter.
pub fn write_program_to_memfd() -> Result<RawFd, SandboxError> {
let bytes = build_program_bytes()?;
// Safety: memfd_create is a normal Linux syscall. We pass a valid C string
// and flags=0, so the fd is created without MFD_CLOEXEC and survives exec
// into bwrap. The name is only a debugging label (shows up in /proc/<pid>/maps).
let raw_fd = unsafe { libc::memfd_create(c"agent-sandbox-seccomp".as_ptr(), 0) };
if raw_fd < 0 {
return Err(SandboxError::Io(std::io::Error::last_os_error()));
}
// Safety: raw_fd is owned by us and currently uniquely held.
let mut file = unsafe { std::fs::File::from_raw_fd(raw_fd) };
file.write_all(&bytes)?;
file.seek(SeekFrom::Start(0))?;
// into_raw_fd consumes the File without closing the underlying fd.
Ok(file.into_raw_fd())
}
fn build_program_bytes() -> Result<Vec<u8>, SandboxError> {
let target_arch = current_target_arch()?;
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
for name in ALLOWED_SYSCALLS {
if let Ok(sysno) = Sysno::from_str(name) {
rules.insert(i64::from(sysno.id()), vec![]);
}
}
let filter = SeccompFilter::new(
rules,
SeccompAction::Errno(libc::ENOSYS as u32),
SeccompAction::Allow,
target_arch,
)
.map_err(|e| SandboxError::Seccomp(e.to_string()))?;
let program: BpfProgram = filter
.try_into()
.map_err(|e: BackendError| SandboxError::Seccomp(e.to_string()))?;
Ok(serialize(&program))
}
fn current_target_arch() -> Result<TargetArch, SandboxError> {
match std::env::consts::ARCH {
"x86_64" => Ok(TargetArch::x86_64),
"aarch64" => Ok(TargetArch::aarch64),
other => Err(SandboxError::SeccompUnsupportedArch(other.to_string())),
}
}
fn serialize(program: &[sock_filter]) -> Vec<u8> {
// Flatten the in-memory BpfProgram into the raw byte stream the kernel's
// seccomp(2) interface expects. A BpfProgram is &[sock_filter], where
// sock_filter is the classic-BPF instruction format from <linux/filter.h>:
//
// struct sock_filter {
// __u16 code; // opcode
// __u8 jt; // jump-if-true offset
// __u8 jf; // jump-if-false offset
// __u32 k; // generic immediate operand
// };
//
// Exactly 8 bytes, no padding. Native endian because producer and consumer
// are the same machine — there is no cross-host serialization.
let mut out = Vec::with_capacity(program.len() * 8);
for insn in program {
out.extend_from_slice(&insn.code.to_ne_bytes());
out.push(insn.jt);
out.push(insn.jf);
out.extend_from_slice(&insn.k.to_ne_bytes());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_on_supported_arch() {
let bytes = build_program_bytes().expect("seccomp program should build");
assert!(!bytes.is_empty(), "serialized BPF program is empty");
assert_eq!(bytes.len() % 8, 0, "BPF byte stream must be 8-byte aligned");
}
#[test]
fn allowlist_contains_essential_syscalls() {
for needed in &[
"read",
"write",
"openat",
"close",
"execve",
"exit_group",
"mmap",
"brk",
"clone",
] {
assert!(
ALLOWED_SYSCALLS.contains(needed),
"allowlist missing essential syscall: {needed}"
);
}
}
#[test]
fn allowlist_excludes_dangerous_syscalls() {
for denied in &[
"bpf",
"perf_event_open",
"userfaultfd",
"kexec_load",
"kexec_file_load",
"init_module",
"finit_module",
"delete_module",
"mount",
"umount",
"umount2",
"unshare",
"setns",
"pivot_root",
"ptrace",
"process_vm_readv",
"process_vm_writev",
"keyctl",
"personality",
"clone3",
"io_uring_setup",
"io_uring_register",
"io_uring_enter",
"fanotify_init",
"fanotify_mark",
"open_by_handle_at",
"name_to_handle_at",
"fsopen",
"fsconfig",
"fsmount",
"fspick",
"open_tree",
"move_mount",
"mount_setattr",
"reboot",
"swapon",
"swapoff",
] {
assert!(
!ALLOWED_SYSCALLS.contains(denied),
"allowlist must not contain dangerous syscall: {denied}"
);
}
}
}