[pitboss] phase 05: M5 — JS/TS, Go, Java, PHP harness emitters

This commit is contained in:
pitboss 2026-05-12 02:20:55 -04:00
parent 84638e7d57
commit 345b44d3cc
103 changed files with 5637 additions and 34 deletions

View file

@ -232,6 +232,142 @@ fn bench_rust_harness_build_cold(c: &mut Criterion) {
});
}
#[cfg(feature = "dynamic")]
fn make_js_sqli_spec() -> HarnessSpec {
HarnessSpec {
finding_id: "bench_js_0001".into(),
entry_file: "tests/dynamic_fixtures/js/sqli_positive.js".into(),
entry_name: "login".into(),
entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function,
lang: Lang::JavaScript,
toolchain_id: "node-20".into(),
payload_slot: PayloadSlot::Param(0),
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "tests/dynamic_fixtures/js/sqli_positive.js".into(),
sink_line: 8,
spec_hash: "benchjssqli000001".into(),
}
}
#[cfg(feature = "dynamic")]
fn make_go_sqli_spec() -> HarnessSpec {
HarnessSpec {
finding_id: "bench_go_0001".into(),
entry_file: "tests/dynamic_fixtures/go/sqli_positive.go".into(),
entry_name: "Login".into(),
entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function,
lang: Lang::Go,
toolchain_id: "go-1.21".into(),
payload_slot: PayloadSlot::Param(0),
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "tests/dynamic_fixtures/go/sqli_positive.go".into(),
sink_line: 12,
spec_hash: "benchgosqli000001".into(),
}
}
#[cfg(feature = "dynamic")]
fn make_java_sqli_spec() -> HarnessSpec {
HarnessSpec {
finding_id: "bench_java_0001".into(),
entry_file: "tests/dynamic_fixtures/java/sqli_positive.java".into(),
entry_name: "login".into(),
entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function,
lang: Lang::Java,
toolchain_id: "java-21".into(),
payload_slot: PayloadSlot::Param(0),
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "tests/dynamic_fixtures/java/sqli_positive.java".into(),
sink_line: 9,
spec_hash: "benchjavasqli00001".into(),
}
}
#[cfg(feature = "dynamic")]
fn make_php_sqli_spec() -> HarnessSpec {
HarnessSpec {
finding_id: "bench_php_0001".into(),
entry_file: "tests/dynamic_fixtures/php/sqli_positive.php".into(),
entry_name: "login".into(),
entry_kind: nyx_scanner::dynamic::spec::EntryKind::Function,
lang: Lang::Php,
toolchain_id: "php-8".into(),
payload_slot: PayloadSlot::Param(0),
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "tests/dynamic_fixtures/php/sqli_positive.php".into(),
sink_line: 9,
spec_hash: "benchphpsqli000001".into(),
}
}
/// JS harness build (source gen + disk write).
#[cfg(feature = "dynamic")]
fn bench_js_harness_build_cold(c: &mut Criterion) {
use nyx_scanner::dynamic::harness;
let spec = make_js_sqli_spec();
c.bench_function("js_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("JS harness build")
});
});
}
/// Go harness build (source gen + disk write, no compilation).
#[cfg(feature = "dynamic")]
fn bench_go_harness_build_cold(c: &mut Criterion) {
use nyx_scanner::dynamic::harness;
let spec = make_go_sqli_spec();
c.bench_function("go_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("Go harness build")
});
});
}
/// Java harness build (source gen + disk write, no compilation).
#[cfg(feature = "dynamic")]
fn bench_java_harness_build_cold(c: &mut Criterion) {
use nyx_scanner::dynamic::harness;
let spec = make_java_sqli_spec();
c.bench_function("java_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("Java harness build")
});
});
}
/// PHP harness build (source gen + disk write).
#[cfg(feature = "dynamic")]
fn bench_php_harness_build_cold(c: &mut Criterion) {
use nyx_scanner::dynamic::harness;
let spec = make_php_sqli_spec();
c.bench_function("php_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("PHP harness build")
});
});
}
#[cfg(feature = "dynamic")]
fn bench_noop(_c: &mut Criterion) {}
@ -251,6 +387,10 @@ criterion_group!(
bench_docker_exec_warm,
bench_docker_payload_cost,
bench_rust_harness_build_cold,
bench_js_harness_build_cold,
bench_go_harness_build_cold,
bench_java_harness_build_cold,
bench_php_harness_build_cold,
);
#[cfg(not(feature = "dynamic"))]

View file

@ -322,6 +322,377 @@ fn build_cache_path(
Ok(path)
}
// ── Node.js build sandbox ─────────────────────────────────────────────────────
/// Prepare a Node.js project for `spec` in `workdir`.
///
/// Runs `npm install --no-save` if `package.json` is present.
/// Build isolation is NOT yet implemented (deferred to a future phase).
/// npm lifecycle scripts run on the host. See deferred.md for details.
pub fn prepare_node(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
let lockfile_hash = compute_node_lockfile_hash(workdir);
let cache_path = build_cache_path(&lockfile_hash, "node", &spec.toolchain_id)?;
// Cache hit: node_modules already installed.
if cache_path.join(".node_cache_done").exists() {
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: true,
duration: std::time::Duration::ZERO,
});
}
// No package.json = no deps to install.
if !workdir.join("package.json").exists() {
std::fs::write(cache_path.join(".node_cache_done"), b"no-package-json")?;
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: false,
duration: std::time::Duration::ZERO,
});
}
let start = std::time::Instant::now();
const MAX_ATTEMPTS: u32 = 2;
const BACKOFF: [u64; 2] = [1, 4];
let mut last_err = String::new();
for attempt in 0..MAX_ATTEMPTS {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1]));
}
match try_npm_install(workdir) {
Ok(()) => {
let _ = std::fs::write(cache_path.join(".node_cache_done"), b"done");
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: false,
duration: start.elapsed(),
});
}
Err(e) => {
last_err = e;
}
}
}
Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS })
}
fn try_npm_install(workdir: &Path) -> Result<(), String> {
let npm = std::env::var("NYX_NPM_BIN").unwrap_or_else(|_| "npm".to_owned());
let output = Command::new(&npm)
.args(["install", "--no-save", "--no-audit", "--no-fund"])
.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.output()
.map_err(|e| format!("npm install: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
Ok(())
}
fn compute_node_lockfile_hash(workdir: &Path) -> String {
let mut h = Hasher::new();
for fname in &["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"] {
if let Ok(content) = std::fs::read(workdir.join(fname)) {
h.update(fname.as_bytes());
h.update(&content);
}
}
let out = h.finalize();
format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap()))
}
// ── Go build sandbox ──────────────────────────────────────────────────────────
/// Prepare a compiled Go binary for `spec`.
///
/// Checks a build cache keyed on `(go.mod + go.sum + entry hash, "go", toolchain_id)`.
/// On a cache hit returns immediately; otherwise runs `go build -o nyx_harness .`
/// in `workdir`.
///
/// Build isolation is NOT yet implemented (deferred). `go build` runs on the
/// host. A malicious `init()` therefore runs with host privileges. See deferred.md.
pub fn prepare_go(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
let lockfile_hash = compute_go_source_hash(workdir);
let cache_path = build_cache_path(&lockfile_hash, "go", &spec.toolchain_id)?;
let binary = cache_path.join("nyx_harness");
if binary.exists() {
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: true,
duration: std::time::Duration::ZERO,
});
}
let start = std::time::Instant::now();
const MAX_ATTEMPTS: u32 = 2;
const BACKOFF: [u64; 2] = [1, 4];
let mut last_err = String::new();
for attempt in 0..MAX_ATTEMPTS {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1]));
}
let _ = std::fs::remove_dir_all(&cache_path);
std::fs::create_dir_all(&cache_path)?;
match try_build_go_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_go_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String> {
let go_bin = std::env::var("NYX_GO_BIN").unwrap_or_else(|_| "go".to_owned());
let output = Command::new(&go_bin)
.args(["build", "-o", binary_dest.to_str().unwrap_or("nyx_harness"), "."])
.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("GOPATH", std::env::var("GOPATH").unwrap_or_else(|_| {
std::env::var("HOME").map(|h| format!("{h}/go")).unwrap_or_else(|_| "/tmp/go".to_owned())
}))
.env("GOMODCACHE", std::env::var("GOMODCACHE").unwrap_or_else(|_| {
std::env::var("HOME").map(|h| format!("{h}/go/pkg/mod")).unwrap_or_else(|_| "/tmp/gomod".to_owned())
}))
.output()
.map_err(|e| format!("go build: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
Ok(())
}
fn compute_go_source_hash(workdir: &Path) -> String {
let mut h = Hasher::new();
for fname in &["go.mod", "go.sum", "main.go"] {
if let Ok(content) = std::fs::read(workdir.join(fname)) {
h.update(fname.as_bytes());
h.update(&content);
}
}
if let Ok(content) = std::fs::read(workdir.join("entry").join("entry.go")) {
h.update(b"entry/entry.go");
h.update(&content);
}
let out = h.finalize();
format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap()))
}
// ── Java build sandbox ────────────────────────────────────────────────────────
/// Prepare compiled Java classes for `spec`.
///
/// Runs `javac NyxHarness.java Entry.java` in `workdir`.
/// Class files land in the workdir (default package, no output dir).
///
/// Build isolation is NOT yet implemented (deferred). `javac` runs on the host.
/// A malicious annotation processor / compile-time plugin could run with host
/// privileges. See deferred.md for planned `nyx-build-java:{toolchain_id}` container.
pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
let source_hash = compute_java_source_hash(workdir);
let cache_path = build_cache_path(&source_hash, "java", &spec.toolchain_id)?;
// Cache hit: class files already compiled. Restore them to workdir so the
// classpath (which points to workdir, not cache_path) can find them when a
// different finding hits the same compiled artefact via a fresh spec_hash.
if cache_path.join("NyxHarness.class").exists() {
for cls in &["NyxHarness.class", "Entry.class"] {
let src = cache_path.join(cls);
let dst = workdir.join(cls);
if src.exists() && !dst.exists() {
let _ = std::fs::copy(&src, &dst);
}
}
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: true,
duration: std::time::Duration::ZERO,
});
}
let start = std::time::Instant::now();
const MAX_ATTEMPTS: u32 = 2;
const BACKOFF: [u64; 2] = [1, 4];
let mut last_err = String::new();
for attempt in 0..MAX_ATTEMPTS {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1]));
}
match try_compile_java(workdir, &cache_path) {
Ok(()) => {
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: false,
duration: start.elapsed(),
});
}
Err(e) => {
last_err = e;
let _ = std::fs::remove_file(cache_path.join("NyxHarness.class"));
let _ = std::fs::remove_file(cache_path.join("Entry.class"));
}
}
}
Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS })
}
fn try_compile_java(workdir: &Path, cache_path: &Path) -> Result<(), String> {
let javac = std::env::var("NYX_JAVAC_BIN").unwrap_or_else(|_| "javac".to_owned());
// Compile sources — class files are written to workdir by default.
let mut args = vec!["-d".to_owned(), workdir.to_string_lossy().into_owned()];
for src in &["NyxHarness.java", "Entry.java"] {
let p = workdir.join(src);
if p.exists() {
args.push(p.to_string_lossy().into_owned());
}
}
let output = Command::new(&javac)
.args(&args)
.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.output()
.map_err(|e| format!("javac: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
// Copy class files to cache.
for cls in &["NyxHarness.class", "Entry.class"] {
let src = workdir.join(cls);
if src.exists() {
let _ = std::fs::copy(&src, cache_path.join(cls));
}
}
Ok(())
}
fn compute_java_source_hash(workdir: &Path) -> String {
let mut h = Hasher::new();
for fname in &["NyxHarness.java", "Entry.java"] {
if let Ok(content) = std::fs::read(workdir.join(fname)) {
h.update(fname.as_bytes());
h.update(&content);
}
}
let out = h.finalize();
format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap()))
}
// ── PHP build sandbox ─────────────────────────────────────────────────────────
/// Prepare a PHP project for `spec` in `workdir`.
///
/// Runs `composer install --no-interaction` if `composer.json` is present.
/// Build isolation is NOT yet implemented (deferred). Composer post-install
/// scripts run on the host. See deferred.md for planned
/// `nyx-build-php:{toolchain_id}` container details.
pub fn prepare_php(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
let lockfile_hash = compute_php_lockfile_hash(workdir);
let cache_path = build_cache_path(&lockfile_hash, "php", &spec.toolchain_id)?;
if cache_path.join(".php_cache_done").exists() {
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: true,
duration: std::time::Duration::ZERO,
});
}
if !workdir.join("composer.json").exists() {
std::fs::write(cache_path.join(".php_cache_done"), b"no-composer-json")?;
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: false,
duration: std::time::Duration::ZERO,
});
}
let start = std::time::Instant::now();
const MAX_ATTEMPTS: u32 = 2;
const BACKOFF: [u64; 2] = [1, 4];
let mut last_err = String::new();
for attempt in 0..MAX_ATTEMPTS {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1]));
}
match try_composer_install(workdir) {
Ok(()) => {
let _ = std::fs::write(cache_path.join(".php_cache_done"), b"done");
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: false,
duration: start.elapsed(),
});
}
Err(e) => {
last_err = e;
}
}
}
Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS })
}
fn try_composer_install(workdir: &Path) -> Result<(), String> {
let composer = std::env::var("NYX_COMPOSER_BIN").unwrap_or_else(|_| "composer".to_owned());
let output = Command::new(&composer)
.args(["install", "--no-interaction", "--no-dev", "--prefer-dist"])
.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("COMPOSER_ALLOW_SUPERUSER", "1")
.output()
.map_err(|e| format!("composer install: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
Ok(())
}
fn compute_php_lockfile_hash(workdir: &Path) -> String {
let mut h = Hasher::new();
for fname in &["composer.json", "composer.lock"] {
if let Ok(content) = std::fs::read(workdir.join(fname)) {
h.update(fname.as_bytes());
h.update(&content);
}
}
let out = h.finalize();
format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap()))
}
#[cfg(test)]
mod tests {
use super::*;
@ -342,4 +713,29 @@ mod tests {
let h2 = compute_lockfile_hash(dir.path());
assert_ne!(h1, h2, "hash must change when requirements.txt changes");
}
#[test]
fn node_lockfile_hash_stable() {
let dir = tempfile::TempDir::new().unwrap();
let h1 = compute_node_lockfile_hash(dir.path());
let h2 = compute_node_lockfile_hash(dir.path());
assert_eq!(h1, h2);
}
#[test]
fn go_source_hash_changes_with_main_go() {
let dir = tempfile::TempDir::new().unwrap();
let h1 = compute_go_source_hash(dir.path());
std::fs::write(dir.path().join("main.go"), "package main\nfunc main() {}").unwrap();
let h2 = compute_go_source_hash(dir.path());
assert_ne!(h1, h2);
}
#[test]
fn java_source_hash_stable() {
let dir = tempfile::TempDir::new().unwrap();
let h1 = compute_java_source_hash(dir.path());
let h2 = compute_java_source_hash(dir.path());
assert_eq!(h1, h2);
}
}

View file

