From fe09986a256b32ad96295c7394ad178218305979 Mon Sep 17 00:00:00 2001 From: elipeter Date: Sat, 23 May 2026 10:31:57 -0500 Subject: [PATCH] refactor(dynamic): standardize shell commands across fixtures, add `__NYX_SINK_HIT__` markers, improve PHP support --- src/dynamic/lang/c.rs | 1 + src/dynamic/lang/cpp.rs | 1 + src/dynamic/lang/go.rs | 1 + src/dynamic/lang/java.rs | 1 + src/dynamic/lang/js_shared.rs | 9 +- src/dynamic/lang/php.rs | 81 ++++- src/dynamic/lang/python.rs | 1 + src/dynamic/lang/ruby.rs | 1 + src/dynamic/lang/rust.rs | 1 + tests/class_method_corpus.rs | 308 +++++++++++++++++- .../dynamic_fixtures/class_method/c/benign.c | 6 +- tests/dynamic_fixtures/class_method/c/vuln.c | 2 +- .../class_method/cpp/benign.cpp | 4 +- .../class_method/cpp/vuln.cpp | 2 +- .../class_method/go/benign.go | 2 +- .../dynamic_fixtures/class_method/go/vuln.go | 2 +- .../class_method/java/Benign.java | 20 +- .../class_method/java/Vuln.java | 27 +- .../class_method/javascript/benign.js | 4 +- .../class_method/javascript/vuln.js | 2 +- .../class_method/php/benign.php | 2 +- .../class_method/php/vuln.php | 2 +- .../class_method/ruby/benign.rb | 2 +- .../class_method/ruby/vuln.rb | 2 +- .../class_method/rust/benign.rs | 2 +- .../class_method/rust/vuln.rs | 2 +- .../class_method/typescript/benign.ts | 11 +- .../class_method/typescript/vuln.ts | 15 +- .../laravel_multi_verb/benign.php | 54 +++ .../laravel_multi_verb/vuln.php | 55 ++++ tests/php_fixtures.rs | 4 +- tests/php_frameworks_corpus.rs | 151 +++++++++ 32 files changed, 707 insertions(+), 71 deletions(-) create mode 100644 tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/benign.php create mode 100644 tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/vuln.php diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs index 05639028..c5b010d9 100644 --- a/src/dynamic/lang/c.rs +++ b/src/dynamic/lang/c.rs @@ -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; }} diff --git a/src/dynamic/lang/cpp.rs b/src/dynamic/lang/cpp.rs index 39150bad..542d74da 100644 --- a/src/dynamic/lang/cpp.rs +++ b/src/dynamic/lang/cpp.rs @@ -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; }} diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 0a049d3c..a8c29bc8 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -1928,6 +1928,7 @@ func main() {{ }} }} out := m.Call(args) + fmt.Println("__NYX_SINK_HIT__") if len(out) > 0 {{ fmt.Println(out[0].Interface()) }} diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 6351eee9..e0ee4a12 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -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()); diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index ec48108c..ffa4ed9e 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -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'); diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index b0c341dc..39dc06c0 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -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::>() + .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"); diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index b27b5e2e..b6e30c34 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -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) diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 0415216d..5f8ca7da 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -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}}") diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index a13de9cc..7dbc43cd 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -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 {{ diff --git a/tests/class_method_corpus.rs b/tests/class_method_corpus.rs index 47fb34e4..f8d0db37 100644 --- a/tests/class_method_corpus.rs +++ b/tests/class_method_corpus.rs @@ -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 { + 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", + ); + } + } +} diff --git a/tests/dynamic_fixtures/class_method/c/benign.c b/tests/dynamic_fixtures/class_method/c/benign.c index de88741b..c25e91a6 100644 --- a/tests/dynamic_fixtures/class_method/c/benign.c +++ b/tests/dynamic_fixtures/class_method/c/benign.c @@ -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); } } diff --git a/tests/dynamic_fixtures/class_method/c/vuln.c b/tests/dynamic_fixtures/class_method/c/vuln.c index 578270f9..55d78173 100644 --- a/tests/dynamic_fixtures/class_method/c/vuln.c +++ b/tests/dynamic_fixtures/class_method/c/vuln.c @@ -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); } diff --git a/tests/dynamic_fixtures/class_method/cpp/benign.cpp b/tests/dynamic_fixtures/class_method/cpp/benign.cpp index 2fa91fe5..1796f4ef 100644 --- a/tests/dynamic_fixtures/class_method/cpp/benign.cpp +++ b/tests/dynamic_fixtures/class_method/cpp/benign.cpp @@ -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(argv)); + const char* argv[] = { "/usr/bin/true", input.c_str(), nullptr }; + execv("/usr/bin/true", const_cast(argv)); _exit(127); } int status = 0; diff --git a/tests/dynamic_fixtures/class_method/cpp/vuln.cpp b/tests/dynamic_fixtures/class_method/cpp/vuln.cpp index 03f1bc42..d6f843a0 100644 --- a/tests/dynamic_fixtures/class_method/cpp/vuln.cpp +++ b/tests/dynamic_fixtures/class_method/cpp/vuln.cpp @@ -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()); } diff --git a/tests/dynamic_fixtures/class_method/go/benign.go b/tests/dynamic_fixtures/class_method/go/benign.go index c4ce63fd..dcca19b7 100644 --- a/tests/dynamic_fixtures/class_method/go/benign.go +++ b/tests/dynamic_fixtures/class_method/go/benign.go @@ -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) } diff --git a/tests/dynamic_fixtures/class_method/go/vuln.go b/tests/dynamic_fixtures/class_method/go/vuln.go index a96a96eb..e98170fc 100644 --- a/tests/dynamic_fixtures/class_method/go/vuln.go +++ b/tests/dynamic_fixtures/class_method/go/vuln.go @@ -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) } diff --git a/tests/dynamic_fixtures/class_method/java/Benign.java b/tests/dynamic_fixtures/class_method/java/Benign.java index 5b707730..2b103089 100644 --- a/tests/dynamic_fixtures/class_method/java/Benign.java +++ b/tests/dynamic_fixtures/class_method/java/Benign.java @@ -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(); } } } diff --git a/tests/dynamic_fixtures/class_method/java/Vuln.java b/tests/dynamic_fixtures/class_method/java/Vuln.java index 2576908c..b08a14c6 100644 --- a/tests/dynamic_fixtures/class_method/java/Vuln.java +++ b/tests/dynamic_fixtures/class_method/java/Vuln.java @@ -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(); } } } diff --git a/tests/dynamic_fixtures/class_method/javascript/benign.js b/tests/dynamic_fixtures/class_method/javascript/benign.js index af55c490..43c6416a 100644 --- a/tests/dynamic_fixtures/class_method/javascript/benign.js +++ b/tests/dynamic_fixtures/class_method/javascript/benign.js @@ -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(); } } diff --git a/tests/dynamic_fixtures/class_method/javascript/vuln.js b/tests/dynamic_fixtures/class_method/javascript/vuln.js index a87f4b4e..babd01f6 100644 --- a/tests/dynamic_fixtures/class_method/javascript/vuln.js +++ b/tests/dynamic_fixtures/class_method/javascript/vuln.js @@ -9,7 +9,7 @@ class UserService { constructor() {} run(input) { // SINK: untrusted input → shell - return execSync('echo ' + input).toString(); + return execSync('true ' + input).toString(); } } diff --git a/tests/dynamic_fixtures/class_method/php/benign.php b/tests/dynamic_fixtures/class_method/php/benign.php index be03409a..a3fa97c9 100644 --- a/tests/dynamic_fixtures/class_method/php/benign.php +++ b/tests/dynamic_fixtures/class_method/php/benign.php @@ -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)); } } diff --git a/tests/dynamic_fixtures/class_method/php/vuln.php b/tests/dynamic_fixtures/class_method/php/vuln.php index 2da1dabb..300834a0 100644 --- a/tests/dynamic_fixtures/class_method/php/vuln.php +++ b/tests/dynamic_fixtures/class_method/php/vuln.php @@ -9,6 +9,6 @@ class UserService { public function run($input) { // SINK: tainted input → shell. - return shell_exec('echo ' . $input); + return shell_exec('true ' . $input); } } diff --git a/tests/dynamic_fixtures/class_method/ruby/benign.rb b/tests/dynamic_fixtures/class_method/ruby/benign.rb index 13fefd1f..cd0efb3c 100644 --- a/tests/dynamic_fixtures/class_method/ruby/benign.rb +++ b/tests/dynamic_fixtures/class_method/ruby/benign.rb @@ -6,6 +6,6 @@ class UserService end def run(input) - `echo #{Shellwords.escape(input)}` + `true #{Shellwords.escape(input)}` end end diff --git a/tests/dynamic_fixtures/class_method/ruby/vuln.rb b/tests/dynamic_fixtures/class_method/ruby/vuln.rb index 8f2c9a18..29ad0032 100644 --- a/tests/dynamic_fixtures/class_method/ruby/vuln.rb +++ b/tests/dynamic_fixtures/class_method/ruby/vuln.rb @@ -8,6 +8,6 @@ class UserService def run(input) # SINK: tainted input → shell - `echo #{input}` + `true #{input}` end end diff --git a/tests/dynamic_fixtures/class_method/rust/benign.rs b/tests/dynamic_fixtures/class_method/rust/benign.rs index d2c4d35a..49fab724 100644 --- a/tests/dynamic_fixtures/class_method/rust/benign.rs +++ b/tests/dynamic_fixtures/class_method/rust/benign.rs @@ -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"); diff --git a/tests/dynamic_fixtures/class_method/rust/vuln.rs b/tests/dynamic_fixtures/class_method/rust/vuln.rs index 0a751535..09e4d91b 100644 --- a/tests/dynamic_fixtures/class_method/rust/vuln.rs +++ b/tests/dynamic_fixtures/class_method/rust/vuln.rs @@ -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) diff --git a/tests/dynamic_fixtures/class_method/typescript/benign.ts b/tests/dynamic_fixtures/class_method/typescript/benign.ts index 5e6e64d8..faf56378 100644 --- a/tests/dynamic_fixtures/class_method/typescript/benign.ts +++ b/tests/dynamic_fixtures/class_method/typescript/benign.ts @@ -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 }; diff --git a/tests/dynamic_fixtures/class_method/typescript/vuln.ts b/tests/dynamic_fixtures/class_method/typescript/vuln.ts index d163b18f..bb01f5d1 100644 --- a/tests/dynamic_fixtures/class_method/typescript/vuln.ts +++ b/tests/dynamic_fixtures/class_method/typescript/vuln.ts @@ -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 }; diff --git a/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/benign.php b/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/benign.php new file mode 100644 index 00000000..e5b5976a --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/benign.php @@ -0,0 +1,54 @@ +$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; + } + } +} diff --git a/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/vuln.php b/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/vuln.php new file mode 100644 index 00000000..d3cf084f --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/vuln.php @@ -0,0 +1,55 @@ +$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; + } + } +} diff --git a/tests/php_fixtures.rs b/tests/php_fixtures.rs index 8cbdd44b..d2b3c9d1 100644 --- a/tests/php_fixtures.rs +++ b/tests/php_fixtures.rs @@ -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") diff --git a/tests/php_frameworks_corpus.rs b/tests/php_frameworks_corpus.rs index bdc62cbb..0ec6e8d2 100644 --- a/tests/php_frameworks_corpus.rs +++ b/tests/php_frameworks_corpus.rs @@ -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 { + 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); + } + } +}