From 3ffe48066073fd7414b7329ff4837fee3d37529d Mon Sep 17 00:00:00 2001 From: pitboss Date: Tue, 12 May 2026 00:57:45 -0400 Subject: [PATCH] =?UTF-8?q?[pitboss]=20phase=2004:=20M4=20=E2=80=94=20Rust?= =?UTF-8?q?=20harness=20(second-language=20validation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- benches/dynamic_bench.rs | 39 ++ src/dynamic/build_sandbox.rs | 119 ++++++ src/dynamic/harness.rs | 59 ++- src/dynamic/lang/mod.rs | 12 +- src/dynamic/lang/python.rs | 2 + src/dynamic/lang/rust.rs | 253 +++++++++++ src/dynamic/repro.rs | 57 ++- src/dynamic/runner.rs | 64 ++- src/dynamic/sandbox.rs | 70 +++- src/dynamic/toolchain.rs | 181 ++++++++ src/dynamic/verify.rs | 8 +- .../escape/rust_build_rs/Cargo.toml | 11 + .../escape/rust_build_rs/build.rs | 16 + .../escape/rust_build_rs/src/main.rs | 4 + .../dynamic_fixtures/rust/cmdi_adversarial.rs | 13 + tests/dynamic_fixtures/rust/cmdi_negative.rs | 23 + tests/dynamic_fixtures/rust/cmdi_positive.rs | 24 ++ tests/dynamic_fixtures/rust/cmdi_positive2.rs | 25 ++ .../dynamic_fixtures/rust/cmdi_unsupported.rs | 21 + .../rust/fileio_adversarial.rs | 14 + .../dynamic_fixtures/rust/fileio_negative.rs | 27 ++ .../dynamic_fixtures/rust/fileio_positive.rs | 16 + .../dynamic_fixtures/rust/fileio_positive2.rs | 24 ++ .../rust/fileio_unsupported.rs | 16 + .../dynamic_fixtures/rust/sqli_adversarial.rs | 15 + tests/dynamic_fixtures/rust/sqli_negative.rs | 33 ++ tests/dynamic_fixtures/rust/sqli_positive.rs | 38 ++ .../dynamic_fixtures/rust/sqli_unsupported.rs | 24 ++ .../dynamic_fixtures/rust/sqli_with_secret.rs | 38 ++ .../dynamic_fixtures/rust/ssrf_adversarial.rs | 14 + tests/dynamic_fixtures/rust/ssrf_negative.rs | 20 + tests/dynamic_fixtures/rust/ssrf_positive.rs | 26 ++ tests/dynamic_fixtures/rust/ssrf_positive2.rs | 32 ++ .../dynamic_fixtures/rust/ssrf_unsupported.rs | 20 + tests/dynamic_sandbox_escape.rs | 45 ++ tests/repro_determinism.rs | 130 ++++++ tests/rust_fixtures.rs | 393 ++++++++++++++++++ 37 files changed, 1872 insertions(+), 54 deletions(-) create mode 100644 src/dynamic/lang/rust.rs create mode 100644 tests/dynamic_fixtures/escape/rust_build_rs/Cargo.toml create mode 100644 tests/dynamic_fixtures/escape/rust_build_rs/build.rs create mode 100644 tests/dynamic_fixtures/escape/rust_build_rs/src/main.rs create mode 100644 tests/dynamic_fixtures/rust/cmdi_adversarial.rs create mode 100644 tests/dynamic_fixtures/rust/cmdi_negative.rs create mode 100644 tests/dynamic_fixtures/rust/cmdi_positive.rs create mode 100644 tests/dynamic_fixtures/rust/cmdi_positive2.rs create mode 100644 tests/dynamic_fixtures/rust/cmdi_unsupported.rs create mode 100644 tests/dynamic_fixtures/rust/fileio_adversarial.rs create mode 100644 tests/dynamic_fixtures/rust/fileio_negative.rs create mode 100644 tests/dynamic_fixtures/rust/fileio_positive.rs create mode 100644 tests/dynamic_fixtures/rust/fileio_positive2.rs create mode 100644 tests/dynamic_fixtures/rust/fileio_unsupported.rs create mode 100644 tests/dynamic_fixtures/rust/sqli_adversarial.rs create mode 100644 tests/dynamic_fixtures/rust/sqli_negative.rs create mode 100644 tests/dynamic_fixtures/rust/sqli_positive.rs create mode 100644 tests/dynamic_fixtures/rust/sqli_unsupported.rs create mode 100644 tests/dynamic_fixtures/rust/sqli_with_secret.rs create mode 100644 tests/dynamic_fixtures/rust/ssrf_adversarial.rs create mode 100644 tests/dynamic_fixtures/rust/ssrf_negative.rs create mode 100644 tests/dynamic_fixtures/rust/ssrf_positive.rs create mode 100644 tests/dynamic_fixtures/rust/ssrf_positive2.rs create mode 100644 tests/dynamic_fixtures/rust/ssrf_unsupported.rs create mode 100644 tests/rust_fixtures.rs diff --git a/benches/dynamic_bench.rs b/benches/dynamic_bench.rs index 7ac297de..2cd20cd0 100644 --- a/benches/dynamic_bench.rs +++ b/benches/dynamic_bench.rs @@ -24,6 +24,24 @@ use nyx_scanner::labels::Cap; #[cfg(feature = "dynamic")] use nyx_scanner::symbol::Lang; +#[cfg(feature = "dynamic")] +fn make_rust_sqli_spec() -> HarnessSpec { + HarnessSpec { + finding_id: "bench_rust_0001".into(), + entry_file: "tests/dynamic_fixtures/rust/sqli_positive.rs".into(), + entry_name: "run".into(), + entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function, + lang: Lang::Rust, + toolchain_id: "rust-stable".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "tests/dynamic_fixtures/rust/sqli_positive.rs".into(), + sink_line: 18, + spec_hash: "benchrustsqli0001".into(), + } +} + #[cfg(feature = "dynamic")] fn make_sqli_spec() -> HarnessSpec { HarnessSpec { @@ -194,6 +212,26 @@ fn bench_docker_payload_cost(c: &mut Criterion) { }); } +/// Rust harness build (source gen + disk write, no compilation). +/// +/// Measures only `harness::build()` — staging files to the workdir. +/// The expensive `cargo build --release` step is NOT included here +/// (that is the province of an integration benchmark, not this microbench). +#[cfg(feature = "dynamic")] +fn bench_rust_harness_build_cold(c: &mut Criterion) { + use nyx_scanner::dynamic::harness; + let spec = make_rust_sqli_spec(); + c.bench_function("rust_harness_build_cold", |b| { + b.iter(|| { + let workdir = std::env::temp_dir() + .join("nyx-harness") + .join(&spec.spec_hash); + let _ = std::fs::remove_dir_all(&workdir); + harness::build(&spec).expect("harness build") + }); + }); +} + #[cfg(feature = "dynamic")] fn bench_noop(_c: &mut Criterion) {} @@ -212,6 +250,7 @@ criterion_group!( bench_docker_image_build, bench_docker_exec_warm, bench_docker_payload_cost, + bench_rust_harness_build_cold, ); #[cfg(not(feature = "dynamic"))] diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index e1dcca8a..312cb8fc 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -19,6 +19,125 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; +// ── Rust build sandbox ──────────────────────────────────────────────────────── + +/// Prepare a compiled Rust binary for `spec`. +/// +/// Checks a build cache keyed on `(Cargo.lock hash, "rust", toolchain_id)`. +/// On a cache hit returns immediately; otherwise runs `cargo build --release` +/// in `workdir` and caches the resulting binary. +/// +/// The compiled binary is at `cache_path/nyx_harness` on success. +/// +/// Build isolation is NOT yet implemented (deferred to Phase 05). `cargo build` +/// runs as a plain subprocess on the host with `env_clear()` plus a minimal +/// inherited env (PATH/HOME/CARGO_HOME/RUSTUP_HOME). A malicious `build.rs` +/// runs with host privileges. Vendoring / network sandboxing comes later (§19.2). +pub fn prepare_rust(spec: &HarnessSpec, workdir: &Path) -> Result { + let lockfile_hash = compute_rust_lockfile_hash(workdir); + let cache_path = build_cache_path(&lockfile_hash, "rust", &spec.toolchain_id)?; + + // Cache hit: binary already compiled and stored. + let binary = cache_path.join("nyx_harness"); + if binary.exists() { + return Ok(BuildResult { venv_path: cache_path, cache_hit: true, duration: Duration::ZERO }); + } + + let start = Instant::now(); + const MAX_ATTEMPTS: u32 = 2; + const BACKOFF: [u64; 2] = [1, 4]; + let mut last_err = String::new(); + + for attempt in 0..MAX_ATTEMPTS { + if attempt > 0 { + std::thread::sleep(Duration::from_secs(BACKOFF[attempt as usize - 1])); + } + let _ = std::fs::remove_dir_all(&cache_path); + std::fs::create_dir_all(&cache_path)?; + + match try_build_rust_binary(workdir, &binary) { + Ok(()) => { + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: false, + duration: start.elapsed(), + }); + } + Err(e) => { + last_err = e; + let _ = std::fs::remove_file(&binary); + } + } + } + + Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS }) +} + +fn try_build_rust_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String> { + let cargo = cargo_binary(); + + // Run `cargo build --release` in the workdir. + let output = Command::new(&cargo) + .args(["build", "--release"]) + .current_dir(workdir) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", std::env::var("HOME").unwrap_or_default()) + // Inherit CARGO_HOME so the local registry cache is reused. + .env("CARGO_HOME", std::env::var("CARGO_HOME").unwrap_or_else(|_| { + dirs_next_cargo_home() + })) + .env("RUSTUP_HOME", std::env::var("RUSTUP_HOME").unwrap_or_default()) + .output() + .map_err(|e| format!("cargo build: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(stderr); + } + + // Copy binary to cache location. + let compiled = workdir.join("target").join("release").join("nyx_harness"); + if compiled.exists() { + std::fs::copy(&compiled, binary_dest) + .map_err(|e| format!("copy binary: {e}"))?; + } + + Ok(()) +} + +fn cargo_binary() -> String { + // Respect NYX_CARGO_BIN for testing. + std::env::var("NYX_CARGO_BIN").unwrap_or_else(|_| "cargo".to_owned()) +} + +fn dirs_next_cargo_home() -> String { + // ~/.cargo is the default CARGO_HOME. + std::env::var("HOME") + .map(|h| format!("{h}/.cargo")) + .unwrap_or_else(|_| ".cargo".to_owned()) +} + +fn compute_rust_lockfile_hash(workdir: &Path) -> String { + let mut h = Hasher::new(); + // Cargo manifest and lock determine dependency graph. + for fname in &["Cargo.lock", "Cargo.toml"] { + if let Ok(content) = std::fs::read(workdir.join(fname)) { + h.update(fname.as_bytes()); + h.update(&content); + } + } + // Entry file is compiled into the binary, so it must be part of the cache key. + // Without this, two fixtures with the same Cargo.toml but different entry.rs + // would collide and the second would receive the wrong cached binary. + if let Ok(content) = std::fs::read(workdir.join("src").join("entry.rs")) { + h.update(b"src/entry.rs"); + h.update(&content); + } + let out = h.finalize(); + format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap())) +} + /// Result of a successful build. #[derive(Debug, Clone)] pub struct BuildResult { diff --git a/src/dynamic/harness.rs b/src/dynamic/harness.rs index cd910ddf..0667c012 100644 --- a/src/dynamic/harness.rs +++ b/src/dynamic/harness.rs @@ -74,31 +74,57 @@ fn stage_harness( let workdir = base_dir.join(&spec.spec_hash); fs::create_dir_all(&workdir)?; - // Write harness source. + // Write harness source (create parent dir if needed, e.g. "src/main.rs"). let harness_path = workdir.join(&harness_src.filename); + if let Some(parent) = harness_path.parent() { + fs::create_dir_all(parent)?; + } fs::write(&harness_path, harness_src.source.as_bytes())?; - // Copy the entry file into the workdir so the harness can import it. - copy_entry_file(spec, &workdir); + // Write any extra files (e.g. Cargo.toml for Rust). + for (rel_path, content) in &harness_src.extra_files { + let dest = workdir.join(rel_path); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&dest, content.as_bytes())?; + } + + // Copy the entry file into the workdir so the harness can import/include it. + copy_entry_file(spec, &workdir, harness_src.entry_subpath.as_deref()); Ok(workdir) } -/// Copy the entry Python file to the workdir so the harness can `import` it. -/// Best-effort: silently skips if the file cannot be found/copied. -fn copy_entry_file(spec: &HarnessSpec, workdir: &PathBuf) { - // Try the entry file relative to the project root candidates. +/// Copy the entry source file to the workdir. +/// +/// `entry_subpath` controls the destination: +/// - `None` → `workdir/{filename}` (Python default: import by module name). +/// - `Some("src/entry.rs")` → `workdir/src/entry.rs` (Rust: `mod entry;`). +/// +/// Best-effort: silently skips if the file cannot be found or copied. +fn copy_entry_file(spec: &HarnessSpec, workdir: &PathBuf, entry_subpath: Option<&str>) { let candidates = [ PathBuf::from(&spec.entry_file), PathBuf::from(".").join(&spec.entry_file), ]; for src in &candidates { if src.exists() { - if let Some(fname) = src.file_name() { - let dst = workdir.join(fname); - if !dst.exists() { - let _ = fs::copy(src, &dst); + let dst = if let Some(subpath) = entry_subpath { + let dest = workdir.join(subpath); + if let Some(parent) = dest.parent() { + let _ = fs::create_dir_all(parent); } + dest + } else { + let fname = match src.file_name() { + Some(f) => f, + None => return, + }; + workdir.join(fname) + }; + if !dst.exists() { + let _ = fs::copy(src, &dst); } return; } @@ -151,17 +177,18 @@ mod tests { #[test] fn build_unsupported_lang_returns_err() { + // Go is not yet supported (unsupported lang path). let spec = HarnessSpec { finding_id: "0000000000000001".into(), - entry_file: "src/main.rs".into(), - entry_name: "handle_request".into(), + entry_file: "main.go".into(), + entry_name: "handleRequest".into(), entry_kind: EntryKind::Function, - lang: Lang::Rust, - toolchain_id: "rust-stable".into(), + lang: Lang::Go, + toolchain_id: "go-stable".into(), payload_slot: PayloadSlot::Param(0), expected_cap: Cap::SQL_QUERY, constraint_hints: vec![], - sink_file: "src/main.rs".into(), + sink_file: "main.go".into(), sink_line: 5, spec_hash: "0000000000000000".into(), }; diff --git a/src/dynamic/lang/mod.rs b/src/dynamic/lang/mod.rs index ec6ae7a8..a221e34f 100644 --- a/src/dynamic/lang/mod.rs +++ b/src/dynamic/lang/mod.rs @@ -4,6 +4,7 @@ //! The top-level [`emit`] function dispatches on `spec.lang`. pub mod python; +pub mod rust; use crate::dynamic::spec::HarnessSpec; use crate::evidence::UnsupportedReason; @@ -14,16 +15,25 @@ use crate::symbol::Lang; pub struct HarnessSource { /// Harness source code as a UTF-8 string. pub source: String, - /// Filename for the harness (e.g. `"harness.py"`). + /// Filename for the harness (e.g. `"harness.py"`, `"src/main.rs"`). pub filename: String, /// Shell command to invoke the harness (relative to the workdir). pub command: Vec, + /// Additional files to write to the workdir alongside the main source. + /// Each entry is `(relative_path, content)`. Subdirectories are created + /// automatically (e.g. `"Cargo.toml"` or `"src/entry.rs"`). + pub extra_files: Vec<(String, String)>, + /// Where to copy the entry source file (relative to workdir). + /// `None` = workdir root (Python default). + /// `Some("src/entry.rs")` = Rust module path. + pub entry_subpath: Option, } /// Dispatch to the appropriate language emitter. pub fn emit(spec: &HarnessSpec) -> Result { match spec.lang { Lang::Python => python::emit(spec), + Lang::Rust => rust::emit(spec), _ => Err(UnsupportedReason::LangUnsupported), } } diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 9379b14e..e7dd4564 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -31,6 +31,8 @@ pub fn emit(spec: &HarnessSpec) -> Result { source, filename: "harness.py".to_owned(), command: vec!["python3".to_owned(), "harness.py".to_owned()], + extra_files: vec![], + entry_subpath: None, }) } diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs new file mode 100644 index 00000000..78df4b56 --- /dev/null +++ b/src/dynamic/lang/rust.rs @@ -0,0 +1,253 @@ +//! Rust harness emitter. +//! +//! Generates a binary crate that: +//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars. +//! 2. Calls the entry function from `src/entry.rs` with the payload routed +//! to the correct parameter slot. +//! 3. The entry function calls `println!("__NYX_SINK_HIT__")` before the +//! actual sink invocation (sink-reachability probe). +//! 4. Captures outcome via stdout markers and exit code (§4.1). +//! +//! Build step: the runner calls `build_sandbox::prepare_rust()` which runs +//! `cargo build --release` in the workdir. `harness.command` is updated to +//! the compiled binary path before sandbox execution. +//! +//! Payload slot support: +//! - `PayloadSlot::Param(0)` — pass payload as `&str` first argument. +//! - `PayloadSlot::EnvVar(name)` — set env var before calling entry. +//! - All other slots (`Stdin`, `Param(n>0)`, `QueryParam`, `HttpBody`, `Argv`) +//! produce `UnsupportedReason::EntryKindUnsupported`. Stdin piping into the +//! generated harness is not yet wired (deferred). +//! +//! HTML_ESCAPE is n/a for Rust (§15.4). + +use crate::dynamic::lang::HarnessSource; +use crate::dynamic::spec::{HarnessSpec, PayloadSlot}; +use crate::evidence::UnsupportedReason; +use crate::labels::Cap; + +/// Emit a Rust harness for `spec`. +pub fn emit(spec: &HarnessSpec) -> Result { + match &spec.payload_slot { + PayloadSlot::Param(0) | PayloadSlot::EnvVar(_) => {} + _ => return Err(UnsupportedReason::EntryKindUnsupported), + } + + let cargo_toml = generate_cargo_toml(spec.expected_cap); + let main_rs = generate_main_rs(spec); + + Ok(HarnessSource { + source: main_rs, + filename: "src/main.rs".into(), + command: vec!["target/release/nyx_harness".into()], + extra_files: vec![("Cargo.toml".into(), cargo_toml)], + entry_subpath: Some("src/entry.rs".into()), + }) +} + +/// Generate `Cargo.toml` for the harness crate. +/// +/// Dependencies are driven by `expected_cap`: +/// - `SQL_QUERY` → `rusqlite` with the `bundled` feature (embeds SQLite). +/// - Other caps use only std (no extra deps). +pub fn generate_cargo_toml(cap: Cap) -> String { + let mut deps = String::new(); + + if cap.contains(Cap::SQL_QUERY) { + deps.push_str("rusqlite = { version = \"0.39\", features = [\"bundled\"] }\n"); + } + + format!( + "[package]\n\ + name = \"nyx-harness\"\n\ + version = \"0.1.0\"\n\ + edition = \"2021\"\n\n\ + [[bin]]\n\ + name = \"nyx_harness\"\n\ + path = \"src/main.rs\"\n\n\ + [dependencies]\n\ + {deps}" + ) +} + +/// Generate `src/main.rs` — the harness entry point. +/// +/// Reads the payload from env, calls `entry::{entry_name}` with the payload +/// routed according to `spec.payload_slot`. +fn generate_main_rs(spec: &HarnessSpec) -> String { + let entry_fn = &spec.entry_name; + let (pre_call, call_expr) = build_call(spec, entry_fn); + + format!( + r#"//! Nyx dynamic harness — auto-generated, do not edit. +mod entry; + +fn main() {{ + let payload = nyx_payload(); +{pre_call} {call_expr} +}} + +fn nyx_payload() -> String {{ + // Prefer raw NYX_PAYLOAD (set on Unix). + if let Ok(v) = std::env::var("NYX_PAYLOAD") {{ + if !v.is_empty() {{ + return v; + }} + }} + // Fall back to base64-encoded NYX_PAYLOAD_B64. + if let Ok(b64) = std::env::var("NYX_PAYLOAD_B64") {{ + if let Some(bytes) = b64_decode(b64.as_bytes()) {{ + return String::from_utf8_lossy(&bytes).into_owned(); + }} + }} + String::new() +}} + +/// Minimal base64 decoder (no external deps). +fn b64_decode(input: &[u8]) -> Option> {{ + const TABLE: [u8; 128] = {{ + let mut t = [255u8; 128]; + let mut i = 0u8; + for &c in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" {{ + t[c as usize] = i; + i += 1; + }} + t + }}; + let input: Vec = input.iter().copied().filter(|&c| c != b'\n' && c != b'\r').collect(); + let mut out = Vec::with_capacity(input.len() * 3 / 4); + let mut i = 0; + while i + 3 < input.len() {{ + let a = *TABLE.get(input[i] as usize)? as u32; + let b = *TABLE.get(input[i + 1] as usize)? as u32; + let c = if input[i + 2] == b'=' {{ 64 }} else {{ *TABLE.get(input[i + 2] as usize)? as u32 }}; + let d = if input[i + 3] == b'=' {{ 64 }} else {{ *TABLE.get(input[i + 3] as usize)? as u32 }}; + if a == 255 || b == 255 || c == 255 || d == 255 {{ return None; }} + out.push(((a << 2) | (b >> 4)) as u8); + if input[i + 2] != b'=' {{ out.push(((b << 4) | (c >> 2)) as u8); }} + if input[i + 3] != b'=' {{ out.push(((c << 6) | d) as u8); }} + i += 4; + }} + Some(out) +}} +"#, + pre_call = pre_call, + call_expr = call_expr, + ) +} + +/// Build `(pre_call_setup, call_expression)` strings for the chosen payload slot. +fn build_call(spec: &HarnessSpec, func: &str) -> (String, String) { + match &spec.payload_slot { + PayloadSlot::Param(0) => { + let pre = String::new(); + let call = format!("entry::{func}(&payload);"); + (pre, call) + } + PayloadSlot::EnvVar(name) => { + let pre = format!(" std::env::set_var({name:?}, &payload);\n"); + let call = format!("entry::{func}();"); + (pre, call) + } + _ => { + // Unreachable: `emit()` rejects all other slots up front. + let pre = String::new(); + let call = format!("entry::{func}(&payload);"); + (pre, call) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::labels::Cap; + use crate::symbol::Lang; + + fn make_spec(payload_slot: PayloadSlot) -> HarnessSpec { + HarnessSpec { + finding_id: "rust000000000001".into(), + entry_file: "src/handler.rs".into(), + entry_name: "run".into(), + entry_kind: EntryKind::Function, + lang: Lang::Rust, + toolchain_id: "rust-stable".into(), + payload_slot, + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "src/handler.rs".into(), + sink_line: 10, + spec_hash: "rusttest00000001".into(), + } + } + + #[test] + fn emit_sql_query_produces_source() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("mod entry;")); + assert!(harness.source.contains("nyx_payload()")); + assert!(harness.source.contains("entry::run(&payload)")); + assert_eq!(harness.filename, "src/main.rs"); + assert_eq!(harness.command, vec!["target/release/nyx_harness"]); + } + + #[test] + fn emit_includes_cargo_toml_in_extra_files() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + let cargo = harness.extra_files.iter().find(|(n, _)| n == "Cargo.toml"); + assert!(cargo.is_some(), "Cargo.toml must be in extra_files"); + let cargo_content = &cargo.unwrap().1; + assert!(cargo_content.contains("rusqlite"), "SQL_QUERY cap needs rusqlite dep"); + assert!(cargo_content.contains("bundled"), "rusqlite must use bundled feature"); + } + + #[test] + fn emit_code_exec_no_rusqlite_dep() { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::CODE_EXEC; + let harness = emit(&spec).unwrap(); + let cargo = harness.extra_files.iter().find(|(n, _)| n == "Cargo.toml").unwrap(); + assert!(!cargo.1.contains("rusqlite"), "CODE_EXEC must not have rusqlite dep"); + } + + #[test] + fn emit_entry_subpath_is_src_entry_rs() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert_eq!(harness.entry_subpath, Some("src/entry.rs".to_string())); + } + + #[test] + fn emit_env_var_slot() { + let spec = make_spec(PayloadSlot::EnvVar("NYX_INPUT".into())); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("set_var")); + assert!(harness.source.contains("\"NYX_INPUT\"")); + } + + #[test] + fn emit_param_gt_0_is_unsupported() { + let spec = make_spec(PayloadSlot::Param(1)); + let err = emit(&spec).unwrap_err(); + assert_eq!(err, UnsupportedReason::EntryKindUnsupported); + } + + #[test] + fn cargo_toml_has_correct_bin_target() { + let cargo = generate_cargo_toml(Cap::SQL_QUERY); + assert!(cargo.contains("name = \"nyx_harness\"")); + assert!(cargo.contains("path = \"src/main.rs\"")); + } + + #[test] + fn b64_decode_roundtrip() { + // Test by compiling: actual b64_decode is in generated code. + // Just verify the Cargo.toml generation doesn't panic. + let _ = generate_cargo_toml(Cap::FILE_IO); + let _ = generate_cargo_toml(Cap::CODE_EXEC); + let _ = generate_cargo_toml(Cap::SSRF); + } +} diff --git a/src/dynamic/repro.rs b/src/dynamic/repro.rs index 41560675..5aaf7679 100644 --- a/src/dynamic/repro.rs +++ b/src/dynamic/repro.rs @@ -111,8 +111,18 @@ pub fn write( let entry_path = root.join("entry").join(format!("extracted_source.{ext}")); fs::write(&entry_path, entry_source.as_bytes())?; - // harness/harness.py (or other lang ext) - let harness_path = root.join("harness").join(format!("harness.{ext}")); + // harness/harness.{ext} (or for Rust: harness/src/main.rs) + use crate::symbol::Lang; + let harness_path = if matches!(spec.lang, Lang::Rust) { + let src_dir = root.join("harness").join("src"); + fs::create_dir_all(&src_dir)?; + // Also write Cargo.toml for Rust repro bundles. + let cargo_content = crate::dynamic::lang::rust::generate_cargo_toml(spec.expected_cap); + fs::write(root.join("harness").join("Cargo.toml"), cargo_content.as_bytes())?; + src_dir.join("main.rs") + } else { + root.join("harness").join(format!("harness.{ext}")) + }; fs::write(&harness_path, harness_source.as_bytes())?; // harness/Dockerfile.harness @@ -232,22 +242,55 @@ fn source_ext_for_lang(lang: &crate::symbol::Lang) -> &'static str { } fn dockerfile_for_spec(spec: &HarnessSpec) -> String { - let image = format!("python:{}", spec.toolchain_id.strip_prefix("python-").unwrap_or("3")); - format!( - "FROM {image}\nWORKDIR /harness\nCOPY harness.py .\nCMD [\"python3\", \"harness.py\"]\n" - ) + use crate::symbol::Lang; + match spec.lang { + Lang::Rust => { + let toolchain = spec.toolchain_id.strip_prefix("rust-").unwrap_or("stable"); + // Multi-stage: build with Rust, run the binary directly. + format!( + "FROM rust:{toolchain}-slim AS builder\n\ + WORKDIR /harness\n\ + COPY Cargo.toml Cargo.lock* ./\n\ + COPY src/ src/\n\ + RUN cargo build --release\n\n\ + FROM debian:bookworm-slim\n\ + WORKDIR /harness\n\ + COPY --from=builder /harness/target/release/nyx_harness .\n\ + CMD [\"/harness/nyx_harness\"]\n" + ) + } + Lang::Python => { + let image = format!("python:{}", spec.toolchain_id.strip_prefix("python-").unwrap_or("3")); + format!( + "FROM {image}\nWORKDIR /harness\nCOPY harness.py .\nCMD [\"python3\", \"harness.py\"]\n" + ) + } + _ => { + format!("# Unsupported language: {:?}\nFROM ubuntu:latest\n", spec.lang) + } + } } fn reproduce_script(spec: &HarnessSpec, payload_label: &str) -> String { + use crate::symbol::Lang; + let run_cmd = match spec.lang { + Lang::Rust => { + "NYX_PAYLOAD=\"$(cat payload/payload.bin)\" ./harness/nyx_harness".to_owned() + } + _ => { + "NYX_PAYLOAD=\"$(cat payload/payload.bin)\" python3 harness/harness.py".to_owned() + } + }; format!( "#!/bin/sh\n\ # Repro script for finding {finding_id} ({payload_label})\n\ set -e\n\ SCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ cd \"$SCRIPT_DIR\"\n\ - NYX_PAYLOAD=\"$(cat payload/payload.bin)\" python3 harness/harness.py\n", + {run_cmd}\n", finding_id = spec.finding_id, payload_label = payload_label, + run_cmd = run_cmd, ) } diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs index d8be4792..cb8aa471 100644 --- a/src/dynamic/runner.rs +++ b/src/dynamic/runner.rs @@ -10,6 +10,7 @@ use crate::dynamic::corpus::{benign_payload_for, payloads_for, Oracle, Payload}; use crate::dynamic::harness::{self, HarnessError}; use crate::dynamic::sandbox::{self, SandboxError, SandboxOptions, SandboxOutcome}; use crate::dynamic::spec::HarnessSpec; +use crate::symbol::Lang; /// Max harness-build attempts before giving up. const MAX_BUILD_ATTEMPTS: u32 = 2; @@ -86,28 +87,55 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result { - // Patch harness command to use venv Python when the venv was built - // or found in cache. - if let Some(cmd0) = harness.command.first_mut() { - if cmd0 == "python3" || cmd0 == "python" { - let venv_python = build_result.venv_path.join("bin").join("python3"); - if venv_python.exists() { - *cmd0 = venv_python.to_string_lossy().into_owned(); + // Build-time isolation and dependency setup — dispatched by language. + match spec.lang { + Lang::Python => { + // Prepare Python venv for dependency caching. + // Errors propagate as RunError::BuildFailed or are swallowed for + // non-fatal failures (Io / Unsupported), falling back to system python3. + match build_sandbox::prepare_python(spec, &harness.workdir) { + Ok(build_result) => { + if let Some(cmd0) = harness.command.first_mut() { + if cmd0 == "python3" || cmd0 == "python" { + let venv_python = build_result.venv_path.join("bin").join("python3"); + if venv_python.exists() { + *cmd0 = venv_python.to_string_lossy().into_owned(); + } + } } } + Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => { + return Err(RunError::BuildFailed { stderr, attempts }); + } + Err(_) => {} + } + } + Lang::Rust => { + // Compile the harness binary with `cargo build --release`. + match build_sandbox::prepare_rust(spec, &harness.workdir) { + Ok(build_result) => { + // Update command to the compiled binary path. + let binary = build_result.venv_path.join("nyx_harness"); + if binary.exists() { + harness.command = vec![binary.to_string_lossy().into_owned()]; + } else { + // Fall back to binary inside the workdir. + let fallback = harness.workdir.join("target").join("release").join("nyx_harness"); + if fallback.exists() { + harness.command = vec![fallback.to_string_lossy().into_owned()]; + } + } + } + Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => { + return Err(RunError::BuildFailed { stderr, attempts }); + } + Err(_) => { + // Io: fall back to whatever command was set (will likely fail at exec). + } } } - Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => { - return Err(RunError::BuildFailed { stderr, attempts }); - } - Err(_) => { - // Io / Unsupported: fall back to system python3 already in command. + _ => { + // No build step for other interpreted languages. } } diff --git a/src/dynamic/sandbox.rs b/src/dynamic/sandbox.rs index 81e656b3..1fbe3571 100644 --- a/src/dynamic/sandbox.rs +++ b/src/dynamic/sandbox.rs @@ -28,6 +28,29 @@ use std::path::Path; use std::sync::OnceLock; use std::time::{Duration, Instant}; +// ── Harness interpretation probe ────────────────────────────────────────────── + +/// Returns true when the harness is driven by an interpreter (Python, Node, …) +/// rather than a compiled native binary. +/// +/// Interpreted harnesses can be run inside a Python/Node Docker image directly. +/// Compiled harnesses (Rust, C) require a platform-matching binary; the Docker +/// backend falls back to the process backend for them in Phase 04. +pub fn harness_is_interpreted(command: &[String]) -> bool { + let cmd0 = match command.first() { + Some(c) => c.as_str(), + None => return false, + }; + let base = std::path::Path::new(cmd0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(cmd0); + matches!( + base, + "python3" | "python" | "python2" | "node" | "nodejs" | "ruby" | "php" | "perl" + ) +} + /// Result of a single sandboxed run. #[derive(Debug, Clone)] pub struct SandboxOutcome { @@ -201,9 +224,18 @@ pub fn run( opts: &SandboxOptions, ) -> Result { match opts.backend { - SandboxBackend::Docker => run_docker(harness, payload, opts), + SandboxBackend::Docker => { + // Docker backend currently only supports interpreted harnesses. + // Compiled binaries (Rust, C) are not yet cross-platform in containers; + // fall back to the process backend for them. + if harness_is_interpreted(&harness.command) { + run_docker(harness, payload, opts) + } else { + run_process(harness, payload, opts) + } + } SandboxBackend::Auto => { - if docker_available() { + if docker_available() && harness_is_interpreted(&harness.command) { run_docker(harness, payload, opts) } else { run_process(harness, payload, opts) @@ -366,15 +398,33 @@ fn exec_in_container( } cmd_args.push(container_name.into()); - // The harness script is at /workdir/{filename} inside the container. - let harness_file = harness - .command - .get(1) - .map(|s| s.as_str()) - .unwrap_or("harness.py"); + // Build the exec command inside the container. + // For interpreters: `python3 /workdir/harness.py` + // For compiled binaries: `/workdir/target/release/nyx_harness` let exec_cmd = harness.command.first().map(|s| s.as_str()).unwrap_or("python3"); - cmd_args.push(exec_cmd.into()); - cmd_args.push(format!("/workdir/{harness_file}")); + if harness_is_interpreted(&harness.command) { + let harness_file = harness + .command + .get(1) + .map(|s| s.as_str()) + .unwrap_or("harness.py"); + cmd_args.push(exec_cmd.into()); + cmd_args.push(format!("/workdir/{harness_file}")); + } else { + // Compiled binary: the command is the relative path within workdir. + // e.g. "target/release/nyx_harness" → run "/workdir/target/release/nyx_harness" + let rel = std::path::Path::new(exec_cmd) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(exec_cmd); + if exec_cmd.contains('/') || exec_cmd.contains('\\') { + // Relative path within workdir (e.g. "target/release/nyx_harness"). + cmd_args.push(format!("/workdir/{exec_cmd}")); + } else { + // Just a filename — try /workdir directly. + cmd_args.push(format!("/workdir/{rel}")); + } + } let mut cmd = Command::new(docker_bin()); cmd.args(&cmd_args); diff --git a/src/dynamic/toolchain.rs b/src/dynamic/toolchain.rs index e8830066..f2f43970 100644 --- a/src/dynamic/toolchain.rs +++ b/src/dynamic/toolchain.rs @@ -32,10 +32,146 @@ pub enum PinOrigin { Pipfile, /// `runtime.txt` (Heroku-style). RuntimeTxt, + /// `rust-toolchain.toml` `[toolchain] channel`. + RustToolchainToml, + /// `rust-toolchain` (plain text channel file). + RustToolchainFile, + /// `Cargo.toml` `rust-version` field. + CargoToml, /// No pin found; used the system default. SystemDefault, } +// ── Rust toolchain resolver ─────────────────────────────────────────────────── + +/// Resolve the Rust toolchain for `project_root` (§22.2). +/// +/// Reads project pin files in priority order: +/// `rust-toolchain.toml` > `rust-toolchain` > `Cargo.toml` `rust-version` > default. +pub fn resolve_rust(project_root: &Path) -> ToolchainResolution { + if let Some(r) = try_rust_toolchain_toml(project_root) { + return r; + } + if let Some(r) = try_rust_toolchain_file(project_root) { + return r; + } + if let Some(r) = try_cargo_toml_rust_version(project_root) { + return r; + } + default_rust() +} + +fn try_rust_toolchain_toml(root: &Path) -> Option { + let content = std::fs::read_to_string(root.join("rust-toolchain.toml")).ok()?; + // Look for `channel = "stable"` or `channel = "1.75"` in [toolchain] section. + let mut in_toolchain = false; + for line in content.lines() { + let line = line.trim(); + if line == "[toolchain]" { + in_toolchain = true; + continue; + } + if line.starts_with('[') { + in_toolchain = false; + } + if in_toolchain && line.starts_with("channel") { + if let Some(ver) = extract_version_from_toml_value(line) { + return Some(map_rust_version(&ver, RustPinOrigin::RustToolchainToml)); + } + } + } + None +} + +fn try_rust_toolchain_file(root: &Path) -> Option { + let content = std::fs::read_to_string(root.join("rust-toolchain")).ok()?; + let version = content.trim().to_owned(); + if version.is_empty() { + return None; + } + // Simple format: just the channel name (e.g. "stable", "1.75.0", "nightly-2024-01-01") + Some(map_rust_version(&version, RustPinOrigin::RustToolchainFile)) +} + +fn try_cargo_toml_rust_version(root: &Path) -> Option { + let content = std::fs::read_to_string(root.join("Cargo.toml")).ok()?; + for line in content.lines() { + let line = line.trim(); + if line.starts_with("rust-version") { + if let Some(ver) = extract_version_from_toml_value(line) { + return Some(map_rust_version(&ver, RustPinOrigin::CargoToml)); + } + } + } + None +} + +fn default_rust() -> ToolchainResolution { + ToolchainResolution { + toolchain_id: "rust-stable".to_owned(), + pin_origin: PinOrigin::SystemDefault, + toolchain_drift: false, + version_string: "stable".to_owned(), + } +} + +/// Internal origin enum for Rust (mapped to PinOrigin for the public API). +enum RustPinOrigin { + RustToolchainToml, + RustToolchainFile, + CargoToml, +} + +fn map_rust_version(version: &str, origin: RustPinOrigin) -> ToolchainResolution { + let pin_origin = match origin { + RustPinOrigin::RustToolchainToml => PinOrigin::RustToolchainToml, + RustPinOrigin::RustToolchainFile => PinOrigin::RustToolchainFile, + RustPinOrigin::CargoToml => PinOrigin::CargoToml, + }; + + // Named channels. + if version == "stable" || version.is_empty() { + return ToolchainResolution { + toolchain_id: "rust-stable".to_owned(), + pin_origin, + toolchain_drift: false, + version_string: "stable".to_owned(), + }; + } + if version.starts_with("nightly") { + return ToolchainResolution { + toolchain_id: "rust-nightly".to_owned(), + pin_origin, + toolchain_drift: true, // nightly != stable reference image + version_string: version.to_owned(), + }; + } + if version.starts_with("beta") { + return ToolchainResolution { + toolchain_id: "rust-beta".to_owned(), + pin_origin, + toolchain_drift: true, + version_string: version.to_owned(), + }; + } + + // Semver pinned version like "1.75.0" or "1.75". + let parts: Vec<&str> = version.splitn(3, '.').collect(); + let major = parts.first().copied().unwrap_or("1"); + let minor = parts.get(1).copied(); + + // Map to stable; drift = true when exact version differs from "stable". + let drift = minor.is_some(); // pin to specific version = drift from "stable" label + ToolchainResolution { + toolchain_id: format!("rust-{major}.{}", minor.unwrap_or("x")), + pin_origin, + toolchain_drift: drift, + version_string: version.to_owned(), + } +} + +// ── Python toolchain resolver ───────────────────────────────────────────────── + /// Resolve the Python toolchain for `project_root`. /// /// Reads project pin files in priority order: @@ -220,4 +356,49 @@ mod tests { let r = resolve_python(dir.path()); assert_eq!(r.pin_origin, PinOrigin::SystemDefault); } + + // ── Rust toolchain tests ───────────────────────────────────────────────── + + #[test] + fn rust_toolchain_toml_stable() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("rust-toolchain.toml"), + "[toolchain]\nchannel = \"stable\"\n", + ).unwrap(); + let r = resolve_rust(dir.path()); + assert_eq!(r.toolchain_id, "rust-stable"); + assert!(!r.toolchain_drift); + assert_eq!(r.pin_origin, PinOrigin::RustToolchainToml); + } + + #[test] + fn rust_toolchain_file_nightly() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("rust-toolchain"), "nightly\n").unwrap(); + let r = resolve_rust(dir.path()); + assert_eq!(r.toolchain_id, "rust-nightly"); + assert!(r.toolchain_drift); + assert_eq!(r.pin_origin, PinOrigin::RustToolchainFile); + } + + #[test] + fn cargo_toml_rust_version() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"foo\"\nrust-version = \"1.75\"\n", + ).unwrap(); + let r = resolve_rust(dir.path()); + assert_eq!(r.pin_origin, PinOrigin::CargoToml); + assert!(r.toolchain_id.starts_with("rust-1")); + } + + #[test] + fn rust_default_is_stable() { + let dir = TempDir::new().unwrap(); + let r = resolve_rust(dir.path()); + assert_eq!(r.toolchain_id, "rust-stable"); + assert_eq!(r.pin_origin, PinOrigin::SystemDefault); + } } diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs index 9e30c990..5ee43820 100644 --- a/src/dynamic/verify.rs +++ b/src/dynamic/verify.rs @@ -94,8 +94,12 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult { } } - // Resolve toolchain information. - let toolchain_res = toolchain::resolve_python(Path::new(".")); + // Resolve toolchain information (lang-aware: §22.2). + use crate::symbol::Lang; + let toolchain_res = match spec.lang { + Lang::Rust => toolchain::resolve_rust(Path::new(".")), + _ => toolchain::resolve_python(Path::new(".")), + }; let toolchain_match = if toolchain_res.toolchain_drift { "drift" } else { "exact" }; let start = Instant::now(); diff --git a/tests/dynamic_fixtures/escape/rust_build_rs/Cargo.toml b/tests/dynamic_fixtures/escape/rust_build_rs/Cargo.toml new file mode 100644 index 00000000..69c43f77 --- /dev/null +++ b/tests/dynamic_fixtures/escape/rust_build_rs/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "nyx-escape-test" +version = "0.1.0" +edition = "2021" +build = "build.rs" + +[[bin]] +name = "nyx_escape_harness" +path = "src/main.rs" + +[dependencies] diff --git a/tests/dynamic_fixtures/escape/rust_build_rs/build.rs b/tests/dynamic_fixtures/escape/rust_build_rs/build.rs new file mode 100644 index 00000000..ee44ca83 --- /dev/null +++ b/tests/dynamic_fixtures/escape/rust_build_rs/build.rs @@ -0,0 +1,16 @@ +// Escape attempt: malicious build.rs that tries to write a host marker file +// during `cargo build`. A sandboxed build should prevent this. +// +// Expected behaviour in an isolated sandbox: file NOT created, build fails +// or marker stays absent. +// Expected behaviour on unsandboxed host: marker would appear at /tmp/pwned_build_rs. +fn main() { + // Attempt to write a marker file on the host. + let marker = std::path::Path::new("/tmp/pwned_build_rs"); + if std::fs::File::create(marker).is_ok() { + // Escape succeeded: signal via stdout (cargo captures build script stdout). + println!("cargo:warning=NYX_ESCAPE_SUCCESS: build.rs created /tmp/pwned_build_rs"); + } else { + println!("cargo:warning=BLOCKED: could not write /tmp/pwned_build_rs"); + } +} diff --git a/tests/dynamic_fixtures/escape/rust_build_rs/src/main.rs b/tests/dynamic_fixtures/escape/rust_build_rs/src/main.rs new file mode 100644 index 00000000..fb16f2fb --- /dev/null +++ b/tests/dynamic_fixtures/escape/rust_build_rs/src/main.rs @@ -0,0 +1,4 @@ +// Benign entry point — the escape attempt lives in build.rs, not here. +fn main() { + println!("nyx_escape_harness: no vulnerability in main"); +} diff --git a/tests/dynamic_fixtures/rust/cmdi_adversarial.rs b/tests/dynamic_fixtures/rust/cmdi_adversarial.rs new file mode 100644 index 00000000..e67d8e46 --- /dev/null +++ b/tests/dynamic_fixtures/rust/cmdi_adversarial.rs @@ -0,0 +1,13 @@ +/// Command injection — adversarial collision fixture. +/// +/// Prints "NYX_PWN_CMDI" unconditionally without going through a shell sink. +/// +/// Expected verdict: Inconclusive(OracleCollisionSuspected) +/// oracle_fired = true (OutputContains("NYX_PWN_CMDI")) +/// sink_hit = false (__NYX_SINK_HIT__ never printed) +/// Cap: CODE_EXEC Entry: `run(payload: &str)` +pub fn run(payload: &str) { + // Coincidental oracle match — not a command execution sink. + println!("NYX_PWN_CMDI"); + let _ = payload.len(); +} diff --git a/tests/dynamic_fixtures/rust/cmdi_negative.rs b/tests/dynamic_fixtures/rust/cmdi_negative.rs new file mode 100644 index 00000000..0b1938b0 --- /dev/null +++ b/tests/dynamic_fixtures/rust/cmdi_negative.rs @@ -0,0 +1,23 @@ +/// Command injection — negative fixture. +/// +/// Safe function: uses Command with a list of args (no shell expansion). +/// Payload is used as a literal argument, not interpreted by the shell. +/// Expected verdict: NotConfirmed. +/// Cap: CODE_EXEC Entry: `run(payload: &str)` +pub fn run(payload: &str) { + use std::process::Command; + + // Safe: list-form args — shell metacharacters in payload are inert. + let safe_target = payload + .chars() + .filter(|c| c.is_alphanumeric() || *c == '.') + .collect::(); + + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + match Command::new("echo").arg(&safe_target).output() { + Ok(out) => print!("{}", String::from_utf8_lossy(&out.stdout)), + Err(e) => eprintln!("exec error: {}", e), + } +} diff --git a/tests/dynamic_fixtures/rust/cmdi_positive.rs b/tests/dynamic_fixtures/rust/cmdi_positive.rs new file mode 100644 index 00000000..c2b8477d --- /dev/null +++ b/tests/dynamic_fixtures/rust/cmdi_positive.rs @@ -0,0 +1,24 @@ +/// Command injection — positive fixture. +/// +/// Vulnerable function: builds a shell command string from user input. +/// Expected verdict: Confirmed (payload "; echo NYX_PWN_CMDI" echoes the marker). +/// Cap: CODE_EXEC Entry: `run(payload: &str)` +pub fn run(payload: &str) { + use std::process::Command; + + // Vulnerable: user input concatenated into shell command string. + let cmd = format!("echo {}", payload); + + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + match Command::new("sh").args(["-c", &cmd]).output() { + Ok(out) => { + print!("{}", String::from_utf8_lossy(&out.stdout)); + if !out.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&out.stderr)); + } + } + Err(e) => eprintln!("exec error: {}", e), + } +} diff --git a/tests/dynamic_fixtures/rust/cmdi_positive2.rs b/tests/dynamic_fixtures/rust/cmdi_positive2.rs new file mode 100644 index 00000000..90863b73 --- /dev/null +++ b/tests/dynamic_fixtures/rust/cmdi_positive2.rs @@ -0,0 +1,25 @@ +/// Command injection — second positive fixture. +/// +/// Variant: builds a script filename from user input and passes it to sh. +/// Expected verdict: Confirmed (payload "; echo NYX_PWN_CMDI" injects into the +/// command string at a different AST site than cmdi_positive.rs). +/// Cap: CODE_EXEC Entry: `run(payload: &str)` +pub fn run(payload: &str) { + use std::process::Command; + + // Vulnerable: payload used as a path argument, which is shell-interpolated. + let script = format!("ls -la {}", payload); + + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + match Command::new("sh").args(["-c", &script]).output() { + Ok(out) => { + print!("{}", String::from_utf8_lossy(&out.stdout)); + if !out.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&out.stderr)); + } + } + Err(e) => eprintln!("exec error: {}", e), + } +} diff --git a/tests/dynamic_fixtures/rust/cmdi_unsupported.rs b/tests/dynamic_fixtures/rust/cmdi_unsupported.rs new file mode 100644 index 00000000..3949ee0a --- /dev/null +++ b/tests/dynamic_fixtures/rust/cmdi_unsupported.rs @@ -0,0 +1,21 @@ +/// Command injection — unsupported entry-kind fixture. +/// +/// Vulnerable logic lives inside a struct method. The test creates a Diag +/// with an unsupported entry kind so `HarnessSpec::from_finding` returns +/// `Err(UnsupportedReason::EntryKindUnsupported)`. +/// +/// Expected verdict: Unsupported(EntryKindUnsupported) +/// Cap: CODE_EXEC +pub struct ShellRunner; + +impl ShellRunner { + pub fn execute(&self, user_cmd: &str) -> Option { + use std::process::Command; + let cmd = format!("run {}", user_cmd); + Command::new("sh") + .args(["-c", &cmd]) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + } +} diff --git a/tests/dynamic_fixtures/rust/fileio_adversarial.rs b/tests/dynamic_fixtures/rust/fileio_adversarial.rs new file mode 100644 index 00000000..cb8060b0 --- /dev/null +++ b/tests/dynamic_fixtures/rust/fileio_adversarial.rs @@ -0,0 +1,14 @@ +/// File I/O — adversarial collision fixture. +/// +/// Prints "root:" unconditionally without opening any file or printing the +/// sink-reachability sentinel. +/// +/// Expected verdict: Inconclusive(OracleCollisionSuspected) +/// oracle_fired = true (OutputContains("root:")) +/// sink_hit = false (__NYX_SINK_HIT__ never printed) +/// Cap: FILE_IO Entry: `run(payload: &str)` +pub fn run(payload: &str) { + // Coincidental oracle match — no file I/O sink involved. + println!("root:x:0:0:root:/root:/bin/bash"); + let _ = payload.len(); +} diff --git a/tests/dynamic_fixtures/rust/fileio_negative.rs b/tests/dynamic_fixtures/rust/fileio_negative.rs new file mode 100644 index 00000000..40ce6634 --- /dev/null +++ b/tests/dynamic_fixtures/rust/fileio_negative.rs @@ -0,0 +1,27 @@ +/// File I/O — negative fixture. +/// +/// Safe function: reads from a fixed path; user input is only used as a search +/// term within file contents, not as the file path itself. +/// Expected verdict: NotConfirmed. +/// Cap: FILE_IO Entry: `run(payload: &str)` +pub fn run(payload: &str) { + // Safe: path is hard-coded; payload cannot influence which file is read. + let fixed_path = "/tmp/nyx_safe_file_does_not_exist"; + + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + match std::fs::read_to_string(fixed_path) { + Ok(contents) => { + // Only use payload as a filter, not as a path. + for line in contents.lines() { + if line.contains(payload) { + println!("{}", line); + } + } + } + Err(_) => { + println!("file not found (expected in test)"); + } + } +} diff --git a/tests/dynamic_fixtures/rust/fileio_positive.rs b/tests/dynamic_fixtures/rust/fileio_positive.rs new file mode 100644 index 00000000..ed360348 --- /dev/null +++ b/tests/dynamic_fixtures/rust/fileio_positive.rs @@ -0,0 +1,16 @@ +/// File I/O — positive fixture. +/// +/// Vulnerable function: reads a file at a user-controlled path. +/// Expected verdict: Confirmed (path-traversal payload "../../../../etc/passwd" +/// causes "root:" to appear in stdout). +/// Cap: FILE_IO Entry: `run(payload: &str)` +pub fn run(payload: &str) { + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + // Vulnerable: user controls the file path — path traversal possible. + match std::fs::read_to_string(payload) { + Ok(contents) => print!("{}", contents), + Err(e) => eprintln!("Error reading {}: {}", payload, e), + } +} diff --git a/tests/dynamic_fixtures/rust/fileio_positive2.rs b/tests/dynamic_fixtures/rust/fileio_positive2.rs new file mode 100644 index 00000000..1aa4b150 --- /dev/null +++ b/tests/dynamic_fixtures/rust/fileio_positive2.rs @@ -0,0 +1,24 @@ +/// File I/O — second positive fixture. +/// +/// Variant: uses std::fs::File::open instead of read_to_string; path constructed +/// from a base directory and user-supplied component (still traversable). +/// Expected verdict: Confirmed (payload "../../../../etc/passwd" reaches /etc/passwd). +/// Cap: FILE_IO Entry: `run(payload: &str)` +pub fn run(payload: &str) { + use std::io::Read; + + // Vulnerable: path joins base with user input without canonicalization. + let path = format!("/var/data/{}", payload); + + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + match std::fs::File::open(&path) { + Ok(mut f) => { + let mut buf = String::new(); + let _ = f.read_to_string(&mut buf); + print!("{}", buf); + } + Err(e) => eprintln!("Error opening {}: {}", path, e), + } +} diff --git a/tests/dynamic_fixtures/rust/fileio_unsupported.rs b/tests/dynamic_fixtures/rust/fileio_unsupported.rs new file mode 100644 index 00000000..c4a9b423 --- /dev/null +++ b/tests/dynamic_fixtures/rust/fileio_unsupported.rs @@ -0,0 +1,16 @@ +/// File I/O — unsupported entry-kind fixture. +/// +/// Vulnerable logic lives inside a struct method. The test creates a Diag +/// with an unsupported entry kind so `HarnessSpec::from_finding` returns +/// `Err(UnsupportedReason::EntryKindUnsupported)`. +/// +/// Expected verdict: Unsupported(EntryKindUnsupported) +/// Cap: FILE_IO +pub struct FileService; + +impl FileService { + pub fn read(&self, path: &str) -> String { + // Vulnerable: path traversal — user controls the path. + std::fs::read_to_string(path).unwrap_or_default() + } +} diff --git a/tests/dynamic_fixtures/rust/sqli_adversarial.rs b/tests/dynamic_fixtures/rust/sqli_adversarial.rs new file mode 100644 index 00000000..1feff77c --- /dev/null +++ b/tests/dynamic_fixtures/rust/sqli_adversarial.rs @@ -0,0 +1,15 @@ +/// SQL injection — adversarial collision fixture. +/// +/// Prints "NYX_SQL_CONFIRMED" unconditionally without going through a SQL sink +/// and without printing the sink-reachability sentinel. +/// +/// Expected verdict: Inconclusive(OracleCollisionSuspected) +/// oracle_fired = true (OutputContains("NYX_SQL_CONFIRMED")) +/// sink_hit = false (__NYX_SINK_HIT__ never printed) +/// Cap: SQL_QUERY Entry: `run(payload: &str)` +pub fn run(payload: &str) { + // Coincidental oracle match — not a SQL sink. + println!("NYX_SQL_CONFIRMED"); + // Ensure payload is consumed so the compiler does not optimise it away. + let _ = payload.len(); +} diff --git a/tests/dynamic_fixtures/rust/sqli_negative.rs b/tests/dynamic_fixtures/rust/sqli_negative.rs new file mode 100644 index 00000000..aa55312d --- /dev/null +++ b/tests/dynamic_fixtures/rust/sqli_negative.rs @@ -0,0 +1,33 @@ +/// SQL injection — negative fixture. +/// +/// Safe function: uses parameterized query (rusqlite params![]). +/// Expected verdict: NotConfirmed (no injection possible; oracle cannot fire). +/// Cap: SQL_QUERY Entry: `run(payload: &str)` +pub fn run(payload: &str) { + use rusqlite::Connection; + + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch( + "CREATE TABLE users (id INTEGER, name TEXT);\ + INSERT INTO users VALUES (1, 'alice');\ + INSERT INTO users VALUES (2, 'bob');", + ) + .expect("setup schema"); + + // Safe: parameterized query — payload cannot escape the literal binding. + let mut stmt = conn + .prepare("SELECT name FROM users WHERE name=?1") + .expect("prepare"); + + // Sink reached via safe parameterized path; sentinel fires but oracle will not. + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + let _ = stmt + .query_map(rusqlite::params![payload], |row| row.get::<_, String>(0)) + .map(|rows| { + for name in rows.flatten() { + println!("{}", name); + } + }); +} diff --git a/tests/dynamic_fixtures/rust/sqli_positive.rs b/tests/dynamic_fixtures/rust/sqli_positive.rs new file mode 100644 index 00000000..667403aa --- /dev/null +++ b/tests/dynamic_fixtures/rust/sqli_positive.rs @@ -0,0 +1,38 @@ +/// SQL injection — positive fixture. +/// +/// Vulnerable function: directly concatenates user input into SQL. +/// Expected verdict: Confirmed (UNION payload causes "NYX_SQL_CONFIRMED" in output). +/// Cap: SQL_QUERY Entry: `run(payload: &str)` +pub fn run(payload: &str) { + use rusqlite::Connection; + + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch( + "CREATE TABLE users (id INTEGER, name TEXT);\ + INSERT INTO users VALUES (1, 'alice');\ + INSERT INTO users VALUES (2, 'bob');", + ) + .expect("setup schema"); + + // Vulnerable: direct string concatenation into SQL. + let query = format!("SELECT name FROM users WHERE name='{}'", payload); + + // Sentinel: the sink (conn.prepare) is reachable with tainted input. + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + match conn.prepare(&query) { + Ok(mut stmt) => { + let _ = stmt.query_map([], |row| row.get::<_, String>(0)).map(|rows| { + for name in rows.flatten() { + println!("{}", name); + } + }); + } + Err(e) => { + // Error-based: print query on failure (oracle can detect via query echo). + println!("DB query: {}", query); + println!("DB error: {}", e); + } + } +} diff --git a/tests/dynamic_fixtures/rust/sqli_unsupported.rs b/tests/dynamic_fixtures/rust/sqli_unsupported.rs new file mode 100644 index 00000000..ce3b5cf6 --- /dev/null +++ b/tests/dynamic_fixtures/rust/sqli_unsupported.rs @@ -0,0 +1,24 @@ +/// SQL injection — unsupported entry-kind fixture. +/// +/// The vulnerable logic lives inside a struct method. The test creates a Diag +/// with an unsupported entry kind, so `HarnessSpec::from_finding` returns +/// `Err(UnsupportedReason::EntryKindUnsupported)`. +/// +/// Expected verdict: Unsupported(EntryKindUnsupported) +/// Cap: SQL_QUERY +pub struct UserRepository; + +impl UserRepository { + pub fn find_user(&self, name: &str) -> Vec { + use rusqlite::Connection; + let conn = Connection::open_in_memory().expect("open db"); + let query = format!("SELECT name FROM users WHERE name='{}'", name); + match conn.prepare(&query) { + Ok(mut stmt) => stmt + .query_map([], |row| row.get::<_, String>(0)) + .map(|rows| rows.flatten().collect()) + .unwrap_or_default(), + Err(_) => vec![], + } + } +} diff --git a/tests/dynamic_fixtures/rust/sqli_with_secret.rs b/tests/dynamic_fixtures/rust/sqli_with_secret.rs new file mode 100644 index 00000000..696145ae --- /dev/null +++ b/tests/dynamic_fixtures/rust/sqli_with_secret.rs @@ -0,0 +1,38 @@ +/// SQL injection fixture — same vulnerability as sqli_positive, placed in a +/// directory that contains a secrets file (.env with AWS key). +/// +/// The test verifies that the AWS key is redacted from outcome.json / telemetry +/// and never appears in any repro artifact after verification. +/// +/// Expected verdict: Confirmed (same oracle as sqli_positive) +/// Cap: SQL_QUERY Entry: `run(payload: &str)` +pub fn run(payload: &str) { + use rusqlite::Connection; + + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch( + "CREATE TABLE users (id INTEGER, name TEXT);\ + INSERT INTO users VALUES (1, 'alice');\ + INSERT INTO users VALUES (2, 'bob');", + ) + .expect("setup schema"); + + let query = format!("SELECT name FROM users WHERE name='{}'", payload); + + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + match conn.prepare(&query) { + Ok(mut stmt) => { + let _ = stmt.query_map([], |row| row.get::<_, String>(0)).map(|rows| { + for name in rows.flatten() { + println!("{}", name); + } + }); + } + Err(e) => { + println!("DB query: {}", query); + println!("DB error: {}", e); + } + } +} diff --git a/tests/dynamic_fixtures/rust/ssrf_adversarial.rs b/tests/dynamic_fixtures/rust/ssrf_adversarial.rs new file mode 100644 index 00000000..e605e588 --- /dev/null +++ b/tests/dynamic_fixtures/rust/ssrf_adversarial.rs @@ -0,0 +1,14 @@ +/// SSRF — adversarial collision fixture. +/// +/// Prints "daemon:" unconditionally without making any network or file request, +/// and without printing the sink-reachability sentinel. +/// +/// Expected verdict: Inconclusive(OracleCollisionSuspected) +/// oracle_fired = true (OutputContains("daemon:")) +/// sink_hit = false (__NYX_SINK_HIT__ never printed) +/// Cap: SSRF Entry: `run(payload: &str)` +pub fn run(payload: &str) { + // Coincidental oracle match — no URL fetch or network sink involved. + println!("daemon:*:1:1:System Services:/var/root:/usr/bin/false"); + let _ = payload.len(); +} diff --git a/tests/dynamic_fixtures/rust/ssrf_negative.rs b/tests/dynamic_fixtures/rust/ssrf_negative.rs new file mode 100644 index 00000000..3b3f13a5 --- /dev/null +++ b/tests/dynamic_fixtures/rust/ssrf_negative.rs @@ -0,0 +1,20 @@ +/// SSRF — negative fixture. +/// +/// Safe function: URL is fixed; user input is used only as a query parameter, +/// not as the URL origin. +/// Expected verdict: NotConfirmed. +/// Cap: SSRF Entry: `run(payload: &str)` +pub fn run(payload: &str) { + // Safe: payload is a query value, not the URL itself — origin is fixed. + let url = format!("file:///tmp/safe_data?q={}", payload); + + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + // Extract the fixed path (no user control over scheme or host). + let path = "/tmp/safe_data"; + match std::fs::read_to_string(path) { + Ok(content) => print!("{}", content), + Err(_) => println!("resource not available (expected in test): {}", url), + } +} diff --git a/tests/dynamic_fixtures/rust/ssrf_positive.rs b/tests/dynamic_fixtures/rust/ssrf_positive.rs new file mode 100644 index 00000000..b33e8065 --- /dev/null +++ b/tests/dynamic_fixtures/rust/ssrf_positive.rs @@ -0,0 +1,26 @@ +/// SSRF — positive fixture. +/// +/// Vulnerable function: fetches a user-controlled URL. Implements a minimal +/// file:// scheme reader so the test requires no network and no async runtime. +/// +/// Expected verdict: Confirmed (payload "file:///etc/passwd" causes "daemon:" +/// to appear in stdout via the file:// scheme handler). +/// Cap: SSRF Entry: `run(payload: &str)` +pub fn run(payload: &str) { + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + // Vulnerable: user controls the URL — SSRF via file:// scheme reaches local files. + let result = fetch_url(payload); + print!("{}", result); +} + +fn fetch_url(url: &str) -> String { + if let Some(path) = url.strip_prefix("file://") { + std::fs::read_to_string(path) + .unwrap_or_else(|e| format!("fetch error: {}", e)) + } else { + // For non-file schemes, report the target (demonstrating SSRF intent). + format!("SSRF: would connect to {}", url) + } +} diff --git a/tests/dynamic_fixtures/rust/ssrf_positive2.rs b/tests/dynamic_fixtures/rust/ssrf_positive2.rs new file mode 100644 index 00000000..f0b7d62e --- /dev/null +++ b/tests/dynamic_fixtures/rust/ssrf_positive2.rs @@ -0,0 +1,32 @@ +/// SSRF — second positive fixture. +/// +/// Variant: user-controlled URL stored in a struct field before being fetched, +/// exercising a different taint path than ssrf_positive.rs. +/// Expected verdict: Confirmed (payload "file:///etc/passwd" reaches the file +/// reader via the stored URL field). +/// Cap: SSRF Entry: `run(payload: &str)` +pub fn run(payload: &str) { + let req = Request { url: payload.to_owned() }; + + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + let result = req.execute(); + print!("{}", result); +} + +struct Request { + url: String, +} + +impl Request { + fn execute(&self) -> String { + // Vulnerable: self.url derived from user input — SSRF. + if let Some(path) = self.url.strip_prefix("file://") { + std::fs::read_to_string(path) + .unwrap_or_else(|e| format!("fetch error: {}", e)) + } else { + format!("SSRF: would connect to {}", self.url) + } + } +} diff --git a/tests/dynamic_fixtures/rust/ssrf_unsupported.rs b/tests/dynamic_fixtures/rust/ssrf_unsupported.rs new file mode 100644 index 00000000..e41caf8e --- /dev/null +++ b/tests/dynamic_fixtures/rust/ssrf_unsupported.rs @@ -0,0 +1,20 @@ +/// SSRF — unsupported entry-kind fixture. +/// +/// Vulnerable logic lives inside a struct method. The test creates a Diag +/// with an unsupported entry kind so `HarnessSpec::from_finding` returns +/// `Err(UnsupportedReason::EntryKindUnsupported)`. +/// +/// Expected verdict: Unsupported(EntryKindUnsupported) +/// Cap: SSRF +pub struct HttpClient; + +impl HttpClient { + pub fn get(&self, url: &str) -> String { + // Vulnerable: user controls the URL — SSRF. + if let Some(path) = url.strip_prefix("file://") { + std::fs::read_to_string(path).unwrap_or_default() + } else { + format!("fetching: {}", url) + } + } +} diff --git a/tests/dynamic_sandbox_escape.rs b/tests/dynamic_sandbox_escape.rs index d4ef38bf..72f63054 100644 --- a/tests/dynamic_sandbox_escape.rs +++ b/tests/dynamic_sandbox_escape.rs @@ -181,6 +181,51 @@ mod escape_tests { 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 Rust build.rs cannot write to the host when compiled + /// inside the sandbox. + /// + /// 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. + /// + /// 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. + #[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. + 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" + ); + } + // ── Docker exec reuse test ──────────────────────────────────────────────── /// Verify that the second payload for the same spec_hash reuses the running diff --git a/tests/repro_determinism.rs b/tests/repro_determinism.rs index 6bebb90d..76b13178 100644 --- a/tests/repro_determinism.rs +++ b/tests/repro_determinism.rs @@ -147,6 +147,136 @@ mod repro_determinism_tests { unsafe { std::env::remove_var("NYX_REPRO_BASE") }; } + // ── Rust repro tests ───────────────────────────────────────────────────── + + fn make_confirmed_rust_spec(spec_hash: &str) -> HarnessSpec { + HarnessSpec { + finding_id: "rust_determ00001".into(), + entry_file: "src/entry.rs".into(), + entry_name: "run".into(), + entry_kind: EntryKind::Function, + lang: Lang::Rust, + toolchain_id: "rust-stable".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "src/entry.rs".into(), + sink_line: 18, + spec_hash: spec_hash.to_owned(), + } + } + + fn make_confirmed_rust_harness_source() -> String { + r#"mod entry; +fn main() { + let payload = std::env::var("NYX_PAYLOAD").unwrap_or_default(); + entry::run(&payload); +} +"# + .into() + } + + /// Rust repro bundle has the correct layout. + /// + /// For Rust, harness is at `harness/src/main.rs` and `harness/Cargo.toml` + /// is also written (unlike Python which uses `harness/harness.py`). + #[test] + fn rust_repro_layout_is_correct() { + let dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("NYX_REPRO_BASE", dir.path().to_str().unwrap()) }; + + let spec = make_confirmed_rust_spec("rust_determ00001"); + let opts = SandboxOptions::default(); + let outcome = make_confirmed_outcome(); + let verdict = make_confirmed_verdict("rust_determ00001"); + let harness_src = make_confirmed_rust_harness_source(); + + let artifact = repro::write( + &spec, + &opts, + &outcome, + &verdict, + &harness_src, + "pub fn run(payload: &str) { println!(\"{}\", payload); }\n", + b"' UNION SELECT 'NYX_SQL_CONFIRMED'--", + "sqli-union-nyx", + None, + ) + .expect("Rust repro write must succeed"); + + // Rust-specific layout: harness lives under harness/src/main.rs. + assert!( + artifact.root.join("harness/src/main.rs").exists(), + "Rust harness must be at harness/src/main.rs" + ); + assert!( + artifact.root.join("harness/Cargo.toml").exists(), + "Rust harness must include harness/Cargo.toml" + ); + // Common layout. + assert!(artifact.root.join("manifest.json").exists()); + assert!(artifact.root.join("entry/extracted_source.rs").exists()); + assert!(artifact.root.join("payload/payload.bin").exists()); + assert!(artifact.root.join("expected/outcome.json").exists()); + assert!(artifact.root.join("expected/verdict.json").exists()); + assert!(artifact.root.join("reproduce.sh").exists()); + + unsafe { std::env::remove_var("NYX_REPRO_BASE") }; + } + + /// Rust repro outcome.json is byte-identical across two writes. + #[test] + fn rust_repro_outcome_is_deterministic() { + let dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("NYX_REPRO_BASE", dir.path().to_str().unwrap()) }; + + let spec = make_confirmed_rust_spec("rust_determ00002"); + let opts = SandboxOptions::default(); + let outcome = make_confirmed_outcome(); + let verdict = make_confirmed_verdict("rust_determ00002"); + let harness_src = make_confirmed_rust_harness_source(); + let entry_src = "pub fn run(payload: &str) { println!(\"{}\", payload); }\n"; + + let artifact1 = repro::write( + &spec, + &opts, + &outcome, + &verdict, + &harness_src, + entry_src, + b"' UNION SELECT 'NYX_SQL_CONFIRMED'--", + "sqli-union-nyx", + None, + ) + .expect("first Rust repro write"); + let json1 = + std::fs::read_to_string(artifact1.root.join("expected/outcome.json")).unwrap(); + + std::fs::remove_dir_all(&artifact1.root).unwrap(); + + let artifact2 = repro::write( + &spec, + &opts, + &outcome, + &verdict, + &harness_src, + entry_src, + b"' UNION SELECT 'NYX_SQL_CONFIRMED'--", + "sqli-union-nyx", + None, + ) + .expect("second Rust repro write"); + let json2 = + std::fs::read_to_string(artifact2.root.join("expected/outcome.json")).unwrap(); + + assert_eq!( + json1, json2, + "Rust outcome.json must be byte-identical across two writes" + ); + + unsafe { std::env::remove_var("NYX_REPRO_BASE") }; + } + /// Verify verdict.json is correctly structured. #[test] fn verdict_json_is_valid() { diff --git a/tests/rust_fixtures.rs b/tests/rust_fixtures.rs new file mode 100644 index 00000000..97ac1d28 --- /dev/null +++ b/tests/rust_fixtures.rs @@ -0,0 +1,393 @@ +//! Rust fixture integration tests (Phase 04 acceptance gate). +//! +//! Runs the dynamic verification pipeline against each Rust fixture and +//! asserts the expected verdict. Requires `--features dynamic` and a +//! working `cargo` toolchain on PATH. +//! +//! Fixture entry points follow the convention: +//! `pub fn run(payload: &str)` in `tests/dynamic_fixtures/rust/{name}.rs` +//! +//! The harness emitter wraps each fixture in a generated `src/main.rs` that +//! reads `NYX_PAYLOAD` from the environment and calls `entry::run(&payload)`. +//! +//! Build note: the first run per capability compiles a Cargo project; subsequent +//! runs with differing entry files hit the build cache only when Cargo.toml and +//! src/entry.rs are identical (the cache key includes the entry file hash). +//! Expect 2-4 compilations per full test run (one per unique dependency set). +//! +//! Run with: `cargo nextest run --features dynamic --test rust_fixtures` + +#[cfg(feature = "dynamic")] +mod rust_fixture_tests { + use nyx_scanner::commands::scan::Diag; + use nyx_scanner::dynamic::verify::{verify_finding, VerifyOptions}; + use nyx_scanner::evidence::{ + Confidence, Evidence, FlowStep, FlowStepKind, InconclusiveReason, UnsupportedReason, + VerifyStatus, + }; + use nyx_scanner::labels::Cap; + use nyx_scanner::patterns::{FindingCategory, Severity}; + use std::path::{Path, PathBuf}; + use std::sync::Mutex; + use tempfile::TempDir; + + // Serialize all fixture tests: prevents races on process-global env vars + // (NYX_REPRO_BASE, NYX_TELEMETRY_PATH) and the shared build cache dir. + static FIXTURE_LOCK: Mutex<()> = Mutex::new(()); + + fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/rust") + .join(name) + } + + /// Run a Rust fixture through the full dynamic verification pipeline. + /// + /// The fixture file is copied to a temp dir as `src/entry.rs`. + /// `NYX_REPRO_BASE` and `NYX_TELEMETRY_PATH` are redirected to temp dirs. + fn run_fixture( + fixture: &str, + func: &str, + cap: Cap, + sink_line: u32, + ) -> nyx_scanner::evidence::VerifyResult { + let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let path = fixture_path(fixture); + + let tmp = TempDir::new().unwrap(); + // Rust fixtures live at src/entry.rs inside the harness workdir; + // the Diag's entry_file points to the fixture source on disk. + let dst_dir = tmp.path().join("src"); + std::fs::create_dir_all(&dst_dir).unwrap(); + let dst = dst_dir.join("entry.rs"); + std::fs::copy(&path, &dst).expect("fixture file must exist"); + + unsafe { + std::env::set_var("NYX_REPRO_BASE", tmp.path().join("repro").to_str().unwrap()); + std::env::set_var( + "NYX_TELEMETRY_PATH", + tmp.path().join("events.jsonl").to_str().unwrap(), + ); + } + + // Point the Diag at the original fixture path (absolute), not the copy. + // The harness emitter reads the file at entry_file to extract source. + let diag = make_diag(&path, func, cap, sink_line); + + let opts = VerifyOptions::default(); + let result = verify_finding(&diag, &opts); + + unsafe { + std::env::remove_var("NYX_REPRO_BASE"); + std::env::remove_var("NYX_TELEMETRY_PATH"); + } + + result + } + + // ── SQLi fixtures ──────────────────────────────────────────────────────── + + #[test] + fn sqli_positive_is_confirmed() { + let result = run_fixture("sqli_positive.rs", "run", Cap::SQL_QUERY, 18); + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "sqli_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + assert!( + result.triggered_payload.is_some(), + "Confirmed result must have triggered_payload" + ); + } + + #[test] + fn sqli_negative_is_not_confirmed() { + let result = run_fixture("sqli_negative.rs", "run", Cap::SQL_QUERY, 22); + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "sqli_negative must be NotConfirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn sqli_unsupported_is_unsupported() { + let path = fixture_path("sqli_unsupported.rs"); + let mut d = make_diag(&path, "find_user", Cap::SQL_QUERY, 10); + d.confidence = Some(Confidence::Low); + let opts = VerifyOptions::default(); + let result = verify_finding(&d, &opts); + assert_eq!(result.status, VerifyStatus::Unsupported); + assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow)); + } + + #[test] + fn sqli_adversarial_is_inconclusive_collision() { + // Adversarial prints oracle marker without __NYX_SINK_HIT__: + // oracle_fired = true, sink_hit = false → OracleCollisionSuspected. + let result = run_fixture("sqli_adversarial.rs", "run", Cap::SQL_QUERY, 999); + assert_eq!( + result.status, + VerifyStatus::Inconclusive, + "sqli_adversarial must be Inconclusive; got {:?}", + result.status + ); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected), + "adversarial must be OracleCollisionSuspected" + ); + } + + // ── Command injection fixtures ─────────────────────────────────────────── + + #[test] + fn cmdi_positive_is_confirmed() { + let result = run_fixture("cmdi_positive.rs", "run", Cap::CODE_EXEC, 17); + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "cmdi_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn cmdi_negative_is_not_confirmed() { + let result = run_fixture("cmdi_negative.rs", "run", Cap::CODE_EXEC, 17); + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "cmdi_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn cmdi_unsupported_is_unsupported() { + let path = fixture_path("cmdi_unsupported.rs"); + let mut d = make_diag(&path, "execute", Cap::CODE_EXEC, 9); + d.confidence = Some(Confidence::Low); + let opts = VerifyOptions::default(); + let result = verify_finding(&d, &opts); + assert_eq!(result.status, VerifyStatus::Unsupported); + assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow)); + } + + #[test] + fn cmdi_adversarial_is_inconclusive_collision() { + let result = run_fixture("cmdi_adversarial.rs", "run", Cap::CODE_EXEC, 999); + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + // ── File I/O fixtures ──────────────────────────────────────────────────── + + #[test] + fn fileio_positive_is_confirmed() { + let result = run_fixture("fileio_positive.rs", "run", Cap::FILE_IO, 7); + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "fileio_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn fileio_negative_is_not_confirmed() { + let result = run_fixture("fileio_negative.rs", "run", Cap::FILE_IO, 17); + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "fileio_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn fileio_unsupported_is_unsupported() { + let path = fixture_path("fileio_unsupported.rs"); + let mut d = make_diag(&path, "read", Cap::FILE_IO, 8); + d.confidence = Some(Confidence::Low); + let opts = VerifyOptions::default(); + let result = verify_finding(&d, &opts); + assert_eq!(result.status, VerifyStatus::Unsupported); + assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow)); + } + + #[test] + fn fileio_adversarial_is_inconclusive_collision() { + let result = run_fixture("fileio_adversarial.rs", "run", Cap::FILE_IO, 999); + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + // ── SSRF fixtures ──────────────────────────────────────────────────────── + + #[test] + fn ssrf_positive_is_confirmed() { + let result = run_fixture("ssrf_positive.rs", "run", Cap::SSRF, 7); + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "ssrf_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn ssrf_negative_is_not_confirmed() { + let result = run_fixture("ssrf_negative.rs", "run", Cap::SSRF, 13); + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "ssrf_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn ssrf_unsupported_is_unsupported() { + let path = fixture_path("ssrf_unsupported.rs"); + let mut d = make_diag(&path, "get", Cap::SSRF, 8); + d.confidence = Some(Confidence::Low); + let opts = VerifyOptions::default(); + let result = verify_finding(&d, &opts); + assert_eq!(result.status, VerifyStatus::Unsupported); + assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow)); + } + + #[test] + fn ssrf_adversarial_is_inconclusive_collision() { + let result = run_fixture("ssrf_adversarial.rs", "run", Cap::SSRF, 999); + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + // ── Variant fixtures (smoke-test second positive paths) ────────────────── + + #[test] + fn cmdi_positive2_is_confirmed() { + let result = run_fixture("cmdi_positive2.rs", "run", Cap::CODE_EXEC, 17); + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "cmdi_positive2 must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn fileio_positive2_is_confirmed() { + let result = run_fixture("fileio_positive2.rs", "run", Cap::FILE_IO, 11); + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "fileio_positive2 must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn ssrf_positive2_is_confirmed() { + let result = run_fixture("ssrf_positive2.rs", "run", Cap::SSRF, 7); + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "ssrf_positive2 must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + // ── Harness architecture: non-Python-specific gate ─────────────────────── + + /// Rust fixture must produce a VerifyResult (not panic or ICE). + /// This is the Phase 04 acceptance gate: the dynamic pipeline handles + /// a compiled-language finding without Python-specific assumptions. + #[test] + fn rust_pipeline_does_not_panic() { + let result = run_fixture("sqli_positive.rs", "run", Cap::SQL_QUERY, 18); + // Any verdict is acceptable; the test asserts non-panic only. + let _ = result; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + fn make_diag(path: &Path, func: &str, cap: Cap, sink_line: u32) -> Diag { + let path_str = path.to_string_lossy().into_owned(); + let evidence = Evidence { + flow_steps: vec![ + FlowStep { + step: 1, + kind: FlowStepKind::Source, + file: path_str.clone(), + line: 1, + col: 0, + snippet: None, + variable: Some("payload".into()), + callee: None, + function: Some(func.to_owned()), + is_cross_file: false, + }, + FlowStep { + step: 2, + kind: FlowStepKind::Sink, + file: path_str.clone(), + line: sink_line, + col: 4, + snippet: None, + variable: None, + callee: None, + function: None, + is_cross_file: false, + }, + ], + sink_caps: cap.bits(), + ..Default::default() + }; + Diag { + path: path_str, + line: sink_line as usize, + col: 0, + severity: Severity::High, + id: "taint-unsanitised-flow".into(), + category: FindingCategory::Security, + path_validated: false, + guard_kind: None, + message: None, + labels: vec![], + confidence: Some(Confidence::High), + evidence: Some(evidence), + rank_score: None, + rank_reason: None, + suppressed: false, + suppression: None, + rollup: None, + finding_id: String::new(), + alternative_finding_ids: vec![], + stable_hash: 0, + } + } +}