@ -177,18 +177,18 @@ mod tests {
#[test]
fn build_unsupported_lang_returns_err() {
// Go is not yet supported (unsupported lang path).
// C is not supported (no emitter exists for it).
let spec = HarnessSpec {
finding_id: "0000000000000001".into(),
entry_file: "main.go".into(),
entry_file: "main.c".into(),
entry_name: "handleRequest".into(),
entry_kind: EntryKind::Function,
lang: Lang::Go,
toolchain_id: "go-stable".into(),
lang: Lang::C,
toolchain_id: "c-stable".into(),
payload_slot: PayloadSlot::Param(0),
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "main.go".into(),
sink_file: "main.c".into(),
sink_line: 5,
spec_hash: "0000000000000000".into(),
};

219
src/dynamic/lang/go.rs Normal file
View file

@ -0,0 +1,219 @@
//! Go harness emitter.
//!
//! Generates a Go `main` package that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Imports the entry package from `./entry/` and calls the entry function.
//! 3. Uses `runtime.Caller`-style wrapping in fixtures for sink-reachability
//! probes (fixtures explicitly emit `__NYX_SINK_HIT__` before the sink).
//!
//! Build step: `prepare_go()` in `build_sandbox.rs` runs `go build -o nyx_harness .`
//! in the workdir. The harness command is updated to the compiled binary path.
//!
//! File layout in workdir:
//! ```text
//! main.go ← harness entry point (generated)
//! go.mod ← module definition (generated)
//! entry/
//! entry.go ← entry function (copied from project; must have `package entry`)
//! ```
//!
//! Payload slot support:
//! - `PayloadSlot::Param(0)` — pass payload as `string` first argument.
//! - `PayloadSlot::EnvVar(name)` — set env var before calling entry.
//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`.
//!
//! Build container: `nyx-build-go:{toolchain_id}` (deferred; §19.1).
use crate::dynamic::lang::HarnessSource;
use crate::dynamic::spec::{HarnessSpec, PayloadSlot};
use crate::evidence::UnsupportedReason;
/// Emit a Go 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 main_go = generate_main_go(spec);
let go_mod = generate_go_mod();
Ok(HarnessSource {
source: main_go,
filename: "main.go".to_owned(),
command: vec!["./nyx_harness".to_owned()],
extra_files: vec![("go.mod".to_owned(), go_mod)],
entry_subpath: Some("entry/entry.go".to_owned()),
})
}
fn generate_main_go(spec: &HarnessSpec) -> String {
let entry_fn = capitalize_first(&spec.entry_name);
let (pre_call, call_expr) = build_call(spec, &entry_fn);
// Determine which imports are needed.
let env_import = if matches!(&spec.payload_slot, PayloadSlot::EnvVar(_)) {
""
} else {
""
};
let _ = env_import;
format!(
r#"// Nyx dynamic harness — auto-generated, do not edit.
package main
import (
"encoding/base64"
"fmt"
"os"
"nyx-harness/entry"
)
func main() {{
payload := nyxPayload()
{pre_call} {call_expr}
_ = fmt.Sprintf("") // suppress unused import if call_expr uses fmt directly
_ = os.Stderr // suppress unused import
}}
func nyxPayload() string {{
if v := os.Getenv("NYX_PAYLOAD"); v != "" {{
return v
}}
if b64 := os.Getenv("NYX_PAYLOAD_B64"); b64 != "" {{
if data, err := base64.StdEncoding.DecodeString(b64); err == nil {{
return string(data)
}}
}}
return ""
}}
"#,
pre_call = pre_call,
call_expr = call_expr,
)
}
fn generate_go_mod() -> String {
"module nyx-harness\n\ngo 1.21\n".to_owned()
}
/// Build `(pre_call_setup, call_expression)` for the chosen payload slot.
fn build_call(spec: &HarnessSpec, entry_fn: &str) -> (String, String) {
match &spec.payload_slot {
PayloadSlot::Param(0) => {
let pre = String::new();
let call = format!("entry.{entry_fn}(payload)");
(pre, call)
}
PayloadSlot::EnvVar(name) => {
let pre = format!("\tos.Setenv({name:?}, payload)\n");
let call = format!("entry.{entry_fn}()");
(pre, call)
}
_ => {
let pre = String::new();
let call = format!("entry.{entry_fn}(payload)");
(pre, call)
}
}
}
/// Capitalize the first character of a string (Go exported names must start uppercase).
pub fn capitalize_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
#[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: "go0000000000001".into(),
entry_file: "cmd/server/main.go".into(),
entry_name: "handleRequest".into(),
entry_kind: EntryKind::Function,
lang: Lang::Go,
toolchain_id: "go-stable".into(),
payload_slot,
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "cmd/server/main.go".into(),
sink_line: 20,
spec_hash: "go0000000000001".into(),
}
}
#[test]
fn emit_produces_source() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("nyx-harness/entry"));
assert!(harness.source.contains("nyxPayload()"));
assert!(harness.source.contains("entry.HandleRequest(payload)"));
assert_eq!(harness.filename, "main.go");
assert_eq!(harness.command, vec!["./nyx_harness"]);
}
#[test]
fn emit_includes_go_mod_in_extra_files() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
let go_mod = harness.extra_files.iter().find(|(n, _)| n == "go.mod");
assert!(go_mod.is_some(), "go.mod must be in extra_files");
assert!(go_mod.unwrap().1.contains("module nyx-harness"));
}
#[test]
fn emit_entry_subpath_is_entry_go() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert_eq!(harness.entry_subpath, Some("entry/entry.go".to_owned()));
}
#[test]
fn emit_env_var_slot() {
let spec = make_spec(PayloadSlot::EnvVar("DB_USER".into()));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("os.Setenv"));
assert!(harness.source.contains("\"DB_USER\""));
}
#[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 emit_stdin_is_unsupported() {
let spec = make_spec(PayloadSlot::Stdin);
let err = emit(&spec).unwrap_err();
assert_eq!(err, UnsupportedReason::EntryKindUnsupported);
}
#[test]
fn capitalize_first_handles_lowercase() {
assert_eq!(capitalize_first("handleRequest"), "HandleRequest");
assert_eq!(capitalize_first("run"), "Run");
assert_eq!(capitalize_first(""), "");
assert_eq!(capitalize_first("A"), "A");
}
#[test]
fn go_mod_has_correct_module() {
let go_mod = generate_go_mod();
assert!(go_mod.contains("module nyx-harness"));
assert!(go_mod.contains("go 1.21"));
}
}

191
src/dynamic/lang/java.rs Normal file
View file

@ -0,0 +1,191 @@
//! Java harness emitter.
//!
//! Generates a Java `NyxHarness.java` that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Calls `Entry.{entry_name}(payload)` from the co-located `Entry.java`.
//! 3. Catches all exceptions to prevent harness crashes from masking results.
//!
//! Sink-reachability probe: fixtures explicitly emit `System.out.println("__NYX_SINK_HIT__")`
//! before the actual sink call (same pattern as Rust and Go fixtures).
//!
//! Build step: `prepare_java()` in `build_sandbox.rs` runs `javac NyxHarness.java Entry.java`
//! in the workdir. The compiled `.class` files land in the workdir.
//!
//! File layout in workdir:
//! ```text
//! NyxHarness.java ← harness main class (generated)
//! Entry.java ← entry class (copied from project)
//! NyxHarness.class ← compiled by prepare_java()
//! Entry.class ← compiled by prepare_java()
//! ```
//!
//! Payload slot support:
//! - `PayloadSlot::Param(0)` — pass payload as `String` first argument.
//! - `PayloadSlot::EnvVar(name)` — set system property before calling entry.
//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`.
//!
//! Build container: `nyx-build-java:{toolchain_id}` (deferred; §19.1).
use crate::dynamic::lang::HarnessSource;
use crate::dynamic::spec::{HarnessSpec, PayloadSlot};
use crate::evidence::UnsupportedReason;
/// Emit a Java 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 source = generate_harness_java(spec);
Ok(HarnessSource {
source,
filename: "NyxHarness.java".to_owned(),
// Use absolute workdir classpath set by runner.rs after compilation.
// Before runner.rs updates it, '.' works for process backend when run
// from the workdir.
command: vec![
"java".to_owned(),
"-cp".to_owned(),
".".to_owned(),
"NyxHarness".to_owned(),
],
extra_files: vec![],
entry_subpath: Some("Entry.java".to_owned()),
})
}
fn generate_harness_java(spec: &HarnessSpec) -> String {
let entry_method = &spec.entry_name;
let (pre_call, call_expr) = build_call(spec, entry_method);
format!(
r#"// Nyx dynamic harness — auto-generated, do not edit.
public class NyxHarness {{
public static void main(String[] args) throws Exception {{
String payload = nyxPayload();
{pre_call} try {{
{call_expr}
}} catch (Exception e) {{
System.err.println("NYX_EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage());
}}
}}
static String nyxPayload() {{
String v = System.getenv("NYX_PAYLOAD");
if (v != null && !v.isEmpty()) {{
return v;
}}
String b64 = System.getenv("NYX_PAYLOAD_B64");
if (b64 != null && !b64.isEmpty()) {{
byte[] decoded = java.util.Base64.getDecoder().decode(b64);
return new String(decoded, java.nio.charset.StandardCharsets.UTF_8);
}}
return "";
}}
}}
"#,
pre_call = pre_call,
call_expr = call_expr,
)
}
/// Build `(pre_call_setup, call_expression)` for the chosen payload slot.
fn build_call(spec: &HarnessSpec, method: &str) -> (String, String) {
match &spec.payload_slot {
PayloadSlot::Param(0) => {
let pre = String::new();
let call = format!("Entry.{method}(payload);");
(pre, call)
}
PayloadSlot::EnvVar(name) => {
// Use System.setProperty since env vars cannot be set post-JVM-launch
// via standard Java APIs. Fixtures that read env vars must use
// System.getProperty as a fallback, or read NYX_PAYLOAD_PROP_{name}.
let pre = format!(
" System.setProperty({name:?}, payload);\n"
);
let call = format!("Entry.{method}();");
(pre, call)
}
_ => {
let pre = String::new();
let call = format!("Entry.{method}(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: "java00000000001".into(),
entry_file: "src/main/java/App.java".into(),
entry_name: "processInput".into(),
entry_kind: EntryKind::Function,
lang: Lang::Java,
toolchain_id: "java-21".into(),
payload_slot,
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "src/main/java/App.java".into(),
sink_line: 25,
spec_hash: "java00000000001".into(),
}
}
#[test]
fn emit_produces_source() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("public class NyxHarness"));
assert!(harness.source.contains("nyxPayload()"));
assert!(harness.source.contains("Entry.processInput(payload)"));
assert_eq!(harness.filename, "NyxHarness.java");
assert_eq!(harness.command, vec!["java", "-cp", ".", "NyxHarness"]);
}
#[test]
fn emit_entry_subpath_is_entry_java() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert_eq!(harness.entry_subpath, Some("Entry.java".to_owned()));
}
#[test]
fn emit_env_var_slot() {
let spec = make_spec(PayloadSlot::EnvVar("DB_PASSWORD".into()));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("System.setProperty"));
assert!(harness.source.contains("\"DB_PASSWORD\""));
}
#[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 emit_stdin_is_unsupported() {
let spec = make_spec(PayloadSlot::Stdin);
let err = emit(&spec).unwrap_err();
assert_eq!(err, UnsupportedReason::EntryKindUnsupported);
}
#[test]
fn harness_has_base64_decoder() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("Base64.getDecoder()"));
assert!(harness.source.contains("NYX_PAYLOAD_B64"));
}
}

View file

@ -0,0 +1,248 @@
//! JavaScript / TypeScript harness emitter.
//!
//! Generates a Node.js script that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Requires the entry module from the workdir (`entry.js`).
//! 3. Calls the entry function with the payload routed to the correct slot.
//! 4. Catches all exceptions to prevent harness crashes from masking results.
//!
//! Sink-reachability probe: the fixture itself emits `__NYX_SINK_HIT__` before
//! the actual sink call (same pattern as Rust fixtures). The harness is a pure
//! runner with no line-level tracing.
//!
//! Payload slot support:
//! - `PayloadSlot::Param(n)` — n-th positional argument.
//! - `PayloadSlot::EnvVar(name)` — set env var before calling.
//! - `PayloadSlot::Stdin` — pipe payload to process.stdin.
//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`.
//!
//! Build: no compilation step. Command is `node harness.js`.
//! Build container: `nyx-build-node:{toolchain_id}` (deferred; §19.1).
use crate::dynamic::lang::HarnessSource;
use crate::dynamic::spec::{HarnessSpec, PayloadSlot};
use crate::evidence::UnsupportedReason;
/// Emit a Node.js harness for `spec`.
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {
PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {}
_ => return Err(UnsupportedReason::EntryKindUnsupported),
}
let source = generate_source(spec);
let entry_filename = entry_module_filename(&spec.entry_file);
Ok(HarnessSource {
source,
filename: "harness.js".to_owned(),
command: vec!["node".to_owned(), "harness.js".to_owned()],
extra_files: vec![],
entry_subpath: Some(entry_filename),
})
}
fn generate_source(spec: &HarnessSpec) -> String {
let entry_module = entry_module_name(&spec.entry_file);
let entry_fn = &spec.entry_name;
let (pre_call, call_expr) = build_call(spec, &entry_module, entry_fn);
format!(
r#"'use strict';
// Nyx dynamic harness — auto-generated, do not edit.
// ── Payload loading ────────────────────────────────────────────────────────────
const _nyx_payload = (() => {{
if (process.env.NYX_PAYLOAD && process.env.NYX_PAYLOAD.length > 0) {{
return process.env.NYX_PAYLOAD;
}}
if (process.env.NYX_PAYLOAD_B64 && process.env.NYX_PAYLOAD_B64.length > 0) {{
return Buffer.from(process.env.NYX_PAYLOAD_B64, 'base64').toString('utf8');
}}
return '';
}})();
// ── Entry module import ────────────────────────────────────────────────────────
let _entry;
try {{
_entry = require('./{entry_module}');
}} catch (e) {{
process.stderr.write('NYX_IMPORT_ERROR: ' + e.message + '\n');
process.exit(77);
}}
const payload = _nyx_payload;
// ── Pre-call setup ─────────────────────────────────────────────────────────────
{pre_call}
// ── Call entry point ──────────────────────────────────────────────────────────
try {{
const _result = {call_expr};
if (_result !== undefined && _result !== null) {{
if (_result && typeof _result.then === 'function') {{
_result
.then(r => {{ if (r != null) process.stdout.write(String(r) + '\n'); }})
.catch(e => {{ process.stderr.write('NYX_EXCEPTION: ' + e.message + '\n'); }});
}} else {{
process.stdout.write(String(_result) + '\n');
}}
}}
}} catch (e) {{
process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n');
}}
"#,
entry_module = entry_module,
pre_call = pre_call,
call_expr = call_expr,
)
}
/// Build `(pre_call_setup, call_expression)` for the chosen payload slot.
fn build_call(spec: &HarnessSpec, _module: &str, func: &str) -> (String, String) {
match &spec.payload_slot {
PayloadSlot::Param(idx) => {
let pre = String::new();
let call = if *idx == 0 {
format!("_entry.{func}(payload)")
} else {
let pads = (0..*idx).map(|_| "''").collect::<Vec<_>>().join(", ");
format!("_entry.{func}({pads}, payload)")
};
(pre, call)
}
PayloadSlot::EnvVar(name) => {
let pre = format!("process.env[{name:?}] = payload;\n");
let call = format!("_entry.{func}()");
(pre, call)
}
PayloadSlot::Stdin => {
// Synchronous stdin replacement via Buffer.
let pre = format!(
"const {{ Readable }} = require('stream');\n\
process.stdin = Readable.from([Buffer.from(payload, 'utf8')]);\n"
);
let call = format!("_entry.{func}()");
(pre, call)
}
_ => {
let pre = String::new();
let call = format!("_entry.{func}(payload)");
(pre, call)
}
}
}
/// Derive the JS module name from an entry file path.
///
/// `"src/handlers/login.js"` → `"login"` (basename without extension).
pub fn entry_module_name(entry_file: &str) -> String {
let base = entry_file
.rsplit('/')
.next()
.unwrap_or(entry_file)
.rsplit('\\')
.next()
.unwrap_or(entry_file);
// Strip known JS/TS extensions.
for ext in &[".js", ".mjs", ".cjs", ".ts", ".mts"] {
if let Some(stem) = base.strip_suffix(ext) {
return stem.to_owned();
}
}
base.to_owned()
}
/// Derive the filename for `entry_subpath` from an entry file path.
///
/// Always returns `"entry.js"` — fixture files are copied here regardless of
/// their original name so the harness can always `require('./entry')`.
pub fn entry_module_filename(_entry_file: &str) -> String {
"entry.js".to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
use crate::labels::Cap;
use crate::symbol::Lang;
fn make_spec(payload_slot: PayloadSlot) -> HarnessSpec {
HarnessSpec {
finding_id: "js000000000001".into(),
entry_file: "src/app.js".into(),
entry_name: "login".into(),
entry_kind: EntryKind::Function,
lang: Lang::JavaScript,
toolchain_id: "node-20".into(),
payload_slot,
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "src/app.js".into(),
sink_line: 15,
spec_hash: "js000000000001".into(),
}
}
#[test]
fn emit_produces_source() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("NYX_PAYLOAD"));
assert!(harness.source.contains("require"));
assert!(harness.source.contains("login"));
assert_eq!(harness.filename, "harness.js");
assert_eq!(harness.command, vec!["node", "harness.js"]);
}
#[test]
fn emit_param_index_0() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("_entry.login(payload)"));
}
#[test]
fn emit_param_index_1() {
let spec = make_spec(PayloadSlot::Param(1));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("_entry.login('', payload)"));
}
#[test]
fn emit_env_var_slot() {
let spec = make_spec(PayloadSlot::EnvVar("DB_HOST".into()));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("process.env[\"DB_HOST\"] = payload"));
}
#[test]
fn emit_stdin_slot() {
let spec = make_spec(PayloadSlot::Stdin);
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("Readable"));
assert!(harness.source.contains("process.stdin"));
}
#[test]
fn emit_http_body_is_unsupported() {
let spec = make_spec(PayloadSlot::HttpBody);
let err = emit(&spec).unwrap_err();
assert_eq!(err, UnsupportedReason::EntryKindUnsupported);
}
#[test]
fn emit_entry_subpath_is_entry_js() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert_eq!(harness.entry_subpath, Some("entry.js".to_owned()));
}
#[test]
fn entry_module_name_strips_extensions() {
assert_eq!(entry_module_name("src/handlers/login.js"), "login");
assert_eq!(entry_module_name("app.ts"), "app");
assert_eq!(entry_module_name("handler.mjs"), "handler");
assert_eq!(entry_module_name("no_ext"), "no_ext");
}
}

