From b6295f8ecc5bd475366a4e263e985318b127138b Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 17:42:57 +0200 Subject: [PATCH 01/20] feat(linux): prove native enforcement substrate --- .github/workflows/ci.yml | 12 +- AGENTS.md | 32 +++- Cargo.lock | 68 +++++++- Cargo.toml | 11 +- Makefile | 5 +- crates/linux/Cargo.toml | 30 ++++ crates/linux/README.md | 8 + crates/linux/src/apply.rs | 32 ++++ crates/linux/src/capabilities.rs | 7 + crates/linux/src/error.rs | 71 ++++++++ crates/linux/src/ffi.rs | 284 +++++++++++++++++++++++++++++++ crates/linux/src/landlock.rs | 119 +++++++++++++ crates/linux/src/lib.rs | 38 +++++ crates/linux/src/mount.rs | 134 +++++++++++++++ crates/linux/src/namespace.rs | 89 ++++++++++ crates/linux/src/plan.rs | 142 ++++++++++++++++ crates/linux/src/prepare.rs | 281 ++++++++++++++++++++++++++++++ crates/linux/src/seccomp.rs | 140 +++++++++++++++ crates/linux/src/support.rs | 60 +++++++ crates/linux/tests/live_linux.rs | 113 ++++++++++++ deny.toml | 1 + docs/LINUX_SECURITY_MODEL.md | 97 +++++++++++ 22 files changed, 1758 insertions(+), 16 deletions(-) create mode 100644 crates/linux/Cargo.toml create mode 100644 crates/linux/README.md create mode 100644 crates/linux/src/apply.rs create mode 100644 crates/linux/src/capabilities.rs create mode 100644 crates/linux/src/error.rs create mode 100644 crates/linux/src/ffi.rs create mode 100644 crates/linux/src/landlock.rs create mode 100644 crates/linux/src/lib.rs create mode 100644 crates/linux/src/mount.rs create mode 100644 crates/linux/src/namespace.rs create mode 100644 crates/linux/src/plan.rs create mode 100644 crates/linux/src/prepare.rs create mode 100644 crates/linux/src/seccomp.rs create mode 100644 crates/linux/src/support.rs create mode 100644 crates/linux/tests/live_linux.rs create mode 100644 docs/LINUX_SECURITY_MODEL.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 803236a..09ce500 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,8 +40,8 @@ jobs: - name: Test Seatbelt enforcement run: make test-live - linux-facade: - name: Linux facade compile + linux-substrate: + name: Linux enforcement substrate runs-on: ubuntu-latest steps: - name: Check out repository @@ -49,8 +49,12 @@ jobs: with: persist-credentials: false - name: Install Rust toolchain - run: rustup toolchain install 1.91.1 --profile minimal - - name: Check facade and run external consumer + run: rustup toolchain install 1.91.1 --profile minimal --component clippy + - name: Lint Linux backend + run: cargo clippy -p sandy-linux --all-targets --all-features --locked -- -D warnings + - name: Test Linux backend + run: make test-live-linux + - name: Preserve unsupported facade contract run: | cargo check -p sandy-sandbox --all-targets --all-features --locked cargo run --manifest-path tests/package-consumer/Cargo.toml --locked diff --git a/AGENTS.md b/AGENTS.md index d94b490..2b14620 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,8 @@ This file governs the entire repository. Sandy is a macOS-native process sandbox for AI coding agents. -- Cargo workspace: `sandy-core`, `sandy-seatbelt`, `sandy-sandbox`, and - `sandy-cli` +- Cargo workspace: `sandy-core`, `sandy-seatbelt`, `sandy-linux`, + `sandy-sandbox`, and `sandy-cli` - Installed executable: `sandy` - Default mode: standalone sandboxing - Optional integrations: preserve verified existing Kontext hooks or @@ -45,7 +45,7 @@ There is no implicit discovery, include, URL, fallback, or user-to-user inheritance. File I/O and ambient resolution remain in `sandy-cli`; lexical validation and deterministic additive composition remain in `sandy-core`. -Do not add Linux, detached sessions, a PTY proxy, domain filtering, credential +Do not add detached sessions, a PTY proxy, domain filtering, credential brokering, dynamic grants, rollback, resource limits, raw Seatbelt input, or organization-managed Kontext support or outside-sandbox synchronous hook decision services without an explicit scope decision. @@ -55,12 +55,13 @@ changes. ## Architecture -Use a virtual workspace with four packages representing validation, +Use a virtual workspace with five packages representing validation, native-code, embedding, and product boundaries. ```text crates/core/ package sandy-core; validated security contract crates/seatbelt/ package sandy-seatbelt; macOS compiler and FFI +crates/linux/ package sandy-linux; Linux preparation and enforcement crates/sandy/ package sandy-sandbox; supported Rust facade crates/cli/ package sandy-cli; sandy binary and product UX ``` @@ -75,10 +76,12 @@ Keep dependencies flowing in one direction: CLI -> sandy-core -> sandy-seatbelt -> sandy-core + -> sandy-linux -> sandy-core sandy facade -> sandy-core -> sandy-seatbelt -> sandy-core + -> sandy-linux -> sandy-core optional integrations -> typed capabilities @@ -86,7 +89,7 @@ optional integrations ``` `sandy-core` performs deterministic validation but no ambient filesystem -discovery. `sandy-seatbelt` receives only validated policy and does not see +discovery. Native backends receive only validated policy and do not see argv, environment, agent preset names, Clap, or service configuration. The CLI does not render policy. `SandboxPolicy` is the shared, side-effect-free intent; callers may construct it with the Rust builder or its strict, bounded, @@ -204,8 +207,23 @@ cleanup. The FFI boundary exposes only owned safe Rust types and functions. Raw pointers, unsafe functions, native error buffers, and raw sandbox flags must not escape it. -Adding a native symbol requires documenting its SDK declaration, availability, -deprecation status, cleanup contract, and live macOS coverage. +`sandy-linux` follows the same rule. Its only permitted unsafe and native-call +module is: + +```text +crates/linux/src/ffi.rs +``` + +The Linux backend separates deterministic `LinuxPolicyPlan`, ambient +`PreparedLinuxSandbox`, and irreversible `apply()` states. It requires a +single-threaded caller, a private user and mount namespace, fixed Landlock ABI +9 semantics, complete capability removal, and final seccomp enforcement. +Unsupported policy combinations fail before enforcement; there is no weaker +fallback. + +Adding a native operation requires documenting its declaration, availability, +deprecation status, cleanup contract, and live coverage on every supported +host and architecture. ## Data and CLI contracts diff --git a/Cargo.lock b/Cargo.lock index b54b1ee..949a2bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,7 +145,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -195,6 +195,26 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "errno" version = "0.3.14" @@ -259,6 +279,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "landlock" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1" +dependencies = [ + "enumflags2", + "libc", + "thiserror", +] + [[package]] name = "libc" version = "0.2.189" @@ -425,6 +456,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "sandy-linux" +version = "0.1.9" +dependencies = [ + "landlock", + "libc", + "sandy-core", + "seccompiler", + "tempfile", +] + [[package]] name = "sandy-sandbox" version = "0.1.9" @@ -443,6 +485,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -470,7 +521,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -503,6 +554,17 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.3" @@ -550,7 +612,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7a51cb7..23ff87f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,11 @@ [workspace] -members = ["crates/core", "crates/seatbelt", "crates/cli", "crates/sandy"] +members = [ + "crates/core", + "crates/seatbelt", + "crates/linux", + "crates/cli", + "crates/sandy", +] default-members = ["crates/cli"] resolver = "3" @@ -16,15 +22,18 @@ authors = ["Kontext Security"] assert_cmd = "2.0.17" clap = { version = "4.5.51", features = ["derive"] } libc = "0.2.177" +landlock = "0.4.7" predicates = "3.1.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" sha2 = "0.10.9" tempfile = "3.23.0" thiserror = "2.0.17" +seccompiler = "0.5.0" sandy-core = { path = "crates/core", version = "=0.1.9" } # x-release-please-version sandy-seatbelt = { path = "crates/seatbelt", version = "=0.1.9" } # x-release-please-version +sandy-linux = { path = "crates/linux", version = "=0.1.9" } # x-release-please-version [workspace.lints.rust] rust_2018_idioms = { level = "deny", priority = -1 } diff --git a/Makefile b/Makefile index 775082d..5752838 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := help -.PHONY: help build test test-live fmt fmt-check clippy deny package-check formula-check release-version-check check run +.PHONY: help build test test-live test-live-linux fmt fmt-check clippy deny package-check formula-check release-version-check check run help: ## Show available targets. @awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make \n\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " %-12s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) @@ -15,6 +15,9 @@ test-live: ## Run sacrificial macOS Seatbelt enforcement tests. cargo test -p sandy-cli --test live_macos --locked -- --ignored --test-threads=1 cargo test -p sandy-sandbox --test live_macos --features live-tests --locked +test-live-linux: ## Run sacrificial Linux enforcement tests. + cargo test -p sandy-linux --test live_linux --locked -- --test-threads=1 + fmt: ## Format Rust sources. cargo fmt --all diff --git a/crates/linux/Cargo.toml b/crates/linux/Cargo.toml new file mode 100644 index 0000000..e637c1c --- /dev/null +++ b/crates/linux/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "sandy-linux" +description = "Linux enforcement backend for Sandy" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +publish = false + +[dependencies] +sandy-core.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +landlock.workspace = true +libc.workspace = true +seccompiler.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[[test]] +name = "live_linux" +path = "tests/live_linux.rs" +harness = false + +[lints] +workspace = true diff --git a/crates/linux/README.md b/crates/linux/README.md new file mode 100644 index 0000000..36817c1 --- /dev/null +++ b/crates/linux/README.md @@ -0,0 +1,8 @@ +# sandy-linux + +Private Linux enforcement backend for Sandy. It lowers a validated, +platform-neutral policy into a fixed native plan, pins every ambient resource, +and applies the irreversible boundary to the calling process. + +This package is an implementation detail. Use the supported `sandy-sandbox` +facade instead of depending on it directly. diff --git a/crates/linux/src/apply.rs b/crates/linux/src/apply.rs new file mode 100644 index 0000000..b78d725 --- /dev/null +++ b/crates/linux/src/apply.rs @@ -0,0 +1,32 @@ +use crate::{ + LinuxError, LinuxErrorKind, PreparedLinuxSandbox, capabilities, landlock, mount, namespace, + seccomp, +}; + +/// Irreversibly applies a fully prepared Linux sandbox to the current process. +/// +/// Any error from this function may leave the process partially restricted. +/// The caller must terminate immediately and must not run untrusted code. +pub fn apply(prepared: PreparedLinuxSandbox) -> Result<(), LinuxError> { + namespace::enter(&prepared.namespace, prepared.block_network)?; + mount::construct_and_enter(&prepared.mount)?; + landlock::apply(prepared.landlock)?; + capabilities::drop_all(prepared.namespace.last_capability)?; + seccomp::apply(&prepared.seccomp)?; + verify_postconditions() +} + +fn verify_postconditions() -> Result<(), LinuxError> { + // After pivot_root the former host root and procfs must be absent. This is + // an invariant check, not a source of policy authority. + if std::path::Path::new("/.old_root").exists() + || std::path::Path::new("/proc/self/root").exists() + { + Err(LinuxError::new( + LinuxErrorKind::EnforcementFailed, + "postcondition verification", + )) + } else { + Ok(()) + } +} diff --git a/crates/linux/src/capabilities.rs b/crates/linux/src/capabilities.rs new file mode 100644 index 0000000..dfc44b8 --- /dev/null +++ b/crates/linux/src/capabilities.rs @@ -0,0 +1,7 @@ +use crate::{LinuxError, LinuxErrorKind, ffi}; + +pub(crate) fn drop_all(last_capability: u32) -> Result<(), LinuxError> { + ffi::drop_all_capabilities(last_capability) + .and_then(|()| ffi::verify_no_capabilities()) + .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "capability removal")) +} diff --git a/crates/linux/src/error.rs b/crates/linux/src/error.rs new file mode 100644 index 0000000..5765b20 --- /dev/null +++ b/crates/linux/src/error.rs @@ -0,0 +1,71 @@ +use std::{error::Error, fmt}; + +/// Stable high-level classification for a Linux backend failure. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum LinuxErrorKind { + /// The host or requested policy cannot provide the required semantics. + Unsupported, + /// The validated policy still contains a Linux-incompatible combination. + InvalidPolicy, + /// Ambient resources could not be pinned before enforcement. + PreparationFailed, + /// An irreversible native enforcement step failed. + EnforcementFailed, +} + +/// Redacted Linux backend failure. +/// +/// Paths and native error strings are intentionally not retained because this +/// error crosses the supported facade and CLI boundaries. +pub struct LinuxError { + kind: LinuxErrorKind, + phase: &'static str, +} + +impl LinuxError { + pub(crate) const fn new(kind: LinuxErrorKind, phase: &'static str) -> Self { + Self { kind, phase } + } + + /// Returns the stable failure class. + #[must_use] + pub const fn kind(&self) -> LinuxErrorKind { + self.kind + } +} + +impl fmt::Debug for LinuxError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LinuxError") + .field("kind", &self.kind) + .field("phase", &self.phase) + .finish() + } +} + +impl fmt::Display for LinuxError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "Linux sandbox {} failed", self.phase) + } +} + +impl Error for LinuxError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_error_format_is_redacted() { + let error = LinuxError::new(LinuxErrorKind::PreparationFailed, "path pinning"); + let display = error.to_string(); + let debug = format!("{error:?}"); + for rendered in [display, debug] { + assert!(!rendered.contains("/home/")); + assert!(!rendered.contains("native error")); + } + assert!(error.source().is_none()); + } +} diff --git a/crates/linux/src/ffi.rs b/crates/linux/src/ffi.rs new file mode 100644 index 0000000..7217390 --- /dev/null +++ b/crates/linux/src/ffi.rs @@ -0,0 +1,284 @@ +//! Sole Linux unsafe/native boundary. + +use std::{ + ffi::{CStr, CString}, + fs::File, + io, + mem::size_of, + os::fd::{FromRawFd, OwnedFd, RawFd}, +}; + +const EMPTY: &CStr = c""; + +#[repr(C)] +struct CapHeader { + version: u32, + pid: i32, +} + +#[repr(C)] +struct OpenHow { + flags: u64, + mode: u64, + resolve: u64, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct CapData { + effective: u32, + permitted: u32, + inheritable: u32, +} + +pub(crate) fn effective_ids() -> (u32, u32) { + // SAFETY: `geteuid` and `getegid` take no pointers, own no resources, and + // return process credentials directly. + unsafe { (libc::geteuid(), libc::getegid()) } +} + +pub(crate) fn unshare_namespaces(block_network: bool) -> io::Result<()> { + let mut flags = libc::CLONE_NEWUSER | libc::CLONE_NEWNS; + if block_network { + flags |= libc::CLONE_NEWNET; + } + // SAFETY: `unshare` receives only a validated fixed flag set. It borrows no + // pointers and transfers no ownership. Callers enforce the single-threaded + // process precondition before invoking this irreversible operation. + cvt(unsafe { libc::unshare(flags) }).map(|_| ()) +} + +pub(crate) fn make_mounts_private() -> io::Result<()> { + // SAFETY: all pointers are either null as required by mount(2), or the + // process-lifetime static C string `/`. No pointer is retained. The flags + // change propagation only in the already-private mount namespace. + cvt(unsafe { + libc::mount( + std::ptr::null(), + c"/".as_ptr(), + std::ptr::null(), + (libc::MS_REC | libc::MS_PRIVATE) as libc::c_ulong, + std::ptr::null(), + ) + }) + .map(|_| ()) +} + +pub(crate) fn mount_tmpfs(target: &CStr) -> io::Result<()> { + // SAFETY: `target` and the static source/type/data strings are valid, + // NUL-terminated for the duration of the call. mount(2) copies them and + // retains no Rust pointer. The mount is confined to the private namespace. + cvt(unsafe { + libc::mount( + c"tmpfs".as_ptr(), + target.as_ptr(), + c"tmpfs".as_ptr(), + (libc::MS_NOSUID | libc::MS_NODEV) as libc::c_ulong, + c"mode=0700,size=64m".as_ptr().cast(), + ) + }) + .map(|_| ()) +} + +pub(crate) fn open_path_at(root: RawFd, relative: &CStr) -> io::Result { + let how = OpenHow { + flags: (libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW) as u64, + mode: 0, + resolve: libc::RESOLVE_IN_ROOT | libc::RESOLVE_NO_MAGICLINKS | libc::RESOLVE_NO_SYMLINKS, + }; + // SAFETY: `root` is a borrowed open directory descriptor, `relative` and + // `how` remain valid for the syscall, and the exact structure size is + // supplied. On success the returned descriptor is uniquely owned here. + let fd = unsafe { + libc::syscall( + libc::SYS_openat2, + root, + relative.as_ptr(), + &how, + size_of::(), + ) + }; + if fd < 0 { + Err(io::Error::last_os_error()) + } else { + let raw = i32::try_from(fd).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))?; + // SAFETY: the successful openat2 call returned a fresh descriptor and + // ownership is transferred exactly once into `OwnedFd`. + Ok(unsafe { OwnedFd::from_raw_fd(raw) }) + } +} + +pub(crate) fn clone_mount( + source: RawFd, + recursive: bool, + read_only: bool, + allow_device: bool, +) -> io::Result { + let mut flags = libc::OPEN_TREE_CLONE | libc::OPEN_TREE_CLOEXEC | libc::AT_EMPTY_PATH as u32; + if recursive { + flags |= libc::AT_RECURSIVE as u32; + } + // SAFETY: `source` is a borrowed O_PATH descriptor. With AT_EMPTY_PATH the + // empty pathname selects that descriptor; no pointer is retained. A + // successful open_tree returns a new mount descriptor owned by this call. + let fd = unsafe { libc::syscall(libc::SYS_open_tree, source, EMPTY.as_ptr(), flags) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + let raw = i32::try_from(fd).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))?; + // SAFETY: the successful syscall returned a fresh descriptor and ownership + // is transferred exactly once. + let mount_fd = unsafe { OwnedFd::from_raw_fd(raw) }; + + let attributes = libc::mount_attr { + attr_set: libc::MOUNT_ATTR_NOSUID + | if allow_device { + 0 + } else { + libc::MOUNT_ATTR_NODEV + } + | if read_only { + libc::MOUNT_ATTR_RDONLY + } else { + 0 + }, + attr_clr: 0, + propagation: 0, + userns_fd: 0, + }; + let attribute_flags = libc::AT_EMPTY_PATH | if recursive { libc::AT_RECURSIVE } else { 0 }; + // SAFETY: the mount descriptor, empty path, and mount_attr pointer remain + // valid for the syscall; the kernel copies the fixed-size structure and + // retains no pointer. The detached mount is not externally visible yet. + let result = unsafe { + libc::syscall( + libc::SYS_mount_setattr, + mount_fd.as_raw_fd(), + EMPTY.as_ptr(), + attribute_flags, + &attributes, + size_of::(), + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(mount_fd) + } +} + +pub(crate) fn attach_mount(mount_fd: RawFd, target_fd: RawFd) -> io::Result<()> { + let flags = libc::MOVE_MOUNT_F_EMPTY_PATH | libc::MOVE_MOUNT_T_EMPTY_PATH; + // SAFETY: both descriptors are borrowed and valid for the call. Empty + // pathnames select the descriptors under the matching flags. move_mount + // consumes the detached mount in the kernel but not the descriptor itself. + let result = unsafe { + libc::syscall( + libc::SYS_move_mount, + mount_fd, + EMPTY.as_ptr(), + target_fd, + EMPTY.as_ptr(), + flags, + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +pub(crate) fn pivot_root(new_root: &CStr, old_root: &CStr) -> io::Result<()> { + // SAFETY: both paths are valid NUL-terminated strings for the syscall and + // are copied immediately. The caller has made `new_root` a mount point and + // created `old_root` beneath it. + cvt_long(unsafe { libc::syscall(libc::SYS_pivot_root, new_root.as_ptr(), old_root.as_ptr()) }) + .map(|_| ()) +} + +pub(crate) fn detach_mount(path: &CStr) -> io::Result<()> { + // SAFETY: `path` is a valid NUL-terminated string for the duration of the + // call and umount2 retains no pointer. + cvt(unsafe { libc::umount2(path.as_ptr(), libc::MNT_DETACH) }).map(|_| ()) +} + +pub(crate) fn drop_all_capabilities(last_capability: u32) -> io::Result<()> { + const SECURE_BITS: libc::c_ulong = 0x01 | 0x02 | 0x04 | 0x08 | 0x40 | 0x80; + + // SAFETY: prctl receives only integer operations and values, borrows no + // pointers, and transfers no ownership. CAP_SETPCAP is still effective + // while the bounding set is reduced. + cvt(unsafe { libc::prctl(libc::PR_SET_SECUREBITS, SECURE_BITS, 0, 0, 0) })?; + // SAFETY: this prctl variant has no pointer arguments. It clears the + // calling thread's ambient set synchronously. + cvt(unsafe { + libc::prctl( + libc::PR_CAP_AMBIENT, + libc::PR_CAP_AMBIENT_CLEAR_ALL, + 0, + 0, + 0, + ) + })?; + for capability in 0..=last_capability { + // SAFETY: this prctl variant has no pointer arguments. Each numeric + // capability is bounded by the kernel-reported cap_last_cap value. + cvt(unsafe { libc::prctl(libc::PR_CAPBSET_DROP, capability, 0, 0, 0) })?; + } + + let header = CapHeader { + version: 0x2008_0522, + pid: 0, + }; + let data = [CapData::default(), CapData::default()]; + // SAFETY: the header and two-element data array match Linux's + // _LINUX_CAPABILITY_VERSION_3 ABI, remain valid for the call, contain no + // outgoing pointers, and are not retained by the kernel. + cvt_long(unsafe { libc::syscall(libc::SYS_capset, &header, data.as_ptr()) }).map(|_| ()) +} + +pub(crate) fn verify_no_capabilities() -> io::Result<()> { + let mut header = CapHeader { + version: 0x2008_0522, + pid: 0, + }; + let mut data = [CapData::default(), CapData::default()]; + // SAFETY: the writable header and data array match Linux's capability ABI, + // remain uniquely borrowed for the syscall, and are fully initialized. + cvt_long(unsafe { libc::syscall(libc::SYS_capget, &mut header, data.as_mut_ptr()) })?; + if data + .iter() + .any(|entry| entry.effective != 0 || entry.permitted != 0 || entry.inheritable != 0) + { + Err(io::Error::from_raw_os_error(libc::EPERM)) + } else { + Ok(()) + } +} + +pub(crate) fn c_string(value: &str) -> io::Result { + CString::new(value).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL)) +} + +pub(crate) fn file_from_owned(fd: OwnedFd) -> File { + File::from(fd) +} + +fn cvt(value: libc::c_int) -> io::Result { + if value == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(value) + } +} + +fn cvt_long(value: libc::c_long) -> io::Result { + if value == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(value) + } +} + +use std::os::fd::AsRawFd; diff --git a/crates/linux/src/landlock.rs b/crates/linux/src/landlock.rs new file mode 100644 index 0000000..cf1363d --- /dev/null +++ b/crates/linux/src/landlock.rs @@ -0,0 +1,119 @@ +use std::{collections::BTreeMap, fs::File}; + +use landlock::{ + ABI, Access, AccessFs, BitFlags, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr, + RulesetCreated, RulesetCreatedAttr, RulesetStatus, Scope, +}; +use sandy_core::{AccessMode, PathScope, PolicySpec}; + +use crate::{ + LinuxError, LinuxErrorKind, + prepare::{PinnedKind, PinnedPath, denied}, +}; + +pub(crate) struct PreparedLandlock { + ruleset: RulesetCreated, +} + +pub(crate) fn prepare( + policy: &PolicySpec, + pinned: &BTreeMap, +) -> Result { + let builder = Ruleset::default() + .handle_access(AccessFs::from_all(ABI::V9)) + .and_then(|ruleset| ruleset.scope(Scope::Signal | Scope::AbstractUnixSocket)) + .map_err(|_| unsupported("Landlock ABI"))? + .set_compatibility(CompatLevel::HardRequirement); + let mut ruleset = builder + .create() + .map_err(|_| unsupported("Landlock ruleset creation"))?; + + for grant in &policy.files { + if denied(&grant.path, &policy.protected_paths) { + continue; + } + let pinned_path = pinned + .get(&grant.path) + .ok_or_else(|| preparation("Landlock path lookup"))?; + let mut rights = read_rights(pinned_path.kind); + if grant.access == AccessMode::ReadWrite { + rights |= write_rights(); + } + ruleset = add_rule(ruleset, &pinned_path.file, rights)?; + } + for grant in &policy.executables { + if denied(&grant.path, &policy.protected_paths) { + continue; + } + let file = &pinned + .get(&grant.path) + .ok_or_else(|| preparation("Landlock executable lookup"))? + .file; + ruleset = add_rule(ruleset, file, AccessFs::Execute.into())?; + } + for grant in &policy.unix_sockets { + if denied(&grant.path, &policy.protected_paths) { + continue; + } + let file = &pinned + .get(&grant.path) + .ok_or_else(|| preparation("Landlock socket lookup"))? + .file; + ruleset = add_rule(ruleset, file, AccessFs::ResolveUnix.into())?; + } + + // The shape checks in preparation ensure all directory rules are subtree + // rules. PathBeneath is therefore an exact rule for files and a recursive + // rule only where the typed policy requested one. + let _ = PathScope::Subtree; + Ok(PreparedLandlock { ruleset }) +} + +pub(crate) fn apply(prepared: PreparedLandlock) -> Result<(), LinuxError> { + let status = prepared + .ruleset + .set_compatibility(CompatLevel::HardRequirement) + .restrict_self() + .map_err(|_| enforcement("Landlock application"))?; + if status.ruleset != RulesetStatus::FullyEnforced || !status.no_new_privs { + return Err(enforcement("Landlock completeness")); + } + Ok(()) +} + +fn add_rule( + ruleset: RulesetCreated, + file: &File, + rights: BitFlags, +) -> Result { + ruleset + .add_rule(PathBeneath::new(file, rights)) + .map_err(|_| preparation("Landlock rule addition")) +} + +fn read_rights(kind: PinnedKind) -> BitFlags { + if kind == PinnedKind::Directory { + AccessFs::ReadFile | AccessFs::ReadDir + } else { + AccessFs::ReadFile.into() + } +} + +fn write_rights() -> BitFlags { + // ABI 9's `from_write` includes ResolveUnix. Socket connection authority + // is independent from filesystem mutation, so use the complete fixed ABI 8 + // mutation set and add ResolveUnix only for typed socket grants. + AccessFs::from_write(ABI::V8) +} + +fn preparation(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::PreparationFailed, phase) +} + +fn enforcement(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::EnforcementFailed, phase) +} + +fn unsupported(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::Unsupported, phase) +} diff --git a/crates/linux/src/lib.rs b/crates/linux/src/lib.rs new file mode 100644 index 0000000..0749012 --- /dev/null +++ b/crates/linux/src/lib.rs @@ -0,0 +1,38 @@ +//! Linux-native enforcement for a validated Sandy policy. +//! +//! This is an implementation crate used by the supported Rust facade and the +//! CLI bootstrap. It is deliberately split into a deterministic plan, ambient +//! preparation, and an irreversible application transaction. + +#![deny(unsafe_code)] +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(missing_docs)] + +#[cfg(target_os = "linux")] +mod apply; +#[cfg(target_os = "linux")] +mod capabilities; +mod error; +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +mod ffi; +#[cfg(target_os = "linux")] +mod landlock; +#[cfg(target_os = "linux")] +mod mount; +#[cfg(target_os = "linux")] +mod namespace; +mod plan; +#[cfg(target_os = "linux")] +mod prepare; +#[cfg(target_os = "linux")] +mod seccomp; +mod support; + +#[cfg(target_os = "linux")] +pub use apply::apply; +pub use error::{LinuxError, LinuxErrorKind}; +pub use plan::{LinuxPolicyPlan, plan}; +#[cfg(target_os = "linux")] +pub use prepare::{PreparedLinuxSandbox, prepare}; +pub use support::{REQUIRED_LANDLOCK_ABI, SupportInfo, probe}; diff --git a/crates/linux/src/mount.rs b/crates/linux/src/mount.rs new file mode 100644 index 0000000..ae5625f --- /dev/null +++ b/crates/linux/src/mount.rs @@ -0,0 +1,134 @@ +use std::{ + fs::{self, OpenOptions}, + os::{ + fd::AsRawFd, + unix::fs::{OpenOptionsExt, PermissionsExt}, + }, + path::{Path, PathBuf}, +}; + +use sandy_core::AbsolutePath; + +use crate::{ + LinuxError, LinuxErrorKind, ffi, + prepare::{MountPreparation, PinnedKind}, +}; + +const STAGING_ROOT: &str = "/tmp/.sandy-root"; + +pub(crate) fn construct_and_enter(preparation: &MountPreparation) -> Result<(), LinuxError> { + let tmp = ffi::c_string("/tmp").map_err(|_| enforcement("root construction"))?; + ffi::mount_tmpfs(&tmp).map_err(|_| enforcement("temporary root isolation"))?; + fs::create_dir(STAGING_ROOT).map_err(|_| enforcement("root construction"))?; + fs::set_permissions(STAGING_ROOT, fs::Permissions::from_mode(0o700)) + .map_err(|_| enforcement("root permissions"))?; + let staging = ffi::c_string(STAGING_ROOT).map_err(|_| enforcement("root construction"))?; + ffi::mount_tmpfs(&staging).map_err(|_| enforcement("private root mount"))?; + fs::create_dir(format!("{STAGING_ROOT}/.old_root")) + .map_err(|_| enforcement("root construction"))?; + + for requirement in &preparation.mounts { + create_target(&requirement.path, preparation)?; + } + for protection in &preparation.protections { + create_target(&protection.path, preparation)?; + } + + let root = OpenOptions::new() + .read(true) + .custom_flags(libc::O_PATH | libc::O_CLOEXEC) + .open(STAGING_ROOT) + .map_err(|_| enforcement("private root pinning"))?; + + for requirement in &preparation.mounts { + let source = preparation + .pinned + .get(&requirement.path) + .ok_or_else(|| enforcement("mount source lookup"))?; + attach( + &root, + &requirement.path, + &source.file, + requirement.recursive, + !requirement.writable, + source.kind == PinnedKind::Device, + )?; + } + for protection in &preparation.protections { + let source = preparation + .pinned + .get(&protection.path) + .ok_or_else(|| enforcement("write-protection lookup"))?; + attach( + &root, + &protection.path, + &source.file, + protection.recursive, + protection.read_only, + source.kind == PinnedKind::Device, + )?; + } + + std::env::set_current_dir(STAGING_ROOT).map_err(|_| enforcement("root entry"))?; + ffi::pivot_root(c".", c".old_root").map_err(|_| enforcement("root entry"))?; + std::env::set_current_dir("/").map_err(|_| enforcement("root entry"))?; + ffi::detach_mount(c"/.old_root").map_err(|_| enforcement("old root detachment"))?; + fs::remove_dir("/.old_root").map_err(|_| enforcement("old root cleanup"))?; + + if let Some(working_directory) = &preparation.working_directory { + std::env::set_current_dir(working_directory.as_path()) + .map_err(|_| enforcement("working directory restoration"))?; + } + Ok(()) +} + +fn create_target(path: &AbsolutePath, preparation: &MountPreparation) -> Result<(), LinuxError> { + let source = preparation + .pinned + .get(path) + .ok_or_else(|| enforcement("mount target lookup"))?; + let target = target_path(path); + if source.kind == PinnedKind::Directory { + fs::create_dir_all(&target).map_err(|_| enforcement("mount target creation"))?; + } else { + let parent = target + .parent() + .ok_or_else(|| enforcement("mount target creation"))?; + fs::create_dir_all(parent).map_err(|_| enforcement("mount target creation"))?; + if !target.exists() { + OpenOptions::new() + .write(true) + .create_new(true) + .open(&target) + .map_err(|_| enforcement("mount target creation"))?; + } + } + Ok(()) +} + +fn attach( + root: &std::fs::File, + path: &AbsolutePath, + source: &std::fs::File, + recursive: bool, + read_only: bool, + allow_device: bool, +) -> Result<(), LinuxError> { + let relative = path.as_str().strip_prefix('/').unwrap_or(path.as_str()); + let relative = ffi::c_string(relative).map_err(|_| enforcement("mount target pinning"))?; + let target = ffi::open_path_at(root.as_raw_fd(), &relative) + .map_err(|_| enforcement("mount target pinning"))?; + let detached = ffi::clone_mount(source.as_raw_fd(), recursive, read_only, allow_device) + .map_err(|_| enforcement("detached mount creation"))?; + ffi::attach_mount(detached.as_raw_fd(), target.as_raw_fd()) + .map_err(|_| enforcement("mount attachment")) +} + +fn target_path(path: &AbsolutePath) -> PathBuf { + let relative = path.as_str().strip_prefix('/').unwrap_or(path.as_str()); + Path::new(STAGING_ROOT).join(relative) +} + +fn enforcement(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::EnforcementFailed, phase) +} diff --git a/crates/linux/src/namespace.rs b/crates/linux/src/namespace.rs new file mode 100644 index 0000000..e47cd2c --- /dev/null +++ b/crates/linux/src/namespace.rs @@ -0,0 +1,89 @@ +use std::{fs, io}; + +use crate::{LinuxError, LinuxErrorKind, ffi}; + +pub(crate) struct NamespacePreparation { + effective_uid: u32, + effective_gid: u32, + pub(crate) last_capability: u32, +} + +pub(crate) fn prepare() -> Result { + ensure_single_threaded()?; + let (effective_uid, effective_gid) = ffi::effective_ids(); + let raw_last_capability = fs::read_to_string("/proc/sys/kernel/cap_last_cap") + .map_err(|_| preparation("capability discovery"))?; + if raw_last_capability.len() > 16 { + return Err(preparation("capability discovery")); + } + let last_capability = raw_last_capability + .trim() + .parse::() + .map_err(|_| preparation("capability discovery"))?; + if last_capability >= 64 { + return Err(LinuxError::new( + LinuxErrorKind::Unsupported, + "capability ABI", + )); + } + + Ok(NamespacePreparation { + effective_uid, + effective_gid, + last_capability, + }) +} + +pub(crate) fn enter( + preparation: &NamespacePreparation, + block_network: bool, +) -> Result<(), LinuxError> { + ffi::unshare_namespaces(block_network).map_err(|_| enforcement("namespace creation"))?; + + match fs::write("/proc/self/setgroups", b"deny") { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(_) => return Err(enforcement("group mapping")), + } + fs::write( + "/proc/self/uid_map", + format!("0 {} 1\n", preparation.effective_uid), + ) + .map_err(|_| enforcement("user mapping"))?; + fs::write( + "/proc/self/gid_map", + format!("0 {} 1\n", preparation.effective_gid), + ) + .map_err(|_| enforcement("group mapping"))?; + + ffi::make_mounts_private().map_err(|_| enforcement("mount propagation")) +} + +fn ensure_single_threaded() -> Result<(), LinuxError> { + let mut entries = fs::read_dir("/proc/self/task") + .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "thread inspection"))?; + let first = entries + .next() + .transpose() + .map_err(|_| preparation("thread inspection"))?; + let second = entries + .next() + .transpose() + .map_err(|_| preparation("thread inspection"))?; + if first.is_none() || second.is_some() { + Err(LinuxError::new( + LinuxErrorKind::Unsupported, + "single-threaded precondition", + )) + } else { + Ok(()) + } +} + +fn preparation(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::PreparationFailed, phase) +} + +fn enforcement(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::EnforcementFailed, phase) +} diff --git a/crates/linux/src/plan.rs b/crates/linux/src/plan.rs new file mode 100644 index 0000000..9088512 --- /dev/null +++ b/crates/linux/src/plan.rs @@ -0,0 +1,142 @@ +use sandy_core::{ + FileMetadataPolicy, NetworkPolicy, PathScope, RuntimeCompatibility, ValidatedPolicy, +}; + +use crate::{LinuxError, LinuxErrorKind}; + +/// Deterministic Linux lowering of a validated platform-neutral policy. +/// +/// This type owns no file descriptors and performs no ambient discovery. It +/// exists so representability decisions can be reviewed and tested separately +/// from path pinning and irreversible enforcement. +pub struct LinuxPolicyPlan { + pub(crate) policy: ValidatedPolicy, +} + +impl LinuxPolicyPlan { + /// Returns whether a private network namespace is required. + #[must_use] + pub fn blocks_network(&self) -> bool { + self.policy.spec().network == NetworkPolicy::BlockAll + } + + /// Returns whether ordinary descendant process creation is authorized. + #[must_use] + pub fn allows_subprocesses(&self) -> bool { + self.policy.spec().allow_subprocesses + } +} + +/// Lowers a validated policy into deterministic Linux semantics. +/// +/// Unsupported combinations are rejected here whenever their incompatibility +/// is independent of the ambient filesystem. File-type-dependent checks occur +/// during [`crate::prepare`]. +pub fn plan(policy: &ValidatedPolicy) -> Result { + let spec = policy.spec(); + + if spec.file_metadata != FileMetadataPolicy::Deny { + return Err(unsupported("filesystem metadata policy")); + } + if !spec.local_host_tcp.is_empty() { + return Err(unsupported("local-host TCP policy")); + } + if spec.runtime_compatibility != RuntimeCompatibility::Minimal + && spec.runtime_compatibility != RuntimeCompatibility::ForegroundCli + { + return Err(unsupported("runtime compatibility policy")); + } + + // A private filesystem view hides non-granted paths completely. A deny + // nested inside a visible subtree, however, would leave at least a mount + // placeholder observable. Reject it until an implementation can prove the + // same metadata-denial semantics as a fully absent path. + for protected in &spec.protected_paths { + let overlaps_visible_subtree = spec.files.iter().any(|grant| { + grant.scope == PathScope::Subtree + && protected.as_path().starts_with(grant.path.as_path()) + }) || spec.executables.iter().any(|grant| { + grant.scope == PathScope::Subtree + && protected.as_path().starts_with(grant.path.as_path()) + }); + if overlaps_visible_subtree { + return Err(unsupported("nested confidential path")); + } + } + + Ok(LinuxPolicyPlan { + policy: policy.clone(), + }) +} + +fn unsupported(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::Unsupported, phase) +} + +#[cfg(test)] +mod tests { + use sandy_core::{ + AbsolutePath, AccessMode, FileGrant, NetworkPolicy, PathScope, PolicySpec, ValidatedPolicy, + }; + + use super::*; + + fn path(value: &str) -> Result> { + Ok(AbsolutePath::new(value.to_owned())?) + } + + #[test] + fn accepts_monotonic_allowlists() -> Result<(), Box> { + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: path("/workspace")?, + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }], + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + })?; + + let plan = plan(&policy)?; + assert!(plan.blocks_network()); + assert!(!plan.allows_subprocesses()); + Ok(()) + } + + #[test] + fn rejects_confidential_child_of_visible_subtree() -> Result<(), Box> { + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: path("/workspace")?, + access: AccessMode::Read, + scope: PathScope::Subtree, + }], + protected_paths: vec![path("/workspace/.secret")?], + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + })?; + + let error = plan(&policy).err().ok_or("policy unexpectedly supported")?; + assert_eq!(error.kind(), LinuxErrorKind::Unsupported); + Ok(()) + } + + #[test] + fn rejects_local_host_tcp_without_broadening_it() -> Result<(), Box> { + use sandy_core::{LocalHostTcpGrant, LocalHostTcpOperation, TcpPort}; + + let port = TcpPort::new(443).ok_or("invalid test port")?; + let policy = ValidatedPolicy::try_from(PolicySpec { + local_host_tcp: vec![LocalHostTcpGrant { + port, + operation: LocalHostTcpOperation::Connect, + }], + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + })?; + + let error = plan(&policy).err().ok_or("policy unexpectedly supported")?; + assert_eq!(error.kind(), LinuxErrorKind::Unsupported); + Ok(()) + } +} diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs new file mode 100644 index 0000000..9aded82 --- /dev/null +++ b/crates/linux/src/prepare.rs @@ -0,0 +1,281 @@ +use std::{ + collections::BTreeMap, + fs::{File, OpenOptions}, + os::unix::fs::{FileTypeExt, OpenOptionsExt}, +}; + +use sandy_core::{AbsolutePath, AccessMode, PathScope}; + +use crate::{LinuxError, LinuxErrorKind, LinuxPolicyPlan, landlock, namespace, seccomp}; + +#[derive(Clone, Copy, Eq, PartialEq)] +pub(crate) enum PinnedKind { + Directory, + Regular, + Socket, + Device, + Other, +} + +pub(crate) struct PinnedPath { + pub(crate) file: File, + pub(crate) kind: PinnedKind, +} + +pub(crate) struct MountRequirement { + pub(crate) path: AbsolutePath, + pub(crate) recursive: bool, + pub(crate) writable: bool, +} + +pub(crate) struct ProtectionRequirement { + pub(crate) path: AbsolutePath, + pub(crate) recursive: bool, + pub(crate) read_only: bool, +} + +pub(crate) struct MountPreparation { + pub(crate) pinned: BTreeMap, + pub(crate) mounts: Vec, + pub(crate) protections: Vec, + pub(crate) working_directory: Option, +} + +/// Fully prepared Linux sandbox whose remaining operations are irreversible. +/// +/// This value owns all pinned path descriptors, the already-created Landlock +/// ruleset, compiled seccomp programs, and namespace inputs. Dropping it before +/// [`crate::apply`] leaves the process unrestricted. +#[must_use] +pub struct PreparedLinuxSandbox { + pub(crate) mount: MountPreparation, + pub(crate) landlock: landlock::PreparedLandlock, + pub(crate) seccomp: seccomp::SeccompPrograms, + pub(crate) namespace: namespace::NamespacePreparation, + pub(crate) block_network: bool, +} + +/// Pins every ambient resource and prepares native enforcement without +/// restricting the current process. +pub fn prepare( + plan: LinuxPolicyPlan, + working_directory: &AbsolutePath, +) -> Result { + let namespace = namespace::prepare()?; + let mount = prepare_mounts(&plan, working_directory)?; + let landlock = landlock::prepare(plan.policy.spec(), &mount.pinned)?; + let block_network = plan.blocks_network(); + let seccomp = seccomp::compile( + plan.allows_subprocesses(), + block_network, + !plan.policy.spec().unix_sockets.is_empty(), + )?; + Ok(PreparedLinuxSandbox { + mount, + landlock, + seccomp, + namespace, + block_network, + }) +} + +fn prepare_mounts( + plan: &LinuxPolicyPlan, + working_directory: &AbsolutePath, +) -> Result { + let spec = plan.policy.spec(); + let mut requested = BTreeMap::::new(); + + for grant in &spec.files { + if denied(&grant.path, &spec.protected_paths) { + continue; + } + let entry = requested + .entry(grant.path.clone()) + .or_insert_with(|| MountRequirement { + path: grant.path.clone(), + recursive: false, + writable: false, + }); + entry.recursive |= grant.scope == PathScope::Subtree; + entry.writable |= grant.access == AccessMode::ReadWrite; + } + for grant in &spec.executables { + if denied(&grant.path, &spec.protected_paths) { + continue; + } + let entry = requested + .entry(grant.path.clone()) + .or_insert_with(|| MountRequirement { + path: grant.path.clone(), + recursive: false, + writable: false, + }); + entry.recursive |= grant.scope == PathScope::Subtree; + } + for grant in &spec.unix_sockets { + if !denied(&grant.path, &spec.protected_paths) { + requested + .entry(grant.path.clone()) + .or_insert_with(|| MountRequirement { + path: grant.path.clone(), + recursive: false, + writable: false, + }); + } + } + + let mut pinned = BTreeMap::new(); + for path in requested.keys().chain( + spec.write_protections + .iter() + .filter(|protection| visible(&protection.path, requested.values())) + .map(|protection| &protection.path), + ) { + if !pinned.contains_key(path) { + pinned.insert(path.clone(), pin(path)?); + } + } + + for grant in &spec.files { + if denied(&grant.path, &spec.protected_paths) { + continue; + } + let pinned_path = pinned + .get(&grant.path) + .ok_or_else(|| preparation("path pinning"))?; + let exact_directory = + grant.scope == PathScope::Exact && pinned_path.kind == PinnedKind::Directory; + let subtree_non_directory = + grant.scope == PathScope::Subtree && pinned_path.kind != PinnedKind::Directory; + let exact_read_write = + grant.scope == PathScope::Exact && grant.access == AccessMode::ReadWrite; + if exact_directory || subtree_non_directory || exact_read_write { + return Err(unsupported("filesystem grant shape")); + } + } + for grant in &spec.executables { + if denied(&grant.path, &spec.protected_paths) { + continue; + } + let kind = pinned + .get(&grant.path) + .ok_or_else(|| preparation("executable pinning"))? + .kind; + if (grant.scope == PathScope::Exact && kind == PinnedKind::Directory) + || (grant.scope == PathScope::Subtree && kind != PinnedKind::Directory) + { + return Err(unsupported("executable grant shape")); + } + } + for grant in &spec.unix_sockets { + if denied(&grant.path, &spec.protected_paths) { + continue; + } + if pinned + .get(&grant.path) + .ok_or_else(|| preparation("socket pinning"))? + .kind + != PinnedKind::Socket + { + return Err(unsupported("pathname socket type")); + } + } + + let mut mounts = requested.into_values().collect::>(); + mounts.sort_by_key(|entry| entry.path.as_path().components().count()); + let mut effective = Vec::::new(); + for mount in mounts { + let covered = effective.iter().any(|parent| { + parent.recursive + && mount.path.as_path().starts_with(parent.path.as_path()) + && (!mount.writable || parent.writable) + }); + if !covered { + effective.push(mount); + } + } + + let protections = spec + .write_protections + .iter() + .filter(|protection| visible(&protection.path, effective.iter())) + .map(|protection| { + let kind = pinned + .get(&protection.path) + .ok_or_else(|| preparation("write-protection pinning"))? + .kind; + if kind == PinnedKind::Directory && protection.scope == PathScope::Exact { + return Err(unsupported("exact directory write protection")); + } + Ok(ProtectionRequirement { + path: protection.path.clone(), + recursive: protection.scope == PathScope::Subtree, + read_only: true, + }) + }) + .collect::, LinuxError>>()?; + + let working_directory = + visible(working_directory, effective.iter()).then(|| working_directory.clone()); + + Ok(MountPreparation { + pinned, + mounts: effective, + protections, + working_directory, + }) +} + +fn pin(path: &AbsolutePath) -> Result { + let root = OpenOptions::new() + .read(true) + .custom_flags(libc::O_PATH | libc::O_CLOEXEC) + .open("/") + .map_err(|_| preparation("root pinning"))?; + let relative = path.as_str().strip_prefix('/').unwrap_or(path.as_str()); + let relative = if relative.is_empty() { "." } else { relative }; + let relative = crate::ffi::c_string(relative).map_err(|_| preparation("path pinning"))?; + let owned = crate::ffi::open_path_at(std::os::fd::AsRawFd::as_raw_fd(&root), &relative) + .map_err(|_| preparation("path pinning"))?; + let file = crate::ffi::file_from_owned(owned); + let file_type = file + .metadata() + .map_err(|_| preparation("path inspection"))? + .file_type(); + let kind = if file_type.is_dir() { + PinnedKind::Directory + } else if file_type.is_file() { + PinnedKind::Regular + } else if file_type.is_socket() { + PinnedKind::Socket + } else if file_type.is_char_device() || file_type.is_block_device() { + PinnedKind::Device + } else { + PinnedKind::Other + }; + Ok(PinnedPath { file, kind }) +} + +pub(crate) fn denied(path: &AbsolutePath, protected_paths: &[AbsolutePath]) -> bool { + protected_paths + .iter() + .any(|protected| path.as_path().starts_with(protected.as_path())) +} + +fn visible<'a>( + path: &AbsolutePath, + mounts: impl IntoIterator, +) -> bool { + mounts.into_iter().any(|mount| { + path == &mount.path || (mount.recursive && path.as_path().starts_with(mount.path.as_path())) + }) +} + +fn preparation(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::PreparationFailed, phase) +} + +fn unsupported(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::Unsupported, phase) +} diff --git a/crates/linux/src/seccomp.rs b/crates/linux/src/seccomp.rs new file mode 100644 index 0000000..9481381 --- /dev/null +++ b/crates/linux/src/seccomp.rs @@ -0,0 +1,140 @@ +use std::{collections::BTreeMap, convert::TryFrom}; + +use seccompiler::{ + BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter, + SeccompRule, TargetArch, +}; + +use crate::{LinuxError, LinuxErrorKind}; + +pub(crate) struct SeccompPrograms { + clone3: BpfProgram, + topology: BpfProgram, +} + +pub(crate) fn compile( + allow_subprocesses: bool, + block_network: bool, + allow_pathname_unix: bool, +) -> Result { + let architecture = TargetArch::try_from(std::env::consts::ARCH) + .map_err(|_| unsupported("seccomp architecture"))?; + + let clone3 = compile_filter( + BTreeMap::from([(libc::SYS_clone3, Vec::new())]), + SeccompAction::Errno(libc::ENOSYS as u32), + architecture, + )?; + + let mut denied = BTreeMap::from([ + (libc::SYS_unshare, Vec::new()), + (libc::SYS_setns, Vec::new()), + (libc::SYS_mount, Vec::new()), + (libc::SYS_umount2, Vec::new()), + (libc::SYS_pivot_root, Vec::new()), + (libc::SYS_chroot, Vec::new()), + (libc::SYS_open_tree, Vec::new()), + (libc::SYS_move_mount, Vec::new()), + (libc::SYS_mount_setattr, Vec::new()), + (libc::SYS_fsopen, Vec::new()), + (libc::SYS_fsconfig, Vec::new()), + (libc::SYS_fsmount, Vec::new()), + (libc::SYS_fspick, Vec::new()), + ]); + + let namespace_flags = [ + libc::CLONE_NEWCGROUP, + libc::CLONE_NEWIPC, + libc::CLONE_NEWNET, + libc::CLONE_NEWNS, + libc::CLONE_NEWPID, + libc::CLONE_NEWTIME, + libc::CLONE_NEWUSER, + libc::CLONE_NEWUTS, + ]; + let mut clone_rules = namespace_flags + .into_iter() + .map(|flag| masked_clone_rule(flag as u64, flag as u64)) + .collect::, _>>()?; + if !allow_subprocesses { + clone_rules.push(masked_clone_rule(libc::CLONE_THREAD as u64, 0)?); + denied.insert(libc::SYS_fork, Vec::new()); + denied.insert(libc::SYS_vfork, Vec::new()); + denied.insert(libc::SYS_execve, Vec::new()); + denied.insert(libc::SYS_execveat, Vec::new()); + } + denied.insert(libc::SYS_clone, clone_rules); + + if block_network { + let socket_rules = if allow_pathname_unix { + vec![ + SeccompRule::new(vec![ + SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_UNIX as u64, + ) + .map_err(|_| preparation("seccomp compilation"))?, + ]) + .map_err(|_| preparation("seccomp compilation"))?, + ] + } else { + Vec::new() + }; + denied.insert(libc::SYS_socket, socket_rules); + } + + let topology = compile_filter( + denied, + SeccompAction::Errno(libc::EPERM as u32), + architecture, + )?; + Ok(SeccompPrograms { clone3, topology }) +} + +pub(crate) fn apply(programs: &SeccompPrograms) -> Result<(), LinuxError> { + seccompiler::apply_filter(&programs.clone3) + .and_then(|()| seccompiler::apply_filter(&programs.topology)) + .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp application")) +} + +fn compile_filter( + rules: BTreeMap>, + match_action: SeccompAction, + architecture: TargetArch, +) -> Result { + SeccompFilter::new(rules, SeccompAction::Allow, match_action, architecture) + .and_then(BpfProgram::try_from) + .map_err(|_| preparation("seccomp compilation")) +} + +fn masked_clone_rule(mask: u64, value: u64) -> Result { + let condition = SeccompCondition::new( + 0, + SeccompCmpArgLen::Qword, + SeccompCmpOp::MaskedEq(mask), + value, + ) + .map_err(|_| preparation("seccomp compilation"))?; + SeccompRule::new(vec![condition]).map_err(|_| preparation("seccomp compilation")) +} + +fn preparation(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::PreparationFailed, phase) +} + +fn unsupported(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::Unsupported, phase) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compiles_both_process_modes() { + assert!(compile(false, true, false).is_ok()); + assert!(compile(true, false, true).is_ok()); + } +} diff --git a/crates/linux/src/support.rs b/crates/linux/src/support.rs new file mode 100644 index 0000000..3961e88 --- /dev/null +++ b/crates/linux/src/support.rs @@ -0,0 +1,60 @@ +use sandy_core::ValidatedPolicy; + +use crate::{LinuxError, LinuxErrorKind}; + +/// Landlock ABI whose rights Sandy has fixed and tested. +/// +/// ABI 9 is required because pathname Unix-socket connection authority must +/// remain independent from filesystem visibility. +pub const REQUIRED_LANDLOCK_ABI: u32 = 9; + +/// Policy-specific host support established without applying restrictions. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SupportInfo { + landlock_abi: u32, +} + +impl SupportInfo { + /// Returns the fixed Landlock ABI used by this Sandy build. + #[must_use] + pub const fn landlock_abi(self) -> u32 { + self.landlock_abi + } +} + +/// Verifies that the running host can represent the supplied policy exactly. +/// +/// This is diagnostic only. [`crate::prepare`] repeats every authoritative +/// check and remains the gate before native application. +pub fn probe(policy: &ValidatedPolicy) -> Result { + let _ = crate::plan(policy)?; + probe_platform() +} + +#[cfg(target_os = "linux")] +fn probe_platform() -> Result { + use landlock::{ABI, Access, AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr, Scope}; + + Ruleset::default() + .handle_access(AccessFs::from_all(ABI::V9)) + .and_then(|ruleset| ruleset.scope(Scope::Signal | Scope::AbstractUnixSocket)) + .and_then(|ruleset| { + ruleset + .set_compatibility(CompatLevel::HardRequirement) + .create() + .map(|_| ()) + }) + .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "support probe"))?; + + Ok(SupportInfo { + landlock_abi: REQUIRED_LANDLOCK_ABI, + }) +} + +#[cfg(not(target_os = "linux"))] +fn probe_platform() -> Result { + Err(LinuxError::new( + LinuxErrorKind::Unsupported, + "support probe", + )) +} diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs new file mode 100644 index 0000000..4a343cb --- /dev/null +++ b/crates/linux/tests/live_linux.rs @@ -0,0 +1,113 @@ +#[cfg(target_os = "linux")] +mod linux { + use std::{env, fs, net::TcpStream, process::Command, time::Duration}; + + use sandy_core::{ + AbsolutePath, AccessMode, FileGrant, NetworkPolicy, PathScope, PolicySpec, ValidatedPolicy, + WriteProtection, + }; + + const CHILD_MODE: &str = "SANDY_LINUX_LIVE_CHILD"; + const WORKSPACE: &str = "SANDY_LINUX_LIVE_WORKSPACE"; + + pub(super) fn run() -> Result<(), Box> { + if env::var_os(CHILD_MODE).is_some() { + return child_checks(); + } + + exact_directory_grants_are_rejected_before_enforcement()?; + + let root = tempfile::tempdir()?; + let workspace = root.path().join("workspace"); + fs::create_dir(&workspace)?; + fs::write(workspace.join("readable.txt"), "visible")?; + fs::write(workspace.join("locked.txt"), "locked")?; + fs::write(root.path().join("outside.txt"), "hidden")?; + + let status = Command::new(env::current_exe()?) + .env(CHILD_MODE, "1") + .env(WORKSPACE, &workspace) + .status()?; + if !status.success() { + return Err("sacrificial sandbox child failed".into()); + } + Ok(()) + } + + fn child_checks() -> Result<(), Box> { + let workspace = fs::canonicalize(env::var_os(WORKSPACE).ok_or("missing workspace")?)?; + let workspace = AbsolutePath::new( + workspace + .to_str() + .ok_or("workspace path is not UTF-8")? + .to_owned(), + )?; + let locked = AbsolutePath::new(format!("{}/locked.txt", workspace.as_str()))?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: workspace.clone(), + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }], + write_protections: vec![WriteProtection { + path: locked, + scope: PathScope::Exact, + }], + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + })?; + + let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace)?; + sandy_linux::apply(prepared)?; + + if fs::read_to_string("readable.txt")? != "visible" { + return Err("granted file contents changed".into()); + } + fs::write("created.txt", "created")?; + if fs::write("locked.txt", "changed").is_ok() { + return Err("write-protected file was mutable".into()); + } + if fs::metadata("/etc/passwd").is_ok() || fs::metadata("../outside.txt").is_ok() { + return Err("non-granted filesystem data remained visible".into()); + } + if TcpStream::connect_timeout(&"1.1.1.1:80".parse()?, Duration::from_millis(100)).is_ok() { + return Err("blocked network connection succeeded".into()); + } + Ok(()) + } + + fn exact_directory_grants_are_rejected_before_enforcement() + -> Result<(), Box> { + let root = tempfile::tempdir()?; + let path = AbsolutePath::new( + fs::canonicalize(root.path())? + .to_str() + .ok_or("path is not UTF-8")? + .to_owned(), + )?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: path.clone(), + access: AccessMode::Read, + scope: PathScope::Exact, + }], + ..PolicySpec::default() + })?; + + let error = sandy_linux::prepare(sandy_linux::plan(&policy)?, &path) + .err() + .ok_or("exact directory unexpectedly supported")?; + if error.kind() != sandy_linux::LinuxErrorKind::Unsupported { + return Err("exact directory returned the wrong error class".into()); + } + Ok(()) + } +} + +#[cfg(target_os = "linux")] +fn main() -> Result<(), Box> { + linux::run() +} + +#[cfg(not(target_os = "linux"))] +fn main() {} diff --git a/deny.toml b/deny.toml index c461e93..d2d1720 100644 --- a/deny.toml +++ b/deny.toml @@ -10,6 +10,7 @@ confidence-threshold = 0.8 allow = [ "Apache-2.0", "Apache-2.0 WITH LLVM-exception", + "BSD-3-Clause", "MIT", "Unicode-3.0", ] diff --git a/docs/LINUX_SECURITY_MODEL.md b/docs/LINUX_SECURITY_MODEL.md new file mode 100644 index 0000000..8ed2ecd --- /dev/null +++ b/docs/LINUX_SECURITY_MODEL.md @@ -0,0 +1,97 @@ +# Linux security model + +The Linux backend is one native security boundary shared by the supported Rust +facade and the CLI bootstrap. It adds no product policy, profiles, arguments, +environment values, or implicit filesystem grants. + +```text +ValidatedPolicy + | + v +LinuxPolicyPlan deterministic and ambient-free + | + v +PreparedLinuxSandbox pins paths and compiles native rules + | + v +apply() irreversible current-process transition +``` + +## Enforcement transaction + +Preparation verifies the single-threaded precondition, rejects unsupported +policy combinations, opens policy paths with `openat2`, creates a hard-required +Landlock ruleset, and compiles the seccomp filters. A preparation failure leaves +the process unrestricted. + +Application then: + +1. enters a user and mount namespace, plus a network namespace for `BlockAll`; +2. installs one-entry UID and GID maps and makes mount propagation private; +3. constructs a private tmpfs root from descriptor-pinned grants; +4. overlays typed write protections and enters the private root; +5. applies the complete Landlock ruleset and `no_new_privs`; +6. removes ambient, bounding, effective, permitted, and inheritable capabilities; +7. installs seccomp filters that freeze namespace and mount topology and enforce + subprocess and network-socket creation policy. + +An application failure can leave the current process partially restricted. The +caller must terminate immediately. The CLI bootstrap exits without executing +its target. + +## Fixed native semantics + +Sandy requires Landlock ABI 9. The implementation requests only the fixed ABI 9 +filesystem rights and scope flags it has reviewed; a newer host ABI does not +silently add restrictions. ABI 9 is required so pathname Unix-socket connect +authority remains separate from filesystem visibility. + +Read authority never includes execute authority. Read/write authority uses the +fixed mutation rights through ABI 8 because ABI 9's aggregate write set also +contains pathname-socket resolution. `ResolveUnix` is added only for an exact +typed socket grant. + +`BlockAll` uses a private network namespace and denies new non-Unix socket file +descriptors. When the policy has no pathname Unix-socket grants, all new socket +file descriptors are denied. Already-open descriptors remain caller-held +capabilities, matching the public current-process contract. + +`clone3` is rejected with `ENOSYS` so standard libraries can use their legacy +`clone` fallback. Legacy `clone` always rejects namespace flags. When subprocess +creation is disabled it additionally permits only thread-forming clones and +rejects `fork`, `vfork`, `execve`, and `execveat`. + +## Filesystem view + +The backend pins each source path before enforcement, clones mounts from those +descriptors, and attaches them to descriptor-pinned targets in a private tmpfs +root. Non-granted siblings, host `/proc`, `/sys`, `/run`, and the former host +root are absent. Open descriptors and memory acquired before `apply()` are not +revoked. + +An exact protected file is overmounted read-only. A protected subtree is +recursively read-only. + +The following combinations are rejected before enforcement rather than being +weakened: + +- a confidential deny nested below a visible subtree, because a mount mask + would still expose placeholder metadata; +- exact grants on directories, because native directory rules are hierarchical; +- subtree grants on non-directories; +- exact read/write file grants, because content mutation and parent-directory + replacement cannot both be represented without broadening authority; +- exact directory write protections, because pinning a mount point prevents + replacement but not every metadata mutation; +- global metadata compatibility; +- local-host-only TCP exceptions. + +The private root deliberately omits procfs. Product compatibility grants and +any future selective procfs design belong to the CLI boundary and require live +positive and negative tests before shipping. + +## Unsafe boundary + +All Linux unsafe code and raw native calls are confined to +`crates/linux/src/ffi.rs`. It exposes owned descriptors and safe functions. Raw +pointers, native structures, and unsafe functions do not escape that module. From 4f00cb643c500d49527ecbbac46d3866a8e32181 Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 18:09:32 +0200 Subject: [PATCH 02/20] fix(linux): handle pre-denied setgroups state --- crates/linux/src/namespace.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/linux/src/namespace.rs b/crates/linux/src/namespace.rs index e47cd2c..180259a 100644 --- a/crates/linux/src/namespace.rs +++ b/crates/linux/src/namespace.rs @@ -40,16 +40,23 @@ pub(crate) fn enter( ) -> Result<(), LinuxError> { ffi::unshare_namespaces(block_network).map_err(|_| enforcement("namespace creation"))?; - match fs::write("/proc/self/setgroups", b"deny") { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(_) => return Err(enforcement("group mapping")), - } fs::write( "/proc/self/uid_map", format!("0 {} 1\n", preparation.effective_uid), ) .map_err(|_| enforcement("user mapping"))?; + match fs::write("/proc/self/setgroups", b"deny") { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { + let state = fs::read_to_string("/proc/self/setgroups") + .map_err(|_| enforcement("group mapping"))?; + if state.trim() != "deny" { + return Err(enforcement("group mapping")); + } + } + Err(_) => return Err(enforcement("group mapping")), + } fs::write( "/proc/self/gid_map", format!("0 {} 1\n", preparation.effective_gid), From 490bf1d1022fea0ad072166b24f37ffc936cd076 Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 18:47:19 +0200 Subject: [PATCH 03/20] fix(linux): probe namespace enforcement safely --- .github/workflows/ci.yml | 6 ++ crates/linux/src/apply.rs | 4 +- crates/linux/src/ffi.rs | 106 +++++++++++++++++++++++++++++++ crates/linux/src/landlock.rs | 16 +++-- crates/linux/src/mount.rs | 6 +- crates/linux/src/namespace.rs | 14 +++- crates/linux/src/prepare.rs | 12 ++-- crates/linux/src/support.rs | 8 ++- crates/linux/tests/live_linux.rs | 30 +++++++++ docs/LINUX_SECURITY_MODEL.md | 25 ++++++-- 10 files changed, 202 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09ce500..1baacfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,12 @@ jobs: run: rustup toolchain install 1.91.1 --profile minimal --component clippy - name: Lint Linux backend run: cargo clippy -p sandy-linux --all-targets --all-features --locked -- -D warnings + - name: Verify restricted user namespaces fail closed + env: + SANDY_LINUX_EXPECT_UNSUPPORTED: "1" + run: cargo test -p sandy-linux --test live_linux --locked + - name: Enable user namespaces for positive enforcement tests + run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Test Linux backend run: make test-live-linux - name: Preserve unsupported facade contract diff --git a/crates/linux/src/apply.rs b/crates/linux/src/apply.rs index b78d725..8507708 100644 --- a/crates/linux/src/apply.rs +++ b/crates/linux/src/apply.rs @@ -19,9 +19,7 @@ pub fn apply(prepared: PreparedLinuxSandbox) -> Result<(), LinuxError> { fn verify_postconditions() -> Result<(), LinuxError> { // After pivot_root the former host root and procfs must be absent. This is // an invariant check, not a source of policy authority. - if std::path::Path::new("/.old_root").exists() - || std::path::Path::new("/proc/self/root").exists() - { + if std::path::Path::new("/.old_root").exists() { Err(LinuxError::new( LinuxErrorKind::EnforcementFailed, "postcondition verification", diff --git a/crates/linux/src/ffi.rs b/crates/linux/src/ffi.rs index 7217390..52f174c 100644 --- a/crates/linux/src/ffi.rs +++ b/crates/linux/src/ffi.rs @@ -48,6 +48,112 @@ pub(crate) fn unshare_namespaces(block_network: bool) -> io::Result<()> { cvt(unsafe { libc::unshare(flags) }).map(|_| ()) } +/// Exercises the irreversible namespace setup in a sacrificial child. +/// +/// Every allocation is completed before `fork`, and the child executes only +/// async-signal-safe libc/syscall operations before `_exit`. +pub(crate) fn probe_namespace_setup( + effective_uid: u32, + effective_gid: u32, + block_network: bool, +) -> io::Result<()> { + let uid_map = format!("{effective_uid} {effective_uid} 1\n").into_bytes(); + let gid_map = format!("{effective_gid} {effective_gid} 1\n").into_bytes(); + + // SAFETY: callers have verified that the process is single-threaded. The + // child uses only the fixed native operations in `namespace_probe_child` + // and terminates through `_exit`; the parent waits synchronously. + let child = unsafe { libc::fork() }; + if child < 0 { + return Err(io::Error::last_os_error()); + } + if child == 0 { + // SAFETY: this is the sacrificial post-fork child described above. + unsafe { namespace_probe_child(&uid_map, &gid_map, block_network) } + } + + let mut status = 0; + loop { + // SAFETY: `child` is the positive PID returned by fork, `status` is a + // valid writable integer, and no ownership is transferred. + let result = unsafe { libc::waitpid(child, &mut status, 0) }; + if result == child { + break; + } + if result < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(io::Error::last_os_error()); + } + if libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(libc::EPERM)) + } +} + +unsafe fn namespace_probe_child(uid_map: &[u8], gid_map: &[u8], block_network: bool) -> ! { + let mut flags = libc::CLONE_NEWUSER | libc::CLONE_NEWNS; + if block_network { + flags |= libc::CLONE_NEWNET; + } + // SAFETY: this child is single-threaded and disposable; all pointers below + // refer to fixed C strings or buffers allocated before fork. + let succeeded = unsafe { libc::unshare(flags) } == 0 + && unsafe { write_probe_file(c"/proc/self/uid_map", uid_map) } + && unsafe { write_probe_file(c"/proc/self/setgroups", b"deny") } + && unsafe { write_probe_file(c"/proc/self/gid_map", gid_map) } + && unsafe { + libc::mount( + std::ptr::null(), + c"/".as_ptr(), + std::ptr::null(), + (libc::MS_REC | libc::MS_PRIVATE) as libc::c_ulong, + std::ptr::null(), + ) + } == 0 + && unsafe { + libc::mount( + c"tmpfs".as_ptr(), + c"/tmp".as_ptr(), + c"tmpfs".as_ptr(), + (libc::MS_NOSUID | libc::MS_NODEV) as libc::c_ulong, + c"mode=0700,size=1m".as_ptr().cast(), + ) + } == 0; + // SAFETY: `_exit` performs no Rust cleanup and cannot return. + unsafe { libc::_exit(if succeeded { 0 } else { 1 }) } +} + +unsafe fn write_probe_file(path: &CStr, contents: &[u8]) -> bool { + // SAFETY: `path` is NUL-terminated and remains valid for the call. + let fd = unsafe { libc::open(path.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC) }; + if fd < 0 { + return path == c"/proc/self/setgroups" + && io::Error::last_os_error().kind() == io::ErrorKind::NotFound; + } + let mut offset = 0; + while offset < contents.len() { + // SAFETY: the byte slice remains valid, and its remaining length is + // passed exactly. The descriptor is owned by this child. + let written = unsafe { + libc::write( + fd, + contents[offset..].as_ptr().cast(), + contents.len() - offset, + ) + }; + if written <= 0 { + // SAFETY: `fd` is a live descriptor opened above. + unsafe { libc::close(fd) }; + return false; + } + offset += written as usize; + } + // SAFETY: `fd` is a live descriptor opened above and is closed once. + (unsafe { libc::close(fd) }) == 0 +} + pub(crate) fn make_mounts_private() -> io::Result<()> { // SAFETY: all pointers are either null as required by mount(2), or the // process-lifetime static C string `/`. No pointer is retained. The flags diff --git a/crates/linux/src/landlock.rs b/crates/linux/src/landlock.rs index cf1363d..f7692e3 100644 --- a/crates/linux/src/landlock.rs +++ b/crates/linux/src/landlock.rs @@ -4,7 +4,7 @@ use landlock::{ ABI, Access, AccessFs, BitFlags, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr, RulesetCreated, RulesetCreatedAttr, RulesetStatus, Scope, }; -use sandy_core::{AccessMode, PathScope, PolicySpec}; +use sandy_core::{AccessMode, NetworkPolicy, PathScope, PolicySpec}; use crate::{ LinuxError, LinuxErrorKind, @@ -19,10 +19,18 @@ pub(crate) fn prepare( policy: &PolicySpec, pinned: &BTreeMap, ) -> Result { + let handled = AccessFs::from_all(ABI::V8) | AccessFs::ResolveUnix; let builder = Ruleset::default() - .handle_access(AccessFs::from_all(ABI::V9)) - .and_then(|ruleset| ruleset.scope(Scope::Signal | Scope::AbstractUnixSocket)) - .map_err(|_| unsupported("Landlock ABI"))? + .handle_access(handled) + .map_err(|_| unsupported("Landlock ABI"))?; + let scope = if policy.network == NetworkPolicy::BlockAll { + Scope::Signal | Scope::AbstractUnixSocket + } else { + Scope::Signal.into() + }; + let builder = builder + .scope(scope) + .map_err(|_| unsupported("Landlock scope"))? .set_compatibility(CompatLevel::HardRequirement); let mut ruleset = builder .create() diff --git a/crates/linux/src/mount.rs b/crates/linux/src/mount.rs index ae5625f..1d760e3 100644 --- a/crates/linux/src/mount.rs +++ b/crates/linux/src/mount.rs @@ -75,10 +75,8 @@ pub(crate) fn construct_and_enter(preparation: &MountPreparation) -> Result<(), ffi::detach_mount(c"/.old_root").map_err(|_| enforcement("old root detachment"))?; fs::remove_dir("/.old_root").map_err(|_| enforcement("old root cleanup"))?; - if let Some(working_directory) = &preparation.working_directory { - std::env::set_current_dir(working_directory.as_path()) - .map_err(|_| enforcement("working directory restoration"))?; - } + std::env::set_current_dir(preparation.working_directory.as_path()) + .map_err(|_| enforcement("working directory restoration"))?; Ok(()) } diff --git a/crates/linux/src/namespace.rs b/crates/linux/src/namespace.rs index 180259a..a972966 100644 --- a/crates/linux/src/namespace.rs +++ b/crates/linux/src/namespace.rs @@ -8,9 +8,11 @@ pub(crate) struct NamespacePreparation { pub(crate) last_capability: u32, } -pub(crate) fn prepare() -> Result { +pub(crate) fn prepare(block_network: bool) -> Result { ensure_single_threaded()?; let (effective_uid, effective_gid) = ffi::effective_ids(); + ffi::probe_namespace_setup(effective_uid, effective_gid, block_network) + .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "namespace support"))?; let raw_last_capability = fs::read_to_string("/proc/sys/kernel/cap_last_cap") .map_err(|_| preparation("capability discovery"))?; if raw_last_capability.len() > 16 { @@ -42,7 +44,10 @@ pub(crate) fn enter( fs::write( "/proc/self/uid_map", - format!("0 {} 1\n", preparation.effective_uid), + format!( + "{} {} 1\n", + preparation.effective_uid, preparation.effective_uid + ), ) .map_err(|_| enforcement("user mapping"))?; match fs::write("/proc/self/setgroups", b"deny") { @@ -59,7 +64,10 @@ pub(crate) fn enter( } fs::write( "/proc/self/gid_map", - format!("0 {} 1\n", preparation.effective_gid), + format!( + "{} {} 1\n", + preparation.effective_gid, preparation.effective_gid + ), ) .map_err(|_| enforcement("group mapping"))?; diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs index 9aded82..68143cb 100644 --- a/crates/linux/src/prepare.rs +++ b/crates/linux/src/prepare.rs @@ -38,7 +38,7 @@ pub(crate) struct MountPreparation { pub(crate) pinned: BTreeMap, pub(crate) mounts: Vec, pub(crate) protections: Vec, - pub(crate) working_directory: Option, + pub(crate) working_directory: AbsolutePath, } /// Fully prepared Linux sandbox whose remaining operations are irreversible. @@ -61,10 +61,10 @@ pub fn prepare( plan: LinuxPolicyPlan, working_directory: &AbsolutePath, ) -> Result { - let namespace = namespace::prepare()?; + let block_network = plan.blocks_network(); + let namespace = namespace::prepare(block_network)?; let mount = prepare_mounts(&plan, working_directory)?; let landlock = landlock::prepare(plan.policy.spec(), &mount.pinned)?; - let block_network = plan.blocks_network(); let seccomp = seccomp::compile( plan.allows_subprocesses(), block_network, @@ -216,8 +216,10 @@ fn prepare_mounts( }) .collect::, LinuxError>>()?; - let working_directory = - visible(working_directory, effective.iter()).then(|| working_directory.clone()); + if !visible(working_directory, effective.iter()) { + return Err(unsupported("working directory visibility")); + } + let working_directory = working_directory.clone(); Ok(MountPreparation { pinned, diff --git a/crates/linux/src/support.rs b/crates/linux/src/support.rs index 3961e88..d116af7 100644 --- a/crates/linux/src/support.rs +++ b/crates/linux/src/support.rs @@ -27,7 +27,13 @@ impl SupportInfo { /// This is diagnostic only. [`crate::prepare`] repeats every authoritative /// check and remains the gate before native application. pub fn probe(policy: &ValidatedPolicy) -> Result { - let _ = crate::plan(policy)?; + let plan = crate::plan(policy)?; + #[cfg(target_os = "linux")] + { + crate::namespace::prepare(plan.blocks_network())?; + } + #[cfg(not(target_os = "linux"))] + let _ = plan; probe_platform() } diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs index 4a343cb..be951e2 100644 --- a/crates/linux/tests/live_linux.rs +++ b/crates/linux/tests/live_linux.rs @@ -8,12 +8,16 @@ mod linux { }; const CHILD_MODE: &str = "SANDY_LINUX_LIVE_CHILD"; + const EXPECT_UNSUPPORTED: &str = "SANDY_LINUX_EXPECT_UNSUPPORTED"; const WORKSPACE: &str = "SANDY_LINUX_LIVE_WORKSPACE"; pub(super) fn run() -> Result<(), Box> { if env::var_os(CHILD_MODE).is_some() { return child_checks(); } + if env::var_os(EXPECT_UNSUPPORTED).is_some() { + return restricted_host_is_rejected_before_enforcement(); + } exact_directory_grants_are_rejected_before_enforcement()?; @@ -34,6 +38,32 @@ mod linux { Ok(()) } + fn restricted_host_is_rejected_before_enforcement() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let path = AbsolutePath::new( + fs::canonicalize(root.path())? + .to_str() + .ok_or("path is not UTF-8")? + .to_owned(), + )?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: path.clone(), + access: AccessMode::Read, + scope: PathScope::Subtree, + }], + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + })?; + let error = sandy_linux::prepare(sandy_linux::plan(&policy)?, &path) + .err() + .ok_or("restricted host unexpectedly passed preparation")?; + if error.kind() != sandy_linux::LinuxErrorKind::Unsupported { + return Err("restricted host returned the wrong error class".into()); + } + Ok(()) + } + fn child_checks() -> Result<(), Box> { let workspace = fs::canonicalize(env::var_os(WORKSPACE).ok_or("missing workspace")?)?; let workspace = AbsolutePath::new( diff --git a/docs/LINUX_SECURITY_MODEL.md b/docs/LINUX_SECURITY_MODEL.md index 8ed2ecd..706480d 100644 --- a/docs/LINUX_SECURITY_MODEL.md +++ b/docs/LINUX_SECURITY_MODEL.md @@ -27,7 +27,8 @@ the process unrestricted. Application then: 1. enters a user and mount namespace, plus a network namespace for `BlockAll`; -2. installs one-entry UID and GID maps and makes mount propagation private; +2. installs same-ID, one-entry UID and GID maps and makes mount propagation + private; 3. constructs a private tmpfs root from descriptor-pinned grants; 4. overlays typed write protections and enters the private root; 5. applies the complete Landlock ruleset and `no_new_privs`; @@ -41,10 +42,20 @@ its target. ## Fixed native semantics -Sandy requires Landlock ABI 9. The implementation requests only the fixed ABI 9 -filesystem rights and scope flags it has reviewed; a newer host ABI does not -silently add restrictions. ABI 9 is required so pathname Unix-socket connect -authority remains separate from filesystem visibility. +Sandy requires Landlock ABI 9. The implementation handles the fixed ABI 8 +filesystem rights plus ABI 9 pathname Unix-socket resolution and reviewed scope +flags; a newer host ABI does not silently add restrictions. Device ioctls on +already-open descriptors remain inherited capabilities. ABI 9 is required so +pathname Unix-socket connect authority remains separate from filesystem +visibility. + +The host must permit the calling executable to create and configure user, +mount, and—when blocking network access—network namespaces. Some distributions +restrict those operations through a system security profile. Sandy exercises +the complete namespace setup in a sacrificial child during preparation and +reports the backend as unsupported when the host denies it; it never falls back +to weaker enforcement. Host administrators must make the namespace capability +available through their normal system security policy. Read authority never includes execute authority. Read/write authority uses the fixed mutation rights through ABI 8 because ABI 9's aggregate write set also @@ -90,6 +101,10 @@ The private root deliberately omits procfs. Product compatibility grants and any future selective procfs design belong to the CLI boundary and require live positive and negative tests before shipping. +The requested working directory must be visible through an explicit grant. +Sandy rejects the policy before enforcement rather than silently changing the +working directory to the private root. + ## Unsafe boundary All Linux unsafe code and raw native calls are confined to From b54b06ecfd528e953db6d7b8c7c29b4854376aaa Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 18:51:20 +0200 Subject: [PATCH 04/20] feat(linux): preserve explicit runtime aliases --- crates/linux/src/mount.rs | 9 ++++++ crates/linux/src/prepare.rs | 57 +++++++++++++++++++++++++++++++++++- docs/LINUX_SECURITY_MODEL.md | 7 +++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/linux/src/mount.rs b/crates/linux/src/mount.rs index 1d760e3..e4c5206 100644 --- a/crates/linux/src/mount.rs +++ b/crates/linux/src/mount.rs @@ -68,6 +68,15 @@ pub(crate) fn construct_and_enter(preparation: &MountPreparation) -> Result<(), source.kind == PinnedKind::Device, )?; } + for alias in &preparation.aliases { + let path = target_path(&alias.path); + let parent = path + .parent() + .ok_or_else(|| enforcement("runtime alias creation"))?; + fs::create_dir_all(parent).map_err(|_| enforcement("runtime alias creation"))?; + std::os::unix::fs::symlink(&alias.target, &path) + .map_err(|_| enforcement("runtime alias creation"))?; + } std::env::set_current_dir(STAGING_ROOT).map_err(|_| enforcement("root entry"))?; ffi::pivot_root(c".", c".old_root").map_err(|_| enforcement("root entry"))?; diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs index 68143cb..93acc25 100644 --- a/crates/linux/src/prepare.rs +++ b/crates/linux/src/prepare.rs @@ -2,9 +2,10 @@ use std::{ collections::BTreeMap, fs::{File, OpenOptions}, os::unix::fs::{FileTypeExt, OpenOptionsExt}, + path::PathBuf, }; -use sandy_core::{AbsolutePath, AccessMode, PathScope}; +use sandy_core::{AbsolutePath, AccessMode, PathScope, RuntimeCompatibility}; use crate::{LinuxError, LinuxErrorKind, LinuxPolicyPlan, landlock, namespace, seccomp}; @@ -38,9 +39,15 @@ pub(crate) struct MountPreparation { pub(crate) pinned: BTreeMap, pub(crate) mounts: Vec, pub(crate) protections: Vec, + pub(crate) aliases: Vec, pub(crate) working_directory: AbsolutePath, } +pub(crate) struct RuntimeAlias { + pub(crate) path: AbsolutePath, + pub(crate) target: PathBuf, +} + /// Fully prepared Linux sandbox whose remaining operations are irreversible. /// /// This value owns all pinned path descriptors, the already-created Landlock @@ -220,15 +227,63 @@ fn prepare_mounts( return Err(unsupported("working directory visibility")); } let working_directory = working_directory.clone(); + let aliases = prepare_runtime_aliases(spec.runtime_compatibility, &effective)?; Ok(MountPreparation { pinned, mounts: effective, protections, + aliases, working_directory, }) } +fn prepare_runtime_aliases( + compatibility: RuntimeCompatibility, + mounts: &[MountRequirement], +) -> Result, LinuxError> { + if compatibility != RuntimeCompatibility::ForegroundCli { + return Ok(Vec::new()); + } + + let mut aliases = Vec::new(); + for requested in [ + "/bin", + "/sbin", + "/lib", + "/lib64", + "/etc/localtime", + "/etc/ssl/cert.pem", + ] { + let metadata = match std::fs::symlink_metadata(requested) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err(preparation("runtime alias inspection")), + }; + if !metadata.file_type().is_symlink() { + continue; + } + let canonical = std::fs::canonicalize(requested) + .map_err(|_| preparation("runtime alias resolution"))?; + let canonical = AbsolutePath::new( + canonical + .to_str() + .ok_or_else(|| preparation("runtime alias representation"))? + .to_owned(), + ) + .map_err(|_| preparation("runtime alias representation"))?; + let path = AbsolutePath::new(requested.to_owned()) + .map_err(|_| preparation("runtime alias representation"))?; + if visible(&path, mounts.iter()) || !visible(&canonical, mounts.iter()) { + continue; + } + let target = + std::fs::read_link(requested).map_err(|_| preparation("runtime alias resolution"))?; + aliases.push(RuntimeAlias { path, target }); + } + Ok(aliases) +} + fn pin(path: &AbsolutePath) -> Result { let root = OpenOptions::new() .read(true) diff --git a/docs/LINUX_SECURITY_MODEL.md b/docs/LINUX_SECURITY_MODEL.md index 706480d..08fe15b 100644 --- a/docs/LINUX_SECURITY_MODEL.md +++ b/docs/LINUX_SECURITY_MODEL.md @@ -101,6 +101,13 @@ The private root deliberately omits procfs. Product compatibility grants and any future selective procfs design belong to the CLI boundary and require live positive and negative tests before shipping. +When the internal foreground-CLI compatibility capability is present, the +backend recreates a bounded set of host runtime symlinks (`/bin`, `/sbin`, +loader directories, timezone data, and the public CA-bundle path) only when +their canonical targets are already visible through explicit grants. This adds +no underlying file authority; it preserves the runtime spelling selected by +the host distribution. + The requested working directory must be visible through an explicit grant. Sandy rejects the policy before enforcement rather than silently changing the working directory to the private root. From 5965f8a8a2fbd79f3a05cf42b46b103e96319957 Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 19:07:57 +0200 Subject: [PATCH 05/20] fix(linux): preserve allow-all socket semantics --- crates/linux/src/landlock.rs | 23 +++++++----- crates/linux/tests/live_linux.rs | 64 ++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/crates/linux/src/landlock.rs b/crates/linux/src/landlock.rs index f7692e3..bfd61f5 100644 --- a/crates/linux/src/landlock.rs +++ b/crates/linux/src/landlock.rs @@ -19,7 +19,10 @@ pub(crate) fn prepare( policy: &PolicySpec, pinned: &BTreeMap, ) -> Result { - let handled = AccessFs::from_all(ABI::V8) | AccessFs::ResolveUnix; + let mut handled = AccessFs::from_all(ABI::V8); + if policy.network == NetworkPolicy::BlockAll { + handled |= AccessFs::ResolveUnix; + } let builder = Ruleset::default() .handle_access(handled) .map_err(|_| unsupported("Landlock ABI"))?; @@ -59,15 +62,17 @@ pub(crate) fn prepare( .file; ruleset = add_rule(ruleset, file, AccessFs::Execute.into())?; } - for grant in &policy.unix_sockets { - if denied(&grant.path, &policy.protected_paths) { - continue; + if policy.network == NetworkPolicy::BlockAll { + for grant in &policy.unix_sockets { + if denied(&grant.path, &policy.protected_paths) { + continue; + } + let file = &pinned + .get(&grant.path) + .ok_or_else(|| preparation("Landlock socket lookup"))? + .file; + ruleset = add_rule(ruleset, file, AccessFs::ResolveUnix.into())?; } - let file = &pinned - .get(&grant.path) - .ok_or_else(|| preparation("Landlock socket lookup"))? - .file; - ruleset = add_rule(ruleset, file, AccessFs::ResolveUnix.into())?; } // The shape checks in preparation ensure all directory rules are subtree diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs index be951e2..4d69e05 100644 --- a/crates/linux/tests/live_linux.rs +++ b/crates/linux/tests/live_linux.rs @@ -1,13 +1,21 @@ #[cfg(target_os = "linux")] mod linux { - use std::{env, fs, net::TcpStream, process::Command, time::Duration}; + use std::{ + env, fs, + net::TcpStream, + os::unix::net::{UnixListener, UnixStream}, + process::Command, + time::Duration, + }; use sandy_core::{ - AbsolutePath, AccessMode, FileGrant, NetworkPolicy, PathScope, PolicySpec, ValidatedPolicy, - WriteProtection, + AbsolutePath, AccessMode, FileGrant, NetworkPolicy, PathScope, PolicySpec, UnixSocketGrant, + UnixSocketOperation, ValidatedPolicy, WriteProtection, }; const CHILD_MODE: &str = "SANDY_LINUX_LIVE_CHILD"; + const ALLOW_SOCKET_CHILD: &str = "SANDY_LINUX_ALLOW_SOCKET_CHILD"; + const SOCKET_PATH: &str = "SANDY_LINUX_LIVE_SOCKET"; const EXPECT_UNSUPPORTED: &str = "SANDY_LINUX_EXPECT_UNSUPPORTED"; const WORKSPACE: &str = "SANDY_LINUX_LIVE_WORKSPACE"; @@ -15,6 +23,9 @@ mod linux { if env::var_os(CHILD_MODE).is_some() { return child_checks(); } + if env::var_os(ALLOW_SOCKET_CHILD).is_some() { + return allow_socket_child(); + } if env::var_os(EXPECT_UNSUPPORTED).is_some() { return restricted_host_is_rejected_before_enforcement(); } @@ -35,9 +46,56 @@ mod linux { if !status.success() { return Err("sacrificial sandbox child failed".into()); } + allow_all_preserves_pathname_socket_connections(root.path(), &workspace)?; Ok(()) } + fn allow_all_preserves_pathname_socket_connections( + root: &std::path::Path, + workspace: &std::path::Path, + ) -> Result<(), Box> { + let socket = root.join("service.sock"); + let _listener = UnixListener::bind(&socket)?; + let status = Command::new(env::current_exe()?) + .env(ALLOW_SOCKET_CHILD, "1") + .env(WORKSPACE, workspace) + .env(SOCKET_PATH, &socket) + .status()?; + if !status.success() { + return Err("allow-all pathname socket child failed".into()); + } + Ok(()) + } + + fn allow_socket_child() -> Result<(), Box> { + let workspace = absolute_from_environment(WORKSPACE)?; + let socket = absolute_from_environment(SOCKET_PATH)?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: workspace.clone(), + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }], + unix_sockets: vec![UnixSocketGrant { + path: socket.clone(), + operation: UnixSocketOperation::Connect, + }], + network: NetworkPolicy::AllowAll, + ..PolicySpec::default() + })?; + let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace)?; + sandy_linux::apply(prepared)?; + UnixStream::connect(socket.as_path())?; + Ok(()) + } + + fn absolute_from_environment(name: &str) -> Result> { + let path = fs::canonicalize(env::var_os(name).ok_or("missing test path")?)?; + Ok(AbsolutePath::new( + path.to_str().ok_or("test path is not UTF-8")?.to_owned(), + )?) + } + fn restricted_host_is_rejected_before_enforcement() -> Result<(), Box> { let root = tempfile::tempdir()?; let path = AbsolutePath::new( From 60e7a0555495e8eafdafa6ca4af051771c2e9a19 Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 19:41:24 +0200 Subject: [PATCH 06/20] fix(linux): harden namespace mount enforcement --- .github/workflows/ci.yml | 18 +++- crates/linux/src/apply.rs | 7 +- crates/linux/src/ffi.rs | 158 ++++++++++++++++++++++++++---- crates/linux/src/landlock.rs | 19 ++-- crates/linux/src/lib.rs | 2 +- crates/linux/src/mount.rs | 4 +- crates/linux/src/prepare.rs | 61 ++++++++++-- crates/linux/src/seccomp.rs | 14 ++- crates/linux/src/support.rs | 84 ++++++++++++---- crates/linux/tests/live_linux.rs | 159 ++++++++++++++++++++++++++++++- docs/LINUX_SECURITY_MODEL.md | 54 ++++++----- 11 files changed, 491 insertions(+), 89 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1baacfa..8bf08d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,8 +41,12 @@ jobs: run: make test-live linux-substrate: - name: Linux enforcement substrate - runs-on: ubuntu-latest + name: Linux enforcement substrate (${{ matrix.runner }}) + strategy: + fail-fast: false + matrix: + runner: [ubuntu-24.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.runner }} steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -55,9 +59,15 @@ jobs: - name: Verify restricted user namespaces fail closed env: SANDY_LINUX_EXPECT_UNSUPPORTED: "1" - run: cargo test -p sandy-linux --test live_linux --locked + run: | + if [ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "1" ]; then + cargo test -p sandy-linux --test live_linux --locked + fi - name: Enable user namespaces for positive enforcement tests - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + run: | + if [ -f /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi - name: Test Linux backend run: make test-live-linux - name: Preserve unsupported facade contract diff --git a/crates/linux/src/apply.rs b/crates/linux/src/apply.rs index 8507708..9f219e5 100644 --- a/crates/linux/src/apply.rs +++ b/crates/linux/src/apply.rs @@ -7,8 +7,13 @@ use crate::{ /// /// Any error from this function may leave the process partially restricted. /// The caller must terminate immediately and must not run untrusted code. -pub fn apply(prepared: PreparedLinuxSandbox) -> Result<(), LinuxError> { +pub fn apply(mut prepared: PreparedLinuxSandbox) -> Result<(), LinuxError> { namespace::enter(&prepared.namespace, prepared.block_network)?; + // Descriptors opened before CLONE_NEWNS still refer to mounts in the old + // namespace and cannot be cloned with open_tree. Reopen every canonical + // source in the new namespace and prove it is the same object before the + // old filesystem view is hidden. + crate::prepare::repin_after_namespace(&mut prepared.mount)?; mount::construct_and_enter(&prepared.mount)?; landlock::apply(prepared.landlock)?; capabilities::drop_all(prepared.namespace.last_capability)?; diff --git a/crates/linux/src/ffi.rs b/crates/linux/src/ffi.rs index 52f174c..b097c57 100644 --- a/crates/linux/src/ffi.rs +++ b/crates/linux/src/ffi.rs @@ -112,19 +112,137 @@ unsafe fn namespace_probe_child(uid_map: &[u8], gid_map: &[u8], block_network: b std::ptr::null(), ) } == 0 - && unsafe { - libc::mount( - c"tmpfs".as_ptr(), - c"/tmp".as_ptr(), - c"tmpfs".as_ptr(), - (libc::MS_NOSUID | libc::MS_NODEV) as libc::c_ulong, - c"mode=0700,size=1m".as_ptr().cast(), - ) - } == 0; + && probe_mount_sequence(); // SAFETY: `_exit` performs no Rust cleanup and cannot return. unsafe { libc::_exit(if succeeded { 0 } else { 1 }) } } +fn probe_mount_sequence() -> bool { + // Exercise the same modern mount transaction as the real backend with a + // source descriptor opened after CLONE_NEWNS. All paths and structures + // are fixed before use, and the sacrificial child exits immediately. + if probe_mount_tmpfs(c"/tmp") != 0 + || probe_mkdir(c"/tmp/.sandy-probe-root") != 0 + || probe_mount_tmpfs(c"/tmp/.sandy-probe-root") != 0 + || probe_mkdir(c"/tmp/.sandy-probe-root/usr") != 0 + || probe_mkdir(c"/tmp/.sandy-probe-root/.old_root") != 0 + { + return false; + } + + let source = probe_open(c"/usr"); + if source < 0 { + return false; + } + let flags = libc::OPEN_TREE_CLONE + | libc::OPEN_TREE_CLOEXEC + | libc::AT_EMPTY_PATH as u32 + | libc::AT_RECURSIVE as u32; + let mount_fd = probe_open_tree(source, flags); + if mount_fd < 0 { + return false; + } + let attributes = libc::mount_attr { + attr_set: libc::MOUNT_ATTR_NOSUID | libc::MOUNT_ATTR_NODEV | libc::MOUNT_ATTR_RDONLY, + attr_clr: 0, + propagation: 0, + userns_fd: 0, + }; + if probe_mount_setattr(mount_fd, &attributes) < 0 { + return false; + } + let target = probe_open(c"/tmp/.sandy-probe-root/usr"); + if target < 0 { + return false; + } + if probe_move_mount(mount_fd, target) < 0 + || probe_chdir(c"/tmp/.sandy-probe-root") != 0 + || probe_pivot_root() < 0 + || probe_chdir(c"/") != 0 + || probe_detach_old_root() != 0 + { + return false; + } + true +} + +fn probe_mount_tmpfs(target: &CStr) -> libc::c_int { + // SAFETY: every pointer is a live C string for the call, mount copies the + // values, and the operation is confined to the sacrificial mount namespace. + unsafe { + libc::mount( + c"tmpfs".as_ptr(), + target.as_ptr(), + c"tmpfs".as_ptr(), + (libc::MS_NOSUID | libc::MS_NODEV) as libc::c_ulong, + c"mode=0700,size=1m".as_ptr().cast(), + ) + } +} + +fn probe_mkdir(path: &CStr) -> libc::c_int { + // SAFETY: `path` is a live C string and mkdir retains no pointer. + unsafe { libc::mkdir(path.as_ptr(), 0o700) } +} + +fn probe_open(path: &CStr) -> libc::c_int { + // SAFETY: `path` is a live C string. The sacrificial child exclusively + // owns any descriptor returned by the kernel until it exits. + unsafe { libc::open(path.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) } +} + +fn probe_open_tree(source: libc::c_int, flags: u32) -> libc::c_long { + // SAFETY: `source` is checked by the kernel and the empty path is a static + // C string selected by AT_EMPTY_PATH. No pointer is retained. + unsafe { libc::syscall(libc::SYS_open_tree, source, EMPTY.as_ptr(), flags) } +} + +fn probe_mount_setattr(mount_fd: libc::c_long, attributes: &libc::mount_attr) -> libc::c_long { + // SAFETY: `mount_fd` is checked by the kernel and `attributes` remains live + // for the fixed-size call. The kernel copies it and retains no pointer. + unsafe { + libc::syscall( + libc::SYS_mount_setattr, + mount_fd, + EMPTY.as_ptr(), + libc::AT_EMPTY_PATH | libc::AT_RECURSIVE, + attributes, + size_of::(), + ) + } +} + +fn probe_move_mount(mount_fd: libc::c_long, target: libc::c_int) -> libc::c_long { + // SAFETY: both descriptors are checked by the kernel; the static empty + // paths select them and no pointer or Rust ownership is retained. + unsafe { + libc::syscall( + libc::SYS_move_mount, + mount_fd, + EMPTY.as_ptr(), + target, + EMPTY.as_ptr(), + libc::MOVE_MOUNT_F_EMPTY_PATH | libc::MOVE_MOUNT_T_EMPTY_PATH, + ) + } +} + +fn probe_chdir(path: &CStr) -> libc::c_int { + // SAFETY: `path` is a live C string and chdir retains no pointer. + unsafe { libc::chdir(path.as_ptr()) } +} + +fn probe_pivot_root() -> libc::c_long { + // SAFETY: both static paths refer to directories created above, remain + // live for the syscall, and are used only in the sacrificial namespace. + unsafe { libc::syscall(libc::SYS_pivot_root, c".".as_ptr(), c".old_root".as_ptr()) } +} + +fn probe_detach_old_root() -> libc::c_int { + // SAFETY: the static path remains live and umount2 retains no pointer. + unsafe { libc::umount2(c"/.old_root".as_ptr(), libc::MNT_DETACH) } +} + unsafe fn write_probe_file(path: &CStr, contents: &[u8]) -> bool { // SAFETY: `path` is NUL-terminated and remains valid for the call. let fd = unsafe { libc::open(path.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC) }; @@ -214,12 +332,7 @@ pub(crate) fn open_path_at(root: RawFd, relative: &CStr) -> io::Result } } -pub(crate) fn clone_mount( - source: RawFd, - recursive: bool, - read_only: bool, - allow_device: bool, -) -> io::Result { +pub(crate) fn clone_mount(source: RawFd, recursive: bool) -> io::Result { let mut flags = libc::OPEN_TREE_CLONE | libc::OPEN_TREE_CLOEXEC | libc::AT_EMPTY_PATH as u32; if recursive { flags |= libc::AT_RECURSIVE as u32; @@ -234,8 +347,15 @@ pub(crate) fn clone_mount( let raw = i32::try_from(fd).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))?; // SAFETY: the successful syscall returned a fresh descriptor and ownership // is transferred exactly once. - let mount_fd = unsafe { OwnedFd::from_raw_fd(raw) }; + Ok(unsafe { OwnedFd::from_raw_fd(raw) }) +} +pub(crate) fn restrict_mount( + mount_fd: RawFd, + recursive: bool, + read_only: bool, + allow_device: bool, +) -> io::Result<()> { let attributes = libc::mount_attr { attr_set: libc::MOUNT_ATTR_NOSUID | if allow_device { @@ -259,7 +379,7 @@ pub(crate) fn clone_mount( let result = unsafe { libc::syscall( libc::SYS_mount_setattr, - mount_fd.as_raw_fd(), + mount_fd, EMPTY.as_ptr(), attribute_flags, &attributes, @@ -269,7 +389,7 @@ pub(crate) fn clone_mount( if result < 0 { Err(io::Error::last_os_error()) } else { - Ok(mount_fd) + Ok(()) } } @@ -386,5 +506,3 @@ fn cvt_long(value: libc::c_long) -> io::Result { Ok(value) } } - -use std::os::fd::AsRawFd; diff --git a/crates/linux/src/landlock.rs b/crates/linux/src/landlock.rs index bfd61f5..0d10e80 100644 --- a/crates/linux/src/landlock.rs +++ b/crates/linux/src/landlock.rs @@ -19,22 +19,23 @@ pub(crate) fn prepare( policy: &PolicySpec, pinned: &BTreeMap, ) -> Result { + let restrict_pathname_sockets = + policy.network == NetworkPolicy::BlockAll && !policy.unix_sockets.is_empty(); let mut handled = AccessFs::from_all(ABI::V8); - if policy.network == NetworkPolicy::BlockAll { + if restrict_pathname_sockets { handled |= AccessFs::ResolveUnix; } let builder = Ruleset::default() + .set_compatibility(CompatLevel::HardRequirement) .handle_access(handled) .map_err(|_| unsupported("Landlock ABI"))?; - let scope = if policy.network == NetworkPolicy::BlockAll { - Scope::Signal | Scope::AbstractUnixSocket + let builder = if restrict_pathname_sockets { + builder + .scope(Scope::AbstractUnixSocket) + .map_err(|_| unsupported("Landlock scope"))? } else { - Scope::Signal.into() + builder }; - let builder = builder - .scope(scope) - .map_err(|_| unsupported("Landlock scope"))? - .set_compatibility(CompatLevel::HardRequirement); let mut ruleset = builder .create() .map_err(|_| unsupported("Landlock ruleset creation"))?; @@ -62,7 +63,7 @@ pub(crate) fn prepare( .file; ruleset = add_rule(ruleset, file, AccessFs::Execute.into())?; } - if policy.network == NetworkPolicy::BlockAll { + if restrict_pathname_sockets { for grant in &policy.unix_sockets { if denied(&grant.path, &policy.protected_paths) { continue; diff --git a/crates/linux/src/lib.rs b/crates/linux/src/lib.rs index 0749012..62cc4a4 100644 --- a/crates/linux/src/lib.rs +++ b/crates/linux/src/lib.rs @@ -35,4 +35,4 @@ pub use error::{LinuxError, LinuxErrorKind}; pub use plan::{LinuxPolicyPlan, plan}; #[cfg(target_os = "linux")] pub use prepare::{PreparedLinuxSandbox, prepare}; -pub use support::{REQUIRED_LANDLOCK_ABI, SupportInfo, probe}; +pub use support::{BASELINE_LANDLOCK_ABI, PATHNAME_SOCKET_LANDLOCK_ABI, SupportInfo, probe}; diff --git a/crates/linux/src/mount.rs b/crates/linux/src/mount.rs index e4c5206..4392904 100644 --- a/crates/linux/src/mount.rs +++ b/crates/linux/src/mount.rs @@ -125,8 +125,10 @@ fn attach( let relative = ffi::c_string(relative).map_err(|_| enforcement("mount target pinning"))?; let target = ffi::open_path_at(root.as_raw_fd(), &relative) .map_err(|_| enforcement("mount target pinning"))?; - let detached = ffi::clone_mount(source.as_raw_fd(), recursive, read_only, allow_device) + let detached = ffi::clone_mount(source.as_raw_fd(), recursive) .map_err(|_| enforcement("detached mount creation"))?; + ffi::restrict_mount(detached.as_raw_fd(), recursive, read_only, allow_device) + .map_err(|_| enforcement("detached mount restriction"))?; ffi::attach_mount(detached.as_raw_fd(), target.as_raw_fd()) .map_err(|_| enforcement("mount attachment")) } diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs index 93acc25..73ad051 100644 --- a/crates/linux/src/prepare.rs +++ b/crates/linux/src/prepare.rs @@ -1,7 +1,10 @@ use std::{ collections::BTreeMap, fs::{File, OpenOptions}, - os::unix::fs::{FileTypeExt, OpenOptionsExt}, + os::{ + fd::AsRawFd, + unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}, + }, path::PathBuf, }; @@ -21,6 +24,8 @@ pub(crate) enum PinnedKind { pub(crate) struct PinnedPath { pub(crate) file: File, pub(crate) kind: PinnedKind, + device: u64, + inode: u64, } pub(crate) struct MountRequirement { @@ -296,11 +301,50 @@ fn pin(path: &AbsolutePath) -> Result { let owned = crate::ffi::open_path_at(std::os::fd::AsRawFd::as_raw_fd(&root), &relative) .map_err(|_| preparation("path pinning"))?; let file = crate::ffi::file_from_owned(owned); - let file_type = file + let metadata = file .metadata() - .map_err(|_| preparation("path inspection"))? - .file_type(); - let kind = if file_type.is_dir() { + .map_err(|_| preparation("path inspection"))?; + let kind = classify(&metadata); + Ok(PinnedPath { + file, + kind, + device: metadata.dev(), + inode: metadata.ino(), + }) +} + +pub(crate) fn repin_after_namespace(preparation: &mut MountPreparation) -> Result<(), LinuxError> { + let root = OpenOptions::new() + .read(true) + .custom_flags(libc::O_PATH | libc::O_CLOEXEC) + .open("/") + .map_err(|_| enforcement("namespace root pinning"))?; + + for (path, pinned) in &mut preparation.pinned { + let relative = path.as_str().strip_prefix('/').unwrap_or(path.as_str()); + let relative = if relative.is_empty() { "." } else { relative }; + let relative = + crate::ffi::c_string(relative).map_err(|_| enforcement("mount source repinning"))?; + let owned = crate::ffi::open_path_at(root.as_raw_fd(), &relative) + .map_err(|_| enforcement("mount source repinning"))?; + let file = crate::ffi::file_from_owned(owned); + let metadata = file + .metadata() + .map_err(|_| enforcement("mount source verification"))?; + if metadata.dev() != pinned.device + || metadata.ino() != pinned.inode + || classify(&metadata) != pinned.kind + { + return Err(enforcement("mount source verification")); + } + pinned.file = file; + } + Ok(()) +} + +fn classify(metadata: &std::fs::Metadata) -> PinnedKind { + let file_type = metadata.file_type(); + if file_type.is_dir() { PinnedKind::Directory } else if file_type.is_file() { PinnedKind::Regular @@ -310,8 +354,7 @@ fn pin(path: &AbsolutePath) -> Result { PinnedKind::Device } else { PinnedKind::Other - }; - Ok(PinnedPath { file, kind }) + } } pub(crate) fn denied(path: &AbsolutePath, protected_paths: &[AbsolutePath]) -> bool { @@ -333,6 +376,10 @@ fn preparation(phase: &'static str) -> LinuxError { LinuxError::new(LinuxErrorKind::PreparationFailed, phase) } +fn enforcement(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::EnforcementFailed, phase) +} + fn unsupported(phase: &'static str) -> LinuxError { LinuxError::new(LinuxErrorKind::Unsupported, phase) } diff --git a/crates/linux/src/seccomp.rs b/crates/linux/src/seccomp.rs index 9481381..67ac73a 100644 --- a/crates/linux/src/seccomp.rs +++ b/crates/linux/src/seccomp.rs @@ -58,8 +58,7 @@ pub(crate) fn compile( .collect::, _>>()?; if !allow_subprocesses { clone_rules.push(masked_clone_rule(libc::CLONE_THREAD as u64, 0)?); - denied.insert(libc::SYS_fork, Vec::new()); - denied.insert(libc::SYS_vfork, Vec::new()); + deny_arch_process_creation(&mut denied); denied.insert(libc::SYS_execve, Vec::new()); denied.insert(libc::SYS_execveat, Vec::new()); } @@ -93,6 +92,17 @@ pub(crate) fn compile( Ok(SeccompPrograms { clone3, topology }) } +fn deny_arch_process_creation(_denied: &mut BTreeMap>) { + // AArch64 has no separate fork or vfork syscalls; libc implements both + // through clone. Other supported architectures retain the legacy entry + // points, which must be denied alongside clone. + #[cfg(target_arch = "x86_64")] + { + _denied.insert(libc::SYS_fork, Vec::new()); + _denied.insert(libc::SYS_vfork, Vec::new()); + } +} + pub(crate) fn apply(programs: &SeccompPrograms) -> Result<(), LinuxError> { seccompiler::apply_filter(&programs.clone3) .and_then(|()| seccompiler::apply_filter(&programs.topology)) diff --git a/crates/linux/src/support.rs b/crates/linux/src/support.rs index d116af7..56d89ab 100644 --- a/crates/linux/src/support.rs +++ b/crates/linux/src/support.rs @@ -1,12 +1,12 @@ -use sandy_core::ValidatedPolicy; +use sandy_core::{NetworkPolicy, PolicySpec, ValidatedPolicy}; use crate::{LinuxError, LinuxErrorKind}; -/// Landlock ABI whose rights Sandy has fixed and tested. -/// -/// ABI 9 is required because pathname Unix-socket connection authority must -/// remain independent from filesystem visibility. -pub const REQUIRED_LANDLOCK_ABI: u32 = 9; +/// Minimum Landlock ABI for Sandy's fixed filesystem-rights baseline. +pub const BASELINE_LANDLOCK_ABI: u32 = 5; + +/// Landlock ABI required by exact external pathname Unix-socket grants. +pub const PATHNAME_SOCKET_LANDLOCK_ABI: u32 = 9; /// Policy-specific host support established without applying restrictions. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -15,7 +15,7 @@ pub struct SupportInfo { } impl SupportInfo { - /// Returns the fixed Landlock ABI used by this Sandy build. + /// Returns the minimum Landlock ABI required by the supplied policy. #[must_use] pub const fn landlock_abi(self) -> u32 { self.landlock_abi @@ -28,39 +28,83 @@ impl SupportInfo { /// check and remains the gate before native application. pub fn probe(policy: &ValidatedPolicy) -> Result { let plan = crate::plan(policy)?; + let required_abi = required_abi(plan.policy.spec()); #[cfg(target_os = "linux")] { crate::namespace::prepare(plan.blocks_network())?; } #[cfg(not(target_os = "linux"))] let _ = plan; - probe_platform() + probe_platform(required_abi) } #[cfg(target_os = "linux")] -fn probe_platform() -> Result { +fn probe_platform(required_abi: u32) -> Result { use landlock::{ABI, Access, AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr, Scope}; - Ruleset::default() - .handle_access(AccessFs::from_all(ABI::V9)) - .and_then(|ruleset| ruleset.scope(Scope::Signal | Scope::AbstractUnixSocket)) - .and_then(|ruleset| { - ruleset - .set_compatibility(CompatLevel::HardRequirement) - .create() - .map(|_| ()) - }) + let mut handled = AccessFs::from_all(ABI::V8); + if required_abi == PATHNAME_SOCKET_LANDLOCK_ABI { + handled |= AccessFs::ResolveUnix; + } + let builder = Ruleset::default() + .set_compatibility(CompatLevel::HardRequirement) + .handle_access(handled) + .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "support probe"))?; + let builder = if required_abi == PATHNAME_SOCKET_LANDLOCK_ABI { + builder + .scope(Scope::AbstractUnixSocket) + .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "support probe"))? + } else { + builder + }; + builder + .create() .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "support probe"))?; Ok(SupportInfo { - landlock_abi: REQUIRED_LANDLOCK_ABI, + landlock_abi: required_abi, }) } #[cfg(not(target_os = "linux"))] -fn probe_platform() -> Result { +fn probe_platform(_required_abi: u32) -> Result { Err(LinuxError::new( LinuxErrorKind::Unsupported, "support probe", )) } + +fn required_abi(policy: &PolicySpec) -> u32 { + if policy.network == NetworkPolicy::BlockAll && !policy.unix_sockets.is_empty() { + PATHNAME_SOCKET_LANDLOCK_ABI + } else { + BASELINE_LANDLOCK_ABI + } +} + +#[cfg(test)] +mod tests { + use sandy_core::{AbsolutePath, UnixSocketGrant, UnixSocketOperation}; + + use super::*; + + #[test] + fn exact_external_socket_authority_raises_only_its_policy_requirement() + -> Result<(), Box> { + let ordinary = PolicySpec { + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + }; + assert_eq!(required_abi(&ordinary), BASELINE_LANDLOCK_ABI); + + let with_socket = PolicySpec { + unix_sockets: vec![UnixSocketGrant { + path: AbsolutePath::new("/service.sock".to_owned())?, + operation: UnixSocketOperation::Connect, + }], + ..ordinary + }; + assert_eq!(required_abi(&with_socket), PATHNAME_SOCKET_LANDLOCK_ABI); + Ok(()) + } +} diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs index 4d69e05..f8be8d6 100644 --- a/crates/linux/tests/live_linux.rs +++ b/crates/linux/tests/live_linux.rs @@ -2,8 +2,11 @@ mod linux { use std::{ env, fs, - net::TcpStream, - os::unix::net::{UnixListener, UnixStream}, + net::{TcpListener, TcpStream, UdpSocket}, + os::{ + linux::net::SocketAddrExt, + unix::net::{SocketAddr, UnixListener, UnixStream}, + }, process::Command, time::Duration, }; @@ -15,6 +18,10 @@ mod linux { const CHILD_MODE: &str = "SANDY_LINUX_LIVE_CHILD"; const ALLOW_SOCKET_CHILD: &str = "SANDY_LINUX_ALLOW_SOCKET_CHILD"; + const BLOCK_SOCKET_CHILD: &str = "SANDY_LINUX_BLOCK_SOCKET_CHILD"; + const DENIED_SOCKET_PATH: &str = "SANDY_LINUX_DENIED_SOCKET"; + const ABSTRACT_SOCKET_NAME: &str = "SANDY_LINUX_ABSTRACT_SOCKET"; + const REPLACEMENT_CHILD: &str = "SANDY_LINUX_REPLACEMENT_CHILD"; const SOCKET_PATH: &str = "SANDY_LINUX_LIVE_SOCKET"; const EXPECT_UNSUPPORTED: &str = "SANDY_LINUX_EXPECT_UNSUPPORTED"; const WORKSPACE: &str = "SANDY_LINUX_LIVE_WORKSPACE"; @@ -26,11 +33,18 @@ mod linux { if env::var_os(ALLOW_SOCKET_CHILD).is_some() { return allow_socket_child(); } + if env::var_os(BLOCK_SOCKET_CHILD).is_some() { + return block_socket_child(); + } + if env::var_os(REPLACEMENT_CHILD).is_some() { + return replacement_child(); + } if env::var_os(EXPECT_UNSUPPORTED).is_some() { return restricted_host_is_rejected_before_enforcement(); } exact_directory_grants_are_rejected_before_enforcement()?; + mount_source_replacement_is_rejected()?; let root = tempfile::tempdir()?; let workspace = root.path().join("workspace"); @@ -47,6 +61,44 @@ mod linux { return Err("sacrificial sandbox child failed".into()); } allow_all_preserves_pathname_socket_connections(root.path(), &workspace)?; + block_all_preserves_only_typed_endpoint_authority(root.path(), &workspace)?; + Ok(()) + } + + fn mount_source_replacement_is_rejected() -> Result<(), Box> { + let status = Command::new(env::current_exe()?) + .env(REPLACEMENT_CHILD, "1") + .status()?; + if !status.success() { + return Err("mount source replacement child failed".into()); + } + Ok(()) + } + + fn replacement_child() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let workspace_path = root.path().join("workspace"); + fs::create_dir(&workspace_path)?; + let workspace = absolute(&workspace_path)?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: workspace.clone(), + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }], + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + })?; + let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace)?; + + fs::rename(&workspace_path, root.path().join("replaced"))?; + fs::create_dir(&workspace_path)?; + let error = sandy_linux::apply(prepared) + .err() + .ok_or("replaced mount source unexpectedly applied")?; + if error.kind() != sandy_linux::LinuxErrorKind::EnforcementFailed { + return Err("mount replacement returned the wrong error class".into()); + } Ok(()) } @@ -89,8 +141,104 @@ mod linux { Ok(()) } + fn block_all_preserves_only_typed_endpoint_authority( + root: &std::path::Path, + workspace: &std::path::Path, + ) -> Result<(), Box> { + let allowed = root.join("allowed.sock"); + let denied = root.join("denied.sock"); + let abstract_name = format!("sandy-live-{}", std::process::id()); + let _allowed_listener = UnixListener::bind(&allowed)?; + let _denied_listener = UnixListener::bind(&denied)?; + let abstract_address = SocketAddr::from_abstract_name(abstract_name.as_bytes())?; + let _abstract_listener = UnixListener::bind_addr(&abstract_address)?; + + let policy = + block_socket_policy(absolute(root)?, absolute(workspace)?, absolute(&allowed)?)?; + match sandy_linux::probe(&policy) { + Ok(_) => {} + Err(error) if error.kind() == sandy_linux::LinuxErrorKind::Unsupported => { + return Ok(()); + } + Err(error) => return Err(error.into()), + } + + let status = Command::new(env::current_exe()?) + .env(BLOCK_SOCKET_CHILD, "1") + .env(WORKSPACE, workspace) + .env(SOCKET_PATH, &allowed) + .env(DENIED_SOCKET_PATH, &denied) + .env(ABSTRACT_SOCKET_NAME, &abstract_name) + .status()?; + if !status.success() { + return Err("block-all socket child failed".into()); + } + Ok(()) + } + + fn block_socket_child() -> Result<(), Box> { + let workspace = absolute_from_environment(WORKSPACE)?; + let allowed = absolute_from_environment(SOCKET_PATH)?; + let denied = absolute_from_environment(DENIED_SOCKET_PATH)?; + let root = absolute(allowed.as_path().parent().ok_or("socket has no parent")?)?; + let policy = block_socket_policy(root.clone(), workspace, allowed.clone())?; + let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &root)?; + sandy_linux::apply(prepared)?; + + UnixStream::connect(allowed.as_path())?; + if UnixStream::connect(denied.as_path()).is_ok() { + return Err("adjacent pathname socket connection succeeded".into()); + } + let abstract_name = env::var(ABSTRACT_SOCKET_NAME)?; + let abstract_address = SocketAddr::from_abstract_name(abstract_name.as_bytes())?; + if UnixStream::connect_addr(&abstract_address).is_ok() { + return Err("external abstract socket connection succeeded".into()); + } + if TcpListener::bind("127.0.0.1:0").is_ok() + || TcpListener::bind("[::1]:0").is_ok() + || UdpSocket::bind("127.0.0.1:0").is_ok() + || TcpStream::connect_timeout(&"1.1.1.1:80".parse()?, Duration::from_millis(100)) + .is_ok() + { + return Err("IP socket creation succeeded".into()); + } + let _local_pair = UnixStream::pair()?; + Ok(()) + } + + fn block_socket_policy( + root: AbsolutePath, + workspace: AbsolutePath, + allowed: AbsolutePath, + ) -> Result> { + Ok(ValidatedPolicy::try_from(PolicySpec { + files: vec![ + FileGrant { + path: root, + access: AccessMode::Read, + scope: PathScope::Subtree, + }, + FileGrant { + path: workspace, + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }, + ], + unix_sockets: vec![UnixSocketGrant { + path: allowed, + operation: UnixSocketOperation::Connect, + }], + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + })?) + } + fn absolute_from_environment(name: &str) -> Result> { let path = fs::canonicalize(env::var_os(name).ok_or("missing test path")?)?; + absolute(&path) + } + + fn absolute(path: &std::path::Path) -> Result> { Ok(AbsolutePath::new( path.to_str().ok_or("test path is not UTF-8")?.to_owned(), )?) @@ -161,6 +309,13 @@ mod linux { if TcpStream::connect_timeout(&"1.1.1.1:80".parse()?, Duration::from_millis(100)).is_ok() { return Err("blocked network connection succeeded".into()); } + if TcpListener::bind("127.0.0.1:0").is_ok() + || UdpSocket::bind("127.0.0.1:0").is_ok() + || UnixListener::bind("server.sock").is_ok() + { + return Err("addressable socket creation succeeded".into()); + } + let _local_pair = UnixStream::pair()?; Ok(()) } diff --git a/docs/LINUX_SECURITY_MODEL.md b/docs/LINUX_SECURITY_MODEL.md index 08fe15b..b64b120 100644 --- a/docs/LINUX_SECURITY_MODEL.md +++ b/docs/LINUX_SECURITY_MODEL.md @@ -20,7 +20,8 @@ apply() irreversible current-process transition ## Enforcement transaction Preparation verifies the single-threaded precondition, rejects unsupported -policy combinations, opens policy paths with `openat2`, creates a hard-required +policy combinations, opens policy paths with `openat2`, exercises the namespace +and modern mount transaction in a sacrificial child, creates a hard-required Landlock ruleset, and compiles the seccomp filters. A preparation failure leaves the process unrestricted. @@ -29,11 +30,13 @@ Application then: 1. enters a user and mount namespace, plus a network namespace for `BlockAll`; 2. installs same-ID, one-entry UID and GID maps and makes mount propagation private; -3. constructs a private tmpfs root from descriptor-pinned grants; -4. overlays typed write protections and enters the private root; -5. applies the complete Landlock ruleset and `no_new_privs`; -6. removes ambient, bounding, effective, permitted, and inheritable capabilities; -7. installs seccomp filters that freeze namespace and mount topology and enforce +3. reopens every grant in the new mount namespace and verifies its device, + inode, and type against the original pinned object; +4. constructs a private tmpfs root from the verified grants; +5. overlays typed write protections and enters the private root; +6. applies the complete Landlock ruleset and `no_new_privs`; +7. removes ambient, bounding, effective, permitted, and inheritable capabilities; +8. installs seccomp filters that freeze namespace and mount topology and enforce subprocess and network-socket creation policy. An application failure can leave the current process partially restricted. The @@ -42,17 +45,18 @@ its target. ## Fixed native semantics -Sandy requires Landlock ABI 9. The implementation handles the fixed ABI 8 -filesystem rights plus ABI 9 pathname Unix-socket resolution and reviewed scope -flags; a newer host ABI does not silently add restrictions. Device ioctls on -already-open descriptors remain inherited capabilities. ABI 9 is required so -pathname Unix-socket connect authority remains separate from filesystem -visibility. +Sandy's fixed filesystem-rights baseline requires Landlock ABI 5. An exact +external pathname Unix-socket grant under `BlockAll` requires ABI 9 so socket +connection authority remains separate from filesystem visibility. The +requirement is policy-specific and hard: a host never receives a weakened +version of a requested policy. A newer host ABI does not silently add +restrictions. The backend does not implicitly restrict signal delivery. Device +ioctls on already-open descriptors remain inherited capabilities. The host must permit the calling executable to create and configure user, mount, and—when blocking network access—network namespaces. Some distributions restrict those operations through a system security profile. Sandy exercises -the complete namespace setup in a sacrificial child during preparation and +the namespace and modern mount setup in a sacrificial child during preparation and reports the backend as unsupported when the host denies it; it never falls back to weaker enforcement. Host administrators must make the namespace capability available through their normal system security policy. @@ -62,10 +66,14 @@ fixed mutation rights through ABI 8 because ABI 9's aggregate write set also contains pathname-socket resolution. `ResolveUnix` is added only for an exact typed socket grant. -`BlockAll` uses a private network namespace and denies new non-Unix socket file -descriptors. When the policy has no pathname Unix-socket grants, all new socket -file descriptors are denied. Already-open descriptors remain caller-held -capabilities, matching the public current-process contract. +`BlockAll` uses a private network namespace and denies new externally +addressable non-Unix socket descriptors. When the policy has no pathname +Unix-socket grants, creating addressable sockets is denied without requiring +pathname-socket mediation. A typed external pathname-socket grant enables only +Unix socket creation and requires ABI 9 to limit connections to that exact +path. Unnamed Unix `socketpair` IPC remains available because it cannot connect +to a host endpoint. Already-open descriptors remain caller-held capabilities, +matching the public current-process contract. `clone3` is rejected with `ENOSYS` so standard libraries can use their legacy `clone` fallback. Legacy `clone` always rejects namespace flags. When subprocess @@ -74,11 +82,13 @@ rejects `fork`, `vfork`, `execve`, and `execveat`. ## Filesystem view -The backend pins each source path before enforcement, clones mounts from those -descriptors, and attaches them to descriptor-pinned targets in a private tmpfs -root. Non-granted siblings, host `/proc`, `/sys`, `/run`, and the former host -root are absent. Open descriptors and memory acquired before `apply()` are not -revoked. +The backend pins each source path before enforcement. After namespace entry it +reopens the canonical path and rejects replacement unless device, inode, and +object type still match the original pin. It clones mounts only from those +new-namespace descriptors and attaches them to descriptor-pinned targets in a +private tmpfs root. Non-granted siblings, host `/proc`, `/sys`, `/run`, and the +former host root are absent. Open descriptors and memory acquired before +`apply()` are not revoked. An exact protected file is overmounted read-only. A protected subtree is recursively read-only. From 32f0e28bd3abac03d40809ada5585b66e4da8eed Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 19:47:24 +0200 Subject: [PATCH 07/20] fix(linux): close asynchronous socket bypasses --- crates/linux/src/ffi.rs | 27 +++++++++++++++++++++++++++ crates/linux/src/seccomp.rs | 21 +++++++++++++++++++-- docs/LINUX_SECURITY_MODEL.md | 6 ++++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/linux/src/ffi.rs b/crates/linux/src/ffi.rs index b097c57..d2bf58f 100644 --- a/crates/linux/src/ffi.rs +++ b/crates/linux/src/ffi.rs @@ -483,6 +483,33 @@ pub(crate) fn verify_no_capabilities() -> io::Result<()> { } } +pub(crate) fn verify_io_uring_blocked() -> io::Result<()> { + // SAFETY: the seccomp filter must reject this syscall before the kernel + // dereferences the deliberately null parameter pointer. No descriptor is + // created on the required EPERM path and no ownership is transferred. + let result = unsafe { + libc::syscall( + libc::SYS_io_uring_setup, + 1_u32, + std::ptr::null::(), + ) + }; + let error = io::Error::last_os_error(); + if result == -1 && error.raw_os_error() == Some(libc::EPERM) { + Ok(()) + } else { + if result >= 0 { + let descriptor = + i32::try_from(result).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))?; + // SAFETY: an unexpected successful setup returned a fresh + // descriptor, which is closed exactly once before reporting the + // failed postcondition. + unsafe { libc::close(descriptor) }; + } + Err(io::Error::from_raw_os_error(libc::EPERM)) + } +} + pub(crate) fn c_string(value: &str) -> io::Result { CString::new(value).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL)) } diff --git a/crates/linux/src/seccomp.rs b/crates/linux/src/seccomp.rs index 67ac73a..cbd05a7 100644 --- a/crates/linux/src/seccomp.rs +++ b/crates/linux/src/seccomp.rs @@ -10,6 +10,7 @@ use crate::{LinuxError, LinuxErrorKind}; pub(crate) struct SeccompPrograms { clone3: BpfProgram, topology: BpfProgram, + block_network: bool, } pub(crate) fn compile( @@ -65,6 +66,12 @@ pub(crate) fn compile( denied.insert(libc::SYS_clone, clone_rules); if block_network { + // io_uring can create and connect sockets without entering the + // corresponding socket/connect syscalls, so BlockAll must disable the + // ring interface as one indivisible capability. + denied.insert(libc::SYS_io_uring_setup, Vec::new()); + denied.insert(libc::SYS_io_uring_enter, Vec::new()); + denied.insert(libc::SYS_io_uring_register, Vec::new()); let socket_rules = if allow_pathname_unix { vec![ SeccompRule::new(vec![ @@ -89,7 +96,11 @@ pub(crate) fn compile( SeccompAction::Errno(libc::EPERM as u32), architecture, )?; - Ok(SeccompPrograms { clone3, topology }) + Ok(SeccompPrograms { + clone3, + topology, + block_network, + }) } fn deny_arch_process_creation(_denied: &mut BTreeMap>) { @@ -106,7 +117,13 @@ fn deny_arch_process_creation(_denied: &mut BTreeMap>) { pub(crate) fn apply(programs: &SeccompPrograms) -> Result<(), LinuxError> { seccompiler::apply_filter(&programs.clone3) .and_then(|()| seccompiler::apply_filter(&programs.topology)) - .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp application")) + .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp application"))?; + if programs.block_network { + crate::ffi::verify_io_uring_blocked().map_err(|_| { + LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition") + })?; + } + Ok(()) } fn compile_filter( diff --git a/docs/LINUX_SECURITY_MODEL.md b/docs/LINUX_SECURITY_MODEL.md index b64b120..1728849 100644 --- a/docs/LINUX_SECURITY_MODEL.md +++ b/docs/LINUX_SECURITY_MODEL.md @@ -72,8 +72,10 @@ Unix-socket grants, creating addressable sockets is denied without requiring pathname-socket mediation. A typed external pathname-socket grant enables only Unix socket creation and requires ABI 9 to limit connections to that exact path. Unnamed Unix `socketpair` IPC remains available because it cannot connect -to a host endpoint. Already-open descriptors remain caller-held capabilities, -matching the public current-process contract. +to a host endpoint. Ring setup, entry, and registration are disabled under +`BlockAll` so asynchronous socket operations cannot bypass these controls. +Already-open ordinary descriptors remain caller-held capabilities, matching the +public current-process contract. `clone3` is rejected with `ENOSYS` so standard libraries can use their legacy `clone` fallback. Legacy `clone` always rejects namespace flags. When subprocess From 84e232b9208ba051cc8cf6274cb2bb92be9ff353 Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 19:57:35 +0200 Subject: [PATCH 08/20] feat(linux): support explicit runtime devices --- crates/linux/src/landlock.rs | 12 ++++++-- crates/linux/src/plan.rs | 11 +++++++ crates/linux/src/prepare.rs | 11 +++++-- crates/linux/src/support.rs | 2 +- crates/linux/tests/live_linux.rs | 49 ++++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 7 deletions(-) diff --git a/crates/linux/src/landlock.rs b/crates/linux/src/landlock.rs index 0d10e80..8e4591b 100644 --- a/crates/linux/src/landlock.rs +++ b/crates/linux/src/landlock.rs @@ -49,7 +49,7 @@ pub(crate) fn prepare( .ok_or_else(|| preparation("Landlock path lookup"))?; let mut rights = read_rights(pinned_path.kind); if grant.access == AccessMode::ReadWrite { - rights |= write_rights(); + rights |= write_rights(pinned_path.kind); } ruleset = add_rule(ruleset, &pinned_path.file, rights)?; } @@ -113,11 +113,17 @@ fn read_rights(kind: PinnedKind) -> BitFlags { } } -fn write_rights() -> BitFlags { +fn write_rights(kind: PinnedKind) -> BitFlags { // ABI 9's `from_write` includes ResolveUnix. Socket connection authority // is independent from filesystem mutation, so use the complete fixed ABI 8 // mutation set and add ResolveUnix only for typed socket grants. - AccessFs::from_write(ABI::V8) + let rights = AccessFs::from_write(ABI::V8); + if kind == PinnedKind::Directory { + rights + } else { + // Never attach directory-mutation rights to an exact file-like rule. + rights & AccessFs::from_file(ABI::V8) + } } fn preparation(phase: &'static str) -> LinuxError { diff --git a/crates/linux/src/plan.rs b/crates/linux/src/plan.rs index 9088512..34e29cd 100644 --- a/crates/linux/src/plan.rs +++ b/crates/linux/src/plan.rs @@ -25,6 +25,16 @@ impl LinuxPolicyPlan { pub fn allows_subprocesses(&self) -> bool { self.policy.spec().allow_subprocesses } + + /// Returns the minimum Landlock ABI required by this policy. + /// + /// The requirement is policy-specific: ordinary filesystem policies use + /// the fixed baseline while exact pathname Unix-socket authority requires + /// the newer socket-resolution right. + #[must_use] + pub fn required_landlock_abi(&self) -> u32 { + crate::support::required_abi(self.policy.spec()) + } } /// Lowers a validated policy into deterministic Linux semantics. @@ -100,6 +110,7 @@ mod tests { let plan = plan(&policy)?; assert!(plan.blocks_network()); assert!(!plan.allows_subprocesses()); + assert_eq!(plan.required_landlock_abi(), crate::BASELINE_LANDLOCK_ABI); Ok(()) } diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs index 73ad051..375ae59 100644 --- a/crates/linux/src/prepare.rs +++ b/crates/linux/src/prepare.rs @@ -160,9 +160,14 @@ fn prepare_mounts( grant.scope == PathScope::Exact && pinned_path.kind == PinnedKind::Directory; let subtree_non_directory = grant.scope == PathScope::Subtree && pinned_path.kind != PinnedKind::Directory; - let exact_read_write = - grant.scope == PathScope::Exact && grant.access == AccessMode::ReadWrite; - if exact_directory || subtree_non_directory || exact_read_write { + // Landlock can constrain file-like write operations on one exact + // object. Directory mutation rights apply beneath directories, so an + // exact read-write directory would silently imply subtree authority. + // A typed exact device grant does not carry that ambiguity. + let unsupported_exact_read_write = grant.scope == PathScope::Exact + && grant.access == AccessMode::ReadWrite + && pinned_path.kind != PinnedKind::Device; + if exact_directory || subtree_non_directory || unsupported_exact_read_write { return Err(unsupported("filesystem grant shape")); } } diff --git a/crates/linux/src/support.rs b/crates/linux/src/support.rs index 56d89ab..55a2718 100644 --- a/crates/linux/src/support.rs +++ b/crates/linux/src/support.rs @@ -74,7 +74,7 @@ fn probe_platform(_required_abi: u32) -> Result { )) } -fn required_abi(policy: &PolicySpec) -> u32 { +pub(crate) fn required_abi(policy: &PolicySpec) -> u32 { if policy.network == NetworkPolicy::BlockAll && !policy.unix_sockets.is_empty() { PATHNAME_SOCKET_LANDLOCK_ABI } else { diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs index f8be8d6..c36ae31 100644 --- a/crates/linux/tests/live_linux.rs +++ b/crates/linux/tests/live_linux.rs @@ -20,6 +20,7 @@ mod linux { const ALLOW_SOCKET_CHILD: &str = "SANDY_LINUX_ALLOW_SOCKET_CHILD"; const BLOCK_SOCKET_CHILD: &str = "SANDY_LINUX_BLOCK_SOCKET_CHILD"; const DENIED_SOCKET_PATH: &str = "SANDY_LINUX_DENIED_SOCKET"; + const DEVICE_CHILD: &str = "SANDY_LINUX_DEVICE_CHILD"; const ABSTRACT_SOCKET_NAME: &str = "SANDY_LINUX_ABSTRACT_SOCKET"; const REPLACEMENT_CHILD: &str = "SANDY_LINUX_REPLACEMENT_CHILD"; const SOCKET_PATH: &str = "SANDY_LINUX_LIVE_SOCKET"; @@ -36,6 +37,9 @@ mod linux { if env::var_os(BLOCK_SOCKET_CHILD).is_some() { return block_socket_child(); } + if env::var_os(DEVICE_CHILD).is_some() { + return exact_device_child(); + } if env::var_os(REPLACEMENT_CHILD).is_some() { return replacement_child(); } @@ -44,6 +48,7 @@ mod linux { } exact_directory_grants_are_rejected_before_enforcement()?; + exact_device_grants_do_not_expose_adjacent_devices()?; mount_source_replacement_is_rejected()?; let root = tempfile::tempdir()?; @@ -65,6 +70,50 @@ mod linux { Ok(()) } + fn exact_device_grants_do_not_expose_adjacent_devices() -> Result<(), Box> + { + let root = tempfile::tempdir()?; + let workspace = root.path().join("workspace"); + fs::create_dir(&workspace)?; + let status = Command::new(env::current_exe()?) + .env(DEVICE_CHILD, "1") + .env(WORKSPACE, &workspace) + .status()?; + if !status.success() { + return Err("exact device child failed".into()); + } + Ok(()) + } + + fn exact_device_child() -> Result<(), Box> { + let workspace = absolute_from_environment(WORKSPACE)?; + let null_device = absolute(std::path::Path::new("/dev/null"))?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![ + FileGrant { + path: workspace.clone(), + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }, + FileGrant { + path: null_device, + access: AccessMode::ReadWrite, + scope: PathScope::Exact, + }, + ], + network: NetworkPolicy::AllowAll, + ..PolicySpec::default() + })?; + let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace)?; + sandy_linux::apply(prepared)?; + + fs::write("/dev/null", b"discarded")?; + if fs::metadata("/dev/zero").is_ok() { + return Err("adjacent device remained visible".into()); + } + Ok(()) + } + fn mount_source_replacement_is_rejected() -> Result<(), Box> { let status = Command::new(env::current_exe()?) .env(REPLACEMENT_CHILD, "1") From e1c2d6c007ed4d1f15cd50ddcd231455cb453b50 Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 20:03:19 +0200 Subject: [PATCH 09/20] fix(linux): rebuild visible runtime aliases --- crates/linux/src/prepare.rs | 162 +++++++++++++++++++++++++++++++++--- 1 file changed, 151 insertions(+), 11 deletions(-) diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs index 375ae59..675a7b9 100644 --- a/crates/linux/src/prepare.rs +++ b/crates/linux/src/prepare.rs @@ -5,7 +5,7 @@ use std::{ fd::AsRawFd, unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}, }, - path::PathBuf, + path::{Path, PathBuf}, }; use sandy_core::{AbsolutePath, AccessMode, PathScope, RuntimeCompatibility}; @@ -257,11 +257,18 @@ fn prepare_runtime_aliases( } let mut aliases = Vec::new(); + let mut destinations = Vec::<(AbsolutePath, AbsolutePath)>::new(); for requested in [ + "/proc/self", + "/dev/fd", + "/dev/stdin", + "/dev/stdout", + "/dev/stderr", "/bin", "/sbin", "/lib", "/lib64", + "/etc/resolv.conf", "/etc/localtime", "/etc/ssl/cert.pem", ] { @@ -273,27 +280,107 @@ fn prepare_runtime_aliases( if !metadata.file_type().is_symlink() { continue; } - let canonical = std::fs::canonicalize(requested) - .map_err(|_| preparation("runtime alias resolution"))?; - let canonical = AbsolutePath::new( - canonical - .to_str() - .ok_or_else(|| preparation("runtime alias representation"))? - .to_owned(), - ) - .map_err(|_| preparation("runtime alias representation"))?; let path = AbsolutePath::new(requested.to_owned()) .map_err(|_| preparation("runtime alias representation"))?; - if visible(&path, mounts.iter()) || !visible(&canonical, mounts.iter()) { + if visible(&path, mounts.iter()) { continue; } let target = std::fs::read_link(requested).map_err(|_| preparation("runtime alias resolution"))?; + let lexical = lexical_symlink_target(path.as_path(), &target) + .ok_or_else(|| preparation("runtime alias resolution"))?; + let rewritten = rewrite_runtime_target(lexical, &destinations); + let resolved = absolute_path(&rewritten) + .ok() + .filter(|candidate| runtime_target_reachable(candidate, mounts)); + let resolved = match resolved { + Some(resolved) => resolved, + None => { + let canonical = std::fs::canonicalize(requested) + .map_err(|_| preparation("runtime alias resolution"))?; + let canonical = absolute_path(&canonical)?; + if !runtime_target_reachable(&canonical, mounts) { + continue; + } + canonical + } + }; aliases.push(RuntimeAlias { path, target }); + destinations.push(( + aliases + .last() + .ok_or_else(|| preparation("runtime alias construction"))? + .path + .clone(), + resolved, + )); } Ok(aliases) } +fn lexical_symlink_target(path: &Path, target: &std::path::Path) -> Option { + let candidate = if target.is_absolute() { + target.to_path_buf() + } else { + path.parent()?.join(target) + }; + let mut normalized = PathBuf::new(); + for component in candidate.components() { + match component { + std::path::Component::RootDir => normalized.push("/"), + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if !normalized.pop() { + return None; + } + } + std::path::Component::Normal(component) => normalized.push(component), + std::path::Component::Prefix(_) => return None, + } + } + normalized.is_absolute().then_some(normalized) +} + +fn rewrite_runtime_target( + mut target: PathBuf, + destinations: &[(AbsolutePath, AbsolutePath)], +) -> PathBuf { + for _ in 0..=destinations.len() { + let Some((source, destination)) = destinations + .iter() + .filter(|(source, _)| target.starts_with(source.as_path())) + .max_by_key(|(source, _)| source.as_path().components().count()) + else { + break; + }; + let Ok(suffix) = target.strip_prefix(source.as_path()) else { + break; + }; + let rewritten = destination.as_path().join(suffix); + if rewritten == target { + break; + } + target = rewritten; + } + target +} + +fn absolute_path(path: &Path) -> Result { + AbsolutePath::new( + path.to_str() + .ok_or_else(|| preparation("runtime alias representation"))? + .to_owned(), + ) + .map_err(|_| preparation("runtime alias representation")) +} + +fn runtime_target_reachable(path: &AbsolutePath, mounts: &[MountRequirement]) -> bool { + visible(path, mounts.iter()) + || mounts + .iter() + .any(|mount| mount.path.as_path().starts_with(path.as_path())) +} + fn pin(path: &AbsolutePath) -> Result { let root = OpenOptions::new() .read(true) @@ -388,3 +475,56 @@ fn enforcement(phase: &'static str) -> LinuxError { fn unsupported(phase: &'static str) -> LinuxError { LinuxError::new(LinuxErrorKind::Unsupported, phase) } + +#[cfg(test)] +mod tests { + use super::*; + + fn path(value: &str) -> Result> { + Ok(AbsolutePath::new(value.to_owned())?) + } + + #[test] + fn runtime_aliases_may_target_an_ancestor_of_a_visible_mount() + -> Result<(), Box> { + let mounts = vec![MountRequirement { + path: path("/proc/123/fd")?, + recursive: true, + writable: false, + }]; + + assert!(runtime_target_reachable(&path("/proc/123")?, &mounts)); + assert!(!runtime_target_reachable(&path("/proc/124")?, &mounts)); + Ok(()) + } + + #[test] + fn runtime_alias_chains_resolve_only_through_known_destinations() + -> Result<(), Box> { + let destinations = vec![(path("/proc/self")?, path("/proc/123")?)]; + assert_eq!( + rewrite_runtime_target(PathBuf::from("/proc/self/fd/0"), &destinations), + PathBuf::from("/proc/123/fd/0") + ); + assert_eq!( + rewrite_runtime_target(PathBuf::from("/etc/passwd"), &destinations), + PathBuf::from("/etc/passwd") + ); + Ok(()) + } + + #[test] + fn relative_runtime_alias_targets_are_normalized_without_filesystem_access() { + assert_eq!( + lexical_symlink_target( + Path::new("/etc/resolv.conf"), + Path::new("../run/resolv.conf") + ), + Some(PathBuf::from("/run/resolv.conf")) + ); + assert_eq!( + lexical_symlink_target(Path::new("/bin"), Path::new("../../outside")), + None + ); + } +} From 1b09ab5367b15b34ffc7659789ec0891dc9c94fa Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 20:29:18 +0200 Subject: [PATCH 10/20] fix(linux): keep readable mounts non-executable --- crates/linux/src/ffi.rs | 30 +++++- crates/linux/src/mount.rs | 13 ++- crates/linux/src/prepare.rs | 177 +++++-------------------------- crates/linux/tests/live_linux.rs | 75 ++++++++++++- docs/LINUX_SECURITY_MODEL.md | 10 +- 5 files changed, 140 insertions(+), 165 deletions(-) diff --git a/crates/linux/src/ffi.rs b/crates/linux/src/ffi.rs index d2bf58f..8d54434 100644 --- a/crates/linux/src/ffi.rs +++ b/crates/linux/src/ffi.rs @@ -143,7 +143,10 @@ fn probe_mount_sequence() -> bool { return false; } let attributes = libc::mount_attr { - attr_set: libc::MOUNT_ATTR_NOSUID | libc::MOUNT_ATTR_NODEV | libc::MOUNT_ATTR_RDONLY, + attr_set: libc::MOUNT_ATTR_NOSUID + | libc::MOUNT_ATTR_NODEV + | libc::MOUNT_ATTR_NOEXEC + | libc::MOUNT_ATTR_RDONLY, attr_clr: 0, propagation: 0, userns_fd: 0, @@ -151,6 +154,15 @@ fn probe_mount_sequence() -> bool { if probe_mount_setattr(mount_fd, &attributes) < 0 { return false; } + let executable_attributes = libc::mount_attr { + attr_set: 0, + attr_clr: libc::MOUNT_ATTR_NOEXEC, + propagation: 0, + userns_fd: 0, + }; + if probe_mount_setattr(mount_fd, &executable_attributes) < 0 { + return false; + } let target = probe_open(c"/tmp/.sandy-probe-root/usr"); if target < 0 { return false; @@ -174,7 +186,7 @@ fn probe_mount_tmpfs(target: &CStr) -> libc::c_int { c"tmpfs".as_ptr(), target.as_ptr(), c"tmpfs".as_ptr(), - (libc::MS_NOSUID | libc::MS_NODEV) as libc::c_ulong, + (libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC) as libc::c_ulong, c"mode=0700,size=1m".as_ptr().cast(), ) } @@ -297,7 +309,7 @@ pub(crate) fn mount_tmpfs(target: &CStr) -> io::Result<()> { c"tmpfs".as_ptr(), target.as_ptr(), c"tmpfs".as_ptr(), - (libc::MS_NOSUID | libc::MS_NODEV) as libc::c_ulong, + (libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC) as libc::c_ulong, c"mode=0700,size=64m".as_ptr().cast(), ) }) @@ -355,6 +367,7 @@ pub(crate) fn restrict_mount( recursive: bool, read_only: bool, allow_device: bool, + allow_execute: bool, ) -> io::Result<()> { let attributes = libc::mount_attr { attr_set: libc::MOUNT_ATTR_NOSUID @@ -367,8 +380,17 @@ pub(crate) fn restrict_mount( libc::MOUNT_ATTR_RDONLY } else { 0 + } + | if allow_execute { + 0 + } else { + libc::MOUNT_ATTR_NOEXEC }, - attr_clr: 0, + attr_clr: if allow_execute { + libc::MOUNT_ATTR_NOEXEC + } else { + 0 + }, propagation: 0, userns_fd: 0, }; diff --git a/crates/linux/src/mount.rs b/crates/linux/src/mount.rs index 4392904..a62d827 100644 --- a/crates/linux/src/mount.rs +++ b/crates/linux/src/mount.rs @@ -52,6 +52,7 @@ pub(crate) fn construct_and_enter(preparation: &MountPreparation) -> Result<(), requirement.recursive, !requirement.writable, source.kind == PinnedKind::Device, + requirement.executable, )?; } for protection in &preparation.protections { @@ -66,6 +67,7 @@ pub(crate) fn construct_and_enter(preparation: &MountPreparation) -> Result<(), protection.recursive, protection.read_only, source.kind == PinnedKind::Device, + protection.executable, )?; } for alias in &preparation.aliases { @@ -120,6 +122,7 @@ fn attach( recursive: bool, read_only: bool, allow_device: bool, + allow_execute: bool, ) -> Result<(), LinuxError> { let relative = path.as_str().strip_prefix('/').unwrap_or(path.as_str()); let relative = ffi::c_string(relative).map_err(|_| enforcement("mount target pinning"))?; @@ -127,8 +130,14 @@ fn attach( .map_err(|_| enforcement("mount target pinning"))?; let detached = ffi::clone_mount(source.as_raw_fd(), recursive) .map_err(|_| enforcement("detached mount creation"))?; - ffi::restrict_mount(detached.as_raw_fd(), recursive, read_only, allow_device) - .map_err(|_| enforcement("detached mount restriction"))?; + ffi::restrict_mount( + detached.as_raw_fd(), + recursive, + read_only, + allow_device, + allow_execute, + ) + .map_err(|_| enforcement("detached mount restriction"))?; ffi::attach_mount(detached.as_raw_fd(), target.as_raw_fd()) .map_err(|_| enforcement("mount attachment")) } diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs index 675a7b9..f2bfb09 100644 --- a/crates/linux/src/prepare.rs +++ b/crates/linux/src/prepare.rs @@ -5,7 +5,7 @@ use std::{ fd::AsRawFd, unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}, }, - path::{Path, PathBuf}, + path::PathBuf, }; use sandy_core::{AbsolutePath, AccessMode, PathScope, RuntimeCompatibility}; @@ -32,12 +32,14 @@ pub(crate) struct MountRequirement { pub(crate) path: AbsolutePath, pub(crate) recursive: bool, pub(crate) writable: bool, + pub(crate) executable: bool, } pub(crate) struct ProtectionRequirement { pub(crate) path: AbsolutePath, pub(crate) recursive: bool, pub(crate) read_only: bool, + pub(crate) executable: bool, } pub(crate) struct MountPreparation { @@ -108,6 +110,7 @@ fn prepare_mounts( path: grant.path.clone(), recursive: false, writable: false, + executable: false, }); entry.recursive |= grant.scope == PathScope::Subtree; entry.writable |= grant.access == AccessMode::ReadWrite; @@ -122,8 +125,10 @@ fn prepare_mounts( path: grant.path.clone(), recursive: false, writable: false, + executable: false, }); entry.recursive |= grant.scope == PathScope::Subtree; + entry.executable = true; } for grant in &spec.unix_sockets { if !denied(&grant.path, &spec.protected_paths) { @@ -133,6 +138,7 @@ fn prepare_mounts( path: grant.path.clone(), recursive: false, writable: false, + executable: false, }); } } @@ -207,6 +213,7 @@ fn prepare_mounts( parent.recursive && mount.path.as_path().starts_with(parent.path.as_path()) && (!mount.writable || parent.writable) + && (!mount.executable || parent.executable) }); if !covered { effective.push(mount); @@ -229,6 +236,7 @@ fn prepare_mounts( path: protection.path.clone(), recursive: protection.scope == PathScope::Subtree, read_only: true, + executable: executable(&protection.path, &spec.executables), }) }) .collect::, LinuxError>>()?; @@ -248,6 +256,14 @@ fn prepare_mounts( }) } +fn executable(path: &AbsolutePath, grants: &[sandy_core::ExecutableGrant]) -> bool { + grants.iter().any(|grant| { + path == &grant.path + || (grant.scope == PathScope::Subtree + && path.as_path().starts_with(grant.path.as_path())) + }) +} + fn prepare_runtime_aliases( compatibility: RuntimeCompatibility, mounts: &[MountRequirement], @@ -257,13 +273,7 @@ fn prepare_runtime_aliases( } let mut aliases = Vec::new(); - let mut destinations = Vec::<(AbsolutePath, AbsolutePath)>::new(); for requested in [ - "/proc/self", - "/dev/fd", - "/dev/stdin", - "/dev/stdout", - "/dev/stderr", "/bin", "/sbin", "/lib", @@ -280,107 +290,27 @@ fn prepare_runtime_aliases( if !metadata.file_type().is_symlink() { continue; } + let canonical = std::fs::canonicalize(requested) + .map_err(|_| preparation("runtime alias resolution"))?; + let canonical = AbsolutePath::new( + canonical + .to_str() + .ok_or_else(|| preparation("runtime alias representation"))? + .to_owned(), + ) + .map_err(|_| preparation("runtime alias representation"))?; let path = AbsolutePath::new(requested.to_owned()) .map_err(|_| preparation("runtime alias representation"))?; - if visible(&path, mounts.iter()) { + if visible(&path, mounts.iter()) || !visible(&canonical, mounts.iter()) { continue; } let target = std::fs::read_link(requested).map_err(|_| preparation("runtime alias resolution"))?; - let lexical = lexical_symlink_target(path.as_path(), &target) - .ok_or_else(|| preparation("runtime alias resolution"))?; - let rewritten = rewrite_runtime_target(lexical, &destinations); - let resolved = absolute_path(&rewritten) - .ok() - .filter(|candidate| runtime_target_reachable(candidate, mounts)); - let resolved = match resolved { - Some(resolved) => resolved, - None => { - let canonical = std::fs::canonicalize(requested) - .map_err(|_| preparation("runtime alias resolution"))?; - let canonical = absolute_path(&canonical)?; - if !runtime_target_reachable(&canonical, mounts) { - continue; - } - canonical - } - }; aliases.push(RuntimeAlias { path, target }); - destinations.push(( - aliases - .last() - .ok_or_else(|| preparation("runtime alias construction"))? - .path - .clone(), - resolved, - )); } Ok(aliases) } -fn lexical_symlink_target(path: &Path, target: &std::path::Path) -> Option { - let candidate = if target.is_absolute() { - target.to_path_buf() - } else { - path.parent()?.join(target) - }; - let mut normalized = PathBuf::new(); - for component in candidate.components() { - match component { - std::path::Component::RootDir => normalized.push("/"), - std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - if !normalized.pop() { - return None; - } - } - std::path::Component::Normal(component) => normalized.push(component), - std::path::Component::Prefix(_) => return None, - } - } - normalized.is_absolute().then_some(normalized) -} - -fn rewrite_runtime_target( - mut target: PathBuf, - destinations: &[(AbsolutePath, AbsolutePath)], -) -> PathBuf { - for _ in 0..=destinations.len() { - let Some((source, destination)) = destinations - .iter() - .filter(|(source, _)| target.starts_with(source.as_path())) - .max_by_key(|(source, _)| source.as_path().components().count()) - else { - break; - }; - let Ok(suffix) = target.strip_prefix(source.as_path()) else { - break; - }; - let rewritten = destination.as_path().join(suffix); - if rewritten == target { - break; - } - target = rewritten; - } - target -} - -fn absolute_path(path: &Path) -> Result { - AbsolutePath::new( - path.to_str() - .ok_or_else(|| preparation("runtime alias representation"))? - .to_owned(), - ) - .map_err(|_| preparation("runtime alias representation")) -} - -fn runtime_target_reachable(path: &AbsolutePath, mounts: &[MountRequirement]) -> bool { - visible(path, mounts.iter()) - || mounts - .iter() - .any(|mount| mount.path.as_path().starts_with(path.as_path())) -} - fn pin(path: &AbsolutePath) -> Result { let root = OpenOptions::new() .read(true) @@ -475,56 +405,3 @@ fn enforcement(phase: &'static str) -> LinuxError { fn unsupported(phase: &'static str) -> LinuxError { LinuxError::new(LinuxErrorKind::Unsupported, phase) } - -#[cfg(test)] -mod tests { - use super::*; - - fn path(value: &str) -> Result> { - Ok(AbsolutePath::new(value.to_owned())?) - } - - #[test] - fn runtime_aliases_may_target_an_ancestor_of_a_visible_mount() - -> Result<(), Box> { - let mounts = vec![MountRequirement { - path: path("/proc/123/fd")?, - recursive: true, - writable: false, - }]; - - assert!(runtime_target_reachable(&path("/proc/123")?, &mounts)); - assert!(!runtime_target_reachable(&path("/proc/124")?, &mounts)); - Ok(()) - } - - #[test] - fn runtime_alias_chains_resolve_only_through_known_destinations() - -> Result<(), Box> { - let destinations = vec![(path("/proc/self")?, path("/proc/123")?)]; - assert_eq!( - rewrite_runtime_target(PathBuf::from("/proc/self/fd/0"), &destinations), - PathBuf::from("/proc/123/fd/0") - ); - assert_eq!( - rewrite_runtime_target(PathBuf::from("/etc/passwd"), &destinations), - PathBuf::from("/etc/passwd") - ); - Ok(()) - } - - #[test] - fn relative_runtime_alias_targets_are_normalized_without_filesystem_access() { - assert_eq!( - lexical_symlink_target( - Path::new("/etc/resolv.conf"), - Path::new("../run/resolv.conf") - ), - Some(PathBuf::from("/run/resolv.conf")) - ); - assert_eq!( - lexical_symlink_target(Path::new("/bin"), Path::new("../../outside")), - None - ); - } -} diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs index c36ae31..ffbb2e3 100644 --- a/crates/linux/tests/live_linux.rs +++ b/crates/linux/tests/live_linux.rs @@ -5,6 +5,7 @@ mod linux { net::{TcpListener, TcpStream, UdpSocket}, os::{ linux::net::SocketAddrExt, + unix::fs::PermissionsExt as _, unix::net::{SocketAddr, UnixListener, UnixStream}, }, process::Command, @@ -12,8 +13,9 @@ mod linux { }; use sandy_core::{ - AbsolutePath, AccessMode, FileGrant, NetworkPolicy, PathScope, PolicySpec, UnixSocketGrant, - UnixSocketOperation, ValidatedPolicy, WriteProtection, + AbsolutePath, AccessMode, ExecutableGrant, FileGrant, NetworkPolicy, PathScope, PolicySpec, + RuntimeCompatibility, UnixSocketGrant, UnixSocketOperation, ValidatedPolicy, + WriteProtection, }; const CHILD_MODE: &str = "SANDY_LINUX_LIVE_CHILD"; @@ -56,6 +58,13 @@ mod linux { fs::create_dir(&workspace)?; fs::write(workspace.join("readable.txt"), "visible")?; fs::write(workspace.join("locked.txt"), "locked")?; + for name in ["allowed-true", "data-true"] { + let path = workspace.join(name); + fs::copy("/bin/true", &path)?; + let mut permissions = fs::metadata(&path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions)?; + } fs::write(root.path().join("outside.txt"), "hidden")?; let status = Command::new(env::current_exe()?) @@ -328,17 +337,48 @@ mod linux { .to_owned(), )?; let locked = AbsolutePath::new(format!("{}/locked.txt", workspace.as_str()))?; - let policy = ValidatedPolicy::try_from(PolicySpec { - files: vec![FileGrant { + let allowed_true = AbsolutePath::new(format!("{}/allowed-true", workspace.as_str()))?; + let data_true = AbsolutePath::new(format!("{}/data-true", workspace.as_str()))?; + let system_libraries = absolute(std::path::Path::new("/usr/lib"))?; + let loader = absolute(&fs::canonicalize(dynamic_loader())?)?; + let mut files = vec![ + FileGrant { path: workspace.clone(), access: AccessMode::ReadWrite, scope: PathScope::Subtree, - }], + }, + FileGrant { + path: system_libraries.clone(), + access: AccessMode::Read, + scope: PathScope::Subtree, + }, + ]; + if let Ok(cache) = fs::canonicalize("/etc/ld.so.cache") { + files.push(FileGrant { + path: absolute(&cache)?, + access: AccessMode::Read, + scope: PathScope::Exact, + }); + } + let policy = ValidatedPolicy::try_from(PolicySpec { + files, + executables: vec![ + ExecutableGrant { + path: system_libraries, + scope: PathScope::Subtree, + }, + ExecutableGrant { + path: allowed_true.clone(), + scope: PathScope::Exact, + }, + ], write_protections: vec![WriteProtection { path: locked, scope: PathScope::Exact, }], network: NetworkPolicy::BlockAll, + allow_subprocesses: true, + runtime_compatibility: RuntimeCompatibility::ForegroundCli, ..PolicySpec::default() })?; @@ -355,6 +395,20 @@ mod linux { if fs::metadata("/etc/passwd").is_ok() || fs::metadata("../outside.txt").is_ok() { return Err("non-granted filesystem data remained visible".into()); } + if !Command::new(loader.as_path()) + .arg(allowed_true.as_path()) + .status()? + .success() + { + return Err("explicit executable mapping was not preserved".into()); + } + if Command::new(loader.as_path()) + .arg(data_true.as_path()) + .status()? + .success() + { + return Err("readable data was mapped executable".into()); + } if TcpStream::connect_timeout(&"1.1.1.1:80".parse()?, Duration::from_millis(100)).is_ok() { return Err("blocked network connection succeeded".into()); } @@ -368,6 +422,17 @@ mod linux { Ok(()) } + fn dynamic_loader() -> &'static std::path::Path { + #[cfg(target_arch = "x86_64")] + { + std::path::Path::new("/lib64/ld-linux-x86-64.so.2") + } + #[cfg(target_arch = "aarch64")] + { + std::path::Path::new("/lib/ld-linux-aarch64.so.1") + } + } + fn exact_directory_grants_are_rejected_before_enforcement() -> Result<(), Box> { let root = tempfile::tempdir()?; diff --git a/docs/LINUX_SECURITY_MODEL.md b/docs/LINUX_SECURITY_MODEL.md index 1728849..0e57c51 100644 --- a/docs/LINUX_SECURITY_MODEL.md +++ b/docs/LINUX_SECURITY_MODEL.md @@ -61,10 +61,12 @@ reports the backend as unsupported when the host denies it; it never falls back to weaker enforcement. Host administrators must make the namespace capability available through their normal system security policy. -Read authority never includes execute authority. Read/write authority uses the -fixed mutation rights through ABI 8 because ABI 9's aggregate write set also -contains pathname-socket resolution. `ResolveUnix` is added only for an exact -typed socket grant. +Read authority never includes execute authority. The private filesystem marks +non-executable mounts `noexec`; an executable nested below a readable subtree +receives its own executable overmount. Landlock separately mediates `execve`. +Read/write authority uses the fixed mutation rights through ABI 8 because ABI +9's aggregate write set also contains pathname-socket resolution. `ResolveUnix` +is added only for an exact typed socket grant. `BlockAll` uses a private network namespace and denies new externally addressable non-Unix socket descriptors. When the policy has no pathname From 510f1afaff0c974ba2c83cc0e9dd8d195b8da24d Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 20:43:51 +0200 Subject: [PATCH 11/20] fix(linux): close remaining policy gaps --- .github/workflows/ci.yml | 2 +- Makefile | 2 +- crates/linux/Cargo.toml | 4 ++++ crates/linux/src/plan.rs | 32 ++++++++++++++++++++++++++++++-- crates/linux/src/seccomp.rs | 9 +++++++++ crates/linux/tests/live_linux.rs | 5 ++++- 6 files changed, 49 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bf08d4..4e736dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: SANDY_LINUX_EXPECT_UNSUPPORTED: "1" run: | if [ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "1" ]; then - cargo test -p sandy-linux --test live_linux --locked + cargo test -p sandy-linux --test live_linux --features live-tests --locked fi - name: Enable user namespaces for positive enforcement tests run: | diff --git a/Makefile b/Makefile index 5752838..9c5ba7d 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ test-live: ## Run sacrificial macOS Seatbelt enforcement tests. cargo test -p sandy-sandbox --test live_macos --features live-tests --locked test-live-linux: ## Run sacrificial Linux enforcement tests. - cargo test -p sandy-linux --test live_linux --locked -- --test-threads=1 + cargo test -p sandy-linux --test live_linux --features live-tests --locked -- --test-threads=1 fmt: ## Format Rust sources. cargo fmt --all diff --git a/crates/linux/Cargo.toml b/crates/linux/Cargo.toml index e637c1c..27d1d01 100644 --- a/crates/linux/Cargo.toml +++ b/crates/linux/Cargo.toml @@ -10,6 +10,9 @@ homepage.workspace = true authors.workspace = true publish = false +[features] +live-tests = [] + [dependencies] sandy-core.workspace = true @@ -25,6 +28,7 @@ tempfile.workspace = true name = "live_linux" path = "tests/live_linux.rs" harness = false +required-features = ["live-tests"] [lints] workspace = true diff --git a/crates/linux/src/plan.rs b/crates/linux/src/plan.rs index 34e29cd..e900aa4 100644 --- a/crates/linux/src/plan.rs +++ b/crates/linux/src/plan.rs @@ -63,10 +63,12 @@ pub fn plan(policy: &ValidatedPolicy) -> Result { // same metadata-denial semantics as a fully absent path. for protected in &spec.protected_paths { let overlaps_visible_subtree = spec.files.iter().any(|grant| { - grant.scope == PathScope::Subtree + !denied(&grant.path, &spec.protected_paths) + && grant.scope == PathScope::Subtree && protected.as_path().starts_with(grant.path.as_path()) }) || spec.executables.iter().any(|grant| { - grant.scope == PathScope::Subtree + !denied(&grant.path, &spec.protected_paths) + && grant.scope == PathScope::Subtree && protected.as_path().starts_with(grant.path.as_path()) }); if overlaps_visible_subtree { @@ -79,6 +81,12 @@ pub fn plan(policy: &ValidatedPolicy) -> Result { }) } +fn denied(path: &sandy_core::AbsolutePath, protected: &[sandy_core::AbsolutePath]) -> bool { + protected + .iter() + .any(|entry| path.as_path().starts_with(entry.as_path())) +} + fn unsupported(phase: &'static str) -> LinuxError { LinuxError::new(LinuxErrorKind::Unsupported, phase) } @@ -132,6 +140,26 @@ mod tests { Ok(()) } + #[test] + fn accepts_a_deny_that_completely_removes_a_grant() -> Result<(), Box> { + for (granted, protected) in [ + (path("/workspace")?, path("/workspace")?), + (path("/workspace/project")?, path("/workspace")?), + ] { + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: granted, + access: AccessMode::Read, + scope: PathScope::Subtree, + }], + protected_paths: vec![protected], + ..PolicySpec::default() + })?; + assert!(plan(&policy).is_ok()); + } + Ok(()) + } + #[test] fn rejects_local_host_tcp_without_broadening_it() -> Result<(), Box> { use sandy_core::{LocalHostTcpGrant, LocalHostTcpOperation, TcpPort}; diff --git a/crates/linux/src/seccomp.rs b/crates/linux/src/seccomp.rs index cbd05a7..46f267f 100644 --- a/crates/linux/src/seccomp.rs +++ b/crates/linux/src/seccomp.rs @@ -89,6 +89,15 @@ pub(crate) fn compile( Vec::new() }; denied.insert(libc::SYS_socket, socket_rules); + if allow_pathname_unix { + // The typed endpoint capability is connect-only. Once AF_UNIX + // socket creation is enabled, prevent it from becoming server + // authority in an otherwise writable directory. + denied.insert(libc::SYS_bind, Vec::new()); + denied.insert(libc::SYS_listen, Vec::new()); + denied.insert(libc::SYS_accept, Vec::new()); + denied.insert(libc::SYS_accept4, Vec::new()); + } } let topology = compile_filter( diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs index ffbb2e3..42ce3d3 100644 --- a/crates/linux/tests/live_linux.rs +++ b/crates/linux/tests/live_linux.rs @@ -239,7 +239,7 @@ mod linux { let allowed = absolute_from_environment(SOCKET_PATH)?; let denied = absolute_from_environment(DENIED_SOCKET_PATH)?; let root = absolute(allowed.as_path().parent().ok_or("socket has no parent")?)?; - let policy = block_socket_policy(root.clone(), workspace, allowed.clone())?; + let policy = block_socket_policy(root.clone(), workspace.clone(), allowed.clone())?; let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &root)?; sandy_linux::apply(prepared)?; @@ -247,6 +247,9 @@ mod linux { if UnixStream::connect(denied.as_path()).is_ok() { return Err("adjacent pathname socket connection succeeded".into()); } + if UnixListener::bind(workspace.as_path().join("unexpected.sock")).is_ok() { + return Err("connect-only endpoint authority enabled a server socket".into()); + } let abstract_name = env::var(ABSTRACT_SOCKET_NAME)?; let abstract_address = SocketAddr::from_abstract_name(abstract_name.as_bytes())?; if UnixStream::connect_addr(&abstract_address).is_ok() { From 2fe80ab03e513516e1769cb348928b1c597281df Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 22:29:49 +0200 Subject: [PATCH 12/20] fix(linux): tighten native enforcement invariants --- AGENTS.md | 3 +- crates/linux/src/ffi.rs | 81 +++++++-- crates/linux/src/landlock.rs | 37 +---- crates/linux/src/lib.rs | 2 +- crates/linux/src/plan.rs | 28 +++- crates/linux/src/prepare.rs | 61 ++++--- crates/linux/src/seccomp.rs | 56 +++---- crates/linux/src/support.rs | 59 ++----- crates/linux/tests/live_linux.rs | 275 ++++++++++++++----------------- docs/LINUX_SECURITY_MODEL.md | 45 ++--- 10 files changed, 311 insertions(+), 336 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2b14620..36984bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,7 +217,8 @@ crates/linux/src/ffi.rs The Linux backend separates deterministic `LinuxPolicyPlan`, ambient `PreparedLinuxSandbox`, and irreversible `apply()` states. It requires a single-threaded caller, a private user and mount namespace, fixed Landlock ABI -9 semantics, complete capability removal, and final seccomp enforcement. +6 semantics with signal scoping, complete capability removal, and final +seccomp enforcement. It never clears an inherited host `noexec` restriction. Unsupported policy combinations fail before enforcement; there is no weaker fallback. diff --git a/crates/linux/src/ffi.rs b/crates/linux/src/ffi.rs index 8d54434..e8fe718 100644 --- a/crates/linux/src/ffi.rs +++ b/crates/linux/src/ffi.rs @@ -154,15 +154,6 @@ fn probe_mount_sequence() -> bool { if probe_mount_setattr(mount_fd, &attributes) < 0 { return false; } - let executable_attributes = libc::mount_attr { - attr_set: 0, - attr_clr: libc::MOUNT_ATTR_NOEXEC, - propagation: 0, - userns_fd: 0, - }; - if probe_mount_setattr(mount_fd, &executable_attributes) < 0 { - return false; - } let target = probe_open(c"/tmp/.sandy-probe-root/usr"); if target < 0 { return false; @@ -386,11 +377,8 @@ pub(crate) fn restrict_mount( } else { libc::MOUNT_ATTR_NOEXEC }, - attr_clr: if allow_execute { - libc::MOUNT_ATTR_NOEXEC - } else { - 0 - }, + // A caller grant must never clear a host or administrator restriction. + attr_clr: 0, propagation: 0, userns_fd: 0, }; @@ -415,6 +403,71 @@ pub(crate) fn restrict_mount( } } +pub(crate) fn mount_is_noexec(fd: RawFd) -> io::Result { + let mut status = std::mem::MaybeUninit::::uninit(); + // SAFETY: `fd` is a borrowed live descriptor. `status` points to writable, + // correctly aligned storage for exactly one `statvfs` value. The kernel + // initializes it on success and retains neither pointer nor descriptor. + cvt(unsafe { libc::fstatvfs(fd, status.as_mut_ptr()) })?; + // SAFETY: the successful call above initialized the complete structure. + let status = unsafe { status.assume_init() }; + Ok(status.f_flag & libc::ST_NOEXEC != 0) +} + +pub(crate) fn verify_clone3_blocked() -> io::Result<()> { + // SAFETY: the installed filter must reject clone3 before inspecting the + // deliberately null argument pointer. No child or ownership transfer can + // occur on the required ENOSYS path. + let result = + unsafe { libc::syscall(libc::SYS_clone3, std::ptr::null::(), 0_usize) }; + expect_errno(result, libc::ENOSYS) +} + +pub(crate) fn verify_namespace_change_blocked() -> io::Result<()> { + // SAFETY: unshare receives only the fixed mount-namespace flag and borrows + // no pointers. The installed filter must reject it before any state change. + let result = unsafe { libc::unshare(libc::CLONE_NEWNS) }; + expect_errno(result.into(), libc::EPERM) +} + +pub(crate) fn verify_fork_blocked() -> io::Result<()> { + // SAFETY: the installed filter must reject fork before a child is created. + // If a broken filter unexpectedly creates one, the child immediately exits + // without running Rust cleanup and the parent reaps it before returning an + // error. No borrowed memory or descriptor ownership crosses this boundary. + let result = unsafe { libc::fork() }; + match result { + -1 => { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EPERM) { + Ok(()) + } else { + Err(error) + } + } + 0 => { + // SAFETY: this is the unexpected child path described above. + unsafe { libc::_exit(127) } + } + child => { + let mut status = 0; + // SAFETY: `child` was returned by fork in this process and is + // reaped exactly once; the status pointer remains live for the call. + unsafe { libc::waitpid(child, &mut status, 0) }; + Err(io::Error::from_raw_os_error(libc::EPERM)) + } + } +} + +fn expect_errno(result: libc::c_long, expected: libc::c_int) -> io::Result<()> { + let error = io::Error::last_os_error(); + if result == -1 && error.raw_os_error() == Some(expected) { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(libc::EPERM)) + } +} + pub(crate) fn attach_mount(mount_fd: RawFd, target_fd: RawFd) -> io::Result<()> { let flags = libc::MOVE_MOUNT_F_EMPTY_PATH | libc::MOVE_MOUNT_T_EMPTY_PATH; // SAFETY: both descriptors are borrowed and valid for the call. Empty diff --git a/crates/linux/src/landlock.rs b/crates/linux/src/landlock.rs index 8e4591b..c03c30b 100644 --- a/crates/linux/src/landlock.rs +++ b/crates/linux/src/landlock.rs @@ -4,7 +4,7 @@ use landlock::{ ABI, Access, AccessFs, BitFlags, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr, RulesetCreated, RulesetCreatedAttr, RulesetStatus, Scope, }; -use sandy_core::{AccessMode, NetworkPolicy, PathScope, PolicySpec}; +use sandy_core::{AccessMode, PathScope, PolicySpec}; use crate::{ LinuxError, LinuxErrorKind, @@ -19,23 +19,11 @@ pub(crate) fn prepare( policy: &PolicySpec, pinned: &BTreeMap, ) -> Result { - let restrict_pathname_sockets = - policy.network == NetworkPolicy::BlockAll && !policy.unix_sockets.is_empty(); - let mut handled = AccessFs::from_all(ABI::V8); - if restrict_pathname_sockets { - handled |= AccessFs::ResolveUnix; - } let builder = Ruleset::default() .set_compatibility(CompatLevel::HardRequirement) - .handle_access(handled) - .map_err(|_| unsupported("Landlock ABI"))?; - let builder = if restrict_pathname_sockets { - builder - .scope(Scope::AbstractUnixSocket) - .map_err(|_| unsupported("Landlock scope"))? - } else { - builder - }; + .handle_access(AccessFs::from_all(ABI::V8)) + .and_then(|builder| builder.scope(Scope::Signal)) + .map_err(|_| unsupported("Landlock ABI or signal scope"))?; let mut ruleset = builder .create() .map_err(|_| unsupported("Landlock ruleset creation"))?; @@ -63,19 +51,6 @@ pub(crate) fn prepare( .file; ruleset = add_rule(ruleset, file, AccessFs::Execute.into())?; } - if restrict_pathname_sockets { - for grant in &policy.unix_sockets { - if denied(&grant.path, &policy.protected_paths) { - continue; - } - let file = &pinned - .get(&grant.path) - .ok_or_else(|| preparation("Landlock socket lookup"))? - .file; - ruleset = add_rule(ruleset, file, AccessFs::ResolveUnix.into())?; - } - } - // The shape checks in preparation ensure all directory rules are subtree // rules. PathBeneath is therefore an exact rule for files and a recursive // rule only where the typed policy requested one. @@ -114,9 +89,7 @@ fn read_rights(kind: PinnedKind) -> BitFlags { } fn write_rights(kind: PinnedKind) -> BitFlags { - // ABI 9's `from_write` includes ResolveUnix. Socket connection authority - // is independent from filesystem mutation, so use the complete fixed ABI 8 - // mutation set and add ResolveUnix only for typed socket grants. + // Keep the fixed filesystem mutation set independent from future rights. let rights = AccessFs::from_write(ABI::V8); if kind == PinnedKind::Directory { rights diff --git a/crates/linux/src/lib.rs b/crates/linux/src/lib.rs index 62cc4a4..6b095de 100644 --- a/crates/linux/src/lib.rs +++ b/crates/linux/src/lib.rs @@ -35,4 +35,4 @@ pub use error::{LinuxError, LinuxErrorKind}; pub use plan::{LinuxPolicyPlan, plan}; #[cfg(target_os = "linux")] pub use prepare::{PreparedLinuxSandbox, prepare}; -pub use support::{BASELINE_LANDLOCK_ABI, PATHNAME_SOCKET_LANDLOCK_ABI, SupportInfo, probe}; +pub use support::{BASELINE_LANDLOCK_ABI, SupportInfo, probe}; diff --git a/crates/linux/src/plan.rs b/crates/linux/src/plan.rs index e900aa4..a3bf3ac 100644 --- a/crates/linux/src/plan.rs +++ b/crates/linux/src/plan.rs @@ -28,12 +28,11 @@ impl LinuxPolicyPlan { /// Returns the minimum Landlock ABI required by this policy. /// - /// The requirement is policy-specific: ordinary filesystem policies use - /// the fixed baseline while exact pathname Unix-socket authority requires - /// the newer socket-resolution right. + /// The initial backend uses one fixed semantic floor for every accepted + /// policy, including process-signal isolation. #[must_use] pub fn required_landlock_abi(&self) -> u32 { - crate::support::required_abi(self.policy.spec()) + crate::BASELINE_LANDLOCK_ABI } } @@ -51,6 +50,9 @@ pub fn plan(policy: &ValidatedPolicy) -> Result { if !spec.local_host_tcp.is_empty() { return Err(unsupported("local-host TCP policy")); } + if !spec.unix_sockets.is_empty() { + return Err(unsupported("pathname Unix-socket policy")); + } if spec.runtime_compatibility != RuntimeCompatibility::Minimal && spec.runtime_compatibility != RuntimeCompatibility::ForegroundCli { @@ -178,4 +180,22 @@ mod tests { assert_eq!(error.kind(), LinuxErrorKind::Unsupported); Ok(()) } + + #[test] + fn rejects_pathname_socket_authority_until_the_backend_contract_supports_it() + -> Result<(), Box> { + use sandy_core::{UnixSocketGrant, UnixSocketOperation}; + + let policy = ValidatedPolicy::try_from(PolicySpec { + unix_sockets: vec![UnixSocketGrant { + path: path("/service.sock")?, + operation: UnixSocketOperation::Connect, + }], + ..PolicySpec::default() + })?; + + let error = plan(&policy).err().ok_or("policy unexpectedly supported")?; + assert_eq!(error.kind(), LinuxErrorKind::Unsupported); + Ok(()) + } } diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs index f2bfb09..08ca6d8 100644 --- a/crates/linux/src/prepare.rs +++ b/crates/linux/src/prepare.rs @@ -79,11 +79,7 @@ pub fn prepare( let namespace = namespace::prepare(block_network)?; let mount = prepare_mounts(&plan, working_directory)?; let landlock = landlock::prepare(plan.policy.spec(), &mount.pinned)?; - let seccomp = seccomp::compile( - plan.allows_subprocesses(), - block_network, - !plan.policy.spec().unix_sockets.is_empty(), - )?; + let seccomp = seccomp::compile(plan.allows_subprocesses(), block_network)?; Ok(PreparedLinuxSandbox { mount, landlock, @@ -130,19 +126,6 @@ fn prepare_mounts( entry.recursive |= grant.scope == PathScope::Subtree; entry.executable = true; } - for grant in &spec.unix_sockets { - if !denied(&grant.path, &spec.protected_paths) { - requested - .entry(grant.path.clone()) - .or_insert_with(|| MountRequirement { - path: grant.path.clone(), - recursive: false, - writable: false, - executable: false, - }); - } - } - let mut pinned = BTreeMap::new(); for path in requested.keys().chain( spec.write_protections @@ -191,20 +174,6 @@ fn prepare_mounts( return Err(unsupported("executable grant shape")); } } - for grant in &spec.unix_sockets { - if denied(&grant.path, &spec.protected_paths) { - continue; - } - if pinned - .get(&grant.path) - .ok_or_else(|| preparation("socket pinning"))? - .kind - != PinnedKind::Socket - { - return Err(unsupported("pathname socket type")); - } - } - let mut mounts = requested.into_values().collect::>(); mounts.sort_by_key(|entry| entry.path.as_path().components().count()); let mut effective = Vec::::new(); @@ -241,6 +210,19 @@ fn prepare_mounts( }) .collect::, LinuxError>>()?; + for requirement in effective + .iter() + .filter(|requirement| requirement.executable) + { + reject_host_noexec(&requirement.path, &pinned)?; + } + for requirement in protections + .iter() + .filter(|requirement| requirement.executable) + { + reject_host_noexec(&requirement.path, &pinned)?; + } + if !visible(working_directory, effective.iter()) { return Err(unsupported("working directory visibility")); } @@ -256,6 +238,21 @@ fn prepare_mounts( }) } +fn reject_host_noexec( + path: &AbsolutePath, + pinned: &BTreeMap, +) -> Result<(), LinuxError> { + let source = pinned + .get(path) + .ok_or_else(|| preparation("executable mount lookup"))?; + if crate::ffi::mount_is_noexec(source.file.as_raw_fd()) + .map_err(|_| preparation("host mount inspection"))? + { + return Err(unsupported("host noexec restriction")); + } + Ok(()) +} + fn executable(path: &AbsolutePath, grants: &[sandy_core::ExecutableGrant]) -> bool { grants.iter().any(|grant| { path == &grant.path diff --git a/crates/linux/src/seccomp.rs b/crates/linux/src/seccomp.rs index 46f267f..93f78c7 100644 --- a/crates/linux/src/seccomp.rs +++ b/crates/linux/src/seccomp.rs @@ -11,12 +11,12 @@ pub(crate) struct SeccompPrograms { clone3: BpfProgram, topology: BpfProgram, block_network: bool, + allow_subprocesses: bool, } pub(crate) fn compile( allow_subprocesses: bool, block_network: bool, - allow_pathname_unix: bool, ) -> Result { let architecture = TargetArch::try_from(std::env::consts::ARCH) .map_err(|_| unsupported("seccomp architecture"))?; @@ -72,32 +72,7 @@ pub(crate) fn compile( denied.insert(libc::SYS_io_uring_setup, Vec::new()); denied.insert(libc::SYS_io_uring_enter, Vec::new()); denied.insert(libc::SYS_io_uring_register, Vec::new()); - let socket_rules = if allow_pathname_unix { - vec![ - SeccompRule::new(vec![ - SeccompCondition::new( - 0, - SeccompCmpArgLen::Dword, - SeccompCmpOp::Ne, - libc::AF_UNIX as u64, - ) - .map_err(|_| preparation("seccomp compilation"))?, - ]) - .map_err(|_| preparation("seccomp compilation"))?, - ] - } else { - Vec::new() - }; - denied.insert(libc::SYS_socket, socket_rules); - if allow_pathname_unix { - // The typed endpoint capability is connect-only. Once AF_UNIX - // socket creation is enabled, prevent it from becoming server - // authority in an otherwise writable directory. - denied.insert(libc::SYS_bind, Vec::new()); - denied.insert(libc::SYS_listen, Vec::new()); - denied.insert(libc::SYS_accept, Vec::new()); - denied.insert(libc::SYS_accept4, Vec::new()); - } + denied.insert(libc::SYS_socket, Vec::new()); } let topology = compile_filter( @@ -109,6 +84,7 @@ pub(crate) fn compile( clone3, topology, block_network, + allow_subprocesses, }) } @@ -132,6 +108,15 @@ pub(crate) fn apply(programs: &SeccompPrograms) -> Result<(), LinuxError> { LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition") })?; } + crate::ffi::verify_clone3_blocked() + .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition"))?; + crate::ffi::verify_namespace_change_blocked() + .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition"))?; + if !programs.allow_subprocesses { + crate::ffi::verify_fork_blocked().map_err(|_| { + LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition") + })?; + } Ok(()) } @@ -170,7 +155,20 @@ mod tests { #[test] fn compiles_both_process_modes() { - assert!(compile(false, true, false).is_ok()); - assert!(compile(true, false, true).is_ok()); + assert!(compile(false, true).is_ok()); + assert!(compile(true, false).is_ok()); + } + + #[test] + fn disabled_process_mode_includes_legacy_entry_points() { + let mut denied = BTreeMap::new(); + deny_arch_process_creation(&mut denied); + #[cfg(target_arch = "x86_64")] + { + assert!(denied.contains_key(&libc::SYS_fork)); + assert!(denied.contains_key(&libc::SYS_vfork)); + } + #[cfg(target_arch = "aarch64")] + assert!(denied.is_empty()); } } diff --git a/crates/linux/src/support.rs b/crates/linux/src/support.rs index 55a2718..87e71b9 100644 --- a/crates/linux/src/support.rs +++ b/crates/linux/src/support.rs @@ -1,12 +1,9 @@ -use sandy_core::{NetworkPolicy, PolicySpec, ValidatedPolicy}; +use sandy_core::ValidatedPolicy; use crate::{LinuxError, LinuxErrorKind}; -/// Minimum Landlock ABI for Sandy's fixed filesystem-rights baseline. -pub const BASELINE_LANDLOCK_ABI: u32 = 5; - -/// Landlock ABI required by exact external pathname Unix-socket grants. -pub const PATHNAME_SOCKET_LANDLOCK_ABI: u32 = 9; +/// Minimum Landlock ABI for Sandy's filesystem and signal-isolation baseline. +pub const BASELINE_LANDLOCK_ABI: u32 = 6; /// Policy-specific host support established without applying restrictions. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -28,7 +25,7 @@ impl SupportInfo { /// check and remains the gate before native application. pub fn probe(policy: &ValidatedPolicy) -> Result { let plan = crate::plan(policy)?; - let required_abi = required_abi(plan.policy.spec()); + let required_abi = BASELINE_LANDLOCK_ABI; #[cfg(target_os = "linux")] { crate::namespace::prepare(plan.blocks_network())?; @@ -42,22 +39,11 @@ pub fn probe(policy: &ValidatedPolicy) -> Result { fn probe_platform(required_abi: u32) -> Result { use landlock::{ABI, Access, AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr, Scope}; - let mut handled = AccessFs::from_all(ABI::V8); - if required_abi == PATHNAME_SOCKET_LANDLOCK_ABI { - handled |= AccessFs::ResolveUnix; - } - let builder = Ruleset::default() + Ruleset::default() .set_compatibility(CompatLevel::HardRequirement) - .handle_access(handled) - .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "support probe"))?; - let builder = if required_abi == PATHNAME_SOCKET_LANDLOCK_ABI { - builder - .scope(Scope::AbstractUnixSocket) - .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "support probe"))? - } else { - builder - }; - builder + .handle_access(AccessFs::from_all(ABI::V8)) + .and_then(|builder| builder.scope(Scope::Signal)) + .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "support probe"))? .create() .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "support probe"))?; @@ -74,37 +60,12 @@ fn probe_platform(_required_abi: u32) -> Result { )) } -pub(crate) fn required_abi(policy: &PolicySpec) -> u32 { - if policy.network == NetworkPolicy::BlockAll && !policy.unix_sockets.is_empty() { - PATHNAME_SOCKET_LANDLOCK_ABI - } else { - BASELINE_LANDLOCK_ABI - } -} - #[cfg(test)] mod tests { - use sandy_core::{AbsolutePath, UnixSocketGrant, UnixSocketOperation}; - use super::*; #[test] - fn exact_external_socket_authority_raises_only_its_policy_requirement() - -> Result<(), Box> { - let ordinary = PolicySpec { - network: NetworkPolicy::BlockAll, - ..PolicySpec::default() - }; - assert_eq!(required_abi(&ordinary), BASELINE_LANDLOCK_ABI); - - let with_socket = PolicySpec { - unix_sockets: vec![UnixSocketGrant { - path: AbsolutePath::new("/service.sock".to_owned())?, - operation: UnixSocketOperation::Connect, - }], - ..ordinary - }; - assert_eq!(required_abi(&with_socket), PATHNAME_SOCKET_LANDLOCK_ABI); - Ok(()) + fn fixed_baseline_includes_signal_scoping() { + assert_eq!(BASELINE_LANDLOCK_ABI, 6); } } diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs index 42ce3d3..cbdeb31 100644 --- a/crates/linux/tests/live_linux.rs +++ b/crates/linux/tests/live_linux.rs @@ -4,28 +4,23 @@ mod linux { env, fs, net::{TcpListener, TcpStream, UdpSocket}, os::{ - linux::net::SocketAddrExt, unix::fs::PermissionsExt as _, - unix::net::{SocketAddr, UnixListener, UnixStream}, + unix::net::{UnixListener, UnixStream}, }, - process::Command, - time::Duration, + process::{Child, Command, ExitStatus}, + thread, + time::{Duration, Instant}, }; use sandy_core::{ AbsolutePath, AccessMode, ExecutableGrant, FileGrant, NetworkPolicy, PathScope, PolicySpec, - RuntimeCompatibility, UnixSocketGrant, UnixSocketOperation, ValidatedPolicy, - WriteProtection, + RuntimeCompatibility, ValidatedPolicy, WriteProtection, }; const CHILD_MODE: &str = "SANDY_LINUX_LIVE_CHILD"; - const ALLOW_SOCKET_CHILD: &str = "SANDY_LINUX_ALLOW_SOCKET_CHILD"; - const BLOCK_SOCKET_CHILD: &str = "SANDY_LINUX_BLOCK_SOCKET_CHILD"; - const DENIED_SOCKET_PATH: &str = "SANDY_LINUX_DENIED_SOCKET"; const DEVICE_CHILD: &str = "SANDY_LINUX_DEVICE_CHILD"; - const ABSTRACT_SOCKET_NAME: &str = "SANDY_LINUX_ABSTRACT_SOCKET"; + const PROCESS_CHILD: &str = "SANDY_LINUX_PROCESS_CHILD"; const REPLACEMENT_CHILD: &str = "SANDY_LINUX_REPLACEMENT_CHILD"; - const SOCKET_PATH: &str = "SANDY_LINUX_LIVE_SOCKET"; const EXPECT_UNSUPPORTED: &str = "SANDY_LINUX_EXPECT_UNSUPPORTED"; const WORKSPACE: &str = "SANDY_LINUX_LIVE_WORKSPACE"; @@ -33,15 +28,12 @@ mod linux { if env::var_os(CHILD_MODE).is_some() { return child_checks(); } - if env::var_os(ALLOW_SOCKET_CHILD).is_some() { - return allow_socket_child(); - } - if env::var_os(BLOCK_SOCKET_CHILD).is_some() { - return block_socket_child(); - } if env::var_os(DEVICE_CHILD).is_some() { return exact_device_child(); } + if env::var_os(PROCESS_CHILD).is_some() { + return disabled_process_child(); + } if env::var_os(REPLACEMENT_CHILD).is_some() { return replacement_child(); } @@ -51,31 +43,35 @@ mod linux { exact_directory_grants_are_rejected_before_enforcement()?; exact_device_grants_do_not_expose_adjacent_devices()?; + host_noexec_is_never_weakened()?; mount_source_replacement_is_rejected()?; + disabled_process_mode_preserves_threads_only()?; let root = tempfile::tempdir()?; let workspace = root.path().join("workspace"); fs::create_dir(&workspace)?; fs::write(workspace.join("readable.txt"), "visible")?; fs::write(workspace.join("locked.txt"), "locked")?; - for name in ["allowed-true", "data-true"] { + for name in ["allowed-true", "data-true", "allowed-sleep"] { let path = workspace.join(name); - fs::copy("/bin/true", &path)?; + let source = if name == "allowed-sleep" { + "/bin/sleep" + } else { + "/bin/true" + }; + fs::copy(source, &path)?; let mut permissions = fs::metadata(&path)?.permissions(); permissions.set_mode(0o755); fs::set_permissions(path, permissions)?; } fs::write(root.path().join("outside.txt"), "hidden")?; - let status = Command::new(env::current_exe()?) - .env(CHILD_MODE, "1") - .env(WORKSPACE, &workspace) - .status()?; + let mut command = Command::new(env::current_exe()?); + command.env(CHILD_MODE, "1").env(WORKSPACE, &workspace); + let status = status_with_timeout(&mut command)?; if !status.success() { return Err("sacrificial sandbox child failed".into()); } - allow_all_preserves_pathname_socket_connections(root.path(), &workspace)?; - block_all_preserves_only_typed_endpoint_authority(root.path(), &workspace)?; Ok(()) } @@ -84,10 +80,9 @@ mod linux { let root = tempfile::tempdir()?; let workspace = root.path().join("workspace"); fs::create_dir(&workspace)?; - let status = Command::new(env::current_exe()?) - .env(DEVICE_CHILD, "1") - .env(WORKSPACE, &workspace) - .status()?; + let mut command = Command::new(env::current_exe()?); + command.env(DEVICE_CHILD, "1").env(WORKSPACE, &workspace); + let status = status_with_timeout(&mut command)?; if !status.success() { return Err("exact device child failed".into()); } @@ -124,9 +119,9 @@ mod linux { } fn mount_source_replacement_is_rejected() -> Result<(), Box> { - let status = Command::new(env::current_exe()?) - .env(REPLACEMENT_CHILD, "1") - .status()?; + let mut command = Command::new(env::current_exe()?); + command.env(REPLACEMENT_CHILD, "1"); + let status = status_with_timeout(&mut command)?; if !status.success() { return Err("mount source replacement child failed".into()); } @@ -160,140 +155,70 @@ mod linux { Ok(()) } - fn allow_all_preserves_pathname_socket_connections( - root: &std::path::Path, - workspace: &std::path::Path, - ) -> Result<(), Box> { - let socket = root.join("service.sock"); - let _listener = UnixListener::bind(&socket)?; - let status = Command::new(env::current_exe()?) - .env(ALLOW_SOCKET_CHILD, "1") - .env(WORKSPACE, workspace) - .env(SOCKET_PATH, &socket) - .status()?; - if !status.success() { - return Err("allow-all pathname socket child failed".into()); - } - Ok(()) - } - - fn allow_socket_child() -> Result<(), Box> { - let workspace = absolute_from_environment(WORKSPACE)?; - let socket = absolute_from_environment(SOCKET_PATH)?; + fn host_noexec_is_never_weakened() -> Result<(), Box> { + let root = tempfile::Builder::new() + .prefix("sandy-noexec-") + .tempdir_in("/dev/shm")?; + let executable = root.path().join("tool"); + fs::copy("/bin/true", &executable)?; + let workspace = absolute(root.path())?; let policy = ValidatedPolicy::try_from(PolicySpec { files: vec![FileGrant { path: workspace.clone(), - access: AccessMode::ReadWrite, + access: AccessMode::Read, scope: PathScope::Subtree, }], - unix_sockets: vec![UnixSocketGrant { - path: socket.clone(), - operation: UnixSocketOperation::Connect, + executables: vec![ExecutableGrant { + path: absolute(&executable)?, + scope: PathScope::Exact, }], - network: NetworkPolicy::AllowAll, ..PolicySpec::default() })?; - let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace)?; - sandy_linux::apply(prepared)?; - UnixStream::connect(socket.as_path())?; + let error = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace) + .err() + .ok_or("host noexec restriction was unexpectedly cleared")?; + if error.kind() != sandy_linux::LinuxErrorKind::Unsupported { + return Err("host noexec restriction returned the wrong error class".into()); + } Ok(()) } - fn block_all_preserves_only_typed_endpoint_authority( - root: &std::path::Path, - workspace: &std::path::Path, - ) -> Result<(), Box> { - let allowed = root.join("allowed.sock"); - let denied = root.join("denied.sock"); - let abstract_name = format!("sandy-live-{}", std::process::id()); - let _allowed_listener = UnixListener::bind(&allowed)?; - let _denied_listener = UnixListener::bind(&denied)?; - let abstract_address = SocketAddr::from_abstract_name(abstract_name.as_bytes())?; - let _abstract_listener = UnixListener::bind_addr(&abstract_address)?; - - let policy = - block_socket_policy(absolute(root)?, absolute(workspace)?, absolute(&allowed)?)?; - match sandy_linux::probe(&policy) { - Ok(_) => {} - Err(error) if error.kind() == sandy_linux::LinuxErrorKind::Unsupported => { - return Ok(()); - } - Err(error) => return Err(error.into()), - } - - let status = Command::new(env::current_exe()?) - .env(BLOCK_SOCKET_CHILD, "1") - .env(WORKSPACE, workspace) - .env(SOCKET_PATH, &allowed) - .env(DENIED_SOCKET_PATH, &denied) - .env(ABSTRACT_SOCKET_NAME, &abstract_name) - .status()?; + fn disabled_process_mode_preserves_threads_only() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let workspace = root.path().join("workspace"); + fs::create_dir(&workspace)?; + let mut command = Command::new(env::current_exe()?); + command.env(PROCESS_CHILD, "1").env(WORKSPACE, &workspace); + let status = status_with_timeout(&mut command)?; if !status.success() { - return Err("block-all socket child failed".into()); + return Err("disabled process-mode child failed".into()); } Ok(()) } - fn block_socket_child() -> Result<(), Box> { + fn disabled_process_child() -> Result<(), Box> { let workspace = absolute_from_environment(WORKSPACE)?; - let allowed = absolute_from_environment(SOCKET_PATH)?; - let denied = absolute_from_environment(DENIED_SOCKET_PATH)?; - let root = absolute(allowed.as_path().parent().ok_or("socket has no parent")?)?; - let policy = block_socket_policy(root.clone(), workspace.clone(), allowed.clone())?; - let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &root)?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: workspace.clone(), + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }], + allow_subprocesses: false, + ..PolicySpec::default() + })?; + let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace)?; sandy_linux::apply(prepared)?; - UnixStream::connect(allowed.as_path())?; - if UnixStream::connect(denied.as_path()).is_ok() { - return Err("adjacent pathname socket connection succeeded".into()); - } - if UnixListener::bind(workspace.as_path().join("unexpected.sock")).is_ok() { - return Err("connect-only endpoint authority enabled a server socket".into()); + if !matches!(thread::spawn(|| 23).join(), Ok(23)) { + return Err("thread creation was not preserved".into()); } - let abstract_name = env::var(ABSTRACT_SOCKET_NAME)?; - let abstract_address = SocketAddr::from_abstract_name(abstract_name.as_bytes())?; - if UnixStream::connect_addr(&abstract_address).is_ok() { - return Err("external abstract socket connection succeeded".into()); + if Command::new("/bin/true").spawn().is_ok() { + return Err("process creation remained available".into()); } - if TcpListener::bind("127.0.0.1:0").is_ok() - || TcpListener::bind("[::1]:0").is_ok() - || UdpSocket::bind("127.0.0.1:0").is_ok() - || TcpStream::connect_timeout(&"1.1.1.1:80".parse()?, Duration::from_millis(100)) - .is_ok() - { - return Err("IP socket creation succeeded".into()); - } - let _local_pair = UnixStream::pair()?; Ok(()) } - fn block_socket_policy( - root: AbsolutePath, - workspace: AbsolutePath, - allowed: AbsolutePath, - ) -> Result> { - Ok(ValidatedPolicy::try_from(PolicySpec { - files: vec![ - FileGrant { - path: root, - access: AccessMode::Read, - scope: PathScope::Subtree, - }, - FileGrant { - path: workspace, - access: AccessMode::ReadWrite, - scope: PathScope::Subtree, - }, - ], - unix_sockets: vec![UnixSocketGrant { - path: allowed, - operation: UnixSocketOperation::Connect, - }], - network: NetworkPolicy::BlockAll, - ..PolicySpec::default() - })?) - } - fn absolute_from_environment(name: &str) -> Result> { let path = fs::canonicalize(env::var_os(name).ok_or("missing test path")?)?; absolute(&path) @@ -341,9 +266,14 @@ mod linux { )?; let locked = AbsolutePath::new(format!("{}/locked.txt", workspace.as_str()))?; let allowed_true = AbsolutePath::new(format!("{}/allowed-true", workspace.as_str()))?; + let allowed_sleep = AbsolutePath::new(format!("{}/allowed-sleep", workspace.as_str()))?; let data_true = AbsolutePath::new(format!("{}/data-true", workspace.as_str()))?; let system_libraries = absolute(std::path::Path::new("/usr/lib"))?; let loader = absolute(&fs::canonicalize(dynamic_loader())?)?; + // This child starts before Landlock creates the sandbox signal domain. + // It therefore represents an unrelated host process even though this + // sacrificial process owns the handle needed for deterministic cleanup. + let mut outside_signal_target = Command::new("/bin/sleep").arg("2").spawn()?; let mut files = vec![ FileGrant { path: workspace.clone(), @@ -374,6 +304,10 @@ mod linux { path: allowed_true.clone(), scope: PathScope::Exact, }, + ExecutableGrant { + path: allowed_sleep.clone(), + scope: PathScope::Exact, + }, ], write_protections: vec![WriteProtection { path: locked, @@ -388,6 +322,23 @@ mod linux { let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace)?; sandy_linux::apply(prepared)?; + match outside_signal_target.kill() { + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {} + Ok(()) => { + let _ = outside_signal_target.wait(); + return Err("sandbox signaled a process outside its signal domain".into()); + } + Err(error) => return Err(error.into()), + } + let _ = wait_child_with_timeout(&mut outside_signal_target)?; + + let mut same_domain = Command::new(loader.as_path()) + .arg(allowed_sleep.as_path()) + .arg("30") + .spawn()?; + same_domain.kill()?; + let _ = wait_child_with_timeout(&mut same_domain)?; + if fs::read_to_string("readable.txt")? != "visible" { return Err("granted file contents changed".into()); } @@ -398,18 +349,14 @@ mod linux { if fs::metadata("/etc/passwd").is_ok() || fs::metadata("../outside.txt").is_ok() { return Err("non-granted filesystem data remained visible".into()); } - if !Command::new(loader.as_path()) - .arg(allowed_true.as_path()) - .status()? - .success() - { + let mut allowed_command = Command::new(loader.as_path()); + allowed_command.arg(allowed_true.as_path()); + if !status_with_timeout(&mut allowed_command)?.success() { return Err("explicit executable mapping was not preserved".into()); } - if Command::new(loader.as_path()) - .arg(data_true.as_path()) - .status()? - .success() - { + let mut denied_command = Command::new(loader.as_path()); + denied_command.arg(data_true.as_path()); + if status_with_timeout(&mut denied_command)?.success() { return Err("readable data was mapped executable".into()); } if TcpStream::connect_timeout(&"1.1.1.1:80".parse()?, Duration::from_millis(100)).is_ok() { @@ -462,6 +409,30 @@ mod linux { } Ok(()) } + + fn status_with_timeout( + command: &mut Command, + ) -> Result> { + let mut child = command.spawn()?; + wait_child_with_timeout(&mut child) + } + + fn wait_child_with_timeout( + child: &mut Child, + ) -> Result> { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if Instant::now() >= deadline { + child.kill()?; + let _ = child.wait(); + return Err("live Linux fixture exceeded its hard timeout".into()); + } + thread::sleep(Duration::from_millis(10)); + } + } } #[cfg(target_os = "linux")] diff --git a/docs/LINUX_SECURITY_MODEL.md b/docs/LINUX_SECURITY_MODEL.md index 0e57c51..d81d667 100644 --- a/docs/LINUX_SECURITY_MODEL.md +++ b/docs/LINUX_SECURITY_MODEL.md @@ -20,10 +20,11 @@ apply() irreversible current-process transition ## Enforcement transaction Preparation verifies the single-threaded precondition, rejects unsupported -policy combinations, opens policy paths with `openat2`, exercises the namespace -and modern mount transaction in a sacrificial child, creates a hard-required -Landlock ruleset, and compiles the seccomp filters. A preparation failure leaves -the process unrestricted. +policy combinations, opens policy paths with `openat2`, verifies that +executable grants do not require clearing an inherited host `noexec`, exercises +the namespace and modern mount transaction in a sacrificial child, creates a +hard-required Landlock ruleset, and compiles the seccomp filters. A preparation +failure leaves the process unrestricted. Application then: @@ -45,13 +46,13 @@ its target. ## Fixed native semantics -Sandy's fixed filesystem-rights baseline requires Landlock ABI 5. An exact -external pathname Unix-socket grant under `BlockAll` requires ABI 9 so socket -connection authority remains separate from filesystem visibility. The -requirement is policy-specific and hard: a host never receives a weakened +Sandy's fixed filesystem and signal-isolation baseline requires Landlock ABI 6. +Every accepted policy scopes signal delivery: a sandboxed process may signal +same-domain descendants but cannot signal an unsandboxed parent, sibling, or +other host process. The requirement is hard: a host never receives a weakened version of a requested policy. A newer host ABI does not silently add -restrictions. The backend does not implicitly restrict signal delivery. Device -ioctls on already-open descriptors remain inherited capabilities. +restrictions. Device ioctls on already-open descriptors remain inherited +capabilities. The host must permit the calling executable to create and configure user, mount, and—when blocking network access—network namespaces. Some distributions @@ -63,18 +64,17 @@ available through their normal system security policy. Read authority never includes execute authority. The private filesystem marks non-executable mounts `noexec`; an executable nested below a readable subtree -receives its own executable overmount. Landlock separately mediates `execve`. -Read/write authority uses the fixed mutation rights through ABI 8 because ABI -9's aggregate write set also contains pathname-socket resolution. `ResolveUnix` -is added only for an exact typed socket grant. - -`BlockAll` uses a private network namespace and denies new externally -addressable non-Unix socket descriptors. When the policy has no pathname -Unix-socket grants, creating addressable sockets is denied without requiring -pathname-socket mediation. A typed external pathname-socket grant enables only -Unix socket creation and requires ABI 9 to limit connections to that exact -path. Unnamed Unix `socketpair` IPC remains available because it cannot connect -to a host endpoint. Ring setup, entry, and registration are disabled under +receives its own executable overmount only when the host mount is executable. +Sandy never clears an inherited `noexec` attribute and rejects an executable +grant that would require doing so. Landlock separately mediates `execve`. +Read/write authority uses a fixed ABI 8 filesystem-mutation set so later kernel +rights do not silently alter the accepted policy. + +`BlockAll` uses a private network namespace and denies creation of new +addressable socket descriptors. Exact pathname Unix-socket authority is not in +the initial Linux contract and is rejected during deterministic planning. +Unnamed Unix `socketpair` IPC remains available because it cannot connect to a +host endpoint. Ring setup, entry, and registration are disabled under `BlockAll` so asynchronous socket operations cannot bypass these controls. Already-open ordinary descriptors remain caller-held capabilities, matching the public current-process contract. @@ -108,6 +108,7 @@ weakened: replacement cannot both be represented without broadening authority; - exact directory write protections, because pinning a mount point prevents replacement but not every metadata mutation; +- pathname Unix-socket exceptions; - global metadata compatibility; - local-host-only TCP exceptions. From a6244e09176599edfdd12f7847088428ca96ed5f Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 31 Aug 2026 22:57:23 +0200 Subject: [PATCH 13/20] test(linux): verify an actual noexec mount --- crates/linux/tests/live_linux.rs | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs index cbdeb31..1a4519f 100644 --- a/crates/linux/tests/live_linux.rs +++ b/crates/linux/tests/live_linux.rs @@ -156,27 +156,34 @@ mod linux { } fn host_noexec_is_never_weakened() -> Result<(), Box> { - let root = tempfile::Builder::new() - .prefix("sandy-noexec-") - .tempdir_in("/dev/shm")?; - let executable = root.path().join("tool"); - fs::copy("/bin/true", &executable)?; + let root = tempfile::tempdir()?; + let executable = std::path::Path::new("/proc/version"); + if !executable.is_file() { + return Err("host procfs has no regular version file".into()); + } let workspace = absolute(root.path())?; let policy = ValidatedPolicy::try_from(PolicySpec { - files: vec![FileGrant { - path: workspace.clone(), - access: AccessMode::Read, - scope: PathScope::Subtree, - }], + files: vec![ + FileGrant { + path: workspace.clone(), + access: AccessMode::Read, + scope: PathScope::Subtree, + }, + FileGrant { + path: absolute(executable)?, + access: AccessMode::Read, + scope: PathScope::Exact, + }, + ], executables: vec![ExecutableGrant { - path: absolute(&executable)?, + path: absolute(executable)?, scope: PathScope::Exact, }], ..PolicySpec::default() })?; let error = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace) .err() - .ok_or("host noexec restriction was unexpectedly cleared")?; + .ok_or("executable grant on a host noexec mount was unexpectedly accepted")?; if error.kind() != sandy_linux::LinuxErrorKind::Unsupported { return Err("host noexec restriction returned the wrong error class".into()); } From 46d9ec5cf0b3caeaeae5f162ddc5c012dc9adbe4 Mon Sep 17 00:00:00 2001 From: tumberger Date: Tue, 1 Sep 2026 07:12:42 +0200 Subject: [PATCH 14/20] fix(linux): isolate IPC and hard-link aliases --- AGENTS.md | 4 +- crates/linux/src/ffi.rs | 6 ++- crates/linux/src/lib.rs | 2 - crates/linux/src/plan.rs | 6 ++- crates/linux/src/prepare.rs | 12 ++++-- crates/linux/src/support.rs | 71 -------------------------------- crates/linux/tests/live_linux.rs | 30 ++++++++++++++ docs/LINUX_SECURITY_MODEL.md | 23 +++++++---- 8 files changed, 64 insertions(+), 90 deletions(-) delete mode 100644 crates/linux/src/support.rs diff --git a/AGENTS.md b/AGENTS.md index 36984bb..0705c54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,8 +216,8 @@ crates/linux/src/ffi.rs The Linux backend separates deterministic `LinuxPolicyPlan`, ambient `PreparedLinuxSandbox`, and irreversible `apply()` states. It requires a -single-threaded caller, a private user and mount namespace, fixed Landlock ABI -6 semantics with signal scoping, complete capability removal, and final +single-threaded caller, private user, mount, and IPC namespaces, fixed Landlock +ABI 6 semantics with signal scoping, complete capability removal, and final seccomp enforcement. It never clears an inherited host `noexec` restriction. Unsupported policy combinations fail before enforcement; there is no weaker fallback. diff --git a/crates/linux/src/ffi.rs b/crates/linux/src/ffi.rs index e8fe718..c8d7d0f 100644 --- a/crates/linux/src/ffi.rs +++ b/crates/linux/src/ffi.rs @@ -9,6 +9,8 @@ use std::{ }; const EMPTY: &CStr = c""; +const BASE_NAMESPACE_FLAGS: libc::c_int = + libc::CLONE_NEWUSER | libc::CLONE_NEWNS | libc::CLONE_NEWIPC; #[repr(C)] struct CapHeader { @@ -38,7 +40,7 @@ pub(crate) fn effective_ids() -> (u32, u32) { } pub(crate) fn unshare_namespaces(block_network: bool) -> io::Result<()> { - let mut flags = libc::CLONE_NEWUSER | libc::CLONE_NEWNS; + let mut flags = BASE_NAMESPACE_FLAGS; if block_network { flags |= libc::CLONE_NEWNET; } @@ -93,7 +95,7 @@ pub(crate) fn probe_namespace_setup( } unsafe fn namespace_probe_child(uid_map: &[u8], gid_map: &[u8], block_network: bool) -> ! { - let mut flags = libc::CLONE_NEWUSER | libc::CLONE_NEWNS; + let mut flags = BASE_NAMESPACE_FLAGS; if block_network { flags |= libc::CLONE_NEWNET; } diff --git a/crates/linux/src/lib.rs b/crates/linux/src/lib.rs index 6b095de..86eada9 100644 --- a/crates/linux/src/lib.rs +++ b/crates/linux/src/lib.rs @@ -27,7 +27,6 @@ mod plan; mod prepare; #[cfg(target_os = "linux")] mod seccomp; -mod support; #[cfg(target_os = "linux")] pub use apply::apply; @@ -35,4 +34,3 @@ pub use error::{LinuxError, LinuxErrorKind}; pub use plan::{LinuxPolicyPlan, plan}; #[cfg(target_os = "linux")] pub use prepare::{PreparedLinuxSandbox, prepare}; -pub use support::{BASELINE_LANDLOCK_ABI, SupportInfo, probe}; diff --git a/crates/linux/src/plan.rs b/crates/linux/src/plan.rs index a3bf3ac..53e972f 100644 --- a/crates/linux/src/plan.rs +++ b/crates/linux/src/plan.rs @@ -4,6 +4,8 @@ use sandy_core::{ use crate::{LinuxError, LinuxErrorKind}; +const BASELINE_LANDLOCK_ABI: u32 = 6; + /// Deterministic Linux lowering of a validated platform-neutral policy. /// /// This type owns no file descriptors and performs no ambient discovery. It @@ -32,7 +34,7 @@ impl LinuxPolicyPlan { /// policy, including process-signal isolation. #[must_use] pub fn required_landlock_abi(&self) -> u32 { - crate::BASELINE_LANDLOCK_ABI + BASELINE_LANDLOCK_ABI } } @@ -120,7 +122,7 @@ mod tests { let plan = plan(&policy)?; assert!(plan.blocks_network()); assert!(!plan.allows_subprocesses()); - assert_eq!(plan.required_landlock_abi(), crate::BASELINE_LANDLOCK_ABI); + assert_eq!(plan.required_landlock_abi(), 6); Ok(()) } diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs index 08ca6d8..55bc214 100644 --- a/crates/linux/src/prepare.rs +++ b/crates/linux/src/prepare.rs @@ -26,6 +26,7 @@ pub(crate) struct PinnedPath { pub(crate) kind: PinnedKind, device: u64, inode: u64, + link_count: u64, } pub(crate) struct MountRequirement { @@ -194,13 +195,16 @@ fn prepare_mounts( .iter() .filter(|protection| visible(&protection.path, effective.iter())) .map(|protection| { - let kind = pinned + let pinned_path = pinned .get(&protection.path) - .ok_or_else(|| preparation("write-protection pinning"))? - .kind; + .ok_or_else(|| preparation("write-protection pinning"))?; + let kind = pinned_path.kind; if kind == PinnedKind::Directory && protection.scope == PathScope::Exact { return Err(unsupported("exact directory write protection")); } + if kind == PinnedKind::Regular && pinned_path.link_count != 1 { + return Err(unsupported("write-protected hard-link alias")); + } Ok(ProtectionRequirement { path: protection.path.clone(), recursive: protection.scope == PathScope::Subtree, @@ -329,6 +333,7 @@ fn pin(path: &AbsolutePath) -> Result { kind, device: metadata.dev(), inode: metadata.ino(), + link_count: metadata.nlink(), }) } @@ -353,6 +358,7 @@ pub(crate) fn repin_after_namespace(preparation: &mut MountPreparation) -> Resul if metadata.dev() != pinned.device || metadata.ino() != pinned.inode || classify(&metadata) != pinned.kind + || metadata.nlink() != pinned.link_count { return Err(enforcement("mount source verification")); } diff --git a/crates/linux/src/support.rs b/crates/linux/src/support.rs deleted file mode 100644 index 87e71b9..0000000 --- a/crates/linux/src/support.rs +++ /dev/null @@ -1,71 +0,0 @@ -use sandy_core::ValidatedPolicy; - -use crate::{LinuxError, LinuxErrorKind}; - -/// Minimum Landlock ABI for Sandy's filesystem and signal-isolation baseline. -pub const BASELINE_LANDLOCK_ABI: u32 = 6; - -/// Policy-specific host support established without applying restrictions. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct SupportInfo { - landlock_abi: u32, -} - -impl SupportInfo { - /// Returns the minimum Landlock ABI required by the supplied policy. - #[must_use] - pub const fn landlock_abi(self) -> u32 { - self.landlock_abi - } -} - -/// Verifies that the running host can represent the supplied policy exactly. -/// -/// This is diagnostic only. [`crate::prepare`] repeats every authoritative -/// check and remains the gate before native application. -pub fn probe(policy: &ValidatedPolicy) -> Result { - let plan = crate::plan(policy)?; - let required_abi = BASELINE_LANDLOCK_ABI; - #[cfg(target_os = "linux")] - { - crate::namespace::prepare(plan.blocks_network())?; - } - #[cfg(not(target_os = "linux"))] - let _ = plan; - probe_platform(required_abi) -} - -#[cfg(target_os = "linux")] -fn probe_platform(required_abi: u32) -> Result { - use landlock::{ABI, Access, AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr, Scope}; - - Ruleset::default() - .set_compatibility(CompatLevel::HardRequirement) - .handle_access(AccessFs::from_all(ABI::V8)) - .and_then(|builder| builder.scope(Scope::Signal)) - .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "support probe"))? - .create() - .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "support probe"))?; - - Ok(SupportInfo { - landlock_abi: required_abi, - }) -} - -#[cfg(not(target_os = "linux"))] -fn probe_platform(_required_abi: u32) -> Result { - Err(LinuxError::new( - LinuxErrorKind::Unsupported, - "support probe", - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fixed_baseline_includes_signal_scoping() { - assert_eq!(BASELINE_LANDLOCK_ABI, 6); - } -} diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs index 1a4519f..ba38a18 100644 --- a/crates/linux/tests/live_linux.rs +++ b/crates/linux/tests/live_linux.rs @@ -44,6 +44,7 @@ mod linux { exact_directory_grants_are_rejected_before_enforcement()?; exact_device_grants_do_not_expose_adjacent_devices()?; host_noexec_is_never_weakened()?; + write_protected_hard_link_aliases_are_rejected()?; mount_source_replacement_is_rejected()?; disabled_process_mode_preserves_threads_only()?; @@ -203,6 +204,35 @@ mod linux { Ok(()) } + fn write_protected_hard_link_aliases_are_rejected() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let workspace_path = root.path().join("workspace"); + fs::create_dir(&workspace_path)?; + let protected_path = workspace_path.join("protected.txt"); + fs::write(&protected_path, "protected")?; + fs::hard_link(&protected_path, workspace_path.join("alias.txt"))?; + let workspace = absolute(&workspace_path)?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: workspace.clone(), + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }], + write_protections: vec![WriteProtection { + path: absolute(&protected_path)?, + scope: PathScope::Exact, + }], + ..PolicySpec::default() + })?; + let error = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace) + .err() + .ok_or("write-protected hard-link alias was unexpectedly accepted")?; + if error.kind() != sandy_linux::LinuxErrorKind::Unsupported { + return Err("hard-link alias returned the wrong error class".into()); + } + Ok(()) + } + fn disabled_process_child() -> Result<(), Box> { let workspace = absolute_from_environment(WORKSPACE)?; let policy = ValidatedPolicy::try_from(PolicySpec { diff --git a/docs/LINUX_SECURITY_MODEL.md b/docs/LINUX_SECURITY_MODEL.md index d81d667..b7e67ad 100644 --- a/docs/LINUX_SECURITY_MODEL.md +++ b/docs/LINUX_SECURITY_MODEL.md @@ -28,7 +28,8 @@ failure leaves the process unrestricted. Application then: -1. enters a user and mount namespace, plus a network namespace for `BlockAll`; +1. enters private user, mount, and IPC namespaces, plus a network namespace + for `BlockAll`; 2. installs same-ID, one-entry UID and GID maps and makes mount propagation private; 3. reopens every grant in the new mount namespace and verifies its device, @@ -55,12 +56,16 @@ restrictions. Device ioctls on already-open descriptors remain inherited capabilities. The host must permit the calling executable to create and configure user, -mount, and—when blocking network access—network namespaces. Some distributions -restrict those operations through a system security profile. Sandy exercises -the namespace and modern mount setup in a sacrificial child during preparation and -reports the backend as unsupported when the host denies it; it never falls back -to weaker enforcement. Host administrators must make the namespace capability -available through their normal system security policy. +mount, and IPC namespaces and—when blocking network access—a network namespace. +Some distributions restrict those operations through a system security profile. +Sandy exercises the namespace and modern mount setup in a sacrificial child +during preparation and reports the backend as unsupported when the host denies +it; it never falls back to weaker enforcement. Host administrators must make +the namespace capability available through their normal system security policy. + +System V shared memory, message queues, and semaphores are confined to the +private IPC namespace. Descendants remain able to create and share new IPC +objects inside the sandbox domain; host IPC objects are not visible. Read authority never includes execute authority. The private filesystem marks non-executable mounts `noexec`; an executable nested below a readable subtree @@ -95,7 +100,9 @@ former host root are absent. Open descriptors and memory acquired before `apply()` are not revoked. An exact protected file is overmounted read-only. A protected subtree is -recursively read-only. +recursively read-only. Filesystem enforcement is pathname-based. A protected +regular file with a pre-existing hard-link alias is rejected, and Sandy verifies +that its link count does not change between preparation and namespace entry. The following combinations are rejected before enforcement rather than being weakened: From 4d79698d857c75baa1384bde9e5fb253cac946b5 Mon Sep 17 00:00:00 2001 From: tumberger Date: Tue, 1 Sep 2026 07:44:35 +0200 Subject: [PATCH 15/20] fix(linux): isolate host IPC capabilities --- AGENTS.md | 7 +- crates/linux/src/error.rs | 3 - crates/linux/src/ffi.rs | 210 +++++++++++++++++++++++++++---- crates/linux/src/landlock.rs | 7 +- crates/linux/src/lib.rs | 32 +++++ crates/linux/src/mount.rs | 44 +++---- crates/linux/src/namespace.rs | 3 +- crates/linux/src/plan.rs | 2 +- crates/linux/src/prepare.rs | 12 +- crates/linux/src/seccomp.rs | 5 + crates/linux/tests/live_linux.rs | 33 ++++- docs/LINUX_SECURITY_MODEL.md | 23 +++- 12 files changed, 307 insertions(+), 74 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0705c54..3086f69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,9 +218,10 @@ The Linux backend separates deterministic `LinuxPolicyPlan`, ambient `PreparedLinuxSandbox`, and irreversible `apply()` states. It requires a single-threaded caller, private user, mount, and IPC namespaces, fixed Landlock ABI 6 semantics with signal scoping, complete capability removal, and final -seccomp enforcement. It never clears an inherited host `noexec` restriction. -Unsupported policy combinations fail before enforcement; there is no weaker -fallback. +seccomp enforcement. It replaces the inherited session keyring with an +anonymous ring and denies key-management syscalls before untrusted work. It +never clears an inherited host `noexec` restriction. Unsupported policy +combinations fail before enforcement; there is no weaker fallback. Adding a native operation requires documenting its declaration, availability, deprecation status, cleanup contract, and live coverage on every supported diff --git a/crates/linux/src/error.rs b/crates/linux/src/error.rs index 5765b20..26dd6f4 100644 --- a/crates/linux/src/error.rs +++ b/crates/linux/src/error.rs @@ -2,12 +2,9 @@ use std::{error::Error, fmt}; /// Stable high-level classification for a Linux backend failure. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] pub enum LinuxErrorKind { /// The host or requested policy cannot provide the required semantics. Unsupported, - /// The validated policy still contains a Linux-incompatible combination. - InvalidPolicy, /// Ambient resources could not be pinned before enforcement. PreparationFailed, /// An irreversible native enforcement step failed. diff --git a/crates/linux/src/ffi.rs b/crates/linux/src/ffi.rs index c8d7d0f..37b4165 100644 --- a/crates/linux/src/ffi.rs +++ b/crates/linux/src/ffi.rs @@ -5,7 +5,7 @@ use std::{ fs::File, io, mem::size_of, - os::fd::{FromRawFd, OwnedFd, RawFd}, + os::fd::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd}, }; const EMPTY: &CStr = c""; @@ -33,6 +33,13 @@ struct CapData { inheritable: u32, } +pub(crate) struct MountRestrictions { + pub(crate) recursive: bool, + pub(crate) read_only: bool, + pub(crate) allow_device: bool, + pub(crate) allow_execute: bool, +} + pub(crate) fn effective_ids() -> (u32, u32) { // SAFETY: `geteuid` and `getegid` take no pointers, own no resources, and // return process credentials directly. @@ -105,6 +112,7 @@ unsafe fn namespace_probe_child(uid_map: &[u8], gid_map: &[u8], block_network: b && unsafe { write_probe_file(c"/proc/self/uid_map", uid_map) } && unsafe { write_probe_file(c"/proc/self/setgroups", b"deny") } && unsafe { write_probe_file(c"/proc/self/gid_map", gid_map) } + && probe_isolate_session_keyring() && unsafe { libc::mount( std::ptr::null(), @@ -119,6 +127,19 @@ unsafe fn namespace_probe_child(uid_map: &[u8], gid_map: &[u8], block_network: b unsafe { libc::_exit(if succeeded { 0 } else { 1 }) } } +fn probe_isolate_session_keyring() -> bool { + // SAFETY: the fixed keyctl operation accepts a null name to create an + // anonymous session keyring, retains no pointer, and affects only this + // sacrificial child. + unsafe { + libc::syscall( + libc::SYS_keyctl, + libc::KEYCTL_JOIN_SESSION_KEYRING, + std::ptr::null::(), + ) >= 0 + } +} + fn probe_mount_sequence() -> bool { // Exercise the same modern mount transaction as the real backend with a // source descriptor opened after CLONE_NEWNS. All paths and structures @@ -309,7 +330,7 @@ pub(crate) fn mount_tmpfs(target: &CStr) -> io::Result<()> { .map(|_| ()) } -pub(crate) fn open_path_at(root: RawFd, relative: &CStr) -> io::Result { +pub(crate) fn open_path_at(root: BorrowedFd<'_>, relative: &CStr) -> io::Result { let how = OpenHow { flags: (libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW) as u64, mode: 0, @@ -321,7 +342,7 @@ pub(crate) fn open_path_at(root: RawFd, relative: &CStr) -> io::Result let fd = unsafe { libc::syscall( libc::SYS_openat2, - root, + root.as_raw_fd(), relative.as_ptr(), &how, size_of::(), @@ -337,7 +358,7 @@ pub(crate) fn open_path_at(root: RawFd, relative: &CStr) -> io::Result } } -pub(crate) fn clone_mount(source: RawFd, recursive: bool) -> io::Result { +pub(crate) fn clone_mount(source: BorrowedFd<'_>, recursive: bool) -> io::Result { let mut flags = libc::OPEN_TREE_CLONE | libc::OPEN_TREE_CLOEXEC | libc::AT_EMPTY_PATH as u32; if recursive { flags |= libc::AT_RECURSIVE as u32; @@ -345,7 +366,14 @@ pub(crate) fn clone_mount(source: RawFd, recursive: bool) -> io::Result // SAFETY: `source` is a borrowed O_PATH descriptor. With AT_EMPTY_PATH the // empty pathname selects that descriptor; no pointer is retained. A // successful open_tree returns a new mount descriptor owned by this call. - let fd = unsafe { libc::syscall(libc::SYS_open_tree, source, EMPTY.as_ptr(), flags) }; + let fd = unsafe { + libc::syscall( + libc::SYS_open_tree, + source.as_raw_fd(), + EMPTY.as_ptr(), + flags, + ) + }; if fd < 0 { return Err(io::Error::last_os_error()); } @@ -356,25 +384,22 @@ pub(crate) fn clone_mount(source: RawFd, recursive: bool) -> io::Result } pub(crate) fn restrict_mount( - mount_fd: RawFd, - recursive: bool, - read_only: bool, - allow_device: bool, - allow_execute: bool, + mount_fd: BorrowedFd<'_>, + restrictions: MountRestrictions, ) -> io::Result<()> { let attributes = libc::mount_attr { attr_set: libc::MOUNT_ATTR_NOSUID - | if allow_device { + | if restrictions.allow_device { 0 } else { libc::MOUNT_ATTR_NODEV } - | if read_only { + | if restrictions.read_only { libc::MOUNT_ATTR_RDONLY } else { 0 } - | if allow_execute { + | if restrictions.allow_execute { 0 } else { libc::MOUNT_ATTR_NOEXEC @@ -384,14 +409,19 @@ pub(crate) fn restrict_mount( propagation: 0, userns_fd: 0, }; - let attribute_flags = libc::AT_EMPTY_PATH | if recursive { libc::AT_RECURSIVE } else { 0 }; + let attribute_flags = libc::AT_EMPTY_PATH + | if restrictions.recursive { + libc::AT_RECURSIVE + } else { + 0 + }; // SAFETY: the mount descriptor, empty path, and mount_attr pointer remain // valid for the syscall; the kernel copies the fixed-size structure and // retains no pointer. The detached mount is not externally visible yet. let result = unsafe { libc::syscall( libc::SYS_mount_setattr, - mount_fd, + mount_fd.as_raw_fd(), EMPTY.as_ptr(), attribute_flags, &attributes, @@ -405,12 +435,12 @@ pub(crate) fn restrict_mount( } } -pub(crate) fn mount_is_noexec(fd: RawFd) -> io::Result { +pub(crate) fn mount_is_noexec(fd: BorrowedFd<'_>) -> io::Result { let mut status = std::mem::MaybeUninit::::uninit(); // SAFETY: `fd` is a borrowed live descriptor. `status` points to writable, // correctly aligned storage for exactly one `statvfs` value. The kernel // initializes it on success and retains neither pointer nor descriptor. - cvt(unsafe { libc::fstatvfs(fd, status.as_mut_ptr()) })?; + cvt(unsafe { libc::fstatvfs(fd.as_raw_fd(), status.as_mut_ptr()) })?; // SAFETY: the successful call above initialized the complete structure. let status = unsafe { status.assume_init() }; Ok(status.f_flag & libc::ST_NOEXEC != 0) @@ -432,6 +462,20 @@ pub(crate) fn verify_namespace_change_blocked() -> io::Result<()> { expect_errno(result.into(), libc::EPERM) } +pub(crate) fn verify_keyring_operations_blocked() -> io::Result<()> { + // SAFETY: this keyctl form takes only integer arguments and the installed + // filter must reject it before any keyring lookup can occur. + let result = unsafe { + libc::syscall( + libc::SYS_keyctl, + libc::KEYCTL_GET_KEYRING_ID, + libc::KEY_SPEC_SESSION_KEYRING, + 0, + ) + }; + expect_errno(result, libc::EPERM) +} + pub(crate) fn verify_fork_blocked() -> io::Result<()> { // SAFETY: the installed filter must reject fork before a child is created. // If a broken filter unexpectedly creates one, the child immediately exits @@ -470,7 +514,7 @@ fn expect_errno(result: libc::c_long, expected: libc::c_int) -> io::Result<()> { } } -pub(crate) fn attach_mount(mount_fd: RawFd, target_fd: RawFd) -> io::Result<()> { +pub(crate) fn attach_mount(mount_fd: BorrowedFd<'_>, target_fd: BorrowedFd<'_>) -> io::Result<()> { let flags = libc::MOVE_MOUNT_F_EMPTY_PATH | libc::MOVE_MOUNT_T_EMPTY_PATH; // SAFETY: both descriptors are borrowed and valid for the call. Empty // pathnames select the descriptors under the matching flags. move_mount @@ -478,9 +522,9 @@ pub(crate) fn attach_mount(mount_fd: RawFd, target_fd: RawFd) -> io::Result<()> let result = unsafe { libc::syscall( libc::SYS_move_mount, - mount_fd, + mount_fd.as_raw_fd(), EMPTY.as_ptr(), - target_fd, + target_fd.as_raw_fd(), EMPTY.as_ptr(), flags, ) @@ -492,6 +536,113 @@ pub(crate) fn attach_mount(mount_fd: RawFd, target_fd: RawFd) -> io::Result<()> } } +pub(crate) fn isolate_session_keyring() -> io::Result<()> { + // SAFETY: a null name asks the kernel for a fresh anonymous session + // keyring. The call retains no pointer and returns no userspace-owned + // resource; it only replaces the calling process's inherited association. + let result = unsafe { + libc::syscall( + libc::SYS_keyctl, + libc::KEYCTL_JOIN_SESSION_KEYRING, + std::ptr::null::(), + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(feature = "live-tests")] +pub(crate) fn create_private_message_queue() -> io::Result { + // SAFETY: msgget receives no pointers. IPC_PRIVATE creates a new queue in + // the caller's current IPC namespace with owner-only permissions. + let id = unsafe { libc::msgget(libc::IPC_PRIVATE, libc::IPC_CREAT | 0o600) }; + if id < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(id) + } +} + +#[cfg(feature = "live-tests")] +pub(crate) fn message_queue_is_visible(id: libc::c_int) -> io::Result { + let mut state = std::mem::MaybeUninit::::uninit(); + // SAFETY: `state` is correctly aligned writable storage for one msqid_ds; + // IPC_STAT initializes it on success and retains no pointer. + let result = unsafe { libc::msgctl(id, libc::IPC_STAT, state.as_mut_ptr()) }; + if result == 0 { + Ok(true) + } else { + let error = io::Error::last_os_error(); + if matches!( + error.raw_os_error(), + Some(libc::EINVAL) | Some(libc::EACCES) + ) { + Ok(false) + } else { + Err(error) + } + } +} + +#[cfg(feature = "live-tests")] +pub(crate) fn remove_message_queue(id: libc::c_int) -> io::Result<()> { + // SAFETY: IPC_RMID consumes no userspace pointer and removes only the + // explicitly identified test queue from the caller's IPC namespace. + cvt(unsafe { libc::msgctl(id, libc::IPC_RMID, std::ptr::null_mut()) }).map(|_| ()) +} + +#[cfg(feature = "live-tests")] +pub(crate) fn create_session_test_key(payload: &[u8]) -> io::Result { + isolate_session_keyring()?; + // SAFETY: the type and description are static C strings, `payload` remains + // live for its exact length, and add_key copies all supplied bytes before + // returning. The key is linked only into the anonymous test session ring. + let result = unsafe { + libc::syscall( + libc::SYS_add_key, + c"user".as_ptr(), + c"sandy-live-key".as_ptr(), + payload.as_ptr(), + payload.len(), + libc::KEY_SPEC_SESSION_KEYRING, + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + i32::try_from(result).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW)) + } +} + +#[cfg(feature = "live-tests")] +pub(crate) fn read_test_key(id: libc::c_int) -> io::Result> { + let mut payload = vec![0_u8; 64]; + // SAFETY: `payload` is writable for the exact supplied length. KEYCTL_READ + // copies at most that many bytes and retains no pointer. + let result = unsafe { + libc::syscall( + libc::SYS_keyctl, + libc::KEYCTL_READ, + id, + payload.as_mut_ptr(), + payload.len(), + ) + }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + let length = + usize::try_from(result).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))?; + if length > payload.len() { + return Err(io::Error::from_raw_os_error(libc::EOVERFLOW)); + } + payload.truncate(length); + Ok(payload) +} + pub(crate) fn pivot_root(new_root: &CStr, old_root: &CStr) -> io::Result<()> { // SAFETY: both paths are valid NUL-terminated strings for the syscall and // are copied immediately. The caller has made `new_root` a mount point and @@ -507,7 +658,19 @@ pub(crate) fn detach_mount(path: &CStr) -> io::Result<()> { } pub(crate) fn drop_all_capabilities(last_capability: u32) -> io::Result<()> { - const SECURE_BITS: libc::c_ulong = 0x01 | 0x02 | 0x04 | 0x08 | 0x40 | 0x80; + const SECURE_NO_SETUID_FIXUP: libc::c_ulong = 0x04; + const SECURE_NO_SETUID_FIXUP_LOCKED: libc::c_ulong = 0x08; + const SECURE_NOROOT: libc::c_ulong = 0x01; + const SECURE_NOROOT_LOCKED: libc::c_ulong = 0x02; + const SECURE_NO_CAP_AMBIENT_RAISE: libc::c_ulong = 0x40; + const SECURE_NO_CAP_AMBIENT_RAISE_LOCKED: libc::c_ulong = 0x80; + const SECURE_BITS: libc::c_ulong = SECURE_NOROOT + | SECURE_NOROOT_LOCKED + | SECURE_NO_SETUID_FIXUP + | SECURE_NO_SETUID_FIXUP_LOCKED + | SECURE_NO_CAP_AMBIENT_RAISE + | SECURE_NO_CAP_AMBIENT_RAISE_LOCKED; + const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522; // SAFETY: prctl receives only integer operations and values, borrows no // pointers, and transfers no ownership. CAP_SETPCAP is still effective @@ -531,7 +694,7 @@ pub(crate) fn drop_all_capabilities(last_capability: u32) -> io::Result<()> { } let header = CapHeader { - version: 0x2008_0522, + version: LINUX_CAPABILITY_VERSION_3, pid: 0, }; let data = [CapData::default(), CapData::default()]; @@ -542,8 +705,9 @@ pub(crate) fn drop_all_capabilities(last_capability: u32) -> io::Result<()> { } pub(crate) fn verify_no_capabilities() -> io::Result<()> { + const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522; let mut header = CapHeader { - version: 0x2008_0522, + version: LINUX_CAPABILITY_VERSION_3, pid: 0, }; let mut data = [CapData::default(), CapData::default()]; diff --git a/crates/linux/src/landlock.rs b/crates/linux/src/landlock.rs index c03c30b..27f9420 100644 --- a/crates/linux/src/landlock.rs +++ b/crates/linux/src/landlock.rs @@ -4,7 +4,7 @@ use landlock::{ ABI, Access, AccessFs, BitFlags, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr, RulesetCreated, RulesetCreatedAttr, RulesetStatus, Scope, }; -use sandy_core::{AccessMode, PathScope, PolicySpec}; +use sandy_core::{AccessMode, PolicySpec}; use crate::{ LinuxError, LinuxErrorKind, @@ -52,9 +52,8 @@ pub(crate) fn prepare( ruleset = add_rule(ruleset, file, AccessFs::Execute.into())?; } // The shape checks in preparation ensure all directory rules are subtree - // rules. PathBeneath is therefore an exact rule for files and a recursive - // rule only where the typed policy requested one. - let _ = PathScope::Subtree; + // rules. PathBeneath is therefore exact for files and recursive only for + // directory grants already validated as subtrees. Ok(PreparedLandlock { ruleset }) } diff --git a/crates/linux/src/lib.rs b/crates/linux/src/lib.rs index 86eada9..100ed44 100644 --- a/crates/linux/src/lib.rs +++ b/crates/linux/src/lib.rs @@ -34,3 +34,35 @@ pub use error::{LinuxError, LinuxErrorKind}; pub use plan::{LinuxPolicyPlan, plan}; #[cfg(target_os = "linux")] pub use prepare::{PreparedLinuxSandbox, prepare}; + +/// Native helpers used only by Sandy's sacrificial Linux enforcement tests. +#[cfg(all(target_os = "linux", feature = "live-tests"))] +#[doc(hidden)] +pub mod live_test_support { + use std::io; + + /// Creates an owner-only System V message queue in the current IPC namespace. + pub fn create_private_message_queue() -> io::Result { + crate::ffi::create_private_message_queue() + } + + /// Reports whether a System V message queue is visible in the current namespace. + pub fn message_queue_is_visible(id: i32) -> io::Result { + crate::ffi::message_queue_is_visible(id) + } + + /// Removes a System V message queue created by a live test. + pub fn remove_message_queue(id: i32) -> io::Result<()> { + crate::ffi::remove_message_queue(id) + } + + /// Creates a test key in a fresh anonymous session keyring. + pub fn create_session_test_key(payload: &[u8]) -> io::Result { + crate::ffi::create_session_test_key(payload) + } + + /// Reads a test key through the native keyctl interface. + pub fn read_test_key(id: i32) -> io::Result> { + crate::ffi::read_test_key(id) + } +} diff --git a/crates/linux/src/mount.rs b/crates/linux/src/mount.rs index a62d827..07ea7dd 100644 --- a/crates/linux/src/mount.rs +++ b/crates/linux/src/mount.rs @@ -1,7 +1,7 @@ use std::{ fs::{self, OpenOptions}, os::{ - fd::AsRawFd, + fd::AsFd, unix::fs::{OpenOptionsExt, PermissionsExt}, }, path::{Path, PathBuf}, @@ -49,10 +49,12 @@ pub(crate) fn construct_and_enter(preparation: &MountPreparation) -> Result<(), &root, &requirement.path, &source.file, - requirement.recursive, - !requirement.writable, - source.kind == PinnedKind::Device, - requirement.executable, + ffi::MountRestrictions { + recursive: requirement.recursive, + read_only: !requirement.writable, + allow_device: source.kind == PinnedKind::Device, + allow_execute: requirement.executable, + }, )?; } for protection in &preparation.protections { @@ -64,10 +66,12 @@ pub(crate) fn construct_and_enter(preparation: &MountPreparation) -> Result<(), &root, &protection.path, &source.file, - protection.recursive, - protection.read_only, - source.kind == PinnedKind::Device, - protection.executable, + ffi::MountRestrictions { + recursive: protection.recursive, + read_only: true, + allow_device: source.kind == PinnedKind::Device, + allow_execute: protection.executable, + }, )?; } for alias in &preparation.aliases { @@ -119,27 +123,17 @@ fn attach( root: &std::fs::File, path: &AbsolutePath, source: &std::fs::File, - recursive: bool, - read_only: bool, - allow_device: bool, - allow_execute: bool, + restrictions: ffi::MountRestrictions, ) -> Result<(), LinuxError> { let relative = path.as_str().strip_prefix('/').unwrap_or(path.as_str()); let relative = ffi::c_string(relative).map_err(|_| enforcement("mount target pinning"))?; - let target = ffi::open_path_at(root.as_raw_fd(), &relative) + let target = ffi::open_path_at(root.as_fd(), &relative) .map_err(|_| enforcement("mount target pinning"))?; - let detached = ffi::clone_mount(source.as_raw_fd(), recursive) + let detached = ffi::clone_mount(source.as_fd(), restrictions.recursive) .map_err(|_| enforcement("detached mount creation"))?; - ffi::restrict_mount( - detached.as_raw_fd(), - recursive, - read_only, - allow_device, - allow_execute, - ) - .map_err(|_| enforcement("detached mount restriction"))?; - ffi::attach_mount(detached.as_raw_fd(), target.as_raw_fd()) - .map_err(|_| enforcement("mount attachment")) + ffi::restrict_mount(detached.as_fd(), restrictions) + .map_err(|_| enforcement("detached mount restriction"))?; + ffi::attach_mount(detached.as_fd(), target.as_fd()).map_err(|_| enforcement("mount attachment")) } fn target_path(path: &AbsolutePath) -> PathBuf { diff --git a/crates/linux/src/namespace.rs b/crates/linux/src/namespace.rs index a972966..4b35175 100644 --- a/crates/linux/src/namespace.rs +++ b/crates/linux/src/namespace.rs @@ -71,7 +71,8 @@ pub(crate) fn enter( ) .map_err(|_| enforcement("group mapping"))?; - ffi::make_mounts_private().map_err(|_| enforcement("mount propagation")) + ffi::make_mounts_private().map_err(|_| enforcement("mount propagation"))?; + ffi::isolate_session_keyring().map_err(|_| enforcement("session keyring isolation")) } fn ensure_single_threaded() -> Result<(), LinuxError> { diff --git a/crates/linux/src/plan.rs b/crates/linux/src/plan.rs index 53e972f..59adab1 100644 --- a/crates/linux/src/plan.rs +++ b/crates/linux/src/plan.rs @@ -42,7 +42,7 @@ impl LinuxPolicyPlan { /// /// Unsupported combinations are rejected here whenever their incompatibility /// is independent of the ambient filesystem. File-type-dependent checks occur -/// during [`crate::prepare`]. +/// during the target-specific `prepare` phase. pub fn plan(policy: &ValidatedPolicy) -> Result { let spec = policy.spec(); diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs index 55bc214..9f54f90 100644 --- a/crates/linux/src/prepare.rs +++ b/crates/linux/src/prepare.rs @@ -2,7 +2,7 @@ use std::{ collections::BTreeMap, fs::{File, OpenOptions}, os::{ - fd::AsRawFd, + fd::AsFd, unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}, }, path::PathBuf, @@ -39,7 +39,6 @@ pub(crate) struct MountRequirement { pub(crate) struct ProtectionRequirement { pub(crate) path: AbsolutePath, pub(crate) recursive: bool, - pub(crate) read_only: bool, pub(crate) executable: bool, } @@ -61,7 +60,7 @@ pub(crate) struct RuntimeAlias { /// This value owns all pinned path descriptors, the already-created Landlock /// ruleset, compiled seccomp programs, and namespace inputs. Dropping it before /// [`crate::apply`] leaves the process unrestricted. -#[must_use] +#[must_use = "a prepared sandbox must be applied or deliberately dropped before untrusted work"] pub struct PreparedLinuxSandbox { pub(crate) mount: MountPreparation, pub(crate) landlock: landlock::PreparedLandlock, @@ -208,7 +207,6 @@ fn prepare_mounts( Ok(ProtectionRequirement { path: protection.path.clone(), recursive: protection.scope == PathScope::Subtree, - read_only: true, executable: executable(&protection.path, &spec.executables), }) }) @@ -249,7 +247,7 @@ fn reject_host_noexec( let source = pinned .get(path) .ok_or_else(|| preparation("executable mount lookup"))?; - if crate::ffi::mount_is_noexec(source.file.as_raw_fd()) + if crate::ffi::mount_is_noexec(source.file.as_fd()) .map_err(|_| preparation("host mount inspection"))? { return Err(unsupported("host noexec restriction")); @@ -321,7 +319,7 @@ fn pin(path: &AbsolutePath) -> Result { let relative = path.as_str().strip_prefix('/').unwrap_or(path.as_str()); let relative = if relative.is_empty() { "." } else { relative }; let relative = crate::ffi::c_string(relative).map_err(|_| preparation("path pinning"))?; - let owned = crate::ffi::open_path_at(std::os::fd::AsRawFd::as_raw_fd(&root), &relative) + let owned = crate::ffi::open_path_at(root.as_fd(), &relative) .map_err(|_| preparation("path pinning"))?; let file = crate::ffi::file_from_owned(owned); let metadata = file @@ -349,7 +347,7 @@ pub(crate) fn repin_after_namespace(preparation: &mut MountPreparation) -> Resul let relative = if relative.is_empty() { "." } else { relative }; let relative = crate::ffi::c_string(relative).map_err(|_| enforcement("mount source repinning"))?; - let owned = crate::ffi::open_path_at(root.as_raw_fd(), &relative) + let owned = crate::ffi::open_path_at(root.as_fd(), &relative) .map_err(|_| enforcement("mount source repinning"))?; let file = crate::ffi::file_from_owned(owned); let metadata = file diff --git a/crates/linux/src/seccomp.rs b/crates/linux/src/seccomp.rs index 93f78c7..d48beeb 100644 --- a/crates/linux/src/seccomp.rs +++ b/crates/linux/src/seccomp.rs @@ -41,6 +41,9 @@ pub(crate) fn compile( (libc::SYS_fsconfig, Vec::new()), (libc::SYS_fsmount, Vec::new()), (libc::SYS_fspick, Vec::new()), + (libc::SYS_add_key, Vec::new()), + (libc::SYS_request_key, Vec::new()), + (libc::SYS_keyctl, Vec::new()), ]); let namespace_flags = [ @@ -112,6 +115,8 @@ pub(crate) fn apply(programs: &SeccompPrograms) -> Result<(), LinuxError> { .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition"))?; crate::ffi::verify_namespace_change_blocked() .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition"))?; + crate::ffi::verify_keyring_operations_blocked() + .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition"))?; if !programs.allow_subprocesses { crate::ffi::verify_fork_blocked().map_err(|_| { LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition") diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs index ba38a18..f712a55 100644 --- a/crates/linux/tests/live_linux.rs +++ b/crates/linux/tests/live_linux.rs @@ -22,7 +22,10 @@ mod linux { const PROCESS_CHILD: &str = "SANDY_LINUX_PROCESS_CHILD"; const REPLACEMENT_CHILD: &str = "SANDY_LINUX_REPLACEMENT_CHILD"; const EXPECT_UNSUPPORTED: &str = "SANDY_LINUX_EXPECT_UNSUPPORTED"; + const HOST_IPC_ID: &str = "SANDY_LINUX_HOST_IPC_ID"; + const HOST_KEY_ID: &str = "SANDY_LINUX_HOST_KEY_ID"; const WORKSPACE: &str = "SANDY_LINUX_LIVE_WORKSPACE"; + const TEST_KEY_PAYLOAD: &[u8] = b"host-session-key"; pub(super) fn run() -> Result<(), Box> { if env::var_os(CHILD_MODE).is_some() { @@ -67,9 +70,18 @@ mod linux { } fs::write(root.path().join("outside.txt"), "hidden")?; + let host_ipc = sandy_linux::live_test_support::create_private_message_queue()?; + let host_key = sandy_linux::live_test_support::create_session_test_key(TEST_KEY_PAYLOAD)?; let mut command = Command::new(env::current_exe()?); - command.env(CHILD_MODE, "1").env(WORKSPACE, &workspace); - let status = status_with_timeout(&mut command)?; + command + .env(CHILD_MODE, "1") + .env(WORKSPACE, &workspace) + .env(HOST_IPC_ID, host_ipc.to_string()) + .env(HOST_KEY_ID, host_key.to_string()); + let status = status_with_timeout(&mut command); + let cleanup = sandy_linux::live_test_support::remove_message_queue(host_ipc); + let status = status?; + cleanup?; if !status.success() { return Err("sacrificial sandbox child failed".into()); } @@ -307,6 +319,14 @@ mod linux { let data_true = AbsolutePath::new(format!("{}/data-true", workspace.as_str()))?; let system_libraries = absolute(std::path::Path::new("/usr/lib"))?; let loader = absolute(&fs::canonicalize(dynamic_loader())?)?; + let host_ipc = env::var(HOST_IPC_ID)?.parse::()?; + let host_key = env::var(HOST_KEY_ID)?.parse::()?; + if !sandy_linux::live_test_support::message_queue_is_visible(host_ipc)? { + return Err("host IPC fixture was not inherited before enforcement".into()); + } + if sandy_linux::live_test_support::read_test_key(host_key)? != TEST_KEY_PAYLOAD { + return Err("host session key fixture was not inherited before enforcement".into()); + } // This child starts before Landlock creates the sandbox signal domain. // It therefore represents an unrelated host process even though this // sacrificial process owns the handle needed for deterministic cleanup. @@ -359,6 +379,15 @@ mod linux { let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace)?; sandy_linux::apply(prepared)?; + if sandy_linux::live_test_support::message_queue_is_visible(host_ipc)? { + return Err("host System V IPC remained visible after enforcement".into()); + } + match sandy_linux::live_test_support::read_test_key(host_key) { + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {} + Ok(_) => return Err("inherited host session key remained readable".into()), + Err(error) => return Err(error.into()), + } + match outside_signal_target.kill() { Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {} Ok(()) => { diff --git a/docs/LINUX_SECURITY_MODEL.md b/docs/LINUX_SECURITY_MODEL.md index b7e67ad..cfae3b9 100644 --- a/docs/LINUX_SECURITY_MODEL.md +++ b/docs/LINUX_SECURITY_MODEL.md @@ -31,7 +31,8 @@ Application then: 1. enters private user, mount, and IPC namespaces, plus a network namespace for `BlockAll`; 2. installs same-ID, one-entry UID and GID maps and makes mount propagation - private; + private, then replaces the inherited session keyring with a fresh anonymous + keyring; 3. reopens every grant in the new mount namespace and verifies its device, inode, and type against the original pinned object; 4. constructs a private tmpfs root from the verified grants; @@ -47,7 +48,8 @@ its target. ## Fixed native semantics -Sandy's fixed filesystem and signal-isolation baseline requires Landlock ABI 6. +Sandy's fixed filesystem and signal-isolation baseline requires Linux 6.12 or +a vendor kernel carrying Landlock ABI 6. Every accepted policy scopes signal delivery: a sandboxed process may signal same-domain descendants but cannot signal an unsandboxed parent, sibling, or other host process. The requirement is hard: a host never receives a weakened @@ -65,7 +67,16 @@ the namespace capability available through their normal system security policy. System V shared memory, message queues, and semaphores are confined to the private IPC namespace. Descendants remain able to create and share new IPC -objects inside the sandbox domain; host IPC objects are not visible. +objects inside the sandbox domain; host IPC objects are not visible. Live tests +create a host message queue before application and prove that its identifier is +unusable afterward. + +Kernel keyrings are not isolated by an IPC namespace. Sandy therefore replaces +the inherited session keyring with a fresh anonymous ring before hiding the host +filesystem, then permanently denies `add_key`, `request_key`, and `keyctl` with +seccomp. A live test proves a key inherited before application is unreadable +afterward. Key payloads already copied into process memory remain part of the +public current-process residual-risk contract. Read authority never includes execute authority. The private filesystem marks non-executable mounts `noexec`; an executable nested below a readable subtree @@ -137,5 +148,7 @@ working directory to the private root. ## Unsafe boundary All Linux unsafe code and raw native calls are confined to -`crates/linux/src/ffi.rs`. It exposes owned descriptors and safe functions. Raw -pointers, native structures, and unsafe functions do not escape that module. +`crates/linux/src/ffi.rs`. It exposes owned descriptors, lifetime-bound +`BorrowedFd` inputs, and safe functions. Raw pointers, native structures, +unvalidated descriptor integers, and unsafe functions do not escape that +module. From 2e9d6b77b6e562e2be18f76e63514ce173a5d352 Mon Sep 17 00:00:00 2001 From: tumberger Date: Tue, 1 Sep 2026 11:10:54 +0200 Subject: [PATCH 16/20] fix(linux): preserve protected runtime aliases --- crates/linux/src/prepare.rs | 55 +++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs index 9f54f90..ff60265 100644 --- a/crates/linux/src/prepare.rs +++ b/crates/linux/src/prepare.rs @@ -229,7 +229,11 @@ fn prepare_mounts( return Err(unsupported("working directory visibility")); } let working_directory = working_directory.clone(); - let aliases = prepare_runtime_aliases(spec.runtime_compatibility, &effective)?; + let aliases = prepare_runtime_aliases( + spec.runtime_compatibility, + &effective, + &spec.protected_paths, + )?; Ok(MountPreparation { pinned, @@ -266,6 +270,7 @@ fn executable(path: &AbsolutePath, grants: &[sandy_core::ExecutableGrant]) -> bo fn prepare_runtime_aliases( compatibility: RuntimeCompatibility, mounts: &[MountRequirement], + protected_paths: &[AbsolutePath], ) -> Result, LinuxError> { if compatibility != RuntimeCompatibility::ForegroundCli { return Ok(Vec::new()); @@ -300,7 +305,7 @@ fn prepare_runtime_aliases( .map_err(|_| preparation("runtime alias representation"))?; let path = AbsolutePath::new(requested.to_owned()) .map_err(|_| preparation("runtime alias representation"))?; - if visible(&path, mounts.iter()) || !visible(&canonical, mounts.iter()) { + if !should_materialize_runtime_alias(&path, &canonical, mounts, protected_paths) { continue; } let target = @@ -310,6 +315,17 @@ fn prepare_runtime_aliases( Ok(aliases) } +fn should_materialize_runtime_alias( + path: &AbsolutePath, + canonical: &AbsolutePath, + mounts: &[MountRequirement], + protected_paths: &[AbsolutePath], +) -> bool { + !denied(path, protected_paths) + && !visible(path, mounts.iter()) + && visible(canonical, mounts.iter()) +} + fn pin(path: &AbsolutePath) -> Result { let root = OpenOptions::new() .read(true) @@ -406,3 +422,38 @@ fn enforcement(phase: &'static str) -> LinuxError { fn unsupported(phase: &'static str) -> LinuxError { LinuxError::new(LinuxErrorKind::Unsupported, phase) } + +#[cfg(test)] +mod tests { + use super::*; + + fn path(value: &str) -> Result> { + Ok(AbsolutePath::new(value.to_owned())?) + } + + #[test] + fn protected_runtime_alias_is_not_materialized() -> Result<(), Box> { + let alias = path("/bin")?; + let canonical = path("/usr/bin")?; + let mounts = [MountRequirement { + path: canonical.clone(), + recursive: true, + writable: false, + executable: true, + }]; + + assert!(should_materialize_runtime_alias( + &alias, + &canonical, + &mounts, + &[], + )); + assert!(!should_materialize_runtime_alias( + &alias, + &canonical, + &mounts, + std::slice::from_ref(&alias), + )); + Ok(()) + } +} From d80649f00b2ab4fa8d3244167453f21d0d42a563 Mon Sep 17 00:00:00 2001 From: tumberger Date: Tue, 1 Sep 2026 12:15:13 +0200 Subject: [PATCH 17/20] fix(linux): reject recursive write protections --- crates/linux/src/plan.rs | 32 ++++++++++++++++++++++++++++++++ docs/LINUX_SECURITY_MODEL.md | 12 ++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/linux/src/plan.rs b/crates/linux/src/plan.rs index 59adab1..9737207 100644 --- a/crates/linux/src/plan.rs +++ b/crates/linux/src/plan.rs @@ -60,6 +60,17 @@ pub fn plan(policy: &ValidatedPolicy) -> Result { { return Err(unsupported("runtime compatibility policy")); } + // A read-only recursive mount protects only names reached through that + // mount. A pre-existing hard link outside the protected subtree can still + // mutate the same inode through a writable mount. Reject the shape until + // Linux can prove the complete requested semantics. + if spec + .write_protections + .iter() + .any(|protection| protection.scope == PathScope::Subtree) + { + return Err(unsupported("recursive write protection")); + } // A private filesystem view hides non-granted paths completely. A deny // nested inside a visible subtree, however, would leave at least a mount @@ -99,6 +110,7 @@ fn unsupported(phase: &'static str) -> LinuxError { mod tests { use sandy_core::{ AbsolutePath, AccessMode, FileGrant, NetworkPolicy, PathScope, PolicySpec, ValidatedPolicy, + WriteProtection, }; use super::*; @@ -164,6 +176,26 @@ mod tests { Ok(()) } + #[test] + fn rejects_recursive_write_protection() -> Result<(), Box> { + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: path("/workspace")?, + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }], + write_protections: vec![WriteProtection { + path: path("/workspace/protected")?, + scope: PathScope::Subtree, + }], + ..PolicySpec::default() + })?; + + let error = plan(&policy).err().ok_or("policy unexpectedly supported")?; + assert_eq!(error.kind(), LinuxErrorKind::Unsupported); + Ok(()) + } + #[test] fn rejects_local_host_tcp_without_broadening_it() -> Result<(), Box> { use sandy_core::{LocalHostTcpGrant, LocalHostTcpOperation, TcpPort}; diff --git a/docs/LINUX_SECURITY_MODEL.md b/docs/LINUX_SECURITY_MODEL.md index cfae3b9..c11b57e 100644 --- a/docs/LINUX_SECURITY_MODEL.md +++ b/docs/LINUX_SECURITY_MODEL.md @@ -110,16 +110,20 @@ private tmpfs root. Non-granted siblings, host `/proc`, `/sys`, `/run`, and the former host root are absent. Open descriptors and memory acquired before `apply()` are not revoked. -An exact protected file is overmounted read-only. A protected subtree is -recursively read-only. Filesystem enforcement is pathname-based. A protected -regular file with a pre-existing hard-link alias is rejected, and Sandy verifies -that its link count does not change between preparation and namespace entry. +An exact protected file is overmounted read-only. Filesystem enforcement is +pathname-based. A protected regular file with a pre-existing hard-link alias is +rejected, and Sandy verifies that its link count does not change between +preparation and namespace entry. Recursive write protections are rejected: a +hard link outside a recursively read-only mount could otherwise mutate the same +inode through a writable pathname. The following combinations are rejected before enforcement rather than being weakened: - a confidential deny nested below a visible subtree, because a mount mask would still expose placeholder metadata; +- recursive write protections, because a hard-link alias outside the protected + subtree could retain write authority; - exact grants on directories, because native directory rules are hierarchical; - subtree grants on non-directories; - exact read/write file grants, because content mutation and parent-directory From d061745bc54ee046a3d3a975bdffc862621fd955 Mon Sep 17 00:00:00 2001 From: tumberger Date: Tue, 1 Sep 2026 15:16:30 +0200 Subject: [PATCH 18/20] refactor(linux): narrow preparation internals --- crates/linux/src/plan.rs | 4 ++-- crates/linux/src/prepare.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/linux/src/plan.rs b/crates/linux/src/plan.rs index 9737207..c59ead3 100644 --- a/crates/linux/src/plan.rs +++ b/crates/linux/src/plan.rs @@ -18,13 +18,13 @@ pub struct LinuxPolicyPlan { impl LinuxPolicyPlan { /// Returns whether a private network namespace is required. #[must_use] - pub fn blocks_network(&self) -> bool { + pub(crate) fn blocks_network(&self) -> bool { self.policy.spec().network == NetworkPolicy::BlockAll } /// Returns whether ordinary descendant process creation is authorized. #[must_use] - pub fn allows_subprocesses(&self) -> bool { + pub(crate) fn allows_subprocesses(&self) -> bool { self.policy.spec().allow_subprocesses } diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs index ff60265..a514b00 100644 --- a/crates/linux/src/prepare.rs +++ b/crates/linux/src/prepare.rs @@ -60,7 +60,7 @@ pub(crate) struct RuntimeAlias { /// This value owns all pinned path descriptors, the already-created Landlock /// ruleset, compiled seccomp programs, and namespace inputs. Dropping it before /// [`crate::apply`] leaves the process unrestricted. -#[must_use = "a prepared sandbox must be applied or deliberately dropped before untrusted work"] +#[must_use = "the sandbox is not enforced until this value is passed to sandy_linux::apply"] pub struct PreparedLinuxSandbox { pub(crate) mount: MountPreparation, pub(crate) landlock: landlock::PreparedLandlock, From fbc3730ae7e4bf86cf70e5ced81f0c5c6cbe4491 Mon Sep 17 00:00:00 2001 From: tumberger Date: Tue, 1 Sep 2026 15:20:55 +0200 Subject: [PATCH 19/20] fix(linux): preserve cross-platform plan checks --- crates/linux/src/plan.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/linux/src/plan.rs b/crates/linux/src/plan.rs index c59ead3..9737207 100644 --- a/crates/linux/src/plan.rs +++ b/crates/linux/src/plan.rs @@ -18,13 +18,13 @@ pub struct LinuxPolicyPlan { impl LinuxPolicyPlan { /// Returns whether a private network namespace is required. #[must_use] - pub(crate) fn blocks_network(&self) -> bool { + pub fn blocks_network(&self) -> bool { self.policy.spec().network == NetworkPolicy::BlockAll } /// Returns whether ordinary descendant process creation is authorized. #[must_use] - pub(crate) fn allows_subprocesses(&self) -> bool { + pub fn allows_subprocesses(&self) -> bool { self.policy.spec().allow_subprocesses } From c1ce3e3b90654ea2fe115c7dd64aeb0820f21e66 Mon Sep 17 00:00:00 2001 From: tumberger Date: Tue, 1 Sep 2026 16:12:31 +0200 Subject: [PATCH 20/20] feat(linux): expose bounded primary executable identity --- crates/linux/src/lib.rs | 2 +- crates/linux/src/prepare.rs | 100 ++++++++++++++++++++++++++++++- crates/linux/tests/live_linux.rs | 24 +++++++- docs/LINUX_SECURITY_MODEL.md | 10 +++- 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/crates/linux/src/lib.rs b/crates/linux/src/lib.rs index 100ed44..9ceb68a 100644 --- a/crates/linux/src/lib.rs +++ b/crates/linux/src/lib.rs @@ -33,7 +33,7 @@ pub use apply::apply; pub use error::{LinuxError, LinuxErrorKind}; pub use plan::{LinuxPolicyPlan, plan}; #[cfg(target_os = "linux")] -pub use prepare::{PreparedLinuxSandbox, prepare}; +pub use prepare::{PreparedLinuxSandbox, prepare, prepare_foreground_launch}; /// Native helpers used only by Sandy's sacrificial Linux enforcement tests. #[cfg(all(target_os = "linux", feature = "live-tests"))] diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs index a514b00..a547f09 100644 --- a/crates/linux/src/prepare.rs +++ b/crates/linux/src/prepare.rs @@ -74,10 +74,33 @@ pub struct PreparedLinuxSandbox { pub fn prepare( plan: LinuxPolicyPlan, working_directory: &AbsolutePath, +) -> Result { + prepare_with_primary_executable(plan, working_directory, None) +} + +/// Prepares the Linux sandbox for a foreground CLI launch. +/// +/// `primary_executable` is already part of the caller's explicit executable +/// policy. Sandy uses it only to recreate the bounded `/proc/self/exe` +/// spelling needed by programs that identify or re-execute their own image; +/// procfs itself remains unmounted. +#[doc(hidden)] +pub fn prepare_foreground_launch( + plan: LinuxPolicyPlan, + working_directory: &AbsolutePath, + primary_executable: &AbsolutePath, +) -> Result { + prepare_with_primary_executable(plan, working_directory, Some(primary_executable)) +} + +fn prepare_with_primary_executable( + plan: LinuxPolicyPlan, + working_directory: &AbsolutePath, + primary_executable: Option<&AbsolutePath>, ) -> Result { let block_network = plan.blocks_network(); let namespace = namespace::prepare(block_network)?; - let mount = prepare_mounts(&plan, working_directory)?; + let mount = prepare_mounts(&plan, working_directory, primary_executable)?; let landlock = landlock::prepare(plan.policy.spec(), &mount.pinned)?; let seccomp = seccomp::compile(plan.allows_subprocesses(), block_network)?; Ok(PreparedLinuxSandbox { @@ -92,6 +115,7 @@ pub fn prepare( fn prepare_mounts( plan: &LinuxPolicyPlan, working_directory: &AbsolutePath, + primary_executable: Option<&AbsolutePath>, ) -> Result { let spec = plan.policy.spec(); let mut requested = BTreeMap::::new(); @@ -233,6 +257,7 @@ fn prepare_mounts( spec.runtime_compatibility, &effective, &spec.protected_paths, + primary_executable, )?; Ok(MountPreparation { @@ -271,6 +296,7 @@ fn prepare_runtime_aliases( compatibility: RuntimeCompatibility, mounts: &[MountRequirement], protected_paths: &[AbsolutePath], + primary_executable: Option<&AbsolutePath>, ) -> Result, LinuxError> { if compatibility != RuntimeCompatibility::ForegroundCli { return Ok(Vec::new()); @@ -312,9 +338,44 @@ fn prepare_runtime_aliases( std::fs::read_link(requested).map_err(|_| preparation("runtime alias resolution"))?; aliases.push(RuntimeAlias { path, target }); } + if let Some(alias) = + prepare_primary_executable_alias(primary_executable, mounts, protected_paths)? + { + aliases.push(alias); + } Ok(aliases) } +fn prepare_primary_executable_alias( + primary_executable: Option<&AbsolutePath>, + mounts: &[MountRequirement], + protected_paths: &[AbsolutePath], +) -> Result, LinuxError> { + let Some(primary_executable) = primary_executable else { + return Ok(None); + }; + let path = AbsolutePath::new("/proc/self/exe".to_owned()) + .map_err(|_| preparation("primary executable alias"))?; + if denied(&path, protected_paths) + || visible(&path, mounts.iter()) + || !visible(primary_executable, mounts.iter()) + || !mounts.iter().any(|mount| { + mount.executable + && (primary_executable == &mount.path + || (mount.recursive + && primary_executable + .as_path() + .starts_with(mount.path.as_path()))) + }) + { + return Err(unsupported("primary executable alias")); + } + Ok(Some(RuntimeAlias { + path, + target: primary_executable.as_path().to_path_buf(), + })) +} + fn should_materialize_runtime_alias( path: &AbsolutePath, canonical: &AbsolutePath, @@ -456,4 +517,41 @@ mod tests { )); Ok(()) } + + #[test] + fn primary_executable_alias_requires_existing_execute_authority() + -> Result<(), Box> { + let executable = path("/opt/agent/bin/codex")?; + let executable_mount = MountRequirement { + path: path("/opt/agent/bin")?, + recursive: true, + writable: false, + executable: true, + }; + let data_mount = MountRequirement { + path: path("/opt/agent/bin")?, + recursive: true, + writable: false, + executable: false, + }; + + let alias = prepare_primary_executable_alias( + Some(&executable), + std::slice::from_ref(&executable_mount), + &[], + )? + .ok_or("missing primary executable alias")?; + assert_eq!(alias.path.as_str(), "/proc/self/exe"); + assert_eq!(alias.target, executable.as_path()); + assert!(prepare_primary_executable_alias(Some(&executable), &[data_mount], &[]).is_err()); + assert!( + prepare_primary_executable_alias( + Some(&executable), + &[executable_mount], + &[path("/proc")?], + ) + .is_err() + ); + Ok(()) + } } diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs index f712a55..94c4a0c 100644 --- a/crates/linux/tests/live_linux.rs +++ b/crates/linux/tests/live_linux.rs @@ -319,6 +319,7 @@ mod linux { let data_true = AbsolutePath::new(format!("{}/data-true", workspace.as_str()))?; let system_libraries = absolute(std::path::Path::new("/usr/lib"))?; let loader = absolute(&fs::canonicalize(dynamic_loader())?)?; + let primary_executable = absolute(&env::current_exe()?)?; let host_ipc = env::var(HOST_IPC_ID)?.parse::()?; let host_key = env::var(HOST_KEY_ID)?.parse::()?; if !sandy_linux::live_test_support::message_queue_is_visible(host_ipc)? { @@ -342,6 +343,11 @@ mod linux { access: AccessMode::Read, scope: PathScope::Subtree, }, + FileGrant { + path: primary_executable.clone(), + access: AccessMode::Read, + scope: PathScope::Exact, + }, ]; if let Ok(cache) = fs::canonicalize("/etc/ld.so.cache") { files.push(FileGrant { @@ -365,6 +371,10 @@ mod linux { path: allowed_sleep.clone(), scope: PathScope::Exact, }, + ExecutableGrant { + path: primary_executable.clone(), + scope: PathScope::Exact, + }, ], write_protections: vec![WriteProtection { path: locked, @@ -376,9 +386,21 @@ mod linux { ..PolicySpec::default() })?; - let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace)?; + let prepared = sandy_linux::prepare_foreground_launch( + sandy_linux::plan(&policy)?, + &workspace, + &primary_executable, + )?; sandy_linux::apply(prepared)?; + if env::current_exe()? != primary_executable.as_path() + || !std::path::Path::new("/proc/self/exe").exists() + || std::path::Path::new("/proc/self/fd").exists() + || std::path::Path::new("/proc/1").exists() + { + return Err("bounded primary executable alias was not preserved".into()); + } + if sandy_linux::live_test_support::message_queue_is_visible(host_ipc)? { return Err("host System V IPC remained visible after enforcement".into()); } diff --git a/docs/LINUX_SECURITY_MODEL.md b/docs/LINUX_SECURITY_MODEL.md index c11b57e..2ff4272 100644 --- a/docs/LINUX_SECURITY_MODEL.md +++ b/docs/LINUX_SECURITY_MODEL.md @@ -134,9 +134,13 @@ weakened: - global metadata compatibility; - local-host-only TCP exceptions. -The private root deliberately omits procfs. Product compatibility grants and -any future selective procfs design belong to the CLI boundary and require live -positive and negative tests before shipping. +The private root never mounts procfs. Process entries, descriptor magic links, +and the host process tree remain absent. A foreground CLI launch recreates +`/proc/self/exe` as a static symlink to the already-visible, executable primary +target. This lets that target identify or re-execute its own image without +adding underlying file authority. Descendants see the primary target rather +than a dynamic per-process link. `/proc/self/fd`, `/dev/fd`, and `/dev/shm` +remain unavailable. When the internal foreground-CLI compatibility capability is present, the backend recreates a bounded set of host runtime symlinks (`/bin`, `/sbin`,