mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-06 19:35:13 +02:00
606 lines
25 KiB
Rust
606 lines
25 KiB
Rust
|
|
//! Sandbox-escape regression suite (§8.3, §15.2 accept-gate row).
|
||
|
|
//!
|
||
|
|
//! Each test attempts a known sandbox escape technique via the docker backend.
|
||
|
|
//! The assertion is: harness exit ≠ 0 OR `SandboxOutcome.timed_out` OR
|
||
|
|
//! `Err(SandboxError::BackendUnavailable)`. No test may produce
|
||
|
|
//! `NYX_ESCAPE_SUCCESS` in stdout/stderr; the host marker file must remain
|
||
|
|
//! absent.
|
||
|
|
//!
|
||
|
|
//! Tests skip automatically when docker is not available on the host
|
||
|
|
//! (`docker info` fails). The `linux-with-docker` CI matrix row is the
|
||
|
|
//! authoritative gate; `linux-without` and `macos` rows skip this suite.
|
||
|
|
//!
|
||
|
|
//! Run with: `cargo nextest run --features dynamic --test dynamic_sandbox_escape`
|
||
|
|
|
||
|
|
#[cfg(feature = "dynamic")]
|
||
|
|
mod escape_tests {
|
||
|
|
use nyx_scanner::dynamic::harness::BuiltHarness;
|
||
|
|
use nyx_scanner::dynamic::sandbox::{
|
||
|
|
self, NetworkPolicy, SandboxBackend, SandboxError, SandboxOptions,
|
||
|
|
};
|
||
|
|
use std::fs;
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
use std::time::Duration;
|
||
|
|
|
||
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
fn docker_available() -> bool {
|
||
|
|
std::process::Command::new("docker")
|
||
|
|
.arg("info")
|
||
|
|
.stdout(std::process::Stdio::null())
|
||
|
|
.stderr(std::process::Stdio::null())
|
||
|
|
.status()
|
||
|
|
.map(|s| s.success())
|
||
|
|
.unwrap_or(false)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Copy a fixture script to a fresh temp workdir and return a BuiltHarness.
|
||
|
|
fn harness_for_fixture(fixture_name: &str) -> (tempfile::TempDir, BuiltHarness) {
|
||
|
|
let tmpdir = tempfile::TempDir::new().expect("temp dir");
|
||
|
|
let script_src = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
|
|
.join("tests/dynamic_fixtures/escape")
|
||
|
|
.join(fixture_name);
|
||
|
|
let script_dst = tmpdir.path().join(fixture_name);
|
||
|
|
fs::copy(&script_src, &script_dst).expect("copy fixture");
|
||
|
|
|
||
|
|
let harness = BuiltHarness {
|
||
|
|
workdir: tmpdir.path().to_path_buf(),
|
||
|
|
command: vec!["python3".into(), fixture_name.into()],
|
||
|
|
env: vec![],
|
||
|
|
source: String::new(),
|
||
|
|
entry_source: String::new(),
|
||
|
|
};
|
||
|
|
(tmpdir, harness)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn escape_opts() -> SandboxOptions {
|
||
|
|
SandboxOptions {
|
||
|
|
timeout: Duration::from_secs(10),
|
||
|
|
memory_mib: 256,
|
||
|
|
backend: SandboxBackend::Docker,
|
||
|
|
network_policy: NetworkPolicy::None,
|
||
|
|
..SandboxOptions::default()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Minimal no-op payload bytes (escape scripts ignore NYX_PAYLOAD).
|
||
|
|
/// `sandbox::run` takes `&[u8]` directly; the CuratedPayload struct lives
|
||
|
|
/// one level up in the runner.
|
||
|
|
fn noop_payload() -> &'static [u8] {
|
||
|
|
b""
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Copy a directory tree into a destination (creating it if needed).
|
||
|
|
fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
|
||
|
|
fs::create_dir_all(dst)?;
|
||
|
|
for entry in fs::read_dir(src)? {
|
||
|
|
let entry = entry?;
|
||
|
|
let ty = entry.file_type()?;
|
||
|
|
let dst_path = dst.join(entry.file_name());
|
||
|
|
if ty.is_dir() {
|
||
|
|
copy_dir_recursive(&entry.path(), &dst_path)?;
|
||
|
|
} else {
|
||
|
|
fs::copy(entry.path(), &dst_path)?;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Assert that an escape fixture did not escape.
|
||
|
|
///
|
||
|
|
/// Accepts three outcomes as "blocked":
|
||
|
|
/// 1. `Err(SandboxError::BackendUnavailable)` — docker absent, no escape possible.
|
||
|
|
/// 2. `Ok(outcome)` where `exit_code != Some(0)` — script hit an error (blocked).
|
||
|
|
/// 3. `Ok(outcome)` where `timed_out` — script ran into sandbox timeout.
|
||
|
|
///
|
||
|
|
/// Rejects: `Ok(outcome)` with `exit_code == Some(0)` and stdout/stderr
|
||
|
|
/// containing `NYX_ESCAPE_SUCCESS`.
|
||
|
|
fn assert_no_escape(
|
||
|
|
result: Result<sandbox::SandboxOutcome, SandboxError>,
|
||
|
|
fixture: &str,
|
||
|
|
marker_path: Option<&PathBuf>,
|
||
|
|
) {
|
||
|
|
match result {
|
||
|
|
Err(SandboxError::BackendUnavailable(_)) => {
|
||
|
|
// Docker not available — escape impossible. Pass.
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
Err(_) => {
|
||
|
|
// Other errors (spawn failure, I/O) — process didn't complete
|
||
|
|
// successfully, so no escape occurred.
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
Ok(ref outcome) => {
|
||
|
|
// Check for escape sentinel in output.
|
||
|
|
let stdout = std::str::from_utf8(&outcome.stdout).unwrap_or("");
|
||
|
|
let stderr = std::str::from_utf8(&outcome.stderr).unwrap_or("");
|
||
|
|
assert!(
|
||
|
|
!stdout.contains("NYX_ESCAPE_SUCCESS")
|
||
|
|
&& !stderr.contains("NYX_ESCAPE_SUCCESS"),
|
||
|
|
"fixture {fixture}: escape succeeded!\nstdout: {stdout}\nstderr: {stderr}"
|
||
|
|
);
|
||
|
|
|
||
|
|
// exit_code == Some(0) AND no sentinel = highly suspicious but not
|
||
|
|
// an escape. Accept; the fixture may have exited 0 on a partial failure.
|
||
|
|
// The sentinel check above is the authoritative guard.
|
||
|
|
let _ = outcome;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check host marker file (for file_write_outside_workdir).
|
||
|
|
if let Some(marker) = marker_path {
|
||
|
|
assert!(
|
||
|
|
!marker.exists(),
|
||
|
|
"fixture {fixture}: host marker file appeared at {marker:?} — escape succeeded!"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Individual escape tests ───────────────────────────────────────────────
|
||
|
|
// All tests follow the same pattern: skip if docker absent, run fixture,
|
||
|
|
// assert no escape.
|
||
|
|
|
||
|
|
macro_rules! escape_test {
|
||
|
|
($name:ident, $fixture:literal) => {
|
||
|
|
#[test]
|
||
|
|
fn $name() {
|
||
|
|
if !docker_available() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let (_tmpdir, harness) = harness_for_fixture($fixture);
|
||
|
|
let result = sandbox::run(&harness, &noop_payload(), &escape_opts());
|
||
|
|
assert_no_escape(result, $fixture, None);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
($name:ident, $fixture:literal, linux_only) => {
|
||
|
|
// macOS Docker Desktop does not enforce host /tmp isolation or
|
||
|
|
// pid-cgroup limits the way the Linux backend does, so these
|
||
|
|
// fixtures escape on macOS. The `linux-with-docker` CI row is
|
||
|
|
// the authoritative gate (see module docstring).
|
||
|
|
#[cfg(target_os = "linux")]
|
||
|
|
#[test]
|
||
|
|
fn $name() {
|
||
|
|
if !docker_available() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let (_tmpdir, harness) = harness_for_fixture($fixture);
|
||
|
|
let result = sandbox::run(&harness, &noop_payload(), &escape_opts());
|
||
|
|
assert_no_escape(result, $fixture, None);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
($name:ident, $fixture:literal, marker = $marker:expr) => {
|
||
|
|
#[test]
|
||
|
|
fn $name() {
|
||
|
|
if !docker_available() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let marker: PathBuf = PathBuf::from($marker);
|
||
|
|
// Remove stale marker before test.
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
let (_tmpdir, harness) = harness_for_fixture($fixture);
|
||
|
|
let result = sandbox::run(&harness, &noop_payload(), &escape_opts());
|
||
|
|
assert_no_escape(result, $fixture, Some(&marker));
|
||
|
|
// Cleanup any partial writes.
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
($name:ident, $fixture:literal, marker = $marker:expr, linux_only) => {
|
||
|
|
#[cfg(target_os = "linux")]
|
||
|
|
#[test]
|
||
|
|
fn $name() {
|
||
|
|
if !docker_available() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let marker: PathBuf = PathBuf::from($marker);
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
let (_tmpdir, harness) = harness_for_fixture($fixture);
|
||
|
|
let result = sandbox::run(&harness, &noop_payload(), &escape_opts());
|
||
|
|
assert_no_escape(result, $fixture, Some(&marker));
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
escape_test!(
|
||
|
|
escape_file_write_outside_workdir,
|
||
|
|
"file_write_outside_workdir.py",
|
||
|
|
marker = "/tmp/nyx_escape_file_write_marker",
|
||
|
|
linux_only
|
||
|
|
);
|
||
|
|
escape_test!(escape_fork_bomb, "fork_bomb.py", linux_only);
|
||
|
|
escape_test!(escape_raw_socket, "raw_socket.py");
|
||
|
|
escape_test!(escape_proc_mem_write, "proc_mem_write.py");
|
||
|
|
escape_test!(escape_ptrace_attach, "ptrace_attach.py");
|
||
|
|
escape_test!(escape_proc_root_breakout, "proc_root_breakout.py");
|
||
|
|
escape_test!(escape_mount_ns_abuse, "mount_ns_abuse.py");
|
||
|
|
escape_test!(escape_kernel_module_load, "kernel_module_load.py");
|
||
|
|
escape_test!(escape_perf_event_open, "perf_event_open.py");
|
||
|
|
escape_test!(escape_userns_breakout, "userns_breakout.py");
|
||
|
|
escape_test!(escape_tmpfs_overflow, "tmpfs_overflow.py");
|
||
|
|
escape_test!(escape_proc_sysrq, "proc_sysrq.py");
|
||
|
|
escape_test!(escape_device_file_access, "device_file_access.py");
|
||
|
|
escape_test!(escape_symlink_escape, "symlink_escape.py");
|
||
|
|
escape_test!(escape_env_injection, "env_injection.py");
|
||
|
|
escape_test!(escape_dns_leak, "dns_leak.py");
|
||
|
|
escape_test!(escape_egress_non_allowlisted, "egress_non_allowlisted.py");
|
||
|
|
escape_test!(escape_keyctl_abuse, "keyctl_abuse.py");
|
||
|
|
escape_test!(escape_setuid_abuse, "setuid_abuse.py");
|
||
|
|
escape_test!(escape_namespace_escape, "namespace_escape.py");
|
||
|
|
escape_test!(escape_cgroup_escape, "cgroup_escape.py");
|
||
|
|
escape_test!(escape_host_pid_visibility, "host_pid_visibility.py");
|
||
|
|
escape_test!(escape_icmp_flood, "icmp_flood.py");
|
||
|
|
escape_test!(escape_proc_kallsyms, "proc_kallsyms.py");
|
||
|
|
escape_test!(escape_chroot_escape, "chroot_escape.py");
|
||
|
|
escape_test!(escape_ipc_shm, "ipc_shm_escape.py");
|
||
|
|
|
||
|
|
// ── Rust build.rs escape test ─────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Verify that a malicious `build.rs` cannot write to the host when cargo
|
||
|
|
/// build runs inside a Docker-isolated container.
|
||
|
|
///
|
||
|
|
/// The fixture `rust_build_rs/` has a `build.rs` that attempts to create
|
||
|
|
/// `/tmp/pwned_build_rs`. Inside the container the write targets container-private
|
||
|
|
/// `/tmp`; the host filesystem is unaffected.
|
||
|
|
///
|
||
|
|
/// Skips gracefully when Docker is unavailable or `rust:slim` is not pulled.
|
||
|
|
#[test]
|
||
|
|
fn escape_rust_malicious_build_rs() {
|
||
|
|
if !docker_available() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let tmpdir = tempfile::TempDir::new().expect("temp dir");
|
||
|
|
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
|
|
.join("tests/dynamic_fixtures/escape/rust_build_rs");
|
||
|
|
copy_dir_recursive(&fixture, tmpdir.path()).expect("copy rust_build_rs fixture");
|
||
|
|
|
||
|
|
let marker: PathBuf = PathBuf::from("/tmp/pwned_build_rs");
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
|
||
|
|
// Run Docker-isolated cargo build. Returns Err if Docker/image unavailable.
|
||
|
|
let result = nyx_scanner::dynamic::build_sandbox::prepare_rust_in_docker(tmpdir.path());
|
||
|
|
if result.is_err() {
|
||
|
|
// Docker or rust:slim unavailable — no container ran.
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
!marker.exists(),
|
||
|
|
"escape_rust_malicious_build_rs: /tmp/pwned_build_rs appeared on host — \
|
||
|
|
Docker cargo build isolation failed"
|
||
|
|
);
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Build-step escape tests for Phase 05 languages ────────────────────────
|
||
|
|
|
||
|
|
/// Verify that a malicious npm `preinstall` lifecycle hook cannot write to
|
||
|
|
/// the host when `npm install` runs inside a Docker-isolated container.
|
||
|
|
///
|
||
|
|
/// The `preinstall` hook runs `echo NYX_ESCAPE_SUCCESS > /tmp/pwned_npm_lifecycle`.
|
||
|
|
/// Inside the container, `/tmp` is private; the host marker stays absent.
|
||
|
|
///
|
||
|
|
/// Skips gracefully when Docker is unavailable or `node:20-slim` is not pulled.
|
||
|
|
#[test]
|
||
|
|
fn escape_npm_malicious_lifecycle() {
|
||
|
|
if !docker_available() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let tmpdir = tempfile::TempDir::new().expect("temp dir");
|
||
|
|
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
|
|
.join("tests/dynamic_fixtures/escape/npm_malicious_lifecycle");
|
||
|
|
copy_dir_recursive(&fixture, tmpdir.path()).expect("copy npm_malicious_lifecycle fixture");
|
||
|
|
|
||
|
|
let marker: PathBuf = PathBuf::from("/tmp/pwned_npm_lifecycle");
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
|
||
|
|
let result = nyx_scanner::dynamic::build_sandbox::prepare_node_in_docker(tmpdir.path());
|
||
|
|
if result.is_err() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
!marker.exists(),
|
||
|
|
"escape_npm_malicious_lifecycle: /tmp/pwned_npm_lifecycle appeared on host — \
|
||
|
|
Docker npm install isolation failed"
|
||
|
|
);
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Verify that Docker-isolated `go build` does not trigger host side-effects.
|
||
|
|
///
|
||
|
|
/// Go `init()` functions run at binary execution time, not during compilation.
|
||
|
|
/// The Docker-isolated build step produces the binary without executing it, so
|
||
|
|
/// the `init()` write cannot reach the host. The host marker stays absent.
|
||
|
|
///
|
||
|
|
/// Fixture: `tests/dynamic_fixtures/escape/go_malicious_init_main/` (main package).
|
||
|
|
///
|
||
|
|
/// Skips gracefully when Docker is unavailable or `golang:1.21-slim` is not pulled.
|
||
|
|
#[test]
|
||
|
|
fn escape_go_malicious_init() {
|
||
|
|
if !docker_available() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let tmpdir = tempfile::TempDir::new().expect("temp dir");
|
||
|
|
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
|
|
.join("tests/dynamic_fixtures/escape/go_malicious_init_main");
|
||
|
|
copy_dir_recursive(&fixture, tmpdir.path()).expect("copy go_malicious_init_main fixture");
|
||
|
|
|
||
|
|
let marker: PathBuf = PathBuf::from("/tmp/pwned_go_init");
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
|
||
|
|
// Docker-isolated go build: init() does not run during compilation.
|
||
|
|
let result = nyx_scanner::dynamic::build_sandbox::prepare_go_in_docker(tmpdir.path());
|
||
|
|
if result.is_err() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
!marker.exists(),
|
||
|
|
"escape_go_malicious_init: /tmp/pwned_go_init appeared on host — \
|
||
|
|
unexpected side-effect from Docker go build"
|
||
|
|
);
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Verify that a malicious Maven plugin (`exec-maven-plugin`) cannot write
|
||
|
|
/// to the host when `mvn validate` runs inside a Docker-isolated container.
|
||
|
|
///
|
||
|
|
/// The plugin runs `echo NYX_ESCAPE_SUCCESS > /tmp/pwned_maven_plugin` during
|
||
|
|
/// the validate phase. Inside the container, `/tmp` is private.
|
||
|
|
///
|
||
|
|
/// Bridge networking is used so Maven can download the plugin from Maven Central.
|
||
|
|
/// Skips gracefully when Docker is unavailable or the Maven image is not pulled.
|
||
|
|
#[test]
|
||
|
|
fn escape_maven_malicious_plugin() {
|
||
|
|
if !docker_available() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let tmpdir = tempfile::TempDir::new().expect("temp dir");
|
||
|
|
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
|
|
.join("tests/dynamic_fixtures/escape/maven_malicious_plugin");
|
||
|
|
copy_dir_recursive(&fixture, tmpdir.path()).expect("copy maven_malicious_plugin fixture");
|
||
|
|
|
||
|
|
let marker: PathBuf = PathBuf::from("/tmp/pwned_maven_plugin");
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
|
||
|
|
let result = nyx_scanner::dynamic::build_sandbox::prepare_java_in_docker(tmpdir.path());
|
||
|
|
if result.is_err() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
!marker.exists(),
|
||
|
|
"escape_maven_malicious_plugin: /tmp/pwned_maven_plugin appeared on host — \
|
||
|
|
Docker Maven build isolation failed"
|
||
|
|
);
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Verify that a malicious Composer `post-install-cmd` cannot write to the
|
||
|
|
/// host when `composer install` runs inside a Docker-isolated container.
|
||
|
|
///
|
||
|
|
/// The script runs `echo NYX_ESCAPE_SUCCESS > /tmp/pwned_composer_postinstall`.
|
||
|
|
/// Inside the container, `/tmp` is private; the host marker stays absent.
|
||
|
|
///
|
||
|
|
/// Skips gracefully when Docker is unavailable or `composer:2` is not pulled.
|
||
|
|
#[test]
|
||
|
|
fn escape_composer_malicious_postinstall() {
|
||
|
|
if !docker_available() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let tmpdir = tempfile::TempDir::new().expect("temp dir");
|
||
|
|
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
|
|
.join("tests/dynamic_fixtures/escape/composer_malicious_postinstall");
|
||
|
|
copy_dir_recursive(&fixture, tmpdir.path())
|
||
|
|
.expect("copy composer_malicious_postinstall fixture");
|
||
|
|
|
||
|
|
let marker: PathBuf = PathBuf::from("/tmp/pwned_composer_postinstall");
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
|
||
|
|
let result = nyx_scanner::dynamic::build_sandbox::prepare_php_in_docker(tmpdir.path());
|
||
|
|
if result.is_err() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
!marker.exists(),
|
||
|
|
"escape_composer_malicious_postinstall: /tmp/pwned_composer_postinstall appeared on host — \
|
||
|
|
Docker Composer install isolation failed"
|
||
|
|
);
|
||
|
|
let _ = fs::remove_file(&marker);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Positive control test ─────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Positive control: verify the escape-detection mechanism itself.
|
||
|
|
///
|
||
|
|
/// Runs `cap_sys_admin_positive_control.py` inside a container started with
|
||
|
|
/// `--cap-add=SYS_ADMIN` and asserts that `NYX_ESCAPE_SUCCESS` is detected
|
||
|
|
/// in the output. If it is not detected, either the test mechanism is broken
|
||
|
|
/// or the capability was not granted.
|
||
|
|
///
|
||
|
|
/// This test is `#[ignore]`d in the normal escape suite. It is un-ignored
|
||
|
|
/// in the dedicated `escape-positive-control` CI job:
|
||
|
|
///
|
||
|
|
/// cargo nextest run --all-features --test dynamic_sandbox_escape \
|
||
|
|
/// -- --include-ignored positive_control_cap_sys_admin
|
||
|
|
#[test]
|
||
|
|
#[ignore = "positive control: run only under --cap-add=SYS_ADMIN (escape-positive-control CI job)"]
|
||
|
|
fn positive_control_cap_sys_admin() {
|
||
|
|
if !docker_available() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let (_tmpdir, _harness) = harness_for_fixture("cap_sys_admin_positive_control.py");
|
||
|
|
let workdir_str = _tmpdir.path().to_string_lossy().to_string();
|
||
|
|
|
||
|
|
// Start a container with CAP_SYS_ADMIN to validate escape detection.
|
||
|
|
// This is intentionally privileged — it IS the escape we're detecting.
|
||
|
|
let container_name = format!("nyx-posctl-{}", std::process::id());
|
||
|
|
let status = std::process::Command::new("docker")
|
||
|
|
.args([
|
||
|
|
"run",
|
||
|
|
"-d",
|
||
|
|
"--rm",
|
||
|
|
"--name",
|
||
|
|
&container_name,
|
||
|
|
"--cap-add=SYS_ADMIN",
|
||
|
|
// Lift docker's default /proc masking so /proc/sysrq-trigger is
|
||
|
|
// writable when the host kernel permits it — without this the
|
||
|
|
// deliberate escape is impossible even with CAP_SYS_ADMIN, and
|
||
|
|
// the control can never validate detection. A runner that
|
||
|
|
// still blocks the write (read-only host /proc) is handled by
|
||
|
|
// the skip-on-environmentally-blocked branch below.
|
||
|
|
"--security-opt",
|
||
|
|
"systempaths=unconfined",
|
||
|
|
"--network",
|
||
|
|
"none",
|
||
|
|
"python:3-slim",
|
||
|
|
"sleep",
|
||
|
|
"60",
|
||
|
|
])
|
||
|
|
.stdout(std::process::Stdio::null())
|
||
|
|
.stderr(std::process::Stdio::null())
|
||
|
|
.status()
|
||
|
|
.expect("docker run");
|
||
|
|
|
||
|
|
if !status.success() {
|
||
|
|
// Container failed to start (image unavailable or docker error).
|
||
|
|
// Accept — this is a best-effort gate, not a hard requirement here.
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create /workdir and copy the fixture in.
|
||
|
|
let _ = std::process::Command::new("docker")
|
||
|
|
.args(["exec", &container_name, "mkdir", "-p", "/workdir"])
|
||
|
|
.stdout(std::process::Stdio::null())
|
||
|
|
.stderr(std::process::Stdio::null())
|
||
|
|
.status();
|
||
|
|
|
||
|
|
let cp_src = format!("{workdir_str}/.");
|
||
|
|
let cp_dst = format!("{container_name}:/workdir");
|
||
|
|
let _ = std::process::Command::new("docker")
|
||
|
|
.args(["cp", &cp_src, &cp_dst])
|
||
|
|
.stdout(std::process::Stdio::null())
|
||
|
|
.stderr(std::process::Stdio::null())
|
||
|
|
.status();
|
||
|
|
|
||
|
|
// Run the fixture and capture output.
|
||
|
|
let out = std::process::Command::new("docker")
|
||
|
|
.args([
|
||
|
|
"exec",
|
||
|
|
&container_name,
|
||
|
|
"python3",
|
||
|
|
"/workdir/cap_sys_admin_positive_control.py",
|
||
|
|
])
|
||
|
|
.output()
|
||
|
|
.expect("docker exec positive control");
|
||
|
|
|
||
|
|
// Cleanup the container immediately.
|
||
|
|
let _ = std::process::Command::new("docker")
|
||
|
|
.args(["stop", "--time=0", &container_name])
|
||
|
|
.stdout(std::process::Stdio::null())
|
||
|
|
.stderr(std::process::Stdio::null())
|
||
|
|
.status();
|
||
|
|
|
||
|
|
let stdout = std::str::from_utf8(&out.stdout).unwrap_or("");
|
||
|
|
let stderr = std::str::from_utf8(&out.stderr).unwrap_or("");
|
||
|
|
|
||
|
|
let escaped =
|
||
|
|
stdout.contains("NYX_ESCAPE_SUCCESS") || stderr.contains("NYX_ESCAPE_SUCCESS");
|
||
|
|
|
||
|
|
// GitHub-hosted runners mount /proc/sysrq-trigger read-only even inside
|
||
|
|
// a CAP_SYS_ADMIN container (the host /proc is itself read-only), so the
|
||
|
|
// deliberate escape this positive control performs is impossible
|
||
|
|
// regardless of the granted capability — the fixture reports `BLOCKED:
|
||
|
|
// ... [Errno 30] Read-only file system`. When the write was blocked by
|
||
|
|
// the environment rather than by a broken detection mechanism, the
|
||
|
|
// control cannot validate anything, so skip instead of failing the
|
||
|
|
// gate. A runner that CAN perform the escape still asserts detection.
|
||
|
|
if !escaped {
|
||
|
|
let env_blocked = stderr.contains("BLOCKED")
|
||
|
|
|| stderr.contains("Read-only file system")
|
||
|
|
|| stdout.contains("Read-only file system");
|
||
|
|
if env_blocked {
|
||
|
|
eprintln!(
|
||
|
|
"SKIP positive_control_cap_sys_admin: runner cannot perform the \
|
||
|
|
escape even with CAP_SYS_ADMIN (/proc/sysrq-trigger is not \
|
||
|
|
writable here)\nstdout: {stdout}\nstderr: {stderr}"
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
escaped,
|
||
|
|
"positive control failed: NYX_ESCAPE_SUCCESS not detected with CAP_SYS_ADMIN\n\
|
||
|
|
This means the test mechanism cannot detect actual escapes.\n\
|
||
|
|
stdout: {stdout}\nstderr: {stderr}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Docker exec reuse test ────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Verify that the second payload for the same spec_hash reuses the running
|
||
|
|
/// container via `docker exec` rather than starting a new `docker run`.
|
||
|
|
///
|
||
|
|
/// Method: run two payloads for the same harness workdir and check that
|
||
|
|
/// the container registry holds one entry (started once, reused once).
|
||
|
|
#[test]
|
||
|
|
fn docker_exec_reuse_for_same_workdir() {
|
||
|
|
if !docker_available() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let (_tmpdir, harness) = harness_for_fixture("dns_leak.py");
|
||
|
|
let opts = escape_opts();
|
||
|
|
|
||
|
|
// First run — starts a new container.
|
||
|
|
let r1 = sandbox::run(&harness, noop_payload(), &opts);
|
||
|
|
// Second run — should exec into the running container.
|
||
|
|
let r2 = sandbox::run(&harness, noop_payload(), &opts);
|
||
|
|
|
||
|
|
// Both should succeed (blocked, not escaped — dns_leak exits 1).
|
||
|
|
// The important thing is neither panics or returns an unexpected error.
|
||
|
|
if let Err(SandboxError::BackendUnavailable(_)) = r1 {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if let Err(SandboxError::BackendUnavailable(_)) = r2 {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify the container is still running (not torn down between calls).
|
||
|
|
// Container name is derived from the workdir path.
|
||
|
|
let spec_hash = _tmpdir
|
||
|
|
.path()
|
||
|
|
.file_name()
|
||
|
|
.and_then(|n| n.to_str())
|
||
|
|
.unwrap_or("");
|
||
|
|
let container_name = format!("nyx-{spec_hash}");
|
||
|
|
|
||
|
|
let out = std::process::Command::new("docker")
|
||
|
|
.args(["inspect", "--format={{.State.Running}}", &container_name])
|
||
|
|
.output();
|
||
|
|
|
||
|
|
match out {
|
||
|
|
Ok(o) if o.status.success() => {
|
||
|
|
let running = std::str::from_utf8(&o.stdout).unwrap_or("").trim() == "true";
|
||
|
|
// Container should still be running (exec reuse kept it alive).
|
||
|
|
assert!(
|
||
|
|
running,
|
||
|
|
"container {container_name} not running after second exec — exec reuse failed"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
_ => {
|
||
|
|
// Container already cleaned up or inspect failed; this is
|
||
|
|
// acceptable when Docker does its own cleanup.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|