View file

@ -3,6 +3,10 @@
//! Each submodule implements `emit(spec) -> HarnessSource` for one language.
//! The top-level [`emit`] function dispatches on `spec.lang`.
pub mod go;
pub mod java;
pub mod javascript;
pub mod php;
pub mod python;
pub mod rust;
@ -34,6 +38,10 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
match spec.lang {
Lang::Python => python::emit(spec),
Lang::Rust => rust::emit(spec),
Lang::JavaScript | Lang::TypeScript => javascript::emit(spec),
Lang::Go => go::emit(spec),
Lang::Java => java::emit(spec),
Lang::Php => php::emit(spec),
_ => Err(UnsupportedReason::LangUnsupported),
}
}

202
src/dynamic/lang/php.rs Normal file
View file

@ -0,0 +1,202 @@
//! PHP harness emitter.
//!
//! Generates a PHP script that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Includes the entry file (`entry.php`) from the workdir.
//! 3. Calls the entry function with the payload routed to the correct slot.
//! 4. Catches all Throwables to prevent harness crashes from masking results.
//!
//! Sink-reachability probe: fixtures explicitly emit `__NYX_SINK_HIT__` before
//! the actual sink call (same pattern as Rust / JS fixtures).
//!
//! Payload slot support:
//! - `PayloadSlot::Param(n)` — n-th positional argument.
//! - `PayloadSlot::EnvVar(name)` — set `$_ENV`/`putenv()` before calling.
//! - `PayloadSlot::Stdin` — wrap `STDIN` with the payload.
//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`.
//!
//! Build: no compilation step. Command is `php harness.php`.
//! Build container: `nyx-build-php:{toolchain_id}` (deferred; §19.1).
use crate::dynamic::lang::HarnessSource;
use crate::dynamic::spec::{HarnessSpec, PayloadSlot};
use crate::evidence::UnsupportedReason;
/// Emit a PHP harness for `spec`.
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {
PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {}
_ => return Err(UnsupportedReason::EntryKindUnsupported),
}
let source = generate_source(spec);
Ok(HarnessSource {
source,
filename: "harness.php".to_owned(),
command: vec!["php".to_owned(), "harness.php".to_owned()],
extra_files: vec![],
entry_subpath: Some("entry.php".to_owned()),
})
}
fn generate_source(spec: &HarnessSpec) -> String {
let entry_fn = &spec.entry_name;
let (pre_call, call_expr) = build_call(spec, entry_fn);
format!(
r#"<?php
// Nyx dynamic harness — auto-generated, do not edit.
// ── Payload loading ────────────────────────────────────────────────────────────
function nyx_payload(): string {{
$v = getenv('NYX_PAYLOAD');
if ($v !== false && $v !== '') {{
return $v;
}}
$b64 = getenv('NYX_PAYLOAD_B64');
if ($b64 !== false && $b64 !== '') {{
return base64_decode($b64, true) ?: '';
}}
return '';
}}
$payload = nyx_payload();
// ── Entry include ─────────────────────────────────────────────────────────────
try {{
require_once __DIR__ . '/entry.php';
}} catch (Throwable $e) {{
fwrite(STDERR, 'NYX_IMPORT_ERROR: ' . $e->getMessage() . "\n");
exit(77);
}}
// ── Pre-call setup ─────────────────────────────────────────────────────────────
{pre_call}
// ── Call entry point ──────────────────────────────────────────────────────────
try {{
$result = {call_expr};
if ($result !== null) {{
echo $result . "\n";
}}
}} catch (Throwable $e) {{
fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n");
}}
"#,
pre_call = pre_call,
call_expr = call_expr,
)
}
/// Build `(pre_call_setup, call_expression)` for the chosen payload slot.
fn build_call(spec: &HarnessSpec, func: &str) -> (String, String) {
match &spec.payload_slot {
PayloadSlot::Param(idx) => {
let pre = String::new();
let call = if *idx == 0 {
format!("{func}($payload)")
} else {
let pads = (0..*idx).map(|_| "''").collect::<Vec<_>>().join(", ");
format!("{func}({pads}, $payload)")
};
(pre, call)
}
PayloadSlot::EnvVar(name) => {
let pre = format!("putenv({name:?} . '=' . $payload);\n$_ENV[{name:?}] = $payload;\n");
let call = format!("{func}()");
(pre, call)
}
PayloadSlot::Stdin => {
// Replace STDIN with an in-memory stream containing the payload.
let pre = "if (defined('STDIN')) {\n $stream = fopen('php://memory', 'r+');\n fwrite($stream, $payload);\n rewind($stream);\n // Note: STDIN reassignment is not portable; fixture reads via fgets(STDIN).\n}\n".to_owned();
let call = format!("{func}()");
(pre, call)
}
_ => {
let pre = String::new();
let call = format!("{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: "php0000000000001".into(),
entry_file: "src/login.php".into(),
entry_name: "login".into(),
entry_kind: EntryKind::Function,
lang: Lang::Php,
toolchain_id: "php-8".into(),
payload_slot,
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "src/login.php".into(),
sink_line: 10,
spec_hash: "php0000000000001".into(),
}
}
#[test]
fn emit_produces_source() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.starts_with("<?php"));
assert!(harness.source.contains("NYX_PAYLOAD"));
assert!(harness.source.contains("require_once"));
assert!(harness.source.contains("login($payload)"));
assert_eq!(harness.filename, "harness.php");
assert_eq!(harness.command, vec!["php", "harness.php"]);
}
#[test]
fn emit_param_index_0() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("login($payload)"));
}
#[test]
fn emit_param_index_2() {
let spec = make_spec(PayloadSlot::Param(2));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("login('', '', $payload)"));
}
#[test]
fn emit_env_var_slot() {
let spec = make_spec(PayloadSlot::EnvVar("DB_HOST".into()));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("putenv"));
assert!(harness.source.contains("\"DB_HOST\""));
}
#[test]
fn emit_http_body_is_unsupported() {
let spec = make_spec(PayloadSlot::HttpBody);
let err = emit(&spec).unwrap_err();
assert_eq!(err, UnsupportedReason::EntryKindUnsupported);
}
#[test]
fn emit_entry_subpath_is_entry_php() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert_eq!(harness.entry_subpath, Some("entry.php".to_owned()));
}
#[test]
fn harness_has_base64_decode() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("base64_decode"));
assert!(harness.source.contains("NYX_PAYLOAD_B64"));
}
}

View file

@ -134,8 +134,64 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
}
}
}
Lang::JavaScript | Lang::TypeScript => {
// npm install for dependency resolution (no deps in basic fixtures).
match build_sandbox::prepare_node(spec, &harness.workdir) {
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
return Err(RunError::BuildFailed { stderr, attempts });
}
_ => {}
}
}
Lang::Go => {
// Compile the harness binary with `go build -o nyx_harness .`.
match build_sandbox::prepare_go(spec, &harness.workdir) {
Ok(build_result) => {
let binary = build_result.venv_path.join("nyx_harness");
if binary.exists() {
harness.command = vec![binary.to_string_lossy().into_owned()];
} else {
let fallback = harness.workdir.join("nyx_harness");
if fallback.exists() {
harness.command = vec![fallback.to_string_lossy().into_owned()];
}
}
}
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
return Err(RunError::BuildFailed { stderr, attempts });
}
Err(_) => {}
}
}
Lang::Java => {
// Compile NyxHarness.java + Entry.java with javac.
match build_sandbox::prepare_java(spec, &harness.workdir) {
Ok(_) => {
// Update classpath to absolute workdir path for Docker compatibility.
harness.command = vec![
"java".to_owned(),
"-cp".to_owned(),
harness.workdir.to_string_lossy().into_owned(),
"NyxHarness".to_owned(),
];
}
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
return Err(RunError::BuildFailed { stderr, attempts });
}
Err(_) => {}
}
}
Lang::Php => {
// composer install if composer.json is present.
match build_sandbox::prepare_php(spec, &harness.workdir) {
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
return Err(RunError::BuildFailed { stderr, attempts });
}
_ => {}
}
}
_ => {
// No build step for other interpreted languages.
// No build step for other languages.
}
}

View file

