[pitboss/grind] deferred session-0030 (20260521T201327Z-3848)

This commit is contained in:
pitboss 2026-05-21 23:24:40 -05:00
parent 824859008e
commit 3d8f988453
6 changed files with 306 additions and 11 deletions

View file

@ -139,12 +139,23 @@ fn compute_rust_lockfile_hash(workdir: &Path) -> String {
h.update(&content);
}
}
// Entry file is compiled into the binary, so it must be part of the cache key.
// Without this, two fixtures with the same Cargo.toml but different entry.rs
// would collide and the second would receive the wrong cached binary.
if let Ok(content) = std::fs::read(workdir.join("src").join("entry.rs")) {
h.update(b"src/entry.rs");
h.update(&content);
// Every Rust file under src/ feeds the binary so any change must
// invalidate the cache. Walk src/ recursively and hash every .rs
// file path + content in deterministic (sorted) order so the cache
// key is stable across runs. Without this, an emitter change to
// main.rs / nyx_harness_stubs.rs / etc. with no Cargo.toml /
// entry.rs change would silently re-use a stale binary built from
// the old emitter source.
let src_dir = workdir.join("src");
let mut rs_files: Vec<PathBuf> = Vec::new();
collect_rs_files(&src_dir, &src_dir, &mut rs_files);
rs_files.sort();
for rel in &rs_files {
if let Ok(content) = std::fs::read(src_dir.join(rel)) {
h.update(rel.to_string_lossy().as_bytes());
h.update(b"\0");
h.update(&content);
}
}
let out = h.finalize();
format!(
@ -153,6 +164,23 @@ fn compute_rust_lockfile_hash(workdir: &Path) -> String {
)
}
fn collect_rs_files(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_rs_files(root, &path, out);
} else if path.extension().and_then(|s| s.to_str()) == Some("rs")
&& let Ok(rel) = path.strip_prefix(root)
{
out.push(rel.to_path_buf());
}
}
}
/// Result of a successful build.
#[derive(Debug, Clone)]
pub struct BuildResult {

View file

@ -10,6 +10,15 @@
//! Benign control: same shape but redirects to the same-origin path
//! `/dashboard`, so the captured `location` has no authority
//! component and the predicate stays clear.
//!
//! OOB-nonce variant (added 2026-05-22): when the runner attaches an
//! [`crate::dynamic::oob::OobListener`] the harness follows the
//! captured `Location:` URL via a real `net/http.Get` against the
//! loopback nonce URL so the listener records the per-finding
//! callback. Ordered first so the runner exercises the OOB observation
//! path before the absolute-URL vuln below triggers and short-circuits
//! iteration; runs without a listener skip cleanly (runner
//! `oob_nonce_slot` branch).
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
use crate::dynamic::oracle::ProbePredicate;
@ -17,6 +26,26 @@ use crate::dynamic::oracle::ProbePredicate;
const ALLOWLIST: &[&str] = &["example.com"];
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
// `bytes` is unused when `oob_nonce_slot = true`; the runner
// substitutes a per-finding loopback URL (see runner.rs:405-413).
bytes: b"",
label: "open-redirect-go-oob-nonce",
oracle: Oracle::OobCallback { host: "127.0.0.1" },
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/go/vuln.go"],
oob_nonce_slot: true,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: Some(
"OOB-nonce open-redirect payload self-confirms via the per-finding listener \
callback when the harness follows the captured Location URL with net/http.Get; \
no benign URL can hit the nonce path.",
),
},
CuratedPayload {
bytes: b"https://attacker.test/",
label: "open-redirect-go-absolute",

View file

@ -9,6 +9,15 @@
//! Benign control: same shape but redirects to the same-origin path
//! `/dashboard`, so the captured `location` has no authority
//! component and the predicate stays clear.
//!
//! OOB-nonce variant (added 2026-05-22): when the runner attaches an
//! [`crate::dynamic::oob::OobListener`] the harness follows the
//! captured `Location:` URL via a zero-dep `std::net::TcpStream`
//! `GET / HTTP/1.0` against the loopback nonce URL so the listener
//! records the per-finding callback. Ordered first so the runner
//! exercises the OOB observation path before the absolute-URL vuln
//! below triggers and short-circuits iteration; runs without a
//! listener skip cleanly (runner `oob_nonce_slot` branch).
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
use crate::dynamic::oracle::ProbePredicate;
@ -16,6 +25,26 @@ use crate::dynamic::oracle::ProbePredicate;
const ALLOWLIST: &[&str] = &["example.com"];
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
// `bytes` is unused when `oob_nonce_slot = true`; the runner
// substitutes a per-finding loopback URL (see runner.rs:405-413).
bytes: b"",
label: "open-redirect-rust-oob-nonce",
oracle: Oracle::OobCallback { host: "127.0.0.1" },
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/rust/vuln.rs"],
oob_nonce_slot: true,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: Some(
"OOB-nonce open-redirect payload self-confirms via the per-finding listener \
callback when the harness follows the captured Location URL with a zero-dep \
TcpStream-based GET; no benign URL can hit the nonce path.",
),
},
CuratedPayload {
bytes: b"https://attacker.test/",
label: "open-redirect-rust-absolute",

View file

@ -972,7 +972,7 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource {
"##
));
via_fixture_invoke.push_str(
"\tif loc, ok := nyxRedirectViaFixture(payload); ok {\n\t\tnyxRedirectProbe(loc, requestHost)\n\t} else {\n\t\tnyxRedirectProbe(payload, requestHost)\n\t}\n",
"\tif loc, ok := nyxRedirectViaFixture(payload); ok {\n\t\tnyxRedirectProbe(loc, requestHost)\n\t\tnyxFollowLocation(loc)\n\t} else {\n\t\tnyxRedirectProbe(payload, requestHost)\n\t\tnyxFollowLocation(payload)\n\t}\n",
);
} else if imports_net_http {
// Plain stdlib `http.Redirect(w, r, value, status)` fixture.
@ -999,10 +999,15 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource {
"##
));
via_fixture_invoke.push_str(
"\tif loc, ok := nyxRedirectViaFixture(payload); ok {\n\t\tnyxRedirectProbe(loc, requestHost)\n\t} else {\n\t\tnyxRedirectProbe(payload, requestHost)\n\t}\n",
"\tif loc, ok := nyxRedirectViaFixture(payload); ok {\n\t\tnyxRedirectProbe(loc, requestHost)\n\t\tnyxFollowLocation(loc)\n\t} else {\n\t\tnyxRedirectProbe(payload, requestHost)\n\t\tnyxFollowLocation(payload)\n\t}\n",
);
} else {
via_fixture_invoke.push_str("\tnyxRedirectProbe(payload, requestHost)\n");
// Tier-(b) fallback gate doesn't import net/http, but the OOB
// follower itself needs it. Pull the stdlib net/http surface
// unconditionally so `nyxFollowLocation` compiles.
extra_imports.push_str("\t\"net/http\"\n");
via_fixture_invoke
.push_str("\tnyxRedirectProbe(payload, requestHost)\n\tnyxFollowLocation(payload)\n");
}
let source = format!(
@ -1034,6 +1039,30 @@ func nyxRedirectProbe(location, requestHost string) {{
}})
}}
// Phase 09 OOB closure: when the captured Location is a loopback URL,
// follow it with a real GET so the OOB listener observes the per-finding
// nonce. Skips non-loopback hosts and non-HTTP schemes (no real network
// egress). Best-effort: errors do not propagate; the listener may still
// record the TCP connect before the read fails.
func nyxFollowLocation(location string) {{
if location == "" {{
return
}}
if !(strings.HasPrefix(location, "http://127.0.0.1") ||
strings.HasPrefix(location, "http://localhost") ||
strings.HasPrefix(location, "http://host-gateway")) {{
return
}}
client := &http.Client{{Timeout: 2 * time.Second}}
resp, err := client.Get(location)
if err != nil {{
return
}}
defer resp.Body.Close()
buf := make([]byte, 1)
_, _ = resp.Body.Read(buf)
}}
{via_fixture_decl}func main() {{
__nyx_install_crash_guard("gin.Context.Redirect")
defer __nyx_recover_crash("gin.Context.Redirect")()
@ -2225,6 +2254,70 @@ mod tests {
);
}
#[test]
fn emit_open_redirect_harness_ships_follow_location_helper() {
let mut spec = make_spec(PayloadSlot::Param(0));
spec.entry_name = "Run".into();
spec.expected_cap = Cap::OPEN_REDIRECT;
spec.entry_file = "/nonexistent/missing.go".into();
let harness = emit_open_redirect_harness(&spec);
assert!(
harness.source.contains("func nyxFollowLocation(location string)"),
"OPEN_REDIRECT harness must declare the nyxFollowLocation helper",
);
assert!(
harness.source.contains("strings.HasPrefix(location, \"http://127.0.0.1\")"),
"follower must gate on loopback 127.0.0.1 host prefix",
);
assert!(
harness.source.contains("strings.HasPrefix(location, \"http://localhost\")"),
"follower must gate on loopback localhost host prefix",
);
assert!(
harness.source.contains("strings.HasPrefix(location, \"http://host-gateway\")"),
"follower must gate on loopback host-gateway prefix",
);
assert!(
harness.source.contains("client.Get(location)"),
"follower must drive a real http.Client.Get against the captured Location",
);
// Tier-(b) callsite must call the follower on the synthetic payload.
assert!(
harness
.source
.contains("nyxRedirectProbe(payload, requestHost)\n\tnyxFollowLocation(payload)"),
"tier-(b) callsite must invoke nyxFollowLocation after the synthetic probe",
);
// Even tier-(b) must pull in net/http so the follower compiles.
assert!(
harness.source.contains("\"net/http\""),
"OPEN_REDIRECT harness must always import net/http so nyxFollowLocation compiles",
);
}
#[test]
fn emit_open_redirect_harness_follows_captured_location_in_tier_a() {
let mut spec = make_spec(PayloadSlot::Param(0));
spec.entry_name = "Run".into();
spec.expected_cap = Cap::OPEN_REDIRECT;
spec.entry_file = "tests/dynamic_fixtures/open_redirect/go/vuln.go".into();
let harness = emit_open_redirect_harness(&spec);
// Tier-(a) gin: when fixture call succeeds, follow the captured loc.
assert!(
harness
.source
.contains("nyxRedirectProbe(loc, requestHost)\n\t\tnyxFollowLocation(loc)"),
"tier-(a) callsite must invoke nyxFollowLocation on the captured Location",
);
// Tier-(a) fixture-call-failed branch falls back to payload-as-loc.
assert!(
harness
.source
.contains("nyxRedirectProbe(payload, requestHost)\n\t\tnyxFollowLocation(payload)"),
"tier-(a) fixture-failure branch must still follow the synthetic payload",
);
}
#[test]
fn gin_stub_pkg_exposes_redirect_method() {
let stub = gin_stub_pkg();

View file

@ -923,9 +923,9 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource {
"##
);
via_fixture_invoke = " let location = match nyx_redirect_via_fixture(payload.clone()) {\n Some(loc) if !loc.is_empty() => loc,\n _ => payload.clone(),\n };\n nyx_redirect_probe(&location, request_host);\n".to_owned();
via_fixture_invoke = " let location = match nyx_redirect_via_fixture(payload.clone()) {\n Some(loc) if !loc.is_empty() => loc,\n _ => payload.clone(),\n };\n nyx_redirect_probe(&location, request_host);\n nyx_follow_location(&location);\n".to_owned();
} else {
via_fixture_invoke = " let location = payload.clone();\n nyx_redirect_probe(&location, request_host);\n".to_owned();
via_fixture_invoke = " let location = payload.clone();\n nyx_redirect_probe(&location, request_host);\n nyx_follow_location(&location);\n".to_owned();
}
let cargo_toml = generate_cargo_toml(Cap::OPEN_REDIRECT);
@ -982,6 +982,52 @@ fn nyx_redirect_probe(location: &str, request_host: &str) {{
}}
}}
// Phase 09 OOB closure: when the captured Location is a loopback URL,
// follow it with a zero-dep `TcpStream` GET so the OOB listener
// observes the per-finding nonce. Skips non-loopback hosts and
// non-HTTP schemes (no real network egress). Best-effort: errors do
// not propagate; the listener may still record the TCP connect before
// the read fails.
fn nyx_follow_location(location: &str) {{
if location.is_empty() {{ return; }}
let loopback = location.starts_with("http://127.0.0.1")
|| location.starts_with("http://localhost")
|| location.starts_with("http://host-gateway");
if !loopback {{ return; }}
let rest = match location.strip_prefix("http://") {{
Some(r) => r,
None => return,
}};
let (authority, path) = match rest.find('/') {{
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, "/"),
}};
let (host, port): (&str, u16) = match authority.rfind(':') {{
Some(i) => {{
let p = authority[i + 1..].parse::<u16>().unwrap_or(80);
(&authority[..i], p)
}}
None => (authority, 80),
}};
use std::io::{{Read, Write}};
use std::net::{{TcpStream, ToSocketAddrs}};
use std::time::Duration;
let addr = match (host, port).to_socket_addrs() {{
Ok(mut it) => match it.next() {{ Some(a) => a, None => return }},
Err(_) => return,
}};
let mut stream = match TcpStream::connect_timeout(&addr, Duration::from_secs(2)) {{
Ok(s) => s,
Err(_) => return,
}};
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
let req = format!("GET {{path}} HTTP/1.0\r\nHost: {{host}}\r\nConnection: close\r\n\r\n", path = path, host = host);
let _ = stream.write_all(req.as_bytes());
let mut buf = [0u8; 1];
let _ = stream.read(&mut buf);
}}
{via_fixture_decl}fn main() {{
let payload = env::var("NYX_PAYLOAD").unwrap_or_default();
let request_host = "example.com";
@ -2252,6 +2298,60 @@ mod tests {
);
}
#[test]
fn emit_open_redirect_harness_ships_follow_location_helper() {
let mut spec = make_spec(PayloadSlot::Param(0));
spec.entry_name = "run".into();
spec.expected_cap = Cap::OPEN_REDIRECT;
spec.entry_file = "/nonexistent/missing.rs".into();
let harness = emit_open_redirect_harness(&spec);
assert!(
harness.source.contains("fn nyx_follow_location(location: &str)"),
"OPEN_REDIRECT harness must declare the nyx_follow_location helper",
);
for prefix in [
"http://127.0.0.1",
"http://localhost",
"http://host-gateway",
] {
assert!(
harness
.source
.contains(&format!("starts_with(\"{prefix}\")")),
"follower must gate on loopback {prefix} prefix",
);
}
assert!(
harness.source.contains("TcpStream::connect_timeout"),
"follower must drive a zero-dep TcpStream::connect_timeout against the captured Location",
);
assert!(
harness.source.contains("GET {path} HTTP/1.0"),
"follower must write a HTTP/1.0 GET request line",
);
// Tier-(b) callsite must call the follower on the synthetic payload.
assert!(
harness
.source
.contains("nyx_redirect_probe(&location, request_host);\n nyx_follow_location(&location);"),
"tier-(b) callsite must invoke nyx_follow_location after the synthetic probe",
);
}
#[test]
fn emit_open_redirect_harness_follows_captured_location_in_tier_a() {
let mut spec = make_spec(PayloadSlot::Param(0));
spec.entry_name = "run".into();
spec.expected_cap = Cap::OPEN_REDIRECT;
spec.entry_file = "tests/dynamic_fixtures/open_redirect/rust/vuln.rs".into();
let harness = emit_open_redirect_harness(&spec);
// Tier-(a) callsite: captured loc → probe + follow.
assert!(
harness.source.contains("nyx_redirect_probe(&location, request_host);\n nyx_follow_location(&location);"),
"tier-(a) callsite must invoke nyx_follow_location on the captured Location",
);
}
#[test]
fn cargo_toml_extras_pins_percent_encoding_when_requested() {
let cargo = generate_cargo_toml_with_extras(Cap::HEADER_INJECTION, true);