//! Phase 10 (Track D.3) — per-(lang, cap) stub end-to-end tests. //! //! These tests spin up a real boundary stub, splice the per-language //! probe shim (which now carries the cap-specific //! `__nyx_stub_*_record` helpers) ahead of a fixture's source, run the //! resulting program with the stub's endpoint + recording-path env //! vars set, then assert the stub captured the boundary event. //! //! Unlike `tests/stubs_per_cap.rs` (which synthesises harness //! behaviour with host-side `SqlStub::record_query` calls), this suite //! drives a real interpreter subprocess so the per-language shim //! contract is exercised end-to-end. When the host is missing the //! interpreter the test eprintln-skips, matching every other lang //! fixture suite in-tree. //! //! Acceptance bullet from `.pitboss/play/deferred.md` Phase 10 //! follow-up: the Python+SQL pair is the cheapest first bite — //! `sqlite3` is stdlib so no new toolchain dependency is required for //! the dynamic CI matrix. #![cfg(feature = "dynamic")] use nyx_scanner::dynamic::lang::go::probe_shim as go_probe_shim; use nyx_scanner::dynamic::lang::java::probe_shim as java_probe_shim; use nyx_scanner::dynamic::lang::javascript::probe_shim as node_probe_shim; use nyx_scanner::dynamic::lang::php::probe_shim as php_probe_shim; use nyx_scanner::dynamic::lang::python::probe_shim as python_probe_shim; use nyx_scanner::dynamic::lang::ruby::probe_shim as ruby_probe_shim; use nyx_scanner::dynamic::lang::rust::probe_shim as rust_probe_shim; use nyx_scanner::dynamic::stubs::{HttpStub, SqlStub, StubProvider}; use std::path::PathBuf; use std::process::Command; use tempfile::TempDir; fn python3_available() -> bool { Command::new("python3") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false) } fn node_available() -> bool { Command::new("node") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false) } fn php_available() -> bool { Command::new("php") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false) } fn go_available() -> bool { Command::new("go") .arg("version") .output() .map(|o| o.status.success()) .unwrap_or(false) } fn ruby_available() -> bool { Command::new("ruby") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false) } fn cargo_available() -> bool { Command::new("cargo") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false) } fn java_available() -> bool { // The Java shim helpers use `java MainSource.java` single-file // source-mode (JEP 330, JDK 11+) so only the `java` runtime is // strictly required. An older `java` binary that does not support // source-mode is treated as missing and the test eprintln-skips. Command::new("java") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false) } /// Wrap the body-only Java HTTP fixture in a complete `public class Main` /// source: splice the Java probe shim as class members ahead of /// `public static void main`, then put the fragment in the method body. /// Mirrors the production [`JavaEmitter::emit`] ordering — the shim is /// declared first so any sink rewrite in the body has the shim helpers /// in scope. The throws clause lets the fragment use checked-exception /// stdlib calls without per-line try/catch. fn wrap_java_fragment(body: &str, shim: &str) -> String { format!( "public class Main {{\n\ {shim}\n\ \n\ public static void main(String[] args) throws Exception {{\n\ {body}\n\ }}\n\ }}\n" ) } /// Wrap the body-only Go HTTP fixture in a complete `package main` /// program: stdlib imports needed by the spliced probe shim plus the /// fragment's own `fmt` / `os` references, the shim itself, and the /// fragment as the body of `func main`. Comments inside the body /// remain valid Go. fn wrap_go_fragment(body: &str, shim: &str) -> String { format!( "package main\n\ \n\ import (\n\ \t\"encoding/json\"\n\ \t\"fmt\"\n\ \t\"os\"\n\ \t\"os/signal\"\n\ \t\"strings\"\n\ \t\"syscall\"\n\ \t\"time\"\n\ )\n\ {shim}\n\ func main() {{\n\ {body}\n\ }}\n" ) } /// Wrap the body-only Rust HTTP fragment in a complete crate: prepend /// the Rust probe shim (which carries `__nyx_stub_http_record`) at /// file scope and wrap the fragment as the body of `fn main()`. The /// caller writes the result alongside a one-line `Cargo.toml` that /// pins `libc = "0.2"` (the shim's `__nyx_install_crash_guard` path /// references `libc::sigaction`) and drives the build through /// `cargo run --quiet`. Mirrors the production Rust emitter ordering /// — shim at file scope, then `fn main()` calling into it. fn wrap_rust_fragment(body: &str, shim: &str) -> String { format!( "{shim}\n\ fn main() {{\n\ {body}\n\ }}\n" ) } /// One-line Cargo.toml for the Rust stub-recorder driver. Mirrors /// the Phase 26 chain_step manifest (session 0014) — `[[bin]]` points /// at `main.rs` so `cargo run --quiet` builds the source the test /// just wrote, and `libc = "0.2"` is unconditionally pinned because /// the spliced probe shim's `__nyx_install_crash_guard` references /// `libc::sigaction` on Unix. const RUST_STUB_CARGO_TOML: &str = "[package]\n\ name = \"nyx-stub-driver\"\n\ version = \"0.0.1\"\n\ edition = \"2021\"\n\n\ [[bin]]\n\ name = \"stub_driver\"\n\ path = \"main.rs\"\n\n\ [dependencies]\n\ libc = \"0.2\"\n"; fn fixture_path(rel: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("dynamic_fixtures") .join("stubs_e2e") .join(rel) } #[test] fn python_sql_stub_captures_tautology_query_via_shim_recorder() { if !python3_available() { eprintln!("SKIP: python3 not available"); return; } let workdir = TempDir::new().expect("tempdir"); let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); // The verifier publishes the SQLite DB path on `NYX_SQL_ENDPOINT` // (primary) and the queries-log path on `NYX_SQL_LOG` (companion). let endpoint = stub.endpoint(); let recording = stub .recording_endpoint() .expect("SqlStub must publish a recording endpoint"); // Splice the probe shim ahead of the fixture source so the // generated program carries the `__nyx_stub_sql_record` helper. // Mirrors the production `PythonEmitter::emit` ordering. let fixture = std::fs::read_to_string(fixture_path("python/sql/vuln/main.py")).expect("read fixture"); let mut combined = String::with_capacity(python_probe_shim().len() + fixture.len() + 64); combined.push_str(python_probe_shim()); combined.push_str("\n# ── fixture begins ─\n"); combined.push_str(&fixture); let script_path = workdir.path().join("driver.py"); std::fs::write(&script_path, combined).expect("write driver"); let output = Command::new("python3") .arg(&script_path) .env("NYX_SQL_ENDPOINT", &endpoint) .env(recording.0, &recording.1) .output() .expect("python3 driver"); assert!( output.status.success(), "driver must exit 0; stderr = {}", String::from_utf8_lossy(&output.stderr) ); let events = stub.drain_events(); assert!( !events.is_empty(), "SqlStub must capture at least one event after the shim recorder fires" ); let tautology = events .iter() .find(|e| e.summary.contains("OR 1=1")) .expect("recorded query must contain the tautology marker"); assert_eq!( tautology.detail.get("driver").map(String::as_str), Some("sqlite3"), "kwargs passed to __nyx_stub_sql_record must surface as event detail entries" ); } #[test] fn python_sql_shim_recorder_is_noop_without_log_env() { if !python3_available() { eprintln!("SKIP: python3 not available"); return; } let workdir = TempDir::new().expect("tempdir"); let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); // Drive the same fixture but withhold NYX_SQL_LOG. The shim // helper must be a no-op so the same source still runs cleanly // under harness modes that didn't spawn a stub. let endpoint = stub.endpoint(); let fixture = std::fs::read_to_string(fixture_path("python/sql/vuln/main.py")).expect("read fixture"); let mut combined = String::new(); combined.push_str(python_probe_shim()); combined.push('\n'); combined.push_str(&fixture); let script_path = workdir.path().join("driver_no_log.py"); std::fs::write(&script_path, combined).expect("write driver"); let output = Command::new("python3") .arg(&script_path) .env("NYX_SQL_ENDPOINT", &endpoint) .env_remove("NYX_SQL_LOG") .output() .expect("python3 driver"); assert!( output.status.success(), "driver must exit 0 even without NYX_SQL_LOG; stderr = {}", String::from_utf8_lossy(&output.stderr) ); let events = stub.drain_events(); assert!( events.is_empty(), "no events expected when the recording env var is unset, got {} entries", events.len() ); } #[test] fn node_sql_stub_captures_tautology_query_via_shim_recorder() { if !node_available() { eprintln!("SKIP: node not available"); return; } let workdir = TempDir::new().expect("tempdir"); let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); let endpoint = stub.endpoint(); let recording = stub .recording_endpoint() .expect("SqlStub must publish a recording endpoint"); // Splice the Node probe shim ahead of the fixture source so the // generated program carries the `__nyx_stub_sql_record` helper. // Mirrors the production `JavaScriptEmitter::emit` ordering. let fixture = std::fs::read_to_string(fixture_path("node/sql/vuln/main.js")).expect("read fixture"); let mut combined = String::with_capacity(node_probe_shim().len() + fixture.len() + 64); combined.push_str(node_probe_shim()); combined.push_str("\n// ── fixture begins ─\n"); combined.push_str(&fixture); let script_path = workdir.path().join("driver.js"); std::fs::write(&script_path, combined).expect("write driver"); let output = Command::new("node") .arg(&script_path) .env("NYX_SQL_ENDPOINT", &endpoint) .env(recording.0, &recording.1) .output() .expect("node driver"); assert!( output.status.success(), "driver must exit 0; stderr = {}", String::from_utf8_lossy(&output.stderr) ); let events = stub.drain_events(); assert!( !events.is_empty(), "SqlStub must capture at least one event after the Node shim recorder fires" ); let tautology = events .iter() .find(|e| e.summary.contains("OR 1=1")) .expect("recorded query must contain the tautology marker"); let driver = tautology .detail .get("driver") .map(String::as_str) .expect("Node shim must publish driver detail on the recorded event"); assert!( driver == "node:sqlite" || driver == "none", "driver detail must report node:sqlite when available or `none` when the stdlib module is missing; got {driver:?}" ); } fn strip_php_open_tag(src: &str) -> &str { src.strip_prefix(" PathBuf { PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("stubs_e2e_rust") } #[test] fn rust_http_stub_captures_attempted_outbound_via_shim_recorder() { // Phase 10 (Track D.3) HTTP recording: Rust leg of the side-channel // `__nyx_stub_http_record` helper. Mirrors the Python / Node / PHP / // Go / Ruby / Java HTTP tests — records an SSRF attempt without // issuing the actual network call. Uses the `extra_files`-driven // `Cargo.toml` shape session 0014 prototyped for chain steps: write // a one-line manifest alongside the wrapped fragment so `cargo run // --quiet` resolves `libc` (referenced by the spliced probe shim's // `__nyx_install_crash_guard`) without any host crate-cache assumptions. if !cargo_available() { eprintln!("SKIP: cargo not available"); return; } let workdir = TempDir::new().expect("tempdir"); let stub = HttpStub::start(workdir.path()).expect("HttpStub::start"); let endpoint = stub.endpoint(); let recording = stub .recording_endpoint() .expect("HttpStub must publish a recording endpoint"); let fragment = std::fs::read_to_string(fixture_path("rust/http/vuln/main.rs")) .expect("read rust fragment"); let source = wrap_rust_fragment(&fragment, rust_probe_shim()); let crate_dir = workdir.path().join("driver"); std::fs::create_dir_all(&crate_dir).expect("create crate dir"); std::fs::write(crate_dir.join("Cargo.toml"), RUST_STUB_CARGO_TOML) .expect("write Cargo.toml"); std::fs::write(crate_dir.join("main.rs"), source).expect("write main.rs"); let output = Command::new("cargo") .arg("run") .arg("--quiet") .arg("--manifest-path") .arg(crate_dir.join("Cargo.toml")) .env("CARGO_TARGET_DIR", rust_stub_target_dir()) .env("NYX_HTTP_ENDPOINT", &endpoint) .env(recording.0, &recording.1) .output() .expect("cargo run rust driver"); assert!( output.status.success(), "driver must exit 0; stdout = {}\nstderr = {}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let events = stub.drain_events(); assert!( !events.is_empty(), "HttpStub must capture at least one event after the Rust shim recorder fires" ); let hit = events .iter() .find(|e| e.summary.contains("169.254.169.254")) .expect("recorded URL must contain the SSRF marker"); assert_eq!( hit.detail.get("method").map(String::as_str), Some("GET"), "method detail must surface on the recorded event" ); assert_eq!( hit.detail.get("url").map(String::as_str), Some("http://169.254.169.254/latest/meta-data/"), ); assert_eq!( hit.detail.get("driver").map(String::as_str), Some("manual"), "detail slice passed to __nyx_stub_http_record must surface as event detail entries" ); } #[test] fn rust_http_shim_recorder_is_noop_without_log_env() { if !cargo_available() { eprintln!("SKIP: cargo not available"); return; } let workdir = TempDir::new().expect("tempdir"); let stub = HttpStub::start(workdir.path()).expect("HttpStub::start"); let endpoint = stub.endpoint(); let fragment = std::fs::read_to_string(fixture_path("rust/http/vuln/main.rs")) .expect("read rust fragment"); let source = wrap_rust_fragment(&fragment, rust_probe_shim()); let crate_dir = workdir.path().join("driver_no_log"); std::fs::create_dir_all(&crate_dir).expect("create crate dir"); std::fs::write(crate_dir.join("Cargo.toml"), RUST_STUB_CARGO_TOML) .expect("write Cargo.toml"); std::fs::write(crate_dir.join("main.rs"), source).expect("write main.rs"); let output = Command::new("cargo") .arg("run") .arg("--quiet") .arg("--manifest-path") .arg(crate_dir.join("Cargo.toml")) .env("CARGO_TARGET_DIR", rust_stub_target_dir()) .env("NYX_HTTP_ENDPOINT", &endpoint) .env_remove("NYX_HTTP_LOG") .output() .expect("cargo run rust driver"); assert!( output.status.success(), "driver must exit 0 even without NYX_HTTP_LOG; stdout = {}\nstderr = {}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let events = stub.drain_events(); assert!( events.is_empty(), "no events expected when the recording env var is unset, got {} entries", events.len() ); }