@ -47,7 +47,7 @@ pub fn harness_is_interpreted(command: &[String]) -> bool {
.unwrap_or(cmd0);
matches!(
base,
"python3" | "python" | "python2" | "node" | "nodejs" | "ruby" | "php" | "perl"
"python3" | "python" | "python2" | "node" | "nodejs" | "ruby" | "php" | "perl" | "java"
)
}
@ -207,11 +207,25 @@ fn workdir_to_container_name(workdir: &Path) -> String {
/// Docker image tag for a Python toolchain ID (e.g. `python-3.11`).
fn python_image_for_toolchain(toolchain_id: &str) -> String {
// toolchain_id examples: "python-3", "python-3.11", "python-3.12"
let ver = toolchain_id.strip_prefix("python-").unwrap_or("3");
format!("python:{ver}-slim")
}
fn node_image_for_toolchain(toolchain_id: &str) -> String {
let ver = toolchain_id.strip_prefix("node-").unwrap_or("20");
format!("node:{ver}-slim")
}
fn java_image_for_toolchain(toolchain_id: &str) -> String {
let ver = toolchain_id.strip_prefix("java-").unwrap_or("21");
format!("eclipse-temurin:{ver}-jre-jammy")
}
fn php_image_for_toolchain(toolchain_id: &str) -> String {
let ver = toolchain_id.strip_prefix("php-").unwrap_or("8");
format!("php:{ver}-cli")
}
// ── Entry point ───────────────────────────────────────────────────────────────
/// Run a built harness once with a chosen payload.
@ -273,7 +287,7 @@ fn run_docker(
if !reused {
// Determine the Python image from the harness command (first element).
// Fall back to python:3-slim when the command is not recognised.
let image = detect_python_toolchain_from_harness(harness);
let image = detect_image_for_harness(harness);
start_container(&container_name, &harness.workdir, &image)?;
registry.insert(container_name.clone(), container_name.clone());
}
@ -374,6 +388,51 @@ fn start_container(name: &str, workdir: &Path, image: &str) -> Result<(), Sandbo
}
}
/// Build the inner-container command args for `docker exec`.
///
/// For 2-arg interpreted commands (`python3 harness.py`, `node harness.js`,
/// `php harness.php`) the file arg is prefixed with `/workdir/`.
/// For Java (`java -cp /host/abs/path NyxHarness`) the classpath argument is
/// replaced with `/workdir` (the container-side mount path, not the host path
/// that runner.rs wrote after `javac`).
fn build_container_exec_args(command: &[String]) -> Vec<String> {
let mut args = Vec::new();
let cmd0 = match command.first() {
Some(c) => c.as_str(),
None => return args,
};
let base = std::path::Path::new(cmd0)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(cmd0);
if base == "java" {
args.push("java".to_owned());
let mut i = 1;
while i < command.len() {
if command[i] == "-cp" || command[i] == "-classpath" {
args.push(command[i].clone());
i += 1;
args.push("/workdir".to_owned());
i += 1;
} else {
args.push(command[i].clone());
i += 1;
}
}
} else {
args.push(cmd0.to_owned());
if let Some(harness_file) = command.get(1) {
if harness_file.starts_with('/') {
args.push(harness_file.clone());
} else {
args.push(format!("/workdir/{harness_file}"));
}
}
}
args
}
/// Execute the harness inside an already-running container.
fn exec_in_container(
container_name: &str,
@ -405,15 +464,10 @@ fn exec_in_container(
}
cmd_args.push(container_name.into());
// Build the exec command inside the container (always interpreted at this point).
let exec_cmd = harness.command.first().map(|s| s.as_str()).unwrap_or("python3");
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}"));
// Build the exec command inside the container.
for arg in build_container_exec_args(&harness.command) {
cmd_args.push(arg);
}
let mut cmd = Command::new(docker_bin());
cmd.args(&cmd_args);
@ -495,20 +549,33 @@ fn exec_in_container(
})
}
/// Detect the Python image to use based on the harness command.
/// Detect the Docker image for the harness based on the interpreter command.
///
/// The first element of `harness.command` is typically `python3` or a venv
/// path like `/path/to/venv/bin/python3`. Fall back to `python:3-slim`.
fn detect_python_toolchain_from_harness(harness: &BuiltHarness) -> String {
// The harness workdir encodes the spec_hash but not the toolchain.
// Use the default image for Python; callers that know the toolchain_id
// should pass it through BuiltHarness.env (NYX_TOOLCHAIN_ID) when needed.
/// Dispatches by the basename of `command[0]` (e.g. `python3`, `node`, `java`,
/// `php`). Falls back to `python:3-slim` for unrecognised interpreters.
/// `NYX_TOOLCHAIN_ID` env var overrides the version portion of the image tag.
fn detect_image_for_harness(harness: &BuiltHarness) -> String {
let cmd0 = harness.command.first().map(|s| s.as_str()).unwrap_or("python3");
let base = std::path::Path::new(cmd0)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(cmd0);
if let Ok(tid) = std::env::var("NYX_TOOLCHAIN_ID") {
return python_image_for_toolchain(&tid);
return match base {
"node" | "nodejs" => node_image_for_toolchain(&tid),
"java" => java_image_for_toolchain(&tid),
"php" => php_image_for_toolchain(&tid),
_ => python_image_for_toolchain(&tid),
};
}
match base {
"node" | "nodejs" => "node:20-slim".to_owned(),
"java" => "eclipse-temurin:21-jre-jammy".to_owned(),
"php" => "php:8-cli".to_owned(),
_ => "python:3-slim".to_owned(),
}
// Default to python:3-slim which is always available in CI.
let _ = harness;
"python:3-slim".to_owned()
}
// ── Process backend ───────────────────────────────────────────────────────────
@ -804,6 +871,82 @@ mod tests {
assert_eq!(python_image_for_toolchain("python-3.12"), "python:3.12-slim");
}
#[test]
fn node_image_for_known_toolchains() {
assert_eq!(node_image_for_toolchain("node-20"), "node:20-slim");
assert_eq!(node_image_for_toolchain("node-18"), "node:18-slim");
assert_eq!(node_image_for_toolchain("node-lts"), "node:lts-slim");
}
#[test]
fn java_image_for_known_toolchains() {
assert_eq!(java_image_for_toolchain("java-21"), "eclipse-temurin:21-jre-jammy");
assert_eq!(java_image_for_toolchain("java-17"), "eclipse-temurin:17-jre-jammy");
}
#[test]
fn php_image_for_known_toolchains() {
assert_eq!(php_image_for_toolchain("php-8"), "php:8-cli");
assert_eq!(php_image_for_toolchain("php-8.2"), "php:8.2-cli");
}
#[test]
fn harness_is_interpreted_java() {
let cmd = vec!["java".to_owned(), "-cp".to_owned(), ".".to_owned(), "NyxHarness".to_owned()];
assert!(harness_is_interpreted(&cmd));
}
#[test]
fn harness_is_interpreted_node() {
assert!(harness_is_interpreted(&["node".to_owned(), "harness.js".to_owned()]));
}
#[test]
fn build_container_exec_args_python() {
let cmd = vec!["python3".to_owned(), "harness.py".to_owned()];
assert_eq!(
build_container_exec_args(&cmd),
vec!["python3", "/workdir/harness.py"]
);
}
#[test]
fn build_container_exec_args_node() {
let cmd = vec!["node".to_owned(), "harness.js".to_owned()];
assert_eq!(
build_container_exec_args(&cmd),
vec!["node", "/workdir/harness.js"]
);
}
#[test]
fn build_container_exec_args_php() {
let cmd = vec!["php".to_owned(), "harness.php".to_owned()];
assert_eq!(
build_container_exec_args(&cmd),
vec!["php", "/workdir/harness.php"]
);
}
#[test]
fn build_container_exec_args_java() {
let cmd = vec![
"java".to_owned(),
"-cp".to_owned(),
"/tmp/nyx-harness/abc123".to_owned(),
"NyxHarness".to_owned(),
];
assert_eq!(
build_container_exec_args(&cmd),
vec!["java", "-cp", "/workdir", "NyxHarness"]
);
}
#[test]
fn build_container_exec_args_empty() {
assert!(build_container_exec_args(&[]).is_empty());
}
/// Verify that a second sandbox::run call for the same workdir does NOT
/// start a new container when one is already registered.
///

View file

@ -38,6 +38,16 @@ pub enum PinOrigin {
RustToolchainFile,
/// `Cargo.toml` `rust-version` field.
CargoToml,
/// `package.json` `engines.node` field.
PackageJson,
/// `go.mod` `go` directive.
GoMod,
/// `pom.xml` `<java.version>` / `<maven.compiler.source>`.
PomXml,
/// `build.gradle` `sourceCompatibility` / `java.toolchain.languageVersion`.
BuildGradle,
/// `composer.json` `require.php`.
ComposerJson,
/// No pin found; used the system default.
SystemDefault,
}
@ -308,6 +318,371 @@ fn map_version(version: &str, origin: PinOrigin) -> ToolchainResolution {
}
}
// ── Node.js toolchain resolver ────────────────────────────────────────────────
/// Resolve the Node.js toolchain for `project_root`.
///
/// Reads pin files in priority order:
/// `.nvmrc` > `package.json` `engines.node` > `.node-version` > default.
pub fn resolve_node(project_root: &Path) -> ToolchainResolution {
if let Some(r) = try_nvmrc(project_root) {
return r;
}
if let Some(r) = try_package_json_engines(project_root) {
return r;
}
if let Some(r) = try_node_version_file(project_root) {
return r;
}
default_node()
}
fn try_nvmrc(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join(".nvmrc")).ok()?;
let version = content.trim().trim_start_matches('v').to_owned();
if version.is_empty() {
return None;
}
Some(map_node_version(&version, PinOrigin::PackageJson))
}
fn try_package_json_engines(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join("package.json")).ok()?;
// Look for "node": ">=18" or "node": "20.x" under "engines".
let mut in_engines = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.contains("\"engines\"") {
in_engines = true;
}
if in_engines && trimmed.contains("\"node\"") {
// Extract version from: "node": ">=18" or "node": "20"
if let Some(ver) = extract_version_from_json_value(trimmed) {
return Some(map_node_version(&ver, PinOrigin::PackageJson));
}
}
if in_engines && trimmed.starts_with('}') {
in_engines = false;
}
}
None
}
fn try_node_version_file(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join(".node-version")).ok()?;
let version = content.trim().trim_start_matches('v').to_owned();
if version.is_empty() {
return None;
}
Some(map_node_version(&version, PinOrigin::PackageJson))
}
fn default_node() -> ToolchainResolution {
ToolchainResolution {
toolchain_id: "node-20".to_owned(),
pin_origin: PinOrigin::SystemDefault,
toolchain_drift: false,
version_string: "20".to_owned(),
}
}
fn map_node_version(version: &str, origin: PinOrigin) -> ToolchainResolution {
// Strip leading >= <= ~ ^ comparators.
let ver = version.trim_start_matches(|c: char| !c.is_ascii_digit());
let parts: Vec<&str> = ver.splitn(3, '.').collect();
let major = parts.first().copied().unwrap_or("20");
// Node.js LTS catalog: 18, 20, 22.
let (toolchain_id, drift) = match major.parse::<u32>() {
Ok(n) if n < 18 => (format!("node-{n}"), true),
Ok(18) => ("node-18".to_owned(), false),
Ok(20) => ("node-20".to_owned(), false),
Ok(22) => ("node-22".to_owned(), false),
Ok(n) => (format!("node-{n}"), true),
_ => ("node-20".to_owned(), true),
};
ToolchainResolution {
toolchain_id,
pin_origin: origin,
toolchain_drift: drift,
version_string: version.to_owned(),
}
}
/// Extract a version string from a JSON value like `">=18"` or `"20.x"`.
fn extract_version_from_json_value(line: &str) -> Option<String> {
// Find the second quoted value after the colon.
let after_colon = line.splitn(2, ':').nth(1)?;
let raw = after_colon.trim().trim_matches('"').trim_matches('\'');
let ver = raw.trim_start_matches(|c: char| !c.is_ascii_digit());
// Strip trailing .x or .* wildcards.
let ver = if let Some(pos) = ver.find(".x") {
&ver[..pos]
} else if let Some(pos) = ver.find(".*") {
&ver[..pos]
} else {
ver
};
if ver.is_empty() {
return None;
}
Some(ver.to_owned())
}
// ── Go toolchain resolver ─────────────────────────────────────────────────────
/// Resolve the Go toolchain for `project_root`.
///
/// Reads pin files in priority order: `go.mod` `go` directive > default.
pub fn resolve_go(project_root: &Path) -> ToolchainResolution {
if let Some(r) = try_go_mod(project_root) {
return r;
}
default_go()
}
fn try_go_mod(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join("go.mod")).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("go ") {
let version = rest.trim().to_owned();
if !version.is_empty() {
return Some(map_go_version(&version, PinOrigin::GoMod));
}
}
}
None
}
fn default_go() -> ToolchainResolution {
ToolchainResolution {
toolchain_id: "go-stable".to_owned(),
pin_origin: PinOrigin::SystemDefault,
toolchain_drift: false,
version_string: "stable".to_owned(),
}
}
fn map_go_version(version: &str, origin: PinOrigin) -> ToolchainResolution {
let parts: Vec<&str> = version.splitn(3, '.').collect();
let major = parts.first().copied().unwrap_or("1");
let minor = parts.get(1).copied();
// Go 1.21+ is the modern catalog.
let (toolchain_id, drift) = match (major, minor) {
("1", Some("21")) => ("go-1.21".to_owned(), false),
("1", Some("22")) => ("go-1.22".to_owned(), false),
("1", Some("23")) => ("go-1.23".to_owned(), false),
("1", Some(m)) if m.parse::<u32>().map_or(false, |v| v >= 24) => {
(format!("go-1.{m}"), true)
}
("1", Some(m)) if m.parse::<u32>().map_or(false, |v| v < 21) => {
(format!("go-1.{m}"), true)
}
_ => ("go-stable".to_owned(), false),
};
ToolchainResolution {
toolchain_id,
pin_origin: origin,
toolchain_drift: drift,
version_string: version.to_owned(),
}
}
// ── Java toolchain resolver ───────────────────────────────────────────────────
/// Resolve the Java toolchain for `project_root`.
///
/// Reads pin files in priority order:
/// `pom.xml` `<java.version>` / `<maven.compiler.source>` >
/// `build.gradle` `sourceCompatibility` > default.
pub fn resolve_java(project_root: &Path) -> ToolchainResolution {
if let Some(r) = try_pom_xml(project_root) {
return r;
}
if let Some(r) = try_build_gradle(project_root) {
return r;
}
default_java()
}
fn try_pom_xml(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join("pom.xml")).ok()?;
// Look for <java.version>21</java.version> or <maven.compiler.source>21</...>
for line in content.lines() {
let trimmed = line.trim();
for tag in &["<java.version>", "<maven.compiler.source>", "<maven.compiler.release>"] {
if trimmed.starts_with(tag) {
if let Some(inner) = trimmed.strip_prefix(tag) {
let version = inner.split('<').next().unwrap_or("").trim();
if !version.is_empty() {
return Some(map_java_version(version, PinOrigin::PomXml));
}
}
}
}
}
None
}
fn try_build_gradle(root: &Path) -> Option<ToolchainResolution> {
for fname in &["build.gradle", "build.gradle.kts"] {
let Ok(content) = std::fs::read_to_string(root.join(fname)) else {
continue;
};
for line in content.lines() {
let trimmed = line.trim();
// Groovy: sourceCompatibility = '21' or JavaVersion.VERSION_21
// Kotlin: sourceCompatibility = JavaVersion.VERSION_21
if trimmed.starts_with("sourceCompatibility") || trimmed.starts_with("languageVersion") {
if let Some(ver) = extract_java_version_from_gradle_line(trimmed) {
return Some(map_java_version(&ver, PinOrigin::BuildGradle));
}
}
}
}
None
}
fn extract_java_version_from_gradle_line(line: &str) -> Option<String> {
// Handle: sourceCompatibility = '21' or sourceCompatibility = 21
// and: languageVersion.set(JavaLanguageVersion.of(21))
let after_eq = line.splitn(2, '=').nth(1).unwrap_or(line);
// Try to find a number in the value.
let digits: String = after_eq.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(|c| c.is_ascii_digit())
.collect();
if digits.is_empty() {
// Try "VERSION_21" pattern.
if let Some(pos) = after_eq.find("VERSION_") {
let rest = &after_eq[pos + 8..];
let digits: String = rest.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !digits.is_empty() {
return Some(digits);
}
}
return None;
}
Some(digits)
}
fn default_java() -> ToolchainResolution {
ToolchainResolution {
toolchain_id: "java-21".to_owned(),
pin_origin: PinOrigin::SystemDefault,
toolchain_drift: false,
version_string: "21".to_owned(),
}
}
fn map_java_version(version: &str, origin: PinOrigin) -> ToolchainResolution {
// Java version: 8, 11, 17, 21, 22 are common LTS/current.
let major = version.split('.').next().unwrap_or(version);
let (toolchain_id, drift) = match major.parse::<u32>() {
Ok(8) => ("java-8".to_owned(), false),
Ok(11) => ("java-11".to_owned(), false),
Ok(17) => ("java-17".to_owned(), false),
Ok(21) => ("java-21".to_owned(), false),
Ok(n) => (format!("java-{n}"), true),
_ => ("java-21".to_owned(), true),
};
ToolchainResolution {
toolchain_id,
pin_origin: origin,
toolchain_drift: drift,
version_string: version.to_owned(),
}
}
// ── PHP toolchain resolver ────────────────────────────────────────────────────
/// Resolve the PHP toolchain for `project_root`.
///
/// Reads pin files in priority order:
/// `composer.json` `require.php` > `.php-version` > default.
pub fn resolve_php(project_root: &Path) -> ToolchainResolution {
if let Some(r) = try_composer_json(project_root) {
return r;
}
if let Some(r) = try_php_version_file(project_root) {
return r;
}
default_php()
}
fn try_composer_json(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join("composer.json")).ok()?;
// Look for "php": ">=8.1" under "require".
let mut in_require = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.contains("\"require\"") {
in_require = true;
}
if in_require && trimmed.contains("\"php\"") {
if let Some(ver) = extract_version_from_json_value(trimmed) {
return Some(map_php_version(&ver, PinOrigin::ComposerJson));
}
}
// Stop at closing brace of require block.
if in_require && trimmed == "}," || (in_require && trimmed == "}") {
in_require = false;
}
}
None
}
fn try_php_version_file(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join(".php-version")).ok()?;
let version = content.trim().to_owned();
if version.is_empty() {
return None;
}
Some(map_php_version(&version, PinOrigin::ComposerJson))
}
fn default_php() -> ToolchainResolution {
ToolchainResolution {
toolchain_id: "php-8".to_owned(),
pin_origin: PinOrigin::SystemDefault,
toolchain_drift: false,
version_string: "8".to_owned(),
}
}
fn map_php_version(version: &str, origin: PinOrigin) -> ToolchainResolution {
let ver = version.trim_start_matches(|c: char| !c.is_ascii_digit());
let parts: Vec<&str> = ver.splitn(3, '.').collect();
let major = parts.first().copied().unwrap_or("8");
let minor = parts.get(1).copied();
let (toolchain_id, drift) = match (major.parse::<u32>(), minor) {
(Ok(8), Some("0")) => ("php-8.0".to_owned(), false),
(Ok(8), Some("1")) => ("php-8.1".to_owned(), false),
(Ok(8), Some("2")) => ("php-8.2".to_owned(), false),
(Ok(8), Some("3")) => ("php-8.3".to_owned(), false),
(Ok(8), None) => ("php-8".to_owned(), false),
(Ok(7), _) => ("php-7".to_owned(), true),
(Ok(n), _) => (format!("php-{n}"), true),
_ => ("php-8".to_owned(), true),
};
ToolchainResolution {
toolchain_id,
pin_origin: origin,
toolchain_drift: drift,
version_string: version.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -401,4 +776,112 @@ mod tests {
assert_eq!(r.toolchain_id, "rust-stable");
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
}
// ── Node.js resolver tests ────────────────────────────────────────────────
#[test]
fn node_nvmrc_exact() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(".nvmrc"), "v20.5.0\n").unwrap();
let r = resolve_node(dir.path());
assert_eq!(r.toolchain_id, "node-20");
assert!(!r.toolchain_drift);
assert_eq!(r.pin_origin, PinOrigin::PackageJson);
}
#[test]
fn node_package_json_engines() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"engines": {"node": ">=18.0.0"}}"#,
).unwrap();
let r = resolve_node(dir.path());
assert_eq!(r.toolchain_id, "node-18");
}
#[test]
fn node_default_is_20() {
let dir = TempDir::new().unwrap();
let r = resolve_node(dir.path());
assert_eq!(r.toolchain_id, "node-20");
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
}
// ── Go resolver tests ─────────────────────────────────────────────────────
#[test]
fn go_mod_version() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("go.mod"), "module example.com/app\n\ngo 1.22\n").unwrap();
let r = resolve_go(dir.path());
assert_eq!(r.toolchain_id, "go-1.22");
assert!(!r.toolchain_drift);
assert_eq!(r.pin_origin, PinOrigin::GoMod);
}
#[test]
fn go_default_is_stable() {
let dir = TempDir::new().unwrap();
let r = resolve_go(dir.path());
assert_eq!(r.toolchain_id, "go-stable");
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
}
// ── Java resolver tests ───────────────────────────────────────────────────
#[test]
fn java_pom_xml_version() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("pom.xml"),
"<project>\n <properties>\n <java.version>21</java.version>\n </properties>\n</project>",
).unwrap();
let r = resolve_java(dir.path());
assert_eq!(r.toolchain_id, "java-21");
assert!(!r.toolchain_drift);
assert_eq!(r.pin_origin, PinOrigin::PomXml);
}
#[test]
fn java_build_gradle_source_compat() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("build.gradle"),
"sourceCompatibility = '17'\ntargetCompatibility = '17'\n",
).unwrap();
let r = resolve_java(dir.path());
assert_eq!(r.toolchain_id, "java-17");
assert_eq!(r.pin_origin, PinOrigin::BuildGradle);
}
#[test]
fn java_default_is_21() {
let dir = TempDir::new().unwrap();
let r = resolve_java(dir.path());
assert_eq!(r.toolchain_id, "java-21");
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
}
// ── PHP resolver tests ────────────────────────────────────────────────────
#[test]
fn php_composer_json_version() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("composer.json"),
r#"{"require": {"php": ">=8.1"}}"#,
).unwrap();
let r = resolve_php(dir.path());
assert_eq!(r.toolchain_id, "php-8.1");
assert_eq!(r.pin_origin, PinOrigin::ComposerJson);
}
#[test]
fn php_default_is_8() {
let dir = TempDir::new().unwrap();
let r = resolve_php(dir.path());
assert_eq!(r.toolchain_id, "php-8");
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
}
}

