diff --git a/tests/common/fixture_harness.rs b/tests/common/fixture_harness.rs index a24d3198..6bf18df7 100644 --- a/tests/common/fixture_harness.rs +++ b/tests/common/fixture_harness.rs @@ -562,6 +562,86 @@ pub fn run_shape_fixture_lang( } } +/// Phase 29 (Track I) — `run_shape_fixture_lang` with structured +/// prerequisite gating. +/// +/// Checks `requires` against the host before staging the fixture; when +/// a prerequisite is unmet, eprintln-skips with a [`SkipReason`] (so +/// `cargo nextest` surfaces the line in test output) and returns +/// `None`. Callers migrate from the bespoke +/// `python3_available()` / `go_available()` / etc. helpers + per-test +/// `eprintln!("SKIP ...") ; return;` blocks to a single +/// `let Some(r) = run_shape_fixture_lang_or_skip(...) else { return; };` +/// at the call site. +#[allow(clippy::too_many_arguments)] +#[allow(dead_code)] +pub fn run_shape_fixture_lang_or_skip( + requires: &[Prerequisite], + lang: nyx_scanner::symbol::Lang, + lang_dir: &str, + shape_dir: &str, + file: &str, + func: &str, + cap: Cap, + sink_line: u32, + entry_kind: EntryKind, + payload_slot: nyx_scanner::dynamic::spec::PayloadSlot, +) -> Option { + if let Err(reason) = check_prerequisites(requires) { + eprintln!("SKIP {lang_dir}/{shape_dir}/{file}: {reason}"); + return None; + } + Some(run_shape_fixture_lang( + lang, + lang_dir, + shape_dir, + file, + func, + cap, + sink_line, + entry_kind, + payload_slot, + )) +} + +/// Phase 29 (Track I) — `run_harness_snapshot_lang` with structured +/// prerequisite gating. Returns `false` and eprintln-skips when a +/// prerequisite is unmet; otherwise runs the snapshot to completion +/// and returns `true`. +#[allow(clippy::too_many_arguments)] +#[allow(dead_code)] +pub fn run_harness_snapshot_lang_or_skip( + requires: &[Prerequisite], + lang: nyx_scanner::symbol::Lang, + lang_dir: &str, + snapshot_ext: &str, + shape_dir: &str, + file: &str, + func: &str, + cap: Cap, + sink_line: u32, + entry_kind: EntryKind, + payload_slot: nyx_scanner::dynamic::spec::PayloadSlot, +) -> bool { + if let Err(reason) = check_prerequisites(requires) { + eprintln!("SKIP {lang_dir}/{shape_dir}/{file}: {reason}"); + return false; + } + run_harness_snapshot_lang( + lang, + lang_dir, + snapshot_ext, + shape_dir, + file, + func, + cap, + sink_line, + entry_kind, + payload_slot, + ); + true +} + /// Phase 12 — Python-specific harness snapshot wrapper. /// /// Pins lang to [`Lang::Python`] and the lang dir to `python` so legacy diff --git a/tests/dynamic_fixtures/stubs_e2e/node/http/vuln/main.js b/tests/dynamic_fixtures/stubs_e2e/node/http/vuln/main.js new file mode 100644 index 00000000..4c8024a4 --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/node/http/vuln/main.js @@ -0,0 +1,31 @@ +// Phase 10 (Track D.3) stub-end-to-end fixture: Node + HTTP. +// +// The verifier publishes: +// +// * NYX_HTTP_ENDPOINT - http://127.0.0.1:{port} the HttpStub listens on. +// * NYX_HTTP_LOG - companion log path the harness appends attempted +// outbound calls to so the host HttpStub picks them +// up on drain_events() even when the request bypasses +// the on-the-wire listener (DNS-mocked, +// network-isolated sandbox, pre-flight check). +// +// This fixture exercises the side-channel path: it records an attempted +// SSRF call to http://169.254.169.254/latest/meta-data/ through the Node +// shim helper __nyx_stub_http_record without issuing the actual network +// call. The companion test in tests/stubs_e2e_per_lang.rs splices in +// crate::dynamic::lang::javascript::probe_shim ahead of this source, runs +// it with both env vars set, and asserts the stub captured the attempt. + +function main() { + const method = 'GET'; + const url = 'http://169.254.169.254/latest/meta-data/'; + const body = ''; + // Record the attempted call through the probe shim so the host + // HttpStub captures it on the next drain_events() call even when the + // harness never reaches the on-the-wire listener. + __nyx_stub_http_record(method, url, body, { driver: 'node:http' }); + // Echo so the host can confirm the driver ran end-to-end. + console.log(process.env.NYX_HTTP_ENDPOINT || 'no-endpoint'); +} + +main(); diff --git a/tests/dynamic_fixtures/stubs_e2e/php/http/vuln/main.php b/tests/dynamic_fixtures/stubs_e2e/php/http/vuln/main.php new file mode 100644 index 00000000..06b5f271 --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/php/http/vuln/main.php @@ -0,0 +1,35 @@ + 'curl']); + // Echo so the host can confirm the driver ran end-to-end. + $endpoint = getenv('NYX_HTTP_ENDPOINT'); + echo ($endpoint === false || $endpoint === '') ? 'no-endpoint' : $endpoint; + echo "\n"; +} + +nyx_e2e_main(); diff --git a/tests/ruby_fixtures.rs b/tests/ruby_fixtures.rs index 3dda9a5b..93c94a43 100644 --- a/tests/ruby_fixtures.rs +++ b/tests/ruby_fixtures.rs @@ -13,20 +13,12 @@ mod common; #[cfg(feature = "dynamic")] mod phase15_shape_tests { - use crate::common::fixture_harness::run_shape_fixture_lang; + use crate::common::fixture_harness::{run_shape_fixture_lang_or_skip, Prerequisite}; use nyx_scanner::dynamic::spec::PayloadSlot; use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus}; use nyx_scanner::labels::Cap; use nyx_scanner::symbol::Lang; - fn ruby_available() -> bool { - std::process::Command::new("ruby") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } - fn assert_confirmed(shape: &str, result: &VerifyResult) { assert_eq!( result.status, @@ -62,9 +54,21 @@ mod phase15_shape_tests { sink_line: u32, kind: EntryKind, slot: PayloadSlot, - ) -> VerifyResult { - run_shape_fixture_lang( - Lang::Ruby, "ruby", shape, file, func, cap, sink_line, kind, slot, + ) -> Option { + // Phase 29 (Track I): structured prerequisite gating replaces + // the bespoke `ruby_available()` + per-test + // `eprintln!("SKIP ..."); return;` pattern. + run_shape_fixture_lang_or_skip( + &[Prerequisite::CommandAvailable("ruby")], + Lang::Ruby, + "ruby", + shape, + file, + func, + cap, + sink_line, + kind, + slot, ) } @@ -72,27 +76,23 @@ mod phase15_shape_tests { #[test] fn sinatra_route_vuln_is_confirmed() { - if !ruby_available() { - eprintln!("SKIP: ruby not available"); - return; - } - let r = run( + let Some(r) = run( "sinatra_route", "vuln.rb", "run", Cap::CODE_EXEC, 7, EntryKind::HttpRoute, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_confirmed("sinatra_route", &r); } #[test] fn sinatra_route_benign_not_confirmed() { - if !ruby_available() { - eprintln!("SKIP: ruby not available"); - return; - } - let r = run( + let Some(r) = run( "sinatra_route", "benign.rb", "run", Cap::CODE_EXEC, 10, EntryKind::HttpRoute, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_not_confirmed("sinatra_route", &r); } @@ -100,27 +100,23 @@ mod phase15_shape_tests { #[test] fn rails_action_vuln_is_confirmed() { - if !ruby_available() { - eprintln!("SKIP: ruby not available"); - return; - } - let r = run( + let Some(r) = run( "rails_action", "vuln.rb", "index", Cap::CODE_EXEC, 17, EntryKind::HttpRoute, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), - ); + ) else { + return; + }; assert_confirmed("rails_action", &r); } #[test] fn rails_action_benign_not_confirmed() { - if !ruby_available() { - eprintln!("SKIP: ruby not available"); - return; - } - let r = run( + let Some(r) = run( "rails_action", "benign.rb", "index", Cap::CODE_EXEC, 20, EntryKind::HttpRoute, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), - ); + ) else { + return; + }; assert_not_confirmed("rails_action", &r); } @@ -128,27 +124,23 @@ mod phase15_shape_tests { #[test] fn rack_middleware_vuln_is_confirmed() { - if !ruby_available() { - eprintln!("SKIP: ruby not available"); - return; - } - let r = run( + let Some(r) = run( "rack_middleware", "vuln.rb", "call", Cap::CODE_EXEC, 9, EntryKind::HttpRoute, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), - ); + ) else { + return; + }; assert_confirmed("rack_middleware", &r); } #[test] fn rack_middleware_benign_not_confirmed() { - if !ruby_available() { - eprintln!("SKIP: ruby not available"); - return; - } - let r = run( + let Some(r) = run( "rack_middleware", "benign.rb", "call", Cap::CODE_EXEC, 11, EntryKind::HttpRoute, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), - ); + ) else { + return; + }; assert_not_confirmed("rack_middleware", &r); } @@ -156,27 +148,23 @@ mod phase15_shape_tests { #[test] fn controller_method_vuln_is_confirmed() { - if !ruby_available() { - eprintln!("SKIP: ruby not available"); - return; - } - let r = run( + let Some(r) = run( "controller_method", "vuln.rb", "authenticate", Cap::CODE_EXEC, 7, EntryKind::Function, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_confirmed("controller_method", &r); } #[test] fn controller_method_benign_not_confirmed() { - if !ruby_available() { - eprintln!("SKIP: ruby not available"); - return; - } - let r = run( + let Some(r) = run( "controller_method", "benign.rb", "authenticate", Cap::CODE_EXEC, 10, EntryKind::Function, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_not_confirmed("controller_method", &r); } } diff --git a/tests/rust_fixtures.rs b/tests/rust_fixtures.rs index cddbd9da..7e39de51 100644 --- a/tests/rust_fixtures.rs +++ b/tests/rust_fixtures.rs @@ -290,20 +290,12 @@ mod rust_fixture_tests { #[cfg(feature = "dynamic")] mod phase16_shape_tests { - use crate::common::fixture_harness::run_shape_fixture_lang; + use crate::common::fixture_harness::{run_shape_fixture_lang_or_skip, Prerequisite}; use nyx_scanner::dynamic::spec::PayloadSlot; use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus}; use nyx_scanner::labels::Cap; use nyx_scanner::symbol::Lang; - fn rust_available() -> bool { - std::process::Command::new("cargo") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } - fn assert_confirmed(shape: &str, result: &VerifyResult) { assert_eq!( result.status, @@ -339,9 +331,24 @@ mod phase16_shape_tests { sink_line: u32, kind: EntryKind, slot: PayloadSlot, - ) -> VerifyResult { - run_shape_fixture_lang( - Lang::Rust, "rust", shape, file, func, cap, sink_line, kind, slot, + ) -> Option { + // Phase 29 (Track I): replace the bespoke `rust_available()` + + // per-test `eprintln!("SKIP ..."); return;` blocks with the + // structured `Prerequisite::CommandAvailable("cargo")` gate. + // The helper emits the same SKIP line and returns `None` so + // each test can short-circuit via `let Some(r) = run(...) else + // { return; };`. + run_shape_fixture_lang_or_skip( + &[Prerequisite::CommandAvailable("cargo")], + Lang::Rust, + "rust", + shape, + file, + func, + cap, + sink_line, + kind, + slot, ) } @@ -349,27 +356,23 @@ mod phase16_shape_tests { #[test] fn actix_route_vuln_is_confirmed() { - if !rust_available() { - eprintln!("SKIP: cargo not available"); - return; - } - let r = run( + let Some(r) = run( "actix_route", "vuln.rs", "handler", Cap::CODE_EXEC, 16, EntryKind::HttpRoute, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_confirmed("actix_route", &r); } #[test] fn actix_route_benign_not_confirmed() { - if !rust_available() { - eprintln!("SKIP: cargo not available"); - return; - } - let r = run( + let Some(r) = run( "actix_route", "benign.rs", "handler", Cap::CODE_EXEC, 14, EntryKind::HttpRoute, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_not_confirmed("actix_route", &r); } @@ -377,27 +380,23 @@ mod phase16_shape_tests { #[test] fn axum_handler_vuln_is_confirmed() { - if !rust_available() { - eprintln!("SKIP: cargo not available"); - return; - } - let r = run( + let Some(r) = run( "axum_handler", "vuln.rs", "handler", Cap::CODE_EXEC, 15, EntryKind::HttpRoute, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_confirmed("axum_handler", &r); } #[test] fn axum_handler_benign_not_confirmed() { - if !rust_available() { - eprintln!("SKIP: cargo not available"); - return; - } - let r = run( + let Some(r) = run( "axum_handler", "benign.rs", "handler", Cap::CODE_EXEC, 13, EntryKind::HttpRoute, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_not_confirmed("axum_handler", &r); } @@ -405,27 +404,23 @@ mod phase16_shape_tests { #[test] fn clap_cli_vuln_is_confirmed() { - if !rust_available() { - eprintln!("SKIP: cargo not available"); - return; - } - let r = run( + let Some(r) = run( "clap_cli", "vuln.rs", "run", Cap::CODE_EXEC, 17, EntryKind::CliSubcommand, PayloadSlot::Argv(0), - ); + ) else { + return; + }; assert_confirmed("clap_cli", &r); } #[test] fn clap_cli_benign_not_confirmed() { - if !rust_available() { - eprintln!("SKIP: cargo not available"); - return; - } - let r = run( + let Some(r) = run( "clap_cli", "benign.rs", "run", Cap::CODE_EXEC, 13, EntryKind::CliSubcommand, PayloadSlot::Argv(0), - ); + ) else { + return; + }; assert_not_confirmed("clap_cli", &r); } @@ -433,27 +428,23 @@ mod phase16_shape_tests { #[test] fn libfuzzer_target_vuln_is_confirmed() { - if !rust_available() { - eprintln!("SKIP: cargo not available"); - return; - } - let r = run( + let Some(r) = run( "libfuzzer_target", "vuln.rs", "fuzz_target", Cap::CODE_EXEC, 15, EntryKind::LibraryApi, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_confirmed("libfuzzer_target", &r); } #[test] fn libfuzzer_target_benign_not_confirmed() { - if !rust_available() { - eprintln!("SKIP: cargo not available"); - return; - } - let r = run( + let Some(r) = run( "libfuzzer_target", "benign.rs", "fuzz_target", Cap::CODE_EXEC, 13, EntryKind::LibraryApi, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_not_confirmed("libfuzzer_target", &r); } } diff --git a/tests/stubs_e2e_per_lang.rs b/tests/stubs_e2e_per_lang.rs index 94728005..8aaa7859 100644 --- a/tests/stubs_e2e_per_lang.rs +++ b/tests/stubs_e2e_per_lang.rs @@ -441,6 +441,220 @@ fn python_http_shim_recorder_is_noop_without_log_env() { ); } +#[test] +fn node_http_stub_captures_attempted_outbound_via_shim_recorder() { + // Phase 10 (Track D.3) HTTP recording: Node leg of the side-channel + // `__nyx_stub_http_record` helper. Mirrors the Python HTTP test — + // records an SSRF attempt without issuing the actual network call. + if !node_available() { + eprintln!("SKIP: node 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 fixture = + std::fs::read_to_string(fixture_path("node/http/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_http.js"); + std::fs::write(&script_path, combined).expect("write driver"); + + let output = Command::new("node") + .arg(&script_path) + .env("NYX_HTTP_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(), + "HttpStub must capture at least one event after the Node 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("node:http"), + "kwargs passed to __nyx_stub_http_record must surface as event detail entries" + ); +} + +#[test] +fn node_http_shim_recorder_is_noop_without_log_env() { + if !node_available() { + eprintln!("SKIP: node not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = HttpStub::start(workdir.path()).expect("HttpStub::start"); + + let endpoint = stub.endpoint(); + let fixture = + std::fs::read_to_string(fixture_path("node/http/vuln/main.js")).expect("read fixture"); + let mut combined = String::new(); + combined.push_str(node_probe_shim()); + combined.push('\n'); + combined.push_str(&fixture); + let script_path = workdir.path().join("driver_http_no_log.js"); + std::fs::write(&script_path, combined).expect("write driver"); + + let output = Command::new("node") + .arg(&script_path) + .env("NYX_HTTP_ENDPOINT", &endpoint) + .env_remove("NYX_HTTP_LOG") + .output() + .expect("node driver"); + assert!( + output.status.success(), + "driver must exit 0 even without NYX_HTTP_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 php_http_stub_captures_attempted_outbound_via_shim_recorder() { + // Phase 10 (Track D.3) HTTP recording: PHP leg of the side-channel + // `__nyx_stub_http_record` helper. Mirrors the Python HTTP test — + // records an SSRF attempt without issuing the actual network call. + if !php_available() { + eprintln!("SKIP: php 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 fixture = + std::fs::read_to_string(fixture_path("php/http/vuln/main.php")).expect("read fixture"); + let body = strip_php_open_tag(&fixture); + let mut combined = String::with_capacity(php_probe_shim().len() + body.len() + 64); + combined.push_str("