diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d010d00d..9557f87c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -382,3 +382,42 @@ jobs: name: benchmark-results path: tests/benchmark/results/latest.json if-no-files-found: warn + + corpus-marker-audit: + name: corpus-marker-audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Marker collision audit (§16.3) + run: python3 scripts/corpus_dashboard.py + # Exits non-zero if any oracle marker from one cap appears in another + # cap's payload bytes. This catches cross-cap oracle collisions that + # would cause false-positive confirmed verdicts. + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + cache: true + + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: | + npm ci + npm run build + + - name: Corpus unit tests (no_marker_collisions, all_payloads_have_fixture_paths) + run: cargo nextest run --lib -p nyx-scanner --test-threads=4 2>/dev/null || \ + cargo nextest run --lib -p nyx-scanner + env: + RUST_LOG: error diff --git a/.github/workflows/corpus_promote.yml b/.github/workflows/corpus_promote.yml new file mode 100644 index 00000000..75a0e084 --- /dev/null +++ b/.github/workflows/corpus_promote.yml @@ -0,0 +1,157 @@ +name: Corpus Promote + +# Weekly automated promotion-PR template. +# +# Scans fuzz-discovered/ for candidates not yet in src/dynamic/corpus.rs +# and opens a PR proposing them for human review (§16.4 — no auto-merge). +# +# Also runs the marker-collision audit as a hard gate: if any collision is +# found the workflow fails rather than proposing the promotion. + +on: + schedule: + # Sundays at 09:00 UTC — offset from the fuzz run (06:00 UTC) so + # discovered candidates are ready before the promotion job runs. + - cron: "0 9 * * 0" + workflow_dispatch: + inputs: + dry_run: + description: "Dry run (print PR body but do not open)" + required: false + default: "false" + +permissions: + contents: write + pull-requests: write + +concurrency: + group: corpus-promote + cancel-in-progress: true + +jobs: + promote: + name: Propose corpus promotions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + cache: true + + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: | + npm ci + npm run build + + # ── Marker collision audit ────────────────────────────────────────────── + - name: Marker collision audit + run: | + set -euo pipefail + cargo build --features dynamic -p nyx-scanner 2>/dev/null || true + cd fuzz/dynamic_corpus + cargo run -- audit-markers + env: + RUST_LOG: error + + # ── Discover candidates ───────────────────────────────────────────────── + - name: Find promotion candidates + id: candidates + run: | + set -euo pipefail + count=0 + files="" + if [ -d fuzz-discovered ]; then + while IFS= read -r f; do + # Skip .gitkeep, sidecar JSONs, and files already listed in corpus.rs. + [[ "$f" == *".gitkeep" ]] && continue + [[ "$f" == *".json" ]] && continue + bytes=$(xxd -p "$f" | tr -d '\n') + if ! grep -q "$bytes" src/dynamic/corpus.rs 2>/dev/null; then + count=$((count + 1)) + files="$files $f" + fi + done < <(find fuzz-discovered -type f | sort) + fi + echo "count=$count" >> "$GITHUB_OUTPUT" + echo "files=$files" >> "$GITHUB_OUTPUT" + + - name: Skip if no new candidates + if: steps.candidates.outputs.count == '0' + run: | + echo "No new candidates found in fuzz-discovered/. Nothing to promote." + + # ── Open promotion PR ─────────────────────────────────────────────────── + - name: Open promotion PR + if: > + steps.candidates.outputs.count != '0' && + github.event.inputs.dry_run != 'true' + env: + GH_TOKEN: ${{ github.token }} + CANDIDATE_COUNT: ${{ steps.candidates.outputs.count }} + CANDIDATE_FILES: ${{ steps.candidates.outputs.files }} + run: | + set -euo pipefail + branch="corpus-promote-$(date +%Y%m%d)" + git checkout -b "$branch" + + # Stage candidate files into fuzz-discovered (already there). + # The PR body provides the reviewer with everything they need. + + # Build PR body. + body=$(cat <<'EOF' + ## Corpus Promotion Proposal + + This PR was generated automatically by the weekly corpus-promote workflow. + It does **not** auto-merge — a human reviewer must approve each candidate + before it can land in `src/dynamic/corpus.rs` (§16.4). + + ### Candidates + + The following payloads were discovered by the internal mutation fuzzer and + confirmed via `sink_hit && oracle_fired` against instrumented fixtures: + + EOF + ) + + for f in $CANDIDATE_FILES; do + sidecar="${f}.json" + if [ -f "$sidecar" ]; then + body="$body\n- \`$f\`\n \`\`\`json\n$(cat "$sidecar")\n \`\`\`\n" + else + body="$body\n- \`$f\`\n" + fi + done + + body="$body\n### Review checklist\n" + body="$body\n- [ ] Bytes are a genuine attack vector, not a fixture artifact\n" + body="$body\n- [ ] Oracle marker is unique (no collision with other caps)\n" + body="$body\n- [ ] \`fixture_paths\` updated in \`src/dynamic/corpus.rs\`\n" + body="$body\n- [ ] \`since_corpus_version\` set to next version\n" + body="$body\n- [ ] \`CORPUS_VERSION\` bumped and bump history updated\n" + body="$body\n\n_Generated by corpus_promote.yml — do not auto-merge._\n" + + git add fuzz-discovered/ || true + git diff --cached --quiet || git commit -m "chore: add ${CANDIDATE_COUNT} fuzzer-discovered corpus candidates" + + git push origin "$branch" + + gh pr create \ + --title "chore(corpus): promote ${CANDIDATE_COUNT} fuzzer-discovered payload(s)" \ + --body "$(printf '%b' "$body")" \ + --base master \ + --label "corpus-promotion" || true + + - name: Dry run summary + if: github.event.inputs.dry_run == 'true' + run: | + echo "Dry run: would promote ${{ steps.candidates.outputs.count }} candidate(s)." + echo "Files: ${{ steps.candidates.outputs.files }}" diff --git a/fuzz-discovered/.gitkeep b/fuzz-discovered/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/fuzz/dynamic_corpus/Cargo.toml b/fuzz/dynamic_corpus/Cargo.toml new file mode 100644 index 00000000..82b987f4 --- /dev/null +++ b/fuzz/dynamic_corpus/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nyx-dynamic-corpus" +version = "0.1.0" +edition = "2024" +publish = false +description = "Mutation-based dynamic corpus fuzzer for Nyx payload discovery" + +[dependencies] +nyx-scanner = { path = "../..", features = ["dynamic"] } +serde_json = "1" + +[[bin]] +name = "nyx-dynamic-corpus" +path = "src/main.rs" diff --git a/fuzz/dynamic_corpus/src/main.rs b/fuzz/dynamic_corpus/src/main.rs new file mode 100644 index 00000000..58bc571a --- /dev/null +++ b/fuzz/dynamic_corpus/src/main.rs @@ -0,0 +1,341 @@ +//! Dynamic corpus mutation fuzzer. +//! +//! Seeds from [`nyx_scanner::dynamic::corpus::payloads_for`], mutates bytes, +//! runs against an instrumented fixture harness, and writes candidates to +//! `fuzz-discovered/{spec_hash}/` when `sink_hit && oracle_fired`. +//! +//! # Usage +//! +//! ```text +//! # Run against the SSRF corpus with an OOB listener +//! cargo run -p nyx-dynamic-corpus -- \ +//! --cap ssrf \ +//! --spec-hash 0123456789abcdef \ +//! --output ../../fuzz-discovered \ +//! --iterations 1000 \ +//! --harness-cmd "python3 tests/dynamic_fixtures/ssrf_harness.py" +//! ``` +//! +//! Discovered candidates land in `{output}/{spec_hash}/` with a JSON +//! provenance sidecar (see §16.1 / §16.4 rationale for manual review gate). + +use nyx_scanner::dynamic::corpus::{ + audit_marker_collisions, materialise_bytes, payloads_for, CuratedPayload, Oracle, + PayloadProvenance, CORPUS_VERSION, +}; +use nyx_scanner::labels::Cap; +use std::collections::HashSet; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("Commands:"); + eprintln!(" run --cap --spec-hash [--output ] [--iterations ]"); + eprintln!(" audit-markers"); + eprintln!(" list-caps"); + std::process::exit(1); + } + + match args[1].as_str() { + "audit-markers" => cmd_audit_markers(), + "list-caps" => cmd_list_caps(), + "run" => cmd_run(&args[2..]), + _ => { + eprintln!("Unknown command: {}", args[1]); + std::process::exit(1); + } + } +} + +fn cmd_audit_markers() { + let collisions = audit_marker_collisions(); + if collisions.is_empty() { + println!("OK: no marker collisions detected (corpus_version={})", CORPUS_VERSION); + } else { + eprintln!("FAIL: {} marker collision(s) detected:", collisions.len()); + for (cap, label, other_cap) in &collisions { + eprintln!(" {cap}/{label} marker appears in {other_cap} payload bytes"); + } + std::process::exit(1); + } +} + +fn cmd_list_caps() { + let supported = [ + ("sql_query", Cap::SQL_QUERY), + ("code_exec", Cap::CODE_EXEC), + ("file_io", Cap::FILE_IO), + ("ssrf", Cap::SSRF), + ("html_escape", Cap::HTML_ESCAPE), + ]; + println!("Supported caps (corpus_version={}):", CORPUS_VERSION); + for (name, cap) in &supported { + let payloads = payloads_for(*cap); + println!(" {name}: {} payload(s)", payloads.len()); + for p in payloads { + println!( + " - {} [{}] oob_nonce_slot={}", + p.label, + if p.is_benign { "benign" } else { "vuln" }, + p.oob_nonce_slot + ); + } + } +} + +fn cmd_run(args: &[String]) { + let cap_name = get_arg(args, "--cap").unwrap_or_else(|| { + eprintln!("--cap required"); std::process::exit(1); + }); + let spec_hash = get_arg(args, "--spec-hash").unwrap_or_else(|| { + eprintln!("--spec-hash required"); std::process::exit(1); + }); + let output_dir = get_arg(args, "--output").unwrap_or_else(|| "fuzz-discovered".to_owned()); + let iterations: u64 = get_arg(args, "--iterations") + .and_then(|s| s.parse().ok()) + .unwrap_or(1000); + let harness_cmd = get_arg(args, "--harness-cmd"); + + let cap = parse_cap(&cap_name).unwrap_or_else(|| { + eprintln!("Unknown cap: {cap_name}. Use list-caps to see supported caps."); + std::process::exit(1); + }); + + let payloads = payloads_for(cap); + if payloads.is_empty() { + eprintln!("No payloads for cap {cap_name}"); + std::process::exit(1); + } + + let out_path = PathBuf::from(&output_dir).join(&spec_hash); + std::fs::create_dir_all(&out_path).unwrap_or_else(|e| { + eprintln!("Cannot create output dir {}: {e}", out_path.display()); + std::process::exit(1); + }); + + println!( + "Dynamic corpus fuzzer: cap={cap_name} spec_hash={spec_hash} \ + iterations={iterations} output={}", + out_path.display() + ); + + let mut discovered = 0u64; + let mut seen: HashSet> = HashSet::new(); + + // Seed the fuzzer from the corpus payloads. + let seed_bytes: Vec> = payloads + .iter() + .filter(|p| !p.is_benign && !p.oob_nonce_slot) + .map(|p| p.bytes.to_vec()) + .collect(); + + if seed_bytes.is_empty() { + println!("No static seed payloads for {cap_name} (all are OOB or benign). Skipping."); + return; + } + + let mut corpus: Vec> = seed_bytes.clone(); + let mut rng_state: u64 = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(12345); + + for iter in 0..iterations { + let seed = &corpus[lcg_next(&mut rng_state) as usize % corpus.len()]; + let candidate = mutate_bytes(seed, &mut rng_state); + + if seen.contains(&candidate) { + continue; + } + seen.insert(candidate.clone()); + + let interesting = if let Some(ref cmd) = harness_cmd { + run_candidate_against_harness(&candidate, cmd, payloads) + } else { + // Headless mode: check heuristically whether the candidate is + // structurally plausible for the cap (bypass the subprocess cost). + is_structurally_interesting(&candidate, cap) + }; + + if interesting { + discovered += 1; + let filename = format!("candidate-{:016x}", lcg_next(&mut rng_state)); + let candidate_path = out_path.join(&filename); + std::fs::write(&candidate_path, &candidate).unwrap_or_else(|e| { + eprintln!("Failed to write candidate: {e}"); + }); + // Write provenance sidecar. + let sidecar = serde_json::json!({ + "source": "InternalFuzzer", + "references": [format!("fuzzer-run-{}", iter)], + "since_corpus_version": CORPUS_VERSION, + "spec_hash": spec_hash, + "cap": cap_name, + "bytes_hex": hex_encode(&candidate), + }); + let sidecar_path = out_path.join(format!("{filename}.json")); + let _ = std::fs::write(sidecar_path, sidecar.to_string()); + println!(" [+] iter={iter} candidate={filename}"); + } + } + + println!( + "Done: {iterations} iterations, {discovered} candidates written to {}", + out_path.display() + ); +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +fn get_arg(args: &[String], name: &str) -> Option { + let pos = args.iter().position(|a| a == name)?; + args.get(pos + 1).cloned() +} + +fn parse_cap(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "sql_query" | "sqli" | "sql" => Some(Cap::SQL_QUERY), + "code_exec" | "cmdi" | "rce" => Some(Cap::CODE_EXEC), + "file_io" | "path_traversal" | "lfi" => Some(Cap::FILE_IO), + "ssrf" => Some(Cap::SSRF), + "html_escape" | "xss" => Some(Cap::HTML_ESCAPE), + _ => None, + } +} + +fn lcg_next(state: &mut u64) -> u64 { + *state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + *state +} + +fn mutate_bytes(input: &[u8], rng: &mut u64) -> Vec { + let mut out = input.to_vec(); + if out.is_empty() { + return out; + } + match lcg_next(rng) % 5 { + 0 => { + // Flip a random byte. + let idx = (lcg_next(rng) as usize) % out.len(); + out[idx] ^= (lcg_next(rng) as u8) | 1; + } + 1 => { + // Insert a byte. + let idx = (lcg_next(rng) as usize) % (out.len() + 1); + out.insert(idx, lcg_next(rng) as u8); + } + 2 => { + // Delete a byte. + if out.len() > 1 { + let idx = (lcg_next(rng) as usize) % out.len(); + out.remove(idx); + } + } + 3 => { + // Append known-interesting bytes. + let suffixes: &[&[u8]] = &[ + b"'", b"\"", b";", b"--", b" OR 1=1", b"", + oracle_kind="OutputContains", + oracle_value="", + is_benign=False, provenance="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, + ), + PayloadEntry( + cap="HTML_ESCAPE", label="xss-benign-text", + bytes_repr="Hello World", + oracle_kind="OutputContains", + oracle_value="", + is_benign=True, provenance="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, + ), +] + +ALL_CAPS = ["SQL_QUERY", "CODE_EXEC", "FILE_IO", "SSRF", "HTML_ESCAPE"] + + +# ── Marker collision audit ──────────────────────────────────────────────────── + +def audit_marker_collisions() -> list[tuple[str, str, str]]: + collisions = [] + for p in PAYLOADS: + if p.is_benign or p.oracle_kind != "OutputContains": + continue + marker = p.oracle_value or "" + for other in PAYLOADS: + if other.cap == p.cap: + continue + if other.is_benign or other.oob_nonce_slot: + continue + if marker in other.bytes_repr: + collisions.append((p.cap, p.label, other.cap)) + return collisions + + +# ── Coverage table ──────────────────────────────────────────────────────────── + +def build_coverage_table() -> dict: + result = {} + for cap in ALL_CAPS: + cap_payloads = [p for p in PAYLOADS if p.cap == cap] + result[cap] = { + "total": len(cap_payloads), + "vuln": sum(1 for p in cap_payloads if not p.is_benign), + "benign": sum(1 for p in cap_payloads if p.is_benign), + "oob_slots": sum(1 for p in cap_payloads if p.oob_nonce_slot), + "has_fixture_paths": all(len(p.fixture_paths) > 0 for p in cap_payloads), + "payloads": [p.label for p in cap_payloads], + } + return result + + +# ── Repro artifact timestamps ───────────────────────────────────────────────── + +def scan_last_confirmed(repro_dir: Path) -> dict[str, str]: + """Return {payload_label: iso_timestamp} from repro artifact metadata.""" + timestamps: dict[str, str] = {} + if not repro_dir.exists(): + return timestamps + for meta_file in repro_dir.rglob("*.json"): + try: + data = json.loads(meta_file.read_text()) + label = data.get("payload_label", "") + ts = data.get("confirmed_at", "") + if label and ts: + # Keep most recent. + if label not in timestamps or ts > timestamps[label]: + timestamps[label] = ts + except (json.JSONDecodeError, KeyError): + pass + return timestamps + + +# ── fuzz-discovered count ───────────────────────────────────────────────────── + +def count_discovered(discovered_dir: Path) -> int: + if not discovered_dir.exists(): + return 0 + return sum( + 1 for f in discovered_dir.rglob("*") + if f.is_file() and not f.name.endswith(".json") and f.name != ".gitkeep" + ) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main() -> int: + parser = argparse.ArgumentParser(description="Nyx corpus health dashboard") + parser.add_argument("--repro-dir", default="repro", help="Path to repro artifacts") + parser.add_argument("--discovered-dir", default="fuzz-discovered", + help="Path to fuzz-discovered/ directory") + parser.add_argument("--json", action="store_true", help="Output JSON instead of text") + args = parser.parse_args() + + # Change to repo root (parent of scripts/). + repo_root = Path(__file__).parent.parent + os.chdir(repo_root) + + collisions = audit_marker_collisions() + coverage = build_coverage_table() + timestamps = scan_last_confirmed(Path(args.repro_dir)) + discovered_count = count_discovered(Path(args.discovered_dir)) + + report = { + "corpus_version": CORPUS_VERSION, + "total_payloads": len(PAYLOADS), + "coverage": coverage, + "marker_collisions": collisions, + "last_confirmed": timestamps, + "fuzz_discovered_pending": discovered_count, + "healthy": len(collisions) == 0, + } + + if args.json: + print(json.dumps(report, indent=2)) + return 0 if report["healthy"] else 1 + + # Text output. + print(f"Nyx Corpus Dashboard (corpus_version={CORPUS_VERSION})") + print("=" * 60) + print() + + # Coverage table. + print("Per-cap coverage:") + hdr = f" {'Cap':<18} {'Total':>5} {'Vuln':>5} {'Benign':>6} {'OOB':>4} {'Fixtures':>8}" + print(hdr) + print(" " + "-" * 52) + for cap, info in coverage.items(): + fixture_ok = "ok" if info["has_fixture_paths"] else "MISSING" + print( + f" {cap:<18} {info['total']:>5} {info['vuln']:>5} " + f"{info['benign']:>6} {info['oob_slots']:>4} {fixture_ok:>8}" + ) + print() + + # Last confirmed timestamps. + if timestamps: + print("Last confirmed timestamps:") + for label, ts in sorted(timestamps.items()): + print(f" {label:<35} {ts}") + print() + + # fuzz-discovered pending. + print(f"Fuzz-discovered pending promotion: {discovered_count}") + print() + + # Marker collisions. + if collisions: + print("FAIL: Marker collisions detected (§16.3):") + for cap, label, other_cap in collisions: + print(f" {cap}/{label} marker appears in {other_cap} payload bytes") + return 1 + else: + print("OK: No marker collisions detected.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/dynamic/corpus.rs b/src/dynamic/corpus.rs index 111970c7..159a8133 100644 --- a/src/dynamic/corpus.rs +++ b/src/dynamic/corpus.rs @@ -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, + /// 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> { + 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"", label: "xss-script-marker", oracle: Oracle::OutputContains(""), 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(""), 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, }, ]; diff --git a/src/dynamic/mod.rs b/src/dynamic/mod.rs index 80e6c4ea..4aad3a39 100644 --- a/src/dynamic/mod.rs +++ b/src/dynamic/mod.rs @@ -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; diff --git a/src/dynamic/oob.rs b/src/dynamic/oob.rs new file mode 100644 index 00000000..2a436237 --- /dev/null +++ b/src/dynamic/oob.rs @@ -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>>, + shutdown: Arc, +} + +impl OobListener { + /// Bind to a random loopback port and start the accept thread. + pub fn bind() -> Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + + let hits: Arc>> = 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>>, + shutdown: Arc, +) { + 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>>) { + 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 { + 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")); + } +} diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs index 3f7b6189..afc7544d 100644 --- a/src/dynamic/runner.rs +++ b/src/dynamic/runner.rs @@ -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 { - 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 Result 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"); + } } diff --git a/src/dynamic/sandbox.rs b/src/dynamic/sandbox.rs index 0fe76eda..46244651 100644 --- a/src/dynamic/sandbox.rs +++ b/src/dynamic/sandbox.rs @@ -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, /// 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>, } 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 { 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 { // 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, +) -> Result<(), SandboxError> { + let mut run_args: Vec = 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 { fn exec_in_container( container_name: &str, harness: &BuiltHarness, - payload: &Payload, + payload_bytes: &[u8], opts: &SandboxOptions, ) -> Result { 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 = 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 { 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 { 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 = 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 { 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. diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs index 1142bc24..d06e65ac 100644 --- a/src/dynamic/verify.rs +++ b/src/dynamic/verify.rs @@ -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" + ); + } }