View file

@ -98,6 +98,10 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
use crate::symbol::Lang;
let toolchain_res = match spec.lang {
Lang::Rust => toolchain::resolve_rust(Path::new(".")),
Lang::JavaScript | Lang::TypeScript => toolchain::resolve_node(Path::new(".")),
Lang::Go => toolchain::resolve_go(Path::new(".")),
Lang::Java => toolchain::resolve_java(Path::new(".")),
Lang::Php => toolchain::resolve_php(Path::new(".")),
_ => toolchain::resolve_python(Path::new(".")),
};
let toolchain_match = if toolchain_res.toolchain_drift { "drift" } else { "exact" };

View file

@ -0,0 +1,10 @@
{
"name": "nyx/escape-composer-postinstall",
"description": "Malicious Composer post-install-cmd escape fixture.",
"require": {},
"scripts": {
"post-install-cmd": [
"echo NYX_ESCAPE_SUCCESS > /tmp/pwned_composer_postinstall"
]
}
}

View file

@ -0,0 +1,16 @@
// Malicious Go init() escape fixture.
//
// init() runs automatically before the entry point when the binary starts.
// Expected: Docker sandbox prevents the write from reaching the host filesystem.
// Host marker: /tmp/pwned_go_init
// Expected verdict: marker absent on host after sandbox run.
package entry
import "os"
func init() {
// Escape attempt: write a marker file to a path outside the workdir.
_ = os.WriteFile("/tmp/pwned_go_init", []byte("NYX_ESCAPE_SUCCESS\n"), 0644)
}
func Login(username string) {}

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Malicious Maven plugin escape fixture.
The exec-maven-plugin runs a shell command during the validate phase of mvn compile.
Expected: Docker-isolated Maven build prevents the write from reaching the host.
Host marker: /tmp/pwned_maven_plugin
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.nyx.escape</groupId>
<artifactId>malicious-plugin-test</artifactId>
<version>1.0.0</version>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>escape-attempt</id>
<phase>validate</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>sh</executable>
<arguments>
<argument>-c</argument>
<argument>echo NYX_ESCAPE_SUCCESS > /tmp/pwned_maven_plugin</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View file

@ -0,0 +1,8 @@
{
"name": "nyx-escape-npm-lifecycle",
"version": "1.0.0",
"description": "Malicious npm lifecycle escape fixture — preinstall runs during npm install.",
"scripts": {
"preinstall": "echo NYX_ESCAPE_SUCCESS > /tmp/pwned_npm_lifecycle"
}
}

View file

@ -0,0 +1,15 @@
// Command injection — adversarial collision fixture.
// Prints NYX_PWN_CMDI unconditionally without reaching a command sink
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: RunPing(host string) Cap: CODE_EXEC
package entry
import "fmt"
func RunPing(host string) {
// Coincidental oracle match — not a shell sink.
fmt.Println("NYX_PWN_CMDI")
_ = len(host)
}

View file

@ -0,0 +1,18 @@
// Command injection — negative fixture.
// Safe: passes host as a separate arg to exec.Command (no shell invoked).
// Entry: RunPing(host string) Cap: CODE_EXEC
// Expected verdict: NotConfirmed
package entry
import (
"fmt"
"os/exec"
)
func RunPing(host string) {
// exec.Command does not invoke a shell; host is a literal argument.
cmd := exec.Command("echo", "hello", host)
out, _ := cmd.CombinedOutput()
fmt.Print(string(out))
}

View file

@ -0,0 +1,18 @@
// Command injection — positive fixture.
// Vulnerable: passes user input to /bin/sh -c.
// Entry: RunPing(host string) Cap: CODE_EXEC
// Expected verdict: Confirmed ("; echo NYX_PWN_CMDI" echoes the marker)
package entry
import (
"fmt"
"os/exec"
)
func RunPing(host string) {
fmt.Print("__NYX_SINK_HIT__\n")
cmd := exec.Command("sh", "-c", "echo hello "+host)
out, _ := cmd.CombinedOutput()
fmt.Print(string(out))
}

View file

@ -0,0 +1,15 @@
// Command injection — unsupported fixture.
// Entry is a method on a struct.
// Test sets confidence = Low to get Unsupported(ConfidenceTooLow).
// Entry: Runner.Execute Cap: CODE_EXEC
// Expected verdict: Unsupported
package entry
import "os/exec"
type Runner struct{}
func (r *Runner) Execute(cmd string) {
exec.Command("sh", "-c", cmd).Run()
}

View file

@ -0,0 +1,15 @@
// File I/O — adversarial collision fixture.
// Prints "root:" unconditionally without reading any file
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: ReadFile(userPath string) Cap: FILE_IO
package entry
import "fmt"
func ReadFile(userPath string) {
// Coincidental oracle match — not a file read sink.
fmt.Println("root: present")
_ = len(userPath)
}

View file

