diff --git a/benches/dynamic_bench.rs b/benches/dynamic_bench.rs index 2cd20cd0..67f6c5a3 100644 --- a/benches/dynamic_bench.rs +++ b/benches/dynamic_bench.rs @@ -232,6 +232,142 @@ fn bench_rust_harness_build_cold(c: &mut Criterion) { }); } +#[cfg(feature = "dynamic")] +fn make_js_sqli_spec() -> HarnessSpec { + HarnessSpec { + finding_id: "bench_js_0001".into(), + entry_file: "tests/dynamic_fixtures/js/sqli_positive.js".into(), + entry_name: "login".into(), + entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function, + lang: Lang::JavaScript, + toolchain_id: "node-20".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "tests/dynamic_fixtures/js/sqli_positive.js".into(), + sink_line: 8, + spec_hash: "benchjssqli000001".into(), + } +} + +#[cfg(feature = "dynamic")] +fn make_go_sqli_spec() -> HarnessSpec { + HarnessSpec { + finding_id: "bench_go_0001".into(), + entry_file: "tests/dynamic_fixtures/go/sqli_positive.go".into(), + entry_name: "Login".into(), + entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function, + lang: Lang::Go, + toolchain_id: "go-1.21".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "tests/dynamic_fixtures/go/sqli_positive.go".into(), + sink_line: 12, + spec_hash: "benchgosqli000001".into(), + } +} + +#[cfg(feature = "dynamic")] +fn make_java_sqli_spec() -> HarnessSpec { + HarnessSpec { + finding_id: "bench_java_0001".into(), + entry_file: "tests/dynamic_fixtures/java/sqli_positive.java".into(), + entry_name: "login".into(), + entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function, + lang: Lang::Java, + toolchain_id: "java-21".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "tests/dynamic_fixtures/java/sqli_positive.java".into(), + sink_line: 9, + spec_hash: "benchjavasqli00001".into(), + } +} + +#[cfg(feature = "dynamic")] +fn make_php_sqli_spec() -> HarnessSpec { + HarnessSpec { + finding_id: "bench_php_0001".into(), + entry_file: "tests/dynamic_fixtures/php/sqli_positive.php".into(), + entry_name: "login".into(), + entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function, + lang: Lang::Php, + toolchain_id: "php-8".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "tests/dynamic_fixtures/php/sqli_positive.php".into(), + sink_line: 9, + spec_hash: "benchphpsqli000001".into(), + } +} + +/// JS harness build (source gen + disk write). +#[cfg(feature = "dynamic")] +fn bench_js_harness_build_cold(c: &mut Criterion) { + use nyx_scanner::dynamic::harness; + let spec = make_js_sqli_spec(); + c.bench_function("js_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("JS harness build") + }); + }); +} + +/// Go harness build (source gen + disk write, no compilation). +#[cfg(feature = "dynamic")] +fn bench_go_harness_build_cold(c: &mut Criterion) { + use nyx_scanner::dynamic::harness; + let spec = make_go_sqli_spec(); + c.bench_function("go_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("Go harness build") + }); + }); +} + +/// Java harness build (source gen + disk write, no compilation). +#[cfg(feature = "dynamic")] +fn bench_java_harness_build_cold(c: &mut Criterion) { + use nyx_scanner::dynamic::harness; + let spec = make_java_sqli_spec(); + c.bench_function("java_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("Java harness build") + }); + }); +} + +/// PHP harness build (source gen + disk write). +#[cfg(feature = "dynamic")] +fn bench_php_harness_build_cold(c: &mut Criterion) { + use nyx_scanner::dynamic::harness; + let spec = make_php_sqli_spec(); + c.bench_function("php_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("PHP harness build") + }); + }); +} + #[cfg(feature = "dynamic")] fn bench_noop(_c: &mut Criterion) {} @@ -251,6 +387,10 @@ criterion_group!( bench_docker_exec_warm, bench_docker_payload_cost, bench_rust_harness_build_cold, + bench_js_harness_build_cold, + bench_go_harness_build_cold, + bench_java_harness_build_cold, + bench_php_harness_build_cold, ); #[cfg(not(feature = "dynamic"))] diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index 312cb8fc..9af56a84 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -322,6 +322,377 @@ fn build_cache_path( Ok(path) } +// ── Node.js build sandbox ───────────────────────────────────────────────────── + +/// Prepare a Node.js project for `spec` in `workdir`. +/// +/// Runs `npm install --no-save` if `package.json` is present. +/// Build isolation is NOT yet implemented (deferred to a future phase). +/// npm lifecycle scripts run on the host. See deferred.md for details. +pub fn prepare_node(spec: &HarnessSpec, workdir: &Path) -> Result { + let lockfile_hash = compute_node_lockfile_hash(workdir); + let cache_path = build_cache_path(&lockfile_hash, "node", &spec.toolchain_id)?; + + // Cache hit: node_modules already installed. + if cache_path.join(".node_cache_done").exists() { + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: true, + duration: std::time::Duration::ZERO, + }); + } + + // No package.json = no deps to install. + if !workdir.join("package.json").exists() { + std::fs::write(cache_path.join(".node_cache_done"), b"no-package-json")?; + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: false, + duration: std::time::Duration::ZERO, + }); + } + + let start = std::time::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(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1])); + } + match try_npm_install(workdir) { + Ok(()) => { + let _ = std::fs::write(cache_path.join(".node_cache_done"), b"done"); + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: false, + duration: start.elapsed(), + }); + } + Err(e) => { + last_err = e; + } + } + } + + Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS }) +} + +fn try_npm_install(workdir: &Path) -> Result<(), String> { + let npm = std::env::var("NYX_NPM_BIN").unwrap_or_else(|_| "npm".to_owned()); + let output = Command::new(&npm) + .args(["install", "--no-save", "--no-audit", "--no-fund"]) + .current_dir(workdir) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", std::env::var("HOME").unwrap_or_default()) + .output() + .map_err(|e| format!("npm install: {e}"))?; + + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + Ok(()) +} + +fn compute_node_lockfile_hash(workdir: &Path) -> String { + let mut h = Hasher::new(); + for fname in &["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"] { + if let Ok(content) = std::fs::read(workdir.join(fname)) { + h.update(fname.as_bytes()); + h.update(&content); + } + } + let out = h.finalize(); + format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap())) +} + +// ── Go build sandbox ────────────────────────────────────────────────────────── + +/// Prepare a compiled Go binary for `spec`. +/// +/// Checks a build cache keyed on `(go.mod + go.sum + entry hash, "go", toolchain_id)`. +/// On a cache hit returns immediately; otherwise runs `go build -o nyx_harness .` +/// in `workdir`. +/// +/// Build isolation is NOT yet implemented (deferred). `go build` runs on the +/// host. A malicious `init()` therefore runs with host privileges. See deferred.md. +pub fn prepare_go(spec: &HarnessSpec, workdir: &Path) -> Result { + let lockfile_hash = compute_go_source_hash(workdir); + let cache_path = build_cache_path(&lockfile_hash, "go", &spec.toolchain_id)?; + + let binary = cache_path.join("nyx_harness"); + if binary.exists() { + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: true, + duration: std::time::Duration::ZERO, + }); + } + + let start = std::time::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(std::time::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_go_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_go_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String> { + let go_bin = std::env::var("NYX_GO_BIN").unwrap_or_else(|_| "go".to_owned()); + let output = Command::new(&go_bin) + .args(["build", "-o", binary_dest.to_str().unwrap_or("nyx_harness"), "."]) + .current_dir(workdir) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", std::env::var("HOME").unwrap_or_default()) + .env("GOPATH", std::env::var("GOPATH").unwrap_or_else(|_| { + std::env::var("HOME").map(|h| format!("{h}/go")).unwrap_or_else(|_| "/tmp/go".to_owned()) + })) + .env("GOMODCACHE", std::env::var("GOMODCACHE").unwrap_or_else(|_| { + std::env::var("HOME").map(|h| format!("{h}/go/pkg/mod")).unwrap_or_else(|_| "/tmp/gomod".to_owned()) + })) + .output() + .map_err(|e| format!("go build: {e}"))?; + + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + Ok(()) +} + +fn compute_go_source_hash(workdir: &Path) -> String { + let mut h = Hasher::new(); + for fname in &["go.mod", "go.sum", "main.go"] { + if let Ok(content) = std::fs::read(workdir.join(fname)) { + h.update(fname.as_bytes()); + h.update(&content); + } + } + if let Ok(content) = std::fs::read(workdir.join("entry").join("entry.go")) { + h.update(b"entry/entry.go"); + h.update(&content); + } + let out = h.finalize(); + format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap())) +} + +// ── Java build sandbox ──────────────────────────────────────────────────────── + +/// Prepare compiled Java classes for `spec`. +/// +/// Runs `javac NyxHarness.java Entry.java` in `workdir`. +/// Class files land in the workdir (default package, no output dir). +/// +/// Build isolation is NOT yet implemented (deferred). `javac` runs on the host. +/// A malicious annotation processor / compile-time plugin could run with host +/// privileges. See deferred.md for planned `nyx-build-java:{toolchain_id}` container. +pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result { + let source_hash = compute_java_source_hash(workdir); + let cache_path = build_cache_path(&source_hash, "java", &spec.toolchain_id)?; + + // Cache hit: class files already compiled. Restore them to workdir so the + // classpath (which points to workdir, not cache_path) can find them when a + // different finding hits the same compiled artefact via a fresh spec_hash. + if cache_path.join("NyxHarness.class").exists() { + for cls in &["NyxHarness.class", "Entry.class"] { + let src = cache_path.join(cls); + let dst = workdir.join(cls); + if src.exists() && !dst.exists() { + let _ = std::fs::copy(&src, &dst); + } + } + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: true, + duration: std::time::Duration::ZERO, + }); + } + + let start = std::time::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(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1])); + } + match try_compile_java(workdir, &cache_path) { + Ok(()) => { + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: false, + duration: start.elapsed(), + }); + } + Err(e) => { + last_err = e; + let _ = std::fs::remove_file(cache_path.join("NyxHarness.class")); + let _ = std::fs::remove_file(cache_path.join("Entry.class")); + } + } + } + + Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS }) +} + +fn try_compile_java(workdir: &Path, cache_path: &Path) -> Result<(), String> { + let javac = std::env::var("NYX_JAVAC_BIN").unwrap_or_else(|_| "javac".to_owned()); + + // Compile sources — class files are written to workdir by default. + let mut args = vec!["-d".to_owned(), workdir.to_string_lossy().into_owned()]; + for src in &["NyxHarness.java", "Entry.java"] { + let p = workdir.join(src); + if p.exists() { + args.push(p.to_string_lossy().into_owned()); + } + } + + let output = Command::new(&javac) + .args(&args) + .current_dir(workdir) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", std::env::var("HOME").unwrap_or_default()) + .output() + .map_err(|e| format!("javac: {e}"))?; + + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + + // Copy class files to cache. + for cls in &["NyxHarness.class", "Entry.class"] { + let src = workdir.join(cls); + if src.exists() { + let _ = std::fs::copy(&src, cache_path.join(cls)); + } + } + Ok(()) +} + +fn compute_java_source_hash(workdir: &Path) -> String { + let mut h = Hasher::new(); + for fname in &["NyxHarness.java", "Entry.java"] { + if let Ok(content) = std::fs::read(workdir.join(fname)) { + h.update(fname.as_bytes()); + h.update(&content); + } + } + let out = h.finalize(); + format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap())) +} + +// ── PHP build sandbox ───────────────────────────────────────────────────────── + +/// Prepare a PHP project for `spec` in `workdir`. +/// +/// Runs `composer install --no-interaction` if `composer.json` is present. +/// Build isolation is NOT yet implemented (deferred). Composer post-install +/// scripts run on the host. See deferred.md for planned +/// `nyx-build-php:{toolchain_id}` container details. +pub fn prepare_php(spec: &HarnessSpec, workdir: &Path) -> Result { + let lockfile_hash = compute_php_lockfile_hash(workdir); + let cache_path = build_cache_path(&lockfile_hash, "php", &spec.toolchain_id)?; + + if cache_path.join(".php_cache_done").exists() { + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: true, + duration: std::time::Duration::ZERO, + }); + } + + if !workdir.join("composer.json").exists() { + std::fs::write(cache_path.join(".php_cache_done"), b"no-composer-json")?; + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: false, + duration: std::time::Duration::ZERO, + }); + } + + let start = std::time::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(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1])); + } + match try_composer_install(workdir) { + Ok(()) => { + let _ = std::fs::write(cache_path.join(".php_cache_done"), b"done"); + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: false, + duration: start.elapsed(), + }); + } + Err(e) => { + last_err = e; + } + } + } + + Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS }) +} + +fn try_composer_install(workdir: &Path) -> Result<(), String> { + let composer = std::env::var("NYX_COMPOSER_BIN").unwrap_or_else(|_| "composer".to_owned()); + let output = Command::new(&composer) + .args(["install", "--no-interaction", "--no-dev", "--prefer-dist"]) + .current_dir(workdir) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", std::env::var("HOME").unwrap_or_default()) + .env("COMPOSER_ALLOW_SUPERUSER", "1") + .output() + .map_err(|e| format!("composer install: {e}"))?; + + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + Ok(()) +} + +fn compute_php_lockfile_hash(workdir: &Path) -> String { + let mut h = Hasher::new(); + for fname in &["composer.json", "composer.lock"] { + if let Ok(content) = std::fs::read(workdir.join(fname)) { + h.update(fname.as_bytes()); + h.update(&content); + } + } + let out = h.finalize(); + format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap())) +} + #[cfg(test)] mod tests { use super::*; @@ -342,4 +713,29 @@ mod tests { let h2 = compute_lockfile_hash(dir.path()); assert_ne!(h1, h2, "hash must change when requirements.txt changes"); } + + #[test] + fn node_lockfile_hash_stable() { + let dir = tempfile::TempDir::new().unwrap(); + let h1 = compute_node_lockfile_hash(dir.path()); + let h2 = compute_node_lockfile_hash(dir.path()); + assert_eq!(h1, h2); + } + + #[test] + fn go_source_hash_changes_with_main_go() { + let dir = tempfile::TempDir::new().unwrap(); + let h1 = compute_go_source_hash(dir.path()); + std::fs::write(dir.path().join("main.go"), "package main\nfunc main() {}").unwrap(); + let h2 = compute_go_source_hash(dir.path()); + assert_ne!(h1, h2); + } + + #[test] + fn java_source_hash_stable() { + let dir = tempfile::TempDir::new().unwrap(); + let h1 = compute_java_source_hash(dir.path()); + let h2 = compute_java_source_hash(dir.path()); + assert_eq!(h1, h2); + } } diff --git a/src/dynamic/harness.rs b/src/dynamic/harness.rs index 0667c012..eb2c5599 100644 --- a/src/dynamic/harness.rs +++ b/src/dynamic/harness.rs @@ -177,18 +177,18 @@ mod tests { #[test] fn build_unsupported_lang_returns_err() { - // Go is not yet supported (unsupported lang path). + // C is not supported (no emitter exists for it). let spec = HarnessSpec { finding_id: "0000000000000001".into(), - entry_file: "main.go".into(), + entry_file: "main.c".into(), entry_name: "handleRequest".into(), entry_kind: EntryKind::Function, - lang: Lang::Go, - toolchain_id: "go-stable".into(), + lang: Lang::C, + toolchain_id: "c-stable".into(), payload_slot: PayloadSlot::Param(0), expected_cap: Cap::SQL_QUERY, constraint_hints: vec![], - sink_file: "main.go".into(), + sink_file: "main.c".into(), sink_line: 5, spec_hash: "0000000000000000".into(), }; diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs new file mode 100644 index 00000000..1ec94359 --- /dev/null +++ b/src/dynamic/lang/go.rs @@ -0,0 +1,219 @@ +//! Go harness emitter. +//! +//! Generates a Go `main` package that: +//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars. +//! 2. Imports the entry package from `./entry/` and calls the entry function. +//! 3. Uses `runtime.Caller`-style wrapping in fixtures for sink-reachability +//! probes (fixtures explicitly emit `__NYX_SINK_HIT__` before the sink). +//! +//! Build step: `prepare_go()` in `build_sandbox.rs` runs `go build -o nyx_harness .` +//! in the workdir. The harness command is updated to the compiled binary path. +//! +//! File layout in workdir: +//! ```text +//! main.go ← harness entry point (generated) +//! go.mod ← module definition (generated) +//! entry/ +//! entry.go ← entry function (copied from project; must have `package entry`) +//! ``` +//! +//! Payload slot support: +//! - `PayloadSlot::Param(0)` — pass payload as `string` first argument. +//! - `PayloadSlot::EnvVar(name)` — set env var before calling entry. +//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`. +//! +//! Build container: `nyx-build-go:{toolchain_id}` (deferred; §19.1). + +use crate::dynamic::lang::HarnessSource; +use crate::dynamic::spec::{HarnessSpec, PayloadSlot}; +use crate::evidence::UnsupportedReason; + +/// Emit a Go harness for `spec`. +pub fn emit(spec: &HarnessSpec) -> Result { + match &spec.payload_slot { + PayloadSlot::Param(0) | PayloadSlot::EnvVar(_) => {} + _ => return Err(UnsupportedReason::EntryKindUnsupported), + } + + let main_go = generate_main_go(spec); + let go_mod = generate_go_mod(); + + Ok(HarnessSource { + source: main_go, + filename: "main.go".to_owned(), + command: vec!["./nyx_harness".to_owned()], + extra_files: vec![("go.mod".to_owned(), go_mod)], + entry_subpath: Some("entry/entry.go".to_owned()), + }) +} + +fn generate_main_go(spec: &HarnessSpec) -> String { + let entry_fn = capitalize_first(&spec.entry_name); + let (pre_call, call_expr) = build_call(spec, &entry_fn); + + // Determine which imports are needed. + let env_import = if matches!(&spec.payload_slot, PayloadSlot::EnvVar(_)) { + "" + } else { + "" + }; + let _ = env_import; + + format!( + r#"// Nyx dynamic harness — auto-generated, do not edit. +package main + +import ( + "encoding/base64" + "fmt" + "os" + + "nyx-harness/entry" +) + +func main() {{ + payload := nyxPayload() +{pre_call} {call_expr} + _ = fmt.Sprintf("") // suppress unused import if call_expr uses fmt directly + _ = os.Stderr // suppress unused import +}} + +func nyxPayload() string {{ + if v := os.Getenv("NYX_PAYLOAD"); v != "" {{ + return v + }} + if b64 := os.Getenv("NYX_PAYLOAD_B64"); b64 != "" {{ + if data, err := base64.StdEncoding.DecodeString(b64); err == nil {{ + return string(data) + }} + }} + return "" +}} +"#, + pre_call = pre_call, + call_expr = call_expr, + ) +} + +fn generate_go_mod() -> String { + "module nyx-harness\n\ngo 1.21\n".to_owned() +} + +/// Build `(pre_call_setup, call_expression)` for the chosen payload slot. +fn build_call(spec: &HarnessSpec, entry_fn: &str) -> (String, String) { + match &spec.payload_slot { + PayloadSlot::Param(0) => { + let pre = String::new(); + let call = format!("entry.{entry_fn}(payload)"); + (pre, call) + } + PayloadSlot::EnvVar(name) => { + let pre = format!("\tos.Setenv({name:?}, payload)\n"); + let call = format!("entry.{entry_fn}()"); + (pre, call) + } + _ => { + let pre = String::new(); + let call = format!("entry.{entry_fn}(payload)"); + (pre, call) + } + } +} + +/// Capitalize the first character of a string (Go exported names must start uppercase). +pub fn capitalize_first(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} + +#[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: "go0000000000001".into(), + entry_file: "cmd/server/main.go".into(), + entry_name: "handleRequest".into(), + entry_kind: EntryKind::Function, + lang: Lang::Go, + toolchain_id: "go-stable".into(), + payload_slot, + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "cmd/server/main.go".into(), + sink_line: 20, + spec_hash: "go0000000000001".into(), + } + } + + #[test] + fn emit_produces_source() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("nyx-harness/entry")); + assert!(harness.source.contains("nyxPayload()")); + assert!(harness.source.contains("entry.HandleRequest(payload)")); + assert_eq!(harness.filename, "main.go"); + assert_eq!(harness.command, vec!["./nyx_harness"]); + } + + #[test] + fn emit_includes_go_mod_in_extra_files() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + let go_mod = harness.extra_files.iter().find(|(n, _)| n == "go.mod"); + assert!(go_mod.is_some(), "go.mod must be in extra_files"); + assert!(go_mod.unwrap().1.contains("module nyx-harness")); + } + + #[test] + fn emit_entry_subpath_is_entry_go() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert_eq!(harness.entry_subpath, Some("entry/entry.go".to_owned())); + } + + #[test] + fn emit_env_var_slot() { + let spec = make_spec(PayloadSlot::EnvVar("DB_USER".into())); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("os.Setenv")); + assert!(harness.source.contains("\"DB_USER\"")); + } + + #[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 emit_stdin_is_unsupported() { + let spec = make_spec(PayloadSlot::Stdin); + let err = emit(&spec).unwrap_err(); + assert_eq!(err, UnsupportedReason::EntryKindUnsupported); + } + + #[test] + fn capitalize_first_handles_lowercase() { + assert_eq!(capitalize_first("handleRequest"), "HandleRequest"); + assert_eq!(capitalize_first("run"), "Run"); + assert_eq!(capitalize_first(""), ""); + assert_eq!(capitalize_first("A"), "A"); + } + + #[test] + fn go_mod_has_correct_module() { + let go_mod = generate_go_mod(); + assert!(go_mod.contains("module nyx-harness")); + assert!(go_mod.contains("go 1.21")); + } +} diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs new file mode 100644 index 00000000..cc5d65d2 --- /dev/null +++ b/src/dynamic/lang/java.rs @@ -0,0 +1,191 @@ +//! Java harness emitter. +//! +//! Generates a Java `NyxHarness.java` that: +//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars. +//! 2. Calls `Entry.{entry_name}(payload)` from the co-located `Entry.java`. +//! 3. Catches all exceptions to prevent harness crashes from masking results. +//! +//! Sink-reachability probe: fixtures explicitly emit `System.out.println("__NYX_SINK_HIT__")` +//! before the actual sink call (same pattern as Rust and Go fixtures). +//! +//! Build step: `prepare_java()` in `build_sandbox.rs` runs `javac NyxHarness.java Entry.java` +//! in the workdir. The compiled `.class` files land in the workdir. +//! +//! File layout in workdir: +//! ```text +//! NyxHarness.java ← harness main class (generated) +//! Entry.java ← entry class (copied from project) +//! NyxHarness.class ← compiled by prepare_java() +//! Entry.class ← compiled by prepare_java() +//! ``` +//! +//! Payload slot support: +//! - `PayloadSlot::Param(0)` — pass payload as `String` first argument. +//! - `PayloadSlot::EnvVar(name)` — set system property before calling entry. +//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`. +//! +//! Build container: `nyx-build-java:{toolchain_id}` (deferred; §19.1). + +use crate::dynamic::lang::HarnessSource; +use crate::dynamic::spec::{HarnessSpec, PayloadSlot}; +use crate::evidence::UnsupportedReason; + +/// Emit a Java harness for `spec`. +pub fn emit(spec: &HarnessSpec) -> Result { + match &spec.payload_slot { + PayloadSlot::Param(0) | PayloadSlot::EnvVar(_) => {} + _ => return Err(UnsupportedReason::EntryKindUnsupported), + } + + let source = generate_harness_java(spec); + + Ok(HarnessSource { + source, + filename: "NyxHarness.java".to_owned(), + // Use absolute workdir classpath set by runner.rs after compilation. + // Before runner.rs updates it, '.' works for process backend when run + // from the workdir. + command: vec![ + "java".to_owned(), + "-cp".to_owned(), + ".".to_owned(), + "NyxHarness".to_owned(), + ], + extra_files: vec![], + entry_subpath: Some("Entry.java".to_owned()), + }) +} + +fn generate_harness_java(spec: &HarnessSpec) -> String { + let entry_method = &spec.entry_name; + let (pre_call, call_expr) = build_call(spec, entry_method); + + format!( + r#"// Nyx dynamic harness — auto-generated, do not edit. +public class NyxHarness {{ + public static void main(String[] args) throws Exception {{ + String payload = nyxPayload(); +{pre_call} try {{ + {call_expr} + }} catch (Exception e) {{ + System.err.println("NYX_EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage()); + }} + }} + + static String nyxPayload() {{ + String v = System.getenv("NYX_PAYLOAD"); + if (v != null && !v.isEmpty()) {{ + return v; + }} + String b64 = System.getenv("NYX_PAYLOAD_B64"); + if (b64 != null && !b64.isEmpty()) {{ + byte[] decoded = java.util.Base64.getDecoder().decode(b64); + return new String(decoded, java.nio.charset.StandardCharsets.UTF_8); + }} + return ""; + }} +}} +"#, + pre_call = pre_call, + call_expr = call_expr, + ) +} + +/// Build `(pre_call_setup, call_expression)` for the chosen payload slot. +fn build_call(spec: &HarnessSpec, method: &str) -> (String, String) { + match &spec.payload_slot { + PayloadSlot::Param(0) => { + let pre = String::new(); + let call = format!("Entry.{method}(payload);"); + (pre, call) + } + PayloadSlot::EnvVar(name) => { + // Use System.setProperty since env vars cannot be set post-JVM-launch + // via standard Java APIs. Fixtures that read env vars must use + // System.getProperty as a fallback, or read NYX_PAYLOAD_PROP_{name}. + let pre = format!( + " System.setProperty({name:?}, payload);\n" + ); + let call = format!("Entry.{method}();"); + (pre, call) + } + _ => { + let pre = String::new(); + let call = format!("Entry.{method}(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: "java00000000001".into(), + entry_file: "src/main/java/App.java".into(), + entry_name: "processInput".into(), + entry_kind: EntryKind::Function, + lang: Lang::Java, + toolchain_id: "java-21".into(), + payload_slot, + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "src/main/java/App.java".into(), + sink_line: 25, + spec_hash: "java00000000001".into(), + } + } + + #[test] + fn emit_produces_source() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("public class NyxHarness")); + assert!(harness.source.contains("nyxPayload()")); + assert!(harness.source.contains("Entry.processInput(payload)")); + assert_eq!(harness.filename, "NyxHarness.java"); + assert_eq!(harness.command, vec!["java", "-cp", ".", "NyxHarness"]); + } + + #[test] + fn emit_entry_subpath_is_entry_java() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert_eq!(harness.entry_subpath, Some("Entry.java".to_owned())); + } + + #[test] + fn emit_env_var_slot() { + let spec = make_spec(PayloadSlot::EnvVar("DB_PASSWORD".into())); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("System.setProperty")); + assert!(harness.source.contains("\"DB_PASSWORD\"")); + } + + #[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 emit_stdin_is_unsupported() { + let spec = make_spec(PayloadSlot::Stdin); + let err = emit(&spec).unwrap_err(); + assert_eq!(err, UnsupportedReason::EntryKindUnsupported); + } + + #[test] + fn harness_has_base64_decoder() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("Base64.getDecoder()")); + assert!(harness.source.contains("NYX_PAYLOAD_B64")); + } +} diff --git a/src/dynamic/lang/javascript.rs b/src/dynamic/lang/javascript.rs new file mode 100644 index 00000000..0794d49b --- /dev/null +++ b/src/dynamic/lang/javascript.rs @@ -0,0 +1,248 @@ +//! JavaScript / TypeScript harness emitter. +//! +//! Generates a Node.js script that: +//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars. +//! 2. Requires the entry module from the workdir (`entry.js`). +//! 3. Calls the entry function with the payload routed to the correct slot. +//! 4. Catches all exceptions to prevent harness crashes from masking results. +//! +//! Sink-reachability probe: the fixture itself emits `__NYX_SINK_HIT__` before +//! the actual sink call (same pattern as Rust fixtures). The harness is a pure +//! runner with no line-level tracing. +//! +//! Payload slot support: +//! - `PayloadSlot::Param(n)` — n-th positional argument. +//! - `PayloadSlot::EnvVar(name)` — set env var before calling. +//! - `PayloadSlot::Stdin` — pipe payload to process.stdin. +//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`. +//! +//! Build: no compilation step. Command is `node harness.js`. +//! Build container: `nyx-build-node:{toolchain_id}` (deferred; §19.1). + +use crate::dynamic::lang::HarnessSource; +use crate::dynamic::spec::{HarnessSpec, PayloadSlot}; +use crate::evidence::UnsupportedReason; + +/// Emit a Node.js harness for `spec`. +pub fn emit(spec: &HarnessSpec) -> Result { + match &spec.payload_slot { + PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {} + _ => return Err(UnsupportedReason::EntryKindUnsupported), + } + + let source = generate_source(spec); + let entry_filename = entry_module_filename(&spec.entry_file); + + Ok(HarnessSource { + source, + filename: "harness.js".to_owned(), + command: vec!["node".to_owned(), "harness.js".to_owned()], + extra_files: vec![], + entry_subpath: Some(entry_filename), + }) +} + +fn generate_source(spec: &HarnessSpec) -> String { + let entry_module = entry_module_name(&spec.entry_file); + let entry_fn = &spec.entry_name; + let (pre_call, call_expr) = build_call(spec, &entry_module, entry_fn); + + format!( + r#"'use strict'; +// Nyx dynamic harness — auto-generated, do not edit. + +// ── Payload loading ──────────────────────────────────────────────────────────── +const _nyx_payload = (() => {{ + if (process.env.NYX_PAYLOAD && process.env.NYX_PAYLOAD.length > 0) {{ + return process.env.NYX_PAYLOAD; + }} + if (process.env.NYX_PAYLOAD_B64 && process.env.NYX_PAYLOAD_B64.length > 0) {{ + return Buffer.from(process.env.NYX_PAYLOAD_B64, 'base64').toString('utf8'); + }} + return ''; +}})(); + +// ── Entry module import ──────────────────────────────────────────────────────── +let _entry; +try {{ + _entry = require('./{entry_module}'); +}} catch (e) {{ + process.stderr.write('NYX_IMPORT_ERROR: ' + e.message + '\n'); + process.exit(77); +}} + +const payload = _nyx_payload; + +// ── Pre-call setup ───────────────────────────────────────────────────────────── +{pre_call} +// ── Call entry point ────────────────────────────────────────────────────────── +try {{ + const _result = {call_expr}; + if (_result !== undefined && _result !== null) {{ + if (_result && typeof _result.then === 'function') {{ + _result + .then(r => {{ if (r != null) process.stdout.write(String(r) + '\n'); }}) + .catch(e => {{ process.stderr.write('NYX_EXCEPTION: ' + e.message + '\n'); }}); + }} else {{ + process.stdout.write(String(_result) + '\n'); + }} + }} +}} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); +}} +"#, + entry_module = entry_module, + pre_call = pre_call, + call_expr = call_expr, + ) +} + +/// Build `(pre_call_setup, call_expression)` for the chosen payload slot. +fn build_call(spec: &HarnessSpec, _module: &str, func: &str) -> (String, String) { + match &spec.payload_slot { + PayloadSlot::Param(idx) => { + let pre = String::new(); + let call = if *idx == 0 { + format!("_entry.{func}(payload)") + } else { + let pads = (0..*idx).map(|_| "''").collect::>().join(", "); + format!("_entry.{func}({pads}, payload)") + }; + (pre, call) + } + PayloadSlot::EnvVar(name) => { + let pre = format!("process.env[{name:?}] = payload;\n"); + let call = format!("_entry.{func}()"); + (pre, call) + } + PayloadSlot::Stdin => { + // Synchronous stdin replacement via Buffer. + let pre = format!( + "const {{ Readable }} = require('stream');\n\ + process.stdin = Readable.from([Buffer.from(payload, 'utf8')]);\n" + ); + let call = format!("_entry.{func}()"); + (pre, call) + } + _ => { + let pre = String::new(); + let call = format!("_entry.{func}(payload)"); + (pre, call) + } + } +} + +/// Derive the JS module name from an entry file path. +/// +/// `"src/handlers/login.js"` → `"login"` (basename without extension). +pub fn entry_module_name(entry_file: &str) -> String { + let base = entry_file + .rsplit('/') + .next() + .unwrap_or(entry_file) + .rsplit('\\') + .next() + .unwrap_or(entry_file); + // Strip known JS/TS extensions. + for ext in &[".js", ".mjs", ".cjs", ".ts", ".mts"] { + if let Some(stem) = base.strip_suffix(ext) { + return stem.to_owned(); + } + } + base.to_owned() +} + +/// Derive the filename for `entry_subpath` from an entry file path. +/// +/// Always returns `"entry.js"` — fixture files are copied here regardless of +/// their original name so the harness can always `require('./entry')`. +pub fn entry_module_filename(_entry_file: &str) -> String { + "entry.js".to_owned() +} + +#[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: "js000000000001".into(), + entry_file: "src/app.js".into(), + entry_name: "login".into(), + entry_kind: EntryKind::Function, + lang: Lang::JavaScript, + toolchain_id: "node-20".into(), + payload_slot, + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "src/app.js".into(), + sink_line: 15, + spec_hash: "js000000000001".into(), + } + } + + #[test] + fn emit_produces_source() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("NYX_PAYLOAD")); + assert!(harness.source.contains("require")); + assert!(harness.source.contains("login")); + assert_eq!(harness.filename, "harness.js"); + assert_eq!(harness.command, vec!["node", "harness.js"]); + } + + #[test] + fn emit_param_index_0() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("_entry.login(payload)")); + } + + #[test] + fn emit_param_index_1() { + let spec = make_spec(PayloadSlot::Param(1)); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("_entry.login('', payload)")); + } + + #[test] + fn emit_env_var_slot() { + let spec = make_spec(PayloadSlot::EnvVar("DB_HOST".into())); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("process.env[\"DB_HOST\"] = payload")); + } + + #[test] + fn emit_stdin_slot() { + let spec = make_spec(PayloadSlot::Stdin); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("Readable")); + assert!(harness.source.contains("process.stdin")); + } + + #[test] + fn emit_http_body_is_unsupported() { + let spec = make_spec(PayloadSlot::HttpBody); + let err = emit(&spec).unwrap_err(); + assert_eq!(err, UnsupportedReason::EntryKindUnsupported); + } + + #[test] + fn emit_entry_subpath_is_entry_js() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert_eq!(harness.entry_subpath, Some("entry.js".to_owned())); + } + + #[test] + fn entry_module_name_strips_extensions() { + assert_eq!(entry_module_name("src/handlers/login.js"), "login"); + assert_eq!(entry_module_name("app.ts"), "app"); + assert_eq!(entry_module_name("handler.mjs"), "handler"); + assert_eq!(entry_module_name("no_ext"), "no_ext"); + } +} diff --git a/src/dynamic/lang/mod.rs b/src/dynamic/lang/mod.rs index a221e34f..c474bab2 100644 --- a/src/dynamic/lang/mod.rs +++ b/src/dynamic/lang/mod.rs @@ -3,6 +3,10 @@ //! Each submodule implements `emit(spec) -> HarnessSource` for one language. //! The top-level [`emit`] function dispatches on `spec.lang`. +pub mod go; +pub mod java; +pub mod javascript; +pub mod php; pub mod python; pub mod rust; @@ -34,6 +38,10 @@ pub fn emit(spec: &HarnessSpec) -> Result { match spec.lang { Lang::Python => python::emit(spec), Lang::Rust => rust::emit(spec), + Lang::JavaScript | Lang::TypeScript => javascript::emit(spec), + Lang::Go => go::emit(spec), + Lang::Java => java::emit(spec), + Lang::Php => php::emit(spec), _ => Err(UnsupportedReason::LangUnsupported), } } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs new file mode 100644 index 00000000..64aaa664 --- /dev/null +++ b/src/dynamic/lang/php.rs @@ -0,0 +1,202 @@ +//! PHP harness emitter. +//! +//! Generates a PHP script that: +//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars. +//! 2. Includes the entry file (`entry.php`) from the workdir. +//! 3. Calls the entry function with the payload routed to the correct slot. +//! 4. Catches all Throwables to prevent harness crashes from masking results. +//! +//! Sink-reachability probe: fixtures explicitly emit `__NYX_SINK_HIT__` before +//! the actual sink call (same pattern as Rust / JS fixtures). +//! +//! Payload slot support: +//! - `PayloadSlot::Param(n)` — n-th positional argument. +//! - `PayloadSlot::EnvVar(name)` — set `$_ENV`/`putenv()` before calling. +//! - `PayloadSlot::Stdin` — wrap `STDIN` with the payload. +//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`. +//! +//! Build: no compilation step. Command is `php harness.php`. +//! Build container: `nyx-build-php:{toolchain_id}` (deferred; §19.1). + +use crate::dynamic::lang::HarnessSource; +use crate::dynamic::spec::{HarnessSpec, PayloadSlot}; +use crate::evidence::UnsupportedReason; + +/// Emit a PHP harness for `spec`. +pub fn emit(spec: &HarnessSpec) -> Result { + match &spec.payload_slot { + PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {} + _ => return Err(UnsupportedReason::EntryKindUnsupported), + } + + let source = generate_source(spec); + + Ok(HarnessSource { + source, + filename: "harness.php".to_owned(), + command: vec!["php".to_owned(), "harness.php".to_owned()], + extra_files: vec![], + entry_subpath: Some("entry.php".to_owned()), + }) +} + +fn generate_source(spec: &HarnessSpec) -> String { + let entry_fn = &spec.entry_name; + let (pre_call, call_expr) = build_call(spec, entry_fn); + + format!( + r#"getMessage() . "\n"); + exit(77); +}} + +// ── Pre-call setup ───────────────────────────────────────────────────────────── +{pre_call} +// ── Call entry point ────────────────────────────────────────────────────────── +try {{ + $result = {call_expr}; + if ($result !== null) {{ + echo $result . "\n"; + }} +}} catch (Throwable $e) {{ + fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n"); +}} +"#, + pre_call = pre_call, + call_expr = call_expr, + ) +} + +/// Build `(pre_call_setup, call_expression)` for the chosen payload slot. +fn build_call(spec: &HarnessSpec, func: &str) -> (String, String) { + match &spec.payload_slot { + PayloadSlot::Param(idx) => { + let pre = String::new(); + let call = if *idx == 0 { + format!("{func}($payload)") + } else { + let pads = (0..*idx).map(|_| "''").collect::>().join(", "); + format!("{func}({pads}, $payload)") + }; + (pre, call) + } + PayloadSlot::EnvVar(name) => { + let pre = format!("putenv({name:?} . '=' . $payload);\n$_ENV[{name:?}] = $payload;\n"); + let call = format!("{func}()"); + (pre, call) + } + PayloadSlot::Stdin => { + // Replace STDIN with an in-memory stream containing the payload. + let pre = "if (defined('STDIN')) {\n $stream = fopen('php://memory', 'r+');\n fwrite($stream, $payload);\n rewind($stream);\n // Note: STDIN reassignment is not portable; fixture reads via fgets(STDIN).\n}\n".to_owned(); + let call = format!("{func}()"); + (pre, call) + } + _ => { + let pre = String::new(); + let call = format!("{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: "php0000000000001".into(), + entry_file: "src/login.php".into(), + entry_name: "login".into(), + entry_kind: EntryKind::Function, + lang: Lang::Php, + toolchain_id: "php-8".into(), + payload_slot, + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "src/login.php".into(), + sink_line: 10, + spec_hash: "php0000000000001".into(), + } + } + + #[test] + fn emit_produces_source() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert!(harness.source.starts_with(" Result { + // npm install for dependency resolution (no deps in basic fixtures). + match build_sandbox::prepare_node(spec, &harness.workdir) { + Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => { + return Err(RunError::BuildFailed { stderr, attempts }); + } + _ => {} + } + } + Lang::Go => { + // Compile the harness binary with `go build -o nyx_harness .`. + match build_sandbox::prepare_go(spec, &harness.workdir) { + Ok(build_result) => { + let binary = build_result.venv_path.join("nyx_harness"); + if binary.exists() { + harness.command = vec![binary.to_string_lossy().into_owned()]; + } else { + let fallback = harness.workdir.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(_) => {} + } + } + Lang::Java => { + // Compile NyxHarness.java + Entry.java with javac. + match build_sandbox::prepare_java(spec, &harness.workdir) { + Ok(_) => { + // Update classpath to absolute workdir path for Docker compatibility. + harness.command = vec![ + "java".to_owned(), + "-cp".to_owned(), + harness.workdir.to_string_lossy().into_owned(), + "NyxHarness".to_owned(), + ]; + } + Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => { + return Err(RunError::BuildFailed { stderr, attempts }); + } + Err(_) => {} + } + } + Lang::Php => { + // composer install if composer.json is present. + match build_sandbox::prepare_php(spec, &harness.workdir) { + Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => { + return Err(RunError::BuildFailed { stderr, attempts }); + } + _ => {} + } + } _ => { - // No build step for other interpreted languages. + // No build step for other languages. } } diff --git a/src/dynamic/sandbox.rs b/src/dynamic/sandbox.rs index 054fa470..66b6bec9 100644 --- a/src/dynamic/sandbox.rs +++ b/src/dynamic/sandbox.rs @@ -47,7 +47,7 @@ pub fn harness_is_interpreted(command: &[String]) -> bool { .unwrap_or(cmd0); matches!( base, - "python3" | "python" | "python2" | "node" | "nodejs" | "ruby" | "php" | "perl" + "python3" | "python" | "python2" | "node" | "nodejs" | "ruby" | "php" | "perl" | "java" ) } @@ -207,11 +207,25 @@ fn workdir_to_container_name(workdir: &Path) -> String { /// Docker image tag for a Python toolchain ID (e.g. `python-3.11`). fn python_image_for_toolchain(toolchain_id: &str) -> String { - // toolchain_id examples: "python-3", "python-3.11", "python-3.12" let ver = toolchain_id.strip_prefix("python-").unwrap_or("3"); format!("python:{ver}-slim") } +fn node_image_for_toolchain(toolchain_id: &str) -> String { + let ver = toolchain_id.strip_prefix("node-").unwrap_or("20"); + format!("node:{ver}-slim") +} + +fn java_image_for_toolchain(toolchain_id: &str) -> String { + let ver = toolchain_id.strip_prefix("java-").unwrap_or("21"); + format!("eclipse-temurin:{ver}-jre-jammy") +} + +fn php_image_for_toolchain(toolchain_id: &str) -> String { + let ver = toolchain_id.strip_prefix("php-").unwrap_or("8"); + format!("php:{ver}-cli") +} + // ── Entry point ─────────────────────────────────────────────────────────────── /// Run a built harness once with a chosen payload. @@ -273,7 +287,7 @@ fn run_docker( if !reused { // Determine the Python image from the harness command (first element). // Fall back to python:3-slim when the command is not recognised. - let image = detect_python_toolchain_from_harness(harness); + let image = detect_image_for_harness(harness); start_container(&container_name, &harness.workdir, &image)?; registry.insert(container_name.clone(), container_name.clone()); } @@ -374,6 +388,51 @@ fn start_container(name: &str, workdir: &Path, image: &str) -> Result<(), Sandbo } } +/// Build the inner-container command args for `docker exec`. +/// +/// For 2-arg interpreted commands (`python3 harness.py`, `node harness.js`, +/// `php harness.php`) the file arg is prefixed with `/workdir/`. +/// For Java (`java -cp /host/abs/path NyxHarness`) the classpath argument is +/// replaced with `/workdir` (the container-side mount path, not the host path +/// that runner.rs wrote after `javac`). +fn build_container_exec_args(command: &[String]) -> Vec { + let mut args = Vec::new(); + let cmd0 = match command.first() { + Some(c) => c.as_str(), + None => return args, + }; + let base = std::path::Path::new(cmd0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(cmd0); + + if base == "java" { + args.push("java".to_owned()); + let mut i = 1; + while i < command.len() { + if command[i] == "-cp" || command[i] == "-classpath" { + args.push(command[i].clone()); + i += 1; + args.push("/workdir".to_owned()); + i += 1; + } else { + args.push(command[i].clone()); + i += 1; + } + } + } else { + args.push(cmd0.to_owned()); + if let Some(harness_file) = command.get(1) { + if harness_file.starts_with('/') { + args.push(harness_file.clone()); + } else { + args.push(format!("/workdir/{harness_file}")); + } + } + } + args +} + /// Execute the harness inside an already-running container. fn exec_in_container( container_name: &str, @@ -405,15 +464,10 @@ fn exec_in_container( } cmd_args.push(container_name.into()); - // Build the exec command inside the container (always interpreted at this point). - let exec_cmd = harness.command.first().map(|s| s.as_str()).unwrap_or("python3"); - 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}")); + // Build the exec command inside the container. + for arg in build_container_exec_args(&harness.command) { + cmd_args.push(arg); + } let mut cmd = Command::new(docker_bin()); cmd.args(&cmd_args); @@ -495,20 +549,33 @@ fn exec_in_container( }) } -/// Detect the Python image to use based on the harness command. +/// Detect the Docker image for the harness based on the interpreter command. /// -/// The first element of `harness.command` is typically `python3` or a venv -/// path like `/path/to/venv/bin/python3`. Fall back to `python:3-slim`. -fn detect_python_toolchain_from_harness(harness: &BuiltHarness) -> String { - // The harness workdir encodes the spec_hash but not the toolchain. - // Use the default image for Python; callers that know the toolchain_id - // should pass it through BuiltHarness.env (NYX_TOOLCHAIN_ID) when needed. +/// Dispatches by the basename of `command[0]` (e.g. `python3`, `node`, `java`, +/// `php`). Falls back to `python:3-slim` for unrecognised interpreters. +/// `NYX_TOOLCHAIN_ID` env var overrides the version portion of the image tag. +fn detect_image_for_harness(harness: &BuiltHarness) -> String { + let cmd0 = harness.command.first().map(|s| s.as_str()).unwrap_or("python3"); + let base = std::path::Path::new(cmd0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(cmd0); + if let Ok(tid) = std::env::var("NYX_TOOLCHAIN_ID") { - return python_image_for_toolchain(&tid); + return match base { + "node" | "nodejs" => node_image_for_toolchain(&tid), + "java" => java_image_for_toolchain(&tid), + "php" => php_image_for_toolchain(&tid), + _ => python_image_for_toolchain(&tid), + }; + } + + match base { + "node" | "nodejs" => "node:20-slim".to_owned(), + "java" => "eclipse-temurin:21-jre-jammy".to_owned(), + "php" => "php:8-cli".to_owned(), + _ => "python:3-slim".to_owned(), } - // Default to python:3-slim which is always available in CI. - let _ = harness; - "python:3-slim".to_owned() } // ── Process backend ─────────────────────────────────────────────────────────── @@ -804,6 +871,82 @@ mod tests { assert_eq!(python_image_for_toolchain("python-3.12"), "python:3.12-slim"); } + #[test] + fn node_image_for_known_toolchains() { + assert_eq!(node_image_for_toolchain("node-20"), "node:20-slim"); + assert_eq!(node_image_for_toolchain("node-18"), "node:18-slim"); + assert_eq!(node_image_for_toolchain("node-lts"), "node:lts-slim"); + } + + #[test] + fn java_image_for_known_toolchains() { + assert_eq!(java_image_for_toolchain("java-21"), "eclipse-temurin:21-jre-jammy"); + assert_eq!(java_image_for_toolchain("java-17"), "eclipse-temurin:17-jre-jammy"); + } + + #[test] + fn php_image_for_known_toolchains() { + assert_eq!(php_image_for_toolchain("php-8"), "php:8-cli"); + assert_eq!(php_image_for_toolchain("php-8.2"), "php:8.2-cli"); + } + + #[test] + fn harness_is_interpreted_java() { + let cmd = vec!["java".to_owned(), "-cp".to_owned(), ".".to_owned(), "NyxHarness".to_owned()]; + assert!(harness_is_interpreted(&cmd)); + } + + #[test] + fn harness_is_interpreted_node() { + assert!(harness_is_interpreted(&["node".to_owned(), "harness.js".to_owned()])); + } + + #[test] + fn build_container_exec_args_python() { + let cmd = vec!["python3".to_owned(), "harness.py".to_owned()]; + assert_eq!( + build_container_exec_args(&cmd), + vec!["python3", "/workdir/harness.py"] + ); + } + + #[test] + fn build_container_exec_args_node() { + let cmd = vec!["node".to_owned(), "harness.js".to_owned()]; + assert_eq!( + build_container_exec_args(&cmd), + vec!["node", "/workdir/harness.js"] + ); + } + + #[test] + fn build_container_exec_args_php() { + let cmd = vec!["php".to_owned(), "harness.php".to_owned()]; + assert_eq!( + build_container_exec_args(&cmd), + vec!["php", "/workdir/harness.php"] + ); + } + + #[test] + fn build_container_exec_args_java() { + let cmd = vec![ + "java".to_owned(), + "-cp".to_owned(), + "/tmp/nyx-harness/abc123".to_owned(), + "NyxHarness".to_owned(), + ]; + assert_eq!( + build_container_exec_args(&cmd), + vec!["java", "-cp", "/workdir", "NyxHarness"] + ); + } + + #[test] + fn build_container_exec_args_empty() { + assert!(build_container_exec_args(&[]).is_empty()); + } + /// Verify that a second sandbox::run call for the same workdir does NOT /// start a new container when one is already registered. /// diff --git a/src/dynamic/toolchain.rs b/src/dynamic/toolchain.rs index f2f43970..1cb1b014 100644 --- a/src/dynamic/toolchain.rs +++ b/src/dynamic/toolchain.rs @@ -38,6 +38,16 @@ pub enum PinOrigin { RustToolchainFile, /// `Cargo.toml` `rust-version` field. CargoToml, + /// `package.json` `engines.node` field. + PackageJson, + /// `go.mod` `go` directive. + GoMod, + /// `pom.xml` `` / ``. + PomXml, + /// `build.gradle` `sourceCompatibility` / `java.toolchain.languageVersion`. + BuildGradle, + /// `composer.json` `require.php`. + ComposerJson, /// No pin found; used the system default. SystemDefault, } @@ -308,6 +318,371 @@ fn map_version(version: &str, origin: PinOrigin) -> ToolchainResolution { } } +// ── Node.js toolchain resolver ──────────────────────────────────────────────── + +/// Resolve the Node.js toolchain for `project_root`. +/// +/// Reads pin files in priority order: +/// `.nvmrc` > `package.json` `engines.node` > `.node-version` > default. +pub fn resolve_node(project_root: &Path) -> ToolchainResolution { + if let Some(r) = try_nvmrc(project_root) { + return r; + } + if let Some(r) = try_package_json_engines(project_root) { + return r; + } + if let Some(r) = try_node_version_file(project_root) { + return r; + } + default_node() +} + +fn try_nvmrc(root: &Path) -> Option { + let content = std::fs::read_to_string(root.join(".nvmrc")).ok()?; + let version = content.trim().trim_start_matches('v').to_owned(); + if version.is_empty() { + return None; + } + Some(map_node_version(&version, PinOrigin::PackageJson)) +} + +fn try_package_json_engines(root: &Path) -> Option { + let content = std::fs::read_to_string(root.join("package.json")).ok()?; + // Look for "node": ">=18" or "node": "20.x" under "engines". + let mut in_engines = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.contains("\"engines\"") { + in_engines = true; + } + if in_engines && trimmed.contains("\"node\"") { + // Extract version from: "node": ">=18" or "node": "20" + if let Some(ver) = extract_version_from_json_value(trimmed) { + return Some(map_node_version(&ver, PinOrigin::PackageJson)); + } + } + if in_engines && trimmed.starts_with('}') { + in_engines = false; + } + } + None +} + +fn try_node_version_file(root: &Path) -> Option { + let content = std::fs::read_to_string(root.join(".node-version")).ok()?; + let version = content.trim().trim_start_matches('v').to_owned(); + if version.is_empty() { + return None; + } + Some(map_node_version(&version, PinOrigin::PackageJson)) +} + +fn default_node() -> ToolchainResolution { + ToolchainResolution { + toolchain_id: "node-20".to_owned(), + pin_origin: PinOrigin::SystemDefault, + toolchain_drift: false, + version_string: "20".to_owned(), + } +} + +fn map_node_version(version: &str, origin: PinOrigin) -> ToolchainResolution { + // Strip leading >= <= ~ ^ comparators. + let ver = version.trim_start_matches(|c: char| !c.is_ascii_digit()); + let parts: Vec<&str> = ver.splitn(3, '.').collect(); + let major = parts.first().copied().unwrap_or("20"); + + // Node.js LTS catalog: 18, 20, 22. + let (toolchain_id, drift) = match major.parse::() { + Ok(n) if n < 18 => (format!("node-{n}"), true), + Ok(18) => ("node-18".to_owned(), false), + Ok(20) => ("node-20".to_owned(), false), + Ok(22) => ("node-22".to_owned(), false), + Ok(n) => (format!("node-{n}"), true), + _ => ("node-20".to_owned(), true), + }; + + ToolchainResolution { + toolchain_id, + pin_origin: origin, + toolchain_drift: drift, + version_string: version.to_owned(), + } +} + +/// Extract a version string from a JSON value like `">=18"` or `"20.x"`. +fn extract_version_from_json_value(line: &str) -> Option { + // Find the second quoted value after the colon. + let after_colon = line.splitn(2, ':').nth(1)?; + let raw = after_colon.trim().trim_matches('"').trim_matches('\''); + let ver = raw.trim_start_matches(|c: char| !c.is_ascii_digit()); + // Strip trailing .x or .* wildcards. + let ver = if let Some(pos) = ver.find(".x") { + &ver[..pos] + } else if let Some(pos) = ver.find(".*") { + &ver[..pos] + } else { + ver + }; + if ver.is_empty() { + return None; + } + Some(ver.to_owned()) +} + +// ── Go toolchain resolver ───────────────────────────────────────────────────── + +/// Resolve the Go toolchain for `project_root`. +/// +/// Reads pin files in priority order: `go.mod` `go` directive > default. +pub fn resolve_go(project_root: &Path) -> ToolchainResolution { + if let Some(r) = try_go_mod(project_root) { + return r; + } + default_go() +} + +fn try_go_mod(root: &Path) -> Option { + let content = std::fs::read_to_string(root.join("go.mod")).ok()?; + for line in content.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("go ") { + let version = rest.trim().to_owned(); + if !version.is_empty() { + return Some(map_go_version(&version, PinOrigin::GoMod)); + } + } + } + None +} + +fn default_go() -> ToolchainResolution { + ToolchainResolution { + toolchain_id: "go-stable".to_owned(), + pin_origin: PinOrigin::SystemDefault, + toolchain_drift: false, + version_string: "stable".to_owned(), + } +} + +fn map_go_version(version: &str, origin: PinOrigin) -> ToolchainResolution { + let parts: Vec<&str> = version.splitn(3, '.').collect(); + let major = parts.first().copied().unwrap_or("1"); + let minor = parts.get(1).copied(); + + // Go 1.21+ is the modern catalog. + let (toolchain_id, drift) = match (major, minor) { + ("1", Some("21")) => ("go-1.21".to_owned(), false), + ("1", Some("22")) => ("go-1.22".to_owned(), false), + ("1", Some("23")) => ("go-1.23".to_owned(), false), + ("1", Some(m)) if m.parse::().map_or(false, |v| v >= 24) => { + (format!("go-1.{m}"), true) + } + ("1", Some(m)) if m.parse::().map_or(false, |v| v < 21) => { + (format!("go-1.{m}"), true) + } + _ => ("go-stable".to_owned(), false), + }; + + ToolchainResolution { + toolchain_id, + pin_origin: origin, + toolchain_drift: drift, + version_string: version.to_owned(), + } +} + +// ── Java toolchain resolver ─────────────────────────────────────────────────── + +/// Resolve the Java toolchain for `project_root`. +/// +/// Reads pin files in priority order: +/// `pom.xml` `` / `` > +/// `build.gradle` `sourceCompatibility` > default. +pub fn resolve_java(project_root: &Path) -> ToolchainResolution { + if let Some(r) = try_pom_xml(project_root) { + return r; + } + if let Some(r) = try_build_gradle(project_root) { + return r; + } + default_java() +} + +fn try_pom_xml(root: &Path) -> Option { + let content = std::fs::read_to_string(root.join("pom.xml")).ok()?; + // Look for 21 or 21 + for line in content.lines() { + let trimmed = line.trim(); + for tag in &["", "", ""] { + if trimmed.starts_with(tag) { + if let Some(inner) = trimmed.strip_prefix(tag) { + let version = inner.split('<').next().unwrap_or("").trim(); + if !version.is_empty() { + return Some(map_java_version(version, PinOrigin::PomXml)); + } + } + } + } + } + None +} + +fn try_build_gradle(root: &Path) -> Option { + for fname in &["build.gradle", "build.gradle.kts"] { + let Ok(content) = std::fs::read_to_string(root.join(fname)) else { + continue; + }; + for line in content.lines() { + let trimmed = line.trim(); + // Groovy: sourceCompatibility = '21' or JavaVersion.VERSION_21 + // Kotlin: sourceCompatibility = JavaVersion.VERSION_21 + if trimmed.starts_with("sourceCompatibility") || trimmed.starts_with("languageVersion") { + if let Some(ver) = extract_java_version_from_gradle_line(trimmed) { + return Some(map_java_version(&ver, PinOrigin::BuildGradle)); + } + } + } + } + None +} + +fn extract_java_version_from_gradle_line(line: &str) -> Option { + // Handle: sourceCompatibility = '21' or sourceCompatibility = 21 + // and: languageVersion.set(JavaLanguageVersion.of(21)) + let after_eq = line.splitn(2, '=').nth(1).unwrap_or(line); + // Try to find a number in the value. + let digits: String = after_eq.chars() + .skip_while(|c| !c.is_ascii_digit()) + .take_while(|c| c.is_ascii_digit()) + .collect(); + if digits.is_empty() { + // Try "VERSION_21" pattern. + if let Some(pos) = after_eq.find("VERSION_") { + let rest = &after_eq[pos + 8..]; + let digits: String = rest.chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if !digits.is_empty() { + return Some(digits); + } + } + return None; + } + Some(digits) +} + +fn default_java() -> ToolchainResolution { + ToolchainResolution { + toolchain_id: "java-21".to_owned(), + pin_origin: PinOrigin::SystemDefault, + toolchain_drift: false, + version_string: "21".to_owned(), + } +} + +fn map_java_version(version: &str, origin: PinOrigin) -> ToolchainResolution { + // Java version: 8, 11, 17, 21, 22 are common LTS/current. + let major = version.split('.').next().unwrap_or(version); + + let (toolchain_id, drift) = match major.parse::() { + Ok(8) => ("java-8".to_owned(), false), + Ok(11) => ("java-11".to_owned(), false), + Ok(17) => ("java-17".to_owned(), false), + Ok(21) => ("java-21".to_owned(), false), + Ok(n) => (format!("java-{n}"), true), + _ => ("java-21".to_owned(), true), + }; + + ToolchainResolution { + toolchain_id, + pin_origin: origin, + toolchain_drift: drift, + version_string: version.to_owned(), + } +} + +// ── PHP toolchain resolver ──────────────────────────────────────────────────── + +/// Resolve the PHP toolchain for `project_root`. +/// +/// Reads pin files in priority order: +/// `composer.json` `require.php` > `.php-version` > default. +pub fn resolve_php(project_root: &Path) -> ToolchainResolution { + if let Some(r) = try_composer_json(project_root) { + return r; + } + if let Some(r) = try_php_version_file(project_root) { + return r; + } + default_php() +} + +fn try_composer_json(root: &Path) -> Option { + let content = std::fs::read_to_string(root.join("composer.json")).ok()?; + // Look for "php": ">=8.1" under "require". + let mut in_require = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.contains("\"require\"") { + in_require = true; + } + if in_require && trimmed.contains("\"php\"") { + if let Some(ver) = extract_version_from_json_value(trimmed) { + return Some(map_php_version(&ver, PinOrigin::ComposerJson)); + } + } + // Stop at closing brace of require block. + if in_require && trimmed == "}," || (in_require && trimmed == "}") { + in_require = false; + } + } + None +} + +fn try_php_version_file(root: &Path) -> Option { + let content = std::fs::read_to_string(root.join(".php-version")).ok()?; + let version = content.trim().to_owned(); + if version.is_empty() { + return None; + } + Some(map_php_version(&version, PinOrigin::ComposerJson)) +} + +fn default_php() -> ToolchainResolution { + ToolchainResolution { + toolchain_id: "php-8".to_owned(), + pin_origin: PinOrigin::SystemDefault, + toolchain_drift: false, + version_string: "8".to_owned(), + } +} + +fn map_php_version(version: &str, origin: PinOrigin) -> ToolchainResolution { + let ver = version.trim_start_matches(|c: char| !c.is_ascii_digit()); + let parts: Vec<&str> = ver.splitn(3, '.').collect(); + let major = parts.first().copied().unwrap_or("8"); + let minor = parts.get(1).copied(); + + let (toolchain_id, drift) = match (major.parse::(), minor) { + (Ok(8), Some("0")) => ("php-8.0".to_owned(), false), + (Ok(8), Some("1")) => ("php-8.1".to_owned(), false), + (Ok(8), Some("2")) => ("php-8.2".to_owned(), false), + (Ok(8), Some("3")) => ("php-8.3".to_owned(), false), + (Ok(8), None) => ("php-8".to_owned(), false), + (Ok(7), _) => ("php-7".to_owned(), true), + (Ok(n), _) => (format!("php-{n}"), true), + _ => ("php-8".to_owned(), true), + }; + + ToolchainResolution { + toolchain_id, + pin_origin: origin, + toolchain_drift: drift, + version_string: version.to_owned(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -401,4 +776,112 @@ mod tests { assert_eq!(r.toolchain_id, "rust-stable"); assert_eq!(r.pin_origin, PinOrigin::SystemDefault); } + + // ── Node.js resolver tests ──────────────────────────────────────────────── + + #[test] + fn node_nvmrc_exact() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join(".nvmrc"), "v20.5.0\n").unwrap(); + let r = resolve_node(dir.path()); + assert_eq!(r.toolchain_id, "node-20"); + assert!(!r.toolchain_drift); + assert_eq!(r.pin_origin, PinOrigin::PackageJson); + } + + #[test] + fn node_package_json_engines() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("package.json"), + r#"{"engines": {"node": ">=18.0.0"}}"#, + ).unwrap(); + let r = resolve_node(dir.path()); + assert_eq!(r.toolchain_id, "node-18"); + } + + #[test] + fn node_default_is_20() { + let dir = TempDir::new().unwrap(); + let r = resolve_node(dir.path()); + assert_eq!(r.toolchain_id, "node-20"); + assert_eq!(r.pin_origin, PinOrigin::SystemDefault); + } + + // ── Go resolver tests ───────────────────────────────────────────────────── + + #[test] + fn go_mod_version() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n\ngo 1.22\n").unwrap(); + let r = resolve_go(dir.path()); + assert_eq!(r.toolchain_id, "go-1.22"); + assert!(!r.toolchain_drift); + assert_eq!(r.pin_origin, PinOrigin::GoMod); + } + + #[test] + fn go_default_is_stable() { + let dir = TempDir::new().unwrap(); + let r = resolve_go(dir.path()); + assert_eq!(r.toolchain_id, "go-stable"); + assert_eq!(r.pin_origin, PinOrigin::SystemDefault); + } + + // ── Java resolver tests ─────────────────────────────────────────────────── + + #[test] + fn java_pom_xml_version() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("pom.xml"), + "\n \n 21\n \n", + ).unwrap(); + let r = resolve_java(dir.path()); + assert_eq!(r.toolchain_id, "java-21"); + assert!(!r.toolchain_drift); + assert_eq!(r.pin_origin, PinOrigin::PomXml); + } + + #[test] + fn java_build_gradle_source_compat() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("build.gradle"), + "sourceCompatibility = '17'\ntargetCompatibility = '17'\n", + ).unwrap(); + let r = resolve_java(dir.path()); + assert_eq!(r.toolchain_id, "java-17"); + assert_eq!(r.pin_origin, PinOrigin::BuildGradle); + } + + #[test] + fn java_default_is_21() { + let dir = TempDir::new().unwrap(); + let r = resolve_java(dir.path()); + assert_eq!(r.toolchain_id, "java-21"); + assert_eq!(r.pin_origin, PinOrigin::SystemDefault); + } + + // ── PHP resolver tests ──────────────────────────────────────────────────── + + #[test] + fn php_composer_json_version() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("composer.json"), + r#"{"require": {"php": ">=8.1"}}"#, + ).unwrap(); + let r = resolve_php(dir.path()); + assert_eq!(r.toolchain_id, "php-8.1"); + assert_eq!(r.pin_origin, PinOrigin::ComposerJson); + } + + #[test] + fn php_default_is_8() { + let dir = TempDir::new().unwrap(); + let r = resolve_php(dir.path()); + assert_eq!(r.toolchain_id, "php-8"); + assert_eq!(r.pin_origin, PinOrigin::SystemDefault); + } } diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs index 5ee43820..255f3dbc 100644 --- a/src/dynamic/verify.rs +++ b/src/dynamic/verify.rs @@ -98,6 +98,10 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult { use crate::symbol::Lang; let toolchain_res = match spec.lang { Lang::Rust => toolchain::resolve_rust(Path::new(".")), + Lang::JavaScript | Lang::TypeScript => toolchain::resolve_node(Path::new(".")), + Lang::Go => toolchain::resolve_go(Path::new(".")), + Lang::Java => toolchain::resolve_java(Path::new(".")), + Lang::Php => toolchain::resolve_php(Path::new(".")), _ => toolchain::resolve_python(Path::new(".")), }; let toolchain_match = if toolchain_res.toolchain_drift { "drift" } else { "exact" }; diff --git a/tests/dynamic_fixtures/escape/composer_malicious_postinstall/composer.json b/tests/dynamic_fixtures/escape/composer_malicious_postinstall/composer.json new file mode 100644 index 00000000..bd8947e3 --- /dev/null +++ b/tests/dynamic_fixtures/escape/composer_malicious_postinstall/composer.json @@ -0,0 +1,10 @@ +{ + "name": "nyx/escape-composer-postinstall", + "description": "Malicious Composer post-install-cmd escape fixture.", + "require": {}, + "scripts": { + "post-install-cmd": [ + "echo NYX_ESCAPE_SUCCESS > /tmp/pwned_composer_postinstall" + ] + } +} diff --git a/tests/dynamic_fixtures/escape/go_malicious_init.go b/tests/dynamic_fixtures/escape/go_malicious_init.go new file mode 100644 index 00000000..6c1cf2f2 --- /dev/null +++ b/tests/dynamic_fixtures/escape/go_malicious_init.go @@ -0,0 +1,16 @@ +// Malicious Go init() escape fixture. +// +// init() runs automatically before the entry point when the binary starts. +// Expected: Docker sandbox prevents the write from reaching the host filesystem. +// Host marker: /tmp/pwned_go_init +// Expected verdict: marker absent on host after sandbox run. +package entry + +import "os" + +func init() { + // Escape attempt: write a marker file to a path outside the workdir. + _ = os.WriteFile("/tmp/pwned_go_init", []byte("NYX_ESCAPE_SUCCESS\n"), 0644) +} + +func Login(username string) {} diff --git a/tests/dynamic_fixtures/escape/maven_malicious_plugin/pom.xml b/tests/dynamic_fixtures/escape/maven_malicious_plugin/pom.xml new file mode 100644 index 00000000..48507b34 --- /dev/null +++ b/tests/dynamic_fixtures/escape/maven_malicious_plugin/pom.xml @@ -0,0 +1,40 @@ + + + + 4.0.0 + com.nyx.escape + malicious-plugin-test + 1.0.0 + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + escape-attempt + validate + + exec + + + sh + + -c + echo NYX_ESCAPE_SUCCESS > /tmp/pwned_maven_plugin + + + + + + + + diff --git a/tests/dynamic_fixtures/escape/npm_malicious_lifecycle/package.json b/tests/dynamic_fixtures/escape/npm_malicious_lifecycle/package.json new file mode 100644 index 00000000..f17558e1 --- /dev/null +++ b/tests/dynamic_fixtures/escape/npm_malicious_lifecycle/package.json @@ -0,0 +1,8 @@ +{ + "name": "nyx-escape-npm-lifecycle", + "version": "1.0.0", + "description": "Malicious npm lifecycle escape fixture — preinstall runs during npm install.", + "scripts": { + "preinstall": "echo NYX_ESCAPE_SUCCESS > /tmp/pwned_npm_lifecycle" + } +} diff --git a/tests/dynamic_fixtures/go/cmdi_adversarial.go b/tests/dynamic_fixtures/go/cmdi_adversarial.go new file mode 100644 index 00000000..612e3c50 --- /dev/null +++ b/tests/dynamic_fixtures/go/cmdi_adversarial.go @@ -0,0 +1,15 @@ +// Command injection — adversarial collision fixture. +// Prints NYX_PWN_CMDI unconditionally without reaching a command sink +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: RunPing(host string) Cap: CODE_EXEC + +package entry + +import "fmt" + +func RunPing(host string) { + // Coincidental oracle match — not a shell sink. + fmt.Println("NYX_PWN_CMDI") + _ = len(host) +} diff --git a/tests/dynamic_fixtures/go/cmdi_negative.go b/tests/dynamic_fixtures/go/cmdi_negative.go new file mode 100644 index 00000000..2e729e6b --- /dev/null +++ b/tests/dynamic_fixtures/go/cmdi_negative.go @@ -0,0 +1,18 @@ +// Command injection — negative fixture. +// Safe: passes host as a separate arg to exec.Command (no shell invoked). +// Entry: RunPing(host string) Cap: CODE_EXEC +// Expected verdict: NotConfirmed + +package entry + +import ( + "fmt" + "os/exec" +) + +func RunPing(host string) { + // exec.Command does not invoke a shell; host is a literal argument. + cmd := exec.Command("echo", "hello", host) + out, _ := cmd.CombinedOutput() + fmt.Print(string(out)) +} diff --git a/tests/dynamic_fixtures/go/cmdi_positive.go b/tests/dynamic_fixtures/go/cmdi_positive.go new file mode 100644 index 00000000..702d31b4 --- /dev/null +++ b/tests/dynamic_fixtures/go/cmdi_positive.go @@ -0,0 +1,18 @@ +// Command injection — positive fixture. +// Vulnerable: passes user input to /bin/sh -c. +// Entry: RunPing(host string) Cap: CODE_EXEC +// Expected verdict: Confirmed ("; echo NYX_PWN_CMDI" echoes the marker) + +package entry + +import ( + "fmt" + "os/exec" +) + +func RunPing(host string) { + fmt.Print("__NYX_SINK_HIT__\n") + cmd := exec.Command("sh", "-c", "echo hello "+host) + out, _ := cmd.CombinedOutput() + fmt.Print(string(out)) +} diff --git a/tests/dynamic_fixtures/go/cmdi_unsupported.go b/tests/dynamic_fixtures/go/cmdi_unsupported.go new file mode 100644 index 00000000..dd99faf6 --- /dev/null +++ b/tests/dynamic_fixtures/go/cmdi_unsupported.go @@ -0,0 +1,15 @@ +// Command injection — unsupported fixture. +// Entry is a method on a struct. +// Test sets confidence = Low to get Unsupported(ConfidenceTooLow). +// Entry: Runner.Execute Cap: CODE_EXEC +// Expected verdict: Unsupported + +package entry + +import "os/exec" + +type Runner struct{} + +func (r *Runner) Execute(cmd string) { + exec.Command("sh", "-c", cmd).Run() +} diff --git a/tests/dynamic_fixtures/go/fileio_adversarial.go b/tests/dynamic_fixtures/go/fileio_adversarial.go new file mode 100644 index 00000000..7fe5516b --- /dev/null +++ b/tests/dynamic_fixtures/go/fileio_adversarial.go @@ -0,0 +1,15 @@ +// File I/O — adversarial collision fixture. +// Prints "root:" unconditionally without reading any file +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: ReadFile(userPath string) Cap: FILE_IO + +package entry + +import "fmt" + +func ReadFile(userPath string) { + // Coincidental oracle match — not a file read sink. + fmt.Println("root: present") + _ = len(userPath) +} diff --git a/tests/dynamic_fixtures/go/fileio_negative.go b/tests/dynamic_fixtures/go/fileio_negative.go new file mode 100644 index 00000000..01230b80 --- /dev/null +++ b/tests/dynamic_fixtures/go/fileio_negative.go @@ -0,0 +1,34 @@ +// File I/O — negative fixture. +// Safe: path is resolved and validated against base directory. +// Entry: ReadFile(userPath string) Cap: FILE_IO +// Expected verdict: NotConfirmed + +package entry + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const baseDir = "/var/data" + +func ReadFile(userPath string) { + resolved, err := filepath.Abs(filepath.Join(baseDir, userPath)) + if err != nil || !strings.HasPrefix(resolved, baseDir+string(filepath.Separator)) { + fmt.Println("Access denied") + return + } + data, err := os.ReadFile(resolved) + if err == nil { + fmt.Print(string(data[:min(len(data), 100)])) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/tests/dynamic_fixtures/go/fileio_positive.go b/tests/dynamic_fixtures/go/fileio_positive.go new file mode 100644 index 00000000..8a2fd332 --- /dev/null +++ b/tests/dynamic_fixtures/go/fileio_positive.go @@ -0,0 +1,21 @@ +// File I/O — positive fixture. +// Vulnerable: reads file at user-controlled path without sanitization. +// Entry: ReadFile(userPath string) Cap: FILE_IO +// Expected verdict: Confirmed (../../../../etc/passwd → "root:" in output) + +package entry + +import ( + "fmt" + "os" + "path/filepath" +) + +func ReadFile(userPath string) { + filePath := filepath.Join("/var/data", userPath) + fmt.Print("__NYX_SINK_HIT__\n") + data, err := os.ReadFile(filePath) + if err == nil { + fmt.Print(string(data)) + } +} diff --git a/tests/dynamic_fixtures/go/fileio_unsupported.go b/tests/dynamic_fixtures/go/fileio_unsupported.go new file mode 100644 index 00000000..4b7f0989 --- /dev/null +++ b/tests/dynamic_fixtures/go/fileio_unsupported.go @@ -0,0 +1,21 @@ +// File I/O — unsupported fixture. +// Entry is a method on a struct. +// Test sets confidence = Low to get Unsupported(ConfidenceTooLow). +// Entry: FileServer.Serve Cap: FILE_IO +// Expected verdict: Unsupported + +package entry + +import ( + "fmt" + "os" +) + +type FileServer struct{ BaseDir string } + +func (s *FileServer) Serve(path string) { + data, err := os.ReadFile(s.BaseDir + "/" + path) + if err == nil { + fmt.Print(string(data)) + } +} diff --git a/tests/dynamic_fixtures/go/sqli_adversarial.go b/tests/dynamic_fixtures/go/sqli_adversarial.go new file mode 100644 index 00000000..65cf80a3 --- /dev/null +++ b/tests/dynamic_fixtures/go/sqli_adversarial.go @@ -0,0 +1,15 @@ +// SQL injection — adversarial collision fixture. +// Prints NYX_SQL_CONFIRMED unconditionally without reaching a SQL sink +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: Login(username string) Cap: SQL_QUERY + +package entry + +import "fmt" + +func Login(username string) { + // Coincidental oracle match — not a SQL sink. + fmt.Println("NYX_SQL_CONFIRMED") + _ = len(username) +} diff --git a/tests/dynamic_fixtures/go/sqli_negative.go b/tests/dynamic_fixtures/go/sqli_negative.go new file mode 100644 index 00000000..1e6d8227 --- /dev/null +++ b/tests/dynamic_fixtures/go/sqli_negative.go @@ -0,0 +1,14 @@ +// SQL injection — negative fixture. +// Safe: uses a parameterized query; payload is a bound argument, not concatenated. +// Entry: Login(username string) Cap: SQL_QUERY +// Expected verdict: NotConfirmed + +package entry + +import "fmt" + +func Login(username string) { + template := "SELECT name FROM users WHERE name = ?" + // Simulate parameterized execution: template is fixed. + fmt.Println("Executing:", template, "with param length:", len(username)) +} diff --git a/tests/dynamic_fixtures/go/sqli_positive.go b/tests/dynamic_fixtures/go/sqli_positive.go new file mode 100644 index 00000000..2d165193 --- /dev/null +++ b/tests/dynamic_fixtures/go/sqli_positive.go @@ -0,0 +1,15 @@ +// SQL injection — positive fixture. +// Vulnerable: directly concatenates user input into SQL query string. +// Entry: Login(username string) Cap: SQL_QUERY +// Expected verdict: Confirmed (UNION payload echoes NYX_SQL_CONFIRMED) + +package entry + +import "fmt" + +func Login(username string) { + query := "SELECT name FROM users WHERE name='" + username + "'" + fmt.Print("__NYX_SINK_HIT__\n") + // Error-based echo: output the query so UNION payload is visible. + fmt.Print("DB query: " + query + "\n") +} diff --git a/tests/dynamic_fixtures/go/sqli_unsupported.go b/tests/dynamic_fixtures/go/sqli_unsupported.go new file mode 100644 index 00000000..649df274 --- /dev/null +++ b/tests/dynamic_fixtures/go/sqli_unsupported.go @@ -0,0 +1,16 @@ +// SQL injection — unsupported fixture. +// Entry is a method on a struct — entry kind unsupported (only Function supported). +// Test sets confidence = Low to get Unsupported(ConfidenceTooLow). +// Entry: UserRepo.FindUser Cap: SQL_QUERY +// Expected verdict: Unsupported + +package entry + +import "fmt" + +type UserRepo struct{} + +func (r *UserRepo) FindUser(name string) { + query := "SELECT * FROM users WHERE name='" + name + "'" + fmt.Println(query) +} diff --git a/tests/dynamic_fixtures/go/ssrf_adversarial.go b/tests/dynamic_fixtures/go/ssrf_adversarial.go new file mode 100644 index 00000000..c0d3b79b --- /dev/null +++ b/tests/dynamic_fixtures/go/ssrf_adversarial.go @@ -0,0 +1,15 @@ +// SSRF — adversarial collision fixture. +// Prints "daemon:" unconditionally without making any HTTP request +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: FetchURL(targetURL string) Cap: SSRF + +package entry + +import "fmt" + +func FetchURL(targetURL string) { + // Coincidental oracle match — not an HTTP sink. + fmt.Println("daemon: present") + _ = len(targetURL) +} diff --git a/tests/dynamic_fixtures/go/ssrf_negative.go b/tests/dynamic_fixtures/go/ssrf_negative.go new file mode 100644 index 00000000..2255eaae --- /dev/null +++ b/tests/dynamic_fixtures/go/ssrf_negative.go @@ -0,0 +1,34 @@ +// SSRF — negative fixture. +// Safe: only allows http/https scheme; file:// rejected. +// Entry: FetchURL(targetURL string) Cap: SSRF +// Expected verdict: NotConfirmed + +package entry + +import ( + "fmt" + "io" + "net/http" + "net/url" +) + +func FetchURL(targetURL string) { + parsed, err := url.Parse(targetURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + fmt.Println("Scheme not allowed:", parsed.Scheme) + return + } + resp, err := http.Get(targetURL) + if err == nil { + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + fmt.Print(string(body[:min(len(body), 64)])) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/tests/dynamic_fixtures/go/ssrf_positive.go b/tests/dynamic_fixtures/go/ssrf_positive.go new file mode 100644 index 00000000..93d8fd5e --- /dev/null +++ b/tests/dynamic_fixtures/go/ssrf_positive.go @@ -0,0 +1,33 @@ +// SSRF — positive fixture. +// Vulnerable: makes a request to a user-controlled URL. +// Entry: FetchURL(targetURL string) Cap: SSRF +// Expected verdict: Confirmed (file:///etc/passwd → "daemon:" in output) +// Note: Go http.Get does not support file:// scheme; we detect it and use os.ReadFile. + +package entry + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" +) + +func FetchURL(targetURL string) { + fmt.Print("__NYX_SINK_HIT__\n") + if strings.HasPrefix(targetURL, "file://") { + path := strings.TrimPrefix(targetURL, "file://") + data, err := os.ReadFile(path) + if err == nil { + fmt.Print(string(data)) + } + return + } + resp, err := http.Get(targetURL) + if err == nil { + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + fmt.Print(string(body)) + } +} diff --git a/tests/dynamic_fixtures/go/ssrf_unsupported.go b/tests/dynamic_fixtures/go/ssrf_unsupported.go new file mode 100644 index 00000000..4471ca4e --- /dev/null +++ b/tests/dynamic_fixtures/go/ssrf_unsupported.go @@ -0,0 +1,20 @@ +// SSRF — unsupported fixture. +// Entry is a method on a struct; test sets confidence = Low. +// Expected verdict: Unsupported + +package entry + +import ( + "io" + "net/http" +) + +type HTTPClient struct{} + +func (c *HTTPClient) Fetch(targetURL string) { + resp, err := http.Get(targetURL) + if err == nil { + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) + } +} diff --git a/tests/dynamic_fixtures/go/xss_adversarial.go b/tests/dynamic_fixtures/go/xss_adversarial.go new file mode 100644 index 00000000..fd1d604b --- /dev/null +++ b/tests/dynamic_fixtures/go/xss_adversarial.go @@ -0,0 +1,15 @@ +// XSS — adversarial collision fixture. +// Prints the XSS oracle marker unconditionally without rendering any template +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: RenderPage(userInput string) Cap: HTML_ESCAPE + +package entry + +import "fmt" + +func RenderPage(userInput string) { + // Coincidental oracle match — not an HTML render sink. + fmt.Println("") + _ = len(userInput) +} diff --git a/tests/dynamic_fixtures/go/xss_negative.go b/tests/dynamic_fixtures/go/xss_negative.go new file mode 100644 index 00000000..d1e6124f --- /dev/null +++ b/tests/dynamic_fixtures/go/xss_negative.go @@ -0,0 +1,16 @@ +// XSS — negative fixture. +// Safe: uses html.EscapeString before output. +// Entry: RenderPage(userInput string) Cap: HTML_ESCAPE +// Expected verdict: NotConfirmed + +package entry + +import ( + "fmt" + "html" +) + +func RenderPage(userInput string) { + safe := html.EscapeString(userInput) + fmt.Print("" + safe + "\n") +} diff --git a/tests/dynamic_fixtures/go/xss_positive.go b/tests/dynamic_fixtures/go/xss_positive.go new file mode 100644 index 00000000..39c42355 --- /dev/null +++ b/tests/dynamic_fixtures/go/xss_positive.go @@ -0,0 +1,13 @@ +// XSS — positive fixture. +// Vulnerable: echoes raw user input into HTML output without escaping. +// Entry: RenderPage(userInput string) Cap: HTML_ESCAPE +// Expected verdict: Confirmed ( echoed) + +package entry + +import "fmt" + +func RenderPage(userInput string) { + fmt.Print("__NYX_SINK_HIT__\n") + fmt.Print("" + userInput + "\n") +} diff --git a/tests/dynamic_fixtures/go/xss_unsupported.go b/tests/dynamic_fixtures/go/xss_unsupported.go new file mode 100644 index 00000000..122c5947 --- /dev/null +++ b/tests/dynamic_fixtures/go/xss_unsupported.go @@ -0,0 +1,13 @@ +// XSS — unsupported fixture. +// Entry is a method on a struct; test sets confidence = Low. +// Expected verdict: Unsupported + +package entry + +import "fmt" + +type Renderer struct{} + +func (r *Renderer) Render(input string) { + fmt.Print("" + input + "\n") +} diff --git a/tests/dynamic_fixtures/java/cmdi_adversarial.java b/tests/dynamic_fixtures/java/cmdi_adversarial.java new file mode 100644 index 00000000..a5dae40e --- /dev/null +++ b/tests/dynamic_fixtures/java/cmdi_adversarial.java @@ -0,0 +1,13 @@ +// Command injection — adversarial collision fixture. +// Prints NYX_PWN_CMDI unconditionally without reaching a command sink +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: Entry.runPing(String) Cap: CODE_EXEC + +public class Entry { + public static void runPing(String host) { + // Coincidental oracle match — not a shell sink. + System.out.println("NYX_PWN_CMDI"); + int x = host.length(); + } +} diff --git a/tests/dynamic_fixtures/java/cmdi_negative.java b/tests/dynamic_fixtures/java/cmdi_negative.java new file mode 100644 index 00000000..46819849 --- /dev/null +++ b/tests/dynamic_fixtures/java/cmdi_negative.java @@ -0,0 +1,20 @@ +// Command injection — negative fixture. +// Safe: exec with args array; no shell; semicolons are inert. +// Entry: Entry.runPing(String) Cap: CODE_EXEC +// Expected verdict: NotConfirmed + +import java.io.*; + +public class Entry { + public static void runPing(String host) throws Exception { + // Array form: each element is a literal argument — no shell expansion. + String[] cmd = {"echo", "hello", host}; + Process p = Runtime.getRuntime().exec(cmd); + BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + System.out.println(line); + } + p.waitFor(); + } +} diff --git a/tests/dynamic_fixtures/java/cmdi_positive.java b/tests/dynamic_fixtures/java/cmdi_positive.java new file mode 100644 index 00000000..8cf547d1 --- /dev/null +++ b/tests/dynamic_fixtures/java/cmdi_positive.java @@ -0,0 +1,20 @@ +// Command injection — positive fixture. +// Vulnerable: passes user input to /bin/sh -c via Runtime.exec. +// Entry: Entry.runPing(String) Cap: CODE_EXEC +// Expected verdict: Confirmed ("; echo NYX_PWN_CMDI" echoes the marker) + +import java.io.*; + +public class Entry { + public static void runPing(String host) throws Exception { + System.out.print("__NYX_SINK_HIT__\n"); + String[] cmd = {"/bin/sh", "-c", "echo hello " + host}; + Process p = Runtime.getRuntime().exec(cmd); + BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + System.out.println(line); + } + p.waitFor(); + } +} diff --git a/tests/dynamic_fixtures/java/cmdi_unsupported.java b/tests/dynamic_fixtures/java/cmdi_unsupported.java new file mode 100644 index 00000000..5f08b149 --- /dev/null +++ b/tests/dynamic_fixtures/java/cmdi_unsupported.java @@ -0,0 +1,11 @@ +// Command injection — unsupported fixture. +// Entry is an instance method; test sets confidence = Low. +// Expected verdict: Unsupported + +import java.io.*; + +public class Entry { + public void execute(String cmd) throws Exception { + Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", cmd}); + } +} diff --git a/tests/dynamic_fixtures/java/fileio_adversarial.java b/tests/dynamic_fixtures/java/fileio_adversarial.java new file mode 100644 index 00000000..4046b6d1 --- /dev/null +++ b/tests/dynamic_fixtures/java/fileio_adversarial.java @@ -0,0 +1,13 @@ +// File I/O — adversarial collision fixture. +// Prints "root:" unconditionally without reading any file +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: Entry.readFile(String) Cap: FILE_IO + +public class Entry { + public static void readFile(String userPath) { + // Coincidental oracle match — not a file read sink. + System.out.println("root: present"); + int x = userPath.length(); + } +} diff --git a/tests/dynamic_fixtures/java/fileio_negative.java b/tests/dynamic_fixtures/java/fileio_negative.java new file mode 100644 index 00000000..c3bd1e6d --- /dev/null +++ b/tests/dynamic_fixtures/java/fileio_negative.java @@ -0,0 +1,27 @@ +// File I/O — negative fixture. +// Safe: normalizes path and checks it stays within the base directory. +// Entry: Entry.readFile(String) Cap: FILE_IO +// Expected verdict: NotConfirmed + +import java.io.*; +import java.nio.file.*; + +public class Entry { + private static final String BASE_DIR = "/var/data"; + + public static void readFile(String userPath) throws Exception { + Path base = Paths.get(BASE_DIR).toRealPath(); + Path resolved = base.resolve(userPath).normalize(); + if (!resolved.startsWith(base)) { + System.out.println("Access denied"); + return; + } + try { + byte[] data = Files.readAllBytes(resolved); + int len = Math.min(data.length, 100); + System.out.write(data, 0, len); + } catch (IOException e) { + System.out.println("File not found"); + } + } +} diff --git a/tests/dynamic_fixtures/java/fileio_positive.java b/tests/dynamic_fixtures/java/fileio_positive.java new file mode 100644 index 00000000..5a99f95a --- /dev/null +++ b/tests/dynamic_fixtures/java/fileio_positive.java @@ -0,0 +1,20 @@ +// File I/O — positive fixture. +// Vulnerable: reads file at user-controlled path without sanitization. +// Entry: Entry.readFile(String) Cap: FILE_IO +// Expected verdict: Confirmed (../../../../etc/passwd → "root:" in output) + +import java.io.*; +import java.nio.file.*; + +public class Entry { + public static void readFile(String userPath) throws Exception { + Path filePath = Paths.get("/var/data", userPath); + System.out.print("__NYX_SINK_HIT__\n"); + try { + String content = new String(Files.readAllBytes(filePath)); + System.out.print(content); + } catch (IOException e) { + // silent + } + } +} diff --git a/tests/dynamic_fixtures/java/fileio_unsupported.java b/tests/dynamic_fixtures/java/fileio_unsupported.java new file mode 100644 index 00000000..19d1db72 --- /dev/null +++ b/tests/dynamic_fixtures/java/fileio_unsupported.java @@ -0,0 +1,13 @@ +// File I/O — unsupported fixture. +// Entry is an instance method; test sets confidence = Low. +// Expected verdict: Unsupported + +import java.io.*; +import java.nio.file.*; + +public class Entry { + public void serve(String path) throws Exception { + byte[] data = Files.readAllBytes(Paths.get(path)); + System.out.write(data); + } +} diff --git a/tests/dynamic_fixtures/java/sqli_adversarial.java b/tests/dynamic_fixtures/java/sqli_adversarial.java new file mode 100644 index 00000000..723359ef --- /dev/null +++ b/tests/dynamic_fixtures/java/sqli_adversarial.java @@ -0,0 +1,13 @@ +// SQL injection — adversarial collision fixture. +// Prints NYX_SQL_CONFIRMED unconditionally without reaching a SQL sink +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: Entry.login(String) Cap: SQL_QUERY + +public class Entry { + public static void login(String username) { + // Coincidental oracle match — not a SQL sink. + System.out.println("NYX_SQL_CONFIRMED"); + int x = username.length(); + } +} diff --git a/tests/dynamic_fixtures/java/sqli_negative.java b/tests/dynamic_fixtures/java/sqli_negative.java new file mode 100644 index 00000000..1db7b4ce --- /dev/null +++ b/tests/dynamic_fixtures/java/sqli_negative.java @@ -0,0 +1,12 @@ +// SQL injection — negative fixture. +// Safe: uses a parameterized query; payload is a bound argument. +// Entry: Entry.login(String) Cap: SQL_QUERY +// Expected verdict: NotConfirmed + +public class Entry { + public static void login(String username) { + String template = "SELECT name FROM users WHERE name = ?"; + // Simulate parameterized execution: template is fixed. + System.out.println("Executing: " + template + " param-len=" + username.length()); + } +} diff --git a/tests/dynamic_fixtures/java/sqli_positive.java b/tests/dynamic_fixtures/java/sqli_positive.java new file mode 100644 index 00000000..511551e9 --- /dev/null +++ b/tests/dynamic_fixtures/java/sqli_positive.java @@ -0,0 +1,13 @@ +// SQL injection — positive fixture. +// Vulnerable: directly concatenates user input into SQL query string. +// Entry: Entry.login(String) Cap: SQL_QUERY +// Expected verdict: Confirmed (UNION payload echoes NYX_SQL_CONFIRMED) + +public class Entry { + public static void login(String username) { + String query = "SELECT name FROM users WHERE name='" + username + "'"; + System.out.print("__NYX_SINK_HIT__\n"); + // Error-based echo: output the query so UNION payload is visible. + System.out.println("DB query: " + query); + } +} diff --git a/tests/dynamic_fixtures/java/sqli_unsupported.java b/tests/dynamic_fixtures/java/sqli_unsupported.java new file mode 100644 index 00000000..26bc92d4 --- /dev/null +++ b/tests/dynamic_fixtures/java/sqli_unsupported.java @@ -0,0 +1,11 @@ +// SQL injection — unsupported fixture. +// Entry is an instance method rather than a static method. +// Test sets confidence = Low to get Unsupported(ConfidenceTooLow). +// Expected verdict: Unsupported + +public class Entry { + public void findUser(String name) { + String query = "SELECT * FROM users WHERE name='" + name + "'"; + System.out.println(query); + } +} diff --git a/tests/dynamic_fixtures/java/ssrf_adversarial.java b/tests/dynamic_fixtures/java/ssrf_adversarial.java new file mode 100644 index 00000000..019ca525 --- /dev/null +++ b/tests/dynamic_fixtures/java/ssrf_adversarial.java @@ -0,0 +1,13 @@ +// SSRF — adversarial collision fixture. +// Prints "daemon:" unconditionally without making any HTTP request +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: Entry.fetchUrl(String) Cap: SSRF + +public class Entry { + public static void fetchUrl(String targetUrl) { + // Coincidental oracle match — not an HTTP sink. + System.out.println("daemon: present"); + int x = targetUrl.length(); + } +} diff --git a/tests/dynamic_fixtures/java/ssrf_negative.java b/tests/dynamic_fixtures/java/ssrf_negative.java new file mode 100644 index 00000000..62b19013 --- /dev/null +++ b/tests/dynamic_fixtures/java/ssrf_negative.java @@ -0,0 +1,27 @@ +// SSRF — negative fixture. +// Safe: only allows http/https scheme; file:// rejected. +// Entry: Entry.fetchUrl(String) Cap: SSRF +// Expected verdict: NotConfirmed + +import java.io.*; +import java.net.*; + +public class Entry { + public static void fetchUrl(String targetUrl) throws Exception { + URL url = new URL(targetUrl); + String proto = url.getProtocol(); + if (!proto.equals("http") && !proto.equals("https")) { + System.out.println("Scheme not allowed: " + proto); + return; + } + try (InputStream in = url.openStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(in))) { + String line; + while ((line = reader.readLine()) != null) { + System.out.println(line.substring(0, Math.min(line.length(), 64))); + } + } catch (Exception e) { + System.out.println("Connection error"); + } + } +} diff --git a/tests/dynamic_fixtures/java/ssrf_positive.java b/tests/dynamic_fixtures/java/ssrf_positive.java new file mode 100644 index 00000000..97495b2a --- /dev/null +++ b/tests/dynamic_fixtures/java/ssrf_positive.java @@ -0,0 +1,24 @@ +// SSRF — positive fixture. +// Vulnerable: makes a request to a user-controlled URL. +// Entry: Entry.fetchUrl(String) Cap: SSRF +// Expected verdict: Confirmed (file:///etc/passwd → "daemon:" in output) +// Note: Java URL supports file:// scheme natively. + +import java.io.*; +import java.net.*; + +public class Entry { + public static void fetchUrl(String targetUrl) throws Exception { + System.out.print("__NYX_SINK_HIT__\n"); + URL url = new URL(targetUrl); + try (InputStream in = url.openStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(in))) { + String line; + while ((line = reader.readLine()) != null) { + System.out.println(line); + } + } catch (Exception e) { + // silent + } + } +} diff --git a/tests/dynamic_fixtures/java/ssrf_unsupported.java b/tests/dynamic_fixtures/java/ssrf_unsupported.java new file mode 100644 index 00000000..910861e7 --- /dev/null +++ b/tests/dynamic_fixtures/java/ssrf_unsupported.java @@ -0,0 +1,12 @@ +// SSRF — unsupported fixture. +// Entry is an instance method; test sets confidence = Low. +// Expected verdict: Unsupported + +import java.io.*; +import java.net.*; + +public class Entry { + public void fetch(String url) throws Exception { + new URL(url).openStream().close(); + } +} diff --git a/tests/dynamic_fixtures/java/xss_adversarial.java b/tests/dynamic_fixtures/java/xss_adversarial.java new file mode 100644 index 00000000..f268fe06 --- /dev/null +++ b/tests/dynamic_fixtures/java/xss_adversarial.java @@ -0,0 +1,13 @@ +// XSS — adversarial collision fixture. +// Prints the XSS oracle marker unconditionally without rendering any template +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: Entry.renderPage(String) Cap: HTML_ESCAPE + +public class Entry { + public static void renderPage(String userInput) { + // Coincidental oracle match — not an HTML render sink. + System.out.println(""); + int x = userInput.length(); + } +} diff --git a/tests/dynamic_fixtures/java/xss_negative.java b/tests/dynamic_fixtures/java/xss_negative.java new file mode 100644 index 00000000..89ff5300 --- /dev/null +++ b/tests/dynamic_fixtures/java/xss_negative.java @@ -0,0 +1,19 @@ +// XSS — negative fixture. +// Safe: HTML-encodes special characters before output. +// Entry: Entry.renderPage(String) Cap: HTML_ESCAPE +// Expected verdict: NotConfirmed + +public class Entry { + private static String escapeHtml(String s) { + return s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + public static void renderPage(String userInput) { + String safe = escapeHtml(userInput); + System.out.print("" + safe + "\n"); + } +} diff --git a/tests/dynamic_fixtures/java/xss_positive.java b/tests/dynamic_fixtures/java/xss_positive.java new file mode 100644 index 00000000..f107c0a2 --- /dev/null +++ b/tests/dynamic_fixtures/java/xss_positive.java @@ -0,0 +1,11 @@ +// XSS — positive fixture. +// Vulnerable: echoes raw user input into HTML output without escaping. +// Entry: Entry.renderPage(String) Cap: HTML_ESCAPE +// Expected verdict: Confirmed ( echoed) + +public class Entry { + public static void renderPage(String userInput) { + System.out.print("__NYX_SINK_HIT__\n"); + System.out.print("" + userInput + "\n"); + } +} diff --git a/tests/dynamic_fixtures/java/xss_unsupported.java b/tests/dynamic_fixtures/java/xss_unsupported.java new file mode 100644 index 00000000..67799b88 --- /dev/null +++ b/tests/dynamic_fixtures/java/xss_unsupported.java @@ -0,0 +1,9 @@ +// XSS — unsupported fixture. +// Entry is an instance method; test sets confidence = Low. +// Expected verdict: Unsupported + +public class Entry { + public void render(String input) { + System.out.print("" + input + "\n"); + } +} diff --git a/tests/dynamic_fixtures/js/cmdi_adversarial.js b/tests/dynamic_fixtures/js/cmdi_adversarial.js new file mode 100644 index 00000000..616a75d9 --- /dev/null +++ b/tests/dynamic_fixtures/js/cmdi_adversarial.js @@ -0,0 +1,13 @@ +// Command injection — adversarial collision fixture. +// Prints NYX_PWN_CMDI unconditionally without reaching a command sink +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: runPing(host) Cap: CODE_EXEC + +function runPing(host) { + // Coincidental oracle match — not a shell sink. + process.stdout.write('NYX_PWN_CMDI\n'); + void host.length; +} + +module.exports = { runPing }; diff --git a/tests/dynamic_fixtures/js/cmdi_negative.js b/tests/dynamic_fixtures/js/cmdi_negative.js new file mode 100644 index 00000000..3b30e75b --- /dev/null +++ b/tests/dynamic_fixtures/js/cmdi_negative.js @@ -0,0 +1,18 @@ +// Command injection — negative fixture. +// Safe: uses execFile (no shell) with args array; shell metacharacters ignored. +// Entry: runPing(host) Cap: CODE_EXEC +// Expected verdict: NotConfirmed + +const { execFileSync } = require('child_process'); + +function runPing(host) { + // execFile does not invoke a shell — semicolons and metacharacters are inert. + try { + const out = execFileSync('echo', ['hello', host], { encoding: 'utf8', timeout: 5000 }); + process.stdout.write(out); + } catch (e) { + process.stdout.write('error\n'); + } +} + +module.exports = { runPing }; diff --git a/tests/dynamic_fixtures/js/cmdi_positive.js b/tests/dynamic_fixtures/js/cmdi_positive.js new file mode 100644 index 00000000..0fcb0fb2 --- /dev/null +++ b/tests/dynamic_fixtures/js/cmdi_positive.js @@ -0,0 +1,18 @@ +// Command injection — positive fixture. +// Vulnerable: passes user input directly to shell via execSync. +// Entry: runPing(host) Cap: CODE_EXEC +// Expected verdict: Confirmed ("; echo NYX_PWN_CMDI" payload echoes marker) + +const { execSync } = require('child_process'); + +function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const out = execSync('echo hello ' + host, { encoding: 'utf8', timeout: 5000 }); + process.stdout.write(out); + } catch (e) { + process.stdout.write((e.stdout || '') + (e.stderr || '')); + } +} + +module.exports = { runPing }; diff --git a/tests/dynamic_fixtures/js/cmdi_unsupported.js b/tests/dynamic_fixtures/js/cmdi_unsupported.js new file mode 100644 index 00000000..73ed8a1f --- /dev/null +++ b/tests/dynamic_fixtures/js/cmdi_unsupported.js @@ -0,0 +1,17 @@ +// Command injection — unsupported fixture. +// Entry expects a pre-parsed args array, not a string payload. +// Test sets confidence = Low to get Unsupported(ConfidenceTooLow). +// Entry: runCommand(args) Cap: CODE_EXEC +// Expected verdict: Unsupported + +const { execFileSync } = require('child_process'); + +function runCommand(args) { + // args is expected to be an array; a string payload can't be routed here. + if (!Array.isArray(args) || args.length === 0) { + return; + } + execFileSync(args[0], args.slice(1), { encoding: 'utf8', timeout: 5000 }); +} + +module.exports = { runCommand }; diff --git a/tests/dynamic_fixtures/js/fileio_adversarial.js b/tests/dynamic_fixtures/js/fileio_adversarial.js new file mode 100644 index 00000000..575fa712 --- /dev/null +++ b/tests/dynamic_fixtures/js/fileio_adversarial.js @@ -0,0 +1,13 @@ +// File I/O — adversarial collision fixture. +// Prints "root:" unconditionally without reading any file +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: readFile(userPath) Cap: FILE_IO + +function readFile(userPath) { + // Coincidental oracle match — not a file read sink. + process.stdout.write('root: present\n'); + void userPath.length; +} + +module.exports = { readFile }; diff --git a/tests/dynamic_fixtures/js/fileio_negative.js b/tests/dynamic_fixtures/js/fileio_negative.js new file mode 100644 index 00000000..459ed7a2 --- /dev/null +++ b/tests/dynamic_fixtures/js/fileio_negative.js @@ -0,0 +1,25 @@ +// File I/O — negative fixture. +// Safe: path is normalized and validated against an allowlist prefix. +// Entry: readFile(userPath) Cap: FILE_IO +// Expected verdict: NotConfirmed + +const fs = require('fs'); +const path = require('path'); + +const BASE_DIR = '/var/data'; + +function readFile(userPath) { + const resolved = path.resolve(BASE_DIR, userPath); + if (!resolved.startsWith(BASE_DIR + path.sep) && resolved !== BASE_DIR) { + process.stdout.write('Access denied\n'); + return; + } + try { + const content = fs.readFileSync(resolved, 'utf8'); + process.stdout.write(content.substring(0, 100)); + } catch (e) { + process.stdout.write('File not found\n'); + } +} + +module.exports = { readFile }; diff --git a/tests/dynamic_fixtures/js/fileio_positive.js b/tests/dynamic_fixtures/js/fileio_positive.js new file mode 100644 index 00000000..487f6e66 --- /dev/null +++ b/tests/dynamic_fixtures/js/fileio_positive.js @@ -0,0 +1,20 @@ +// File I/O — positive fixture. +// Vulnerable: reads a file at a user-controlled path without sanitization. +// Entry: readFile(userPath) Cap: FILE_IO +// Expected verdict: Confirmed (../../../../etc/passwd → "root:" in output) + +const fs = require('fs'); +const path = require('path'); + +function readFile(userPath) { + const filePath = path.join('/var/data', userPath); + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const content = fs.readFileSync(filePath, 'utf8'); + process.stdout.write(content); + } catch (e) { + // Silent on error — target file may not exist in all envs + } +} + +module.exports = { readFile }; diff --git a/tests/dynamic_fixtures/js/fileio_unsupported.js b/tests/dynamic_fixtures/js/fileio_unsupported.js new file mode 100644 index 00000000..806ca2a8 --- /dev/null +++ b/tests/dynamic_fixtures/js/fileio_unsupported.js @@ -0,0 +1,20 @@ +// File I/O — unsupported fixture. +// Entry takes a Buffer (binary), not a UTF-8 string payload. +// Test sets confidence = Low to get Unsupported(ConfidenceTooLow). +// Entry: processUpload(buf) Cap: FILE_IO +// Expected verdict: Unsupported + +const fs = require('fs'); + +function processUpload(buf) { + if (!Buffer.isBuffer(buf)) { + return; + } + const tmpPath = '/tmp/upload_' + Date.now(); + fs.writeFileSync(tmpPath, buf); + const content = fs.readFileSync(tmpPath, 'utf8'); + process.stdout.write(content.substring(0, 64)); + fs.unlinkSync(tmpPath); +} + +module.exports = { processUpload }; diff --git a/tests/dynamic_fixtures/js/sqli_adversarial.js b/tests/dynamic_fixtures/js/sqli_adversarial.js new file mode 100644 index 00000000..b84e3430 --- /dev/null +++ b/tests/dynamic_fixtures/js/sqli_adversarial.js @@ -0,0 +1,14 @@ +// SQL injection — adversarial collision fixture. +// Prints NYX_SQL_CONFIRMED unconditionally without reaching a SQL sink +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// oracle_fired = true, sink_hit = false +// Entry: login(username) Cap: SQL_QUERY + +function login(username) { + // Coincidental oracle match — not a SQL sink. + process.stdout.write('NYX_SQL_CONFIRMED\n'); + void username.length; +} + +module.exports = { login }; diff --git a/tests/dynamic_fixtures/js/sqli_negative.js b/tests/dynamic_fixtures/js/sqli_negative.js new file mode 100644 index 00000000..bf77b002 --- /dev/null +++ b/tests/dynamic_fixtures/js/sqli_negative.js @@ -0,0 +1,14 @@ +// SQL injection — negative fixture. +// Safe: uses a parameterized query pattern; payload never concatenated. +// Entry: login(username) Cap: SQL_QUERY +// Expected verdict: NotConfirmed + +function login(username) { + // Parameterized: the query template is fixed, payload is a bound param. + const template = 'SELECT name FROM users WHERE name = ?'; + // Simulate param binding — payload is never embedded in the query string. + const safeQuery = template; // template unchanged regardless of username + process.stdout.write('Query executed with param: ' + safeQuery + '\n'); +} + +module.exports = { login }; diff --git a/tests/dynamic_fixtures/js/sqli_positive.js b/tests/dynamic_fixtures/js/sqli_positive.js new file mode 100644 index 00000000..0c1708bb --- /dev/null +++ b/tests/dynamic_fixtures/js/sqli_positive.js @@ -0,0 +1,13 @@ +// SQL injection — positive fixture. +// Vulnerable: directly concatenates user input into SQL query string. +// Entry: login(username) Cap: SQL_QUERY +// Expected verdict: Confirmed (UNION payload echoes NYX_SQL_CONFIRMED) + +function login(username) { + const query = "SELECT name FROM users WHERE name='" + username + "'"; + process.stdout.write('__NYX_SINK_HIT__\n'); + // Simulate error-based SQL execution: echo query on failure. + process.stdout.write('DB query: ' + query + '\n'); +} + +module.exports = { login }; diff --git a/tests/dynamic_fixtures/js/sqli_unsupported.js b/tests/dynamic_fixtures/js/sqli_unsupported.js new file mode 100644 index 00000000..9d23c913 --- /dev/null +++ b/tests/dynamic_fixtures/js/sqli_unsupported.js @@ -0,0 +1,15 @@ +// SQL injection — unsupported fixture. +// The entry point accepts an HTTP request object, not a plain string. +// HarnessSpec derivation will map this to HttpBody slot (unsupported by JS emitter). +// Entry: handleRequest(req) Cap: SQL_QUERY +// Expected verdict: Unsupported (EntryKindUnsupported or ConfidenceTooLow) + +class UserRepository { + findUser(req) { + const name = req && req.body && req.body.name; + const query = "SELECT * FROM users WHERE name='" + name + "'"; + return query; + } +} + +module.exports = { UserRepository }; diff --git a/tests/dynamic_fixtures/js/ssrf_adversarial.js b/tests/dynamic_fixtures/js/ssrf_adversarial.js new file mode 100644 index 00000000..de142e21 --- /dev/null +++ b/tests/dynamic_fixtures/js/ssrf_adversarial.js @@ -0,0 +1,13 @@ +// SSRF — adversarial collision fixture. +// Prints "daemon:" unconditionally without making any HTTP request +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: fetchUrl(targetUrl) Cap: SSRF + +function fetchUrl(targetUrl) { + // Coincidental oracle match — not an HTTP sink. + process.stdout.write('daemon: present\n'); + void targetUrl.length; +} + +module.exports = { fetchUrl }; diff --git a/tests/dynamic_fixtures/js/ssrf_negative.js b/tests/dynamic_fixtures/js/ssrf_negative.js new file mode 100644 index 00000000..88b1565e --- /dev/null +++ b/tests/dynamic_fixtures/js/ssrf_negative.js @@ -0,0 +1,24 @@ +// SSRF — negative fixture. +// Safe: only allows http/https scheme; file:// and other schemes are rejected. +// Entry: fetchUrl(targetUrl) Cap: SSRF +// Expected verdict: NotConfirmed + +const http = require('http'); +const https = require('https'); +const url = require('url'); + +function fetchUrl(targetUrl) { + const parsed = url.parse(targetUrl); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + process.stdout.write('Scheme not allowed: ' + parsed.protocol + '\n'); + return; + } + const mod = parsed.protocol === 'https:' ? https : http; + const req = mod.get(targetUrl, (res) => { + res.on('data', (chunk) => process.stdout.write(chunk.toString().substring(0, 64))); + }); + req.on('error', () => {}); + req.end(); +} + +module.exports = { fetchUrl }; diff --git a/tests/dynamic_fixtures/js/ssrf_positive.js b/tests/dynamic_fixtures/js/ssrf_positive.js new file mode 100644 index 00000000..ee4de2f7 --- /dev/null +++ b/tests/dynamic_fixtures/js/ssrf_positive.js @@ -0,0 +1,35 @@ +// SSRF — positive fixture. +// Vulnerable: makes a request to a user-controlled URL. +// Entry: fetch(url) Cap: SSRF +// Expected verdict: Confirmed (file:///etc/passwd → "daemon:" in output) +// Note: Node.js http/https module does not support file:// scheme. +// We detect the file:// prefix and use fs.readFile directly to simulate +// the SSRF behaviour (same oracle: reads /etc/passwd, outputs "daemon:"). + +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const url = require('url'); + +function fetchUrl(targetUrl) { + process.stdout.write('__NYX_SINK_HIT__\n'); + const parsed = url.parse(targetUrl); + if (parsed.protocol === 'file:') { + // Simulate SSRF via file:// — read local file (oracle expects "daemon:") + try { + const content = fs.readFileSync(parsed.pathname || '/', 'utf8'); + process.stdout.write(content); + } catch (e) { + // silent + } + return; + } + const mod = parsed.protocol === 'https:' ? https : http; + const req = mod.get(targetUrl, (res) => { + res.on('data', (chunk) => process.stdout.write(chunk)); + }); + req.on('error', () => {}); + req.end(); +} + +module.exports = { fetchUrl }; diff --git a/tests/dynamic_fixtures/js/ssrf_unsupported.js b/tests/dynamic_fixtures/js/ssrf_unsupported.js new file mode 100644 index 00000000..70f739f3 --- /dev/null +++ b/tests/dynamic_fixtures/js/ssrf_unsupported.js @@ -0,0 +1,20 @@ +// SSRF — unsupported fixture. +// Entry takes an already-parsed URL object, not a string. +// Test sets confidence = Low to get Unsupported(ConfidenceTooLow). +// Entry: fetchParsed(parsedUrl) Cap: SSRF +// Expected verdict: Unsupported + +const http = require('http'); + +function fetchParsed(parsedUrl) { + if (!parsedUrl || typeof parsedUrl !== 'object') { + return; + } + const req = http.get(parsedUrl, (res) => { + res.on('data', (d) => process.stdout.write(d)); + }); + req.on('error', () => {}); + req.end(); +} + +module.exports = { fetchParsed }; diff --git a/tests/dynamic_fixtures/js/xss_adversarial.js b/tests/dynamic_fixtures/js/xss_adversarial.js new file mode 100644 index 00000000..230fda1e --- /dev/null +++ b/tests/dynamic_fixtures/js/xss_adversarial.js @@ -0,0 +1,13 @@ +// XSS — adversarial collision fixture. +// Prints the XSS oracle marker unconditionally without rendering any template +// and without emitting __NYX_SINK_HIT__. +// Expected verdict: Inconclusive(OracleCollisionSuspected) +// Entry: renderPage(userInput) Cap: HTML_ESCAPE + +function renderPage(userInput) { + // Coincidental oracle match — not an HTML render sink. + process.stdout.write('\n'); + void userInput.length; +} + +module.exports = { renderPage }; diff --git a/tests/dynamic_fixtures/js/xss_negative.js b/tests/dynamic_fixtures/js/xss_negative.js new file mode 100644 index 00000000..30a413d9 --- /dev/null +++ b/tests/dynamic_fixtures/js/xss_negative.js @@ -0,0 +1,20 @@ +// XSS — negative fixture. +// Safe: HTML-escapes all special characters before output. +// Entry: renderPage(userInput) Cap: HTML_ESCAPE +// Expected verdict: NotConfirmed + +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function renderPage(userInput) { + const safe = escapeHtml(userInput); + process.stdout.write('' + safe + '\n'); +} + +module.exports = { renderPage }; diff --git a/tests/dynamic_fixtures/js/xss_positive.js b/tests/dynamic_fixtures/js/xss_positive.js new file mode 100644 index 00000000..d7131de9 --- /dev/null +++ b/tests/dynamic_fixtures/js/xss_positive.js @@ -0,0 +1,12 @@ +// XSS — positive fixture. +// Vulnerable: echoes raw user input into HTML output without escaping. +// Entry: renderPage(userInput) Cap: HTML_ESCAPE +// Expected verdict: Confirmed ( echoed) + +function renderPage(userInput) { + process.stdout.write('__NYX_SINK_HIT__\n'); + // Unescaped output — script tags pass through verbatim. + process.stdout.write('' + userInput + '\n'); +} + +module.exports = { renderPage }; diff --git a/tests/dynamic_fixtures/js/xss_unsupported.js b/tests/dynamic_fixtures/js/xss_unsupported.js new file mode 100644 index 00000000..08cc66cd --- /dev/null +++ b/tests/dynamic_fixtures/js/xss_unsupported.js @@ -0,0 +1,13 @@ +// XSS — unsupported fixture. +// Entry is a class method rather than a top-level function. +// Test sets confidence = Low to get Unsupported(ConfidenceTooLow). +// Entry: TemplateEngine.render(input) Cap: HTML_ESCAPE +// Expected verdict: Unsupported + +class TemplateEngine { + render(input) { + return '' + input + ''; + } +} + +module.exports = { TemplateEngine }; diff --git a/tests/dynamic_fixtures/php/cmdi_adversarial.php b/tests/dynamic_fixtures/php/cmdi_adversarial.php new file mode 100644 index 00000000..0ba6853b --- /dev/null +++ b/tests/dynamic_fixtures/php/cmdi_adversarial.php @@ -0,0 +1,12 @@ +NYX_XSS_CONFIRMED\n"; + $x = strlen($userInput); +} diff --git a/tests/dynamic_fixtures/php/xss_negative.php b/tests/dynamic_fixtures/php/xss_negative.php new file mode 100644 index 00000000..73768341 --- /dev/null +++ b/tests/dynamic_fixtures/php/xss_negative.php @@ -0,0 +1,10 @@ +' . $safe . '' . "\n"; +} diff --git a/tests/dynamic_fixtures/php/xss_positive.php b/tests/dynamic_fixtures/php/xss_positive.php new file mode 100644 index 00000000..68177719 --- /dev/null +++ b/tests/dynamic_fixtures/php/xss_positive.php @@ -0,0 +1,10 @@ +NYX_XSS_CONFIRMED echoed) + +function renderPage($userInput) { + echo "__NYX_SINK_HIT__\n"; + echo '' . $userInput . '' . "\n"; +} diff --git a/tests/dynamic_fixtures/php/xss_unsupported.php b/tests/dynamic_fixtures/php/xss_unsupported.php new file mode 100644 index 00000000..10941a58 --- /dev/null +++ b/tests/dynamic_fixtures/php/xss_unsupported.php @@ -0,0 +1,10 @@ +' . $input . '' . "\n"; + } +} diff --git a/tests/dynamic_sandbox_escape.rs b/tests/dynamic_sandbox_escape.rs index eae44054..af33f7a2 100644 --- a/tests/dynamic_sandbox_escape.rs +++ b/tests/dynamic_sandbox_escape.rs @@ -226,6 +226,112 @@ mod escape_tests { ); } + // ── 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. + /// + /// 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. + /// + /// Fixture: `tests/dynamic_fixtures/escape/npm_malicious_lifecycle/package.json` + #[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"); + 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(). + + assert!( + !marker.exists(), + "host marker /tmp/pwned_npm_lifecycle must not exist before Docker+npm install is implemented" + ); + } + + /// Verify that a malicious Go `init()` function cannot write to the host + /// when the compiled binary runs inside the Docker sandbox. + /// + /// 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. + /// + /// Fixture: `tests/dynamic_fixtures/escape/go_malicious_init.go` + #[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"); + 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. + + assert!( + !marker.exists(), + "host marker /tmp/pwned_go_init must not exist; Go init() write stays inside container /tmp" + ); + } + + /// Verify that a malicious Maven plugin (`exec-maven-plugin`) cannot write + /// to the host when `mvn compile` runs inside the sandbox. + /// + /// 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. + /// + /// Fixture: `tests/dynamic_fixtures/escape/maven_malicious_plugin/pom.xml` + #[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"); + let _ = fs::remove_file(&marker); + + // Phase 06 TODO: wire Docker-isolated mvn compile and re-enable body. + + assert!( + !marker.exists(), + "host marker /tmp/pwned_maven_plugin must not exist before Docker+Maven build is implemented" + ); + } + + /// Verify that a malicious Composer `post-install-cmd` cannot write to the + /// host when `composer install` runs inside the sandbox. + /// + /// 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. + /// + /// Fixture: `tests/dynamic_fixtures/escape/composer_malicious_postinstall/composer.json` + #[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"); + let _ = fs::remove_file(&marker); + + // Phase 06 TODO: wire Docker-isolated composer install and re-enable body. + + assert!( + !marker.exists(), + "host marker /tmp/pwned_composer_postinstall must not exist before Docker+Composer install is implemented" + ); + } + // ── Positive control test ───────────────────────────────────────────────── /// Positive control: verify the escape-detection mechanism itself. diff --git a/tests/dynamic_verify_e2e.rs b/tests/dynamic_verify_e2e.rs index c5952099..da27bdce 100644 --- a/tests/dynamic_verify_e2e.rs +++ b/tests/dynamic_verify_e2e.rs @@ -85,12 +85,46 @@ mod verify_e2e { } } + /// Same as `taint_diag_with_cap` but uses a C source file so that + /// `HarnessSpec::from_finding` derives `Lang::C`, which has no emitter. + fn taint_diag_c_lang(cap: Cap) -> Diag { + Diag { + path: "src/handler.c".into(), + line: 10, + 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 { + flow_steps: vec![ + source_step("src/handler.c", "handle_request"), + sink_step("src/handler.c"), + ], + sink_caps: cap.bits(), + ..Default::default() + }), + rank_score: None, + rank_reason: None, + suppressed: false, + suppression: None, + rollup: None, + finding_id: String::new(), + alternative_finding_ids: vec![], + stable_hash: 0, + } + } + /// A finding with a supported cap (SQL_QUERY) and a derivable spec reaches - /// `harness::build`. The finding uses a Rust entry file, so the Python-only - /// harness emitter returns `LangUnsupported`. + /// `harness::build`. The finding uses a C entry file; `Lang::C` has no + /// emitter so `LangUnsupported` is returned. #[test] fn verify_finding_rust_lang_returns_lang_unsupported() { - let diag = taint_diag_with_cap(Cap::SQL_QUERY); + let diag = taint_diag_c_lang(Cap::SQL_QUERY); let opts = VerifyOptions::default(); let result = verify_finding(&diag, &opts); @@ -127,12 +161,12 @@ mod verify_e2e { assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow)); } - /// The JSON shape of `VerifyResult` for a Rust finding (lang unsupported) + /// The JSON shape of `VerifyResult` for a C finding (lang unsupported) /// matches the documented contract: `status`, `reason` present; /// `triggered_payload`, `detail`, `attempts` absent (skipped by serde). #[test] fn verify_result_json_shape_lang_unsupported() { - let diag = taint_diag_with_cap(Cap::SQL_QUERY); + let diag = taint_diag_c_lang(Cap::SQL_QUERY); let opts = VerifyOptions::default(); let result = verify_finding(&diag, &opts); diff --git a/tests/go_fixtures.rs b/tests/go_fixtures.rs new file mode 100644 index 00000000..e3274ad1 --- /dev/null +++ b/tests/go_fixtures.rs @@ -0,0 +1,447 @@ +//! Go fixture integration tests (Phase 05 acceptance gate). +//! +//! Runs the dynamic verification pipeline against each Go fixture and asserts +//! the expected verdict. Requires `--features dynamic` and `go` on PATH. +//! +//! Entry points follow: `func FuncName(payload string)` in package `entry`. +//! The harness wraps each fixture in a generated `main.go` that reads +//! `NYX_PAYLOAD` and calls `entry.FuncName(payload)`. +//! +//! Run with: `cargo nextest run --features dynamic --test go_fixtures` + +#[cfg(feature = "dynamic")] +mod go_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; + + static FIXTURE_LOCK: Mutex<()> = Mutex::new(()); + + fn go_available() -> bool { + std::process::Command::new("go") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/go") + .join(name) + } + + 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()); + if !go_available() { + return nyx_scanner::evidence::VerifyResult { + finding_id: String::new(), + status: VerifyStatus::Unsupported, + triggered_payload: None, + reason: Some(UnsupportedReason::BackendUnavailable), + inconclusive_reason: None, + detail: None, + attempts: vec![], + toolchain_match: None, + }; + } + + let path = fixture_path(fixture); + let tmp = TempDir::new().unwrap(); + + 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(), + ); + } + + 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 go_sqli_positive_is_confirmed() { + let result = run_fixture("sqli_positive.go", "Login", Cap::SQL_QUERY, 13); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "sqli_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn go_sqli_negative_is_not_confirmed() { + let result = run_fixture("sqli_negative.go", "Login", Cap::SQL_QUERY, 12); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "sqli_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn go_sqli_adversarial_is_oracle_collision() { + let result = run_fixture("sqli_adversarial.go", "Login", Cap::SQL_QUERY, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn go_sqli_unsupported_is_confidence_too_low() { + let path = fixture_path("sqli_unsupported.go"); + let mut d = make_diag(&path, "FindUser", Cap::SQL_QUERY, 12); + 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)); + } + + // ── Command injection fixtures ─────────────────────────────────────────── + + #[test] + fn go_cmdi_positive_is_confirmed() { + let result = run_fixture("cmdi_positive.go", "RunPing", Cap::CODE_EXEC, 15); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "cmdi_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn go_cmdi_negative_is_not_confirmed() { + let result = run_fixture("cmdi_negative.go", "RunPing", Cap::CODE_EXEC, 14); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "cmdi_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn go_cmdi_adversarial_is_oracle_collision() { + let result = run_fixture("cmdi_adversarial.go", "RunPing", Cap::CODE_EXEC, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn go_cmdi_unsupported_is_confidence_too_low() { + let path = fixture_path("cmdi_unsupported.go"); + let mut d = make_diag(&path, "Execute", Cap::CODE_EXEC, 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)); + } + + // ── File I/O fixtures ──────────────────────────────────────────────────── + + #[test] + fn go_fileio_positive_is_confirmed() { + let result = run_fixture("fileio_positive.go", "ReadFile", Cap::FILE_IO, 17); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "fileio_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn go_fileio_negative_is_not_confirmed() { + let result = run_fixture("fileio_negative.go", "ReadFile", Cap::FILE_IO, 20); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "fileio_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn go_fileio_adversarial_is_oracle_collision() { + let result = run_fixture("fileio_adversarial.go", "ReadFile", Cap::FILE_IO, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn go_fileio_unsupported_is_confidence_too_low() { + let path = fixture_path("fileio_unsupported.go"); + let mut d = make_diag(&path, "Serve", Cap::FILE_IO, 13); + 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)); + } + + // ── SSRF fixtures ──────────────────────────────────────────────────────── + + #[test] + fn go_ssrf_positive_is_confirmed() { + let result = run_fixture("ssrf_positive.go", "FetchURL", Cap::SSRF, 21); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "ssrf_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn go_ssrf_negative_is_not_confirmed() { + let result = run_fixture("ssrf_negative.go", "FetchURL", Cap::SSRF, 18); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "ssrf_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn go_ssrf_adversarial_is_oracle_collision() { + let result = run_fixture("ssrf_adversarial.go", "FetchURL", Cap::SSRF, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn go_ssrf_unsupported_is_confidence_too_low() { + let path = fixture_path("ssrf_unsupported.go"); + let mut d = make_diag(&path, "Fetch", Cap::SSRF, 11); + 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)); + } + + // ── XSS fixtures ───────────────────────────────────────────────────────── + + #[test] + fn go_xss_positive_is_confirmed() { + let result = run_fixture("xss_positive.go", "RenderPage", Cap::HTML_ESCAPE, 12); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "xss_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn go_xss_negative_is_not_confirmed() { + let result = run_fixture("xss_negative.go", "RenderPage", Cap::HTML_ESCAPE, 12); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "xss_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn go_xss_adversarial_is_oracle_collision() { + let result = run_fixture("xss_adversarial.go", "RenderPage", Cap::HTML_ESCAPE, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn go_xss_unsupported_is_confidence_too_low() { + let path = fixture_path("xss_unsupported.go"); + let mut d = make_diag(&path, "Render", Cap::HTML_ESCAPE, 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)); + } + + // ── 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, + } + } +} diff --git a/tests/java_fixtures.rs b/tests/java_fixtures.rs new file mode 100644 index 00000000..5e4426fb --- /dev/null +++ b/tests/java_fixtures.rs @@ -0,0 +1,447 @@ +//! Java fixture integration tests (Phase 05 acceptance gate). +//! +//! Runs the dynamic verification pipeline against each Java fixture and asserts +//! the expected verdict. Requires `--features dynamic` and `java`/`javac` on PATH. +//! +//! Entry points follow: `public static void FuncName(String)` in class `Entry`. +//! The harness wraps each fixture in a generated `NyxHarness.java` that reads +//! `NYX_PAYLOAD` and calls `Entry.FuncName(payload)`. +//! +//! Run with: `cargo nextest run --features dynamic --test java_fixtures` + +#[cfg(feature = "dynamic")] +mod java_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; + + static FIXTURE_LOCK: Mutex<()> = Mutex::new(()); + + fn java_available() -> bool { + std::process::Command::new("java") + .arg("-version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/java") + .join(name) + } + + 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()); + if !java_available() { + return nyx_scanner::evidence::VerifyResult { + finding_id: String::new(), + status: VerifyStatus::Unsupported, + triggered_payload: None, + reason: Some(UnsupportedReason::BackendUnavailable), + inconclusive_reason: None, + detail: None, + attempts: vec![], + toolchain_match: None, + }; + } + + let path = fixture_path(fixture); + let tmp = TempDir::new().unwrap(); + + 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(), + ); + } + + 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 java_sqli_positive_is_confirmed() { + let result = run_fixture("sqli_positive.java", "login", Cap::SQL_QUERY, 9); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "sqli_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn java_sqli_negative_is_not_confirmed() { + let result = run_fixture("sqli_negative.java", "login", Cap::SQL_QUERY, 10); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "sqli_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn java_sqli_adversarial_is_oracle_collision() { + let result = run_fixture("sqli_adversarial.java", "login", Cap::SQL_QUERY, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn java_sqli_unsupported_is_confidence_too_low() { + let path = fixture_path("sqli_unsupported.java"); + let mut d = make_diag(&path, "findUser", 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)); + } + + // ── Command injection fixtures ─────────────────────────────────────────── + + #[test] + fn java_cmdi_positive_is_confirmed() { + let result = run_fixture("cmdi_positive.java", "runPing", Cap::CODE_EXEC, 10); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "cmdi_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn java_cmdi_negative_is_not_confirmed() { + let result = run_fixture("cmdi_negative.java", "runPing", Cap::CODE_EXEC, 12); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "cmdi_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn java_cmdi_adversarial_is_oracle_collision() { + let result = run_fixture("cmdi_adversarial.java", "runPing", Cap::CODE_EXEC, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn java_cmdi_unsupported_is_confidence_too_low() { + let path = fixture_path("cmdi_unsupported.java"); + 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)); + } + + // ── File I/O fixtures ──────────────────────────────────────────────────── + + #[test] + fn java_fileio_positive_is_confirmed() { + let result = run_fixture("fileio_positive.java", "readFile", Cap::FILE_IO, 12); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "fileio_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn java_fileio_negative_is_not_confirmed() { + let result = run_fixture("fileio_negative.java", "readFile", Cap::FILE_IO, 20); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "fileio_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn java_fileio_adversarial_is_oracle_collision() { + let result = run_fixture("fileio_adversarial.java", "readFile", Cap::FILE_IO, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn java_fileio_unsupported_is_confidence_too_low() { + let path = fixture_path("fileio_unsupported.java"); + let mut d = make_diag(&path, "serve", Cap::FILE_IO, 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)); + } + + // ── SSRF fixtures ──────────────────────────────────────────────────────── + + #[test] + fn java_ssrf_positive_is_confirmed() { + let result = run_fixture("ssrf_positive.java", "fetchUrl", Cap::SSRF, 12); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "ssrf_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn java_ssrf_negative_is_not_confirmed() { + let result = run_fixture("ssrf_negative.java", "fetchUrl", Cap::SSRF, 17); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "ssrf_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn java_ssrf_adversarial_is_oracle_collision() { + let result = run_fixture("ssrf_adversarial.java", "fetchUrl", Cap::SSRF, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn java_ssrf_unsupported_is_confidence_too_low() { + let path = fixture_path("ssrf_unsupported.java"); + let mut d = make_diag(&path, "fetch", Cap::SSRF, 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)); + } + + // ── XSS fixtures ───────────────────────────────────────────────────────── + + #[test] + fn java_xss_positive_is_confirmed() { + let result = run_fixture("xss_positive.java", "renderPage", Cap::HTML_ESCAPE, 8); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "xss_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn java_xss_negative_is_not_confirmed() { + let result = run_fixture("xss_negative.java", "renderPage", Cap::HTML_ESCAPE, 17); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "xss_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn java_xss_adversarial_is_oracle_collision() { + let result = run_fixture("xss_adversarial.java", "renderPage", Cap::HTML_ESCAPE, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn java_xss_unsupported_is_confidence_too_low() { + let path = fixture_path("xss_unsupported.java"); + let mut d = make_diag(&path, "render", Cap::HTML_ESCAPE, 7); + 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)); + } + + // ── 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, + } + } +} diff --git a/tests/js_fixtures.rs b/tests/js_fixtures.rs new file mode 100644 index 00000000..a45afcf2 --- /dev/null +++ b/tests/js_fixtures.rs @@ -0,0 +1,452 @@ +//! JavaScript/Node.js fixture integration tests (Phase 05 acceptance gate). +//! +//! Runs the dynamic verification pipeline against each JS fixture and asserts +//! the expected verdict. Requires `--features dynamic` and `node` on PATH. +//! +//! Entry points follow: `function funcName(payload)` + `module.exports = { funcName }`. +//! The harness emitter wraps each fixture in a generated `harness.js` that +//! reads `NYX_PAYLOAD` from the environment and calls `_entry.funcName(payload)`. +//! +//! Run with: `cargo nextest run --features dynamic --test js_fixtures` + +#[cfg(feature = "dynamic")] +mod js_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; + + static FIXTURE_LOCK: Mutex<()> = Mutex::new(()); + + fn node_available() -> bool { + std::process::Command::new("node") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/js") + .join(name) + } + + /// Run a JS fixture through the full dynamic verification pipeline. + /// + /// The fixture file is copied to a temp dir as `entry.js`. + 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()); + if !node_available() { + return nyx_scanner::evidence::VerifyResult { + finding_id: String::new(), + status: VerifyStatus::Unsupported, + triggered_payload: None, + reason: Some(UnsupportedReason::BackendUnavailable), + inconclusive_reason: None, + detail: None, + attempts: vec![], + toolchain_match: None, + }; + } + + let path = fixture_path(fixture); + let tmp = TempDir::new().unwrap(); + let dst = tmp.path().join("entry.js"); + 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(), + ); + } + + 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 js_sqli_positive_is_confirmed() { + let result = run_fixture("sqli_positive.js", "login", Cap::SQL_QUERY, 12); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; // node not available + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "sqli_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn js_sqli_negative_is_not_confirmed() { + let result = run_fixture("sqli_negative.js", "login", Cap::SQL_QUERY, 13); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "sqli_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn js_sqli_adversarial_is_oracle_collision() { + let result = run_fixture("sqli_adversarial.js", "login", Cap::SQL_QUERY, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn js_sqli_unsupported_is_confidence_too_low() { + let path = fixture_path("sqli_unsupported.js"); + let mut d = make_diag(&path, "findUser", 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)); + } + + // ── Command injection fixtures ─────────────────────────────────────────── + + #[test] + fn js_cmdi_positive_is_confirmed() { + let result = run_fixture("cmdi_positive.js", "runPing", Cap::CODE_EXEC, 11); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "cmdi_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn js_cmdi_negative_is_not_confirmed() { + let result = run_fixture("cmdi_negative.js", "runPing", Cap::CODE_EXEC, 11); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "cmdi_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn js_cmdi_adversarial_is_oracle_collision() { + let result = run_fixture("cmdi_adversarial.js", "runPing", Cap::CODE_EXEC, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn js_cmdi_unsupported_is_confidence_too_low() { + let path = fixture_path("cmdi_unsupported.js"); + let mut d = make_diag(&path, "runCommand", Cap::CODE_EXEC, 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)); + } + + // ── File I/O fixtures ──────────────────────────────────────────────────── + + #[test] + fn js_fileio_positive_is_confirmed() { + let result = run_fixture("fileio_positive.js", "readFile", Cap::FILE_IO, 13); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "fileio_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn js_fileio_negative_is_not_confirmed() { + let result = run_fixture("fileio_negative.js", "readFile", Cap::FILE_IO, 16); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "fileio_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn js_fileio_adversarial_is_oracle_collision() { + let result = run_fixture("fileio_adversarial.js", "readFile", Cap::FILE_IO, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn js_fileio_unsupported_is_confidence_too_low() { + let path = fixture_path("fileio_unsupported.js"); + let mut d = make_diag(&path, "processUpload", Cap::FILE_IO, 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)); + } + + // ── SSRF fixtures ──────────────────────────────────────────────────────── + + #[test] + fn js_ssrf_positive_is_confirmed() { + let result = run_fixture("ssrf_positive.js", "fetchUrl", Cap::SSRF, 21); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "ssrf_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn js_ssrf_negative_is_not_confirmed() { + let result = run_fixture("ssrf_negative.js", "fetchUrl", Cap::SSRF, 16); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "ssrf_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn js_ssrf_adversarial_is_oracle_collision() { + let result = run_fixture("ssrf_adversarial.js", "fetchUrl", Cap::SSRF, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn js_ssrf_unsupported_is_confidence_too_low() { + let path = fixture_path("ssrf_unsupported.js"); + let mut d = make_diag(&path, "fetchParsed", Cap::SSRF, 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)); + } + + // ── XSS fixtures ───────────────────────────────────────────────────────── + + #[test] + fn js_xss_positive_is_confirmed() { + let result = run_fixture("xss_positive.js", "renderPage", Cap::HTML_ESCAPE, 10); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "xss_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn js_xss_negative_is_not_confirmed() { + let result = run_fixture("xss_negative.js", "renderPage", Cap::HTML_ESCAPE, 14); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "xss_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn js_xss_adversarial_is_oracle_collision() { + let result = run_fixture("xss_adversarial.js", "renderPage", Cap::HTML_ESCAPE, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn js_xss_unsupported_is_confidence_too_low() { + let path = fixture_path("xss_unsupported.js"); + let mut d = make_diag(&path, "render", Cap::HTML_ESCAPE, 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)); + } + + // ── 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, + } + } +} diff --git a/tests/php_fixtures.rs b/tests/php_fixtures.rs new file mode 100644 index 00000000..968a19b0 --- /dev/null +++ b/tests/php_fixtures.rs @@ -0,0 +1,447 @@ +//! PHP fixture integration tests (Phase 05 acceptance gate). +//! +//! Runs the dynamic verification pipeline against each PHP fixture and asserts +//! the expected verdict. Requires `--features dynamic` and `php` on PATH. +//! +//! Entry points follow: `function funcName($payload)` at top level. +//! The harness wraps each fixture in a generated runner that reads +//! `NYX_PAYLOAD` and calls `funcName($payload)`. +//! +//! Run with: `cargo nextest run --features dynamic --test php_fixtures` + +#[cfg(feature = "dynamic")] +mod php_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; + + static FIXTURE_LOCK: Mutex<()> = Mutex::new(()); + + fn php_available() -> bool { + std::process::Command::new("php") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/php") + .join(name) + } + + 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()); + if !php_available() { + return nyx_scanner::evidence::VerifyResult { + finding_id: String::new(), + status: VerifyStatus::Unsupported, + triggered_payload: None, + reason: Some(UnsupportedReason::BackendUnavailable), + inconclusive_reason: None, + detail: None, + attempts: vec![], + toolchain_match: None, + }; + } + + let path = fixture_path(fixture); + let tmp = TempDir::new().unwrap(); + + 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(), + ); + } + + 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 php_sqli_positive_is_confirmed() { + let result = run_fixture("sqli_positive.php", "login", Cap::SQL_QUERY, 9); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "sqli_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn php_sqli_negative_is_not_confirmed() { + let result = run_fixture("sqli_negative.php", "login", Cap::SQL_QUERY, 10); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "sqli_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn php_sqli_adversarial_is_oracle_collision() { + let result = run_fixture("sqli_adversarial.php", "login", Cap::SQL_QUERY, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn php_sqli_unsupported_is_confidence_too_low() { + let path = fixture_path("sqli_unsupported.php"); + let mut d = make_diag(&path, "findUser", 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)); + } + + // ── Command injection fixtures ─────────────────────────────────────────── + + #[test] + fn php_cmdi_positive_is_confirmed() { + let result = run_fixture("cmdi_positive.php", "runPing", Cap::CODE_EXEC, 8); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "cmdi_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn php_cmdi_negative_is_not_confirmed() { + let result = run_fixture("cmdi_negative.php", "runPing", Cap::CODE_EXEC, 10); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "cmdi_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn php_cmdi_adversarial_is_oracle_collision() { + let result = run_fixture("cmdi_adversarial.php", "runPing", Cap::CODE_EXEC, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn php_cmdi_unsupported_is_confidence_too_low() { + let path = fixture_path("cmdi_unsupported.php"); + let mut d = make_diag(&path, "execute", Cap::CODE_EXEC, 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)); + } + + // ── File I/O fixtures ──────────────────────────────────────────────────── + + #[test] + fn php_fileio_positive_is_confirmed() { + let result = run_fixture("fileio_positive.php", "readFile", Cap::FILE_IO, 9); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "fileio_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn php_fileio_negative_is_not_confirmed() { + let result = run_fixture("fileio_negative.php", "readFile", Cap::FILE_IO, 14); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "fileio_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn php_fileio_adversarial_is_oracle_collision() { + let result = run_fixture("fileio_adversarial.php", "readFile", Cap::FILE_IO, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn php_fileio_unsupported_is_confidence_too_low() { + let path = fixture_path("fileio_unsupported.php"); + let mut d = make_diag(&path, "serve", 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)); + } + + // ── SSRF fixtures ──────────────────────────────────────────────────────── + + #[test] + fn php_ssrf_positive_is_confirmed() { + let result = run_fixture("ssrf_positive.php", "fetchUrl", Cap::SSRF, 9); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "ssrf_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn php_ssrf_negative_is_not_confirmed() { + let result = run_fixture("ssrf_negative.php", "fetchUrl", Cap::SSRF, 14); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "ssrf_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn php_ssrf_adversarial_is_oracle_collision() { + let result = run_fixture("ssrf_adversarial.php", "fetchUrl", Cap::SSRF, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn php_ssrf_unsupported_is_confidence_too_low() { + let path = fixture_path("ssrf_unsupported.php"); + let mut d = make_diag(&path, "fetch", 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)); + } + + // ── XSS fixtures ───────────────────────────────────────────────────────── + + #[test] + fn php_xss_positive_is_confirmed() { + let result = run_fixture("xss_positive.php", "renderPage", Cap::HTML_ESCAPE, 8); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "xss_positive must be Confirmed; got {:?} (detail: {:?})", + result.status, + result.detail + ); + } + + #[test] + fn php_xss_negative_is_not_confirmed() { + let result = run_fixture("xss_negative.php", "renderPage", Cap::HTML_ESCAPE, 9); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!( + result.status, + VerifyStatus::NotConfirmed, + "xss_negative must be NotConfirmed; got {:?}", + result.status + ); + } + + #[test] + fn php_xss_adversarial_is_oracle_collision() { + let result = run_fixture("xss_adversarial.php", "renderPage", Cap::HTML_ESCAPE, 999); + if result.status == VerifyStatus::Unsupported + && result.reason == Some(UnsupportedReason::BackendUnavailable) + { + return; + } + assert_eq!(result.status, VerifyStatus::Inconclusive); + assert_eq!( + result.inconclusive_reason, + Some(InconclusiveReason::OracleCollisionSuspected) + ); + } + + #[test] + fn php_xss_unsupported_is_confidence_too_low() { + let path = fixture_path("xss_unsupported.php"); + let mut d = make_diag(&path, "render", Cap::HTML_ESCAPE, 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)); + } + + // ── 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, + } + } +} diff --git a/tests/repro_determinism.rs b/tests/repro_determinism.rs index 76b13178..0ad839c6 100644 --- a/tests/repro_determinism.rs +++ b/tests/repro_determinism.rs @@ -277,6 +277,218 @@ fn main() { unsafe { std::env::remove_var("NYX_REPRO_BASE") }; } + // ── JS repro tests ─────────────────────────────────────────────────────── + + fn make_confirmed_js_spec(spec_hash: &str) -> HarnessSpec { + HarnessSpec { + finding_id: "js_determ000001".into(), + entry_file: "tests/dynamic_fixtures/js/sqli_positive.js".into(), + entry_name: "login".into(), + entry_kind: EntryKind::Function, + lang: Lang::JavaScript, + toolchain_id: "node-20".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "tests/dynamic_fixtures/js/sqli_positive.js".into(), + sink_line: 8, + spec_hash: spec_hash.to_owned(), + } + } + + #[test] + fn js_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_js_spec("js_determ000001a"); + let opts = SandboxOptions::default(); + let outcome = make_confirmed_outcome(); + let verdict = make_confirmed_verdict("js_determ000001"); + let entry_src = "function login(username) { console.log(username); }\n"; + + let artifact1 = repro::write( + &spec, &opts, &outcome, &verdict, + "// harness js\n", entry_src, + b"' UNION SELECT 'NYX_SQL_CONFIRMED'--", "sqli-union-nyx", None, + ).expect("first JS 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 js\n", entry_src, + b"' UNION SELECT 'NYX_SQL_CONFIRMED'--", "sqli-union-nyx", None, + ).expect("second JS repro write"); + let json2 = + std::fs::read_to_string(artifact2.root.join("expected/outcome.json")).unwrap(); + + assert_eq!(json1, json2, "JS outcome.json must be byte-identical across two writes"); + + unsafe { std::env::remove_var("NYX_REPRO_BASE") }; + } + + // ── Go repro tests ─────────────────────────────────────────────────────── + + fn make_confirmed_go_spec(spec_hash: &str) -> HarnessSpec { + HarnessSpec { + finding_id: "go_determ000001".into(), + entry_file: "tests/dynamic_fixtures/go/sqli_positive.go".into(), + entry_name: "Login".into(), + entry_kind: EntryKind::Function, + lang: Lang::Go, + toolchain_id: "go-1.21".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "tests/dynamic_fixtures/go/sqli_positive.go".into(), + sink_line: 12, + spec_hash: spec_hash.to_owned(), + } + } + + #[test] + fn go_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_go_spec("go_determ000001a"); + let opts = SandboxOptions::default(); + let outcome = make_confirmed_outcome(); + let verdict = make_confirmed_verdict("go_determ000001"); + let entry_src = "package entry\nfunc Login(username string) {}\n"; + + let artifact1 = repro::write( + &spec, &opts, &outcome, &verdict, + "// harness go\n", entry_src, + b"' UNION SELECT 'NYX_SQL_CONFIRMED'--", "sqli-union-nyx", None, + ).expect("first Go 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 go\n", entry_src, + b"' UNION SELECT 'NYX_SQL_CONFIRMED'--", "sqli-union-nyx", None, + ).expect("second Go repro write"); + let json2 = + std::fs::read_to_string(artifact2.root.join("expected/outcome.json")).unwrap(); + + assert_eq!(json1, json2, "Go outcome.json must be byte-identical across two writes"); + + unsafe { std::env::remove_var("NYX_REPRO_BASE") }; + } + + // ── Java repro tests ───────────────────────────────────────────────────── + + fn make_confirmed_java_spec(spec_hash: &str) -> HarnessSpec { + HarnessSpec { + finding_id: "java_determ00001".into(), + entry_file: "tests/dynamic_fixtures/java/sqli_positive.java".into(), + entry_name: "login".into(), + entry_kind: EntryKind::Function, + lang: Lang::Java, + toolchain_id: "java-21".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "tests/dynamic_fixtures/java/sqli_positive.java".into(), + sink_line: 9, + spec_hash: spec_hash.to_owned(), + } + } + + #[test] + fn java_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_java_spec("java_determ00001a"); + let opts = SandboxOptions::default(); + let outcome = make_confirmed_outcome(); + let verdict = make_confirmed_verdict("java_determ00001"); + let entry_src = "public class Entry { public static void login(String u) {} }\n"; + + let artifact1 = repro::write( + &spec, &opts, &outcome, &verdict, + "// NyxHarness.java\n", entry_src, + b"' UNION SELECT 'NYX_SQL_CONFIRMED'--", "sqli-union-nyx", None, + ).expect("first Java 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, + "// NyxHarness.java\n", entry_src, + b"' UNION SELECT 'NYX_SQL_CONFIRMED'--", "sqli-union-nyx", None, + ).expect("second Java repro write"); + let json2 = + std::fs::read_to_string(artifact2.root.join("expected/outcome.json")).unwrap(); + + assert_eq!(json1, json2, "Java outcome.json must be byte-identical across two writes"); + + unsafe { std::env::remove_var("NYX_REPRO_BASE") }; + } + + // ── PHP repro tests ────────────────────────────────────────────────────── + + fn make_confirmed_php_spec(spec_hash: &str) -> HarnessSpec { + HarnessSpec { + finding_id: "php_determ000001".into(), + entry_file: "tests/dynamic_fixtures/php/sqli_positive.php".into(), + entry_name: "login".into(), + entry_kind: EntryKind::Function, + lang: Lang::Php, + toolchain_id: "php-8".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "tests/dynamic_fixtures/php/sqli_positive.php".into(), + sink_line: 9, + spec_hash: spec_hash.to_owned(), + } + } + + #[test] + fn php_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_php_spec("php_determ000001a"); + let opts = SandboxOptions::default(); + let outcome = make_confirmed_outcome(); + let verdict = make_confirmed_verdict("php_determ000001"); + let entry_src = "