From 56e934656c0e345b05191ea0fba6af45f94b3e92 Mon Sep 17 00:00:00 2001 From: elipeter Date: Tue, 5 May 2026 03:43:45 -0400 Subject: [PATCH] feat: Implement dynamic verification layer with harness generation and payload orchestration --- Cargo.toml | 4 ++ src/dynamic/corpus.rs | 89 ++++++++++++++++++++++++++++++++++++ src/dynamic/harness.rs | 52 +++++++++++++++++++++ src/dynamic/mod.rs | 36 +++++++++++++++ src/dynamic/report.rs | 42 +++++++++++++++++ src/dynamic/runner.rs | 100 +++++++++++++++++++++++++++++++++++++++++ src/dynamic/sandbox.rs | 90 +++++++++++++++++++++++++++++++++++++ src/dynamic/spec.rs | 81 +++++++++++++++++++++++++++++++++ src/dynamic/verify.rs | 86 +++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + 10 files changed, 582 insertions(+) create mode 100644 src/dynamic/corpus.rs create mode 100644 src/dynamic/harness.rs create mode 100644 src/dynamic/mod.rs create mode 100644 src/dynamic/report.rs create mode 100644 src/dynamic/runner.rs create mode 100644 src/dynamic/sandbox.rs create mode 100644 src/dynamic/spec.rs create mode 100644 src/dynamic/verify.rs diff --git a/Cargo.toml b/Cargo.toml index 2b39957d..73dbe6c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/dynamic/corpus.rs b/src/dynamic/corpus.rs new file mode 100644 index 00000000..50d02205 --- /dev/null +++ b/src/dynamic/corpus.rs @@ -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"", + label: "xss-script-marker", + oracle: Oracle::OutputContains(""), +}]; diff --git a/src/dynamic/harness.rs b/src/dynamic/harness.rs new file mode 100644 index 00000000..011c0291 --- /dev/null +++ b/src/dynamic/harness.rs @@ -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, + /// 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 { + Err(HarnessError::Unimplemented) +} + +#[derive(Debug)] +pub enum HarnessError { + Unimplemented, + UnsupportedLang(Lang), + BuildFailed(String), + Io(std::io::Error), +} + +impl From for HarnessError { + fn from(e: std::io::Error) -> Self { + HarnessError::Io(e) + } +} diff --git a/src/dynamic/mod.rs b/src/dynamic/mod.rs new file mode 100644 index 00000000..411574ce --- /dev/null +++ b/src/dynamic/mod.rs @@ -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}; diff --git a/src/dynamic/report.rs b/src/dynamic/report.rs new file mode 100644 index 00000000..324c14ad --- /dev/null +++ b/src/dynamic/report.rs @@ -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, + /// Free-form note for inconclusive/unsupported cases. + pub reason: Option, + /// Per-attempt log (payload label, exit code, timed_out flag). + pub attempts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttemptSummary { + pub payload_label: String, + pub exit_code: Option, + pub timed_out: bool, + pub triggered: bool, +} diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs new file mode 100644 index 00000000..f1ee22ca --- /dev/null +++ b/src/dynamic/runner.rs @@ -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, + /// First attempt that fired the sink, if any. + pub triggered_by: Option, +} + +#[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 for RunError { + fn from(e: HarnessError) -> Self { + RunError::Harness(e) + } +} + +impl From 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 { + 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) +} + diff --git a/src/dynamic/sandbox.rs b/src/dynamic/sandbox.rs new file mode 100644 index 00000000..87a367c7 --- /dev/null +++ b/src/dynamic/sandbox.rs @@ -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, + /// Captured stdout (truncated to a bound, default 64 KiB). + pub stdout: Vec, + /// Captured stderr (same bound). + pub stderr: Vec, + /// 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 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 { + Err(SandboxError::BackendUnavailable(SandboxBackend::Auto)) +} diff --git a/src/dynamic/spec.rs b/src/dynamic/spec.rs new file mode 100644 index 00000000..e05df92d --- /dev/null +++ b/src/dynamic/spec.rs @@ -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, +} + +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 { + // TODO(dynamic): map flow_steps[0] -> entry function, evidence.source_span -> PayloadSlot, + // evidence.sink_caps -> expected_cap. + None + } +} diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs new file mode 100644 index 00000000..e3f8a72d --- /dev/null +++ b/src/dynamic/verify.rs @@ -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![], + }, + } +} diff --git a/src/lib.rs b/src/lib.rs index 93815af7..f6d802b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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;