//! Phase 18 (Track E.2) — macOS process-backend hardening acceptance tests. //! //! On macOS the process backend wraps the harness command with //! `sandbox-exec -f -D WORKDIR= ...`. This suite //! drives a python probe that tries to read `/etc/passwd`; under the //! `path_traversal` profile the read is denied by the kernel and the //! probe exits non-zero, matching the verifier's `NotConfirmed` rule. //! //! The suite is gated on `target_os = "macos"`; on Linux / other targets //! it falls through to a placeholder test so //! `cargo nextest run --features dynamic --test sandbox_hardening_macos` //! still discovers something to run. //! //! Run with: //! `cargo nextest run --features dynamic --test sandbox_hardening_macos` #[cfg(all(feature = "dynamic", target_os = "macos"))] mod hardening_tests { use std::path::{Path, PathBuf}; use std::time::Duration; use nyx_scanner::dynamic::harness::BuiltHarness; use nyx_scanner::dynamic::sandbox::process_macos::{ profile_for_caps, sandbox_exec_available, HardeningLevel, SANDBOX_EXEC_BIN_ENV, }; use nyx_scanner::dynamic::sandbox::{ 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 /// read succeeds (escape), 7 when it is denied (sandbox holding), and /// prints a structural marker line for the test to assert on. const PROBE_SOURCE: &str = r#" import sys try: with open("/etc/passwd", "rb") as fh: fh.read(16) print("escape:escaped") sys.exit(0) except Exception as exc: print(f"escape:blocked errno={getattr(exc, 'errno', None)} {exc}") sys.exit(7) "#; fn workdir() -> tempfile::TempDir { tempfile::TempDir::new().expect("temp dir") } fn write_probe(workdir: &Path) -> PathBuf { let path = workdir.join("probe.py"); std::fs::write(&path, PROBE_SOURCE).expect("write probe"); path } fn build_harness(workdir: &Path) -> BuiltHarness { let probe = write_probe(workdir); BuiltHarness { workdir: workdir.to_path_buf(), command: vec![ "/usr/bin/python3".to_owned(), probe.to_string_lossy().into_owned(), ], env: vec![], source: String::new(), entry_source: String::new(), } } fn strict_opts(caps: u32) -> SandboxOptions { SandboxOptions { timeout: Duration::from_secs(10), memory_mib: 256, backend: SandboxBackend::Process, output_limit: 65536, process_hardening: ProcessHardeningProfile::Strict, seccomp_caps: caps, ..SandboxOptions::default() } } fn standard_opts() -> SandboxOptions { SandboxOptions { timeout: Duration::from_secs(10), memory_mib: 256, backend: SandboxBackend::Process, output_limit: 65536, process_hardening: ProcessHardeningProfile::Standard, ..SandboxOptions::default() } } fn stdout_string(out: &sandbox::SandboxOutcome) -> String { String::from_utf8_lossy(&out.stdout).into_owned() } // ── Tests ───────────────────────────────────────────────────────────────── /// Profile selection: `FILE_IO` selects `path_traversal`, etc. #[test] fn profile_for_caps_matches_phase18_table() { const FILE_IO: u32 = 1 << 5; const DESERIALIZE: u32 = 1 << 8; const SSRF: u32 = 1 << 9; const CODE_EXEC: u32 = 1 << 10; assert_eq!(profile_for_caps(FILE_IO), "path_traversal"); assert_eq!(profile_for_caps(SSRF), "ssrf"); assert_eq!(profile_for_caps(CODE_EXEC), "cmdi"); assert_eq!(profile_for_caps(DESERIALIZE), "deserialize"); assert_eq!(profile_for_caps(0), "base"); } /// `sandbox-exec` is on every supported macOS release; the /// availability probe should return `true` on CI macOS runners. /// If a test image strips the binary we want the verifier's /// fallback to engage — see `verify_finding_refuses_filesystem_*`. #[test] fn sandbox_exec_present_on_default_host() { // Clear any override left by a sibling test in the same process. unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; if !sandbox_exec_available() { eprintln!( "SKIP: /usr/bin/sandbox-exec missing on this host — refuse_filesystem_confirm tests still cover the fallback." ); } else { assert!(sandbox_exec_available()); } } /// Phase 18 acceptance (a): a filesystem-escape payload under the /// `path_traversal` profile cannot read `/etc/passwd` — the wrapped /// `sandbox-exec` blocks the open and the probe exits non-zero /// with the `escape:blocked` marker. The verifier reads this as /// `NotConfirmed` (exit != 0 + no sink-hit + no oracle fire). #[test] fn path_traversal_payload_blocked_under_strict() { unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; if !sandbox_exec_available() { eprintln!("SKIP: /usr/bin/sandbox-exec missing — cannot exercise wrap"); return; } const FILE_IO: u32 = 1 << 5; let tmp = workdir(); let harness = build_harness(tmp.path()); let opts = strict_opts(FILE_IO); let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); let stdout = stdout_string(&result); eprintln!("stdout under path_traversal:\n{stdout}"); let outcome = macos_outcome(&result).expect("hardening outcome recorded"); assert_eq!(outcome.level, HardeningLevel::Sandboxed); assert_eq!(outcome.profile, "path_traversal"); assert!( stdout.contains("escape:blocked"), "expected sandbox-exec to block /etc/passwd read; stdout:\n{stdout}" ); assert_ne!( result.exit_code, Some(0), "probe exited 0 — escape succeeded against the sandbox; stdout:\n{stdout}" ); } /// Standard profile: no sandbox-exec wrap, the probe reads /// `/etc/passwd` cleanly and exits 0. Sanity check for the wrap /// gating logic — without it we can't tell whether the strict test /// above is actually exercising the sandbox or a probe quirk. #[test] fn standard_profile_does_not_wrap_with_sandbox_exec() { unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; let tmp = workdir(); let harness = build_harness(tmp.path()); let opts = standard_opts(); 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 — // `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}" ); } /// When `sandbox-exec` is unavailable the wrap is a no-op and the /// 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() { const FILE_IO: u32 = 1 << 5; unsafe { std::env::set_var(SANDBOX_EXEC_BIN_ENV, "/nonexistent/sandbox-exec") }; let tmp = workdir(); let harness = build_harness(tmp.path()); let opts = strict_opts(FILE_IO); let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); let stdout = stdout_string(&result); 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) }; } /// Phase 18 acceptance (b): when sandbox-exec is missing the /// verifier's `refuse_filesystem_confirm` flag flips to `true` /// via `VerifyOptions::from_config`. Filesystem-cap findings then /// short-circuit to `Inconclusive(BackendInsufficient)` instead of /// running unconfined. #[test] fn verify_options_from_config_sets_refuse_when_sandbox_exec_missing() { use nyx_scanner::dynamic::verify::VerifyOptions; use nyx_scanner::utils::config::Config; unsafe { std::env::set_var(SANDBOX_EXEC_BIN_ENV, "/nonexistent/sandbox-exec") }; let opts = VerifyOptions::from_config(&Config::default()); assert!( opts.refuse_filesystem_confirm, "expected refuse_filesystem_confirm=true when sandbox-exec is missing on macOS" ); unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; } /// Companion to the case above: with `sandbox-exec` reachable the /// flag stays `false` so filesystem oracles run normally. #[test] fn verify_options_from_config_does_not_refuse_when_sandbox_exec_present() { use nyx_scanner::dynamic::verify::VerifyOptions; use nyx_scanner::utils::config::Config; unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; if !sandbox_exec_available() { eprintln!("SKIP: /usr/bin/sandbox-exec missing on this host"); return; } let opts = VerifyOptions::from_config(&Config::default()); assert!( !opts.refuse_filesystem_confirm, "refuse_filesystem_confirm should be false when sandbox-exec is reachable" ); } } // Non-macOS placeholder so `cargo nextest run --test sandbox_hardening_macos` // reports something on the Linux row instead of "no tests to run". The real // suite gates every test on `target_os = "macos"`. #[cfg(not(all(feature = "dynamic", target_os = "macos")))] mod non_macos_placeholder { #[test] fn macos_only_suite_skipped_on_this_target() { eprintln!( "SKIP: tests/sandbox_hardening_macos.rs requires `--features dynamic` and target_os = macos" ); } }