mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0014 (20260516T052512Z-20f8)
This commit is contained in:
parent
a2cc5f7700
commit
6a169f51b8
23 changed files with 737 additions and 29 deletions
|
|
@ -336,6 +336,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness {
|
|||
)]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
extra_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -308,6 +308,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness {
|
|||
)]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
extra_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness {
|
|||
)]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
extra_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness {
|
|||
)]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
extra_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,14 @@ pub struct ChainStepHarness {
|
|||
pub filename: String,
|
||||
pub command: Vec<String>,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"<prev>"));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"#
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness {
|
|||
)]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
extra_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Mutex<Vec<StubEvent>>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
/// Tempdir holding the side-channel recording log. Drop releases
|
||||
/// the file along with the directory.
|
||||
tempdir: Option<TempDir>,
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
impl HttpStub {
|
||||
/// Bind to a random loopback port and start the accept thread.
|
||||
pub fn start() -> std::io::Result<Self> {
|
||||
/// 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<Self> {
|
||||
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<StubEvent> {
|
||||
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::<String, String>::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<StubEvent> {
|
||||
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<StubEven
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn send_request(port: u16, request: &[u8]) -> Vec<u8> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ impl StubHarness {
|
|||
seen.push(k);
|
||||
let stub: Arc<dyn StubProvider> = 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)?),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = "<TMPDIR>/<ENTRY_FILE>"
|
||||
_NYX_SINK_LINE = 13
|
||||
|
|
|
|||
|
|
@ -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 = "<TMPDIR>/<ENTRY_FILE>"
|
||||
_NYX_SINK_LINE = 17
|
||||
|
|
|
|||
|
|
@ -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 = "<TMPDIR>/<ENTRY_FILE>"
|
||||
_NYX_SINK_LINE = 14
|
||||
|
|
|
|||
|
|
@ -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 = "<TMPDIR>/<ENTRY_FILE>"
|
||||
_NYX_SINK_LINE = 15
|
||||
|
|
|
|||
|
|
@ -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 = "<TMPDIR>/<ENTRY_FILE>"
|
||||
_NYX_SINK_LINE = 16
|
||||
|
|
|
|||
|
|
@ -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 = "<TMPDIR>/<ENTRY_FILE>"
|
||||
_NYX_SINK_LINE = 18
|
||||
|
|
|
|||
|
|
@ -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 = "<TMPDIR>/<ENTRY_FILE>"
|
||||
_NYX_SINK_LINE = 12
|
||||
|
|
|
|||
|
|
@ -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 = "<TMPDIR>/<ENTRY_FILE>"
|
||||
_NYX_SINK_LINE = 14
|
||||
|
|
|
|||
36
tests/dynamic_fixtures/stubs_e2e/python/http/vuln/main.py
Normal file
36
tests/dynamic_fixtures/stubs_e2e/python/http/vuln/main.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue