//! 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::dynamic::rand::SpecRng; use nyx_scanner::labels::Cap; use std::collections::HashSet; use std::path::{Path, PathBuf}; 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(); // Deterministic RNG keyed on the spec hash so two runs against the // same fixture produce identical candidate streams. The Phase 27 // events.jsonl replay invariant + Phase 28 repro bundle hermeticity // contract both require the verifier (and any fuzzer feeding it) to // be reproducible from inputs alone — no host entropy mixed in. let mut rng = SpecRng::seeded(&spec_hash); for iter in 0..iterations { let seed = &corpus[rng.gen_range(corpus.len())]; let candidate = mutate_bytes(seed, &mut rng); 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}", rng.next_u64()); 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 mutate_bytes(input: &[u8], rng: &mut SpecRng) -> Vec { let mut out = input.to_vec(); if out.is_empty() { return out; } match rng.next_u64() % 5 { 0 => { // Flip a random byte. let idx = rng.gen_range(out.len()); out[idx] ^= (rng.next_u64() as u8) | 1; } 1 => { // Insert a byte. let idx = rng.gen_range(out.len() + 1); out.insert(idx, rng.next_u64() as u8); } 2 => { // Delete a byte. if out.len() > 1 { let idx = rng.gen_range(out.len()); out.remove(idx); } } 3 => { // Append known-interesting bytes. let suffixes: &[&[u8]] = &[ b"'", b"\"", b";", b"--", b" OR 1=1", b"