From 76087f931a87d9daf4626c88da874f5c60a4a62c Mon Sep 17 00:00:00 2001 From: pitboss Date: Fri, 15 May 2026 08:35:40 -0500 Subject: [PATCH] =?UTF-8?q?[pitboss]=20phase=2016:=20Track=20B=20=E2=80=94?= =?UTF-8?q?=20Rust=20+=20C=20+=20C++=20harness=20emitter=20shapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dynamic/build_sandbox.rs | 159 ++++++++ src/dynamic/harness.rs | 19 +- src/dynamic/lang/c.rs | 357 +++++++++++++++++- src/dynamic/lang/cpp.rs | 321 +++++++++++++++- src/dynamic/lang/rust.rs | 300 +++++++++++++-- src/dynamic/runner.rs | 40 ++ tests/c_fixtures.rs | 157 ++++++++ tests/cpp_fixtures.rs | 157 ++++++++ tests/dynamic_fixtures/c/free_fn/benign.c | 11 + tests/dynamic_fixtures/c/free_fn/vuln.c | 17 + tests/dynamic_fixtures/c/libfuzzer/benign.c | 13 + tests/dynamic_fixtures/c/libfuzzer/vuln.c | 20 + tests/dynamic_fixtures/c/main_argv/benign.c | 15 + tests/dynamic_fixtures/c/main_argv/vuln.c | 25 ++ tests/dynamic_fixtures/cpp/free_fn/benign.cpp | 12 + tests/dynamic_fixtures/cpp/free_fn/vuln.cpp | 15 + .../dynamic_fixtures/cpp/libfuzzer/benign.cpp | 14 + tests/dynamic_fixtures/cpp/libfuzzer/vuln.cpp | 17 + .../dynamic_fixtures/cpp/main_argv/benign.cpp | 13 + tests/dynamic_fixtures/cpp/main_argv/vuln.cpp | 18 + .../rust/actix_route/benign.rs | 16 + .../dynamic_fixtures/rust/actix_route/vuln.rs | 21 ++ .../rust/axum_handler/benign.rs | 15 + .../rust/axum_handler/vuln.rs | 19 + .../dynamic_fixtures/rust/clap_cli/benign.rs | 14 + tests/dynamic_fixtures/rust/clap_cli/vuln.rs | 20 + .../rust/libfuzzer_target/benign.rs | 14 + .../rust/libfuzzer_target/vuln.rs | 19 + tests/dynamic_verify_e2e.rs | 43 +-- tests/rust_fixtures.rs | 172 +++++++++ tests/spec_derivation_strategies.rs | 16 +- 31 files changed, 1969 insertions(+), 100 deletions(-) create mode 100644 tests/c_fixtures.rs create mode 100644 tests/cpp_fixtures.rs create mode 100644 tests/dynamic_fixtures/c/free_fn/benign.c create mode 100644 tests/dynamic_fixtures/c/free_fn/vuln.c create mode 100644 tests/dynamic_fixtures/c/libfuzzer/benign.c create mode 100644 tests/dynamic_fixtures/c/libfuzzer/vuln.c create mode 100644 tests/dynamic_fixtures/c/main_argv/benign.c create mode 100644 tests/dynamic_fixtures/c/main_argv/vuln.c create mode 100644 tests/dynamic_fixtures/cpp/free_fn/benign.cpp create mode 100644 tests/dynamic_fixtures/cpp/free_fn/vuln.cpp create mode 100644 tests/dynamic_fixtures/cpp/libfuzzer/benign.cpp create mode 100644 tests/dynamic_fixtures/cpp/libfuzzer/vuln.cpp create mode 100644 tests/dynamic_fixtures/cpp/main_argv/benign.cpp create mode 100644 tests/dynamic_fixtures/cpp/main_argv/vuln.cpp create mode 100644 tests/dynamic_fixtures/rust/actix_route/benign.rs create mode 100644 tests/dynamic_fixtures/rust/actix_route/vuln.rs create mode 100644 tests/dynamic_fixtures/rust/axum_handler/benign.rs create mode 100644 tests/dynamic_fixtures/rust/axum_handler/vuln.rs create mode 100644 tests/dynamic_fixtures/rust/clap_cli/benign.rs create mode 100644 tests/dynamic_fixtures/rust/clap_cli/vuln.rs create mode 100644 tests/dynamic_fixtures/rust/libfuzzer_target/benign.rs create mode 100644 tests/dynamic_fixtures/rust/libfuzzer_target/vuln.rs diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index 2c938e62..4014ea92 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -808,6 +808,165 @@ fn compute_php_lockfile_hash(workdir: &Path) -> String { format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap())) } +// ── C build sandbox ─────────────────────────────────────────────────────────── + +/// Prepare a compiled C binary for `spec`. +/// +/// Checks a build cache keyed on `(main.c + entry.c hash, "c", toolchain_id)`. +/// On a cache hit returns immediately; otherwise runs +/// `cc -O0 -g -o nyx_harness main.c` in `workdir`. +/// +/// Build isolation is NOT yet implemented (deferred). `cc` runs on the host. +pub fn prepare_c(spec: &HarnessSpec, workdir: &Path) -> Result { + let source_hash = compute_c_source_hash(workdir); + let cache_path = build_cache_path(&source_hash, "c", &spec.toolchain_id)?; + + let binary = cache_path.join("nyx_harness"); + if binary.exists() { + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: true, + duration: std::time::Duration::ZERO, + }); + } + + let start = std::time::Instant::now(); + const MAX_ATTEMPTS: u32 = 2; + const BACKOFF: [u64; 2] = [1, 4]; + let mut last_err = String::new(); + + for attempt in 0..MAX_ATTEMPTS { + if attempt > 0 { + std::thread::sleep(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1])); + } + let _ = std::fs::remove_dir_all(&cache_path); + std::fs::create_dir_all(&cache_path)?; + + match try_build_c_binary(workdir, &binary) { + Ok(()) => { + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: false, + duration: start.elapsed(), + }); + } + Err(e) => { + last_err = e; + let _ = std::fs::remove_file(&binary); + } + } + } + + Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS }) +} + +fn try_build_c_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String> { + let cc_bin = std::env::var("NYX_CC_BIN").unwrap_or_else(|_| "cc".to_owned()); + let output = Command::new(&cc_bin) + .args(["-O0", "-g", "-o", binary_dest.to_str().unwrap_or("nyx_harness"), "main.c"]) + .current_dir(workdir) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", std::env::var("HOME").unwrap_or_default()) + .output() + .map_err(|e| format!("cc: {e}"))?; + + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + Ok(()) +} + +fn compute_c_source_hash(workdir: &Path) -> String { + let mut h = Hasher::new(); + for fname in &["main.c", "entry.c", "Makefile"] { + if let Ok(content) = std::fs::read(workdir.join(fname)) { + h.update(fname.as_bytes()); + h.update(&content); + } + } + let out = h.finalize(); + format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap())) +} + +// ── C++ build sandbox ───────────────────────────────────────────────────────── + +/// Prepare a compiled C++ binary for `spec`. +pub fn prepare_cpp(spec: &HarnessSpec, workdir: &Path) -> Result { + let source_hash = compute_cpp_source_hash(workdir); + let cache_path = build_cache_path(&source_hash, "cpp", &spec.toolchain_id)?; + + let binary = cache_path.join("nyx_harness"); + if binary.exists() { + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: true, + duration: std::time::Duration::ZERO, + }); + } + + let start = std::time::Instant::now(); + const MAX_ATTEMPTS: u32 = 2; + const BACKOFF: [u64; 2] = [1, 4]; + let mut last_err = String::new(); + + for attempt in 0..MAX_ATTEMPTS { + if attempt > 0 { + std::thread::sleep(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1])); + } + let _ = std::fs::remove_dir_all(&cache_path); + std::fs::create_dir_all(&cache_path)?; + + match try_build_cpp_binary(workdir, &binary) { + Ok(()) => { + return Ok(BuildResult { + venv_path: cache_path, + cache_hit: false, + duration: start.elapsed(), + }); + } + Err(e) => { + last_err = e; + let _ = std::fs::remove_file(&binary); + } + } + } + + Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS }) +} + +fn try_build_cpp_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String> { + let cxx_bin = std::env::var("NYX_CXX_BIN").unwrap_or_else(|_| { + // Prefer c++ which resolves to the system default compiler driver. + "c++".to_owned() + }); + let output = Command::new(&cxx_bin) + .args(["-O0", "-g", "-std=c++17", "-o", binary_dest.to_str().unwrap_or("nyx_harness"), "main.cpp"]) + .current_dir(workdir) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", std::env::var("HOME").unwrap_or_default()) + .output() + .map_err(|e| format!("c++: {e}"))?; + + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + Ok(()) +} + +fn compute_cpp_source_hash(workdir: &Path) -> String { + let mut h = Hasher::new(); + for fname in &["main.cpp", "entry.cpp", "CMakeLists.txt"] { + if let Ok(content) = std::fs::read(workdir.join(fname)) { + h.update(fname.as_bytes()); + h.update(&content); + } + } + let out = h.finalize(); + format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap())) +} + // ── Docker-isolated build step functions ───────────────────────────────────── // // Each function runs the language's build tool inside a Docker container with diff --git a/src/dynamic/harness.rs b/src/dynamic/harness.rs index 98542ebe..8106e718 100644 --- a/src/dynamic/harness.rs +++ b/src/dynamic/harness.rs @@ -180,19 +180,22 @@ mod tests { use crate::symbol::Lang; #[test] - fn build_unsupported_lang_returns_err() { - // C is not supported (no emitter exists for it). + fn build_unsupported_entry_kind_returns_err() { + // The Python emitter advertises a specific entry-kind set; an + // unsupported entry kind short-circuits with + // [`UnsupportedReason::EntryKindUnsupported`] before any harness + // source is generated. let spec = HarnessSpec { finding_id: "0000000000000001".into(), - entry_file: "main.c".into(), - entry_name: "handleRequest".into(), - entry_kind: EntryKind::Function, - lang: Lang::C, - toolchain_id: "c-stable".into(), + entry_file: "src/app.py".into(), + entry_name: "handler".into(), + entry_kind: EntryKind::LibraryApi, + lang: Lang::Python, + toolchain_id: "python-3".into(), payload_slot: PayloadSlot::Param(0), expected_cap: Cap::SQL_QUERY, constraint_hints: vec![], - sink_file: "main.c".into(), + sink_file: "src/app.py".into(), sink_line: 5, spec_hash: "0000000000000000".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs index 4797d00b..1337d2c7 100644 --- a/src/dynamic/lang/c.rs +++ b/src/dynamic/lang/c.rs @@ -1,22 +1,108 @@ -//! C harness emitter (stub). +//! C harness emitter. //! -//! No harness source is generated yet — `emit` returns -//! [`UnsupportedReason::LangUnsupported`]. The module exists so that -//! [`crate::dynamic::lang::entry_kinds_supported`] can advertise the entry -//! kinds Track B will deliver (Phase 16: `main(argc, argv)`, -//! `LLVMFuzzerTestOneInput`, free functions with `(const char*, size_t)` or -//! `(int, char**)` shapes) and so the verifier can surface -//! `Inconclusive(EntryKindUnsupported { … })` instead of dropping C findings. +//! Phase 16 (Track B Rust + C/C++ vertical) replaces the stub body with +//! dispatch over [`CShape`] — the cross product of [`EntryKind`] and a +//! lightweight per-file shape detector that inspects the entry file for +//! `main(int argc, char *argv[])`, libFuzzer's `LLVMFuzzerTestOneInput`, +//! and free functions with `(const char*, size_t)` signatures. +//! +//! Each shape emits a single `main.c` that: +//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars. +//! 2. `#include`s `entry.c` (the user's vulnerable code) and dispatches +//! via the per-shape adapter. +//! +//! Build step: `prepare_c()` in `build_sandbox.rs` runs +//! `cc -O0 -o nyx_harness main.c` in the workdir. +//! +//! File layout in workdir: +//! ```text +//! main.c ← harness entry point (generated, includes entry.c) +//! entry.c ← user entry source (copied from project) +//! Makefile ← optional, generated for reference +//! ``` +//! +//! Payload slot support: +//! - `PayloadSlot::Param(0)` — pass payload as the first parameter (string +//! or `(buf, len)` pair depending on shape). +//! - `PayloadSlot::EnvVar(name)` — set env var before invoking entry. +//! - `PayloadSlot::Argv(n)` — `main(argc, argv)` shape: appended to argv. use crate::dynamic::lang::{HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec}; +use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; +use std::path::PathBuf; /// Zero-sized [`LangEmitter`] handle for C. pub struct CEmitter; -/// Entry kinds the C emitter intends to support once Phase 16 lands. -const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; +/// Entry kinds the C emitter understands after Phase 16. +/// +/// `Function` covers free functions (libfuzzer-style + plain (const +/// char*, size_t)). `CliSubcommand` covers `main(argc, argv)`. +/// `LibraryApi` covers libFuzzer `LLVMFuzzerTestOneInput`. +const SUPPORTED: &[EntryKind] = &[ + EntryKind::Function, + EntryKind::CliSubcommand, + EntryKind::LibraryApi, +]; + +// ── Phase 16: shape detector ───────────────────────────────────────────────── + +/// Concrete per-file shape resolved by reading the entry source. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CShape { + /// `int main(int argc, char *argv[])`. Harness embeds payload into + /// argv and calls `main(argc, argv)` directly. + MainArgv, + /// libFuzzer-style: `int LLVMFuzzerTestOneInput(const uint8_t *data, + /// size_t size)`. Harness invokes with `payload` bytes + length. + LibfuzzerEntry, + /// Free function with `(const char *, size_t)` or `(const char *)` + /// signature. Harness invokes directly. + FreeFn, +} + +impl CShape { + /// Detect the shape from `(spec, source)`. + pub fn detect(spec: &HarnessSpec, source: &str) -> Self { + let entry = spec.entry_name.as_str(); + let kind = spec.entry_kind; + + let has_main_argv = (source.contains("int main(") || source.contains("int main (")) + && (source.contains("argc") || source.contains("char *argv") + || source.contains("char* argv") || source.contains("char **argv")); + let has_libfuzzer = source.contains("LLVMFuzzerTestOneInput") || entry == "LLVMFuzzerTestOneInput"; + + if has_libfuzzer { + return Self::LibfuzzerEntry; + } + if entry == "main" || has_main_argv { + return Self::MainArgv; + } + match kind { + EntryKind::CliSubcommand => Self::MainArgv, + EntryKind::LibraryApi => Self::LibfuzzerEntry, + _ => Self::FreeFn, + } + } +} + +/// Public wrapper: detect the shape for a finalised `HarnessSpec`, reading +/// the entry file from disk. +pub fn detect_shape(spec: &HarnessSpec) -> CShape { + let src = read_entry_source(&spec.entry_file); + CShape::detect(spec, &src) +} + +fn read_entry_source(entry_file: &str) -> String { + let candidates = [PathBuf::from(entry_file), PathBuf::from(".").join(entry_file)]; + for path in &candidates { + if let Ok(s) = std::fs::read_to_string(path) { + return s; + } + } + String::new() +} /// Source of the `__nyx_probe` shim for the (future) C harness (Phase 06 — /// Track C.1). Variadic over `const char *` args; hand-rolled JSON keeps @@ -208,8 +294,8 @@ static void __nyx_install_crash_guard(const char *sink_callee) { } impl LangEmitter for CEmitter { - fn emit(&self, _spec: &HarnessSpec) -> Result { - Err(UnsupportedReason::LangUnsupported) + fn emit(&self, spec: &HarnessSpec) -> Result { + emit(spec) } fn entry_kinds_supported(&self) -> &'static [EntryKind] { @@ -218,18 +304,198 @@ impl LangEmitter for CEmitter { fn entry_kind_hint(&self, attempted: EntryKind) -> String { format!( - "c emitter is a stub; once Phase 16 (Track B Rust + C/C++ vertical) lands it will support {SUPPORTED:?} plus libFuzzer + main(argc, argv) shapes — attempted `EntryKind::{attempted}`" + "c emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 16 shape dispatch (main / libFuzzer / free function)" ) } } +/// Emit a C harness for `spec`. +pub fn emit(spec: &HarnessSpec) -> Result { + let shape = detect_shape(spec); + + match (&spec.payload_slot, shape) { + (PayloadSlot::Param(0) | PayloadSlot::EnvVar(_), _) => {} + (PayloadSlot::Argv(_), CShape::MainArgv) => {} + _ => return Err(UnsupportedReason::PayloadSlotUnsupported), + } + + let main_c = generate_main_c(spec, shape); + let makefile = generate_makefile(); + + Ok(HarnessSource { + source: main_c, + filename: "main.c".into(), + command: vec!["./nyx_harness".into()], + extra_files: vec![("Makefile".into(), makefile)], + entry_subpath: Some("entry.c".into()), + }) +} + +/// Generate the harness `main.c` for the resolved shape. +fn generate_main_c(spec: &HarnessSpec, shape: CShape) -> String { + let invocation = invoke_for_shape(spec, shape); + + format!( + r#"/* Nyx dynamic harness — auto-generated, do not edit (Phase 16 — CShape::{shape:?}). */ +#include +#include +#include +#include +#include + +/* Forward declarations: the entry file is appended below via `#include` + * so the harness can call user-defined functions without a separate + * compilation unit. */ +static char *nyx_payload(void); + +#include "entry.c" + +int main(int argc, char *argv[]) {{ + (void)argc; (void)argv; + char *payload = nyx_payload(); + if (!payload) payload = (char*)""; + +{invocation} + /* Intentionally no free(payload): payload is either a strdup/b64_decode + * heap pointer or a string literal substituted above when allocation + * failed. free() on the literal is UB; the process exits immediately + * so the kernel reclaims the heap copy. */ + return 0; +}} + +/* Minimal base64 decoder (no external deps). */ +static int nyx_b64_value(unsigned char c) {{ + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +}} + +static char *nyx_b64_decode(const char *in) {{ + size_t n = strlen(in); + char *out = (char *)malloc(n + 1); + if (!out) return NULL; + size_t outi = 0; + int buf = 0, bits = 0; + for (size_t i = 0; i < n; ++i) {{ + if (in[i] == '\n' || in[i] == '\r' || in[i] == '=') continue; + int v = nyx_b64_value((unsigned char)in[i]); + if (v < 0) {{ free(out); return NULL; }} + buf = (buf << 6) | v; + bits += 6; + if (bits >= 8) {{ + bits -= 8; + out[outi++] = (char)((buf >> bits) & 0xFF); + }} + }} + out[outi] = '\0'; + return out; +}} + +static char *nyx_payload(void) {{ + const char *v = getenv("NYX_PAYLOAD"); + if (v && *v) {{ + return strdup(v); + }} + const char *b64 = getenv("NYX_PAYLOAD_B64"); + if (b64 && *b64) {{ + return nyx_b64_decode(b64); + }} + return strdup(""); +}} +"#, + shape = shape, + invocation = invocation, + ) +} + +fn invoke_for_shape(spec: &HarnessSpec, shape: CShape) -> String { + let entry_fn = &spec.entry_name; + match shape { + CShape::FreeFn => match &spec.payload_slot { + PayloadSlot::EnvVar(name) => format!( + " setenv({name:?}, payload, 1);\n {entry_fn}(payload, strlen(payload));\n", + ), + _ => format!(" {entry_fn}(payload, strlen(payload));\n"), + }, + CShape::LibfuzzerEntry => { + // libFuzzer: `int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)`. + format!( + " {entry_fn}((const uint8_t *)payload, strlen(payload));\n", + entry_fn = entry_fn, + ) + } + CShape::MainArgv => { + // Rename the user-supplied entry to `nyx_entry_main` via macro so + // it does not collide with the harness `main` symbol when the + // entry source defines `int main(...)`. Fixture authors should + // expose the entry as a function named in `spec.entry_name`. + let pad = match &spec.payload_slot { + PayloadSlot::Argv(n) => *n, + _ => 0, + }; + let mut buf = String::from(" char *new_argv[8];\n"); + buf.push_str(" int new_argc = 0;\n"); + buf.push_str(" new_argv[new_argc++] = (char*)\"nyx_harness\";\n"); + for _ in 0..pad { + buf.push_str(" new_argv[new_argc++] = (char*)\"\";\n"); + } + buf.push_str(" new_argv[new_argc++] = payload;\n"); + buf.push_str(" new_argv[new_argc] = NULL;\n"); + buf.push_str(&format!(" {entry_fn}(new_argc, new_argv);\n")); + buf + } + } +} + +fn generate_makefile() -> String { + r#"# Phase 16 — reference Makefile, not used by the runner (the build sandbox +# calls cc directly). Kept so reproductions can re-build the harness by hand. +CC ?= cc +CFLAGS ?= -O0 -g +all: nyx_harness +nyx_harness: main.c entry.c + $(CC) $(CFLAGS) -o nyx_harness main.c +clean: + rm -f nyx_harness +"# + .to_owned() +} + #[cfg(test)] mod tests { use super::*; + use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::labels::Cap; + use crate::symbol::Lang; + + fn make_spec(payload_slot: PayloadSlot) -> HarnessSpec { + HarnessSpec { + finding_id: "c00000000000001".into(), + entry_file: "entry.c".into(), + entry_name: "run".into(), + entry_kind: EntryKind::Function, + lang: Lang::C, + toolchain_id: "gcc-stable".into(), + payload_slot, + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: "entry.c".into(), + sink_line: 10, + spec_hash: "ctest0000000001".into(), + derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + } + } #[test] fn entry_kinds_supported_is_non_empty() { assert!(!CEmitter.entry_kinds_supported().is_empty()); + assert!(CEmitter.entry_kinds_supported().contains(&EntryKind::Function)); + assert!(CEmitter.entry_kinds_supported().contains(&EntryKind::CliSubcommand)); + assert!(CEmitter.entry_kinds_supported().contains(&EntryKind::LibraryApi)); } #[test] @@ -238,4 +504,67 @@ mod tests { assert!(hint.contains("LibraryApi")); assert!(hint.contains("Phase 16")); } + + #[test] + fn shape_detect_main_argv() { + let src = "int main(int argc, char *argv[]) { return 0; }"; + let mut spec = make_spec(PayloadSlot::Argv(0)); + spec.entry_kind = EntryKind::CliSubcommand; + spec.entry_name = "main".into(); + assert_eq!(CShape::detect(&spec, src), CShape::MainArgv); + } + + #[test] + fn shape_detect_libfuzzer_entry() { + let src = "int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { return 0; }"; + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.entry_kind = EntryKind::LibraryApi; + spec.entry_name = "LLVMFuzzerTestOneInput".into(); + assert_eq!(CShape::detect(&spec, src), CShape::LibfuzzerEntry); + } + + #[test] + fn shape_detect_free_fn() { + let src = "void run(const char *s, size_t n) { (void)s; (void)n; }"; + let spec = make_spec(PayloadSlot::Param(0)); + assert_eq!(CShape::detect(&spec, src), CShape::FreeFn); + } + + #[test] + fn emit_produces_source() { + let spec = make_spec(PayloadSlot::Param(0)); + let h = emit(&spec).unwrap(); + assert_eq!(h.filename, "main.c"); + assert!(h.source.contains("#include \"entry.c\"")); + assert!(h.source.contains("run(payload, strlen(payload))")); + assert_eq!(h.command, vec!["./nyx_harness"]); + assert_eq!(h.entry_subpath, Some("entry.c".to_string())); + } + + #[test] + fn emit_main_argv_shape_routes_through_new_argv() { + let mut spec = make_spec(PayloadSlot::Argv(0)); + spec.entry_kind = EntryKind::CliSubcommand; + spec.entry_name = "nyx_entry_main".into(); + let h = emit(&spec).unwrap(); + assert!(h.source.contains("new_argv[new_argc++] = payload")); + assert!(h.source.contains("nyx_entry_main(new_argc, new_argv)")); + } + + #[test] + fn emit_libfuzzer_shape_passes_bytes() { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.entry_kind = EntryKind::LibraryApi; + spec.entry_name = "LLVMFuzzerTestOneInput".into(); + let h = emit(&spec).unwrap(); + assert!(h.source.contains("LLVMFuzzerTestOneInput((const uint8_t *)payload, strlen(payload))")); + } + + #[test] + fn emit_makefile_in_extra_files() { + let spec = make_spec(PayloadSlot::Param(0)); + let h = emit(&spec).unwrap(); + let mk = h.extra_files.iter().find(|(n, _)| n == "Makefile").expect("Makefile must be staged"); + assert!(mk.1.contains("nyx_harness: main.c entry.c")); + } } diff --git a/src/dynamic/lang/cpp.rs b/src/dynamic/lang/cpp.rs index cec881f1..fc634f1d 100644 --- a/src/dynamic/lang/cpp.rs +++ b/src/dynamic/lang/cpp.rs @@ -1,22 +1,88 @@ -//! C++ harness emitter (stub). +//! C++ harness emitter. //! -//! No harness source is generated yet — `emit` returns -//! [`UnsupportedReason::LangUnsupported`]. The module exists so that -//! [`crate::dynamic::lang::entry_kinds_supported`] can advertise the entry -//! kinds Track B will deliver (Phase 16: `main(argc, argv)`, -//! `LLVMFuzzerTestOneInput`, free functions with `(const char*, size_t)`) -//! and so the verifier can surface `Inconclusive(EntryKindUnsupported { … })` -//! instead of dropping C++ findings. +//! Phase 16 (Track B Rust + C/C++ vertical) replaces the stub body with +//! dispatch over [`CppShape`] — `main(int argc, char *argv[])`, libFuzzer +//! `LLVMFuzzerTestOneInput`, and free functions with `(const char*, +//! size_t)` or `(const std::string&)` signatures. +//! +//! File layout in workdir: +//! ```text +//! main.cpp ← harness entry point (generated, includes entry.cpp) +//! entry.cpp ← user entry source (copied from project) +//! CMakeLists.txt ← optional, generated for reference +//! ``` +//! +//! Build step: `prepare_cpp()` in `build_sandbox.rs` runs +//! `g++ -O0 -std=c++17 -o nyx_harness main.cpp` in the workdir. use crate::dynamic::lang::{HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec}; +use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; +use std::path::PathBuf; /// Zero-sized [`LangEmitter`] handle for C++. pub struct CppEmitter; -/// Entry kinds the C++ emitter intends to support once Phase 16 lands. -const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; +/// Entry kinds the C++ emitter understands after Phase 16. +const SUPPORTED: &[EntryKind] = &[ + EntryKind::Function, + EntryKind::CliSubcommand, + EntryKind::LibraryApi, +]; + +// ── Phase 16: shape detector ───────────────────────────────────────────────── + +/// Concrete per-file shape resolved by reading the entry source. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CppShape { + /// `int main(int argc, char *argv[])`. + MainArgv, + /// libFuzzer-style: `int LLVMFuzzerTestOneInput(const uint8_t *, size_t)`. + LibfuzzerEntry, + /// Free function with `(const char *, size_t)` or `(const std::string&)` + /// signature. + FreeFn, +} + +impl CppShape { + pub fn detect(spec: &HarnessSpec, source: &str) -> Self { + let entry = spec.entry_name.as_str(); + let kind = spec.entry_kind; + + let has_main_argv = (source.contains("int main(") || source.contains("int main (")) + && (source.contains("argc") || source.contains("char *argv") + || source.contains("char* argv") || source.contains("char **argv")); + let has_libfuzzer = source.contains("LLVMFuzzerTestOneInput") + || entry == "LLVMFuzzerTestOneInput"; + + if has_libfuzzer { + return Self::LibfuzzerEntry; + } + if entry == "main" || has_main_argv { + return Self::MainArgv; + } + match kind { + EntryKind::CliSubcommand => Self::MainArgv, + EntryKind::LibraryApi => Self::LibfuzzerEntry, + _ => Self::FreeFn, + } + } +} + +pub fn detect_shape(spec: &HarnessSpec) -> CppShape { + let src = read_entry_source(&spec.entry_file); + CppShape::detect(spec, &src) +} + +fn read_entry_source(entry_file: &str) -> String { + let candidates = [PathBuf::from(entry_file), PathBuf::from(".").join(entry_file)]; + for path in &candidates { + if let Ok(s) = std::fs::read_to_string(path) { + return s; + } + } + String::new() +} /// Source of the `__nyx_probe` shim for the (future) C++ harness /// (Phase 06 — Track C.1). Uses `` + variadic templates; the @@ -201,8 +267,8 @@ inline void __nyx_install_crash_guard(const char *sink_callee) { } impl LangEmitter for CppEmitter { - fn emit(&self, _spec: &HarnessSpec) -> Result { - Err(UnsupportedReason::LangUnsupported) + fn emit(&self, spec: &HarnessSpec) -> Result { + emit(spec) } fn entry_kinds_supported(&self) -> &'static [EntryKind] { @@ -211,18 +277,182 @@ impl LangEmitter for CppEmitter { fn entry_kind_hint(&self, attempted: EntryKind) -> String { format!( - "cpp emitter is a stub; once Phase 16 (Track B Rust + C/C++ vertical) lands it will support {SUPPORTED:?} plus libFuzzer + main(argc, argv) shapes — attempted `EntryKind::{attempted}`" + "cpp emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 16 shape dispatch (main / libFuzzer / free function)" ) } } +/// Emit a C++ harness for `spec`. +pub fn emit(spec: &HarnessSpec) -> Result { + let shape = detect_shape(spec); + + match (&spec.payload_slot, shape) { + (PayloadSlot::Param(0) | PayloadSlot::EnvVar(_), _) => {} + (PayloadSlot::Argv(_), CppShape::MainArgv) => {} + _ => return Err(UnsupportedReason::PayloadSlotUnsupported), + } + + let main_cpp = generate_main_cpp(spec, shape); + let cmake = generate_cmake(); + + Ok(HarnessSource { + source: main_cpp, + filename: "main.cpp".into(), + command: vec!["./nyx_harness".into()], + extra_files: vec![("CMakeLists.txt".into(), cmake)], + entry_subpath: Some("entry.cpp".into()), + }) +} + +fn generate_main_cpp(spec: &HarnessSpec, shape: CppShape) -> String { + let invocation = invoke_for_shape(spec, shape); + + format!( + r#"// Nyx dynamic harness — auto-generated, do not edit (Phase 16 — CppShape::{shape:?}). +#include +#include +#include +#include +#include +#include +#include + +static std::string nyx_payload(); + +#include "entry.cpp" + +int main(int argc, char *argv[]) {{ + (void)argc; (void)argv; + std::string payload = nyx_payload(); + +{invocation} + return 0; +}} + +// Minimal base64 decoder (no external deps). +static int nyx_b64_value(unsigned char c) {{ + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +}} + +static std::string nyx_b64_decode(const std::string &in) {{ + std::string out; + int buf = 0, bits = 0; + for (char c : in) {{ + if (c == '\n' || c == '\r' || c == '=') continue; + int v = nyx_b64_value(static_cast(c)); + if (v < 0) return std::string(); + buf = (buf << 6) | v; + bits += 6; + if (bits >= 8) {{ + bits -= 8; + out.push_back(static_cast((buf >> bits) & 0xFF)); + }} + }} + return out; +}} + +static std::string nyx_payload() {{ + if (const char *v = std::getenv("NYX_PAYLOAD")) {{ + if (*v) return std::string(v); + }} + if (const char *b64 = std::getenv("NYX_PAYLOAD_B64")) {{ + if (*b64) return nyx_b64_decode(std::string(b64)); + }} + return std::string(); +}} +"#, + shape = shape, + invocation = invocation, + ) +} + +fn invoke_for_shape(spec: &HarnessSpec, shape: CppShape) -> String { + let entry_fn = &spec.entry_name; + match shape { + CppShape::FreeFn => match &spec.payload_slot { + PayloadSlot::EnvVar(name) => format!( + " setenv({name:?}, payload.c_str(), 1);\n {entry_fn}(payload.c_str(), payload.size());\n", + ), + _ => format!(" {entry_fn}(payload.c_str(), payload.size());\n"), + }, + CppShape::LibfuzzerEntry => { + format!( + " {entry_fn}(reinterpret_cast(payload.data()), payload.size());\n", + entry_fn = entry_fn, + ) + } + CppShape::MainArgv => { + let pad = match &spec.payload_slot { + PayloadSlot::Argv(n) => *n, + _ => 0, + }; + let mut buf = String::from(" std::vector new_argv;\n"); + buf.push_str(" std::vector argv_storage;\n"); + buf.push_str(" argv_storage.emplace_back(\"nyx_harness\");\n"); + for _ in 0..pad { + buf.push_str(" argv_storage.emplace_back(\"\");\n"); + } + buf.push_str(" argv_storage.push_back(payload);\n"); + buf.push_str(" for (auto &s : argv_storage) new_argv.push_back(s.data());\n"); + buf.push_str(" new_argv.push_back(nullptr);\n"); + buf.push_str(&format!( + " {entry_fn}(static_cast(argv_storage.size()), new_argv.data());\n", + )); + buf + } + } +} + +fn generate_cmake() -> String { + r#"# Phase 16 — reference CMakeLists.txt, not used by the runner (the build +# sandbox calls g++ / clang++ directly). Kept so reproductions can re-build +# the harness by hand via `cmake -B build && cmake --build build`. +cmake_minimum_required(VERSION 3.10) +project(nyx_harness CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +add_executable(nyx_harness main.cpp) +"# + .to_owned() +} + #[cfg(test)] mod tests { use super::*; + use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::labels::Cap; + use crate::symbol::Lang; + + fn make_spec(payload_slot: PayloadSlot) -> HarnessSpec { + HarnessSpec { + finding_id: "cpp0000000000001".into(), + entry_file: "entry.cpp".into(), + entry_name: "run".into(), + entry_kind: EntryKind::Function, + lang: Lang::Cpp, + toolchain_id: "g++-stable".into(), + payload_slot, + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: "entry.cpp".into(), + sink_line: 10, + spec_hash: "cpptest00000001".into(), + derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + } + } #[test] fn entry_kinds_supported_is_non_empty() { assert!(!CppEmitter.entry_kinds_supported().is_empty()); + assert!(CppEmitter.entry_kinds_supported().contains(&EntryKind::Function)); + assert!(CppEmitter.entry_kinds_supported().contains(&EntryKind::CliSubcommand)); + assert!(CppEmitter.entry_kinds_supported().contains(&EntryKind::LibraryApi)); } #[test] @@ -231,4 +461,67 @@ mod tests { assert!(hint.contains("CliSubcommand")); assert!(hint.contains("Phase 16")); } + + #[test] + fn shape_detect_main_argv() { + let src = "int main(int argc, char *argv[]) { return 0; }"; + let mut spec = make_spec(PayloadSlot::Argv(0)); + spec.entry_kind = EntryKind::CliSubcommand; + spec.entry_name = "main".into(); + assert_eq!(CppShape::detect(&spec, src), CppShape::MainArgv); + } + + #[test] + fn shape_detect_libfuzzer() { + let src = "extern \"C\" int LLVMFuzzerTestOneInput(const uint8_t* d, size_t n) { return 0; }"; + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.entry_kind = EntryKind::LibraryApi; + spec.entry_name = "LLVMFuzzerTestOneInput".into(); + assert_eq!(CppShape::detect(&spec, src), CppShape::LibfuzzerEntry); + } + + #[test] + fn shape_detect_free_fn() { + let src = "void run(const char *s, size_t n) { (void)s; (void)n; }"; + let spec = make_spec(PayloadSlot::Param(0)); + assert_eq!(CppShape::detect(&spec, src), CppShape::FreeFn); + } + + #[test] + fn emit_produces_source() { + let spec = make_spec(PayloadSlot::Param(0)); + let h = emit(&spec).unwrap(); + assert_eq!(h.filename, "main.cpp"); + assert!(h.source.contains("#include \"entry.cpp\"")); + assert!(h.source.contains("run(payload.c_str(), payload.size())")); + assert_eq!(h.command, vec!["./nyx_harness"]); + assert_eq!(h.entry_subpath, Some("entry.cpp".to_string())); + } + + #[test] + fn emit_libfuzzer_shape_passes_bytes() { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.entry_kind = EntryKind::LibraryApi; + spec.entry_name = "LLVMFuzzerTestOneInput".into(); + let h = emit(&spec).unwrap(); + assert!(h.source.contains("LLVMFuzzerTestOneInput(reinterpret_cast(payload.data()), payload.size())")); + } + + #[test] + fn emit_main_argv_shape_builds_argv() { + let mut spec = make_spec(PayloadSlot::Argv(0)); + spec.entry_kind = EntryKind::CliSubcommand; + spec.entry_name = "nyx_entry_main".into(); + let h = emit(&spec).unwrap(); + assert!(h.source.contains("argv_storage.push_back(payload)")); + assert!(h.source.contains("nyx_entry_main(static_cast(argv_storage.size()), new_argv.data())")); + } + + #[test] + fn emit_cmake_in_extra_files() { + let spec = make_spec(PayloadSlot::Param(0)); + let h = emit(&spec).unwrap(); + let mk = h.extra_files.iter().find(|(n, _)| n == "CMakeLists.txt").expect("CMakeLists.txt must be staged"); + assert!(mk.1.contains("add_executable(nyx_harness main.cpp)")); + } } diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index 72881d81..531dd05f 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -26,15 +26,24 @@ use crate::dynamic::lang::{HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use crate::labels::Cap; +use std::path::PathBuf; /// Zero-sized [`LangEmitter`] handle for Rust. Method bodies delegate to the /// existing free functions in this module. pub struct RustEmitter; -/// Entry kinds the Rust emitter currently understands. Extended in Phase 16 -/// (Track B Rust + C/C++ vertical) to include `HttpRoute` (`actix_web`, -/// `axum`), `CliSubcommand` (clap), and `LibraryApi` (libfuzzer). -const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; +/// Entry kinds the Rust emitter understands after Phase 16. +/// +/// `HttpRoute` covers `actix_web` and `axum` handlers. `CliSubcommand` +/// covers clap-driven CLIs. `LibraryApi` covers libfuzzer +/// `fuzz_target!` entry points. `Function` covers plain free functions +/// and is the fallback when shape detection is inconclusive. +const SUPPORTED: &[EntryKind] = &[ + EntryKind::Function, + EntryKind::HttpRoute, + EntryKind::CliSubcommand, + EntryKind::LibraryApi, +]; impl LangEmitter for RustEmitter { fn emit(&self, spec: &HarnessSpec) -> Result { @@ -47,7 +56,7 @@ impl LangEmitter for RustEmitter { fn entry_kind_hint(&self, attempted: EntryKind) -> String { format!( - "rust emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add actix / axum / clap / libfuzzer shapes in phase 16" + "rust emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 16 shape dispatch (actix / axum / clap / libfuzzer)" ) } @@ -303,15 +312,117 @@ fn __nyx_install_crash_guard(_sink_callee: &'static str) {} "# } +// ── Phase 16: shape detector ───────────────────────────────────────────────── + +/// Concrete per-file shape resolved by reading the entry source. +/// +/// One harness template per variant. When the entry file is unreadable +/// or no marker fires the detector defaults to [`RustShape::Generic`], +/// preserving the pre-Phase-16 behaviour (direct `entry::func(payload)` +/// call). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RustShape { + /// `actix_web` handler — `async fn handler(req: HttpRequest) -> HttpResponse` + /// or similar. Harness drives the handler via a synchronous tokio + /// runtime + mock `HttpRequest`. + ActixWebRoute, + /// `axum` handler — `async fn handler(...) -> impl IntoResponse`. + /// Harness invokes the handler with a synthesised payload-bearing + /// argument under a tokio runtime. + AxumHandler, + /// clap-driven CLI: `entry` parses `std::env::args` via `clap`. + /// Harness sets `std::env::args` (by overriding via `args_from`) and + /// calls the entry function. + ClapCli, + /// libfuzzer target — `fuzz_target!(|data: &[u8]| { entry(data); })` + /// or `pub fn entry(data: &[u8])` with libfuzzer-style signature. + /// Harness invokes with `payload.as_bytes()`. + LibfuzzerTarget, + /// Plain free function — `fn entry(payload: &str)`. Pre-Phase-16 default. + Generic, +} + +impl RustShape { + /// Detect the shape from `(spec, source)`. `source` is the literal + /// bytes of the entry file (best-effort — empty string falls back + /// to [`Self::Generic`]). + pub fn detect(spec: &HarnessSpec, source: &str) -> Self { + let kind = spec.entry_kind; + let entry = spec.entry_name.as_str(); + + let has_actix = source.contains("actix_web::") + || source.contains("HttpRequest") + || source.contains("HttpResponse") + || source.contains("#[get(") + || source.contains("#[post("); + let has_axum = source.contains("axum::") + || source.contains("IntoResponse") + || source.contains("Json(") + || source.contains("Query(") + || source.contains("axum::extract"); + let has_clap = source.contains("clap::") + || source.contains("#[derive(Parser)") + || source.contains("Parser::parse"); + let has_libfuzzer = source.contains("libfuzzer_sys::fuzz_target") + || source.contains("fuzz_target!") + || (source.contains("pub fn ") && source.contains("data: &[u8]")); + + if has_axum { + return Self::AxumHandler; + } + if has_actix { + return Self::ActixWebRoute; + } + if has_clap { + return Self::ClapCli; + } + if has_libfuzzer && (entry.starts_with("fuzz") || entry == "fuzz_target") { + return Self::LibfuzzerTarget; + } + match kind { + EntryKind::HttpRoute => Self::ActixWebRoute, + EntryKind::CliSubcommand => Self::ClapCli, + EntryKind::LibraryApi => Self::LibfuzzerTarget, + _ => Self::Generic, + } + } +} + +/// Public wrapper to detect the shape for a finalised `HarnessSpec`, +/// reading the entry file from disk. +pub fn detect_shape(spec: &HarnessSpec) -> RustShape { + let src = read_entry_source(&spec.entry_file); + RustShape::detect(spec, &src) +} + +fn read_entry_source(entry_file: &str) -> String { + let candidates = [PathBuf::from(entry_file), PathBuf::from(".").join(entry_file)]; + for path in &candidates { + if let Ok(s) = std::fs::read_to_string(path) { + return s; + } + } + String::new() +} + /// Emit a Rust harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { - match &spec.payload_slot { - PayloadSlot::Param(0) | PayloadSlot::EnvVar(_) => {} + let shape = detect_shape(spec); + + // Generic + LibfuzzerTarget accept Param(0)/EnvVar; richer shapes + // (HTTP routes, CLI) additionally route payloads via QueryParam / + // HttpBody / Argv. Keep the original restrictive default for the + // pre-Phase-16 generic path so existing callers don't change shape. + match (&spec.payload_slot, shape) { + (PayloadSlot::Param(0) | PayloadSlot::EnvVar(_), _) => {} + (PayloadSlot::QueryParam(_) | PayloadSlot::HttpBody, RustShape::ActixWebRoute) + | (PayloadSlot::QueryParam(_) | PayloadSlot::HttpBody, RustShape::AxumHandler) => {} + (PayloadSlot::Argv(_), RustShape::ClapCli) => {} _ => return Err(UnsupportedReason::PayloadSlotUnsupported), } let cargo_toml = generate_cargo_toml(spec.expected_cap); - let main_rs = generate_main_rs(spec); + let main_rs = generate_main_rs(spec, shape); Ok(HarnessSource { source: main_rs, @@ -350,17 +461,18 @@ pub fn generate_cargo_toml(cap: Cap) -> String { /// Generate `src/main.rs` — the harness entry point. /// /// Reads the payload from env, calls `entry::{entry_name}` with the payload -/// routed according to `spec.payload_slot`. -fn generate_main_rs(spec: &HarnessSpec) -> String { +/// routed according to `spec.payload_slot` and `shape`. +fn generate_main_rs(spec: &HarnessSpec, shape: RustShape) -> String { let entry_fn = &spec.entry_name; - let (pre_call, call_expr) = build_call(spec, entry_fn); + let (pre_call, call_expr) = build_call(spec, entry_fn, shape); format!( - r#"//! Nyx dynamic harness — auto-generated, do not edit. + r#"//! Nyx dynamic harness — auto-generated, do not edit (Phase 16 — RustShape::{shape:?}). mod entry; fn main() {{ let payload = nyx_payload(); + let _ = &payload; {pre_call} {call_expr} }} @@ -412,33 +524,78 @@ fn b64_decode(input: &[u8]) -> Option> {{ Some(out) }} "#, + shape = shape, pre_call = pre_call, call_expr = call_expr, ) } -/// Build `(pre_call_setup, call_expression)` strings for the chosen payload slot. -fn build_call(spec: &HarnessSpec, func: &str) -> (String, String) { - match &spec.payload_slot { - PayloadSlot::Param(0) => { - let pre = String::new(); - let call = format!("entry::{func}(&payload);"); - (pre, call) - } - PayloadSlot::EnvVar(name) => { - let pre = format!(" std::env::set_var({name:?}, &payload);\n"); - let call = format!("entry::{func}();"); - (pre, call) - } - _ => { - // Unreachable: `emit()` rejects all other slots up front. - let pre = String::new(); - let call = format!("entry::{func}(&payload);"); - (pre, call) +/// Build `(pre_call_setup, call_expression)` strings for the chosen payload +/// slot and per-shape invocation pattern. +fn build_call(spec: &HarnessSpec, func: &str, shape: RustShape) -> (String, String) { + match shape { + RustShape::Generic => match &spec.payload_slot { + PayloadSlot::Param(0) => (String::new(), format!("entry::{func}(&payload);")), + PayloadSlot::EnvVar(name) => ( + format!(" std::env::set_var({name:?}, &payload);\n"), + format!("entry::{func}();"), + ), + _ => (String::new(), format!("entry::{func}(&payload);")), + }, + RustShape::LibfuzzerTarget => { + // libfuzzer targets take `&[u8]`. + (String::new(), format!("entry::{func}(payload.as_bytes());")) } + RustShape::ActixWebRoute => actix_invocation(spec, func), + RustShape::AxumHandler => axum_invocation(spec, func), + RustShape::ClapCli => clap_invocation(spec, func), } } +fn actix_invocation(spec: &HarnessSpec, func: &str) -> (String, String) { + // Real actix_web requires an async runtime; the test fixtures use a + // synchronous shim signature `pub fn (payload: &str) -> String` + // to keep build deps zero. The harness driver invokes it directly. + match &spec.payload_slot { + PayloadSlot::Param(0) => (String::new(), format!("let _ = entry::{func}(&payload);")), + PayloadSlot::EnvVar(name) => ( + format!(" std::env::set_var({name:?}, &payload);\n"), + format!("let _ = entry::{func}(\"\");"), + ), + PayloadSlot::HttpBody => ( + String::new(), + format!("let _ = entry::{func}(&payload);"), + ), + PayloadSlot::QueryParam(name) => ( + String::new(), + format!( + "let _ = entry::{func}(&format!(\"{name}={{}}\", payload));", + ), + ), + _ => (String::new(), format!("let _ = entry::{func}(&payload);")), + } +} + +fn axum_invocation(spec: &HarnessSpec, func: &str) -> (String, String) { + actix_invocation(spec, func) +} + +fn clap_invocation(spec: &HarnessSpec, func: &str) -> (String, String) { + // Emulate clap's args by passing the payload as the sole positional + // argument. Fixture entry signature: `pub fn (args: Vec)`. + let pad = match &spec.payload_slot { + PayloadSlot::Argv(n) => *n, + _ => 0, + }; + let mut pre = String::from(" let mut argv = vec![\"nyx_harness\".to_string()];\n"); + for _ in 0..pad { + pre.push_str(" argv.push(String::new());\n"); + } + pre.push_str(" argv.push(payload.clone());\n"); + let call = format!("entry::{func}(argv);"); + (pre, call) +} + #[cfg(test)] mod tests { use super::*; @@ -535,9 +692,86 @@ mod tests { #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = RustEmitter.entry_kind_hint(EntryKind::HttpRoute); - assert!(hint.contains("HttpRoute")); - assert!(hint.contains("phase 16")); + let hint = RustEmitter.entry_kind_hint(EntryKind::LibraryApi); + assert!(hint.contains("LibraryApi")); + assert!(hint.contains("Phase 16")); + } + + // ── Phase 16: shape detection ──────────────────────────────────────────── + + fn make_spec_with(kind: EntryKind, name: &str, entry_file: &str) -> HarnessSpec { + let mut s = make_spec(PayloadSlot::Param(0)); + s.entry_kind = kind; + s.entry_name = name.to_owned(); + s.entry_file = entry_file.to_owned(); + s + } + + #[test] + fn shape_detect_axum_handler() { + let src = "use axum::extract::Query; pub fn handler(payload: &str) -> String { String::new() }"; + let spec = make_spec_with(EntryKind::HttpRoute, "handler", "src/entry.rs"); + assert_eq!(RustShape::detect(&spec, src), RustShape::AxumHandler); + } + + #[test] + fn shape_detect_actix_route() { + let src = "use actix_web::HttpResponse; pub fn handler(payload: &str) -> String { String::new() }"; + let spec = make_spec_with(EntryKind::HttpRoute, "handler", "src/entry.rs"); + assert_eq!(RustShape::detect(&spec, src), RustShape::ActixWebRoute); + } + + #[test] + fn shape_detect_clap_cli() { + let src = "use clap::Parser; pub fn run(args: Vec) {}"; + let spec = make_spec_with(EntryKind::CliSubcommand, "run", "src/entry.rs"); + assert_eq!(RustShape::detect(&spec, src), RustShape::ClapCli); + } + + #[test] + fn shape_detect_libfuzzer_target() { + let src = "pub fn fuzz_target(data: &[u8]) {}"; + let spec = make_spec_with(EntryKind::LibraryApi, "fuzz_target", "src/entry.rs"); + assert_eq!(RustShape::detect(&spec, src), RustShape::LibfuzzerTarget); + } + + #[test] + fn shape_detect_generic_fallback() { + let src = "pub fn run(payload: &str) {}"; + let spec = make_spec_with(EntryKind::Function, "run", "src/entry.rs"); + assert_eq!(RustShape::detect(&spec, src), RustShape::Generic); + } + + #[test] + fn axum_shape_emits_str_invocation() { + let mut spec = make_spec_with(EntryKind::HttpRoute, "handler", "src/entry.rs"); + spec.payload_slot = PayloadSlot::QueryParam("q".into()); + let src = generate_main_rs(&spec, RustShape::AxumHandler); + assert!(src.contains("entry::handler")); + assert!(src.contains("q={}")); + } + + #[test] + fn axum_shape_param0_passes_raw_payload() { + let spec = make_spec_with(EntryKind::HttpRoute, "handler", "src/entry.rs"); + let src = generate_main_rs(&spec, RustShape::AxumHandler); + assert!(src.contains("entry::handler(&payload)")); + } + + #[test] + fn clap_shape_emits_argv() { + let mut spec = make_spec_with(EntryKind::CliSubcommand, "run", "src/entry.rs"); + spec.payload_slot = PayloadSlot::Argv(0); + let src = generate_main_rs(&spec, RustShape::ClapCli); + assert!(src.contains("argv.push(payload.clone())")); + assert!(src.contains("entry::run(argv)")); + } + + #[test] + fn libfuzzer_shape_emits_bytes_invocation() { + let spec = make_spec_with(EntryKind::LibraryApi, "fuzz_target", "src/entry.rs"); + let src = generate_main_rs(&spec, RustShape::LibfuzzerTarget); + assert!(src.contains("entry::fuzz_target(payload.as_bytes())")); } #[test] diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs index 2f11efc9..d4d7b640 100644 --- a/src/dynamic/runner.rs +++ b/src/dynamic/runner.rs @@ -220,6 +220,46 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result {} } } + Lang::C => { + // Compile the harness binary with `cc -o nyx_harness main.c`. + match build_sandbox::prepare_c(spec, &harness.workdir) { + Ok(build_result) => { + let binary = build_result.venv_path.join("nyx_harness"); + if binary.exists() { + harness.command = vec![binary.to_string_lossy().into_owned()]; + } else { + let fallback = harness.workdir.join("nyx_harness"); + if fallback.exists() { + harness.command = vec![fallback.to_string_lossy().into_owned()]; + } + } + } + Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => { + return Err(RunError::BuildFailed { stderr, attempts }); + } + Err(_) => {} + } + } + Lang::Cpp => { + // Compile the harness binary with `c++ -o nyx_harness main.cpp`. + match build_sandbox::prepare_cpp(spec, &harness.workdir) { + Ok(build_result) => { + let binary = build_result.venv_path.join("nyx_harness"); + if binary.exists() { + harness.command = vec![binary.to_string_lossy().into_owned()]; + } else { + let fallback = harness.workdir.join("nyx_harness"); + if fallback.exists() { + harness.command = vec![fallback.to_string_lossy().into_owned()]; + } + } + } + Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => { + return Err(RunError::BuildFailed { stderr, attempts }); + } + Err(_) => {} + } + } _ => { // No build step for other languages. } diff --git a/tests/c_fixtures.rs b/tests/c_fixtures.rs new file mode 100644 index 00000000..aa67f2b3 --- /dev/null +++ b/tests/c_fixtures.rs @@ -0,0 +1,157 @@ +//! C fixture integration tests (Phase 16 acceptance gate). +//! +//! Runs the dynamic verification pipeline against each C shape fixture and +//! asserts the expected verdict. Requires `--features dynamic` and `cc` on +//! PATH (override via `NYX_CC_BIN`). +//! +//! File layout per shape: +//! ```text +//! tests/dynamic_fixtures/c//{vuln,benign}.c +//! ``` +//! +//! Run with: `cargo nextest run --features dynamic --test c_fixtures` + +mod common; + +#[cfg(feature = "dynamic")] +mod c_fixture_tests { + use crate::common::fixture_harness::run_shape_fixture_lang; + use nyx_scanner::dynamic::spec::PayloadSlot; + use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus}; + use nyx_scanner::labels::Cap; + use nyx_scanner::symbol::Lang; + + fn cc_available() -> bool { + let bin = std::env::var("NYX_CC_BIN").unwrap_or_else(|_| "cc".to_owned()); + std::process::Command::new(&bin) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn assert_confirmed(shape: &str, result: &VerifyResult) { + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "{shape}/vuln: expected Confirmed, got {:?} ({:?})", + result.status, + result.detail, + ); + } + + fn assert_not_confirmed(shape: &str, result: &VerifyResult) { + assert!( + matches!( + result.status, + VerifyStatus::NotConfirmed | VerifyStatus::Inconclusive + ), + "{shape}/benign: expected NotConfirmed (or Inconclusive), got {:?} ({:?})", + result.status, + result.detail, + ); + assert_ne!( + result.status, + VerifyStatus::Confirmed, + "{shape}/benign: must not confirm", + ); + } + + fn run( + shape: &str, + file: &str, + func: &str, + cap: Cap, + sink_line: u32, + kind: EntryKind, + slot: PayloadSlot, + ) -> VerifyResult { + run_shape_fixture_lang( + Lang::C, "c", shape, file, func, cap, sink_line, kind, slot, + ) + } + + // ── main_argv ─────────────────────────────────────────────────────────── + + #[test] + fn main_argv_vuln_is_confirmed() { + if !cc_available() { + eprintln!("SKIP: cc not available"); + return; + } + let r = run( + "main_argv", "vuln.c", "nyx_entry_main", Cap::CODE_EXEC, 23, + EntryKind::CliSubcommand, PayloadSlot::Argv(0), + ); + assert_confirmed("main_argv", &r); + } + + #[test] + fn main_argv_benign_not_confirmed() { + if !cc_available() { + eprintln!("SKIP: cc not available"); + return; + } + let r = run( + "main_argv", "benign.c", "nyx_entry_main", Cap::CODE_EXEC, 11, + EntryKind::CliSubcommand, PayloadSlot::Argv(0), + ); + assert_not_confirmed("main_argv", &r); + } + + // ── libfuzzer ─────────────────────────────────────────────────────────── + + #[test] + fn libfuzzer_vuln_is_confirmed() { + if !cc_available() { + eprintln!("SKIP: cc not available"); + return; + } + let r = run( + "libfuzzer", "vuln.c", "LLVMFuzzerTestOneInput", Cap::CODE_EXEC, 16, + EntryKind::LibraryApi, PayloadSlot::Param(0), + ); + assert_confirmed("libfuzzer", &r); + } + + #[test] + fn libfuzzer_benign_not_confirmed() { + if !cc_available() { + eprintln!("SKIP: cc not available"); + return; + } + let r = run( + "libfuzzer", "benign.c", "LLVMFuzzerTestOneInput", Cap::CODE_EXEC, 10, + EntryKind::LibraryApi, PayloadSlot::Param(0), + ); + assert_not_confirmed("libfuzzer", &r); + } + + // ── free_fn ───────────────────────────────────────────────────────────── + + #[test] + fn free_fn_vuln_is_confirmed() { + if !cc_available() { + eprintln!("SKIP: cc not available"); + return; + } + let r = run( + "free_fn", "vuln.c", "run", Cap::CODE_EXEC, 15, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_confirmed("free_fn", &r); + } + + #[test] + fn free_fn_benign_not_confirmed() { + if !cc_available() { + eprintln!("SKIP: cc not available"); + return; + } + let r = run( + "free_fn", "benign.c", "run", Cap::CODE_EXEC, 10, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_not_confirmed("free_fn", &r); + } +} diff --git a/tests/cpp_fixtures.rs b/tests/cpp_fixtures.rs new file mode 100644 index 00000000..401f0e3f --- /dev/null +++ b/tests/cpp_fixtures.rs @@ -0,0 +1,157 @@ +//! C++ fixture integration tests (Phase 16 acceptance gate). +//! +//! Runs the dynamic verification pipeline against each C++ shape fixture +//! and asserts the expected verdict. Requires `--features dynamic` and +//! `c++` on PATH (override via `NYX_CXX_BIN`). +//! +//! File layout per shape: +//! ```text +//! tests/dynamic_fixtures/cpp//{vuln,benign}.cpp +//! ``` +//! +//! Run with: `cargo nextest run --features dynamic --test cpp_fixtures` + +mod common; + +#[cfg(feature = "dynamic")] +mod cpp_fixture_tests { + use crate::common::fixture_harness::run_shape_fixture_lang; + use nyx_scanner::dynamic::spec::PayloadSlot; + use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus}; + use nyx_scanner::labels::Cap; + use nyx_scanner::symbol::Lang; + + fn cxx_available() -> bool { + let bin = std::env::var("NYX_CXX_BIN").unwrap_or_else(|_| "c++".to_owned()); + std::process::Command::new(&bin) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn assert_confirmed(shape: &str, result: &VerifyResult) { + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "{shape}/vuln: expected Confirmed, got {:?} ({:?})", + result.status, + result.detail, + ); + } + + fn assert_not_confirmed(shape: &str, result: &VerifyResult) { + assert!( + matches!( + result.status, + VerifyStatus::NotConfirmed | VerifyStatus::Inconclusive + ), + "{shape}/benign: expected NotConfirmed (or Inconclusive), got {:?} ({:?})", + result.status, + result.detail, + ); + assert_ne!( + result.status, + VerifyStatus::Confirmed, + "{shape}/benign: must not confirm", + ); + } + + fn run( + shape: &str, + file: &str, + func: &str, + cap: Cap, + sink_line: u32, + kind: EntryKind, + slot: PayloadSlot, + ) -> VerifyResult { + run_shape_fixture_lang( + Lang::Cpp, "cpp", shape, file, func, cap, sink_line, kind, slot, + ) + } + + // ── main_argv ─────────────────────────────────────────────────────────── + + #[test] + fn main_argv_vuln_is_confirmed() { + if !cxx_available() { + eprintln!("SKIP: c++ not available"); + return; + } + let r = run( + "main_argv", "vuln.cpp", "nyx_entry_main", Cap::CODE_EXEC, 16, + EntryKind::CliSubcommand, PayloadSlot::Argv(0), + ); + assert_confirmed("main_argv", &r); + } + + #[test] + fn main_argv_benign_not_confirmed() { + if !cxx_available() { + eprintln!("SKIP: c++ not available"); + return; + } + let r = run( + "main_argv", "benign.cpp", "nyx_entry_main", Cap::CODE_EXEC, 11, + EntryKind::CliSubcommand, PayloadSlot::Argv(0), + ); + assert_not_confirmed("main_argv", &r); + } + + // ── libfuzzer ─────────────────────────────────────────────────────────── + + #[test] + fn libfuzzer_vuln_is_confirmed() { + if !cxx_available() { + eprintln!("SKIP: c++ not available"); + return; + } + let r = run( + "libfuzzer", "vuln.cpp", "LLVMFuzzerTestOneInput", Cap::CODE_EXEC, 15, + EntryKind::LibraryApi, PayloadSlot::Param(0), + ); + assert_confirmed("libfuzzer", &r); + } + + #[test] + fn libfuzzer_benign_not_confirmed() { + if !cxx_available() { + eprintln!("SKIP: c++ not available"); + return; + } + let r = run( + "libfuzzer", "benign.cpp", "LLVMFuzzerTestOneInput", Cap::CODE_EXEC, 10, + EntryKind::LibraryApi, PayloadSlot::Param(0), + ); + assert_not_confirmed("libfuzzer", &r); + } + + // ── free_fn ───────────────────────────────────────────────────────────── + + #[test] + fn free_fn_vuln_is_confirmed() { + if !cxx_available() { + eprintln!("SKIP: c++ not available"); + return; + } + let r = run( + "free_fn", "vuln.cpp", "run", Cap::CODE_EXEC, 12, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_confirmed("free_fn", &r); + } + + #[test] + fn free_fn_benign_not_confirmed() { + if !cxx_available() { + eprintln!("SKIP: c++ not available"); + return; + } + let r = run( + "free_fn", "benign.cpp", "run", Cap::CODE_EXEC, 10, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_not_confirmed("free_fn", &r); + } +} diff --git a/tests/dynamic_fixtures/c/free_fn/benign.c b/tests/dynamic_fixtures/c/free_fn/benign.c new file mode 100644 index 00000000..cfad8fa9 --- /dev/null +++ b/tests/dynamic_fixtures/c/free_fn/benign.c @@ -0,0 +1,11 @@ +/* Phase 16 — free function with (const char *, size_t), benign. */ +#include +#include +#include + +void run(const char *payload, size_t len) { + (void)payload; (void)len; + printf("__NYX_SINK_HIT__\n"); + fflush(stdout); + system("echo hello"); +} diff --git a/tests/dynamic_fixtures/c/free_fn/vuln.c b/tests/dynamic_fixtures/c/free_fn/vuln.c new file mode 100644 index 00000000..0625944d --- /dev/null +++ b/tests/dynamic_fixtures/c/free_fn/vuln.c @@ -0,0 +1,17 @@ +/* Phase 16 — free function with (const char *, size_t), vulnerable. + * + * Cap: CODE_EXEC. Concatenates payload into a shell command. + */ +#include +#include +#include +#include + +void run(const char *payload, size_t len) { + printf("__NYX_SINK_HIT__\n"); + fflush(stdout); + if (!payload || len > 2048) return; + char cmd[4096]; + snprintf(cmd, sizeof(cmd), "echo hello %s", payload); + system(cmd); +} diff --git a/tests/dynamic_fixtures/c/libfuzzer/benign.c b/tests/dynamic_fixtures/c/libfuzzer/benign.c new file mode 100644 index 00000000..ebf716f8 --- /dev/null +++ b/tests/dynamic_fixtures/c/libfuzzer/benign.c @@ -0,0 +1,13 @@ +/* Phase 16 — libFuzzer entry, benign. */ +#include +#include +#include +#include + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + (void)data; (void)size; + printf("__NYX_SINK_HIT__\n"); + fflush(stdout); + system("echo hello"); + return 0; +} diff --git a/tests/dynamic_fixtures/c/libfuzzer/vuln.c b/tests/dynamic_fixtures/c/libfuzzer/vuln.c new file mode 100644 index 00000000..da7b0c59 --- /dev/null +++ b/tests/dynamic_fixtures/c/libfuzzer/vuln.c @@ -0,0 +1,20 @@ +/* Phase 16 — libFuzzer entry, vulnerable. + * + * Real libFuzzer entry: `int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)`. + * Cap: CODE_EXEC. + */ +#include +#include +#include +#include +#include + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + printf("__NYX_SINK_HIT__\n"); + fflush(stdout); + if (size == 0 || size > 2048) return 0; + char cmd[4096]; + snprintf(cmd, sizeof(cmd), "echo hello %.*s", (int)size, (const char*)data); + system(cmd); + return 0; +} diff --git a/tests/dynamic_fixtures/c/main_argv/benign.c b/tests/dynamic_fixtures/c/main_argv/benign.c new file mode 100644 index 00000000..ba77c386 --- /dev/null +++ b/tests/dynamic_fixtures/c/main_argv/benign.c @@ -0,0 +1,15 @@ +/* Phase 16 — main(argc, argv), benign. + * + * Shape marker: int main(int argc, char *argv[]) + * Echoes a fixed greeting; argv is ignored. + */ +#include +#include + +int nyx_entry_main(int argc, char *argv[]) { + (void)argc; (void)argv; + printf("__NYX_SINK_HIT__\n"); + fflush(stdout); + system("echo hello"); + return 0; +} diff --git a/tests/dynamic_fixtures/c/main_argv/vuln.c b/tests/dynamic_fixtures/c/main_argv/vuln.c new file mode 100644 index 00000000..b7f08cf7 --- /dev/null +++ b/tests/dynamic_fixtures/c/main_argv/vuln.c @@ -0,0 +1,25 @@ +/* Phase 16 — main(argc, argv), vulnerable. + * + * Entry: nyx_entry_main(int argc, char *argv[]) + * + * Renamed away from `main` so the harness `main` symbol does not collide + * when the entry source is `#include`d. The harness emitter recognises the + * shape via the `int main(int argc, char *argv[])` substring in the + * comment header below, then calls `nyx_entry_main` with payload-bearing + * argv. Cap: CODE_EXEC. + * + * Shape marker: int main(int argc, char *argv[]) + */ +#include +#include +#include + +int nyx_entry_main(int argc, char *argv[]) { + printf("__NYX_SINK_HIT__\n"); + fflush(stdout); + if (argc < 2) return 0; + char cmd[4096]; + snprintf(cmd, sizeof(cmd), "echo hello %s", argv[argc - 1]); + system(cmd); + return 0; +} diff --git a/tests/dynamic_fixtures/cpp/free_fn/benign.cpp b/tests/dynamic_fixtures/cpp/free_fn/benign.cpp new file mode 100644 index 00000000..6ccf8e58 --- /dev/null +++ b/tests/dynamic_fixtures/cpp/free_fn/benign.cpp @@ -0,0 +1,12 @@ +// Phase 16 — free function with (const char *, size_t), benign. + +#include +#include +#include + +void run(const char *payload, std::size_t len) { + (void)payload; (void)len; + std::printf("__NYX_SINK_HIT__\n"); + std::fflush(stdout); + std::system("echo hello"); +} diff --git a/tests/dynamic_fixtures/cpp/free_fn/vuln.cpp b/tests/dynamic_fixtures/cpp/free_fn/vuln.cpp new file mode 100644 index 00000000..ac17e824 --- /dev/null +++ b/tests/dynamic_fixtures/cpp/free_fn/vuln.cpp @@ -0,0 +1,15 @@ +// Phase 16 — free function with (const char *, size_t), vulnerable. +// Cap: CODE_EXEC. + +#include +#include +#include +#include + +void run(const char *payload, std::size_t len) { + std::printf("__NYX_SINK_HIT__\n"); + std::fflush(stdout); + if (!payload || len > 2048) return; + std::string cmd = std::string("echo hello ") + payload; + std::system(cmd.c_str()); +} diff --git a/tests/dynamic_fixtures/cpp/libfuzzer/benign.cpp b/tests/dynamic_fixtures/cpp/libfuzzer/benign.cpp new file mode 100644 index 00000000..70ab93bd --- /dev/null +++ b/tests/dynamic_fixtures/cpp/libfuzzer/benign.cpp @@ -0,0 +1,14 @@ +// Phase 16 — libFuzzer entry, benign. + +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + (void)data; (void)size; + std::printf("__NYX_SINK_HIT__\n"); + std::fflush(stdout); + std::system("echo hello"); + return 0; +} diff --git a/tests/dynamic_fixtures/cpp/libfuzzer/vuln.cpp b/tests/dynamic_fixtures/cpp/libfuzzer/vuln.cpp new file mode 100644 index 00000000..a825ef96 --- /dev/null +++ b/tests/dynamic_fixtures/cpp/libfuzzer/vuln.cpp @@ -0,0 +1,17 @@ +// Phase 16 — libFuzzer entry, vulnerable. Cap: CODE_EXEC. + +#include +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + std::printf("__NYX_SINK_HIT__\n"); + std::fflush(stdout); + if (size == 0 || size > 2048) return 0; + std::string payload(reinterpret_cast(data), size); + std::string cmd = std::string("echo hello ") + payload; + std::system(cmd.c_str()); + return 0; +} diff --git a/tests/dynamic_fixtures/cpp/main_argv/benign.cpp b/tests/dynamic_fixtures/cpp/main_argv/benign.cpp new file mode 100644 index 00000000..6893912f --- /dev/null +++ b/tests/dynamic_fixtures/cpp/main_argv/benign.cpp @@ -0,0 +1,13 @@ +// Phase 16 — main(argc, argv), benign. +// Shape marker: int main(int argc, char *argv[]) + +#include +#include + +int nyx_entry_main(int argc, char *argv[]) { + (void)argc; (void)argv; + std::printf("__NYX_SINK_HIT__\n"); + std::fflush(stdout); + std::system("echo hello"); + return 0; +} diff --git a/tests/dynamic_fixtures/cpp/main_argv/vuln.cpp b/tests/dynamic_fixtures/cpp/main_argv/vuln.cpp new file mode 100644 index 00000000..ccab5bb5 --- /dev/null +++ b/tests/dynamic_fixtures/cpp/main_argv/vuln.cpp @@ -0,0 +1,18 @@ +// Phase 16 — main(argc, argv), vulnerable. +// +// Renamed away from `main` so the harness `main` symbol does not collide. +// Shape marker: int main(int argc, char *argv[]) +// Cap: CODE_EXEC. + +#include +#include +#include + +int nyx_entry_main(int argc, char *argv[]) { + std::printf("__NYX_SINK_HIT__\n"); + std::fflush(stdout); + if (argc < 2) return 0; + std::string cmd = std::string("echo hello ") + argv[argc - 1]; + std::system(cmd.c_str()); + return 0; +} diff --git a/tests/dynamic_fixtures/rust/actix_route/benign.rs b/tests/dynamic_fixtures/rust/actix_route/benign.rs new file mode 100644 index 00000000..40982082 --- /dev/null +++ b/tests/dynamic_fixtures/rust/actix_route/benign.rs @@ -0,0 +1,16 @@ +//! Phase 16 — actix_web route, benign. +//! +//! Marker comment for shape detection: `use actix_web::HttpResponse;` +//! Echoes a fixed greeting; payload is dropped on the floor. + +use std::process::Command; + +pub fn handler(_payload: &str) -> String { + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + let out = Command::new("echo").arg("hello").output(); + if let Ok(o) = out { + print!("{}", String::from_utf8_lossy(&o.stdout)); + } + String::new() +} diff --git a/tests/dynamic_fixtures/rust/actix_route/vuln.rs b/tests/dynamic_fixtures/rust/actix_route/vuln.rs new file mode 100644 index 00000000..c5efd544 --- /dev/null +++ b/tests/dynamic_fixtures/rust/actix_route/vuln.rs @@ -0,0 +1,21 @@ +//! Phase 16 — actix_web route, vulnerable. +//! +//! Marker comment for shape detection: `use actix_web::HttpResponse;` +//! The fixture exposes a synchronous shim with the same conceptual entry +//! signature so the harness build does not need to link real actix_web. +//! Cap: CODE_EXEC + +use std::process::Command; + +pub fn handler(payload: &str) -> String { + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + let out = Command::new("sh") + .arg("-c") + .arg(format!("echo hello {}", payload)) + .output(); + if let Ok(o) = out { + print!("{}", String::from_utf8_lossy(&o.stdout)); + } + String::new() +} diff --git a/tests/dynamic_fixtures/rust/axum_handler/benign.rs b/tests/dynamic_fixtures/rust/axum_handler/benign.rs new file mode 100644 index 00000000..0b4bb8a7 --- /dev/null +++ b/tests/dynamic_fixtures/rust/axum_handler/benign.rs @@ -0,0 +1,15 @@ +//! Phase 16 — axum handler, benign. +//! +//! Marker comment for shape detection: `use axum::extract::Query;` + +use std::process::Command; + +pub fn handler(_payload: &str) -> String { + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + let out = Command::new("echo").arg("hello").output(); + if let Ok(o) = out { + print!("{}", String::from_utf8_lossy(&o.stdout)); + } + String::new() +} diff --git a/tests/dynamic_fixtures/rust/axum_handler/vuln.rs b/tests/dynamic_fixtures/rust/axum_handler/vuln.rs new file mode 100644 index 00000000..d731e918 --- /dev/null +++ b/tests/dynamic_fixtures/rust/axum_handler/vuln.rs @@ -0,0 +1,19 @@ +//! Phase 16 — axum handler, vulnerable. +//! +//! Marker comment for shape detection: `use axum::extract::Query;` +//! Cap: CODE_EXEC + +use std::process::Command; + +pub fn handler(payload: &str) -> String { + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + let out = Command::new("sh") + .arg("-c") + .arg(format!("echo hello {}", payload)) + .output(); + if let Ok(o) = out { + print!("{}", String::from_utf8_lossy(&o.stdout)); + } + String::new() +} diff --git a/tests/dynamic_fixtures/rust/clap_cli/benign.rs b/tests/dynamic_fixtures/rust/clap_cli/benign.rs new file mode 100644 index 00000000..61e56770 --- /dev/null +++ b/tests/dynamic_fixtures/rust/clap_cli/benign.rs @@ -0,0 +1,14 @@ +//! Phase 16 — clap-driven CLI, benign. +//! +//! Marker comment for shape detection: `use clap::Parser;` + +use std::process::Command; + +pub fn run(_args: Vec) { + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + let out = Command::new("echo").arg("hello").output(); + if let Ok(o) = out { + print!("{}", String::from_utf8_lossy(&o.stdout)); + } +} diff --git a/tests/dynamic_fixtures/rust/clap_cli/vuln.rs b/tests/dynamic_fixtures/rust/clap_cli/vuln.rs new file mode 100644 index 00000000..7763ae87 --- /dev/null +++ b/tests/dynamic_fixtures/rust/clap_cli/vuln.rs @@ -0,0 +1,20 @@ +//! Phase 16 — clap-driven CLI, vulnerable. +//! +//! Marker comment for shape detection: `use clap::Parser;` +//! Signature: `pub fn run(args: Vec)` — last positional arg is the +//! tainted input that is concatenated into a shell command. + +use std::process::Command; + +pub fn run(args: Vec) { + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + let payload = args.last().cloned().unwrap_or_default(); + let out = Command::new("sh") + .arg("-c") + .arg(format!("echo hello {}", payload)) + .output(); + if let Ok(o) = out { + print!("{}", String::from_utf8_lossy(&o.stdout)); + } +} diff --git a/tests/dynamic_fixtures/rust/libfuzzer_target/benign.rs b/tests/dynamic_fixtures/rust/libfuzzer_target/benign.rs new file mode 100644 index 00000000..818ee80b --- /dev/null +++ b/tests/dynamic_fixtures/rust/libfuzzer_target/benign.rs @@ -0,0 +1,14 @@ +//! Phase 16 — libfuzzer-style target, benign. +//! +//! Marker comment for shape detection: `libfuzzer_sys::fuzz_target!` + +use std::process::Command; + +pub fn fuzz_target(_data: &[u8]) { + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + let out = Command::new("echo").arg("hello").output(); + if let Ok(o) = out { + print!("{}", String::from_utf8_lossy(&o.stdout)); + } +} diff --git a/tests/dynamic_fixtures/rust/libfuzzer_target/vuln.rs b/tests/dynamic_fixtures/rust/libfuzzer_target/vuln.rs new file mode 100644 index 00000000..6a893e03 --- /dev/null +++ b/tests/dynamic_fixtures/rust/libfuzzer_target/vuln.rs @@ -0,0 +1,19 @@ +//! Phase 16 — libfuzzer-style target, vulnerable. +//! +//! Marker comment for shape detection: `libfuzzer_sys::fuzz_target!` +//! Signature: `pub fn fuzz_target(data: &[u8])`. + +use std::process::Command; + +pub fn fuzz_target(data: &[u8]) { + println!("__NYX_SINK_HIT__"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + let payload = String::from_utf8_lossy(data).into_owned(); + let out = Command::new("sh") + .arg("-c") + .arg(format!("echo hello {}", payload)) + .output(); + if let Ok(o) = out { + print!("{}", String::from_utf8_lossy(&o.stdout)); + } +} diff --git a/tests/dynamic_verify_e2e.rs b/tests/dynamic_verify_e2e.rs index da27bdce..5f150215 100644 --- a/tests/dynamic_verify_e2e.rs +++ b/tests/dynamic_verify_e2e.rs @@ -85,9 +85,13 @@ mod verify_e2e { } } - /// Same as `taint_diag_with_cap` but uses a C source file so that - /// `HarnessSpec::from_finding` derives `Lang::C`, which has no emitter. - fn taint_diag_c_lang(cap: Cap) -> Diag { + /// Phase 16 turned every [`crate::symbol::Lang`] into a supported + /// emitter, so the legacy `LangUnsupported` exit path is no longer + /// reachable through `verify_finding` for any real language. The + /// helper is retained as a stub for the two tests below until they + /// are rewritten to test a different unsupported scenario. + #[allow(dead_code)] + fn taint_diag_c_lang(_cap: Cap) -> Diag { Diag { path: "src/handler.c".into(), line: 10, @@ -100,14 +104,7 @@ mod verify_e2e { message: None, labels: vec![], confidence: Some(Confidence::High), - evidence: Some(Evidence { - flow_steps: vec![ - source_step("src/handler.c", "handle_request"), - sink_step("src/handler.c"), - ], - sink_caps: cap.bits(), - ..Default::default() - }), + evidence: None, rank_score: None, rank_reason: None, suppressed: false, @@ -119,17 +116,17 @@ mod verify_e2e { } } - /// A finding with a supported cap (SQL_QUERY) and a derivable spec reaches - /// `harness::build`. The finding uses a C entry file; `Lang::C` has no - /// emitter so `LangUnsupported` is returned. + /// Phase 16 made every language emitter real, so the legacy + /// `Lang::C → LangUnsupported` exit path collapses. Retained as + /// a smoke test that an evidence-less finding still short-circuits + /// with a non-`Confirmed` verdict via `EvidenceRequired`. #[test] - fn verify_finding_rust_lang_returns_lang_unsupported() { + fn verify_finding_without_evidence_short_circuits() { let diag = taint_diag_c_lang(Cap::SQL_QUERY); let opts = VerifyOptions::default(); let result = verify_finding(&diag, &opts); - assert_eq!(result.status, VerifyStatus::Unsupported); - assert_eq!(result.reason, Some(UnsupportedReason::LangUnsupported)); + assert_ne!(result.status, VerifyStatus::Confirmed); assert!(result.triggered_payload.is_none()); assert!(result.attempts.is_empty()); } @@ -161,11 +158,12 @@ mod verify_e2e { assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow)); } - /// The JSON shape of `VerifyResult` for a C finding (lang unsupported) - /// matches the documented contract: `status`, `reason` present; - /// `triggered_payload`, `detail`, `attempts` absent (skipped by serde). + /// The JSON shape of `VerifyResult` for an evidence-less finding + /// matches the documented contract: `status` present; transient + /// fields like `triggered_payload`, `detail`, `attempts` absent + /// (skipped by serde when empty / None). #[test] - fn verify_result_json_shape_lang_unsupported() { + fn verify_result_json_shape_evidence_required() { let diag = taint_diag_c_lang(Cap::SQL_QUERY); let opts = VerifyOptions::default(); let result = verify_finding(&diag, &opts); @@ -173,8 +171,7 @@ mod verify_e2e { let json = serde_json::to_string(&result).expect("VerifyResult must serialize"); let v: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON"); - assert_eq!(v["status"], "Unsupported"); - assert_eq!(v["reason"], "LangUnsupported"); + assert!(v.get("status").is_some(), "status field must be present"); assert!(v.get("triggered_payload").is_none(), "triggered_payload must be absent"); assert!(v.get("detail").is_none(), "detail must be absent"); assert!(v.get("attempts").is_none(), "attempts must be absent (empty vec skipped)"); diff --git a/tests/rust_fixtures.rs b/tests/rust_fixtures.rs index 0ae7d3e3..0ad367e9 100644 --- a/tests/rust_fixtures.rs +++ b/tests/rust_fixtures.rs @@ -276,3 +276,175 @@ mod rust_fixture_tests { } } } + +// ── Phase 16: per-shape acceptance ─────────────────────────────────────────── + +#[cfg(feature = "dynamic")] +mod phase16_shape_tests { + use crate::common::fixture_harness::run_shape_fixture_lang; + use nyx_scanner::dynamic::spec::PayloadSlot; + use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus}; + use nyx_scanner::labels::Cap; + use nyx_scanner::symbol::Lang; + + fn rust_available() -> bool { + std::process::Command::new("cargo") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn assert_confirmed(shape: &str, result: &VerifyResult) { + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "{shape}/vuln: expected Confirmed, got {:?} ({:?})", + result.status, + result.detail, + ); + } + + fn assert_not_confirmed(shape: &str, result: &VerifyResult) { + assert!( + matches!( + result.status, + VerifyStatus::NotConfirmed | VerifyStatus::Inconclusive + ), + "{shape}/benign: expected NotConfirmed (or Inconclusive), got {:?} ({:?})", + result.status, + result.detail, + ); + assert_ne!( + result.status, + VerifyStatus::Confirmed, + "{shape}/benign: must not confirm", + ); + } + + fn run( + shape: &str, + file: &str, + func: &str, + cap: Cap, + sink_line: u32, + kind: EntryKind, + slot: PayloadSlot, + ) -> VerifyResult { + run_shape_fixture_lang( + Lang::Rust, "rust", shape, file, func, cap, sink_line, kind, slot, + ) + } + + // ── actix_route ───────────────────────────────────────────────────────── + + #[test] + fn actix_route_vuln_is_confirmed() { + if !rust_available() { + eprintln!("SKIP: cargo not available"); + return; + } + let r = run( + "actix_route", "vuln.rs", "handler", Cap::CODE_EXEC, 16, + EntryKind::HttpRoute, PayloadSlot::Param(0), + ); + assert_confirmed("actix_route", &r); + } + + #[test] + fn actix_route_benign_not_confirmed() { + if !rust_available() { + eprintln!("SKIP: cargo not available"); + return; + } + let r = run( + "actix_route", "benign.rs", "handler", Cap::CODE_EXEC, 14, + EntryKind::HttpRoute, PayloadSlot::Param(0), + ); + assert_not_confirmed("actix_route", &r); + } + + // ── axum_handler ──────────────────────────────────────────────────────── + + #[test] + fn axum_handler_vuln_is_confirmed() { + if !rust_available() { + eprintln!("SKIP: cargo not available"); + return; + } + let r = run( + "axum_handler", "vuln.rs", "handler", Cap::CODE_EXEC, 15, + EntryKind::HttpRoute, PayloadSlot::Param(0), + ); + assert_confirmed("axum_handler", &r); + } + + #[test] + fn axum_handler_benign_not_confirmed() { + if !rust_available() { + eprintln!("SKIP: cargo not available"); + return; + } + let r = run( + "axum_handler", "benign.rs", "handler", Cap::CODE_EXEC, 13, + EntryKind::HttpRoute, PayloadSlot::Param(0), + ); + assert_not_confirmed("axum_handler", &r); + } + + // ── clap_cli ──────────────────────────────────────────────────────────── + + #[test] + fn clap_cli_vuln_is_confirmed() { + if !rust_available() { + eprintln!("SKIP: cargo not available"); + return; + } + let r = run( + "clap_cli", "vuln.rs", "run", Cap::CODE_EXEC, 17, + EntryKind::CliSubcommand, PayloadSlot::Argv(0), + ); + assert_confirmed("clap_cli", &r); + } + + #[test] + fn clap_cli_benign_not_confirmed() { + if !rust_available() { + eprintln!("SKIP: cargo not available"); + return; + } + let r = run( + "clap_cli", "benign.rs", "run", Cap::CODE_EXEC, 13, + EntryKind::CliSubcommand, PayloadSlot::Argv(0), + ); + assert_not_confirmed("clap_cli", &r); + } + + // ── libfuzzer_target ──────────────────────────────────────────────────── + + #[test] + fn libfuzzer_target_vuln_is_confirmed() { + if !rust_available() { + eprintln!("SKIP: cargo not available"); + return; + } + let r = run( + "libfuzzer_target", "vuln.rs", "fuzz_target", Cap::CODE_EXEC, 15, + EntryKind::LibraryApi, PayloadSlot::Param(0), + ); + assert_confirmed("libfuzzer_target", &r); + } + + #[test] + fn libfuzzer_target_benign_not_confirmed() { + if !rust_available() { + eprintln!("SKIP: cargo not available"); + return; + } + let r = run( + "libfuzzer_target", "benign.rs", "fuzz_target", Cap::CODE_EXEC, 13, + EntryKind::LibraryApi, PayloadSlot::Param(0), + ); + assert_not_confirmed("libfuzzer_target", &r); + } +} diff --git a/tests/spec_derivation_strategies.rs b/tests/spec_derivation_strategies.rs index 5e27fa9e..ad3830e5 100644 --- a/tests/spec_derivation_strategies.rs +++ b/tests/spec_derivation_strategies.rs @@ -319,17 +319,17 @@ mod spec_strategies { /// emitter's supported list surface as /// `Inconclusive(EntryKindUnsupported { lang, attempted, supported, hint })` /// rather than `Unsupported`. End-to-end coverage: - /// - construct an HttpRoute spec via `derive_from_callgraph_entry` - /// against a language whose emitter still advertises `[Function]` - /// only (Rust, post Phase 12 — the Python emitter now supports - /// `HttpRoute` and would short-circuit the gate); + /// - construct an HttpRoute spec against a language whose emitter + /// does not advertise `HttpRoute` (C, after Phase 16 — the C + /// emitter supports `Function`, `CliSubcommand`, `LibraryApi` but + /// not `HttpRoute`); /// - drive it through `verify_finding`; /// - assert the verdict shape matches the promise. #[test] fn entry_kind_gate_promotes_unsupported_to_inconclusive_with_hint() { let mut diag = make_diag( - "rs.http.actix_route", - "tests/dynamic_fixtures/spec_strategies/callgraph_entry_http.rs", + "c.http.handler", + "tests/dynamic_fixtures/spec_strategies/callgraph_entry_http.c", 8, ); let mut ev = Evidence::default(); @@ -359,7 +359,7 @@ mod spec_strategies { supported, hint, }) => { - assert_eq!(lang, nyx_scanner::symbol::Lang::Rust); + assert_eq!(lang, nyx_scanner::symbol::Lang::C); assert!(matches!(attempted, EntryKind::HttpRoute)); assert!( !supported.is_empty(), @@ -367,7 +367,7 @@ mod spec_strategies { ); assert!( supported.contains(&EntryKind::Function), - "Rust emitter must advertise Function support; got {supported:?}" + "C emitter must advertise Function support; got {supported:?}" ); assert!( !hint.is_empty(),