diff --git a/Cargo.lock b/Cargo.lock index b5e21ec266..c6173fee09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -770,12 +770,6 @@ dependencies = [ "sha2 0.11.0", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.2" @@ -923,17 +917,6 @@ dependencies = [ "libbz2-rs-sys", ] -[[package]] -name = "capctl" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6e71767585f51c2a33fed6d67147ec0343725fc3c03bf4b89fe67fede56aa5" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", -] - [[package]] name = "cassowary" version = "0.3.0" @@ -1299,7 +1282,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.13.2", + "bitflags", "crossterm_winapi", "libc", "mio 0.8.11", @@ -1315,7 +1298,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.13.2", + "bitflags", "crossterm_winapi", "mio 1.2.0", "parking_lot", @@ -2912,7 +2895,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "bitflags 2.13.2", + "bitflags", "inotify-sys", "libc", ] @@ -3247,7 +3230,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.13.2", + "bitflags", "libc", ] @@ -3421,7 +3404,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.13.2", + "bitflags", "libc", "plain", "redox_syscall 0.7.4", @@ -3700,7 +3683,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.2", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -3712,7 +3695,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.2", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -3734,7 +3717,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.13.2", + "bitflags", "fsevent-sys", "inotify", "kqueue", @@ -3752,7 +3735,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] @@ -4553,7 +4536,6 @@ dependencies = [ "async-trait", "base64 0.22.1", "bytes", - "capctl", "clap", "hex", "ipnet", @@ -5675,7 +5657,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ - "bitflags 2.13.2", + "bitflags", "memchr", "unicase", ] @@ -5882,7 +5864,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" dependencies = [ - "bitflags 2.13.2", + "bitflags", "cassowary", "compact_str", "crossterm 0.27.0", @@ -5902,7 +5884,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] @@ -5925,7 +5907,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] @@ -5934,7 +5916,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] @@ -6167,7 +6149,7 @@ checksum = "da7c230e0ed9cbeb92fbad6c8848985d6df2a1464c0dc247a021abd666e9005e" dependencies = [ "aes", "aws-lc-rs", - "bitflags 2.13.2", + "bitflags", "block-padding", "byteorder", "bytes", @@ -6249,7 +6231,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "093197e526668d92bba562e2bbbe98d1af9831bf080b619c736316ca1fa35101" dependencies = [ - "bitflags 2.13.2", + "bitflags", "bytes", "chrono", "dashmap", @@ -6317,7 +6299,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.2", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6330,7 +6312,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.2", + "bitflags", "errno", "libc", "linux-raw-sys 0.12.1", @@ -6557,7 +6539,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.2", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -7088,7 +7070,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "bitflags 2.13.2", + "bitflags", "byteorder", "bytes", "crc", @@ -7116,7 +7098,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.13.2", + "bitflags", "byteorder", "crc", "dotenvy", @@ -7812,7 +7794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "base64 0.22.1", - "bitflags 2.13.2", + "bitflags", "bytes", "futures-util", "http 1.4.0", @@ -8327,7 +8309,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.13.2", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -8911,7 +8893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.13.2", + "bitflags", "indexmap", "log", "serde", diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index da8ddd45aa..50d91f0abf 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -73,7 +73,6 @@ russh-sftp = "3.0" uuid = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] -capctl = "0.2.4" landlock = "0.4" seccompiler = "0.5" socket2 = { workspace = true } diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 6e7dd23ca3..f84f98d424 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -27,7 +27,6 @@ mod linux { use crate::boundary_io::BoundaryRuntimeState; use crate::delegated::{AgentSignaler, spawn_workload}; - use crate::identity::{DriverIdentity, resolve_process_identity}; use crate::main_session::{MainOutput, MainSession}; use crate::network_broker::NetworkBroker; use crate::process::ProcessStatus; @@ -2498,13 +2497,8 @@ mod linux { .build() ); } - let driver_identity = DriverIdentity::Resolved { - uid: self.config.workload_identity.uid, - gid: self.config.workload_identity.gid, - }; - if let Err(error) = resolve_process_identity(&mut policy, &driver_identity) { - return guest_error(BoundaryErrorKind::Process, error.to_string()); - } + policy.process.run_as_user = Some(self.config.workload_identity.uid.to_string()); + policy.process.run_as_group = Some(self.config.workload_identity.gid.to_string()); let launch = ManagedProcessLaunch { process_id: format!("{}:main:0", self.config.generation), spec, diff --git a/crates/openshell-sandbox/src/identity.rs b/crates/openshell-sandbox/src/identity.rs deleted file mode 100644 index df79a4137d..0000000000 --- a/crates/openshell-sandbox/src/identity.rs +++ /dev/null @@ -1,833 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Driver identity normalization and OCI `USER` resolution. - -use crate::process::ResolvedProcessIdentity; -use miette::{IntoDiagnostic, Result}; -use openshell_core::policy::SandboxPolicy; -use std::fs::{File, OpenOptions}; -use std::io::Read; -use std::os::unix::fs::OpenOptionsExt; -use std::path::Path; - -const PASSWD_PATH: &str = "/etc/passwd"; -const GROUP_PATH: &str = "/etc/group"; -const MAX_ACCOUNT_FILE_SIZE: u64 = 1024 * 1024; -const MAX_ACCOUNT_LINE_SIZE: usize = 8 * 1024; -const MAX_ACCOUNT_FIELD_SIZE: usize = 1024; - -/// Identity input selected by the active compute driver. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DriverIdentity { - /// Platform-selected identity used by Kubernetes and `OpenShift`. - Resolved { uid: u32, gid: u32 }, - /// Raw OCI `Config.User` selected by Docker and Podman. - OciUser { declaration: String }, - /// Drivers with no authoritative identity metadata. - None, -} - -impl DriverIdentity { - /// Normalize the protected driver environment into one identity variant. - pub fn from_env() -> Result { - let oci_user = optional_utf8_env(openshell_core::sandbox_env::OCI_IMAGE_USER)?; - let uid = optional_nonempty_utf8_env(openshell_core::sandbox_env::SANDBOX_UID)?; - let gid = optional_nonempty_utf8_env(openshell_core::sandbox_env::SANDBOX_GID)?; - Self::from_values(oci_user, uid, gid) - } - - fn from_values( - oci_user: Option, - uid: Option, - gid: Option, - ) -> Result { - // Resolved-identity drivers explicitly clear the OCI declaration so - // an image-baked or user-supplied value cannot select the OCI path. - // Preserve an empty declaration when no resolved pair is present: - // Docker and Podman use that state to reject images without USER. - let oci_user = if oci_user.as_deref() == Some("") && (uid.is_some() || gid.is_some()) { - None - } else { - oci_user - }; - - match (oci_user, uid, gid) { - (Some(declaration), None, None) => Ok(Self::OciUser { declaration }), - (None, Some(uid), Some(gid)) => { - let uid = uid.parse::().ok().filter(|uid| { - (openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID) - .contains(uid) - }); - let gid = gid.parse::().ok().filter(|gid| { - (openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID) - .contains(gid) - }); - let (Some(uid), Some(gid)) = (uid, gid) else { - return Err(miette::miette!( - "driver UID/GID must be numeric identities in range [{}, {}]", - openshell_policy::MIN_SANDBOX_UID, - openshell_policy::MAX_SANDBOX_UID - )); - }; - Ok(Self::Resolved { uid, gid }) - } - (None, None, None) => Ok(Self::None), - (Some(_), _, _) => Err(miette::miette!( - "{} conflicts with non-empty {}/{} driver identity", - openshell_core::sandbox_env::OCI_IMAGE_USER, - openshell_core::sandbox_env::SANDBOX_UID, - openshell_core::sandbox_env::SANDBOX_GID - )), - (None, _, _) => Err(miette::miette!( - "{} and {} must be supplied together", - openshell_core::sandbox_env::SANDBOX_UID, - openshell_core::sandbox_env::SANDBOX_GID - )), - } - } -} - -/// Apply a driver identity before any workload child becomes reachable. -pub fn resolve_process_identity( - policy: &mut SandboxPolicy, - driver_identity: &DriverIdentity, -) -> Result { - match driver_identity { - DriverIdentity::Resolved { uid, gid } => { - policy.process.run_as_user = Some(uid.to_string()); - policy.process.run_as_group = Some(gid.to_string()); - // Kubernetes/OpenShift already supply numeric policy values and - // retain their existing privilege-drop path. - Ok(ResolvedProcessIdentity::default()) - } - DriverIdentity::OciUser { declaration } => resolve_oci_process_identity_at( - policy, - declaration, - Path::new(PASSWD_PATH), - Path::new(GROUP_PATH), - ), - DriverIdentity::None => { - // VM/offline drivers retain the pre-OCI per-field fallback. A - // partial policy must never leave the omitted component at the - // root supervisor identity. - if policy - .process - .run_as_user - .as_deref() - .is_none_or(str::is_empty) - { - policy.process.run_as_user = Some("sandbox".into()); - } - if policy - .process - .run_as_group - .as_deref() - .is_none_or(str::is_empty) - { - policy.process.run_as_group = Some("sandbox".into()); - } - Ok(ResolvedProcessIdentity::default()) - } - } -} - -#[allow(clippy::similar_names)] -fn resolve_oci_process_identity_at( - policy: &mut SandboxPolicy, - declaration: &str, - passwd_path: &Path, - group_path: &Path, -) -> Result { - let explicit_user = policy - .process - .run_as_user - .as_deref() - .is_some_and(|value| !value.is_empty()); - let explicit_group = policy - .process - .run_as_group - .as_deref() - .is_some_and(|value| !value.is_empty()); - - if explicit_user && explicit_group { - return Ok(ResolvedProcessIdentity::default()); - } - - let (oci_user, oci_group) = split_oci_declaration(declaration); - let needs_primary_gid = !explicit_group && oci_group.is_none(); - let resolved_user = if !explicit_user || needs_primary_gid { - Some(resolve_required_oci_user( - oci_user, - passwd_path, - declaration, - needs_primary_gid, - )?) - } else { - None - }; - - let oci_uid = if explicit_user { - None - } else { - Some( - resolved_user - .as_ref() - .expect("omitted OCI user must have been resolved") - .0, - ) - }; - - if !explicit_user { - policy.process.run_as_user = Some(oci_user.to_string()); - } - - let oci_gid = if explicit_group { - None - } else { - let (group_value, gid) = match oci_group { - Some(group) if !group.is_empty() => { - let gid = validate_oci_group(group, group_path, declaration)?; - (group.to_string(), gid) - } - Some(_) => { - return Err(miette::miette!( - "OCI USER '{declaration}' has an empty group component" - )); - } - None => { - let gid = resolved_user - .and_then(|(_, primary_gid)| primary_gid) - .ok_or_else(|| { - miette::miette!( - "OCI USER '{declaration}' uses a numeric UID without an explicit group, \ - but /etc/passwd has no matching primary GID" - ) - })?; - (gid.to_string(), gid) - } - }; - policy.process.run_as_group = Some(group_value); - Some(gid) - }; - - Ok(ResolvedProcessIdentity::new(oci_uid, oci_gid)) -} - -fn split_oci_declaration(declaration: &str) -> (&str, Option<&str>) { - declaration - .split_once(':') - .map_or((declaration, None), |(user, group)| (user, Some(group))) -} - -fn resolve_required_oci_user( - user: &str, - passwd_path: &Path, - declaration: &str, - require_primary_gid: bool, -) -> Result<(u32, Option)> { - if user.is_empty() { - return Err(miette::miette!( - "OCI USER is required because run_as_user is omitted" - )); - } - validate_component(user, "OCI user")?; - if user == "root" { - return Err(miette::miette!("OCI USER '{declaration}' selects root")); - } - if let Ok(uid) = user.parse::() { - if uid == 0 { - return Err(miette::miette!("OCI USER '{declaration}' selects UID 0")); - } - let primary_gid = if require_primary_gid { - find_passwd_by_uid(passwd_path, uid)?.map(|entry| entry.gid) - } else { - None - }; - if primary_gid == Some(0) { - return Err(miette::miette!( - "OCI USER '{declaration}' resolves to prohibited primary GID 0" - )); - } - return Ok((uid, primary_gid)); - } - let entry = find_passwd_by_name(passwd_path, user)? - .ok_or_else(|| miette::miette!("OCI USER name '{user}' was not found in /etc/passwd"))?; - if entry.uid == 0 { - return Err(miette::miette!( - "OCI USER '{declaration}' resolves to prohibited UID 0" - )); - } - if require_primary_gid && entry.gid == 0 { - return Err(miette::miette!( - "OCI USER '{declaration}' resolves to prohibited primary GID 0" - )); - } - Ok((entry.uid, require_primary_gid.then_some(entry.gid))) -} - -fn validate_oci_group(value: &str, group_path: &Path, declaration: &str) -> Result { - validate_component(value, "OCI group")?; - if value == "root" { - return Err(miette::miette!( - "OCI USER '{declaration}' selects root group" - )); - } - let gid = if let Ok(gid) = value.parse::() { - gid - } else { - find_group_by_name(group_path, value)? - .ok_or_else(|| miette::miette!("OCI group '{value}' was not found in /etc/group"))? - .gid - }; - if gid == 0 { - return Err(miette::miette!( - "OCI USER '{declaration}' resolves to prohibited GID 0" - )); - } - Ok(gid) -} - -fn validate_component(value: &str, kind: &str) -> Result<()> { - if value.is_empty() - || value.len() > MAX_ACCOUNT_FIELD_SIZE - || value.trim() != value - || value.chars().any(|ch| ch.is_control() || ch == ':') - { - return Err(miette::miette!("{kind} component '{value}' is malformed")); - } - Ok(()) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct PasswdEntry { - uid: u32, - gid: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct GroupEntry { - gid: u32, -} - -fn find_passwd_by_name(path: &Path, name: &str) -> Result> { - find_unique(path, |fields| { - (fields.first().copied() == Some(name)).then(|| parse_passwd(fields)) - }) -} - -fn find_passwd_by_uid(path: &Path, uid: u32) -> Result> { - find_unique(path, |fields| { - fields - .get(2) - .and_then(|value| value.parse::().ok()) - .filter(|candidate| *candidate == uid) - .map(|_| parse_passwd(fields)) - }) -} - -fn find_group_by_name(path: &Path, name: &str) -> Result> { - find_unique(path, |fields| { - (fields.first().copied() == Some(name)).then(|| parse_group(fields)) - }) -} - -/// Resolve supplementary groups declared for an OCI named user without -/// consulting NSS. Numeric OCI users have no trustworthy group-membership -/// name and therefore receive no supplementary groups. -pub fn resolve_oci_supplementary_gids(declaration: &str, primary_gid: u32) -> Result> { - resolve_oci_supplementary_gids_at(declaration, primary_gid, Path::new(GROUP_PATH)) -} - -fn resolve_oci_supplementary_gids_at( - declaration: &str, - primary_gid: u32, - group_path: &Path, -) -> Result> { - let (user, _) = split_oci_declaration(declaration); - validate_component(user, "OCI user")?; - if user.parse::().is_ok() { - return Ok(Vec::new()); - } - - let content = read_account_file(group_path)?; - let mut gids = vec![primary_gid]; - for line in content.lines() { - if line.is_empty() || line.starts_with('#') { - continue; - } - if line.len() > MAX_ACCOUNT_LINE_SIZE { - return Err(miette::miette!( - "account file '{}' contains an oversized line", - group_path.display() - )); - } - let fields = line.split(':').collect::>(); - if fields.len() != 4 - || fields - .iter() - .any(|field| field.len() > MAX_ACCOUNT_FIELD_SIZE) - { - return Err(miette::miette!( - "group membership entry in '{}' is malformed", - group_path.display() - )); - } - if !fields[3].split(',').any(|member| member == user) { - continue; - } - let gid = fields[2].parse::().map_err(|_| { - miette::miette!( - "group membership GID in '{}' is malformed", - group_path.display() - ) - })?; - if gid == 0 { - return Err(miette::miette!( - "OCI user '{user}' is a member of prohibited GID 0" - )); - } - gids.push(gid); - } - gids.sort_unstable(); - gids.dedup(); - Ok(gids) -} - -fn find_unique( - path: &Path, - mut select: impl FnMut(&[&str]) -> Option>, -) -> Result> { - let content = read_account_file(path)?; - let mut found = None; - for line in content.lines() { - if line.is_empty() || line.starts_with('#') { - continue; - } - if line.len() > MAX_ACCOUNT_LINE_SIZE { - return Err(miette::miette!( - "account file '{}' contains an oversized line", - path.display() - )); - } - let fields = line.split(':').collect::>(); - if fields - .iter() - .any(|field| field.len() > MAX_ACCOUNT_FIELD_SIZE) - { - return Err(miette::miette!( - "account file '{}' contains an oversized field", - path.display() - )); - } - let Some(candidate) = select(&fields) else { - continue; - }; - let candidate = candidate?; - if found.replace(candidate).is_some() { - return Err(miette::miette!( - "account identity is ambiguous in '{}'", - path.display() - )); - } - } - Ok(found) -} - -fn parse_passwd(fields: &[&str]) -> Result { - if fields.len() != 7 { - return Err(miette::miette!("matching /etc/passwd entry is malformed")); - } - Ok(PasswdEntry { - uid: fields[2] - .parse() - .map_err(|_| miette::miette!("matching /etc/passwd UID is malformed"))?, - gid: fields[3] - .parse() - .map_err(|_| miette::miette!("matching /etc/passwd GID is malformed"))?, - }) -} - -fn parse_group(fields: &[&str]) -> Result { - if fields.len() != 4 { - return Err(miette::miette!("matching /etc/group entry is malformed")); - } - Ok(GroupEntry { - gid: fields[2] - .parse() - .map_err(|_| miette::miette!("matching /etc/group GID is malformed"))?, - }) -} - -fn read_account_file(path: &Path) -> Result { - let mut options = OpenOptions::new(); - options - .read(true) - .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); - let mut file = options - .open(path) - .into_diagnostic() - .map_err(|error| miette::miette!("failed to open '{}': {error}", path.display()))?; - validate_account_file(&file, path)?; - - let mut bytes = Vec::new(); - file.by_ref() - .take(MAX_ACCOUNT_FILE_SIZE + 1) - .read_to_end(&mut bytes) - .into_diagnostic()?; - if bytes.len() as u64 > MAX_ACCOUNT_FILE_SIZE { - return Err(miette::miette!( - "account file '{}' exceeds {MAX_ACCOUNT_FILE_SIZE} bytes", - path.display() - )); - } - String::from_utf8(bytes) - .map_err(|_| miette::miette!("account file '{}' is not valid UTF-8", path.display())) -} - -fn validate_account_file(file: &File, path: &Path) -> Result<()> { - let metadata = file.metadata().into_diagnostic()?; - if !metadata.is_file() { - return Err(miette::miette!( - "account path '{}' is not a regular file", - path.display() - )); - } - if metadata.len() > MAX_ACCOUNT_FILE_SIZE { - return Err(miette::miette!( - "account file '{}' exceeds {MAX_ACCOUNT_FILE_SIZE} bytes", - path.display() - )); - } - Ok(()) -} - -fn optional_utf8_env(name: &str) -> Result> { - std::env::var_os(name) - .map(|value| { - value - .into_string() - .map_err(|_| miette::miette!("{name} is not valid UTF-8")) - }) - .transpose() -} - -fn optional_nonempty_utf8_env(name: &str) -> Result> { - Ok(optional_utf8_env(name)?.filter(|value| !value.is_empty())) -} - -#[cfg(test)] -mod tests { - use super::*; - use openshell_core::policy::SandboxPolicy; - use std::fs; - use tempfile::tempdir; - - fn account_files( - passwd: &str, - group: &str, - ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { - let dir = tempdir().unwrap(); - let passwd_path = dir.path().join("passwd"); - let group_path = dir.path().join("group"); - fs::write(&passwd_path, passwd).unwrap(); - fs::write(&group_path, group).unwrap(); - (dir, passwd_path, group_path) - } - - fn policy(user: Option<&str>, group: Option<&str>) -> SandboxPolicy { - let mut policy = SandboxPolicy { - version: 1, - filesystem: openshell_core::policy::FilesystemPolicy::default(), - network: openshell_core::policy::NetworkPolicy::default(), - landlock: openshell_core::policy::LandlockPolicy::default(), - process: openshell_core::policy::ProcessPolicy::default(), - }; - policy.process.run_as_user = user.map(str::to_string); - policy.process.run_as_group = group.map(str::to_string); - policy - } - - #[test] - fn per_field_policy_precedence_resolves_complete_pair() { - let (_dir, passwd, group) = account_files( - "app:x:1234:1235::/home/app:/bin/sh\nsandbox:x:2000:2001::/sandbox:/bin/sh\n", - "staff:x:1235:\nsandbox:x:2001:\n", - ); - let cases = [ - ( - Some("2000"), - Some("2001"), - "root", - "2000", - "2001", - None, - None, - ), - ( - Some("2000"), - None, - "app:staff", - "2000", - "staff", - None, - Some(1235), - ), - ( - None, - Some("2001"), - "app:root", - "app", - "2001", - Some(1234), - None, - ), - ( - None, - None, - "app:staff", - "app", - "staff", - Some(1234), - Some(1235), - ), - (None, None, "app", "app", "1235", Some(1234), Some(1235)), - ]; - for ( - user, - group_name, - declaration, - expected_user, - expected_group, - resolved_uid, - resolved_gid, - ) in cases - { - let mut policy = policy(user, group_name); - let resolved = - resolve_oci_process_identity_at(&mut policy, declaration, &passwd, &group).unwrap(); - assert_eq!(policy.process.run_as_user.as_deref(), Some(expected_user)); - assert_eq!(policy.process.run_as_group.as_deref(), Some(expected_group)); - assert_eq!(resolved.uid(), resolved_uid); - assert_eq!(resolved.gid(), resolved_gid); - } - } - - #[test] - fn numeric_pair_does_not_require_account_entries() { - let dir = tempdir().unwrap(); - let passwd = dir.path().join("missing-passwd"); - let group = dir.path().join("missing-group"); - let mut policy = policy(None, None); - let resolved = - resolve_oci_process_identity_at(&mut policy, "1234:1235", &passwd, &group).unwrap(); - assert_eq!(policy.process.run_as_user.as_deref(), Some("1234")); - assert_eq!(policy.process.run_as_group.as_deref(), Some("1235")); - assert_eq!( - resolved, - ResolvedProcessIdentity::new(Some(1234), Some(1235)) - ); - } - - #[test] - fn explicit_identity_is_preserved_without_inspecting_oci_or_accounts() { - let dir = tempdir().unwrap(); - let mut policy = policy(Some("sandbox"), Some("sandbox")); - - let resolved = resolve_oci_process_identity_at( - &mut policy, - "root:root", - &dir.path().join("missing-passwd"), - &dir.path().join("missing-group"), - ) - .unwrap(); - - assert_eq!(policy.process.run_as_user.as_deref(), Some("sandbox")); - assert_eq!(policy.process.run_as_group.as_deref(), Some("sandbox")); - assert_eq!(resolved, ResolvedProcessIdentity::default()); - } - - #[test] - fn driver_identity_inputs_are_mutually_exclusive_and_complete() { - assert_eq!( - DriverIdentity::from_values(Some("app".into()), None, None).unwrap(), - DriverIdentity::OciUser { - declaration: "app".into() - } - ); - assert_eq!( - DriverIdentity::from_values(None, Some("1234".into()), Some("1235".into())).unwrap(), - DriverIdentity::Resolved { - uid: 1234, - gid: 1235 - } - ); - assert_eq!( - DriverIdentity::from_values(None, Some("500".into()), Some("30".into())).unwrap(), - DriverIdentity::Resolved { uid: 500, gid: 30 } - ); - assert_eq!( - DriverIdentity::from_values( - Some(String::new()), - Some("1234".into()), - Some("1235".into()) - ) - .unwrap(), - DriverIdentity::Resolved { - uid: 1234, - gid: 1235 - } - ); - assert_eq!( - DriverIdentity::from_values(Some(String::new()), None, None).unwrap(), - DriverIdentity::OciUser { - declaration: String::new() - } - ); - assert_eq!( - DriverIdentity::from_values(None, None, None).unwrap(), - DriverIdentity::None - ); - assert!( - DriverIdentity::from_values( - Some("app".into()), - Some("1234".into()), - Some("1235".into()) - ) - .is_err() - ); - assert!(DriverIdentity::from_values(None, Some("1234".into()), None).is_err()); - } - - #[test] - fn no_driver_identity_completes_partial_policy_with_sandbox() { - let cases = [ - (None, Some("staff"), "sandbox", "staff"), - (Some("app"), None, "app", "sandbox"), - (None, None, "sandbox", "sandbox"), - (Some("app"), Some("staff"), "app", "staff"), - ]; - - for (user, group, expected_user, expected_group) in cases { - let mut policy = policy(user, group); - let resolved = resolve_process_identity(&mut policy, &DriverIdentity::None).unwrap(); - - assert_eq!(policy.process.run_as_user.as_deref(), Some(expected_user)); - assert_eq!(policy.process.run_as_group.as_deref(), Some(expected_group)); - assert_eq!(resolved, ResolvedProcessIdentity::default()); - } - } - - #[test] - fn numeric_uid_uses_passwd_primary_gid() { - let (_dir, passwd, group) = account_files("app:x:1234:4321::/home/app:/bin/sh\n", ""); - let mut policy = policy(None, None); - let resolved = - resolve_oci_process_identity_at(&mut policy, "1234", &passwd, &group).unwrap(); - assert_eq!(policy.process.run_as_group.as_deref(), Some("4321")); - assert_eq!( - resolved, - ResolvedProcessIdentity::new(Some(1234), Some(4321)) - ); - } - - #[test] - fn named_oci_user_resolves_bounded_supplementary_groups() { - let (_dir, _passwd, group) = account_files( - "", - "primary:x:1235:\nvideo:x:44:app,other\naudio:x:63:other\nrender:x:107:app\n", - ); - - let gids = resolve_oci_supplementary_gids_at("app:primary", 1235, &group).unwrap(); - assert_eq!(gids, vec![44, 107, 1235]); - } - - #[test] - fn numeric_oci_user_has_no_named_supplementary_groups() { - let dir = tempdir().unwrap(); - let missing_group = dir.path().join("missing-group"); - - let gids = resolve_oci_supplementary_gids_at("1234:1235", 1235, &missing_group).unwrap(); - assert!(gids.is_empty()); - } - - #[test] - fn oci_supplementary_membership_rejects_root_group() { - let (_dir, _passwd, group) = account_files("", "root:x:0:app\n"); - - let error = - resolve_oci_supplementary_gids_at("app", 1235, &group).expect_err("GID 0 must fail"); - assert!(error.to_string().contains("prohibited GID 0")); - } - - #[test] - fn missing_unknown_ambiguous_and_root_identities_fail() { - let (_dir, passwd, group) = account_files( - "app:x:1234:1235::/home/app:/bin/sh\napp:x:2234:2235::/home/app2:/bin/sh\n", - "staff:x:1235:\nstaff:x:2235:\n", - ); - for declaration in ["", "unknown", "app", "9999", "0:1235", "1234:0"] { - let mut policy = policy(None, None); - assert!( - resolve_oci_process_identity_at(&mut policy, declaration, &passwd, &group).is_err(), - "{declaration:?} unexpectedly resolved" - ); - } - } - - #[test] - fn selected_component_is_validated_independently() { - let (_dir, passwd, group) = - account_files("app:x:1234:1235::/home/app:/bin/sh\n", "staff:x:1235:\n"); - - let mut explicit_user = policy(Some("1234"), None); - let resolved = - resolve_oci_process_identity_at(&mut explicit_user, "root:staff", &passwd, &group) - .unwrap(); - assert_eq!(explicit_user.process.run_as_user.as_deref(), Some("1234")); - assert_eq!(explicit_user.process.run_as_group.as_deref(), Some("staff")); - assert_eq!(resolved, ResolvedProcessIdentity::new(None, Some(1235))); - - let mut explicit_group = policy(None, Some("1235")); - let resolved = - resolve_oci_process_identity_at(&mut explicit_group, "app:root", &passwd, &group) - .unwrap(); - assert_eq!(explicit_group.process.run_as_user.as_deref(), Some("app")); - assert_eq!(explicit_group.process.run_as_group.as_deref(), Some("1235")); - assert_eq!(resolved, ResolvedProcessIdentity::new(Some(1234), None)); - } - - #[test] - fn named_oci_components_mapping_to_root_are_rejected() { - let (_dir, passwd, group) = account_files( - "root_alias:x:0:1235::/root:/bin/sh\napp:x:1234:1235::/home/app:/bin/sh\n", - "root_alias:x:0:\nstaff:x:1235:\n", - ); - - let mut root_user = policy(None, None); - assert!( - resolve_oci_process_identity_at(&mut root_user, "root_alias:staff", &passwd, &group) - .is_err() - ); - - let mut root_group = policy(None, None); - assert!( - resolve_oci_process_identity_at(&mut root_group, "app:root_alias", &passwd, &group) - .is_err() - ); - } - - #[cfg(unix)] - #[test] - fn account_file_symlinks_are_rejected() { - use std::os::unix::fs::symlink; - - let (_dir, passwd, group) = - account_files("app:x:1234:1235::/home/app:/bin/sh\n", "staff:x:1235:\n"); - let link = passwd.with_file_name("passwd-link"); - symlink(&passwd, &link).unwrap(); - - let mut policy = policy(None, None); - assert!(resolve_oci_process_identity_at(&mut policy, "app:staff", &link, &group).is_err()); - } -} diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 5ba996181a..6c3a9829f9 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -11,8 +11,6 @@ mod boundary_server; pub mod child_env; #[cfg(target_os = "linux")] pub(crate) mod delegated; -#[cfg(unix)] -pub mod identity; #[cfg(target_os = "linux")] pub mod main_session; pub mod managed_children; diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 4dad330771..8ef97be1a3 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -33,7 +33,6 @@ const SANDBOX_RUNTIME_ROOT: &str = "/.openshell/runtime"; #[cfg(target_os = "linux")] const SANDBOX_STATE_ROOT: &str = "/.openshell/state"; -const VALIDATE_WORKSPACE_SUBCOMMAND: &str = "validate-workspace"; const CAPABILITY_PROBE_SUBCOMMAND: &str = "capability-probe"; const CAPABILITY_PROBE_LAUNCH_SUBCOMMAND: &str = "capability-probe-launch"; const CAPABILITY_SOCKET_CHILD_SUBCOMMAND: &str = "capability-socket-child"; @@ -60,50 +59,6 @@ struct BoundaryArgs { log_level: String, } -/// Internal one-shot command used by trusted driver bootstrap to validate an -/// image-provided workdir as the final sandbox identity. -#[derive(Parser, Debug)] -#[command(name = "validate-workspace", hide = true)] -struct ValidateWorkspaceArgs { - #[arg(long)] - workdir: String, - #[arg(long)] - expected_uid: u32, - #[arg(long)] - expected_gid: u32, -} - -#[cfg(target_os = "linux")] -fn validate_workspace(args: &[String]) -> Result<()> { - let args = ValidateWorkspaceArgs::try_parse_from( - std::iter::once(VALIDATE_WORKSPACE_SUBCOMMAND.to_string()).chain(args.iter().cloned()), - ) - .into_diagnostic()?; - let actual = ( - nix::unistd::geteuid().as_raw(), - nix::unistd::getegid().as_raw(), - ); - if actual != (args.expected_uid, args.expected_gid) { - return Err(miette::miette!( - "workspace validator privilege drop failed: expected {}:{}, got {}:{}", - args.expected_uid, - args.expected_gid, - actual.0, - actual.1 - )); - } - openshell_sandbox::process::validate_oci_workspace_as_effective_identity(Path::new( - &args.workdir, - )) -} - -#[cfg(not(target_os = "linux"))] -fn validate_workspace(_args: &[String]) -> Result<()> { - Err(miette::miette!( - "workspace validation is only supported on Unix" - )) -} - /// Run the active Phase 0 probe inside the exact workload runtime profile. #[cfg(target_os = "linux")] #[allow(unsafe_code)] @@ -1923,9 +1878,6 @@ fn main() -> Result<()> { } return seed_kubernetes_workspace(); } - if raw_args.get(1).map(String::as_str) == Some(VALIDATE_WORKSPACE_SUBCOMMAND) { - return validate_workspace(&raw_args[2..]); - } if raw_args.get(1).map(String::as_str) == Some(CAPABILITY_PROBE_SUBCOMMAND) { return run_capability_probe(); } @@ -2031,31 +1983,6 @@ mod tests { assert!(destination.join(".openshell-initialized").is_file()); } - #[cfg(target_os = "linux")] - #[test] - fn workspace_validation_subcommand_uses_final_policy_identity() { - let uid = nix::unistd::geteuid().as_raw(); - let gid = nix::unistd::getegid().as_raw(); - if uid < 1000 || gid < 1000 { - return; - } - let dir = tempfile::tempdir_in("/tmp").unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); - let root = dir.path().canonicalize().unwrap().join("workspace"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); - let args = vec![ - "--workdir".to_string(), - root.display().to_string(), - "--expected-uid".to_string(), - uid.to_string(), - "--expected-gid".to_string(), - gid.to_string(), - ]; - - validate_workspace(&args).expect("current identity should retain workspace authority"); - } - /// Drives `copy_self`'s file-copy logic against an arbitrary source path /// so tests don't depend on `current_exe()`. fn copy_executable(src: &Path, dest: &Path) -> Result<()> { diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 7b61d30030..595ceac29e 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -11,22 +11,19 @@ use crate::sandbox; use miette::WrapErr; use miette::{IntoDiagnostic, Result}; use nix::sys::signal::{self, Signal}; -use nix::unistd::{Gid, Group, Pid, Uid, User}; +use nix::unistd::{Pid, User}; use openshell_core::policy::SandboxPolicy; use std::collections::HashMap; -use std::ffi::CString; #[cfg(unix)] use std::os::fd::AsRawFd; -#[cfg(unix)] -use std::os::unix::fs::{MetadataExt, PermissionsExt}; -#[cfg(any(test, unix))] +#[cfg(target_os = "linux")] use std::path::Path; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; -use tracing::{debug, info}; +use tracing::debug; // `libc::TIOCSCTTY` and the request parameter accepted by `ioctl` vary across // glibc, musl, and BSD targets. The conversion is a no-op on some targets but @@ -40,45 +37,6 @@ fn set_controlling_tty(fd: libc::c_int) -> std::io::Result<()> { Ok(()) } -/// Numeric identity components resolved once from driver-owned metadata. -/// -/// A component is `None` when the corresponding policy field was explicit and -/// must continue through the existing policy identity path. OCI-derived -/// components are carried numerically so later filesystem setup and direct/SSH -/// privilege drops cannot resolve them differently through NSS. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct ResolvedProcessIdentity { - uid: Option, - gid: Option, -} - -impl ResolvedProcessIdentity { - #[must_use] - pub const fn new(uid: Option, gid: Option) -> Self { - Self { uid, gid } - } - - #[must_use] - pub const fn uid(self) -> Option { - self.uid - } - - #[must_use] - pub const fn gid(self) -> Option { - self.gid - } - - /// Whether at least one process identity component came from OCI `USER`. - /// - /// Platform-resolved identities are written directly into the policy and - /// return the default value, so this is specific to Docker/Podman OCI - /// fallback without adding another driver contract. - #[must_use] - pub const fn uses_oci_user_fallback(self) -> bool { - self.uid.is_some() || self.gid.is_some() - } -} - /// Resolved process workspace and its child-environment semantics. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ResolvedWorkspace { @@ -380,46 +338,6 @@ fn parse_pids_max(contents: &str) -> RuntimePidLimitStatus { } } -#[cfg(target_os = "linux")] -fn drop_capability_bounding_set() -> Result<()> { - let clear_result = capctl::caps::bounding::clear(); - let remaining = capctl::caps::bounding::probe(); - - validate_capability_bounding_set_clear( - clear_result, - remaining, - capctl::caps::bounding::clear_unknown, - ) -} - -#[cfg(target_os = "linux")] -fn validate_capability_bounding_set_clear( - clear_result: capctl::Result<()>, - remaining: capctl::caps::CapSet, - clear_unknown: impl FnOnce() -> capctl::Result<()>, -) -> Result<()> { - match clear_result { - Ok(()) if remaining.is_empty() => Ok(()), - Ok(()) => Err(miette::miette!( - "Failed to clear child capability bounding set: capabilities remain raised: {remaining:?}" - )), - Err(err) if err.code() == libc::EPERM && remaining.is_empty() => match clear_unknown() { - Ok(()) => { - debug!( - "CAP_SETPCAP is unavailable, but the child capability bounding set is already empty" - ); - Ok(()) - } - Err(unknown_err) => Err(miette::miette!( - "Failed to clear unknown child capability bounding set entries: {unknown_err}" - )), - }, - Err(err) => Err(miette::miette!( - "Failed to clear child capability bounding set: {err}" - )), - } -} - #[cfg(target_os = "linux")] pub fn spawn_command_with_workload_launcher( launcher: &openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, @@ -920,513 +838,11 @@ impl Drop for ProcessHandle { } } -/// Validate the configured process user. -/// -/// Numeric identities do not require a passwd entry. The legacy explicit -/// `"sandbox"` identity and other names must resolve in `/etc/passwd`. -#[cfg(unix)] -pub fn validate_sandbox_user(policy: &SandboxPolicy) -> Result<()> { - let identity = policy.process.run_as_user.as_deref().unwrap_or("sandbox"); - - if let Ok(uid) = identity.parse::() { - if !(MIN_SANDBOX_UID..=MAX_SANDBOX_UID).contains(&uid) { - return Err(miette::miette!( - "process user UID must be in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}]" - )); - } - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "validated") - .message(format!( - "Accepted numeric UID {identity} (no passwd entry required)" - )) - .build() - ); - return Ok(()); - } - - // Legacy explicit "sandbox" name — must exist in /etc/passwd. - if identity == "sandbox" { - match User::from_name("sandbox") { - Ok(Some(_)) => { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "validated") - .message("Validated 'sandbox' user exists in image") - .build() - ); - } - Ok(None) => { - return Err(miette::miette!( - "explicit process user 'sandbox' was not found in the image" - )); - } - Err(e) => { - return Err(miette::miette!("failed to look up 'sandbox' user: {e}")); - } - } - } else if !identity.is_empty() { - // Other names are supported by local/offline policy paths and must - // resolve before privilege dropping. - match User::from_name(identity) { - Ok(Some(_)) => { - tracing::warn!(identity, "named process user accepted via passwd entry"); - } - Ok(None) => { - return Err(miette::miette!( - "unrecognized sandbox identity '{identity}'; \ - expected 'sandbox' or a numeric UID in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}]" - )); - } - Err(e) => { - return Err(miette::miette!( - "failed to look up identity '{identity}': {e}" - )); - } - } - } - - Ok(()) -} - -/// Validate that the configured sandbox group identity is acceptable. -/// -/// Mirrors [`validate_sandbox_user`] for the group dimension. -#[cfg(unix)] -pub fn validate_sandbox_group(policy: &SandboxPolicy) -> Result<()> { - let identity = policy.process.run_as_group.as_deref().unwrap_or("sandbox"); - - if let Ok(gid) = identity.parse::() { - if !(MIN_SANDBOX_UID..=MAX_SANDBOX_UID).contains(&gid) { - return Err(miette::miette!( - "process group GID must be in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}]" - )); - } - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "validated") - .message(format!( - "Accepted numeric GID {identity} (no group entry required)" - )) - .build() - ); - return Ok(()); - } - - if identity == "sandbox" { - match Group::from_name("sandbox") { - Ok(Some(_)) => { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "validated") - .message("Validated 'sandbox' group exists in image") - .build() - ); - } - Ok(None) => { - return Err(miette::miette!( - "explicit process group 'sandbox' was not found in the image" - )); - } - Err(e) => { - return Err(miette::miette!("failed to look up 'sandbox' group: {e}")); - } - } - } else if !identity.is_empty() { - match Group::from_name(identity) { - Ok(Some(_)) => { - tracing::warn!(identity, "named process group accepted via group entry"); - } - Ok(None) => { - return Err(miette::miette!( - "unrecognized sandbox group identity '{identity}'; \ - expected 'sandbox' or a numeric GID in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}]" - )); - } - Err(e) => { - return Err(miette::miette!( - "failed to look up group identity '{identity}': {e}" - )); - } - } - } - - Ok(()) -} - -#[cfg(unix)] -pub fn validate_sandbox_user_with_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, -) -> Result<()> { - let Some(uid) = resolved_identity.uid() else { - return validate_sandbox_user(policy); - }; - if uid == 0 { - return Err(miette::miette!("process user must not select UID 0")); - } - Ok(()) -} - -#[cfg(unix)] -pub fn validate_sandbox_group_with_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, -) -> Result<()> { - let Some(gid) = resolved_identity.gid() else { - return validate_sandbox_group(policy); - }; - if gid == 0 { - return Err(miette::miette!("process group must not select GID 0")); - } - Ok(()) -} - -pub use openshell_policy::{MAX_SANDBOX_UID, MIN_SANDBOX_UID}; - -/// Prepare a `read_write` path for the sandboxed process. -/// -/// Returns `true` when the path was created by the supervisor and therefore -/// still needs to be chowned to the sandbox user/group. Existing paths keep -/// their image-defined ownership. -#[cfg(unix)] -fn prepare_read_write_path(path: &Path) -> Result { - // SECURITY: use symlink_metadata (lstat) to inspect each path *before* - // calling chown. chown follows symlinks, so a malicious container image - // could place a symlink (e.g. /sandbox -> /etc/shadow) to trick the - // root supervisor into transferring ownership of arbitrary files. - // The TOCTOU window between lstat and chown is not exploitable because - // no untrusted process is running yet (the child has not been forked). - if let Ok(meta) = std::fs::symlink_metadata(path) { - if meta.file_type().is_symlink() { - return Err(miette::miette!( - "read_write path '{}' is a symlink — refusing to chown (potential privilege escalation)", - path.display() - )); - } - - debug!( - path = %path.display(), - "Preserving ownership for existing read_write path" - ); - Ok(false) - } else { - debug!(path = %path.display(), "Creating read_write directory"); - std::fs::create_dir_all(path).into_diagnostic()?; - Ok(true) - } -} - -/// Update `/etc/passwd` and `/etc/group` so the "sandbox" user/group entries -/// match the driver-injected UID/GID from environment variables. -/// -/// When `OPENSHELL_SANDBOX_UID` is set, the image-baked "sandbox" entry may -/// have a different UID. Updating the files ensures `whoami`, `id`, `ls -l`, -/// SSH sessions, and `initgroups` resolve the sandbox identity correctly. -/// If no "sandbox" entry exists, one is appended. -#[cfg(unix)] -pub fn update_sandbox_passwd_entries() -> Result<()> { - let uid_str = match std::env::var(openshell_core::sandbox_env::SANDBOX_UID) { - Ok(v) if !v.is_empty() => v, - _ => return Ok(()), - }; - let gid_str = match std::env::var(openshell_core::sandbox_env::SANDBOX_GID) { - Ok(v) if !v.is_empty() => v, - _ => uid_str.clone(), - }; - - let _: u32 = uid_str - .parse() - .map_err(|e| miette::miette!("invalid OPENSHELL_SANDBOX_UID '{uid_str}': {e}"))?; - let _: u32 = gid_str - .parse() - .map_err(|e| miette::miette!("invalid OPENSHELL_SANDBOX_GID '{gid_str}': {e}"))?; - - update_passwd_file(&uid_str, &gid_str)?; - update_group_file(&gid_str)?; - - info!( - uid = %uid_str, - gid = %gid_str, - "Updated /etc/passwd and /etc/group for sandbox identity" - ); - Ok(()) -} - -/// Rewrite the `sandbox` line in `/etc/passwd` with the given UID/GID, -/// or append a new entry if none exists. -#[cfg(unix)] -fn update_passwd_file(uid: &str, gid: &str) -> Result<()> { - rewrite_passwd_at(Path::new("/etc/passwd"), uid, gid) -} - -/// Rewrite the `sandbox` line in `/etc/group` with the given GID, -/// or append a new entry if none exists. -#[cfg(unix)] -fn update_group_file(gid: &str) -> Result<()> { - rewrite_group_at(Path::new("/etc/group"), gid) -} - -#[cfg(unix)] -fn rewrite_passwd_at(path: &Path, uid: &str, gid: &str) -> Result<()> { - let content = std::fs::read_to_string(path).into_diagnostic()?; - - let mut found = false; - let mut lines: Vec = content - .lines() - .map(|line| { - if line.starts_with("sandbox:") { - found = true; - let fields: Vec<&str> = line.split(':').collect(); - if let [name, pass, _, _, gecos, home, shell, ..] = fields.as_slice() { - format!("{name}:{pass}:{uid}:{gid}:{gecos}:{home}:{shell}") - } else { - line.to_string() - } - } else { - line.to_string() - } - }) - .collect(); - - if !found { - lines.push(format!("sandbox:x:{uid}:{gid}::/sandbox:/bin/sh")); - } - - let mut output = lines.join("\n"); - if content.ends_with('\n') || !found { - output.push('\n'); - } - - std::fs::write(path, output).into_diagnostic()?; - Ok(()) -} - -#[cfg(unix)] -fn rewrite_group_at(path: &Path, gid: &str) -> Result<()> { - let content = std::fs::read_to_string(path).into_diagnostic()?; - - let mut found = false; - let mut lines: Vec = content - .lines() - .map(|line| { - if line.starts_with("sandbox:") { - found = true; - let fields: Vec<&str> = line.split(':').collect(); - if let [name, pass, _, members, ..] = fields.as_slice() { - format!("{name}:{pass}:{gid}:{members}") - } else { - line.to_string() - } - } else { - line.to_string() - } - }) - .collect(); - - if !found { - lines.push(format!("sandbox:x:{gid}:")); - } - - let mut output = lines.join("\n"); - if content.ends_with('\n') || !found { - output.push('\n'); - } - - std::fs::write(path, output).into_diagnostic()?; - Ok(()) -} - -/// Recursively chown a directory tree to the given UID/GID. -/// -/// This retains the Kubernetes/OpenShift workspace reconciliation from before -/// OCI image identity fallback. Symlinks are skipped, and read-only nested -/// mounts are not traversed. -#[cfg(unix)] -fn chown_sandbox_home(root: &Path, uid: Option, gid: Option) -> Result<()> { - let meta = std::fs::symlink_metadata(root).into_diagnostic()?; - if meta.file_type().is_symlink() { - return Err(miette::miette!( - "path '{}' is a symlink — refusing to chown (potential privilege escalation)", - root.display() - )); - } - - nix::unistd::chown(root, uid, gid).into_diagnostic()?; - - if meta.is_dir() { - chown_children(root, uid, gid, &nix::unistd::chown)?; - } - - Ok(()) -} - -#[cfg(unix)] -fn prepare_oci_workspace( - root: &Path, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], -) -> Result<()> { - prepare_oci_workspace_with(root, uid, gid, supplementary_gids, &nix::unistd::chown) -} - -/// Validate that selecting an image-provided OCI workdir does not grant the -/// sandbox identity any filesystem authority it lacked in the immutable image. -/// -/// Every path component must be a real directory (never a symlink), every -/// parent must already be traversable, and the final directory must already be -/// writable and traversable. No ownership or mode bits are changed. -#[cfg(unix)] -pub fn validate_oci_workspace( - root: &Path, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], -) -> Result<()> { - let components = validated_workspace_components(root, false)?; - let mut current = PathBuf::from("/"); - validate_workspace_component(¤t, uid, gid, supplementary_gids, false)?; - let last_component = components.len().saturating_sub(1); - for (index, component) in components.into_iter().enumerate() { - current.push(component); - validate_workspace_component( - ¤t, - uid, - gid, - supplementary_gids, - index == last_component, - )?; - } - Ok(()) -} - -/// Validate an image-provided workdir in a clean copy of the supervisor so the -/// main process retains the root authority needed for subsequent setup. -#[cfg(target_os = "linux")] -fn validate_oci_workspace_in_subprocess( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, - workdir: &Path, -) -> Result<()> { - use std::os::unix::process::CommandExt; - - let (uid, gid, supplementary_gids) = resolve_filesystem_identity(policy, resolved_identity)?; - let uid = uid.ok_or_else(|| miette::miette!("workspace validator UID is unresolved"))?; - let gid = gid.ok_or_else(|| miette::miette!("workspace validator GID is unresolved"))?; - let groups = supplementary_gids - .iter() - .map(|group| group.as_raw()) - .collect::>(); - let executable = std::env::current_exe().into_diagnostic()?; - let mut command = std::process::Command::new(executable); - command - .arg("validate-workspace") - .arg("--workdir") - .arg(workdir) - .arg("--expected-uid") - .arg(uid.to_string()) - .arg("--expected-gid") - .arg(gid.to_string()) - .env_clear() - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()); - - // `pre_exec` runs after fork and before exec. These direct credential - // syscalls are async-signal-safe and affect only the one-shot child. - #[allow(unsafe_code)] - unsafe { - command.pre_exec(move || { - if libc::setgroups(groups.len(), groups.as_ptr()) != 0 - || libc::setgid(gid.as_raw()) != 0 - || libc::setuid(uid.as_raw()) != 0 - { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); - } - - let output = command.output().into_diagnostic()?; - if output.status.success() { - return Ok(()); - } - - let diagnostic = String::from_utf8_lossy(&output.stderr); - let diagnostic = diagnostic.trim(); - if diagnostic.is_empty() { - return Err(miette::miette!( - "image workspace validation failed with status {}", - output.status - )); - } - Err(miette::miette!( - "image workspace validation failed: {diagnostic}" - )) -} - -#[cfg(unix)] -fn validate_workspace_component( - path: &Path, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], - is_workspace: bool, -) -> Result<()> { - let metadata = std::fs::symlink_metadata(path).map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - miette::miette!( - "image workspace path component '{}' does not exist", - path.display() - ) - } else { - miette::miette!( - "failed to inspect image workspace path component '{}': {error}", - path.display() - ) - } - })?; - if metadata.file_type().is_symlink() { - return Err(miette::miette!( - "workspace path component '{}' is a symlink — refusing to follow it", - path.display() - )); - } - if !metadata.is_dir() { - return Err(miette::miette!( - "workspace path component '{}' is not a directory", - path.display() - )); - } - let required = if is_workspace { 0o3 } else { 0o1 }; - if !identity_has_permissions(&metadata, uid, gid, supplementary_gids, required) { - let requirement = if is_workspace { - "writable and traversable" - } else { - "traversable" - }; - return Err(miette::miette!( - "workspace path component '{}' is not {requirement} by the sandbox identity in the image", - path.display() - )); - } - Ok(()) -} - #[cfg(target_os = "linux")] pub fn validate_oci_workspace_as_effective_identity(root: &Path) -> Result<()> { use rustix::fs::{Access, AtFlags, FileType, Mode, OFlags}; - let components = validated_workspace_components(root, false)?; + let components = validated_workspace_components(root)?; let open_flags = OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; let mut current_path = PathBuf::from("/"); let mut current_fd = rustix::fs::open("/", open_flags, Mode::empty()).into_diagnostic()?; @@ -1554,91 +970,21 @@ fn validate_effective_workspace_write(fd: &impl std::os::fd::AsFd, path: &Path) )) } -/// Prepare only the resolved `OpenShell` workspace directory itself. -/// -/// Image-provided children retain their declared ownership. This avoids -/// crossing symlinks or user-provided nested mounts. -#[cfg(unix)] -fn prepare_oci_workspace_with( - root: &Path, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], - do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, -) -> Result<()> { - let components = validated_workspace_components(root, true)?; - - let last_component = components.len().saturating_sub(1); - let mut current = PathBuf::from("/"); - for (index, component) in components.into_iter().enumerate() { - current.push(component); - match std::fs::symlink_metadata(¤t) { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(miette::miette!( - "workspace path component '{}' is a symlink — refusing to follow it", - current.display() - )); - } - Ok(metadata) if !metadata.is_dir() => { - return Err(miette::miette!( - "workspace path component '{}' is not a directory", - current.display() - )); - } - Ok(metadata) => { - if index != last_component - && !identity_can_traverse(&metadata, uid, gid, supplementary_gids) - { - return Err(miette::miette!( - "workspace parent '{}' is not traversable by the sandbox identity", - current.display() - )); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::create_dir(¤t).into_diagnostic()?; - std::fs::set_permissions(¤t, std::fs::Permissions::from_mode(0o755)) - .into_diagnostic()?; - } - Err(error) => return Err(error).into_diagnostic(), - } - } - - do_chown(root, uid, gid).into_diagnostic()?; - - let metadata = std::fs::symlink_metadata(root).into_diagnostic()?; - let mode = metadata.permissions().mode() & 0o7777; - if mode & 0o300 != 0o300 { - std::fs::set_permissions(root, std::fs::Permissions::from_mode(mode | 0o300)) - .into_diagnostic()?; - } - Ok(()) -} - -#[cfg(unix)] -fn validated_workspace_components( - root: &Path, - allow_managed_fallback: bool, -) -> Result> { - let root_str = root - .to_str() - .ok_or_else(|| miette::miette!("workspace path must be valid UTF-8"))?; - let validated_root = openshell_core::driver_mounts::resolve_oci_workspace_root(root_str) - .map_err(|error| miette::miette!(error))?; - if Path::new(&validated_root) != root - || (!allow_managed_fallback - && validated_root == openshell_core::driver_mounts::DEFAULT_WORKSPACE_ROOT) - { - return Err(miette::miette!( - "workspace path '{}' must be a normalized absolute {}path", - root.display(), - if allow_managed_fallback { - "non-root " - } else { - "non-fallback " - } - )); - } +#[cfg(target_os = "linux")] +fn validated_workspace_components(root: &Path) -> Result> { + let root_str = root + .to_str() + .ok_or_else(|| miette::miette!("workspace path must be valid UTF-8"))?; + let validated_root = openshell_core::driver_mounts::resolve_oci_workspace_root(root_str) + .map_err(|error| miette::miette!(error))?; + if Path::new(&validated_root) != root + || validated_root == openshell_core::driver_mounts::DEFAULT_WORKSPACE_ROOT + { + return Err(miette::miette!( + "workspace path '{}' must be a normalized absolute non-fallback path", + root.display() + )); + } root.components() .skip(1) @@ -1652,476 +998,6 @@ fn validated_workspace_components( .collect() } -#[cfg(unix)] -fn identity_can_traverse( - metadata: &std::fs::Metadata, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], -) -> bool { - identity_has_permissions(metadata, uid, gid, supplementary_gids, 0o1) -} - -#[cfg(unix)] -fn identity_has_permissions( - metadata: &std::fs::Metadata, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], - required: u32, -) -> bool { - let user_id = uid.unwrap_or_else(nix::unistd::geteuid).as_raw(); - if user_id == 0 { - return true; - } - - let group_id = gid.unwrap_or_else(nix::unistd::getegid).as_raw(); - let mode = metadata.permissions().mode(); - if metadata.uid() == user_id { - mode & (required << 6) == required << 6 - } else if metadata.gid() == group_id - || supplementary_gids - .iter() - .any(|supplementary_gid| supplementary_gid.as_raw() == metadata.gid()) - { - mode & (required << 3) == required << 3 - } else { - mode & required == required - } -} - -#[cfg(not(any( - target_os = "aix", - target_os = "haiku", - target_os = "illumos", - target_os = "ios", - target_os = "macos", - target_os = "redox", - target_os = "solaris" -)))] -fn named_user_supplementary_groups(user_name: &str, primary_gid: Gid) -> Result> { - let user_name = CString::new(user_name).map_err(|_| miette::miette!("Invalid user name"))?; - nix::unistd::getgrouplist(user_name.as_c_str(), primary_gid).into_diagnostic() -} - -#[cfg(any( - target_os = "aix", - target_os = "haiku", - target_os = "illumos", - target_os = "ios", - target_os = "macos", - target_os = "redox", - target_os = "solaris" -))] -#[allow(clippy::unnecessary_wraps)] -fn named_user_supplementary_groups(_user_name: &str, _primary_gid: Gid) -> Result> { - // Privilege dropping does not call initgroups on these targets. - Ok(Vec::new()) -} - -#[cfg(unix)] -fn chown_children( - dir: &Path, - uid: Option, - gid: Option, - do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, -) -> Result<()> { - match std::fs::read_dir(dir) { - Ok(entries) => { - for entry in entries { - let entry = entry.into_diagnostic()?; - chown_recursive(&entry.path(), uid, gid, do_chown)?; - } - } - Err(error) => { - debug!( - path = %dir.display(), - %error, - "Cannot list directory during sandbox home chown" - ); - } - } - Ok(()) -} - -#[cfg(unix)] -fn chown_recursive( - path: &Path, - uid: Option, - gid: Option, - do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, -) -> Result<()> { - let meta = std::fs::symlink_metadata(path).into_diagnostic()?; - if meta.file_type().is_symlink() { - debug!(path = %path.display(), "Skipping symlink during sandbox home chown"); - return Ok(()); - } - - if let Err(error) = do_chown(path, uid, gid) { - if error == nix::errno::Errno::EROFS { - debug!(path = %path.display(), "Skipping read-only path during sandbox home chown"); - return Ok(()); - } - return Err(error).into_diagnostic(); - } - - if meta.is_dir() { - chown_children(path, uid, gid, do_chown)?; - } - - Ok(()) -} - -/// Prepare filesystem for the sandboxed process. -/// -/// Creates `read_write` directories if they don't exist and sets ownership -/// on newly-created paths to the configured sandbox user/group. This runs as -/// the supervisor (root) before forking the child process. -/// -/// Accepts both name-based identities (resolved via `/etc/passwd`) and numeric -/// UIDs/GIDs (passed directly to `chown` without a passwd lookup). -#[cfg(unix)] -pub fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> { - prepare_filesystem_with_identity(policy, ResolvedProcessIdentity::default(), None, false) -} - -#[cfg(unix)] -pub fn prepare_filesystem_with_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, - workdir: Option<&str>, - prepare_workspace: bool, -) -> Result<()> { - use nix::unistd::chown; - - // If no user/group configured, nothing to do - if policy - .process - .run_as_user - .as_deref() - .is_none_or(str::is_empty) - && policy - .process - .run_as_group - .as_deref() - .is_none_or(str::is_empty) - { - return Ok(()); - } - - let (uid, gid, supplementary_gids) = resolve_filesystem_identity(policy, resolved_identity)?; - - // Docker owns workspace resolution and must make the selected root usable - // by the final effective identity, including when both policy identity - // fields were explicit. Validate it before processing any user-authored - // read-write paths so an unsafe image path fails first. Other drivers - // retain their preparation. - if prepare_workspace { - let workspace = workdir.ok_or_else(|| { - miette::miette!("local container driver did not supply a workspace workdir") - })?; - let workspace = Path::new(workspace); - if workspace == Path::new(openshell_core::driver_mounts::DEFAULT_WORKSPACE_ROOT) { - info!(path = %workspace.display(), ?uid, ?gid, "Preparing managed workspace"); - prepare_oci_workspace(workspace, uid, gid, &supplementary_gids)?; - } else { - info!(path = %workspace.display(), ?uid, ?gid, "Validating image workspace authority"); - #[cfg(target_os = "linux")] - validate_oci_workspace_in_subprocess(policy, resolved_identity, workspace)?; - #[cfg(not(target_os = "linux"))] - validate_oci_workspace(workspace, uid, gid, &supplementary_gids)?; - } - } - - // Create missing read_write paths and only chown the ones we created. - for path in &policy.filesystem.read_write { - if prepare_read_write_path(path)? { - debug!( - path = %path.display(), - ?uid, - ?gid, - "Setting ownership on newly created read_write path" - ); - chown(path, uid, gid).into_diagnostic()?; - } - } - - // Retain the existing Kubernetes/OpenShift behavior for driver-injected - // numeric identities. Docker clears this variable and does not receive - // identity-specific workspace preparation. - if std::env::var(openshell_core::sandbox_env::SANDBOX_UID).is_ok_and(|uid| !uid.is_empty()) { - let sandbox_home = Path::new("/sandbox"); - if sandbox_home.exists() { - info!(?uid, ?gid, "Chowning /sandbox for driver-injected UID/GID"); - chown_sandbox_home(sandbox_home, uid, gid)?; - } - } - - Ok(()) -} - -#[cfg(unix)] -fn resolve_filesystem_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, -) -> Result<(Option, Option, Vec)> { - let user_name = policy - .process - .run_as_user - .as_deref() - .filter(|name| !name.is_empty()); - let group_name = policy - .process - .run_as_group - .as_deref() - .filter(|name| !name.is_empty()); - - let uid = match resolved_identity.uid() { - Some(uid) => Some(Uid::from_raw(uid)), - None => match user_name { - Some(name) if name.parse::().is_ok() => { - Some(Uid::from_raw(name.parse().into_diagnostic()?)) - } - Some(name) => User::from_name(name).into_diagnostic()?.map(|u| u.uid), - _ => None, - }, - }; - - // Resolve GID: numeric values are passed directly; names resolve via group. - let gid = match resolved_identity.gid() { - Some(gid) => Some(Gid::from_raw(gid)), - None => match group_name { - Some(name) if name.parse::().is_ok() => { - Some(Gid::from_raw(name.parse().into_diagnostic()?)) - } - Some(name) => Group::from_name(name).into_diagnostic()?.map(|g| g.gid), - _ => None, - }, - }; - - let supplementary_gids = match user_name { - Some(name) if name.parse::().is_err() => { - let primary_gid = if let Some(gid) = gid { - gid - } else { - let uid = - uid.ok_or_else(|| miette::miette!("Failed to resolve sandbox user '{name}'"))?; - User::from_uid(uid) - .into_diagnostic()? - .ok_or_else(|| miette::miette!("Failed to resolve user from UID {uid}"))? - .gid - }; - if resolved_identity.uid().is_some() { - crate::identity::resolve_oci_supplementary_gids(name, primary_gid.as_raw())? - .into_iter() - .map(Gid::from_raw) - .collect() - } else { - named_user_supplementary_groups(name, primary_gid)? - } - } - _ => Vec::new(), - }; - - Ok((uid, gid, supplementary_gids)) -} - -#[cfg(not(unix))] -pub fn prepare_filesystem(_policy: &SandboxPolicy) -> Result<()> { - Ok(()) -} - -// `effective_gid`/`effective_uid` are intentionally parallel names (same role -// for different identifiers) and the noise from renaming would obscure intent. -#[cfg(unix)] -#[allow(clippy::similar_names)] -pub fn drop_privileges(policy: &SandboxPolicy) -> Result<()> { - drop_privileges_with_identity(policy, ResolvedProcessIdentity::default()) -} - -#[cfg(unix)] -#[allow(clippy::similar_names)] -pub fn drop_privileges_with_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, -) -> Result<()> { - let user_name = match policy.process.run_as_user.as_deref() { - Some(name) if !name.is_empty() => Some(name), - _ => None, - }; - let group_name = match policy.process.run_as_group.as_deref() { - Some(name) if !name.is_empty() => Some(name), - _ => None, - }; - - // If no user/group is configured and we are running as root, fall back to - // "sandbox:sandbox" instead of silently keeping root. This covers the - // local/dev-mode path for drivers that provide no identity metadata. - // For non-root runtimes, the no-op is safe -- we are already unprivileged. - if user_name.is_none() && group_name.is_none() { - if nix::unistd::geteuid().is_root() { - let mut fallback = policy.clone(); - fallback.process.run_as_user = Some("sandbox".into()); - fallback.process.run_as_group = Some("sandbox".into()); - return drop_privileges_with_identity(&fallback, resolved_identity); - } - return Ok(()); - } - - // Resolve UID: numeric values are used directly; names resolve via passwd. - let target_uid = match resolved_identity.uid() { - Some(uid) => Uid::from_raw(uid), - None => match user_name { - Some(name) if name.parse::().is_ok() => { - Uid::from_raw(name.parse().into_diagnostic()?) - } - Some(name) => { - User::from_name(name) - .into_diagnostic()? - .ok_or_else(|| miette::miette!("Sandbox user not found: {name}"))? - .uid - } - None => nix::unistd::geteuid(), - }, - }; - - // Resolve group: if a numeric GID is configured use it directly. - // Otherwise try name resolution, then fall back to current user's primary group. - let target_gid = match resolved_identity.gid() { - Some(gid) => Gid::from_raw(gid), - None => match group_name { - Some(name) if name.parse::().is_ok() => { - Gid::from_raw(name.parse().into_diagnostic()?) - } - Some(name) => { - Group::from_name(name) - .into_diagnostic()? - .ok_or_else(|| miette::miette!("Sandbox group not found: {name}"))? - .gid - } - None => match target_uid.as_raw() { - 0 => nix::unistd::getegid(), - _ => Group::from_gid( - User::from_uid(target_uid) - .into_diagnostic()? - .ok_or_else(|| { - miette::miette!("Failed to resolve user from UID {target_uid}") - })? - .gid, - ) - .into_diagnostic()? - .map_or_else(nix::unistd::getegid, |g| g.gid), - }, - }, - }; - - // Resolve the name for initgroups only for the existing explicit-policy - // path. OCI-derived users carry a numeric UID from the bounded parser and - // must not be looked up again through NSS. - let user_name_is_numeric = user_name.is_some_and(|n| n.parse::().is_ok()); - let initgroups_name = - if user_name.is_some() && !user_name_is_numeric && resolved_identity.uid().is_none() { - Some( - User::from_uid(target_uid) - .into_diagnostic()? - .ok_or_else(|| { - miette::miette!("Failed to resolve user record for UID {target_uid}") - })? - .name, - ) - } else { - None - }; - - if target_uid != nix::unistd::geteuid() { - if resolved_identity.uses_oci_user_fallback() { - // OCI named users use the bounded /etc/group parser shared with - // workspace validation. Numeric OCI users resolve to an empty - // list. Never retain the root supervisor's inherited groups. - #[cfg(not(any( - target_os = "macos", - target_os = "ios", - target_os = "haiku", - target_os = "redox" - )))] - { - let (_, _, supplementary_gids) = - resolve_filesystem_identity(policy, resolved_identity)?; - nix::unistd::setgroups(&supplementary_gids).into_diagnostic()?; - } - } else if let Some(ref user_name) = initgroups_name { - let user_cstr = CString::new(user_name.as_str()) - .map_err(|_| miette::miette!("Invalid user name"))?; - #[cfg(any( - target_os = "macos", - target_os = "ios", - target_os = "haiku", - target_os = "redox" - ))] - { - let _ = user_cstr; - } - #[cfg(not(any( - target_os = "macos", - target_os = "ios", - target_os = "haiku", - target_os = "redox" - )))] - { - nix::unistd::initgroups(user_cstr.as_c_str(), target_gid).into_diagnostic()?; - } - } - } - - if target_gid != nix::unistd::getegid() { - nix::unistd::setgid(target_gid).into_diagnostic()?; - } - - // Verify effective GID actually changed (defense-in-depth, CWE-250 / CERT POS37-C) - let effective_gid = nix::unistd::getegid(); - if effective_gid != target_gid { - return Err(miette::miette!( - "Privilege drop verification failed: expected effective GID {}, got {}", - target_gid, - effective_gid - )); - } - - #[cfg(target_os = "linux")] - if nix::unistd::geteuid().is_root() { - drop_capability_bounding_set()?; - } - - if user_name.is_some() { - if target_uid != nix::unistd::geteuid() { - nix::unistd::setuid(target_uid).into_diagnostic()?; - } - - // Verify effective UID actually changed (defense-in-depth, CWE-250 / CERT POS37-C) - let effective_uid = nix::unistd::geteuid(); - if effective_uid != target_uid { - return Err(miette::miette!( - "Privilege drop verification failed: expected effective UID {}, got {}", - target_uid, - effective_uid - )); - } - - // Verify root cannot be re-acquired (CERT POS37-C hardening). - // If we dropped from root, setuid(0) must fail; success means privileges - // were not fully relinquished. - if nix::unistd::setuid(Uid::from_raw(0)).is_ok() && target_uid.as_raw() != 0 { - return Err(miette::miette!( - "Privilege drop verification failed: process can still re-acquire root (UID 0) \ - after switching to UID {}", - target_uid - )); - } - } - - Ok(()) -} - /// Process exit status. #[derive(Debug, Clone, Copy)] pub struct ProcessStatus { @@ -2188,8 +1064,12 @@ mod tests { use openshell_core::policy::{ FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy, }; + #[cfg(target_os = "linux")] + use std::ffi::CString; #[cfg(unix)] use std::mem::size_of; + #[cfg(target_os = "linux")] + use std::os::unix::fs::PermissionsExt; use std::process::Stdio as StdStdio; /// Helper to create a minimal `SandboxPolicy` with the given process policy. @@ -2294,333 +1174,6 @@ mod tests { } } - /// Unknown names may yield `Ok(None)` (`… not found …`) or `Err` when NSS fails first - /// (e.g. `ENOENT: No such file or directory`). - fn assert_unknown_identity_lookup_failed(msg: &str) { - assert!( - msg.contains("not found") - || msg.contains("ENOENT") - || msg.contains("No such file or directory"), - "expected unknown user/group lookup failure (…not found… or ENOENT): {msg}" - ); - } - - #[test] - #[cfg(unix)] - fn explicit_identity_accepts_non_root_system_ids() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("101".into()), - run_as_group: Some("102".into()), - }); - - assert!(validate_sandbox_user(&policy).is_ok()); - assert!(validate_sandbox_group(&policy).is_ok()); - } - - #[test] - #[cfg(unix)] - fn resolved_oci_identity_accepts_non_root_system_ids() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("app".into()), - run_as_group: Some("staff".into()), - }); - let resolved = ResolvedProcessIdentity::new(Some(101), Some(102)); - - assert!(validate_sandbox_user_with_identity(&policy, resolved).is_ok()); - assert!(validate_sandbox_group_with_identity(&policy, resolved).is_ok()); - } - - #[test] - #[cfg(unix)] - fn completed_runtime_identity_rejects_numeric_root() { - let root_user = policy_with_process(ProcessPolicy { - run_as_user: Some("0".into()), - run_as_group: Some("102".into()), - }); - let root_group = policy_with_process(ProcessPolicy { - run_as_user: Some("101".into()), - run_as_group: Some("0".into()), - }); - - assert!(validate_sandbox_user(&root_user).is_err()); - assert!(validate_sandbox_group(&root_group).is_err()); - } - - #[test] - #[cfg(unix)] - fn resolved_oci_components_do_not_repeat_nss_validation() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("__oci_name_not_in_host_nss__".into()), - run_as_group: Some("__oci_group_not_in_host_nss__".into()), - }); - let resolved = ResolvedProcessIdentity::new(Some(1234), Some(1235)); - - assert!(validate_sandbox_user_with_identity(&policy, resolved).is_ok()); - assert!(validate_sandbox_group_with_identity(&policy, resolved).is_ok()); - } - - #[test] - #[cfg(unix)] - fn explicit_policy_components_keep_existing_validation_path() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("__explicit_name_not_in_host_nss__".into()), - run_as_group: Some("__oci_group_not_in_host_nss__".into()), - }); - let resolved = ResolvedProcessIdentity::new(None, Some(1235)); - - assert!(validate_sandbox_user_with_identity(&policy, resolved).is_err()); - assert!(validate_sandbox_group_with_identity(&policy, resolved).is_ok()); - } - - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_available() -> bool { - capctl::caps::CapState::get_current() - .is_ok_and(|state| state.effective.has(capctl::caps::Cap::SETPCAP)) - || capctl::caps::bounding::probe().is_empty() - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_accepts_empty_eperm() { - let remaining = capctl::caps::CapSet::empty(); - - assert!( - validate_capability_bounding_set_clear( - Err(capctl::Error::from_code(libc::EPERM)), - remaining, - || Ok(()), - ) - .is_ok() - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_rejects_nonempty_eperm() { - let mut remaining = capctl::caps::CapSet::empty(); - remaining.add(capctl::caps::Cap::CHOWN); - - let result = validate_capability_bounding_set_clear( - Err(capctl::Error::from_code(libc::EPERM)), - remaining, - || panic!("unknown capabilities should not be checked when known caps remain"), - ); - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("Failed to clear child capability bounding set") - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_rejects_nonempty_success() { - let mut remaining = capctl::caps::CapSet::empty(); - remaining.add(capctl::caps::Cap::CHOWN); - - let result = validate_capability_bounding_set_clear(Ok(()), remaining, || { - panic!("unknown capabilities should not be checked when known caps remain") - }); - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("capabilities remain raised") - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_rejects_unknown_eperm() { - let remaining = capctl::caps::CapSet::empty(); - - let result = validate_capability_bounding_set_clear( - Err(capctl::Error::from_code(libc::EPERM)), - remaining, - || Err(capctl::Error::from_code(libc::EPERM)), - ); - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("Failed to clear unknown child capability bounding set entries") - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_probe_child() { - if std::env::var_os("OPENSHELL_TEST_PROBE_CHILD_CAPS").is_none() { - return; - } - - assert!( - capctl::caps::bounding::probe().is_empty(), - "child CapBnd should be empty after exec" - ); - } - - #[test] - fn drop_privileges_noop_when_no_user_or_group() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: None, - run_as_group: None, - }); - if nix::unistd::geteuid().is_root() { - // As root, drop_privileges falls back to "sandbox:sandbox". - // If that user exists, it succeeds; if not (e.g. CI), it - // must error rather than silently keep root. - let has_sandbox = User::from_name("sandbox").ok().flatten().is_some(); - assert_eq!(drop_privileges(&policy).is_ok(), has_sandbox); - } else { - assert!(drop_privileges(&policy).is_ok()); - } - } - - #[test] - fn drop_privileges_noop_when_empty_strings() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(String::new()), - run_as_group: Some(String::new()), - }); - if nix::unistd::geteuid().is_root() { - let has_sandbox = User::from_name("sandbox").ok().flatten().is_some(); - assert_eq!(drop_privileges(&policy).is_ok(), has_sandbox); - } else { - assert!(drop_privileges(&policy).is_ok()); - } - } - - #[test] - fn drop_privileges_succeeds_for_current_group() { - // Set only run_as_group (no run_as_user) so that initgroups() is not - // called. initgroups(3) requires CAP_SETGID/root even when the target - // is the current user, so it cannot be exercised without elevated - // privileges. This test covers the setgid() + GID post-condition - // verification path without needing root. - let current_group = Group::from_gid(nix::unistd::getegid()) - .expect("getgrgid") - .expect("current group entry"); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: None, - run_as_group: Some(current_group.name), - }); - - let result = drop_privileges(&policy); - #[cfg(target_os = "linux")] - { - if nix::unistd::geteuid().is_root() && !capability_bounding_set_clear_available() { - let msg = format!("{}", result.unwrap_err()); - assert!( - msg.contains("Failed to clear child capability bounding set"), - "unexpected failure: {msg}" - ); - return; - } - } - assert!(result.is_ok(), "drop_privileges failed: {result:?}"); - } - - #[test] - #[cfg(target_os = "linux")] - #[allow(unsafe_code)] - fn drop_privileges_clears_bounding_set_for_spawned_child_when_permitted() { - use std::os::unix::process::CommandExt; - - if !capability_bounding_set_clear_available() { - eprintln!( - "skipping: CAP_SETPCAP is not effective and the capability bounding set is nonempty" - ); - return; - } - - let current_group = Group::from_gid(nix::unistd::getegid()) - .expect("getgrgid") - .expect("current group entry"); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: None, - run_as_group: Some(current_group.name), - }); - - let mut cmd = std::process::Command::new(std::env::current_exe().expect("current exe")); - cmd.arg("capability_probe_child") - .arg("--nocapture") - .env("OPENSHELL_TEST_PROBE_CHILD_CAPS", "1") - .stdin(StdStdio::null()) - .stdout(StdStdio::piped()) - .stderr(StdStdio::piped()); - - unsafe { - cmd.pre_exec(move || { - drop_privileges(&policy).map_err(|err| std::io::Error::other(err.to_string())) - }); - } - - let output = cmd.output().expect("spawn child status probe"); - assert!( - output.status.success(), - "status probe failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - #[test] - #[ignore = "initgroups(3) requires CAP_SETGID; run as root: sudo cargo test -- --ignored"] - fn drop_privileges_succeeds_for_current_user() { - // Exercises the full privilege-drop path including initgroups(), - // setgid(), setuid(), and the root-reacquisition check. Requires - // CAP_SETGID (root) because initgroups(3) calls setgroups(2) - // internally. Fixes: https://github.com/NVIDIA/OpenShell/issues/622 - let current_user = User::from_uid(nix::unistd::geteuid()) - .expect("getpwuid") - .expect("current user entry"); - let current_group = Group::from_gid(nix::unistd::getegid()) - .expect("getgrgid") - .expect("current group entry"); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(current_user.name), - run_as_group: Some(current_group.name), - }); - - assert!(drop_privileges(&policy).is_ok()); - } - - #[test] - fn drop_privileges_fails_for_nonexistent_user() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("__nonexistent_test_user_42__".to_string()), - run_as_group: None, - }); - - let result = drop_privileges(&policy); - assert!(result.is_err()); - let msg = format!("{}", result.unwrap_err()); - assert_unknown_identity_lookup_failed(&msg); - } - - #[test] - fn drop_privileges_fails_for_nonexistent_group() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: None, - run_as_group: Some("__nonexistent_test_group_42__".to_string()), - }); - - let result = drop_privileges(&policy); - assert!(result.is_err()); - let msg = format!("{}", result.unwrap_err()); - assert_unknown_identity_lookup_failed(&msg); - } - #[cfg(unix)] #[allow(unsafe_code)] fn probe_hardened_child(probe: unsafe fn() -> i64) -> i64 { @@ -2697,416 +1250,75 @@ mod tests { } #[test] - #[cfg(target_os = "linux")] - fn harden_child_process_marks_process_nondumpable() { - assert_eq!(probe_hardened_child(dumpable_flag_probe), 0); - } - - #[test] - #[cfg(target_os = "linux")] - fn parse_pids_max_detects_limited_runtime() { - assert_eq!( - parse_pids_max("2048\n"), - RuntimePidLimitStatus::Limited(2048) - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn parse_pids_max_detects_unlimited_runtime() { - assert_eq!(parse_pids_max("max\n"), RuntimePidLimitStatus::Unlimited); - } - - #[test] - #[cfg(target_os = "linux")] - fn parse_pids_max_reports_invalid_values() { - let status = parse_pids_max("not-a-number\n"); - assert!(matches!(status, RuntimePidLimitStatus::Unavailable(_))); - } - - #[test] - #[cfg(target_os = "linux")] - fn pid_limit_require_mode_rejects_missing_guardrail_statuses() { - for status in [ - RuntimePidLimitStatus::Unlimited, - RuntimePidLimitStatus::Unavailable("missing".to_string()), - ] { - let result = check_runtime_pid_limit_status(status, RuntimePidLimitMode::Require); - assert!(result.is_err()); - } - } - - #[test] - #[cfg(target_os = "linux")] - fn pid_limit_warn_mode_accepts_missing_guardrail_statuses() { - for status in [ - RuntimePidLimitStatus::Unlimited, - RuntimePidLimitStatus::Unavailable("missing".to_string()), - ] { - let result = check_runtime_pid_limit_status(status, RuntimePidLimitMode::Warn); - assert!(result.is_ok()); - } - } - - #[tokio::test] - async fn inject_provider_env_sets_placeholder_values() { - let mut cmd = Command::new("/usr/bin/env"); - cmd.stdin(StdStdio::null()) - .stdout(StdStdio::piped()) - .stderr(StdStdio::null()); - - let provider_env = std::iter::once(( - "ANTHROPIC_API_KEY".to_string(), - "openshell:resolve:env:ANTHROPIC_API_KEY".to_string(), - )) - .collect(); - - inject_provider_env(&mut cmd, &provider_env); - - let output = cmd.output().await.expect("spawn env"); - let stdout = String::from_utf8(output.stdout).expect("utf8"); - assert!(stdout.contains("ANTHROPIC_API_KEY=openshell:resolve:env:ANTHROPIC_API_KEY")); - } - - #[cfg(unix)] - fn sandbox_policy_with_read_write( - path: PathBuf, - run_as_user: Option, - run_as_group: Option, - ) -> SandboxPolicy { - SandboxPolicy { - version: 1, - filesystem: FilesystemPolicy { - read_only: vec![], - read_write: vec![path], - include_workdir: false, - }, - network: NetworkPolicy::default(), - landlock: LandlockPolicy::default(), - process: ProcessPolicy { - run_as_user, - run_as_group, - }, - } - } - - #[cfg(unix)] - #[test] - fn prepare_read_write_path_creates_missing_directory() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("missing").join("nested"); - - assert!(prepare_read_write_path(&missing).unwrap()); - assert!(missing.is_dir()); - } - - #[cfg(unix)] - #[test] - fn prepare_read_write_path_preserves_existing_directory() { - let dir = tempfile::tempdir().unwrap(); - let existing = dir.path().join("existing"); - std::fs::create_dir(&existing).unwrap(); - - assert!(!prepare_read_write_path(&existing).unwrap()); - assert!(existing.is_dir()); - } - - #[cfg(unix)] - #[test] - fn prepare_read_write_path_rejects_symlink() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("target"); - let link = dir.path().join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let error = prepare_read_write_path(&link).unwrap_err(); - assert!( - error - .to_string() - .contains("is a symlink — refusing to chown"), - "unexpected error: {error}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_filesystem_skips_chown_for_existing_read_write_paths() { - use std::os::unix::fs::MetadataExt; - - if nix::unistd::geteuid().is_root() { - return; - } - - let Ok(Some(current_user)) = User::from_uid(nix::unistd::geteuid()) else { - eprintln!("skipping: current UID has no /etc/passwd entry"); - return; - }; - let restricted_group = Group::from_gid(Gid::from_raw(0)) - .unwrap() - .expect("gid 0 group entry"); - if restricted_group.gid == nix::unistd::getegid() { - return; - } - - let dir = tempfile::tempdir().unwrap(); - let existing = dir.path().join("existing"); - std::fs::create_dir(&existing).unwrap(); - let before = std::fs::metadata(&existing).unwrap(); - - let policy = sandbox_policy_with_read_write( - existing.clone(), - Some(current_user.name), - Some(restricted_group.name), - ); - - prepare_filesystem(&policy).expect("existing path should not be re-owned"); - - let after = std::fs::metadata(&existing).unwrap(); - assert_eq!(after.uid(), before.uid()); - assert_eq!(after.gid(), before.gid()); - } - - #[cfg(unix)] - #[test] - #[allow(clippy::similar_names)] - fn chown_sandbox_home_changes_ownership_recursively() { - use std::os::unix::fs::MetadataExt; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - std::fs::write(root.join("file.txt"), "hello").unwrap(); - std::fs::create_dir(root.join("subdir")).unwrap(); - std::fs::write(root.join("subdir").join("nested.txt"), "world").unwrap(); - - let expected_uid = nix::unistd::geteuid(); - let expected_gid = nix::unistd::getegid(); - chown_sandbox_home(&root, Some(expected_uid), Some(expected_gid)).unwrap(); - - for path in &[ - root.clone(), - root.join("file.txt"), - root.join("subdir"), - root.join("subdir").join("nested.txt"), - ] { - let meta = std::fs::metadata(path).unwrap(); - assert_eq!(meta.uid(), expected_uid.as_raw()); - assert_eq!(meta.gid(), expected_gid.as_raw()); - } - } - - #[cfg(unix)] - #[test] - fn chown_sandbox_home_rejects_symlink_root() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("real"); - let link = dir.path().join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let err = chown_sandbox_home( - &link, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - ) - .unwrap_err(); - assert!( - err.to_string().contains("symlink"), - "expected symlink rejection: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn chown_sandbox_home_skips_symlink_children() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let target = dir.path().join("outside"); - std::fs::write(&target, "secret").unwrap(); - symlink(&target, root.join("link")).unwrap(); - - chown_sandbox_home( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - ) - .expect("symlink children should be skipped"); - } - - #[cfg(unix)] - #[test] - fn chown_recursive_skips_erofs_subtree_but_continues_siblings() { - use std::sync::{Arc, Mutex}; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - - let readonly_dir = root.join("ro-mount"); - std::fs::create_dir(&readonly_dir).unwrap(); - std::fs::write(readonly_dir.join("child-under-ro.txt"), "data").unwrap(); - std::fs::write(root.join("writable-sibling.txt"), "data").unwrap(); - - let chowned = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&chowned); - let readonly_dir_for_chown = readonly_dir.clone(); - let fake_chown = - move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - if path == readonly_dir_for_chown { - return Err(nix::errno::Errno::EROFS); - } - observed.lock().unwrap().push(path.to_path_buf()); - Ok(()) - }; - - chown_children( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &fake_chown, - ) - .expect("read-only subtree should be skipped"); - - let chowned = chowned.lock().unwrap(); - assert!( - !chowned.contains(&readonly_dir.join("child-under-ro.txt")), - "children under EROFS directory must not be traversed" - ); - assert!( - chowned.contains(&root.join("writable-sibling.txt")), - "writable sibling should still be chowned" - ); - } - - #[cfg(unix)] - #[test] - fn chown_recursive_propagates_non_erofs_errors() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let fake_chown = |_path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - Err(nix::errno::Errno::EPERM) - }; + #[cfg(target_os = "linux")] + fn harden_child_process_marks_process_nondumpable() { + assert_eq!(probe_hardened_child(dumpable_flag_probe), 0); + } - let result = chown_recursive( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &fake_chown, + #[test] + #[cfg(target_os = "linux")] + fn parse_pids_max_detects_limited_runtime() { + assert_eq!( + parse_pids_max("2048\n"), + RuntimePidLimitStatus::Limited(2048) ); - assert!(result.is_err(), "non-EROFS errors should propagate"); } - #[cfg(unix)] #[test] - fn prepare_oci_workspace_chowns_only_root() { - use std::sync::{Arc, Mutex}; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let child = root.join("image-content.txt"); - std::fs::write(&child, "image-owned").unwrap(); - - let chowned = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&chowned); - let fake_chown = - move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - observed.lock().unwrap().push(path.to_path_buf()); - Ok(()) - }; - - prepare_oci_workspace_with( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - &fake_chown, - ) - .expect("workspace root should be prepared"); - - assert_eq!(*chowned.lock().unwrap(), vec![root]); - assert!(child.exists(), "image-provided child should be untouched"); + #[cfg(target_os = "linux")] + fn parse_pids_max_detects_unlimited_runtime() { + assert_eq!(parse_pids_max("max\n"), RuntimePidLimitStatus::Unlimited); } - #[cfg(unix)] #[test] - fn validate_oci_workspace_accepts_existing_owner_writable_directory() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("project"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); - - validate_oci_workspace( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .expect("image owner already has write and traverse authority"); + #[cfg(target_os = "linux")] + fn parse_pids_max_reports_invalid_values() { + let status = parse_pids_max("not-a-number\n"); + assert!(matches!(status, RuntimePidLimitStatus::Unavailable(_))); } - #[cfg(unix)] #[test] - fn validate_oci_workspace_accepts_supplementary_group_write_authority() { - let dir = tempfile::tempdir_in("/tmp").unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); - let root = dir.path().canonicalize().unwrap().join("project"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o070)).unwrap(); - let metadata = std::fs::symlink_metadata(&root).unwrap(); - - validate_oci_workspace( - &root, - Some(Uid::from_raw(metadata.uid().wrapping_add(1))), - Some(Gid::from_raw(metadata.gid().wrapping_add(1))), - &[Gid::from_raw(metadata.gid())], - ) - .expect("supplementary group already has write and traverse authority"); + #[cfg(target_os = "linux")] + fn pid_limit_require_mode_rejects_missing_guardrail_statuses() { + for status in [ + RuntimePidLimitStatus::Unlimited, + RuntimePidLimitStatus::Unavailable("missing".to_string()), + ] { + let result = check_runtime_pid_limit_status(status, RuntimePidLimitMode::Require); + assert!(result.is_err()); + } } - #[cfg(unix)] #[test] - fn validate_oci_workspace_rejects_unwritable_directory() { - let dir = tempfile::tempdir_in("/tmp").unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); - let root = dir.path().canonicalize().unwrap().join("project"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap(); - let metadata = std::fs::symlink_metadata(&root).unwrap(); - - let error = validate_oci_workspace( - &root, - Some(Uid::from_raw(metadata.uid().wrapping_add(1))), - Some(Gid::from_raw(metadata.gid().wrapping_add(1))), - &[], - ) - .unwrap_err(); - assert!(error.to_string().contains("not writable and traversable")); + #[cfg(target_os = "linux")] + fn pid_limit_warn_mode_accepts_missing_guardrail_statuses() { + for status in [ + RuntimePidLimitStatus::Unlimited, + RuntimePidLimitStatus::Unavailable("missing".to_string()), + ] { + let result = check_runtime_pid_limit_status(status, RuntimePidLimitMode::Warn); + assert!(result.is_ok()); + } } - #[cfg(unix)] - #[test] - fn validate_oci_workspace_rejects_missing_path() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("missing"); - - let error = validate_oci_workspace( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!(error.to_string().contains("does not exist")); + #[tokio::test] + async fn inject_provider_env_sets_placeholder_values() { + let mut cmd = Command::new("/usr/bin/env"); + cmd.stdin(StdStdio::null()) + .stdout(StdStdio::piped()) + .stderr(StdStdio::null()); + + let provider_env = std::iter::once(( + "ANTHROPIC_API_KEY".to_string(), + "openshell:resolve:env:ANTHROPIC_API_KEY".to_string(), + )) + .collect(); + + inject_provider_env(&mut cmd, &provider_env); + + let output = cmd.output().await.expect("spawn env"); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + assert!(stdout.contains("ANTHROPIC_API_KEY=openshell:resolve:env:ANTHROPIC_API_KEY")); } #[cfg(target_os = "linux")] @@ -3301,405 +1513,6 @@ mod tests { } } - #[cfg(unix)] - #[test] - fn validate_oci_workspace_rejects_restrictive_parent() { - let dir = tempfile::tempdir().unwrap(); - let parent = dir.path().canonicalize().unwrap().join("private"); - let root = parent.join("project"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap(); - let metadata = std::fs::symlink_metadata(&parent).unwrap(); - - let error = validate_oci_workspace( - &root, - Some(Uid::from_raw(metadata.uid().wrapping_add(1))), - Some(Gid::from_raw(metadata.gid().wrapping_add(1))), - &[], - ) - .unwrap_err(); - assert!(error.to_string().contains("not traversable")); - } - - #[cfg(unix)] - #[test] - fn validate_oci_workspace_rejects_symlink_component() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let base = dir.path().canonicalize().unwrap(); - let target = base.join("target"); - let link = base.join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let error = validate_oci_workspace( - &link, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!(error.to_string().contains("symlink")); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_makes_existing_root_owner_writable() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).unwrap(); - - prepare_oci_workspace_with(&root, None, None, &[], &|_, _, _| Ok(())) - .expect("read-only workspace root should be prepared"); - - let mode = std::fs::symlink_metadata(&root) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o755); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_symlink_root() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let base = dir.path().canonicalize().unwrap(); - let target = base.join("real"); - let link = base.join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let err = prepare_oci_workspace( - &link, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!( - err.to_string().contains("symlink"), - "expected symlink rejection: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_symlink_parent() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let base = dir.path().canonicalize().unwrap(); - let target = base.join("real"); - let parent_link = base.join("parent-link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &parent_link).unwrap(); - - let err = prepare_oci_workspace( - &parent_link.join("workspace"), - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!( - err.to_string().contains("symlink"), - "expected parent symlink rejection: {err}" - ); - assert!( - !target.join("workspace").exists(), - "workspace must not be created through a symlink parent" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_parent_traversal() { - let err = prepare_oci_workspace( - Path::new("/tmp/workspace/../escape"), - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!( - err.to_string().contains("must be normalized"), - "expected traversal rejection: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_inaccessible_existing_parent() { - let dir = tempfile::tempdir().unwrap(); - let parent = dir.path().canonicalize().unwrap().join("workspace"); - std::fs::create_dir(&parent).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); - let metadata = std::fs::symlink_metadata(&parent).unwrap(); - let different_user = Uid::from_raw(metadata.uid().wrapping_add(1)); - let different_group = Gid::from_raw(metadata.gid().wrapping_add(1)); - let root = parent.join("project"); - - let error = prepare_oci_workspace_with( - &root, - Some(different_user), - Some(different_group), - &[], - &|_, _, _| Ok(()), - ) - .unwrap_err(); - - assert!( - error.to_string().contains("is not traversable"), - "unexpected error: {error}" - ); - assert!( - !root.exists(), - "workspace must not be created below an inaccessible parent" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_accepts_supplementary_group_parent() { - let dir = tempfile::tempdir_in("/tmp").unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o710)).unwrap(); - let parent = dir.path().canonicalize().unwrap().join("workspace"); - std::fs::create_dir(&parent).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o710)).unwrap(); - let metadata = std::fs::symlink_metadata(&parent).unwrap(); - let different_user = Uid::from_raw(metadata.uid().wrapping_add(1)); - let different_group = Gid::from_raw(metadata.gid().wrapping_add(1)); - let supplementary_group = Gid::from_raw(metadata.gid()); - let root = parent.join("project"); - - prepare_oci_workspace_with( - &root, - Some(different_user), - Some(different_group), - &[supplementary_group], - &|_, _, _| Ok(()), - ) - .expect("supplementary group execute permission should allow traversal"); - - assert!(root.is_dir()); - } - - #[cfg(not(any( - target_os = "aix", - target_os = "haiku", - target_os = "illumos", - target_os = "ios", - target_os = "macos", - target_os = "redox", - target_os = "solaris" - )))] - #[test] - fn named_user_supplementary_groups_include_primary_group() { - let user = User::from_uid(nix::unistd::geteuid()) - .expect("resolve current UID") - .expect("current user exists"); - - let groups = named_user_supplementary_groups(&user.name, user.gid) - .expect("resolve named-user supplementary groups"); - - assert!(groups.contains(&user.gid)); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_non_directory_root() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("sandbox"); - std::fs::write(&root, "not a directory").unwrap(); - - let error = prepare_oci_workspace( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!( - error.to_string().contains("is not a directory"), - "unexpected error: {error}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_propagates_root_chown_error() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let fake_chown = |_path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - Err(nix::errno::Errno::EROFS) - }; - - let error = prepare_oci_workspace_with( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - &fake_chown, - ) - .unwrap_err(); - - assert!( - error.to_string().contains("Read-only file system"), - "unexpected error: {error}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_creates_missing_root() { - use std::sync::{Arc, Mutex}; - - let dir = tempfile::tempdir().unwrap(); - let missing = dir - .path() - .canonicalize() - .unwrap() - .join("missing") - .join("sandbox"); - let chowned = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&chowned); - let fake_chown = - move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - observed.lock().unwrap().push(path.to_path_buf()); - Ok(()) - }; - - prepare_oci_workspace_with( - &missing, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - &fake_chown, - ) - .expect("missing OCI workspace should be created"); - - assert!(missing.is_dir()); - assert_eq!( - std::fs::symlink_metadata(missing.parent().unwrap()) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o755 - ); - assert_eq!(*chowned.lock().unwrap(), vec![missing]); - } - - #[cfg(unix)] - #[test] - fn rewrite_passwd_modifies_existing_sandbox_entry() { - let dir = tempfile::tempdir().unwrap(); - let passwd = dir.path().join("passwd"); - std::fs::write( - &passwd, - "root:x:0:0:root:/root:/bin/bash\nsandbox:x:1000:1000::/sandbox:/bin/bash\n", - ) - .unwrap(); - - rewrite_passwd_at(&passwd, "5000", "6000").unwrap(); - - let content = std::fs::read_to_string(&passwd).unwrap(); - assert!(content.contains("sandbox:x:5000:6000::/sandbox:/bin/bash")); - assert!(content.contains("root:x:0:0:root:/root:/bin/bash")); - } - - #[cfg(unix)] - #[test] - fn rewrite_passwd_appends_when_no_sandbox_entry() { - let dir = tempfile::tempdir().unwrap(); - let passwd = dir.path().join("passwd"); - std::fs::write(&passwd, "root:x:0:0:root:/root:/bin/bash\n").unwrap(); - - rewrite_passwd_at(&passwd, "5000", "6000").unwrap(); - - let content = std::fs::read_to_string(&passwd).unwrap(); - assert!(content.contains("root:x:0:0:root:/root:/bin/bash")); - assert!(content.contains("sandbox:x:5000:6000::/sandbox:/bin/sh")); - } - - #[cfg(unix)] - #[test] - fn rewrite_group_modifies_existing_sandbox_entry() { - let dir = tempfile::tempdir().unwrap(); - let group = dir.path().join("group"); - std::fs::write(&group, "root:x:0:\nsandbox:x:1000:\n").unwrap(); - - rewrite_group_at(&group, "6000").unwrap(); - - let content = std::fs::read_to_string(&group).unwrap(); - assert!(content.contains("sandbox:x:6000:")); - assert!(content.contains("root:x:0:")); - } - - #[cfg(unix)] - #[test] - fn rewrite_group_appends_when_no_sandbox_entry() { - let dir = tempfile::tempdir().unwrap(); - let group = dir.path().join("group"); - std::fs::write(&group, "root:x:0:\n").unwrap(); - - rewrite_group_at(&group, "6000").unwrap(); - - let content = std::fs::read_to_string(&group).unwrap(); - assert!(content.contains("root:x:0:")); - assert!(content.contains("sandbox:x:6000:")); - } - - #[cfg(unix)] - #[test] - fn rewrite_passwd_leaves_malformed_entry_unchanged() { - let dir = tempfile::tempdir().unwrap(); - let passwd = dir.path().join("passwd"); - // Only 3 fields — slice pattern should fall through instead of panic. - std::fs::write(&passwd, "sandbox:x:1000\n").unwrap(); - rewrite_passwd_at(&passwd, "5000", "6000").unwrap(); - let content = std::fs::read_to_string(&passwd).unwrap(); - assert!(content.contains("sandbox:x:1000")); - } - - #[cfg(unix)] - #[test] - fn rewrite_group_leaves_malformed_entry_unchanged() { - let dir = tempfile::tempdir().unwrap(); - let group = dir.path().join("group"); - // Only 2 fields — slice pattern should fall through instead of panic. - std::fs::write(&group, "sandbox:x\n").unwrap(); - rewrite_group_at(&group, "6000").unwrap(); - let content = std::fs::read_to_string(&group).unwrap(); - assert!(content.contains("sandbox:x")); - } - - #[cfg(unix)] - #[test] - fn rewrite_passwd_preserves_other_entries() { - let dir = tempfile::tempdir().unwrap(); - let passwd = dir.path().join("passwd"); - std::fs::write( - &passwd, - "root:x:0:0:root:/root:/bin/bash\nnobody:x:65534:65534:nobody:/:/usr/sbin/nologin\nsandbox:x:1000:1000::/sandbox:/bin/bash\n", - ) - .unwrap(); - - rewrite_passwd_at(&passwd, "1234567", "1234567").unwrap(); - - let content = std::fs::read_to_string(&passwd).unwrap(); - assert!(content.contains("root:x:0:0:root:/root:/bin/bash")); - assert!(content.contains("nobody:x:65534:65534:nobody:/:/usr/sbin/nologin")); - assert!(content.contains("sandbox:x:1234567:1234567::/sandbox:/bin/bash")); - assert_eq!(content.lines().count(), 3); - } - #[tokio::test] async fn inject_provider_env_skips_supervisor_identity_material() { let mut cmd = Command::new("/usr/bin/env"); @@ -3789,122 +1602,4 @@ mod tests { } assert!(stdout.contains("PATH=/usr/bin:/bin")); } - - // ---- Numeric UID tests (Phase 2) ---- - - // Even a failing setuid(0) probe synchronizes libc credentials across all - // threads. Other tests own seccomp-notified launcher threads in this same - // process; signaling those while they await their broker can deadlock the - // parallel harness. Re-exec just the credential probe, without those threads. - fn numeric_uid_probe_runs_in_child(test_name: &str) -> bool { - const MARKER: &str = "OPENSHELL_TEST_ISOLATED_NUMERIC_UID_PROBE"; - if std::env::var(MARKER).as_deref() == Ok(test_name) { - return true; - } - let output = std::process::Command::new(std::env::current_exe().expect("test executable")) - .args(["--exact", test_name, "--test-threads=1", "--nocapture"]) - .env(MARKER, test_name) - .output() - .expect("run isolated credential probe"); - assert!( - output.status.success(), - "isolated credential probe failed: {}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - false - } - - #[test] - fn drop_privileges_accepts_numeric_uid() { - if !numeric_uid_probe_runs_in_child("process::tests::drop_privileges_accepts_numeric_uid") { - return; - } - // When running as non-root, a numeric UID/GID that matches the - // current process should succeed without any passwd lookup. - if nix::unistd::geteuid().is_root() { - return; - } - - let uid_raw = nix::unistd::geteuid().as_raw(); - let gid_raw = nix::unistd::getegid().as_raw(); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(uid_raw.to_string()), - run_as_group: Some(gid_raw.to_string()), - }); - - assert!( - drop_privileges(&policy).is_ok(), - "should accept current process UID/GID as numeric strings" - ); - } - - #[test] - fn drop_privileges_numeric_uid_skips_initgroups() { - if !numeric_uid_probe_runs_in_child( - "process::tests::drop_privileges_numeric_uid_skips_initgroups", - ) { - return; - } - // When running as non-root with a numeric user but group matches, - // initgroups should not be called (guard: target_uid != geteuid()). - if nix::unistd::geteuid().is_root() { - return; - } - - let current_uid = nix::unistd::geteuid().as_raw(); - - // Use a different group name that exists (the current one). - let current_group = Group::from_gid(nix::unistd::getegid()) - .expect("should resolve current group") - .expect("current group should exist"); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(current_uid.to_string()), // numeric UID, no passwd entry needed - run_as_group: Some(current_group.name), // name-based group - }); - - assert!( - drop_privileges(&policy).is_ok(), - "should accept numeric UID with name-based group (initgroups guarded)" - ); - } - - #[test] - fn numeric_uid_privilege_drop_child() { - if std::env::var_os("OPENSHELL_TEST_NUMERIC_UID_CHILD").is_none() { - return; - } - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("999999".into()), - run_as_group: Some("999999".into()), - }); - match drop_privileges(&policy) { - Ok(()) => {} - Err(e) => { - assert!( - !e.to_string().contains("Failed to resolve user record"), - "unexpected error for numeric UID without passwd entry: {e}" - ); - } - } - } - - #[test] - fn drop_privileges_numeric_uid_without_passwd_entry_skips_lookup() { - let mut cmd = std::process::Command::new(std::env::current_exe().expect("current exe")); - cmd.arg("numeric_uid_privilege_drop_child") - .arg("--nocapture") - .env("OPENSHELL_TEST_NUMERIC_UID_CHILD", "1") - .stdin(StdStdio::null()) - .stdout(StdStdio::piped()) - .stderr(StdStdio::piped()); - let output = cmd.output().expect("spawn child"); - assert!( - output.status.success(), - "numeric UID privilege drop child failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } } diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs index c18b633e75..486495c956 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs @@ -88,9 +88,10 @@ pub fn probe_availability() -> LandlockAvailability { /// A prepared Landlock ruleset ready to be enforced via `restrict_self()`. /// -/// Created by [`prepare`] while running as root (so `PathFd::new()` can open -/// any path regardless of DAC permissions). Enforced by [`enforce`] after -/// `drop_privileges()` — `restrict_self()` does not require elevated privileges. +/// Path FDs are opened before enforcement. The capability-free launch path +/// prepares the baseline and user rules as the workload identity, then calls +/// [`enforce`] in the child before exec. `restrict_self()` does not require +/// elevated privileges. pub struct PreparedRuleset { ruleset: landlock::RulesetCreated, compatibility: LandlockCompatibility, @@ -102,10 +103,11 @@ enum PathOpenMode { CurrentUser, } -/// Phase 1: Open `PathFds` and build the Landlock ruleset **as root**. +/// Phase 1: Open `PathFds` and build the Landlock ruleset with strict path opening. /// -/// This must run before `drop_privileges()` so that `PathFd::new()` can open -/// paths that are only accessible to root (e.g. mode 700 directories). +/// Opens configured paths as the calling identity. Inaccessible paths fail in +/// hard-requirement mode and are skipped in best-effort mode. Unlike +/// [`prepare_current_user`], this does not always omit inaccessible paths. /// /// Returns `None` if there are no filesystem paths to restrict (no-op). /// Returns `Some(PreparedRuleset)` on success, or an error. @@ -394,9 +396,9 @@ fn prepare_with_path_open_mode( /// Phase 2: Enforce a prepared Landlock ruleset by calling `restrict_self()`. /// -/// This runs **after** `drop_privileges()`. The `restrict_self()` syscall does -/// not require root — it only restricts the calling thread (and its future -/// children), which is always permitted. +/// The capability-free launch path calls this in the child before exec, already +/// running as the workload identity. `restrict_self()` does not require root; +/// it restricts the calling thread and its future children. /// /// Respects the same `best_effort` / `hard_requirement` compatibility as /// [`prepare`]: if `restrict_self()` fails and the policy is `best_effort`, diff --git a/crates/openshell-sandbox/src/sandbox/linux/mod.rs b/crates/openshell-sandbox/src/sandbox/linux/mod.rs index 3f0084450e..acfd46edc5 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/mod.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/mod.rs @@ -18,10 +18,11 @@ pub struct PreparedSandbox { policy: SandboxPolicy, } -/// Phase 1: Prepare sandbox restrictions **as root** (before `drop_privileges`). +/// Phase 1: Prepare sandbox restrictions with strict path opening. /// -/// Opens Landlock `PathFds` while the process still has root privileges, -/// ensuring paths like mode-700 directories are accessible. +/// Opens configured paths as the calling identity and handles failures according +/// to the policy's Landlock compatibility mode. +/// The capability-free launch path uses [`prepare_capability_free`] instead. pub fn prepare(policy: &SandboxPolicy, workdir: Option<&str>) -> Result { let landlock = landlock::prepare(policy, workdir)?; Ok(PreparedSandbox { @@ -66,7 +67,7 @@ pub fn prepare_capability_free( }) } -/// Phase 2: Enforce prepared sandbox restrictions (after `drop_privileges`). +/// Phase 2: Enforce prepared sandbox restrictions in the child before exec. /// /// Calls `restrict_self()` for Landlock and applies seccomp filters. /// Neither operation requires root privileges.