mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 05: M5 — JS/TS, Go, Java, PHP harness emitters
This commit is contained in:
parent
84638e7d57
commit
345b44d3cc
103 changed files with 5637 additions and 34 deletions
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
219
src/dynamic/lang/go.rs
Normal 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
191
src/dynamic/lang/java.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
248
src/dynamic/lang/javascript.rs
Normal file
248
src/dynamic/lang/javascript.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
202
src/dynamic/lang/php.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
16
tests/dynamic_fixtures/escape/go_malicious_init.go
Normal file
16
tests/dynamic_fixtures/escape/go_malicious_init.go
Normal 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) {}
|
||||
40
tests/dynamic_fixtures/escape/maven_malicious_plugin/pom.xml
Normal file
40
tests/dynamic_fixtures/escape/maven_malicious_plugin/pom.xml
Normal 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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
15
tests/dynamic_fixtures/go/cmdi_adversarial.go
Normal file
15
tests/dynamic_fixtures/go/cmdi_adversarial.go
Normal 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)
|
||||
}
|
||||
18
tests/dynamic_fixtures/go/cmdi_negative.go
Normal file
18
tests/dynamic_fixtures/go/cmdi_negative.go
Normal 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))
|
||||
}
|
||||
18
tests/dynamic_fixtures/go/cmdi_positive.go
Normal file
18
tests/dynamic_fixtures/go/cmdi_positive.go
Normal 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))
|
||||
}
|
||||
15
tests/dynamic_fixtures/go/cmdi_unsupported.go
Normal file
15
tests/dynamic_fixtures/go/cmdi_unsupported.go
Normal 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()
|
||||
}
|
||||
15
tests/dynamic_fixtures/go/fileio_adversarial.go
Normal file
15
tests/dynamic_fixtures/go/fileio_adversarial.go
Normal 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)
|
||||
}
|
||||
34
tests/dynamic_fixtures/go/fileio_negative.go
Normal file
34
tests/dynamic_fixtures/go/fileio_negative.go
Normal 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
|
||||
}
|
||||
21
tests/dynamic_fixtures/go/fileio_positive.go
Normal file
21
tests/dynamic_fixtures/go/fileio_positive.go
Normal 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))
|
||||
}
|
||||
}
|
||||
21
tests/dynamic_fixtures/go/fileio_unsupported.go
Normal file
21
tests/dynamic_fixtures/go/fileio_unsupported.go
Normal 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))
|
||||
}
|
||||
}
|
||||
15
tests/dynamic_fixtures/go/sqli_adversarial.go
Normal file
15
tests/dynamic_fixtures/go/sqli_adversarial.go
Normal 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)
|
||||
}
|
||||
14
tests/dynamic_fixtures/go/sqli_negative.go
Normal file
14
tests/dynamic_fixtures/go/sqli_negative.go
Normal 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))
|
||||
}
|
||||
15
tests/dynamic_fixtures/go/sqli_positive.go
Normal file
15
tests/dynamic_fixtures/go/sqli_positive.go
Normal 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")
|
||||
}
|
||||
16
tests/dynamic_fixtures/go/sqli_unsupported.go
Normal file
16
tests/dynamic_fixtures/go/sqli_unsupported.go
Normal 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)
|
||||
}
|
||||
15
tests/dynamic_fixtures/go/ssrf_adversarial.go
Normal file
15
tests/dynamic_fixtures/go/ssrf_adversarial.go
Normal 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)
|
||||
}
|
||||
34
tests/dynamic_fixtures/go/ssrf_negative.go
Normal file
34
tests/dynamic_fixtures/go/ssrf_negative.go
Normal 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
|
||||
}
|
||||
33
tests/dynamic_fixtures/go/ssrf_positive.go
Normal file
33
tests/dynamic_fixtures/go/ssrf_positive.go
Normal 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))
|
||||
}
|
||||
}
|
||||
20
tests/dynamic_fixtures/go/ssrf_unsupported.go
Normal file
20
tests/dynamic_fixtures/go/ssrf_unsupported.go
Normal 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)
|
||||
}
|
||||
}
|
||||
15
tests/dynamic_fixtures/go/xss_adversarial.go
Normal file
15
tests/dynamic_fixtures/go/xss_adversarial.go
Normal 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)
|
||||
}
|
||||
16
tests/dynamic_fixtures/go/xss_negative.go
Normal file
16
tests/dynamic_fixtures/go/xss_negative.go
Normal 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")
|
||||
}
|
||||
13
tests/dynamic_fixtures/go/xss_positive.go
Normal file
13
tests/dynamic_fixtures/go/xss_positive.go
Normal 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")
|
||||
}
|
||||
13
tests/dynamic_fixtures/go/xss_unsupported.go
Normal file
13
tests/dynamic_fixtures/go/xss_unsupported.go
Normal 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")
|
||||
}
|
||||
13
tests/dynamic_fixtures/java/cmdi_adversarial.java
Normal file
13
tests/dynamic_fixtures/java/cmdi_adversarial.java
Normal 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();
|
||||
}
|
||||
}
|
||||
20
tests/dynamic_fixtures/java/cmdi_negative.java
Normal file
20
tests/dynamic_fixtures/java/cmdi_negative.java
Normal 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();
|
||||
}
|
||||
}
|
||||
20
tests/dynamic_fixtures/java/cmdi_positive.java
Normal file
20
tests/dynamic_fixtures/java/cmdi_positive.java
Normal 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();
|
||||
}
|
||||
}
|
||||
11
tests/dynamic_fixtures/java/cmdi_unsupported.java
Normal file
11
tests/dynamic_fixtures/java/cmdi_unsupported.java
Normal 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});
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/java/fileio_adversarial.java
Normal file
13
tests/dynamic_fixtures/java/fileio_adversarial.java
Normal 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();
|
||||
}
|
||||
}
|
||||
27
tests/dynamic_fixtures/java/fileio_negative.java
Normal file
27
tests/dynamic_fixtures/java/fileio_negative.java
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
20
tests/dynamic_fixtures/java/fileio_positive.java
Normal file
20
tests/dynamic_fixtures/java/fileio_positive.java
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/java/fileio_unsupported.java
Normal file
13
tests/dynamic_fixtures/java/fileio_unsupported.java
Normal 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);
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/java/sqli_adversarial.java
Normal file
13
tests/dynamic_fixtures/java/sqli_adversarial.java
Normal 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();
|
||||
}
|
||||
}
|
||||
12
tests/dynamic_fixtures/java/sqli_negative.java
Normal file
12
tests/dynamic_fixtures/java/sqli_negative.java
Normal 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());
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/java/sqli_positive.java
Normal file
13
tests/dynamic_fixtures/java/sqli_positive.java
Normal 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);
|
||||
}
|
||||
}
|
||||
11
tests/dynamic_fixtures/java/sqli_unsupported.java
Normal file
11
tests/dynamic_fixtures/java/sqli_unsupported.java
Normal 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);
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/java/ssrf_adversarial.java
Normal file
13
tests/dynamic_fixtures/java/ssrf_adversarial.java
Normal 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();
|
||||
}
|
||||
}
|
||||
27
tests/dynamic_fixtures/java/ssrf_negative.java
Normal file
27
tests/dynamic_fixtures/java/ssrf_negative.java
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
24
tests/dynamic_fixtures/java/ssrf_positive.java
Normal file
24
tests/dynamic_fixtures/java/ssrf_positive.java
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
12
tests/dynamic_fixtures/java/ssrf_unsupported.java
Normal file
12
tests/dynamic_fixtures/java/ssrf_unsupported.java
Normal 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();
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/java/xss_adversarial.java
Normal file
13
tests/dynamic_fixtures/java/xss_adversarial.java
Normal 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();
|
||||
}
|
||||
}
|
||||
19
tests/dynamic_fixtures/java/xss_negative.java
Normal file
19
tests/dynamic_fixtures/java/xss_negative.java
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
public static void renderPage(String userInput) {
|
||||
String safe = escapeHtml(userInput);
|
||||
System.out.print("<html><body>" + safe + "</body></html>\n");
|
||||
}
|
||||
}
|
||||
11
tests/dynamic_fixtures/java/xss_positive.java
Normal file
11
tests/dynamic_fixtures/java/xss_positive.java
Normal 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");
|
||||
}
|
||||
}
|
||||
9
tests/dynamic_fixtures/java/xss_unsupported.java
Normal file
9
tests/dynamic_fixtures/java/xss_unsupported.java
Normal 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");
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/js/cmdi_adversarial.js
Normal file
13
tests/dynamic_fixtures/js/cmdi_adversarial.js
Normal 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 };
|
||||
18
tests/dynamic_fixtures/js/cmdi_negative.js
Normal file
18
tests/dynamic_fixtures/js/cmdi_negative.js
Normal 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 };
|
||||
18
tests/dynamic_fixtures/js/cmdi_positive.js
Normal file
18
tests/dynamic_fixtures/js/cmdi_positive.js
Normal 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 };
|
||||
17
tests/dynamic_fixtures/js/cmdi_unsupported.js
Normal file
17
tests/dynamic_fixtures/js/cmdi_unsupported.js
Normal 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 };
|
||||
13
tests/dynamic_fixtures/js/fileio_adversarial.js
Normal file
13
tests/dynamic_fixtures/js/fileio_adversarial.js
Normal 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 };
|
||||
25
tests/dynamic_fixtures/js/fileio_negative.js
Normal file
25
tests/dynamic_fixtures/js/fileio_negative.js
Normal 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 };
|
||||
20
tests/dynamic_fixtures/js/fileio_positive.js
Normal file
20
tests/dynamic_fixtures/js/fileio_positive.js
Normal 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 };
|
||||
20
tests/dynamic_fixtures/js/fileio_unsupported.js
Normal file
20
tests/dynamic_fixtures/js/fileio_unsupported.js
Normal 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 };
|
||||
14
tests/dynamic_fixtures/js/sqli_adversarial.js
Normal file
14
tests/dynamic_fixtures/js/sqli_adversarial.js
Normal 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 };
|
||||
14
tests/dynamic_fixtures/js/sqli_negative.js
Normal file
14
tests/dynamic_fixtures/js/sqli_negative.js
Normal 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 };
|
||||
13
tests/dynamic_fixtures/js/sqli_positive.js
Normal file
13
tests/dynamic_fixtures/js/sqli_positive.js
Normal 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 };
|
||||
15
tests/dynamic_fixtures/js/sqli_unsupported.js
Normal file
15
tests/dynamic_fixtures/js/sqli_unsupported.js
Normal 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 };
|
||||
13
tests/dynamic_fixtures/js/ssrf_adversarial.js
Normal file
13
tests/dynamic_fixtures/js/ssrf_adversarial.js
Normal 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 };
|
||||
24
tests/dynamic_fixtures/js/ssrf_negative.js
Normal file
24
tests/dynamic_fixtures/js/ssrf_negative.js
Normal 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 };
|
||||
35
tests/dynamic_fixtures/js/ssrf_positive.js
Normal file
35
tests/dynamic_fixtures/js/ssrf_positive.js
Normal 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 };
|
||||
20
tests/dynamic_fixtures/js/ssrf_unsupported.js
Normal file
20
tests/dynamic_fixtures/js/ssrf_unsupported.js
Normal 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 };
|
||||
13
tests/dynamic_fixtures/js/xss_adversarial.js
Normal file
13
tests/dynamic_fixtures/js/xss_adversarial.js
Normal 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 };
|
||||
20
tests/dynamic_fixtures/js/xss_negative.js
Normal file
20
tests/dynamic_fixtures/js/xss_negative.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderPage(userInput) {
|
||||
const safe = escapeHtml(userInput);
|
||||
process.stdout.write('<html><body>' + safe + '</body></html>\n');
|
||||
}
|
||||
|
||||
module.exports = { renderPage };
|
||||
12
tests/dynamic_fixtures/js/xss_positive.js
Normal file
12
tests/dynamic_fixtures/js/xss_positive.js
Normal 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 };
|
||||
13
tests/dynamic_fixtures/js/xss_unsupported.js
Normal file
13
tests/dynamic_fixtures/js/xss_unsupported.js
Normal 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 };
|
||||
12
tests/dynamic_fixtures/php/cmdi_adversarial.php
Normal file
12
tests/dynamic_fixtures/php/cmdi_adversarial.php
Normal 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);
|
||||
}
|
||||
14
tests/dynamic_fixtures/php/cmdi_negative.php
Normal file
14
tests/dynamic_fixtures/php/cmdi_negative.php
Normal 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;
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/php/cmdi_positive.php
Normal file
13
tests/dynamic_fixtures/php/cmdi_positive.php
Normal 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;
|
||||
}
|
||||
}
|
||||
10
tests/dynamic_fixtures/php/cmdi_unsupported.php
Normal file
10
tests/dynamic_fixtures/php/cmdi_unsupported.php
Normal 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);
|
||||
}
|
||||
}
|
||||
12
tests/dynamic_fixtures/php/fileio_adversarial.php
Normal file
12
tests/dynamic_fixtures/php/fileio_adversarial.php
Normal 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);
|
||||
}
|
||||
20
tests/dynamic_fixtures/php/fileio_negative.php
Normal file
20
tests/dynamic_fixtures/php/fileio_negative.php
Normal 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";
|
||||
}
|
||||
}
|
||||
14
tests/dynamic_fixtures/php/fileio_positive.php
Normal file
14
tests/dynamic_fixtures/php/fileio_positive.php
Normal 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;
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/php/fileio_unsupported.php
Normal file
13
tests/dynamic_fixtures/php/fileio_unsupported.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
tests/dynamic_fixtures/php/sqli_adversarial.php
Normal file
12
tests/dynamic_fixtures/php/sqli_adversarial.php
Normal 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);
|
||||
}
|
||||
11
tests/dynamic_fixtures/php/sqli_negative.php
Normal file
11
tests/dynamic_fixtures/php/sqli_negative.php
Normal 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";
|
||||
}
|
||||
12
tests/dynamic_fixtures/php/sqli_positive.php
Normal file
12
tests/dynamic_fixtures/php/sqli_positive.php
Normal 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";
|
||||
}
|
||||
12
tests/dynamic_fixtures/php/sqli_unsupported.php
Normal file
12
tests/dynamic_fixtures/php/sqli_unsupported.php
Normal 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";
|
||||
}
|
||||
}
|
||||
12
tests/dynamic_fixtures/php/ssrf_adversarial.php
Normal file
12
tests/dynamic_fixtures/php/ssrf_adversarial.php
Normal 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);
|
||||
}
|
||||
18
tests/dynamic_fixtures/php/ssrf_negative.php
Normal file
18
tests/dynamic_fixtures/php/ssrf_negative.php
Normal 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);
|
||||
}
|
||||
}
|
||||
14
tests/dynamic_fixtures/php/ssrf_positive.php
Normal file
14
tests/dynamic_fixtures/php/ssrf_positive.php
Normal 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;
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/php/ssrf_unsupported.php
Normal file
13
tests/dynamic_fixtures/php/ssrf_unsupported.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
tests/dynamic_fixtures/php/xss_adversarial.php
Normal file
12
tests/dynamic_fixtures/php/xss_adversarial.php
Normal 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);
|
||||
}
|
||||
10
tests/dynamic_fixtures/php/xss_negative.php
Normal file
10
tests/dynamic_fixtures/php/xss_negative.php
Normal 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";
|
||||
}
|
||||
10
tests/dynamic_fixtures/php/xss_positive.php
Normal file
10
tests/dynamic_fixtures/php/xss_positive.php
Normal 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";
|
||||
}
|
||||
10
tests/dynamic_fixtures/php/xss_unsupported.php
Normal file
10
tests/dynamic_fixtures/php/xss_unsupported.php
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
447
tests/go_fixtures.rs
Normal 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
447
tests/java_fixtures.rs
Normal 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
Loading…
Add table
Add a link
Reference in a new issue