nyx/src/dynamic/sandbox.rs

354 lines
12 KiB
Rust
Raw Normal View History

//! Execution sandbox.
//!
//! The sandbox isolates a [`crate::dynamic::harness::BuiltHarness`] from
//! the host: no outbound network except to the oracle's OOB host, no file
//! writes outside the workdir, hard timeout, memory cap, no host PID
//! visibility.
//!
//! Two backends planned, picked at runtime:
//!
//! - **`docker`**: portable, default on Linux/macOS. Image is a thin debian
//! plus the language toolchain matching `spec.lang`.
//! - **`process`**: fallback for hosts without docker. Uses OS primitives
//! (`unshare` on Linux, `sandbox-exec` on macOS) and runs the harness
//! directly. Less isolation; gated behind `--unsafe-sandbox`.
//!
//! All public state on the sandbox is owned by the caller — there is no
//! global runtime, no daemon, no persistent containers between runs.
use crate::dynamic::corpus::Payload;
use crate::dynamic::harness::BuiltHarness;
use std::time::{Duration, Instant};
/// Result of a single sandboxed run.
#[derive(Debug, Clone)]
pub struct SandboxOutcome {
/// Process exit code; `None` on timeout or signal kill.
pub exit_code: Option<i32>,
/// Captured stdout (truncated to a bound, default 64 KiB).
pub stdout: Vec<u8>,
/// Captured stderr (same bound).
pub stderr: Vec<u8>,
/// Whether the run hit `timeout`.
pub timed_out: bool,
/// Whether the OOB host received a probe.
pub oob_callback_seen: bool,
/// Whether the in-harness `sys.settrace` sink-reachability probe fired.
/// Set by the Python harness via the `__NYX_SINK_HIT__` sentinel in stdout.
pub sink_hit: bool,
/// Wall-clock duration of the run.
pub duration: Duration,
}
#[derive(Debug, Clone)]
pub struct SandboxOptions {
/// Hard timeout. Default: 5s.
pub timeout: Duration,
/// Memory cap in MiB. Default: 256.
pub memory_mib: u64,
/// Backend selection. `Auto` = docker if available, else process.
pub backend: SandboxBackend,
/// Environment variables passed through to the sandboxed process.
/// All other env vars are stripped. Empty = strip everything.
pub env_passthrough: Vec<String>,
/// Maximum stdout/stderr bytes captured. Default: 65536 (64 KiB).
pub output_limit: usize,
}
impl Default for SandboxOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(5),
memory_mib: 256,
backend: SandboxBackend::Auto,
env_passthrough: vec![],
output_limit: 65536,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxBackend {
Auto,
Docker,
Process,
}
#[derive(Debug)]
pub enum SandboxError {
BackendUnavailable(SandboxBackend),
Spawn(std::io::Error),
Io(std::io::Error),
}
impl From<std::io::Error> for SandboxError {
fn from(e: std::io::Error) -> Self {
SandboxError::Io(e)
}
}
/// Run a built harness once with a chosen payload.
///
/// Dispatches to the process backend (subprocess with timeout, env stripping,
/// and memory cap via `setrlimit(RLIMIT_AS)` on Linux).
pub fn run(
harness: &BuiltHarness,
payload: &Payload,
opts: &SandboxOptions,
) -> Result<SandboxOutcome, SandboxError> {
match opts.backend {
SandboxBackend::Docker => Err(SandboxError::BackendUnavailable(SandboxBackend::Docker)),
SandboxBackend::Auto | SandboxBackend::Process => {
run_process(harness, payload, opts)
}
}
}
/// Process backend: spawns the harness command in a subprocess with timeout,
/// stdout/stderr capture, env stripping, and memory cap (Linux: RLIMIT_AS).
fn run_process(
harness: &BuiltHarness,
payload: &Payload,
opts: &SandboxOptions,
) -> Result<SandboxOutcome, SandboxError> {
use std::io::Read;
use std::process::{Command, Stdio};
let cmd_name = harness.command.first().ok_or_else(|| {
SandboxError::Spawn(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"empty command",
))
})?;
let mut cmd = Command::new(cmd_name);
cmd.args(&harness.command[1..]);
cmd.current_dir(&harness.workdir);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
// Strip all env and pass only the allowlist + harness env + payload.
cmd.env_clear();
for k in &opts.env_passthrough {
if let Ok(v) = std::env::var(k) {
cmd.env(k, v);
}
}
for (k, v) in &harness.env {
cmd.env(k, v);
}
// Payload injected via NYX_PAYLOAD env var.
let payload_b64 = base64_encode(payload.bytes);
cmd.env("NYX_PAYLOAD_B64", &payload_b64);
// NYX_PAYLOAD as raw bytes: Unix-only (OsStr can hold arbitrary bytes).
// On other platforms we skip this env var; the harness falls back to NYX_PAYLOAD_B64.
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
cmd.env("NYX_PAYLOAD", std::ffi::OsStr::from_bytes(payload.bytes));
}
// Enforce memory cap before exec on Linux via RLIMIT_AS.
// RLIMIT_AS limits total virtual address space. Python uses significantly
// more virtual AS than RSS (shared libs, mmap arenas), so the enforced
// limit is memory_mib * 8 with a floor of 4 GiB. This prevents multi-GiB
// memory bombs while leaving normal Python workloads headroom.
#[cfg(target_os = "linux")]
{
use std::os::unix::process::CommandExt;
let memory_mib = opts.memory_mib;
// Safety: called in the child after fork but before exec; no allocator use.
unsafe {
cmd.pre_exec(move || rlimit_as_linux(memory_mib));
}
}
let start = Instant::now();
let mut child = cmd.spawn().map_err(SandboxError::Spawn)?;
let timeout = opts.timeout;
let timed_out = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let timed_out_clone = timed_out.clone();
let child_id = child.id();
// Timeout thread: kill the child after the deadline.
let _timer = std::thread::spawn(move || {
std::thread::sleep(timeout);
timed_out_clone.store(true, std::sync::atomic::Ordering::SeqCst);
// SIGKILL the child process.
#[cfg(unix)]
libc_kill(child_id as i32, 9);
#[cfg(not(unix))]
{
let _ = child_id; // unused on non-unix
}
});
// Read stdout/stderr to EOF in parallel threads to avoid pipe-fill deadlock
// and to capture writes that arrive after the first available chunk (e.g.
// probe sentinel printed early, payload output printed later). Each stream
// is capped at `output_limit` bytes via `Read::take`.
let limit = opts.output_limit;
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_handle = stdout_pipe.map(|s| {
std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
let mut buf = Vec::new();
std::io::Read::take(s, limit as u64).read_to_end(&mut buf)?;
Ok(buf)
})
});
let stderr_handle = stderr_pipe.map(|s| {
std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
let mut buf = Vec::new();
std::io::Read::take(s, limit as u64).read_to_end(&mut buf)?;
Ok(buf)
})
});
let status = child.wait().map_err(SandboxError::Io)?;
let stdout_buf = stdout_handle
.and_then(|h| h.join().ok())
.and_then(|r| r.ok())
.unwrap_or_default();
let stderr_buf = stderr_handle
.and_then(|h| h.join().ok())
.and_then(|r| r.ok())
.unwrap_or_default();
let duration = start.elapsed();
let did_time_out = timed_out.load(std::sync::atomic::Ordering::SeqCst);
let exit_code = if did_time_out { None } else { status.code() };
// Check for sink-hit sentinel emitted by the sys.settrace probe.
const SINK_HIT_SENTINEL: &[u8] = b"__NYX_SINK_HIT__";
let sink_hit = contains_subslice(&stdout_buf, SINK_HIT_SENTINEL)
|| contains_subslice(&stderr_buf, SINK_HIT_SENTINEL);
Ok(SandboxOutcome {
exit_code,
stdout: stdout_buf,
stderr: stderr_buf,
timed_out: did_time_out,
oob_callback_seen: false,
sink_hit,
duration,
})
}
fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
if needle.len() > hay.len() {
return false;
}
hay.windows(needle.len()).any(|w| w == needle)
}
fn base64_encode(data: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
if chunk.len() > 1 {
out.push(ALPHABET[((n >> 6) & 63) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
out.push(ALPHABET[(n & 63) as usize] as char);
} else {
out.push('=');
}
}
out
}
/// Set RLIMIT_AS (virtual address space) in a `pre_exec` context on Linux.
///
/// `memory_mib` is the configured cap; we enforce `max(memory_mib * 8, 4096)`
/// MiB of virtual AS to give Python's mmap-heavy runtime adequate headroom
/// while still capping runaway memory bombs.
///
/// RLIMIT_AS = 9 on x86_64, aarch64, arm, ppc64, s390x, and all other major
/// Linux architectures (kernel source: include/uapi/asm-generic/resource.h).
#[cfg(target_os = "linux")]
fn rlimit_as_linux(memory_mib: u64) -> std::io::Result<()> {
#[repr(C)]
struct Rlimit {
cur: u64,
max: u64,
}
unsafe extern "C" {
fn setrlimit(resource: i32, rlim: *const Rlimit) -> i32;
}
const RLIMIT_AS: i32 = 9;
let cap_mib = memory_mib.saturating_mul(8).max(4096);
let bytes = cap_mib.saturating_mul(1024 * 1024);
let rl = Rlimit { cur: bytes, max: bytes };
let ret = unsafe { setrlimit(RLIMIT_AS, &rl) };
if ret == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
#[cfg(unix)]
fn libc_kill(pid: i32, sig: i32) -> i32 {
unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
unsafe { kill(pid, sig) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sink_hit_detected_in_stdout() {
let mut outcome = SandboxOutcome {
exit_code: Some(0),
stdout: b"some output __NYX_SINK_HIT__ more".to_vec(),
stderr: vec![],
timed_out: false,
oob_callback_seen: false,
sink_hit: false,
duration: Duration::from_millis(10),
};
const SENTINEL: &[u8] = b"__NYX_SINK_HIT__";
outcome.sink_hit = contains_subslice(&outcome.stdout, SENTINEL);
assert!(outcome.sink_hit);
}
#[test]
fn sink_hit_not_detected_when_absent() {
let outcome = SandboxOutcome {
exit_code: Some(0),
stdout: b"clean output".to_vec(),
stderr: vec![],
timed_out: false,
oob_callback_seen: false,
sink_hit: false,
duration: Duration::from_millis(10),
};
assert!(!outcome.sink_hit);
}
#[test]
fn base64_encode_basic() {
assert_eq!(base64_encode(b"Man"), "TWFu");
assert_eq!(base64_encode(b"Ma"), "TWE=");
assert_eq!(base64_encode(b"M"), "TQ==");
}
}