@ -0,0 +1,34 @@
// File I/O — negative fixture.
// Safe: path is resolved and validated against base directory.
// Entry: ReadFile(userPath string) Cap: FILE_IO
// Expected verdict: NotConfirmed
package entry
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const baseDir = "/var/data"
func ReadFile(userPath string) {
resolved, err := filepath.Abs(filepath.Join(baseDir, userPath))
if err != nil || !strings.HasPrefix(resolved, baseDir+string(filepath.Separator)) {
fmt.Println("Access denied")
return
}
data, err := os.ReadFile(resolved)
if err == nil {
fmt.Print(string(data[:min(len(data), 100)]))
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,21 @@
// File I/O — positive fixture.
// Vulnerable: reads file at user-controlled path without sanitization.
// Entry: ReadFile(userPath string) Cap: FILE_IO
// Expected verdict: Confirmed (../../../../etc/passwd → "root:" in output)
package entry
import (
"fmt"
"os"
"path/filepath"
)
func ReadFile(userPath string) {
filePath := filepath.Join("/var/data", userPath)
fmt.Print("__NYX_SINK_HIT__\n")
data, err := os.ReadFile(filePath)
if err == nil {
fmt.Print(string(data))
}
}

View file

@ -0,0 +1,21 @@
// File I/O — unsupported fixture.
// Entry is a method on a struct.
// Test sets confidence = Low to get Unsupported(ConfidenceTooLow).
// Entry: FileServer.Serve Cap: FILE_IO
// Expected verdict: Unsupported
package entry
import (
"fmt"
"os"
)
type FileServer struct{ BaseDir string }
func (s *FileServer) Serve(path string) {
data, err := os.ReadFile(s.BaseDir + "/" + path)
if err == nil {
fmt.Print(string(data))
}
}

View file

@ -0,0 +1,15 @@
// SQL injection — adversarial collision fixture.
// Prints NYX_SQL_CONFIRMED unconditionally without reaching a SQL sink
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: Login(username string) Cap: SQL_QUERY
package entry
import "fmt"
func Login(username string) {
// Coincidental oracle match — not a SQL sink.
fmt.Println("NYX_SQL_CONFIRMED")
_ = len(username)
}

View file

@ -0,0 +1,14 @@
// SQL injection — negative fixture.
// Safe: uses a parameterized query; payload is a bound argument, not concatenated.
// Entry: Login(username string) Cap: SQL_QUERY
// Expected verdict: NotConfirmed
package entry
import "fmt"
func Login(username string) {
template := "SELECT name FROM users WHERE name = ?"
// Simulate parameterized execution: template is fixed.
fmt.Println("Executing:", template, "with param length:", len(username))
}

View file

@ -0,0 +1,15 @@
// SQL injection — positive fixture.
// Vulnerable: directly concatenates user input into SQL query string.
// Entry: Login(username string) Cap: SQL_QUERY
// Expected verdict: Confirmed (UNION payload echoes NYX_SQL_CONFIRMED)
package entry
import "fmt"
func Login(username string) {
query := "SELECT name FROM users WHERE name='" + username + "'"
fmt.Print("__NYX_SINK_HIT__\n")
// Error-based echo: output the query so UNION payload is visible.
fmt.Print("DB query: " + query + "\n")
}

View file

@ -0,0 +1,16 @@
// SQL injection — unsupported fixture.
// Entry is a method on a struct — entry kind unsupported (only Function supported).
// Test sets confidence = Low to get Unsupported(ConfidenceTooLow).
// Entry: UserRepo.FindUser Cap: SQL_QUERY
// Expected verdict: Unsupported
package entry
import "fmt"
type UserRepo struct{}
func (r *UserRepo) FindUser(name string) {
query := "SELECT * FROM users WHERE name='" + name + "'"
fmt.Println(query)
}

View file

@ -0,0 +1,15 @@
// SSRF — adversarial collision fixture.
// Prints "daemon:" unconditionally without making any HTTP request
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: FetchURL(targetURL string) Cap: SSRF
package entry
import "fmt"
func FetchURL(targetURL string) {
// Coincidental oracle match — not an HTTP sink.
fmt.Println("daemon: present")
_ = len(targetURL)
}

View file

@ -0,0 +1,34 @@
// SSRF — negative fixture.
// Safe: only allows http/https scheme; file:// rejected.
// Entry: FetchURL(targetURL string) Cap: SSRF
// Expected verdict: NotConfirmed
package entry
import (
"fmt"
"io"
"net/http"
"net/url"
)
func FetchURL(targetURL string) {
parsed, err := url.Parse(targetURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
fmt.Println("Scheme not allowed:", parsed.Scheme)
return
}
resp, err := http.Get(targetURL)
if err == nil {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Print(string(body[:min(len(body), 64)]))
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,33 @@
// SSRF — positive fixture.
// Vulnerable: makes a request to a user-controlled URL.
// Entry: FetchURL(targetURL string) Cap: SSRF
// Expected verdict: Confirmed (file:///etc/passwd → "daemon:" in output)
// Note: Go http.Get does not support file:// scheme; we detect it and use os.ReadFile.
package entry
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func FetchURL(targetURL string) {
fmt.Print("__NYX_SINK_HIT__\n")
if strings.HasPrefix(targetURL, "file://") {
path := strings.TrimPrefix(targetURL, "file://")
data, err := os.ReadFile(path)
if err == nil {
fmt.Print(string(data))
}
return
}
resp, err := http.Get(targetURL)
if err == nil {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Print(string(body))
}
}

View file

@ -0,0 +1,20 @@
// SSRF — unsupported fixture.
// Entry is a method on a struct; test sets confidence = Low.
// Expected verdict: Unsupported
package entry
import (
"io"
"net/http"
)
type HTTPClient struct{}
func (c *HTTPClient) Fetch(targetURL string) {
resp, err := http.Get(targetURL)
if err == nil {
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
}
}

View file

@ -0,0 +1,15 @@
// XSS — adversarial collision fixture.
// Prints the XSS oracle marker unconditionally without rendering any template
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: RenderPage(userInput string) Cap: HTML_ESCAPE
package entry
import "fmt"
func RenderPage(userInput string) {
// Coincidental oracle match — not an HTML render sink.
fmt.Println("<script>NYX_XSS_CONFIRMED</script>")
_ = len(userInput)
}

View file

@ -0,0 +1,16 @@
// XSS — negative fixture.
// Safe: uses html.EscapeString before output.
// Entry: RenderPage(userInput string) Cap: HTML_ESCAPE
// Expected verdict: NotConfirmed
package entry
import (
"fmt"
"html"
)
func RenderPage(userInput string) {
safe := html.EscapeString(userInput)
fmt.Print("<html><body>" + safe + "</body></html>\n")
}

View file

@ -0,0 +1,13 @@
// XSS — positive fixture.
// Vulnerable: echoes raw user input into HTML output without escaping.
// Entry: RenderPage(userInput string) Cap: HTML_ESCAPE
// Expected verdict: Confirmed (<script>NYX_XSS_CONFIRMED</script> echoed)
package entry
import "fmt"
func RenderPage(userInput string) {
fmt.Print("__NYX_SINK_HIT__\n")
fmt.Print("<html><body>" + userInput + "</body></html>\n")
}

View file

@ -0,0 +1,13 @@
// XSS — unsupported fixture.
// Entry is a method on a struct; test sets confidence = Low.
// Expected verdict: Unsupported
package entry
import "fmt"
type Renderer struct{}
func (r *Renderer) Render(input string) {
fmt.Print("<html><body>" + input + "</body></html>\n")
}

View file

@ -0,0 +1,13 @@
// Command injection adversarial collision fixture.
// Prints NYX_PWN_CMDI unconditionally without reaching a command sink
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: Entry.runPing(String) Cap: CODE_EXEC
public class Entry {
public static void runPing(String host) {
// Coincidental oracle match not a shell sink.
System.out.println("NYX_PWN_CMDI");
int x = host.length();
}
}

View file

@ -0,0 +1,20 @@
// Command injection negative fixture.
// Safe: exec with args array; no shell; semicolons are inert.
// Entry: Entry.runPing(String) Cap: CODE_EXEC
// Expected verdict: NotConfirmed
import java.io.*;
public class Entry {
public static void runPing(String host) throws Exception {
// Array form: each element is a literal argument no shell expansion.
String[] cmd = {"echo", "hello", host};
Process p = Runtime.getRuntime().exec(cmd);
BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
p.waitFor();
}
}

View file

@ -0,0 +1,20 @@
// Command injection positive fixture.
// Vulnerable: passes user input to /bin/sh -c via Runtime.exec.
// Entry: Entry.runPing(String) Cap: CODE_EXEC
// Expected verdict: Confirmed ("; echo NYX_PWN_CMDI" echoes the marker)
import java.io.*;
public class Entry {
public static void runPing(String host) throws Exception {
System.out.print("__NYX_SINK_HIT__\n");
String[] cmd = {"/bin/sh", "-c", "echo hello " + host};
Process p = Runtime.getRuntime().exec(cmd);
BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
p.waitFor();
}
}

View file

@ -0,0 +1,11 @@
// Command injection unsupported fixture.
// Entry is an instance method; test sets confidence = Low.
// Expected verdict: Unsupported
import java.io.*;
public class Entry {
public void execute(String cmd) throws Exception {
Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", cmd});
}
}

View file

@ -0,0 +1,13 @@
// File I/O adversarial collision fixture.
// Prints "root:" unconditionally without reading any file
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: Entry.readFile(String) Cap: FILE_IO
public class Entry {
public static void readFile(String userPath) {
// Coincidental oracle match not a file read sink.
System.out.println("root: present");
int x = userPath.length();
}
}

View file

@ -0,0 +1,27 @@
// File I/O negative fixture.
// Safe: normalizes path and checks it stays within the base directory.
// Entry: Entry.readFile(String) Cap: FILE_IO
// Expected verdict: NotConfirmed
import java.io.*;
import java.nio.file.*;
public class Entry {
private static final String BASE_DIR = "/var/data";
public static void readFile(String userPath) throws Exception {
Path base = Paths.get(BASE_DIR).toRealPath();
Path resolved = base.resolve(userPath).normalize();
if (!resolved.startsWith(base)) {
System.out.println("Access denied");
return;
}
try {
byte[] data = Files.readAllBytes(resolved);
int len = Math.min(data.length, 100);
System.out.write(data, 0, len);
} catch (IOException e) {
System.out.println("File not found");
}
}
}

View file

@ -0,0 +1,20 @@
// File I/O positive fixture.
// Vulnerable: reads file at user-controlled path without sanitization.
// Entry: Entry.readFile(String) Cap: FILE_IO
// Expected verdict: Confirmed (../../../../etc/passwd "root:" in output)
import java.io.*;
import java.nio.file.*;
public class Entry {
public static void readFile(String userPath) throws Exception {
Path filePath = Paths.get("/var/data", userPath);
System.out.print("__NYX_SINK_HIT__\n");
try {
String content = new String(Files.readAllBytes(filePath));
System.out.print(content);
} catch (IOException e) {
// silent
}
}
}

View file

@ -0,0 +1,13 @@
// File I/O unsupported fixture.
// Entry is an instance method; test sets confidence = Low.
// Expected verdict: Unsupported
import java.io.*;
import java.nio.file.*;
public class Entry {
public void serve(String path) throws Exception {
byte[] data = Files.readAllBytes(Paths.get(path));
System.out.write(data);
}
}

View file

@ -0,0 +1,13 @@
// SQL injection adversarial collision fixture.
// Prints NYX_SQL_CONFIRMED unconditionally without reaching a SQL sink
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: Entry.login(String) Cap: SQL_QUERY
public class Entry {
public static void login(String username) {
// Coincidental oracle match not a SQL sink.
System.out.println("NYX_SQL_CONFIRMED");
int x = username.length();
}
}

View file

@ -0,0 +1,12 @@
// SQL injection negative fixture.
// Safe: uses a parameterized query; payload is a bound argument.
// Entry: Entry.login(String) Cap: SQL_QUERY
// Expected verdict: NotConfirmed
public class Entry {
public static void login(String username) {
String template = "SELECT name FROM users WHERE name = ?";
// Simulate parameterized execution: template is fixed.
System.out.println("Executing: " + template + " param-len=" + username.length());
}
}

View file

@ -0,0 +1,13 @@
// SQL injection positive fixture.
// Vulnerable: directly concatenates user input into SQL query string.
// Entry: Entry.login(String) Cap: SQL_QUERY
// Expected verdict: Confirmed (UNION payload echoes NYX_SQL_CONFIRMED)
public class Entry {
public static void login(String username) {
String query = "SELECT name FROM users WHERE name='" + username + "'";
System.out.print("__NYX_SINK_HIT__\n");
// Error-based echo: output the query so UNION payload is visible.
System.out.println("DB query: " + query);
}
}

View file

@ -0,0 +1,11 @@
// SQL injection unsupported fixture.
// Entry is an instance method rather than a static method.
// Test sets confidence = Low to get Unsupported(ConfidenceTooLow).
// Expected verdict: Unsupported
public class Entry {
public void findUser(String name) {
String query = "SELECT * FROM users WHERE name='" + name + "'";
System.out.println(query);
}
}

View file

@ -0,0 +1,13 @@
// SSRF adversarial collision fixture.
// Prints "daemon:" unconditionally without making any HTTP request
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: Entry.fetchUrl(String) Cap: SSRF
public class Entry {
public static void fetchUrl(String targetUrl) {
// Coincidental oracle match not an HTTP sink.
System.out.println("daemon: present");
int x = targetUrl.length();
}
}

View file

@ -0,0 +1,27 @@
// SSRF negative fixture.
// Safe: only allows http/https scheme; file:// rejected.
// Entry: Entry.fetchUrl(String) Cap: SSRF
// Expected verdict: NotConfirmed
import java.io.*;
import java.net.*;
public class Entry {
public static void fetchUrl(String targetUrl) throws Exception {
URL url = new URL(targetUrl);
String proto = url.getProtocol();
if (!proto.equals("http") && !proto.equals("https")) {
System.out.println("Scheme not allowed: " + proto);
return;
}
try (InputStream in = url.openStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line.substring(0, Math.min(line.length(), 64)));
}
} catch (Exception e) {
System.out.println("Connection error");
}
}
}

View file

@ -0,0 +1,24 @@
// SSRF positive fixture.
// Vulnerable: makes a request to a user-controlled URL.
// Entry: Entry.fetchUrl(String) Cap: SSRF
// Expected verdict: Confirmed (file:///etc/passwd "daemon:" in output)
// Note: Java URL supports file:// scheme natively.
import java.io.*;
import java.net.*;
public class Entry {
public static void fetchUrl(String targetUrl) throws Exception {
System.out.print("__NYX_SINK_HIT__\n");
URL url = new URL(targetUrl);
try (InputStream in = url.openStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (Exception e) {
// silent
}
}
}

View file

@ -0,0 +1,12 @@
// SSRF unsupported fixture.
// Entry is an instance method; test sets confidence = Low.
// Expected verdict: Unsupported
import java.io.*;
import java.net.*;
public class Entry {
public void fetch(String url) throws Exception {
new URL(url).openStream().close();
}
}

View file

@ -0,0 +1,13 @@
// XSS adversarial collision fixture.
// Prints the XSS oracle marker unconditionally without rendering any template
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: Entry.renderPage(String) Cap: HTML_ESCAPE
public class Entry {
public static void renderPage(String userInput) {
// Coincidental oracle match not an HTML render sink.
System.out.println("<script>NYX_XSS_CONFIRMED</script>");
int x = userInput.length();
}
}

View file

@ -0,0 +1,19 @@
// XSS negative fixture.
// Safe: HTML-encodes special characters before output.
// Entry: Entry.renderPage(String) Cap: HTML_ESCAPE
// Expected verdict: NotConfirmed
public class Entry {
private static String escapeHtml(String s) {
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
public static void renderPage(String userInput) {
String safe = escapeHtml(userInput);
System.out.print("<html><body>" + safe + "</body></html>\n");
}
}

View file

@ -0,0 +1,11 @@
// XSS positive fixture.
// Vulnerable: echoes raw user input into HTML output without escaping.
// Entry: Entry.renderPage(String) Cap: HTML_ESCAPE
// Expected verdict: Confirmed (<script>NYX_XSS_CONFIRMED</script> echoed)
public class Entry {
public static void renderPage(String userInput) {
System.out.print("__NYX_SINK_HIT__\n");
System.out.print("<html><body>" + userInput + "</body></html>\n");
}
}

View file

@ -0,0 +1,9 @@
// XSS unsupported fixture.
// Entry is an instance method; test sets confidence = Low.
// Expected verdict: Unsupported
public class Entry {
public void render(String input) {
System.out.print("<html><body>" + input + "</body></html>\n");
}
}

View file

@ -0,0 +1,13 @@
// Command injection — adversarial collision fixture.
// Prints NYX_PWN_CMDI unconditionally without reaching a command sink
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: runPing(host) Cap: CODE_EXEC
function runPing(host) {
// Coincidental oracle match — not a shell sink.
process.stdout.write('NYX_PWN_CMDI\n');
void host.length;
}
module.exports = { runPing };

View file

@ -0,0 +1,18 @@
// Command injection — negative fixture.
// Safe: uses execFile (no shell) with args array; shell metacharacters ignored.
// Entry: runPing(host) Cap: CODE_EXEC
// Expected verdict: NotConfirmed
const { execFileSync } = require('child_process');
function runPing(host) {
// execFile does not invoke a shell — semicolons and metacharacters are inert.
try {
const out = execFileSync('echo', ['hello', host], { encoding: 'utf8', timeout: 5000 });
process.stdout.write(out);
} catch (e) {
process.stdout.write('error\n');
}
}
module.exports = { runPing };

View file

@ -0,0 +1,18 @@
// Command injection — positive fixture.
// Vulnerable: passes user input directly to shell via execSync.
// Entry: runPing(host) Cap: CODE_EXEC
// Expected verdict: Confirmed ("; echo NYX_PWN_CMDI" payload echoes marker)
const { execSync } = require('child_process');
function runPing(host) {
process.stdout.write('__NYX_SINK_HIT__\n');
try {
const out = execSync('echo hello ' + host, { encoding: 'utf8', timeout: 5000 });
process.stdout.write(out);
} catch (e) {
process.stdout.write((e.stdout || '') + (e.stderr || ''));
}
}
module.exports = { runPing };

View file

@ -0,0 +1,17 @@
// Command injection — unsupported fixture.
// Entry expects a pre-parsed args array, not a string payload.
// Test sets confidence = Low to get Unsupported(ConfidenceTooLow).
// Entry: runCommand(args) Cap: CODE_EXEC
// Expected verdict: Unsupported
const { execFileSync } = require('child_process');
function runCommand(args) {
// args is expected to be an array; a string payload can't be routed here.
if (!Array.isArray(args) || args.length === 0) {
return;
}
execFileSync(args[0], args.slice(1), { encoding: 'utf8', timeout: 5000 });
}
module.exports = { runCommand };

View file

@ -0,0 +1,13 @@
// File I/O — adversarial collision fixture.
// Prints "root:" unconditionally without reading any file
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: readFile(userPath) Cap: FILE_IO
function readFile(userPath) {
// Coincidental oracle match — not a file read sink.
process.stdout.write('root: present\n');
void userPath.length;
}
module.exports = { readFile };

View file

@ -0,0 +1,25 @@
// File I/O — negative fixture.
// Safe: path is normalized and validated against an allowlist prefix.
// Entry: readFile(userPath) Cap: FILE_IO
// Expected verdict: NotConfirmed
const fs = require('fs');
const path = require('path');
const BASE_DIR = '/var/data';
function readFile(userPath) {
const resolved = path.resolve(BASE_DIR, userPath);
if (!resolved.startsWith(BASE_DIR + path.sep) && resolved !== BASE_DIR) {
process.stdout.write('Access denied\n');
return;
}
try {
const content = fs.readFileSync(resolved, 'utf8');
process.stdout.write(content.substring(0, 100));
} catch (e) {
process.stdout.write('File not found\n');
}
}
module.exports = { readFile };

View file

@ -0,0 +1,20 @@
// File I/O — positive fixture.
// Vulnerable: reads a file at a user-controlled path without sanitization.
// Entry: readFile(userPath) Cap: FILE_IO
// Expected verdict: Confirmed (../../../../etc/passwd → "root:" in output)
const fs = require('fs');
const path = require('path');
function readFile(userPath) {
const filePath = path.join('/var/data', userPath);
process.stdout.write('__NYX_SINK_HIT__\n');
try {
const content = fs.readFileSync(filePath, 'utf8');
process.stdout.write(content);
} catch (e) {
// Silent on error — target file may not exist in all envs
}
}
module.exports = { readFile };

View file

@ -0,0 +1,20 @@
// File I/O — unsupported fixture.
// Entry takes a Buffer (binary), not a UTF-8 string payload.
// Test sets confidence = Low to get Unsupported(ConfidenceTooLow).
// Entry: processUpload(buf) Cap: FILE_IO
// Expected verdict: Unsupported
const fs = require('fs');
function processUpload(buf) {
if (!Buffer.isBuffer(buf)) {
return;
}
const tmpPath = '/tmp/upload_' + Date.now();
fs.writeFileSync(tmpPath, buf);
const content = fs.readFileSync(tmpPath, 'utf8');
process.stdout.write(content.substring(0, 64));
fs.unlinkSync(tmpPath);
}
module.exports = { processUpload };

View file

@ -0,0 +1,14 @@
// SQL injection — adversarial collision fixture.
// Prints NYX_SQL_CONFIRMED unconditionally without reaching a SQL sink
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// oracle_fired = true, sink_hit = false
// Entry: login(username) Cap: SQL_QUERY
function login(username) {
// Coincidental oracle match — not a SQL sink.
process.stdout.write('NYX_SQL_CONFIRMED\n');
void username.length;
}
module.exports = { login };

View file

@ -0,0 +1,14 @@
// SQL injection — negative fixture.
// Safe: uses a parameterized query pattern; payload never concatenated.
// Entry: login(username) Cap: SQL_QUERY
// Expected verdict: NotConfirmed
function login(username) {
// Parameterized: the query template is fixed, payload is a bound param.
const template = 'SELECT name FROM users WHERE name = ?';
// Simulate param binding — payload is never embedded in the query string.
const safeQuery = template; // template unchanged regardless of username
process.stdout.write('Query executed with param: ' + safeQuery + '\n');
}
module.exports = { login };

View file

@ -0,0 +1,13 @@
// SQL injection — positive fixture.
// Vulnerable: directly concatenates user input into SQL query string.
// Entry: login(username) Cap: SQL_QUERY
// Expected verdict: Confirmed (UNION payload echoes NYX_SQL_CONFIRMED)
function login(username) {
const query = "SELECT name FROM users WHERE name='" + username + "'";
process.stdout.write('__NYX_SINK_HIT__\n');
// Simulate error-based SQL execution: echo query on failure.
process.stdout.write('DB query: ' + query + '\n');
}
module.exports = { login };

View file

@ -0,0 +1,15 @@
// SQL injection — unsupported fixture.
// The entry point accepts an HTTP request object, not a plain string.
// HarnessSpec derivation will map this to HttpBody slot (unsupported by JS emitter).
// Entry: handleRequest(req) Cap: SQL_QUERY
// Expected verdict: Unsupported (EntryKindUnsupported or ConfidenceTooLow)
class UserRepository {
findUser(req) {
const name = req && req.body && req.body.name;
const query = "SELECT * FROM users WHERE name='" + name + "'";
return query;
}
}
module.exports = { UserRepository };

View file

@ -0,0 +1,13 @@
// SSRF — adversarial collision fixture.
// Prints "daemon:" unconditionally without making any HTTP request
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: fetchUrl(targetUrl) Cap: SSRF
function fetchUrl(targetUrl) {
// Coincidental oracle match — not an HTTP sink.
process.stdout.write('daemon: present\n');
void targetUrl.length;
}
module.exports = { fetchUrl };

View file

@ -0,0 +1,24 @@
// SSRF — negative fixture.
// Safe: only allows http/https scheme; file:// and other schemes are rejected.
// Entry: fetchUrl(targetUrl) Cap: SSRF
// Expected verdict: NotConfirmed
const http = require('http');
const https = require('https');
const url = require('url');
function fetchUrl(targetUrl) {
const parsed = url.parse(targetUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
process.stdout.write('Scheme not allowed: ' + parsed.protocol + '\n');
return;
}
const mod = parsed.protocol === 'https:' ? https : http;
const req = mod.get(targetUrl, (res) => {
res.on('data', (chunk) => process.stdout.write(chunk.toString().substring(0, 64)));
});
req.on('error', () => {});
req.end();
}
module.exports = { fetchUrl };

View file

@ -0,0 +1,35 @@
// SSRF — positive fixture.
// Vulnerable: makes a request to a user-controlled URL.
// Entry: fetch(url) Cap: SSRF
// Expected verdict: Confirmed (file:///etc/passwd → "daemon:" in output)
// Note: Node.js http/https module does not support file:// scheme.
// We detect the file:// prefix and use fs.readFile directly to simulate
// the SSRF behaviour (same oracle: reads /etc/passwd, outputs "daemon:").
const fs = require('fs');
const http = require('http');
const https = require('https');
const url = require('url');
function fetchUrl(targetUrl) {
process.stdout.write('__NYX_SINK_HIT__\n');
const parsed = url.parse(targetUrl);
if (parsed.protocol === 'file:') {
// Simulate SSRF via file:// — read local file (oracle expects "daemon:")
try {
const content = fs.readFileSync(parsed.pathname || '/', 'utf8');
process.stdout.write(content);
} catch (e) {
// silent
}
return;
}
const mod = parsed.protocol === 'https:' ? https : http;
const req = mod.get(targetUrl, (res) => {
res.on('data', (chunk) => process.stdout.write(chunk));
});
req.on('error', () => {});
req.end();
}
module.exports = { fetchUrl };

View file

@ -0,0 +1,20 @@
// SSRF — unsupported fixture.
// Entry takes an already-parsed URL object, not a string.
// Test sets confidence = Low to get Unsupported(ConfidenceTooLow).
// Entry: fetchParsed(parsedUrl) Cap: SSRF
// Expected verdict: Unsupported
const http = require('http');
function fetchParsed(parsedUrl) {
if (!parsedUrl || typeof parsedUrl !== 'object') {
return;
}
const req = http.get(parsedUrl, (res) => {
res.on('data', (d) => process.stdout.write(d));
});
req.on('error', () => {});
req.end();
}
module.exports = { fetchParsed };

View file

@ -0,0 +1,13 @@
// XSS — adversarial collision fixture.
// Prints the XSS oracle marker unconditionally without rendering any template
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: renderPage(userInput) Cap: HTML_ESCAPE
function renderPage(userInput) {
// Coincidental oracle match — not an HTML render sink.
process.stdout.write('<script>NYX_XSS_CONFIRMED</script>\n');
void userInput.length;
}
module.exports = { renderPage };

View file

@ -0,0 +1,20 @@
// XSS — negative fixture.
// Safe: HTML-escapes all special characters before output.
// Entry: renderPage(userInput) Cap: HTML_ESCAPE
// Expected verdict: NotConfirmed
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderPage(userInput) {
const safe = escapeHtml(userInput);
process.stdout.write('<html><body>' + safe + '</body></html>\n');
}
module.exports = { renderPage };

View file

@ -0,0 +1,12 @@
// XSS — positive fixture.
// Vulnerable: echoes raw user input into HTML output without escaping.
// Entry: renderPage(userInput) Cap: HTML_ESCAPE
// Expected verdict: Confirmed (<script>NYX_XSS_CONFIRMED</script> echoed)
function renderPage(userInput) {
process.stdout.write('__NYX_SINK_HIT__\n');
// Unescaped output — script tags pass through verbatim.
process.stdout.write('<html><body>' + userInput + '</body></html>\n');
}
module.exports = { renderPage };

View file

@ -0,0 +1,13 @@
// XSS — unsupported fixture.
// Entry is a class method rather than a top-level function.
// Test sets confidence = Low to get Unsupported(ConfidenceTooLow).
// Entry: TemplateEngine.render(input) Cap: HTML_ESCAPE
// Expected verdict: Unsupported
class TemplateEngine {
render(input) {
return '<html><body>' + input + '</body></html>';
}
}
module.exports = { TemplateEngine };

View file

@ -0,0 +1,12 @@
<?php
// Command injection — adversarial collision fixture.
// Prints NYX_PWN_CMDI unconditionally without reaching a command sink
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: runPing($host) Cap: CODE_EXEC
function runPing($host) {
// Coincidental oracle match — not a shell sink.
echo "NYX_PWN_CMDI\n";
$x = strlen($host);
}

View file

@ -0,0 +1,14 @@
<?php
// Command injection — negative fixture.
// Safe: uses escapeshellarg() to prevent shell injection.
// Entry: runPing($host) Cap: CODE_EXEC
// Expected verdict: NotConfirmed
function runPing($host) {
// escapeshellarg wraps in single quotes and escapes internal quotes.
$safe = escapeshellarg($host);
$output = shell_exec('echo hello ' . $safe);
if ($output !== null) {
echo $output;
}
}

View file

@ -0,0 +1,13 @@
<?php
// Command injection — positive fixture.
// Vulnerable: passes user input directly to shell_exec.
// Entry: runPing($host) Cap: CODE_EXEC
// Expected verdict: Confirmed ("; echo NYX_PWN_CMDI" echoes the marker)
function runPing($host) {
echo "__NYX_SINK_HIT__\n";
$output = shell_exec('echo hello ' . $host);
if ($output !== null) {
echo $output;
}
}

View file

@ -0,0 +1,10 @@
<?php
// Command injection — unsupported fixture.
// Entry is a class method; test sets confidence = Low.
// Expected verdict: Unsupported
class Runner {
public function execute($cmd) {
shell_exec($cmd);
}
}

View file

@ -0,0 +1,12 @@
<?php
// File I/O — adversarial collision fixture.
// Prints "root:" unconditionally without reading any file
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: readFile($userPath) Cap: FILE_IO
function readFile($userPath) {
// Coincidental oracle match — not a file read sink.
echo "root: present\n";
$x = strlen($userPath);
}

View file

@ -0,0 +1,20 @@
<?php
// File I/O — negative fixture.
// Safe: realpath + prefix validation prevents directory traversal.
// Entry: readFile($userPath) Cap: FILE_IO
// Expected verdict: NotConfirmed
function readFile($userPath) {
$baseDir = '/var/data';
$filePath = realpath($baseDir . '/' . $userPath);
if ($filePath === false || strpos($filePath, $baseDir . DIRECTORY_SEPARATOR) !== 0) {
echo "Access denied\n";
return;
}
$content = @file_get_contents($filePath);
if ($content !== false) {
echo substr($content, 0, 100);
} else {
echo "File not found\n";
}
}

View file

@ -0,0 +1,14 @@
<?php
// File I/O — positive fixture.
// Vulnerable: reads file at user-controlled path without sanitization.
// Entry: readFile($userPath) Cap: FILE_IO
// Expected verdict: Confirmed (../../../../etc/passwd → "root:" in output)
function readFile($userPath) {
$filePath = '/var/data/' . $userPath;
echo "__NYX_SINK_HIT__\n";
$content = @file_get_contents($filePath);
if ($content !== false) {
echo $content;
}
}

View file

@ -0,0 +1,13 @@
<?php
// File I/O — unsupported fixture.
// Entry is a class method; test sets confidence = Low.
// Expected verdict: Unsupported
class FileServer {
public function serve($path) {
$content = @file_get_contents($path);
if ($content !== false) {
echo $content;
}
}
}

View file

@ -0,0 +1,12 @@
<?php
// SQL injection — adversarial collision fixture.
// Prints NYX_SQL_CONFIRMED unconditionally without reaching a SQL sink
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: login($username) Cap: SQL_QUERY
function login($username) {
// Coincidental oracle match — not a SQL sink.
echo "NYX_SQL_CONFIRMED\n";
$x = strlen($username);
}

View file

@ -0,0 +1,11 @@
<?php
// SQL injection — negative fixture.
// Safe: uses PDO prepared statement; payload is a bound param, not concatenated.
// Entry: login($username) Cap: SQL_QUERY
// Expected verdict: NotConfirmed
function login($username) {
$template = "SELECT name FROM users WHERE name = ?";
// Simulate parameterized execution: template is fixed.
echo "Executing: " . $template . " param-len=" . strlen($username) . "\n";
}

View file

@ -0,0 +1,12 @@
<?php
// SQL injection — positive fixture.
// Vulnerable: directly concatenates user input into SQL query string.
// Entry: login($username) Cap: SQL_QUERY
// Expected verdict: Confirmed (UNION payload echoes NYX_SQL_CONFIRMED)
function login($username) {
$query = "SELECT name FROM users WHERE name='" . $username . "'";
echo "__NYX_SINK_HIT__\n";
// Error-based echo: output the query so UNION payload is visible.
echo "DB query: " . $query . "\n";
}

View file

@ -0,0 +1,12 @@
<?php
// SQL injection — unsupported fixture.
// Entry is a class method — entry kind unsupported.
// Test sets confidence = Low to get Unsupported(ConfidenceTooLow).
// Expected verdict: Unsupported
class UserRepository {
public function findUser($name) {
$query = "SELECT * FROM users WHERE name='" . $name . "'";
echo $query . "\n";
}
}

View file

@ -0,0 +1,12 @@
<?php
// SSRF — adversarial collision fixture.
// Prints "daemon:" unconditionally without making any HTTP request
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: fetchUrl($url) Cap: SSRF
function fetchUrl($url) {
// Coincidental oracle match — not an HTTP sink.
echo "daemon: present\n";
$x = strlen($url);
}

View file

@ -0,0 +1,18 @@
<?php
// SSRF — negative fixture.
// Safe: only allows http/https scheme; file:// and others rejected.
// Entry: fetchUrl($url) Cap: SSRF
// Expected verdict: NotConfirmed
function fetchUrl($url) {
$parsed = parse_url($url);
$scheme = $parsed['scheme'] ?? '';
if ($scheme !== 'http' && $scheme !== 'https') {
echo "Scheme not allowed: " . $scheme . "\n";
return;
}
$content = @file_get_contents($url);
if ($content !== false) {
echo substr($content, 0, 64);
}
}

View file

@ -0,0 +1,14 @@
<?php
// SSRF — positive fixture.
// Vulnerable: fetches a user-controlled URL via file_get_contents.
// PHP's file_get_contents supports file:// scheme natively.
// Entry: fetchUrl($url) Cap: SSRF
// Expected verdict: Confirmed (file:///etc/passwd → "daemon:" in output)
function fetchUrl($url) {
echo "__NYX_SINK_HIT__\n";
$content = @file_get_contents($url);
if ($content !== false) {
echo $content;
}
}

View file

@ -0,0 +1,13 @@
<?php
// SSRF — unsupported fixture.
// Entry is a class method; test sets confidence = Low.
// Expected verdict: Unsupported
class HttpClient {
public function fetch($url) {
$content = @file_get_contents($url);
if ($content !== false) {
echo $content;
}
}
}

View file

@ -0,0 +1,12 @@
<?php
// XSS — adversarial collision fixture.
// Prints the XSS oracle marker unconditionally without rendering any template
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: renderPage($userInput) Cap: HTML_ESCAPE
function renderPage($userInput) {
// Coincidental oracle match — not an HTML render sink.
echo "<script>NYX_XSS_CONFIRMED</script>\n";
$x = strlen($userInput);
}

View file

@ -0,0 +1,10 @@
<?php
// XSS — negative fixture.
// Safe: uses htmlspecialchars() before output.
// Entry: renderPage($userInput) Cap: HTML_ESCAPE
// Expected verdict: NotConfirmed
function renderPage($userInput) {
$safe = htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');
echo '<html><body>' . $safe . '</body></html>' . "\n";
}

View file

@ -0,0 +1,10 @@
<?php
// XSS — positive fixture.
// Vulnerable: echoes raw user input into HTML output without escaping.
// Entry: renderPage($userInput) Cap: HTML_ESCAPE
// Expected verdict: Confirmed (<script>NYX_XSS_CONFIRMED</script> echoed)
function renderPage($userInput) {
echo "__NYX_SINK_HIT__\n";
echo '<html><body>' . $userInput . '</body></html>' . "\n";
}

View file

@ -0,0 +1,10 @@
<?php
// XSS — unsupported fixture.
// Entry is a class method; test sets confidence = Low.
// Expected verdict: Unsupported
class TemplateEngine {
public function render($input) {
echo '<html><body>' . $input . '</body></html>' . "\n";
}
}

View file

@ -226,6 +226,112 @@ mod escape_tests {
);
}
// ── Build-step escape tests for Phase 05 languages ────────────────────────
/// Verify that a malicious npm lifecycle script (`preinstall`) cannot write
/// to the host when `npm install` runs inside the sandbox.
///
/// NOTE (Phase 05): Docker + npm install is deferred. `prepare_node()` runs
/// `npm install` via the process backend on the host — Docker isolation does
/// NOT protect the build step yet.
///
/// Fixture: `tests/dynamic_fixtures/escape/npm_malicious_lifecycle/package.json`
#[test]
#[ignore = "Phase 06: Docker-isolated npm install not yet implemented"]
fn escape_npm_malicious_lifecycle() {
if !docker_available() {
return;
}
let marker = std::path::PathBuf::from("/tmp/pwned_npm_lifecycle");
let _ = fs::remove_file(&marker);
// Phase 06 TODO: wire Docker-isolated npm install and re-enable body.
// When implemented: copy npm_malicious_lifecycle/ to temp workdir,
// run prepare_node_in_docker(spec, workdir), assert !marker.exists().
assert!(
!marker.exists(),
"host marker /tmp/pwned_npm_lifecycle must not exist before Docker+npm install is implemented"
);
}
/// Verify that a malicious Go `init()` function cannot write to the host
/// when the compiled binary runs inside the Docker sandbox.
///
/// NOTE (Phase 05): `go build` runs via the process backend on the host;
/// the resulting binary executes inside Docker (sandboxed runtime). The
/// `init()` write targets `/tmp/pwned_go_init` which is isolated inside
/// the container — host marker must remain absent.
///
/// Fixture: `tests/dynamic_fixtures/escape/go_malicious_init.go`
#[test]
#[ignore = "Phase 06: Docker-isolated go build not yet implemented; init() runtime escape sandboxed by container /tmp isolation"]
fn escape_go_malicious_init() {
if !docker_available() {
return;
}
let marker = std::path::PathBuf::from("/tmp/pwned_go_init");
let _ = fs::remove_file(&marker);
// Phase 06 TODO: wire Docker-isolated go build, then run the binary
// inside the sandbox and assert the host marker is absent.
assert!(
!marker.exists(),
"host marker /tmp/pwned_go_init must not exist; Go init() write stays inside container /tmp"
);
}
/// Verify that a malicious Maven plugin (`exec-maven-plugin`) cannot write
/// to the host when `mvn compile` runs inside the sandbox.
///
/// NOTE (Phase 05): Docker + Maven compilation is deferred. `prepare_java()`
/// runs `javac` via the process backend on the host — Docker isolation does
/// NOT protect the build step yet.
///
/// Fixture: `tests/dynamic_fixtures/escape/maven_malicious_plugin/pom.xml`
#[test]
#[ignore = "Phase 06: Docker-isolated Maven build not yet implemented"]
fn escape_maven_malicious_plugin() {
if !docker_available() {
return;
}
let marker = std::path::PathBuf::from("/tmp/pwned_maven_plugin");
let _ = fs::remove_file(&marker);
// Phase 06 TODO: wire Docker-isolated mvn compile and re-enable body.
assert!(
!marker.exists(),
"host marker /tmp/pwned_maven_plugin must not exist before Docker+Maven build is implemented"
);
}
/// Verify that a malicious Composer `post-install-cmd` cannot write to the
/// host when `composer install` runs inside the sandbox.
///
/// NOTE (Phase 05): Docker + Composer install is deferred. `prepare_php()`
/// runs `php` directly via the process backend — Docker isolation does NOT
/// protect the install step yet.
///
/// Fixture: `tests/dynamic_fixtures/escape/composer_malicious_postinstall/composer.json`
#[test]
#[ignore = "Phase 06: Docker-isolated composer install not yet implemented"]
fn escape_composer_malicious_postinstall() {
if !docker_available() {
return;
}
let marker = std::path::PathBuf::from("/tmp/pwned_composer_postinstall");
let _ = fs::remove_file(&marker);
// Phase 06 TODO: wire Docker-isolated composer install and re-enable body.
assert!(
!marker.exists(),
"host marker /tmp/pwned_composer_postinstall must not exist before Docker+Composer install is implemented"
);
}
// ── Positive control test ─────────────────────────────────────────────────
/// Positive control: verify the escape-detection mechanism itself.

View file

@ -85,12 +85,46 @@ mod verify_e2e {
}
}
/// Same as `taint_diag_with_cap` but uses a C source file so that
/// `HarnessSpec::from_finding` derives `Lang::C`, which has no emitter.
fn taint_diag_c_lang(cap: Cap) -> Diag {
Diag {
path: "src/handler.c".into(),
line: 10,
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 {
flow_steps: vec![
source_step("src/handler.c", "handle_request"),
sink_step("src/handler.c"),
],
sink_caps: cap.bits(),
..Default::default()
}),
rank_score: None,
rank_reason: None,
suppressed: false,
suppression: None,
rollup: None,
finding_id: String::new(),
alternative_finding_ids: vec![],
stable_hash: 0,
}
}
/// A finding with a supported cap (SQL_QUERY) and a derivable spec reaches
/// `harness::build`. The finding uses a Rust entry file, so the Python-only
/// harness emitter returns `LangUnsupported`.
/// `harness::build`. The finding uses a C entry file; `Lang::C` has no
/// emitter so `LangUnsupported` is returned.
#[test]
fn verify_finding_rust_lang_returns_lang_unsupported() {
let diag = taint_diag_with_cap(Cap::SQL_QUERY);
let diag = taint_diag_c_lang(Cap::SQL_QUERY);
let opts = VerifyOptions::default();
let result = verify_finding(&diag, &opts);
@ -127,12 +161,12 @@ mod verify_e2e {
assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow));
}
/// The JSON shape of `VerifyResult` for a Rust finding (lang unsupported)
/// The JSON shape of `VerifyResult` for a C finding (lang unsupported)
/// matches the documented contract: `status`, `reason` present;
/// `triggered_payload`, `detail`, `attempts` absent (skipped by serde).
#[test]
fn verify_result_json_shape_lang_unsupported() {
let diag = taint_diag_with_cap(Cap::SQL_QUERY);
let diag = taint_diag_c_lang(Cap::SQL_QUERY);
let opts = VerifyOptions::default();
let result = verify_finding(&diag, &opts);

447
tests/go_fixtures.rs Normal file
View file

@ -0,0 +1,447 @@
//! Go fixture integration tests (Phase 05 acceptance gate).
//!
//! Runs the dynamic verification pipeline against each Go fixture and asserts
//! the expected verdict. Requires `--features dynamic` and `go` on PATH.
//!
//! Entry points follow: `func FuncName(payload string)` in package `entry`.
//! The harness wraps each fixture in a generated `main.go` that reads
//! `NYX_PAYLOAD` and calls `entry.FuncName(payload)`.
//!
//! Run with: `cargo nextest run --features dynamic --test go_fixtures`
#[cfg(feature = "dynamic")]
mod go_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;
static FIXTURE_LOCK: Mutex<()> = Mutex::new(());
fn go_available() -> bool {
std::process::Command::new("go")
.arg("version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/dynamic_fixtures/go")
.join(name)
}
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());
if !go_available() {
return nyx_scanner::evidence::VerifyResult {
finding_id: String::new(),
status: VerifyStatus::Unsupported,
triggered_payload: None,
reason: Some(UnsupportedReason::BackendUnavailable),
inconclusive_reason: None,
detail: None,
attempts: vec![],
toolchain_match: None,
};
}
let path = fixture_path(fixture);
let tmp = TempDir::new().unwrap();
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(),
);
}
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 go_sqli_positive_is_confirmed() {
let result = run_fixture("sqli_positive.go", "Login", Cap::SQL_QUERY, 13);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"sqli_positive must be Confirmed; got {:?} (detail: {:?})",
result.status,
result.detail
);
}
#[test]
fn go_sqli_negative_is_not_confirmed() {
let result = run_fixture("sqli_negative.go", "Login", Cap::SQL_QUERY, 12);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::NotConfirmed,
"sqli_negative must be NotConfirmed; got {:?}",
result.status
);
}
#[test]
fn go_sqli_adversarial_is_oracle_collision() {
let result = run_fixture("sqli_adversarial.go", "Login", Cap::SQL_QUERY, 999);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(result.status, VerifyStatus::Inconclusive);
assert_eq!(
result.inconclusive_reason,
Some(InconclusiveReason::OracleCollisionSuspected)
);
}
#[test]
fn go_sqli_unsupported_is_confidence_too_low() {
let path = fixture_path("sqli_unsupported.go");
let mut d = make_diag(&path, "FindUser", Cap::SQL_QUERY, 12);
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));
}
// ── Command injection fixtures ───────────────────────────────────────────
#[test]
fn go_cmdi_positive_is_confirmed() {
let result = run_fixture("cmdi_positive.go", "RunPing", Cap::CODE_EXEC, 15);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"cmdi_positive must be Confirmed; got {:?} (detail: {:?})",
result.status,
result.detail
);
}
#[test]
fn go_cmdi_negative_is_not_confirmed() {
let result = run_fixture("cmdi_negative.go", "RunPing", Cap::CODE_EXEC, 14);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::NotConfirmed,
"cmdi_negative must be NotConfirmed; got {:?}",
result.status
);
}
#[test]
fn go_cmdi_adversarial_is_oracle_collision() {
let result = run_fixture("cmdi_adversarial.go", "RunPing", Cap::CODE_EXEC, 999);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(result.status, VerifyStatus::Inconclusive);
assert_eq!(
result.inconclusive_reason,
Some(InconclusiveReason::OracleCollisionSuspected)
);
}
#[test]
fn go_cmdi_unsupported_is_confidence_too_low() {
let path = fixture_path("cmdi_unsupported.go");
let mut d = make_diag(&path, "Execute", Cap::CODE_EXEC, 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));
}
// ── File I/O fixtures ────────────────────────────────────────────────────
#[test]
fn go_fileio_positive_is_confirmed() {
let result = run_fixture("fileio_positive.go", "ReadFile", Cap::FILE_IO, 17);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"fileio_positive must be Confirmed; got {:?} (detail: {:?})",
result.status,
result.detail
);
}
#[test]
fn go_fileio_negative_is_not_confirmed() {
let result = run_fixture("fileio_negative.go", "ReadFile", Cap::FILE_IO, 20);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::NotConfirmed,
"fileio_negative must be NotConfirmed; got {:?}",
result.status
);
}
#[test]
fn go_fileio_adversarial_is_oracle_collision() {
let result = run_fixture("fileio_adversarial.go", "ReadFile", Cap::FILE_IO, 999);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(result.status, VerifyStatus::Inconclusive);
assert_eq!(
result.inconclusive_reason,
Some(InconclusiveReason::OracleCollisionSuspected)
);
}
#[test]
fn go_fileio_unsupported_is_confidence_too_low() {
let path = fixture_path("fileio_unsupported.go");
let mut d = make_diag(&path, "Serve", Cap::FILE_IO, 13);
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));
}
// ── SSRF fixtures ────────────────────────────────────────────────────────
#[test]
fn go_ssrf_positive_is_confirmed() {
let result = run_fixture("ssrf_positive.go", "FetchURL", Cap::SSRF, 21);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"ssrf_positive must be Confirmed; got {:?} (detail: {:?})",
result.status,
result.detail
);
}
#[test]
fn go_ssrf_negative_is_not_confirmed() {
let result = run_fixture("ssrf_negative.go", "FetchURL", Cap::SSRF, 18);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::NotConfirmed,
"ssrf_negative must be NotConfirmed; got {:?}",
result.status
);
}
#[test]
fn go_ssrf_adversarial_is_oracle_collision() {
let result = run_fixture("ssrf_adversarial.go", "FetchURL", Cap::SSRF, 999);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(result.status, VerifyStatus::Inconclusive);
assert_eq!(
result.inconclusive_reason,
Some(InconclusiveReason::OracleCollisionSuspected)
);
}
#[test]
fn go_ssrf_unsupported_is_confidence_too_low() {
let path = fixture_path("ssrf_unsupported.go");
let mut d = make_diag(&path, "Fetch", Cap::SSRF, 11);
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));
}
// ── XSS fixtures ─────────────────────────────────────────────────────────
#[test]
fn go_xss_positive_is_confirmed() {
let result = run_fixture("xss_positive.go", "RenderPage", Cap::HTML_ESCAPE, 12);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"xss_positive must be Confirmed; got {:?} (detail: {:?})",
result.status,
result.detail
);
}
#[test]
fn go_xss_negative_is_not_confirmed() {
let result = run_fixture("xss_negative.go", "RenderPage", Cap::HTML_ESCAPE, 12);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::NotConfirmed,
"xss_negative must be NotConfirmed; got {:?}",
result.status
);
}
#[test]
fn go_xss_adversarial_is_oracle_collision() {
let result = run_fixture("xss_adversarial.go", "RenderPage", Cap::HTML_ESCAPE, 999);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(result.status, VerifyStatus::Inconclusive);
assert_eq!(
result.inconclusive_reason,
Some(InconclusiveReason::OracleCollisionSuspected)
);
}
#[test]
fn go_xss_unsupported_is_confidence_too_low() {
let path = fixture_path("xss_unsupported.go");
let mut d = make_diag(&path, "Render", Cap::HTML_ESCAPE, 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));
}
// ── 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,
}
}
}

