diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 0b49efe4..97e8e069 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -240,7 +240,7 @@ impl JavaShape { /// dependencies; matches the /// [`crate::dynamic::probe::SinkProbe`] wire format. pub fn probe_shim() -> &'static str { - r#" + r##" // ── __nyx_probe shim (Phase 06 — Track C.1, Phase 08 — Track C.4 + C.5) ── private static final String[] __NYX_DENY = { "TOKEN","SECRET","PASSWORD","PASSWD","API_KEY","APIKEY","PRIVATE_KEY", @@ -371,7 +371,40 @@ pub fn probe_shim() -> &'static str { } } } -"# + + // 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. Format matches the + // Python / Node / PHP / Go / Ruby siblings so the host-side HttpStub + // log-line merger parses all six 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. The hash prefix is emitted via + // String.valueOf('#') so this method body contains no literal hash-after- + // double-quote sequence that would terminate the surrounding Rust raw + // string. + static void __nyx_stub_http_record(String method, String url, String body, java.util.Map detail) { + String p = System.getenv("NYX_HTTP_LOG"); + if (p == null || p.isEmpty()) return; + String hashSp = String.valueOf('#') + " "; + try (java.io.FileWriter fw = new java.io.FileWriter(p, true)) { + fw.write(hashSp + "method: " + method + "\n"); + fw.write(hashSp + "url: " + url + "\n"); + if (body != null) { + fw.write(hashSp + "body: " + body + "\n"); + } + if (detail != null) { + for (java.util.Map.Entry e : detail.entrySet()) { + fw.write(hashSp + e.getKey() + ": " + e.getValue() + "\n"); + } + } + fw.write(method + " " + url + "\n"); + } catch (java.io.IOException e) { + // best-effort + } + } +"## } // ── Runtime / pom.xml synthesis (Phase 09) ────────────────────────────────── @@ -1040,6 +1073,27 @@ mod tests { assert_eq!(harness.entry_subpath, Some("Entry.java".to_owned())); } + #[test] + fn probe_shim_publishes_stub_http_recorder() { + let shim = probe_shim(); + assert!( + shim.contains("static void __nyx_stub_http_record"), + "Java probe shim must define __nyx_stub_http_record" + ); + assert!( + shim.contains("\"NYX_HTTP_LOG\""), + "Java HTTP recorder must read NYX_HTTP_LOG to find the side-channel log" + ); + assert!( + shim.contains("\"method: \""), + "Java HTTP recorder must emit a method detail line" + ); + assert!( + shim.contains("\"url: \""), + "Java HTTP recorder must emit a url detail line" + ); + } + #[test] fn chain_step_splices_probe_shim_for_composite_reverify() { let step = chain_step(Some(b"")); diff --git a/tests/dynamic_fixtures/stubs_e2e/java/http/vuln/main.java.fragment b/tests/dynamic_fixtures/stubs_e2e/java/http/vuln/main.java.fragment new file mode 100644 index 00000000..01f458ec --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/java/http/vuln/main.java.fragment @@ -0,0 +1,24 @@ +// Phase 10 (Track D.3) stub-end-to-end fixture: Java + 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 file is a body-only fragment: the companion test in +// tests/stubs_e2e_per_lang.rs wraps it with a `public class Main { … }` +// shell that splices the Java probe shim as class members ahead of +// `public static void main`, so the shim's __nyx_stub_http_record helper +// is in scope without needing an import. java.net.HttpURLConnection is +// JDK stdlib, so no extra classpath dep is required. +String method = "GET"; +String url = "http://169.254.169.254/latest/meta-data/"; +String body = ""; +java.util.Map detail = new java.util.LinkedHashMap<>(); +detail.put("driver", "HttpURLConnection"); +__nyx_stub_http_record(method, url, body, detail); +String ep = System.getenv("NYX_HTTP_ENDPOINT"); +System.out.println(ep == null ? "no-endpoint" : ep); diff --git a/tests/java_fixtures.rs b/tests/java_fixtures.rs index 97d1e84a..a60ac41f 100644 --- a/tests/java_fixtures.rs +++ b/tests/java_fixtures.rs @@ -463,25 +463,12 @@ mod java_fixture_tests { #[cfg(feature = "dynamic")] mod phase14_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 java_available() -> bool { - std::process::Command::new("javac") - .arg("-version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - && std::process::Command::new("java") - .arg("-version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } - fn assert_confirmed(shape: &str, result: &VerifyResult) { assert_eq!( result.status, @@ -517,8 +504,18 @@ mod phase14_shape_tests { sink_line: u32, kind: EntryKind, slot: PayloadSlot, - ) -> VerifyResult { - run_shape_fixture_lang( + ) -> Option { + // Phase 29 (Track I): replace the bespoke `java_available()` + + // per-test `eprintln!("SKIP ..."); return;` blocks with the + // structured `Prerequisite::CommandAvailable("javac"|"java")` + // 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("javac"), + Prerequisite::CommandAvailable("java"), + ], Lang::Java, "java", shape, file, func, cap, sink_line, kind, slot, ) } @@ -527,27 +524,23 @@ mod phase14_shape_tests { #[test] fn static_method_vuln_is_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "static_method", "Vuln.java", "processInput", Cap::CODE_EXEC, 12, EntryKind::Function, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_confirmed("static_method", &r); } #[test] fn static_method_benign_not_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "static_method", "Benign.java", "processInput", Cap::CODE_EXEC, 13, EntryKind::Function, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_not_confirmed("static_method", &r); } @@ -555,27 +548,23 @@ mod phase14_shape_tests { #[test] fn static_main_vuln_is_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "static_main", "Vuln.java", "main", Cap::CODE_EXEC, 13, EntryKind::CliSubcommand, PayloadSlot::Argv(0), - ); + ) else { + return; + }; assert_confirmed("static_main", &r); } #[test] fn static_main_benign_not_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "static_main", "Benign.java", "main", Cap::CODE_EXEC, 12, EntryKind::CliSubcommand, PayloadSlot::Argv(0), - ); + ) else { + return; + }; assert_not_confirmed("static_main", &r); } @@ -583,27 +572,23 @@ mod phase14_shape_tests { #[test] fn servlet_doget_vuln_is_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "servlet_doget", "Vuln.java", "doGet", Cap::CODE_EXEC, 14, EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()), - ); + ) else { + return; + }; assert_confirmed("servlet_doget", &r); } #[test] fn servlet_doget_benign_not_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "servlet_doget", "Benign.java", "doGet", Cap::CODE_EXEC, 14, EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()), - ); + ) else { + return; + }; assert_not_confirmed("servlet_doget", &r); } @@ -611,27 +596,23 @@ mod phase14_shape_tests { #[test] fn servlet_dopost_vuln_is_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "servlet_dopost", "Vuln.java", "doPost", Cap::CODE_EXEC, 13, EntryKind::HttpRoute, PayloadSlot::HttpBody, - ); + ) else { + return; + }; assert_confirmed("servlet_dopost", &r); } #[test] fn servlet_dopost_benign_not_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "servlet_dopost", "Benign.java", "doPost", Cap::CODE_EXEC, 12, EntryKind::HttpRoute, PayloadSlot::HttpBody, - ); + ) else { + return; + }; assert_not_confirmed("servlet_dopost", &r); } @@ -639,27 +620,23 @@ mod phase14_shape_tests { #[test] fn spring_controller_vuln_is_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "spring_controller", "Vuln.java", "run", Cap::CODE_EXEC, 16, EntryKind::HttpRoute, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_confirmed("spring_controller", &r); } #[test] fn spring_controller_benign_not_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "spring_controller", "Benign.java", "run", Cap::CODE_EXEC, 14, EntryKind::HttpRoute, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_not_confirmed("spring_controller", &r); } @@ -667,27 +644,23 @@ mod phase14_shape_tests { #[test] fn junit_test_vuln_is_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "junit_test", "Vuln.java", "testRun", Cap::CODE_EXEC, 17, EntryKind::Function, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), - ); + ) else { + return; + }; assert_confirmed("junit_test", &r); } #[test] fn junit_test_benign_not_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "junit_test", "Benign.java", "testRun", Cap::CODE_EXEC, 15, EntryKind::Function, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), - ); + ) else { + return; + }; assert_not_confirmed("junit_test", &r); } @@ -695,27 +668,23 @@ mod phase14_shape_tests { #[test] fn quarkus_route_vuln_is_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "quarkus_route", "Vuln.java", "run", Cap::CODE_EXEC, 17, EntryKind::HttpRoute, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_confirmed("quarkus_route", &r); } #[test] fn quarkus_route_benign_not_confirmed() { - if !java_available() { - eprintln!("SKIP: javac/java not available"); - return; - } - let r = run( + let Some(r) = run( "quarkus_route", "Benign.java", "run", Cap::CODE_EXEC, 14, EntryKind::HttpRoute, PayloadSlot::Param(0), - ); + ) else { + return; + }; assert_not_confirmed("quarkus_route", &r); } diff --git a/tests/stubs_e2e_per_lang.rs b/tests/stubs_e2e_per_lang.rs index 9d6f132b..d4b31aa1 100644 --- a/tests/stubs_e2e_per_lang.rs +++ b/tests/stubs_e2e_per_lang.rs @@ -21,6 +21,7 @@ #![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; @@ -70,6 +71,37 @@ fn ruby_available() -> bool { .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 @@ -909,6 +941,112 @@ fn ruby_http_shim_recorder_is_noop_without_log_env() { ); } +#[test] +fn java_http_stub_captures_attempted_outbound_via_shim_recorder() { + // Phase 10 (Track D.3) HTTP recording: Java leg of the side-channel + // `__nyx_stub_http_record` helper. Mirrors the Python / Node / PHP / + // Go / Ruby HTTP tests — records an SSRF attempt without issuing the + // actual network call. Uses `java MainSource.java` single-file + // source-mode (JEP 330, JDK 11+) so no separate `javac` step is + // required. + if !java_available() { + eprintln!("SKIP: java 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("java/http/vuln/main.java.fragment")) + .expect("read java fragment"); + let combined = wrap_java_fragment(&fragment, java_probe_shim()); + + // Single-file source-mode requires the filename to match the public + // class — name the file `Main.java` so `java Main.java` compiles + // and runs in one step. + let script_path = workdir.path().join("Main.java"); + std::fs::write(&script_path, combined).expect("write java driver"); + + let output = Command::new("java") + .arg(&script_path) + .env("NYX_HTTP_ENDPOINT", &endpoint) + .env(recording.0, &recording.1) + .output() + .expect("java 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 Java 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("HttpURLConnection"), + "detail map entries passed to __nyx_stub_http_record must surface as event detail entries" + ); +} + +#[test] +fn java_http_shim_recorder_is_noop_without_log_env() { + if !java_available() { + eprintln!("SKIP: java 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("java/http/vuln/main.java.fragment")) + .expect("read java fragment"); + let combined = wrap_java_fragment(&fragment, java_probe_shim()); + + let script_path = workdir.path().join("Main.java"); + std::fs::write(&script_path, combined).expect("write java driver"); + + let output = Command::new("java") + .arg(&script_path) + .env("NYX_HTTP_ENDPOINT", &endpoint) + .env_remove("NYX_HTTP_LOG") + .output() + .expect("java 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() {