feat: Implement dynamic verification layer with harness generation and payload orchestration

This commit is contained in:
elipeter 2026-05-05 03:43:45 -04:00
parent fb698d2c27
commit 56e934656c
10 changed files with 582 additions and 0 deletions

View file

@ -46,6 +46,10 @@ serve = ["dep:axum", "dep:tokio", "dep:tokio-stream", "dep:tower-http"]
smt = ["dep:z3", "z3/bundled"]
smt-system-z3 = ["dep:z3"]
docgen = []
# Dynamic verification layer: builds harnesses from findings, runs them in a
# sandbox, reports back whether the sink fires. Off by default until the
# static side is honest on real corpora (see ROADMAP.md).
dynamic = []
[lib]
name = "nyx_scanner"

89
src/dynamic/corpus.rs Normal file
View file

@ -0,0 +1,89 @@
//! Per-capability payload corpus.
//!
//! Each [`Cap`] maps to a small set of canonical payloads plus a matching
//! detection oracle. Payloads are static data — adding a new one is a code
//! review, not a runtime config knob, so they cannot drift between versions.
//!
//! The corpus is intentionally minimal at the start. Goal is one payload
//! per cap that triggers reliably on the obvious case; tuning happens once
//! we have real targets.
use crate::labels::Cap;
/// A single payload + the oracle that confirms it fired.
#[derive(Debug, Clone)]
pub struct Payload {
/// Bytes injected into the [`crate::dynamic::spec::PayloadSlot`].
pub bytes: &'static [u8],
/// Human label for logs and reports (`"sqli-quote-or-1"` etc.).
pub label: &'static str,
/// How we decide the sink fired. See [`Oracle`].
pub oracle: Oracle,
}
/// Detection strategy. Multiple oracles run in parallel; first hit wins.
#[derive(Debug, Clone)]
pub enum Oracle {
/// Substring on stdout/stderr (e.g. `"PAYLOAD-MARKER"`, SQL error text).
OutputContains(&'static str),
/// Process exited with a crash signal (SIGSEGV, SIGABRT).
Crash,
/// Outbound network connection observed to a controlled sink host.
OobCallback { host: &'static str },
/// File written outside the sandbox sandbox root.
FileEscape,
/// Non-zero exit with specific status (e.g. shell command success).
ExitStatus(i32),
}
/// Pick the payload set for a given cap. Empty slice = unsupported cap.
pub fn payloads_for(cap: Cap) -> &'static [Payload] {
if cap.contains(Cap::SQL_QUERY) {
return SQLI;
}
if cap.contains(Cap::CODE_EXEC) {
return CMDI;
}
if cap.contains(Cap::FILE_IO) {
return PATH_TRAV;
}
if cap.contains(Cap::SSRF) {
return SSRF_PAYLOADS;
}
if cap.contains(Cap::HTML_ESCAPE) {
return XSS;
}
&[]
}
const SQLI: &[Payload] = &[Payload {
bytes: b"' OR 1=1-- NYX",
label: "sqli-or-1",
oracle: Oracle::OutputContains("NYX"),
}];
const CMDI: &[Payload] = &[Payload {
bytes: b"; echo NYX_PWN",
label: "cmdi-echo-marker",
oracle: Oracle::OutputContains("NYX_PWN"),
}];
const PATH_TRAV: &[Payload] = &[Payload {
bytes: b"../../../../etc/passwd",
label: "path-traversal-passwd",
oracle: Oracle::OutputContains("root:"),
}];
const SSRF_PAYLOADS: &[Payload] = &[Payload {
bytes: b"http://nyx-oob.invalid/probe",
label: "ssrf-oob",
oracle: Oracle::OobCallback {
host: "nyx-oob.invalid",
},
}];
const XSS: &[Payload] = &[Payload {
bytes: b"<script>NYX_XSS</script>",
label: "xss-script-marker",
oracle: Oracle::OutputContains("<script>NYX_XSS</script>"),
}];

52
src/dynamic/harness.rs Normal file
View file

@ -0,0 +1,52 @@
//! Harness code generation.
//!
//! Given a [`HarnessSpec`], emit a small program that:
//!
//! 1. Imports/loads the target module from the project tree.
//! 2. Reads the payload from a known channel (env var `NYX_PAYLOAD`).
//! 3. Invokes the entry point with the payload routed to the right slot.
//! 4. Lets the sink either fire or not — the oracle observes from outside.
//!
//! One generator per [`Lang`]. Each emits source plus a build command.
//! Build artefacts are staged inside the sandbox working dir, never the
//! user's tree.
use crate::dynamic::spec::HarnessSpec;
use crate::symbol::Lang;
use std::path::PathBuf;
/// A built harness ready to hand off to the sandbox.
#[derive(Debug, Clone)]
pub struct BuiltHarness {
/// Working directory containing the harness source + any build output.
pub workdir: PathBuf,
/// Command to invoke (e.g. `["python3", "harness.py"]` or
/// `["./target/release/harness"]`).
pub command: Vec<String>,
/// Environment variables to set when running. Payload bytes go in via
/// `NYX_PAYLOAD` regardless of language.
pub env: Vec<(String, String)>,
}
/// Build a harness from a spec. Returns the artefact + run command.
///
/// Stub: per-language emitters will live in their own files
/// (`harness/python.rs`, `harness/rust.rs`, etc.) and dispatch off
/// `spec.lang`.
pub fn build(_spec: &HarnessSpec) -> Result<BuiltHarness, HarnessError> {
Err(HarnessError::Unimplemented)
}
#[derive(Debug)]
pub enum HarnessError {
Unimplemented,
UnsupportedLang(Lang),
BuildFailed(String),
Io(std::io::Error),
}
impl From<std::io::Error> for HarnessError {
fn from(e: std::io::Error) -> Self {
HarnessError::Io(e)
}
}

36
src/dynamic/mod.rs Normal file
View file

@ -0,0 +1,36 @@
//! Dynamic verification layer (feature-gated: `dynamic`).
//!
//! Static analysis confirms a flow exists. Dynamic execution confirms it fires.
//! This module turns a [`crate::commands::scan::Diag`] into a runnable harness,
//! injects a payload from a per-cap corpus, executes inside a sandbox, and
//! reports back whether the sink actually triggered.
//!
//! Pipeline:
//!
//! ```text
//! Diag --> HarnessSpec --> Harness (generated source/binary)
//! |
//! v
//! Sandbox::run(payload)
//! |
//! v
//! VerifyResult
//! ```
//!
//! All submodules are read-only consumers of the static engine's output.
//! Nothing in this tree mutates SSA, taint, or label state.
//!
//! Off by default. Enable with `--features dynamic`. Heavy deps (container
//! runtime client, fuzzer harness) live behind the same gate.
pub mod corpus;
pub mod harness;
pub mod report;
pub mod runner;
pub mod sandbox;
pub mod spec;
pub mod verify;
pub use report::{VerifyResult, VerifyStatus};
pub use spec::HarnessSpec;
pub use verify::{verify_finding, VerifyOptions};

42
src/dynamic/report.rs Normal file
View file

@ -0,0 +1,42 @@
//! Verdict types returned by the dynamic layer.
//!
//! Kept separate from the run pipeline so the CLI / JSON output side can
//! depend on this without pulling in sandbox or harness deps.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VerifyStatus {
/// Sink fired with at least one payload. Static finding is exploitable
/// against the live target.
Confirmed,
/// All payloads ran cleanly. Either the path is infeasible at runtime
/// or the corpus is too narrow. Treat as "static-only" not "false".
NotConfirmed,
/// Could not build, run, or observe (toolchain missing, sandbox refused,
/// timeout on every attempt, etc.).
Inconclusive,
/// We do not yet know how to drive this finding (missing language
/// support, unsupported entry kind, no payloads for cap).
Unsupported,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyResult {
pub finding_id: String,
pub status: VerifyStatus,
/// Label of the payload that triggered, when [`VerifyStatus::Confirmed`].
pub triggered_payload: Option<String>,
/// Free-form note for inconclusive/unsupported cases.
pub reason: Option<String>,
/// Per-attempt log (payload label, exit code, timed_out flag).
pub attempts: Vec<AttemptSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttemptSummary {
pub payload_label: String,
pub exit_code: Option<i32>,
pub timed_out: bool,
pub triggered: bool,
}

100
src/dynamic/runner.rs Normal file
View file

@ -0,0 +1,100 @@
//! Orchestration: spec -> harness -> sandbox -> oracle -> verdict.
//!
//! The runner is the only place that knows about all four submodules at
//! once. Everything below it (corpus, harness, sandbox) is independent;
//! everything above it ([`crate::dynamic::verify`]) just calls
//! [`run_spec`] and turns the result into a [`crate::dynamic::report::VerifyResult`].
use crate::dynamic::corpus::{payloads_for, Oracle};
use crate::dynamic::harness::{self, BuiltHarness, HarnessError};
use crate::dynamic::sandbox::{self, SandboxError, SandboxOptions, SandboxOutcome};
use crate::dynamic::spec::HarnessSpec;
#[derive(Debug)]
pub struct RunOutcome {
pub spec: HarnessSpec,
pub attempts: Vec<Attempt>,
/// First attempt that fired the sink, if any.
pub triggered_by: Option<usize>,
}
#[derive(Debug)]
pub struct Attempt {
pub payload_label: &'static str,
pub outcome: SandboxOutcome,
pub triggered: bool,
}
#[derive(Debug)]
pub enum RunError {
NoPayloadsForCap,
Harness(HarnessError),
Sandbox(SandboxError),
}
impl From<HarnessError> for RunError {
fn from(e: HarnessError) -> Self {
RunError::Harness(e)
}
}
impl From<SandboxError> for RunError {
fn from(e: SandboxError) -> Self {
RunError::Sandbox(e)
}
}
/// Build harness once, run every payload from the cap-matched corpus,
/// stop at first trigger.
pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome, RunError> {
let payloads = payloads_for(spec.expected_cap);
if payloads.is_empty() {
return Err(RunError::NoPayloadsForCap);
}
let harness: BuiltHarness = harness::build(spec)?;
let mut attempts = Vec::with_capacity(payloads.len());
let mut triggered_by = None;
for (i, payload) in payloads.iter().enumerate() {
let outcome = sandbox::run(&harness, payload, opts)?;
let triggered = oracle_fired(&payload.oracle, &outcome);
attempts.push(Attempt {
payload_label: payload.label,
outcome,
triggered,
});
if triggered {
triggered_by = Some(i);
break;
}
}
Ok(RunOutcome {
spec: spec.clone(),
attempts,
triggered_by,
})
}
fn oracle_fired(oracle: &Oracle, outcome: &SandboxOutcome) -> bool {
match oracle {
Oracle::OutputContains(needle) => {
let nb = needle.as_bytes();
contains_subslice(&outcome.stdout, nb) || contains_subslice(&outcome.stderr, nb)
}
Oracle::Crash => matches!(outcome.exit_code, None) && !outcome.timed_out,
Oracle::OobCallback { .. } => outcome.oob_callback_seen,
Oracle::FileEscape => false, // TODO(dynamic): wire fs watcher in sandbox layer.
Oracle::ExitStatus(code) => outcome.exit_code == Some(*code),
}
}
fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || needle.len() > hay.len() {
return needle.is_empty();
}
hay.windows(needle.len()).any(|w| w == needle)
}

90
src/dynamic/sandbox.rs Normal file
View file

@ -0,0 +1,90 @@
//! Execution sandbox.
//!
//! The sandbox isolates a [`crate::dynamic::harness::BuiltHarness`] from
//! the host: no outbound network except to the oracle's OOB host, no file
//! writes outside the workdir, hard timeout, memory cap, no host PID
//! visibility.
//!
//! Two backends planned, picked at runtime:
//!
//! - **`docker`**: portable, default on Linux/macOS. Image is a thin debian
//! plus the language toolchain matching `spec.lang`.
//! - **`process`**: fallback for hosts without docker. Uses OS primitives
//! (`unshare` on Linux, `sandbox-exec` on macOS) and runs the harness
//! directly. Less isolation; gated behind `--unsafe-sandbox`.
//!
//! All public state on the sandbox is owned by the caller — there is no
//! global runtime, no daemon, no persistent containers between runs.
use crate::dynamic::corpus::Payload;
use crate::dynamic::harness::BuiltHarness;
use std::time::Duration;
/// Result of a single sandboxed run.
#[derive(Debug, Clone)]
pub struct SandboxOutcome {
/// Process exit code; `None` on timeout or signal kill.
pub exit_code: Option<i32>,
/// Captured stdout (truncated to a bound, default 64 KiB).
pub stdout: Vec<u8>,
/// Captured stderr (same bound).
pub stderr: Vec<u8>,
/// Whether the run hit `timeout`.
pub timed_out: bool,
/// Whether the OOB host received a probe.
pub oob_callback_seen: bool,
/// Wall-clock duration of the run.
pub duration: Duration,
}
#[derive(Debug, Clone)]
pub struct SandboxOptions {
/// Hard timeout. Default: 5s.
pub timeout: Duration,
/// Memory cap in MiB. Default: 256.
pub memory_mib: u64,
/// Backend selection. `Auto` = docker if available, else process.
pub backend: SandboxBackend,
}
impl Default for SandboxOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(5),
memory_mib: 256,
backend: SandboxBackend::Auto,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxBackend {
Auto,
Docker,
Process,
}
#[derive(Debug)]
pub enum SandboxError {
BackendUnavailable(SandboxBackend),
Spawn(std::io::Error),
Io(std::io::Error),
}
impl From<std::io::Error> for SandboxError {
fn from(e: std::io::Error) -> Self {
SandboxError::Io(e)
}
}
/// Run a built harness once with a chosen payload.
///
/// Stub: dispatches to one of the backend submodules
/// (`sandbox/docker.rs`, `sandbox/process.rs`) once those land.
pub fn run(
_harness: &BuiltHarness,
_payload: &Payload,
_opts: &SandboxOptions,
) -> Result<SandboxOutcome, SandboxError> {
Err(SandboxError::BackendUnavailable(SandboxBackend::Auto))
}

81
src/dynamic/spec.rs Normal file
View file

@ -0,0 +1,81 @@
//! Harness specification: the bridge between a static finding and a runnable harness.
//!
//! A [`HarnessSpec`] is built from a [`crate::commands::scan::Diag`] without
//! any further analysis. It records what the dynamic side needs to know:
//! which entry point to drive, which parameter carries the payload, what
//! sink (cap) we expect to hit, and which language toolchain to use.
//!
//! Construction is total but may return `None` when the finding lacks the
//! evidence required to drive it dynamically (no source span, no callable
//! entry, sink in dead code, etc.). Those findings stay static-only.
use crate::commands::scan::Diag;
use crate::labels::Cap;
use crate::symbol::Lang;
use serde::{Deserialize, Serialize};
/// What kind of entry point the harness should call.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EntryKind {
/// Free function. Build a `main` that calls it directly.
Function,
/// HTTP route. Stand up the framework, send a request.
HttpRoute,
/// CLI subcommand. Spawn the binary with crafted argv.
CliSubcommand,
/// Library API surface. Build an in-process consumer.
LibraryApi,
}
/// Where the payload goes when the harness fires.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PayloadSlot {
/// Nth positional parameter of the entry function.
Param(usize),
/// Named HTTP query parameter.
QueryParam(String),
/// HTTP request body (raw bytes).
HttpBody,
/// Environment variable.
EnvVar(String),
/// CLI argv slot (0-based, excluding argv[0]).
Argv(usize),
/// stdin.
Stdin,
}
/// Self-contained recipe for building and running a single harness.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarnessSpec {
/// Stable id of the source finding (`Diag::id` plus location hash).
pub finding_id: String,
/// Project-relative path to the file holding the entry point.
pub entry_file: String,
/// Function/route/subcommand name to drive.
pub entry_name: String,
/// How to invoke it.
pub entry_kind: EntryKind,
/// Source language (drives toolchain selection).
pub lang: Lang,
/// Where the payload is injected.
pub payload_slot: PayloadSlot,
/// Sink capability we expect to fire (drives oracle + corpus pick).
pub expected_cap: Cap,
/// Optional symex-derived constraint hints (prefix/suffix locks, etc.).
/// Populated later from `Evidence::engine_notes` when available.
#[serde(default)]
pub constraint_hints: Vec<String>,
}
impl HarnessSpec {
/// Build a spec from a finding. Returns `None` when the finding cannot
/// be driven dynamically (missing entry, ambient sink, etc.).
///
/// Stub: real impl will read `Diag::evidence.flow_steps` to pick the
/// outermost entry function and walk the source span back to a parameter.
pub fn from_finding(_diag: &Diag) -> Option<Self> {
// TODO(dynamic): map flow_steps[0] -> entry function, evidence.source_span -> PayloadSlot,
// evidence.sink_caps -> expected_cap.
None
}
}

