diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7a297e..9b714e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,13 @@ jobs: # ${{ matrix.distro }} \ # "$TEST_BIN" --ignored --test-threads=1 + cargo-deny: + name: Cargo Deny + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 + semver-check: name: SemVer Check runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index a803b6f..7df85ac 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,9 @@ target/ bindings/ +# Fuzz corpus (generated by cargo-fuzz) +fuzz/corpus/ +fuzz/artifacts/ + # Local cargo config .cargo/ \ No newline at end of file diff --git a/crates/evalbox-sandbox/src/executor.rs b/crates/evalbox-sandbox/src/executor.rs index 57512fb..0b60f1c 100644 --- a/crates/evalbox-sandbox/src/executor.rs +++ b/crates/evalbox-sandbox/src/executor.rs @@ -35,7 +35,7 @@ //! } //! ``` -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ffi::CString; use std::io::{self, Write as _}; use std::os::fd::{AsRawFd, OwnedFd, RawFd}; @@ -49,7 +49,7 @@ use rustix::process::{Pid, PidfdFlags, Signal, pidfd_open, pidfd_send_signal}; use thiserror::Error; use evalbox_sys::seccomp::{ - DEFAULT_WHITELIST, NOTIFY_FS_SYSCALLS, SockFprog, build_notify_filter, build_whitelist_filter, + SockFprog, build_notify_filter, build_whitelist_filter, default_whitelist, notify_fs_syscalls, }; use evalbox_sys::seccomp_notify::seccomp_set_mode_filter_listener; use evalbox_sys::{check, last_errno, seccomp::seccomp_set_mode_filter}; @@ -165,6 +165,27 @@ struct SpawnedSandbox { workspace: std::mem::ManuallyDrop, } +impl Drop for SpawnedSandbox { + fn drop(&mut self) { + // Close remaining pipe fds. Some may already be closed by + // close_parent_pipe_ends or the event loop (EBADF is harmless). + unsafe { + if self.stdin_fd >= 0 { + libc::close(self.stdin_fd); + } + if self.stdout_fd >= 0 { + libc::close(self.stdout_fd); + } + if self.stderr_fd >= 0 { + libc::close(self.stderr_fd); + } + } + // Clean up temp directory without dropping workspace (which would + // trigger OwnedFd double-close for fds already closed by libc::close). + let _ = std::fs::remove_dir_all(self.workspace.root()); + } +} + /// Internal state for a running sandbox. struct SandboxState { spawned: SpawnedSandbox, @@ -253,6 +274,7 @@ impl Executor { None }; + // SAFETY: fork is safe, returns child pid in parent, 0 in child, or -1 on error. let child_pid = unsafe { libc::fork() }; if child_pid < 0 { return Err(ExecutorError::Fork(last_errno())); @@ -270,6 +292,11 @@ impl Executor { } } + // SAFETY: child_pid is a valid, just-forked PID. There is a theoretical race + // between fork() and pidfd_open() if PIDs wrap around in microseconds, but this + // is extremely unlikely. The ideal fix is clone3(CLONE_PIDFD) which atomically + // returns a pidfd, but is complex to implement with the current fork-based + // architecture. Acceptable for v0.1.x. let pid = unsafe { Pid::from_raw_unchecked(child_pid) }; let pidfd = pidfd_open(pid, PidfdFlags::empty()).map_err(ExecutorError::Pidfd)?; @@ -395,6 +422,8 @@ impl Executor { } /// Write data to a sandbox's stdin. + // Cast is safe: libc::write returns bytes written which fits in usize on 64-bit. + #[allow(clippy::cast_sign_loss)] pub fn write_stdin(&mut self, id: SandboxId, data: &[u8]) -> io::Result { if let Some(state) = self.sandboxes.get(&id) { let fd = state.spawned.stdin_fd; @@ -435,6 +464,9 @@ impl Executor { } } + // Cast is safe: libc::read returns bytes read (positive) which fits in usize; + // max_output fits in usize on 64-bit. + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] fn read_pipe(&mut self, sandbox_id: SandboxId, is_stdout: bool, events: &mut Vec) { let Some(state) = self.sandboxes.get_mut(&sandbox_id) else { return; @@ -495,6 +527,8 @@ impl Executor { } } + // Cast is safe: max_output fits in usize on 64-bit. + #[allow(clippy::cast_possible_truncation)] fn check_completions(&mut self, events: &mut Vec) -> io::Result<()> { let now = Instant::now(); let mut to_remove = Vec::new(); @@ -575,6 +609,7 @@ fn poll_or_kill(fd: RawFd, child_pid: libc::pid_t, msg: &str) -> Result<(), Exec events: libc::POLLIN, revents: 0, }; + // SAFETY: pollfd is valid, nfds=1, timeout in ms. if unsafe { libc::poll(&mut pfd, 1, 30000) } <= 0 { unsafe { libc::kill(child_pid, libc::SIGKILL) }; return Err(ExecutorError::ChildSetup(msg.into())); @@ -638,6 +673,7 @@ fn spawn_sandbox(plan: Plan) -> Result { None }; + // SAFETY: fork is safe, returns child pid in parent, 0 in child, or -1 on error. let child_pid = unsafe { libc::fork() }; if child_pid < 0 { return Err(ExecutorError::Fork(last_errno())); @@ -654,6 +690,7 @@ fn spawn_sandbox(plan: Plan) -> Result { } } + // SAFETY: See comment in Executor::run() about fork/pidfd_open race window. let pid = unsafe { Pid::from_raw_unchecked(child_pid) }; let pidfd = pidfd_open(pid, PidfdFlags::empty()).map_err(ExecutorError::Pidfd)?; @@ -717,6 +754,9 @@ fn blocking_parent( workspace: Workspace, plan: Plan, ) -> Result { + // ManuallyDrop prevents OwnedFd double-close: we close pipe fds via libc::close + // at specific points (stdin.write before monitor for EOF, rest after monitor), + // and OwnedFd::Drop must not run again on those same fds. let workspace = std::mem::ManuallyDrop::new(workspace); close_parent_pipe_ends(&workspace); @@ -726,6 +766,7 @@ fn blocking_parent( if let Some(ref stdin_data) = plan.stdin { write_stdin(&workspace, stdin_data).map_err(ExecutorError::Monitor)?; } + // Close stdin write end to signal EOF to child unsafe { libc::close(workspace.pipes.stdin.write.as_raw_fd()) }; let result = monitor(pidfd, &workspace, &plan).map_err(ExecutorError::Monitor); @@ -737,6 +778,10 @@ fn blocking_parent( libc::close(workspace.pipes.sync.parent_done_fd()); } + // Clean up temp directory. We can't drop workspace normally because + // ManuallyDrop prevents it (intentionally, to avoid OwnedFd double-close). + let _ = std::fs::remove_dir_all(workspace.root()); + result } @@ -752,6 +797,8 @@ fn blocking_parent( /// 8. Wait for parent signal /// 9. `close_range(3, MAX, 0)` /// 10. execve +// Cast is safe: BPF filter length fits in u16 (max ~220 instructions). +#[allow(clippy::cast_possible_truncation)] fn child_process( workspace: &Workspace, plan: &Plan, @@ -786,7 +833,8 @@ fn child_process( // 5. If notify mode != Disabled: install notify seccomp filter, send listener fd if plan.notify_mode != NotifyMode::Disabled { - let notify_filter = build_notify_filter(NOTIFY_FS_SYSCALLS); + let nfs = notify_fs_syscalls(); + let notify_filter = build_notify_filter(&nfs); let fprog = SockFprog { len: notify_filter.len() as u16, filter: notify_filter.as_ptr(), @@ -832,6 +880,7 @@ fn setup_stdio(workspace: &Workspace) -> Result<(), ExecutorError> { let stdout_fd = workspace.pipes.stdout.write.as_raw_fd(); let stderr_fd = workspace.pipes.stderr.write.as_raw_fd(); + // SAFETY: dup2 is safe with valid fds obtained from OwnedFd; close is always safe. unsafe { libc::close(0); libc::close(1); @@ -849,21 +898,21 @@ fn setup_stdio(workspace: &Workspace) -> Result<(), ExecutorError> { Ok(()) } +// Cast is safe: filter length fits in u16 (max whitelist is 200 + ~20 overhead). +#[allow(clippy::cast_possible_truncation)] fn apply_seccomp(plan: &Plan) -> Result<(), ExecutorError> { + let base = default_whitelist(); let whitelist: Vec = if let Some(ref syscalls) = plan.syscalls { - let mut wl: Vec = DEFAULT_WHITELIST - .iter() - .copied() - .filter(|s| !syscalls.denied.contains(s)) - .collect(); + let mut wl_set: HashSet = base.into_iter().collect(); + for s in &syscalls.denied { + wl_set.remove(s); + } for s in &syscalls.allowed { - if !wl.contains(s) { - wl.push(*s); - } + wl_set.insert(*s); } - wl + wl_set.into_iter().collect() } else { - DEFAULT_WHITELIST.to_vec() + base }; let filter = build_whitelist_filter(&whitelist); diff --git a/crates/evalbox-sandbox/src/isolation/lockdown.rs b/crates/evalbox-sandbox/src/isolation/lockdown.rs index 3c0006e..888aaa4 100644 --- a/crates/evalbox-sandbox/src/isolation/lockdown.rs +++ b/crates/evalbox-sandbox/src/isolation/lockdown.rs @@ -247,6 +247,8 @@ fn set_no_new_privs() -> Result<(), LockdownError> { } fn drop_all_caps() -> Result<(), LockdownError> { + // SAFETY: prctl with PR_CAP_AMBIENT_CLEAR_ALL and PR_CAPBSET_DROP is safe + // for any capability value 0..63. These calls only affect the current process. unsafe { libc::prctl( libc::PR_CAP_AMBIENT, diff --git a/crates/evalbox-sandbox/src/lib.rs b/crates/evalbox-sandbox/src/lib.rs index 006b14a..7442490 100644 --- a/crates/evalbox-sandbox/src/lib.rs +++ b/crates/evalbox-sandbox/src/lib.rs @@ -26,9 +26,6 @@ //! - Linux kernel 6.12+ (for Landlock ABI 5) //! - Seccomp enabled in kernel -#![allow(clippy::cast_possible_truncation)] -#![allow(clippy::cast_sign_loss)] - pub mod executor; pub mod isolation; pub mod monitor; diff --git a/crates/evalbox-sandbox/src/monitor.rs b/crates/evalbox-sandbox/src/monitor.rs index bc08a08..e31ee08 100644 --- a/crates/evalbox-sandbox/src/monitor.rs +++ b/crates/evalbox-sandbox/src/monitor.rs @@ -20,6 +20,7 @@ //! - `CLD_EXITED` - Normal exit with exit code //! - `CLD_KILLED` / `CLD_DUMPED` - Killed by signal +use std::borrow::Cow; use std::io; use std::os::fd::{AsRawFd, OwnedFd, RawFd}; use std::time::{Duration, Instant}; @@ -30,6 +31,7 @@ use crate::plan::Plan; use crate::workspace::Workspace; /// Output from a sandboxed execution. +#[must_use] #[derive(Debug, Clone)] pub struct Output { pub stdout: Vec, @@ -47,13 +49,13 @@ impl Output { } #[inline] - pub fn stdout_str(&self) -> String { - String::from_utf8_lossy(&self.stdout).into_owned() + pub fn stdout_str(&self) -> Cow<'_, str> { + String::from_utf8_lossy(&self.stdout) } #[inline] - pub fn stderr_str(&self) -> String { - String::from_utf8_lossy(&self.stderr).into_owned() + pub fn stderr_str(&self) -> Cow<'_, str> { + String::from_utf8_lossy(&self.stderr) } } @@ -67,6 +69,8 @@ pub enum Status { } /// Monitor the child process and collect output. +// Casts are safe: poll timeout capped at 100ms fits i32; max_output fits usize on 64-bit. +#[allow(clippy::cast_possible_truncation)] pub fn monitor(pidfd: OwnedFd, workspace: &Workspace, plan: &Plan) -> io::Result { let start = Instant::now(); let deadline = start + plan.timeout; @@ -187,6 +191,8 @@ pub fn monitor(pidfd: OwnedFd, workspace: &Workspace, plan: &Plan) -> io::Result } /// Write stdin data to the child process. +// Cast is safe: libc::write returns bytes written which fits in usize on 64-bit. +#[allow(clippy::cast_sign_loss)] pub fn write_stdin(workspace: &Workspace, data: &[u8]) -> io::Result<()> { let fd = workspace.pipes.stdin.write.as_raw_fd(); let mut written = 0; @@ -220,6 +226,8 @@ pub(crate) fn set_nonblocking(fd: RawFd) -> io::Result<()> { } } +// Cast is safe: libc::read returns bytes read which fits in usize on 64-bit. +#[allow(clippy::cast_sign_loss)] #[inline] fn read_nonblocking(fd: RawFd, buf: &mut [u8]) -> io::Result { let ret = unsafe { libc::read(fd, buf.as_mut_ptr().cast::(), buf.len()) }; @@ -230,6 +238,8 @@ fn read_nonblocking(fd: RawFd, buf: &mut [u8]) -> io::Result { } } +// Cast is safe: max_output fits in usize on 64-bit. +#[allow(clippy::cast_possible_truncation)] fn drain_remaining(fd: RawFd, output: &mut Vec, buf: &mut [u8], max_output: u64) { let max = max_output as usize; loop { @@ -249,6 +259,8 @@ fn drain_remaining(fd: RawFd, output: &mut Vec, buf: &mut [u8], max_output: } } +// Cast is safe: pidfd (small fd number) fits in libc::id_t (u32). +#[allow(clippy::cast_sign_loss)] pub(crate) fn wait_for_exit(pidfd: RawFd) -> io::Result<(Option, Option)> { let mut siginfo: libc::siginfo_t = unsafe { std::mem::zeroed() }; let ret = unsafe { diff --git a/crates/evalbox-sandbox/src/notify/scm_rights.rs b/crates/evalbox-sandbox/src/notify/scm_rights.rs index 3acf6c4..3b76337 100644 --- a/crates/evalbox-sandbox/src/notify/scm_rights.rs +++ b/crates/evalbox-sandbox/src/notify/scm_rights.rs @@ -29,6 +29,8 @@ pub fn create_socketpair() -> io::Result<(OwnedFd, OwnedFd)> { } /// Send a file descriptor over a unix socket using `SCM_RIGHTS`. +// Casts are safe: size_of::() is 4 which fits u32; CMSG_SPACE returns small values fitting usize. +#[allow(clippy::cast_possible_truncation)] pub fn send_fd(socket: RawFd, fd: RawFd) -> io::Result<()> { let data = [0u8; 1]; let iov = libc::iovec { @@ -72,6 +74,8 @@ pub fn send_fd(socket: RawFd, fd: RawFd) -> io::Result<()> { } /// Receive a file descriptor from a unix socket using `SCM_RIGHTS`. +// Casts are safe: size_of::() is 4 which fits u32; CMSG_SPACE returns small values fitting usize. +#[allow(clippy::cast_possible_truncation)] pub fn recv_fd(socket: RawFd) -> io::Result { let mut data = [0u8; 1]; let mut iov = libc::iovec { diff --git a/crates/evalbox-sandbox/src/notify/supervisor.rs b/crates/evalbox-sandbox/src/notify/supervisor.rs index cf458d6..69fc8fc 100644 --- a/crates/evalbox-sandbox/src/notify/supervisor.rs +++ b/crates/evalbox-sandbox/src/notify/supervisor.rs @@ -107,17 +107,14 @@ impl Supervisor { })) } + // Casts are safe: SYS_* constants fit i32; args values are kernel ABI (small ints for flags/fds). + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] fn handle_virtualize(&self, notif: &SeccompNotif) -> io::Result> { let syscall_nr = notif.data.nr; - // For openat-family syscalls, args[1] is the pathname pointer - // For open/creat, args[0] is the pathname pointer - let path_addr = if syscall_nr == libc::SYS_openat as i32 - || syscall_nr == libc::SYS_newfstatat as i32 - || syscall_nr == libc::SYS_faccessat as i32 - || syscall_nr == libc::SYS_faccessat2 as i32 - || syscall_nr == libc::SYS_readlinkat as i32 - { + // For *at()-family syscalls, args[1] is the pathname pointer. + // For legacy syscalls (open/creat, x86_64 only), args[0] is the pathname pointer. + let path_addr = if is_at_syscall(syscall_nr) { notif.data.args[1] } else { notif.data.args[0] @@ -140,11 +137,8 @@ impl Supervisor { // Try to translate path if let Some(real_path) = self.vfs.translate(&path) { - // For openat: open the file ourselves and inject the fd - if syscall_nr == libc::SYS_openat as i32 - || syscall_nr == libc::SYS_open as i32 - || syscall_nr == libc::SYS_creat as i32 - { + // For open-family: open the file ourselves and inject the fd + if is_open_syscall(syscall_nr) { let flags = if syscall_nr == libc::SYS_openat as i32 { notif.data.args[2] as i32 } else { @@ -186,6 +180,8 @@ impl Supervisor { .map_err(|e| io::Error::from_raw_os_error(e.raw_os_error())) } + // Cast is safe: fd from libc::open is a small non-negative int fitting u32. + #[allow(clippy::cast_sign_loss)] fn open_and_inject( &self, notif: &SeccompNotif, @@ -242,33 +238,67 @@ impl Supervisor { } } +/// Returns true if this is an *at()-family syscall where args[1] is the pathname. +#[allow(clippy::cast_possible_truncation)] +fn is_at_syscall(nr: i32) -> bool { + let nr = nr as i64; + nr == libc::SYS_openat + || nr == libc::SYS_newfstatat + || nr == libc::SYS_faccessat + || nr == libc::SYS_faccessat2 + || nr == libc::SYS_readlinkat +} + +/// Returns true if this is an open-family syscall (fd injection target). +#[allow(clippy::cast_possible_truncation)] +fn is_open_syscall(nr: i32) -> bool { + let nr = nr as i64; + if nr == libc::SYS_openat { + return true; + } + #[cfg(target_arch = "x86_64")] + if nr == libc::SYS_open || nr == libc::SYS_creat { + return true; + } + false +} + /// Map syscall number to name for logging. +// Cast is safe: i32 syscall number to i64 is always lossless. +#[allow(clippy::cast_possible_truncation)] fn syscall_name(nr: i32) -> &'static str { match nr as i64 { libc::SYS_openat => "openat", + libc::SYS_faccessat => "faccessat", + libc::SYS_faccessat2 => "faccessat2", + libc::SYS_newfstatat => "newfstatat", + libc::SYS_statx => "statx", + libc::SYS_readlinkat => "readlinkat", + #[cfg(target_arch = "x86_64")] libc::SYS_open => "open", + #[cfg(target_arch = "x86_64")] libc::SYS_creat => "creat", + #[cfg(target_arch = "x86_64")] libc::SYS_access => "access", - libc::SYS_faccessat => "faccessat", - libc::SYS_faccessat2 => "faccessat2", + #[cfg(target_arch = "x86_64")] libc::SYS_stat => "stat", + #[cfg(target_arch = "x86_64")] libc::SYS_lstat => "lstat", - libc::SYS_newfstatat => "newfstatat", - libc::SYS_statx => "statx", + #[cfg(target_arch = "x86_64")] libc::SYS_readlink => "readlink", - libc::SYS_readlinkat => "readlinkat", _ => "unknown", } } #[cfg(test)] +#[allow(clippy::cast_possible_truncation)] mod tests { use super::*; #[test] fn syscall_names() { assert_eq!(syscall_name(libc::SYS_openat as i32), "openat"); - assert_eq!(syscall_name(libc::SYS_stat as i32), "stat"); + assert_eq!(syscall_name(libc::SYS_newfstatat as i32), "newfstatat"); assert_eq!(syscall_name(9999), "unknown"); } } diff --git a/crates/evalbox-sandbox/src/notify/virtual_fs.rs b/crates/evalbox-sandbox/src/notify/virtual_fs.rs index 7c7df5b..5c15bd4 100644 --- a/crates/evalbox-sandbox/src/notify/virtual_fs.rs +++ b/crates/evalbox-sandbox/src/notify/virtual_fs.rs @@ -12,36 +12,39 @@ //! | `/tmp` | `{workspace}/tmp` | //! | `/home` | `{workspace}/home` | -use std::collections::HashMap; use std::path::{Path, PathBuf}; /// Virtual filesystem with path translation. +/// +/// Uses a `Vec` instead of `HashMap` since there are typically only ~3 mappings, +/// where linear scan is faster than hash lookup. #[derive(Debug, Clone)] pub struct VirtualFs { /// Maps virtual prefix → real prefix. - mappings: HashMap, + mappings: Vec<(PathBuf, PathBuf)>, } impl VirtualFs { /// Create a new `VirtualFs` with default mappings for the given workspace root. pub fn new(workspace_root: &Path) -> Self { - let mut mappings = HashMap::new(); - mappings.insert(PathBuf::from("/work"), workspace_root.join("work")); - mappings.insert(PathBuf::from("/tmp"), workspace_root.join("tmp")); - mappings.insert(PathBuf::from("/home"), workspace_root.join("home")); + let mappings = vec![ + (PathBuf::from("/work"), workspace_root.join("work")), + (PathBuf::from("/tmp"), workspace_root.join("tmp")), + (PathBuf::from("/home"), workspace_root.join("home")), + ]; Self { mappings } } /// Create an empty `VirtualFs` with no mappings. pub fn empty() -> Self { Self { - mappings: HashMap::new(), + mappings: Vec::new(), } } /// Add a path mapping. pub fn add_mapping(&mut self, virtual_path: impl Into, real_path: impl Into) { - self.mappings.insert(virtual_path.into(), real_path.into()); + self.mappings.push((virtual_path.into(), real_path.into())); } /// Translate a path from child's view to host's view. @@ -65,7 +68,7 @@ impl VirtualFs { let path = Path::new(path); // Check virtual mappings - for virtual_prefix in self.mappings.keys() { + for (virtual_prefix, _) in &self.mappings { if path.starts_with(virtual_prefix) { return true; } diff --git a/crates/evalbox-sandbox/src/plan.rs b/crates/evalbox-sandbox/src/plan.rs index 62a31ae..a3655f0 100644 --- a/crates/evalbox-sandbox/src/plan.rs +++ b/crates/evalbox-sandbox/src/plan.rs @@ -286,6 +286,7 @@ impl UserFile { /// /// let output = Executor::run(plan)?; /// ``` +#[must_use] #[derive(Debug, Clone)] pub struct Plan { pub cmd: Vec, @@ -460,9 +461,8 @@ impl Plan { fn default_env() -> HashMap { // Default PATH covers common locations on FHS and NixOS systems. - // For NixOS, the caller (evalbox) should set PATH from SYSTEM_PATHS. let default_path = if std::path::Path::new("/nix/store").exists() { - "/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:/usr/bin:/bin" + "/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:/usr/local/bin:/usr/bin:/bin" } else { "/usr/local/bin:/usr/bin:/bin" }; diff --git a/crates/evalbox-sandbox/src/workspace.rs b/crates/evalbox-sandbox/src/workspace.rs index dcf5d42..079627d 100644 --- a/crates/evalbox-sandbox/src/workspace.rs +++ b/crates/evalbox-sandbox/src/workspace.rs @@ -70,6 +70,7 @@ pub struct SyncPair { impl SyncPair { pub fn new() -> io::Result { + // SAFETY: eventfd returns a valid fd on success, -1 on error. let child_ready = unsafe { libc::eventfd(0, 0) }; if child_ready < 0 { return Err(io::Error::last_os_error()); diff --git a/crates/evalbox-sys/src/check.rs b/crates/evalbox-sys/src/check.rs index 6076036..470d0fb 100644 --- a/crates/evalbox-sys/src/check.rs +++ b/crates/evalbox-sys/src/check.rs @@ -58,9 +58,11 @@ pub enum CheckError { KernelVersionReadFailed, } -// Minimum kernel version: 6.12 (Landlock ABI 5 with SCOPE_SIGNAL + SCOPE_ABSTRACT_UNIX_SOCKET) -const MIN_KERNEL_VERSION: (u32, u32, u32) = (6, 12, 0); -const MIN_LANDLOCK_ABI: u32 = 5; +// Minimum kernel version: 6.7 (Landlock ABI 4 with TCP network + IOCTL_DEV) +// ABI 5 (kernel 6.12+) adds SCOPE_SIGNAL + SCOPE_ABSTRACT_UNIX_SOCKET +// but is not required — lockdown.rs degrades gracefully with a warning. +const MIN_KERNEL_VERSION: (u32, u32, u32) = (6, 7, 0); +const MIN_LANDLOCK_ABI: u32 = 4; static SYSTEM_INFO: OnceLock> = OnceLock::new(); @@ -154,15 +156,13 @@ mod tests { #[test] fn test_check() { - match check() { - Ok(info) => { - println!("Kernel version: {:?}", info.kernel_version); - println!("Landlock ABI: {}", info.landlock_abi); - println!("Seccomp enabled: {}", info.seccomp_enabled); - } - Err(e) => { - println!("System check failed: {e}"); - } + // Verify it doesn't panic and returns a valid result + let result = check(); + assert!(result.is_ok() || result.is_err()); + if let Ok(info) = result { + assert!(info.kernel_version.0 > 0, "major version should be > 0"); + assert!(info.landlock_abi > 0, "landlock ABI should be > 0"); + assert!(info.seccomp_enabled, "seccomp should be enabled"); } } } diff --git a/crates/evalbox-sys/src/landlock.rs b/crates/evalbox-sys/src/landlock.rs index c862cd4..9ca2936 100644 --- a/crates/evalbox-sys/src/landlock.rs +++ b/crates/evalbox-sys/src/landlock.rs @@ -103,6 +103,8 @@ pub struct LandlockPathBeneathAttr { /// # Errors /// /// Returns `Errno` if the kernel doesn't support Landlock. +// Cast is safe: landlock ABI version fits in u32 (currently 1-5). +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] pub fn landlock_abi_version() -> Result { // SAFETY: Passing null with size 0 and VERSION flag queries the ABI version. let ret = unsafe { @@ -125,6 +127,8 @@ pub fn landlock_abi_version() -> Result { /// # Errors /// /// Returns `Errno` if the ruleset creation fails. +// Cast is safe: syscall returns a small fd number that fits in i32. +#[allow(clippy::cast_possible_truncation)] pub fn landlock_create_ruleset(attr: &LandlockRulesetAttr) -> Result { // SAFETY: attr points to valid memory with correct size. let ret = unsafe { diff --git a/crates/evalbox-sys/src/lib.rs b/crates/evalbox-sys/src/lib.rs index 78a4688..83a8b9f 100644 --- a/crates/evalbox-sys/src/lib.rs +++ b/crates/evalbox-sys/src/lib.rs @@ -22,8 +22,8 @@ //! ## Seccomp-BPF //! //! Seccomp-BPF allows filtering syscalls via BPF programs. This crate provides -//! a whitelist-based filter that allows ~40 safe syscalls and kills the process -//! on any other syscall. +//! an architecture-aware whitelist-based filter (x86_64 and aarch64) that allows +//! safe syscalls and kills the process on any other syscall. //! //! ## Seccomp User Notify //! @@ -35,9 +35,6 @@ //! This crate contains raw syscall wrappers. Casts between integer types //! are unavoidable when interfacing with the kernel ABI. -#![allow(clippy::cast_possible_truncation)] -#![allow(clippy::cast_sign_loss)] - pub mod check; pub mod landlock; pub mod seccomp; diff --git a/crates/evalbox-sys/src/seccomp.rs b/crates/evalbox-sys/src/seccomp.rs index 1fd50d4..8325760 100644 --- a/crates/evalbox-sys/src/seccomp.rs +++ b/crates/evalbox-sys/src/seccomp.rs @@ -8,7 +8,7 @@ //! //! The BPF filter runs on every syscall: //! -//! 1. Verify architecture is `x86_64` (kill otherwise) +//! 1. Verify architecture is supported (x86_64 or aarch64, kill otherwise) //! 2. Load syscall number from `seccomp_data` //! 3. Block `clone3` entirely (cannot inspect flags in struct) //! 4. For `clone`, inspect flags and block namespace creation @@ -83,9 +83,17 @@ const BPF_JEQ: u16 = 0x10; const BPF_JSET: u16 = 0x40; const BPF_K: u16 = 0x00; -const AUDIT_ARCH_X86_64: u32 = 0xc000003e; +// BPF ALU operations +const BPF_ALU: u16 = 0x04; +const BPF_AND: u16 = 0x50; -// seccomp_data offsets (x86_64) +#[cfg(target_arch = "x86_64")] +const AUDIT_ARCH: u32 = 0xc000003e; // AUDIT_ARCH + +#[cfg(target_arch = "aarch64")] +const AUDIT_ARCH: u32 = 0xc00000b7; // AUDIT_ARCH_AARCH64 + +// seccomp_data offsets (same layout on x86_64 and aarch64) const OFFSET_SYSCALL_NR: u32 = 0; const OFFSET_ARCH: u32 = 4; const OFFSET_ARGS_0: u32 = 16; // args[0], lower 32 bits @@ -158,7 +166,11 @@ pub struct SockFprog { pub filter: *const SockFilter, } -/// Syscalls allowed in the sandbox. +/// Base syscalls allowed on all architectures (x86_64 and aarch64). +/// +/// **Ordering**: Hot syscalls first for faster BPF linear scan. +/// The kernel checks each JEQ instruction sequentially, so placing +/// the most frequently called syscalls first reduces average iterations. /// /// ## Special handling (not in this list): /// - `clone` - Allowed with flag filtering (blocks `CLONE_NEW`*) @@ -175,14 +187,23 @@ pub struct SockFprog { /// ## Notes: /// - `kill`/`tgkill` safe due to Landlock v5 `SCOPE_SIGNAL` isolation /// - `prctl` kept for runtime needs (`PR_SET_NAME`, etc.) -pub const DEFAULT_WHITELIST: &[i64] = &[ - // === Basic I/O === +const BASE_WHITELIST: &[i64] = &[ + // === Hot syscalls (ordered by typical frequency) === libc::SYS_read, libc::SYS_write, libc::SYS_close, - libc::SYS_close_range, // Modern fd range closing - libc::SYS_fstat, + libc::SYS_futex, + libc::SYS_mmap, + libc::SYS_mprotect, + libc::SYS_munmap, + libc::SYS_brk, + libc::SYS_rt_sigaction, + libc::SYS_rt_sigprocmask, + libc::SYS_rt_sigreturn, + libc::SYS_openat, libc::SYS_lseek, + // === Basic I/O === + libc::SYS_close_range, // Modern fd range closing libc::SYS_pread64, libc::SYS_pwrite64, libc::SYS_readv, @@ -192,7 +213,6 @@ pub const DEFAULT_WHITELIST: &[i64] = &[ libc::SYS_preadv2, libc::SYS_pwritev2, libc::SYS_dup, - libc::SYS_dup2, libc::SYS_dup3, libc::SYS_fcntl, libc::SYS_flock, @@ -200,11 +220,7 @@ pub const DEFAULT_WHITELIST: &[i64] = &[ libc::SYS_fdatasync, libc::SYS_ftruncate, libc::SYS_fadvise64, - libc::SYS_access, - libc::SYS_pipe, libc::SYS_pipe2, - libc::SYS_select, - libc::SYS_poll, libc::SYS_ppoll, libc::SYS_pselect6, // Efficient file operations (Python/Node use these) @@ -213,10 +229,6 @@ pub const DEFAULT_WHITELIST: &[i64] = &[ libc::SYS_splice, libc::SYS_tee, // === Memory === - libc::SYS_mmap, - libc::SYS_mprotect, - libc::SYS_munmap, - libc::SYS_brk, libc::SYS_mremap, libc::SYS_msync, libc::SYS_mincore, @@ -239,7 +251,6 @@ pub const DEFAULT_WHITELIST: &[i64] = &[ libc::SYS_getresuid, libc::SYS_getresgid, // setresuid/setresgid REMOVED - no need to change UID in sandbox - libc::SYS_getpgrp, // setpgid/setsid REMOVED - session manipulation unnecessary libc::SYS_getgroups, libc::SYS_getsid, @@ -254,48 +265,29 @@ pub const DEFAULT_WHITELIST: &[i64] = &[ libc::SYS_gettimeofday, libc::SYS_nanosleep, // === Filesystem (Landlock restricts actual access) === - libc::SYS_openat, - libc::SYS_open, - libc::SYS_creat, - libc::SYS_unlink, libc::SYS_unlinkat, - libc::SYS_rename, libc::SYS_renameat, libc::SYS_renameat2, - libc::SYS_mkdir, libc::SYS_mkdirat, - libc::SYS_rmdir, - libc::SYS_symlink, libc::SYS_symlinkat, - libc::SYS_link, libc::SYS_linkat, - libc::SYS_chmod, libc::SYS_fchmod, libc::SYS_fchmodat, - libc::SYS_chown, libc::SYS_fchown, libc::SYS_fchownat, - libc::SYS_lchown, libc::SYS_utimensat, libc::SYS_faccessat, libc::SYS_faccessat2, - libc::SYS_stat, - libc::SYS_lstat, libc::SYS_newfstatat, libc::SYS_statfs, libc::SYS_fstatfs, libc::SYS_statx, - libc::SYS_getdents, libc::SYS_getdents64, libc::SYS_getcwd, libc::SYS_chdir, libc::SYS_fchdir, - libc::SYS_readlink, libc::SYS_readlinkat, // === Signals (safe due to Landlock SCOPE_SIGNAL) === - libc::SYS_rt_sigaction, - libc::SYS_rt_sigprocmask, - libc::SYS_rt_sigreturn, libc::SYS_rt_sigsuspend, libc::SYS_rt_sigpending, libc::SYS_rt_sigtimedwait, @@ -306,14 +298,11 @@ pub const DEFAULT_WHITELIST: &[i64] = &[ // === Process control === libc::SYS_execve, // execveat REMOVED - with memfd_create enables fileless execution - libc::SYS_fork, // Safe: no flags - libc::SYS_vfork, // Safe: no flags libc::SYS_exit, libc::SYS_exit_group, libc::SYS_wait4, libc::SYS_waitid, libc::SYS_set_tid_address, - libc::SYS_futex, libc::SYS_get_robust_list, libc::SYS_set_robust_list, libc::SYS_sched_yield, @@ -324,7 +313,6 @@ pub const DEFAULT_WHITELIST: &[i64] = &[ libc::SYS_sched_getscheduler, libc::SYS_sched_get_priority_max, libc::SYS_sched_get_priority_min, - libc::SYS_arch_prctl, libc::SYS_prctl, // Kept for PR_SET_NAME, etc. PR_SET_SECCOMP is no-op libc::SYS_getrandom, libc::SYS_prlimit64, @@ -334,18 +322,14 @@ pub const DEFAULT_WHITELIST: &[i64] = &[ // ioctl is handled specially below - blocks TIOCSTI, TIOCSETD, TIOCLINUX // (not in whitelist, filtered like socket) // === Event mechanisms === - libc::SYS_eventfd, libc::SYS_eventfd2, - libc::SYS_epoll_create, libc::SYS_epoll_create1, libc::SYS_epoll_ctl, - libc::SYS_epoll_wait, libc::SYS_epoll_pwait, libc::SYS_epoll_pwait2, libc::SYS_timerfd_create, libc::SYS_timerfd_settime, libc::SYS_timerfd_gettime, - libc::SYS_signalfd, libc::SYS_signalfd4, // === Sockets (filtered separately for domain/type) === // SYS_socket handled specially - blocks AF_NETLINK, SOCK_RAW @@ -368,6 +352,60 @@ pub const DEFAULT_WHITELIST: &[i64] = &[ libc::SYS_recvmmsg, ]; +/// Legacy x86_64 syscalls not available on aarch64. +/// +/// On aarch64, glibc always uses the modern `*at()` equivalents +/// (e.g., `openat` instead of `open`, `newfstatat` instead of `stat`). +/// These legacy syscalls only exist in the x86_64 syscall table. +#[cfg(target_arch = "x86_64")] +const LEGACY_WHITELIST: &[i64] = &[ + libc::SYS_fstat, + libc::SYS_dup2, + libc::SYS_access, + libc::SYS_pipe, + libc::SYS_select, + libc::SYS_poll, + // Filesystem (legacy variants, aarch64 uses *at() equivalents) + libc::SYS_open, + libc::SYS_creat, + libc::SYS_unlink, + libc::SYS_rename, + libc::SYS_mkdir, + libc::SYS_rmdir, + libc::SYS_symlink, + libc::SYS_link, + libc::SYS_chmod, + libc::SYS_chown, + libc::SYS_lchown, + libc::SYS_stat, + libc::SYS_lstat, + libc::SYS_getdents, + libc::SYS_readlink, + // Process control (aarch64 uses clone() for all) + libc::SYS_fork, // Safe: no flags + libc::SYS_vfork, // Safe: no flags + libc::SYS_getpgrp, + libc::SYS_arch_prctl, + // Event mechanisms (legacy variants) + libc::SYS_eventfd, + libc::SYS_epoll_create, + libc::SYS_epoll_wait, + libc::SYS_signalfd, +]; + +/// On aarch64, all equivalent functionality is provided by the modern +/// syscalls already in `BASE_WHITELIST`. +#[cfg(target_arch = "aarch64")] +const LEGACY_WHITELIST: &[i64] = &[]; + +/// Returns the default syscall whitelist for the current architecture. +/// +/// Combines `BASE_WHITELIST` (common to all architectures) with +/// `LEGACY_WHITELIST` (x86_64-only legacy syscalls). +pub fn default_whitelist() -> Vec { + [BASE_WHITELIST, LEGACY_WHITELIST].concat() +} + /// Builds a BPF filter with clone and socket argument filtering. /// /// ## Filter Layout @@ -388,6 +426,7 @@ pub const DEFAULT_WHITELIST: &[i64] = &[ /// # Panics /// /// Panics if `syscalls.len()` > 200 (BPF jump offsets are u8) +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] pub fn build_whitelist_filter(syscalls: &[i64]) -> Vec { assert!( syscalls.len() <= MAX_WHITELIST_SIZE, @@ -403,7 +442,7 @@ pub fn build_whitelist_filter(syscalls: &[i64]) -> Vec { filter.push(SockFilter::stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_ARCH)); filter.push(SockFilter::jump( BPF_JMP | BPF_JEQ | BPF_K, - AUDIT_ARCH_X86_64, + AUDIT_ARCH, 1, 0, )); @@ -448,8 +487,8 @@ pub fn build_whitelist_filter(syscalls: &[i64]) -> Vec { )); // === ioctl -> ioctl_handler === - // Jump to ioctl handler: skip whitelist + KILL + ALLOW + ERRNO + clone_handler(4) + socket_handler(6) - let ioctl_handler_offset = (n + 3 + 4 + 6) as u8; + // Jump to ioctl handler: skip whitelist + KILL + ALLOW + ERRNO + clone_handler(4) + socket_handler(7) + let ioctl_handler_offset = (n + 3 + 4 + 7) as u8; filter.push(SockFilter::jump( BPF_JMP | BPF_JEQ | BPF_K, libc::SYS_ioctl as u32, @@ -493,22 +532,26 @@ pub fn build_whitelist_filter(syscalls: &[i64]) -> Vec { // Blocked flags -> KILL filter.push(SockFilter::stmt(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS)); - // === Socket handler (6 instructions) === + // === Socket handler (7 instructions) === // Load socket domain (args[0]) filter.push(SockFilter::stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_ARGS_0)); // Block AF_NETLINK (domain 16) - access to nf_tables, etc. + // jt=4: skip load_type(2), AND(3), JEQ_RAW(4), ALLOW(5) → land on KILL(6) filter.push(SockFilter::jump( BPF_JMP | BPF_JEQ | BPF_K, AF_NETLINK, - 3, + 4, 0, - )); // -> KILL + )); // Load socket type (args[1]) filter.push(SockFilter::stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_ARGS_1)); - // Block SOCK_RAW (type 3) - but need to mask out flags (SOCK_NONBLOCK, etc.) - // SOCK_RAW = 3, SOCK_NONBLOCK = 0x800, SOCK_CLOEXEC = 0x80000 - // We check if (type & 0xF) == SOCK_RAW + // Mask out SOCK_NONBLOCK (0x800) and SOCK_CLOEXEC (0x80000) flags. + // Without this, socket(AF_INET, SOCK_RAW | SOCK_NONBLOCK, 0) = 0x803 != 3 + // would bypass the SOCK_RAW check. + #[allow(clippy::cast_possible_truncation)] + filter.push(SockFilter::stmt(BPF_ALU | BPF_AND | BPF_K, 0xF)); + // Block SOCK_RAW (type 3) after masking filter.push(SockFilter::jump(BPF_JMP | BPF_JEQ | BPF_K, SOCK_RAW, 1, 0)); // -> KILL // Socket OK -> ALLOW @@ -580,6 +623,7 @@ pub fn seccomp_available() -> bool { /// # Panics /// /// Panics if `syscalls.len()` > 200 (BPF jump offsets are u8). +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] pub fn build_notify_filter(syscalls: &[i64]) -> Vec { assert!( syscalls.len() <= MAX_WHITELIST_SIZE, @@ -595,7 +639,7 @@ pub fn build_notify_filter(syscalls: &[i64]) -> Vec { filter.push(SockFilter::stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_ARCH)); filter.push(SockFilter::jump( BPF_JMP | BPF_JEQ | BPF_K, - AUDIT_ARCH_X86_64, + AUDIT_ARCH, 1, 0, )); @@ -627,23 +671,37 @@ pub fn build_notify_filter(syscalls: &[i64]) -> Vec { filter } -/// Syscalls that are intercepted by the notify filter for filesystem virtualization. -pub const NOTIFY_FS_SYSCALLS: &[i64] = &[ +/// Base FS syscalls intercepted by the notify filter (all architectures). +const BASE_NOTIFY_FS_SYSCALLS: &[i64] = &[ libc::SYS_openat, + libc::SYS_faccessat, + libc::SYS_faccessat2, + libc::SYS_newfstatat, + libc::SYS_statx, + libc::SYS_readlinkat, +]; + +/// Legacy FS syscalls intercepted on x86_64 only. +#[cfg(target_arch = "x86_64")] +const LEGACY_NOTIFY_FS_SYSCALLS: &[i64] = &[ libc::SYS_open, libc::SYS_creat, libc::SYS_access, - libc::SYS_faccessat, - libc::SYS_faccessat2, libc::SYS_stat, libc::SYS_lstat, - libc::SYS_newfstatat, - libc::SYS_statx, libc::SYS_readlink, - libc::SYS_readlinkat, ]; +#[cfg(target_arch = "aarch64")] +const LEGACY_NOTIFY_FS_SYSCALLS: &[i64] = &[]; + +/// Returns the FS syscalls to intercept via the notify filter. +pub fn notify_fs_syscalls() -> Vec { + [BASE_NOTIFY_FS_SYSCALLS, LEGACY_NOTIFY_FS_SYSCALLS].concat() +} + #[cfg(test)] +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] mod tests { use super::*; @@ -652,13 +710,14 @@ mod tests { let syscalls = &[libc::SYS_read, libc::SYS_write, libc::SYS_exit]; let filter = build_whitelist_filter(syscalls); // 3 (arch) + 1 (load) + 4 (clone3/clone/socket/ioctl) + 3 (whitelist) + 3 (kill/allow/errno) - // + 4 (clone handler) + 6 (socket handler) + 6 (ioctl handler) = 30 - assert_eq!(filter.len(), 30); + // + 4 (clone handler) + 7 (socket handler with AND mask) + 6 (ioctl handler) = 31 + assert_eq!(filter.len(), 31); } #[test] fn clone3_returns_enosys() { - let filter = build_whitelist_filter(DEFAULT_WHITELIST); + let wl = default_whitelist(); + let filter = build_whitelist_filter(&wl); let clone3_check = &filter[4]; assert_eq!(clone3_check.k, libc::SYS_clone3 as u32); assert!(clone3_check.jt > 0); @@ -667,7 +726,8 @@ mod tests { #[test] fn clone_has_flag_check() { - let filter = build_whitelist_filter(DEFAULT_WHITELIST); + let wl = default_whitelist(); + let filter = build_whitelist_filter(&wl); let clone_check = &filter[5]; assert_eq!(clone_check.k, libc::SYS_clone as u32); assert!(clone_check.jt > 0); @@ -680,7 +740,8 @@ mod tests { #[test] fn socket_is_filtered() { - let filter = build_whitelist_filter(DEFAULT_WHITELIST); + let wl = default_whitelist(); + let filter = build_whitelist_filter(&wl); let socket_check = &filter[6]; assert_eq!(socket_check.k, libc::SYS_socket as u32); assert!(socket_check.jt > 0); @@ -688,7 +749,8 @@ mod tests { #[test] fn ioctl_is_filtered() { - let filter = build_whitelist_filter(DEFAULT_WHITELIST); + let wl = default_whitelist(); + let filter = build_whitelist_filter(&wl); let ioctl_check = &filter[7]; assert_eq!(ioctl_check.k, libc::SYS_ioctl as u32); assert!(ioctl_check.jt > 0); @@ -707,26 +769,30 @@ mod tests { #[test] fn dangerous_syscalls_removed() { + let wl = default_whitelist(); // These should NOT be in the whitelist - assert!(!DEFAULT_WHITELIST.contains(&libc::SYS_clone)); - assert!(!DEFAULT_WHITELIST.contains(&libc::SYS_clone3)); - assert!(!DEFAULT_WHITELIST.contains(&libc::SYS_socket)); // Filtered separately - assert!(!DEFAULT_WHITELIST.contains(&libc::SYS_memfd_create)); - assert!(!DEFAULT_WHITELIST.contains(&libc::SYS_execveat)); - assert!(!DEFAULT_WHITELIST.contains(&libc::SYS_setresuid)); - assert!(!DEFAULT_WHITELIST.contains(&libc::SYS_setresgid)); - assert!(!DEFAULT_WHITELIST.contains(&libc::SYS_setsid)); - assert!(!DEFAULT_WHITELIST.contains(&libc::SYS_setpgid)); - // Note: ioctl is now allowed as it's needed for terminal ops and Landlock restricts device access + assert!(!wl.contains(&libc::SYS_clone)); + assert!(!wl.contains(&libc::SYS_clone3)); + assert!(!wl.contains(&libc::SYS_socket)); // Filtered separately + assert!(!wl.contains(&libc::SYS_memfd_create)); + assert!(!wl.contains(&libc::SYS_execveat)); + assert!(!wl.contains(&libc::SYS_setresuid)); + assert!(!wl.contains(&libc::SYS_setresgid)); + assert!(!wl.contains(&libc::SYS_setsid)); + assert!(!wl.contains(&libc::SYS_setpgid)); } #[test] fn safe_syscalls_present() { - assert!(DEFAULT_WHITELIST.contains(&libc::SYS_fork)); - assert!(DEFAULT_WHITELIST.contains(&libc::SYS_vfork)); - assert!(DEFAULT_WHITELIST.contains(&libc::SYS_execve)); - assert!(DEFAULT_WHITELIST.contains(&libc::SYS_sendfile)); - assert!(DEFAULT_WHITELIST.contains(&libc::SYS_close_range)); + let wl = default_whitelist(); + assert!(wl.contains(&libc::SYS_execve)); + assert!(wl.contains(&libc::SYS_sendfile)); + assert!(wl.contains(&libc::SYS_close_range)); + #[cfg(target_arch = "x86_64")] + { + assert!(wl.contains(&libc::SYS_fork)); + assert!(wl.contains(&libc::SYS_vfork)); + } } #[test] @@ -738,7 +804,7 @@ mod tests { #[test] fn notify_filter_structure() { - let syscalls = &[libc::SYS_openat, libc::SYS_open, libc::SYS_stat]; + let syscalls = &[libc::SYS_openat, libc::SYS_newfstatat, libc::SYS_statx]; let filter = build_notify_filter(syscalls); // 3 (arch) + 1 (load) + 3 (checks) + 1 (allow) + 1 (notify) = 9 assert_eq!(filter.len(), 9); @@ -746,9 +812,14 @@ mod tests { #[test] fn notify_fs_syscalls_present() { - assert!(NOTIFY_FS_SYSCALLS.contains(&libc::SYS_openat)); - assert!(NOTIFY_FS_SYSCALLS.contains(&libc::SYS_open)); - assert!(NOTIFY_FS_SYSCALLS.contains(&libc::SYS_stat)); - assert!(NOTIFY_FS_SYSCALLS.contains(&libc::SYS_readlink)); + let nfs = notify_fs_syscalls(); + assert!(nfs.contains(&libc::SYS_openat)); + assert!(nfs.contains(&libc::SYS_readlinkat)); + #[cfg(target_arch = "x86_64")] + { + assert!(nfs.contains(&libc::SYS_open)); + assert!(nfs.contains(&libc::SYS_stat)); + assert!(nfs.contains(&libc::SYS_readlink)); + } } } diff --git a/crates/evalbox-sys/src/seccomp_notify.rs b/crates/evalbox-sys/src/seccomp_notify.rs index 91a2357..ed91ceb 100644 --- a/crates/evalbox-sys/src/seccomp_notify.rs +++ b/crates/evalbox-sys/src/seccomp_notify.rs @@ -40,7 +40,7 @@ pub const SECCOMP_ADDFD_FLAG_SEND: u32 = 1 << 0; pub const SECCOMP_ADDFD_FLAG_SETFD: u32 = 1 << 1; // ioctl numbers for seccomp notify (from kernel headers) -// These are architecture-dependent; values below are for x86_64. +// These use the "new-style" ioctl encoding which is consistent across x86_64 and aarch64. // SECCOMP_IOCTL_NOTIF_RECV = SECCOMP_IOWR(0, struct seccomp_notif) // SECCOMP_IOCTL_NOTIF_SEND = SECCOMP_IOWR(1, struct seccomp_notif_resp) // SECCOMP_IOCTL_NOTIF_ID_VALID = SECCOMP_IOW(2, __u64) @@ -134,6 +134,8 @@ pub struct SeccompNotifAddfd { /// # Errors /// /// Returns `Errno` if the filter cannot be installed. +// Cast is safe: syscall returns a small fd number that fits in i32. +#[allow(clippy::cast_possible_truncation)] pub unsafe fn seccomp_set_mode_filter_listener(fprog: &SockFprog) -> Result { unsafe { let ret = libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); diff --git a/crates/evalbox/src/go/builder.rs b/crates/evalbox/src/go/builder.rs index 5ca416f..08f9bb2 100644 --- a/crates/evalbox/src/go/builder.rs +++ b/crates/evalbox/src/go/builder.rs @@ -1,8 +1,6 @@ //! Go execution builder. -use std::collections::hash_map::DefaultHasher; use std::fs; -use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::sync::LazyLock; use std::time::Duration; @@ -25,6 +23,7 @@ static PROBE_CACHE: LazyLock = LazyLock::new(ProbeCache::new); /// /// Created by [`go::run()`](super::run). Configure with method chaining, /// then execute with `.exec()`. +#[must_use] #[derive(Debug, Clone)] pub struct GoBuilder { code: String, @@ -222,6 +221,8 @@ impl GoBuilder { binary }; + // TODO: Plan::executable_file() to avoid double-copy of binary content. + // Currently the binary is read into memory then copied to workspace. // Execute in restrictive sandbox let mut plan = Plan::new(["/work/main".to_string()]) .cwd("/work") @@ -307,7 +308,7 @@ fn compile_in_sandbox( if !output.success() { return Err(Error::Compilation { - stderr: output.stderr_str(), + stderr: output.stderr_str().into_owned(), exit_code: Some(output.exit_code.unwrap_or(-1)), }); } @@ -334,12 +335,28 @@ fn get_go_cache_dir() -> Result { Ok(cache_base.join("evalbox").join("go")) } +/// Compute a stable cache key using FNV-1a. +/// +/// Uses a simple FNV-1a implementation instead of `DefaultHasher` which is not +/// guaranteed to be stable across Rust versions. The Go binary cache is persisted +/// to disk, so stability matters. fn compute_cache_key(code: &str, go_mod: Option<&str>, cgo_enabled: bool) -> String { - let mut hasher = DefaultHasher::new(); - code.hash(&mut hasher); - go_mod.hash(&mut hasher); - cgo_enabled.hash(&mut hasher); - format!("{:016x}", hasher.finish()) + let mut hash: u64 = 0xcbf29ce484222325; // FNV-1a offset basis + for byte in code.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); // FNV-1a prime + } + if let Some(m) = go_mod { + for byte in m.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + } + if cgo_enabled { + hash ^= 1; + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") } #[cfg(test)] diff --git a/crates/evalbox/src/go/wrap.rs b/crates/evalbox/src/go/wrap.rs index c388e04..e680a40 100644 --- a/crates/evalbox/src/go/wrap.rs +++ b/crates/evalbox/src/go/wrap.rs @@ -1,7 +1,16 @@ //! Go code wrapping utilities. +use std::sync::LazyLock; + use regex::Regex; +static RE_IMPORT: LazyLock = + LazyLock::new(|| Regex::new(r"\b([a-z]+)\.([A-Z][a-zA-Z0-9]*)").unwrap()); +static RE_MAIN_FUNC: LazyLock = + LazyLock::new(|| Regex::new(r"(?m)^func\s+main\s*\(\s*\)").unwrap()); +static RE_PACKAGE: LazyLock = LazyLock::new(|| Regex::new(r"(?m)^package\s+").unwrap()); +static RE_IMPORT_DECL: LazyLock = LazyLock::new(|| Regex::new(r"(?m)^import\s+").unwrap()); + pub const AUTO_IMPORTS: &[(&str, &str)] = &[ ("fmt", "fmt"), ("strings", "strings"), @@ -78,9 +87,8 @@ pub fn wrap_go_code(code: &str, auto_wrap: bool, auto_import: bool) -> String { fn detect_imports(code: &str) -> Vec { let mut imports = Vec::new(); - let re = Regex::new(r"\b([a-z]+)\.([A-Z][a-zA-Z0-9]*)").unwrap(); - for cap in re.captures_iter(code) { + for cap in RE_IMPORT.captures_iter(code) { let pkg = &cap[1]; if let Some((_, import_path)) = AUTO_IMPORTS.iter().find(|(name, _)| *name == pkg) { let import = import_path.to_string(); @@ -94,18 +102,15 @@ fn detect_imports(code: &str) -> Vec { } fn has_main_func(code: &str) -> bool { - let re = Regex::new(r"(?m)^func\s+main\s*\(\s*\)").unwrap(); - re.is_match(code) + RE_MAIN_FUNC.is_match(code) } fn has_package_decl(code: &str) -> bool { - let re = Regex::new(r"(?m)^package\s+").unwrap(); - re.is_match(code) + RE_PACKAGE.is_match(code) } fn has_imports(code: &str) -> bool { - let re = Regex::new(r"(?m)^import\s+").unwrap(); - re.is_match(code) + RE_IMPORT_DECL.is_match(code) } #[cfg(test)] diff --git a/crates/evalbox/src/lib.rs b/crates/evalbox/src/lib.rs index 7826ea1..a6f6d6f 100644 --- a/crates/evalbox/src/lib.rs +++ b/crates/evalbox/src/lib.rs @@ -4,8 +4,8 @@ //! //! ## Features //! -//! - **Unprivileged**: Uses user namespaces, no root required -//! - **Secure**: Multiple isolation layers (namespaces, Landlock, seccomp, rlimits) +//! - **Unprivileged**: Uses Landlock v5 + seccomp-BPF, no root required +//! - **Secure**: Multiple isolation layers (Landlock, seccomp, rlimits, capabilities) //! - **Fast**: No VM or container startup overhead //! - **Simple**: Single function call to run sandboxed code //! @@ -62,8 +62,7 @@ //! //! ## Requirements //! -//! - Linux kernel 5.13+ (for Landlock) -//! - User namespaces enabled +//! - Linux kernel 6.12+ (for Landlock ABI 5) //! - Seccomp enabled // Internal modules diff --git a/crates/evalbox/src/output.rs b/crates/evalbox/src/output.rs index 8d07fd2..7caa533 100644 --- a/crates/evalbox/src/output.rs +++ b/crates/evalbox/src/output.rs @@ -2,11 +2,16 @@ //! //! Contains the result of a sandboxed execution: stdout, stderr, exit code, and timing. +use std::borrow::Cow; use std::time::Duration; pub use evalbox_sandbox::Status; /// Output from a sandboxed execution. +/// +/// This is a simplified version of [`evalbox_sandbox::Output`] with +/// `exit_code` defaulting to `-1` when unavailable. +#[must_use] #[derive(Debug, Clone)] pub struct Output { pub stdout: Vec, @@ -35,13 +40,13 @@ impl Output { } #[inline] - pub fn stdout_str(&self) -> String { - String::from_utf8_lossy(&self.stdout).into_owned() + pub fn stdout_str(&self) -> Cow<'_, str> { + String::from_utf8_lossy(&self.stdout) } #[inline] - pub fn stderr_str(&self) -> String { - String::from_utf8_lossy(&self.stderr).into_owned() + pub fn stderr_str(&self) -> Cow<'_, str> { + String::from_utf8_lossy(&self.stderr) } } diff --git a/crates/evalbox/src/python/builder.rs b/crates/evalbox/src/python/builder.rs index c838ce2..5435b51 100644 --- a/crates/evalbox/src/python/builder.rs +++ b/crates/evalbox/src/python/builder.rs @@ -21,6 +21,7 @@ static PROBE_CACHE: LazyLock = LazyLock::new(ProbeCache::new); /// /// Created by [`python::run()`](super::run). Configure with method chaining, /// then execute with `.exec()`. +#[must_use] #[derive(Debug, Clone)] pub struct PythonBuilder { code: String, diff --git a/crates/evalbox/src/python/elf.rs b/crates/evalbox/src/python/elf.rs index 244d385..2962270 100644 --- a/crates/evalbox/src/python/elf.rs +++ b/crates/evalbox/src/python/elf.rs @@ -10,60 +10,21 @@ use crate::error::ProbeError; use super::ldcache::LdCache; -pub fn resolve_shared_libs(binary: &Path) -> Result, ProbeError> { - let ldcache = LdCache::load()?; - let mut resolved = HashSet::new(); - let mut result = Vec::new(); - let mut queue = vec![binary.to_path_buf()]; - - while let Some(path) = queue.pop() { - if !resolved.insert(path.clone()) { - continue; - } - - let needed = parse_needed(&path)?; - let (rpath, runpath) = parse_rpath_runpath(&path)?; - - for lib_name in needed { - if let Some(lib_path) = resolve_library(&lib_name, &path, &rpath, &runpath, &ldcache) { - if !resolved.contains(&lib_path) { - resolved.insert(lib_path.clone()); - result.push(lib_path.clone()); - queue.push(lib_path); - } - } - } - } - - Ok(result) -} - -fn parse_needed(path: &Path) -> Result, ProbeError> { - let file = std::fs::File::open(path)?; - - let mmap = unsafe { Mmap::map(&file) }.map_err(|e| ProbeError::ElfError { - path: path.to_path_buf(), - message: format!("failed to mmap: {e}"), - })?; - - let object = Object::parse(&mmap).map_err(|e| ProbeError::ElfError { - path: path.to_path_buf(), - message: e.to_string(), - })?; - - let Object::Elf(elf) = object else { - return Err(ProbeError::ElfError { - path: path.to_path_buf(), - message: "not an ELF binary".to_string(), - }); - }; - - Ok(elf.libraries.iter().map(|s| s.to_string()).collect()) +/// Parsed ELF information: needed libraries and search paths in a single pass. +struct ElfInfo { + needed: Vec, + rpath: Vec, + runpath: Vec, } -fn parse_rpath_runpath(path: &Path) -> Result<(Vec, Vec), ProbeError> { +/// Parse needed libraries and rpath/runpath from an ELF binary in a single pass. +/// +/// Avoids opening, mmapping, and parsing the same file twice (previously +/// `parse_needed` and `parse_rpath_runpath` each did this independently). +fn parse_elf_info(path: &Path) -> Result { let file = std::fs::File::open(path)?; + // SAFETY: File is open and valid for the lifetime of the mmap. let mmap = unsafe { Mmap::map(&file) }.map_err(|e| ProbeError::ElfError { path: path.to_path_buf(), message: format!("failed to mmap: {e}"), @@ -94,12 +55,45 @@ fn parse_rpath_runpath(path: &Path) -> Result<(Vec, Vec), ProbeE .iter() .filter(|p| !p.is_empty()) .flat_map(|p| p.split(':')) - .map(expand_path) + .map(&expand_path) .collect(); let runpath = rpath.clone(); - Ok((rpath, runpath)) + Ok(ElfInfo { + needed: elf.libraries.iter().map(|s| s.to_string()).collect(), + rpath, + runpath, + }) +} + +pub fn resolve_shared_libs(binary: &Path) -> Result, ProbeError> { + let ldcache = LdCache::load()?; + let mut resolved = HashSet::new(); + let mut result = Vec::new(); + let mut queue = vec![binary.to_path_buf()]; + + while let Some(path) = queue.pop() { + if !resolved.insert(path.clone()) { + continue; + } + + let info = parse_elf_info(&path)?; + + for lib_name in info.needed { + if let Some(lib_path) = + resolve_library(&lib_name, &path, &info.rpath, &info.runpath, &ldcache) + { + if !resolved.contains(&lib_path) { + resolved.insert(lib_path.clone()); + result.push(lib_path.clone()); + queue.push(lib_path); + } + } + } + } + + Ok(result) } #[allow(dead_code)] @@ -173,37 +167,39 @@ mod tests { } #[test] - fn test_parse_needed_dynamic_binary() { + fn test_parse_elf_info_dynamic_binary() { let Some(binary) = get_elf_binary() else { eprintln!("Skipping: No suitable ELF binary found"); return; }; - let result = parse_needed(&binary); + let result = parse_elf_info(&binary); assert!(result.is_ok(), "Should parse {}", binary.display()); - let libs = result.unwrap(); - if !libs.is_empty() { + let info = result.unwrap(); + if !info.needed.is_empty() { assert!( - libs.iter() + info.needed + .iter() .any(|l| l.contains("libc") || l.contains("musl")), - "Dynamic binary should link libc/musl: {libs:?}" + "Dynamic binary should link libc/musl: {:?}", + info.needed ); } } #[test] - fn test_parse_needed_nonexistent() { - let result = parse_needed(Path::new("/nonexistent/binary")); + fn test_parse_elf_info_nonexistent() { + let result = parse_elf_info(Path::new("/nonexistent/binary")); assert!(result.is_err(), "Should fail for nonexistent file"); } #[test] - fn test_parse_needed_not_elf() { + fn test_parse_elf_info_not_elf() { let test_files = ["/etc/passwd", "/proc/self/cmdline"]; for file in test_files { if Path::new(file).exists() { - let result = parse_needed(Path::new(file)); + let result = parse_elf_info(Path::new(file)); assert!(result.is_err(), "Should fail for non-ELF file: {file}"); return; } @@ -236,13 +232,13 @@ mod tests { } #[test] - fn test_parse_rpath_runpath() { + fn test_parse_elf_info_rpath_runpath() { let Some(binary) = get_elf_binary() else { eprintln!("Skipping: No suitable ELF binary found"); return; }; - let result = parse_rpath_runpath(&binary); + let result = parse_elf_info(&binary); assert!(result.is_ok(), "Should parse RPATH/RUNPATH"); } diff --git a/crates/evalbox/src/python/mod.rs b/crates/evalbox/src/python/mod.rs index 88ee005..8c61201 100644 --- a/crates/evalbox/src/python/mod.rs +++ b/crates/evalbox/src/python/mod.rs @@ -220,13 +220,8 @@ print(json.dumps(result)) runtime.mounts = mounts; - match resolve_shared_libs(binary) { - Ok(libs) => { - runtime.shared_libs = libs; - } - Err(e) => { - eprintln!("Warning: failed to resolve shared libs for Python: {e}"); - } + if let Ok(libs) = resolve_shared_libs(binary) { + runtime.shared_libs = libs; } Ok(runtime) diff --git a/crates/evalbox/src/shell/builder.rs b/crates/evalbox/src/shell/builder.rs index dd01e6f..6444a8e 100644 --- a/crates/evalbox/src/shell/builder.rs +++ b/crates/evalbox/src/shell/builder.rs @@ -12,6 +12,7 @@ use crate::output::Output; /// /// Created by [`shell::run()`](super::run). Configure with method chaining, /// then execute with `.exec()`. +#[must_use] #[derive(Debug, Clone)] pub struct ShellBuilder { script: String, diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..0bf7a56 --- /dev/null +++ b/deny.toml @@ -0,0 +1,34 @@ +[graph] +targets = [{ triple = "x86_64-unknown-linux-gnu" }] +all-features = true + +[advisories] +ignore = [] + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Unicode-DFS-2016", +] +confidence-threshold = 0.8 + +[[licenses.clarify]] +name = "ring" +expression = "MIT AND ISC AND OpenSSL" +license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }] + +[bans] +multiple-versions = "warn" +wildcards = "deny" +highlight = "all" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/docs/arm64-support-analysis.md b/docs/arm64-support-analysis.md new file mode 100644 index 0000000..e31844b --- /dev/null +++ b/docs/arm64-support-analysis.md @@ -0,0 +1,423 @@ +# ARM64 (aarch64) Support Analysis + +## Status Atual + +O evalbox e **x86_64-only**. O filtro seccomp-BPF **mata o processo** se detectar +outra arquitetura. Este documento mapeia todas as mudancas necessarias e analisa +o tradeoff de versao minima do kernel. + +--- + +## 1. Mudancas Necessarias + +### 1.1 Seccomp BPF — Constantes de Arquitetura + +**Arquivo:** `crates/evalbox-sys/src/seccomp.rs:90-96` + +```rust +// HOJE (hardcoded x86_64) +const AUDIT_ARCH_X86_64: u32 = 0xc000003e; +const OFFSET_SYSCALL_NR: u32 = 0; +const OFFSET_ARCH: u32 = 4; +const OFFSET_ARGS_0: u32 = 16; +const OFFSET_ARGS_1: u32 = 24; +``` + +O `seccomp_data` struct tem layout identico em ambas as arquiteturas (definido em +`linux/seccomp.h`), entao os offsets sao os mesmos. So a constante AUDIT_ARCH muda: + +```rust +#[cfg(target_arch = "x86_64")] +const AUDIT_ARCH: u32 = 0xc000003e; // AUDIT_ARCH_X86_64 + +#[cfg(target_arch = "aarch64")] +const AUDIT_ARCH: u32 = 0xc00000b7; // AUDIT_ARCH_AARCH64 +``` + +**Impacto:** Baixo — troca de 1 constante. + +--- + +### 1.2 Seccomp Notify — ioctl Numbers + +**Arquivo:** `crates/evalbox-sys/src/seccomp_notify.rs:50-56` + +Os ioctl numbers codificam direction + size + type + nr. No aarch64 o encoding e +o mesmo (ambos usam o "new-style" ioctl encoding), entao os valores sao identicos: + +``` +SECCOMP_IOCTL_NOTIF_RECV = 0xc0502100 // mesmo em aarch64 +SECCOMP_IOCTL_NOTIF_SEND = 0xc0182101 // mesmo em aarch64 +SECCOMP_IOCTL_NOTIF_ID_VALID = 0x40082102 // mesmo em aarch64 +SECCOMP_IOCTL_NOTIF_ADDFD = 0x40182103 // mesmo em aarch64 +``` + +**Impacto:** Zero — seccomp ioctl encoding e consistente entre x86_64 e aarch64. + +--- + +### 1.3 Syscall Whitelist — Syscalls que NAO existem no aarch64 + +**Arquivo:** `crates/evalbox-sys/src/seccomp.rs:186-378` (`DEFAULT_WHITELIST`) + +O aarch64 usa uma tabela de syscalls limpa — muitas syscalls legacy do x86_64 foram +removidas. Tentativas de usar `libc::SYS_open` no aarch64 nao compilam. + +#### Syscalls x86_64-only (nao existem no aarch64): + +| Syscall x86_64 | Equivalente aarch64 | Nota | +|---|---|---| +| `SYS_open` | `SYS_openat` | Ja na whitelist | +| `SYS_creat` | `SYS_openat` com flags | Ja na whitelist | +| `SYS_stat` | `SYS_newfstatat` | Ja na whitelist | +| `SYS_lstat` | `SYS_newfstatat` | Ja na whitelist | +| `SYS_fstat` | `SYS_newfstatat` | -- | +| `SYS_access` | `SYS_faccessat` | Ja na whitelist | +| `SYS_dup2` | `SYS_dup3` | Ja na whitelist | +| `SYS_pipe` | `SYS_pipe2` | Ja na whitelist | +| `SYS_fork` | `SYS_clone` | Tratado separado | +| `SYS_vfork` | `SYS_clone` | Tratado separado | +| `SYS_select` | `SYS_pselect6` | Ja na whitelist | +| `SYS_poll` | `SYS_ppoll` | Ja na whitelist | +| `SYS_readlink` | `SYS_readlinkat` | Ja na whitelist | +| `SYS_unlink` | `SYS_unlinkat` | Ja na whitelist | +| `SYS_rename` | `SYS_renameat`/`renameat2` | Ja na whitelist | +| `SYS_mkdir` | `SYS_mkdirat` | Ja na whitelist | +| `SYS_rmdir` | `SYS_unlinkat(AT_REMOVEDIR)` | -- | +| `SYS_symlink` | `SYS_symlinkat` | Ja na whitelist | +| `SYS_link` | `SYS_linkat` | Ja na whitelist | +| `SYS_chmod` | `SYS_fchmodat` | Ja na whitelist | +| `SYS_chown` | `SYS_fchownat` | Ja na whitelist | +| `SYS_lchown` | `SYS_fchownat` | Ja na whitelist | +| `SYS_epoll_create` | `SYS_epoll_create1` | Ja na whitelist | +| `SYS_epoll_wait` | `SYS_epoll_pwait` | Ja na whitelist | +| `SYS_eventfd` | `SYS_eventfd2` | Ja na whitelist | +| `SYS_signalfd` | `SYS_signalfd4` | Ja na whitelist | +| `SYS_getdents` | `SYS_getdents64` | Ja na whitelist | +| `SYS_getpgrp` | `SYS_getpgid(0)` | -- | +| `SYS_arch_prctl` | N/A | x86_64-only, remover | +| `SYS_fadvise64` | `SYS_fadvise64` | Existe mas como `fadvise64_64` | + +**~28 syscalls** precisam de `#[cfg(target_arch = "x86_64")]`. + +Abordagem recomendada — whitelist separada por arch: + +```rust +// Syscalls comuns (ambas as arquiteturas) +const COMMON_WHITELIST: &[i64] = &[ + libc::SYS_read, + libc::SYS_write, + libc::SYS_close, + libc::SYS_openat, + // ... syscalls que existem em ambas +]; + +#[cfg(target_arch = "x86_64")] +const ARCH_WHITELIST: &[i64] = &[ + libc::SYS_open, + libc::SYS_stat, + libc::SYS_arch_prctl, + // ... legacy syscalls +]; + +#[cfg(target_arch = "aarch64")] +const ARCH_WHITELIST: &[i64] = &[ + // aarch64 nao precisa de extras — glibc usa os *at() variants +]; +``` + +**Impacto:** Medio — refatorar whitelist em common + arch-specific. + +--- + +### 1.4 NOTIFY_FS_SYSCALLS — Syscalls Interceptadas + +**Arquivo:** `crates/evalbox-sys/src/seccomp.rs:646-659` + +```rust +pub const NOTIFY_FS_SYSCALLS: &[i64] = &[ + libc::SYS_openat, + libc::SYS_open, // NAO EXISTE no aarch64 + libc::SYS_creat, // NAO EXISTE no aarch64 + libc::SYS_access, // NAO EXISTE no aarch64 + libc::SYS_faccessat, + libc::SYS_faccessat2, + libc::SYS_stat, // NAO EXISTE no aarch64 + libc::SYS_lstat, // NAO EXISTE no aarch64 + libc::SYS_newfstatat, + libc::SYS_statx, + libc::SYS_readlink, // NAO EXISTE no aarch64 + libc::SYS_readlinkat, +]; +``` + +No aarch64 essas syscalls nao existem, entao nao precisa interceptar — glibc no +aarch64 sempre usa `openat`, `newfstatat`, etc. + +**Impacto:** Baixo — `cfg` gates ou construcao dinamica da lista. + +--- + +### 1.5 Ioctl Constants (TIOCSTI, TIOCSETD, TIOCLINUX) + +**Arquivo:** `crates/evalbox-sys/src/seccomp.rs:123-127` + +```rust +const TIOCSTI: u32 = 0x5412; +const TIOCSETD: u32 = 0x5423; +const TIOCLINUX: u32 = 0x541C; +``` + +No aarch64 esses valores sao os mesmos (TTY ioctls usam encoding legacy consistente). + +**Impacto:** Zero. + +--- + +### 1.6 Landlock Syscall Numbers + +**Arquivo:** `crates/evalbox-sys/src/landlock.rs:46-48` + +```rust +const SYS_LANDLOCK_CREATE_RULESET: i64 = 444; +const SYS_LANDLOCK_ADD_RULE: i64 = 445; +const SYS_LANDLOCK_RESTRICT_SELF: i64 = 446; +``` + +Landlock foi adicionado no kernel 5.13, **apos a unificacao de syscall numbers** +para novas syscalls. Os numeros 444/445/446 sao os mesmos em x86_64 e aarch64. + +**Impacto:** Zero. + +--- + +### 1.7 Clone Flags e Socket Constants + +Os valores de `CLONE_NEW*`, `AF_NETLINK`, `SOCK_RAW` sao identicos entre +arquiteturas (definidos em headers genericos do kernel). + +**Impacto:** Zero. + +--- + +### 1.8 Lockdown e Paths do Sistema + +**Arquivo:** `crates/evalbox-sandbox/src/isolation/lockdown.rs:147-148` + +```rust +for path in ["/usr", "/bin", "/lib", "/lib64", "/etc"] { +``` + +No aarch64 nao existe `/lib64` (usa `/lib/aarch64-linux-gnu/` ou so `/lib/`). +O codigo ja trata path inexistente silenciosamente (`add_path_rule` ignora erros), +entao funciona mas `/lib64` nunca vai matchear. + +**Impacto:** Zero funcional (talvez adicionar `/lib/aarch64-linux-gnu` para clareza). + +--- + +### 1.9 fork/vfork no aarch64 + +No aarch64, `fork()` e `vfork()` nao existem como syscalls diretas — glibc implementa +via `clone()`. Isso significa que o filtro de `clone` com flag checking se torna o +unico ponto de controle. `SYS_fork` e `SYS_vfork` podem ser removidos da whitelist +no aarch64 sem impacto. + +**Impacto:** Baixo — ja tratado pelo cfg da whitelist. + +--- + +## 2. Resumo de Impacto por Arquivo + +| Arquivo | Mudanca | Esforco | +|---|---|---| +| `evalbox-sys/src/seccomp.rs` | AUDIT_ARCH cfg + whitelist split | **Medio** | +| `evalbox-sys/src/seccomp_notify.rs` | Nenhuma | Zero | +| `evalbox-sys/src/landlock.rs` | Nenhuma | Zero | +| `evalbox-sys/src/check.rs` | Nenhuma | Zero | +| `evalbox-sandbox/src/isolation/lockdown.rs` | Opcional: path aarch64 | Trivial | +| `evalbox-sandbox/src/notify/supervisor.rs` | Ajustar syscall numbers no match | Baixo | +| `evalbox/src/python/elf.rs` | Verificar ELF arch check | Baixo | +| CI/testes | Adicionar aarch64 runner | Medio | + +**Total: o trabalho real e na whitelist de syscalls + CI.** + +--- + +## 3. Analise de Versao Minima do Kernel + +### 3.1 O que cada Landlock ABI da + +| ABI | Kernel | Feature | Sem ela, precisa de... | +|---|---|---|---| +| 1 | 5.13 | FS basico | Nada — minimo pra Landlock funcionar | +| 2 | 5.19 | REFER (cross-dir rename) | Sem protecao contra rename escape | +| 3 | 6.2 | TRUNCATE | Sem protecao contra truncate de arquivos | +| 4 | 6.7 | TCP network + IOCTL_DEV | Sem bloqueio de rede via Landlock | +| **5** | **6.12** | **SCOPE_SIGNAL + SCOPE_ABSTRACT_UNIX_SOCKET** | **PID namespace + IPC namespace (requer root ou user ns)** | + +### 3.2 O que SCOPE_SIGNAL e SCOPE_ABSTRACT_UNIX_SOCKET protegem + +**SCOPE_SIGNAL (ABI 5):** +- Sem isso, sandbox pode enviar `kill -9` para processos do host +- Alternativa: PID namespace (`CLONE_NEWPID`) — requer root ou user namespace + +**SCOPE_ABSTRACT_UNIX_SOCKET (ABI 5):** +- Sem isso, sandbox pode conectar em abstract unix sockets do host (D-Bus, systemd, etc.) +- Alternativa: IPC/network namespace — requer root ou user namespace + +### 3.3 Distribuicoes e Versoes de Kernel + +| Distro | Kernel | Landlock ABI | Status | +|---|---|---|---| +| Ubuntu 22.04 LTS | 5.15 (HWE: 6.5) | 1 (HWE: 3) | Fora | +| Ubuntu 24.04 LTS | 6.8 | 4 | **Fora com ABI 5, OK com ABI 4** | +| Debian 12 (bookworm) | 6.1 | 3 | Fora | +| Debian 13 (trixie) | 6.12+ | 5 | OK | +| Fedora 41 | 6.11 | 4 | **Fora com ABI 5, OK com ABI 4** | +| Fedora 42 | 6.13+ | 5 | OK | +| RHEL 9 | 5.14 | 1 | Fora | +| Amazon Linux 2023 | 6.1 | 3 | Fora | +| Arch Linux | rolling (6.12+) | 5 | OK | +| NixOS unstable | rolling (6.12+) | 5 | OK | +| Raspberry Pi OS | 6.1 (Debian 12) | 3 | **Fora** | +| Ubuntu ARM (24.04) | 6.8 | 4 | **Fora com ABI 5** | + +### 3.4 Opcoes + +#### Opcao A: Manter Kernel 6.12+ (ABI 5) — status quo + +**Pros:** +- Seguranca maxima sem root/namespaces +- Codigo mais simples (sem fallback paths) +- SCOPE_SIGNAL e SCOPE_ABSTRACT_UNIX_SOCKET garantidos + +**Contras:** +- Exclui Ubuntu 24.04 LTS (a LTS mais usada atualmente) +- Exclui Fedora 41 +- Exclui todo hardware ARM com Raspberry Pi OS +- Exclui RHEL/Amazon Linux completamente +- Adocao limitada a rolling releases + +**Publico:** Desenvolvedores em Arch/NixOS/Fedora 42+, CI com kernel custom. + +--- + +#### Opcao B: Dropar para Kernel 6.7+ (ABI 4) + +Perda: `SCOPE_SIGNAL` e `SCOPE_ABSTRACT_UNIX_SOCKET` + +**Mitigacao necessaria:** +- Fallback para user namespace (`CLONE_NEWPID` + `CLONE_NEWIPC`) quando ABI < 5 +- Ou aceitar que sem ABI 5 nao tem isolacao de sinais/IPC (risco real mas limitado) + +**Pros:** +- Ubuntu 24.04 LTS entra (o maior ganho) +- Fedora 41 entra +- Network blocking via Landlock funciona +- IOCTL_DEV funciona + +**Contras:** +- Sem SCOPE_SIGNAL/SCOPE_ABSTRACT_UNIX_SOCKET em ABI 4 +- Precisa de fallback path ou aceitar risco +- Dois code paths = mais complexidade + +**Publico:** Ubuntu 24.04+, Fedora 41+, ARM com Ubuntu. + +--- + +#### Opcao C: Dropar para Kernel 6.2+ (ABI 3) + +Perda adicional: Network blocking + IOCTL_DEV + +**Pros:** +- Debian 12 e Amazon Linux 2023 quase entram (6.1 → precisaria 6.2) +- Raspberry Pi OS com backport entraria + +**Contras:** +- Sem bloqueio de rede via Landlock (precisaria de seccomp socket filtering mais agressivo) +- Sem IOCTL_DEV +- Mais fallback paths + +**Publico:** Questionavel — os ganhos nao justificam a perda. + +--- + +#### Opcao D: Kernel minimo dinamico com feature tiers + +``` +Tier 1 (ABI 5, kernel 6.12+): Seguranca completa +Tier 2 (ABI 4, kernel 6.7+): Sem signal/IPC scoping (warning) +Tier 3 (ABI 3, kernel 6.2+): Sem network blocking (warning) +``` + +O codigo ja faz parcialmente isso em `lockdown.rs:88-95`: +```rust +let abi = match landlock::landlock_abi_version() { + Ok(v) => v, + Err(_) => return Ok(()), +}; +if abi < 5 { + eprintln!("warning: landlock ABI {abi} < 5, ..."); +} +``` + +**Pros:** +- Maximo de compatibilidade +- Usuarios com kernel novo tem seguranca total +- Usuarios com kernel antigo tem seguranca parcial (melhor que nada) +- Reflete como o proprio Landlock foi desenhado (backward-compatible by design) + +**Contras:** +- Complexidade de testar todos os tiers +- Risco de dar falsa sensacao de seguranca no Tier 3 +- Documentacao mais complexa + +--- + +## 4. Recomendacao + +### Para ARM64 Support + +O trabalho e viavel e concentrado. A maior parte e refatorar a whitelist de +syscalls com `cfg(target_arch)`. O resto (Landlock, ioctl encoding, structs) +e identico entre arquiteturas. + +### Para Versao Minima do Kernel + +**Recomendacao: Opcao B (dropar para 6.7+/ABI 4) com degradacao graceful para ABI 5.** + +Justificativa: +1. Ubuntu 24.04 LTS e o target mais importante — excluir ele limita adocao severamente +2. ABI 4 ja tem network blocking (feature critica pra sandbox) +3. SCOPE_SIGNAL/SCOPE_ABSTRACT_UNIX_SOCKET sao defesas adicionais, nao primarias — + o seccomp ja bloqueia `CLONE_NEW*`, e sinais sao raramente vetor de escape real +4. O codigo de Landlock ja suporta degradacao por ABI +5. O `check.rs` so precisa mudar `MIN_LANDLOCK_ABI` de 5 para 4 + +Mudanca minima: +```rust +// check.rs +const MIN_KERNEL_VERSION: (u32, u32, u32) = (6, 7, 0); // era (6, 12, 0) +const MIN_LANDLOCK_ABI: u32 = 4; // era 5 +``` + +O lockdown.rs ja trata ABI < 5 com warning. Opcionalmente, tentar +user namespace fallback quando ABI < 5 para manter signal/IPC isolation. + +--- + +## 5. Checklist de Implementacao + +- [ ] `seccomp.rs`: Extrair `AUDIT_ARCH` com cfg +- [ ] `seccomp.rs`: Split whitelist em COMMON + ARCH +- [ ] `seccomp.rs`: cfg gates em NOTIFY_FS_SYSCALLS +- [ ] `seccomp.rs`: Remover `SYS_arch_prctl` no aarch64 +- [ ] `check.rs`: Baixar MIN_KERNEL_VERSION para (6, 7, 0) +- [ ] `check.rs`: Baixar MIN_LANDLOCK_ABI para 4 +- [ ] `lockdown.rs`: (Opcional) adicionar `/lib/aarch64-linux-gnu` +- [ ] `notify/supervisor.rs`: Verificar match arms de syscalls arch-specific +- [ ] `python/elf.rs`: Verificar ELF machine type check +- [ ] CI: Adicionar aarch64 test matrix (QEMU ou runner ARM) +- [ ] Testes: Rodar test suite completa em aarch64 +- [ ] Docs: Documentar tiers de seguranca por ABI diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..f710402 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,787 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "evalbox" +version = "0.1.1" +dependencies = [ + "dashmap", + "evalbox-sandbox", + "goblin", + "memmap2", + "regex", + "serde", + "serde_json", + "tempfile", + "thiserror", + "walkdir", + "which", +] + +[[package]] +name = "evalbox-fuzz" +version = "0.0.0" +dependencies = [ + "evalbox", + "evalbox-sys", + "libfuzzer-sys", +] + +[[package]] +name = "evalbox-sandbox" +version = "0.1.1" +dependencies = [ + "cc", + "evalbox-sys", + "libc", + "mio", + "rustix", + "tempfile", + "thiserror", + "which", +] + +[[package]] +name = "evalbox-sys" +version = "0.1.1" +dependencies = [ + "libc", + "rustix", + "thiserror", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "goblin" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa0a64d21a7eb230583b4c5f4e23b7e4e57974f96620f42a7e75e08ae66d745" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..e265657 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "evalbox-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +evalbox = { path = "../crates/evalbox", features = ["python", "go", "shell"] } +evalbox-sys = { path = "../crates/evalbox-sys" } + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] + +[[bin]] +name = "fuzz_seccomp_filter" +path = "fuzz_targets/fuzz_seccomp_filter.rs" +doc = false diff --git a/fuzz/fuzz_targets/fuzz_seccomp_filter.rs b/fuzz/fuzz_targets/fuzz_seccomp_filter.rs new file mode 100644 index 0000000..701ccf7 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_seccomp_filter.rs @@ -0,0 +1,22 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + // Interpret fuzzer bytes as a slice of i64 syscall numbers + if data.len() < 8 || data.len() % 8 != 0 { + return; + } + + let syscalls: Vec = data + .chunks_exact(8) + .map(|chunk| i64::from_le_bytes(chunk.try_into().unwrap())) + .collect(); + + // build_whitelist_filter panics if len > 200, so cap it + if syscalls.len() > 200 { + return; + } + + let _filter = evalbox_sys::seccomp::build_whitelist_filter(&syscalls); +}); diff --git a/nix/devshell.nix b/nix/devshell.nix index 001172d..4ad8f5d 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -1,6 +1,6 @@ { ... }: { - perSystem = { pkgs, toolchainWithExtensions, ... }: { + perSystem = { pkgs, toolchainWithExtensions, nightlyToolchain, ... }: { devShells.default = pkgs.mkShell { name = "evalbox-dev"; buildInputs = with pkgs; [ @@ -9,9 +9,21 @@ gcc python3 go + cargo-deny ]; RUST_SRC_PATH = "${toolchainWithExtensions}/lib/rustlib/src/rust/library"; RUST_BACKTRACE = "1"; }; + + devShells.fuzz = pkgs.mkShell { + name = "evalbox-fuzz"; + buildInputs = with pkgs; [ + nightlyToolchain + pkg-config + gcc + cargo-fuzz + ]; + RUST_BACKTRACE = "1"; + }; }; } diff --git a/nix/toolchain.nix b/nix/toolchain.nix index 3b90771..2cd5df0 100644 --- a/nix/toolchain.nix +++ b/nix/toolchain.nix @@ -10,6 +10,9 @@ toolchainWithExtensions = toolchain.override { extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ]; }; + nightlyToolchain = pkgs.rust-bin.nightly.latest.default.override { + extensions = [ "rust-src" "llvm-tools" ]; + }; craneLib = (inputs.crane.mkLib pkgs).overrideToolchain toolchain; src = craneLib.cleanCargoSource ./..; crateInfo = craneLib.crateNameFromCargoToml { cargoToml = ./../Cargo.toml; }; @@ -21,7 +24,7 @@ cargoArtifacts = craneLib.buildDepsOnly commonArgs; in { _module.args = { - inherit pkgs craneLib toolchainWithExtensions src commonArgs cargoArtifacts; + inherit pkgs craneLib toolchainWithExtensions nightlyToolchain src commonArgs cargoArtifacts; }; }; }