mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-06 20:42:10 +02:00
[pitboss] phase 04: M4 — Rust harness (second-language validation)
This commit is contained in:
parent
e875aa1208
commit
3ffe480660
37 changed files with 1872 additions and 54 deletions
|
|
@ -24,6 +24,24 @@ use nyx_scanner::labels::Cap;
|
||||||
#[cfg(feature = "dynamic")]
|
#[cfg(feature = "dynamic")]
|
||||||
use nyx_scanner::symbol::Lang;
|
use nyx_scanner::symbol::Lang;
|
||||||
|
|
||||||
|
#[cfg(feature = "dynamic")]
|
||||||
|
fn make_rust_sqli_spec() -> HarnessSpec {
|
||||||
|
HarnessSpec {
|
||||||
|
finding_id: "bench_rust_0001".into(),
|
||||||
|
entry_file: "tests/dynamic_fixtures/rust/sqli_positive.rs".into(),
|
||||||
|
entry_name: "run".into(),
|
||||||
|
entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function,
|
||||||
|
lang: Lang::Rust,
|
||||||
|
toolchain_id: "rust-stable".into(),
|
||||||
|
payload_slot: PayloadSlot::Param(0),
|
||||||
|
expected_cap: Cap::SQL_QUERY,
|
||||||
|
constraint_hints: vec![],
|
||||||
|
sink_file: "tests/dynamic_fixtures/rust/sqli_positive.rs".into(),
|
||||||
|
sink_line: 18,
|
||||||
|
spec_hash: "benchrustsqli0001".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "dynamic")]
|
#[cfg(feature = "dynamic")]
|
||||||
fn make_sqli_spec() -> HarnessSpec {
|
fn make_sqli_spec() -> HarnessSpec {
|
||||||
HarnessSpec {
|
HarnessSpec {
|
||||||
|
|
@ -194,6 +212,26 @@ fn bench_docker_payload_cost(c: &mut Criterion) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rust harness build (source gen + disk write, no compilation).
|
||||||
|
///
|
||||||
|
/// Measures only `harness::build()` — staging files to the workdir.
|
||||||
|
/// The expensive `cargo build --release` step is NOT included here
|
||||||
|
/// (that is the province of an integration benchmark, not this microbench).
|
||||||
|
#[cfg(feature = "dynamic")]
|
||||||
|
fn bench_rust_harness_build_cold(c: &mut Criterion) {
|
||||||
|
use nyx_scanner::dynamic::harness;
|
||||||
|
let spec = make_rust_sqli_spec();
|
||||||
|
c.bench_function("rust_harness_build_cold", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let workdir = std::env::temp_dir()
|
||||||
|
.join("nyx-harness")
|
||||||
|
.join(&spec.spec_hash);
|
||||||
|
let _ = std::fs::remove_dir_all(&workdir);
|
||||||
|
harness::build(&spec).expect("harness build")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "dynamic")]
|
#[cfg(feature = "dynamic")]
|
||||||
fn bench_noop(_c: &mut Criterion) {}
|
fn bench_noop(_c: &mut Criterion) {}
|
||||||
|
|
||||||
|
|
@ -212,6 +250,7 @@ criterion_group!(
|
||||||
bench_docker_image_build,
|
bench_docker_image_build,
|
||||||
bench_docker_exec_warm,
|
bench_docker_exec_warm,
|
||||||
bench_docker_payload_cost,
|
bench_docker_payload_cost,
|
||||||
|
bench_rust_harness_build_cold,
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(not(feature = "dynamic"))]
|
#[cfg(not(feature = "dynamic"))]
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,125 @@ use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
// ── Rust build sandbox ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Prepare a compiled Rust binary for `spec`.
|
||||||
|
///
|
||||||
|
/// Checks a build cache keyed on `(Cargo.lock hash, "rust", toolchain_id)`.
|
||||||
|
/// On a cache hit returns immediately; otherwise runs `cargo build --release`
|
||||||
|
/// in `workdir` and caches the resulting binary.
|
||||||
|
///
|
||||||
|
/// The compiled binary is at `cache_path/nyx_harness` on success.
|
||||||
|
///
|
||||||
|
/// Build isolation is NOT yet implemented (deferred to Phase 05). `cargo build`
|
||||||
|
/// runs as a plain subprocess on the host with `env_clear()` plus a minimal
|
||||||
|
/// inherited env (PATH/HOME/CARGO_HOME/RUSTUP_HOME). A malicious `build.rs`
|
||||||
|
/// runs with host privileges. Vendoring / network sandboxing comes later (§19.2).
|
||||||
|
pub fn prepare_rust(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
|
||||||
|
let lockfile_hash = compute_rust_lockfile_hash(workdir);
|
||||||
|
let cache_path = build_cache_path(&lockfile_hash, "rust", &spec.toolchain_id)?;
|
||||||
|
|
||||||
|
// Cache hit: binary already compiled and stored.
|
||||||
|
let binary = cache_path.join("nyx_harness");
|
||||||
|
if binary.exists() {
|
||||||
|
return Ok(BuildResult { venv_path: cache_path, cache_hit: true, duration: Duration::ZERO });
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = 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(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_rust_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_rust_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String> {
|
||||||
|
let cargo = cargo_binary();
|
||||||
|
|
||||||
|
// Run `cargo build --release` in the workdir.
|
||||||
|
let output = Command::new(&cargo)
|
||||||
|
.args(["build", "--release"])
|
||||||
|
.current_dir(workdir)
|
||||||
|
.env_clear()
|
||||||
|
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||||
|
.env("HOME", std::env::var("HOME").unwrap_or_default())
|
||||||
|
// Inherit CARGO_HOME so the local registry cache is reused.
|
||||||
|
.env("CARGO_HOME", std::env::var("CARGO_HOME").unwrap_or_else(|_| {
|
||||||
|
dirs_next_cargo_home()
|
||||||
|
}))
|
||||||
|
.env("RUSTUP_HOME", std::env::var("RUSTUP_HOME").unwrap_or_default())
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("cargo build: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||||
|
return Err(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy binary to cache location.
|
||||||
|
let compiled = workdir.join("target").join("release").join("nyx_harness");
|
||||||
|
if compiled.exists() {
|
||||||
|
std::fs::copy(&compiled, binary_dest)
|
||||||
|
.map_err(|e| format!("copy binary: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cargo_binary() -> String {
|
||||||
|
// Respect NYX_CARGO_BIN for testing.
|
||||||
|
std::env::var("NYX_CARGO_BIN").unwrap_or_else(|_| "cargo".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dirs_next_cargo_home() -> String {
|
||||||
|
// ~/.cargo is the default CARGO_HOME.
|
||||||
|
std::env::var("HOME")
|
||||||
|
.map(|h| format!("{h}/.cargo"))
|
||||||
|
.unwrap_or_else(|_| ".cargo".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_rust_lockfile_hash(workdir: &Path) -> String {
|
||||||
|
let mut h = Hasher::new();
|
||||||
|
// Cargo manifest and lock determine dependency graph.
|
||||||
|
for fname in &["Cargo.lock", "Cargo.toml"] {
|
||||||
|
if let Ok(content) = std::fs::read(workdir.join(fname)) {
|
||||||
|
h.update(fname.as_bytes());
|
||||||
|
h.update(&content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Entry file is compiled into the binary, so it must be part of the cache key.
|
||||||
|
// Without this, two fixtures with the same Cargo.toml but different entry.rs
|
||||||
|
// would collide and the second would receive the wrong cached binary.
|
||||||
|
if let Ok(content) = std::fs::read(workdir.join("src").join("entry.rs")) {
|
||||||
|
h.update(b"src/entry.rs");
|
||||||
|
h.update(&content);
|
||||||
|
}
|
||||||
|
let out = h.finalize();
|
||||||
|
format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a successful build.
|
/// Result of a successful build.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BuildResult {
|
pub struct BuildResult {
|
||||||
|
|
|
||||||
|
|
@ -74,31 +74,57 @@ fn stage_harness(
|
||||||
let workdir = base_dir.join(&spec.spec_hash);
|
let workdir = base_dir.join(&spec.spec_hash);
|
||||||
fs::create_dir_all(&workdir)?;
|
fs::create_dir_all(&workdir)?;
|
||||||
|
|
||||||
// Write harness source.
|
// Write harness source (create parent dir if needed, e.g. "src/main.rs").
|
||||||
let harness_path = workdir.join(&harness_src.filename);
|
let harness_path = workdir.join(&harness_src.filename);
|
||||||
|
if let Some(parent) = harness_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
fs::write(&harness_path, harness_src.source.as_bytes())?;
|
fs::write(&harness_path, harness_src.source.as_bytes())?;
|
||||||
|
|
||||||
// Copy the entry file into the workdir so the harness can import it.
|
// Write any extra files (e.g. Cargo.toml for Rust).
|
||||||
copy_entry_file(spec, &workdir);
|
for (rel_path, content) in &harness_src.extra_files {
|
||||||
|
let dest = workdir.join(rel_path);
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(&dest, content.as_bytes())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the entry file into the workdir so the harness can import/include it.
|
||||||
|
copy_entry_file(spec, &workdir, harness_src.entry_subpath.as_deref());
|
||||||
|
|
||||||
Ok(workdir)
|
Ok(workdir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy the entry Python file to the workdir so the harness can `import` it.
|
/// Copy the entry source file to the workdir.
|
||||||
/// Best-effort: silently skips if the file cannot be found/copied.
|
///
|
||||||
fn copy_entry_file(spec: &HarnessSpec, workdir: &PathBuf) {
|
/// `entry_subpath` controls the destination:
|
||||||
// Try the entry file relative to the project root candidates.
|
/// - `None` → `workdir/{filename}` (Python default: import by module name).
|
||||||
|
/// - `Some("src/entry.rs")` → `workdir/src/entry.rs` (Rust: `mod entry;`).
|
||||||
|
///
|
||||||
|
/// Best-effort: silently skips if the file cannot be found or copied.
|
||||||
|
fn copy_entry_file(spec: &HarnessSpec, workdir: &PathBuf, entry_subpath: Option<&str>) {
|
||||||
let candidates = [
|
let candidates = [
|
||||||
PathBuf::from(&spec.entry_file),
|
PathBuf::from(&spec.entry_file),
|
||||||
PathBuf::from(".").join(&spec.entry_file),
|
PathBuf::from(".").join(&spec.entry_file),
|
||||||
];
|
];
|
||||||
for src in &candidates {
|
for src in &candidates {
|
||||||
if src.exists() {
|
if src.exists() {
|
||||||
if let Some(fname) = src.file_name() {
|
let dst = if let Some(subpath) = entry_subpath {
|
||||||
let dst = workdir.join(fname);
|
let dest = workdir.join(subpath);
|
||||||
if !dst.exists() {
|
if let Some(parent) = dest.parent() {
|
||||||
let _ = fs::copy(src, &dst);
|
let _ = fs::create_dir_all(parent);
|
||||||
}
|
}
|
||||||
|
dest
|
||||||
|
} else {
|
||||||
|
let fname = match src.file_name() {
|
||||||
|
Some(f) => f,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
workdir.join(fname)
|
||||||
|
};
|
||||||
|
if !dst.exists() {
|
||||||
|
let _ = fs::copy(src, &dst);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -151,17 +177,18 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_unsupported_lang_returns_err() {
|
fn build_unsupported_lang_returns_err() {
|
||||||
|
// Go is not yet supported (unsupported lang path).
|
||||||
let spec = HarnessSpec {
|
let spec = HarnessSpec {
|
||||||
finding_id: "0000000000000001".into(),
|
finding_id: "0000000000000001".into(),
|
||||||
entry_file: "src/main.rs".into(),
|
entry_file: "main.go".into(),
|
||||||
entry_name: "handle_request".into(),
|
entry_name: "handleRequest".into(),
|
||||||
entry_kind: EntryKind::Function,
|
entry_kind: EntryKind::Function,
|
||||||
lang: Lang::Rust,
|
lang: Lang::Go,
|
||||||
toolchain_id: "rust-stable".into(),
|
toolchain_id: "go-stable".into(),
|
||||||
payload_slot: PayloadSlot::Param(0),
|
payload_slot: PayloadSlot::Param(0),
|
||||||
expected_cap: Cap::SQL_QUERY,
|
expected_cap: Cap::SQL_QUERY,
|
||||||
constraint_hints: vec![],
|
constraint_hints: vec![],
|
||||||
sink_file: "src/main.rs".into(),
|
sink_file: "main.go".into(),
|
||||||
sink_line: 5,
|
sink_line: 5,
|
||||||
spec_hash: "0000000000000000".into(),
|
spec_hash: "0000000000000000".into(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
//! The top-level [`emit`] function dispatches on `spec.lang`.
|
//! The top-level [`emit`] function dispatches on `spec.lang`.
|
||||||
|
|
||||||
pub mod python;
|
pub mod python;
|
||||||
|
pub mod rust;
|
||||||
|
|
||||||
use crate::dynamic::spec::HarnessSpec;
|
use crate::dynamic::spec::HarnessSpec;
|
||||||
use crate::evidence::UnsupportedReason;
|
use crate::evidence::UnsupportedReason;
|
||||||
|
|
@ -14,16 +15,25 @@ use crate::symbol::Lang;
|
||||||
pub struct HarnessSource {
|
pub struct HarnessSource {
|
||||||
/// Harness source code as a UTF-8 string.
|
/// Harness source code as a UTF-8 string.
|
||||||
pub source: String,
|
pub source: String,
|
||||||
/// Filename for the harness (e.g. `"harness.py"`).
|
/// Filename for the harness (e.g. `"harness.py"`, `"src/main.rs"`).
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
/// Shell command to invoke the harness (relative to the workdir).
|
/// Shell command to invoke the harness (relative to the workdir).
|
||||||
pub command: Vec<String>,
|
pub command: Vec<String>,
|
||||||
|
/// Additional files to write to the workdir alongside the main source.
|
||||||
|
/// Each entry is `(relative_path, content)`. Subdirectories are created
|
||||||
|
/// automatically (e.g. `"Cargo.toml"` or `"src/entry.rs"`).
|
||||||
|
pub extra_files: Vec<(String, String)>,
|
||||||
|
/// Where to copy the entry source file (relative to workdir).
|
||||||
|
/// `None` = workdir root (Python default).
|
||||||
|
/// `Some("src/entry.rs")` = Rust module path.
|
||||||
|
pub entry_subpath: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch to the appropriate language emitter.
|
/// Dispatch to the appropriate language emitter.
|
||||||
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
match spec.lang {
|
match spec.lang {
|
||||||
Lang::Python => python::emit(spec),
|
Lang::Python => python::emit(spec),
|
||||||
|
Lang::Rust => rust::emit(spec),
|
||||||
_ => Err(UnsupportedReason::LangUnsupported),
|
_ => Err(UnsupportedReason::LangUnsupported),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
source,
|
source,
|
||||||
filename: "harness.py".to_owned(),
|
filename: "harness.py".to_owned(),
|
||||||
command: vec!["python3".to_owned(), "harness.py".to_owned()],
|
command: vec!["python3".to_owned(), "harness.py".to_owned()],
|
||||||
|
extra_files: vec![],
|
||||||
|
entry_subpath: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
253
src/dynamic/lang/rust.rs
Normal file
253
src/dynamic/lang/rust.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
//! Rust harness emitter.
|
||||||
|
//!
|
||||||
|
//! Generates a binary crate that:
|
||||||
|
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
|
||||||
|
//! 2. Calls the entry function from `src/entry.rs` with the payload routed
|
||||||
|
//! to the correct parameter slot.
|
||||||
|
//! 3. The entry function calls `println!("__NYX_SINK_HIT__")` before the
|
||||||
|
//! actual sink invocation (sink-reachability probe).
|
||||||
|
//! 4. Captures outcome via stdout markers and exit code (§4.1).
|
||||||
|
//!
|
||||||
|
//! Build step: the runner calls `build_sandbox::prepare_rust()` which runs
|
||||||
|
//! `cargo build --release` in the workdir. `harness.command` is updated to
|
||||||
|
//! the compiled binary path before sandbox execution.
|
||||||
|
//!
|
||||||
|
//! Payload slot support:
|
||||||
|
//! - `PayloadSlot::Param(0)` — pass payload as `&str` first argument.
|
||||||
|
//! - `PayloadSlot::EnvVar(name)` — set env var before calling entry.
|
||||||
|
//! - All other slots (`Stdin`, `Param(n>0)`, `QueryParam`, `HttpBody`, `Argv`)
|
||||||
|
//! produce `UnsupportedReason::EntryKindUnsupported`. Stdin piping into the
|
||||||
|
//! generated harness is not yet wired (deferred).
|
||||||
|
//!
|
||||||
|
//! HTML_ESCAPE is n/a for Rust (§15.4).
|
||||||
|
|
||||||
|
use crate::dynamic::lang::HarnessSource;
|
||||||
|
use crate::dynamic::spec::{HarnessSpec, PayloadSlot};
|
||||||
|
use crate::evidence::UnsupportedReason;
|
||||||
|
use crate::labels::Cap;
|
||||||
|
|
||||||
|
/// Emit a Rust harness for `spec`.
|
||||||
|
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
|
match &spec.payload_slot {
|
||||||
|
PayloadSlot::Param(0) | PayloadSlot::EnvVar(_) => {}
|
||||||
|
_ => return Err(UnsupportedReason::EntryKindUnsupported),
|
||||||
|
}
|
||||||
|
|
||||||
|
let cargo_toml = generate_cargo_toml(spec.expected_cap);
|
||||||
|
let main_rs = generate_main_rs(spec);
|
||||||
|
|
||||||
|
Ok(HarnessSource {
|
||||||
|
source: main_rs,
|
||||||
|
filename: "src/main.rs".into(),
|
||||||
|
command: vec!["target/release/nyx_harness".into()],
|
||||||
|
extra_files: vec![("Cargo.toml".into(), cargo_toml)],
|
||||||
|
entry_subpath: Some("src/entry.rs".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate `Cargo.toml` for the harness crate.
|
||||||
|
///
|
||||||
|
/// Dependencies are driven by `expected_cap`:
|
||||||
|
/// - `SQL_QUERY` → `rusqlite` with the `bundled` feature (embeds SQLite).
|
||||||
|
/// - Other caps use only std (no extra deps).
|
||||||
|
pub fn generate_cargo_toml(cap: Cap) -> String {
|
||||||
|
let mut deps = String::new();
|
||||||
|
|
||||||
|
if cap.contains(Cap::SQL_QUERY) {
|
||||||
|
deps.push_str("rusqlite = { version = \"0.39\", features = [\"bundled\"] }\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"[package]\n\
|
||||||
|
name = \"nyx-harness\"\n\
|
||||||
|
version = \"0.1.0\"\n\
|
||||||
|
edition = \"2021\"\n\n\
|
||||||
|
[[bin]]\n\
|
||||||
|
name = \"nyx_harness\"\n\
|
||||||
|
path = \"src/main.rs\"\n\n\
|
||||||
|
[dependencies]\n\
|
||||||
|
{deps}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
let entry_fn = &spec.entry_name;
|
||||||
|
let (pre_call, call_expr) = build_call(spec, entry_fn);
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r#"//! Nyx dynamic harness — auto-generated, do not edit.
|
||||||
|
mod entry;
|
||||||
|
|
||||||
|
fn main() {{
|
||||||
|
let payload = nyx_payload();
|
||||||
|
{pre_call} {call_expr}
|
||||||
|
}}
|
||||||
|
|
||||||
|
fn nyx_payload() -> String {{
|
||||||
|
// Prefer raw NYX_PAYLOAD (set on Unix).
|
||||||
|
if let Ok(v) = std::env::var("NYX_PAYLOAD") {{
|
||||||
|
if !v.is_empty() {{
|
||||||
|
return v;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
// Fall back to base64-encoded NYX_PAYLOAD_B64.
|
||||||
|
if let Ok(b64) = std::env::var("NYX_PAYLOAD_B64") {{
|
||||||
|
if let Some(bytes) = b64_decode(b64.as_bytes()) {{
|
||||||
|
return String::from_utf8_lossy(&bytes).into_owned();
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
String::new()
|
||||||
|
}}
|
||||||
|
|
||||||
|
/// Minimal base64 decoder (no external deps).
|
||||||
|
fn b64_decode(input: &[u8]) -> Option<Vec<u8>> {{
|
||||||
|
const TABLE: [u8; 128] = {{
|
||||||
|
let mut t = [255u8; 128];
|
||||||
|
let mut i = 0u8;
|
||||||
|
for &c in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" {{
|
||||||
|
t[c as usize] = i;
|
||||||
|
i += 1;
|
||||||
|
}}
|
||||||
|
t
|
||||||
|
}};
|
||||||
|
let input: Vec<u8> = input.iter().copied().filter(|&c| c != b'\n' && c != b'\r').collect();
|
||||||
|
let mut out = Vec::with_capacity(input.len() * 3 / 4);
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 3 < input.len() {{
|
||||||
|
let a = *TABLE.get(input[i] as usize)? as u32;
|
||||||
|
let b = *TABLE.get(input[i + 1] as usize)? as u32;
|
||||||
|
let c = if input[i + 2] == b'=' {{ 64 }} else {{ *TABLE.get(input[i + 2] as usize)? as u32 }};
|
||||||
|
let d = if input[i + 3] == b'=' {{ 64 }} else {{ *TABLE.get(input[i + 3] as usize)? as u32 }};
|
||||||
|
if a == 255 || b == 255 || c == 255 || d == 255 {{ return None; }}
|
||||||
|
out.push(((a << 2) | (b >> 4)) as u8);
|
||||||
|
if input[i + 2] != b'=' {{ out.push(((b << 4) | (c >> 2)) as u8); }}
|
||||||
|
if input[i + 3] != b'=' {{ out.push(((c << 6) | d) as u8); }}
|
||||||
|
i += 4;
|
||||||
|
}}
|
||||||
|
Some(out)
|
||||||
|
}}
|
||||||
|
"#,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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: "rust000000000001".into(),
|
||||||
|
entry_file: "src/handler.rs".into(),
|
||||||
|
entry_name: "run".into(),
|
||||||
|
entry_kind: EntryKind::Function,
|
||||||
|
lang: Lang::Rust,
|
||||||
|
toolchain_id: "rust-stable".into(),
|
||||||
|
payload_slot,
|
||||||
|
expected_cap: Cap::SQL_QUERY,
|
||||||
|
constraint_hints: vec![],
|
||||||
|
sink_file: "src/handler.rs".into(),
|
||||||
|
sink_line: 10,
|
||||||
|
spec_hash: "rusttest00000001".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_sql_query_produces_source() {
|
||||||
|
let spec = make_spec(PayloadSlot::Param(0));
|
||||||
|
let harness = emit(&spec).unwrap();
|
||||||
|
assert!(harness.source.contains("mod entry;"));
|
||||||
|
assert!(harness.source.contains("nyx_payload()"));
|
||||||
|
assert!(harness.source.contains("entry::run(&payload)"));
|
||||||
|
assert_eq!(harness.filename, "src/main.rs");
|
||||||
|
assert_eq!(harness.command, vec!["target/release/nyx_harness"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_includes_cargo_toml_in_extra_files() {
|
||||||
|
let spec = make_spec(PayloadSlot::Param(0));
|
||||||
|
let harness = emit(&spec).unwrap();
|
||||||
|
let cargo = harness.extra_files.iter().find(|(n, _)| n == "Cargo.toml");
|
||||||
|
assert!(cargo.is_some(), "Cargo.toml must be in extra_files");
|
||||||
|
let cargo_content = &cargo.unwrap().1;
|
||||||
|
assert!(cargo_content.contains("rusqlite"), "SQL_QUERY cap needs rusqlite dep");
|
||||||
|
assert!(cargo_content.contains("bundled"), "rusqlite must use bundled feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_code_exec_no_rusqlite_dep() {
|
||||||
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
||||||
|
spec.expected_cap = Cap::CODE_EXEC;
|
||||||
|
let harness = emit(&spec).unwrap();
|
||||||
|
let cargo = harness.extra_files.iter().find(|(n, _)| n == "Cargo.toml").unwrap();
|
||||||
|
assert!(!cargo.1.contains("rusqlite"), "CODE_EXEC must not have rusqlite dep");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_entry_subpath_is_src_entry_rs() {
|
||||||
|
let spec = make_spec(PayloadSlot::Param(0));
|
||||||
|
let harness = emit(&spec).unwrap();
|
||||||
|
assert_eq!(harness.entry_subpath, Some("src/entry.rs".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_env_var_slot() {
|
||||||
|
let spec = make_spec(PayloadSlot::EnvVar("NYX_INPUT".into()));
|
||||||
|
let harness = emit(&spec).unwrap();
|
||||||
|
assert!(harness.source.contains("set_var"));
|
||||||
|
assert!(harness.source.contains("\"NYX_INPUT\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_param_gt_0_is_unsupported() {
|
||||||
|
let spec = make_spec(PayloadSlot::Param(1));
|
||||||
|
let err = emit(&spec).unwrap_err();
|
||||||
|
assert_eq!(err, UnsupportedReason::EntryKindUnsupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cargo_toml_has_correct_bin_target() {
|
||||||
|
let cargo = generate_cargo_toml(Cap::SQL_QUERY);
|
||||||
|
assert!(cargo.contains("name = \"nyx_harness\""));
|
||||||
|
assert!(cargo.contains("path = \"src/main.rs\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn b64_decode_roundtrip() {
|
||||||
|
// Test by compiling: actual b64_decode is in generated code.
|
||||||
|
// Just verify the Cargo.toml generation doesn't panic.
|
||||||
|
let _ = generate_cargo_toml(Cap::FILE_IO);
|
||||||
|
let _ = generate_cargo_toml(Cap::CODE_EXEC);
|
||||||
|
let _ = generate_cargo_toml(Cap::SSRF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -111,8 +111,18 @@ pub fn write(
|
||||||
let entry_path = root.join("entry").join(format!("extracted_source.{ext}"));
|
let entry_path = root.join("entry").join(format!("extracted_source.{ext}"));
|
||||||
fs::write(&entry_path, entry_source.as_bytes())?;
|
fs::write(&entry_path, entry_source.as_bytes())?;
|
||||||
|
|
||||||
// harness/harness.py (or other lang ext)
|
// harness/harness.{ext} (or for Rust: harness/src/main.rs)
|
||||||
let harness_path = root.join("harness").join(format!("harness.{ext}"));
|
use crate::symbol::Lang;
|
||||||
|
let harness_path = if matches!(spec.lang, Lang::Rust) {
|
||||||
|
let src_dir = root.join("harness").join("src");
|
||||||
|
fs::create_dir_all(&src_dir)?;
|
||||||
|
// Also write Cargo.toml for Rust repro bundles.
|
||||||
|
let cargo_content = crate::dynamic::lang::rust::generate_cargo_toml(spec.expected_cap);
|
||||||
|
fs::write(root.join("harness").join("Cargo.toml"), cargo_content.as_bytes())?;
|
||||||
|
src_dir.join("main.rs")
|
||||||
|
} else {
|
||||||
|
root.join("harness").join(format!("harness.{ext}"))
|
||||||
|
};
|
||||||
fs::write(&harness_path, harness_source.as_bytes())?;
|
fs::write(&harness_path, harness_source.as_bytes())?;
|
||||||
|
|
||||||
// harness/Dockerfile.harness
|
// harness/Dockerfile.harness
|
||||||
|
|
@ -232,22 +242,55 @@ fn source_ext_for_lang(lang: &crate::symbol::Lang) -> &'static str {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dockerfile_for_spec(spec: &HarnessSpec) -> String {
|
fn dockerfile_for_spec(spec: &HarnessSpec) -> String {
|
||||||
let image = format!("python:{}", spec.toolchain_id.strip_prefix("python-").unwrap_or("3"));
|
use crate::symbol::Lang;
|
||||||
format!(
|
match spec.lang {
|
||||||
"FROM {image}\nWORKDIR /harness\nCOPY harness.py .\nCMD [\"python3\", \"harness.py\"]\n"
|
Lang::Rust => {
|
||||||
)
|
let toolchain = spec.toolchain_id.strip_prefix("rust-").unwrap_or("stable");
|
||||||
|
// Multi-stage: build with Rust, run the binary directly.
|
||||||
|
format!(
|
||||||
|
"FROM rust:{toolchain}-slim AS builder\n\
|
||||||
|
WORKDIR /harness\n\
|
||||||
|
COPY Cargo.toml Cargo.lock* ./\n\
|
||||||
|
COPY src/ src/\n\
|
||||||
|
RUN cargo build --release\n\n\
|
||||||
|
FROM debian:bookworm-slim\n\
|
||||||
|
WORKDIR /harness\n\
|
||||||
|
COPY --from=builder /harness/target/release/nyx_harness .\n\
|
||||||
|
CMD [\"/harness/nyx_harness\"]\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Lang::Python => {
|
||||||
|
let image = format!("python:{}", spec.toolchain_id.strip_prefix("python-").unwrap_or("3"));
|
||||||
|
format!(
|
||||||
|
"FROM {image}\nWORKDIR /harness\nCOPY harness.py .\nCMD [\"python3\", \"harness.py\"]\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
format!("# Unsupported language: {:?}\nFROM ubuntu:latest\n", spec.lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reproduce_script(spec: &HarnessSpec, payload_label: &str) -> String {
|
fn reproduce_script(spec: &HarnessSpec, payload_label: &str) -> String {
|
||||||
|
use crate::symbol::Lang;
|
||||||
|
let run_cmd = match spec.lang {
|
||||||
|
Lang::Rust => {
|
||||||
|
"NYX_PAYLOAD=\"$(cat payload/payload.bin)\" ./harness/nyx_harness".to_owned()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
"NYX_PAYLOAD=\"$(cat payload/payload.bin)\" python3 harness/harness.py".to_owned()
|
||||||
|
}
|
||||||
|
};
|
||||||
format!(
|
format!(
|
||||||
"#!/bin/sh\n\
|
"#!/bin/sh\n\
|
||||||
# Repro script for finding {finding_id} ({payload_label})\n\
|
# Repro script for finding {finding_id} ({payload_label})\n\
|
||||||
set -e\n\
|
set -e\n\
|
||||||
SCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\
|
SCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\
|
||||||
cd \"$SCRIPT_DIR\"\n\
|
cd \"$SCRIPT_DIR\"\n\
|
||||||
NYX_PAYLOAD=\"$(cat payload/payload.bin)\" python3 harness/harness.py\n",
|
{run_cmd}\n",
|
||||||
finding_id = spec.finding_id,
|
finding_id = spec.finding_id,
|
||||||
payload_label = payload_label,
|
payload_label = payload_label,
|
||||||
|
run_cmd = run_cmd,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use crate::dynamic::corpus::{benign_payload_for, payloads_for, Oracle, Payload};
|
||||||
use crate::dynamic::harness::{self, HarnessError};
|
use crate::dynamic::harness::{self, HarnessError};
|
||||||
use crate::dynamic::sandbox::{self, SandboxError, SandboxOptions, SandboxOutcome};
|
use crate::dynamic::sandbox::{self, SandboxError, SandboxOptions, SandboxOutcome};
|
||||||
use crate::dynamic::spec::HarnessSpec;
|
use crate::dynamic::spec::HarnessSpec;
|
||||||
|
use crate::symbol::Lang;
|
||||||
|
|
||||||
/// Max harness-build attempts before giving up.
|
/// Max harness-build attempts before giving up.
|
||||||
const MAX_BUILD_ATTEMPTS: u32 = 2;
|
const MAX_BUILD_ATTEMPTS: u32 = 2;
|
||||||
|
|
@ -86,28 +87,55 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prepare Python venv for build-time isolation and dependency caching.
|
// Build-time isolation and dependency setup — dispatched by language.
|
||||||
// Errors from prepare_python propagate as RunError::BuildFailed (making
|
match spec.lang {
|
||||||
// that variant reachable) or are swallowed for non-fatal failures (Io /
|
Lang::Python => {
|
||||||
// Unsupported), falling back to the system python3 in the harness command.
|
// Prepare Python venv for dependency caching.
|
||||||
match build_sandbox::prepare_python(spec, &harness.workdir) {
|
// Errors propagate as RunError::BuildFailed or are swallowed for
|
||||||
Ok(build_result) => {
|
// non-fatal failures (Io / Unsupported), falling back to system python3.
|
||||||
// Patch harness command to use venv Python when the venv was built
|
match build_sandbox::prepare_python(spec, &harness.workdir) {
|
||||||
// or found in cache.
|
Ok(build_result) => {
|
||||||
if let Some(cmd0) = harness.command.first_mut() {
|
if let Some(cmd0) = harness.command.first_mut() {
|
||||||
if cmd0 == "python3" || cmd0 == "python" {
|
if cmd0 == "python3" || cmd0 == "python" {
|
||||||
let venv_python = build_result.venv_path.join("bin").join("python3");
|
let venv_python = build_result.venv_path.join("bin").join("python3");
|
||||||
if venv_python.exists() {
|
if venv_python.exists() {
|
||||||
*cmd0 = venv_python.to_string_lossy().into_owned();
|
*cmd0 = venv_python.to_string_lossy().into_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
|
||||||
|
return Err(RunError::BuildFailed { stderr, attempts });
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Lang::Rust => {
|
||||||
|
// Compile the harness binary with `cargo build --release`.
|
||||||
|
match build_sandbox::prepare_rust(spec, &harness.workdir) {
|
||||||
|
Ok(build_result) => {
|
||||||
|
// Update command to the compiled binary path.
|
||||||
|
let binary = build_result.venv_path.join("nyx_harness");
|
||||||
|
if binary.exists() {
|
||||||
|
harness.command = vec![binary.to_string_lossy().into_owned()];
|
||||||
|
} else {
|
||||||
|
// Fall back to binary inside the workdir.
|
||||||
|
let fallback = harness.workdir.join("target").join("release").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(_) => {
|
||||||
|
// Io: fall back to whatever command was set (will likely fail at exec).
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
|
_ => {
|
||||||
return Err(RunError::BuildFailed { stderr, attempts });
|
// No build step for other interpreted languages.
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Io / Unsupported: fall back to system python3 already in command.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,29 @@ use std::path::Path;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
// ── Harness interpretation probe ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns true when the harness is driven by an interpreter (Python, Node, …)
|
||||||
|
/// rather than a compiled native binary.
|
||||||
|
///
|
||||||
|
/// Interpreted harnesses can be run inside a Python/Node Docker image directly.
|
||||||
|
/// Compiled harnesses (Rust, C) require a platform-matching binary; the Docker
|
||||||
|
/// backend falls back to the process backend for them in Phase 04.
|
||||||
|
pub fn harness_is_interpreted(command: &[String]) -> bool {
|
||||||
|
let cmd0 = match command.first() {
|
||||||
|
Some(c) => c.as_str(),
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let base = std::path::Path::new(cmd0)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or(cmd0);
|
||||||
|
matches!(
|
||||||
|
base,
|
||||||
|
"python3" | "python" | "python2" | "node" | "nodejs" | "ruby" | "php" | "perl"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a single sandboxed run.
|
/// Result of a single sandboxed run.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SandboxOutcome {
|
pub struct SandboxOutcome {
|
||||||
|
|
@ -201,9 +224,18 @@ pub fn run(
|
||||||
opts: &SandboxOptions,
|
opts: &SandboxOptions,
|
||||||
) -> Result<SandboxOutcome, SandboxError> {
|
) -> Result<SandboxOutcome, SandboxError> {
|
||||||
match opts.backend {
|
match opts.backend {
|
||||||
SandboxBackend::Docker => run_docker(harness, payload, opts),
|
SandboxBackend::Docker => {
|
||||||
|
// Docker backend currently only supports interpreted harnesses.
|
||||||
|
// Compiled binaries (Rust, C) are not yet cross-platform in containers;
|
||||||
|
// fall back to the process backend for them.
|
||||||
|
if harness_is_interpreted(&harness.command) {
|
||||||
|
run_docker(harness, payload, opts)
|
||||||
|
} else {
|
||||||
|
run_process(harness, payload, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
SandboxBackend::Auto => {
|
SandboxBackend::Auto => {
|
||||||
if docker_available() {
|
if docker_available() && harness_is_interpreted(&harness.command) {
|
||||||
run_docker(harness, payload, opts)
|
run_docker(harness, payload, opts)
|
||||||
} else {
|
} else {
|
||||||
run_process(harness, payload, opts)
|
run_process(harness, payload, opts)
|
||||||
|
|
@ -366,15 +398,33 @@ fn exec_in_container(
|
||||||
}
|
}
|
||||||
cmd_args.push(container_name.into());
|
cmd_args.push(container_name.into());
|
||||||
|
|
||||||
// The harness script is at /workdir/{filename} inside the container.
|
// Build the exec command inside the container.
|
||||||
let harness_file = harness
|
// For interpreters: `python3 /workdir/harness.py`
|
||||||
.command
|
// For compiled binaries: `/workdir/target/release/nyx_harness`
|
||||||
.get(1)
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
.unwrap_or("harness.py");
|
|
||||||
let exec_cmd = harness.command.first().map(|s| s.as_str()).unwrap_or("python3");
|
let exec_cmd = harness.command.first().map(|s| s.as_str()).unwrap_or("python3");
|
||||||
cmd_args.push(exec_cmd.into());
|
if harness_is_interpreted(&harness.command) {
|
||||||
cmd_args.push(format!("/workdir/{harness_file}"));
|
let harness_file = harness
|
||||||
|
.command
|
||||||
|
.get(1)
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("harness.py");
|
||||||
|
cmd_args.push(exec_cmd.into());
|
||||||
|
cmd_args.push(format!("/workdir/{harness_file}"));
|
||||||
|
} else {
|
||||||
|
// Compiled binary: the command is the relative path within workdir.
|
||||||
|
// e.g. "target/release/nyx_harness" → run "/workdir/target/release/nyx_harness"
|
||||||
|
let rel = std::path::Path::new(exec_cmd)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or(exec_cmd);
|
||||||
|
if exec_cmd.contains('/') || exec_cmd.contains('\\') {
|
||||||
|
// Relative path within workdir (e.g. "target/release/nyx_harness").
|
||||||
|
cmd_args.push(format!("/workdir/{exec_cmd}"));
|
||||||
|
} else {
|
||||||
|
// Just a filename — try /workdir directly.
|
||||||
|
cmd_args.push(format!("/workdir/{rel}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut cmd = Command::new(docker_bin());
|
let mut cmd = Command::new(docker_bin());
|
||||||
cmd.args(&cmd_args);
|
cmd.args(&cmd_args);
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,146 @@ pub enum PinOrigin {
|
||||||
Pipfile,
|
Pipfile,
|
||||||
/// `runtime.txt` (Heroku-style).
|
/// `runtime.txt` (Heroku-style).
|
||||||
RuntimeTxt,
|
RuntimeTxt,
|
||||||
|
/// `rust-toolchain.toml` `[toolchain] channel`.
|
||||||
|
RustToolchainToml,
|
||||||
|
/// `rust-toolchain` (plain text channel file).
|
||||||
|
RustToolchainFile,
|
||||||
|
/// `Cargo.toml` `rust-version` field.
|
||||||
|
CargoToml,
|
||||||
/// No pin found; used the system default.
|
/// No pin found; used the system default.
|
||||||
SystemDefault,
|
SystemDefault,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Rust toolchain resolver ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Resolve the Rust toolchain for `project_root` (§22.2).
|
||||||
|
///
|
||||||
|
/// Reads project pin files in priority order:
|
||||||
|
/// `rust-toolchain.toml` > `rust-toolchain` > `Cargo.toml` `rust-version` > default.
|
||||||
|
pub fn resolve_rust(project_root: &Path) -> ToolchainResolution {
|
||||||
|
if let Some(r) = try_rust_toolchain_toml(project_root) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
if let Some(r) = try_rust_toolchain_file(project_root) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
if let Some(r) = try_cargo_toml_rust_version(project_root) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
default_rust()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_rust_toolchain_toml(root: &Path) -> Option<ToolchainResolution> {
|
||||||
|
let content = std::fs::read_to_string(root.join("rust-toolchain.toml")).ok()?;
|
||||||
|
// Look for `channel = "stable"` or `channel = "1.75"` in [toolchain] section.
|
||||||
|
let mut in_toolchain = false;
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line == "[toolchain]" {
|
||||||
|
in_toolchain = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if line.starts_with('[') {
|
||||||
|
in_toolchain = false;
|
||||||
|
}
|
||||||
|
if in_toolchain && line.starts_with("channel") {
|
||||||
|
if let Some(ver) = extract_version_from_toml_value(line) {
|
||||||
|
return Some(map_rust_version(&ver, RustPinOrigin::RustToolchainToml));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_rust_toolchain_file(root: &Path) -> Option<ToolchainResolution> {
|
||||||
|
let content = std::fs::read_to_string(root.join("rust-toolchain")).ok()?;
|
||||||
|
let version = content.trim().to_owned();
|
||||||
|
if version.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Simple format: just the channel name (e.g. "stable", "1.75.0", "nightly-2024-01-01")
|
||||||
|
Some(map_rust_version(&version, RustPinOrigin::RustToolchainFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_cargo_toml_rust_version(root: &Path) -> Option<ToolchainResolution> {
|
||||||
|
let content = std::fs::read_to_string(root.join("Cargo.toml")).ok()?;
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.starts_with("rust-version") {
|
||||||
|
if let Some(ver) = extract_version_from_toml_value(line) {
|
||||||
|
return Some(map_rust_version(&ver, RustPinOrigin::CargoToml));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_rust() -> ToolchainResolution {
|
||||||
|
ToolchainResolution {
|
||||||
|
toolchain_id: "rust-stable".to_owned(),
|
||||||
|
pin_origin: PinOrigin::SystemDefault,
|
||||||
|
toolchain_drift: false,
|
||||||
|
version_string: "stable".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal origin enum for Rust (mapped to PinOrigin for the public API).
|
||||||
|
enum RustPinOrigin {
|
||||||
|
RustToolchainToml,
|
||||||
|
RustToolchainFile,
|
||||||
|
CargoToml,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_rust_version(version: &str, origin: RustPinOrigin) -> ToolchainResolution {
|
||||||
|
let pin_origin = match origin {
|
||||||
|
RustPinOrigin::RustToolchainToml => PinOrigin::RustToolchainToml,
|
||||||
|
RustPinOrigin::RustToolchainFile => PinOrigin::RustToolchainFile,
|
||||||
|
RustPinOrigin::CargoToml => PinOrigin::CargoToml,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Named channels.
|
||||||
|
if version == "stable" || version.is_empty() {
|
||||||
|
return ToolchainResolution {
|
||||||
|
toolchain_id: "rust-stable".to_owned(),
|
||||||
|
pin_origin,
|
||||||
|
toolchain_drift: false,
|
||||||
|
version_string: "stable".to_owned(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if version.starts_with("nightly") {
|
||||||
|
return ToolchainResolution {
|
||||||
|
toolchain_id: "rust-nightly".to_owned(),
|
||||||
|
pin_origin,
|
||||||
|
toolchain_drift: true, // nightly != stable reference image
|
||||||
|
version_string: version.to_owned(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if version.starts_with("beta") {
|
||||||
|
return ToolchainResolution {
|
||||||
|
toolchain_id: "rust-beta".to_owned(),
|
||||||
|
pin_origin,
|
||||||
|
toolchain_drift: true,
|
||||||
|
version_string: version.to_owned(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semver pinned version like "1.75.0" or "1.75".
|
||||||
|
let parts: Vec<&str> = version.splitn(3, '.').collect();
|
||||||
|
let major = parts.first().copied().unwrap_or("1");
|
||||||
|
let minor = parts.get(1).copied();
|
||||||
|
|
||||||
|
// Map to stable; drift = true when exact version differs from "stable".
|
||||||
|
let drift = minor.is_some(); // pin to specific version = drift from "stable" label
|
||||||
|
ToolchainResolution {
|
||||||
|
toolchain_id: format!("rust-{major}.{}", minor.unwrap_or("x")),
|
||||||
|
pin_origin,
|
||||||
|
toolchain_drift: drift,
|
||||||
|
version_string: version.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Python toolchain resolver ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Resolve the Python toolchain for `project_root`.
|
/// Resolve the Python toolchain for `project_root`.
|
||||||
///
|
///
|
||||||
/// Reads project pin files in priority order:
|
/// Reads project pin files in priority order:
|
||||||
|
|
@ -220,4 +356,49 @@ mod tests {
|
||||||
let r = resolve_python(dir.path());
|
let r = resolve_python(dir.path());
|
||||||
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
|
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Rust toolchain tests ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rust_toolchain_toml_stable() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("rust-toolchain.toml"),
|
||||||
|
"[toolchain]\nchannel = \"stable\"\n",
|
||||||
|
).unwrap();
|
||||||
|
let r = resolve_rust(dir.path());
|
||||||
|
assert_eq!(r.toolchain_id, "rust-stable");
|
||||||
|
assert!(!r.toolchain_drift);
|
||||||
|
assert_eq!(r.pin_origin, PinOrigin::RustToolchainToml);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rust_toolchain_file_nightly() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
fs::write(dir.path().join("rust-toolchain"), "nightly\n").unwrap();
|
||||||
|
let r = resolve_rust(dir.path());
|
||||||
|
assert_eq!(r.toolchain_id, "rust-nightly");
|
||||||
|
assert!(r.toolchain_drift);
|
||||||
|
assert_eq!(r.pin_origin, PinOrigin::RustToolchainFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cargo_toml_rust_version() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("Cargo.toml"),
|
||||||
|
"[package]\nname = \"foo\"\nrust-version = \"1.75\"\n",
|
||||||
|
).unwrap();
|
||||||
|
let r = resolve_rust(dir.path());
|
||||||
|
assert_eq!(r.pin_origin, PinOrigin::CargoToml);
|
||||||
|
assert!(r.toolchain_id.starts_with("rust-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rust_default_is_stable() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let r = resolve_rust(dir.path());
|
||||||
|
assert_eq!(r.toolchain_id, "rust-stable");
|
||||||
|
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,12 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve toolchain information.
|
// Resolve toolchain information (lang-aware: §22.2).
|
||||||
let toolchain_res = toolchain::resolve_python(Path::new("."));
|
use crate::symbol::Lang;
|
||||||
|
let toolchain_res = match spec.lang {
|
||||||
|
Lang::Rust => toolchain::resolve_rust(Path::new(".")),
|
||||||
|
_ => toolchain::resolve_python(Path::new(".")),
|
||||||
|
};
|
||||||
let toolchain_match = if toolchain_res.toolchain_drift { "drift" } else { "exact" };
|
let toolchain_match = if toolchain_res.toolchain_drift { "drift" } else { "exact" };
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
|
||||||
11
tests/dynamic_fixtures/escape/rust_build_rs/Cargo.toml
Normal file
11
tests/dynamic_fixtures/escape/rust_build_rs/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[package]
|
||||||
|
name = "nyx-escape-test"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "nyx_escape_harness"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
16
tests/dynamic_fixtures/escape/rust_build_rs/build.rs
Normal file
16
tests/dynamic_fixtures/escape/rust_build_rs/build.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Escape attempt: malicious build.rs that tries to write a host marker file
|
||||||
|
// during `cargo build`. A sandboxed build should prevent this.
|
||||||
|
//
|
||||||
|
// Expected behaviour in an isolated sandbox: file NOT created, build fails
|
||||||
|
// or marker stays absent.
|
||||||
|
// Expected behaviour on unsandboxed host: marker would appear at /tmp/pwned_build_rs.
|
||||||
|
fn main() {
|
||||||
|
// Attempt to write a marker file on the host.
|
||||||
|
let marker = std::path::Path::new("/tmp/pwned_build_rs");
|
||||||
|
if std::fs::File::create(marker).is_ok() {
|
||||||
|
// Escape succeeded: signal via stdout (cargo captures build script stdout).
|
||||||
|
println!("cargo:warning=NYX_ESCAPE_SUCCESS: build.rs created /tmp/pwned_build_rs");
|
||||||
|
} else {
|
||||||
|
println!("cargo:warning=BLOCKED: could not write /tmp/pwned_build_rs");
|
||||||
|
}
|
||||||
|
}
|
||||||
4
tests/dynamic_fixtures/escape/rust_build_rs/src/main.rs
Normal file
4
tests/dynamic_fixtures/escape/rust_build_rs/src/main.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Benign entry point — the escape attempt lives in build.rs, not here.
|
||||||
|
fn main() {
|
||||||
|
println!("nyx_escape_harness: no vulnerability in main");
|
||||||
|
}
|
||||||
13
tests/dynamic_fixtures/rust/cmdi_adversarial.rs
Normal file
13
tests/dynamic_fixtures/rust/cmdi_adversarial.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/// Command injection — adversarial collision fixture.
|
||||||
|
///
|
||||||
|
/// Prints "NYX_PWN_CMDI" unconditionally without going through a shell sink.
|
||||||
|
///
|
||||||
|
/// Expected verdict: Inconclusive(OracleCollisionSuspected)
|
||||||
|
/// oracle_fired = true (OutputContains("NYX_PWN_CMDI"))
|
||||||
|
/// sink_hit = false (__NYX_SINK_HIT__ never printed)
|
||||||
|
/// Cap: CODE_EXEC Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
// Coincidental oracle match — not a command execution sink.
|
||||||
|
println!("NYX_PWN_CMDI");
|
||||||
|
let _ = payload.len();
|
||||||
|
}
|
||||||
23
tests/dynamic_fixtures/rust/cmdi_negative.rs
Normal file
23
tests/dynamic_fixtures/rust/cmdi_negative.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
/// Command injection — negative fixture.
|
||||||
|
///
|
||||||
|
/// Safe function: uses Command with a list of args (no shell expansion).
|
||||||
|
/// Payload is used as a literal argument, not interpreted by the shell.
|
||||||
|
/// Expected verdict: NotConfirmed.
|
||||||
|
/// Cap: CODE_EXEC Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Safe: list-form args — shell metacharacters in payload are inert.
|
||||||
|
let safe_target = payload
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || *c == '.')
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
|
match Command::new("echo").arg(&safe_target).output() {
|
||||||
|
Ok(out) => print!("{}", String::from_utf8_lossy(&out.stdout)),
|
||||||
|
Err(e) => eprintln!("exec error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tests/dynamic_fixtures/rust/cmdi_positive.rs
Normal file
24
tests/dynamic_fixtures/rust/cmdi_positive.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/// Command injection — positive fixture.
|
||||||
|
///
|
||||||
|
/// Vulnerable function: builds a shell command string from user input.
|
||||||
|
/// Expected verdict: Confirmed (payload "; echo NYX_PWN_CMDI" echoes the marker).
|
||||||
|
/// Cap: CODE_EXEC Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Vulnerable: user input concatenated into shell command string.
|
||||||
|
let cmd = format!("echo {}", payload);
|
||||||
|
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
|
match Command::new("sh").args(["-c", &cmd]).output() {
|
||||||
|
Ok(out) => {
|
||||||
|
print!("{}", String::from_utf8_lossy(&out.stdout));
|
||||||
|
if !out.stderr.is_empty() {
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("exec error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
25
tests/dynamic_fixtures/rust/cmdi_positive2.rs
Normal file
25
tests/dynamic_fixtures/rust/cmdi_positive2.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/// Command injection — second positive fixture.
|
||||||
|
///
|
||||||
|
/// Variant: builds a script filename from user input and passes it to sh.
|
||||||
|
/// Expected verdict: Confirmed (payload "; echo NYX_PWN_CMDI" injects into the
|
||||||
|
/// command string at a different AST site than cmdi_positive.rs).
|
||||||
|
/// Cap: CODE_EXEC Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Vulnerable: payload used as a path argument, which is shell-interpolated.
|
||||||
|
let script = format!("ls -la {}", payload);
|
||||||
|
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
|
match Command::new("sh").args(["-c", &script]).output() {
|
||||||
|
Ok(out) => {
|
||||||
|
print!("{}", String::from_utf8_lossy(&out.stdout));
|
||||||
|
if !out.stderr.is_empty() {
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("exec error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
21
tests/dynamic_fixtures/rust/cmdi_unsupported.rs
Normal file
21
tests/dynamic_fixtures/rust/cmdi_unsupported.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/// Command injection — unsupported entry-kind fixture.
|
||||||
|
///
|
||||||
|
/// Vulnerable logic lives inside a struct method. The test creates a Diag
|
||||||
|
/// with an unsupported entry kind so `HarnessSpec::from_finding` returns
|
||||||
|
/// `Err(UnsupportedReason::EntryKindUnsupported)`.
|
||||||
|
///
|
||||||
|
/// Expected verdict: Unsupported(EntryKindUnsupported)
|
||||||
|
/// Cap: CODE_EXEC
|
||||||
|
pub struct ShellRunner;
|
||||||
|
|
||||||
|
impl ShellRunner {
|
||||||
|
pub fn execute(&self, user_cmd: &str) -> Option<String> {
|
||||||
|
use std::process::Command;
|
||||||
|
let cmd = format!("run {}", user_cmd);
|
||||||
|
Command::new("sh")
|
||||||
|
.args(["-c", &cmd])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tests/dynamic_fixtures/rust/fileio_adversarial.rs
Normal file
14
tests/dynamic_fixtures/rust/fileio_adversarial.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/// File I/O — adversarial collision fixture.
|
||||||
|
///
|
||||||
|
/// Prints "root:" unconditionally without opening any file or printing the
|
||||||
|
/// sink-reachability sentinel.
|
||||||
|
///
|
||||||
|
/// Expected verdict: Inconclusive(OracleCollisionSuspected)
|
||||||
|
/// oracle_fired = true (OutputContains("root:"))
|
||||||
|
/// sink_hit = false (__NYX_SINK_HIT__ never printed)
|
||||||
|
/// Cap: FILE_IO Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
// Coincidental oracle match — no file I/O sink involved.
|
||||||
|
println!("root:x:0:0:root:/root:/bin/bash");
|
||||||
|
let _ = payload.len();
|
||||||
|
}
|
||||||
27
tests/dynamic_fixtures/rust/fileio_negative.rs
Normal file
27
tests/dynamic_fixtures/rust/fileio_negative.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/// File I/O — negative fixture.
|
||||||
|
///
|
||||||
|
/// Safe function: reads from a fixed path; user input is only used as a search
|
||||||
|
/// term within file contents, not as the file path itself.
|
||||||
|
/// Expected verdict: NotConfirmed.
|
||||||
|
/// Cap: FILE_IO Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
// Safe: path is hard-coded; payload cannot influence which file is read.
|
||||||
|
let fixed_path = "/tmp/nyx_safe_file_does_not_exist";
|
||||||
|
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
|
match std::fs::read_to_string(fixed_path) {
|
||||||
|
Ok(contents) => {
|
||||||
|
// Only use payload as a filter, not as a path.
|
||||||
|
for line in contents.lines() {
|
||||||
|
if line.contains(payload) {
|
||||||
|
println!("{}", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
println!("file not found (expected in test)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tests/dynamic_fixtures/rust/fileio_positive.rs
Normal file
16
tests/dynamic_fixtures/rust/fileio_positive.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/// File I/O — positive fixture.
|
||||||
|
///
|
||||||
|
/// Vulnerable function: reads a file at a user-controlled path.
|
||||||
|
/// Expected verdict: Confirmed (path-traversal payload "../../../../etc/passwd"
|
||||||
|
/// causes "root:" to appear in stdout).
|
||||||
|
/// Cap: FILE_IO Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
|
// Vulnerable: user controls the file path — path traversal possible.
|
||||||
|
match std::fs::read_to_string(payload) {
|
||||||
|
Ok(contents) => print!("{}", contents),
|
||||||
|
Err(e) => eprintln!("Error reading {}: {}", payload, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tests/dynamic_fixtures/rust/fileio_positive2.rs
Normal file
24
tests/dynamic_fixtures/rust/fileio_positive2.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/// File I/O — second positive fixture.
|
||||||
|
///
|
||||||
|
/// Variant: uses std::fs::File::open instead of read_to_string; path constructed
|
||||||
|
/// from a base directory and user-supplied component (still traversable).
|
||||||
|
/// Expected verdict: Confirmed (payload "../../../../etc/passwd" reaches /etc/passwd).
|
||||||
|
/// Cap: FILE_IO Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
// Vulnerable: path joins base with user input without canonicalization.
|
||||||
|
let path = format!("/var/data/{}", payload);
|
||||||
|
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
|
match std::fs::File::open(&path) {
|
||||||
|
Ok(mut f) => {
|
||||||
|
let mut buf = String::new();
|
||||||
|
let _ = f.read_to_string(&mut buf);
|
||||||
|
print!("{}", buf);
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Error opening {}: {}", path, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tests/dynamic_fixtures/rust/fileio_unsupported.rs
Normal file
16
tests/dynamic_fixtures/rust/fileio_unsupported.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/// File I/O — unsupported entry-kind fixture.
|
||||||
|
///
|
||||||
|
/// Vulnerable logic lives inside a struct method. The test creates a Diag
|
||||||
|
/// with an unsupported entry kind so `HarnessSpec::from_finding` returns
|
||||||
|
/// `Err(UnsupportedReason::EntryKindUnsupported)`.
|
||||||
|
///
|
||||||
|
/// Expected verdict: Unsupported(EntryKindUnsupported)
|
||||||
|
/// Cap: FILE_IO
|
||||||
|
pub struct FileService;
|
||||||
|
|
||||||
|
impl FileService {
|
||||||
|
pub fn read(&self, path: &str) -> String {
|
||||||
|
// Vulnerable: path traversal — user controls the path.
|
||||||
|
std::fs::read_to_string(path).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
15
tests/dynamic_fixtures/rust/sqli_adversarial.rs
Normal file
15
tests/dynamic_fixtures/rust/sqli_adversarial.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/// SQL injection — adversarial collision fixture.
|
||||||
|
///
|
||||||
|
/// Prints "NYX_SQL_CONFIRMED" unconditionally without going through a SQL sink
|
||||||
|
/// and without printing the sink-reachability sentinel.
|
||||||
|
///
|
||||||
|
/// Expected verdict: Inconclusive(OracleCollisionSuspected)
|
||||||
|
/// oracle_fired = true (OutputContains("NYX_SQL_CONFIRMED"))
|
||||||
|
/// sink_hit = false (__NYX_SINK_HIT__ never printed)
|
||||||
|
/// Cap: SQL_QUERY Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
// Coincidental oracle match — not a SQL sink.
|
||||||
|
println!("NYX_SQL_CONFIRMED");
|
||||||
|
// Ensure payload is consumed so the compiler does not optimise it away.
|
||||||
|
let _ = payload.len();
|
||||||
|
}
|
||||||
33
tests/dynamic_fixtures/rust/sqli_negative.rs
Normal file
33
tests/dynamic_fixtures/rust/sqli_negative.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
/// SQL injection — negative fixture.
|
||||||
|
///
|
||||||
|
/// Safe function: uses parameterized query (rusqlite params![]).
|
||||||
|
/// Expected verdict: NotConfirmed (no injection possible; oracle cannot fire).
|
||||||
|
/// Cap: SQL_QUERY Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE users (id INTEGER, name TEXT);\
|
||||||
|
INSERT INTO users VALUES (1, 'alice');\
|
||||||
|
INSERT INTO users VALUES (2, 'bob');",
|
||||||
|
)
|
||||||
|
.expect("setup schema");
|
||||||
|
|
||||||
|
// Safe: parameterized query — payload cannot escape the literal binding.
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT name FROM users WHERE name=?1")
|
||||||
|
.expect("prepare");
|
||||||
|
|
||||||
|
// Sink reached via safe parameterized path; sentinel fires but oracle will not.
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
|
let _ = stmt
|
||||||
|
.query_map(rusqlite::params![payload], |row| row.get::<_, String>(0))
|
||||||
|
.map(|rows| {
|
||||||
|
for name in rows.flatten() {
|
||||||
|
println!("{}", name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
38
tests/dynamic_fixtures/rust/sqli_positive.rs
Normal file
38
tests/dynamic_fixtures/rust/sqli_positive.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/// SQL injection — positive fixture.
|
||||||
|
///
|
||||||
|
/// Vulnerable function: directly concatenates user input into SQL.
|
||||||
|
/// Expected verdict: Confirmed (UNION payload causes "NYX_SQL_CONFIRMED" in output).
|
||||||
|
/// Cap: SQL_QUERY Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE users (id INTEGER, name TEXT);\
|
||||||
|
INSERT INTO users VALUES (1, 'alice');\
|
||||||
|
INSERT INTO users VALUES (2, 'bob');",
|
||||||
|
)
|
||||||
|
.expect("setup schema");
|
||||||
|
|
||||||
|
// Vulnerable: direct string concatenation into SQL.
|
||||||
|
let query = format!("SELECT name FROM users WHERE name='{}'", payload);
|
||||||
|
|
||||||
|
// Sentinel: the sink (conn.prepare) is reachable with tainted input.
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
|
match conn.prepare(&query) {
|
||||||
|
Ok(mut stmt) => {
|
||||||
|
let _ = stmt.query_map([], |row| row.get::<_, String>(0)).map(|rows| {
|
||||||
|
for name in rows.flatten() {
|
||||||
|
println!("{}", name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Error-based: print query on failure (oracle can detect via query echo).
|
||||||
|
println!("DB query: {}", query);
|
||||||
|
println!("DB error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tests/dynamic_fixtures/rust/sqli_unsupported.rs
Normal file
24
tests/dynamic_fixtures/rust/sqli_unsupported.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/// SQL injection — unsupported entry-kind fixture.
|
||||||
|
///
|
||||||
|
/// The vulnerable logic lives inside a struct method. The test creates a Diag
|
||||||
|
/// with an unsupported entry kind, so `HarnessSpec::from_finding` returns
|
||||||
|
/// `Err(UnsupportedReason::EntryKindUnsupported)`.
|
||||||
|
///
|
||||||
|
/// Expected verdict: Unsupported(EntryKindUnsupported)
|
||||||
|
/// Cap: SQL_QUERY
|
||||||
|
pub struct UserRepository;
|
||||||
|
|
||||||
|
impl UserRepository {
|
||||||
|
pub fn find_user(&self, name: &str) -> Vec<String> {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
let conn = Connection::open_in_memory().expect("open db");
|
||||||
|
let query = format!("SELECT name FROM users WHERE name='{}'", name);
|
||||||
|
match conn.prepare(&query) {
|
||||||
|
Ok(mut stmt) => stmt
|
||||||
|
.query_map([], |row| row.get::<_, String>(0))
|
||||||
|
.map(|rows| rows.flatten().collect())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Err(_) => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
tests/dynamic_fixtures/rust/sqli_with_secret.rs
Normal file
38
tests/dynamic_fixtures/rust/sqli_with_secret.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/// SQL injection fixture — same vulnerability as sqli_positive, placed in a
|
||||||
|
/// directory that contains a secrets file (.env with AWS key).
|
||||||
|
///
|
||||||
|
/// The test verifies that the AWS key is redacted from outcome.json / telemetry
|
||||||
|
/// and never appears in any repro artifact after verification.
|
||||||
|
///
|
||||||
|
/// Expected verdict: Confirmed (same oracle as sqli_positive)
|
||||||
|
/// Cap: SQL_QUERY Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE users (id INTEGER, name TEXT);\
|
||||||
|
INSERT INTO users VALUES (1, 'alice');\
|
||||||
|
INSERT INTO users VALUES (2, 'bob');",
|
||||||
|
)
|
||||||
|
.expect("setup schema");
|
||||||
|
|
||||||
|
let query = format!("SELECT name FROM users WHERE name='{}'", payload);
|
||||||
|
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
|
match conn.prepare(&query) {
|
||||||
|
Ok(mut stmt) => {
|
||||||
|
let _ = stmt.query_map([], |row| row.get::<_, String>(0)).map(|rows| {
|
||||||
|
for name in rows.flatten() {
|
||||||
|
println!("{}", name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("DB query: {}", query);
|
||||||
|
println!("DB error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tests/dynamic_fixtures/rust/ssrf_adversarial.rs
Normal file
14
tests/dynamic_fixtures/rust/ssrf_adversarial.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/// SSRF — adversarial collision fixture.
|
||||||
|
///
|
||||||
|
/// Prints "daemon:" unconditionally without making any network or file request,
|
||||||
|
/// and without printing the sink-reachability sentinel.
|
||||||
|
///
|
||||||
|
/// Expected verdict: Inconclusive(OracleCollisionSuspected)
|
||||||
|
/// oracle_fired = true (OutputContains("daemon:"))
|
||||||
|
/// sink_hit = false (__NYX_SINK_HIT__ never printed)
|
||||||
|
/// Cap: SSRF Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
// Coincidental oracle match — no URL fetch or network sink involved.
|
||||||
|
println!("daemon:*:1:1:System Services:/var/root:/usr/bin/false");
|
||||||
|
let _ = payload.len();
|
||||||
|
}
|
||||||
20
tests/dynamic_fixtures/rust/ssrf_negative.rs
Normal file
20
tests/dynamic_fixtures/rust/ssrf_negative.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/// SSRF — negative fixture.
|
||||||
|
///
|
||||||
|
/// Safe function: URL is fixed; user input is used only as a query parameter,
|
||||||
|
/// not as the URL origin.
|
||||||
|
/// Expected verdict: NotConfirmed.
|
||||||
|
/// Cap: SSRF Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
// Safe: payload is a query value, not the URL itself — origin is fixed.
|
||||||
|
let url = format!("file:///tmp/safe_data?q={}", payload);
|
||||||
|
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
|
// Extract the fixed path (no user control over scheme or host).
|
||||||
|
let path = "/tmp/safe_data";
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(content) => print!("{}", content),
|
||||||
|
Err(_) => println!("resource not available (expected in test): {}", url),
|
||||||
|
}
|
||||||
|
}
|
||||||
26
tests/dynamic_fixtures/rust/ssrf_positive.rs
Normal file
26
tests/dynamic_fixtures/rust/ssrf_positive.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
/// SSRF — positive fixture.
|
||||||
|
///
|
||||||
|
/// Vulnerable function: fetches a user-controlled URL. Implements a minimal
|
||||||
|
/// file:// scheme reader so the test requires no network and no async runtime.
|
||||||
|
///
|
||||||
|
/// Expected verdict: Confirmed (payload "file:///etc/passwd" causes "daemon:"
|
||||||
|
/// to appear in stdout via the file:// scheme handler).
|
||||||
|
/// Cap: SSRF Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
|
// Vulnerable: user controls the URL — SSRF via file:// scheme reaches local files.
|
||||||
|
let result = fetch_url(payload);
|
||||||
|
print!("{}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_url(url: &str) -> String {
|
||||||
|
if let Some(path) = url.strip_prefix("file://") {
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
.unwrap_or_else(|e| format!("fetch error: {}", e))
|
||||||
|
} else {
|
||||||
|
// For non-file schemes, report the target (demonstrating SSRF intent).
|
||||||
|
format!("SSRF: would connect to {}", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
tests/dynamic_fixtures/rust/ssrf_positive2.rs
Normal file
32
tests/dynamic_fixtures/rust/ssrf_positive2.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
/// SSRF — second positive fixture.
|
||||||
|
///
|
||||||
|
/// Variant: user-controlled URL stored in a struct field before being fetched,
|
||||||
|
/// exercising a different taint path than ssrf_positive.rs.
|
||||||
|
/// Expected verdict: Confirmed (payload "file:///etc/passwd" reaches the file
|
||||||
|
/// reader via the stored URL field).
|
||||||
|
/// Cap: SSRF Entry: `run(payload: &str)`
|
||||||
|
pub fn run(payload: &str) {
|
||||||
|
let req = Request { url: payload.to_owned() };
|
||||||
|
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
|
let result = req.execute();
|
||||||
|
print!("{}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Request {
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Request {
|
||||||
|
fn execute(&self) -> String {
|
||||||
|
// Vulnerable: self.url derived from user input — SSRF.
|
||||||
|
if let Some(path) = self.url.strip_prefix("file://") {
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
.unwrap_or_else(|e| format!("fetch error: {}", e))
|
||||||
|
} else {
|
||||||
|
format!("SSRF: would connect to {}", self.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tests/dynamic_fixtures/rust/ssrf_unsupported.rs
Normal file
20
tests/dynamic_fixtures/rust/ssrf_unsupported.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/// SSRF — unsupported entry-kind fixture.
|
||||||
|
///
|
||||||
|
/// Vulnerable logic lives inside a struct method. The test creates a Diag
|
||||||
|
/// with an unsupported entry kind so `HarnessSpec::from_finding` returns
|
||||||
|
/// `Err(UnsupportedReason::EntryKindUnsupported)`.
|
||||||
|
///
|
||||||
|
/// Expected verdict: Unsupported(EntryKindUnsupported)
|
||||||
|
/// Cap: SSRF
|
||||||
|
pub struct HttpClient;
|
||||||
|
|
||||||
|
impl HttpClient {
|
||||||
|
pub fn get(&self, url: &str) -> String {
|
||||||
|
// Vulnerable: user controls the URL — SSRF.
|
||||||
|
if let Some(path) = url.strip_prefix("file://") {
|
||||||
|
std::fs::read_to_string(path).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
format!("fetching: {}", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -181,6 +181,51 @@ mod escape_tests {
|
||||||
escape_test!(escape_chroot_escape, "chroot_escape.py");
|
escape_test!(escape_chroot_escape, "chroot_escape.py");
|
||||||
escape_test!(escape_ipc_shm, "ipc_shm_escape.py");
|
escape_test!(escape_ipc_shm, "ipc_shm_escape.py");
|
||||||
|
|
||||||
|
// ── Rust build.rs escape test ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Verify that a malicious Rust build.rs cannot write to the host when compiled
|
||||||
|
/// inside the sandbox.
|
||||||
|
///
|
||||||
|
/// NOTE (Phase 04): Docker + Rust compilation is deferred to Phase 05.
|
||||||
|
/// `prepare_rust()` currently runs `cargo build` via the process backend on
|
||||||
|
/// the host, so Docker isolation does NOT protect the build step yet.
|
||||||
|
///
|
||||||
|
/// This test documents the expected behaviour once Phase 05 is complete:
|
||||||
|
/// - Docker available + Rust compilation in Docker → marker absent (BLOCKED).
|
||||||
|
/// - No Docker or Phase 05 not yet implemented → test is skipped.
|
||||||
|
///
|
||||||
|
/// The fixture is at `tests/dynamic_fixtures/escape/rust_build_rs/`.
|
||||||
|
///
|
||||||
|
/// Ignored until Phase 05 wires real Docker-isolated cargo builds — the
|
||||||
|
/// current body would always pass (it removes the marker, then asserts it
|
||||||
|
/// is absent) so leaving it active gives a false-green signal.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "Phase 05: Docker-isolated cargo build not yet implemented"]
|
||||||
|
fn escape_rust_malicious_build_rs() {
|
||||||
|
if !docker_available() {
|
||||||
|
// Docker required for build isolation; skip on machines without it.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 05 TODO: wire Docker-isolated cargo build and re-enable this body.
|
||||||
|
// When Docker + Rust compilation is implemented:
|
||||||
|
// 1. Copy rust_build_rs/ to a temp workdir.
|
||||||
|
// 2. Run prepare_rust_in_docker(spec, workdir).
|
||||||
|
// 3. Assert !Path::new("/tmp/pwned_build_rs").exists().
|
||||||
|
//
|
||||||
|
// For now: assert the marker is absent (it always is because we don't run
|
||||||
|
// the malicious build here), establishing the baseline for regression tracking.
|
||||||
|
let marker = std::path::PathBuf::from("/tmp/pwned_build_rs");
|
||||||
|
let _ = fs::remove_file(&marker);
|
||||||
|
|
||||||
|
// No build is triggered yet (Docker + Rust deferred).
|
||||||
|
// The marker must remain absent.
|
||||||
|
assert!(
|
||||||
|
!marker.exists(),
|
||||||
|
"host marker /tmp/pwned_build_rs must not exist before Docker+Rust compilation is implemented"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Docker exec reuse test ────────────────────────────────────────────────
|
// ── Docker exec reuse test ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Verify that the second payload for the same spec_hash reuses the running
|
/// Verify that the second payload for the same spec_hash reuses the running
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,136 @@ mod repro_determinism_tests {
|
||||||
unsafe { std::env::remove_var("NYX_REPRO_BASE") };
|
unsafe { std::env::remove_var("NYX_REPRO_BASE") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Rust repro tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn make_confirmed_rust_spec(spec_hash: &str) -> HarnessSpec {
|
||||||
|
HarnessSpec {
|
||||||
|
finding_id: "rust_determ00001".into(),
|
||||||
|
entry_file: "src/entry.rs".into(),
|
||||||
|
entry_name: "run".into(),
|
||||||
|
entry_kind: EntryKind::Function,
|
||||||
|
lang: Lang::Rust,
|
||||||
|
toolchain_id: "rust-stable".into(),
|
||||||
|
payload_slot: PayloadSlot::Param(0),
|
||||||
|
expected_cap: Cap::SQL_QUERY,
|
||||||
|
constraint_hints: vec![],
|
||||||
|
sink_file: "src/entry.rs".into(),
|
||||||
|
sink_line: 18,
|
||||||
|
spec_hash: spec_hash.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_confirmed_rust_harness_source() -> String {
|
||||||
|
r#"mod entry;
|
||||||
|
fn main() {
|
||||||
|
let payload = std::env::var("NYX_PAYLOAD").unwrap_or_default();
|
||||||
|
entry::run(&payload);
|
||||||
|
}
|
||||||
|
"#
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rust repro bundle has the correct layout.
|
||||||
|
///
|
||||||
|
/// For Rust, harness is at `harness/src/main.rs` and `harness/Cargo.toml`
|
||||||
|
/// is also written (unlike Python which uses `harness/harness.py`).
|
||||||
|
#[test]
|
||||||
|
fn rust_repro_layout_is_correct() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
unsafe { std::env::set_var("NYX_REPRO_BASE", dir.path().to_str().unwrap()) };
|
||||||
|
|
||||||
|
let spec = make_confirmed_rust_spec("rust_determ00001");
|
||||||
|
let opts = SandboxOptions::default();
|
||||||
|
let outcome = make_confirmed_outcome();
|
||||||
|
let verdict = make_confirmed_verdict("rust_determ00001");
|
||||||
|
let harness_src = make_confirmed_rust_harness_source();
|
||||||
|
|
||||||
|
let artifact = repro::write(
|
||||||
|
&spec,
|
||||||
|
&opts,
|
||||||
|
&outcome,
|
||||||
|
&verdict,
|
||||||
|
&harness_src,
|
||||||
|
"pub fn run(payload: &str) { println!(\"{}\", payload); }\n",
|
||||||
|
b"' UNION SELECT 'NYX_SQL_CONFIRMED'--",
|
||||||
|
"sqli-union-nyx",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("Rust repro write must succeed");
|
||||||
|
|
||||||
|
// Rust-specific layout: harness lives under harness/src/main.rs.
|
||||||
|
assert!(
|
||||||
|
artifact.root.join("harness/src/main.rs").exists(),
|
||||||
|
"Rust harness must be at harness/src/main.rs"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
artifact.root.join("harness/Cargo.toml").exists(),
|
||||||
|
"Rust harness must include harness/Cargo.toml"
|
||||||
|
);
|
||||||
|
// Common layout.
|
||||||
|
assert!(artifact.root.join("manifest.json").exists());
|
||||||
|
assert!(artifact.root.join("entry/extracted_source.rs").exists());
|
||||||
|
assert!(artifact.root.join("payload/payload.bin").exists());
|
||||||
|
assert!(artifact.root.join("expected/outcome.json").exists());
|
||||||
|
assert!(artifact.root.join("expected/verdict.json").exists());
|
||||||
|
assert!(artifact.root.join("reproduce.sh").exists());
|
||||||
|
|
||||||
|
unsafe { std::env::remove_var("NYX_REPRO_BASE") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rust repro outcome.json is byte-identical across two writes.
|
||||||
|
#[test]
|
||||||
|
fn rust_repro_outcome_is_deterministic() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
unsafe { std::env::set_var("NYX_REPRO_BASE", dir.path().to_str().unwrap()) };
|
||||||
|
|
||||||
|
let spec = make_confirmed_rust_spec("rust_determ00002");
|
||||||
|
let opts = SandboxOptions::default();
|
||||||
|
let outcome = make_confirmed_outcome();
|
||||||
|
let verdict = make_confirmed_verdict("rust_determ00002");
|
||||||
|
let harness_src = make_confirmed_rust_harness_source();
|
||||||
|
let entry_src = "pub fn run(payload: &str) { println!(\"{}\", payload); }\n";
|
||||||
|
|
||||||
|
let artifact1 = repro::write(
|
||||||
|
&spec,
|
||||||
|
&opts,
|
||||||
|
&outcome,
|
||||||
|
&verdict,
|
||||||
|
&harness_src,
|
||||||
|
entry_src,
|
||||||
|
b"' UNION SELECT 'NYX_SQL_CONFIRMED'--",
|
||||||
|
"sqli-union-nyx",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("first Rust repro write");
|
||||||
|
let json1 =
|
||||||
|
std::fs::read_to_string(artifact1.root.join("expected/outcome.json")).unwrap();
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&artifact1.root).unwrap();
|
||||||
|
|
||||||
|
let artifact2 = repro::write(
|
||||||
|
&spec,
|
||||||
|
&opts,
|
||||||
|
&outcome,
|
||||||
|
&verdict,
|
||||||
|
&harness_src,
|
||||||
|
entry_src,
|
||||||
|
b"' UNION SELECT 'NYX_SQL_CONFIRMED'--",
|
||||||
|
"sqli-union-nyx",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("second Rust repro write");
|
||||||
|
let json2 =
|
||||||
|
std::fs::read_to_string(artifact2.root.join("expected/outcome.json")).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
json1, json2,
|
||||||
|
"Rust outcome.json must be byte-identical across two writes"
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe { std::env::remove_var("NYX_REPRO_BASE") };
|
||||||
|
}
|
||||||
|
|
||||||
/// Verify verdict.json is correctly structured.
|
/// Verify verdict.json is correctly structured.
|
||||||
#[test]
|
#[test]
|
||||||
fn verdict_json_is_valid() {
|
fn verdict_json_is_valid() {
|
||||||
|
|
|
||||||
393
tests/rust_fixtures.rs
Normal file
393
tests/rust_fixtures.rs
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
//! Rust fixture integration tests (Phase 04 acceptance gate).
|
||||||
|
//!
|
||||||
|
//! Runs the dynamic verification pipeline against each Rust fixture and
|
||||||
|
//! asserts the expected verdict. Requires `--features dynamic` and a
|
||||||
|
//! working `cargo` toolchain on PATH.
|
||||||
|
//!
|
||||||
|
//! Fixture entry points follow the convention:
|
||||||
|
//! `pub fn run(payload: &str)` in `tests/dynamic_fixtures/rust/{name}.rs`
|
||||||
|
//!
|
||||||
|
//! The harness emitter wraps each fixture in a generated `src/main.rs` that
|
||||||
|
//! reads `NYX_PAYLOAD` from the environment and calls `entry::run(&payload)`.
|
||||||
|
//!
|
||||||
|
//! Build note: the first run per capability compiles a Cargo project; subsequent
|
||||||
|
//! runs with differing entry files hit the build cache only when Cargo.toml and
|
||||||
|
//! src/entry.rs are identical (the cache key includes the entry file hash).
|
||||||
|
//! Expect 2-4 compilations per full test run (one per unique dependency set).
|
||||||
|
//!
|
||||||
|
//! Run with: `cargo nextest run --features dynamic --test rust_fixtures`
|
||||||
|
|
||||||
|
#[cfg(feature = "dynamic")]
|
||||||
|
mod rust_fixture_tests {
|
||||||
|
use nyx_scanner::commands::scan::Diag;
|
||||||
|
use nyx_scanner::dynamic::verify::{verify_finding, VerifyOptions};
|
||||||
|
use nyx_scanner::evidence::{
|
||||||
|
Confidence, Evidence, FlowStep, FlowStepKind, InconclusiveReason, UnsupportedReason,
|
||||||
|
VerifyStatus,
|
||||||
|
};
|
||||||
|
use nyx_scanner::labels::Cap;
|
||||||
|
use nyx_scanner::patterns::{FindingCategory, Severity};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// Serialize all fixture tests: prevents races on process-global env vars
|
||||||
|
// (NYX_REPRO_BASE, NYX_TELEMETRY_PATH) and the shared build cache dir.
|
||||||
|
static FIXTURE_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
fn fixture_path(name: &str) -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("tests/dynamic_fixtures/rust")
|
||||||
|
.join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a Rust fixture through the full dynamic verification pipeline.
|
||||||
|
///
|
||||||
|
/// The fixture file is copied to a temp dir as `src/entry.rs`.
|
||||||
|
/// `NYX_REPRO_BASE` and `NYX_TELEMETRY_PATH` are redirected to temp dirs.
|
||||||
|
fn run_fixture(
|
||||||
|
fixture: &str,
|
||||||
|
func: &str,
|
||||||
|
cap: Cap,
|
||||||
|
sink_line: u32,
|
||||||
|
) -> nyx_scanner::evidence::VerifyResult {
|
||||||
|
let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
|
||||||
|
let path = fixture_path(fixture);
|
||||||
|
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
// Rust fixtures live at src/entry.rs inside the harness workdir;
|
||||||
|
// the Diag's entry_file points to the fixture source on disk.
|
||||||
|
let dst_dir = tmp.path().join("src");
|
||||||
|
std::fs::create_dir_all(&dst_dir).unwrap();
|
||||||
|
let dst = dst_dir.join("entry.rs");
|
||||||
|
std::fs::copy(&path, &dst).expect("fixture file must exist");
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("NYX_REPRO_BASE", tmp.path().join("repro").to_str().unwrap());
|
||||||
|
std::env::set_var(
|
||||||
|
"NYX_TELEMETRY_PATH",
|
||||||
|
tmp.path().join("events.jsonl").to_str().unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point the Diag at the original fixture path (absolute), not the copy.
|
||||||
|
// The harness emitter reads the file at entry_file to extract source.
|
||||||
|
let diag = make_diag(&path, func, cap, sink_line);
|
||||||
|
|
||||||
|
let opts = VerifyOptions::default();
|
||||||
|
let result = verify_finding(&diag, &opts);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("NYX_REPRO_BASE");
|
||||||
|
std::env::remove_var("NYX_TELEMETRY_PATH");
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SQLi fixtures ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqli_positive_is_confirmed() {
|
||||||
|
let result = run_fixture("sqli_positive.rs", "run", Cap::SQL_QUERY, 18);
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
VerifyStatus::Confirmed,
|
||||||
|
"sqli_positive must be Confirmed; got {:?} (detail: {:?})",
|
||||||
|
result.status,
|
||||||
|
result.detail
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.triggered_payload.is_some(),
|
||||||
|
"Confirmed result must have triggered_payload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqli_negative_is_not_confirmed() {
|
||||||
|
let result = run_fixture("sqli_negative.rs", "run", Cap::SQL_QUERY, 22);
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
VerifyStatus::NotConfirmed,
|
||||||
|
"sqli_negative must be NotConfirmed; got {:?} (detail: {:?})",
|
||||||
|
result.status,
|
||||||
|
result.detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqli_unsupported_is_unsupported() {
|
||||||
|
let path = fixture_path("sqli_unsupported.rs");
|
||||||
|
let mut d = make_diag(&path, "find_user", Cap::SQL_QUERY, 10);
|
||||||
|
d.confidence = Some(Confidence::Low);
|
||||||
|
let opts = VerifyOptions::default();
|
||||||
|
let result = verify_finding(&d, &opts);
|
||||||
|
assert_eq!(result.status, VerifyStatus::Unsupported);
|
||||||
|
assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqli_adversarial_is_inconclusive_collision() {
|
||||||
|
// Adversarial prints oracle marker without __NYX_SINK_HIT__:
|
||||||
|
// oracle_fired = true, sink_hit = false → OracleCollisionSuspected.
|
||||||
|
let result = run_fixture("sqli_adversarial.rs", "run", Cap::SQL_QUERY, 999);
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
VerifyStatus::Inconclusive,
|
||||||
|
"sqli_adversarial must be Inconclusive; got {:?}",
|
||||||
|
result.status
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.inconclusive_reason,
|
||||||
|
Some(InconclusiveReason::OracleCollisionSuspected),
|
||||||
|
"adversarial must be OracleCollisionSuspected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command injection fixtures ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cmdi_positive_is_confirmed() {
|
||||||
|
let result = run_fixture("cmdi_positive.rs", "run", Cap::CODE_EXEC, 17);
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
VerifyStatus::Confirmed,
|
||||||
|
"cmdi_positive must be Confirmed; got {:?} (detail: {:?})",
|
||||||
|
result.status,
|
||||||
|
result.detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cmdi_negative_is_not_confirmed() {
|
||||||
|
let result = run_fixture("cmdi_negative.rs", "run", Cap::CODE_EXEC, 17);
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
VerifyStatus::NotConfirmed,
|
||||||
|
"cmdi_negative must be NotConfirmed; got {:?}",
|
||||||
|
result.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cmdi_unsupported_is_unsupported() {
|
||||||
|
let path = fixture_path("cmdi_unsupported.rs");
|
||||||
|
let mut d = make_diag(&path, "execute", Cap::CODE_EXEC, 9);
|
||||||
|
d.confidence = Some(Confidence::Low);
|
||||||
|
let opts = VerifyOptions::default();
|
||||||
|
let result = verify_finding(&d, &opts);
|
||||||
|
assert_eq!(result.status, VerifyStatus::Unsupported);
|
||||||
|
assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cmdi_adversarial_is_inconclusive_collision() {
|
||||||
|
let result = run_fixture("cmdi_adversarial.rs", "run", Cap::CODE_EXEC, 999);
|
||||||
|
assert_eq!(result.status, VerifyStatus::Inconclusive);
|
||||||
|
assert_eq!(
|
||||||
|
result.inconclusive_reason,
|
||||||
|
Some(InconclusiveReason::OracleCollisionSuspected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File I/O fixtures ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fileio_positive_is_confirmed() {
|
||||||
|
let result = run_fixture("fileio_positive.rs", "run", Cap::FILE_IO, 7);
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
VerifyStatus::Confirmed,
|
||||||
|
"fileio_positive must be Confirmed; got {:?} (detail: {:?})",
|
||||||
|
result.status,
|
||||||
|
result.detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fileio_negative_is_not_confirmed() {
|
||||||
|
let result = run_fixture("fileio_negative.rs", "run", Cap::FILE_IO, 17);
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
VerifyStatus::NotConfirmed,
|
||||||
|
"fileio_negative must be NotConfirmed; got {:?}",
|
||||||
|
result.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fileio_unsupported_is_unsupported() {
|
||||||
|
let path = fixture_path("fileio_unsupported.rs");
|
||||||
|
let mut d = make_diag(&path, "read", Cap::FILE_IO, 8);
|
||||||
|
d.confidence = Some(Confidence::Low);
|
||||||
|
let opts = VerifyOptions::default();
|
||||||
|
let result = verify_finding(&d, &opts);
|
||||||
|
assert_eq!(result.status, VerifyStatus::Unsupported);
|
||||||
|
assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fileio_adversarial_is_inconclusive_collision() {
|
||||||
|
let result = run_fixture("fileio_adversarial.rs", "run", Cap::FILE_IO, 999);
|
||||||
|
assert_eq!(result.status, VerifyStatus::Inconclusive);
|
||||||
|
assert_eq!(
|
||||||
|
result.inconclusive_reason,
|
||||||
|
Some(InconclusiveReason::OracleCollisionSuspected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSRF fixtures ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssrf_positive_is_confirmed() {
|
||||||
|
let result = run_fixture("ssrf_positive.rs", "run", Cap::SSRF, 7);
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
VerifyStatus::Confirmed,
|
||||||
|
"ssrf_positive must be Confirmed; got {:?} (detail: {:?})",
|
||||||
|
result.status,
|
||||||
|
result.detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssrf_negative_is_not_confirmed() {
|
||||||
|
let result = run_fixture("ssrf_negative.rs", "run", Cap::SSRF, 13);
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
VerifyStatus::NotConfirmed,
|
||||||
|
"ssrf_negative must be NotConfirmed; got {:?}",
|
||||||
|
result.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssrf_unsupported_is_unsupported() {
|
||||||
|
let path = fixture_path("ssrf_unsupported.rs");
|
||||||
|
let mut d = make_diag(&path, "get", Cap::SSRF, 8);
|
||||||
|
d.confidence = Some(Confidence::Low);
|
||||||
|
let opts = VerifyOptions::default();
|
||||||
|
let result = verify_finding(&d, &opts);
|
||||||
|
assert_eq!(result.status, VerifyStatus::Unsupported);
|
||||||
|
assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssrf_adversarial_is_inconclusive_collision() {
|
||||||
|
let result = run_fixture("ssrf_adversarial.rs", "run", Cap::SSRF, 999);
|
||||||
|
assert_eq!(result.status, VerifyStatus::Inconclusive);
|
||||||
|
assert_eq!(
|
||||||
|
result.inconclusive_reason,
|
||||||
|
Some(InconclusiveReason::OracleCollisionSuspected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Variant fixtures (smoke-test second positive paths) ──────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cmdi_positive2_is_confirmed() {
|
||||||
|
let result = run_fixture("cmdi_positive2.rs", "run", Cap::CODE_EXEC, 17);
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
VerifyStatus::Confirmed,
|
||||||
|
"cmdi_positive2 must be Confirmed; got {:?} (detail: {:?})",
|
||||||
|
result.status,
|
||||||
|
result.detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fileio_positive2_is_confirmed() {
|
||||||
|
let result = run_fixture("fileio_positive2.rs", "run", Cap::FILE_IO, 11);
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
VerifyStatus::Confirmed,
|
||||||
|
"fileio_positive2 must be Confirmed; got {:?} (detail: {:?})",
|
||||||
|
result.status,
|
||||||
|
result.detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssrf_positive2_is_confirmed() {
|
||||||
|
let result = run_fixture("ssrf_positive2.rs", "run", Cap::SSRF, 7);
|
||||||
|
assert_eq!(
|
||||||
|
result.status,
|
||||||
|
VerifyStatus::Confirmed,
|
||||||
|
"ssrf_positive2 must be Confirmed; got {:?} (detail: {:?})",
|
||||||
|
result.status,
|
||||||
|
result.detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Harness architecture: non-Python-specific gate ───────────────────────
|
||||||
|
|
||||||
|
/// Rust fixture must produce a VerifyResult (not panic or ICE).
|
||||||
|
/// This is the Phase 04 acceptance gate: the dynamic pipeline handles
|
||||||
|
/// a compiled-language finding without Python-specific assumptions.
|
||||||
|
#[test]
|
||||||
|
fn rust_pipeline_does_not_panic() {
|
||||||
|
let result = run_fixture("sqli_positive.rs", "run", Cap::SQL_QUERY, 18);
|
||||||
|
// Any verdict is acceptable; the test asserts non-panic only.
|
||||||
|
let _ = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn make_diag(path: &Path, func: &str, cap: Cap, sink_line: u32) -> Diag {
|
||||||
|
let path_str = path.to_string_lossy().into_owned();
|
||||||
|
let evidence = Evidence {
|
||||||
|
flow_steps: vec![
|
||||||
|
FlowStep {
|
||||||
|
step: 1,
|
||||||
|
kind: FlowStepKind::Source,
|
||||||
|
file: path_str.clone(),
|
||||||
|
line: 1,
|
||||||
|
col: 0,
|
||||||
|
snippet: None,
|
||||||
|
variable: Some("payload".into()),
|
||||||
|
callee: None,
|
||||||
|
function: Some(func.to_owned()),
|
||||||
|
is_cross_file: false,
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
step: 2,
|
||||||
|
kind: FlowStepKind::Sink,
|
||||||
|
file: path_str.clone(),
|
||||||
|
line: sink_line,
|
||||||
|
col: 4,
|
||||||
|
snippet: None,
|
||||||
|
variable: None,
|
||||||
|
callee: None,
|
||||||
|
function: None,
|
||||||
|
is_cross_file: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sink_caps: cap.bits(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Diag {
|
||||||
|
path: path_str,
|
||||||
|
line: sink_line as usize,
|
||||||
|
col: 0,
|
||||||
|
severity: Severity::High,
|
||||||
|
id: "taint-unsanitised-flow".into(),
|
||||||
|
category: FindingCategory::Security,
|
||||||
|
path_validated: false,
|
||||||
|
guard_kind: None,
|
||||||
|
message: None,
|
||||||
|
labels: vec![],
|
||||||
|
confidence: Some(Confidence::High),
|
||||||
|
evidence: Some(evidence),
|
||||||
|
rank_score: None,
|
||||||
|
rank_reason: None,
|
||||||
|
suppressed: false,
|
||||||
|
suppression: None,
|
||||||
|
rollup: None,
|
||||||
|
finding_id: String::new(),
|
||||||
|
alternative_finding_ids: vec![],
|
||||||
|
stable_hash: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue