From e360a1db583eb672c8476e1673b2699fbc415631 Mon Sep 17 00:00:00 2001 From: pitboss Date: Fri, 22 May 2026 16:56:12 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0013 (20260522T163126Z-7d60) --- src/dynamic/lang/rust.rs | 616 ++++++++++++++++++++++++++++++++ tests/data_exfil_corpus.rs | 48 ++- tests/unauthorized_id_corpus.rs | 45 ++- 3 files changed, 701 insertions(+), 8 deletions(-) diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index e4599423..ad704d56 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -1553,6 +1553,353 @@ fn main() {{ } } +/// Phase 11 (Track J.9) UNAUTHORIZED_ID IDOR harness for Rust. +/// +/// Stages the fixture at `src/entry.rs`, invokes +/// `entry::(&payload)` which is expected to return an +/// `Option<_>`, and emits a +/// [`crate::dynamic::probe::ProbeKind::IdorAccess`] probe iff the +/// fixture materialises a `Some(_)` record. The +/// [`crate::dynamic::oracle::ProbePredicate::IdorBoundaryCrossed`] +/// predicate fires when `caller_id != owner_id`; the harness pins +/// `caller_id = "alice"` and treats the payload as `owner_id`. Falls +/// back to a payload-only path that emits an +/// `IdorAccess(alice, payload)` probe when the fixture source is +/// unreachable so the universal sink-hit path still fires. +pub fn emit_unauthorized_id_harness(spec: &HarnessSpec) -> HarnessSource { + let shim = probe_shim(); + let entry_fn = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; + let cargo_toml = generate_cargo_toml(Cap::UNAUTHORIZED_ID); + let entry_source = read_entry_source(&spec.entry_file); + let tier_a_active = !entry_source.is_empty(); + + let (mod_decls, via_fixture_invoke, extra_files, entry_subpath) = if tier_a_active { + let extras: Vec<(String, String)> = vec![ + ("Cargo.toml".into(), cargo_toml), + ("src/entry.rs".into(), entry_source.clone()), + ]; + let invoke = format!( + " let nyx_record = entry::{entry_fn}(&payload);\n if nyx_record.is_some() {{\n nyx_idor_access_probe(_NYX_CALLER_ID, &payload);\n }}\n", + ); + ( + "mod entry;\n".to_owned(), + invoke, + extras, + Some("ignored/raw_fixture.rs".to_owned()), + ) + } else { + let extras: Vec<(String, String)> = vec![("Cargo.toml".into(), cargo_toml)]; + let invoke = + " nyx_idor_access_probe(_NYX_CALLER_ID, &payload);\n".to_owned(); + ( + String::new(), + invoke, + extras, + Some("ignored/raw_fixture.rs".to_owned()), + ) + }; + + let main_rs = format!( + r##"//! Nyx dynamic harness — UNAUTHORIZED_ID IDOR boundary (Phase 11 / Track J.9). +{mod_decls}use std::env; +use std::fs::OpenOptions; +use std::io::Write; +use std::time::{{SystemTime, UNIX_EPOCH}}; + +{shim} + +const _NYX_CALLER_ID: &str = "alice"; + +fn nyx_idor_access_probe(caller: &str, owner: &str) {{ + let p = match env::var("NYX_PROBE_PATH") {{ Ok(s) => s, Err(_) => return }}; + if p.is_empty() {{ return; }} + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let payload_id = env::var("NYX_PAYLOAD_ID").unwrap_or_default(); + let mut esc_caller = String::new(); + __nyx_esc(caller, &mut esc_caller); + let mut esc_owner = String::new(); + __nyx_esc(owner, &mut esc_owner); + let mut esc_pid = String::new(); + __nyx_esc(&payload_id, &mut esc_pid); + let mut line = String::with_capacity(256); + line.push_str("{{\"sink_callee\":\"__nyx_idor_lookup\",\"args\":["); + line.push_str("{{\"kind\":\"String\",\"value\":\""); + line.push_str(&esc_caller); + line.push_str("\"}},{{\"kind\":\"String\",\"value\":\""); + line.push_str(&esc_owner); + line.push_str("\"}}],"); + line.push_str("\"captured_at_ns\":"); + line.push_str(&now.to_string()); + line.push_str(",\"payload_id\":\""); + line.push_str(&esc_pid); + line.push_str("\",\"kind\":{{\"kind\":\"IdorAccess\",\"caller_id\":\""); + line.push_str(&esc_caller); + line.push_str("\",\"owner_id\":\""); + line.push_str(&esc_owner); + line.push_str("\"}},\"witness\":"); + line.push_str(&__nyx_witness_json("__nyx_idor_lookup", &[caller, owner])); + line.push_str("}}\n"); + if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&p) {{ + let _ = f.write_all(line.as_bytes()); + }} +}} + +fn main() {{ + __nyx_install_crash_guard("__nyx_idor_lookup"); + let payload = env::var("NYX_PAYLOAD").unwrap_or_default(); +{via_fixture_invoke} println!("__NYX_SINK_HIT__"); + println!("{{{{\"payload_len\":{{}}}}}}", payload.len()); +}} +"## + ); + + HarnessSource { + source: main_rs, + filename: "src/main.rs".into(), + command: vec!["target/release/nyx_harness".into()], + extra_files, + entry_subpath, + } +} + +/// Phase 11 (Track J.9) DATA_EXFIL outbound-network harness for Rust. +/// +/// Rust has no monkey-patch hook for `reqwest::blocking::get` / +/// `reqwest::get`, but the emitter ships an `nyx_http` module via +/// `extra_files` that exposes the same surface area (`get` / +/// `blocking::get`) and rewrites the fixture's `reqwest::` references +/// to `crate::nyx_http::` so the outbound call routes through a +/// host-capturing shim. The shim parses the URL host, emits a +/// [`crate::dynamic::probe::ProbeKind::OutboundNetwork`] probe, and +/// returns a benign stand-in `Response` whose `text()` returns an +/// empty string. No real network egress; no `reqwest` dep is added +/// to `Cargo.toml`, so the harness build avoids the multi-minute +/// reqwest compilation tax. Falls back to a payload-only path that +/// emits an `OutboundNetwork(payload)` probe when the fixture source +/// is unreachable so the universal sink-hit path still fires. +pub fn emit_data_exfil_harness(spec: &HarnessSpec) -> HarnessSource { + let shim = probe_shim(); + let entry_fn = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; + let cargo_toml = generate_cargo_toml(Cap::DATA_EXFIL); + let entry_source = read_entry_source(&spec.entry_file); + let tier_a_active = !entry_source.is_empty(); + + let (mod_decls, via_fixture_invoke, extra_files, entry_subpath) = if tier_a_active { + let rewritten = rewrite_reqwest_imports(&entry_source); + let extras: Vec<(String, String)> = vec![ + ("Cargo.toml".into(), cargo_toml), + ("src/entry.rs".into(), rewritten), + ("src/nyx_http.rs".into(), nyx_http_module_source().to_owned()), + ]; + let invoke = format!( + " let _ = entry::{entry_fn}(&payload);\n", + ); + ( + "mod entry;\nmod nyx_http;\n".to_owned(), + invoke, + extras, + Some("ignored/raw_fixture.rs".to_owned()), + ) + } else { + let extras: Vec<(String, String)> = vec![("Cargo.toml".into(), cargo_toml)]; + let invoke = " nyx_outbound_probe(&payload);\n".to_owned(); + ( + String::new(), + invoke, + extras, + Some("ignored/raw_fixture.rs".to_owned()), + ) + }; + + let main_rs = format!( + r##"//! Nyx dynamic harness — DATA_EXFIL outbound-host (Phase 11 / Track J.9). +{mod_decls}use std::env; +use std::fs::OpenOptions; +use std::io::Write; +use std::time::{{SystemTime, UNIX_EPOCH}}; + +{shim} + +pub fn nyx_outbound_probe(host: &str) {{ + let p = match env::var("NYX_PROBE_PATH") {{ Ok(s) => s, Err(_) => return }}; + if p.is_empty() {{ return; }} + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let payload_id = env::var("NYX_PAYLOAD_ID").unwrap_or_default(); + let mut esc_host = String::new(); + __nyx_esc(host, &mut esc_host); + let mut esc_pid = String::new(); + __nyx_esc(&payload_id, &mut esc_pid); + let mut line = String::with_capacity(256); + line.push_str("{{\"sink_callee\":\"__nyx_mock_http\",\"args\":["); + line.push_str("{{\"kind\":\"String\",\"value\":\""); + line.push_str(&esc_host); + line.push_str("\"}}],"); + line.push_str("\"captured_at_ns\":"); + line.push_str(&now.to_string()); + line.push_str(",\"payload_id\":\""); + line.push_str(&esc_pid); + line.push_str("\",\"kind\":{{\"kind\":\"OutboundNetwork\",\"host\":\""); + line.push_str(&esc_host); + line.push_str("\"}},\"witness\":"); + line.push_str(&__nyx_witness_json("__nyx_mock_http", &[host])); + line.push_str("}}\n"); + if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&p) {{ + let _ = f.write_all(line.as_bytes()); + }} +}} + +fn main() {{ + __nyx_install_crash_guard("__nyx_mock_http"); + let payload = env::var("NYX_PAYLOAD").unwrap_or_default(); +{via_fixture_invoke} println!("__NYX_SINK_HIT__"); + println!("{{{{\"payload_len\":{{}}}}}}", payload.len()); +}} +"## + ); + + HarnessSource { + source: main_rs, + filename: "src/main.rs".into(), + command: vec!["target/release/nyx_harness".into()], + extra_files, + entry_subpath, + } +} + +/// Rewrite `reqwest::` references in the fixture source to +/// `crate::nyx_http::` so the fixture's outbound call routes through +/// the harness-supplied shim. Idempotent and byte-level: matches both +/// the `reqwest::blocking::get` form (today's curated Rust DATA_EXFIL +/// fixtures) and the bare `reqwest::get` form (async variant). A +/// `use reqwest::...;` line is normalised to `use crate::nyx_http::...;` +/// by the same prefix replacement. +fn rewrite_reqwest_imports(src: &str) -> String { + src.replace("reqwest::", "crate::nyx_http::") +} + +/// Source for the `nyx_http` module — permissive stand-in for the +/// fraction of `reqwest::blocking` / `reqwest` the curated Rust +/// DATA_EXFIL fixtures use (`blocking::get(url)` returning a result- +/// shaped value whose `text()` is callable). The shim parses the URL +/// host, calls [`crate::nyx_outbound_probe`] on the main crate, and +/// returns a benign empty `Response`. No real wire I/O. +fn nyx_http_module_source() -> &'static str { + r##"//! Permissive `reqwest` stand-in — record outbound host bytes verbatim. +#![allow(dead_code)] + +pub struct Response; + +impl Response { + pub fn text(self) -> Result { + Ok(String::new()) + } + + pub fn bytes(self) -> Result, NyxHttpError> { + Ok(Vec::new()) + } + + pub fn status(&self) -> u16 { + 200 + } +} + +#[derive(Debug)] +pub struct NyxHttpError; + +impl std::fmt::Display for NyxHttpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "nyx-http error") + } +} + +impl std::error::Error for NyxHttpError {} + +fn extract_host(url: &str) -> String { + let rest = match url.find("://") { + Some(i) => &url[i + 3..], + None => url, + }; + let end = rest.find(|c: char| c == '/' || c == '?' || c == '#').unwrap_or(rest.len()); + let authority = &rest[..end]; + match authority.rfind(':') { + Some(i) => authority[..i].to_owned(), + None => authority.to_owned(), + } +} + +fn capture>(url: U) -> Result { + let host = extract_host(url.as_ref()); + crate::nyx_outbound_probe(&host); + Ok(Response) +} + +/// Top-level `reqwest::get` shape (async stub). Returns synchronously +/// because the curated fixtures discard the future / response; if a +/// future fixture awaits the value the discard still type-checks. +pub fn get>(url: U) -> Result { + capture(url) +} + +pub mod blocking { + use super::{NyxHttpError, Response, capture}; + + pub fn get>(url: U) -> Result { + capture(url) + } + + pub struct Client; + + impl Client { + pub fn new() -> Self { + Self + } + + pub fn get>(&self, url: U) -> RequestBuilder { + RequestBuilder { url: url.as_ref().to_owned() } + } + } + + impl Default for Client { + fn default() -> Self { + Self::new() + } + } + + pub struct RequestBuilder { + url: String, + } + + impl RequestBuilder { + pub fn send(self) -> Result { + capture(self.url.as_str()) + } + + pub fn header, V: AsRef>(self, _key: K, _value: V) -> Self { + self + } + + pub fn body(self, _body: B) -> Self { + self + } + } +} +"## +} + /// Emit a Rust harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { // Phase 08 (Track J.6): HEADER_INJECTION-sink short-circuit. The @@ -1594,6 +1941,29 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_json_parse_harness(spec)); } + // Phase 11 (Track J.9): UNAUTHORIZED_ID IDOR short-circuit. Stages + // the fixture at `src/entry.rs`, invokes `entry::(&payload)` + // which is expected to return an `Option<_>`, and emits a + // `ProbeKind::IdorAccess { caller_id: "alice", owner_id: payload }` + // record iff the fixture materialised a `Some(_)` record so the + // benign fixture's `None` boundary-cross rejection clears the + // predicate. + if spec.expected_cap == crate::labels::Cap::UNAUTHORIZED_ID { + return Ok(emit_unauthorized_id_harness(spec)); + } + + // Phase 11 (Track J.9): DATA_EXFIL outbound-network short-circuit. + // Rust has no monkey-patch hook for `reqwest::blocking::get`, but + // the emitter ships an `nyx_http` module via `extra_files` and + // rewrites `reqwest::` references in the fixture source to + // `crate::nyx_http::` so the fixture's outbound call routes through + // a host-capturing shim that emits a `ProbeKind::OutboundNetwork` + // record before returning a benign stand-in `Response`. No real + // network egress. + if spec.expected_cap == crate::labels::Cap::DATA_EXFIL { + return Ok(emit_data_exfil_harness(spec)); + } + // Phase 19 (Track M.1): ClassMethod short-circuit. Rust has no // class system — the dispatcher maps `class` to a struct exported // from `entry::`, and `method` to a `&self` method on that @@ -3297,4 +3667,250 @@ mod tests { assert!(h.source.contains("depth > 64")); assert!(h.source.contains("__NYX_SINK_HIT__")); } + + // ── Phase 11 (Track J.9) Rust UNAUTHORIZED_ID emitter tests ──────────────── + + fn make_unauthorized_id_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::UNAUTHORIZED_ID; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + #[test] + fn emit_dispatches_to_unauthorized_id_harness_when_cap_is_unauthorized_id() { + let h = emit(&make_unauthorized_id_spec( + "tests/dynamic_fixtures/unauthorized_id/rust/vuln.rs", + "run", + )) + .unwrap(); + assert!( + h.source.contains("nyx_idor_access_probe"), + "dispatcher must short-circuit Cap::UNAUTHORIZED_ID into emit_unauthorized_id_harness so the IDOR probe shim is present", + ); + assert!( + h.source.contains(r#"\"kind\":\"IdorAccess\""#), + "Rust UNAUTHORIZED_ID harness must record probes with kind IdorAccess so IdorBoundaryCrossed fires", + ); + } + + #[test] + fn emit_unauthorized_id_harness_pins_caller_id() { + let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec( + "tests/dynamic_fixtures/unauthorized_id/rust/vuln.rs", + "run", + )); + assert!( + h.source + .contains("const _NYX_CALLER_ID: &str = \"alice\";"), + "Rust UNAUTHORIZED_ID harness must pin caller_id to \"alice\"", + ); + assert!( + h.source + .contains("nyx_idor_access_probe(_NYX_CALLER_ID, &payload)"), + "Rust UNAUTHORIZED_ID harness must call probe with caller_id + payload-as-owner", + ); + } + + #[test] + fn emit_unauthorized_id_harness_gates_probe_on_some_record() { + let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec( + "tests/dynamic_fixtures/unauthorized_id/rust/benign.rs", + "run", + )); + assert!( + h.source.contains("let nyx_record = entry::run(&payload);"), + "Rust UNAUTHORIZED_ID harness must invoke the fixture entry and bind its return", + ); + assert!( + h.source.contains("if nyx_record.is_some() {"), + "Rust UNAUTHORIZED_ID harness must gate probe emission on Some so the benign fixture's None boundary-cross rejection clears the predicate", + ); + } + + #[test] + fn emit_unauthorized_id_harness_stages_entry_via_extra_files() { + let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec( + "tests/dynamic_fixtures/unauthorized_id/rust/vuln.rs", + "run", + )); + let staged = h + .extra_files + .iter() + .find(|(name, _)| name == "src/entry.rs"); + assert!( + staged.is_some(), + "tier-(a) UNAUTHORIZED_ID harness must stage the fixture at src/entry.rs so `mod entry;` resolves", + ); + let body = &staged.unwrap().1; + assert!( + body.contains("pub fn run(owner_id: &str)"), + "staged entry.rs must carry the fixture's `run` signature verbatim", + ); + assert!( + h.source.contains("mod entry;"), + "main.rs must declare `mod entry;` so the staged file is reachable", + ); + assert_eq!( + h.entry_subpath, + Some("ignored/raw_fixture.rs".to_owned()), + "entry_subpath must park the runner's raw copy out of the way so the staged tier-(a) copy wins", + ); + } + + #[test] + fn emit_unauthorized_id_harness_falls_back_when_fixture_source_unavailable() { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::UNAUTHORIZED_ID; + spec.entry_file = "/nonexistent/path/missing.rs".into(); + spec.entry_name = "run".into(); + let h = emit_unauthorized_id_harness(&spec); + let staged = h + .extra_files + .iter() + .find(|(name, _)| name == "src/entry.rs"); + assert!( + staged.is_none(), + "fallback path must not stage an entry copy when the fixture cannot be read", + ); + assert!( + !h.source.contains("mod entry;"), + "fallback path must omit `mod entry;` so the harness compiles without src/entry.rs", + ); + assert!( + h.source + .contains("nyx_idor_access_probe(_NYX_CALLER_ID, &payload)"), + "fallback path must still emit an IDOR probe so the universal sink-hit path fires", + ); + } + + // ── Phase 11 (Track J.9) Rust DATA_EXFIL emitter tests ───────────────────── + + fn make_data_exfil_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::DATA_EXFIL; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + #[test] + fn emit_dispatches_to_data_exfil_harness_when_cap_is_data_exfil() { + let h = emit(&make_data_exfil_spec( + "tests/dynamic_fixtures/data_exfil/rust/vuln.rs", + "run", + )) + .unwrap(); + assert!( + h.source.contains("nyx_outbound_probe"), + "dispatcher must short-circuit Cap::DATA_EXFIL into emit_data_exfil_harness so the outbound probe shim is present", + ); + assert!( + h.source.contains(r#"\"kind\":\"OutboundNetwork\""#), + "Rust DATA_EXFIL harness must record probes with kind OutboundNetwork so OutboundHostNotIn fires", + ); + } + + #[test] + fn emit_data_exfil_harness_ships_nyx_http_shim() { + let h = emit_data_exfil_harness(&make_data_exfil_spec( + "tests/dynamic_fixtures/data_exfil/rust/vuln.rs", + "run", + )); + let shim = h + .extra_files + .iter() + .find(|(name, _)| name == "src/nyx_http.rs"); + assert!( + shim.is_some(), + "Rust DATA_EXFIL harness must ship the nyx_http shim so the rewritten fixture compiles without a real reqwest dep", + ); + let body = &shim.unwrap().1; + assert!( + body.contains("pub mod blocking"), + "nyx_http shim must expose the `blocking` submodule the fixture's reqwest::blocking path rewrites to", + ); + assert!( + body.contains("crate::nyx_outbound_probe"), + "nyx_http shim must call back into the harness's nyx_outbound_probe so the captured host is emitted", + ); + assert!( + h.source.contains("mod nyx_http;"), + "main.rs must declare `mod nyx_http;` so the shim resolves at crate-root", + ); + } + + #[test] + fn emit_data_exfil_harness_rewrites_reqwest_imports_in_staged_entry() { + let h = emit_data_exfil_harness(&make_data_exfil_spec( + "tests/dynamic_fixtures/data_exfil/rust/vuln.rs", + "run", + )); + let staged = h + .extra_files + .iter() + .find(|(name, _)| name == "src/entry.rs"); + assert!( + staged.is_some(), + "tier-(a) DATA_EXFIL harness must stage the rewritten fixture at src/entry.rs", + ); + let body = &staged.unwrap().1; + assert!( + !body.contains("reqwest::blocking"), + "staged entry.rs must have `reqwest::` references rewritten away so the harness does not pull the real reqwest dep", + ); + assert!( + body.contains("crate::nyx_http::blocking"), + "staged entry.rs must route reqwest::blocking calls through crate::nyx_http::blocking", + ); + } + + #[test] + fn rewrite_reqwest_imports_is_idempotent_and_byte_level() { + let src = "use reqwest::blocking::Client;\nlet _ = reqwest::blocking::get(&url);\nlet _ = reqwest::get(&u).await;"; + let once = rewrite_reqwest_imports(src); + assert!(once.contains("crate::nyx_http::blocking::Client")); + assert!(once.contains("crate::nyx_http::blocking::get(&url)")); + assert!(once.contains("crate::nyx_http::get(&u)")); + let twice = rewrite_reqwest_imports(&once); + assert_eq!(once, twice, "rewrite must be idempotent"); + } + + #[test] + fn emit_data_exfil_harness_invokes_fixture_entry() { + let h = emit_data_exfil_harness(&make_data_exfil_spec( + "tests/dynamic_fixtures/data_exfil/rust/vuln.rs", + "run", + )); + assert!( + h.source.contains("let _ = entry::run(&payload);"), + "Rust DATA_EXFIL harness must invoke entry::run via the rewritten fixture so reqwest calls land in the shim", + ); + } + + #[test] + fn emit_data_exfil_harness_falls_back_when_fixture_source_unavailable() { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::DATA_EXFIL; + spec.entry_file = "/nonexistent/path/missing.rs".into(); + spec.entry_name = "run".into(); + let h = emit_data_exfil_harness(&spec); + let staged = h + .extra_files + .iter() + .find(|(name, _)| name == "src/nyx_http.rs"); + assert!( + staged.is_none(), + "fallback path must not stage the nyx_http shim when the fixture cannot be read", + ); + assert!( + !h.source.contains("mod entry;"), + "fallback path must omit `mod entry;` so the harness compiles without src/entry.rs", + ); + assert!( + h.source.contains("nyx_outbound_probe(&payload)"), + "fallback path must still emit an outbound probe so the universal sink-hit path fires", + ); + } } diff --git a/tests/data_exfil_corpus.rs b/tests/data_exfil_corpus.rs index 578a1c8c..6cdac0d8 100644 --- a/tests/data_exfil_corpus.rs +++ b/tests/data_exfil_corpus.rs @@ -136,8 +136,8 @@ mod e2e_data_exfil { fn command_available(bin: &str) -> bool { // Go's CLI uses `go version` (subcommand) instead of `go // --version` and exits non-zero on `--version`. Every other - // toolchain here (python3, ruby, node, javac, php) accepts - // `--version`. + // toolchain here (python3, ruby, node, javac, php, cargo) + // accepts `--version`. let arg = if bin == "go" { "version" } else { "--version" }; Command::new(bin) .arg(arg) @@ -156,8 +156,9 @@ mod e2e_data_exfil { Lang::Java => "java", Lang::Php => "php", Lang::Go => "go", + Lang::Rust => "rust", _ => unreachable!( - "DATA_EXFIL e2e currently covers Python + Ruby + JavaScript + Java + Php + Go" + "DATA_EXFIL e2e currently covers Python + Ruby + JavaScript + Java + Php + Go + Rust" ), }) .join(fixture); @@ -204,8 +205,9 @@ mod e2e_data_exfil { Lang::Java => "javac", Lang::Php => "php", Lang::Go => "go", + Lang::Rust => "cargo", _ => unreachable!( - "DATA_EXFIL e2e currently covers Python + Ruby + JavaScript + Java + Php + Go" + "DATA_EXFIL e2e currently covers Python + Ruby + JavaScript + Java + Php + Go + Rust" ), }; if !command_available(required) { @@ -448,4 +450,42 @@ mod e2e_data_exfil { "Go DATA_EXFIL benign control must not confirm via run_spec; got {outcome:?}", ); } + + /// Rust pair, same shape as Python + Ruby + JavaScript + Java + + /// Php + Go. The vuln fixture's `reqwest::blocking::get(&url)` + /// has its `reqwest::` prefix rewritten to `crate::nyx_http::` at + /// staging time so the outbound call lands in the harness-shipped + /// `nyx_http::blocking::get` shim, which parses the URL host, emits + /// a `ProbeKind::OutboundNetwork`, and returns a benign empty + /// `Response`. `OutboundHostNotIn` fires for the `attacker.test` + /// payload. The benign fixture's `!ALLOWLIST.contains(&host)` + /// guard short-circuits before reaching the rewritten reqwest call + /// for non-loopback payloads so no probe fires. Skips when `cargo` + /// is not on PATH. + #[test] + fn rust_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Rust, "vuln.rs", "run") else { + return; + }; + assert!( + outcome.triggered_by.is_some(), + "Rust DATA_EXFIL vuln must confirm via run_spec; got {outcome:?}", + ); + let diff = outcome + .differential + .as_ref() + .expect("confirmed run must carry a DifferentialOutcome"); + assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + } + + #[test] + fn rust_benign_does_not_confirm_via_run_spec() { + let Some(outcome) = run(Lang::Rust, "benign.rs", "run") else { + return; + }; + assert!( + outcome.triggered_by.is_none(), + "Rust DATA_EXFIL benign control must not confirm via run_spec; got {outcome:?}", + ); + } } diff --git a/tests/unauthorized_id_corpus.rs b/tests/unauthorized_id_corpus.rs index 4d53fe65..eb2104a3 100644 --- a/tests/unauthorized_id_corpus.rs +++ b/tests/unauthorized_id_corpus.rs @@ -127,8 +127,8 @@ mod e2e_unauthorized_id { fn command_available(bin: &str) -> bool { // Go's CLI uses `go version` (subcommand) instead of `go // --version` and exits non-zero on `--version`. Every other - // toolchain here (python3, ruby, node, javac, php) accepts - // `--version`. + // toolchain here (python3, ruby, node, javac, php, cargo) + // accepts `--version`. let arg = if bin == "go" { "version" } else { "--version" }; Command::new(bin) .arg(arg) @@ -147,8 +147,9 @@ mod e2e_unauthorized_id { Lang::Java => "java", Lang::Php => "php", Lang::Go => "go", + Lang::Rust => "rust", _ => unreachable!( - "UNAUTHORIZED_ID e2e currently covers Python + Ruby + JavaScript + Java + Php + Go" + "UNAUTHORIZED_ID e2e currently covers Python + Ruby + JavaScript + Java + Php + Go + Rust" ), }) .join(fixture); @@ -195,8 +196,9 @@ mod e2e_unauthorized_id { Lang::Java => "javac", Lang::Php => "php", Lang::Go => "go", + Lang::Rust => "cargo", _ => unreachable!( - "UNAUTHORIZED_ID e2e currently covers Python + Ruby + JavaScript + Java + Php + Go" + "UNAUTHORIZED_ID e2e currently covers Python + Ruby + JavaScript + Java + Php + Go + Rust" ), }; if !command_available(required) { @@ -430,4 +432,39 @@ mod e2e_unauthorized_id { "Go UNAUTHORIZED_ID benign control must not confirm via run_spec; got {outcome:?}", ); } + + /// Rust pair, same shape as Python + Ruby + JavaScript + Java + + /// Php + Go. The vuln fixture's `store.get(owner_id).cloned()` + /// returns `Some(_)` for any `owner_id`; the harness's `.is_some()` + /// gate fires the `IdorAccess(alice, bob)` probe and + /// `IdorBoundaryCrossed` confirms the differential. The benign + /// fixture's `if owner_id != CALLER_ID { return None; }` short- + /// circuit returns `None` for the non-caller payload so the gate + /// clears and no probe fires. Skips when `cargo` is not on PATH. + #[test] + fn rust_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Rust, "vuln.rs", "run") else { + return; + }; + assert!( + outcome.triggered_by.is_some(), + "Rust UNAUTHORIZED_ID vuln must confirm via run_spec; got {outcome:?}", + ); + let diff = outcome + .differential + .as_ref() + .expect("confirmed run must carry a DifferentialOutcome"); + assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + } + + #[test] + fn rust_benign_does_not_confirm_via_run_spec() { + let Some(outcome) = run(Lang::Rust, "benign.rs", "run") else { + return; + }; + assert!( + outcome.triggered_by.is_none(), + "Rust UNAUTHORIZED_ID benign control must not confirm via run_spec; got {outcome:?}", + ); + } }