refactor(dynamic): add cross-file route detection for frameworks, enhance test coverage in PHP and Ruby

This commit is contained in:
elipeter 2026-05-24 19:14:50 -05:00
parent 43ab4aa469
commit 0e8c900078
22 changed files with 1208 additions and 134 deletions

View file

@ -166,6 +166,16 @@ fn class_method_python_dispatch_reads_payload_and_invokes_method() {
assert!(h.source.contains("_nyx_resolve_annotation"));
}
#[test]
fn class_method_js_dispatch_builds_recursive_receiver() {
let spec = make_spec(Lang::JavaScript);
let h = lang::emit(&spec).expect("emit ok");
assert!(h.source.contains("_nyxBuildReceiver(_Cls, 3)"));
assert!(h.source.contains("_nyxConstructorParams"));
assert!(h.source.contains("_nyxExportedClass"));
assert!(h.source.contains("depth = 3"));
}
#[test]
fn class_method_java_emits_reflective_dispatch() {
let spec = make_spec(Lang::Java);
@ -285,6 +295,17 @@ mod e2e_phase_19 {
cap: Cap::CODE_EXEC,
bins: &["node"],
},
Case {
lang: Lang::JavaScript,
fixture_dir: "javascript_recursive_deps",
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",
@ -296,6 +317,17 @@ mod e2e_phase_19 {
cap: Cap::CODE_EXEC,
bins: &["node"],
},
Case {
lang: Lang::TypeScript,
fixture_dir: "typescript_recursive_deps",
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",

View file

@ -0,0 +1,29 @@
'use strict';
class ShellRunner {
run(_command) {
return 'safe';
}
}
class UserRepository {
constructor(shellRunner) {
this.shellRunner = shellRunner;
}
find(input) {
return this.shellRunner.run(input);
}
}
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
run(input) {
return this.userRepository.find(input);
}
}
module.exports = { UserService, UserRepository, ShellRunner };

View file

@ -0,0 +1,30 @@
'use strict';
const { execSync } = require('child_process');
class ShellRunner {
run(command) {
return execSync('true ' + command).toString();
}
}
class UserRepository {
constructor(shellRunner) {
this.shellRunner = shellRunner;
}
find(input) {
return this.shellRunner.run(input);
}
}
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
run(input) {
return this.userRepository.find(input);
}
}
module.exports = { UserService, UserRepository, ShellRunner };

View file

@ -0,0 +1,29 @@
'use strict';
class ShellRunner {
run(_command) {
return 'safe';
}
}
class UserRepository {
constructor(shellRunner) {
this.shellRunner = shellRunner;
}
find(input) {
return this.shellRunner.run(input);
}
}
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
run(input) {
return this.userRepository.find(input);
}
}
module.exports = { UserService, UserRepository, ShellRunner };

View file

@ -0,0 +1,30 @@
'use strict';
const { execSync } = require('child_process');
class ShellRunner {
run(command) {
return execSync('true ' + command).toString();
}
}
class UserRepository {
constructor(shellRunner) {
this.shellRunner = shellRunner;
}
find(input) {
return this.shellRunner.run(input);
}
}
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
run(input) {
return this.userRepository.find(input);
}
}
module.exports = { UserService, UserRepository, ShellRunner };

View file

@ -0,0 +1,4 @@
<?php
use CodeIgniter\Router\RouteCollection;
$routes->get('users/(:num)', 'UserController::show');

View file

