diff --git a/tests/open_redirect_corpus.rs b/tests/open_redirect_corpus.rs index 92c6f307..fb5eefe0 100644 --- a/tests/open_redirect_corpus.rs +++ b/tests/open_redirect_corpus.rs @@ -392,3 +392,203 @@ fn slug(lang: Lang) -> &'static str { _ => "other", } } + +// ── End-to-end Phase 09 acceptance via run_spec ─────────────────────────────── +// +// Mirrors the `e2e_phase_08` block in `header_injection_corpus.rs`. +// Drives `run_spec` directly on a `Cap::OPEN_REDIRECT` spec per +// language and asserts the polarity via the `ProbeKind::Redirect { +// location, request_host }` probe — the synthetic harness records +// the raw redirect target the host attempted, and the +// `RedirectHostNotIn` predicate fires when `location` resolves +// off-origin against the request's `request_host` allowlist. The +// synthetic harness inlines the entire redirect shim, so the +// verdict path is deterministic without binding the host's real +// servlet / flask / rack / express / gin / axum redirect entry. +// +// Per-lang skips mirror the Phase 08 e2e block: +// - Java: fixture imports `javax.servlet.http`, not on the JDK +// stdlib classpath; `javac` over `Vuln.java` errors before +// `NyxHarness.java` compiles. Skipped via the SKIP-on- +// BuildFailed branch in `run`. +// - Go: fixture declares `package vuln` against the synthetic +// harness's `package main`; `go build .` rejects the directory +// for mixing two packages. Skipped via the same branch. +// - Rust: fixture declares `use axum::response::Redirect;`, but the +// harness's `Cargo.toml` only depends on `libc`; the entry source +// lands at `src/entry.rs` and is ignored because the synthetic +// `src/main.rs` never `mod entry;`s it, so the build succeeds and +// the test does not skip — see the Phase 08 e2e note. + +mod e2e_phase_09 { + use crate::common::fixture_harness::FIXTURE_LOCK; + use nyx_scanner::dynamic::runner::{run_spec, RunError, RunOutcome}; + use nyx_scanner::dynamic::sandbox::{SandboxBackend, SandboxOptions}; + use nyx_scanner::dynamic::spec::{ + default_toolchain_id, EntryKind, HarnessSpec, PayloadSlot, SpecDerivationStrategy, + }; + use nyx_scanner::evidence::DifferentialVerdict; + use nyx_scanner::labels::Cap; + use nyx_scanner::symbol::Lang; + use std::path::PathBuf; + use std::process::Command; + use tempfile::TempDir; + + fn command_available(bin: &str) -> bool { + Command::new(bin) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn toolchain_for(lang: Lang) -> &'static str { + match lang { + Lang::Java => "java", + Lang::Python => "python3", + Lang::Php => "php", + Lang::Ruby => "ruby", + Lang::JavaScript => "node", + Lang::Go => "go", + Lang::Rust => "cargo", + _ => unreachable!("e2e_phase_09 covers J/P/Ph/R/JS/Go/Rust"), + } + } + + fn lang_subdir(lang: Lang) -> &'static str { + match lang { + Lang::Java => "java", + Lang::Python => "python", + Lang::Php => "php", + Lang::Ruby => "ruby", + Lang::JavaScript => "js", + Lang::Go => "go", + Lang::Rust => "rust", + _ => unreachable!(), + } + } + + fn build_spec(lang: Lang, fixture: &str, entry_name: &str) -> (HarnessSpec, TempDir) { + let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/open_redirect") + .join(lang_subdir(lang)) + .join(fixture); + let tmp = TempDir::new().expect("create tempdir"); + let dst = tmp.path().join(fixture); + std::fs::copy(&fixture_src, &dst).expect("copy fixture into tempdir"); + + let entry_file = dst.to_string_lossy().into_owned(); + let mut digest = blake3::Hasher::new(); + digest.update(b"phase09-e2e-open-redirect|"); + digest.update(lang_subdir(lang).as_bytes()); + digest.update(b"|"); + digest.update(fixture.as_bytes()); + let spec_hash = format!("{:016x}", { + let bytes = digest.finalize(); + u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap()) + }); + + if matches!(lang, Lang::Java) { + let workdir = std::path::PathBuf::from("/tmp/nyx-harness").join(&spec_hash); + let _ = std::fs::remove_dir_all(&workdir); + } + + let spec = HarnessSpec { + finding_id: spec_hash.clone(), + entry_file: entry_file.clone(), + entry_name: entry_name.to_owned(), + entry_kind: EntryKind::Function, + lang, + toolchain_id: default_toolchain_id(lang).into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::OPEN_REDIRECT, + constraint_hints: vec![], + sink_file: entry_file, + sink_line: 1, + spec_hash: spec_hash.clone(), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: None, + }; + + (spec, tmp) + } + + fn run(lang: Lang, fixture: &str, entry_name: &str) -> Option { + let bin = toolchain_for(lang); + if !command_available(bin) { + eprintln!("SKIP {lang:?} {fixture}: missing toolchain {bin}"); + return None; + } + let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (spec, _tmp) = build_spec(lang, fixture, entry_name); + let opts = SandboxOptions { + backend: SandboxBackend::Process, + ..SandboxOptions::default() + }; + match run_spec(&spec, &opts) { + Ok(outcome) => Some(outcome), + Err(RunError::BuildFailed { stderr, attempts }) => { + eprintln!( + "SKIP {lang:?} {fixture}: harness build failed after {attempts} attempts: {stderr}", + ); + None + } + Err(e) => panic!("run_spec({lang:?} {fixture}) errored: {e:?}"), + } + } + + fn assert_confirmed(lang: Lang, outcome: &RunOutcome) { + assert!( + outcome.triggered_by.is_some(), + "{lang:?} OPEN_REDIRECT vuln must Confirm via run_spec; got {outcome:?}", + ); + let diff = outcome + .differential + .as_ref() + .expect("Confirmed run must carry a DifferentialOutcome"); + assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + } + + #[test] + fn java_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Java, "Vuln.java", "run") else { return }; + assert_confirmed(Lang::Java, &outcome); + } + + #[test] + fn python_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Python, "vuln.py", "run") else { return }; + assert_confirmed(Lang::Python, &outcome); + } + + #[test] + fn php_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Php, "vuln.php", "run") else { return }; + assert_confirmed(Lang::Php, &outcome); + } + + #[test] + fn ruby_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Ruby, "vuln.rb", "run") else { return }; + assert_confirmed(Lang::Ruby, &outcome); + } + + #[test] + fn js_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::JavaScript, "vuln.js", "run") else { return }; + assert_confirmed(Lang::JavaScript, &outcome); + } + + #[test] + fn go_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Go, "vuln.go", "Run") else { return }; + assert_confirmed(Lang::Go, &outcome); + } + + #[test] + fn rust_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Rust, "vuln.rs", "run") else { return }; + assert_confirmed(Lang::Rust, &outcome); + } +} diff --git a/tests/prototype_pollution_corpus.rs b/tests/prototype_pollution_corpus.rs index edaa4ba0..f1cd1fa5 100644 --- a/tests/prototype_pollution_corpus.rs +++ b/tests/prototype_pollution_corpus.rs @@ -384,3 +384,150 @@ fn slug(lang: Lang) -> &'static str { _ => "other", } } + +// ── End-to-end Phase 10 acceptance via run_spec ─────────────────────────────── +// +// Mirrors the `e2e_phase_08` block in `header_injection_corpus.rs` +// and `e2e_phase_09` in `open_redirect_corpus.rs`. Drives +// `run_spec` directly on a `Cap::PROTOTYPE_POLLUTION` spec for +// JavaScript and TypeScript and asserts the polarity via the +// `ProbeKind::PrototypePollution { property, value }` probe — the +// synthetic JS-shared harness installs a canary trap on +// `Object.prototype` and the `PrototypeCanaryTouched` predicate +// fires when the deep-merge walks the payload's `__proto__` key +// into the prototype chain. +// +// Per-lang skips mirror the Phase 08 e2e block: +// - TypeScript: the synthetic harness short-circuits the entry +// source load entirely (`entry_subpath: None`), so no `tsx` / +// `ts-node` is needed at runtime — but on hosts without +// `tree_sitter_typescript` or the npm Node toolchain, the +// harness build will fall through `BuildFailed` and skip via the +// same branch. + +mod e2e_phase_10 { + use crate::common::fixture_harness::FIXTURE_LOCK; + use nyx_scanner::dynamic::runner::{run_spec, RunError, RunOutcome}; + use nyx_scanner::dynamic::sandbox::{SandboxBackend, SandboxOptions}; + use nyx_scanner::dynamic::spec::{ + default_toolchain_id, EntryKind, HarnessSpec, PayloadSlot, SpecDerivationStrategy, + }; + use nyx_scanner::evidence::DifferentialVerdict; + use nyx_scanner::labels::Cap; + use nyx_scanner::symbol::Lang; + use std::path::PathBuf; + use std::process::Command; + use tempfile::TempDir; + + fn command_available(bin: &str) -> bool { + Command::new(bin) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn toolchain_for(lang: Lang) -> &'static str { + match lang { + Lang::JavaScript | Lang::TypeScript => "node", + _ => unreachable!("e2e_phase_10 covers JS/TS"), + } + } + + fn lang_subdir(lang: Lang) -> &'static str { + match lang { + Lang::JavaScript => "javascript", + Lang::TypeScript => "typescript", + _ => unreachable!(), + } + } + + fn build_spec(lang: Lang, fixture: &str, entry_name: &str) -> (HarnessSpec, TempDir) { + let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/prototype_pollution") + .join(lang_subdir(lang)) + .join(fixture); + let tmp = TempDir::new().expect("create tempdir"); + let dst = tmp.path().join(fixture); + std::fs::copy(&fixture_src, &dst).expect("copy fixture into tempdir"); + + let entry_file = dst.to_string_lossy().into_owned(); + let mut digest = blake3::Hasher::new(); + digest.update(b"phase10-e2e-prototype-pollution|"); + digest.update(lang_subdir(lang).as_bytes()); + digest.update(b"|"); + digest.update(fixture.as_bytes()); + let spec_hash = format!("{:016x}", { + let bytes = digest.finalize(); + u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap()) + }); + + let spec = HarnessSpec { + finding_id: spec_hash.clone(), + entry_file: entry_file.clone(), + entry_name: entry_name.to_owned(), + entry_kind: EntryKind::Function, + lang, + toolchain_id: default_toolchain_id(lang).into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::PROTOTYPE_POLLUTION, + constraint_hints: vec![], + sink_file: entry_file, + sink_line: 1, + spec_hash: spec_hash.clone(), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: None, + }; + + (spec, tmp) + } + + fn run(lang: Lang, fixture: &str, entry_name: &str) -> Option { + let bin = toolchain_for(lang); + if !command_available(bin) { + eprintln!("SKIP {lang:?} {fixture}: missing toolchain {bin}"); + return None; + } + let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (spec, _tmp) = build_spec(lang, fixture, entry_name); + let opts = SandboxOptions { + backend: SandboxBackend::Process, + ..SandboxOptions::default() + }; + match run_spec(&spec, &opts) { + Ok(outcome) => Some(outcome), + Err(RunError::BuildFailed { stderr, attempts }) => { + eprintln!( + "SKIP {lang:?} {fixture}: harness build failed after {attempts} attempts: {stderr}", + ); + None + } + Err(e) => panic!("run_spec({lang:?} {fixture}) errored: {e:?}"), + } + } + + fn assert_confirmed(lang: Lang, outcome: &RunOutcome) { + assert!( + outcome.triggered_by.is_some(), + "{lang:?} PROTOTYPE_POLLUTION vuln must Confirm via run_spec; got {outcome:?}", + ); + let diff = outcome + .differential + .as_ref() + .expect("Confirmed run must carry a DifferentialOutcome"); + assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + } + + #[test] + fn js_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::JavaScript, "vuln.js", "run") else { return }; + assert_confirmed(Lang::JavaScript, &outcome); + } + + #[test] + fn ts_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::TypeScript, "vuln.ts", "run") else { return }; + assert_confirmed(Lang::TypeScript, &outcome); + } +}