nyx/tests/sandbox_hardening_macos.rs

266 lines
11 KiB
Rust

//! Phase 18 (Track E.2) — macOS process-backend hardening acceptance tests.
//!
//! On macOS the process backend wraps the harness command with
//! `sandbox-exec -f <profile.sb> -D WORKDIR=<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"
);
}
}