mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-31 07:44:57 +08:00
3392 lines
115 KiB
Rust
3392 lines
115 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Process management and signal handling.
|
|
|
|
use crate::child_env;
|
|
#[cfg(target_os = "linux")]
|
|
use crate::managed_children;
|
|
#[cfg(target_os = "linux")]
|
|
use crate::netns::NetworkNamespace;
|
|
use crate::sandbox;
|
|
use miette::{IntoDiagnostic, Result};
|
|
use nix::sys::signal::{self, Signal};
|
|
use nix::unistd::{Gid, Group, Pid, Uid, User};
|
|
use openshell_core::policy::{NetworkMode, SandboxPolicy};
|
|
use std::collections::HashMap;
|
|
use std::ffi::CString;
|
|
#[cfg(target_os = "linux")]
|
|
use std::os::fd::{AsRawFd, OwnedFd, RawFd};
|
|
#[cfg(target_os = "linux")]
|
|
use std::os::unix::ffi::OsStrExt;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
|
#[cfg(any(test, unix))]
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::process::Stdio;
|
|
#[cfg(target_os = "linux")]
|
|
use std::sync::OnceLock;
|
|
use tokio::process::{Child, Command};
|
|
use tracing::{debug, info};
|
|
|
|
/// Process/filesystem enforcement performed by the process supervisor.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ProcessEnforcementMode {
|
|
/// Preserve the existing supervisor behavior: prepare filesystem policy,
|
|
/// drop privileges, and apply Landlock/seccomp to workload processes.
|
|
Full,
|
|
/// Preserve process launch and SSH/session behavior, but skip controls
|
|
/// that require root or extra Linux capabilities. Kubernetes sidecar mode
|
|
/// uses this when network policy is enforced by the network sidecar.
|
|
NetworkOnly,
|
|
}
|
|
|
|
/// 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<u32>,
|
|
gid: Option<u32>,
|
|
}
|
|
|
|
impl ResolvedProcessIdentity {
|
|
#[must_use]
|
|
pub const fn new(uid: Option<u32>, gid: Option<u32>) -> Self {
|
|
Self { uid, gid }
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn uid(self) -> Option<u32> {
|
|
self.uid
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn gid(self) -> Option<u32> {
|
|
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 {
|
|
root: Option<String>,
|
|
use_as_home: bool,
|
|
}
|
|
|
|
impl ResolvedWorkspace {
|
|
#[must_use]
|
|
pub fn new(root: Option<String>, use_as_home: bool) -> Self {
|
|
Self { root, use_as_home }
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn root(&self) -> Option<&str> {
|
|
self.root.as_deref()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn owned_root(&self) -> Option<String> {
|
|
self.root.clone()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn home(&self) -> Option<&str> {
|
|
self.use_as_home.then(|| self.root()).flatten()
|
|
}
|
|
}
|
|
|
|
impl ProcessEnforcementMode {
|
|
#[must_use]
|
|
pub const fn uses_privileged_process_setup(self) -> bool {
|
|
matches!(self, Self::Full)
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn enforces_child_sandbox(self) -> bool {
|
|
matches!(self, Self::Full | Self::NetworkOnly)
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
pub(crate) fn prepare_child_sandbox(
|
|
policy: &SandboxPolicy,
|
|
workdir: Option<&str>,
|
|
enforcement_mode: ProcessEnforcementMode,
|
|
) -> Result<Option<sandbox::linux::PreparedSandbox>> {
|
|
if !enforcement_mode.enforces_child_sandbox() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let prepared = if enforcement_mode.uses_privileged_process_setup() {
|
|
sandbox::linux::prepare(policy, workdir)
|
|
} else {
|
|
sandbox::linux::prepare_current_user(policy, workdir)
|
|
}?;
|
|
Ok(Some(prepared))
|
|
}
|
|
|
|
const SUPERVISOR_ONLY_ENV_VARS: &[&str] = &[
|
|
openshell_core::sandbox_env::OCI_IMAGE_USER,
|
|
openshell_core::sandbox_env::SANDBOX_UID,
|
|
openshell_core::sandbox_env::SANDBOX_GID,
|
|
openshell_core::sandbox_env::SANDBOX_TOKEN,
|
|
openshell_core::sandbox_env::SANDBOX_TOKEN_FILE,
|
|
openshell_core::sandbox_env::K8S_SA_TOKEN_FILE,
|
|
openshell_core::sandbox_env::TLS_CA,
|
|
openshell_core::sandbox_env::TLS_CERT,
|
|
openshell_core::sandbox_env::TLS_KEY,
|
|
openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET,
|
|
];
|
|
|
|
pub fn is_supervisor_only_env_var(key: &str) -> bool {
|
|
SUPERVISOR_ONLY_ENV_VARS.contains(&key)
|
|
}
|
|
|
|
fn strip_supervisor_only_env(cmd: &mut Command) {
|
|
for key in SUPERVISOR_ONLY_ENV_VARS {
|
|
cmd.env_remove(key);
|
|
}
|
|
}
|
|
|
|
fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap<String, String>) {
|
|
for (key, value) in provider_env {
|
|
if is_supervisor_only_env_var(key) {
|
|
continue;
|
|
}
|
|
cmd.env(key, value);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
pub fn harden_child_process() -> Result<()> {
|
|
use rustix::process::{Resource, Rlimit, setrlimit};
|
|
|
|
setrlimit(
|
|
Resource::Core,
|
|
Rlimit {
|
|
current: Some(0),
|
|
maximum: Some(0),
|
|
},
|
|
)
|
|
.map_err(|e| miette::miette!("Failed to disable core dumps: {e}"))?;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
use rustix::process::{DumpableBehavior, set_dumpable_behavior};
|
|
set_dumpable_behavior(DumpableBehavior::NotDumpable)
|
|
.map_err(|e| miette::miette!("Failed to set PR_SET_DUMPABLE=0: {e}"))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
const CGROUP_PIDS_MAX_PATH: &str = "/sys/fs/cgroup/pids.max";
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum RuntimePidLimitStatus {
|
|
Limited(u64),
|
|
Unlimited,
|
|
Unavailable(String),
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RuntimePidLimitMode {
|
|
Warn,
|
|
Require,
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
pub fn check_runtime_pid_limit(mode: RuntimePidLimitMode) -> Result<()> {
|
|
check_runtime_pid_limit_status(runtime_pid_limit_status(), mode)
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn check_runtime_pid_limit_status(
|
|
status: RuntimePidLimitStatus,
|
|
mode: RuntimePidLimitMode,
|
|
) -> Result<()> {
|
|
match status {
|
|
RuntimePidLimitStatus::Limited(limit) => {
|
|
debug!(pids_max = limit, "runtime PID limit detected");
|
|
Ok(())
|
|
}
|
|
RuntimePidLimitStatus::Unlimited => {
|
|
let message = "runtime cgroup pids.max is unlimited; configure the compute driver or container runtime to enforce a PID limit";
|
|
if matches!(mode, RuntimePidLimitMode::Require) {
|
|
Err(miette::miette!(message))
|
|
} else {
|
|
tracing::warn!("{message}");
|
|
Ok(())
|
|
}
|
|
}
|
|
RuntimePidLimitStatus::Unavailable(reason) => {
|
|
let message = format!(
|
|
"runtime cgroup pids.max is unavailable ({reason}); configure the compute driver or container runtime to enforce a PID limit"
|
|
);
|
|
if matches!(mode, RuntimePidLimitMode::Require) {
|
|
Err(miette::miette!(message))
|
|
} else {
|
|
tracing::warn!("{message}");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn runtime_pid_limit_status() -> RuntimePidLimitStatus {
|
|
match std::fs::read_to_string(CGROUP_PIDS_MAX_PATH) {
|
|
Ok(contents) => parse_pids_max(&contents),
|
|
Err(err) => RuntimePidLimitStatus::Unavailable(err.to_string()),
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn parse_pids_max(contents: &str) -> RuntimePidLimitStatus {
|
|
let raw = contents.trim();
|
|
if raw.eq_ignore_ascii_case("max") {
|
|
return RuntimePidLimitStatus::Unlimited;
|
|
}
|
|
match raw.parse::<u64>() {
|
|
Ok(limit) => RuntimePidLimitStatus::Limited(limit),
|
|
Err(err) => {
|
|
RuntimePidLimitStatus::Unavailable(format!("invalid pids.max value {raw:?}: {err}"))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
// Pins the pre-seccomp child mount namespace where supervisor identity sockets
|
|
// are shadowed. Children enter it with setns before dropping privileges.
|
|
#[cfg(target_os = "linux")]
|
|
static SUPERVISOR_IDENTITY_MOUNT_NS: OnceLock<Option<SupervisorIdentityMountNamespace>> =
|
|
OnceLock::new();
|
|
|
|
#[cfg(target_os = "linux")]
|
|
pub struct SupervisorIdentityMountNamespace {
|
|
fd: OwnedFd,
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
type SupervisorIdentityNsRef = &'static SupervisorIdentityMountNamespace;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
impl SupervisorIdentityMountNamespace {
|
|
fn from_socket_path(socket_path: &str) -> Result<Option<Self>> {
|
|
let Some(target) = supervisor_identity_mount_target(socket_path)? else {
|
|
return Ok(None);
|
|
};
|
|
Ok(Some(Self {
|
|
fd: create_supervisor_identity_mount_namespace(&target)?,
|
|
}))
|
|
}
|
|
|
|
pub fn enter_for_child(&self) -> std::io::Result<()> {
|
|
set_mount_namespace(self.fd.as_raw_fd())
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
pub fn prepare_supervisor_identity_mount_namespace_from_env() -> Result<()> {
|
|
if SUPERVISOR_IDENTITY_MOUNT_NS.get().is_some() {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some((_env_name, socket_path)) = supervisor_identity_socket_path_from_env() else {
|
|
let _ = SUPERVISOR_IDENTITY_MOUNT_NS.set(None);
|
|
return Ok(());
|
|
};
|
|
let namespace = SupervisorIdentityMountNamespace::from_socket_path(&socket_path)?;
|
|
let _ = SUPERVISOR_IDENTITY_MOUNT_NS.set(namespace);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
pub fn supervisor_identity_mount_from_env() -> Result<Option<SupervisorIdentityNsRef>> {
|
|
let Some(namespace) = SUPERVISOR_IDENTITY_MOUNT_NS.get() else {
|
|
if supervisor_identity_socket_path_from_env().is_some() {
|
|
return Err(miette::miette!(
|
|
"supervisor identity mount namespace was not prepared before startup hardening"
|
|
));
|
|
}
|
|
return Ok(None);
|
|
};
|
|
Ok(namespace.as_ref())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn supervisor_identity_socket_path_from_env() -> Option<(&'static str, String)> {
|
|
std::env::var(openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET)
|
|
.ok()
|
|
.filter(|socket_path| !socket_path.trim().is_empty())
|
|
.map(|socket_path| {
|
|
(
|
|
openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET,
|
|
socket_path,
|
|
)
|
|
})
|
|
}
|
|
|
|
#[cfg(any(test, target_os = "linux"))]
|
|
fn supervisor_identity_mount_target(socket_path: &str) -> Result<Option<PathBuf>> {
|
|
let trimmed = socket_path.trim();
|
|
if trimmed.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
if trimmed.starts_with("tcp:") {
|
|
return Err(miette::miette!(
|
|
"{} must be a UNIX socket path so sandbox child processes can hide it",
|
|
openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET
|
|
));
|
|
}
|
|
let path = trimmed.strip_prefix("unix:").unwrap_or(trimmed);
|
|
let path = Path::new(path);
|
|
if !path.is_absolute() {
|
|
return Err(miette::miette!(
|
|
"{} must be an absolute UNIX socket path",
|
|
openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET
|
|
));
|
|
}
|
|
let Some(parent) = path.parent() else {
|
|
return Err(miette::miette!(
|
|
"{} has no parent directory",
|
|
openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET
|
|
));
|
|
};
|
|
if parent == Path::new("/") {
|
|
return Err(miette::miette!(
|
|
"{} must live below a dedicated directory, not directly under /",
|
|
openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET
|
|
));
|
|
}
|
|
if is_shared_root_mount_shadow(parent) {
|
|
return Err(miette::miette!(
|
|
"{} must live below a dedicated subdirectory; refusing to hide shared directory {}",
|
|
openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET,
|
|
parent.display()
|
|
));
|
|
}
|
|
Ok(Some(parent.to_path_buf()))
|
|
}
|
|
|
|
#[cfg(any(test, target_os = "linux"))]
|
|
fn is_shared_root_mount_shadow(parent: &Path) -> bool {
|
|
matches!(parent.to_str(), Some("/run" | "/var" | "/tmp" | "/etc"))
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn cstring_path(path: &Path) -> Result<CString> {
|
|
CString::new(path.as_os_str().as_bytes())
|
|
.map_err(|_| miette::miette!("path contains an interior NUL byte: {}", path.display()))
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn create_supervisor_identity_mount_namespace(target: &Path) -> Result<OwnedFd> {
|
|
let original_ns = open_current_mount_namespace()
|
|
.map_err(|err| miette::miette!("failed to open original mount namespace: {err}"))?;
|
|
|
|
private_mount_namespace()
|
|
.map_err(|err| miette::miette!("failed to create supervisor identity namespace: {err}"))?;
|
|
|
|
let target = cstring_path(target)?;
|
|
let result = (|| -> Result<OwnedFd> {
|
|
mount_empty_tmpfs(&target).map_err(|err| {
|
|
miette::miette!("failed to hide supervisor identity mount from child namespace: {err}")
|
|
})?;
|
|
open_current_mount_namespace()
|
|
.map_err(|err| miette::miette!("failed to open sanitized mount namespace: {err}"))
|
|
})();
|
|
|
|
set_mount_namespace(original_ns.as_raw_fd()).map_err(|restore_err| {
|
|
let result_msg = result.as_ref().err().map_or_else(
|
|
|| "sanitized namespace was created".to_string(),
|
|
ToString::to_string,
|
|
);
|
|
miette::miette!(
|
|
"failed to restore original mount namespace after supervisor identity isolation setup: \
|
|
{restore_err}; setup result: {result_msg}"
|
|
)
|
|
})?;
|
|
|
|
result
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn open_current_mount_namespace() -> std::io::Result<OwnedFd> {
|
|
let file = std::fs::File::open("/proc/thread-self/ns/mnt")?;
|
|
Ok(file.into())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn private_mount_namespace() -> std::io::Result<()> {
|
|
#[allow(unsafe_code)]
|
|
let rc = unsafe { libc::unshare(libc::CLONE_NEWNS) };
|
|
if rc != 0 {
|
|
return Err(std::io::Error::other(format!(
|
|
"failed to create private mount namespace: {}",
|
|
std::io::Error::last_os_error()
|
|
)));
|
|
}
|
|
|
|
#[allow(unsafe_code)]
|
|
let rc = unsafe {
|
|
let flags: libc::c_ulong = libc::MS_REC | libc::MS_PRIVATE;
|
|
libc::mount(
|
|
std::ptr::null(),
|
|
c"/".as_ptr(),
|
|
std::ptr::null(),
|
|
flags,
|
|
std::ptr::null(),
|
|
)
|
|
};
|
|
if rc != 0 {
|
|
return Err(std::io::Error::other(format!(
|
|
"failed to mark mount namespace private: {}",
|
|
std::io::Error::last_os_error()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn set_mount_namespace(fd: RawFd) -> std::io::Result<()> {
|
|
#[allow(unsafe_code)]
|
|
let rc = unsafe { libc::setns(fd, libc::CLONE_NEWNS) };
|
|
if rc != 0 {
|
|
return Err(std::io::Error::other(format!(
|
|
"failed to enter mount namespace: {}",
|
|
std::io::Error::last_os_error()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn mount_empty_tmpfs(target: &CString) -> std::io::Result<()> {
|
|
#[allow(unsafe_code)]
|
|
let rc = unsafe {
|
|
let flags: libc::c_ulong =
|
|
libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC | libc::MS_RDONLY;
|
|
libc::mount(
|
|
c"tmpfs".as_ptr(),
|
|
target.as_ptr(),
|
|
c"tmpfs".as_ptr(),
|
|
flags,
|
|
c"mode=0555,size=4k".as_ptr().cast(),
|
|
)
|
|
};
|
|
if rc != 0 {
|
|
return Err(std::io::Error::other(format!(
|
|
"failed to hide supervisor identity mount from child process: {}",
|
|
std::io::Error::last_os_error()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle to a running process.
|
|
pub struct ProcessHandle {
|
|
child: Child,
|
|
pid: u32,
|
|
}
|
|
|
|
impl ProcessHandle {
|
|
/// Spawn a new process.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the process fails to start.
|
|
#[cfg(target_os = "linux")]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn spawn(
|
|
program: &str,
|
|
args: &[String],
|
|
workspace: &ResolvedWorkspace,
|
|
interactive: bool,
|
|
policy: &SandboxPolicy,
|
|
resolved_identity: ResolvedProcessIdentity,
|
|
enforcement_mode: ProcessEnforcementMode,
|
|
netns: Option<&NetworkNamespace>,
|
|
ca_paths: Option<&(PathBuf, PathBuf)>,
|
|
provider_env: &HashMap<String, String>,
|
|
) -> Result<Self> {
|
|
Self::spawn_impl(
|
|
program,
|
|
args,
|
|
workspace,
|
|
interactive,
|
|
policy,
|
|
resolved_identity,
|
|
enforcement_mode,
|
|
netns.and_then(NetworkNamespace::ns_fd),
|
|
ca_paths,
|
|
provider_env,
|
|
)
|
|
}
|
|
|
|
/// Spawn a new process (non-Linux platforms).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the process fails to start.
|
|
#[cfg(not(target_os = "linux"))]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn spawn(
|
|
program: &str,
|
|
args: &[String],
|
|
workspace: &ResolvedWorkspace,
|
|
interactive: bool,
|
|
policy: &SandboxPolicy,
|
|
resolved_identity: ResolvedProcessIdentity,
|
|
enforcement_mode: ProcessEnforcementMode,
|
|
ca_paths: Option<&(PathBuf, PathBuf)>,
|
|
provider_env: &HashMap<String, String>,
|
|
) -> Result<Self> {
|
|
Self::spawn_impl(
|
|
program,
|
|
args,
|
|
workspace,
|
|
interactive,
|
|
policy,
|
|
resolved_identity,
|
|
enforcement_mode,
|
|
ca_paths,
|
|
provider_env,
|
|
)
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn spawn_impl(
|
|
program: &str,
|
|
args: &[String],
|
|
workspace: &ResolvedWorkspace,
|
|
interactive: bool,
|
|
policy: &SandboxPolicy,
|
|
resolved_identity: ResolvedProcessIdentity,
|
|
enforcement_mode: ProcessEnforcementMode,
|
|
netns_fd: Option<RawFd>,
|
|
ca_paths: Option<&(PathBuf, PathBuf)>,
|
|
provider_env: &HashMap<String, String>,
|
|
) -> Result<Self> {
|
|
let mut cmd = Command::new(program);
|
|
cmd.args(args)
|
|
.stdin(Stdio::inherit())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.kill_on_drop(true)
|
|
.env(openshell_core::sandbox_env::SANDBOX, "1");
|
|
|
|
// Strip supervisor-only identity material from the entrypoint's
|
|
// inherited environment. The entrypoint drops to the sandbox user
|
|
// before `exec`; without this strip, sandbox code could recover
|
|
// supervisor credentials from its inherited environment.
|
|
strip_supervisor_only_env(&mut cmd);
|
|
|
|
inject_provider_env(&mut cmd, provider_env);
|
|
|
|
if let Some(dir) = workspace.root() {
|
|
cmd.current_dir(dir);
|
|
}
|
|
if let Some(home) = workspace.home() {
|
|
cmd.env("HOME", home);
|
|
}
|
|
|
|
if matches!(policy.network.mode, NetworkMode::Proxy) {
|
|
let proxy = policy.network.proxy.as_ref().ok_or_else(|| {
|
|
miette::miette!(
|
|
"Network mode is set to proxy but no proxy configuration was provided"
|
|
)
|
|
})?;
|
|
// When using network namespace, set proxy URL to the veth host IP
|
|
if netns_fd.is_some() {
|
|
// The proxy is on 10.200.0.1:3128 (or configured port)
|
|
let port = proxy.http_addr.map_or(3128, |addr| addr.port());
|
|
let proxy_url = format!("http://10.200.0.1:{port}");
|
|
// Both uppercase and lowercase variants: curl/wget use uppercase,
|
|
// gRPC C-core (libgrpc) checks lowercase http_proxy/https_proxy.
|
|
for (key, value) in child_env::proxy_env_vars(&proxy_url) {
|
|
cmd.env(key, value);
|
|
}
|
|
} else if let Some(http_addr) = proxy.http_addr {
|
|
let proxy_url = format!("http://{http_addr}");
|
|
for (key, value) in child_env::proxy_env_vars(&proxy_url) {
|
|
cmd.env(key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set TLS trust store env vars so sandbox processes trust the ephemeral CA
|
|
if let Some((ca_cert_path, combined_bundle_path)) = ca_paths {
|
|
for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) {
|
|
cmd.env(key, value);
|
|
}
|
|
}
|
|
|
|
// Probe Landlock availability and emit OCSF logs from the parent
|
|
// process where the tracing subscriber is functional. The child's
|
|
// pre_exec context cannot reliably emit structured logs.
|
|
#[cfg(target_os = "linux")]
|
|
if enforcement_mode.enforces_child_sandbox() {
|
|
sandbox::linux::log_sandbox_readiness(policy, workspace.root());
|
|
}
|
|
|
|
// Phase 1: Prepare Landlock ruleset by opening PathFds.
|
|
// In full mode this runs before drop_privileges() so root-only paths
|
|
// can be opened. In sidecar network-only mode the container already
|
|
// runs as the sandbox UID, so inaccessible paths are unavailable to
|
|
// the workload and best-effort compatibility skips them.
|
|
#[cfg(target_os = "linux")]
|
|
let prepared_sandbox = prepare_child_sandbox(policy, workspace.root(), enforcement_mode)
|
|
.map_err(|err| miette::miette!("Failed to prepare sandbox: {err}"))?;
|
|
#[cfg(target_os = "linux")]
|
|
let supervisor_identity_mount = if enforcement_mode.uses_privileged_process_setup() {
|
|
supervisor_identity_mount_from_env().map_err(|err| {
|
|
miette::miette!("Failed to prepare supervisor identity isolation: {err}")
|
|
})?
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Set up process group for signal handling (non-interactive mode only).
|
|
// In interactive mode, we inherit the parent's process group to maintain
|
|
// proper terminal control for shells and interactive programs.
|
|
// SAFETY: pre_exec runs after fork but before exec in the child process.
|
|
// setpgid and setns are async-signal-safe and safe to call in this context.
|
|
{
|
|
let policy = policy.clone();
|
|
// Wrap in Option so we can .take() it out of the FnMut closure.
|
|
// pre_exec is only called once (after fork, before exec).
|
|
#[cfg(target_os = "linux")]
|
|
let mut prepared_sandbox = prepared_sandbox;
|
|
#[allow(unsafe_code)]
|
|
unsafe {
|
|
cmd.pre_exec(move || {
|
|
if !interactive {
|
|
// Create new process group
|
|
libc::setpgid(0, 0);
|
|
}
|
|
|
|
// Enter network namespace before applying other restrictions
|
|
if let Some(fd) = netns_fd {
|
|
let result = libc::setns(fd, libc::CLONE_NEWNET);
|
|
if result != 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
if let Some(mount) = supervisor_identity_mount {
|
|
mount.enter_for_child()?;
|
|
}
|
|
|
|
// Drop privileges. initgroups/setgid/setuid need access to
|
|
// /etc/group and /etc/passwd which would be blocked if
|
|
// Landlock were already enforced.
|
|
if enforcement_mode.uses_privileged_process_setup() {
|
|
drop_privileges_with_identity(&policy, resolved_identity)
|
|
.map_err(|err| std::io::Error::other(err.to_string()))?;
|
|
}
|
|
|
|
harden_child_process().map_err(|err| std::io::Error::other(err.to_string()))?;
|
|
|
|
// Phase 2 (as unprivileged user): Enforce the prepared
|
|
// Landlock ruleset via restrict_self() + apply seccomp.
|
|
// restrict_self() does not require root.
|
|
#[cfg(target_os = "linux")]
|
|
if let Some(prepared) = prepared_sandbox.take() {
|
|
sandbox::linux::enforce(prepared)
|
|
.map_err(|err| std::io::Error::other(err.to_string()))?;
|
|
}
|
|
|
|
Ok(())
|
|
});
|
|
}
|
|
}
|
|
|
|
let child = cmd.spawn().into_diagnostic()?;
|
|
let pid = child.id().unwrap_or(0);
|
|
managed_children::register(pid);
|
|
|
|
debug!(pid, program, "Process spawned");
|
|
|
|
Ok(Self { child, pid })
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn spawn_impl(
|
|
program: &str,
|
|
args: &[String],
|
|
workspace: &ResolvedWorkspace,
|
|
interactive: bool,
|
|
policy: &SandboxPolicy,
|
|
resolved_identity: ResolvedProcessIdentity,
|
|
enforcement_mode: ProcessEnforcementMode,
|
|
ca_paths: Option<&(PathBuf, PathBuf)>,
|
|
provider_env: &HashMap<String, String>,
|
|
) -> Result<Self> {
|
|
let mut cmd = Command::new(program);
|
|
cmd.args(args)
|
|
.stdin(Stdio::inherit())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.kill_on_drop(true)
|
|
.env(openshell_core::sandbox_env::SANDBOX, "1");
|
|
|
|
// Strip supervisor-only identity material from the entrypoint's
|
|
// inherited environment.
|
|
strip_supervisor_only_env(&mut cmd);
|
|
|
|
inject_provider_env(&mut cmd, provider_env);
|
|
|
|
if let Some(dir) = workspace.root() {
|
|
cmd.current_dir(dir);
|
|
}
|
|
if let Some(home) = workspace.home() {
|
|
cmd.env("HOME", home);
|
|
}
|
|
|
|
if matches!(policy.network.mode, NetworkMode::Proxy) {
|
|
let proxy = policy.network.proxy.as_ref().ok_or_else(|| {
|
|
miette::miette!(
|
|
"Network mode is set to proxy but no proxy configuration was provided"
|
|
)
|
|
})?;
|
|
if let Some(http_addr) = proxy.http_addr {
|
|
let proxy_url = format!("http://{http_addr}");
|
|
for (key, value) in child_env::proxy_env_vars(&proxy_url) {
|
|
cmd.env(key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set TLS trust store env vars so sandbox processes trust the ephemeral CA
|
|
if let Some((ca_cert_path, combined_bundle_path)) = ca_paths {
|
|
for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) {
|
|
cmd.env(key, value);
|
|
}
|
|
}
|
|
|
|
// Set up process group for signal handling (non-interactive mode only).
|
|
// In interactive mode, we inherit the parent's process group to maintain
|
|
// proper terminal control for shells and interactive programs.
|
|
// SAFETY: pre_exec runs after fork but before exec in the child process.
|
|
// setpgid is async-signal-safe and safe to call in this context.
|
|
#[cfg(unix)]
|
|
{
|
|
let policy = policy.clone();
|
|
let workdir = workspace.owned_root();
|
|
#[allow(unsafe_code)]
|
|
unsafe {
|
|
cmd.pre_exec(move || {
|
|
if !interactive {
|
|
// Create new process group
|
|
libc::setpgid(0, 0);
|
|
}
|
|
|
|
// Drop privileges before applying sandbox restrictions.
|
|
// initgroups/setgid/setuid need access to /etc/group and /etc/passwd
|
|
// which may be blocked by Landlock.
|
|
if enforcement_mode.uses_privileged_process_setup() {
|
|
drop_privileges_with_identity(&policy, resolved_identity)
|
|
.map_err(|err| std::io::Error::other(err.to_string()))?;
|
|
}
|
|
|
|
harden_child_process().map_err(|err| std::io::Error::other(err.to_string()))?;
|
|
|
|
if enforcement_mode.enforces_child_sandbox() {
|
|
sandbox::apply(&policy, workdir.as_deref())
|
|
.map_err(|err| std::io::Error::other(err.to_string()))?;
|
|
}
|
|
|
|
Ok(())
|
|
});
|
|
}
|
|
}
|
|
|
|
let child = cmd.spawn().into_diagnostic()?;
|
|
let pid = child.id().unwrap_or(0);
|
|
#[cfg(target_os = "linux")]
|
|
managed_children::register(pid);
|
|
|
|
debug!(pid, program, "Process spawned");
|
|
|
|
Ok(Self { child, pid })
|
|
}
|
|
|
|
/// Get the process ID.
|
|
#[must_use]
|
|
pub const fn pid(&self) -> u32 {
|
|
self.pid
|
|
}
|
|
|
|
/// Wait for the process to exit.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if waiting fails.
|
|
pub async fn wait(&mut self) -> std::io::Result<ProcessStatus> {
|
|
let status = self.child.wait().await;
|
|
#[cfg(target_os = "linux")]
|
|
managed_children::unregister(self.pid);
|
|
let status = status?;
|
|
Ok(ProcessStatus::from(status))
|
|
}
|
|
|
|
/// Send a signal to the process.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the signal cannot be sent.
|
|
pub fn signal(&self, sig: Signal) -> Result<()> {
|
|
let pid = i32::try_from(self.pid).unwrap_or(i32::MAX);
|
|
signal::kill(Pid::from_raw(pid), sig).into_diagnostic()
|
|
}
|
|
|
|
/// Kill the process.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the process cannot be killed.
|
|
pub fn kill(&mut self) -> Result<()> {
|
|
// First try SIGTERM
|
|
if let Err(e) = self.signal(Signal::SIGTERM) {
|
|
openshell_ocsf::ocsf_emit!(
|
|
openshell_ocsf::ProcessActivityBuilder::new(openshell_ocsf::ctx::ctx())
|
|
.activity(openshell_ocsf::ActivityId::Close)
|
|
.severity(openshell_ocsf::SeverityId::Medium)
|
|
.status(openshell_ocsf::StatusId::Failure)
|
|
.message(format!("Failed to send SIGTERM: {e}"))
|
|
.build()
|
|
);
|
|
}
|
|
|
|
// Give the process a moment to terminate gracefully
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
|
|
// Force kill if still running
|
|
if let Some(id) = self.child.id() {
|
|
debug!(pid = id, "Sending SIGKILL");
|
|
let pid = i32::try_from(id).unwrap_or(i32::MAX);
|
|
let _ = signal::kill(Pid::from_raw(pid), Signal::SIGKILL);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for ProcessHandle {
|
|
fn drop(&mut self) {
|
|
#[cfg(target_os = "linux")]
|
|
managed_children::unregister(self.pid);
|
|
}
|
|
}
|
|
|
|
/// 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::<u32>() {
|
|
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::<u32>() {
|
|
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<bool> {
|
|
// 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<String> = 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<String> = 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<Uid>, gid: Option<Gid>) -> 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<Uid>,
|
|
gid: Option<Gid>,
|
|
supplementary_gids: &[Gid],
|
|
) -> Result<()> {
|
|
prepare_oci_workspace_with(root, uid, gid, supplementary_gids, &nix::unistd::chown)
|
|
}
|
|
|
|
/// Validate an OCI workspace's visible structure without testing identity
|
|
/// permissions. Local container supervisors run this before loading policy,
|
|
/// credentials, or networking state.
|
|
#[cfg(target_os = "linux")]
|
|
pub fn validate_oci_workspace_structure(root: &Path) -> Result<()> {
|
|
use rustix::fs::{AtFlags, FileType, Mode, OFlags};
|
|
|
|
let components = validated_workspace_components(root, false)?;
|
|
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()?;
|
|
|
|
for component in components {
|
|
current_path.push(&component);
|
|
let stat = rustix::fs::statat(¤t_fd, &component, AtFlags::SYMLINK_NOFOLLOW).map_err(
|
|
|error| {
|
|
if error == rustix::io::Errno::NOENT {
|
|
miette::miette!(
|
|
"image workspace path component '{}' does not exist",
|
|
current_path.display()
|
|
)
|
|
} else {
|
|
miette::miette!(
|
|
"failed to inspect image workspace path component '{}': {error}",
|
|
current_path.display()
|
|
)
|
|
}
|
|
},
|
|
)?;
|
|
let file_type = FileType::from_raw_mode(stat.st_mode);
|
|
if file_type.is_symlink() {
|
|
return Err(miette::miette!(
|
|
"workspace path component '{}' is a symlink — refusing to follow it",
|
|
current_path.display()
|
|
));
|
|
}
|
|
if !file_type.is_dir() {
|
|
return Err(miette::miette!(
|
|
"workspace path component '{}' is not a directory",
|
|
current_path.display()
|
|
));
|
|
}
|
|
let next_fd = rustix::fs::openat(¤t_fd, &component, open_flags, Mode::empty())
|
|
.map_err(|error| {
|
|
miette::miette!(
|
|
"failed to open image workspace path component '{}': {error}",
|
|
current_path.display()
|
|
)
|
|
})?;
|
|
current_fd = next_fd;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
pub fn validate_oci_workspace_structure(root: &Path) -> Result<()> {
|
|
let components = validated_workspace_components(root, false)?;
|
|
let mut current = PathBuf::from("/");
|
|
for component in components {
|
|
current.push(component);
|
|
let metadata = std::fs::symlink_metadata(¤t).map_err(|error| {
|
|
if error.kind() == std::io::ErrorKind::NotFound {
|
|
miette::miette!(
|
|
"image workspace path component '{}' does not exist",
|
|
current.display()
|
|
)
|
|
} else {
|
|
miette::miette!(
|
|
"failed to inspect image workspace path component '{}': {error}",
|
|
current.display()
|
|
)
|
|
}
|
|
})?;
|
|
if metadata.file_type().is_symlink() {
|
|
return Err(miette::miette!(
|
|
"workspace path component '{}' is a symlink — refusing to follow it",
|
|
current.display()
|
|
));
|
|
}
|
|
if !metadata.is_dir() {
|
|
return Err(miette::miette!(
|
|
"workspace path component '{}' is not a directory",
|
|
current.display()
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 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<Uid>,
|
|
gid: Option<Gid>,
|
|
supplementary_gids: &[Gid],
|
|
do_chown: &impl Fn(&Path, Option<Uid>, Option<Gid>) -> 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<Vec<std::ffi::OsString>> {
|
|
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 "
|
|
}
|
|
));
|
|
}
|
|
|
|
root.components()
|
|
.skip(1)
|
|
.map(|component| match component {
|
|
std::path::Component::Normal(component) => Ok(component.to_os_string()),
|
|
_ => Err(miette::miette!(
|
|
"workspace path '{}' must be normalized",
|
|
root.display()
|
|
)),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn identity_can_traverse(
|
|
metadata: &std::fs::Metadata,
|
|
uid: Option<Uid>,
|
|
gid: Option<Gid>,
|
|
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<Uid>,
|
|
gid: Option<Gid>,
|
|
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<Vec<Gid>> {
|
|
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<Vec<Gid>> {
|
|
// Privilege dropping does not call initgroups on these targets.
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn chown_children(
|
|
dir: &Path,
|
|
uid: Option<Uid>,
|
|
gid: Option<Gid>,
|
|
do_chown: &impl Fn(&Path, Option<Uid>, Option<Gid>) -> 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<Uid>,
|
|
gid: Option<Gid>,
|
|
do_chown: &impl Fn(&Path, Option<Uid>, Option<Gid>) -> 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)?;
|
|
|
|
// Local OCI drivers structurally validate image-derived workdirs during
|
|
// supervisor startup. Only the /sandbox compatibility fallback is managed
|
|
// here; image authors own permissions on every other OCI workdir.
|
|
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)?;
|
|
}
|
|
}
|
|
|
|
// 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 and Podman clear this variable and do 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<Uid>, Option<Gid>, Vec<Gid>)> {
|
|
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::<u32>().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::<u32>().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::<u32>().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::<u32>().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::<u32>().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::<u32>().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 {
|
|
code: Option<i32>,
|
|
signal: Option<i32>,
|
|
}
|
|
|
|
impl ProcessStatus {
|
|
/// Get the exit code, or 128 + signal number if killed by signal.
|
|
#[must_use]
|
|
pub fn code(&self) -> i32 {
|
|
self.code
|
|
.or_else(|| self.signal.map(|s| 128 + s))
|
|
.unwrap_or(-1)
|
|
}
|
|
|
|
/// Check if the process exited successfully.
|
|
#[must_use]
|
|
pub fn success(&self) -> bool {
|
|
self.code == Some(0)
|
|
}
|
|
|
|
/// Get the signal that killed the process, if any.
|
|
#[must_use]
|
|
pub const fn signal(&self) -> Option<i32> {
|
|
self.signal
|
|
}
|
|
}
|
|
|
|
impl From<std::process::ExitStatus> for ProcessStatus {
|
|
fn from(status: std::process::ExitStatus) -> Self {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::process::ExitStatusExt;
|
|
Self {
|
|
code: status.code(),
|
|
signal: status.signal(),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
{
|
|
Self {
|
|
code: status.code(),
|
|
signal: None,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[cfg(unix)]
|
|
use nix::sys::wait::{WaitStatus, waitpid};
|
|
#[cfg(unix)]
|
|
use nix::unistd::{ForkResult, fork};
|
|
use openshell_core::policy::{
|
|
FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy,
|
|
};
|
|
#[cfg(unix)]
|
|
use std::mem::size_of;
|
|
use std::process::Stdio as StdStdio;
|
|
|
|
/// Helper to create a minimal `SandboxPolicy` with the given process policy.
|
|
fn policy_with_process(process: ProcessPolicy) -> SandboxPolicy {
|
|
SandboxPolicy {
|
|
version: 1,
|
|
filesystem: FilesystemPolicy::default(),
|
|
network: NetworkPolicy::default(),
|
|
landlock: LandlockPolicy::default(),
|
|
process,
|
|
}
|
|
}
|
|
|
|
/// 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_rejects_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_err());
|
|
assert!(validate_sandbox_group(&policy).is_err());
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
#[test]
|
|
fn full_enforcement_uses_privileged_setup_and_child_sandbox() {
|
|
assert!(ProcessEnforcementMode::Full.uses_privileged_process_setup());
|
|
assert!(ProcessEnforcementMode::Full.enforces_child_sandbox());
|
|
}
|
|
|
|
#[test]
|
|
fn network_only_enforcement_keeps_child_sandbox_without_privileged_setup() {
|
|
assert!(!ProcessEnforcementMode::NetworkOnly.uses_privileged_process_setup());
|
|
assert!(ProcessEnforcementMode::NetworkOnly.enforces_child_sandbox());
|
|
}
|
|
|
|
#[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 {
|
|
const HARDEN_FAILED: i64 = -2;
|
|
|
|
let mut fds = [0; 2];
|
|
let pipe_rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
|
|
assert_eq!(
|
|
pipe_rc,
|
|
0,
|
|
"pipe failed: {}",
|
|
std::io::Error::last_os_error()
|
|
);
|
|
|
|
match unsafe { fork() }.expect("fork should succeed") {
|
|
ForkResult::Child => {
|
|
unsafe { libc::close(fds[0]) };
|
|
let value = match harden_child_process() {
|
|
Ok(()) => unsafe { probe() },
|
|
Err(_) => HARDEN_FAILED,
|
|
};
|
|
let bytes = value.to_ne_bytes();
|
|
let written = unsafe { libc::write(fds[1], bytes.as_ptr().cast(), bytes.len()) };
|
|
unsafe {
|
|
libc::close(fds[1]);
|
|
libc::_exit(i32::from(written != bytes.len().cast_signed()));
|
|
}
|
|
}
|
|
ForkResult::Parent { child } => {
|
|
unsafe { libc::close(fds[1]) };
|
|
let mut bytes = [0u8; size_of::<i64>()];
|
|
let read = unsafe { libc::read(fds[0], bytes.as_mut_ptr().cast(), bytes.len()) };
|
|
unsafe { libc::close(fds[0]) };
|
|
assert_eq!(
|
|
read.cast_unsigned(),
|
|
bytes.len(),
|
|
"expected {} probe bytes, got {}",
|
|
bytes.len(),
|
|
read
|
|
);
|
|
|
|
match waitpid(child, None).expect("waitpid should succeed") {
|
|
WaitStatus::Exited(_, 0) => {}
|
|
status => panic!("probe child exited unexpectedly: {status:?}"),
|
|
}
|
|
|
|
i64::from_ne_bytes(bytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[allow(unsafe_code)]
|
|
unsafe fn core_dump_limit_is_zero_probe() -> i64 {
|
|
let mut limit = std::mem::MaybeUninit::<libc::rlimit>::uninit();
|
|
let rc = unsafe { libc::getrlimit(libc::RLIMIT_CORE, limit.as_mut_ptr()) };
|
|
if rc != 0 {
|
|
return -1;
|
|
}
|
|
let limit = unsafe { limit.assume_init() };
|
|
i64::from(limit.rlim_cur == 0 && limit.rlim_max == 0)
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(unix)]
|
|
fn harden_child_process_disables_core_dumps() {
|
|
assert_eq!(probe_hardened_child(core_dump_limit_is_zero_probe), 1);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[allow(unsafe_code)]
|
|
unsafe fn dumpable_flag_probe() -> i64 {
|
|
unsafe { i64::from(libc::prctl(libc::PR_GET_DUMPABLE, 0, 0, 0, 0)) }
|
|
}
|
|
|
|
#[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<String>,
|
|
run_as_group: Option<String>,
|
|
) -> 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<Uid>, _gid: Option<Gid>| -> 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<Uid>, _gid: Option<Gid>| -> nix::Result<()> {
|
|
Err(nix::errno::Errno::EPERM)
|
|
};
|
|
|
|
let result = chown_recursive(
|
|
&root,
|
|
Some(nix::unistd::geteuid()),
|
|
Some(nix::unistd::getegid()),
|
|
&fake_chown,
|
|
);
|
|
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<Uid>, _gid: Option<Gid>| -> 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");
|
|
}
|
|
|
|
#[test]
|
|
fn structural_workspace_validation_rejects_symlink_components_without_testing_writes() {
|
|
use std::os::unix::fs::{PermissionsExt, symlink};
|
|
|
|
let temp_root = std::env::temp_dir().canonicalize().unwrap();
|
|
let dir = tempfile::tempdir_in(temp_root).unwrap();
|
|
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
let real = dir.path().join("real");
|
|
std::fs::create_dir(&real).unwrap();
|
|
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o555)).unwrap();
|
|
|
|
validate_oci_workspace_structure(&real)
|
|
.expect("structure-only validation must not require write access");
|
|
|
|
let alias = dir.path().join("alias");
|
|
symlink(&real, &alias).unwrap();
|
|
let error = validate_oci_workspace_structure(&alias).unwrap_err();
|
|
assert!(error.to_string().contains("symlink"));
|
|
}
|
|
|
|
#[test]
|
|
fn structural_workspace_validation_rejects_missing_components() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let missing = dir.path().canonicalize().unwrap().join("missing");
|
|
|
|
let error = validate_oci_workspace_structure(&missing).unwrap_err();
|
|
assert!(error.to_string().contains("does not exist"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn filesystem_preparation_does_not_repair_nondefault_oci_workspace() {
|
|
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(0o555)).unwrap();
|
|
let policy = policy_with_process(ProcessPolicy {
|
|
run_as_user: Some(nix::unistd::geteuid().as_raw().to_string()),
|
|
run_as_group: Some(nix::unistd::getegid().as_raw().to_string()),
|
|
});
|
|
|
|
prepare_filesystem_with_identity(
|
|
&policy,
|
|
ResolvedProcessIdentity::default(),
|
|
root.to_str(),
|
|
true,
|
|
)
|
|
.expect("non-default workspace permissions are image-owned");
|
|
|
|
let mode = std::fs::symlink_metadata(&root)
|
|
.unwrap()
|
|
.permissions()
|
|
.mode();
|
|
assert_eq!(mode & 0o777, 0o555);
|
|
}
|
|
|
|
#[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<Uid>, _gid: Option<Gid>| -> 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<Uid>, _gid: Option<Gid>| -> 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");
|
|
cmd.env_clear()
|
|
.stdin(StdStdio::null())
|
|
.stdout(StdStdio::piped())
|
|
.stderr(StdStdio::null());
|
|
|
|
let provider_env = HashMap::from([
|
|
(
|
|
"ANTHROPIC_API_KEY".to_string(),
|
|
"openshell:resolve:env:ANTHROPIC_API_KEY".to_string(),
|
|
),
|
|
(
|
|
openshell_core::sandbox_env::SANDBOX_TOKEN.to_string(),
|
|
"provider-token".to_string(),
|
|
),
|
|
(
|
|
openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET.to_string(),
|
|
"/spiffe-workload-api/spire-agent.sock".to_string(),
|
|
),
|
|
]);
|
|
|
|
inject_provider_env(&mut cmd, &provider_env);
|
|
|
|
let output = cmd.output().await.expect("spawn env");
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
assert!(stdout.contains("ANTHROPIC_API_KEY=openshell:resolve:env:ANTHROPIC_API_KEY"));
|
|
assert!(!stdout.contains(openshell_core::sandbox_env::SANDBOX_TOKEN));
|
|
assert!(!stdout.contains(openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn strip_supervisor_only_env_removes_identity_material() {
|
|
let mut cmd = Command::new("/usr/bin/env");
|
|
cmd.stdin(StdStdio::null())
|
|
.stdout(StdStdio::piped())
|
|
.stderr(StdStdio::null())
|
|
.env("OPENSHELL_ENDPOINT", "https://gateway.example.test");
|
|
|
|
for key in SUPERVISOR_ONLY_ENV_VARS {
|
|
cmd.env(key, format!("{key}-secret"));
|
|
}
|
|
|
|
strip_supervisor_only_env(&mut cmd);
|
|
|
|
let output = cmd.output().await.expect("spawn env");
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
|
|
for key in SUPERVISOR_ONLY_ENV_VARS {
|
|
assert!(
|
|
!stdout
|
|
.lines()
|
|
.any(|line| line.starts_with(&format!("{key}="))),
|
|
"{key} must not be inherited by sandbox child processes"
|
|
);
|
|
}
|
|
assert!(stdout.contains("OPENSHELL_ENDPOINT=https://gateway.example.test"));
|
|
}
|
|
|
|
#[test]
|
|
fn supervisor_identity_mount_target_uses_socket_parent() {
|
|
assert_eq!(
|
|
supervisor_identity_mount_target("/spiffe-workload-api/spire-agent.sock")
|
|
.expect("plain path should parse"),
|
|
Some(PathBuf::from("/spiffe-workload-api"))
|
|
);
|
|
assert_eq!(
|
|
supervisor_identity_mount_target("unix:/spiffe-workload-api/spire-agent.sock")
|
|
.expect("unix path should parse"),
|
|
Some(PathBuf::from("/spiffe-workload-api"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn supervisor_identity_mount_target_ignores_empty_socket_path() {
|
|
assert_eq!(
|
|
supervisor_identity_mount_target(" ").expect("empty path should be ignored"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn supervisor_identity_mount_target_rejects_unhideable_endpoints() {
|
|
assert!(supervisor_identity_mount_target("tcp:127.0.0.1:8081").is_err());
|
|
assert!(supervisor_identity_mount_target("spiffe-workload-api/spire-agent.sock").is_err());
|
|
assert!(supervisor_identity_mount_target("/spire-agent.sock").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn supervisor_identity_mount_target_rejects_shared_root_shadowing() {
|
|
for socket_path in [
|
|
"/run/spire-agent.sock",
|
|
"/var/spire-agent.sock",
|
|
"/tmp/spire-agent.sock",
|
|
"/etc/spire-agent.sock",
|
|
] {
|
|
let err = supervisor_identity_mount_target(socket_path)
|
|
.expect_err("shared root shadowing should be rejected");
|
|
assert!(err.to_string().contains("dedicated subdirectory"));
|
|
}
|
|
|
|
assert_eq!(
|
|
supervisor_identity_mount_target("/run/spire/spire-agent.sock")
|
|
.expect("dedicated subdirectory should be accepted"),
|
|
Some(PathBuf::from("/run/spire"))
|
|
);
|
|
}
|
|
|
|
// ---- Numeric UID tests (Phase 2) ----
|
|
|
|
#[test]
|
|
fn drop_privileges_accepts_numeric_uid() {
|
|
// 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() {
|
|
// 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)
|
|
);
|
|
}
|
|
}
|