[pitboss] phase 06: M5.5 — Coverage-feedback payload generation + OOB listener finalized

This commit is contained in:
pitboss 2026-05-12 12:51:04 -04:00
parent 86613f5279
commit 6f8a645077
12 changed files with 1556 additions and 69 deletions

View file

@ -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

157
.github/workflows/corpus_promote.yml vendored Normal file
View file

@ -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 }}"

0
fuzz-discovered/.gitkeep Normal file
View file

View file

@ -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"

View file

@ -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<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("Usage: {} <command>", args[0]);
eprintln!("Commands:");
eprintln!(" run --cap <cap> --spec-hash <hash> [--output <dir>] [--iterations <n>]");
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<Vec<u8>> = HashSet::new();
// Seed the fuzzer from the corpus payloads.
let seed_bytes: Vec<Vec<u8>> = 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<Vec<u8>> = 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<String> {
let pos = args.iter().position(|a| a == name)?;
args.get(pos + 1).cloned()
}
fn parse_cap(name: &str) -> Option<Cap> {
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<u8> {
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"<script>", b"../",
b"\x00", b"{{", b"|", b"`",
];
let s = suffixes[(lcg_next(rng) as usize) % suffixes.len()];
out.extend_from_slice(s);
}
_ => {
// Replace a slice with an interesting pattern.
let interesting: &[&[u8]] = &[b"'", b"\"", b"<", b">", b"%00", b"../", b"//"];
if !out.is_empty() {
let idx = (lcg_next(rng) as usize) % out.len();
let pat = interesting[(lcg_next(rng) as usize) % interesting.len()];
let end = (idx + pat.len()).min(out.len());
out[idx..end].copy_from_slice(&pat[..end - idx]);
}
}
}
out
}
/// Heuristic: does the candidate look structurally plausible for the cap?
/// Used in headless (no-harness) mode.
fn is_structurally_interesting(candidate: &[u8], cap: Cap) -> bool {
if cap.contains(Cap::SQL_QUERY) {
let s = String::from_utf8_lossy(candidate);
s.contains('\'') || s.contains("--") || s.to_ascii_uppercase().contains("UNION")
} else if cap.contains(Cap::CODE_EXEC) {
candidate.contains(&b';') || candidate.contains(&b'|') || candidate.contains(&b'`')
} else if cap.contains(Cap::FILE_IO) {
let s = String::from_utf8_lossy(candidate);
s.contains("../") || s.contains("/etc/")
} else if cap.contains(Cap::HTML_ESCAPE) {
let s = String::from_utf8_lossy(candidate);
s.contains('<') || s.contains('>')
} else {
false
}
}
/// Run a candidate against an external harness subprocess.
///
/// Passes the candidate via `NYX_PAYLOAD_B64` env var and checks for
/// `__NYX_SINK_HIT__` sentinel in output.
fn run_candidate_against_harness(
candidate: &[u8],
harness_cmd: &str,
payloads: &[CuratedPayload],
) -> bool {
let b64 = base64_encode(candidate);
let oracle_marker = payloads
.iter()
.filter(|p| !p.is_benign && !p.oob_nonce_slot)
.find_map(|p| {
if let Oracle::OutputContains(m) = &p.oracle {
Some(*m)
} else {
None
}
});
let parts: Vec<&str> = harness_cmd.split_whitespace().collect();
let (cmd, cmd_args) = match parts.split_first() {
Some(s) => s,
None => return false,
};
let output = std::process::Command::new(cmd)
.args(cmd_args)
.env("NYX_PAYLOAD_B64", &b64)
.output();
let Ok(out) = output else { return false };
let combined: Vec<u8> = out.stdout.iter().chain(out.stderr.iter()).copied().collect();
let sink_hit = combined.windows(16).any(|w| w == b"__NYX_SINK_HIT__");
let oracle = oracle_marker
.map(|m| combined.windows(m.len()).any(|w| w == m.as_bytes()))
.unwrap_or(false);
sink_hit && oracle
}
fn hex_encode(data: &[u8]) -> String {
data.iter().map(|b| format!("{b:02x}")).collect()
}
fn base64_encode(data: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
if chunk.len() > 1 { out.push(ALPHABET[((n >> 6) & 63) as usize] as char); } else { out.push('='); }
if chunk.len() > 2 { out.push(ALPHABET[(n & 63) as usize] as char); } else { out.push('='); }
}
out
}

