mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-21 20:18:06 +02:00
[pitboss] phase 18: Track E.2 — macOS sandbox-exec backend
This commit is contained in:
parent
b127ea2832
commit
6ca9bddedb
12 changed files with 921 additions and 2 deletions
|
|
@ -107,6 +107,7 @@ mod parity_tests {
|
|||
verify_all_confidence: false,
|
||||
summaries: None,
|
||||
callgraph: None,
|
||||
refuse_filesystem_confirm: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +123,7 @@ mod parity_tests {
|
|||
verify_all_confidence: false,
|
||||
summaries: None,
|
||||
callgraph: None,
|
||||
refuse_filesystem_confirm: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
258
tests/sandbox_hardening_macos.rs
Normal file
258
tests/sandbox_hardening_macos.rs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
//! 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::{
|
||||
last_hardening_outcome, profile_for_caps, reset_last_hardening_outcome,
|
||||
sandbox_exec_available, HardeningLevel, SANDBOX_EXEC_BIN_ENV,
|
||||
};
|
||||
use nyx_scanner::dynamic::sandbox::{
|
||||
self, ProcessHardeningProfile, SandboxBackend, SandboxOptions,
|
||||
};
|
||||
|
||||
// ── 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);
|
||||
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");
|
||||
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();
|
||||
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.
|
||||
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
|
||||
/// outcome registry 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);
|
||||
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");
|
||||
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
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue