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