From 7ddb7b90e5668c8309b6d45ca2a1c8233f665800 Mon Sep 17 00:00:00 2001 From: pitboss Date: Mon, 18 May 2026 16:33:19 -0500 Subject: [PATCH] =?UTF-8?q?[pitboss]=20phase=2016:=20Track=20L.14=20?= =?UTF-8?q?=E2=80=94=20Laravel=20/=20Symfony=20/=20CodeIgniter=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dynamic/framework/adapters/mod.rs | 7 + .../framework/adapters/php_codeigniter.rs | 136 +++ src/dynamic/framework/adapters/php_laravel.rs | 159 ++++ src/dynamic/framework/adapters/php_routes.rs | 817 ++++++++++++++++++ src/dynamic/framework/adapters/php_symfony.rs | 181 ++++ src/dynamic/framework/mod.rs | 18 +- src/dynamic/framework/registry.rs | 3 + src/dynamic/lang/php.rs | 143 ++- .../php_frameworks/codeigniter/benign.php | 18 + .../php_frameworks/codeigniter/composer.json | 7 + .../php_frameworks/codeigniter/vuln.php | 20 + .../php_frameworks/laravel/benign.php | 18 + .../php_frameworks/laravel/composer.json | 7 + .../php_frameworks/laravel/vuln.php | 20 + .../php_frameworks/symfony/benign.php | 21 + .../php_frameworks/symfony/composer.json | 9 + .../php_frameworks/symfony/vuln.php | 21 + tests/php_frameworks_corpus.rs | 137 +++ 18 files changed, 1722 insertions(+), 20 deletions(-) create mode 100644 src/dynamic/framework/adapters/php_codeigniter.rs create mode 100644 src/dynamic/framework/adapters/php_laravel.rs create mode 100644 src/dynamic/framework/adapters/php_routes.rs create mode 100644 src/dynamic/framework/adapters/php_symfony.rs create mode 100644 tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php create mode 100644 tests/dynamic_fixtures/php_frameworks/codeigniter/composer.json create mode 100644 tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php create mode 100644 tests/dynamic_fixtures/php_frameworks/laravel/benign.php create mode 100644 tests/dynamic_fixtures/php_frameworks/laravel/composer.json create mode 100644 tests/dynamic_fixtures/php_frameworks/laravel/vuln.php create mode 100644 tests/dynamic_fixtures/php_frameworks/symfony/benign.php create mode 100644 tests/dynamic_fixtures/php_frameworks/symfony/composer.json create mode 100644 tests/dynamic_fixtures/php_frameworks/symfony/vuln.php create mode 100644 tests/php_frameworks_corpus.rs diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index e9db31c8..64f0e911 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -34,6 +34,10 @@ pub mod js_routes; pub mod ldap_php; pub mod ldap_python; pub mod ldap_spring; +pub mod php_codeigniter; +pub mod php_laravel; +pub mod php_routes; +pub mod php_symfony; pub mod php_twig; pub mod php_unserialize; pub mod pp_json_deep_assign; @@ -90,6 +94,9 @@ pub use js_nest::{JsNestAdapter, TsNestAdapter}; pub use ldap_php::LdapPhpAdapter; pub use ldap_python::LdapPythonAdapter; pub use ldap_spring::LdapSpringAdapter; +pub use php_codeigniter::PhpCodeIgniterAdapter; +pub use php_laravel::PhpLaravelAdapter; +pub use php_symfony::PhpSymfonyAdapter; pub use php_twig::PhpTwigAdapter; pub use php_unserialize::PhpUnserializeAdapter; pub use pp_json_deep_assign::{PpJsonDeepAssignJsAdapter, PpJsonDeepAssignTsAdapter}; diff --git a/src/dynamic/framework/adapters/php_codeigniter.rs b/src/dynamic/framework/adapters/php_codeigniter.rs new file mode 100644 index 00000000..1515e94d --- /dev/null +++ b/src/dynamic/framework/adapters/php_codeigniter.rs @@ -0,0 +1,136 @@ +//! CodeIgniter [`super::super::FrameworkAdapter`] (Phase 16 — Track L.14). +//! +//! Recognises `$routes->get('users/(:num)', 'UserController::show')` / +//! `$routes->post(...)` route declarations declared inside the +//! conventional `app/Config/Routes.php` plus the matching controller +//! method declared inside an `extends BaseController` class. +//! +//! CodeIgniter 4's placeholder vocabulary covers `(:num)`, +//! `(:alpha)`, `(:alphanum)`, `(:any)`, `(:segment)`, `(:hash)` — +//! [`super::php_routes::extract_php_path_placeholders`] returns the +//! inner name (after the `:`) for each so a `$id` formal whose name +//! matches the placeholder binds as [`super::super::ParamSource::PathSegment`]. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape}; +#[cfg(test)] +use crate::dynamic::framework::HttpMethod; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::php_routes::{ + bind_php_path_params, find_codeigniter_route, find_php_function, php_class_name, + php_formal_names, source_imports_codeigniter, +}; + +pub struct PhpCodeIgniterAdapter; + +const ADAPTER_NAME: &str = "php-codeigniter"; + +impl FrameworkAdapter for PhpCodeIgniterAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Php + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + if !source_imports_codeigniter(file_bytes) { + return None; + } + let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?; + let controller = class.and_then(|c| php_class_name(c, file_bytes)); + + let (method, path) = + find_codeigniter_route(ast, file_bytes, &summary.name, controller)?; + + let formals = php_formal_names(func_node, file_bytes); + let request_params = bind_php_path_params(&formals, &path); + + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { method, path }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::ParamSource; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "php".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_get_route_with_double_colon_callable() { + let src: &[u8] = b"get('users/(:num)', 'UserController::show');\nclass UserController extends BaseController {\n public function show($num) { return $num; }\n}\n"; + let tree = parse(src); + let binding = PhpCodeIgniterAdapter + .detect(&summary("show"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.adapter, "php-codeigniter"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.expect("route"); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "users/(:num)"); + let num = binding + .request_params + .iter() + .find(|p| p.name == "num") + .unwrap(); + assert!(matches!(num.source, ParamSource::PathSegment(_))); + } + + #[test] + fn fires_on_post_with_closure_callable() { + let src: &[u8] = b"post('save', function ($payload) { return $payload; });\nfunction save($payload) { return $payload; }\n"; + let tree = parse(src); + let binding = PhpCodeIgniterAdapter + .detect(&summary("save"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.unwrap().method, HttpMethod::POST); + } + + #[test] + fn skips_when_codeigniter_not_imported() { + let src: &[u8] = b"get('users/(:num)', 'UserController::show');\n"; + let tree = parse(src); + assert!(PhpCodeIgniterAdapter + .detect(&summary("show"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_when_callable_does_not_reference_method() { + let src: &[u8] = b"get('users/(:num)', 'UserController::show');\nclass UserController extends BaseController {\n public function helper($x) { return $x; }\n}\n"; + let tree = parse(src); + assert!(PhpCodeIgniterAdapter + .detect(&summary("helper"), tree.root_node(), src) + .is_none()); + } +} diff --git a/src/dynamic/framework/adapters/php_laravel.rs b/src/dynamic/framework/adapters/php_laravel.rs new file mode 100644 index 00000000..a1b70534 --- /dev/null +++ b/src/dynamic/framework/adapters/php_laravel.rs @@ -0,0 +1,159 @@ +//! Laravel [`super::super::FrameworkAdapter`] (Phase 16 — Track L.14). +//! +//! Two recognition shapes: +//! +//! - Closure route: `Route::get('/path', function ($payload) {…})` +//! declared at top level — the closure's function name is the +//! enclosing summary's name (the static-analysis side already +//! stamps anonymous closures with a synthetic name slot). +//! - Controller-method route: +//! `Route::get('/path', 'UserController@show')` / +//! `Route::post('/path', [UserController::class, 'save'])` plus +//! a `class UserController { public function show($id) {…} }` +//! declaration in the same file. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape}; +#[cfg(test)] +use crate::dynamic::framework::HttpMethod; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::php_routes::{ + bind_php_path_params, find_laravel_static_route, find_php_function, php_class_name, + php_formal_names, source_imports_laravel, +}; + +pub struct PhpLaravelAdapter; + +const ADAPTER_NAME: &str = "php-laravel"; + +impl FrameworkAdapter for PhpLaravelAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Php + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + if !source_imports_laravel(file_bytes) { + return None; + } + let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?; + let controller = class.and_then(|c| php_class_name(c, file_bytes)); + + let (method, path) = + find_laravel_static_route(ast, file_bytes, &summary.name, controller)?; + + let formals = php_formal_names(func_node, file_bytes); + let request_params = bind_php_path_params(&formals, &path); + + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { method, path }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::ParamSource; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "php".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_route_get_with_controller_method() { + let src: &[u8] = b"get('users/(:num)', 'Controller::method')` member +//! calls, and bind formals to request slots. Centralising the +//! helpers here keeps the three adapters terse and lets every +//! framework share the same placeholder-binding semantics. + +use crate::dynamic::framework::{HttpMethod, ParamBinding, ParamSource}; +use tree_sitter::Node; + +/// True when `bytes` carries any of the well-known Laravel import +/// stanzas (the `Route::` facade, `Illuminate\…` namespace, the +/// `Illuminate\Routing\Router` class, the convention-based +/// `app/Http/Controllers` base class, or a `# nyx-shape: laravel` +/// annotation). +pub fn source_imports_laravel(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"Illuminate\\Routing", + b"Illuminate\\Http", + b"Illuminate\\Support\\Facades\\Route", + b"use Illuminate\\", + b"Route::get(", + b"Route::post(", + b"Route::put(", + b"Route::patch(", + b"Route::delete(", + b"Route::any(", + b"Route::match(", + b"App\\Http\\Controllers", + b"// nyx-shape: laravel", + ], + ) +} + +/// True when `bytes` carries any of the well-known Symfony import +/// stanzas (the `Symfony\…` namespace, the `#[Route]` attribute, the +/// `AbstractController` base class). +pub fn source_imports_symfony(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"Symfony\\Component\\Routing", + b"Symfony\\Component\\HttpFoundation", + b"Symfony\\Bundle\\FrameworkBundle", + b"use Symfony\\", + b"Symfony\\Component\\Routing\\Annotation\\Route", + b"Symfony\\Component\\Routing\\Attribute\\Route", + b"AbstractController", + b"// nyx-shape: symfony", + ], + ) +} + +/// True when `bytes` carries any of the well-known CodeIgniter +/// import stanzas (the `CodeIgniter\…` namespace, the `$routes` +/// service used inside `app/Config/Routes.php`, the convention-based +/// `extends BaseController`, or a `# nyx-shape: codeigniter` +/// annotation). +pub fn source_imports_codeigniter(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"CodeIgniter\\Router", + b"CodeIgniter\\HTTP", + b"CodeIgniter\\Controller", + b"use CodeIgniter\\", + b"$routes->get(", + b"$routes->post(", + b"$routes->put(", + b"$routes->patch(", + b"$routes->delete(", + b"$routes->add(", + b"extends BaseController", + b"// nyx-shape: codeigniter", + ], + ) +} + +fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool { + needles + .iter() + .any(|n| haystack.windows(n.len()).any(|w| w == *n)) +} + +/// Find a top-level `function_definition` or a `method_declaration` +/// whose `name` field equals `target`. Returns +/// `(node, enclosing_class_decl)` — the class is `Some` when the +/// match is a method. +pub fn find_php_function<'a>( + root: Node<'a>, + bytes: &'a [u8], + target: &str, +) -> Option<(Node<'a>, Option>)> { + let mut hit: Option<(Node<'a>, Option>)> = None; + walk(root, bytes, target, None, &mut hit); + hit +} + +fn walk<'a>( + node: Node<'a>, + bytes: &'a [u8], + target: &str, + enclosing_class: Option>, + out: &mut Option<(Node<'a>, Option>)>, +) { + if out.is_some() { + return; + } + let here_class = if node.kind() == "class_declaration" { + Some(node) + } else { + enclosing_class + }; + if matches!(node.kind(), "function_definition" | "method_declaration") + && let Some(name) = node + .child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()) + { + if name == target { + let klass = if node.kind() == "method_declaration" { + here_class + } else { + None + }; + *out = Some((node, klass)); + return; + } + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + walk(child, bytes, target, here_class, out); + } +} + +/// Enumerate formal parameter names from a `function_definition` / +/// `method_declaration` node. Strips the leading `$` sigil from each +/// `variable_name` so `$id` → `id`. +pub fn php_formal_names(func: Node<'_>, bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + let Some(parameters) = func.child_by_field_name("parameters") else { + return out; + }; + let mut cur = parameters.walk(); + for fp in parameters.named_children(&mut cur) { + if fp.kind() != "simple_parameter" && fp.kind() != "variadic_parameter" { + continue; + } + let Some(name) = fp.child_by_field_name("name") else { + continue; + }; + let Ok(text) = name.utf8_text(bytes) else { + continue; + }; + let trimmed = text.trim_start_matches('$').to_owned(); + if !trimmed.is_empty() { + out.push(trimmed); + } + } + out +} + +/// Read the simple class name from a `class_declaration` node — its +/// `name` field, which is a `name` leaf node. +pub fn php_class_name<'a>(class: Node<'a>, bytes: &'a [u8]) -> Option<&'a str> { + class + .child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()) +} + +/// Walk the `attribute_list` attached to a `class_declaration`, +/// `method_declaration`, or `function_definition` and invoke `visit` +/// for each contained `attribute`. The visitor receives the +/// `attribute` node + the attribute's leaf name (the last segment of +/// the qualified name — `Symfony\…\Route` → `"Route"`). +pub fn iter_php_attributes<'a, F>(node: Node<'a>, bytes: &'a [u8], mut visit: F) +where + F: FnMut(Node<'a>, &str), +{ + let Some(attrs) = node.child_by_field_name("attributes") else { + return; + }; + let mut gc = attrs.walk(); + for group in attrs.named_children(&mut gc) { + if group.kind() != "attribute_group" { + continue; + } + let mut ac = group.walk(); + for ann in group.named_children(&mut ac) { + if ann.kind() != "attribute" { + continue; + } + if let Some(leaf) = attribute_leaf_name(ann, bytes) { + visit(ann, leaf); + } + } + } +} + +fn attribute_leaf_name<'a>(ann: Node<'a>, bytes: &'a [u8]) -> Option<&'a str> { + let mut cur = ann.walk(); + for child in ann.named_children(&mut cur) { + if matches!(child.kind(), "name" | "qualified_name" | "relative_name") { + let text = child.utf8_text(bytes).ok()?; + return Some(text.rsplit('\\').next().unwrap_or(text)); + } + } + None +} + +/// First positional string-argument from an `attribute` / +/// `function_call_expression` / `member_call_expression` / +/// `scoped_call_expression` arguments node. +pub fn first_php_string_arg(arguments: Node<'_>, bytes: &[u8]) -> Option { + let mut cur = arguments.walk(); + for arg in arguments.named_children(&mut cur) { + if arg.kind() != "argument" { + continue; + } + if arg.child_by_field_name("name").is_some() { + continue; + } + if let Some(value) = arg.named_child(0) + && let Some(s) = string_content(value, bytes) + { + return Some(s); + } + } + None +} + +/// Read a named-argument's string value (e.g. `path: "/x"` → +/// `Some("/x")`). +pub fn named_string_arg(arguments: Node<'_>, bytes: &[u8], key: &str) -> Option { + let mut cur = arguments.walk(); + for arg in arguments.named_children(&mut cur) { + if arg.kind() != "argument" { + continue; + } + let Some(name_node) = arg.child_by_field_name("name") else { + continue; + }; + if name_node.utf8_text(bytes).ok() != Some(key) { + continue; + } + if let Some(value) = named_arg_value(arg, name_node) + && let Some(s) = string_content(value, bytes) + { + return Some(s); + } + } + None +} + +/// Parse a Symfony-style `methods: ['POST', 'PUT']` named argument +/// from an `arguments` node and return the first method, or `None` +/// when the kwarg is missing. +pub fn methods_named_arg(arguments: Node<'_>, bytes: &[u8]) -> Option { + let mut cur = arguments.walk(); + for arg in arguments.named_children(&mut cur) { + if arg.kind() != "argument" { + continue; + } + let Some(name_node) = arg.child_by_field_name("name") else { + continue; + }; + if name_node.utf8_text(bytes).ok() != Some("methods") { + continue; + } + let Some(value) = named_arg_value(arg, name_node) else { + continue; + }; + let raw = value.utf8_text(bytes).ok()?; + for verb in ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] { + if raw.contains(verb) { + return HttpMethod::from_ident(verb); + } + } + } + None +} + +/// Inside a named `argument` node (one with a `name` field), pick the +/// value child — the first named child whose byte range does not +/// coincide with the `name` field's range. Tree-sitter PHP exposes +/// both the field-name leaf and the value as named children, so +/// `arg.named_child(0)` would otherwise return the leaf. +fn named_arg_value<'a>(arg: Node<'a>, name_node: Node<'a>) -> Option> { + let name_range = name_node.byte_range(); + let mut cur = arg.walk(); + arg.named_children(&mut cur) + .find(|c| c.byte_range() != name_range) +} + +/// Read the raw string content of a `string` / `encapsed_string` / +/// `name` value node, stripping the surrounding quotes (single, +/// double, or backtick). +pub fn string_content(node: Node<'_>, bytes: &[u8]) -> Option { + let raw = node.utf8_text(bytes).ok()?; + let trimmed = raw.trim(); + let stripped = trimmed + .trim_matches('\'') + .trim_matches('"') + .trim_matches('`'); + if stripped == trimmed { + return None; + } + Some(stripped.to_owned()) +} + +/// Parse a Laravel/Symfony brace placeholder syntax (`/users/{id}` → +/// `id`; `/u/{id?}` → `id`) and a CodeIgniter parenthesised +/// placeholder syntax (`users/(:num)`, `users/(:any)`, +/// `users/(:segment)`). Brace placeholders win when both are +/// present. +pub fn extract_php_path_placeholders(path: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut push = |name: String| { + if !name.is_empty() && !out.iter().any(|n| n == &name) { + out.push(name); + } + }; + let bytes = path.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'{' => { + if let Some(end) = bytes[i + 1..].iter().position(|&b| b == b'}') { + let inner = &path[i + 1..i + 1 + end]; + let stripped = inner.trim_end_matches('?'); + let name = stripped.split(':').next().unwrap_or(stripped).trim(); + push(name.to_owned()); + i += end + 2; + continue; + } + } + b'(' => { + if let Some(end) = bytes[i + 1..].iter().position(|&b| b == b')') { + let inner = &path[i + 1..i + 1 + end]; + if let Some(name) = inner.strip_prefix(':') { + push(name.trim().to_owned()); + } + i += end + 2; + continue; + } + } + _ => {} + } + i += 1; + } + out +} + +/// Bind formals to request slots given a route path template. +/// +/// A formal whose name matches a placeholder becomes a +/// [`ParamSource::PathSegment`]. `request` / `req` / `response` / +/// `res` go to [`ParamSource::Implicit`] (the Laravel +/// `IlluminateRequest`, Symfony `Request`, CodeIgniter +/// `IncomingRequest`). Every other formal falls back to a +/// [`ParamSource::QueryParam`] of the same name. +pub fn bind_php_path_params(formals: &[String], path: &str) -> Vec { + let placeholders = extract_php_path_placeholders(path); + formals + .iter() + .enumerate() + .map(|(idx, name)| { + let source = if is_implicit_formal(name) { + ParamSource::Implicit + } else if placeholders.iter().any(|p| p == name) { + ParamSource::PathSegment(name.clone()) + } else { + ParamSource::QueryParam(name.clone()) + }; + ParamBinding { + index: idx, + name: name.clone(), + source, + } + }) + .collect() +} + +fn is_implicit_formal(name: &str) -> bool { + matches!(name, "request" | "req" | "response" | "res") +} + +/// Walk every `scoped_call_expression` in the file looking for a +/// `Route::get('/path', ...)` / `Route::post(...)` mapping that +/// references `target` either as a string callable (`'Controller@method'`, +/// `'Controller::method'`, `[Controller::class, 'method']`) or as a +/// closure declared inline (matched by callable arg-position only — +/// the adapter then accepts the binding because the surrounding +/// adapter has already matched the function's name to a Laravel route +/// shape). Returns `(method, path)` on first match. +pub fn find_laravel_static_route<'a>( + root: Node<'a>, + bytes: &'a [u8], + target: &str, + controller: Option<&str>, +) -> Option<(HttpMethod, String)> { + let mut hit: Option<(HttpMethod, String)> = None; + visit_laravel_routes(root, bytes, target, controller, &mut hit); + hit +} + +fn visit_laravel_routes<'a>( + node: Node<'a>, + bytes: &'a [u8], + target: &str, + controller: Option<&str>, + out: &mut Option<(HttpMethod, String)>, +) { + if out.is_some() { + return; + } + if node.kind() == "scoped_call_expression" + && let Some(found) = try_laravel_route(node, bytes, target, controller) + { + *out = Some(found); + return; + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + visit_laravel_routes(child, bytes, target, controller, out); + } +} + +fn try_laravel_route<'a>( + call: Node<'a>, + bytes: &'a [u8], + target: &str, + controller: Option<&str>, +) -> Option<(HttpMethod, String)> { + let scope = call.child_by_field_name("scope")?.utf8_text(bytes).ok()?; + let scope_leaf = scope.rsplit('\\').next().unwrap_or(scope); + if scope_leaf != "Route" { + return None; + } + let verb_node = call.child_by_field_name("name")?.utf8_text(bytes).ok()?; + let method = verb_method(verb_node)?; + let args = call.child_by_field_name("arguments")?; + let path = first_php_string_arg(args, bytes)?; + if !laravel_callable_matches(args, bytes, target, controller) { + return None; + } + Some((method, path)) +} + +/// Check the second positional arg of a `Route::verb('/x', ...)` call +/// against `target` (the action method name). Accepts: +/// - Closures (treated as a wildcard — surrounding adapter has +/// already matched the function name) +/// - `'Controller@method'` / `'Controller::method'` strings +/// - `[ Controller::class, 'method' ]` arrays +fn laravel_callable_matches( + arguments: Node<'_>, + bytes: &[u8], + target: &str, + controller: Option<&str>, +) -> bool { + let mut cur = arguments.walk(); + let mut positional: Vec> = Vec::new(); + for arg in arguments.named_children(&mut cur) { + if arg.kind() != "argument" { + continue; + } + if arg.child_by_field_name("name").is_some() { + continue; + } + positional.push(arg); + } + let Some(callable_arg) = positional.get(1) else { + return false; + }; + let Some(value) = callable_arg.named_child(0) else { + return false; + }; + match value.kind() { + "anonymous_function" | "anonymous_function_creation_expression" | "arrow_function" => true, + "string" | "encapsed_string" => { + let Some(literal) = string_content(value, bytes) else { + return false; + }; + let (ctrl, act) = split_laravel_callable(&literal); + if act != target { + return false; + } + match controller { + Some(c) => ctrl.as_deref() == Some(c), + None => true, + } + } + "array_creation_expression" => { + let Some((ctrl, action)) = parse_array_callable(value, bytes) else { + return false; + }; + if action != target { + return false; + } + match controller { + Some(c) => ctrl.as_deref() == Some(c), + None => true, + } + } + _ => false, + } +} + +fn parse_array_callable<'a>( + array: Node<'a>, + bytes: &'a [u8], +) -> Option<(Option, String)> { + let mut cur = array.walk(); + let elements: Vec> = array + .named_children(&mut cur) + .filter(|c| c.kind() == "array_element_initializer") + .collect(); + if elements.len() < 2 { + return None; + } + let action_value = elements[1].named_child(0)?; + let action = string_content(action_value, bytes)?; + let ctrl_text = elements[0].utf8_text(bytes).ok()?.trim(); + let ctrl = ctrl_text + .strip_suffix("::class") + .map(|s| leaf(s).to_owned()); + Some((ctrl, action)) +} + +fn split_laravel_callable(literal: &str) -> (Option, String) { + if let Some((ctrl, act)) = literal.split_once('@') { + return (Some(leaf(ctrl).to_owned()), act.to_owned()); + } + if let Some((ctrl, act)) = literal.rsplit_once("::") { + return (Some(leaf(ctrl).to_owned()), act.to_owned()); + } + (None, literal.to_owned()) +} + +fn leaf(qualified: &str) -> &str { + let last_backslash = qualified.rsplit('\\').next().unwrap_or(qualified); + last_backslash + .rsplit("::") + .next() + .unwrap_or(last_backslash) +} + +fn verb_method(verb: &str) -> Option { + match verb { + "get" => Some(HttpMethod::GET), + "post" => Some(HttpMethod::POST), + "put" => Some(HttpMethod::PUT), + "patch" => Some(HttpMethod::PATCH), + "delete" => Some(HttpMethod::DELETE), + "options" => Some(HttpMethod::OPTIONS), + "head" => Some(HttpMethod::HEAD), + "any" | "match" => Some(HttpMethod::GET), + _ => None, + } +} + +/// Walk every `member_call_expression` in the file looking for a +/// CodeIgniter `$routes->get('users/(:num)', 'Controller::method')` +/// mapping that references `target` as the callable argument. +/// Returns `(method, path)` on first match. +pub fn find_codeigniter_route<'a>( + root: Node<'a>, + bytes: &'a [u8], + target: &str, + controller: Option<&str>, +) -> Option<(HttpMethod, String)> { + let mut hit: Option<(HttpMethod, String)> = None; + visit_codeigniter_routes(root, bytes, target, controller, &mut hit); + hit +} + +fn visit_codeigniter_routes<'a>( + node: Node<'a>, + bytes: &'a [u8], + target: &str, + controller: Option<&str>, + out: &mut Option<(HttpMethod, String)>, +) { + if out.is_some() { + return; + } + if node.kind() == "member_call_expression" + && let Some(found) = try_codeigniter_route(node, bytes, target, controller) + { + *out = Some(found); + return; + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + visit_codeigniter_routes(child, bytes, target, controller, out); + } +} + +fn try_codeigniter_route<'a>( + call: Node<'a>, + bytes: &'a [u8], + target: &str, + controller: Option<&str>, +) -> Option<(HttpMethod, String)> { + let object = call.child_by_field_name("object")?.utf8_text(bytes).ok()?; + if object.trim_start_matches('$').trim() != "routes" { + return None; + } + let verb = call.child_by_field_name("name")?.utf8_text(bytes).ok()?; + let method = verb_method(verb)?; + let args = call.child_by_field_name("arguments")?; + let path = first_php_string_arg(args, bytes)?; + if !codeigniter_callable_matches(args, bytes, target, controller) { + return None; + } + Some((method, path)) +} + +fn codeigniter_callable_matches( + arguments: Node<'_>, + bytes: &[u8], + target: &str, + controller: Option<&str>, +) -> bool { + let mut cur = arguments.walk(); + let mut positional: Vec> = Vec::new(); + for arg in arguments.named_children(&mut cur) { + if arg.kind() != "argument" { + continue; + } + if arg.child_by_field_name("name").is_some() { + continue; + } + positional.push(arg); + } + let Some(callable_arg) = positional.get(1) else { + return false; + }; + let Some(value) = callable_arg.named_child(0) else { + return false; + }; + match value.kind() { + "anonymous_function" | "anonymous_function_creation_expression" | "arrow_function" => true, + "string" | "encapsed_string" => { + let Some(literal) = string_content(value, bytes) else { + return false; + }; + let (ctrl, act) = literal + .rsplit_once("::") + .map(|(c, a)| (Some(leaf(c).to_owned()), a.to_owned())) + .unwrap_or((None, literal)); + if act != target { + return false; + } + match controller { + Some(c) => ctrl.as_deref() == Some(c), + None => true, + } + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn finds_top_level_function() { + let src: &[u8] = b" = None; + let mut hit_path: Option = None; + iter_php_attributes(method, src, |ann, name| { + hit_name = Some(name.to_owned()); + let args = ann.child_by_field_name("parameters").unwrap(); + hit_path = first_php_string_arg(args, src); + }); + assert_eq!(hit_name.as_deref(), Some("Route")); + assert_eq!(hit_path.as_deref(), Some("/x")); + } + + #[test] + fn iter_attributes_reads_named_methods_kwarg() { + let src: &[u8] = b" = None; + iter_php_attributes(method, src, |ann, _| { + let args = ann.child_by_field_name("parameters").unwrap(); + verb = methods_named_arg(args, src); + }); + assert_eq!(verb, Some(HttpMethod::POST)); + } + + #[test] + fn finds_laravel_static_route_with_string_callable() { + let src: &[u8] = b"get('users/(:num)', 'UserController::show');\n"; + let tree = parse(src); + let hit = find_codeigniter_route( + tree.root_node(), + src, + "show", + Some("UserController"), + ) + .unwrap(); + assert_eq!(hit.0, HttpMethod::GET); + assert_eq!(hit.1, "users/(:num)"); + } +} diff --git a/src/dynamic/framework/adapters/php_symfony.rs b/src/dynamic/framework/adapters/php_symfony.rs new file mode 100644 index 00000000..51fa51ea --- /dev/null +++ b/src/dynamic/framework/adapters/php_symfony.rs @@ -0,0 +1,181 @@ +//! Symfony [`super::super::FrameworkAdapter`] (Phase 16 — Track L.14). +//! +//! Recognises `#[Route('/path', methods: ['GET'])]` PHP attributes on +//! controller methods or top-level functions. Class-level +//! `#[Route('/api')]` prefix is concatenated with the method-level +//! path so `#[Route('/api')] + #[Route('/x')]` produces `"/api/x"`. +//! +//! YAML routing (`config/routes.yaml`) is not handled in v1 — the +//! attribute path covers >90% of modern Symfony 5/6/7 controller +//! declarations and is the only path the harness needs to bind a +//! single route inside a single source file. YAML lookup belongs to +//! a later phase once the framework adapter trait gains access to +//! the project-level config file list. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::php_routes::{ + bind_php_path_params, find_php_function, first_php_string_arg, iter_php_attributes, + methods_named_arg, php_formal_names, source_imports_symfony, +}; + +pub struct PhpSymfonyAdapter; + +const ADAPTER_NAME: &str = "php-symfony"; + +fn route_attribute_shape(node: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, String)> { + let mut hit: Option<(HttpMethod, String)> = None; + iter_php_attributes(node, bytes, |ann, name| { + if hit.is_some() || name != "Route" { + return; + } + let Some(args) = ann.child_by_field_name("parameters") else { + return; + }; + let path = first_php_string_arg(args, bytes).unwrap_or_default(); + let method = methods_named_arg(args, bytes).unwrap_or(HttpMethod::GET); + hit = Some((method, path)); + }); + hit +} + +fn join_route_path(class_path: &str, method_path: &str) -> String { + if class_path.is_empty() { + return method_path.to_owned(); + } + if method_path.is_empty() { + return class_path.to_owned(); + } + format!( + "{}/{}", + class_path.trim_end_matches('/'), + method_path.trim_start_matches('/') + ) +} + +impl FrameworkAdapter for PhpSymfonyAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Php + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + if !source_imports_symfony(file_bytes) { + return None; + } + let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?; + let (http_method, method_path) = route_attribute_shape(func_node, file_bytes)?; + let class_prefix = class + .and_then(|c| route_attribute_shape(c, file_bytes)) + .map(|(_, p)| p) + .unwrap_or_default(); + let path = join_route_path(&class_prefix, &method_path); + let formals = php_formal_names(func_node, file_bytes); + let request_params = bind_php_path_params(&formals, &path); + + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: http_method, + path, + }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::ParamSource; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "php".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_method_route_attribute_with_class_prefix() { + let src: &[u8] = b" String { /// preserving the pre-Phase-15 behaviour (direct function call). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PhpShape { - /// Slim / Laravel / Symfony route closure. Harness builds a - /// minimal request stub (query/body) and invokes the closure - /// resolved from `$GLOBALS['__nyx_route']` (which the entry file - /// publishes during include). + /// Slim / generic route closure published via + /// `$GLOBALS['__nyx_route']`. Harness builds a minimal request + /// stub (query/body) and invokes the closure resolved from the + /// global (which the entry file publishes during include). RouteClosure, + /// Laravel route — `Route::get('/x', 'Controller@method')` or + /// closure callable. Phase 16 v1 dispatches through the same + /// `$GLOBALS['__nyx_route']` channel as `RouteClosure` but + /// publishes a `NYX_LARAVEL_TEST=1` stdout marker so the + /// verifier can confirm the framework toolchain knob propagated. + LaravelRoute, + /// Symfony route — `#[Route('/x')]` PHP attribute on a + /// controller method or top-level function. Phase 16 v1 + /// dispatches via reflective invocation (the entry file's + /// `entry.php` instantiates the controller class and the harness + /// calls the method) plus an `NYX_SYMFONY_TEST=1` stdout marker. + SymfonyRoute, + /// CodeIgniter route — `$routes->get('users/(:num)', ...)` + /// published from `app/Config/Routes.php`. Phase 16 v1 + /// dispatches via the `$GLOBALS['__nyx_route']` channel plus a + /// `NYX_CODEIGNITER_TEST=1` stdout marker. + CodeIgniterRoute, /// CLI script driven by `$argv`. Harness mutates `$argv` then /// includes the entry file (whose top-level body reads `$argv`), /// or — when the spec names a function — calls the function after @@ -159,15 +176,37 @@ impl PhpShape { let entry = spec.entry_name.as_str(); let kind = spec.entry_kind; + let has_symfony_marker = source.contains("#[Route(") + || source.contains("Symfony\\Component\\Routing") + || source.contains("Symfony\\Component\\HttpKernel") + || source.contains("// nyx-shape: symfony"); + let has_laravel_marker = source.contains("Illuminate\\Support\\Facades\\Route") + || source.contains("Illuminate\\Routing") + || source.contains("Route::get(") + || source.contains("Route::post(") + || source.contains("Route::put(") + || source.contains("Route::patch(") + || source.contains("Route::delete(") + || source.contains("Route::any(") + || source.contains("Route::match(") + || source.contains("App\\Http\\Controllers") + || source.contains("// nyx-shape: laravel"); + let has_codeigniter_marker = source.contains("CodeIgniter\\Router") + || source.contains("CodeIgniter\\HTTP") + || source.contains("$routes->get(") + || source.contains("$routes->post(") + || source.contains("$routes->put(") + || source.contains("$routes->patch(") + || source.contains("$routes->delete(") + || source.contains("$routes->add(") + || source.contains("extends BaseController") + || source.contains("// nyx-shape: codeigniter"); let has_route_marker = source.contains("$app->get(") || source.contains("$app->post(") || source.contains("$app->any(") || source.contains("$app->map(") || source.contains("$router->get(") || source.contains("$router->post(") - || source.contains("Route::get(") - || source.contains("Route::post(") - || source.contains("Route::any(") || source.contains("// nyx-shape: route"); let has_argv = source.contains("$argv") || source.contains("// nyx-shape: cli"); let has_function_decl = source.contains("function ") @@ -177,6 +216,15 @@ impl PhpShape { && !entry.is_empty() && source.contains(&format!("function {entry}")); + if has_symfony_marker { + return Self::SymfonyRoute; + } + if has_laravel_marker { + return Self::LaravelRoute; + } + if has_codeigniter_marker { + return Self::CodeIgniterRoute; + } if has_route_marker { return Self::RouteClosure; } @@ -982,11 +1030,12 @@ fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String { let entry_block = build_entry_block(shape); let call_expr = build_call_expr(spec, shape, entry_fn); let shim = probe_shim(); + let toolchain_marker = build_toolchain_marker(shape); let crash_callee = if entry_fn.is_empty() { "main" } else { entry_fn.as_str() }; format!( r#" String { "null".to_owned() } } - PhpShape::RouteClosure => { + PhpShape::RouteClosure + | PhpShape::LaravelRoute + | PhpShape::CodeIgniterRoute => { // Entry script publishes the route closure via // `$GLOBALS['__nyx_route']`. When the global is missing, // fall back to calling the named function directly. @@ -1108,10 +1161,35 @@ fn build_call_expr(spec: &HarnessSpec, shape: PhpShape, func: &str) -> String { "(isset($GLOBALS['__nyx_route']) && is_callable($GLOBALS['__nyx_route'])) ? call_user_func($GLOBALS['__nyx_route'], $payload) : (function_exists({func:?}) ? {func}($payload) : null)" ) } + PhpShape::SymfonyRoute => { + // Symfony controllers are normally reached through + // `HttpKernel::handle`. The Phase 16 v1 harness drives + // the action directly: the entry file publishes a + // controller instance via `$GLOBALS['__nyx_controller']` + // and the harness reflectively invokes the action method. + // Falls back to calling a bare function when no + // controller class was published. + format!( + "(isset($GLOBALS['__nyx_controller']) && is_object($GLOBALS['__nyx_controller'])) ? $GLOBALS['__nyx_controller']->{func}($payload) : (function_exists({func:?}) ? {func}($payload) : null)" + ) + } PhpShape::Generic => build_generic_call(spec, func), } } +/// Per-shape stdout toolchain markers. Mirrors the Phase 14 +/// `JavaShape::SpringController` `NYX_SPRING_TEST` stdout marker so +/// the verifier can confirm a framework knob propagated through to +/// the harness — even though the v1 invocation path is reflective. +fn build_toolchain_marker(shape: PhpShape) -> &'static str { + match shape { + PhpShape::LaravelRoute => "echo \"NYX_LARAVEL_TEST=1\\n\";\n", + PhpShape::SymfonyRoute => "echo \"NYX_SYMFONY_TEST=1\\n\";\n", + PhpShape::CodeIgniterRoute => "echo \"NYX_CODEIGNITER_TEST=1\\n\";\n", + _ => "", + } +} + fn build_generic_call(spec: &HarnessSpec, func: &str) -> String { match &spec.payload_slot { PayloadSlot::Param(idx) => { @@ -1259,9 +1337,52 @@ mod tests { #[test] fn shape_detect_laravel_route_closure() { + // Phase 16 reroutes Laravel-marker sources to the dedicated + // LaravelRoute shape so the harness can emit the + // `NYX_LARAVEL_TEST=1` toolchain stdout marker (mirroring the + // Phase 14 Spring `NYX_SPRING_TEST=1` channel). let src = "get('run', 'UserController::run');\n"; + let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php"); + assert_eq!(PhpShape::detect(&spec, src), PhpShape::CodeIgniterRoute); + } + + #[test] + fn laravel_shape_emits_toolchain_marker() { + let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php"); + let src = generate_source(&spec, PhpShape::LaravelRoute); + assert!(src.contains("NYX_LARAVEL_TEST=1")); + assert!(src.contains("$GLOBALS['__nyx_route']")); + } + + #[test] + fn symfony_shape_emits_toolchain_marker_and_controller_dispatch() { + let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php"); + let src = generate_source(&spec, PhpShape::SymfonyRoute); + assert!(src.contains("NYX_SYMFONY_TEST=1")); + assert!(src.contains("$GLOBALS['__nyx_controller']")); + assert!(src.contains("->run($payload)")); + } + + #[test] + fn codeigniter_shape_emits_toolchain_marker() { + let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php"); + let src = generate_source(&spec, PhpShape::CodeIgniterRoute); + assert!(src.contains("NYX_CODEIGNITER_TEST=1")); + assert!(src.contains("$GLOBALS['__nyx_route']")); } #[test] diff --git a/tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php b/tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php new file mode 100644 index 00000000..3eb3e222 --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php @@ -0,0 +1,18 @@ +get('run', 'UserController::run'); + +class UserController extends BaseController +{ + public function run($payload) + { + echo "__NYX_SINK_HIT__\n"; + $cmd = "echo hello " . escapeshellarg($payload); + $out = shell_exec($cmd); + echo $out; + return $out; + } +} diff --git a/tests/dynamic_fixtures/php_frameworks/codeigniter/composer.json b/tests/dynamic_fixtures/php_frameworks/codeigniter/composer.json new file mode 100644 index 00000000..0013dccf --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/codeigniter/composer.json @@ -0,0 +1,7 @@ +{ + "name": "nyx/fixture-codeigniter", + "require": { + "php": ">=8.1", + "codeigniter4/framework": "^4.4" + } +} diff --git a/tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php b/tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php new file mode 100644 index 00000000..88a70f49 --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php @@ -0,0 +1,20 @@ +get('run', 'UserController::run')` references the +// controller method whose body shells out without sanitisation. + +use CodeIgniter\Router\RouteCollection; + +$routes->get('run', 'UserController::run'); + +class UserController extends BaseController +{ + public function run($payload) + { + echo "__NYX_SINK_HIT__\n"; + $cmd = "echo hello " . $payload; + $out = shell_exec($cmd); + echo $out; + return $out; + } +} diff --git a/tests/dynamic_fixtures/php_frameworks/laravel/benign.php b/tests/dynamic_fixtures/php_frameworks/laravel/benign.php new file mode 100644 index 00000000..4da700ec --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/laravel/benign.php @@ -0,0 +1,18 @@ +=8.1", + "laravel/framework": "^11.0" + } +} diff --git a/tests/dynamic_fixtures/php_frameworks/laravel/vuln.php b/tests/dynamic_fixtures/php_frameworks/laravel/vuln.php new file mode 100644 index 00000000..822036b6 --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/laravel/vuln.php @@ -0,0 +1,20 @@ +=8.1", + "symfony/framework-bundle": "^7.0", + "symfony/routing": "^7.0", + "symfony/http-kernel": "^7.0" + } +} diff --git a/tests/dynamic_fixtures/php_frameworks/symfony/vuln.php b/tests/dynamic_fixtures/php_frameworks/symfony/vuln.php new file mode 100644 index 00000000..bd595b14 --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/symfony/vuln.php @@ -0,0 +1,21 @@ + tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() +} + +fn summary_for(name: &str, file: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + file_path: file.into(), + lang: "php".into(), + ..Default::default() + } +} + +#[test] +fn laravel_vuln_fixture_binds_route() { + let path = "tests/dynamic_fixtures/php_frameworks/laravel/vuln.php"; + let bytes = std::fs::read(path).expect("laravel vuln 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"); + assert_eq!(binding.adapter, "php-laravel"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); + let payload = binding + .request_params + .iter() + .find(|p| p.name == "payload") + .expect("payload formal"); + assert!(matches!(payload.source, ParamSource::QueryParam(_))); +} + +#[test] +fn laravel_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/php_frameworks/laravel/benign.php"; + let bytes = std::fs::read(path).expect("laravel benign 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 benign 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); +} + +#[test] +fn symfony_vuln_fixture_binds_route_via_attribute() { + let path = "tests/dynamic_fixtures/php_frameworks/symfony/vuln.php"; + let bytes = std::fs::read(path).expect("symfony vuln 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("symfony adapter must bind"); + assert_eq!(binding.adapter, "php-symfony"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); +} + +#[test] +fn symfony_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/php_frameworks/symfony/benign.php"; + let bytes = std::fs::read(path).expect("symfony benign 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("symfony adapter must bind benign fixture"); + assert_eq!(binding.adapter, "php-symfony"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); +} + +#[test] +fn codeigniter_vuln_fixture_binds_route() { + let path = "tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php"; + let bytes = std::fs::read(path).expect("codeigniter vuln 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("codeigniter adapter must bind"); + assert_eq!(binding.adapter, "php-codeigniter"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "run"); + assert_eq!(route.method, HttpMethod::GET); +} + +#[test] +fn codeigniter_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php"; + let bytes = std::fs::read(path).expect("codeigniter benign 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("codeigniter adapter must bind benign fixture"); + assert_eq!(binding.adapter, "php-codeigniter"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "run"); +} + +#[test] +fn laravel_adapter_ignores_helper_method() { + // `helper` is declared but not referenced in any `Route::*` call. + // The adapter must return `None` so the verifier surfaces + // `SpecDerivationFailed` for non-route helpers in a route file. + let path = "tests/dynamic_fixtures/php_frameworks/laravel/vuln.php"; + let bytes = std::fs::read(path).expect("laravel vuln fixture exists"); + let tree = parse_php(&bytes); + let summary = summary_for("nonexistent_helper", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Php); + assert!(binding.is_none()); +}