286
scripts/corpus_dashboard.py Executable file
View file

@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""Corpus health report for src/dynamic/corpus.rs.
Produces:
- Per-cap coverage table (payload count, benign controls, OOB slots)
- Per-payload last-confirmed timestamp (from repro artifacts if present)
- CVE reference count
- Marker collision audit
Exit code 0 = healthy. Non-zero = collision or missing coverage.
Usage:
python3 scripts/corpus_dashboard.py [--repro-dir REPRO_DIR] [--json]
"""
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
# ── Payload table (mirrors src/dynamic/corpus.rs) ────────────────────────────
# Manually synced; CI should flag drift via cargo test no_marker_collisions.
CORPUS_VERSION = 3
@dataclass
class PayloadEntry:
cap: str
label: str
bytes_repr: str
oracle_kind: str
oracle_value: Optional[str]
is_benign: bool
provenance: str
since_corpus_version: int
deprecated_at_corpus_version: Optional[int]
fixture_paths: list[str]
oob_nonce_slot: bool
cve_refs: list[str] = field(default_factory=list)
PAYLOADS: list[PayloadEntry] = [
# ── SQL_QUERY ──────────────────────────────────────────────────────────────
PayloadEntry(
cap="SQL_QUERY", label="sqli-tautology",
bytes_repr="' OR '1'='1", oracle_kind="OutputContains",
oracle_value="NYX_SQL_CONFIRMED", is_benign=False,
provenance="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,
),
PayloadEntry(
cap="SQL_QUERY", label="sqli-union-nyx",
bytes_repr="' UNION SELECT 'NYX_SQL_CONFIRMED'--",
oracle_kind="OutputContains", oracle_value="NYX_SQL_CONFIRMED",
is_benign=False, provenance="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,
),
# ── CODE_EXEC ──────────────────────────────────────────────────────────────
PayloadEntry(
cap="CODE_EXEC", label="cmdi-echo-marker",
bytes_repr="; echo NYX_PWN_CMDI",
oracle_kind="OutputContains", oracle_value="NYX_PWN_CMDI",
is_benign=False, provenance="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,
),
# ── FILE_IO ────────────────────────────────────────────────────────────────
PayloadEntry(
cap="FILE_IO", label="path-traversal-passwd",
bytes_repr="../../../../etc/passwd",
oracle_kind="OutputContains", oracle_value="root:",
is_benign=False, provenance="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,
),
PayloadEntry(
cap="FILE_IO", label="path-traversal-benign",
bytes_repr="benign_safe_file_that_does_not_exist_NYX_BENIGN",
oracle_kind="OutputContains", oracle_value="root:",
is_benign=True, provenance="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 ───────────────────────────────────────────────────────────────────
PayloadEntry(
cap="SSRF", label="ssrf-file-scheme",
bytes_repr="file:///etc/passwd",
oracle_kind="OutputContains", oracle_value="daemon:",
is_benign=False, provenance="Curated", since_corpus_version=1,
deprecated_at_corpus_version=None,
fixture_paths=["tests/benchmark/corpus/rust/ssrf/ssrf_reqwest.rs"],
oob_nonce_slot=False,
),
PayloadEntry(
cap="SSRF", label="ssrf-oob-nonce",
bytes_repr="<OOB URL generated at runtime>",
oracle_kind="OobCallback", oracle_value="host=127.0.0.1",
is_benign=False, provenance="Curated", since_corpus_version=2,
deprecated_at_corpus_version=None,
fixture_paths=["tests/benchmark/corpus/rust/ssrf/ssrf_reqwest.rs"],
oob_nonce_slot=True,
),
# ── HTML_ESCAPE ────────────────────────────────────────────────────────────
PayloadEntry(
cap="HTML_ESCAPE", label="xss-script-marker",
bytes_repr="<script>NYX_XSS_CONFIRMED</script>",
oracle_kind="OutputContains",
oracle_value="<script>NYX_XSS_CONFIRMED</script>",
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="<script>NYX_XSS_CONFIRMED</script>",
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())

View file

@ -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,
},
];

View file

@ -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
View 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"));
}
}

View file

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

View file

@ -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.

View file

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