[pitboss/grind] deferred session-0013 (20260522T163126Z-7d60)

This commit is contained in:
pitboss 2026-05-22 16:56:12 -05:00
parent ad18008712
commit e360a1db58
3 changed files with 701 additions and 8 deletions

View file

@ -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::<entry_name>(&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<String, NyxHttpError> {
Ok(String::new())
}
pub fn bytes(self) -> Result<Vec<u8>, 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<U: AsRef<str>>(url: U) -> Result<Response, NyxHttpError> {
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<U: AsRef<str>>(url: U) -> Result<Response, NyxHttpError> {
capture(url)
}
pub mod blocking {
use super::{NyxHttpError, Response, capture};
pub fn get<U: AsRef<str>>(url: U) -> Result<Response, NyxHttpError> {
capture(url)
}
pub struct Client;
impl Client {
pub fn new() -> Self {
Self
}
pub fn get<U: AsRef<str>>(&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<Response, NyxHttpError> {
capture(self.url.as_str())
}
pub fn header<K: AsRef<str>, V: AsRef<str>>(self, _key: K, _value: V) -> Self {
self
}
pub fn body<B>(self, _body: B) -> Self {
self
}
}
}
"##
}
/// Emit a Rust harness for `spec`.
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
// Phase 08 (Track J.6): HEADER_INJECTION-sink short-circuit. The
@ -1594,6 +1941,29 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
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::<entry_name>(&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",
);
}
}

View file

@ -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:?}",
);
}
}

View file

@ -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:?}",
);
}
}