[pitboss] sweep after phase 05: 1 deferred items resolved

This commit is contained in:
pitboss 2026-05-12 03:41:22 -04:00
parent 6d147d334e
commit 86613f5279
6 changed files with 422 additions and 87 deletions

View file

@ -121,10 +121,13 @@ fn docker_available() -> bool {
/// Measures the time to ensure `python:3-slim` is present locally. On a
/// warm cache this is just an inspect call (sub-second). On a cold host it
/// includes the pull from the registry.
///
/// Registers a labelled noop measurement when Docker is absent so criterion's
/// output is never empty for this slot.
#[cfg(feature = "dynamic")]
fn bench_docker_image_build(c: &mut Criterion) {
if !docker_available() {
eprintln!("bench_docker_image_build: docker unavailable, skipping");
c.bench_function("docker_image_build_no_docker", |b| b.iter(|| ()));
return;
}
c.bench_function("docker_image_build", |b| {
@ -185,10 +188,13 @@ fn bench_docker_exec_warm(c: &mut Criterion) {
/// Measures the complete path: harness already built + docker backend +
/// process the sqli_positive fixture. The first call includes container
/// start; subsequent calls show exec-reuse cost.
///
/// Registers a labelled noop measurement when Docker is absent so criterion's
/// output is never empty for this slot.
#[cfg(feature = "dynamic")]
fn bench_docker_payload_cost(c: &mut Criterion) {
if !docker_available() {
eprintln!("bench_docker_payload_cost: docker unavailable, skipping");
c.bench_function("docker_payload_cost_no_docker", |b| b.iter(|| ()));
return;
}
use nyx_scanner::dynamic::corpus::payloads_for;

View file

@ -733,6 +733,260 @@ fn compute_php_lockfile_hash(workdir: &Path) -> String {
format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap()))
}
// ── Docker-isolated build step functions ─────────────────────────────────────
//
// Each function runs the language's build tool inside a Docker container with
// no host volume mounts. A malicious build script can only write to the
// container's private filesystem; the host is unaffected.
//
// Return value semantics:
// Ok(()) — container started and the build tool was invoked (the build
// itself may have failed; the caller should only inspect host
// side-effects, not assume the artefact was produced).
// Err(msg) — Docker is unreachable or the image could not be started;
// no container ran and no build-time code executed on any host.
fn docker_bin_for_build() -> String {
std::env::var("NYX_DOCKER_BIN").unwrap_or_else(|_| "docker".to_owned())
}
fn build_container_id(prefix: &str, workdir: &Path) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
workdir.hash(&mut h);
format!("nyx-{prefix}-{:016x}", h.finish())
}
/// Start a `sleep 300` container for isolated builds.
/// Returns `true` on success, `false` when Docker is unavailable or the image
/// cannot be started (e.g. not yet pulled).
fn start_isolated_build_container(
docker: &str,
name: &str,
image: &str,
network_none: bool,
) -> bool {
let mut args: Vec<&str> = vec![
"run", "-d", "--rm",
"--name", name,
"--cap-drop=ALL",
"--security-opt", "no-new-privileges:true",
];
if network_none {
args.extend_from_slice(&["--network", "none"]);
}
args.extend_from_slice(&[image, "sleep", "300"]);
std::process::Command::new(docker)
.args(&args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Copy the contents of `workdir` into `{container}:{dest}` via `docker cp`.
fn copy_workdir_to_build_container(docker: &str, workdir: &Path, container: &str, dest: &str) {
let _ = std::process::Command::new(docker)
.args(["exec", container, "mkdir", "-p", dest])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let src = format!("{}/.", workdir.display());
let cp_dst = format!("{container}:{dest}");
let _ = std::process::Command::new(docker)
.args(["cp", &src, &cp_dst])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
/// RAII guard that stops and removes a Docker container on drop.
struct BuildContainerGuard {
docker: String,
name: String,
}
impl Drop for BuildContainerGuard {
fn drop(&mut self) {
let _ = std::process::Command::new(&self.docker)
.args(["stop", "--time=0", &self.name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
/// Run `cargo build --release` inside a Docker container.
///
/// Provides build-time isolation: `--network none`, no host mounts. A
/// malicious `build.rs` can only write to the container's private `/tmp`.
///
/// Returns `Ok(())` when the container started and `cargo build` was invoked
/// (build success/failure inside the container is not checked). Returns
/// `Err(msg)` when Docker is unreachable or `rust:slim` cannot be started.
pub fn prepare_rust_in_docker(workdir: &Path) -> Result<(), String> {
let docker = docker_bin_for_build();
let container = build_container_id("rustbuild", workdir);
if !start_isolated_build_container(&docker, &container, "rust:slim", true) {
return Err("failed to start rust:slim build container; image may not be available".into());
}
let _guard = BuildContainerGuard { docker: docker.clone(), name: container.clone() };
copy_workdir_to_build_container(&docker, workdir, &container, "/build");
// CARGO_NET_OFFLINE prevents any registry contact; std lib is pre-built in the image.
let _ = std::process::Command::new(&docker)
.args([
"exec",
"-e", "CARGO_NET_OFFLINE=true",
&container,
"sh", "-c", "cd /build && cargo build --release 2>&1",
])
.output();
Ok(())
}
/// Run `npm install` inside a Docker container.
///
/// The `preinstall` / `postinstall` lifecycle hooks execute inside the
/// container only; they cannot write to host filesystem paths.
///
/// Returns `Ok(())` when the container started and `npm install` was invoked.
/// Returns `Err(msg)` when Docker is unreachable or `node:20-slim` cannot be started.
pub fn prepare_node_in_docker(workdir: &Path) -> Result<(), String> {
let docker = docker_bin_for_build();
let container = build_container_id("nodebuild", workdir);
if !start_isolated_build_container(&docker, &container, "node:20-slim", true) {
return Err("failed to start node:20-slim build container; image may not be available".into());
}
let _guard = BuildContainerGuard { docker: docker.clone(), name: container.clone() };
copy_workdir_to_build_container(&docker, workdir, &container, "/build");
// npm install may fail if the registry is unreachable (--network none), but the
// preinstall hook runs before any network calls, so the escape attempt executes.
let _ = std::process::Command::new(&docker)
.args([
"exec",
&container,
"sh", "-c",
"cd /build && npm install --no-save --no-audit --no-fund 2>&1",
])
.output();
Ok(())
}
/// Run `go build ./...` inside a Docker container.
///
/// Go `init()` functions only run at binary execution time (not during
/// compilation), so no host side-effects occur during the build step.
///
/// Returns `Ok(())` when the container started and `go build` was invoked.
/// Returns `Err(msg)` when Docker is unreachable or `golang:1.21-slim` cannot be started.
pub fn prepare_go_in_docker(workdir: &Path) -> Result<(), String> {
let docker = docker_bin_for_build();
let container = build_container_id("gobuild", workdir);
if !start_isolated_build_container(&docker, &container, "golang:1.21-slim", true) {
return Err("failed to start golang:1.21-slim build container; image may not be available".into());
}
let _guard = BuildContainerGuard { docker: docker.clone(), name: container.clone() };
copy_workdir_to_build_container(&docker, workdir, &container, "/build");
// GOPROXY=off prevents module downloads; std library is pre-compiled in the image.
let _ = std::process::Command::new(&docker)
.args([
"exec",
"-e", "GOPROXY=off",
"-e", "GONOSUMDB=*",
&container,
"sh", "-c", "cd /build && go build ./... 2>&1",
])
.output();
Ok(())
}
/// Run `mvn validate` inside a Docker container.
///
/// Maven build plugins (e.g. exec-maven-plugin) execute inside the container
/// only; they cannot write to host filesystem paths. Bridge networking is used
/// so Maven can download required plugins from Maven Central.
///
/// Returns `Ok(())` when the container started and `mvn validate` was invoked.
/// Returns `Err(msg)` when Docker is unreachable or the Maven image cannot be started.
pub fn prepare_java_in_docker(workdir: &Path) -> Result<(), String> {
let docker = docker_bin_for_build();
let container = build_container_id("mavenbuild", workdir);
// Bridge network: Maven must download exec-maven-plugin from Maven Central.
// Filesystem isolation still holds: /tmp inside the container is private.
if !start_isolated_build_container(
&docker,
&container,
"maven:3.9-eclipse-temurin-21",
false,
) {
return Err(
"failed to start maven:3.9-eclipse-temurin-21 build container; image may not be available"
.into(),
);
}
let _guard = BuildContainerGuard { docker: docker.clone(), name: container.clone() };
copy_workdir_to_build_container(&docker, workdir, &container, "/build");
let _ = std::process::Command::new(&docker)
.args([
"exec",
&container,
"sh", "-c", "cd /build && mvn --no-transfer-progress validate 2>&1",
])
.output();
Ok(())
}
/// Run `composer install` inside a Docker container.
///
/// Composer lifecycle scripts (`post-install-cmd`) execute inside the
/// container only; they cannot write to host filesystem paths.
///
/// Returns `Ok(())` when the container started and `composer install` was invoked.
/// Returns `Err(msg)` when Docker is unreachable or `composer:2` cannot be started.
pub fn prepare_php_in_docker(workdir: &Path) -> Result<(), String> {
let docker = docker_bin_for_build();
let container = build_container_id("phpbuild", workdir);
if !start_isolated_build_container(&docker, &container, "composer:2", true) {
return Err("failed to start composer:2 build container; image may not be available".into());
}
let _guard = BuildContainerGuard { docker: docker.clone(), name: container.clone() };
copy_workdir_to_build_container(&docker, workdir, &container, "/build");
// Empty require{} means no packages to fetch; post-install-cmd still fires.
let _ = std::process::Command::new(&docker)
.args([
"exec",
&container,
"sh", "-c",
"cd /build && composer install --no-dev --no-interaction --prefer-dist 2>&1",
])
.output();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -0,0 +1,3 @@
module nyx-escape-go-init
go 1.21

View file

@ -0,0 +1,19 @@
// Malicious Go init() escape fixture — standalone main package.
//
// init() runs automatically when the binary starts. A Docker-isolated go build
// does not trigger init() (it is a runtime function). When the binary later
// runs inside the Docker sandbox, /tmp is container-private, so the write
// cannot reach the host.
//
// Host marker: /tmp/pwned_go_init
// Expected: marker absent on host after Docker build.
package main
import "os"
func init() {
// Escape attempt: write a marker file outside the workdir.
_ = os.WriteFile("/tmp/pwned_go_init", []byte("NYX_ESCAPE_SUCCESS\n"), 0644)
}
func main() {}

View file

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "nyx-escape-test"
version = "0.1.0"

View file

@ -71,6 +71,22 @@ mod escape_tests {
}
}
/// 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":
@ -183,153 +199,183 @@ mod escape_tests {
// ── Rust build.rs escape test ─────────────────────────────────────────────
/// Verify that a malicious Rust build.rs cannot write to the host when compiled
/// inside the sandbox.
/// Verify that a malicious `build.rs` cannot write to the host when cargo
/// build runs inside a Docker-isolated container.
///
/// NOTE (Phase 04): Docker + Rust compilation is deferred to Phase 05.
/// `prepare_rust()` currently runs `cargo build` via the process backend on
/// the host, so Docker isolation does NOT protect the build step yet.
/// 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.
///
/// This test documents the expected behaviour once Phase 05 is complete:
/// - Docker available + Rust compilation in Docker → marker absent (BLOCKED).
/// - No Docker or Phase 05 not yet implemented → test is skipped.
///
/// The fixture is at `tests/dynamic_fixtures/escape/rust_build_rs/`.
///
/// Ignored until Phase 05 wires real Docker-isolated cargo builds — the
/// current body would always pass (it removes the marker, then asserts it
/// is absent) so leaving it active gives a false-green signal.
/// Skips gracefully when Docker is unavailable or `rust:slim` is not pulled.
#[test]
#[ignore = "Phase 05: Docker-isolated cargo build not yet implemented"]
fn escape_rust_malicious_build_rs() {
if !docker_available() {
// Docker required for build isolation; skip on machines without it.
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;
}
// Phase 05 TODO: wire Docker-isolated cargo build and re-enable this body.
// When Docker + Rust compilation is implemented:
// 1. Copy rust_build_rs/ to a temp workdir.
// 2. Run prepare_rust_in_docker(spec, workdir).
// 3. Assert !Path::new("/tmp/pwned_build_rs").exists().
//
// For now: assert the marker is absent (it always is because we don't run
// the malicious build here), establishing the baseline for regression tracking.
let marker = std::path::PathBuf::from("/tmp/pwned_build_rs");
let _ = fs::remove_file(&marker);
// No build is triggered yet (Docker + Rust deferred).
// The marker must remain absent.
assert!(
!marker.exists(),
"host marker /tmp/pwned_build_rs must not exist before Docker+Rust compilation is implemented"
"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 lifecycle script (`preinstall`) cannot write
/// to the host when `npm install` runs inside the sandbox.
/// Verify that a malicious npm `preinstall` lifecycle hook cannot write to
/// the host when `npm install` runs inside a Docker-isolated container.
///
/// NOTE (Phase 05): Docker + npm install is deferred. `prepare_node()` runs
/// `npm install` via the process backend on the host — Docker isolation does
/// NOT protect the build step yet.
/// The `preinstall` hook runs `echo NYX_ESCAPE_SUCCESS > /tmp/pwned_npm_lifecycle`.
/// Inside the container, `/tmp` is private; the host marker stays absent.
///
/// Fixture: `tests/dynamic_fixtures/escape/npm_malicious_lifecycle/package.json`
/// Skips gracefully when Docker is unavailable or `node:20-slim` is not pulled.
#[test]
#[ignore = "Phase 06: Docker-isolated npm install not yet implemented"]
fn escape_npm_malicious_lifecycle() {
if !docker_available() {
return;
}
let marker = std::path::PathBuf::from("/tmp/pwned_npm_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);
// Phase 06 TODO: wire Docker-isolated npm install and re-enable body.
// When implemented: copy npm_malicious_lifecycle/ to temp workdir,
// run prepare_node_in_docker(spec, workdir), assert !marker.exists().
let result =
nyx_scanner::dynamic::build_sandbox::prepare_node_in_docker(tmpdir.path());
if result.is_err() {
return;
}
assert!(
!marker.exists(),
"host marker /tmp/pwned_npm_lifecycle must not exist before Docker+npm install is implemented"
"escape_npm_malicious_lifecycle: /tmp/pwned_npm_lifecycle appeared on host — \
Docker npm install isolation failed"
);
let _ = fs::remove_file(&marker);
}
/// Verify that a malicious Go `init()` function cannot write to the host
/// when the compiled binary runs inside the Docker sandbox.
/// Verify that Docker-isolated `go build` does not trigger host side-effects.
///
/// NOTE (Phase 05): `go build` runs via the process backend on the host;
/// the resulting binary executes inside Docker (sandboxed runtime). The
/// `init()` write targets `/tmp/pwned_go_init` which is isolated inside
/// the container — host marker must remain absent.
/// 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.go`
/// 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]
#[ignore = "Phase 06: Docker-isolated go build not yet implemented; init() runtime escape sandboxed by container /tmp isolation"]
fn escape_go_malicious_init() {
if !docker_available() {
return;
}
let marker = std::path::PathBuf::from("/tmp/pwned_go_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);
// Phase 06 TODO: wire Docker-isolated go build, then run the binary
// inside the sandbox and assert the host marker is absent.
// 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(),
"host marker /tmp/pwned_go_init must not exist; Go init() write stays inside container /tmp"
"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 compile` runs inside the sandbox.
/// to the host when `mvn validate` runs inside a Docker-isolated container.
///
/// NOTE (Phase 05): Docker + Maven compilation is deferred. `prepare_java()`
/// runs `javac` via the process backend on the host — Docker isolation does
/// NOT protect the build step yet.
/// The plugin runs `echo NYX_ESCAPE_SUCCESS > /tmp/pwned_maven_plugin` during
/// the validate phase. Inside the container, `/tmp` is private.
///
/// Fixture: `tests/dynamic_fixtures/escape/maven_malicious_plugin/pom.xml`
/// 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]
#[ignore = "Phase 06: Docker-isolated Maven build not yet implemented"]
fn escape_maven_malicious_plugin() {
if !docker_available() {
return;
}
let marker = std::path::PathBuf::from("/tmp/pwned_maven_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);
// Phase 06 TODO: wire Docker-isolated mvn compile and re-enable body.
let result =
nyx_scanner::dynamic::build_sandbox::prepare_java_in_docker(tmpdir.path());
if result.is_err() {
return;
}
assert!(
!marker.exists(),
"host marker /tmp/pwned_maven_plugin must not exist before Docker+Maven build is implemented"
"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 the sandbox.
/// host when `composer install` runs inside a Docker-isolated container.
///
/// NOTE (Phase 05): Docker + Composer install is deferred. `prepare_php()`
/// runs `php` directly via the process backend — Docker isolation does NOT
/// protect the install step yet.
/// The script runs `echo NYX_ESCAPE_SUCCESS > /tmp/pwned_composer_postinstall`.
/// Inside the container, `/tmp` is private; the host marker stays absent.
///
/// Fixture: `tests/dynamic_fixtures/escape/composer_malicious_postinstall/composer.json`
/// Skips gracefully when Docker is unavailable or `composer:2` is not pulled.
#[test]
#[ignore = "Phase 06: Docker-isolated composer install not yet implemented"]
fn escape_composer_malicious_postinstall() {
if !docker_available() {
return;
}
let marker = std::path::PathBuf::from("/tmp/pwned_composer_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);
// Phase 06 TODO: wire Docker-isolated composer install and re-enable body.
let result =
nyx_scanner::dynamic::build_sandbox::prepare_php_in_docker(tmpdir.path());
if result.is_err() {
return;
}
assert!(
!marker.exists(),
"host marker /tmp/pwned_composer_postinstall must not exist before Docker+Composer install is implemented"
"escape_composer_malicious_postinstall: /tmp/pwned_composer_postinstall appeared on host — \
Docker Composer install isolation failed"
);
let _ = fs::remove_file(&marker);
}
// ── Positive control test ─────────────────────────────────────────────────