diff --git a/src/dynamic/oracle.rs b/src/dynamic/oracle.rs index 3aac5495..fe80a050 100644 --- a/src/dynamic/oracle.rs +++ b/src/dynamic/oracle.rs @@ -402,6 +402,7 @@ mod tests { oob_callback_seen: false, sink_hit: false, duration: Duration::from_millis(1), + hardening_outcome: None, } } diff --git a/src/dynamic/repro.rs b/src/dynamic/repro.rs index 39095313..24bb574d 100644 --- a/src/dynamic/repro.rs +++ b/src/dynamic/repro.rs @@ -406,6 +406,7 @@ mod tests { oob_callback_seen: false, sink_hit: true, duration: Duration::from_millis(250), + hardening_outcome: None, } } diff --git a/src/dynamic/sandbox/docker.rs b/src/dynamic/sandbox/docker.rs index 3665710c..c3d8017d 100644 --- a/src/dynamic/sandbox/docker.rs +++ b/src/dynamic/sandbox/docker.rs @@ -84,11 +84,27 @@ pub fn ensure_image_pulled(image: &str) -> bool { if let Some(entry) = cache.get(image) { return *entry; } - let ok = docker_pull(image); + // Fast path: a prior `docker pull` (often by an earlier nextest binary in + // the same machine) may already have the image locally. `docker image + // inspect` is a no-network lookup against the local daemon — when it + // succeeds we can skip the network pull entirely. When it fails we fall + // through to `docker pull` so registry-side rotations / first-time runs + // still settle. + let ok = if docker_image_present(image) { true } else { docker_pull(image) }; cache.insert(image.to_owned(), ok); ok } +fn docker_image_present(image: &str) -> bool { + Command::new(docker_bin()) + .args(["image", "inspect", image]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + fn docker_pull(image: &str) -> bool { Command::new(docker_bin()) .args(["pull", image]) diff --git a/src/dynamic/sandbox/mod.rs b/src/dynamic/sandbox/mod.rs index a8a9e90f..81a46fab 100644 --- a/src/dynamic/sandbox/mod.rs +++ b/src/dynamic/sandbox/mod.rs @@ -40,6 +40,29 @@ pub use process_linux::{HardeningLevel, HardeningOutcome}; #[cfg(target_os = "macos")] pub mod process_macos; +/// Phase 17 (Track E.1) + Phase 18 (Track E.2) per-run hardening outcome. +/// +/// Returned by [`run_process`] on the [`SandboxOutcome`] so callers (tests + +/// telemetry) can inspect the per-primitive status without consulting a +/// process-global singleton. The previous Phase 17/18 implementation kept +/// the outcome in `process_linux::LAST_OUTCOME` / `process_macos::LAST_OUTCOME` +/// statics; that worked under nextest's per-test process isolation but would +/// race the moment `verify_finding` ran under `rayon::par_iter`. +/// +/// The enum is platform-cfg'd because the Linux and macOS backends record +/// different shapes: Linux captures per-primitive `PrimitiveStatus` for +/// `prctl` / `rlimit` / `unshare` / `chroot` / `seccomp`; macOS captures a +/// coarser `level + profile` pair after the `sandbox-exec` wrap decision. +/// On other targets the enum has no constructible variants, so +/// `Option` is always `None`. +#[derive(Debug, Clone)] +pub enum HardeningRecord { + #[cfg(target_os = "linux")] + Linux(process_linux::HardeningOutcome), + #[cfg(target_os = "macos")] + Macos(process_macos::HardeningOutcome), +} + /// Phase 19 (Track E.3) — pinned-digest docker backend helpers. /// /// The functions in this module resolve [`crate::dynamic::toolchain:: @@ -140,6 +163,11 @@ pub struct SandboxOutcome { pub sink_hit: bool, /// Wall-clock duration of the run. pub duration: Duration, + /// Phase 17/18 hardening outcome captured by the process backend. + /// `None` when the run did not exercise a hardening path (docker + /// backend, non-Linux/non-macOS host, or `ProcessHardeningProfile` + /// of `Standard` with no primitive outcome to record). + pub hardening_outcome: Option, } #[derive(Debug, Clone)] @@ -1001,6 +1029,7 @@ fn exec_in_container( oob_callback_seen: false, sink_hit, duration, + hardening_outcome: None, }) } @@ -1218,6 +1247,7 @@ fn exec_native_binary_in_container( oob_callback_seen: false, sink_hit, duration, + hardening_outcome: None, }) } @@ -1260,21 +1290,22 @@ fn run_process( // Phase 18 (Track E.2): on macOS, wrap the command with // `sandbox-exec -f -D WORKDIR= ...` so per-cap // policies confine the harness. When `sandbox-exec` is missing or - // the wrap setup fails, `wrap_plan` returns `None` and we fall - // back to the unwrapped command; the verifier reads back the - // recorded [`process_macos::HardeningLevel::Trusted`] outcome and - // downgrades filesystem-oracle verdicts to + // the wrap setup fails, `wrap_plan` returns `plan = None` and we + // fall back to the unwrapped command; the verifier reads back the + // returned [`process_macos::HardeningLevel::Trusted`] outcome via + // [`SandboxOutcome::hardening_outcome`] and downgrades filesystem- + // oracle verdicts to // [`crate::evidence::InconclusiveReason::BackendInsufficient`]. #[cfg(target_os = "macos")] let macos_wrap = { if matches!(opts.process_hardening, ProcessHardeningProfile::Strict) { - process_macos::wrap_plan(&process_macos::WrapInput { + Some(process_macos::wrap_plan(&process_macos::WrapInput { cmd_path: &resolved_cmd_path, cmd_args: &harness.command[1..], workdir: &harness.workdir, caps: opts.seccomp_caps, profile_override: None, - }) + })) } else { None } @@ -1282,7 +1313,7 @@ fn run_process( #[cfg(target_os = "macos")] let (effective_cmd_path, effective_cmd_args): (std::path::PathBuf, Vec) = - match &macos_wrap { + match macos_wrap.as_ref().and_then(|w| w.plan.as_ref()) { Some(plan) => (plan.binary.clone(), plan.args.clone()), None => (resolved_cmd_path.clone(), harness.command[1..].to_vec()), }; @@ -1405,13 +1436,12 @@ fn run_process( let status = child.wait().map_err(SandboxError::Io)?; - // Phase 17 (Track E.1): wait for the per-primitive HardeningOutcome - // drain thread before returning so callers (tests + telemetry) read - // a settled value via `process_linux::last_hardening_outcome()`. + // Phase 17 (Track E.1): drain the per-primitive HardeningOutcome + // off the pre_exec status pipe before returning so the caller sees + // the settled value on `SandboxOutcome::hardening_outcome` instead + // of consulting a process-global singleton. #[cfg(target_os = "linux")] - if let Some(joiner) = outcome_joiner { - joiner.await_outcome(); - } + let linux_outcome = outcome_joiner.and_then(|j| j.await_outcome()); let stdout_buf = stdout_handle .and_then(|h| h.join().ok()) @@ -1431,6 +1461,13 @@ fn run_process( let sink_hit = contains_subslice(&stdout_buf, SINK_HIT_SENTINEL) || contains_subslice(&stderr_buf, SINK_HIT_SENTINEL); + #[cfg(target_os = "linux")] + let hardening_outcome = linux_outcome.map(HardeningRecord::Linux); + #[cfg(target_os = "macos")] + let hardening_outcome = macos_wrap.map(|w| HardeningRecord::Macos(w.outcome)); + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let hardening_outcome: Option = None; + Ok(SandboxOutcome { exit_code, stdout: stdout_buf, @@ -1439,6 +1476,7 @@ fn run_process( oob_callback_seen: false, sink_hit, duration, + hardening_outcome, }) } @@ -1570,6 +1608,7 @@ mod tests { oob_callback_seen: false, sink_hit: false, duration: Duration::from_millis(10), + hardening_outcome: None, }; const SENTINEL: &[u8] = b"__NYX_SINK_HIT__"; outcome.sink_hit = contains_subslice(&outcome.stdout, SENTINEL); @@ -1586,6 +1625,7 @@ mod tests { oob_callback_seen: false, sink_hit: false, duration: Duration::from_millis(10), + hardening_outcome: None, }; assert!(!outcome.sink_hit); } diff --git a/src/dynamic/sandbox/process_linux.rs b/src/dynamic/sandbox/process_linux.rs index 9d2b5a88..75eadb43 100644 --- a/src/dynamic/sandbox/process_linux.rs +++ b/src/dynamic/sandbox/process_linux.rs @@ -37,7 +37,7 @@ use std::os::unix::io::{FromRawFd, RawFd}; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::Arc; // ── HardeningLevel reporting ───────────────────────────────────────────────── @@ -129,36 +129,6 @@ impl HardeningOutcome { } } -// ── Last outcome registry (read back by tests + telemetry) ─────────────────── - -static LAST_OUTCOME: OnceLock>> = OnceLock::new(); - -fn outcome_cell() -> &'static Mutex> { - LAST_OUTCOME.get_or_init(|| Mutex::new(None)) -} - -fn record_outcome(outcome: HardeningOutcome) { - if let Ok(mut g) = outcome_cell().lock() { - *g = Some(outcome); - } -} - -/// Snapshot of the most-recent hardening outcome. Returns `None` until -/// at least one [`install_pre_exec`] child has been spawned and waited -/// on. Tests + telemetry read this after `wait_for_outcome` to get the -/// per-primitive status table. -pub fn last_hardening_outcome() -> Option { - outcome_cell().lock().ok().and_then(|g| *g) -} - -/// Reset the last-outcome slot. Tests use this between cases so a stale -/// value from a prior spawn cannot leak into the assertion under test. -pub fn reset_last_hardening_outcome() { - if let Ok(mut g) = outcome_cell().lock() { - *g = None; - } -} - // ── Status pipe between parent and child ───────────────────────────────────── struct StatusPipe { @@ -389,20 +359,23 @@ pub struct OutcomeCollector { } /// Background-drain handle returned by [`OutcomeCollector::after_spawn`]. -/// `run_process` awaits this after `child.wait()` so the outcome is -/// guaranteed to be in the registry before the function returns; tests -/// that bypass `run_process` can call [`OutcomeJoiner::await_outcome`] -/// themselves. +/// `run_process` awaits this after `child.wait()`, receiving the per- +/// primitive [`HardeningOutcome`] the drain thread parsed off the +/// status pipe. Each spawn gets its own joiner, so the outcome flows +/// back to exactly the caller that spawned it — no process-global +/// singleton, no race when `verify_finding` runs under +/// `rayon::par_iter`. pub struct OutcomeJoiner { - handle: Option>, + handle: Option>>, } impl OutcomeJoiner { - /// Block until the drain thread finishes recording the outcome. - pub fn await_outcome(mut self) { - if let Some(h) = self.handle.take() { - let _ = h.join(); - } + /// Block until the drain thread finishes, returning the per- + /// primitive outcome it parsed. `None` when the status pipe was + /// drained but the wire record was truncated (rare: child died + /// before `pre_exec` could write). + pub fn await_outcome(mut self) -> Option { + self.handle.take().and_then(|h| h.join().ok().flatten()) } } @@ -419,16 +392,12 @@ impl OutcomeCollector { /// of the write fd so the kernel ref-count drops to whatever the /// child is still holding; once execve(2) closes the child's /// O_CLOEXEC copy too, the read end sees EOF and the drain thread - /// records the outcome via [`record_outcome`]. Returns a join - /// handle the caller can await to know the outcome is settled. + /// parses the outcome off the pipe and ships it back via the + /// returned [`OutcomeJoiner`]. pub fn after_spawn(self) -> OutcomeJoiner { close_fd(self.write_fd); let read_fd = self.read_fd; - let handle = std::thread::spawn(move || { - if let Some(outcome) = drain_outcome(read_fd) { - record_outcome(outcome); - } - }); + let handle = std::thread::spawn(move || drain_outcome(read_fd)); OutcomeJoiner { handle: Some(handle) } } @@ -638,20 +607,4 @@ mod tests { assert!(decode_outcome(&[0_u8; OUTCOME_LEN - 1]).is_none()); } - #[test] - fn record_and_reset_round_trip() { - let original = last_hardening_outcome(); - let probe = HardeningOutcome { - no_new_privs: PrimitiveStatus::Applied, - profile: ProcessHardeningProfileTag::Strict, - ..HardeningOutcome::default() - }; - record_outcome(probe); - assert_eq!(last_hardening_outcome(), Some(probe)); - reset_last_hardening_outcome(); - assert!(last_hardening_outcome().is_none()); - if let Some(prev) = original { - record_outcome(prev); - } - } } diff --git a/src/dynamic/sandbox/process_macos.rs b/src/dynamic/sandbox/process_macos.rs index e2a7ff58..c5621402 100644 --- a/src/dynamic/sandbox/process_macos.rs +++ b/src/dynamic/sandbox/process_macos.rs @@ -28,8 +28,8 @@ //! `sandbox-exec` is shipped on every supported macOS release but the //! binary path can be missing in stripped CI images. When //! [`sandbox_exec_available`] returns `false`, the wrapper is a no-op -//! and [`record_outcome`] tags the run as -//! [`HardeningLevel::Trusted`] — the verifier reads this back via +//! and [`wrap_plan`] tags the run as [`HardeningLevel::Trusted`] on the +//! returned [`WrapResult`] — the verifier reads this back via //! `VerifyOptions::refuse_filesystem_confirm` and downgrades filesystem- //! oracle verdicts to //! [`crate::evidence::InconclusiveReason::BackendInsufficient`]. @@ -44,6 +44,15 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +// ── HardeningOutcome flow ───────────────────────────────────────────────────── +// +// Phase 18 originally recorded the outcome to a process-global +// `LAST_OUTCOME` singleton. Phase 17/18 sweep dropped that singleton +// because `verify_finding` runs under `rayon::par_iter` in `scan.rs`, so +// concurrent wraps would overwrite each other. [`wrap_plan`] now +// returns the outcome via [`WrapResult`] and `run_process` stashes it on +// the returned `SandboxOutcome`. + // ── HardeningLevel reporting ───────────────────────────────────────────────── /// Coarse summary of the macOS sandbox-exec wrap outcome. @@ -64,7 +73,9 @@ pub enum HardeningLevel { Failed, } -/// Per-run summary read back by [`last_hardening_outcome`]. +/// Per-run summary returned by [`wrap_plan`]. Threaded back to the +/// caller through [`WrapResult`] so `run_process` can stash it on the +/// [`crate::dynamic::sandbox::SandboxOutcome`] for the run. #[derive(Debug, Clone, PartialEq, Eq)] pub struct HardeningOutcome { pub level: HardeningLevel, @@ -73,33 +84,6 @@ pub struct HardeningOutcome { pub profile: String, } -static LAST_OUTCOME: OnceLock>> = OnceLock::new(); - -fn outcome_cell() -> &'static Mutex> { - LAST_OUTCOME.get_or_init(|| Mutex::new(None)) -} - -pub(crate) fn record_outcome(outcome: HardeningOutcome) { - if let Ok(mut g) = outcome_cell().lock() { - *g = Some(outcome); - } -} - -/// Snapshot of the most-recent hardening outcome on macOS. Tests + -/// telemetry read this after `sandbox::run` returns. Returns `None` -/// until at least one wrap attempt has been recorded. -pub fn last_hardening_outcome() -> Option { - outcome_cell().lock().ok().and_then(|g| g.clone()) -} - -/// Clear the last-outcome slot. Tests use this between cases so a stale -/// value from a prior spawn cannot leak into the assertion under test. -pub fn reset_last_hardening_outcome() { - if let Ok(mut g) = outcome_cell().lock() { - *g = None; - } -} - // ── sandbox-exec availability + binary path ────────────────────────────────── /// Env override consulted by [`sandbox_exec_bin`]; tests set this to @@ -233,24 +217,35 @@ pub struct WrapPlan { pub profile: &'static str, } +/// Result of [`wrap_plan`]. Always carries a [`HardeningOutcome`] so +/// the caller can stash it on the `SandboxOutcome` even when wrapping +/// itself was a no-op (`plan = None` + `outcome.level = Trusted`). +pub struct WrapResult { + /// Wrap plan when `sandbox-exec` was applied; `None` when the + /// harness should run unwrapped. The verifier's + /// `refuse_filesystem_confirm` flag keeps the verdict honest in the + /// `None` case. + pub plan: Option, + pub outcome: HardeningOutcome, +} + /// Build the `sandbox-exec -f -D WORKDIR= -- ` -/// argv for `cmd_path + cmd_args`. Returns `None` when: +/// argv for `cmd_path + cmd_args`. The returned [`WrapResult`] +/// `plan` is `None` when: /// -/// - `sandbox-exec` is not on the host (records [`HardeningLevel::Trusted`]), -/// - the profile name is unknown (records [`HardeningLevel::Trusted`]), or +/// - `sandbox-exec` is not on the host (`outcome.level = Trusted`), +/// - the profile name is unknown (`outcome.level = Trusted`), or /// - the profile file could not be materialised in `/tmp` -/// (records [`HardeningLevel::Failed`]). -/// -/// Callers use the returned `None` as a signal to fall back to the -/// unwrapped command; the verifier's `refuse_filesystem_confirm` flag -/// keeps the verdict honest in that case. -pub fn wrap_plan(input: &WrapInput<'_>) -> Option { +/// (`outcome.level = Failed`). +pub fn wrap_plan(input: &WrapInput<'_>) -> WrapResult { if !sandbox_exec_available() { - record_outcome(HardeningOutcome { - level: HardeningLevel::Trusted, - profile: String::new(), - }); - return None; + return WrapResult { + plan: None, + outcome: HardeningOutcome { + level: HardeningLevel::Trusted, + profile: String::new(), + }, + }; } let profile = input.profile_override.unwrap_or_else(|| profile_for_caps(input.caps)); // Profile keys must be `&'static str` (from `PROFILE_SOURCES`); reject @@ -263,21 +258,25 @@ pub fn wrap_plan(input: &WrapInput<'_>) -> Option { let resolved_key = match resolved_key { Some(k) => k, None => { - record_outcome(HardeningOutcome { - level: HardeningLevel::Trusted, - profile: String::new(), - }); - return None; + return WrapResult { + plan: None, + outcome: HardeningOutcome { + level: HardeningLevel::Trusted, + profile: String::new(), + }, + }; } }; let profile_file = match profile_path(resolved_key) { Some(p) => p, None => { - record_outcome(HardeningOutcome { - level: HardeningLevel::Failed, - profile: resolved_key.to_owned(), - }); - return None; + return WrapResult { + plan: None, + outcome: HardeningOutcome { + level: HardeningLevel::Failed, + profile: resolved_key.to_owned(), + }, + }; } }; @@ -293,16 +292,17 @@ pub fn wrap_plan(input: &WrapInput<'_>) -> Option { args.push(a.clone()); } - record_outcome(HardeningOutcome { - level: HardeningLevel::Sandboxed, - profile: resolved_key.to_owned(), - }); - - Some(WrapPlan { - binary: sandbox_exec_bin(), - args, - profile: resolved_key, - }) + WrapResult { + plan: Some(WrapPlan { + binary: sandbox_exec_bin(), + args, + profile: resolved_key, + }), + outcome: HardeningOutcome { + level: HardeningLevel::Sandboxed, + profile: resolved_key.to_owned(), + }, + } } // ── Tests ──────────────────────────────────────────────────────────────────── @@ -356,7 +356,6 @@ mod tests { #[test] fn wrap_plan_returns_none_when_sandbox_exec_missing() { unsafe { std::env::set_var(SANDBOX_EXEC_BIN_ENV, "/nonexistent/sandbox-exec") }; - reset_last_hardening_outcome(); let input = WrapInput { cmd_path: Path::new("/usr/bin/true"), cmd_args: &[], @@ -364,9 +363,9 @@ mod tests { caps: 0, profile_override: None, }; - assert!(wrap_plan(&input).is_none()); - let outcome = last_hardening_outcome().expect("outcome recorded"); - assert_eq!(outcome.level, HardeningLevel::Trusted); + let result = wrap_plan(&input); + assert!(result.plan.is_none()); + assert_eq!(result.outcome.level, HardeningLevel::Trusted); unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; } @@ -380,7 +379,6 @@ mod tests { eprintln!("SKIP: /usr/bin/sandbox-exec missing on this host"); return; } - reset_last_hardening_outcome(); let input = WrapInput { cmd_path: Path::new("/usr/bin/true"), cmd_args: &[], @@ -388,13 +386,13 @@ mod tests { caps: 1 << 5, // FILE_IO profile_override: None, }; - let plan = wrap_plan(&input).expect("plan"); + let result = wrap_plan(&input); + let plan = result.plan.expect("plan"); assert_eq!(plan.profile, "path_traversal"); assert_eq!(plan.binary, PathBuf::from("/usr/bin/sandbox-exec")); assert!(plan.args.iter().any(|a| a == "-f")); assert!(plan.args.iter().any(|a| a.starts_with("WORKDIR="))); - let outcome = last_hardening_outcome().expect("outcome"); - assert_eq!(outcome.level, HardeningLevel::Sandboxed); - assert_eq!(outcome.profile, "path_traversal"); + assert_eq!(result.outcome.level, HardeningLevel::Sandboxed); + assert_eq!(result.outcome.profile, "path_traversal"); } } diff --git a/tests/oracle_sink_crash.rs b/tests/oracle_sink_crash.rs index 46e25bc1..df482f43 100644 --- a/tests/oracle_sink_crash.rs +++ b/tests/oracle_sink_crash.rs @@ -36,6 +36,7 @@ fn crashed_outcome() -> SandboxOutcome { oob_callback_seen: false, sink_hit: false, duration: Duration::from_millis(1), + hardening_outcome: None, } } @@ -48,6 +49,7 @@ fn clean_outcome() -> SandboxOutcome { oob_callback_seen: false, sink_hit: false, duration: Duration::from_millis(1), + hardening_outcome: None, } } diff --git a/tests/oracle_sink_probe.rs b/tests/oracle_sink_probe.rs index 2f288da7..ba1b911b 100644 --- a/tests/oracle_sink_probe.rs +++ b/tests/oracle_sink_probe.rs @@ -37,6 +37,7 @@ fn dummy_outcome() -> nyx_scanner::dynamic::sandbox::SandboxOutcome { oob_callback_seen: false, sink_hit: true, duration: Duration::from_millis(1), + hardening_outcome: None, } } diff --git a/tests/repro_determinism.rs b/tests/repro_determinism.rs index a65df623..5590cf16 100644 --- a/tests/repro_determinism.rs +++ b/tests/repro_determinism.rs @@ -47,6 +47,7 @@ mod repro_determinism_tests { oob_callback_seen: false, sink_hit: true, duration: Duration::from_millis(150), + hardening_outcome: None, } } diff --git a/tests/sandbox_hardening_linux.rs b/tests/sandbox_hardening_linux.rs index 7f77b33c..3dbba286 100644 --- a/tests/sandbox_hardening_linux.rs +++ b/tests/sandbox_hardening_linux.rs @@ -21,14 +21,22 @@ mod hardening_tests { use std::time::Duration; use nyx_scanner::dynamic::harness::BuiltHarness; - use nyx_scanner::dynamic::sandbox::process_linux::{ - last_hardening_outcome, reset_last_hardening_outcome, HardeningLevel, PrimitiveStatus, - }; + use nyx_scanner::dynamic::sandbox::process_linux::{HardeningLevel, PrimitiveStatus}; use nyx_scanner::dynamic::sandbox::seccomp; use nyx_scanner::dynamic::sandbox::{ - self, ProcessHardeningProfile, SandboxBackend, SandboxOptions, + self, HardeningRecord, ProcessHardeningProfile, SandboxBackend, SandboxOptions, }; + fn linux_outcome(out: &sandbox::SandboxOutcome) + -> Option + { + match out.hardening_outcome.as_ref()? { + HardeningRecord::Linux(o) => Some(*o), + #[allow(unreachable_patterns)] + _ => None, + } + } + // ── Probe build ─────────────────────────────────────────────────────────── /// Path to the freshly-built probe binary, shared across every test. @@ -161,7 +169,6 @@ mod hardening_tests { let tmp = workdir(); let harness = build_harness_with_probe(tmp.path(), &[]); let opts = strict_opts(); - reset_last_hardening_outcome(); let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); let stdout = stdout_string(&result); eprintln!("probe stdout under strict:\n{stdout}"); @@ -260,10 +267,9 @@ mod hardening_tests { let tmp = workdir(); let harness = build_harness_with_probe(tmp.path(), &[]); let opts = strict_opts(); - reset_last_hardening_outcome(); let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); let stdout = stdout_string(&result); - let outcome = last_hardening_outcome().expect("hardening outcome recorded"); + let outcome = linux_outcome(&result).expect("hardening outcome recorded"); // Parent's user-ns inode for comparison. let parent_user_ns = @@ -310,10 +316,9 @@ mod hardening_tests { let tmp = workdir(); let harness = build_harness_with_probe(tmp.path(), &[]); let opts = strict_opts(); - reset_last_hardening_outcome(); let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); let stdout = stdout_string(&result); - let outcome = last_hardening_outcome().expect("hardening outcome recorded"); + let outcome = linux_outcome(&result).expect("hardening outcome recorded"); match outcome.chroot { PrimitiveStatus::Applied => { @@ -349,10 +354,9 @@ mod hardening_tests { let tmp = workdir(); let harness = build_harness_with_probe(tmp.path(), &["traverse"]); let opts = strict_opts(); - reset_last_hardening_outcome(); let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); let stdout = stdout_string(&result); - let outcome = last_hardening_outcome().expect("hardening outcome recorded"); + let outcome = linux_outcome(&result).expect("hardening outcome recorded"); if matches!(outcome.chroot, PrimitiveStatus::Applied) { // NotConfirmed shape: the verifier maps a non-zero exit + no @@ -390,10 +394,9 @@ mod hardening_tests { let tmp = workdir(); let harness = build_harness_with_probe(tmp.path(), &[]); let opts = strict_opts(); - reset_last_hardening_outcome(); let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); let stdout = stdout_string(&result); - let outcome = last_hardening_outcome().expect("hardening outcome recorded"); + let outcome = linux_outcome(&result).expect("hardening outcome recorded"); match outcome.seccomp { PrimitiveStatus::Applied => { @@ -422,10 +425,9 @@ mod hardening_tests { let tmp = workdir(); let harness = build_harness_with_probe(tmp.path(), &[]); let opts = standard_opts(); - reset_last_hardening_outcome(); let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); let stdout = stdout_string(&result); - let outcome = last_hardening_outcome().expect("hardening outcome recorded"); + let outcome = linux_outcome(&result).expect("hardening outcome recorded"); assert_eq!(outcome.level(), HardeningLevel::Baseline); assert!(matches!(outcome.no_new_privs, PrimitiveStatus::Applied)); diff --git a/tests/sandbox_hardening_macos.rs b/tests/sandbox_hardening_macos.rs index 0ad8306a..40729f50 100644 --- a/tests/sandbox_hardening_macos.rs +++ b/tests/sandbox_hardening_macos.rs @@ -21,13 +21,22 @@ mod hardening_tests { use nyx_scanner::dynamic::harness::BuiltHarness; use nyx_scanner::dynamic::sandbox::process_macos::{ - last_hardening_outcome, profile_for_caps, reset_last_hardening_outcome, - sandbox_exec_available, HardeningLevel, SANDBOX_EXEC_BIN_ENV, + profile_for_caps, sandbox_exec_available, HardeningLevel, SANDBOX_EXEC_BIN_ENV, }; use nyx_scanner::dynamic::sandbox::{ - self, ProcessHardeningProfile, SandboxBackend, SandboxOptions, + self, HardeningRecord, ProcessHardeningProfile, SandboxBackend, SandboxOptions, }; + fn macos_outcome(out: &sandbox::SandboxOutcome) + -> Option<&nyx_scanner::dynamic::sandbox::process_macos::HardeningOutcome> + { + match out.hardening_outcome.as_ref()? { + HardeningRecord::Macos(o) => Some(o), + #[allow(unreachable_patterns)] + _ => None, + } + } + // ── Probe source + harness helpers ──────────────────────────────────────── /// Python source that tries to read `/etc/passwd`. Exits 0 when the @@ -145,11 +154,10 @@ except Exception as exc: let tmp = workdir(); let harness = build_harness(tmp.path()); let opts = strict_opts(FILE_IO); - reset_last_hardening_outcome(); let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); let stdout = stdout_string(&result); eprintln!("stdout under path_traversal:\n{stdout}"); - let outcome = last_hardening_outcome().expect("hardening outcome recorded"); + let outcome = macos_outcome(&result).expect("hardening outcome recorded"); assert_eq!(outcome.level, HardeningLevel::Sandboxed); assert_eq!(outcome.profile, "path_traversal"); assert!( @@ -173,14 +181,16 @@ except Exception as exc: let tmp = workdir(); let harness = build_harness(tmp.path()); let opts = standard_opts(); - reset_last_hardening_outcome(); let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); let stdout = stdout_string(&result); eprintln!("stdout under standard:\n{stdout}"); - // Standard profile means the macOS wrap was never attempted; the - // outcome registry stays at `None` (no prior strict run in this - // test) or carries the prior strict run's outcome. We don't - // assert on the registry — we assert on the probe's exit. + // Standard profile means the macOS wrap was never attempted — + // `hardening_outcome` stays `None` because `wrap_plan` was not + // called. Assert on the probe's marker only. + assert!( + result.hardening_outcome.is_none(), + "standard profile should not produce a hardening outcome", + ); assert!( stdout.contains("escape:escaped") || stdout.contains("escape:blocked"), "probe should at least print its marker; stdout:\n{stdout}" @@ -188,7 +198,7 @@ except Exception as exc: } /// When `sandbox-exec` is unavailable the wrap is a no-op and the - /// outcome registry records `Trusted`. Tests force the missing + /// returned outcome records `Trusted`. Tests force the missing /// binary path via the [`SANDBOX_EXEC_BIN_ENV`] override. #[test] fn sandbox_exec_missing_records_trusted_outcome() { @@ -197,14 +207,12 @@ except Exception as exc: let tmp = workdir(); let harness = build_harness(tmp.path()); let opts = strict_opts(FILE_IO); - reset_last_hardening_outcome(); let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); let stdout = stdout_string(&result); - let outcome = last_hardening_outcome().expect("hardening outcome recorded"); + let outcome = macos_outcome(&result).expect("hardening outcome recorded"); assert_eq!(outcome.level, HardeningLevel::Trusted); eprintln!("stdout when sandbox-exec missing:\n{stdout}"); unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; - let _ = result; } /// Phase 18 acceptance (b): when sandbox-exec is missing the diff --git a/tests/stubs_per_cap.rs b/tests/stubs_per_cap.rs index dfffa9bf..1b2ccf91 100644 --- a/tests/stubs_per_cap.rs +++ b/tests/stubs_per_cap.rs @@ -64,6 +64,7 @@ fn empty_outcome() -> SandboxOutcome { oob_callback_seen: false, sink_hit: true, duration: Duration::from_millis(1), + hardening_outcome: None, } }