diff --git a/.github/workflows/standards-build.yml b/.github/workflows/standards-build.yml index be065846c..8fd496328 100644 --- a/.github/workflows/standards-build.yml +++ b/.github/workflows/standards-build.yml @@ -508,6 +508,23 @@ jobs: # process tree, spawned suspended and read before it is reaped. Its # real-engine tests are gated to Apple Silicon macOS; each run they # meter prints this line. + # sandbox-exec-os earns S1 only where its probe shows the operating + # system's sandbox confining a program on this host, and that branch + # exists only on macOS: every other runner takes the S0 branch. The step + # fails unless the test printed the confinement it found, the only proof + # the S1 branch ran rather than the S0 one passing in its place. + - name: sandbox-exec-os (Seatbelt confinement at S1) + shell: bash + working-directory: sandbox/sandbox-rs + env: + SANDBOX_EXEC_OS_EXPECT_S1: "1" + run: | + set -o pipefail + log=$(mktemp) + cargo test -p sandbox-exec-os -- --nocapture 2>&1 | tee "$log" + grep -q 'sandbox-exec-os: confinement seatbelt/' "$log" + grep -q 'denied a read outside the grants' "$log" + - name: sandbox-proof (local engine process tree) shell: bash working-directory: sandbox/sandbox-rs diff --git a/sandbox/sandbox-rs/Cargo.lock b/sandbox/sandbox-rs/Cargo.lock index bd6e3eea2..93c46159e 100644 --- a/sandbox/sandbox-rs/Cargo.lock +++ b/sandbox/sandbox-rs/Cargo.lock @@ -1698,6 +1698,7 @@ dependencies = [ "sandbox-attest", "sandbox-capability", "sandbox-exec-microvm", + "sandbox-exec-os", "sandbox-exec-wasm", "sandbox-meter", "sandbox-runtime", @@ -1761,6 +1762,18 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "sandbox-exec-os" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "sandbox-attest", + "sandbox-capability", + "sandbox-meter", + "sandbox-runtime", + "sandbox-schema", +] + [[package]] name = "sandbox-exec-wasm" version = "0.1.0" diff --git a/sandbox/sandbox-rs/Cargo.toml b/sandbox/sandbox-rs/Cargo.toml index 3f5e36e79..5f732386b 100644 --- a/sandbox/sandbox-rs/Cargo.toml +++ b/sandbox/sandbox-rs/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/sandbox-tee", "crates/sandbox-exec-wasm", "crates/sandbox-exec-microvm", + "crates/sandbox-exec-os", "crates/sandbox-conformance", "crates/sandbox-proof", "crates/sandbox-proof-wasm", @@ -34,6 +35,7 @@ sandbox-runtime = { path = "crates/sandbox-runtime" } sandbox-tee = { path = "crates/sandbox-tee" } sandbox-exec-wasm = { path = "crates/sandbox-exec-wasm" } sandbox-exec-microvm = { path = "crates/sandbox-exec-microvm" } +sandbox-exec-os = { path = "crates/sandbox-exec-os" } sandbox-conformance = { path = "crates/sandbox-conformance" } sandbox-proof = { path = "crates/sandbox-proof" } sandbox-build = { path = "crates/sandbox-build" } diff --git a/sandbox/sandbox-rs/crates/sandbox-build/Cargo.toml b/sandbox/sandbox-rs/crates/sandbox-build/Cargo.toml index 643c1b55f..dd59159eb 100644 --- a/sandbox/sandbox-rs/crates/sandbox-build/Cargo.toml +++ b/sandbox/sandbox-rs/crates/sandbox-build/Cargo.toml @@ -19,6 +19,7 @@ sandbox-attest.workspace = true sandbox-runtime.workspace = true sandbox-exec-wasm.workspace = true sandbox-exec-microvm.workspace = true +sandbox-exec-os.workspace = true ed25519-dalek.workspace = true serde = { workspace = true } serde_json.workspace = true diff --git a/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs index 248f8c2dd..55359fef1 100644 --- a/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs @@ -22,6 +22,7 @@ use sandbox_capability::Grants; use sandbox_exec_microvm::firecracker::{FirecrackerConfig, ProcessVmm, Vmm}; use sandbox_exec_microvm::power::NoMeter; use sandbox_exec_microvm::MicroVmExecutor; +use sandbox_exec_os::OsExecutor; use sandbox_runtime::{ grants_content_id, unit_content_id, Executor, Host, RunError, TierRequest, Unit, }; @@ -466,11 +467,14 @@ fn resolve_candidate( Ok((rec, true)) } -/// Resolve a **command** step: run the program in the S5 microVM (a real -/// subprocess via `ProcessVmm`; Firecracker on Linux+KVM in production), meter -/// it, and seal at S5 (an escalation above the grant floor — the host attests it -/// ran in the microVM). L0 if the identical command is cached. Returns the -/// record and whether it was a fresh execution. +/// Resolve a **command** step: run the program, meter it, and seal it at the +/// rung it actually reached. Where this host's operating-system confinement +/// holds and can enforce the step's grants, the command runs confined and +/// seals **S1** with its mechanism (`sandbox-exec-os`). Otherwise it runs as +/// an ordinary child process under `ProcessVmm` (Firecracker on Linux+KVM in +/// production) and seals the tier that reports, S0 on a host without one. L0 +/// if the identical command is cached. Returns the record and whether it was a +/// fresh execution. #[allow(clippy::too_many_arguments)] fn resolve_command( host: &Host, @@ -503,20 +507,34 @@ fn resolve_command( return Ok((rec, false)); } - let vmm = ProcessVmm::new(program.to_string(), args.to_vec()); - // The tier is read off the executor, never asserted here. `ProcessVmm::new` - // supervises the build command as an ordinary child process on the host, so - // on a host without hardware virtualization this is S0 — and the receipt - // will say S0. Sealing a rung the run did not reach would make the receipt - // worthless precisely where it is load-bearing. - let attained = vmm.attained_tier(); - let exec = MicroVmExecutor::new(Box::new(vmm), template.clone(), Box::new(NoMeter)); let unit = Unit::new(image, grants); - let report = exec.run(&unit).map_err(|e| BuildError::Run(e.into()))?; + // The tier is read off the executor that ran the command, never asserted + // here: sealing a rung the run did not reach would make the receipt + // worthless precisely where it is load-bearing. The confined executor is + // used only where its confinement holds on this host AND it can enforce + // these grants; a step whose grants it cannot enforce (a subprocess, the + // network, a withheld clock) is never run confined with a capability its + // grant denies, and falls back to the ordinary child process. + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/")); + let confined = OsExecutor::new(program.to_string(), args.to_vec(), cwd); + let (attained, report, mechanism) = + if OsExecutor::confinement().is_some() && confined.policy(grants).is_ok() { + let report = confined.run(&unit).map_err(|e| BuildError::Run(e.into()))?; + (confined.tier(), report, confined.mechanism()) + } else { + // `ProcessVmm::new` supervises the command as an ordinary child + // process on the host: S0 on a host without hardware virtualization. + let vmm = ProcessVmm::new(program.to_string(), args.to_vec()); + let attained = vmm.attained_tier(); + let exec = MicroVmExecutor::new(Box::new(vmm), template.clone(), Box::new(NoMeter)); + let report = exec.run(&unit).map_err(|e| BuildError::Run(e.into()))?; + (attained, report, None) + }; let unit_id = unit_content_id(&image_id, &grants_id); - // The host attests the run at the tier the executor reports reaching. - let sealed = host.seal_run( + // The host attests the run at the tier the executor reports reaching, with + // the mechanism that executor committed to (none below S1). + let sealed = host.seal_run_with_mechanism( unit_id, image_id.clone(), grants, @@ -526,6 +544,7 @@ fn resolve_command( [0; 8], started, started + 1, + mechanism, )?; sealed .receipt @@ -1067,6 +1086,46 @@ mod tests { assert!(!verify_build_receipt(&bad)); } + /// A command step whose grants the operating-system confinement can + /// enforce — the clock and randomness, a granted directory to write — runs + /// confined and seals S1 with its mechanism where that confinement holds on + /// this host, and the ordinary S0 child process everywhere else. + #[test] + fn a_confinable_command_step_seals_s1_where_the_confinement_holds() { + use sandbox_capability::HostGrant; + use sandbox_exec_wasm::WasmExecutor; + let host = host(); + let exec = WasmExecutor::with_defaults().unwrap(); + let key = SigningKey::from_bytes(&[89u8; 32]); + let mut cache = BuildCache::new(); + let dir = std::env::temp_dir().join(format!("sandbox-build-os-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dir = dir.canonicalize().unwrap(); + let mut grants = Grants { + host: HostGrant { logging: true, clock: true, random: true, inference: false }, + ..cpu_grants() + }; + grants.storage.write = vec![dir.to_str().unwrap().into()]; + let task = BuildTask { + intent: "write a build artifact".into(), + steps: vec![BuildStep::command( + "touch", + "/usr/bin/touch", + vec![dir.join("artifact").to_str().unwrap().into()], + grants, + )], + }; + let signed = + run_build(&host, &exec, "steward.build", &task, &mut cache, &key, zero_id(), 1_800_000).unwrap(); + let s = &signed.receipt.steps[0]; + assert_eq!(s.outcome, "Completed"); + assert!(dir.join("artifact").exists()); + let want = if OsExecutor::confinement().is_some() { Tier::S1 } else { Tier::S0 }; + assert_eq!(s.tier, want); + assert!(verify_build_receipt(&signed)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_command_step_seals_the_tier_it_actually_reached() { use sandbox_exec_wasm::WasmExecutor; @@ -1078,9 +1137,11 @@ mod tests { // A real command (`true`) is supervised by `ProcessVmm::new` — an // ordinary child process on the host, not a guest under a VMM. It is // attested and carries op provenance (the path for non-wasm source - // builds), but the rung it seals is the floor, S0. This crate never - // constructs `supervising_vmm`, so the tier here does not vary with the - // host: a build receipt from this path claims S0 everywhere. + // builds), but the rung it seals is the floor, S0. Its grants withhold + // the clock and randomness, which no operating-system confinement can + // take from a native process, so the confined executor will not run it + // either; and this crate never constructs `supervising_vmm`. So the + // tier here does not vary with the host: S0 everywhere. let task = BuildTask { intent: "run the test suite".into(), steps: vec![BuildStep::command("test", "true", vec![], cpu_grants()) diff --git a/sandbox/sandbox-rs/crates/sandbox-exec-os/Cargo.toml b/sandbox/sandbox-rs/crates/sandbox-exec-os/Cargo.toml new file mode 100644 index 000000000..1bb90c178 --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-exec-os/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "sandbox-exec-os" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Sandbox — an S1 executor: a native program confined by the operating system's own sandbox, earning its rung only when a probe on this host shows the confinement denies what the grants do not name." + +[dependencies] +sandbox-schema.workspace = true +sandbox-capability.workspace = true +sandbox-meter.workspace = true +sandbox-attest.workspace = true +sandbox-runtime.workspace = true + +[dev-dependencies] +ed25519-dalek.workspace = true diff --git a/sandbox/sandbox-rs/crates/sandbox-exec-os/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-exec-os/src/lib.rs new file mode 100644 index 000000000..df5604483 --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-exec-os/src/lib.rs @@ -0,0 +1,508 @@ +//! # sandbox-exec-os — an S1 executor: a native program, confined by the operating system +//! +//! The rungs below S2 had no executor between a bare child process (S0) and +//! a managed runtime (S2, `sandbox-exec-wasm`). This crate runs a **native +//! program** under the operating system's own sandbox, with a policy derived +//! from the unit's grants, and claims **S1** only where it can show the +//! policy holds. +//! +//! ## Why S1, and only S1 +//! +//! S1's guarantee (`spec/tiers.md`) is that no resource is reachable without +//! a capability for it. Here the resources are the ones grants govern — +//! storage, network, subprocesses, devices, host calls — and the policy is +//! **deny by default**: the program may read and write exactly the paths its +//! storage grant names, and nothing else; it has no network, cannot spawn a +//! process, and reaches no device beyond the host CPU. +//! +//! S2 is out of reach by construction: it needs a managed runtime with fuel +//! metering, memory caps and no raw syscall surface, and a native process has +//! none of them. So a unit whose grants need S2 — subprocesses (§5.5), a +//! browser (§5.6), a display (§5.7), an accelerator (§5.1) — is refused, never +//! run at a rung it did not reach. +//! +//! ## What the confinement leaves reachable +//! +//! A native process cannot run at all without part of its host, and that +//! part is stated rather than hidden: +//! +//! - the system libraries and frameworks, read-only, and the root directory's +//! own listing (the loader reads it); +//! - `/dev/null`; +//! - the clock, and the system's randomness: no operating-system sandbox can +//! withhold them from a native process. +//! +//! The last point decides which units can be confined honestly. A grant that +//! withholds the clock or randomness (`host.clock` or `host.random` false) +//! asks for something this executor cannot enforce, so such a unit is refused +//! ([`Unenforceable::HostCall`]) rather than run with a capability its grant +//! denies. Nor can it limit egress to named endpoints, so any network grant is +//! refused too; with no network grant the program has no network at all. +//! +//! ## The rung is earned on this host +//! +//! [`OsExecutor::tier`] is S1 only when a probe, run once per process, shows +//! the confinement working here: a program runs under a policy that grants +//! nothing, and is denied a file outside it, a write, a subprocess, and (where +//! `nc` exists) a connection to a listener the probe opened. Anywhere the +//! probe fails, or the platform has no backend, the executor runs the program +//! unconfined and says **S0**, with no mechanism: a mechanism at S0 is +//! incoherent (SPEC §7.9). +//! +//! The backend is macOS's sandbox (Seatbelt), driven through `sandbox-exec`. +//! There is no Linux backend yet. An unprivileged namespace sandbox there +//! would also need a system-call filter to enforce "no subprocess", and the +//! fleet's Linux hosts could not run one to verify it (their user-namespace +//! policy refuses the network namespace). Until one can be verified, Linux is +//! S0. +//! +//! ## What the attestation carries +//! +//! At S1, [`Executor::mechanism`] commits to the runtime (`seatbelt/`) and the exact policy text applied to the last run (SPEC §7.9). +//! Energy is the child's attributed share of the host's CPU reading +//! ([`sandbox_meter::run_attributed`]): `ModelBased` where that reading is a +//! hardware one, `Estimator` otherwise. + +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use sandbox_attest::{DeviceIdentity, Mechanism, Outcome}; +use sandbox_capability::Grants; +use sandbox_meter::{DeviceEnergy, PowerMeter, Provenance}; +use sandbox_runtime::{ExecError, Executor, RunReport, Unit}; +use sandbox_schema::{DeviceClass, Tier}; + +/// The program that applies a Seatbelt policy and then runs the command. +const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec"; + +/// Why a unit's grants cannot be enforced by this executor. Each is a +/// refusal: the unit is never run with a capability its grants deny, or at a +/// rung below the one its grants need. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Unenforceable { + /// An accelerator class: reaching one needs a brokered device interface + /// (S2) or device assignment (S5). + Device(DeviceClass), + /// Network egress or ingress: the confinement cannot limit traffic to the + /// granted endpoints, only deny it all. + Network, + /// Subprocesses: spawning is untrusted-execution class (S2, SPEC §5.5). + Process, + /// Browser automation (S2, SPEC §5.6). + Browser, + /// A framebuffer or desktop (S2, SPEC §5.7). + Display, + /// A key-value store: reached through host calls this executor does not + /// broker. + Kv, + /// A host call the grant withholds and a native process holds anyway + /// (`"clock"`, `"random"`), or one it grants and this executor cannot + /// provide (`"inference"`). + HostCall(&'static str), + /// A storage path that is relative, cannot be resolved, or cannot be + /// written into a policy safely. + Path(String), + /// The program cannot be found as an executable file. + Program(String), +} + +impl std::fmt::Display for Unenforceable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Device(c) => write!(f, "device class {c:?} needs a brokered device interface"), + Self::Network => write!(f, "network grants cannot be limited to the granted endpoints"), + Self::Process => write!(f, "spawning subprocesses needs a brokered boundary (S2)"), + Self::Browser => write!(f, "browser automation needs S2"), + Self::Display => write!(f, "display access needs S2"), + Self::Kv => write!(f, "key-value access needs a brokered host call"), + Self::HostCall(c) => write!(f, "host call {c:?} cannot be enforced for a native process"), + Self::Path(p) => write!(f, "storage path {p:?} cannot be confined"), + Self::Program(p) => write!(f, "program {p:?} is not an executable file"), + } + } +} + +/// The operating-system confinement this host offers, as the probe found it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Confinement { + /// The runtime, name and version: `seatbelt/`. + pub runtime: String, +} + +/// A native program run under the operating system's sandbox (see the crate +/// docs). The program and its arguments are the executor's; the unit +/// supplies the grants the policy is derived from. +pub struct OsExecutor { + program: String, + args: Vec, + cwd: PathBuf, + /// The policy applied to the last confined run, for [`Executor::mechanism`]. + last_profile: Mutex>, +} + +impl OsExecutor { + /// Run `program args` in `cwd`. + pub fn new(program: impl Into, args: Vec, cwd: impl Into) -> Self { + OsExecutor { program: program.into(), args, cwd: cwd.into(), last_profile: Mutex::new(None) } + } + + /// The confinement this host offers, when the probe showed it working; + /// `None` elsewhere. Probed once per process. + pub fn confinement() -> Option<&'static Confinement> { + static PROBED: OnceLock> = OnceLock::new(); + PROBED.get_or_init(probe).as_ref() + } + + /// Whether this executor can enforce `grants`, and if so the policy it + /// would apply. Pure: it reads the file system to resolve paths but runs + /// nothing. + pub fn policy(&self, grants: &Grants) -> Result { + if let Some(c) = grants.compute.devices.iter().find(|d| !d.is_host_cpu()) { + return Err(Unenforceable::Device(*c)); + } + if !grants.network.outbound.is_empty() || grants.network.inbound || !grants.network.credentials.is_empty() { + return Err(Unenforceable::Network); + } + if grants.process.exec || !grants.process.programs.is_empty() { + return Err(Unenforceable::Process); + } + if grants.browser.enabled { + return Err(Unenforceable::Browser); + } + if grants.display.enabled { + return Err(Unenforceable::Display); + } + if grants.kv.read || grants.kv.write { + return Err(Unenforceable::Kv); + } + if !grants.host.clock { + return Err(Unenforceable::HostCall("clock")); + } + if !grants.host.random { + return Err(Unenforceable::HostCall("random")); + } + if grants.host.inference { + return Err(Unenforceable::HostCall("inference")); + } + let program = resolve_program(&self.program).ok_or_else(|| Unenforceable::Program(self.program.clone()))?; + let mut reads = Vec::new(); + for p in &grants.storage.read { + reads.push(policy_path(p)?); + } + let mut writes = Vec::new(); + for p in grants.storage.write.iter().chain(&grants.storage.delete) { + writes.push(policy_path(p)?); + } + Ok(seatbelt_policy(&program, &reads, &writes)) + } + + /// Run `program args` with `policy` applied (or unconfined when `None`), + /// metered. + fn run_metered(&self, policy: Option<&str>) -> Result<(bool, sandbox_meter::Attribution), ExecError> { + let mut meter = PowerMeter::detect(); + let mut argv: Vec<&str> = Vec::new(); + let program = match policy { + Some(p) => { + argv.extend(["-p", p, self.program.as_str()]); + SANDBOX_EXEC + } + None => self.program.as_str(), + }; + argv.extend(self.args.iter().map(String::as_str)); + let (ok, _output, attribution) = sandbox_meter::run_attributed(&mut meter, program, &argv, &self.cwd) + .map_err(|e| ExecError::Unavailable(format!("spawn {program}: {e}")))?; + Ok((ok, attribution)) + } +} + +impl Executor for OsExecutor { + /// S1 where the probe showed the confinement working on this host; S0 + /// everywhere else. Never asserted by a caller. + fn tier(&self) -> Tier { + if Self::confinement().is_some() { + Tier::S1 + } else { + Tier::S0 + } + } + + fn run(&self, unit: &Unit) -> Result { + let confined = Self::confinement(); + let policy = match confined { + Some(_) => { + let policy = self.policy(unit.grants).map_err(|e| { + ExecError::Unavailable(format!("{e}: this executor will not run the unit at S1")) + })?; + *self.last_profile.lock().expect("profile lock") = Some(policy.clone()); + Some(policy) + } + None => { + *self.last_profile.lock().expect("profile lock") = None; + None + } + }; + let (ok, a) = self.run_metered(policy.as_deref())?; + // The child's attributed share of the host's CPU reading: a model + // over a hardware reading where there is one, an estimate otherwise. + let energy = DeviceEnergy { + class: DeviceClass::Cpu, + measured_uj: None, + estimated_uj: a.attributed_uj, + provenance: if a.attributed { Provenance::ModelBased } else { Provenance::Estimator }, + }; + Ok(RunReport { + device_energy: vec![energy], + device_identity: vec![DeviceIdentity { + class: DeviceClass::Cpu, + model: "host".into(), + driver: confined.map_or_else(|| "process".to_string(), |c| c.runtime.clone()), + }], + outcome: if ok { Outcome::Completed } else { Outcome::Failed }, + journal: None, + storage: None, + }) + } + + /// At S1, the runtime and the policy applied to the last run; `None` at + /// S0 and before any confined run. + fn mechanism(&self) -> Option { + let c = Self::confinement()?; + let policy = self.last_profile.lock().expect("profile lock").clone()?; + Some(Mechanism::commit(c.runtime.clone(), policy.as_bytes(), &[])) + } +} + +/// `program` as the absolute, symlink-resolved path the policy names: as +/// given when it contains a `/`, else the first executable file of that name +/// on `PATH`. +fn resolve_program(program: &str) -> Option { + let found = if program.contains('/') { + Some(PathBuf::from(program)) + } else { + std::env::var_os("PATH") + .into_iter() + .flat_map(|p| std::env::split_paths(&p).collect::>()) + .map(|d| d.join(program)) + .find(|c| c.is_file()) + }?; + let real = found.canonicalize().ok()?; + let s = real.to_str()?.to_string(); + policy_safe(&s).then_some(s) +} + +/// A storage path as the policy names it: absolute, symlinks resolved (the +/// sandbox matches resolved paths, so `/tmp` must become `/private/tmp`). A +/// path that does not exist yet resolves through its parent. +fn policy_path(p: &str) -> Result { + let path = Path::new(p); + if !path.is_absolute() { + return Err(Unenforceable::Path(p.into())); + } + let resolved = match path.canonicalize() { + Ok(r) => r, + Err(_) => { + let parent = path.parent().and_then(|d| d.canonicalize().ok()); + match (parent, path.file_name()) { + (Some(d), Some(name)) => d.join(name), + _ => return Err(Unenforceable::Path(p.into())), + } + } + }; + match resolved.to_str() { + Some(s) if policy_safe(s) => Ok(s.to_string()), + _ => Err(Unenforceable::Path(p.into())), + } +} + +/// Whether `s` can be written into a policy string literal as it is: no quote +/// or backslash to escape, no control character. +fn policy_safe(s: &str) -> bool { + !s.chars().any(|c| c == '"' || c == '\\' || c.is_control()) +} + +/// The Seatbelt policy for `program` reading `reads` and writing `writes`: +/// deny by default, then the substrate a native process needs (see the crate +/// docs), then exactly the granted paths. +fn seatbelt_policy(program: &str, reads: &[String], writes: &[String]) -> String { + let mut p = String::from("(version 1)\n(deny default)\n"); + p.push_str(&format!("(allow process-exec (literal \"{program}\"))\n")); + p.push_str(&format!( + "(allow file-read* (literal \"/\") (literal \"/dev/null\") (subpath \"/usr/lib\") (subpath \"/System\") (literal \"{program}\"))\n" + )); + p.push_str("(allow sysctl-read)\n"); + for r in reads { + p.push_str(&format!("(allow file-read* (subpath \"{r}\"))\n")); + } + for w in writes { + p.push_str(&format!("(allow file-read* file-write* (subpath \"{w}\"))\n")); + } + p +} + +/// Whether Seatbelt confines a program here, shown rather than assumed: under +/// a policy that grants nothing, a control run must succeed, and a read +/// outside the policy, a write, a subprocess and (where `nc` exists) a +/// connection to a listener this probe opened must each fail. +#[cfg(target_os = "macos")] +fn probe() -> Option { + use std::process::{Command, Stdio}; + if !Path::new(SANDBOX_EXEC).is_file() { + return None; + } + let version = Command::new("/usr/bin/sw_vers").arg("-productVersion").output().ok()?; + let version = String::from_utf8(version.stdout).ok()?.trim().to_string(); + if version.is_empty() { + return None; + } + let dir = std::env::temp_dir().join(format!("sandbox-exec-os-probe-{}", std::process::id())); + std::fs::create_dir_all(&dir).ok()?; + let secret = dir.join("secret"); + std::fs::write(&secret, b"not granted").ok()?; + let written = dir.join("written"); + let run = |program: &str, args: &[&str]| -> Option { + let policy = seatbelt_policy(&resolve_program(program)?, &[], &[]); + let status = Command::new(SANDBOX_EXEC) + .arg("-p") + .arg(&policy) + .arg(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .ok()?; + Some(status.success()) + }; + let secret_s = secret.to_str()?; + let written_s = written.to_str()?; + let holds = run("/bin/cat", &["/dev/null"]) == Some(true) + && run("/bin/cat", &[secret_s]) == Some(false) + && run("/usr/bin/touch", &[written_s]) == Some(false) + && !written.exists() + && run("/bin/sh", &["-c", "/usr/bin/true; /usr/bin/true"]) == Some(false) + && network_denied(&run); + let _ = std::fs::remove_dir_all(&dir); + holds.then(|| Confinement { runtime: format!("seatbelt/{version}") }) +} + +/// Where `nc` exists: a connection from inside the policy to a listener on +/// this host's loopback must fail, and the listener must see nothing. +#[cfg(target_os = "macos")] +fn network_denied(run: &dyn Fn(&str, &[&str]) -> Option) -> bool { + use std::net::TcpListener; + if !Path::new("/usr/bin/nc").is_file() { + return true; + } + let Ok(listener) = TcpListener::bind("127.0.0.1:0") else { return false }; + let Ok(addr) = listener.local_addr() else { return false }; + if listener.set_nonblocking(true).is_err() { + return false; + } + let port = addr.port().to_string(); + let refused = run("/usr/bin/nc", &["-z", "-G", "2", "127.0.0.1", &port]) == Some(false); + refused && listener.accept().is_err() +} + +/// No backend off macOS: the executor is S0 (see the crate docs). +#[cfg(not(target_os = "macos"))] +fn probe() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + use sandbox_capability::{BrowserGrant, ComputeGrant, DisplayGrant, HostGrant, NetworkGrant, ProcessGrant}; + + /// Grants this executor can enforce: CPU, the clock and randomness, and + /// the given storage paths. + fn enforceable(read: Vec, write: Vec) -> Grants { + let mut g = Grants { + compute: ComputeGrant::cpu_only(5_000_000, 200, 16 * 1024 * 1024), + host: HostGrant { logging: true, clock: true, random: true, inference: false }, + ..Grants::default() + }; + g.storage.read = read; + g.storage.write = write; + g + } + + fn executor() -> OsExecutor { + OsExecutor::new("/bin/cat", vec![], std::env::temp_dir()) + } + + /// Every grant the executor cannot enforce is refused, by name. + #[test] + fn grants_it_cannot_enforce_are_refused() { + let ok = enforceable(vec![], vec![]); + assert!(executor().policy(&ok).is_ok()); + let cases: Vec<(Grants, Unenforceable)> = vec![ + ( + Grants { network: NetworkGrant { outbound: vec!["example:443".into()], ..ok.network.clone() }, ..ok.clone() }, + Unenforceable::Network, + ), + ( + Grants { process: ProcessGrant { exec: true, ..ok.process.clone() }, ..ok.clone() }, + Unenforceable::Process, + ), + (Grants { browser: BrowserGrant { enabled: true, ..ok.browser.clone() }, ..ok.clone() }, Unenforceable::Browser), + (Grants { display: DisplayGrant { enabled: true, ..ok.display }, ..ok.clone() }, Unenforceable::Display), + (Grants { host: HostGrant { clock: false, ..ok.host }, ..ok.clone() }, Unenforceable::HostCall("clock")), + (Grants { host: HostGrant { random: false, ..ok.host }, ..ok.clone() }, Unenforceable::HostCall("random")), + (enforceable(vec!["relative/path".into()], vec![]), Unenforceable::Path("relative/path".into())), + (enforceable(vec![], vec!["/tmp/a\"b".into()]), Unenforceable::Path("/tmp/a\"b".into())), + ]; + for (grants, want) in cases { + assert_eq!(executor().policy(&grants).err(), Some(want)); + } + let mut gpu = ok.clone(); + gpu.compute.devices.push(DeviceClass::Gpu); + assert_eq!(executor().policy(&gpu).err(), Some(Unenforceable::Device(DeviceClass::Gpu))); + assert!(matches!( + OsExecutor::new("no-such-program-anywhere", vec![], "/").policy(&ok), + Err(Unenforceable::Program(_)) + )); + } + + /// The policy denies by default and then names exactly the program and + /// the granted paths, resolved; it never allows the network or a fork. + #[test] + fn the_policy_names_exactly_what_was_granted() { + let tmp = std::env::temp_dir().canonicalize().unwrap(); + let policy = executor() + .policy(&enforceable(vec![tmp.to_str().unwrap().into()], vec![tmp.join("out").to_str().unwrap().into()])) + .unwrap(); + assert!(policy.starts_with("(version 1)\n(deny default)\n"), "{policy}"); + assert!(policy.contains(&format!("(allow file-read* (subpath \"{}\"))", tmp.display())), "{policy}"); + assert!(policy.contains(&format!("(allow file-read* file-write* (subpath \"{}\"))", tmp.join("out").display())), "{policy}"); + assert!(policy.contains("(allow process-exec (literal \""), "{policy}"); + for never in ["network", "process-fork", "mach-lookup"] { + assert!(!policy.contains(never), "{never} in {policy}"); + } + } + + /// The rung matches the probe: S1 with a confinement here, S0 with no + /// mechanism where there is none. A host that is expected to confine + /// (`SANDBOX_EXEC_OS_EXPECT_S1`, set by the macOS CI job) fails here when + /// the probe does not hold, rather than passing on the S0 branch. + #[test] + fn the_rung_is_the_probes() { + if std::env::var_os("SANDBOX_EXEC_OS_EXPECT_S1").is_some() { + assert!(OsExecutor::confinement().is_some(), "this host is expected to confine, and the probe did not hold"); + } + let e = executor(); + match OsExecutor::confinement() { + Some(c) => { + eprintln!("sandbox-exec-os: confinement {} -> S1", c.runtime); + assert_eq!(e.tier(), Tier::S1); + assert!(c.runtime.starts_with("seatbelt/"), "{c:?}"); + } + None => { + eprintln!("sandbox-exec-os: no confinement on this host -> S0"); + assert_eq!(e.tier(), Tier::S0); + assert_eq!(e.mechanism(), None); + } + } + } +} diff --git a/sandbox/sandbox-rs/crates/sandbox-exec-os/tests/confined.rs b/sandbox/sandbox-rs/crates/sandbox-exec-os/tests/confined.rs new file mode 100644 index 000000000..db704cb52 --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-exec-os/tests/confined.rs @@ -0,0 +1,134 @@ +//! The executor end to end on this host: what it lets through, what it +//! denies, the mechanism it commits to, and the rung `Host::run_and_seal` +//! seals. Where the probe finds no confinement (any host but macOS, or a +//! macOS host whose sandbox did not hold), the same tests assert the S0 path +//! instead, so neither branch passes by skipping. + +use ed25519_dalek::SigningKey; +use sandbox_attest::{Mechanism, Outcome}; +use sandbox_capability::{ComputeGrant, Grants, HostGrant}; +use sandbox_exec_os::OsExecutor; +use sandbox_runtime::{Executor, Host, RunError, TierRequest, Unit}; +use sandbox_schema::Tier; +use std::path::{Path, PathBuf}; + +/// A scratch directory of its own for each test. +fn scratch(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("sandbox-exec-os-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d.canonicalize().unwrap() +} + +fn grants(read: &Path, write: &Path) -> Grants { + let mut g = Grants { + compute: ComputeGrant::cpu_only(5_000_000, 200, 16 * 1024 * 1024), + host: HostGrant { logging: true, clock: true, random: true, inference: false }, + ..Grants::default() + }; + g.storage.read = vec![read.to_str().unwrap().into()]; + g.storage.write = vec![write.to_str().unwrap().into()]; + g +} + +/// A granted read and a granted write go through; a file outside the grants +/// does not, and the run that tried says so. At S1 the mechanism commits to +/// the runtime and the very policy applied. +#[test] +fn it_reads_and_writes_what_was_granted_and_nothing_else() { + let dir = scratch("rw"); + let (input, output, secret) = (dir.join("in"), dir.join("out"), dir.join("secret")); + std::fs::create_dir_all(&input).unwrap(); + std::fs::create_dir_all(&output).unwrap(); + std::fs::create_dir_all(&secret).unwrap(); + std::fs::write(input.join("a.txt"), b"granted").unwrap(); + std::fs::write(secret.join("s.txt"), b"not granted").unwrap(); + let g = grants(&input, &output); + let unit = Unit::new(b"cp", &g); + + let copy = OsExecutor::new( + "/bin/cp", + vec![input.join("a.txt").to_str().unwrap().into(), output.join("b.txt").to_str().unwrap().into()], + &dir, + ); + let report = copy.run(&unit).expect("a run with enforceable grants"); + assert_eq!(report.outcome, Outcome::Completed); + assert_eq!(std::fs::read(output.join("b.txt")).unwrap(), b"granted"); + + let peek = OsExecutor::new("/bin/cat", vec![secret.join("s.txt").to_str().unwrap().into()], &dir); + let report = peek.run(&unit).expect("the run itself starts"); + match OsExecutor::confinement() { + Some(c) => { + eprintln!("sandbox-exec-os: {} denied a read outside the grants", c.runtime); + assert_eq!(report.outcome, Outcome::Failed, "a file outside the grants was read"); + let policy = peek.policy(&g).unwrap(); + let m = peek.mechanism().expect("a confined run commits to its mechanism"); + assert_eq!(m, Mechanism::commit(c.runtime.clone(), policy.as_bytes(), &[])); + assert!(m.runtime.starts_with("seatbelt/")); + } + None => { + eprintln!("sandbox-exec-os: no confinement here; the run is S0 and unconfined"); + assert_eq!(report.outcome, Outcome::Completed, "unconfined, the read succeeds"); + assert_eq!(peek.mechanism(), None, "no mechanism at S0"); + } + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// The canonical host path: a trusted unit that writes storage needs exactly +/// S1. Where the confinement holds, the run is sealed at S1 with its +/// mechanism; where it does not, the host refuses to seal it at all. +#[test] +fn run_and_seal_seals_s1_only_where_the_confinement_holds() { + let dir = scratch("seal"); + let (input, output) = (dir.join("in"), dir.join("out")); + std::fs::create_dir_all(&input).unwrap(); + std::fs::create_dir_all(&output).unwrap(); + std::fs::write(input.join("a.txt"), b"granted").unwrap(); + let g = grants(&input, &output); + let unit = Unit::new(b"cp", &g); + let exec = OsExecutor::new( + "/bin/cp", + vec![input.join("a.txt").to_str().unwrap().into(), output.join("b.txt").to_str().unwrap().into()], + &dir, + ); + let supervisor = SigningKey::from_bytes(&[42u8; 32]); + let host = Host::new("sandbox-exec-os", "steward.os", SigningKey::from_bytes(&[41u8; 32]), supervisor.clone()); + let req = TierRequest { trusted: true, ..TierRequest::minimal(&g) }; + let sealed = host.run_and_seal(&exec, &unit, &req, 1, [0; 8], 1, 2); + match OsExecutor::confinement() { + Some(_) => { + let sealed = sealed.expect("sealed at S1"); + assert_eq!(sealed.attestation.tier, Tier::S1); + assert!(sealed.attestation.mechanism.is_some(), "an S1 run carries its mechanism"); + sealed.attestation.verify(&sealed.attestation_sig, &supervisor.verifying_key()).unwrap(); + } + None => { + assert!( + matches!(sealed, Err(RunError::TierMismatch { .. })), + "an S0 executor must not be sealed for a unit that needs S1" + ); + } + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// Where the confinement holds, a unit whose grants it cannot enforce is +/// refused before anything runs, rather than run with a capability its grant +/// denies. +#[test] +fn a_unit_it_cannot_confine_is_refused_before_it_runs() { + if OsExecutor::confinement().is_none() { + eprintln!("sandbox-exec-os: no confinement here; nothing to refuse"); + return; + } + let dir = scratch("refuse"); + let marker = dir.join("ran"); + let mut g = grants(&dir, &dir); + g.host.clock = false; + let unit = Unit::new(b"touch", &g); + let exec = OsExecutor::new("/usr/bin/touch", vec![marker.to_str().unwrap().into()], &dir); + assert!(exec.run(&unit).is_err(), "a grant withholding the clock cannot be enforced natively"); + assert!(!marker.exists(), "the refused unit ran"); + let _ = std::fs::remove_dir_all(&dir); +}