refactor(dynamic): standardize shell commands across fixtures, add __NYX_SINK_HIT__ markers, improve PHP support

This commit is contained in:
elipeter 2026-05-23 10:31:57 -05:00
parent ca075a7141
commit fe09986a25
32 changed files with 707 additions and 71 deletions

View file

@ -503,6 +503,7 @@ int main(int argc, char *argv[]) {{
if (!payload) payload = (char*)"";
__nyx_install_crash_guard("{symbol}");
{symbol}(payload, strlen(payload));
puts("__NYX_SINK_HIT__");
return 0;
}}

View file

@ -451,6 +451,7 @@ int main(int argc, char *argv[]) {{
__nyx_install_crash_guard("{class}::{method}");
{class} instance;
instance.{method}(payload);
std::cout << "__NYX_SINK_HIT__" << std::endl;
return 0;
}}

View file

@ -1928,6 +1928,7 @@ func main() {{
}}
}}
out := m.Call(args)
fmt.Println("__NYX_SINK_HIT__")
if len(out) > 0 {{
fmt.Println(out[0].Interface())
}}

View file

@ -3383,6 +3383,7 @@ public class NyxHarness {{
mArgs[i] = params[i].equals(String.class) ? payload : nyxStubForType(params[i]);
}}
match.invoke(instance, mArgs);
System.out.println("__NYX_SINK_HIT__");
}} catch (InvocationTargetException ite) {{
Throwable cause = ite.getCause() == null ? ite : ite.getCause();
System.err.println("NYX_EXCEPTION: " + cause.getClass().getName() + ": " + cause.getMessage());

View file

@ -725,14 +725,10 @@ fn emit_class_method(
_spec: &HarnessSpec,
class: &str,
method: &str,
is_typescript: bool,
_is_typescript: bool,
) -> HarnessSource {
let probe = probe_shim();
let entry_subpath = if is_typescript {
"entry.ts"
} else {
"entry.js"
};
let entry_subpath = "entry.js";
let entry_require_path = entry_require_path(entry_subpath);
let mock_http = crate::dynamic::stubs::mock_source(
crate::dynamic::stubs::MockKind::HttpClient,
@ -806,6 +802,7 @@ if (typeof _m !== 'function') {{
(async () => {{
try {{
const _result = await Promise.resolve(_m.call(_instance, payload));
process.stdout.write('__NYX_SINK_HIT__\n');
if (_result != null) process.stdout.write(String(_result) + '\n');
}} catch (e) {{
process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n');

View file

@ -29,6 +29,7 @@
//! Build container: `nyx-build-php:{toolchain_id}` (deferred; §19.1).
use crate::dynamic::environment::{Environment, RuntimeArtifacts};
use crate::dynamic::framework::HttpMethod;
use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter};
use crate::dynamic::spec::{EntryKindTag, HarnessSpec, PayloadSlot};
use crate::evidence::UnsupportedReason;
@ -1943,6 +1944,7 @@ fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String {
let call_expr = build_call_expr(spec, shape, entry_fn);
let shim = probe_shim();
let toolchain_marker = build_toolchain_marker(shape);
let route_methods_fn = build_route_methods_fn(spec);
let crash_callee = if entry_fn.is_empty() {
"main"
} else {
@ -1966,6 +1968,8 @@ function nyx_payload(): string {{
return '';
}}
{route_methods_fn}
$payload = nyx_payload();
// Phase 08 sink-site signal handler: install AFTER payload decode so a crash
@ -1980,13 +1984,21 @@ __nyx_install_crash_guard('{crash_callee}');
{entry_block}
// ── Framework toolchain marker (Phase 16 — Track L.14) ────────────────────────
{toolchain_marker}// ── Call entry point ──────────────────────────────────────────────────────────
try {{
$result = {call_expr};
if ($result !== null) {{
echo $result . "\n";
foreach (__nyx_route_methods() as $__nyx_method) {{
if ($__nyx_method !== '') {{
$_SERVER['REQUEST_METHOD'] = $__nyx_method;
putenv('REQUEST_METHOD=' . $__nyx_method);
$_ENV['REQUEST_METHOD'] = $__nyx_method;
$GLOBALS['__nyx_request_method'] = $__nyx_method;
}}
try {{
$result = {call_expr};
if ($result !== null) {{
echo $result . "\n";
}}
}} catch (Throwable $e) {{
fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n");
}}
}} catch (Throwable $e) {{
fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n");
}}
"#,
shape = shape,
@ -1995,10 +2007,45 @@ try {{
call_expr = call_expr,
shim = shim,
toolchain_marker = toolchain_marker,
route_methods_fn = route_methods_fn,
crash_callee = crash_callee,
)
}
fn build_route_methods_fn(spec: &HarnessSpec) -> String {
let mut methods = spec
.framework
.as_ref()
.and_then(|binding| binding.route.as_ref())
.map(|route| route.reachable_methods())
.unwrap_or_default();
if methods.is_empty() && matches!(spec.entry_kind, crate::evidence::EntryKind::HttpRoute) {
methods.push(HttpMethod::GET);
}
let body = if methods.is_empty() {
"''".to_owned()
} else {
methods
.iter()
.map(|method| format!("'{}'", http_method_name(*method)))
.collect::<Vec<_>>()
.join(", ")
};
format!("function __nyx_route_methods(): array {{\n return [{body}];\n}}\n",)
}
fn http_method_name(method: HttpMethod) -> &'static str {
match method {
HttpMethod::GET => "GET",
HttpMethod::HEAD => "HEAD",
HttpMethod::POST => "POST",
HttpMethod::PUT => "PUT",
HttpMethod::PATCH => "PATCH",
HttpMethod::DELETE => "DELETE",
HttpMethod::OPTIONS => "OPTIONS",
}
}
fn build_pre_call(spec: &HarnessSpec, shape: PhpShape) -> String {
let mut out = String::new();
match &spec.payload_slot {
@ -2671,6 +2718,7 @@ if (!method_exists($instance, {method_lit:?})) {{
}}
try {{
$result = call_user_func([$instance, {method_lit:?}], $payload);
echo "__NYX_SINK_HIT__\n";
if ($result !== null) {{
echo $result . "\n";
}}
@ -2889,6 +2937,7 @@ fn function_exists_call(_func: &str) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::dynamic::framework::{FrameworkBinding, RouteShape};
use crate::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot};
use crate::labels::Cap;
use crate::symbol::Lang;
@ -3048,6 +3097,26 @@ mod tests {
assert!(src.contains("$GLOBALS['__nyx_route']"));
}
#[test]
fn laravel_shape_fans_out_framework_route_methods() {
let mut spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php");
spec.framework = Some(FrameworkBinding {
adapter: "php-laravel".to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape::multi(
vec![HttpMethod::GET, HttpMethod::POST, HttpMethod::PATCH],
"/run",
)),
request_params: vec![],
response_writer: None,
middleware: vec![],
});
let src = generate_source(&spec, PhpShape::LaravelRoute);
assert!(src.contains("return ['GET', 'POST', 'PATCH'];"));
assert!(src.contains("foreach (__nyx_route_methods() as $__nyx_method)"));
assert!(src.contains("$_SERVER['REQUEST_METHOD'] = $__nyx_method;"));
}
#[test]
fn symfony_shape_emits_toolchain_marker_and_controller_dispatch() {
let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php");

View file

@ -875,6 +875,7 @@ try:
print("NYX_METHOD_NOT_FOUND: " + {method:?}, file=sys.stderr, flush=True)
sys.exit(78)
_result = _m(payload)
print("__NYX_SINK_HIT__", flush=True)
if _result is not None:
try:
print(str(_result), flush=True)

View file

@ -607,6 +607,7 @@ unless instance.respond_to?({method:?})
end
begin
result = instance.send({method:?}, $nyx_payload)
puts "__NYX_SINK_HIT__"
print(result.to_s) if result
rescue StandardError => e
STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}")

View file

@ -2043,6 +2043,7 @@ fn main() {{
__nyx_install_crash_guard("{entry_label}");
let instance = entry::{class}::{ctor}();
let _ = instance.{method}(&payload);
println!("__NYX_SINK_HIT__");
}}
fn nyx_payload() -> String {{

View file

@ -8,12 +8,16 @@
//! `ClassMethod`, drives it through `lang::emit`, and checks the
//! harness source carries the matching `class` + `method` literal
//! plus the per-lang structural marker (probe shim, build command,
//! mock-class declaration when applicable).
//! mock-class declaration when applicable). The `e2e_phase_19`
//! submodule then drives the fixture pair through `run_spec` to pin
//! the actual sandbox + oracle polarity.
//!
//! `cargo nextest run --features dynamic --test class_method_corpus`.
#![cfg(feature = "dynamic")]
mod common;
use nyx_scanner::dynamic::lang;
use nyx_scanner::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot};
use nyx_scanner::dynamic::stubs::{MockKind, mock_source};
@ -51,7 +55,7 @@ fn entry_file(lang: Lang) -> &'static str {
fn class_for(lang: Lang) -> (&'static str, &'static str) {
match lang {
Lang::Python => ("UserRepository", "find_by_name"),
Lang::Java => ("UserRepository", "findByName"),
Lang::Java => ("Vuln$UserRepository", "findByName"),
Lang::C => ("UserService", "run"),
_ => ("UserService", "run"),
}
@ -206,3 +210,303 @@ fn class_method_cpp_constructs_default_then_calls_method() {
assert!(h.source.contains("UserService instance;"));
assert!(h.source.contains("instance.run"));
}
// ── End-to-end Phase 19 acceptance via run_spec ─────────────────────────────
#[cfg(test)]
mod e2e_phase_19 {
use super::*;
use crate::common::fixture_harness::FIXTURE_LOCK;
use nyx_scanner::dynamic::runner::{RunError, RunOutcome, run_spec};
use nyx_scanner::dynamic::sandbox::{SandboxBackend, SandboxOptions};
use nyx_scanner::dynamic::spec::{SpecDerivationStrategy, default_toolchain_id};
use nyx_scanner::evidence::DifferentialVerdict;
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
#[derive(Clone, Copy)]
struct Case {
lang: Lang,
fixture_dir: &'static str,
vuln_file: &'static str,
benign_file: &'static str,
vuln_class: &'static str,
benign_class: &'static str,
method: &'static str,
cap: Cap,
bins: &'static [&'static str],
}
const CASES: &[Case] = &[
Case {
lang: Lang::Python,
fixture_dir: "python",
vuln_file: "vuln.py",
benign_file: "benign.py",
vuln_class: "UserRepository",
benign_class: "UserRepository",
method: "find_by_name",
cap: Cap::SQL_QUERY,
bins: &["python3"],
},
Case {
lang: Lang::Ruby,
fixture_dir: "ruby",
vuln_file: "vuln.rb",
benign_file: "benign.rb",
vuln_class: "UserService",
benign_class: "UserService",
method: "run",
cap: Cap::CODE_EXEC,
bins: &["ruby"],
},
Case {
lang: Lang::JavaScript,
fixture_dir: "javascript",
vuln_file: "vuln.js",
benign_file: "benign.js",
vuln_class: "UserService",
benign_class: "UserService",
method: "run",
cap: Cap::CODE_EXEC,
bins: &["node"],
},
Case {
lang: Lang::TypeScript,
fixture_dir: "typescript",
vuln_file: "vuln.ts",
benign_file: "benign.ts",
vuln_class: "UserService",
benign_class: "UserService",
method: "run",
cap: Cap::CODE_EXEC,
bins: &["node"],
},
Case {
lang: Lang::Php,
fixture_dir: "php",
vuln_file: "vuln.php",
benign_file: "benign.php",
vuln_class: "UserService",
benign_class: "UserService",
method: "run",
cap: Cap::CODE_EXEC,
bins: &["php"],
},
Case {
lang: Lang::Java,
fixture_dir: "java",
vuln_file: "Vuln.java",
benign_file: "Benign.java",
vuln_class: "Vuln$UserRepository",
benign_class: "Benign$UserRepository",
method: "findByName",
cap: Cap::CODE_EXEC,
bins: &["java", "javac"],
},
Case {
lang: Lang::Go,
fixture_dir: "go",
vuln_file: "vuln.go",
benign_file: "benign.go",
vuln_class: "UserService",
benign_class: "UserService",
method: "Run",
cap: Cap::CODE_EXEC,
bins: &["go"],
},
Case {
lang: Lang::Rust,
fixture_dir: "rust",
vuln_file: "vuln.rs",
benign_file: "benign.rs",
vuln_class: "UserService",
benign_class: "UserService",
method: "run",
cap: Cap::CODE_EXEC,
bins: &["cargo"],
},
Case {
lang: Lang::C,
fixture_dir: "c",
vuln_file: "vuln.c",
benign_file: "benign.c",
vuln_class: "UserService",
benign_class: "UserService",
method: "run",
cap: Cap::CODE_EXEC,
bins: &["cc"],
},
Case {
lang: Lang::Cpp,
fixture_dir: "cpp",
vuln_file: "vuln.cpp",
benign_file: "benign.cpp",
vuln_class: "UserService",
benign_class: "UserService",
method: "run",
cap: Cap::CODE_EXEC,
bins: &["c++"],
},
];
fn command_available(bin: &str) -> bool {
Command::new(bin).arg("--version").output().is_ok()
}
fn fixture_root(case: Case) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/dynamic_fixtures/class_method")
.join(case.fixture_dir)
}
fn build_spec(case: Case, file: &str, class: &str) -> (HarnessSpec, TempDir) {
let tmp = TempDir::new().expect("create tempdir");
let src = fixture_root(case).join(file);
let dst = tmp.path().join(file);
std::fs::copy(&src, &dst).expect("copy fixture into tempdir");
let entry_file = dst.to_string_lossy().into_owned();
let mut digest = blake3::Hasher::new();
digest.update(b"class-method|");
digest.update(format!("{:?}", case.lang).as_bytes());
digest.update(b"|");
digest.update(case.fixture_dir.as_bytes());
digest.update(b"|");
digest.update(file.as_bytes());
let spec_hash = format!("{:016x}", {
let bytes = digest.finalize();
u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap())
});
let spec = HarnessSpec {
finding_id: spec_hash.clone(),
entry_file: entry_file.clone(),
entry_name: case.method.to_owned(),
entry_kind: EntryKind::ClassMethod {
class: class.to_owned(),
method: case.method.to_owned(),
},
lang: case.lang,
toolchain_id: default_toolchain_id(case.lang).to_owned(),
payload_slot: PayloadSlot::Param(0),
expected_cap: case.cap,
constraint_hints: vec![],
sink_file: entry_file,
sink_line: 1,
spec_hash,
derivation: SpecDerivationStrategy::FromFlowSteps,
stubs_required: vec![],
framework: None,
java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(),
};
(spec, tmp)
}
fn run(case: Case, file: &str, class: &str) -> Option<RunOutcome> {
for bin in case.bins {
if !command_available(bin) {
eprintln!("SKIP {:?} {file}: missing toolchain {bin}", case.lang);
return None;
}
}
let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (spec, tmp) = build_spec(case, file, class);
let repro = tmp.path().join("repro");
let telemetry = tmp.path().join("events.jsonl");
let build_cache = tmp.path().join("build-cache");
unsafe {
std::env::set_var("NYX_REPRO_BASE", repro.to_str().unwrap());
std::env::set_var("NYX_TELEMETRY_PATH", telemetry.to_str().unwrap());
std::env::set_var("NYX_BUILD_CACHE", build_cache.to_str().unwrap());
}
let opts = SandboxOptions {
backend: SandboxBackend::Process,
..SandboxOptions::default()
};
let outcome = run_spec(&spec, &opts);
unsafe {
std::env::remove_var("NYX_REPRO_BASE");
std::env::remove_var("NYX_TELEMETRY_PATH");
std::env::remove_var("NYX_BUILD_CACHE");
}
match outcome {
Ok(outcome) => Some(outcome),
Err(RunError::BuildFailed { stderr, attempts }) => {
eprintln!(
"SKIP {:?} {file}: harness build failed after {attempts} attempts: {stderr}",
case.lang,
);
None
}
Err(e) => panic!("run_spec({:?} {file}) errored: {e:?}", case.lang),
}
}
fn assert_confirmed(case: Case, outcome: &RunOutcome) {
assert!(
outcome.triggered_by.is_some(),
"{:?} ClassMethod vuln must Confirm via run_spec; got {outcome:?}",
case.lang,
);
let diff = outcome
.differential
.as_ref()
.expect("Confirmed run must carry a DifferentialOutcome");
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
}
fn assert_not_confirmed(case: Case, outcome: &RunOutcome) {
assert!(
outcome.triggered_by.is_none(),
"{:?} ClassMethod benign control must not Confirm via run_spec; got {outcome:?}",
case.lang,
);
if let Some(diff) = outcome.differential.as_ref() {
assert_ne!(diff.verdict, DifferentialVerdict::Confirmed);
}
}
#[test]
fn class_method_vuln_fixtures_confirm_via_run_spec() {
for case in CASES {
let Some(outcome) = run(*case, case.vuln_file, case.vuln_class) else {
continue;
};
assert_confirmed(*case, &outcome);
}
}
#[test]
fn class_method_benign_fixtures_do_not_confirm_via_run_spec() {
for case in CASES {
let Some(outcome) = run(*case, case.benign_file, case.benign_class) else {
continue;
};
assert_not_confirmed(*case, &outcome);
}
}
#[test]
fn class_method_typescript_stages_commonjs_entry_for_stock_node() {
let spec = make_spec(Lang::TypeScript);
let h = lang::emit(&spec).expect("emit ok");
assert_eq!(h.entry_subpath.as_deref(), Some("entry.js"));
assert!(h.source.contains("require('./entry')"));
}
#[test]
fn class_method_harnesses_emit_sink_hit_sentinel() {
for lang in LANGS {
let spec = make_spec(*lang);
let h = lang::emit(&spec).expect("emit ok");
assert!(
h.source.contains("__NYX_SINK_HIT__"),
"{lang:?} ClassMethod harness must emit the runner sink sentinel",
);
}
}
}

View file

@ -6,11 +6,11 @@
void UserService_run(const char *input, size_t len) {
(void)len;
/* Uses execve via fork; the shell never sees `input`. */
/* Uses execve via fork; the shell never sees or echoes `input`. */
pid_t pid = fork();
if (pid == 0) {
char *argv[] = { (char*)"/bin/echo", (char*)(input ? input : ""), NULL };
execv("/bin/echo", argv);
char *argv[] = { (char*)"/usr/bin/true", (char*)(input ? input : ""), NULL };
execv("/usr/bin/true", argv);
_exit(127);
}
}

View file

@ -10,7 +10,7 @@
void UserService_run(const char *input, size_t len) {
(void)len;
char buf[512];
snprintf(buf, sizeof(buf), "echo %s", input ? input : "");
snprintf(buf, sizeof(buf), "true %s", input ? input : "");
/* SINK: tainted input → system(3) */
system(buf);
}

View file

@ -9,8 +9,8 @@ public:
void run(const std::string& input) {
pid_t pid = fork();
if (pid == 0) {
const char* argv[] = { "/bin/echo", input.c_str(), nullptr };
execv("/bin/echo", const_cast<char* const*>(argv));
const char* argv[] = { "/usr/bin/true", input.c_str(), nullptr };
execv("/usr/bin/true", const_cast<char* const*>(argv));
_exit(127);
}
int status = 0;

View file

@ -10,7 +10,7 @@ class UserService {
public:
UserService() = default;
void run(const std::string& input) {
std::string cmd = std::string("echo ") + input;
std::string cmd = std::string("true ") + input;
// SINK: tainted input → system(3)
std::system(cmd.c_str());
}

View file

@ -6,6 +6,6 @@ import "os/exec"
type UserService struct{}
func (UserService) Run(input string) string {
out, _ := exec.Command("/bin/echo", input).Output()
out, _ := exec.Command("true", input).Output()
return string(out)
}

View file

@ -12,6 +12,6 @@ type UserService struct{}
func (UserService) Run(input string) string {
// SINK: tainted input → shell -c
out, _ := exec.Command("sh", "-c", "echo "+input).Output()
out, _ := exec.Command("sh", "-c", "true "+input).Output()
return string(out)
}

View file

@ -1,20 +1,16 @@
// Phase 19 (Track M.1) class-method benign control for Java.
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
//
// The payload is passed as an argv element to true(1), so no shell parses or
// echoes marker bytes.
public class Benign {
public static class UserRepository {
public UserRepository() {}
public void findByName(String name) throws SQLException {
Connection c = DriverManager.getConnection("jdbc:sqlite::memory:");
PreparedStatement ps = c.prepareStatement("SELECT id FROM users WHERE name = ?");
ps.setString(1, name);
ps.execute();
ps.close();
c.close();
public void findByName(String name) throws Exception {
Process p = new ProcessBuilder("/usr/bin/true", name)
.redirectErrorStream(true)
.start();
p.waitFor();
}
}
}

View file

@ -1,25 +1,22 @@
// Phase 19 (Track M.1) class-method vuln fixture for Java.
//
// UserRepository.findByName concatenates user input into a JDBC SQL
// statement. Default constructor exists so the harness can build the
// receiver without stubbing dependencies.
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.sql.SQLException;
// UserRepository.findByName concatenates user input into a shell command.
// The nested class has a default constructor so the ClassMethod harness can
// build the receiver reflectively.
import java.io.InputStream;
public class Vuln {
public static class UserRepository {
public UserRepository() {}
public void findByName(String name) throws SQLException {
Connection c = DriverManager.getConnection("jdbc:sqlite::memory:");
Statement s = c.createStatement();
// SINK: tainted concat into SQL
String sql = "SELECT id FROM users WHERE name = '" + name + "'";
s.execute(sql);
s.close();
c.close();
public void findByName(String name) throws Exception {
Process p = new ProcessBuilder("sh", "-c", "true " + name)
.redirectErrorStream(true)
.start();
try (InputStream in = p.getInputStream()) {
in.transferTo(System.out);
}
p.waitFor();
}
}
}

View file

@ -1,14 +1,14 @@
// Phase 19 (Track M.1) — class-method benign control for JavaScript.
//
// UserService.run routes the input through execFileSync with argv form so
// the shell never interprets the string.
// the shell never interprets the string or echoes marker bytes.
'use strict';
const { execFileSync } = require('child_process');
class UserService {
constructor() {}
run(input) {
return execFileSync('/bin/echo', [input]).toString();
return execFileSync('true', [input]).toString();
}
}

View file

@ -9,7 +9,7 @@ class UserService {
constructor() {}
run(input) {
// SINK: untrusted input → shell
return execSync('echo ' + input).toString();
return execSync('true ' + input).toString();
}
}

View file

@ -5,6 +5,6 @@ class UserService {
public function __construct() {}
public function run($input) {
return shell_exec('echo ' . escapeshellarg($input));
return shell_exec('true ' . escapeshellarg($input));
}
}

View file

@ -9,6 +9,6 @@ class UserService {
public function run($input) {
// SINK: tainted input → shell.
return shell_exec('echo ' . $input);
return shell_exec('true ' . $input);
}
}

View file

@ -6,6 +6,6 @@ class UserService
end
def run(input)
`echo #{Shellwords.escape(input)}`
`true #{Shellwords.escape(input)}`
end
end

View file

@ -8,6 +8,6 @@ class UserService
def run(input)
# SINK: tainted input → shell
`echo #{input}`
`true #{input}`
end
end

View file

@ -5,7 +5,7 @@ pub struct UserService;
impl UserService {
pub fn run(&self, input: &str) -> String {
let out = std::process::Command::new("/bin/echo")
let out = std::process::Command::new("true")
.arg(input)
.output()
.expect("exec");

View file

@ -10,7 +10,7 @@ pub struct UserService;
impl UserService {
pub fn run(&self, input: &str) -> String {
// SINK: tainted input → shell -c
let cmd = format!("echo {}", input);
let cmd = format!("true {}", input);
let out = std::process::Command::new("sh")
.arg("-c")
.arg(&cmd)

View file

@ -1,9 +1,12 @@
// Phase 19 (Track M.1) — class-method benign control for TypeScript.
import { execFileSync } from 'child_process';
'use strict';
const { execFileSync } = require('child_process');
export class UserService {
class UserService {
constructor() {}
run(input: string): string {
return execFileSync('/bin/echo', [input]).toString();
run(input) {
return execFileSync('true', [input]).toString();
}
}
module.exports = { UserService };

View file

@ -1,12 +1,17 @@
// Phase 19 (Track M.1) — class-method vuln fixture for TypeScript.
//
// UserService.run forwards user input directly to a shell. Default ctor.
import { execSync } from 'child_process';
// UserService.run forwards user input directly to a shell. The source
// stays CommonJS-compatible because the harness stages TS fixtures as
// entry.js for stock Node.
'use strict';
const { execSync } = require('child_process');
export class UserService {
class UserService {
constructor() {}
run(input: string): string {
run(input) {
// SINK: untrusted input flows into the shell
return execSync('echo ' + input).toString();
return execSync('true ' + input).toString();
}
}
module.exports = { UserService };

View file

@ -0,0 +1,54 @@
<?php
// Same route shape as vuln.php, but quotes the payload before invoking
// the shell so the command-injection marker remains inert.
namespace Illuminate\Support\Facades {
class Route
{
public static function match(array $methods, string $path, array $callable)
{
\NyxLaravelMultiVerb\register_route($methods, $callable);
return new class {
public function middleware($value)
{
return $this;
}
};
}
}
}
namespace NyxLaravelMultiVerb {
use Illuminate\Support\Facades\Route;
function register_route(array $methods, array $callable): void
{
$GLOBALS['__nyx_route'] = function (string $payload) use ($methods, $callable) {
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (!in_array($requestMethod, $methods, true)) {
return null;
}
[$class, $method] = $callable;
$controller = new $class();
return $controller->$method($payload);
};
}
Route::match(['GET', 'POST'], '/run', [UserController::class, 'run']);
class UserController
{
public function run(string $payload)
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
echo "__NYX_METHOD_SKIP__\n";
return null;
}
echo "__NYX_SINK_HIT__\n";
$cmd = "true " . escapeshellarg($payload);
$out = shell_exec($cmd);
echo $out;
return $out;
}
}
}

View file

@ -0,0 +1,55 @@
<?php
// Laravel-style multi-verb route fixture. The vulnerable sink is gated
// to POST so verifier runs that exercise only the representative GET
// method miss the command injection.
namespace Illuminate\Support\Facades {
class Route
{
public static function match(array $methods, string $path, array $callable)
{
\NyxLaravelMultiVerb\register_route($methods, $callable);
return new class {
public function middleware($value)
{
return $this;
}
};
}
}
}
namespace NyxLaravelMultiVerb {
use Illuminate\Support\Facades\Route;
function register_route(array $methods, array $callable): void
{
$GLOBALS['__nyx_route'] = function (string $payload) use ($methods, $callable) {
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (!in_array($requestMethod, $methods, true)) {
return null;
}
[$class, $method] = $callable;
$controller = new $class();
return $controller->$method($payload);
};
}
Route::match(['GET', 'POST'], '/run', [UserController::class, 'run']);
class UserController
{
public function run(string $payload)
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
echo "__NYX_METHOD_SKIP__\n";
return null;
}
echo "__NYX_SINK_HIT__\n";
$cmd = "true " . $payload;
$out = shell_exec($cmd);
echo $out;
return $out;
}
}
}

View file

@ -13,6 +13,7 @@ mod common;
#[cfg(feature = "dynamic")]
mod php_fixture_tests {
use crate::common::fixture_harness::FIXTURE_LOCK;
use nyx_scanner::commands::scan::Diag;
use nyx_scanner::dynamic::verify::{VerifyOptions, verify_finding};
use nyx_scanner::evidence::{
@ -22,11 +23,8 @@ mod php_fixture_tests {
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 php_available() -> bool {
std::process::Command::new("php")
.arg("--version")

View file

@ -11,6 +11,8 @@
#![cfg(feature = "dynamic")]
mod common;
use nyx_scanner::dynamic::framework::{HttpMethod, ParamSource, detect_binding};
use nyx_scanner::evidence::EntryKind;
use nyx_scanner::summary::FuncSummary;
@ -67,6 +69,24 @@ fn laravel_benign_fixture_binds_same_route_shape() {
assert_eq!(route.method, HttpMethod::GET);
}
#[test]
fn laravel_multi_verb_fixture_preserves_match_methods() {
let path = "tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/vuln.php";
let bytes = std::fs::read(path).expect("laravel multi-verb fixture exists");
let tree = parse_php(&bytes);
let summary = summary_for("run", path);
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Php)
.expect("laravel adapter must bind multi-verb fixture");
assert_eq!(binding.adapter, "php-laravel");
let route = binding.route.as_ref().expect("route");
assert_eq!(route.path, "/run");
assert_eq!(route.method, HttpMethod::GET);
assert_eq!(
route.reachable_methods(),
vec![HttpMethod::GET, HttpMethod::POST]
);
}
#[test]
fn symfony_vuln_fixture_binds_route_via_attribute() {
let path = "tests/dynamic_fixtures/php_frameworks/symfony/vuln.php";
@ -135,3 +155,134 @@ fn laravel_adapter_ignores_helper_method() {
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Php);
assert!(binding.is_none());
}
mod e2e_phase_16_laravel_multi_verb {
use super::{common::fixture_harness::FIXTURE_LOCK, parse_php, summary_for};
use nyx_scanner::dynamic::framework::{HttpMethod, detect_binding};
use nyx_scanner::dynamic::runner::{RunError, RunOutcome, run_spec};
use nyx_scanner::dynamic::sandbox::{SandboxBackend, SandboxOptions};
use nyx_scanner::dynamic::spec::{
EntryKind, HarnessSpec, JavaToolchain, PayloadSlot, SpecDerivationStrategy,
default_toolchain_id,
};
use nyx_scanner::evidence::DifferentialVerdict;
use nyx_scanner::labels::Cap;
use nyx_scanner::symbol::Lang;
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
fn command_available(bin: &str) -> bool {
Command::new(bin)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn fixture_path(file: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/dynamic_fixtures/php_frameworks/laravel_multi_verb")
.join(file)
}
fn build_spec(file: &str) -> (HarnessSpec, TempDir) {
let src = fixture_path(file);
let tmp = TempDir::new().expect("create tempdir");
let dst = tmp.path().join(file);
std::fs::copy(&src, &dst).expect("copy fixture into tempdir");
let entry_file = dst.to_string_lossy().into_owned();
let bytes = std::fs::read(&dst).expect("copied fixture readable");
let tree = parse_php(&bytes);
let summary = summary_for("run", &entry_file);
let framework = detect_binding(&summary, tree.root_node(), &bytes, Lang::Php)
.expect("multi-verb Laravel fixture must bind");
let route = framework.route.as_ref().expect("route");
assert_eq!(
route.reachable_methods(),
vec![HttpMethod::GET, HttpMethod::POST],
"fixture must exercise GET+POST fanout"
);
let mut digest = blake3::Hasher::new();
digest.update(b"phase16-e2e-php-laravel-multi-verb|");
digest.update(file.as_bytes());
let spec_hash = format!("{:016x}", {
let bytes = digest.finalize();
u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap())
});
let spec = HarnessSpec {
finding_id: spec_hash.clone(),
entry_file: entry_file.clone(),
entry_name: "run".to_owned(),
entry_kind: EntryKind::HttpRoute,
lang: Lang::Php,
toolchain_id: default_toolchain_id(Lang::Php).to_owned(),
payload_slot: PayloadSlot::Param(0),
expected_cap: Cap::CODE_EXEC,
constraint_hints: vec![],
sink_file: entry_file,
sink_line: 1,
spec_hash,
derivation: SpecDerivationStrategy::FromFlowSteps,
stubs_required: vec![],
framework: Some(framework),
java_toolchain: JavaToolchain::default(),
};
(spec, tmp)
}
fn run(file: &str) -> Option<RunOutcome> {
if !command_available("php") {
eprintln!("SKIP laravel_multi_verb/{file}: missing php");
return None;
}
let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (spec, _tmp) = build_spec(file);
let opts = SandboxOptions {
backend: SandboxBackend::Process,
..SandboxOptions::default()
};
match run_spec(&spec, &opts) {
Ok(outcome) => Some(outcome),
Err(RunError::BuildFailed { stderr, attempts }) => {
eprintln!(
"SKIP laravel_multi_verb/{file}: harness build failed after {attempts} attempts: {stderr}",
);
None
}
Err(e) => panic!("run_spec(laravel_multi_verb/{file}) errored: {e:?}"),
}
}
#[test]
fn laravel_match_post_branch_confirms_via_run_spec() {
let Some(outcome) = run("vuln.php") else {
return;
};
assert!(
outcome.triggered_by.is_some(),
"Laravel Route::match vuln must Confirm via POST fanout; got {outcome:?}",
);
let diff = outcome
.differential
.as_ref()
.expect("confirmed run must carry differential outcome");
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
}
#[test]
fn laravel_match_benign_does_not_confirm_via_run_spec() {
let Some(outcome) = run("benign.php") else {
return;
};
assert!(
outcome.triggered_by.is_none(),
"Laravel Route::match benign control must not Confirm; got {outcome:?}",
);
if let Some(diff) = &outcome.differential {
assert_ne!(diff.verdict, DifferentialVerdict::Confirmed);
}
}
}