From 608929194d108a0be1a7e75f0c506ac3af4e427b Mon Sep 17 00:00:00 2001 From: pitboss Date: Sat, 16 May 2026 09:25:31 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0016 (20260516T052512Z-20f8) --- src/dynamic/lang/go.rs | 48 +++++- .../stubs_e2e/go/http/vuln/main.go | 27 ++++ tests/go_fixtures.rs | 101 ++++++------- tests/php_fixtures.rs | 81 +++++----- tests/stubs_e2e_per_lang.rs | 138 ++++++++++++++++++ 5 files changed, 287 insertions(+), 108 deletions(-) create mode 100644 tests/dynamic_fixtures/stubs_e2e/go/http/vuln/main.go diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 7d0e2f17..6e0d1800 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -273,7 +273,7 @@ fn is_go_stdlib(path: &str) -> bool { /// Track C.1). Variadic over `string` so callers can pass any number of /// captured args at the sink site. pub fn probe_shim() -> &'static str { - r#" + r##" // ── __nyx_probe shim (Phase 06 — Track C.1, Phase 08 — Track C.4 + C.5) ────── var __nyx_deny_substrings = []string{ "TOKEN","SECRET","PASSWORD","PASSWD","API_KEY","APIKEY","PRIVATE_KEY", @@ -402,7 +402,38 @@ func __nyx_recover_crash(sinkCallee string) func() { } } } -"# + +// Phase 10 (Track D.3) HTTP recording helper. When the verifier +// spawned an HttpStub it publishes the side-channel log path +// through NYX_HTTP_LOG; a sink call site whose outbound request +// never reaches the on-the-wire listener (DNS-mocked, +// network-isolated sandbox, pre-flight check) can call this helper +// to surface the attempted call. Hash-prefixed detail lines plus a +// trailing summary line match the Python / Node / PHP siblings so +// the host-side HttpStub merger parses all four streams identically. +// No-op when NYX_HTTP_LOG is unset so the same harness still runs +// cleanly under modes that did not spawn a stub. +func __nyx_stub_http_record(method, url, body string, detail map[string]string) { + p := os.Getenv("NYX_HTTP_LOG") + if p == "" { + return + } + f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + f.WriteString("# method: " + method + "\n") + f.WriteString("# url: " + url + "\n") + if body != "" { + f.WriteString("# body: " + body + "\n") + } + for k, v := range detail { + f.WriteString("# " + k + ": " + v + "\n") + } + f.WriteString(method + " " + url + "\n") +} +"## } /// Emit a Go harness for `spec`. @@ -877,6 +908,19 @@ mod tests { } } + #[test] + fn probe_shim_publishes_stub_http_recorder() { + let shim = probe_shim(); + assert!( + shim.contains("func __nyx_stub_http_record"), + "Go probe shim must define __nyx_stub_http_record" + ); + assert!( + shim.contains("NYX_HTTP_LOG"), + "stub recorder must read NYX_HTTP_LOG" + ); + } + #[test] fn chain_step_splices_probe_shim_for_composite_reverify() { let step = chain_step(Some(b"")); diff --git a/tests/dynamic_fixtures/stubs_e2e/go/http/vuln/main.go b/tests/dynamic_fixtures/stubs_e2e/go/http/vuln/main.go new file mode 100644 index 00000000..5ce96522 --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/go/http/vuln/main.go @@ -0,0 +1,27 @@ +// Phase 10 (Track D.3) stub-end-to-end fixture: Go + HTTP. +// +// Body-only fragment, not a standalone `go run`-able program. The +// companion test in `tests/stubs_e2e_per_lang.rs` wraps these lines +// in `package main` + the union of stdlib imports required by both +// the spliced probe shim and this fragment, places the Go probe +// shim ahead of `func main`, and then invokes `go run` on the +// resulting file. +// +// 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 fragment records an attempted SSRF call to +// http://169.254.169.254/latest/meta-data/ through the Go shim helper +// __nyx_stub_http_record without issuing the actual network call. +method := "GET" +url := "http://169.254.169.254/latest/meta-data/" +body := "" +__nyx_stub_http_record(method, url, body, map[string]string{"driver": "net/http"}) +// Echo so the host can confirm the driver ran end-to-end. +fmt.Print(os.Getenv("NYX_HTTP_ENDPOINT")) diff --git a/tests/go_fixtures.rs b/tests/go_fixtures.rs index 8bd993fa..f0f931d6 100644 --- a/tests/go_fixtures.rs +++ b/tests/go_fixtures.rs @@ -455,20 +455,12 @@ mod go_fixture_tests { #[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 go_available() -> bool { - std::process::Command::new("go") - .arg("version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } - fn assert_confirmed(shape: &str, result: &VerifyResult) { assert_eq!( result.status, @@ -504,8 +496,15 @@ mod phase15_shape_tests { sink_line: u32, kind: EntryKind, slot: PayloadSlot, - ) -> VerifyResult { - run_shape_fixture_lang( + ) -> Option { + // Phase 29 (Track I): replace the bespoke `go_available()` + + // per-test `eprintln!("SKIP ..."); return;` blocks with the + // structured `Prerequisite::CommandAvailable("go")` 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("go")], Lang::Go, "go", shape, file, func, cap, sink_line, kind, slot, ) } @@ -514,27 +513,23 @@ mod phase15_shape_tests { #[test] fn handler_func_vuln_is_confirmed() { - if !go_available() { - eprintln!("SKIP: go not available"); - return; - } - let r = run( + let Some(r) = run( "handler_func", "vuln.go", "Handle", Cap::CODE_EXEC, 17, EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()), - ); + ) else { + return; + }; assert_confirmed("handler_func", &r); } #[test] fn handler_func_benign_not_confirmed() { - if !go_available() { - eprintln!("SKIP: go not available"); - return; - } - let r = run( + let Some(r) = run( "handler_func", "benign.go", "Handle", Cap::CODE_EXEC, 14, EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()), - ); + ) else { + return; + }; assert_not_confirmed("handler_func", &r); } @@ -542,27 +537,23 @@ mod phase15_shape_tests { #[test] fn gin_handler_vuln_is_confirmed() { - if !go_available() { - eprintln!("SKIP: go not available"); - return; - } - let r = run( + let Some(r) = run( "gin_handler", "vuln.go", "Handle", Cap::CODE_EXEC, 16, EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()), - ); + ) else { + return; + }; assert_confirmed("gin_handler", &r); } #[test] fn gin_handler_benign_not_confirmed() { - if !go_available() { - eprintln!("SKIP: go not available"); - return; - } - let r = run( + let Some(r) = run( "gin_handler", "benign.go", "Handle", Cap::CODE_EXEC, 14, EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()), - ); + ) else { + return; + }; assert_not_confirmed("gin_handler", &r); } @@ -570,27 +561,23 @@ mod phase15_shape_tests { #[test] fn flag_cli_vuln_is_confirmed() { - if !go_available() { - eprintln!("SKIP: go not available"); - return; - } - let r = run( + let Some(r) = run( "flag_cli", "vuln.go", "Run", Cap::CODE_EXEC, 19, EntryKind::CliSubcommand, PayloadSlot::Argv(0), - ); + ) else { + return; + }; assert_confirmed("flag_cli", &r); } #[test] fn flag_cli_benign_not_confirmed() { - if !go_available() { - eprintln!("SKIP: go not available"); - return; - } - let r = run( + let Some(r) = run( "flag_cli", "benign.go", "Run", Cap::CODE_EXEC, 15, EntryKind::CliSubcommand, PayloadSlot::Argv(0), - ); + ) else { + return; + }; assert_not_confirmed("flag_cli", &r); } @@ -598,27 +585,23 @@ mod phase15_shape_tests { #[test] fn fuzz_variadic_vuln_is_confirmed() { - if !go_available() { - eprintln!("SKIP: go not available"); - return; - } - let r = run( + let Some(r) = run( "fuzz_variadic", "vuln.go", "FuzzHandle", Cap::CODE_EXEC, 14, EntryKind::Function, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_confirmed("fuzz_variadic", &r); } #[test] fn fuzz_variadic_benign_not_confirmed() { - if !go_available() { - eprintln!("SKIP: go not available"); - return; - } - let r = run( + let Some(r) = run( "fuzz_variadic", "benign.go", "FuzzHandle", Cap::CODE_EXEC, 14, EntryKind::Function, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_not_confirmed("fuzz_variadic", &r); } } diff --git a/tests/php_fixtures.rs b/tests/php_fixtures.rs index 6058f26b..c27fb450 100644 --- a/tests/php_fixtures.rs +++ b/tests/php_fixtures.rs @@ -455,20 +455,12 @@ mod php_fixture_tests { #[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 php_available() -> bool { - std::process::Command::new("php") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } - fn assert_confirmed(shape: &str, result: &VerifyResult) { assert_eq!( result.status, @@ -504,8 +496,15 @@ mod phase15_shape_tests { sink_line: u32, kind: EntryKind, slot: PayloadSlot, - ) -> VerifyResult { - run_shape_fixture_lang( + ) -> Option { + // Phase 29 (Track I): replace the bespoke `php_available()` + + // per-test `eprintln!("SKIP ..."); return;` blocks with the + // structured `Prerequisite::CommandAvailable("php")` 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("php")], Lang::Php, "php", shape, file, func, cap, sink_line, kind, slot, ) } @@ -514,27 +513,23 @@ mod phase15_shape_tests { #[test] fn route_closure_vuln_is_confirmed() { - if !php_available() { - eprintln!("SKIP: php not available"); - return; - } - let r = run( + let Some(r) = run( "route_closure", "vuln.php", "run", Cap::CODE_EXEC, 10, EntryKind::HttpRoute, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_confirmed("route_closure", &r); } #[test] fn route_closure_benign_not_confirmed() { - if !php_available() { - eprintln!("SKIP: php not available"); - return; - } - let r = run( + let Some(r) = run( "route_closure", "benign.php", "run", Cap::CODE_EXEC, 11, EntryKind::HttpRoute, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_not_confirmed("route_closure", &r); } @@ -542,27 +537,23 @@ mod phase15_shape_tests { #[test] fn cli_script_vuln_is_confirmed() { - if !php_available() { - eprintln!("SKIP: php not available"); - return; - } - let r = run( + let Some(r) = run( "cli_script", "vuln.php", "main", Cap::CODE_EXEC, 8, EntryKind::CliSubcommand, PayloadSlot::Argv(0), - ); + ) else { + return; + }; assert_confirmed("cli_script", &r); } #[test] fn cli_script_benign_not_confirmed() { - if !php_available() { - eprintln!("SKIP: php not available"); - return; - } - let r = run( + let Some(r) = run( "cli_script", "benign.php", "main", Cap::CODE_EXEC, 11, EntryKind::CliSubcommand, PayloadSlot::Argv(0), - ); + ) else { + return; + }; assert_not_confirmed("cli_script", &r); } @@ -570,27 +561,23 @@ mod phase15_shape_tests { #[test] fn top_level_script_vuln_is_confirmed() { - if !php_available() { - eprintln!("SKIP: php not available"); - return; - } - let r = run( + let Some(r) = run( "top_level_script", "vuln.php", "", Cap::CODE_EXEC, 8, EntryKind::Function, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), - ); + ) else { + return; + }; assert_confirmed("top_level_script", &r); } #[test] fn top_level_script_benign_not_confirmed() { - if !php_available() { - eprintln!("SKIP: php not available"); - return; - } - let r = run( + let Some(r) = run( "top_level_script", "benign.php", "", Cap::CODE_EXEC, 10, EntryKind::Function, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), - ); + ) else { + return; + }; assert_not_confirmed("top_level_script", &r); } } diff --git a/tests/stubs_e2e_per_lang.rs b/tests/stubs_e2e_per_lang.rs index 8aaa7859..88f7b5f5 100644 --- a/tests/stubs_e2e_per_lang.rs +++ b/tests/stubs_e2e_per_lang.rs @@ -20,6 +20,7 @@ #![cfg(feature = "dynamic")] +use nyx_scanner::dynamic::lang::go::probe_shim as go_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; @@ -52,6 +53,39 @@ fn php_available() -> bool { .unwrap_or(false) } +fn go_available() -> bool { + Command::new("go") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// 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" + ) +} + fn fixture_path(rel: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests") @@ -655,6 +689,110 @@ fn php_http_shim_recorder_is_noop_without_log_env() { ); } +#[test] +fn go_http_stub_captures_attempted_outbound_via_shim_recorder() { + // Phase 10 (Track D.3) HTTP recording: Go 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 !go_available() { + eprintln!("SKIP: go 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"); + + // Go fragments need wrapping: the file under tests/dynamic_fixtures + // is a body-only fragment, not a standalone program. + let fragment = std::fs::read_to_string(fixture_path("go/http/vuln/main.go")) + .expect("read go fragment"); + let combined = wrap_go_fragment(&fragment, go_probe_shim()); + + let script_path = workdir.path().join("driver_http.go"); + std::fs::write(&script_path, combined).expect("write go driver"); + + let output = Command::new("go") + .arg("run") + .arg(&script_path) + .env("NYX_HTTP_ENDPOINT", &endpoint) + .env(recording.0, &recording.1) + .output() + .expect("go 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 Go 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("net/http"), + "detail map passed to __nyx_stub_http_record must surface as event detail entries" + ); +} + +#[test] +fn go_http_shim_recorder_is_noop_without_log_env() { + if !go_available() { + eprintln!("SKIP: go 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("go/http/vuln/main.go")) + .expect("read go fragment"); + let combined = wrap_go_fragment(&fragment, go_probe_shim()); + + let script_path = workdir.path().join("driver_http_no_log.go"); + std::fs::write(&script_path, combined).expect("write go driver"); + + let output = Command::new("go") + .arg("run") + .arg(&script_path) + .env("NYX_HTTP_ENDPOINT", &endpoint) + .env_remove("NYX_HTTP_LOG") + .output() + .expect("go 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 node_sql_shim_recorder_is_noop_without_log_env() { if !node_available() {