86
src/dynamic/verify.rs Normal file
View file

@ -0,0 +1,86 @@
//! Top-level entry point for the dynamic layer.
//!
//! The CLI subcommand and any library consumer call [`verify_finding`].
//! It is the only function the rest of the crate needs to know about.
use crate::commands::scan::Diag;
use crate::dynamic::report::{AttemptSummary, VerifyResult, VerifyStatus};
use crate::dynamic::runner::{run_spec, RunError};
use crate::dynamic::sandbox::SandboxOptions;
use crate::dynamic::spec::HarnessSpec;
#[derive(Debug, Clone, Default)]
pub struct VerifyOptions {
pub sandbox: SandboxOptions,
}
/// Try to dynamically confirm a static finding.
///
/// Never fails: every error path collapses into a [`VerifyStatus`] so the
/// caller can treat dynamic verification as best-effort enrichment.
pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
let finding_id = diag.id.clone();
let Some(spec) = HarnessSpec::from_finding(diag) else {
return VerifyResult {
finding_id,
status: VerifyStatus::Unsupported,
triggered_payload: None,
reason: Some("no harness spec derivable from finding".into()),
attempts: vec![],
};
};
match run_spec(&spec, &opts.sandbox) {
Ok(run) => {
let attempts = run
.attempts
.iter()
.map(|a| AttemptSummary {
payload_label: a.payload_label.to_string(),
exit_code: a.outcome.exit_code,
timed_out: a.outcome.timed_out,
triggered: a.triggered,
})
.collect();
match run.triggered_by {
Some(i) => VerifyResult {
finding_id,
status: VerifyStatus::Confirmed,
triggered_payload: Some(run.attempts[i].payload_label.to_string()),
reason: None,
attempts,
},
None => VerifyResult {
finding_id,
status: VerifyStatus::NotConfirmed,
triggered_payload: None,
reason: None,
attempts,
},
}
}
Err(RunError::NoPayloadsForCap) => VerifyResult {
finding_id,
status: VerifyStatus::Unsupported,
triggered_payload: None,
reason: Some("no payload corpus for sink cap".into()),
attempts: vec![],
},
Err(RunError::Harness(e)) => VerifyResult {
finding_id,
status: VerifyStatus::Inconclusive,
triggered_payload: None,
reason: Some(format!("harness build failed: {e:?}")),
attempts: vec![],
},
Err(RunError::Sandbox(e)) => VerifyResult {
finding_id,
status: VerifyStatus::Inconclusive,
triggered_payload: None,
reason: Some(format!("sandbox failed: {e:?}")),
attempts: vec![],
},
}
}

View file

@ -99,6 +99,8 @@ pub mod commands;
pub mod constraint;
pub mod convergence_telemetry;
pub mod database;
#[cfg(feature = "dynamic")]
pub mod dynamic;
pub mod engine_notes;
pub mod errors;
pub mod evidence;