447
tests/java_fixtures.rs Normal file
View file

@ -0,0 +1,447 @@
//! Java fixture integration tests (Phase 05 acceptance gate).
//!
//! Runs the dynamic verification pipeline against each Java fixture and asserts
//! the expected verdict. Requires `--features dynamic` and `java`/`javac` on PATH.
//!
//! Entry points follow: `public static void FuncName(String)` in class `Entry`.
//! The harness wraps each fixture in a generated `NyxHarness.java` that reads
//! `NYX_PAYLOAD` and calls `Entry.FuncName(payload)`.
//!
//! Run with: `cargo nextest run --features dynamic --test java_fixtures`
#[cfg(feature = "dynamic")]
mod java_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;
static FIXTURE_LOCK: Mutex<()> = Mutex::new(());
fn java_available() -> bool {
std::process::Command::new("java")
.arg("-version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/dynamic_fixtures/java")
.join(name)
}
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());
if !java_available() {
return nyx_scanner::evidence::VerifyResult {
finding_id: String::new(),
status: VerifyStatus::Unsupported,
triggered_payload: None,
reason: Some(UnsupportedReason::BackendUnavailable),
inconclusive_reason: None,
detail: None,
attempts: vec![],
toolchain_match: None,
};
}
let path = fixture_path(fixture);
let tmp = TempDir::new().unwrap();
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(),
);
}
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 java_sqli_positive_is_confirmed() {
let result = run_fixture("sqli_positive.java", "login", Cap::SQL_QUERY, 9);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"sqli_positive must be Confirmed; got {:?} (detail: {:?})",
result.status,
result.detail
);
}
#[test]
fn java_sqli_negative_is_not_confirmed() {
let result = run_fixture("sqli_negative.java", "login", Cap::SQL_QUERY, 10);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::NotConfirmed,
"sqli_negative must be NotConfirmed; got {:?}",
result.status
);
}
#[test]
fn java_sqli_adversarial_is_oracle_collision() {
let result = run_fixture("sqli_adversarial.java", "login", Cap::SQL_QUERY, 999);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(result.status, VerifyStatus::Inconclusive);
assert_eq!(
result.inconclusive_reason,
Some(InconclusiveReason::OracleCollisionSuspected)
);
}
#[test]
fn java_sqli_unsupported_is_confidence_too_low() {
let path = fixture_path("sqli_unsupported.java");
let mut d = make_diag(&path, "findUser", 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));
}
// ── Command injection fixtures ───────────────────────────────────────────
#[test]
fn java_cmdi_positive_is_confirmed() {
let result = run_fixture("cmdi_positive.java", "runPing", Cap::CODE_EXEC, 10);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"cmdi_positive must be Confirmed; got {:?} (detail: {:?})",
result.status,
result.detail
);
}
#[test]
fn java_cmdi_negative_is_not_confirmed() {
let result = run_fixture("cmdi_negative.java", "runPing", Cap::CODE_EXEC, 12);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::NotConfirmed,
"cmdi_negative must be NotConfirmed; got {:?}",
result.status
);
}
#[test]
fn java_cmdi_adversarial_is_oracle_collision() {
let result = run_fixture("cmdi_adversarial.java", "runPing", Cap::CODE_EXEC, 999);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(result.status, VerifyStatus::Inconclusive);
assert_eq!(
result.inconclusive_reason,
Some(InconclusiveReason::OracleCollisionSuspected)
);
}
#[test]
fn java_cmdi_unsupported_is_confidence_too_low() {
let path = fixture_path("cmdi_unsupported.java");
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));
}
// ── File I/O fixtures ────────────────────────────────────────────────────
#[test]
fn java_fileio_positive_is_confirmed() {
let result = run_fixture("fileio_positive.java", "readFile", Cap::FILE_IO, 12);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"fileio_positive must be Confirmed; got {:?} (detail: {:?})",
result.status,
result.detail
);
}
#[test]
fn java_fileio_negative_is_not_confirmed() {
let result = run_fixture("fileio_negative.java", "readFile", Cap::FILE_IO, 20);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::NotConfirmed,
"fileio_negative must be NotConfirmed; got {:?}",
result.status
);
}
#[test]
fn java_fileio_adversarial_is_oracle_collision() {
let result = run_fixture("fileio_adversarial.java", "readFile", Cap::FILE_IO, 999);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(result.status, VerifyStatus::Inconclusive);
assert_eq!(
result.inconclusive_reason,
Some(InconclusiveReason::OracleCollisionSuspected)
);
}
#[test]
fn java_fileio_unsupported_is_confidence_too_low() {
let path = fixture_path("fileio_unsupported.java");
let mut d = make_diag(&path, "serve", Cap::FILE_IO, 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));
}
// ── SSRF fixtures ────────────────────────────────────────────────────────
#[test]
fn java_ssrf_positive_is_confirmed() {
let result = run_fixture("ssrf_positive.java", "fetchUrl", Cap::SSRF, 12);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"ssrf_positive must be Confirmed; got {:?} (detail: {:?})",
result.status,
result.detail
);
}
#[test]
fn java_ssrf_negative_is_not_confirmed() {
let result = run_fixture("ssrf_negative.java", "fetchUrl", Cap::SSRF, 17);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::NotConfirmed,
"ssrf_negative must be NotConfirmed; got {:?}",
result.status
);
}
#[test]
fn java_ssrf_adversarial_is_oracle_collision() {
let result = run_fixture("ssrf_adversarial.java", "fetchUrl", Cap::SSRF, 999);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(result.status, VerifyStatus::Inconclusive);
assert_eq!(
result.inconclusive_reason,
Some(InconclusiveReason::OracleCollisionSuspected)
);
}
#[test]
fn java_ssrf_unsupported_is_confidence_too_low() {
let path = fixture_path("ssrf_unsupported.java");
let mut d = make_diag(&path, "fetch", Cap::SSRF, 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));
}
// ── XSS fixtures ─────────────────────────────────────────────────────────
#[test]
fn java_xss_positive_is_confirmed() {
let result = run_fixture("xss_positive.java", "renderPage", Cap::HTML_ESCAPE, 8);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"xss_positive must be Confirmed; got {:?} (detail: {:?})",
result.status,
result.detail
);
}
#[test]
fn java_xss_negative_is_not_confirmed() {
let result = run_fixture("xss_negative.java", "renderPage", Cap::HTML_ESCAPE, 17);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(
result.status,
VerifyStatus::NotConfirmed,
"xss_negative must be NotConfirmed; got {:?}",
result.status
);
}
#[test]
fn java_xss_adversarial_is_oracle_collision() {
let result = run_fixture("xss_adversarial.java", "renderPage", Cap::HTML_ESCAPE, 999);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
return;
}
assert_eq!(result.status, VerifyStatus::Inconclusive);
assert_eq!(
result.inconclusive_reason,
Some(InconclusiveReason::OracleCollisionSuspected)
);
}
#[test]
fn java_xss_unsupported_is_confidence_too_low() {
let path = fixture_path("xss_unsupported.java");
let mut d = make_diag(&path, "render", Cap::HTML_ESCAPE, 7);
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));
}
// ── 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,
}
}
}

Some files were not shown because too many files have changed in this diff Show more