//! 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::{ clear_profile_path_cache_for_tests, profile_for_caps, profile_path, sandbox_exec_available, HardeningLevel, SANDBOX_EXEC_BIN_ENV, SB_DENY_DEFAULT_ENV, SB_SEED_DIR_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 ───────────────────────────────────────────────────────────────── /// XXE probe: simulates an XML parser issuing the outbound HTTP /// fetch for an external SYSTEM entity. Targets TEST-NET-1 so the /// DNS layer is sidestepped; under the `xxe.sb` profile the /// outbound connect is denied with EPERM and the probe exits 7. /// Under a default-allow sandbox the connect attempt proceeds and /// the probe exits 0 with the `network-attempted` marker. /// /// The probe source is read in at compile time and written into /// the harness workdir at run time so the sandbox-exec narrow /// `/Users//Library/...` denies cannot accidentally shadow a /// home-relative script-load path. const XXE_PROBE_SOURCE: &str = include_str!("dynamic_fixtures/hardening/xxe_probe.py"); fn write_xxe_probe(workdir: &Path) -> PathBuf { let path = workdir.join("xxe_probe.py"); std::fs::write(&path, XXE_PROBE_SOURCE).expect("write xxe probe"); path } fn build_xxe_harness(workdir: &Path) -> BuiltHarness { let probe = write_xxe_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(), } } /// 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; const XXE: u32 = 1 << 19; 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(XXE), "xxe"); 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) }; } /// Phase 18 acceptance (c): the XXE entity-resolution kill path /// runs the probe under the `xxe.sb` profile and asserts the /// outbound TCP connect against TEST-NET-1 is denied at the /// kernel layer (EPERM). Sanity-cross-checked against the /// `standard` profile run: without the wrap, the same probe gets /// a non-EPERM error class (or a stub-loopback connect succeeds) /// and exits 0 with the `network-attempted` marker. #[test] fn xxe_outbound_blocked_under_strict_xxe_profile() { unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; if !sandbox_exec_available() { eprintln!("SKIP: /usr/bin/sandbox-exec missing — cannot exercise xxe profile"); return; } const XXE: u32 = 1 << 19; let tmp = workdir(); let harness = build_xxe_harness(tmp.path()); let opts = strict_opts(XXE); let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); let stdout = stdout_string(&result); eprintln!("stdout under xxe profile:\n{stdout}"); let outcome = macos_outcome(&result).expect("hardening outcome recorded"); assert_eq!(outcome.level, HardeningLevel::Sandboxed); assert_eq!(outcome.profile, "xxe"); assert!( stdout.contains("xxe:network-denied"), "expected sandbox-exec to deny outbound connect with EPERM; stdout:\n{stdout}" ); assert_eq!( result.exit_code, Some(7), "probe should exit 7 on EPERM-denied connect; stdout:\n{stdout}" ); } /// Cross-check: the same probe under the `standard` profile (no /// sandbox-exec wrap) does not receive EPERM on the outbound /// connect. This guards against a future regression where every /// fixture starts surfacing EPERM and the `xxe` test passes /// vacuously. #[test] fn xxe_probe_under_standard_does_not_surface_eperm() { unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; let tmp = workdir(); let harness = build_xxe_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}"); assert!( result.hardening_outcome.is_none(), "standard profile should not produce a hardening outcome", ); // The probe should NOT report EPERM under the unwrapped run — // it should report `network-attempted` (typical) or // `probe-error` (extremely unlikely). EPERM here would mean // a host-level firewall is independently denying the syscall, // which would mask the sandbox effect. assert!( !stdout.contains("xxe:network-denied"), "standard profile produced an EPERM signal — host firewall \ may be masking the sandbox effect; stdout:\n{stdout}" ); } /// 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" ); } /// Phase 18 verifier-side projection: when a real strict run lands a /// macOS `HardeningRecord`, `summarize_hardening` collapses it into /// the portable [`crate::evidence::HardeningSummary`] that /// `build_verdict` stamps on a `Confirmed` `VerifyResult`. Drives /// the same `sandbox::run` path the existing /// `path_traversal_payload_blocked_under_strict` test uses, then /// asserts on the projection that would land on /// `VerifyResult::hardening_outcome` if this run had triggered the /// finding's oracle. #[test] fn summarize_hardening_lands_path_traversal_on_strict_file_io_run() { 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 summary = nyx_scanner::dynamic::verify::summarize_hardening(&result) .expect("hardening summary should populate after a strict macOS run"); assert_eq!(summary.backend, "macos-process"); assert_eq!(summary.level, "sandboxed"); assert_eq!( summary.profile, "path_traversal", "FILE_IO-cap strict run should select the path_traversal profile" ); assert!( summary.primitives.is_empty(), "macOS backend records no per-primitive entries" ); } /// Standard-profile runs leave `SandboxOutcome::hardening_outcome` /// unset, so `summarize_hardening` returns `None` and /// `VerifyResult::hardening_outcome` stays `None`. Companion to /// `standard_profile_does_not_wrap_with_sandbox_exec`. #[test] fn summarize_hardening_returns_none_for_standard_profile_run() { 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"); assert!( nyx_scanner::dynamic::verify::summarize_hardening(&result).is_none(), "standard profile should leave hardening_outcome unset" ); } /// Companion to the test below: the same fixture under the default /// `harden_profile = "standard"` produces a `Confirmed` verdict /// (path-of-least-resistance) but does *not* stamp a /// `hardening_outcome`. Guards against a future regression where /// `from_config` unconditionally engages Strict — the macOS process /// backend's wrap is opt-in and the operator's verdict shape must /// reflect that. #[test] fn verify_finding_under_standard_leaves_hardening_outcome_unset() { use std::path::PathBuf; let python3_available = std::process::Command::new("/usr/bin/python3") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false); if !python3_available { eprintln!("SKIP: /usr/bin/python3 missing — cannot run python harness"); return; } use nyx_scanner::commands::scan::Diag; use nyx_scanner::dynamic::verify::{verify_finding, VerifyOptions}; use nyx_scanner::evidence::{ Confidence, Evidence, FlowStep, FlowStepKind, VerifyStatus, }; use nyx_scanner::labels::Cap; use nyx_scanner::patterns::{FindingCategory, Severity}; use nyx_scanner::utils::config::Config; let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/dynamic_fixtures/python/cmdi_positive.py"); let tmp = tempfile::TempDir::new().expect("create tempdir"); let dst = tmp.path().join("cmdi_positive.py"); std::fs::copy(&fixture_src, &dst).expect("stage fixture into tempdir"); unsafe { std::env::set_var( "NYX_REPRO_BASE", tmp.path().join("repro").to_str().unwrap(), ); std::env::set_var( "NYX_TELEMETRY_PATH", tmp.path().join("events.jsonl").to_str().unwrap(), ); } let path_str = dst.to_string_lossy().into_owned(); let evidence = Evidence { flow_steps: vec![ FlowStep { step: 1, kind: FlowStepKind::Source, file: path_str.clone(), line: 1, col: 0, snippet: None, variable: Some("host".into()), callee: None, function: Some("run_ping".into()), is_cross_file: false, }, FlowStep { step: 2, kind: FlowStepKind::Sink, file: path_str.clone(), line: 13, col: 4, snippet: None, variable: None, callee: None, function: None, is_cross_file: false, }, ], sink_caps: Cap::CODE_EXEC.bits(), ..Default::default() }; let diag = Diag { path: path_str, line: 13, col: 0, severity: Severity::High, id: "taint-unsanitised-flow".into(), category: FindingCategory::Security, path_validated: false, guard_kind: None, message: None, labels: vec![], confidence: Some(Confidence::High), evidence: Some(evidence), rank_score: None, rank_reason: None, suppressed: false, suppression: None, rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], stable_hash: 0, }; let config = Config::default(); let opts = VerifyOptions::from_config(&config); let result = verify_finding(&diag, &opts); unsafe { std::env::remove_var("NYX_REPRO_BASE"); std::env::remove_var("NYX_TELEMETRY_PATH"); } assert_eq!( result.status, VerifyStatus::Confirmed, "cmdi_positive.py under the default profile should still confirm: detail={:?}", result.detail, ); assert!( result.hardening_outcome.is_none(), "standard profile must not stamp hardening_outcome — the macOS \ process backend never engaged sandbox-exec, so claiming the run \ was sandboxed would be a false witness; got: {:?}", result.hardening_outcome, ); } /// Phase 18 acceptance (d): Strict-profile run of the cmdi positive /// fixture confirms AND stamps `VerifyResult::hardening_outcome`. /// Mirrors `verify_finding_under_standard_leaves_hardening_outcome_unset` /// with `harden_profile = "strict"` so the macOS process backend /// engages `sandbox-exec -f cmdi.sb -D WORKDIR=...` end-to-end. /// The cmdi.sb profile's narrowed `/Users` deny (regex-matched /// secret subpaths only, not a blanket `(subpath "/Users")` deny) /// keeps `_path_importer_cache` reachable so the python harness /// cold-starts; the `subprocess.run("echo NYX_PWN_CMDI", shell=True)` /// invocation in the auto-emitted harness is the sink probe and /// fires under the cmdi profile (process-exec is allowed; filesystem /// reads of host secrets are denied via the inherited denylist). #[test] fn verify_finding_under_strict_stamps_hardening_outcome() { use std::path::PathBuf; unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; if !sandbox_exec_available() { eprintln!("SKIP: /usr/bin/sandbox-exec missing — cannot exercise wrap"); return; } let python3_available = std::process::Command::new("/usr/bin/python3") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false); if !python3_available { eprintln!("SKIP: /usr/bin/python3 missing — cannot run python harness"); return; } use nyx_scanner::commands::scan::Diag; use nyx_scanner::dynamic::verify::{verify_finding, VerifyOptions}; use nyx_scanner::evidence::{ Confidence, Evidence, FlowStep, FlowStepKind, VerifyStatus, }; use nyx_scanner::labels::Cap; use nyx_scanner::patterns::{FindingCategory, Severity}; use nyx_scanner::utils::config::Config; let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/dynamic_fixtures/python/cmdi_positive.py"); let tmp = tempfile::TempDir::new().expect("create tempdir"); let dst = tmp.path().join("cmdi_positive.py"); std::fs::copy(&fixture_src, &dst).expect("stage fixture into tempdir"); unsafe { std::env::set_var( "NYX_REPRO_BASE", tmp.path().join("repro").to_str().unwrap(), ); std::env::set_var( "NYX_TELEMETRY_PATH", tmp.path().join("events.jsonl").to_str().unwrap(), ); } let path_str = dst.to_string_lossy().into_owned(); let evidence = Evidence { flow_steps: vec![ FlowStep { step: 1, kind: FlowStepKind::Source, file: path_str.clone(), line: 1, col: 0, snippet: None, variable: Some("host".into()), callee: None, function: Some("run_ping".into()), is_cross_file: false, }, FlowStep { step: 2, kind: FlowStepKind::Sink, file: path_str.clone(), line: 13, col: 4, snippet: None, variable: None, callee: None, function: None, is_cross_file: false, }, ], sink_caps: Cap::CODE_EXEC.bits(), ..Default::default() }; let diag = Diag { path: path_str, line: 13, col: 0, severity: Severity::High, id: "taint-unsanitised-flow".into(), category: FindingCategory::Security, path_validated: false, guard_kind: None, message: None, labels: vec![], confidence: Some(Confidence::High), evidence: Some(evidence), rank_score: None, rank_reason: None, suppressed: false, suppression: None, rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], stable_hash: 0, }; let mut config = Config::default(); config.scanner.harden_profile = "strict".to_owned(); // Force the process backend: the macOS sandbox-exec wrap is gated // on `SandboxBackend::Process`, and `SandboxBackend::Auto` would // route the python harness to docker when docker is reachable // (the common CI shape). Docker ignores `process_hardening`, so // running under `Auto` would leave `hardening_outcome` unset // regardless of `--harden=strict`, masking the wiring this test // is asserting. config.scanner.verify_backend = "process".to_owned(); let opts = VerifyOptions::from_config(&config); let result = verify_finding(&diag, &opts); unsafe { std::env::remove_var("NYX_REPRO_BASE"); std::env::remove_var("NYX_TELEMETRY_PATH"); } assert_eq!( result.status, VerifyStatus::Confirmed, "cmdi_positive.py under --harden=strict should confirm: detail={:?}", result.detail, ); let summary = result .hardening_outcome .as_ref() .expect("Strict run must stamp hardening_outcome"); assert_eq!( summary.backend, "macos-process", "macOS host should produce a macos-process backend stamp", ); assert_eq!( summary.level, "sandboxed", "Strict-engaged sandbox-exec wrap should record level=sandboxed", ); assert_eq!( summary.profile, "cmdi", "CODE_EXEC-cap finding should land the cmdi profile", ); assert!( summary.primitives.is_empty(), "macOS backend records no per-primitive entries", ); } /// Phase 18 follow-up smoke test: a synthetic seed under /// `NYX_SB_SEED_DIR` rewrites the materialised `.sb` profile to /// `(deny default)` and appends the seed body verbatim. Exercises /// the splice path through the production [`profile_path`] call /// site so the env-var → seed-dir → splice → on-disk file flow is /// validated end-to-end, not just via the unit tests on /// [`splice_deny_default`]. /// /// Uses the `ssrf` profile because no other test in this file /// touches it; the cache-clear helper resets state regardless so /// the assertion holds even if a future test materialises ssrf /// before this one. #[test] fn deny_default_seed_loads_under_strict() { let seed_dir = tempfile::TempDir::new().expect("seed tempdir"); // The seed body is intentionally over-permissive so the // /usr/bin/true probe at the end of the test can clear without // tripping on missing allowances. A real seed generated by // `tools/sb-trace.sh` would be much tighter (only the rules // each interpreter cold-start needs). let seed_body = ";; synthetic seed for end-to-end smoke test\n\ (allow process-fork)\n\ (allow process-exec*)\n\ (allow file-read*)\n\ (allow file-read-metadata)\n\ (allow file-write-data (literal \"/dev/null\"))\n\ (allow mach-lookup)\n\ (allow signal (target self))\n\ (allow sysctl-read)\n\ (allow ipc-posix-shm-read*)\n"; std::fs::write(seed_dir.path().join("ssrf.allow"), seed_body) .expect("write synthetic seed"); clear_profile_path_cache_for_tests(); unsafe { std::env::set_var(SB_DENY_DEFAULT_ENV, "1"); std::env::set_var(SB_SEED_DIR_ENV, seed_dir.path()); } let materialised = profile_path("ssrf").expect("profile materialises"); let body = std::fs::read_to_string(&materialised).expect("read profile body"); unsafe { std::env::remove_var(SB_DENY_DEFAULT_ENV); std::env::remove_var(SB_SEED_DIR_ENV); } clear_profile_path_cache_for_tests(); assert!( body.contains("(deny default)"), "splice should rewrite (allow default) -> (deny default); got: {body}", ); assert!( !body.contains("(allow default)"), "no (allow default) directive should survive the splice; got: {body}", ); assert!( body.contains(";; ── deny-default seed (spliced by NYX_SB_DENY_DEFAULT=1) ──"), "splice banner should appear once; got: {body}", ); assert!( body.contains("(allow process-fork)"), "synthetic seed body should land verbatim; got: {body}", ); assert!( body.contains("(allow mach-lookup)"), "later seed rule should also appear verbatim; got: {body}", ); // The spliced profile should still parse as valid sandbox-exec // syntax when the host has the binary on PATH; skip when it // is missing (stripped CI images) since this assertion is the // only one that needs the live binary. if sandbox_exec_available() { let probe = std::process::Command::new("/usr/bin/sandbox-exec") .arg("-f") .arg(&materialised) .arg("-D") .arg("WORKDIR=/tmp") .arg("/usr/bin/true") .output() .expect("invoke sandbox-exec on spliced profile"); assert!( probe.status.success(), "spliced profile should be valid sandbox-exec syntax; \ status={:?}, stderr={}", probe.status, String::from_utf8_lossy(&probe.stderr), ); } } /// Round-trip the portable summary through JSON to lock in the /// repro-bundle wire shape: `VerifyResult::hardening_outcome` lands /// on `expected/verdict.json` so the eval-corpus tabulator and any /// downstream replay reads the same fields back. #[test] fn hardening_summary_round_trips_through_json() { use nyx_scanner::evidence::{HardeningSummary, HardeningPrimitive}; let summary = HardeningSummary { backend: "macos-process".into(), level: "sandboxed".into(), profile: "path_traversal".into(), primitives: vec![], }; let json = serde_json::to_string(&summary).expect("serialize"); let parsed: HardeningSummary = serde_json::from_str(&json).expect("deserialize"); assert_eq!(parsed, summary); // Defaults: missing `profile` and `primitives` must decode as // empty so older `verdict.json` payloads keep round-tripping. let minimal: HardeningSummary = serde_json::from_str(r#"{"backend":"linux-process","level":"full"}"#) .expect("minimal decode"); assert_eq!(minimal.profile, ""); assert!(minimal.primitives.is_empty()); // Linux-shape: per-primitive entries decode + re-encode with // their `errno` field intact when populated. let with_primitives = HardeningSummary { backend: "linux-process".into(), level: "partial".into(), profile: "strict".into(), primitives: vec![ HardeningPrimitive { name: "no_new_privs".into(), status: "applied".into(), errno: None, }, HardeningPrimitive { name: "seccomp".into(), status: "failed".into(), errno: Some(1), }, ], }; let json = serde_json::to_string(&with_primitives).expect("serialize primitives"); assert!( json.contains("\"errno\":1"), "errno field should survive JSON round-trip; got: {json}" ); let parsed: HardeningSummary = serde_json::from_str(&json).expect("decode primitives"); assert_eq!(parsed, with_primitives); } } // 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" ); } }