mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-12 19:55:14 +02:00
[pitboss] phase 06: M5.5 — Coverage-feedback payload generation + OOB listener finalized
This commit is contained in:
parent
86613f5279
commit
6f8a645077
12 changed files with 1556 additions and 69 deletions
|
|
@ -8,17 +8,51 @@
|
|||
//! mandatory benign payload is included. `Confirmed` requires the vuln oracle
|
||||
//! to fire AND the benign oracle NOT to fire. This prevents false-positives
|
||||
//! from coincidental output matches.
|
||||
//!
|
||||
//! # Corpus governance (§16.1)
|
||||
//!
|
||||
//! Every payload carries [`PayloadProvenance`], a [`since_corpus_version`],
|
||||
//! and at least one [`fixture_paths`] entry. The [`CORPUS_VERSION`] const
|
||||
//! tracks the history of incompatible corpus changes; bumping it invalidates
|
||||
//! all `dynamic_verdict_cache` entries whose spec touched the changed cap.
|
||||
|
||||
use crate::labels::Cap;
|
||||
|
||||
/// Bump when the corpus content changes in a way that invalidates previously-
|
||||
/// computed [`crate::dynamic::spec::HarnessSpec::spec_hash`] values.
|
||||
pub const CORPUS_VERSION: u32 = 2;
|
||||
///
|
||||
/// # Bump history
|
||||
///
|
||||
/// | Version | Date | Change |
|
||||
/// |---------|------------|-----------------------------------------------|
|
||||
/// | 1 | 2025-11-01 | Initial corpus (SQLi, CMDI, PATH_TRAV, SSRF, XSS) |
|
||||
/// | 2 | 2025-12-15 | SSRF OOB-variant added; oracle semantics tightened |
|
||||
/// | 3 | 2026-05-12 | Migrated to `CuratedPayload`; provenance + fixture_paths enforced; SSRF OOB-nonce slot added |
|
||||
pub const CORPUS_VERSION: u32 = 3;
|
||||
|
||||
/// A single payload + the oracle that confirms it fired.
|
||||
/// Where a payload originated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PayloadProvenance {
|
||||
/// Manually written and reviewed by the Nyx team.
|
||||
Curated,
|
||||
/// Produced by the internal mutation fuzzer (`fuzz/dynamic_corpus/`).
|
||||
/// Still requires human promotion review (§16.4) before landing here.
|
||||
InternalFuzzer,
|
||||
/// Derived from a public CVE or external security report.
|
||||
ExternalReport,
|
||||
}
|
||||
|
||||
/// A single payload entry in the curated corpus.
|
||||
///
|
||||
/// Governs both static payload bytes (or an OOB-nonce template) and the
|
||||
/// oracle used to confirm the vulnerability fired. All fields are
|
||||
/// `'static` so the corpus can live in read-only memory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Payload {
|
||||
pub struct CuratedPayload {
|
||||
/// Bytes injected into the [`crate::dynamic::spec::PayloadSlot`].
|
||||
///
|
||||
/// When [`oob_nonce_slot`] is `true` this field is ignored; the runner
|
||||
/// materialises the actual bytes from the OOB listener URL at call time.
|
||||
pub bytes: &'static [u8],
|
||||
/// Human label for logs and reports.
|
||||
pub label: &'static str,
|
||||
|
|
@ -28,8 +62,24 @@ pub struct Payload {
|
|||
/// `Confirmed` requires the vuln payload to trigger AND the benign payload
|
||||
/// NOT to trigger (differential confirmation, §4.1).
|
||||
pub is_benign: bool,
|
||||
/// Where this payload came from.
|
||||
pub provenance: PayloadProvenance,
|
||||
/// `CORPUS_VERSION` when this payload was added.
|
||||
pub since_corpus_version: u32,
|
||||
/// `CORPUS_VERSION` at which this payload was deprecated, if any.
|
||||
pub deprecated_at_corpus_version: Option<u32>,
|
||||
/// Source files that exercise this payload in the dynamic harness.
|
||||
/// At least one entry required per §16.1.
|
||||
pub fixture_paths: &'static [&'static str],
|
||||
/// When `true`, the runner generates the actual bytes from the OOB
|
||||
/// listener URL + per-finding nonce at execution time (SSRF OOB variant).
|
||||
/// The `bytes` field is unused for such payloads.
|
||||
pub oob_nonce_slot: bool,
|
||||
}
|
||||
|
||||
/// Backward-compatible type alias.
|
||||
pub type Payload = CuratedPayload;
|
||||
|
||||
/// Detection strategy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Oracle {
|
||||
|
|
@ -54,7 +104,7 @@ pub enum Oracle {
|
|||
/// | SQL_QUERY | yes | SQLI payloads (echo-query style) |
|
||||
/// | CODE_EXEC | yes | command injection echo marker |
|
||||
/// | FILE_IO | yes | path traversal + benign control |
|
||||
/// | SSRF | yes | file:// scheme + OutputContains |
|
||||
/// | SSRF | yes | file:// scheme + OOB nonce slot |
|
||||
/// | HTML_ESCAPE | yes | XSS script marker + benign control |
|
||||
/// | ENV_VAR | no | source-only cap; no sink oracle |
|
||||
/// | SHELL_ESCAPE | no | sanitizer cap; no sink oracle |
|
||||
|
|
@ -104,7 +154,7 @@ const _: () = assert!(
|
|||
add to CORPUS_SUPPORTED or CORPUS_UNSUPPORTED and update payloads_for",
|
||||
);
|
||||
|
||||
pub fn payloads_for(cap: Cap) -> &'static [Payload] {
|
||||
pub fn payloads_for(cap: Cap) -> &'static [CuratedPayload] {
|
||||
if cap.contains(Cap::SQL_QUERY) {
|
||||
return SQLI;
|
||||
}
|
||||
|
|
@ -124,10 +174,70 @@ pub fn payloads_for(cap: Cap) -> &'static [Payload] {
|
|||
}
|
||||
|
||||
/// Return the benign control payload for a cap, if one exists.
|
||||
pub fn benign_payload_for(cap: Cap) -> Option<&'static Payload> {
|
||||
pub fn benign_payload_for(cap: Cap) -> Option<&'static CuratedPayload> {
|
||||
payloads_for(cap).iter().find(|p| p.is_benign)
|
||||
}
|
||||
|
||||
/// Materialise the effective bytes for a payload.
|
||||
///
|
||||
/// For static payloads (`oob_nonce_slot == false`) returns the `bytes` slice
|
||||
/// directly. For OOB-nonce payloads, constructs the callback URL from the
|
||||
/// listener and nonce; returns `None` when no listener is configured.
|
||||
pub fn materialise_bytes<'a>(
|
||||
payload: &'a CuratedPayload,
|
||||
oob_url: Option<&str>,
|
||||
) -> Option<std::borrow::Cow<'a, [u8]>> {
|
||||
if payload.oob_nonce_slot {
|
||||
oob_url.map(|u| std::borrow::Cow::Owned(u.as_bytes().to_vec()))
|
||||
} else {
|
||||
Some(std::borrow::Cow::Borrowed(payload.bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a marker-collision audit on all corpus payloads.
|
||||
///
|
||||
/// Returns a list of `(cap_name, label, conflicting_cap_name)` triples where
|
||||
/// a payload's oracle marker string also appears in a different cap's payload
|
||||
/// bytes. An empty result is the expected (passing) state.
|
||||
pub fn audit_marker_collisions() -> Vec<(&'static str, &'static str, &'static str)> {
|
||||
// Build (cap_name, label, marker_bytes) triples for OutputContains oracles.
|
||||
let entries: &[(&str, &[CuratedPayload])] = &[
|
||||
("SQL_QUERY", SQLI),
|
||||
("CODE_EXEC", CMDI),
|
||||
("FILE_IO", PATH_TRAV),
|
||||
("SSRF", SSRF_PAYLOADS),
|
||||
("HTML_ESCAPE", XSS),
|
||||
];
|
||||
|
||||
let mut collisions = Vec::new();
|
||||
for &(cap_name, payloads) in entries {
|
||||
for p in payloads {
|
||||
if p.is_benign {
|
||||
continue;
|
||||
}
|
||||
let Oracle::OutputContains(marker) = &p.oracle else {
|
||||
continue;
|
||||
};
|
||||
let marker_bytes = marker.as_bytes();
|
||||
// Check if this marker appears in ANY other cap's payload bytes.
|
||||
for &(other_cap, other_payloads) in entries {
|
||||
if other_cap == cap_name {
|
||||
continue;
|
||||
}
|
||||
for op in other_payloads {
|
||||
if op.is_benign {
|
||||
continue;
|
||||
}
|
||||
if op.bytes.windows(marker_bytes.len()).any(|w| w == marker_bytes) {
|
||||
collisions.push((cap_name, p.label, other_cap));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
collisions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -170,7 +280,6 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn vuln_payloads_not_benign() {
|
||||
// At least one non-benign payload per supported cap.
|
||||
for cap in [Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::HTML_ESCAPE] {
|
||||
let has_vuln = payloads_for(cap).iter().any(|p| !p.is_benign);
|
||||
assert!(has_vuln, "{cap:?} must have at least one vuln (non-benign) payload");
|
||||
|
|
@ -179,85 +288,216 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn marker_uniqueness_sqli() {
|
||||
// NYX_PWN must not appear in SQLI payloads (see marker_uniqueness test).
|
||||
for p in SQLI {
|
||||
assert!(!p.bytes.windows(7).any(|w| w == b"NYX_PWN"),
|
||||
"NYX_PWN (CODE_EXEC marker) must not appear in SQLI payloads");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_payloads_have_fixture_paths() {
|
||||
let caps = [Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::SSRF, Cap::HTML_ESCAPE];
|
||||
for cap in caps {
|
||||
for p in payloads_for(cap) {
|
||||
assert!(
|
||||
!p.fixture_paths.is_empty(),
|
||||
"payload '{}' for {cap:?} must have at least one fixture_path (§16.1)",
|
||||
p.label,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_payloads_have_valid_since_corpus_version() {
|
||||
let caps = [Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::SSRF, Cap::HTML_ESCAPE];
|
||||
for cap in caps {
|
||||
for p in payloads_for(cap) {
|
||||
assert!(
|
||||
p.since_corpus_version >= 1 && p.since_corpus_version <= CORPUS_VERSION,
|
||||
"payload '{}': since_corpus_version {} out of range [1, {}]",
|
||||
p.label, p.since_corpus_version, CORPUS_VERSION,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_collisions() {
|
||||
let collisions = audit_marker_collisions();
|
||||
assert!(
|
||||
collisions.is_empty(),
|
||||
"marker collisions detected (§16.3): {collisions:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_has_oob_nonce_slot() {
|
||||
let has_oob = payloads_for(Cap::SSRF).iter().any(|p| p.oob_nonce_slot);
|
||||
assert!(has_oob, "SSRF corpus must include an OOB-nonce-slot payload");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialise_static_payload() {
|
||||
let p = &SQLI[0];
|
||||
assert!(!p.oob_nonce_slot);
|
||||
let bytes = materialise_bytes(p, None).expect("static payload must materialise without OOB");
|
||||
assert_eq!(&*bytes, p.bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialise_oob_payload_with_url() {
|
||||
let p = SSRF_PAYLOADS.iter().find(|p| p.oob_nonce_slot).expect("must have OOB payload");
|
||||
let url = "http://127.0.0.1:54321/mynonce";
|
||||
let bytes = materialise_bytes(p, Some(url)).expect("OOB payload materialises with URL");
|
||||
assert_eq!(&*bytes, url.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialise_oob_payload_without_listener_returns_none() {
|
||||
let p = SSRF_PAYLOADS.iter().find(|p| p.oob_nonce_slot).expect("must have OOB payload");
|
||||
assert!(materialise_bytes(p, None).is_none(), "no OOB URL → None");
|
||||
}
|
||||
}
|
||||
|
||||
// ── SQLi ─────────────────────────────────────────────────────────────────────
|
||||
// Payloads designed to cause the target code to echo a query containing "NYX".
|
||||
// Fixtures should print the raw query or error message to stdout/stderr.
|
||||
const SQLI: &[Payload] = &[
|
||||
Payload {
|
||||
const SQLI: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"' OR '1'='1",
|
||||
label: "sqli-tautology",
|
||||
oracle: Oracle::OutputContains("NYX_SQL_CONFIRMED"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/sqli/sqli_rusqlite_format.rs"],
|
||||
oob_nonce_slot: false,
|
||||
},
|
||||
Payload {
|
||||
CuratedPayload {
|
||||
bytes: b"' UNION SELECT 'NYX_SQL_CONFIRMED'--",
|
||||
label: "sqli-union-nyx",
|
||||
oracle: Oracle::OutputContains("NYX_SQL_CONFIRMED"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/sqli/sqli_rusqlite_format.rs"],
|
||||
oob_nonce_slot: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Command injection ─────────────────────────────────────────────────────────
|
||||
const CMDI: &[Payload] = &[Payload {
|
||||
const CMDI: &[CuratedPayload] = &[CuratedPayload {
|
||||
bytes: b"; echo NYX_PWN_CMDI",
|
||||
label: "cmdi-echo-marker",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/rust/cmdi/cmdi_command.rs",
|
||||
"tests/benchmark/corpus/rust/cmdi/cmdi_args.rs",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
}];
|
||||
|
||||
// ── Path traversal ────────────────────────────────────────────────────────────
|
||||
// Benign payload reads a known-safe file (Python's os module source path).
|
||||
// Differential confirmation: vuln fires on /etc/passwd; benign fires on a
|
||||
// non-sensitive file and must NOT fire the same oracle (different file content).
|
||||
const PATH_TRAV: &[Payload] = &[
|
||||
Payload {
|
||||
const PATH_TRAV: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"../../../../etc/passwd",
|
||||
label: "path-traversal-passwd",
|
||||
oracle: Oracle::OutputContains("root:"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/rust/path_traversal/path_file_open.rs",
|
||||
"tests/benchmark/corpus/rust/path_traversal/path_read.rs",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
},
|
||||
Payload {
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_file_that_does_not_exist_NYX_BENIGN",
|
||||
label: "path-traversal-benign",
|
||||
oracle: Oracle::OutputContains("root:"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/path_traversal/path_file_open.rs"],
|
||||
oob_nonce_slot: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ── SSRF ─────────────────────────────────────────────────────────────────────
|
||||
// Uses file:// scheme which is supported by Python's urllib out of the box.
|
||||
// Oracle: OutputContains("daemon:") — a distinct /etc/passwd substring that
|
||||
// matches both Linux (`daemon:x:1:1:`) and macOS (`daemon:*:1:1:`) formats.
|
||||
// Must NOT collide with FILE_IO's "root:" marker (see marker_uniqueness test).
|
||||
const SSRF_PAYLOADS: &[Payload] = &[Payload {
|
||||
bytes: b"file:///etc/passwd",
|
||||
label: "ssrf-file-scheme",
|
||||
oracle: Oracle::OutputContains("daemon:"),
|
||||
is_benign: false,
|
||||
}];
|
||||
// Two SSRF variants:
|
||||
// 1. file:// scheme — static payload, OutputContains oracle. Works in
|
||||
// the process backend without OOB infrastructure.
|
||||
// 2. OOB nonce slot — URL generated at runtime from the OOB listener.
|
||||
// Confirms SSRF by recording the callback nonce.
|
||||
//
|
||||
// Oracle notes:
|
||||
// OutputContains("daemon:") matches both Linux (`daemon:x:1:1:`) and
|
||||
// macOS (`daemon:*:1:1:`) /etc/passwd formats and must NOT collide with
|
||||
// FILE_IO's "root:" marker (see marker_uniqueness test).
|
||||
const SSRF_PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"file:///etc/passwd",
|
||||
label: "ssrf-file-scheme",
|
||||
oracle: Oracle::OutputContains("daemon:"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/ssrf/ssrf_reqwest.rs"],
|
||||
oob_nonce_slot: false,
|
||||
},
|
||||
CuratedPayload {
|
||||
// `bytes` is unused when `oob_nonce_slot = true`; the runner
|
||||
// materialises the URL from the OOB listener at call time.
|
||||
bytes: b"",
|
||||
label: "ssrf-oob-nonce",
|
||||
oracle: Oracle::OobCallback { host: "127.0.0.1" },
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 2,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/ssrf/ssrf_reqwest.rs"],
|
||||
oob_nonce_slot: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ── XSS ──────────────────────────────────────────────────────────────────────
|
||||
// Vuln: raw script tag echoed to output.
|
||||
// Benign: harmless text that should NOT produce the XSS marker.
|
||||
const XSS: &[Payload] = &[
|
||||
Payload {
|
||||
const XSS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"<script>NYX_XSS_CONFIRMED</script>",
|
||||
label: "xss-script-marker",
|
||||
oracle: Oracle::OutputContains("<script>NYX_XSS_CONFIRMED</script>"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/xss/axum_html/main.rs"],
|
||||
oob_nonce_slot: false,
|
||||
},
|
||||
Payload {
|
||||
CuratedPayload {
|
||||
bytes: b"Hello World",
|
||||
label: "xss-benign-text",
|
||||
oracle: Oracle::OutputContains("<script>NYX_XSS_CONFIRMED</script>"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/xss/axum_html/main.rs"],
|
||||
oob_nonce_slot: false,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ pub mod corpus;
|
|||
pub mod harness;
|
||||
pub mod lang;
|
||||
pub mod mount_filter;
|
||||
pub mod oob;
|
||||
pub mod repro;
|
||||
pub mod report;
|
||||
pub mod runner;
|
||||
|
|
|
|||
230
src/dynamic/oob.rs
Normal file
230
src/dynamic/oob.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
//! Out-of-band (OOB) callback listener.
|
||||
//!
|
||||
//! Binds a TCP server to `127.0.0.1:0` (OS-assigned port), spins up a
|
||||
//! background accept thread, and records every nonce it receives via the
|
||||
//! URL path. The lifetime of the listener is per-scan: create one
|
||||
//! [`OobListener`] at scan start, drop it when the scan finishes.
|
||||
//!
|
||||
//! # Nonce URL
|
||||
//!
|
||||
//! The caller generates a per-finding nonce (UUID4 hex) and embeds it in
|
||||
//! the payload via [`OobListener::nonce_url`]. After each sandbox run the
|
||||
//! caller calls [`OobListener::was_nonce_hit`] to confirm the callback
|
||||
//! actually arrived.
|
||||
//!
|
||||
//! # Docker sandboxes
|
||||
//!
|
||||
//! For Docker sandboxes the OOB host is reachable at the Docker bridge
|
||||
//! gateway address (`host-gateway` via `--add-host`). The runner populates
|
||||
//! the `NYX_OOB_URL` env-var inside the container with the correct URL.
|
||||
//! The process sandbox uses `127.0.0.1` directly.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Per-scan out-of-band callback listener.
|
||||
///
|
||||
/// Binds to `127.0.0.1:0` on creation. Drop to stop the accept thread.
|
||||
#[derive(Debug)]
|
||||
pub struct OobListener {
|
||||
port: u16,
|
||||
hits: Arc<Mutex<HashSet<String>>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl OobListener {
|
||||
/// Bind to a random loopback port and start the accept thread.
|
||||
pub fn bind() -> Result<Self, std::io::Error> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
let hits: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let hits_clone = Arc::clone(&hits);
|
||||
let shutdown_clone = Arc::clone(&shutdown);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
accept_loop(listener, hits_clone, shutdown_clone);
|
||||
});
|
||||
|
||||
Ok(Self { port, hits, shutdown })
|
||||
}
|
||||
|
||||
/// Port the listener is bound to.
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// URL to embed in a payload for `nonce`.
|
||||
///
|
||||
/// Format: `http://127.0.0.1:{port}/{nonce}`. Use this URL for the
|
||||
/// process sandbox. For Docker sandboxes use [`nonce_url_for_host`].
|
||||
pub fn nonce_url(&self, nonce: &str) -> String {
|
||||
format!("http://127.0.0.1:{}/{}", self.port, nonce)
|
||||
}
|
||||
|
||||
/// URL using an explicit host (e.g. `host-gateway` inside Docker).
|
||||
pub fn nonce_url_for_host(&self, host: &str, nonce: &str) -> String {
|
||||
format!("http://{}:{}/{}", host, self.port, nonce)
|
||||
}
|
||||
|
||||
/// Returns `true` if `nonce` was received by the listener.
|
||||
pub fn was_nonce_hit(&self, nonce: &str) -> bool {
|
||||
self.hits
|
||||
.lock()
|
||||
.map(|h| h.contains(nonce))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OobListener {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.store(true, Ordering::Relaxed);
|
||||
// Wake up the blocking accept() call by connecting to ourselves.
|
||||
let _ = TcpStream::connect(format!("127.0.0.1:{}", self.port));
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_loop(
|
||||
listener: TcpListener,
|
||||
hits: Arc<Mutex<HashSet<String>>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
) {
|
||||
for stream in listener.incoming() {
|
||||
if shutdown.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
match stream {
|
||||
Ok(s) => {
|
||||
let h = Arc::clone(&hits);
|
||||
std::thread::spawn(move || handle_connection(s, h));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_connection(stream: TcpStream, hits: Arc<Mutex<HashSet<String>>>) {
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut first_line = String::new();
|
||||
if reader.read_line(&mut first_line).is_ok() {
|
||||
if let Some(nonce) = parse_nonce_from_request_line(&first_line) {
|
||||
if let Ok(mut h) = hits.lock() {
|
||||
h.insert(nonce);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drain remaining headers so the client doesn't get ECONNRESET.
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => break,
|
||||
Err(_) => break,
|
||||
Ok(_) if line == "\r\n" || line == "\n" => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
let mut w = &stream;
|
||||
let _ = w.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nContent-Type: text/plain\r\n\r\nok");
|
||||
}
|
||||
|
||||
/// Extract the nonce from a `GET /{nonce} HTTP/1.1` request line.
|
||||
fn parse_nonce_from_request_line(line: &str) -> Option<String> {
|
||||
let mut parts = line.trim().splitn(3, ' ');
|
||||
let method = parts.next()?;
|
||||
let path = parts.next()?;
|
||||
if method != "GET" {
|
||||
return None;
|
||||
}
|
||||
let nonce = path.trim_start_matches('/').split('?').next()?;
|
||||
if nonce.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(nonce.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_nonce_standard_get() {
|
||||
assert_eq!(
|
||||
parse_nonce_from_request_line("GET /abc123 HTTP/1.1"),
|
||||
Some("abc123".to_owned()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nonce_strips_query() {
|
||||
assert_eq!(
|
||||
parse_nonce_from_request_line("GET /abc123?foo=bar HTTP/1.1"),
|
||||
Some("abc123".to_owned()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nonce_empty_path() {
|
||||
assert!(parse_nonce_from_request_line("GET / HTTP/1.1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nonce_non_get() {
|
||||
assert!(parse_nonce_from_request_line("POST /abc123 HTTP/1.1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oob_listener_bind_and_port() {
|
||||
let listener = OobListener::bind().expect("bind must succeed on loopback");
|
||||
assert_ne!(listener.port(), 0, "OS must assign a non-zero port");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oob_listener_records_nonce_via_http() {
|
||||
let listener = OobListener::bind().expect("bind");
|
||||
let nonce = "nyx_test_nonce_abc123";
|
||||
let url = listener.nonce_url(nonce);
|
||||
|
||||
// Give the accept thread a moment to start.
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
|
||||
// Make an HTTP request with the nonce in the path.
|
||||
let addr = format!("127.0.0.1:{}", listener.port());
|
||||
if let Ok(mut stream) = TcpStream::connect(&addr) {
|
||||
let req = format!("GET /{nonce} HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n");
|
||||
let _ = stream.write_all(req.as_bytes());
|
||||
// Read response to ensure the server processed the request.
|
||||
let mut buf = [0u8; 64];
|
||||
let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(500)));
|
||||
let _ = std::io::Read::read(&mut stream, &mut buf);
|
||||
}
|
||||
|
||||
// Allow the handler thread to update the hits set.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
assert!(
|
||||
listener.was_nonce_hit(nonce),
|
||||
"listener must record the nonce from the HTTP request; url={url}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oob_listener_unknown_nonce_not_hit() {
|
||||
let listener = OobListener::bind().expect("bind");
|
||||
assert!(!listener.was_nonce_hit("not_a_real_nonce_xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonce_url_format() {
|
||||
let listener = OobListener::bind().expect("bind");
|
||||
let port = listener.port();
|
||||
let url = listener.nonce_url("mynonce");
|
||||
assert_eq!(url, format!("http://127.0.0.1:{port}/mynonce"));
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
//! the result into a [`crate::dynamic::report::VerifyResult`].
|
||||
|
||||
use crate::dynamic::build_sandbox;
|
||||
use crate::dynamic::corpus::{benign_payload_for, payloads_for, Oracle, Payload};
|
||||
use crate::dynamic::corpus::{benign_payload_for, materialise_bytes, payloads_for, Oracle, Payload};
|
||||
use crate::dynamic::harness::{self, HarnessError};
|
||||
use crate::dynamic::sandbox::{self, SandboxError, SandboxOptions, SandboxOutcome};
|
||||
use crate::dynamic::spec::HarnessSpec;
|
||||
|
|
@ -127,7 +127,10 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
|
|||
}
|
||||
}
|
||||
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
|
||||
return Err(RunError::BuildFailed { stderr, attempts });
|
||||
return Err(RunError::BuildFailed {
|
||||
stderr,
|
||||
attempts,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
// Io: fall back to whatever command was set (will likely fail at exec).
|
||||
|
|
@ -207,7 +210,35 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
|
|||
let mut oracle_collision = false;
|
||||
|
||||
for (i, payload) in vuln_payloads.iter().enumerate() {
|
||||
let outcome = sandbox::run(&harness, payload, opts)?;
|
||||
// Materialise payload bytes (OOB nonce-slot payloads generate a URL).
|
||||
let (oob_nonce, effective_bytes) = if payload.oob_nonce_slot {
|
||||
if let Some(ref listener) = opts.oob_listener {
|
||||
let nonce = generate_nonce();
|
||||
let url = listener.nonce_url(&nonce);
|
||||
let bytes = url.into_bytes();
|
||||
(Some(nonce), bytes)
|
||||
} else {
|
||||
// No OOB listener configured — skip OOB payloads.
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
(None, payload.bytes.to_vec())
|
||||
};
|
||||
|
||||
let mut outcome = sandbox::run(&harness, &effective_bytes, opts)?;
|
||||
|
||||
// For OOB payloads, check the nonce listener and update the outcome flag.
|
||||
if let (Some(nonce), Some(listener)) = (&oob_nonce, &opts.oob_listener) {
|
||||
// Give the harness a brief window to complete the callback before we check.
|
||||
// The sandbox run already waited for process exit, so the callback should
|
||||
// have arrived. A short sleep handles edge cases where the OS hasn't yet
|
||||
// delivered the TCP segment to the listener thread.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
if listener.was_nonce_hit(nonce) {
|
||||
outcome.oob_callback_seen = true;
|
||||
}
|
||||
}
|
||||
|
||||
let fired = oracle_fired(&payload.oracle, &outcome);
|
||||
let sink_hit = outcome.sink_hit;
|
||||
|
||||
|
|
@ -215,7 +246,10 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
|
|||
// Full confirmation: oracle + probe both fired.
|
||||
// Check differential: if benign payload also triggers oracle, downgrade.
|
||||
if let Some(benign) = benign_payload {
|
||||
let benign_outcome = sandbox::run(&harness, benign, opts)?;
|
||||
let benign_bytes = materialise_bytes(benign, None)
|
||||
.map(|b| b.into_owned())
|
||||
.unwrap_or_default();
|
||||
let benign_outcome = sandbox::run(&harness, &benign_bytes, opts)?;
|
||||
let benign_fired = oracle_fired(&benign.oracle, &benign_outcome);
|
||||
!benign_fired
|
||||
} else {
|
||||
|
|
@ -273,6 +307,21 @@ fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
|
|||
hay.windows(needle.len()).any(|w| w == needle)
|
||||
}
|
||||
|
||||
/// Generate a random 16-character hex nonce for OOB callback tracking.
|
||||
fn generate_nonce() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
// Simple pseudo-random nonce: mix timestamp, thread ID, and a counter.
|
||||
// Good enough for deduplication; not cryptographically secure.
|
||||
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let cnt = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let mixed = ts.wrapping_mul(0x517cc1b727220a95).wrapping_add(cnt);
|
||||
format!("{mixed:016x}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -291,4 +340,18 @@ mod tests {
|
|||
fn contains_subslice_no_match() {
|
||||
assert!(!contains_subslice(b"hello", b"xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_nonce_is_16_hex_chars() {
|
||||
let n = generate_nonce();
|
||||
assert_eq!(n.len(), 16);
|
||||
assert!(n.chars().all(|c| c.is_ascii_hexdigit()), "nonce must be hex: {n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_nonce_unique_per_call() {
|
||||
let n1 = generate_nonce();
|
||||
let n2 = generate_nonce();
|
||||
assert_ne!(n1, n2, "consecutive nonces must differ");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@
|
|||
//! global runtime, no daemon. Containers are stopped and removed when the
|
||||
//! process exits.
|
||||
|
||||
use crate::dynamic::corpus::Payload;
|
||||
use crate::dynamic::harness::BuiltHarness;
|
||||
use crate::dynamic::oob::OobListener;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ── Harness interpretation probe ──────────────────────────────────────────────
|
||||
|
|
@ -112,6 +112,10 @@ pub struct SandboxOptions {
|
|||
pub env_passthrough: Vec<String>,
|
||||
/// Maximum stdout/stderr bytes captured. Default: 65536 (64 KiB).
|
||||
pub output_limit: usize,
|
||||
/// Per-scan OOB listener. When set, the Docker backend uses bridge
|
||||
/// networking so the harness can reach the listener on the host, and the
|
||||
/// runner checks [`OobListener::was_nonce_hit`] after each sandbox run.
|
||||
pub oob_listener: Option<Arc<OobListener>>,
|
||||
}
|
||||
|
||||
impl Default for SandboxOptions {
|
||||
|
|
@ -122,6 +126,7 @@ impl Default for SandboxOptions {
|
|||
backend: SandboxBackend::Auto,
|
||||
env_passthrough: vec![],
|
||||
output_limit: 65536,
|
||||
oob_listener: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -258,33 +263,36 @@ fn php_image_for_toolchain(toolchain_id: &str) -> String {
|
|||
|
||||
/// Run a built harness once with a chosen payload.
|
||||
///
|
||||
/// `payload_bytes` overrides `payload.bytes` so the runner can inject
|
||||
/// materialised OOB-nonce URLs without cloning the static corpus entry.
|
||||
///
|
||||
/// Dispatches to the docker backend when available (or when explicitly
|
||||
/// requested), otherwise to the process backend.
|
||||
pub fn run(
|
||||
harness: &BuiltHarness,
|
||||
payload: &Payload,
|
||||
payload_bytes: &[u8],
|
||||
opts: &SandboxOptions,
|
||||
) -> Result<SandboxOutcome, SandboxError> {
|
||||
match opts.backend {
|
||||
SandboxBackend::Docker => {
|
||||
if harness_is_interpreted(&harness.command) {
|
||||
run_docker(harness, payload, opts)
|
||||
run_docker(harness, payload_bytes, opts)
|
||||
} else if harness_is_native_binary(&harness.command) {
|
||||
run_native_binary_docker(harness, payload, opts)
|
||||
run_native_binary_docker(harness, payload_bytes, opts)
|
||||
} else {
|
||||
run_process(harness, payload, opts)
|
||||
run_process(harness, payload_bytes, opts)
|
||||
}
|
||||
}
|
||||
SandboxBackend::Auto => {
|
||||
if docker_available() && harness_is_interpreted(&harness.command) {
|
||||
run_docker(harness, payload, opts)
|
||||
run_docker(harness, payload_bytes, opts)
|
||||
} else if docker_available() && harness_is_native_binary(&harness.command) {
|
||||
run_native_binary_docker(harness, payload, opts)
|
||||
run_native_binary_docker(harness, payload_bytes, opts)
|
||||
} else {
|
||||
run_process(harness, payload, opts)
|
||||
run_process(harness, payload_bytes, opts)
|
||||
}
|
||||
}
|
||||
SandboxBackend::Process => run_process(harness, payload, opts),
|
||||
SandboxBackend::Process => run_process(harness, payload_bytes, opts),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -293,7 +301,7 @@ pub fn run(
|
|||
/// Docker backend: image per toolchain_id, container reuse via `docker exec`.
|
||||
fn run_docker(
|
||||
harness: &BuiltHarness,
|
||||
payload: &Payload,
|
||||
payload_bytes: &[u8],
|
||||
opts: &SandboxOptions,
|
||||
) -> Result<SandboxOutcome, SandboxError> {
|
||||
// Quick availability check (uses same binary as docker_available but not
|
||||
|
|
@ -317,11 +325,12 @@ fn run_docker(
|
|||
// Determine the Python image from the harness command (first element).
|
||||
// Fall back to python:3-slim when the command is not recognised.
|
||||
let image = detect_image_for_harness(harness);
|
||||
start_container(&container_name, &harness.workdir, &image)?;
|
||||
let oob_port = opts.oob_listener.as_ref().map(|l| l.port());
|
||||
start_container(&container_name, &harness.workdir, &image, oob_port)?;
|
||||
registry.insert(container_name.clone(), container_name.clone());
|
||||
}
|
||||
|
||||
exec_in_container(&container_name, harness, payload, opts)
|
||||
exec_in_container(&container_name, harness, payload_bytes, opts)
|
||||
}
|
||||
|
||||
/// Returns true when `docker info` succeeds using the current `NYX_DOCKER_BIN`.
|
||||
|
|
@ -358,22 +367,37 @@ fn is_container_running(name: &str) -> bool {
|
|||
/// - `--rm`: auto-remove on stop (no manual cleanup required).
|
||||
/// - `--cap-drop=ALL`: drop all Linux capabilities.
|
||||
/// - `--security-opt no-new-privileges:true`: block privilege escalation.
|
||||
/// - `--network none`: no network access (loopback only).
|
||||
fn start_container(name: &str, workdir: &Path, image: &str) -> Result<(), SandboxError> {
|
||||
/// - `--network none`: no network access (loopback only), OR `bridge` when
|
||||
/// `oob_port` is set so the harness can reach the host OOB listener.
|
||||
/// - `--add-host=host-gateway:host-gateway`: host-gateway DNS alias when
|
||||
/// using bridge mode (Docker ≥ 20.10).
|
||||
fn start_container(
|
||||
name: &str,
|
||||
workdir: &Path,
|
||||
image: &str,
|
||||
oob_port: Option<u16>,
|
||||
) -> Result<(), SandboxError> {
|
||||
let mut run_args: Vec<String> = vec![
|
||||
"run".into(),
|
||||
"-d".into(),
|
||||
"--rm".into(),
|
||||
"--name".into(), name.into(),
|
||||
"--cap-drop=ALL".into(),
|
||||
"--security-opt".into(), "no-new-privileges:true".into(),
|
||||
"--tmpfs".into(), "/tmp:size=128m,exec".into(),
|
||||
];
|
||||
if oob_port.is_some() {
|
||||
// Bridge mode: container can reach host via host-gateway.
|
||||
run_args.extend(["--network".into(), "bridge".into()]);
|
||||
run_args.extend(["--add-host=host-gateway:host-gateway".into()]);
|
||||
} else {
|
||||
run_args.extend(["--network".into(), "none".into()]);
|
||||
}
|
||||
run_args.extend([image.into(), "sleep".into(), "300".into()]);
|
||||
|
||||
// Start container (no volume mount).
|
||||
let status = std::process::Command::new(docker_bin())
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--rm",
|
||||
"--name", name,
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt", "no-new-privileges:true",
|
||||
"--network", "none",
|
||||
"--tmpfs", "/tmp:size=128m,exec",
|
||||
image,
|
||||
"sleep", "300",
|
||||
])
|
||||
.args(&run_args)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
|
|
@ -466,7 +490,7 @@ fn build_container_exec_args(command: &[String]) -> Vec<String> {
|
|||
fn exec_in_container(
|
||||
container_name: &str,
|
||||
harness: &BuiltHarness,
|
||||
payload: &Payload,
|
||||
payload_bytes: &[u8],
|
||||
opts: &SandboxOptions,
|
||||
) -> Result<SandboxOutcome, SandboxError> {
|
||||
use std::io::Read;
|
||||
|
|
@ -475,7 +499,7 @@ fn exec_in_container(
|
|||
// Build the docker exec command.
|
||||
// exec_in_container is only called for interpreted harnesses (python3, node, …);
|
||||
// compiled binaries are routed to run_process by the dispatch in run().
|
||||
let payload_b64 = base64_encode(payload.bytes);
|
||||
let payload_b64 = base64_encode(payload_bytes);
|
||||
let mut cmd_args: Vec<String> = vec![
|
||||
"exec".into(),
|
||||
"-i".into(),
|
||||
|
|
@ -620,7 +644,7 @@ fn detect_image_for_harness(harness: &BuiltHarness) -> String {
|
|||
/// the dispatch in [`run`] routes compiled harnesses to [`run_process`].
|
||||
fn run_native_binary_docker(
|
||||
harness: &BuiltHarness,
|
||||
payload: &Payload,
|
||||
payload_bytes: &[u8],
|
||||
opts: &SandboxOptions,
|
||||
) -> Result<SandboxOutcome, SandboxError> {
|
||||
if !is_docker_reachable() {
|
||||
|
|
@ -645,7 +669,8 @@ fn run_native_binary_docker(
|
|||
};
|
||||
|
||||
if !reused {
|
||||
start_container(&container_name, &harness.workdir, NATIVE_BINARY_IMAGE)?;
|
||||
let oob_port = opts.oob_listener.as_ref().map(|l| l.port());
|
||||
start_container(&container_name, &harness.workdir, NATIVE_BINARY_IMAGE, oob_port)?;
|
||||
|
||||
// Copy the compiled binary into the container as /workdir/nyx_harness.
|
||||
let cp_dst = format!("{container_name}:/workdir/nyx_harness");
|
||||
|
|
@ -673,20 +698,20 @@ fn run_native_binary_docker(
|
|||
registry.insert(container_name.clone(), container_name.clone());
|
||||
}
|
||||
|
||||
exec_native_binary_in_container(&container_name, harness, payload, opts)
|
||||
exec_native_binary_in_container(&container_name, harness, payload_bytes, opts)
|
||||
}
|
||||
|
||||
/// Execute a native binary already in the container at `/workdir/nyx_harness`.
|
||||
fn exec_native_binary_in_container(
|
||||
container_name: &str,
|
||||
harness: &BuiltHarness,
|
||||
payload: &Payload,
|
||||
payload_bytes: &[u8],
|
||||
opts: &SandboxOptions,
|
||||
) -> Result<SandboxOutcome, SandboxError> {
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let payload_b64 = base64_encode(payload.bytes);
|
||||
let payload_b64 = base64_encode(payload_bytes);
|
||||
let mut cmd_args: Vec<String> = vec![
|
||||
"exec".into(),
|
||||
"-i".into(),
|
||||
|
|
@ -787,7 +812,7 @@ fn exec_native_binary_in_container(
|
|||
/// behind `--unsafe-sandbox` in production.
|
||||
fn run_process(
|
||||
harness: &BuiltHarness,
|
||||
payload: &Payload,
|
||||
payload_bytes: &[u8],
|
||||
opts: &SandboxOptions,
|
||||
) -> Result<SandboxOutcome, SandboxError> {
|
||||
use std::io::Read;
|
||||
|
|
@ -817,14 +842,14 @@ fn run_process(
|
|||
cmd.env(k, v);
|
||||
}
|
||||
// Payload injected via NYX_PAYLOAD env var.
|
||||
let payload_b64 = base64_encode(payload.bytes);
|
||||
let payload_b64 = base64_encode(payload_bytes);
|
||||
cmd.env("NYX_PAYLOAD_B64", &payload_b64);
|
||||
// NYX_PAYLOAD as raw bytes: Unix-only (OsStr can hold arbitrary bytes).
|
||||
// On other platforms we skip this env var; the harness falls back to NYX_PAYLOAD_B64.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
cmd.env("NYX_PAYLOAD", std::ffi::OsStr::from_bytes(payload.bytes));
|
||||
cmd.env("NYX_PAYLOAD", std::ffi::OsStr::from_bytes(payload_bytes));
|
||||
}
|
||||
|
||||
// Enforce memory cap before exec on Linux via RLIMIT_AS + PR_SET_NO_NEW_PRIVS.
|
||||
|
|
|
|||
|
|
@ -591,4 +591,95 @@ mod tests {
|
|||
insert_verdict_cache(db_path, "spec", "hash", "", "python-3", &result);
|
||||
assert!(!db_path.exists(), "insert must not create a new DB");
|
||||
}
|
||||
|
||||
/// Verify that a cache entry keyed on an older corpus_version is a miss
|
||||
/// once CORPUS_VERSION is bumped. This proves the cache invalidation
|
||||
/// mechanic in §15.4 / Pillar D: changing a payload's cap evicts stale entries.
|
||||
///
|
||||
/// The test simulates a bump by inserting with an old version literal and
|
||||
/// then looking up with the current CORPUS_VERSION (which is the default).
|
||||
#[test]
|
||||
fn dynamic_verdict_cache_corpus_version_invalidation() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let db_path = dir.path().join("test_corp_ver.db");
|
||||
|
||||
{
|
||||
use rusqlite::Connection;
|
||||
let conn = Connection::open(&db_path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS dynamic_verdict_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
spec_hash TEXT NOT NULL,
|
||||
entry_content_hash TEXT NOT NULL,
|
||||
transitive_import_digest TEXT NOT NULL,
|
||||
toolchain_id TEXT NOT NULL,
|
||||
corpus_version INTEGER NOT NULL,
|
||||
spec_format_version INTEGER NOT NULL,
|
||||
verdict_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(spec_hash, entry_content_hash, transitive_import_digest,
|
||||
toolchain_id, corpus_version, spec_format_version)
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// The current CORPUS_VERSION is 3. Simulate an entry from version 2.
|
||||
let stale_corpus_version = CORPUS_VERSION.saturating_sub(1);
|
||||
assert!(
|
||||
stale_corpus_version < CORPUS_VERSION,
|
||||
"test requires CORPUS_VERSION > 1"
|
||||
);
|
||||
|
||||
let result = VerifyResult {
|
||||
finding_id: "stale_entry".to_owned(),
|
||||
status: crate::evidence::VerifyStatus::Confirmed,
|
||||
triggered_payload: Some("sqli-tautology".to_owned()),
|
||||
reason: None,
|
||||
inconclusive_reason: None,
|
||||
detail: None,
|
||||
attempts: vec![],
|
||||
toolchain_match: Some("exact".to_owned()),
|
||||
};
|
||||
|
||||
// Insert directly with the old corpus_version bypassing the helper.
|
||||
{
|
||||
use rusqlite::Connection;
|
||||
let conn = Connection::open(&db_path).unwrap();
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO dynamic_verdict_cache \
|
||||
(spec_hash, entry_content_hash, transitive_import_digest, toolchain_id, \
|
||||
corpus_version, spec_format_version, verdict_json, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
rusqlite::params![
|
||||
"spec_stale",
|
||||
"hash_stale",
|
||||
"",
|
||||
"python-3.11",
|
||||
stale_corpus_version as i64,
|
||||
SPEC_FORMAT_VERSION as i64,
|
||||
json,
|
||||
now,
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Lookup using current CORPUS_VERSION → must be a MISS.
|
||||
let miss = lookup_verdict_cache(&db_path, "spec_stale", "hash_stale", "", "python-3.11");
|
||||
assert!(
|
||||
miss.is_none(),
|
||||
"stale corpus_version ({stale_corpus_version}) must not match current CORPUS_VERSION ({CORPUS_VERSION})"
|
||||
);
|
||||
|
||||
// Insert with current CORPUS_VERSION → must be a HIT.
|
||||
insert_verdict_cache(&db_path, "spec_stale", "hash_stale", "", "python-3.11", &result);
|
||||
let hit = lookup_verdict_cache(&db_path, "spec_stale", "hash_stale", "", "python-3.11");
|
||||
assert!(
|
||||
hit.is_some(),
|
||||
"current corpus_version entry must be a cache hit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue