From b6978522d972f479c37606ccc82ac72c2c641563 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Mon, 19 Oct 2026 11:22:17 -0700 Subject: [PATCH 1/4] [Bubblewrap] Restrict socket address families and io_uring with an always-on seccomp filter Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e313a8c-149b-4c27-b3f7-06592ff192cd --- docs/backends/bwrap/bubblewrap-backend.md | 57 +- .../bubblewrap/common/bwrap_runner.rs | 17 +- .../bubblewrap/common/bwrap_version.rs | 29 + .../src/backends/bubblewrap/common/mod.rs | 2 + .../bubblewrap/common/proxy_network.rs | 2 +- .../bubblewrap/common/seccomp_filter.rs | 831 ++++++++++++++++++ ...e_tests_e2e_bubblewrap_characterization.rs | 202 +++++ 7 files changed, 1124 insertions(+), 16 deletions(-) create mode 100644 src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs diff --git a/docs/backends/bwrap/bubblewrap-backend.md b/docs/backends/bwrap/bubblewrap-backend.md index 9da4e21d07..f0122d384e 100644 --- a/docs/backends/bwrap/bubblewrap-backend.md +++ b/docs/backends/bwrap/bubblewrap-backend.md @@ -24,6 +24,10 @@ requiring root privileges or a container runtime. ## Prerequisites - **Linux** host with kernel 3.8+ (user namespace support) +- **x86-64 or AArch64** host. The filter also preserves x32 and i386 + compatibility on x86-64, and AArch32 compatibility on AArch64. Platform + detection reports Bubblewrap as unavailable on architectures for which MXC + cannot generate the required seccomp program. - **Bubblewrap** installed and on PATH: ```bash # Debian/Ubuntu @@ -37,17 +41,19 @@ requiring root privileges or a container runtime. ``` The deny-by-default baseline (see [How It Works](#how-it-works)) emits its read-only mounts via `--ro-bind-try` (bwrap 0.3.1+) and the sandbox - environment is built with `--clearenv` (bwrap 0.5.0+), so **bwrap 0.5.0 or - newer** is required. Platform detection probes `bwrap --version` and reports - the backend as unavailable — with the detected version — when the host is - below that floor. The probe has a 5-second deadline and retains at most 64 KB - from each output stream. On timeout, its process group is terminated with - `SIGKILL` so wrappers and descendants cannot keep the probe alive. Successful - Rust-executor advisory results are cached for the process lifetime; Rust - failures are not cached, and execution validation probes again before launch - so a changed PATH target cannot reuse an advisory result. The Node SDK caches - the complete `getPlatformSupport()` result, including failures, for the module - lifetime; restart the Node process after remediating the host. + environment is built with `--clearenv` (bwrap 0.5.0+). The address-family + filter uses `--seccomp FD`, which predates that release, so **bwrap 0.5.0 or + newer** remains the requirement. Platform detection probes `bwrap --version` + and reports the backend as unavailable — with the detected version — when + the host is below that floor. The probe has a 5-second deadline and retains + at most 64 KB from each output stream. On timeout, its process group is + terminated with `SIGKILL` so wrappers and descendants cannot keep the probe + alive. Successful Rust-executor advisory results are cached for the process + lifetime; Rust failures are not cached, and execution validation probes again + before launch so a changed PATH target cannot reuse an advisory result. The + Node SDK caches the complete `getPlatformSupport()` result, including + failures, for the module lifetime; restart the Node process after remediating + the host. - **Network-enabled private-namespace modes (v0.9+):** `slirp4netns` installed and on PATH, plus iproute2 `ip`, util-linux `unshare` (with `--map-current-user` and `--keep-caps`), `nsenter`, `iptables`, `ip6tables`, `iptables-restore`, and @@ -141,12 +147,37 @@ Bubblewrap creates a namespace-isolated process by: invisible inside the sandbox. 3. Layering filesystem policy overrides (read-write, read-only, denied paths) 4. Setting up minimal `/dev`, `/proc`, and `/tmp` -5. Clearing the environment and applying only requested variables -6. Executing the command via `sh -c` +5. Installing a seccomp filter that permits `socket()` and `socketpair()` only + for Unix-domain, IPv4, IPv6, and netlink address families, and disables + io_uring +6. Clearing the environment and applying only requested variables +7. Executing the command via `sh -c` The sandboxed process runs as a child of `bwrap` and dies automatically when execution completes — no container lifecycle management required. +The address-family filter is always active. Attempts to create VSOCK, packet, +or other unlisted socket families fail with `EPERM`. Because io_uring can +create sockets from submission-queue entries that seccomp cannot inspect, the +filter also makes `io_uring_setup`, `io_uring_enter`, and `io_uring_register` +fail with `ENOSYS`; runtimes that probe for io_uring fall back to their +non-io_uring paths. Other system calls are not restricted by this filter. +Filter construction, writing, or descriptor inheritance failures abort the +launch instead of running without the filter. + +On x86-64 hosts, native and x32 workloads retain their normal syscall ABI, and +i386 workloads using the direct `socket` and `socketpair` syscalls receive the +same family filtering. The legacy i386 `socketcall` interface passes the socket +arguments through a pointer that seccomp cannot inspect, so its socket-creation +and socket-pair subcalls fail with `EPERM`; its other subcalls remain available. +On AArch64 hosts, native and AArch32 direct socket syscalls receive the same +family filtering. + +MXC supplies one generated program through the legacy `--seccomp FD` option. +Bubblewrap rejects combining that option with `--add-seccomp-fd`, so any future +Bubblewrap syscall policy must be composed into this generated program rather +than passed as a second filter descriptor. + ### Terminal isolation CLI execution and SDK execution with pipes do not share the caller's diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs index f7b284ae80..22a9ada87c 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs @@ -15,7 +15,7 @@ use std::collections::HashSet; use std::fmt::Write as FmtWrite; -use std::os::fd::AsFd; +use std::os::fd::{AsFd, AsRawFd}; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; @@ -40,7 +40,7 @@ use crate::bwrap_common::{ bwrap_command::{self, ResolvedNetworkMode}, bwrap_version, network_rules, provider_monitor::ProviderMonitor, - proxy_network, + proxy_network, seccomp_filter, }; const DNS_PORT: u16 = 53; @@ -534,6 +534,17 @@ impl BubblewrapScriptRunner { args.insert(0, "--new-session".into()); } + let seccomp_filter = match seccomp_filter::create_file() { + Ok(file) => file, + Err(error) => { + stop_proxy_network(&mut proxy_network, logger); + return Err(ScriptResponse::error(&error)); + } + }; + if let Err(error) = seccomp_filter::add_bwrap_args(&mut args, seccomp_filter.as_raw_fd()) { + stop_proxy_network(&mut proxy_network, logger); + return Err(ScriptResponse::error(&error)); + } let mut network_startup = match proxy_network.as_ref() { Some(network) => match network.configure_bwrap(&mut args, logger) { Ok(startup) => Some(startup), @@ -606,6 +617,7 @@ impl BubblewrapScriptRunner { if let Some(startup) = network_startup.as_ref() { startup.prepare_command(&mut command); } + proxy_network::inherit_descriptors(&mut command, vec![seccomp_filter.as_raw_fd()]); let mut child = match command.spawn() { Ok(process) => process, @@ -2121,6 +2133,7 @@ mod tests { fn validate_surfaces_every_environment_probe_failure() { let request = base_request(); let failures = [ + bwrap_version::BwrapUnavailable::UnsupportedArchitecture("riscv64".to_string()), bwrap_version::BwrapUnavailable::NotFound, bwrap_version::BwrapUnavailable::ProbeFailed { status: Some(126), diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_version.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_version.rs index b4fda627b8..6f55351a01 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_version.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_version.rs @@ -32,6 +32,7 @@ use crate::bwrap_common::probe_exec::{self, CapturedOutput}; /// | Flag | First `bwrap` release | /// |--------------|-----------------------| /// | `--bind`, `--ro-bind`, `--dev`, `--proc`, `--tmpfs`, `--symlink`, `--chdir`, `--setenv`, `--unshare-*` | 0.1.0 | +/// | `--seccomp` (address-family filter descriptor) | 0.2.1 | /// | `--ro-bind-try` (deny-by-default baseline mounts) | 0.3.1 | /// | `--die-with-parent` (descendants die with the sandbox) | 0.4.0 | /// | `--clearenv` (minimal sandbox environment) | **0.5.0** | @@ -153,6 +154,8 @@ impl fmt::Display for BwrapVersion { /// `validate` and the engine's platform probe) share one message source. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BwrapUnavailable { + /// This build cannot generate a seccomp program for the host architecture. + UnsupportedArchitecture(String), /// No `bwrap` executable was found on `PATH`. NotFound, /// The `bwrap --version` probe failed to start or did not complete @@ -176,6 +179,11 @@ pub enum BwrapUnavailable { impl fmt::Display for BwrapUnavailable { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::UnsupportedArchitecture(architecture) => write!( + f, + "Bubblewrap is unavailable on {architecture}: MXC cannot generate its required \ + seccomp address-family filter for this architecture." + ), Self::NotFound => write!( f, "Bubblewrap (bwrap) is not installed or not on PATH. \ @@ -225,6 +233,7 @@ impl std::error::Error for BwrapUnavailable {} /// Runs `bwrap --version` and validates the reported version against /// [`MIN_BWRAP_VERSION`]. Successful advisory probes are cached. pub fn probe_bwrap() -> Result { + ensure_filter_architecture()?; probe_bwrap_cached( &CACHED_BWRAP_VERSION, cached_probe_gate(), @@ -265,12 +274,25 @@ where /// approve a different executable after `PATH` or its contents change. #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub(crate) fn probe_bwrap_uncached() -> Result { + ensure_filter_architecture()?; probe_bwrap_uncached_until( Instant::now() + BWRAP_VERSION_TIMEOUT, BWRAP_VERSION_TIMEOUT, ) } +#[cfg(target_os = "linux")] +fn ensure_filter_architecture() -> Result<(), BwrapUnavailable> { + crate::bwrap_common::seccomp_filter::unsupported_architecture(std::env::consts::ARCH) + .map(BwrapUnavailable::UnsupportedArchitecture) + .map_or(Ok(()), Err) +} + +#[cfg(not(target_os = "linux"))] +fn ensure_filter_architecture() -> Result<(), BwrapUnavailable> { + Ok(()) +} + fn probe_bwrap_uncached_until( deadline: Instant, timeout: Duration, @@ -724,6 +746,13 @@ mod tests { } } + #[test] + fn unsupported_architecture_message_names_the_filter_requirement() { + let message = BwrapUnavailable::UnsupportedArchitecture("riscv64".to_string()).to_string(); + assert!(message.contains("riscv64")); + assert!(message.contains("seccomp address-family filter")); + } + #[test] fn probe_failure_message_preserves_status_and_stderr() { // A broken-but-present bwrap must not be reported as "not installed": diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/mod.rs b/src/mxc-sdk/src/backends/bubblewrap/common/mod.rs index 2d768a94e1..596dfe3614 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/mod.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/mod.rs @@ -32,3 +32,5 @@ pub(crate) mod provider_monitor; /// crate; every other item stays `pub(crate)`. #[cfg(target_os = "linux")] pub mod proxy_network; +#[cfg(any(target_os = "linux", test))] +pub(crate) mod seccomp_filter; diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs b/src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs index 54798332e7..72489cb3d2 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs @@ -2327,7 +2327,7 @@ fn wait_for_file( /// receives it in argv (`--info-fd N`) and parses it as an integer. A child /// that must *redirect* to the descriptor from a shell needs /// [`remap_descriptors`] instead. -fn inherit_descriptors(command: &mut Command, fds: Vec) { +pub(crate) fn inherit_descriptors(command: &mut Command, fds: Vec) { // SAFETY: `pre_exec` runs between fork and exec, where only // async-signal-safe work is permitted. `fcntl` is async-signal-safe and // this closure allocates nothing -- `fds` is captured by move and holds diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs b/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs new file mode 100644 index 0000000000..69b39ad2b8 --- /dev/null +++ b/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs @@ -0,0 +1,831 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Builds Bubblewrap's always-on address-family seccomp filter. + +#[cfg(target_os = "linux")] +use std::io::{Seek, SeekFrom, Write}; + +use crate::bwrap_common::bwrap_command::COMMAND_TAIL; + +const BPF_LD_W_ABS: u16 = 0x20; +#[cfg(any(target_arch = "x86_64", test))] +const BPF_ALU_AND_K: u16 = 0x54; +const BPF_JMP_JEQ_K: u16 = 0x15; +const BPF_RET_K: u16 = 0x06; + +const SECCOMP_RET_ERRNO: u32 = 0x0005_0000; +const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; +const EPERM: u32 = 1; +const ENOSYS: u32 = 38; + +const SECCOMP_DATA_NR_OFFSET: u32 = 0; +const SECCOMP_DATA_ARCH_OFFSET: u32 = 4; +const SECCOMP_DATA_ARG0_LOW_OFFSET: u32 = 16; +const SECCOMP_DATA_ARG0_HIGH_OFFSET: u32 = 20; + +#[cfg(any(target_arch = "x86_64", test))] +const AUDIT_ARCH_X86_64: u32 = 0xc000_003e; +#[cfg(any(target_arch = "x86_64", test))] +const AUDIT_ARCH_I386: u32 = 0x4000_0003; +#[cfg(any(target_arch = "aarch64", test))] +const AUDIT_ARCH_AARCH64: u32 = 0xc000_00b7; +#[cfg(any(target_arch = "aarch64", test))] +const AUDIT_ARCH_ARM: u32 = 0x4000_0028; +#[cfg(any(target_arch = "x86_64", test))] +const X32_SYSCALL_BIT: u32 = 0x4000_0000; + +#[cfg(any(target_arch = "x86_64", test))] +const X86_64_SOCKET: u32 = 41; +#[cfg(any(target_arch = "x86_64", test))] +const X86_64_SOCKETPAIR: u32 = 53; +#[cfg(any(target_arch = "x86_64", test))] +const I386_SOCKETCALL: u32 = 102; +#[cfg(any(target_arch = "x86_64", test))] +const I386_SOCKET: u32 = 359; +#[cfg(any(target_arch = "x86_64", test))] +const I386_SOCKETPAIR: u32 = 360; +#[cfg(any(target_arch = "x86_64", test))] +const SOCKETCALL_SOCKET: u32 = 1; +#[cfg(any(target_arch = "x86_64", test))] +const SOCKETCALL_SOCKETPAIR: u32 = 8; +#[cfg(any(target_arch = "aarch64", test))] +const AARCH64_SOCKET: u32 = 198; +#[cfg(any(target_arch = "aarch64", test))] +const AARCH64_SOCKETPAIR: u32 = 199; +#[cfg(any(target_arch = "aarch64", test))] +const ARM_SOCKET: u32 = 281; +#[cfg(any(target_arch = "aarch64", test))] +const ARM_SOCKETPAIR: u32 = 288; + +const IO_URING_SETUP: u32 = 425; +const IO_URING_ENTER: u32 = 426; +const IO_URING_REGISTER: u32 = 427; +const IO_URING_SYSCALLS: [u32; 3] = [IO_URING_SETUP, IO_URING_ENTER, IO_URING_REGISTER]; + +const AF_UNIX: u32 = 1; +const AF_INET: u32 = 2; +const AF_INET6: u32 = 10; +const AF_NETLINK: u32 = 16; +const ALLOWED_FAMILIES: [u32; 4] = [AF_UNIX, AF_INET, AF_INET6, AF_NETLINK]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Instruction { + code: u16, + jump_if_true: u8, + jump_if_false: u8, + value: u32, +} + +impl Instruction { + const fn statement(code: u16, value: u32) -> Self { + Self { + code, + jump_if_true: 0, + jump_if_false: 0, + value, + } + } + + const fn jump(code: u16, value: u32) -> Self { + Self { + code, + jump_if_true: 0, + jump_if_false: 0, + value, + } + } + + fn encode(self, output: &mut Vec) { + output.extend_from_slice(&self.code.to_ne_bytes()); + output.push(self.jump_if_true); + output.push(self.jump_if_false); + output.extend_from_slice(&self.value.to_ne_bytes()); + } +} + +#[derive(Clone, Copy)] +enum NativeArchitecture { + #[cfg(any(target_arch = "x86_64", test))] + X86_64, + #[cfg(any(target_arch = "aarch64", test))] + Aarch64, +} + +#[cfg(target_os = "linux")] +pub(crate) fn create_file() -> Result { + let program = native_program()?; + let mut bytes = Vec::with_capacity(program.len() * 8); + for instruction in program { + instruction.encode(&mut bytes); + } + + let mut file = tempfile::tempfile() + .map_err(|error| format!("Bubblewrap: failed to create seccomp filter file: {error}"))?; + file.write_all(&bytes) + .map_err(|error| format!("Bubblewrap: failed to write seccomp filter: {error}"))?; + file.seek(SeekFrom::Start(0)) + .map_err(|error| format!("Bubblewrap: failed to rewind seccomp filter: {error}"))?; + Ok(file) +} + +pub(crate) fn add_bwrap_args(args: &mut Vec, fd: i32) -> Result<(), String> { + let command_index = args + .len() + .checked_sub(COMMAND_TAIL.len() + 1) + .filter(|&index| args[index..index + COMMAND_TAIL.len()] == COMMAND_TAIL) + .ok_or_else(|| { + "Bubblewrap: cannot install the seccomp filter because the command tail is malformed" + .to_string() + })?; + reject_conflicting_options(&args[..command_index])?; + args.splice( + command_index..command_index, + ["--seccomp".to_string(), fd.to_string()], + ); + Ok(()) +} + +fn reject_conflicting_options(args: &[String]) -> Result<(), String> { + let mut index = 0; + while index < args.len() { + let option = args[index].as_str(); + if matches!(option, "--seccomp" | "--add-seccomp-fd") { + return Err( + "Bubblewrap: cannot combine the address-family filter with another seccomp option" + .to_string(), + ); + } + let operands = match option { + "--unshare-user" | "--unshare-pid" | "--unshare-ipc" | "--unshare-uts" + | "--die-with-parent" | "--unshare-net" | "--clearenv" | "--new-session" => 0, + "--dev" | "--proc" | "--tmpfs" | "--chdir" | "--userns" | "--info-fd" + | "--block-fd" => 1, + "--ro-bind-try" | "--symlink" | "--bind" | "--ro-bind" | "--setenv" => 2, + _ => { + return Err(format!( + "Bubblewrap: cannot install the seccomp filter after unknown option {option}" + )); + } + }; + index = index + .checked_add(operands + 1) + .filter(|next| *next <= args.len()) + .ok_or_else(|| { + format!("Bubblewrap: option {option} is missing one or more operands") + })?; + } + Ok(()) +} + +pub(crate) fn unsupported_architecture(architecture: &str) -> Option { + (!matches!(architecture, "x86_64" | "aarch64")).then(|| architecture.to_string()) +} + +#[cfg(target_os = "linux")] +fn native_program() -> Result, String> { + if let Some(architecture) = unsupported_architecture(std::env::consts::ARCH) { + return Err(format!( + "Bubblewrap: seccomp address-family filtering is unsupported on {architecture}" + )); + } + + #[cfg(target_arch = "x86_64")] + { + Ok(build_program(NativeArchitecture::X86_64)) + } + + #[cfg(target_arch = "aarch64")] + { + Ok(build_program(NativeArchitecture::Aarch64)) + } + + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + { + unreachable!("unsupported architectures return before program construction") + } +} + +fn build_program(architecture: NativeArchitecture) -> Vec { + match architecture { + #[cfg(any(target_arch = "x86_64", test))] + NativeArchitecture::X86_64 => build_x86_program(), + #[cfg(any(target_arch = "aarch64", test))] + NativeArchitecture::Aarch64 => build_aarch64_program(), + } +} + +#[cfg(any(target_arch = "x86_64", test))] +fn build_x86_program() -> Vec { + let mut program = vec![Instruction::statement( + BPF_LD_W_ABS, + SECCOMP_DATA_ARCH_OFFSET, + )]; + let x86_64_arch_check = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, AUDIT_ARCH_X86_64)); + let i386_arch_check = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, AUDIT_ARCH_I386)); + program.push(Instruction::statement(BPF_RET_K, errno(ENOSYS))); + + let x86_64_start = program.len(); + set_true_target(&mut program, x86_64_arch_check, x86_64_start); + program.extend([ + Instruction::statement(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET), + Instruction::statement(BPF_ALU_AND_K, !X32_SYSCALL_BIT), + ]); + let x86_64_socket = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, X86_64_SOCKET)); + let x86_64_socketpair = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, X86_64_SOCKETPAIR)); + let mut io_uring_checks = push_io_uring_checks(&mut program); + program.push(Instruction::statement(BPF_RET_K, SECCOMP_RET_ALLOW)); + + let i386_start = program.len(); + set_true_target(&mut program, i386_arch_check, i386_start); + program.push(Instruction::statement(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET)); + let i386_socket = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, I386_SOCKET)); + let i386_socketpair = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, I386_SOCKETPAIR)); + let i386_socketcall = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, I386_SOCKETCALL)); + io_uring_checks.extend(push_io_uring_checks(&mut program)); + program.push(Instruction::statement(BPF_RET_K, SECCOMP_RET_ALLOW)); + + let socketcall_check = program.len(); + set_true_target(&mut program, i386_socketcall, socketcall_check); + + // i386 socketcall hides the family behind a pointer, so creation subcalls + // must be denied as a unit. + program.push(Instruction::statement( + BPF_LD_W_ABS, + SECCOMP_DATA_ARG0_LOW_OFFSET, + )); + program.push(Instruction::jump(BPF_JMP_JEQ_K, SOCKETCALL_SOCKET)); + let socketcall_socket = program.len() - 1; + program.push(Instruction::jump(BPF_JMP_JEQ_K, SOCKETCALL_SOCKETPAIR)); + let socketcall_socketpair = program.len() - 1; + program.push(Instruction::statement(BPF_RET_K, SECCOMP_RET_ALLOW)); + let deny_socketcall_creation = program.len(); + program.push(Instruction::statement(BPF_RET_K, errno(EPERM))); + set_true_target(&mut program, socketcall_socket, deny_socketcall_creation); + set_true_target( + &mut program, + socketcall_socketpair, + deny_socketcall_creation, + ); + + let family_check = append_family_filter(&mut program); + for socket_check in [ + x86_64_socket, + x86_64_socketpair, + i386_socket, + i386_socketpair, + ] { + set_true_target(&mut program, socket_check, family_check); + } + append_io_uring_denial(&mut program, &io_uring_checks); + + program +} + +#[cfg(any(target_arch = "aarch64", test))] +fn build_aarch64_program() -> Vec { + let mut program = vec![Instruction::statement( + BPF_LD_W_ABS, + SECCOMP_DATA_ARCH_OFFSET, + )]; + let aarch64_arch_check = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, AUDIT_ARCH_AARCH64)); + let arm_arch_check = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, AUDIT_ARCH_ARM)); + program.push(Instruction::statement(BPF_RET_K, errno(ENOSYS))); + + let aarch64_start = program.len(); + set_true_target(&mut program, aarch64_arch_check, aarch64_start); + program.push(Instruction::statement(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET)); + let aarch64_socket = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, AARCH64_SOCKET)); + let aarch64_socketpair = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, AARCH64_SOCKETPAIR)); + let mut io_uring_checks = push_io_uring_checks(&mut program); + program.push(Instruction::statement(BPF_RET_K, SECCOMP_RET_ALLOW)); + + let arm_start = program.len(); + set_true_target(&mut program, arm_arch_check, arm_start); + program.push(Instruction::statement(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET)); + let arm_socket = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, ARM_SOCKET)); + let arm_socketpair = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, ARM_SOCKETPAIR)); + io_uring_checks.extend(push_io_uring_checks(&mut program)); + program.push(Instruction::statement(BPF_RET_K, SECCOMP_RET_ALLOW)); + + let family_check = append_family_filter(&mut program); + for socket_check in [ + aarch64_socket, + aarch64_socketpair, + arm_socket, + arm_socketpair, + ] { + set_true_target(&mut program, socket_check, family_check); + } + append_io_uring_denial(&mut program, &io_uring_checks); + + program +} + +// io_uring creates sockets from shared-memory requests that seccomp cannot +// inspect, so the address-family policy holds only if rings cannot be used. +fn push_io_uring_checks(program: &mut Vec) -> Vec { + IO_URING_SYSCALLS + .into_iter() + .map(|number| { + let index = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, number)); + index + }) + .collect() +} + +fn append_io_uring_denial(program: &mut Vec, checks: &[usize]) { + let deny = program.len(); + program.push(Instruction::statement(BPF_RET_K, errno(ENOSYS))); + for &check in checks { + set_true_target(program, check, deny); + } +} + +fn append_family_filter(program: &mut Vec) -> usize { + let family_check = program.len(); + program.push(Instruction::statement( + BPF_LD_W_ABS, + SECCOMP_DATA_ARG0_HIGH_OFFSET, + )); + program.push(Instruction::jump(BPF_JMP_JEQ_K, 0)); + program.push(Instruction::statement(BPF_RET_K, errno(EPERM))); + let high_word_check = program.len() - 2; + program[high_word_check].jump_if_true = 1; + + program.push(Instruction::statement( + BPF_LD_W_ABS, + SECCOMP_DATA_ARG0_LOW_OFFSET, + )); + let family_jumps: Vec = ALLOWED_FAMILIES + .into_iter() + .map(|family| { + let index = program.len(); + program.push(Instruction::jump(BPF_JMP_JEQ_K, family)); + index + }) + .collect(); + program.push(Instruction::statement(BPF_RET_K, errno(EPERM))); + let allow = program.len(); + program.push(Instruction::statement(BPF_RET_K, SECCOMP_RET_ALLOW)); + + for family_jump in family_jumps { + set_true_target(program, family_jump, allow); + } + + family_check +} + +fn set_true_target(program: &mut [Instruction], source: usize, target: usize) { + program[source].jump_if_true = u8::try_from(target - source - 1) + .expect("seccomp filter jump must fit in an 8-bit cBPF offset"); +} + +const fn errno(value: u32) -> u32 { + SECCOMP_RET_ERRNO | value +} + +#[cfg(test)] +mod tests { + use super::*; + + const AF_PACKET: u64 = 17; + const AF_VSOCK: u64 = 40; + + #[derive(Clone, Copy)] + struct SeccompData { + number: u32, + architecture: u32, + args: [u64; 6], + } + + fn evaluate(program: &[Instruction], data: SeccompData) -> u32 { + let mut accumulator = 0_u32; + let mut pc = 0_usize; + + loop { + let instruction = program[pc]; + match instruction.code { + BPF_LD_W_ABS => { + accumulator = load_word(data, instruction.value); + pc += 1; + } + BPF_ALU_AND_K => { + accumulator &= instruction.value; + pc += 1; + } + BPF_JMP_JEQ_K => { + let jump = if accumulator == instruction.value { + instruction.jump_if_true + } else { + instruction.jump_if_false + }; + pc += usize::from(jump) + 1; + } + BPF_RET_K => return instruction.value, + code => panic!("unsupported cBPF instruction {code:#x}"), + } + } + } + + fn load_word(data: SeccompData, offset: u32) -> u32 { + match offset { + SECCOMP_DATA_NR_OFFSET => data.number, + SECCOMP_DATA_ARCH_OFFSET => data.architecture, + SECCOMP_DATA_ARG0_LOW_OFFSET => data.args[0] as u32, + SECCOMP_DATA_ARG0_HIGH_OFFSET => (data.args[0] >> 32) as u32, + _ => panic!("unsupported seccomp_data offset {offset}"), + } + } + + fn result(native: NativeArchitecture, audit: u32, number: u32, first_argument: u64) -> u32 { + evaluate( + &build_program(native), + SeccompData { + number, + architecture: audit, + args: [first_argument, 0, 0, 0, 0, 0], + }, + ) + } + + #[test] + fn allows_required_socket_families() { + for (native, audit, socket, socketpair) in [ + ( + NativeArchitecture::X86_64, + AUDIT_ARCH_X86_64, + X86_64_SOCKET, + X86_64_SOCKETPAIR, + ), + ( + NativeArchitecture::X86_64, + AUDIT_ARCH_X86_64, + X32_SYSCALL_BIT | X86_64_SOCKET, + X32_SYSCALL_BIT | X86_64_SOCKETPAIR, + ), + ( + NativeArchitecture::X86_64, + AUDIT_ARCH_I386, + I386_SOCKET, + I386_SOCKETPAIR, + ), + ( + NativeArchitecture::Aarch64, + AUDIT_ARCH_AARCH64, + AARCH64_SOCKET, + AARCH64_SOCKETPAIR, + ), + ( + NativeArchitecture::Aarch64, + AUDIT_ARCH_ARM, + ARM_SOCKET, + ARM_SOCKETPAIR, + ), + ] { + for number in [socket, socketpair] { + for family in ALLOWED_FAMILIES { + assert_eq!( + result(native, audit, number, u64::from(family)), + SECCOMP_RET_ALLOW, + "family {family} should be allowed for syscall {number} on arch {audit:#x}" + ); + } + } + } + } + + #[test] + fn denies_vsock_for_socketpair() { + for (native, audit, socketpair) in [ + ( + NativeArchitecture::X86_64, + AUDIT_ARCH_X86_64, + X86_64_SOCKETPAIR, + ), + ( + NativeArchitecture::X86_64, + AUDIT_ARCH_X86_64, + X32_SYSCALL_BIT | X86_64_SOCKETPAIR, + ), + (NativeArchitecture::X86_64, AUDIT_ARCH_I386, I386_SOCKETPAIR), + ( + NativeArchitecture::Aarch64, + AUDIT_ARCH_AARCH64, + AARCH64_SOCKETPAIR, + ), + (NativeArchitecture::Aarch64, AUDIT_ARCH_ARM, ARM_SOCKETPAIR), + ] { + assert_eq!(result(native, audit, socketpair, AF_VSOCK), errno(EPERM)); + } + } + + #[test] + fn denies_vsock_packet_and_unknown_families() { + for (native, audit, socket) in [ + (NativeArchitecture::X86_64, AUDIT_ARCH_X86_64, X86_64_SOCKET), + ( + NativeArchitecture::X86_64, + AUDIT_ARCH_X86_64, + X32_SYSCALL_BIT | X86_64_SOCKET, + ), + (NativeArchitecture::X86_64, AUDIT_ARCH_I386, I386_SOCKET), + ( + NativeArchitecture::Aarch64, + AUDIT_ARCH_AARCH64, + AARCH64_SOCKET, + ), + (NativeArchitecture::Aarch64, AUDIT_ARCH_ARM, ARM_SOCKET), + ] { + for family in [AF_VSOCK, AF_PACKET, 0xffff] { + assert_eq!( + result(native, audit, socket, family), + errno(EPERM), + "family {family} should be denied on arch {audit:#x}" + ); + } + } + } + + #[test] + fn allows_unfiltered_syscalls() { + for (native, audit, number) in [ + (NativeArchitecture::X86_64, AUDIT_ARCH_X86_64, 0), + ( + NativeArchitecture::X86_64, + AUDIT_ARCH_X86_64, + X32_SYSCALL_BIT, + ), + (NativeArchitecture::X86_64, AUDIT_ARCH_I386, 3), + (NativeArchitecture::Aarch64, AUDIT_ARCH_AARCH64, 63), + (NativeArchitecture::Aarch64, AUDIT_ARCH_ARM, 3), + ] { + assert_eq!(result(native, audit, number, AF_VSOCK), SECCOMP_RET_ALLOW); + } + } + + #[test] + fn rejects_foreign_architectures() { + for (native, foreign) in [ + (NativeArchitecture::X86_64, AUDIT_ARCH_AARCH64), + (NativeArchitecture::X86_64, AUDIT_ARCH_ARM), + (NativeArchitecture::Aarch64, AUDIT_ARCH_X86_64), + (NativeArchitecture::Aarch64, AUDIT_ARCH_I386), + ] { + assert_eq!(result(native, foreign, 0, 0), errno(ENOSYS)); + } + } + + #[test] + fn filters_x32_socket_syscalls_without_rejecting_the_abi() { + assert_eq!( + result( + NativeArchitecture::X86_64, + AUDIT_ARCH_X86_64, + X32_SYSCALL_BIT | X86_64_SOCKET, + AF_VSOCK, + ), + errno(EPERM) + ); + assert_eq!( + result( + NativeArchitecture::X86_64, + AUDIT_ARCH_X86_64, + X32_SYSCALL_BIT, + AF_VSOCK, + ), + SECCOMP_RET_ALLOW + ); + } + + #[test] + fn filters_i386_direct_socket_syscalls() { + assert_eq!( + result( + NativeArchitecture::X86_64, + AUDIT_ARCH_I386, + I386_SOCKET, + AF_VSOCK, + ), + errno(EPERM) + ); + assert_eq!( + result( + NativeArchitecture::X86_64, + AUDIT_ARCH_I386, + I386_SOCKET, + u64::from(AF_INET), + ), + SECCOMP_RET_ALLOW + ); + } + + #[test] + fn denies_io_uring_on_every_supported_abi() { + for (native, audit, bit) in [ + (NativeArchitecture::X86_64, AUDIT_ARCH_X86_64, 0), + ( + NativeArchitecture::X86_64, + AUDIT_ARCH_X86_64, + X32_SYSCALL_BIT, + ), + (NativeArchitecture::X86_64, AUDIT_ARCH_I386, 0), + (NativeArchitecture::Aarch64, AUDIT_ARCH_AARCH64, 0), + (NativeArchitecture::Aarch64, AUDIT_ARCH_ARM, 0), + ] { + for number in IO_URING_SYSCALLS { + assert_eq!( + result(native, audit, bit | number, 0), + errno(ENOSYS), + "io_uring syscall {number} should be denied on arch {audit:#x}" + ); + } + for neighbor in [IO_URING_SETUP - 1, IO_URING_REGISTER + 1] { + assert_eq!( + result(native, audit, bit | neighbor, 0), + SECCOMP_RET_ALLOW, + "syscall {neighbor} should stay allowed on arch {audit:#x}" + ); + } + } + } + + #[test] + fn denies_i386_socketcall_creation_subcalls() { + for subcall in [SOCKETCALL_SOCKET, SOCKETCALL_SOCKETPAIR] { + assert_eq!( + result( + NativeArchitecture::X86_64, + AUDIT_ARCH_I386, + I386_SOCKETCALL, + u64::from(subcall), + ), + errno(EPERM) + ); + } + assert_eq!( + result( + NativeArchitecture::X86_64, + AUDIT_ARCH_I386, + I386_SOCKETCALL, + 2, + ), + SECCOMP_RET_ALLOW + ); + } + + #[test] + fn rejects_nonzero_family_upper_word() { + let family = (1_u64 << 32) | u64::from(AF_UNIX); + for (native, audit, socket) in [ + (NativeArchitecture::X86_64, AUDIT_ARCH_X86_64, X86_64_SOCKET), + ( + NativeArchitecture::X86_64, + AUDIT_ARCH_X86_64, + X32_SYSCALL_BIT | X86_64_SOCKET, + ), + (NativeArchitecture::X86_64, AUDIT_ARCH_I386, I386_SOCKET), + ( + NativeArchitecture::Aarch64, + AUDIT_ARCH_AARCH64, + AARCH64_SOCKET, + ), + (NativeArchitecture::Aarch64, AUDIT_ARCH_ARM, ARM_SOCKET), + ] { + assert_eq!(result(native, audit, socket, family), errno(EPERM)); + } + } + + #[test] + fn encodes_packed_sock_filter_records() { + let program = build_program(NativeArchitecture::X86_64); + let mut bytes = Vec::new(); + for instruction in &program { + instruction.encode(&mut bytes); + } + assert_eq!(bytes.len(), program.len() * 8); + } + + #[test] + fn supports_only_architectures_with_generated_filters() { + assert_eq!(unsupported_architecture("x86_64"), None); + assert_eq!(unsupported_architecture("aarch64"), None); + assert_eq!( + unsupported_architecture("riscv64"), + Some("riscv64".to_string()) + ); + } + + #[test] + fn inserts_seccomp_before_the_workload_command() { + let mut args = vec![ + "--clearenv".to_string(), + "--".to_string(), + "sh".to_string(), + "-c".to_string(), + "echo hello".to_string(), + ]; + add_bwrap_args(&mut args, 17).expect("valid bwrap arguments"); + assert_eq!( + args, + [ + "--clearenv", + "--seccomp", + "17", + "--", + "sh", + "-c", + "echo hello" + ] + ); + } + + #[test] + fn rejects_malformed_bwrap_arguments() { + let mut args = vec!["--clearenv".to_string(), "echo hello".to_string()]; + assert!(add_bwrap_args(&mut args, 17).is_err()); + } + + #[test] + fn rejects_conflicting_seccomp_arguments() { + for option in ["--seccomp", "--add-seccomp-fd"] { + let mut args = vec![ + option.to_string(), + "23".to_string(), + "--".to_string(), + "sh".to_string(), + "-c".to_string(), + "echo hello".to_string(), + ]; + assert!(add_bwrap_args(&mut args, 17).is_err()); + } + } + + #[test] + fn ignores_seccomp_words_in_option_operands_and_the_script() { + let mut args = vec![ + "--setenv".to_string(), + "FIRST".to_string(), + "--seccomp".to_string(), + "--chdir".to_string(), + "--add-seccomp-fd".to_string(), + "--".to_string(), + "sh".to_string(), + "-c".to_string(), + "printf '%s' --seccomp".to_string(), + ]; + add_bwrap_args(&mut args, 17).expect("seccomp words in operands are not options"); + assert_eq!(&args[5..7], ["--seccomp", "17"]); + } + + #[test] + fn rejects_unknown_or_incomplete_generated_options() { + for mut args in [ + vec![ + "--future-option".to_string(), + "--".to_string(), + "sh".to_string(), + "-c".to_string(), + "echo hello".to_string(), + ], + vec![ + "--setenv".to_string(), + "NAME".to_string(), + "--".to_string(), + "sh".to_string(), + "-c".to_string(), + "echo hello".to_string(), + ], + ] { + assert!(add_bwrap_args(&mut args, 17).is_err()); + } + } + + #[test] + fn accepts_the_cli_new_session_option() { + let mut args = vec![ + "--new-session".to_string(), + "--clearenv".to_string(), + "--".to_string(), + "sh".to_string(), + "-c".to_string(), + "echo hello".to_string(), + ]; + add_bwrap_args(&mut args, 17).expect("--new-session takes no operand"); + assert_eq!(&args[2..4], ["--seccomp", "17"]); + } +} diff --git a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_bubblewrap_characterization.rs b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_bubblewrap_characterization.rs index 96c2cdcb73..e8376b4d71 100644 --- a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_bubblewrap_characterization.rs +++ b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_bubblewrap_characterization.rs @@ -21,6 +21,7 @@ mod wxc_e2e_tests; use serde_json::json; use std::fs; use std::path::PathBuf; +use std::process::Command; use std::time::{Duration, Instant}; use wxc_e2e_tests::{ has_bwrap, has_platform_exec, run_platform_config_value, @@ -28,6 +29,9 @@ use wxc_e2e_tests::{ }; const SCHEMA_VERSION: &str = "0.9.0-alpha"; +const AF_VSOCK: libc::c_int = 40; +const UNASSIGNED_FAMILY: libc::c_int = 255; +const IO_URING_PARAMS_SIZE: usize = 120; /// The schema that introduced the default environment block and start-directory /// normalization, so cases asserting either must name it explicitly. @@ -321,3 +325,201 @@ fn bubblewrap_timeout_is_enforced() { descendant; took {elapsed:?}" ); } + +#[test] +fn bubblewrap_allows_common_socket_families() { + if !ready() { + return; + } + if Command::new("python3").arg("--version").output().is_err() { + println!("SKIPPED: python3 is required for the Bubblewrap socket-family seccomp test"); + return; + } + + let command = r#"python3 -c 'import socket +for family in (socket.AF_UNIX, socket.AF_INET): + sock = socket.socket(family, socket.SOCK_STREAM) + sock.close() +print("ALLOWED_FAMILIES_OK")'"#; + let result = run_platform_config_value( + "bwrap allowed socket families", + &config("allowed-socket-families", command), + &[], + None, + ); + let output = result.combined_output(); + assert_eq!( + result.code, + Some(0), + "allowed socket family creation failed:\n{output}" + ); + assert!( + output.contains("ALLOWED_FAMILIES_OK"), + "allowed socket family probe did not finish:\n{output}" + ); +} + +#[test] +fn bubblewrap_denies_unlisted_families_with_eperm() { + if !ready() { + return; + } + if Command::new("python3").arg("--version").output().is_err() { + println!("SKIPPED: python3 is required for the Bubblewrap socket-family seccomp test"); + return; + } + + // SAFETY: The call passes constant Linux socket arguments and checks the + // returned descriptor. + let control = unsafe { libc::socket(UNASSIGNED_FAMILY, libc::SOCK_STREAM, 0) }; + let control_errno = std::io::Error::last_os_error() + .raw_os_error() + .unwrap_or_default(); + if control >= 0 { + // SAFETY: The descriptor was returned by the successful socket call above. + unsafe { libc::close(control) }; + } + assert!( + control < 0 && control_errno != libc::EPERM, + "unfiltered socket({UNASSIGNED_FAMILY}) must fail with something other than EPERM \ + so EPERM inside the sandbox proves the filter; got fd {control}, errno {control_errno}" + ); + + let command = format!( + r#"python3 -c 'import errno, socket, sys +results = [] +for name, create in (("SOCKET", socket.socket), ("SOCKETPAIR", socket.socketpair)): + try: + created = create({UNASSIGNED_FAMILY}, socket.SOCK_STREAM) + except OSError as error: + print(f"{{name}}_ERRNO={{error.errno}}") + results.append(error.errno == errno.EPERM) + else: + print(f"{{name}}_ALLOWED") + results.append(False) +sys.exit(0 if all(results) else 2)'"# + ); + let result = run_platform_config_value( + "bwrap unlisted family seccomp", + &config("unlisted-family-seccomp", &command), + &[], + None, + ); + let output = result.combined_output(); + assert_eq!( + result.code, + Some(0), + "unlisted family was not denied with EPERM:\n{output}" + ); + for call in ["SOCKET", "SOCKETPAIR"] { + assert!( + output.contains(&format!("{call}_ERRNO={}", libc::EPERM)), + "expected exact EPERM from {call}:\n{output}" + ); + } +} + +#[test] +fn bubblewrap_denies_vsock_with_eperm() { + if !ready() { + return; + } + if Command::new("python3").arg("--version").output().is_err() { + println!("SKIPPED: python3 is required for the Bubblewrap VSOCK seccomp test"); + return; + } + + // SAFETY: The call passes constant Linux socket arguments and checks the + // returned descriptor. + let control = unsafe { libc::socket(AF_VSOCK, libc::SOCK_STREAM, 0) }; + if control < 0 { + println!( + "SKIPPED: unfiltered AF_VSOCK control failed with errno {}; the host cannot prove the filter caused EPERM", + std::io::Error::last_os_error() + .raw_os_error() + .unwrap_or_default() + ); + return; + } + + // SAFETY: The descriptor was returned by the successful socket call above. + unsafe { libc::close(control) }; + + let command = r#"python3 -c 'import errno, socket, sys +try: + socket.socket(40, socket.SOCK_STREAM) +except OSError as error: + print(f"AF_VSOCK_ERRNO={error.errno}") + sys.exit(0 if error.errno == errno.EPERM else 2) +else: + print("AF_VSOCK_ALLOWED") + sys.exit(3)'"#; + let result = run_platform_config_value( + "bwrap vsock seccomp", + &config("vsock-seccomp", command), + &[], + None, + ); + let output = result.combined_output(); + assert_eq!(result.code, Some(0), "VSOCK filter failed:\n{output}"); + assert!( + output.contains("AF_VSOCK_ERRNO=1"), + "expected exact EPERM from AF_VSOCK:\n{output}" + ); +} + +#[test] +fn bubblewrap_denies_io_uring_with_enosys() { + if !ready() { + return; + } + if Command::new("python3").arg("--version").output().is_err() { + println!("SKIPPED: python3 is required for the Bubblewrap io_uring seccomp test"); + return; + } + + let mut params = [0_u8; IO_URING_PARAMS_SIZE]; + // SAFETY: `params` is a zeroed buffer the size of `struct io_uring_params`, + // and the returned descriptor is checked before use. + let control = + unsafe { libc::syscall(libc::SYS_io_uring_setup, 1, params.as_mut_ptr()) } as libc::c_int; + if control < 0 { + println!( + "SKIPPED: unfiltered io_uring_setup control failed with errno {}; the host cannot prove the filter caused ENOSYS", + std::io::Error::last_os_error() + .raw_os_error() + .unwrap_or_default() + ); + return; + } + + // SAFETY: The descriptor was returned by the successful io_uring_setup call above. + unsafe { libc::close(control) }; + + let command = format!( + r#"python3 -c 'import ctypes, errno, os, sys +libc = ctypes.CDLL(None, use_errno=True) +params = ctypes.create_string_buffer({IO_URING_PARAMS_SIZE}) +ring = libc.syscall({}, 1, params) +if ring >= 0: + os.close(ring) + print("IO_URING_ALLOWED") + sys.exit(3) +error = ctypes.get_errno() +print(f"IO_URING_ERRNO={{error}}") +sys.exit(0 if error == errno.ENOSYS else 2)'"#, + libc::SYS_io_uring_setup + ); + let result = run_platform_config_value( + "bwrap io_uring seccomp", + &config("io-uring-seccomp", &command), + &[], + None, + ); + let output = result.combined_output(); + assert_eq!(result.code, Some(0), "io_uring filter failed:\n{output}"); + assert!( + output.contains(&format!("IO_URING_ERRNO={}", libc::ENOSYS)), + "expected exact ENOSYS from io_uring_setup:\n{output}" + ); +} From 75f2f61b0c0833a4a2d153916842b41e2c9101cb Mon Sep 17 00:00:00 2001 From: Soham Das Date: Tue, 20 Oct 2026 13:25:56 -0700 Subject: [PATCH 2/4] Addressed PR comments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e313a8c-149b-4c27-b3f7-06592ff192cd --- .../bubblewrap/common/seccomp_filter.rs | 100 +++++++++++++++--- 1 file changed, 85 insertions(+), 15 deletions(-) diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs b/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs index 69b39ad2b8..493dea8561 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs @@ -3,8 +3,15 @@ //! Builds Bubblewrap's always-on address-family seccomp filter. +#[cfg(target_os = "linux")] +use std::fs::File; #[cfg(target_os = "linux")] use std::io::{Seek, SeekFrom, Write}; +#[cfg(target_os = "linux")] +use std::os::fd::{AsRawFd, FromRawFd}; + +#[cfg(target_os = "linux")] +use nix::fcntl::{fcntl, FcntlArg}; use crate::bwrap_common::bwrap_command::COMMAND_TAIL; @@ -22,7 +29,9 @@ const ENOSYS: u32 = 38; const SECCOMP_DATA_NR_OFFSET: u32 = 0; const SECCOMP_DATA_ARCH_OFFSET: u32 = 4; const SECCOMP_DATA_ARG0_LOW_OFFSET: u32 = 16; -const SECCOMP_DATA_ARG0_HIGH_OFFSET: u32 = 20; + +#[cfg(target_os = "linux")] +const FIRST_NON_STDIO_FD: i32 = 3; #[cfg(any(target_arch = "x86_64", test))] const AUDIT_ARCH_X86_64: u32 = 0xc000_003e; @@ -113,7 +122,7 @@ enum NativeArchitecture { } #[cfg(target_os = "linux")] -pub(crate) fn create_file() -> Result { +pub(crate) fn create_file() -> Result { let program = native_program()?; let mut bytes = Vec::with_capacity(program.len() * 8); for instruction in program { @@ -126,7 +135,21 @@ pub(crate) fn create_file() -> Result { .map_err(|error| format!("Bubblewrap: failed to write seccomp filter: {error}"))?; file.seek(SeekFrom::Start(0)) .map_err(|error| format!("Bubblewrap: failed to rewind seccomp filter: {error}"))?; - Ok(file) + above_stdio(file) +} + +// The child's stdio is mapped onto descriptors 0-2 before `pre_exec` runs, so +// a filter left on one of those numbers is replaced before bwrap reads it. +#[cfg(target_os = "linux")] +fn above_stdio(file: File) -> Result { + let fd = fcntl( + file.as_raw_fd(), + FcntlArg::F_DUPFD_CLOEXEC(FIRST_NON_STDIO_FD), + ) + .map_err(|error| format!("Bubblewrap: failed to move seccomp filter descriptor: {error}"))?; + + // SAFETY: `fcntl` returned a new descriptor that nothing else owns. + Ok(unsafe { File::from_raw_fd(fd) }) } pub(crate) fn add_bwrap_args(args: &mut Vec, fd: i32) -> Result<(), String> { @@ -358,15 +381,9 @@ fn append_io_uring_denial(program: &mut Vec, checks: &[usize]) { fn append_family_filter(program: &mut Vec) -> usize { let family_check = program.len(); - program.push(Instruction::statement( - BPF_LD_W_ABS, - SECCOMP_DATA_ARG0_HIGH_OFFSET, - )); - program.push(Instruction::jump(BPF_JMP_JEQ_K, 0)); - program.push(Instruction::statement(BPF_RET_K, errno(EPERM))); - let high_word_check = program.len() - 2; - program[high_word_check].jump_if_true = 1; + // The kernel reads the family as a 32-bit int and ignores the upper half + // of the argument register. program.push(Instruction::statement( BPF_LD_W_ABS, SECCOMP_DATA_ARG0_LOW_OFFSET, @@ -447,7 +464,6 @@ mod tests { SECCOMP_DATA_NR_OFFSET => data.number, SECCOMP_DATA_ARCH_OFFSET => data.architecture, SECCOMP_DATA_ARG0_LOW_OFFSET => data.args[0] as u32, - SECCOMP_DATA_ARG0_HIGH_OFFSET => (data.args[0] >> 32) as u32, _ => panic!("unsupported seccomp_data offset {offset}"), } } @@ -689,8 +705,7 @@ mod tests { } #[test] - fn rejects_nonzero_family_upper_word() { - let family = (1_u64 << 32) | u64::from(AF_UNIX); + fn ignores_the_family_upper_word() { for (native, audit, socket) in [ (NativeArchitecture::X86_64, AUDIT_ARCH_X86_64, X86_64_SOCKET), ( @@ -706,7 +721,62 @@ mod tests { ), (NativeArchitecture::Aarch64, AUDIT_ARCH_ARM, ARM_SOCKET), ] { - assert_eq!(result(native, audit, socket, family), errno(EPERM)); + for family in [u64::from(AF_UNIX), u64::from(AF_INET6), AF_VSOCK, AF_PACKET] { + let expected = result(native, audit, socket, family); + for upper in [1_u64, 0xffff_ffff] { + assert_eq!( + result(native, audit, socket, (upper << 32) | family), + expected, + "upper word {upper:#x} changed the verdict for family {family} on arch {audit:#x}" + ); + } + } + } + } + + #[cfg(target_os = "linux")] + #[test] + fn moves_the_filter_descriptor_above_stdio() { + const NO_LOW_DESCRIPTOR: i32 = 2; + + // SAFETY: The child makes only async-signal-safe calls before `_exit`, + // so it takes no lock another test thread held across the fork. + let child = unsafe { libc::fork() }; + assert!( + child >= 0, + "fork failed: {}", + std::io::Error::last_os_error() + ); + if child == 0 { + // SAFETY: The child owns its copy of descriptor 0. + let code = unsafe { + libc::close(0); + let low = libc::open(c"/dev/null".as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC); + if low == 0 { + match above_stdio(File::from_raw_fd(low)) { + Ok(moved) if moved.as_raw_fd() >= FIRST_NON_STDIO_FD => 0, + _ => 1, + } + } else { + NO_LOW_DESCRIPTOR + } + }; + + // SAFETY: The forked child exits here without returning into the + // test harness. + unsafe { libc::_exit(code) }; + } + + let mut status = 0; + + // SAFETY: `child` is this test's own forked process. + let waited = unsafe { libc::waitpid(child, &mut status, 0) }; + assert_eq!(waited, child, "waitpid failed"); + assert!(libc::WIFEXITED(status), "child did not exit: {status:#x}"); + match libc::WEXITSTATUS(status) { + 0 => {} + NO_LOW_DESCRIPTOR => panic!("the child could not place a file on descriptor 0"), + code => panic!("the filter stayed on a stdio descriptor (child exit {code})"), } } From 1c47af86c0d189c1eb7bed2ef0e68af22e367dc5 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Wed, 21 Oct 2026 12:39:12 -0700 Subject: [PATCH 3/4] Run seccomp tests in Linux CI, make Bubblewrap test skips strict, and avoid allocation in forked test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e313a8c-149b-4c27-b3f7-06592ff192cd --- .github/workflows/Build.Linux.Job.yml | 23 ++++++++++++++++ .../bubblewrap/common/seccomp_filter.rs | 8 +++--- src/mxc-sdk/tests/support/wxc_e2e_tests.rs | 11 +++++++- ...e_tests_e2e_bubblewrap_characterization.rs | 27 +++++++++++-------- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/.github/workflows/Build.Linux.Job.yml b/.github/workflows/Build.Linux.Job.yml index 286f127276..6201e25bcf 100644 --- a/.github/workflows/Build.Linux.Job.yml +++ b/.github/workflows/Build.Linux.Job.yml @@ -132,6 +132,25 @@ jobs: exit 1 fi + # The library tests are selected by module, so the seccomp filter needs + # its own step; a zero-test run must fail. + - name: Test Bubblewrap seccomp_filter module + working-directory: src + shell: bash + run: | + set -uo pipefail + log="$(mktemp)" + cargo test --locked --release --target "${{ matrix.target }}" \ + -p mxc-sdk --lib 'backend_modules::bubblewrap::common::seccomp_filter::' 2>&1 | tee "$log" + status=${PIPESTATUS[0]} + if [ "$status" -ne 0 ]; then + exit "$status" + fi + if ! grep -qE 'test result: ok\. [1-9][0-9]* passed' "$log"; then + echo "::error::Bubblewrap seccomp_filter module filter ran no tests." + exit 1 + fi + # The job defaults working-directory to src/tools/lxc, so the `Test lxc` # step above covers only the lxc package. This streaming suite must follow # the install. Strict mode makes a failed availability probe fail this @@ -204,8 +223,12 @@ jobs: # Runs the Bubblewrap executor characterization tests. lxc-exec was built # into src/target//release above, where find_binary() locates it. + # Strict mode fails a test whose Bubblewrap or python3 prerequisite is + # missing instead of letting it pass without running. - name: Test Bubblewrap executor characterization working-directory: src + env: + MXC_BWRAP_TESTS_REQUIRE_EXECUTION: "1" run: cargo test --locked --release --target ${{ matrix.target }} -p mxc-sdk --test wxc_e2e_tests_e2e_bubblewrap_characterization diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs b/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs index 493dea8561..4c71ef05c4 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs @@ -10,6 +10,8 @@ use std::io::{Seek, SeekFrom, Write}; #[cfg(target_os = "linux")] use std::os::fd::{AsRawFd, FromRawFd}; +#[cfg(target_os = "linux")] +use nix::errno::Errno; #[cfg(target_os = "linux")] use nix::fcntl::{fcntl, FcntlArg}; @@ -136,17 +138,17 @@ pub(crate) fn create_file() -> Result { file.seek(SeekFrom::Start(0)) .map_err(|error| format!("Bubblewrap: failed to rewind seccomp filter: {error}"))?; above_stdio(file) + .map_err(|error| format!("Bubblewrap: failed to move seccomp filter descriptor: {error}")) } // The child's stdio is mapped onto descriptors 0-2 before `pre_exec` runs, so // a filter left on one of those numbers is replaced before bwrap reads it. #[cfg(target_os = "linux")] -fn above_stdio(file: File) -> Result { +fn above_stdio(file: File) -> Result { let fd = fcntl( file.as_raw_fd(), FcntlArg::F_DUPFD_CLOEXEC(FIRST_NON_STDIO_FD), - ) - .map_err(|error| format!("Bubblewrap: failed to move seccomp filter descriptor: {error}"))?; + )?; // SAFETY: `fcntl` returned a new descriptor that nothing else owns. Ok(unsafe { File::from_raw_fd(fd) }) diff --git a/src/mxc-sdk/tests/support/wxc_e2e_tests.rs b/src/mxc-sdk/tests/support/wxc_e2e_tests.rs index 7f81f3aab8..612b0c60d0 100644 --- a/src/mxc-sdk/tests/support/wxc_e2e_tests.rs +++ b/src/mxc-sdk/tests/support/wxc_e2e_tests.rs @@ -686,7 +686,7 @@ pub fn has_bwrap() -> bool { match mxc_sdk::bwrap_common::bwrap_version::probe_bwrap() { Ok(_) => true, Err(err) => { - println!("SKIPPED: {err}"); + skip_bwrap_test(&err.to_string()); false } } @@ -699,6 +699,15 @@ pub fn has_bwrap() -> bool { } } +/// Report a missing Bubblewrap test prerequisite, failing instead when +/// `MXC_BWRAP_TESTS_REQUIRE_EXECUTION` is set. +pub fn skip_bwrap_test(reason: &str) { + if std::env::var("MXC_BWRAP_TESTS_REQUIRE_EXECUTION").is_ok_and(|value| value != "0") { + panic!("strict mode: {reason}"); + } + println!("SKIPPED: {reason}"); +} + /// Opt-in switch for the Windows ProcessContainer characterization tests. /// /// AppContainer/BaseContainer execution requires an elevated, host-prepped diff --git a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_bubblewrap_characterization.rs b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_bubblewrap_characterization.rs index e8376b4d71..661d3869a5 100644 --- a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_bubblewrap_characterization.rs +++ b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_bubblewrap_characterization.rs @@ -12,8 +12,9 @@ //! tracked separately. //! //! They run in the existing Linux CI job (`cargo test`) **only when `bwrap` is -//! installed** — `has_bwrap()` skips them cleanly otherwise. Each test also -//! skips if `lxc-exec` has not been built. +//! installed** — `has_bwrap()` skips them cleanly otherwise, and fails them +//! when `MXC_BWRAP_TESTS_REQUIRE_EXECUTION` is set. Each test also skips if +//! `lxc-exec` has not been built. #![cfg(target_os = "linux")] #[path = "support/wxc_e2e_tests.rs"] @@ -25,7 +26,7 @@ use std::process::Command; use std::time::{Duration, Instant}; use wxc_e2e_tests::{ has_bwrap, has_platform_exec, run_platform_config_value, - run_platform_config_value_within_duration, + run_platform_config_value_within_duration, skip_bwrap_test, }; const SCHEMA_VERSION: &str = "0.9.0-alpha"; @@ -42,6 +43,14 @@ fn ready() -> bool { has_platform_exec() && has_bwrap() } +fn has_python3() -> bool { + let available = Command::new("python3").arg("--version").output().is_ok(); + if !available { + skip_bwrap_test("python3 is required for the Bubblewrap seccomp tests"); + } + available +} + /// Build a one-shot config that omits `containment` so the binary selects its /// OS-native backend (Bubblewrap on Linux). fn config(label: &str, command_line: &str) -> serde_json::Value { @@ -331,8 +340,7 @@ fn bubblewrap_allows_common_socket_families() { if !ready() { return; } - if Command::new("python3").arg("--version").output().is_err() { - println!("SKIPPED: python3 is required for the Bubblewrap socket-family seccomp test"); + if !has_python3() { return; } @@ -364,8 +372,7 @@ fn bubblewrap_denies_unlisted_families_with_eperm() { if !ready() { return; } - if Command::new("python3").arg("--version").output().is_err() { - println!("SKIPPED: python3 is required for the Bubblewrap socket-family seccomp test"); + if !has_python3() { return; } @@ -424,8 +431,7 @@ fn bubblewrap_denies_vsock_with_eperm() { if !ready() { return; } - if Command::new("python3").arg("--version").output().is_err() { - println!("SKIPPED: python3 is required for the Bubblewrap VSOCK seccomp test"); + if !has_python3() { return; } @@ -473,8 +479,7 @@ fn bubblewrap_denies_io_uring_with_enosys() { if !ready() { return; } - if Command::new("python3").arg("--version").output().is_err() { - println!("SKIPPED: python3 is required for the Bubblewrap io_uring seccomp test"); + if !has_python3() { return; } From fdc6174d7f5f164993abb33e798f9ccdcfb1b5fb Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 22 Oct 2026 13:36:22 -0700 Subject: [PATCH 4/4] Addressed PR comments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e313a8c-149b-4c27-b3f7-06592ff192cd --- .github/workflows/Build.Linux.Job.yml | 26 +- .../ci-validation-infrastructure.md | 2 +- .../bubblewrap/common/bwrap_command.rs | 70 ++- .../bubblewrap/common/bwrap_runner.rs | 23 +- .../bubblewrap/common/seccomp_filter.rs | 432 ++++-------------- ...e_tests_e2e_bubblewrap_characterization.rs | 130 ++++-- 6 files changed, 265 insertions(+), 418 deletions(-) diff --git a/.github/workflows/Build.Linux.Job.yml b/.github/workflows/Build.Linux.Job.yml index 6201e25bcf..8a191faac7 100644 --- a/.github/workflows/Build.Linux.Job.yml +++ b/.github/workflows/Build.Linux.Job.yml @@ -132,24 +132,24 @@ jobs: exit 1 fi - # The library tests are selected by module, so the seccomp filter needs - # its own step; a zero-test run must fail. - - name: Test Bubblewrap seccomp_filter module + - name: Test Bubblewrap seccomp and argument-builder modules working-directory: src shell: bash run: | set -uo pipefail log="$(mktemp)" - cargo test --locked --release --target "${{ matrix.target }}" \ - -p mxc-sdk --lib 'backend_modules::bubblewrap::common::seccomp_filter::' 2>&1 | tee "$log" - status=${PIPESTATUS[0]} - if [ "$status" -ne 0 ]; then - exit "$status" - fi - if ! grep -qE 'test result: ok\. [1-9][0-9]* passed' "$log"; then - echo "::error::Bubblewrap seccomp_filter module filter ran no tests." - exit 1 - fi + for module in seccomp_filter bwrap_command; do + cargo test --locked --release --target "${{ matrix.target }}" \ + -p mxc-sdk --lib "backend_modules::bubblewrap::common::$module::" 2>&1 | tee "$log" + status=${PIPESTATUS[0]} + if [ "$status" -ne 0 ]; then + exit "$status" + fi + if ! grep -qE 'test result: ok\. [1-9][0-9]* passed' "$log"; then + echo "::error::Bubblewrap $module module filter ran no tests." + exit 1 + fi + done # The job defaults working-directory to src/tools/lxc, so the `Test lxc` # step above covers only the lxc package. This streaming suite must follow diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 7e8ec40d7a..be41bf607f 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -66,7 +66,7 @@ test jobs only ever `download-artifact`. |-----|--------------| | `dependency-feed-check` | Resolves the locked crate graph through the public `MxcDependencies` feed. Gates the builds. | | `windows` | `Build.Windows.Job.yml` — x64 + arm64 release build, unit tests, uploads `wxc-binaries-`. | -| `linux` | `Build.Linux.Job.yml` — x64 + arm64 release build, unit tests, Bubblewrap SDK streaming/PTY tests, `wxc_e2e_tests`, uploads `lxc-binaries-`. | +| `linux` | `Build.Linux.Job.yml` — x64 + arm64 release build, unit tests including Bubblewrap seccomp and argument generation, Bubblewrap SDK streaming/PTY tests, `wxc_e2e_tests`, uploads `lxc-binaries-`. | | `macos` | `Build.MacOS.Job.yml` — arm64 release build, unit tests, Seatbelt SDK streaming/PTY tests, `wxc_e2e_tests`, uploads `mxc-binaries-aarch64-apple-darwin`. | | `isolation-session-bundle` | `Package.IsolationSession.TestBundle.Job.yml` — uploads `isolation-session-test-bundle-` for x64 + arm64. | | `test-nightly` | Calls the matrix job with `plan: nightly`. Runs on every schedule tick and on a `nightly` dispatch. | diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs index 111a6393c0..28007062a8 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs @@ -403,15 +403,15 @@ pub fn build_args_classified( denied_files: &HashSet, ) -> Vec { let network_mode = ResolvedNetworkMode::from_request(request, proxy_address.is_some()); - build_args_classified_with_mode(request, proxy_address, denied_files, network_mode) + build_args_classified_with_mode(request, proxy_address, denied_files, network_mode, None) } -/// Build Bubblewrap arguments using a previously resolved network mode. pub(crate) fn build_args_classified_with_mode( request: &ExecutionRequest, proxy_address: Option<&ProxyAddress>, denied_files: &HashSet, network_mode: ResolvedNetworkMode, + seccomp_fd: Option, ) -> Vec { // -- Namespace isolation (all unshared by default) --------------------- let mut args = Vec::new(); @@ -533,6 +533,10 @@ pub(crate) fn build_args_classified_with_mode( } } + if let Some(fd) = seccomp_fd { + args.extend(["--seccomp".into(), fd.to_string()]); + } + // -- Command ----------------------------------------------------------- args.extend(COMMAND_TAIL.iter().map(|arg| arg.to_string())); args.push(request.script_code.clone()); @@ -552,6 +556,68 @@ mod tests { } } + #[test] + fn seccomp_descriptor_precedes_the_command_in_every_network_mode() { + let request = base_request(); + let proxy = ProxyAddress::new("127.0.0.1".into(), 3128); + for (mode, proxy_address) in [ + (ResolvedNetworkMode::Isolated, None), + (ResolvedNetworkMode::FirewallEnforced, None), + (ResolvedNetworkMode::ProxyOnly, Some(&proxy)), + ] { + let args = build_args_classified_with_mode( + &request, + proxy_address, + &HashSet::new(), + mode, + Some(17), + ); + assert_eq!( + &args[args.len() - 6..], + ["--seccomp", "17", "--", "sh", "-c", "echo hello"], + "{mode:?}" + ); + assert_eq!(args.iter().filter(|arg| *arg == "--seccomp").count(), 1); + assert!(!args.iter().any(|arg| arg == "--add-seccomp-fd")); + } + } + + #[test] + fn seccomp_preserves_option_like_operands_in_generated_arguments() { + let mut request = base_request(); + request.env = Some(vec![ + "FIRST=--seccomp".into(), + "SECOND=--add-seccomp-fd".into(), + "THIRD=--".into(), + ]); + request.script_code = "--seccomp".into(); + let mut expected = build_args(&request, None); + let command_index = expected.len() - 4; + expected.splice( + command_index..command_index, + ["--seccomp".into(), "17".into()], + ); + let args = build_args_classified_with_mode( + &request, + None, + &HashSet::new(), + ResolvedNetworkMode::Isolated, + Some(17), + ); + assert_eq!(args, expected); + for (key, value) in [ + ("FIRST", "--seccomp"), + ("SECOND", "--add-seccomp-fd"), + ("THIRD", "--"), + ] { + assert!(args.windows(3).any(|args| args == ["--setenv", key, value])); + } + assert_eq!( + &args[args.len() - 6..], + ["--seccomp", "17", "--", "sh", "-c", "--seccomp"] + ); + } + #[test] fn resolver_target_hint_requires_no_mount_overlap() { let mut policy = ContainerPolicy::default(); diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs index 22a9ada87c..a1b070d75f 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs @@ -520,31 +520,24 @@ impl BubblewrapScriptRunner { None => None, }; - // 2. Build the bwrap argument vector. `denied_files` is the file-mask - // subset classified during symlink resolution (see - // [`resolve_denied_paths`]). + let seccomp_filter = match seccomp_filter::create_file() { + Ok(file) => file, + Err(error) => { + stop_proxy_network(&mut proxy_network, logger); + return Err(ScriptResponse::error(&error)); + } + }; let mut args = bwrap_command::build_args_classified_with_mode( request, proxy_address, denied_files, network_mode, + Some(seccomp_filter.as_raw_fd()), ); if stdio == StdioMode::Inherit { args.insert(0, "--new-session".into()); } - - let seccomp_filter = match seccomp_filter::create_file() { - Ok(file) => file, - Err(error) => { - stop_proxy_network(&mut proxy_network, logger); - return Err(ScriptResponse::error(&error)); - } - }; - if let Err(error) = seccomp_filter::add_bwrap_args(&mut args, seccomp_filter.as_raw_fd()) { - stop_proxy_network(&mut proxy_network, logger); - return Err(ScriptResponse::error(&error)); - } let mut network_startup = match proxy_network.as_ref() { Some(network) => match network.configure_bwrap(&mut args, logger) { Ok(startup) => Some(startup), diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs b/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs index 4c71ef05c4..8c53a4a3ea 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/seccomp_filter.rs @@ -15,8 +15,6 @@ use nix::errno::Errno; #[cfg(target_os = "linux")] use nix::fcntl::{fcntl, FcntlArg}; -use crate::bwrap_common::bwrap_command::COMMAND_TAIL; - const BPF_LD_W_ABS: u16 = 0x20; #[cfg(any(target_arch = "x86_64", test))] const BPF_ALU_AND_K: u16 = 0x54; @@ -154,55 +152,6 @@ fn above_stdio(file: File) -> Result { Ok(unsafe { File::from_raw_fd(fd) }) } -pub(crate) fn add_bwrap_args(args: &mut Vec, fd: i32) -> Result<(), String> { - let command_index = args - .len() - .checked_sub(COMMAND_TAIL.len() + 1) - .filter(|&index| args[index..index + COMMAND_TAIL.len()] == COMMAND_TAIL) - .ok_or_else(|| { - "Bubblewrap: cannot install the seccomp filter because the command tail is malformed" - .to_string() - })?; - reject_conflicting_options(&args[..command_index])?; - args.splice( - command_index..command_index, - ["--seccomp".to_string(), fd.to_string()], - ); - Ok(()) -} - -fn reject_conflicting_options(args: &[String]) -> Result<(), String> { - let mut index = 0; - while index < args.len() { - let option = args[index].as_str(); - if matches!(option, "--seccomp" | "--add-seccomp-fd") { - return Err( - "Bubblewrap: cannot combine the address-family filter with another seccomp option" - .to_string(), - ); - } - let operands = match option { - "--unshare-user" | "--unshare-pid" | "--unshare-ipc" | "--unshare-uts" - | "--die-with-parent" | "--unshare-net" | "--clearenv" | "--new-session" => 0, - "--dev" | "--proc" | "--tmpfs" | "--chdir" | "--userns" | "--info-fd" - | "--block-fd" => 1, - "--ro-bind-try" | "--symlink" | "--bind" | "--ro-bind" | "--setenv" => 2, - _ => { - return Err(format!( - "Bubblewrap: cannot install the seccomp filter after unknown option {option}" - )); - } - }; - index = index - .checked_add(operands + 1) - .filter(|next| *next <= args.len()) - .ok_or_else(|| { - format!("Bubblewrap: option {option} is missing one or more operands") - })?; - } - Ok(()) -} - pub(crate) fn unsupported_architecture(architecture: &str) -> Option { (!matches!(architecture, "x86_64" | "aarch64")).then(|| architecture.to_string()) } @@ -425,29 +374,40 @@ mod tests { const AF_PACKET: u64 = 17; const AF_VSOCK: u64 = 40; - #[derive(Clone, Copy)] - struct SeccompData { - number: u32, - architecture: u32, - args: [u64; 6], - } - - fn evaluate(program: &[Instruction], data: SeccompData) -> u32 { + // Syscall and audit values follow Linux v6.18's UAPI headers and syscall tables: + // https://github.com/torvalds/linux/tree/v6.18 + const SOCKET_ABIS: [(NativeArchitecture, u32, u32, u32); 5] = [ + (NativeArchitecture::X86_64, 0xc000_003e, 41, 53), + ( + NativeArchitecture::X86_64, + 0xc000_003e, + 0x4000_0029, + 0x4000_0035, + ), + (NativeArchitecture::X86_64, 0x4000_0003, 359, 360), + (NativeArchitecture::Aarch64, 0xc000_00b7, 198, 199), + (NativeArchitecture::Aarch64, 0x4000_0028, 281, 288), + ]; + + fn evaluate(program: &[Instruction], data: &[u8; 64]) -> u32 { let mut accumulator = 0_u32; let mut pc = 0_usize; loop { let instruction = program[pc]; match instruction.code { - BPF_LD_W_ABS => { - accumulator = load_word(data, instruction.value); + 0x20 => { + let offset = usize::try_from(instruction.value).expect("cBPF load offset"); + assert_eq!(offset % 4, 0, "seccomp loads must be word-aligned"); + accumulator = + u32::from_le_bytes(data[offset..offset + 4].try_into().expect("cBPF word")); pc += 1; } - BPF_ALU_AND_K => { + 0x54 => { accumulator &= instruction.value; pc += 1; } - BPF_JMP_JEQ_K => { + 0x15 => { let jump = if accumulator == instruction.value { instruction.jump_if_true } else { @@ -455,71 +415,29 @@ mod tests { }; pc += usize::from(jump) + 1; } - BPF_RET_K => return instruction.value, + 0x06 => return instruction.value, code => panic!("unsupported cBPF instruction {code:#x}"), } } } - fn load_word(data: SeccompData, offset: u32) -> u32 { - match offset { - SECCOMP_DATA_NR_OFFSET => data.number, - SECCOMP_DATA_ARCH_OFFSET => data.architecture, - SECCOMP_DATA_ARG0_LOW_OFFSET => data.args[0] as u32, - _ => panic!("unsupported seccomp_data offset {offset}"), - } - } - + // Byte layout: https://github.com/torvalds/linux/blob/v6.18/include/uapi/linux/seccomp.h fn result(native: NativeArchitecture, audit: u32, number: u32, first_argument: u64) -> u32 { - evaluate( - &build_program(native), - SeccompData { - number, - architecture: audit, - args: [first_argument, 0, 0, 0, 0, 0], - }, - ) + let mut data = [0xa5; 64]; + data[0..4].copy_from_slice(&number.to_le_bytes()); + data[4..8].copy_from_slice(&audit.to_le_bytes()); + data[16..24].copy_from_slice(&first_argument.to_le_bytes()); + evaluate(&build_program(native), &data) } #[test] fn allows_required_socket_families() { - for (native, audit, socket, socketpair) in [ - ( - NativeArchitecture::X86_64, - AUDIT_ARCH_X86_64, - X86_64_SOCKET, - X86_64_SOCKETPAIR, - ), - ( - NativeArchitecture::X86_64, - AUDIT_ARCH_X86_64, - X32_SYSCALL_BIT | X86_64_SOCKET, - X32_SYSCALL_BIT | X86_64_SOCKETPAIR, - ), - ( - NativeArchitecture::X86_64, - AUDIT_ARCH_I386, - I386_SOCKET, - I386_SOCKETPAIR, - ), - ( - NativeArchitecture::Aarch64, - AUDIT_ARCH_AARCH64, - AARCH64_SOCKET, - AARCH64_SOCKETPAIR, - ), - ( - NativeArchitecture::Aarch64, - AUDIT_ARCH_ARM, - ARM_SOCKET, - ARM_SOCKETPAIR, - ), - ] { + for (native, audit, socket, socketpair) in SOCKET_ABIS { for number in [socket, socketpair] { - for family in ALLOWED_FAMILIES { + for family in [1_u64, 2, 10, 16] { assert_eq!( - result(native, audit, number, u64::from(family)), - SECCOMP_RET_ALLOW, + result(native, audit, number, family), + 0x7fff_0000, "family {family} should be allowed for syscall {number} on arch {audit:#x}" ); } @@ -529,52 +447,22 @@ mod tests { #[test] fn denies_vsock_for_socketpair() { - for (native, audit, socketpair) in [ - ( - NativeArchitecture::X86_64, - AUDIT_ARCH_X86_64, - X86_64_SOCKETPAIR, - ), - ( - NativeArchitecture::X86_64, - AUDIT_ARCH_X86_64, - X32_SYSCALL_BIT | X86_64_SOCKETPAIR, - ), - (NativeArchitecture::X86_64, AUDIT_ARCH_I386, I386_SOCKETPAIR), - ( - NativeArchitecture::Aarch64, - AUDIT_ARCH_AARCH64, - AARCH64_SOCKETPAIR, - ), - (NativeArchitecture::Aarch64, AUDIT_ARCH_ARM, ARM_SOCKETPAIR), - ] { - assert_eq!(result(native, audit, socketpair, AF_VSOCK), errno(EPERM)); + for (native, audit, _, socketpair) in SOCKET_ABIS { + assert_eq!(result(native, audit, socketpair, AF_VSOCK), 0x0005_0001); } } #[test] fn denies_vsock_packet_and_unknown_families() { - for (native, audit, socket) in [ - (NativeArchitecture::X86_64, AUDIT_ARCH_X86_64, X86_64_SOCKET), - ( - NativeArchitecture::X86_64, - AUDIT_ARCH_X86_64, - X32_SYSCALL_BIT | X86_64_SOCKET, - ), - (NativeArchitecture::X86_64, AUDIT_ARCH_I386, I386_SOCKET), - ( - NativeArchitecture::Aarch64, - AUDIT_ARCH_AARCH64, - AARCH64_SOCKET, - ), - (NativeArchitecture::Aarch64, AUDIT_ARCH_ARM, ARM_SOCKET), - ] { - for family in [AF_VSOCK, AF_PACKET, 0xffff] { - assert_eq!( - result(native, audit, socket, family), - errno(EPERM), - "family {family} should be denied on arch {audit:#x}" - ); + for (native, audit, socket, socketpair) in SOCKET_ABIS { + for number in [socket, socketpair] { + for family in [AF_VSOCK, AF_PACKET, 0xffff] { + assert_eq!( + result(native, audit, number, family), + 0x0005_0001, + "family {family} should be denied for syscall {number} on arch {audit:#x}" + ); + } } } } @@ -582,29 +470,25 @@ mod tests { #[test] fn allows_unfiltered_syscalls() { for (native, audit, number) in [ - (NativeArchitecture::X86_64, AUDIT_ARCH_X86_64, 0), - ( - NativeArchitecture::X86_64, - AUDIT_ARCH_X86_64, - X32_SYSCALL_BIT, - ), - (NativeArchitecture::X86_64, AUDIT_ARCH_I386, 3), - (NativeArchitecture::Aarch64, AUDIT_ARCH_AARCH64, 63), - (NativeArchitecture::Aarch64, AUDIT_ARCH_ARM, 3), + (NativeArchitecture::X86_64, 0xc000_003e, 0), + (NativeArchitecture::X86_64, 0xc000_003e, 0x4000_0000), + (NativeArchitecture::X86_64, 0x4000_0003, 3), + (NativeArchitecture::Aarch64, 0xc000_00b7, 63), + (NativeArchitecture::Aarch64, 0x4000_0028, 3), ] { - assert_eq!(result(native, audit, number, AF_VSOCK), SECCOMP_RET_ALLOW); + assert_eq!(result(native, audit, number, AF_VSOCK), 0x7fff_0000); } } #[test] fn rejects_foreign_architectures() { for (native, foreign) in [ - (NativeArchitecture::X86_64, AUDIT_ARCH_AARCH64), - (NativeArchitecture::X86_64, AUDIT_ARCH_ARM), - (NativeArchitecture::Aarch64, AUDIT_ARCH_X86_64), - (NativeArchitecture::Aarch64, AUDIT_ARCH_I386), + (NativeArchitecture::X86_64, 0xc000_00b7), + (NativeArchitecture::X86_64, 0x4000_0028), + (NativeArchitecture::Aarch64, 0xc000_003e), + (NativeArchitecture::Aarch64, 0x4000_0003), ] { - assert_eq!(result(native, foreign, 0, 0), errno(ENOSYS)); + assert_eq!(result(native, foreign, 0, 0), 0x0005_0026); } } @@ -613,69 +497,50 @@ mod tests { assert_eq!( result( NativeArchitecture::X86_64, - AUDIT_ARCH_X86_64, - X32_SYSCALL_BIT | X86_64_SOCKET, + 0xc000_003e, + 0x4000_0029, AF_VSOCK, ), - errno(EPERM) + 0x0005_0001 ); assert_eq!( result( NativeArchitecture::X86_64, - AUDIT_ARCH_X86_64, - X32_SYSCALL_BIT, + 0xc000_003e, + 0x4000_0000, AF_VSOCK, ), - SECCOMP_RET_ALLOW + 0x7fff_0000 ); } #[test] fn filters_i386_direct_socket_syscalls() { assert_eq!( - result( - NativeArchitecture::X86_64, - AUDIT_ARCH_I386, - I386_SOCKET, - AF_VSOCK, - ), - errno(EPERM) + result(NativeArchitecture::X86_64, 0x4000_0003, 359, AF_VSOCK), + 0x0005_0001 ); assert_eq!( - result( - NativeArchitecture::X86_64, - AUDIT_ARCH_I386, - I386_SOCKET, - u64::from(AF_INET), - ), - SECCOMP_RET_ALLOW + result(NativeArchitecture::X86_64, 0x4000_0003, 359, 2), + 0x7fff_0000 ); } #[test] fn denies_io_uring_on_every_supported_abi() { - for (native, audit, bit) in [ - (NativeArchitecture::X86_64, AUDIT_ARCH_X86_64, 0), - ( - NativeArchitecture::X86_64, - AUDIT_ARCH_X86_64, - X32_SYSCALL_BIT, - ), - (NativeArchitecture::X86_64, AUDIT_ARCH_I386, 0), - (NativeArchitecture::Aarch64, AUDIT_ARCH_AARCH64, 0), - (NativeArchitecture::Aarch64, AUDIT_ARCH_ARM, 0), - ] { - for number in IO_URING_SYSCALLS { + for (native, audit, socket, _) in SOCKET_ABIS { + let bit = socket & 0x4000_0000; + for number in [425, 426, 427] { assert_eq!( result(native, audit, bit | number, 0), - errno(ENOSYS), + 0x0005_0026, "io_uring syscall {number} should be denied on arch {audit:#x}" ); } - for neighbor in [IO_URING_SETUP - 1, IO_URING_REGISTER + 1] { + for neighbor in [424, 428] { assert_eq!( result(native, audit, bit | neighbor, 0), - SECCOMP_RET_ALLOW, + 0x7fff_0000, "syscall {neighbor} should stay allowed on arch {audit:#x}" ); } @@ -684,53 +549,31 @@ mod tests { #[test] fn denies_i386_socketcall_creation_subcalls() { - for subcall in [SOCKETCALL_SOCKET, SOCKETCALL_SOCKETPAIR] { + for subcall in [1, 8] { assert_eq!( - result( - NativeArchitecture::X86_64, - AUDIT_ARCH_I386, - I386_SOCKETCALL, - u64::from(subcall), - ), - errno(EPERM) + result(NativeArchitecture::X86_64, 0x4000_0003, 102, subcall), + 0x0005_0001 ); } assert_eq!( - result( - NativeArchitecture::X86_64, - AUDIT_ARCH_I386, - I386_SOCKETCALL, - 2, - ), - SECCOMP_RET_ALLOW + result(NativeArchitecture::X86_64, 0x4000_0003, 102, 2), + 0x7fff_0000 ); } #[test] fn ignores_the_family_upper_word() { - for (native, audit, socket) in [ - (NativeArchitecture::X86_64, AUDIT_ARCH_X86_64, X86_64_SOCKET), - ( - NativeArchitecture::X86_64, - AUDIT_ARCH_X86_64, - X32_SYSCALL_BIT | X86_64_SOCKET, - ), - (NativeArchitecture::X86_64, AUDIT_ARCH_I386, I386_SOCKET), - ( - NativeArchitecture::Aarch64, - AUDIT_ARCH_AARCH64, - AARCH64_SOCKET, - ), - (NativeArchitecture::Aarch64, AUDIT_ARCH_ARM, ARM_SOCKET), - ] { - for family in [u64::from(AF_UNIX), u64::from(AF_INET6), AF_VSOCK, AF_PACKET] { - let expected = result(native, audit, socket, family); - for upper in [1_u64, 0xffff_ffff] { - assert_eq!( - result(native, audit, socket, (upper << 32) | family), - expected, - "upper word {upper:#x} changed the verdict for family {family} on arch {audit:#x}" - ); + for (native, audit, socket, socketpair) in SOCKET_ABIS { + for number in [socket, socketpair] { + for family in [1, 10, AF_VSOCK, AF_PACKET] { + let expected = result(native, audit, number, family); + for upper in [1_u64, 0xffff_ffff] { + assert_eq!( + result(native, audit, number, (upper << 32) | family), + expected, + "upper word {upper:#x} changed the verdict for family {family} on arch {audit:#x}" + ); + } } } } @@ -801,103 +644,4 @@ mod tests { Some("riscv64".to_string()) ); } - - #[test] - fn inserts_seccomp_before_the_workload_command() { - let mut args = vec![ - "--clearenv".to_string(), - "--".to_string(), - "sh".to_string(), - "-c".to_string(), - "echo hello".to_string(), - ]; - add_bwrap_args(&mut args, 17).expect("valid bwrap arguments"); - assert_eq!( - args, - [ - "--clearenv", - "--seccomp", - "17", - "--", - "sh", - "-c", - "echo hello" - ] - ); - } - - #[test] - fn rejects_malformed_bwrap_arguments() { - let mut args = vec!["--clearenv".to_string(), "echo hello".to_string()]; - assert!(add_bwrap_args(&mut args, 17).is_err()); - } - - #[test] - fn rejects_conflicting_seccomp_arguments() { - for option in ["--seccomp", "--add-seccomp-fd"] { - let mut args = vec![ - option.to_string(), - "23".to_string(), - "--".to_string(), - "sh".to_string(), - "-c".to_string(), - "echo hello".to_string(), - ]; - assert!(add_bwrap_args(&mut args, 17).is_err()); - } - } - - #[test] - fn ignores_seccomp_words_in_option_operands_and_the_script() { - let mut args = vec![ - "--setenv".to_string(), - "FIRST".to_string(), - "--seccomp".to_string(), - "--chdir".to_string(), - "--add-seccomp-fd".to_string(), - "--".to_string(), - "sh".to_string(), - "-c".to_string(), - "printf '%s' --seccomp".to_string(), - ]; - add_bwrap_args(&mut args, 17).expect("seccomp words in operands are not options"); - assert_eq!(&args[5..7], ["--seccomp", "17"]); - } - - #[test] - fn rejects_unknown_or_incomplete_generated_options() { - for mut args in [ - vec![ - "--future-option".to_string(), - "--".to_string(), - "sh".to_string(), - "-c".to_string(), - "echo hello".to_string(), - ], - vec![ - "--setenv".to_string(), - "NAME".to_string(), - "--".to_string(), - "sh".to_string(), - "-c".to_string(), - "echo hello".to_string(), - ], - ] { - assert!(add_bwrap_args(&mut args, 17).is_err()); - } - } - - #[test] - fn accepts_the_cli_new_session_option() { - let mut args = vec![ - "--new-session".to_string(), - "--clearenv".to_string(), - "--".to_string(), - "sh".to_string(), - "-c".to_string(), - "echo hello".to_string(), - ]; - add_bwrap_args(&mut args, 17).expect("--new-session takes no operand"); - assert_eq!(&args[2..4], ["--seccomp", "17"]); - } } diff --git a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_bubblewrap_characterization.rs b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_bubblewrap_characterization.rs index 661d3869a5..486eaa22b9 100644 --- a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_bubblewrap_characterization.rs +++ b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_bubblewrap_characterization.rs @@ -142,21 +142,35 @@ fn bubblewrap_clears_host_env_by_default() { ); } -/// Locks in that an explicitly requested `process.env` reaches the child. #[test] fn bubblewrap_applies_requested_env() { if !ready() { return; } - let mut cfg = config("env-set", "printf 'SET=[%s]\\n' \"$MXC_CHAR_SET\""); - cfg["process"]["env"] = json!(["MXC_CHAR_SET=from_config_c93b"]); + let mut cfg = config( + "env-set", + r#"printf 'SET=[%s]\nSECCOMP=[%s]\nADD_SECCOMP=[%s]\nSEPARATOR=[%s]\n' "$MXC_CHAR_SET" "$MXC_CHAR_SECCOMP" "$MXC_CHAR_ADD_SECCOMP" "$MXC_CHAR_SEPARATOR""#, + ); + cfg["process"]["env"] = json!([ + "MXC_CHAR_SET=from_config_c93b", + "MXC_CHAR_SECCOMP=--seccomp", + "MXC_CHAR_ADD_SECCOMP=--add-seccomp-fd", + "MXC_CHAR_SEPARATOR=--" + ]); let result = run_platform_config_value("bwrap env set", &cfg, &[], None); assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - assert!( - result.combined_output().contains("SET=[from_config_c93b]"), - "expected requested env var to reach the child. Output:\n{}", - result.combined_output() - ); + let output = result.combined_output(); + for expected in [ + "SET=[from_config_c93b]", + "SECCOMP=[--seccomp]", + "ADD_SECCOMP=[--add-seccomp-fd]", + "SEPARATOR=[--]", + ] { + assert!( + output.contains(expected), + "expected requested env value {expected} to reach the child:\n{output}" + ); + } } /// Locks in that a filesystem grant is **not** a working directory. @@ -483,48 +497,78 @@ fn bubblewrap_denies_io_uring_with_enosys() { return; } - let mut params = [0_u8; IO_URING_PARAMS_SIZE]; - // SAFETY: `params` is a zeroed buffer the size of `struct io_uring_params`, - // and the returned descriptor is checked before use. - let control = - unsafe { libc::syscall(libc::SYS_io_uring_setup, 1, params.as_mut_ptr()) } as libc::c_int; - if control < 0 { - println!( - "SKIPPED: unfiltered io_uring_setup control failed with errno {}; the host cannot prove the filter caused ENOSYS", - std::io::Error::last_os_error() - .raw_os_error() - .unwrap_or_default() - ); - return; - } - - // SAFETY: The descriptor was returned by the successful io_uring_setup call above. - unsafe { libc::close(control) }; - - let command = format!( - r#"python3 -c 'import ctypes, errno, os, sys + let probe = format!( + r#"import ctypes, json, os libc = ctypes.CDLL(None, use_errno=True) +libc.syscall.restype = ctypes.c_long params = ctypes.create_string_buffer({IO_URING_PARAMS_SIZE}) -ring = libc.syscall({}, 1, params) -if ring >= 0: - os.close(ring) - print("IO_URING_ALLOWED") - sys.exit(3) -error = ctypes.get_errno() -print(f"IO_URING_ERRNO={{error}}") -sys.exit(0 if error == errno.ENOSYS else 2)'"#, - libc::SYS_io_uring_setup +invalid_fd = ctypes.c_int(-1) +zero = ctypes.c_uint(0) +null = ctypes.c_void_p() +unregister_buffers = ctypes.c_uint(1) +calls = ( + ("io_uring_setup", {setup}, (ctypes.c_uint(1), ctypes.byref(params))), + ("io_uring_enter", {enter}, (invalid_fd, zero, zero, zero, null, ctypes.c_size_t(0))), + ("io_uring_register", {register}, (invalid_fd, unregister_buffers, null, zero)), +) +errors = {{}} +for name, number, args in calls: + result = libc.syscall(ctypes.c_long(number), *args) + errors[name] = ctypes.get_errno() if result < 0 else 0 + if name == "io_uring_setup" and result >= 0: + os.close(result) +print("IO_URING_ERRNOS=" + json.dumps(errors))"#, + setup = libc::SYS_io_uring_setup, + enter = libc::SYS_io_uring_enter, + register = libc::SYS_io_uring_register, + ); + let parse = |output: &str| -> std::collections::BTreeMap { + let payload = output + .lines() + .find_map(|line| line.strip_prefix("IO_URING_ERRNOS=")) + .unwrap_or_else(|| panic!("missing io_uring probe results:\n{output}")); + serde_json::from_str(payload).expect("io_uring errno results") + }; + let control = Command::new("python3") + .args(["-c", &probe]) + .output() + .expect("run unfiltered io_uring control"); + assert!( + control.status.success(), + "unfiltered io_uring probe failed:\n{}", + String::from_utf8_lossy(&control.stderr) ); + let host_errors = parse(&String::from_utf8_lossy(&control.stdout)); + let mut verifiable = Vec::new(); + for name in ["io_uring_setup", "io_uring_enter", "io_uring_register"] { + if host_errors[name] == libc::ENOSYS { + println!( + "SKIPPED: unfiltered {name} also returned ENOSYS; its sandbox denial would be indistinguishable" + ); + } else { + verifiable.push(name); + } + } + if verifiable.is_empty() { + return; + } + let result = run_platform_config_value( "bwrap io_uring seccomp", - &config("io-uring-seccomp", &command), + &config("io-uring-seccomp", &format!("python3 -c '{probe}'")), &[], None, ); let output = result.combined_output(); - assert_eq!(result.code, Some(0), "io_uring filter failed:\n{output}"); - assert!( - output.contains(&format!("IO_URING_ERRNO={}", libc::ENOSYS)), - "expected exact ENOSYS from io_uring_setup:\n{output}" - ); + assert_eq!(result.code, Some(0), "io_uring probe failed:\n{output}"); + let sandbox_errors = parse(&output); + for name in verifiable { + assert_eq!( + sandbox_errors[name], + libc::ENOSYS, + "{name}: expected sandbox ENOSYS after host errno {}:\n{output}", + host_errors[name] + ); + println!("{name}: host errno {}, sandbox ENOSYS", host_errors[name]); + } }