sandbox: an S1 executor — a native program confined by the operating system, earning the rung by probe #281
8 changed files with 773 additions and 19 deletions
17
.github/workflows/standards-build.yml
vendored
17
.github/workflows/standards-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
13
sandbox/sandbox-rs/Cargo.lock
generated
13
sandbox/sandbox-rs/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
18
sandbox/sandbox-rs/crates/sandbox-exec-os/Cargo.toml
Normal file
18
sandbox/sandbox-rs/crates/sandbox-exec-os/Cargo.toml
Normal file
|
|
@ -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
|
||||
508
sandbox/sandbox-rs/crates/sandbox-exec-os/src/lib.rs
Normal file
508
sandbox/sandbox-rs/crates/sandbox-exec-os/src/lib.rs
Normal file
|
|
@ -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/<macOS
|
||||
//! version>`) 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/<macOS version>`.
|
||||
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<String>,
|
||||
cwd: PathBuf,
|
||||
/// The policy applied to the last confined run, for [`Executor::mechanism`].
|
||||
last_profile: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl OsExecutor {
|
||||
/// Run `program args` in `cwd`.
|
||||
pub fn new(program: impl Into<String>, args: Vec<String>, cwd: impl Into<PathBuf>) -> 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<Option<Confinement>> = 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<String, Unenforceable> {
|
||||
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<RunReport, ExecError> {
|
||||
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<Mechanism> {
|
||||
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<String> {
|
||||
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::<Vec<_>>())
|
||||
.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<String, Unenforceable> {
|
||||
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<Confinement> {
|
||||
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<bool> {
|
||||
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>) -> 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<Confinement> {
|
||||
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<String>, write: Vec<String>) -> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
134
sandbox/sandbox-rs/crates/sandbox-exec-os/tests/confined.rs
Normal file
134
sandbox/sandbox-rs/crates/sandbox-exec-os/tests/confined.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in a new issue