diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 64a2f30e..35e681ca 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -85,13 +85,21 @@ impl LangEmitter for JavaEmitter { /// Emits a `Step.java` class whose `main` reads `NYX_PREV_OUTPUT` and /// forwards it on stdout. The command shell-wraps `javac` + `java` so /// the step actually runs after the build step completes (the -/// `ChainStepHarness.command` slot models a single process). The Java -/// probe shim is class-level and requires `System` / `java.io.*` imports -/// the chain step already pulls in implicitly; wiring the full shim is -/// tracked alongside the Phase 14 emitter follow-up about probe shim -/// splicing. +/// `ChainStepHarness.command` slot models a single process). +/// +/// The Java probe shim (`__nyx_probe`, `__nyx_install_crash_guard`, +/// helpers) is spliced as class-member declarations inside `class Step +/// { … }` between the class-open brace and `public static void main`, +/// so a downstream sink rewrite within the step body has the shim +/// helpers already in scope. The shim uses only `java.lang.*` plus +/// fully-qualified `java.util.TreeMap` / `java.io.FileWriter` / +/// `java.nio.charset.StandardCharsets`, so no extra `import` lines +/// are needed beyond what stock Java implicitly imports. fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { - let source = "public class Step {\n public static void main(String[] args) {\n String prev = System.getenv(\"NYX_PREV_OUTPUT\");\n if (prev == null) prev = \"\";\n System.out.print(prev);\n }\n}\n".to_owned(); + let shim = probe_shim(); + let source = format!( + "public class Step {{\n{shim}\n public static void main(String[] args) {{\n String prev = System.getenv(\"NYX_PREV_OUTPUT\");\n if (prev == null) prev = \"\";\n System.out.print(prev);\n }}\n}}\n" + ); ChainStepHarness { source, filename: "Step.java".to_owned(), @@ -1031,6 +1039,35 @@ mod tests { assert_eq!(harness.entry_subpath, Some("Entry.java".to_owned())); } + #[test] + fn chain_step_splices_probe_shim_for_composite_reverify() { + let step = chain_step(Some(b"")); + assert!( + step.source.contains("__nyx_probe"), + "Java chain step must splice the probe shim" + ); + assert!( + step.source.starts_with("public class Step {"), + "Java chain step must open with the `public class Step {{` declaration" + ); + assert!( + step.source.contains("System.getenv(\"NYX_PREV_OUTPUT\")"), + "Java chain step must keep its NYX_PREV_OUTPUT forwarder" + ); + let shim_pos = step.source.find("__nyx_probe").unwrap(); + let driver_pos = step.source.find("System.getenv(\"NYX_PREV_OUTPUT\")").unwrap(); + assert!( + shim_pos < driver_pos, + "probe shim must come before the driver so the shim's helpers are in scope when a sink rewrite splices in" + ); + let main_pos = step.source.find("public static void main").unwrap(); + assert!( + shim_pos < main_pos, + "probe shim members must be declared before `main` so the class compiles cleanly" + ); + assert_eq!(step.filename, "Step.java"); + } + #[test] fn detect_shape_reads_file_and_returns_shape() { // Drive the public `detect_shape(spec)` wrapper end-to-end: diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index fc34de98..d3528427 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -250,6 +250,34 @@ function __nyx_install_crash_guard(sinkCallee) { } catch (e) { /* runtime refused signal handler */ } } } + +// Phase 10 (Track D.3) stub helpers. When the verifier spawned a SqlStub it +// publishes the queries-log path through NYX_SQL_LOG; a sink call site that +// wants the host-side stub to see its query appends one record-per-call. The +// helper is a no-op when NYX_SQL_LOG is unset so the same fixture source still +// runs under harness modes that didn't spawn a stub. Mirrors the Python +// shim's __nyx_stub_sql_record so the host-side SqlStub log-line format +// (key/value detail lines prefixed with hash-space, followed by the query +// line) is identical across language emitters. +function __nyx_stub_sql_record(query, detail) { + const _p = process.env.NYX_SQL_LOG; + if (!_p) return; + const _fs = require('fs'); + try { + let _buf = ''; + if (detail && typeof detail === 'object') { + for (const _k of Object.keys(detail)) { + _buf += '# ' + String(_k) + ': ' + String(detail[_k]) + '\n'; + } + } + const _q = String(query); + _buf += _q; + if (!_q.endsWith('\n')) _buf += '\n'; + _fs.appendFileSync(_p, _buf); + } catch (e) { + // best-effort: stub recorder write failure is non-fatal. + } +} "# } @@ -1029,4 +1057,21 @@ mod tests { assert_eq!(h_js.entry_subpath, h_ts.entry_subpath); assert_eq!(h_js.entry_subpath.as_deref(), Some("entry.js")); } + + #[test] + fn probe_shim_publishes_stub_sql_recorder() { + let shim = probe_shim(); + assert!( + shim.contains("function __nyx_stub_sql_record"), + "Node probe shim must define __nyx_stub_sql_record" + ); + assert!( + shim.contains("NYX_SQL_LOG"), + "stub recorder must read NYX_SQL_LOG" + ); + assert!( + shim.contains("appendFileSync"), + "stub recorder must append to the log file" + ); + } } diff --git a/tests/dynamic_fixtures/stubs_e2e/node/sql/vuln/main.js b/tests/dynamic_fixtures/stubs_e2e/node/sql/vuln/main.js new file mode 100644 index 00000000..65fd1f8a --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/node/sql/vuln/main.js @@ -0,0 +1,46 @@ +// Phase 10 (Track D.3) stub-end-to-end fixture: Node + SQL. +// +// The verifier publishes: +// +// * NYX_SQL_ENDPOINT — absolute path of a SQLite DB the SqlStub owns. +// * NYX_SQL_LOG — companion log path the harness appends executed +// queries to so the host SqlStub picks them up on drain_events(). +// +// This fixture mirrors the Python sibling at +// tests/dynamic_fixtures/stubs_e2e/python/sql/vuln/main.py. It opens +// the stub DB through Node's experimental stdlib `node:sqlite` module +// (Node 22.5+), runs a tautology SELECT (OR 1=1), and forwards the +// executed query to the stub through the JS shim helper +// `__nyx_stub_sql_record`. When `node:sqlite` is missing (older Node +// or stripped runtimes) the DB exec step is skipped but the shim +// recorder still fires so the stub captures the query regardless. + +'use strict'; + +function main() { + const dbPath = process.env.NYX_SQL_ENDPOINT; + if (!dbPath) return; + const query = "SELECT 1 WHERE 'a' = 'a' OR 1=1 --"; + + let driverName = 'none'; + try { + const sqlite = require('node:sqlite'); + const db = new sqlite.DatabaseSync(dbPath); + try { + const rows = db.prepare(query).all(); + for (const row of rows) { + process.stdout.write(String(Object.values(row)[0]) + '\n'); + } + driverName = 'node:sqlite'; + } finally { + db.close(); + } + } catch (e) { + // node:sqlite unavailable on this Node version; skip the + // exec but still record the query so the stub sees the call. + } + + __nyx_stub_sql_record(query, { driver: driverName }); +} + +main(); diff --git a/tests/stubs_e2e_per_lang.rs b/tests/stubs_e2e_per_lang.rs index b27109af..72180011 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::javascript::probe_shim as node_probe_shim; use nyx_scanner::dynamic::lang::python::probe_shim as python_probe_shim; use nyx_scanner::dynamic::stubs::{SqlStub, StubProvider}; use std::path::PathBuf; @@ -34,6 +35,14 @@ fn python3_available() -> bool { .unwrap_or(false) } +fn node_available() -> bool { + Command::new("node") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + fn fixture_path(rel: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests") @@ -142,3 +151,103 @@ fn python_sql_shim_recorder_is_noop_without_log_env() { 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:?}" + ); +} + +#[test] +fn node_sql_shim_recorder_is_noop_without_log_env() { + 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 fixture = + std::fs::read_to_string(fixture_path("node/sql/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_no_log.js"); + std::fs::write(&script_path, combined).expect("write driver"); + + let output = Command::new("node") + .arg(&script_path) + .env("NYX_SQL_ENDPOINT", &endpoint) + .env_remove("NYX_SQL_LOG") + .output() + .expect("node 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() + ); +}