@ -0,0 +1,10 @@
<?php
namespace App\Controllers;
class UserController extends BaseController
{
public function show($num)
{
return $num;
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
class UserController
{
public function show($id)
{
return $id;
}
}

View file

@ -0,0 +1,5 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\UserController;
Route::get('/users/{id}', [UserController::class, 'show'])->middleware('auth');

View file

@ -0,0 +1,4 @@
report_show:
path: /reports/{id}
controller: App\Controller\ReportController::show
methods: [POST]

View file

@ -0,0 +1,12 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class ReportController extends AbstractController
{
public function show($id)
{
return $id;
}
}

View file

@ -0,0 +1,11 @@
require "hanami/action"
module Books
class Show
include Hanami::Action
def call(req)
req.params[:id]
end
end
end

View file

@ -0,0 +1,3 @@
Hanami.app.routes do
get "/books/:id", to: "books.show"
end

View file

@ -13,7 +13,10 @@
mod common;
use nyx_scanner::dynamic::framework::{HttpMethod, ParamSource, detect_binding};
use nyx_scanner::dynamic::framework::{
FrameworkDetectionContext, HttpMethod, ParamSource, ProjectFileIndex, detect_binding,
detect_binding_with_project_context,
};
use nyx_scanner::evidence::EntryKind;
use nyx_scanner::summary::FuncSummary;
use nyx_scanner::symbol::Lang;
@ -115,6 +118,30 @@ fn symfony_benign_fixture_binds_same_route_shape() {
assert_eq!(route.path, "/run");
}
#[test]
fn symfony_yaml_fixture_binds_cross_file_route() {
let path =
"tests/dynamic_fixtures/php_frameworks/symfony_yaml/src/Controller/ReportController.php";
let routes = "tests/dynamic_fixtures/php_frameworks/symfony_yaml/config/routes.yaml";
let bytes = std::fs::read(path).expect("symfony yaml controller fixture exists");
let route_bytes = std::fs::read(routes).expect("symfony yaml routes fixture exists");
let tree = parse_php(&bytes);
let summary = summary_for("show", path);
let mut project_files = ProjectFileIndex::new();
project_files.insert("config/routes.yaml", route_bytes);
let context = FrameworkDetectionContext {
ssa_summary: None,
project_files: &project_files,
};
let binding =
detect_binding_with_project_context(&summary, context, tree.root_node(), &bytes, Lang::Php)
.expect("symfony adapter must bind through config/routes.yaml");
assert_eq!(binding.adapter, "php-symfony");
let route = binding.route.as_ref().expect("route");
assert_eq!(route.path, "/reports/{id}");
assert_eq!(route.method, HttpMethod::POST);
}
#[test]
fn codeigniter_vuln_fixture_binds_route() {
let path = "tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php";
@ -143,6 +170,57 @@ fn codeigniter_benign_fixture_binds_same_route_shape() {
assert_eq!(route.path, "run");
}
#[test]
fn laravel_routes_fixture_binds_cross_file_route() {
let path = "tests/dynamic_fixtures/php_frameworks/laravel_routes/app/Http/Controllers/UserController.php";
let routes = "tests/dynamic_fixtures/php_frameworks/laravel_routes/routes/web.php";
let bytes = std::fs::read(path).expect("laravel controller fixture exists");
let route_bytes = std::fs::read(routes).expect("laravel routes fixture exists");
let tree = parse_php(&bytes);
let summary = summary_for("show", path);
let mut project_files = ProjectFileIndex::new();
project_files.insert("routes/web.php", route_bytes);
let context = FrameworkDetectionContext {
ssa_summary: None,
project_files: &project_files,
};
let binding =
detect_binding_with_project_context(&summary, context, tree.root_node(), &bytes, Lang::Php)
.expect("laravel adapter must bind through routes/web.php");
assert_eq!(binding.adapter, "php-laravel");
let route = binding.route.as_ref().expect("route");
assert_eq!(route.path, "/users/{id}");
assert_eq!(route.method, HttpMethod::GET);
assert!(
binding.middleware.iter().any(|m| m.name == "auth"),
"expected auth middleware from route config, got {:?}",
binding.middleware
);
}
#[test]
fn codeigniter_config_fixture_binds_cross_file_route() {
let path = "tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Controllers/UserController.php";
let routes = "tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Config/Routes.php";
let bytes = std::fs::read(path).expect("codeigniter controller fixture exists");
let route_bytes = std::fs::read(routes).expect("codeigniter routes fixture exists");
let tree = parse_php(&bytes);
let summary = summary_for("show", path);
let mut project_files = ProjectFileIndex::new();
project_files.insert("app/Config/Routes.php", route_bytes);
let context = FrameworkDetectionContext {
ssa_summary: None,
project_files: &project_files,
};
let binding =
detect_binding_with_project_context(&summary, context, tree.root_node(), &bytes, Lang::Php)
.expect("codeigniter adapter must bind through app/Config/Routes.php");
assert_eq!(binding.adapter, "php-codeigniter");
let route = binding.route.as_ref().expect("route");
assert_eq!(route.path, "users/(:num)");
assert_eq!(route.method, HttpMethod::GET);
}
#[test]
fn laravel_adapter_ignores_helper_method() {
// `helper` is declared but not referenced in any `Route::*` call.

View file

@ -11,7 +11,10 @@
#![cfg(feature = "dynamic")]
use nyx_scanner::dynamic::framework::{HttpMethod, ParamSource, detect_binding};
use nyx_scanner::dynamic::framework::{
FrameworkDetectionContext, HttpMethod, ParamSource, ProjectFileIndex, detect_binding,
detect_binding_with_project_context,
};
use nyx_scanner::evidence::EntryKind;
use nyx_scanner::summary::FuncSummary;
use nyx_scanner::symbol::Lang;
@ -147,6 +150,34 @@ fn hanami_benign_fixture_binds_same_route_shape() {
assert_eq!(route.path, "/run");
}
#[test]
fn hanami_config_routes_fixture_binds_cross_file_route() {
let path = "tests/dynamic_fixtures/ruby/hanami_config_routes/app/actions/books/show.rb";
let routes = "tests/dynamic_fixtures/ruby/hanami_config_routes/config/routes.rb";
let bytes = std::fs::read(path).expect("hanami action fixture exists");
let route_bytes = std::fs::read(routes).expect("hanami routes fixture exists");
let tree = parse_ruby(&bytes);
let summary = summary_for("call", path);
let mut project_files = ProjectFileIndex::new();
project_files.insert("config/routes.rb", route_bytes);
let context = FrameworkDetectionContext {
ssa_summary: None,
project_files: &project_files,
};
let binding = detect_binding_with_project_context(
&summary,
context,
tree.root_node(),
&bytes,
Lang::Ruby,
)
.expect("hanami adapter must bind through config/routes.rb");
assert_eq!(binding.adapter, "ruby-hanami");
let route = binding.route.as_ref().expect("route");
assert_eq!(route.method, HttpMethod::GET);
assert_eq!(route.path, "/books/:id");
}
// ── Cross-adapter disambiguation ─────────────────────────────────────────────
#[test]