diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs index 7b62b9d8..da1f0864 100644 --- a/src/dynamic/lang/c.rs +++ b/src/dynamic/lang/c.rs @@ -336,6 +336,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { )] }) .unwrap_or_default(), + extra_files: Vec::new(), } } diff --git a/src/dynamic/lang/cpp.rs b/src/dynamic/lang/cpp.rs index 60798527..ea780408 100644 --- a/src/dynamic/lang/cpp.rs +++ b/src/dynamic/lang/cpp.rs @@ -308,6 +308,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { )] }) .unwrap_or_default(), + extra_files: Vec::new(), } } diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index a3023177..7d0e2f17 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -111,6 +111,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { )] }) .unwrap_or_default(), + extra_files: Vec::new(), } } diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 35e681ca..0b49efe4 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -116,6 +116,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { )] }) .unwrap_or_default(), + extra_files: Vec::new(), } } diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index d3528427..989a01bb 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -278,6 +278,35 @@ function __nyx_stub_sql_record(query, detail) { // best-effort: stub recorder write failure is non-fatal. } } + +// 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 SQL +// helper so the host-side merger parses both streams identically. +function __nyx_stub_http_record(method, url, body, detail) { + const _p = process.env.NYX_HTTP_LOG; + if (!_p) return; + const _fs = require('fs'); + try { + let _buf = ''; + _buf += '# method: ' + String(method) + '\n'; + _buf += '# url: ' + String(url) + '\n'; + if (body !== undefined && body !== null) { + _buf += '# body: ' + String(body) + '\n'; + } + if (detail && typeof detail === 'object') { + for (const _k of Object.keys(detail)) { + _buf += '# ' + String(_k) + ': ' + String(detail[_k]) + '\n'; + } + } + _buf += String(method) + ' ' + String(url) + '\n'; + _fs.appendFileSync(_p, _buf); + } catch (e) { + // best-effort: stub recorder write failure is non-fatal. + } +} "# } @@ -465,6 +494,7 @@ pub fn chain_step(prev_output: Option<&[u8]>, is_typescript: bool) -> ChainStepH )] }) .unwrap_or_default(), + extra_files: Vec::new(), } } @@ -1074,4 +1104,17 @@ mod tests { "stub recorder must append to the log file" ); } + + #[test] + fn probe_shim_publishes_stub_http_recorder() { + let shim = probe_shim(); + assert!( + shim.contains("function __nyx_stub_http_record"), + "Node probe shim must define __nyx_stub_http_record" + ); + assert!( + shim.contains("NYX_HTTP_LOG"), + "stub recorder must read NYX_HTTP_LOG" + ); + } } diff --git a/src/dynamic/lang/mod.rs b/src/dynamic/lang/mod.rs index 45d2de58..2c24dc7c 100644 --- a/src/dynamic/lang/mod.rs +++ b/src/dynamic/lang/mod.rs @@ -66,6 +66,14 @@ pub struct ChainStepHarness { pub filename: String, pub command: Vec, pub extra_env: Vec<(String, String)>, + /// Companion files staged alongside [`Self::source`] in the chain + /// step's workdir. Each entry is `(relative_path, content)`; + /// subdirectories in `relative_path` are created automatically. + /// Mirrors [`HarnessSource::extra_files`] so an emitter whose chain + /// step needs a build manifest (Rust's `Cargo.toml`, future + /// `pom.xml`, etc.) can ship it without smuggling everything into + /// `source`. + pub extra_files: Vec<(String, String)>, } impl ChainStepHarness { @@ -156,6 +164,7 @@ pub fn default_chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { )] }) .unwrap_or_default(), + extra_files: Vec::new(), } } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index ed2ac2b2..bc010dd1 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -98,6 +98,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { )] }) .unwrap_or_default(), + extra_files: Vec::new(), } } @@ -352,6 +353,28 @@ function __nyx_stub_sql_record($query, array $detail = []): void { if (substr($q, -1) !== "\n") $buf .= "\n"; @file_put_contents($p, $buf, FILE_APPEND); } + +// 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 SQL +// helper so the host-side merger parses both streams identically. +function __nyx_stub_http_record($method, $url, $body = null, array $detail = []): void { + $p = getenv('NYX_HTTP_LOG'); + if ($p === false || $p === '') return; + $buf = ''; + $buf .= '# method: ' . (string)$method . "\n"; + $buf .= '# url: ' . (string)$url . "\n"; + if ($body !== null) { + $buf .= '# body: ' . (string)$body . "\n"; + } + foreach ($detail as $k => $v) { + $buf .= '# ' . (string)$k . ': ' . (string)$v . "\n"; + } + $buf .= (string)$method . ' ' . (string)$url . "\n"; + @file_put_contents($p, $buf, FILE_APPEND); +} "# } @@ -751,6 +774,19 @@ mod tests { ); } + #[test] + fn probe_shim_publishes_stub_http_recorder() { + let shim = probe_shim(); + assert!( + shim.contains("function __nyx_stub_http_record"), + "PHP 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/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index eddb4b5d..62441cde 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -92,6 +92,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { )] }) .unwrap_or_default(), + extra_files: Vec::new(), } } @@ -382,6 +383,29 @@ def __nyx_stub_sql_record(query, **detail): _f.write('\n') except OSError: pass + +# 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 SQL +# helper so the host-side merger parses both streams identically. +def __nyx_stub_http_record(method, url, body=None, **detail): + import os + p = os.environ.get("NYX_HTTP_LOG") + if not p: + return + try: + with open(p, "a") as _f: + _f.write('# method: %s\n' % str(method)) + _f.write('# url: %s\n' % str(url)) + if body is not None: + _f.write('# body: %s\n' % str(body)) + for k, v in detail.items(): + _f.write('# %s: %s\n' % (str(k), str(v))) + _f.write('%s %s\n' % (str(method), str(url))) + except OSError: + pass "# } diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index a0580f9d..945c4187 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -93,6 +93,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { )] }) .unwrap_or_default(), + extra_files: Vec::new(), } } diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index 42592bbd..ba993594 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -71,24 +71,31 @@ impl LangEmitter for RustEmitter { /// Phase 26 — Rust chain-step harness. /// -/// Emits a minimal `step.rs` file that reads `NYX_PREV_OUTPUT` and writes -/// it on stdout. The chain composer drives the step with `rustc step.rs` -/// (single-file build) — full Cargo crate scaffolding is reserved for -/// chain members whose underlying finding already produced a HarnessSpec -/// via the standard emit path. +/// Splices the Rust probe shim ([`probe_shim`]) in front of a minimal +/// driver that reads `NYX_PREV_OUTPUT` and writes it on stdout. The +/// shim references `libc::*` from its `__nyx_install_crash_guard` +/// definition, so a single-file `rustc step.rs` build cannot resolve +/// the symbols. Instead the step ships a companion `Cargo.toml` +/// pinning `libc = "0.2"` via [`ChainStepHarness::extra_files`] and +/// drives the build through `cargo run --quiet`. fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { - let source = "use std::env;\nuse std::io::{self, Write};\n\nfn main() {\n let prev = env::var(\"NYX_PREV_OUTPUT\").unwrap_or_default();\n let _ = io::stdout().write_all(prev.as_bytes());\n}\n".to_owned(); - // Shell-wrap build + run so the step actually executes the compiled binary. - // `ChainStepHarness.command` models a single process; without the wrap the - // step ends after `rustc` exits and the next chain member sees no output. + let shim = probe_shim(); + let driver = "use std::env;\nuse std::io::{self, Write};\n\nfn main() {\n let prev = env::var(\"NYX_PREV_OUTPUT\").unwrap_or_default();\n let _ = io::stdout().write_all(prev.as_bytes());\n}\n"; + let source = format!("{shim}\n{driver}"); + let cargo_toml = "[package]\n\ + name = \"nyx-chain-step\"\n\ + version = \"0.0.1\"\n\ + edition = \"2021\"\n\n\ + [[bin]]\n\ + name = \"step\"\n\ + path = \"step.rs\"\n\n\ + [dependencies]\n\ + libc = \"0.2\"\n" + .to_owned(); ChainStepHarness { source, filename: "step.rs".to_owned(), - command: vec![ - "sh".to_owned(), - "-c".to_owned(), - "rustc step.rs -o step && ./step".to_owned(), - ], + command: vec!["cargo".to_owned(), "run".to_owned(), "--quiet".to_owned()], extra_env: prev_output .map(|bytes| { vec![( @@ -97,6 +104,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { )] }) .unwrap_or_default(), + extra_files: vec![("Cargo.toml".to_owned(), cargo_toml)], } } @@ -878,4 +886,67 @@ mod tests { let _ = generate_cargo_toml(Cap::CODE_EXEC); let _ = generate_cargo_toml(Cap::SSRF); } + + #[test] + fn chain_step_splices_probe_shim_for_composite_reverify() { + // Phase 26 follow-up: Rust chain_step now splices the probe + // shim ahead of the driver so a chain step that terminates at + // a sink can drive the `__nyx_probe` channel directly. The + // shim references `libc::*` so the step also ships a companion + // `Cargo.toml` via `extra_files` and drives the build through + // `cargo run --quiet` rather than single-file `rustc`. + let step = chain_step(Some(b"prev-output")); + assert!( + step.source.contains("__nyx_probe shim (Phase 06"), + "probe_shim banner missing from chain step source", + ); + assert!( + step.source.contains("fn __nyx_install_crash_guard("), + "install_crash_guard missing from chain step source", + ); + let shim_pos = step + .source + .find("__nyx_probe shim (Phase 06") + .expect("shim banner"); + let main_pos = step.source.find("fn main()").expect("main fn"); + assert!( + shim_pos < main_pos, + "shim must be spliced before fn main(): shim={shim_pos} main={main_pos}", + ); + assert_eq!(step.filename, "step.rs"); + assert_eq!( + step.command, + vec!["cargo".to_owned(), "run".to_owned(), "--quiet".to_owned()], + ); + assert!( + step.extra_env + .iter() + .any(|(k, v)| k == ChainStepHarness::PREV_OUTPUT_ENV && v == "prev-output"), + "prev_output must be threaded through extra_env, got {:?}", + step.extra_env, + ); + } + + #[test] + fn chain_step_emits_cargo_toml_with_libc_dep() { + let step = chain_step(None); + let cargo = step + .extra_files + .iter() + .find(|(n, _)| n == "Cargo.toml") + .expect("Cargo.toml must be in extra_files for cargo run"); + let body = &cargo.1; + assert!( + body.contains("libc = \"0.2\""), + "Cargo.toml must pin libc for the probe shim's sigaction path, got: {body}", + ); + assert!( + body.contains("path = \"step.rs\""), + "[[bin]] must point at step.rs so cargo run picks it up, got: {body}", + ); + assert!( + body.contains("edition = \"2021\""), + "Cargo.toml must declare edition 2021, got: {body}", + ); + } } diff --git a/src/dynamic/stubs/http.rs b/src/dynamic/stubs/http.rs index 3864613a..65f149fe 100644 --- a/src/dynamic/stubs/http.rs +++ b/src/dynamic/stubs/http.rs @@ -10,19 +10,41 @@ //! //! Endpoint: `http://127.0.0.1:{port}`. //! +//! # Side-channel recording +//! +//! In addition to the on-the-wire listener, [`HttpStub`] publishes a +//! companion log path under the [`HTTP_STUB_LOG_ENV_VAR`] env var +//! (`NYX_HTTP_LOG`). A per-language shim helper +//! (`__nyx_stub_http_record`) appends one record per attempted outbound +//! HTTP call to that file, in the same hash-prefixed detail-then-query +//! format the SQL stub uses. The host merges those records into +//! [`StubProvider::drain_events`] alongside the on-the-wire captures, so +//! a harness whose outbound call never reaches the listener (DNS-mocked, +//! network-isolated sandbox, pre-flight check) still produces an +//! event the oracle can match. +//! //! # Drop //! //! Signals the accept thread to shut down and connects to itself to //! wake the blocking `accept()`. The thread joins on its next loop -//! iteration; the listener socket is released by the OS. +//! iteration; the listener socket is released by the OS. The +//! recording log lives under the workdir-rooted tempdir which is +//! cleaned up by the verifier's tempdir handle. use super::{monotonic_ns, StubEvent, StubKind, StubProvider}; use std::collections::BTreeMap; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; +use tempfile::TempDir; + +/// Companion env var that publishes [`HttpStub::log_path`] so a +/// language-side shim can append outbound HTTP attempts the host will +/// pick up on [`HttpStub::drain_events`]. +pub const HTTP_STUB_LOG_ENV_VAR: &str = "NYX_HTTP_LOG"; /// Localhost HTTP request recorder. #[derive(Debug)] @@ -30,11 +52,22 @@ pub struct HttpStub { port: u16, events: Arc>>, shutdown: Arc, + /// Tempdir holding the side-channel recording log. Drop releases + /// the file along with the directory. + tempdir: Option, + /// Path to the side-channel recording log. + log_path: PathBuf, + /// Read cursor on the log file so `drain_events` only surfaces + /// records appended since the last drain. + log_cursor: Mutex, } impl HttpStub { - /// Bind to a random loopback port and start the accept thread. - pub fn start() -> std::io::Result { + /// Bind to a random loopback port, start the accept thread, and + /// prepare a side-channel recording log under `workdir`. Falls + /// back to the process-wide temp directory when `workdir` is not + /// writable. + pub fn start(workdir: &Path) -> std::io::Result { let listener = TcpListener::bind("127.0.0.1:0")?; listener.set_nonblocking(false)?; let port = listener.local_addr()?.port(); @@ -46,7 +79,18 @@ impl HttpStub { let shutdown_clone = Arc::clone(&shutdown); std::thread::spawn(move || accept_loop(listener, events_clone, shutdown_clone)); - Ok(Self { port, events, shutdown }) + let tempdir = TempDir::new_in(workdir).or_else(|_| TempDir::new())?; + let log_path = tempdir.path().join("nyx_http_stub.requests.log"); + std::fs::File::create(&log_path)?; + + Ok(Self { + port, + events, + shutdown, + tempdir: Some(tempdir), + log_path, + log_cursor: Mutex::new(0), + }) } /// Port the listener is bound to. Useful for tests that need to @@ -55,6 +99,13 @@ impl HttpStub { self.port } + /// Absolute path of the side-channel recording log. The + /// `__nyx_stub_http_record` shim helpers append outbound HTTP + /// attempts here; the stub reads new records on drain. + pub fn log_path(&self) -> &Path { + &self.log_path + } + /// Host-side helper to record a request as if it arrived on the /// wire. The Phase 10 integration test uses this to bypass the /// `connect → write → parse` path so the test runs without a real @@ -65,6 +116,60 @@ impl HttpStub { g.push(ev); } } + + /// Drain the side-channel log file, returning every record + /// appended since the previous call. Format mirrors the SQL stub + /// log: `# key: value` lines stitch onto the next non-comment line + /// (which becomes the event summary). + fn drain_log_file(&self) -> Vec { + let mut cursor = match self.log_cursor.lock() { + Ok(g) => g, + Err(_) => return Vec::new(), + }; + let file = match std::fs::File::open(&self.log_path) { + Ok(f) => f, + Err(_) => return Vec::new(), + }; + use std::io::Seek; + let mut reader = BufReader::new(file); + if reader.seek(std::io::SeekFrom::Start(*cursor)).is_err() { + return Vec::new(); + } + + let mut events = Vec::new(); + let mut pending_detail = BTreeMap::::new(); + let mut bytes_read: u64 = 0; + let mut buf = String::new(); + loop { + buf.clear(); + let n = match reader.read_line(&mut buf) { + Ok(0) => break, + Ok(n) => n, + Err(_) => break, + }; + bytes_read += n as u64; + let line = buf.trim_end_matches(['\r', '\n']).to_owned(); + if line.is_empty() { + continue; + } + if let Some(rest) = line.strip_prefix("# ") { + if let Some((k, v)) = rest.split_once(':') { + pending_detail.insert(k.trim().to_owned(), v.trim().to_owned()); + } + continue; + } + let mut ev = StubEvent { + kind: StubKind::Http, + captured_at_ns: monotonic_ns(), + summary: line, + detail: BTreeMap::new(), + }; + ev.detail.append(&mut pending_detail); + events.push(ev); + } + *cursor += bytes_read; + events + } } impl StubProvider for HttpStub { @@ -76,11 +181,17 @@ impl StubProvider for HttpStub { format!("http://127.0.0.1:{}", self.port) } + fn recording_endpoint(&self) -> Option<(&'static str, String)> { + Some((HTTP_STUB_LOG_ENV_VAR, self.log_path.to_string_lossy().into_owned())) + } + fn drain_events(&self) -> Vec { - match self.events.lock() { + let mut out = match self.events.lock() { Ok(mut g) => std::mem::take(&mut *g), Err(_) => Vec::new(), - } + }; + out.extend(self.drain_log_file()); + out } } @@ -89,6 +200,8 @@ impl Drop for HttpStub { self.shutdown.store(true, Ordering::Relaxed); // Wake the blocking accept by connecting once. let _ = TcpStream::connect(format!("127.0.0.1:{}", self.port)); + // TempDir's own Drop deletes the side-channel log + dir. + self.tempdir.take(); } } @@ -197,6 +310,7 @@ fn handle_connection(mut stream: TcpStream, max_bytes: usize) -> Option Vec { let mut s = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap(); @@ -207,9 +321,15 @@ mod tests { out } + fn start_stub() -> (TempDir, HttpStub) { + let dir = TempDir::new().unwrap(); + let stub = HttpStub::start(dir.path()).unwrap(); + (dir, stub) + } + #[test] fn endpoint_uses_loopback_with_assigned_port() { - let stub = HttpStub::start().unwrap(); + let (_dir, stub) = start_stub(); let ep = stub.endpoint(); assert!(ep.starts_with("http://127.0.0.1:")); assert!(ep.ends_with(&stub.port().to_string())); @@ -217,7 +337,7 @@ mod tests { #[test] fn captures_request_line_via_real_socket() { - let stub = HttpStub::start().unwrap(); + let (_dir, stub) = start_stub(); let reply = send_request( stub.port(), b"GET /api/users HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", @@ -236,7 +356,7 @@ mod tests { #[test] fn captures_post_body() { - let stub = HttpStub::start().unwrap(); + let (_dir, stub) = start_stub(); let body = b"username=admin&password=hunter2"; let req = format!( "POST /login HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: {}\r\n\r\n", @@ -256,7 +376,7 @@ mod tests { #[test] fn drain_resets_event_buffer() { - let stub = HttpStub::start().unwrap(); + let (_dir, stub) = start_stub(); stub.record("GET /first HTTP/1.1"); assert_eq!(stub.drain_events().len(), 1); assert!(stub.drain_events().is_empty(), "second drain must be empty"); @@ -265,7 +385,7 @@ mod tests { #[test] fn drop_releases_port_for_rebind() { let port = { - let stub = HttpStub::start().unwrap(); + let (_dir, stub) = start_stub(); stub.port() }; // After drop, the OS releases the port. The accept thread may @@ -276,4 +396,75 @@ mod tests { // We don't assert success here — the OS may hold the port in // TIME_WAIT — but Drop must not panic or deadlock. } + + #[test] + fn recording_endpoint_publishes_log_path_under_nyx_http_log() { + let (_dir, stub) = start_stub(); + let pair = stub + .recording_endpoint() + .expect("HttpStub must publish a recording endpoint"); + assert_eq!(pair.0, HTTP_STUB_LOG_ENV_VAR); + assert_eq!(pair.0, "NYX_HTTP_LOG"); + assert_eq!(pair.1, stub.log_path().to_string_lossy()); + assert!( + stub.log_path().exists(), + "side-channel log file must be created on start", + ); + } + + #[test] + fn drain_events_merges_log_file_records_with_in_memory_events() { + let (_dir, stub) = start_stub(); + // Simulate the on-the-wire path. + stub.record("GET /listener-hit HTTP/1.1"); + // Simulate the shim path: append a detail-then-summary record + // mirroring the SQL stub log format. + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(stub.log_path()) + .unwrap(); + f.write_all(b"# method: POST\n# url: http://example.com/login\nPOST http://example.com/login\n") + .unwrap(); + drop(f); + + let events = stub.drain_events(); + assert_eq!(events.len(), 2, "both sources must surface, got {events:?}"); + let summaries: Vec<_> = events.iter().map(|e| e.summary.as_str()).collect(); + assert!(summaries.contains(&"GET /listener-hit HTTP/1.1")); + assert!(summaries.contains(&"POST http://example.com/login")); + let shim_event = events + .iter() + .find(|e| e.summary.starts_with("POST http://example.com")) + .unwrap(); + assert_eq!( + shim_event.detail.get("method").map(String::as_str), + Some("POST"), + ); + assert_eq!( + shim_event.detail.get("url").map(String::as_str), + Some("http://example.com/login"), + ); + } + + #[test] + fn drain_log_file_returns_only_new_entries() { + let (_dir, stub) = start_stub(); + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(stub.log_path()) + .unwrap(); + f.write_all(b"GET /one\n").unwrap(); + drop(f); + assert_eq!(stub.drain_events().len(), 1); + + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(stub.log_path()) + .unwrap(); + f.write_all(b"GET /two\n").unwrap(); + drop(f); + let second = stub.drain_events(); + assert_eq!(second.len(), 1, "drain must return only the new record"); + assert_eq!(second[0].summary, "GET /two"); + } } diff --git a/src/dynamic/stubs/mod.rs b/src/dynamic/stubs/mod.rs index 97810da8..a80d985a 100644 --- a/src/dynamic/stubs/mod.rs +++ b/src/dynamic/stubs/mod.rs @@ -241,7 +241,7 @@ impl StubHarness { seen.push(k); let stub: Arc = match k { StubKind::Sql => Arc::new(SqlStub::start(workdir)?), - StubKind::Http => Arc::new(HttpStub::start()?), + StubKind::Http => Arc::new(HttpStub::start(workdir)?), StubKind::Redis => Arc::new(RedisStub::start()?), StubKind::Filesystem => Arc::new(FilesystemStub::start(workdir)?), }; diff --git a/tests/dynamic_fixtures/python/async/vuln.py.golden_harness.py b/tests/dynamic_fixtures/python/async/vuln.py.golden_harness.py index 34d59743..c11752c5 100644 --- a/tests/dynamic_fixtures/python/async/vuln.py.golden_harness.py +++ b/tests/dynamic_fixtures/python/async/vuln.py.golden_harness.py @@ -141,6 +141,29 @@ def __nyx_stub_sql_record(query, **detail): except OSError: pass +# 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 SQL +# helper so the host-side merger parses both streams identically. +def __nyx_stub_http_record(method, url, body=None, **detail): + import os + p = os.environ.get("NYX_HTTP_LOG") + if not p: + return + try: + with open(p, "a") as _f: + _f.write('# method: %s\n' % str(method)) + _f.write('# url: %s\n' % str(url)) + if body is not None: + _f.write('# body: %s\n' % str(body)) + for k, v in detail.items(): + _f.write('# %s: %s\n' % (str(k), str(v))) + _f.write('%s %s\n' % (str(method), str(url))) + except OSError: + pass + _NYX_SINK_FILE = "/" _NYX_SINK_LINE = 13 diff --git a/tests/dynamic_fixtures/python/celery/vuln.py.golden_harness.py b/tests/dynamic_fixtures/python/celery/vuln.py.golden_harness.py index 3e62a3ea..e8917caf 100644 --- a/tests/dynamic_fixtures/python/celery/vuln.py.golden_harness.py +++ b/tests/dynamic_fixtures/python/celery/vuln.py.golden_harness.py @@ -141,6 +141,29 @@ def __nyx_stub_sql_record(query, **detail): except OSError: pass +# 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 SQL +# helper so the host-side merger parses both streams identically. +def __nyx_stub_http_record(method, url, body=None, **detail): + import os + p = os.environ.get("NYX_HTTP_LOG") + if not p: + return + try: + with open(p, "a") as _f: + _f.write('# method: %s\n' % str(method)) + _f.write('# url: %s\n' % str(url)) + if body is not None: + _f.write('# body: %s\n' % str(body)) + for k, v in detail.items(): + _f.write('# %s: %s\n' % (str(k), str(v))) + _f.write('%s %s\n' % (str(method), str(url))) + except OSError: + pass + _NYX_SINK_FILE = "/" _NYX_SINK_LINE = 17 diff --git a/tests/dynamic_fixtures/python/cli/vuln.py.golden_harness.py b/tests/dynamic_fixtures/python/cli/vuln.py.golden_harness.py index 8ec02588..f51f903f 100644 --- a/tests/dynamic_fixtures/python/cli/vuln.py.golden_harness.py +++ b/tests/dynamic_fixtures/python/cli/vuln.py.golden_harness.py @@ -141,6 +141,29 @@ def __nyx_stub_sql_record(query, **detail): except OSError: pass +# 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 SQL +# helper so the host-side merger parses both streams identically. +def __nyx_stub_http_record(method, url, body=None, **detail): + import os + p = os.environ.get("NYX_HTTP_LOG") + if not p: + return + try: + with open(p, "a") as _f: + _f.write('# method: %s\n' % str(method)) + _f.write('# url: %s\n' % str(url)) + if body is not None: + _f.write('# body: %s\n' % str(body)) + for k, v in detail.items(): + _f.write('# %s: %s\n' % (str(k), str(v))) + _f.write('%s %s\n' % (str(method), str(url))) + except OSError: + pass + _NYX_SINK_FILE = "/" _NYX_SINK_LINE = 14 diff --git a/tests/dynamic_fixtures/python/django/vuln.py.golden_harness.py b/tests/dynamic_fixtures/python/django/vuln.py.golden_harness.py index 87c892a6..608f1bb3 100644 --- a/tests/dynamic_fixtures/python/django/vuln.py.golden_harness.py +++ b/tests/dynamic_fixtures/python/django/vuln.py.golden_harness.py @@ -141,6 +141,29 @@ def __nyx_stub_sql_record(query, **detail): except OSError: pass +# 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 SQL +# helper so the host-side merger parses both streams identically. +def __nyx_stub_http_record(method, url, body=None, **detail): + import os + p = os.environ.get("NYX_HTTP_LOG") + if not p: + return + try: + with open(p, "a") as _f: + _f.write('# method: %s\n' % str(method)) + _f.write('# url: %s\n' % str(url)) + if body is not None: + _f.write('# body: %s\n' % str(body)) + for k, v in detail.items(): + _f.write('# %s: %s\n' % (str(k), str(v))) + _f.write('%s %s\n' % (str(method), str(url))) + except OSError: + pass + _NYX_SINK_FILE = "/" _NYX_SINK_LINE = 15 diff --git a/tests/dynamic_fixtures/python/fastapi/vuln.py.golden_harness.py b/tests/dynamic_fixtures/python/fastapi/vuln.py.golden_harness.py index 3b337ba8..dd9ad641 100644 --- a/tests/dynamic_fixtures/python/fastapi/vuln.py.golden_harness.py +++ b/tests/dynamic_fixtures/python/fastapi/vuln.py.golden_harness.py @@ -141,6 +141,29 @@ def __nyx_stub_sql_record(query, **detail): except OSError: pass +# 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 SQL +# helper so the host-side merger parses both streams identically. +def __nyx_stub_http_record(method, url, body=None, **detail): + import os + p = os.environ.get("NYX_HTTP_LOG") + if not p: + return + try: + with open(p, "a") as _f: + _f.write('# method: %s\n' % str(method)) + _f.write('# url: %s\n' % str(url)) + if body is not None: + _f.write('# body: %s\n' % str(body)) + for k, v in detail.items(): + _f.write('# %s: %s\n' % (str(k), str(v))) + _f.write('%s %s\n' % (str(method), str(url))) + except OSError: + pass + _NYX_SINK_FILE = "/" _NYX_SINK_LINE = 16 diff --git a/tests/dynamic_fixtures/python/flask/vuln.py.golden_harness.py b/tests/dynamic_fixtures/python/flask/vuln.py.golden_harness.py index 66b80917..58da0355 100644 --- a/tests/dynamic_fixtures/python/flask/vuln.py.golden_harness.py +++ b/tests/dynamic_fixtures/python/flask/vuln.py.golden_harness.py @@ -141,6 +141,29 @@ def __nyx_stub_sql_record(query, **detail): except OSError: pass +# 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 SQL +# helper so the host-side merger parses both streams identically. +def __nyx_stub_http_record(method, url, body=None, **detail): + import os + p = os.environ.get("NYX_HTTP_LOG") + if not p: + return + try: + with open(p, "a") as _f: + _f.write('# method: %s\n' % str(method)) + _f.write('# url: %s\n' % str(url)) + if body is not None: + _f.write('# body: %s\n' % str(body)) + for k, v in detail.items(): + _f.write('# %s: %s\n' % (str(k), str(v))) + _f.write('%s %s\n' % (str(method), str(url))) + except OSError: + pass + _NYX_SINK_FILE = "/" _NYX_SINK_LINE = 18 diff --git a/tests/dynamic_fixtures/python/generic/vuln.py.golden_harness.py b/tests/dynamic_fixtures/python/generic/vuln.py.golden_harness.py index f5fbc41a..3ce25280 100644 --- a/tests/dynamic_fixtures/python/generic/vuln.py.golden_harness.py +++ b/tests/dynamic_fixtures/python/generic/vuln.py.golden_harness.py @@ -141,6 +141,29 @@ def __nyx_stub_sql_record(query, **detail): except OSError: pass +# 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 SQL +# helper so the host-side merger parses both streams identically. +def __nyx_stub_http_record(method, url, body=None, **detail): + import os + p = os.environ.get("NYX_HTTP_LOG") + if not p: + return + try: + with open(p, "a") as _f: + _f.write('# method: %s\n' % str(method)) + _f.write('# url: %s\n' % str(url)) + if body is not None: + _f.write('# body: %s\n' % str(body)) + for k, v in detail.items(): + _f.write('# %s: %s\n' % (str(k), str(v))) + _f.write('%s %s\n' % (str(method), str(url))) + except OSError: + pass + _NYX_SINK_FILE = "/" _NYX_SINK_LINE = 12 diff --git a/tests/dynamic_fixtures/python/pytest/vuln.py.golden_harness.py b/tests/dynamic_fixtures/python/pytest/vuln.py.golden_harness.py index 1fa4b18c..76ef61ad 100644 --- a/tests/dynamic_fixtures/python/pytest/vuln.py.golden_harness.py +++ b/tests/dynamic_fixtures/python/pytest/vuln.py.golden_harness.py @@ -141,6 +141,29 @@ def __nyx_stub_sql_record(query, **detail): except OSError: pass +# 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 SQL +# helper so the host-side merger parses both streams identically. +def __nyx_stub_http_record(method, url, body=None, **detail): + import os + p = os.environ.get("NYX_HTTP_LOG") + if not p: + return + try: + with open(p, "a") as _f: + _f.write('# method: %s\n' % str(method)) + _f.write('# url: %s\n' % str(url)) + if body is not None: + _f.write('# body: %s\n' % str(body)) + for k, v in detail.items(): + _f.write('# %s: %s\n' % (str(k), str(v))) + _f.write('%s %s\n' % (str(method), str(url))) + except OSError: + pass + _NYX_SINK_FILE = "/" _NYX_SINK_LINE = 14 diff --git a/tests/dynamic_fixtures/stubs_e2e/python/http/vuln/main.py b/tests/dynamic_fixtures/stubs_e2e/python/http/vuln/main.py new file mode 100644 index 00000000..b646da5c --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/python/http/vuln/main.py @@ -0,0 +1,36 @@ +"""Phase 10 (Track D.3) stub-end-to-end fixture: Python + 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 +Python 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::python::probe_shim`` ahead of this source, runs +it with both env vars set, and asserts the stub captured the attempt. +""" + +import os + + +def main(): + method = "GET" + url = "http://169.254.169.254/latest/meta-data/" + 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="urllib") + # Echo so the host can confirm the driver ran end-to-end. + print(os.environ.get("NYX_HTTP_ENDPOINT", "no-endpoint")) + + +if __name__ == "__main__": + main() diff --git a/tests/stubs_e2e_per_lang.rs b/tests/stubs_e2e_per_lang.rs index 1749cfad..94728005 100644 --- a/tests/stubs_e2e_per_lang.rs +++ b/tests/stubs_e2e_per_lang.rs @@ -23,7 +23,7 @@ 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::stubs::{SqlStub, StubProvider}; +use nyx_scanner::dynamic::stubs::{HttpStub, SqlStub, StubProvider}; use std::path::PathBuf; use std::process::Command; use tempfile::TempDir; @@ -334,6 +334,113 @@ fn php_sql_shim_recorder_is_noop_without_log_env() { ); } +#[test] +fn python_http_stub_captures_attempted_outbound_via_shim_recorder() { + // Phase 10 (Track D.3) HTTP recording: the side-channel + // `__nyx_stub_http_record` lets a harness surface outbound HTTP + // attempts even when the request never reaches the on-the-wire + // listener (DNS-mocked, network-isolated sandbox, pre-flight + // check). This test drives the Python helper. + if !python3_available() { + eprintln!("SKIP: python3 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("python/http/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_http.py"); + std::fs::write(&script_path, combined).expect("write driver"); + + let output = Command::new("python3") + .arg(&script_path) + .env("NYX_HTTP_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(), + "HttpStub must capture at least one event after the 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("urllib"), + "kwargs passed to __nyx_stub_http_record must surface as event detail entries" + ); +} + +#[test] +fn python_http_shim_recorder_is_noop_without_log_env() { + if !python3_available() { + eprintln!("SKIP: python3 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("python/http/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_http_no_log.py"); + std::fs::write(&script_path, combined).expect("write driver"); + + let output = Command::new("python3") + .arg(&script_path) + .env("NYX_HTTP_ENDPOINT", &endpoint) + .env_remove("NYX_HTTP_LOG") + .output() + .expect("python3 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() { diff --git a/tests/stubs_per_cap.rs b/tests/stubs_per_cap.rs index 1b2ccf91..5301cad4 100644 --- a/tests/stubs_per_cap.rs +++ b/tests/stubs_per_cap.rs @@ -159,7 +159,8 @@ fn sql_stub_captured_query_threads_through_probe_predicate() { #[test] fn http_stub_vuln_fixture_confirms_recorded_request() { - let stub = HttpStub::start().unwrap(); + let workdir = TempDir::new().unwrap(); + let stub = HttpStub::start(workdir.path()).unwrap(); let payload = extract_payload(&read_fixture("http", "vuln.txt")); assert!(payload.contains("169.254"), "vuln fixture must carry metadata host"); @@ -177,7 +178,8 @@ fn http_stub_vuln_fixture_confirms_recorded_request() { #[test] fn http_stub_benign_fixture_does_not_confirm() { - let stub = HttpStub::start().unwrap(); + let workdir = TempDir::new().unwrap(); + let stub = HttpStub::start(workdir.path()).unwrap(); let payload = extract_payload(&read_fixture("http", "benign.txt")); stub.record(payload); let events = stub.drain_events();