From 3bf196ada92606b57fa0cb34fa14a66cc76a8f72 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Fri, 10 Jul 2026 20:50:52 -0400 Subject: [PATCH] fix(supervisor): fail fast with diagnostic error when CAP_SETPCAP is unavailable When the supervisor lacks CAP_SETPCAP in its effective set (for example, a container runtime that does not grant it), the child's prctl(PR_CAPBSET_DROP) returns EPERM during privilege drop. The clear runs inside pre_exec, which cannot emit structured logs, so the failure surfaced only as an opaque child exit with code 1. Keep the bounding-set clear fail-closed and make the failure diagnosable: - Probe PR_CAPBSET_DROP availability in the parent before fork() with a non-destructive bounding::drop() on a capability that is already absent from the bounding set. When the probe detects EPERM with a non-empty bounding set, emit an OCSF DetectionFinding alert and refuse the spawn with an actionable error. - Extend the child-side EPERM error in validate_capability_bounding_set_clear() with the CAP_SETPCAP diagnosis and remediation hint as a backstop for environments the parent probe cannot predict. Related to #2069 Signed-off-by: Wayne Sun --- architecture/sandbox.md | 6 +- .../src/process.rs | 108 +++++++++++++++++- 2 files changed, 107 insertions(+), 7 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 30d1bf478..2f6828ced 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -19,7 +19,11 @@ capability bounding set during privilege drop so later execs cannot regain container-granted capabilities. This is fail-closed: the supervisor retains `CAP_SETPCAP` solely to perform the clear, and spawning the workload or SSH shell aborts unless the bounding set ends up empty. A `setpcap` `EPERM` is tolerated -only when the set is already empty; any other outcome fails the spawn. +only when the set is already empty; any other outcome fails the spawn. Because +the clear runs inside `pre_exec`, which cannot emit structured logs, the parent +probes `PR_CAPBSET_DROP` availability before forking and refuses the spawn with +a diagnostic error and an OCSF `DetectionFinding` when `CAP_SETPCAP` is +unavailable (for example, when the container runtime did not grant it). ## Startup Flow diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index 72effa98f..a15aa569d 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -234,12 +234,87 @@ fn validate_capability_bounding_set_clear( "Failed to clear unknown child capability bounding set entries: {unknown_err}" )), }, + Err(err) if err.code() == libc::EPERM => Err(miette::miette!( + "Failed to clear child capability bounding set: CAP_SETPCAP is not in the \ + effective set (EPERM) and capabilities remain raised: {remaining:?}; \ + configure the container runtime to grant CAP_SETPCAP to the supervisor" + )), Err(err) => Err(miette::miette!( "Failed to clear child capability bounding set: {err}" )), } } +/// Preflight check for the child capability bounding-set clear, run in the +/// parent before `fork()`. The clear is fail-closed inside `pre_exec`, a +/// context that cannot reliably emit structured logs — without this check a +/// blocked clear surfaces only as an opaque child exit. +/// +/// The probe tries a non-destructive `bounding::drop()` on a capability +/// that is already absent from the bounding set. This triggers the same +/// `prctl(PR_CAPBSET_DROP)` syscall that `bounding::clear()` uses, so +/// environments where `CAP_SETPCAP` is missing from the effective set +/// (e.g. container runtimes that do not grant it, LSM restrictions) are +/// detected here. On detection, emit an OCSF `DetectionFinding` and refuse +/// the spawn with a diagnostic error. +#[cfg(target_os = "linux")] +fn check_capability_bounding_set_readiness() -> Result<()> { + // The child clears the bounding set only when it starts as root. + if !nix::unistd::geteuid().is_root() { + return Ok(()); + } + + let bounding = capctl::caps::bounding::probe(); + // An EPERM clear is tolerated when the set is already empty. + if bounding.is_empty() { + return Ok(()); + } + + // Find a capability NOT in the bounding set so that drop() is a no-op + // when the syscall is permitted. If every known capability is raised + // (unusual), skip the probe — clear() is attempted in the child and + // fails the spawn there. + let probe_cap = capctl::caps::Cap::iter().find(|cap| !bounding.has(*cap)); + let clear_blocked = probe_cap.is_some_and(|cap| { + capctl::caps::bounding::drop(cap).is_err_and(|e| e.code() == libc::EPERM) + }); + + if !clear_blocked { + return Ok(()); + } + + openshell_ocsf::ocsf_emit!( + openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(openshell_ocsf::ActivityId::Open) + .severity(openshell_ocsf::SeverityId::High) + .confidence(openshell_ocsf::ConfidenceId::High) + .is_alert(true) + .finding_info( + openshell_ocsf::FindingInfo::new( + "bounding-set-clear-blocked", + "Capability Bounding Set Clear Blocked", + ) + .with_desc( + "The supervisor cannot clear the child capability bounding set \ + because PR_CAPBSET_DROP returns EPERM — CAP_SETPCAP is not \ + in the effective set. The spawn is refused to keep privilege \ + drop fail-closed. Configure the container runtime to grant \ + CAP_SETPCAP to the supervisor.", + ), + ) + .message(format!( + "PR_CAPBSET_DROP blocked, refusing spawn with non-empty capability bounding set: {bounding:?}" + )) + .build() + ); + + Err(miette::miette!( + "Cannot clear the child capability bounding set: PR_CAPBSET_DROP returns EPERM \ + (CAP_SETPCAP is not in the effective set) and capabilities remain raised: {bounding:?}; \ + configure the container runtime to grant CAP_SETPCAP to the supervisor" + )) +} + // 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")] @@ -607,6 +682,15 @@ impl ProcessHandle { sandbox::linux::log_sandbox_readiness(policy, workdir); } + // Fail fast when the child's capability bounding-set clear would + // fail: the clear is fail-closed inside pre_exec, which cannot emit + // structured logs, so the parent probes first and refuses the spawn + // with a diagnostic error and an OCSF finding. + #[cfg(target_os = "linux")] + if enforcement_mode.uses_privileged_process_setup() { + check_capability_bounding_set_readiness()?; + } + // 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 @@ -1542,6 +1626,21 @@ mod tests { || capctl::caps::bounding::probe().is_empty() } + #[test] + #[cfg(target_os = "linux")] + fn capability_bounding_set_readiness_matches_clear_availability() { + let result = check_capability_bounding_set_readiness(); + if !nix::unistd::geteuid().is_root() + || capctl::caps::bounding::probe().is_empty() + || capability_bounding_set_clear_available() + { + assert!(result.is_ok(), "readiness check failed: {result:?}"); + } else { + let msg = format!("{}", result.unwrap_err()); + assert!(msg.contains("CAP_SETPCAP"), "unexpected error: {msg}"); + } + } + #[test] #[cfg(target_os = "linux")] fn capability_bounding_set_clear_accepts_empty_eperm() { @@ -1570,12 +1669,9 @@ mod tests { ); assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("Failed to clear child capability bounding set") - ); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("Failed to clear child capability bounding set")); + assert!(msg.contains("CAP_SETPCAP")); } #[test]