From 4bcdec3a1b6a3076c2f3c7f0174d9007c11d09ce Mon Sep 17 00:00:00 2001 From: elipeter Date: Sat, 23 May 2026 09:17:02 -0500 Subject: [PATCH] refactor(dynamic): ensure unique workdir names to avoid conflicts, improve Java sibling stub handling, and enhance comments --- .../framework/adapters/java_micronaut.rs | 94 ++++-- .../framework/adapters/java_quarkus.rs | 95 ++++-- src/dynamic/framework/adapters/java_routes.rs | 94 ++++++ .../framework/adapters/java_servlet.rs | 120 ++++++-- src/dynamic/framework/adapters/java_spring.rs | 167 +++++++--- src/dynamic/framework/adapters/js_express.rs | 137 +++++++-- src/dynamic/framework/adapters/js_fastify.rs | 85 +++-- src/dynamic/framework/adapters/js_koa.rs | 170 +++++++--- src/dynamic/framework/adapters/js_nest.rs | 42 ++- src/dynamic/framework/adapters/js_routes.rs | 290 ++++++++++++++++++ src/dynamic/lang/js_shared.rs | 24 +- .../js_frameworks/express/vuln.js | 2 +- .../js_frameworks/fastify/vuln.js | 2 +- .../js_frameworks/koa/vuln.js | 2 +- .../js_frameworks/nest/vuln.js | 2 +- tests/js_frameworks_corpus.rs | 169 ++++++++++ 16 files changed, 1267 insertions(+), 228 deletions(-) diff --git a/src/dynamic/framework/adapters/java_micronaut.rs b/src/dynamic/framework/adapters/java_micronaut.rs index edc9b8bd..96336088 100644 --- a/src/dynamic/framework/adapters/java_micronaut.rs +++ b/src/dynamic/framework/adapters/java_micronaut.rs @@ -10,12 +10,14 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; use tree_sitter::Node; use super::java_routes::{ annotation_string_arg, bind_java_params, collect_security_annotations, find_class_with_method, - iter_annotations, join_route_path, method_formal_types, source_imports_micronaut, + iter_annotations, java_receiver_facts_allow_formals, join_route_path, method_formal_types, + source_imports_micronaut, }; pub struct JavaMicronautAdapter; @@ -59,6 +61,38 @@ fn method_verb_and_path(method: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, S hit } +fn detect_micronaut( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], +) -> Option { + if !source_imports_micronaut(file_bytes) { + return None; + } + let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; + let class_prefix = class_path_prefix(class, file_bytes)?; + let (http_method, method_path) = method_verb_and_path(method, file_bytes)?; + let path = join_route_path(&class_prefix, &method_path); + let formals = method_formal_types(method, file_bytes); + if !java_receiver_facts_allow_formals(summary, ssa_summary, &formals) { + return None; + } + let request_params = bind_java_params(&formals, &path); + let middleware = collect_security_annotations(class, method, file_bytes); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: http_method, + path, + }), + request_params, + response_writer: None, + middleware, + }) +} + impl FrameworkAdapter for JavaMicronautAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -74,27 +108,17 @@ impl FrameworkAdapter for JavaMicronautAdapter { ast: Node<'_>, file_bytes: &[u8], ) -> Option { - if !source_imports_micronaut(file_bytes) { - return None; - } - let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; - let class_prefix = class_path_prefix(class, file_bytes)?; - let (http_method, method_path) = method_verb_and_path(method, file_bytes)?; - let path = join_route_path(&class_prefix, &method_path); - let formals = method_formal_types(method, file_bytes); - let request_params = bind_java_params(&formals, &path); - let middleware = collect_security_annotations(class, method, file_bytes); - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::HttpRoute, - route: Some(RouteShape { - method: http_method, - path, - }), - request_params, - response_writer: None, - middleware, - }) + detect_micronaut(summary, None, ast, file_bytes) + } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_micronaut(summary, ssa_summary, ast, file_bytes) } } @@ -102,6 +126,7 @@ impl FrameworkAdapter for JavaMicronautAdapter { mod tests { use super::*; use crate::dynamic::framework::ParamSource; + use crate::summary::CalleeSite; fn parse(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -118,6 +143,17 @@ mod tests { } } + fn summary_with_receiver(name: &str, receiver: &str, callee: &str) -> FuncSummary { + let mut s = summary(name); + s.callees.push(CalleeSite { + name: callee.into(), + receiver: Some(receiver.into()), + ordinal: 0, + ..Default::default() + }); + s + } + #[test] fn fires_on_controller_plus_get() { let src: &[u8] = b"import io.micronaut.http.annotation.Controller;\nimport io.micronaut.http.annotation.Get;\n@Controller(\"/api\")\npublic class V {\n @Get(\"/{id}\")\n public String show(String id) { return id; }\n}\n"; @@ -180,4 +216,18 @@ mod tests { .expect("binding"); assert!(binding.middleware.iter().any(|m| m.name == "@Secured")); } + + #[test] + fn ssa_rejects_incompatible_request_receiver() { + let src: &[u8] = b"import io.micronaut.http.annotation.Controller;\nimport io.micronaut.http.annotation.Get;\n@Controller(\"/api\")\npublic class V {\n @Get(\"/x\")\n public String run(HttpRequest req) { return req.getPath(); }\n}\n"; + let tree = parse(src); + let summary = summary_with_receiver("run", "req", "getPath"); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "HttpClient".into())); + assert!( + JavaMicronautAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/java_quarkus.rs b/src/dynamic/framework/adapters/java_quarkus.rs index 67e9c95a..266e03f5 100644 --- a/src/dynamic/framework/adapters/java_quarkus.rs +++ b/src/dynamic/framework/adapters/java_quarkus.rs @@ -10,12 +10,14 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; use tree_sitter::Node; use super::java_routes::{ annotation_string_arg, bind_java_params, collect_security_annotations, find_class_with_method, - iter_annotations, join_route_path, method_formal_types, source_imports_quarkus, + iter_annotations, java_receiver_facts_allow_formals, join_route_path, method_formal_types, + source_imports_quarkus, }; pub struct JavaQuarkusAdapter; @@ -63,6 +65,38 @@ fn method_verb_and_path(method: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, S Some((verb?, path)) } +fn detect_quarkus( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], +) -> Option { + if !source_imports_quarkus(file_bytes) { + return None; + } + let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; + let (http_method, method_path) = method_verb_and_path(method, file_bytes)?; + let class_prefix = class_path_prefix(class, file_bytes); + let path = join_route_path(&class_prefix, &method_path); + let formals = method_formal_types(method, file_bytes); + if !java_receiver_facts_allow_formals(summary, ssa_summary, &formals) { + return None; + } + let request_params = bind_java_params(&formals, &path); + let middleware = collect_security_annotations(class, method, file_bytes); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: http_method, + path, + }), + request_params, + response_writer: None, + middleware, + }) +} + impl FrameworkAdapter for JavaQuarkusAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -78,27 +112,17 @@ impl FrameworkAdapter for JavaQuarkusAdapter { ast: Node<'_>, file_bytes: &[u8], ) -> Option { - if !source_imports_quarkus(file_bytes) { - return None; - } - let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; - let (http_method, method_path) = method_verb_and_path(method, file_bytes)?; - let class_prefix = class_path_prefix(class, file_bytes); - let path = join_route_path(&class_prefix, &method_path); - let formals = method_formal_types(method, file_bytes); - let request_params = bind_java_params(&formals, &path); - let middleware = collect_security_annotations(class, method, file_bytes); - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::HttpRoute, - route: Some(RouteShape { - method: http_method, - path, - }), - request_params, - response_writer: None, - middleware, - }) + detect_quarkus(summary, None, ast, file_bytes) + } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_quarkus(summary, ssa_summary, ast, file_bytes) } } @@ -106,6 +130,7 @@ impl FrameworkAdapter for JavaQuarkusAdapter { mod tests { use super::*; use crate::dynamic::framework::ParamSource; + use crate::summary::CalleeSite; fn parse(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -122,6 +147,17 @@ mod tests { } } + fn summary_with_receiver(name: &str, receiver: &str, callee: &str) -> FuncSummary { + let mut s = summary(name); + s.callees.push(CalleeSite { + name: callee.into(), + receiver: Some(receiver.into()), + ordinal: 0, + ..Default::default() + }); + s + } + #[test] fn fires_on_class_path_plus_method_get() { let src: &[u8] = b"import jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\n@Path(\"/api\")\npublic class V {\n @GET\n @Path(\"/{id}\")\n public String show(String id) { return id; }\n}\n"; @@ -184,4 +220,19 @@ mod tests { .expect("binding"); assert!(binding.middleware.iter().any(|m| m.name == "@RolesAllowed")); } + + #[test] + fn ssa_rejects_incompatible_request_receiver() { + let src: &[u8] = b"import jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\n@Path(\"/api\")\npublic class V {\n @GET\n public String run(HttpServletRequest req) { return req.getParameter(\"q\"); }\n}\n"; + let tree = parse(src); + let summary = summary_with_receiver("run", "req", "getParameter"); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers + .push((0, "DatabaseConnection".into())); + assert!( + JavaQuarkusAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/java_routes.rs b/src/dynamic/framework/adapters/java_routes.rs index 49096762..15aebd66 100644 --- a/src/dynamic/framework/adapters/java_routes.rs +++ b/src/dynamic/framework/adapters/java_routes.rs @@ -11,6 +11,8 @@ use crate::dynamic::framework::auth_markers; use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource}; +use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; use tree_sitter::Node; @@ -374,6 +376,98 @@ pub fn bind_java_params(formals: &[(String, String)], path: &str) -> Vec, + formals: &[(String, String)], +) -> bool { + let Some(ssa_summary) = ssa_summary else { + return true; + }; + if ssa_summary.typed_call_receivers.is_empty() { + return true; + } + + for site in &summary.callees { + let Some(receiver) = site.receiver.as_deref() else { + continue; + }; + let receiver = last_segment(receiver); + let Some(role) = formal_receiver_role(formals, receiver) else { + continue; + }; + let Some(container) = + container_for_ordinal(&ssa_summary.typed_call_receivers, site.ordinal) + else { + continue; + }; + if !typed_container_allows_java_receiver(container, role) { + return false; + } + } + true +} + +fn formal_receiver_role(formals: &[(String, String)], receiver: &str) -> Option { + formals.iter().find_map(|(ty, name)| { + if name != receiver { + return None; + } + match ty.as_str() { + "HttpServletRequest" | "ServletRequest" | "HttpRequest" | "Request" => { + Some(JavaReceiverRole::Request) + } + "HttpServletResponse" | "ServletResponse" | "HttpResponse" | "Response" => { + Some(JavaReceiverRole::Response) + } + _ => None, + } + }) +} + +fn container_for_ordinal(typed: &[(u32, String)], ordinal: u32) -> Option<&str> { + typed + .iter() + .find(|(ord, _)| *ord == ordinal) + .map(|(_, container)| container.as_str()) +} + +fn typed_container_allows_java_receiver(container: &str, role: JavaReceiverRole) -> bool { + let leaf = last_segment(container) + .trim_end_matches("[]") + .trim_end_matches('*') + .to_ascii_lowercase(); + match role { + JavaReceiverRole::Request => matches!( + leaf.as_str(), + "httpservletrequest" | "servletrequest" | "httprequest" | "request" + ), + JavaReceiverRole::Response => matches!( + leaf.as_str(), + "httpservletresponse" | "servletresponse" | "httpresponse" | "response" + ), + } +} + +fn last_segment(text: &str) -> &str { + text.rsplit(['.', ':', '$']).next().unwrap_or(text).trim() +} + fn is_implicit_type(ty: &str) -> bool { matches!( ty, diff --git a/src/dynamic/framework/adapters/java_servlet.rs b/src/dynamic/framework/adapters/java_servlet.rs index 894ddf75..fb9ef782 100644 --- a/src/dynamic/framework/adapters/java_servlet.rs +++ b/src/dynamic/framework/adapters/java_servlet.rs @@ -13,12 +13,14 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; use tree_sitter::Node; use super::java_routes::{ annotation_string_arg, bind_java_params, class_extends, collect_security_annotations, - find_class_with_method, iter_annotations, method_formal_types, source_imports_servlet, + find_class_with_method, iter_annotations, java_receiver_facts_allow_formals, + method_formal_types, source_imports_servlet, }; pub struct JavaServletAdapter; @@ -53,6 +55,42 @@ fn formals_look_like_servlet(formals: &[(String, String)]) -> bool { .any(|(ty, _)| ty == "HttpServletRequest" || ty == "ServletRequest") } +fn detect_servlet( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], +) -> Option { + if !source_imports_servlet(file_bytes) { + return None; + } + let http_method = servlet_method_for(&summary.name)?; + let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; + let formals = method_formal_types(method, file_bytes); + let extends_servlet = class_extends(class, file_bytes, "HttpServlet") + || class_extends(class, file_bytes, "GenericServlet"); + if !extends_servlet && !formals_look_like_servlet(&formals) { + return None; + } + if !java_receiver_facts_allow_formals(summary, ssa_summary, &formals) { + return None; + } + let path = web_servlet_path(class, file_bytes).unwrap_or_else(|| "/".to_owned()); + let request_params = bind_java_params(&formals, &path); + let middleware = collect_security_annotations(class, method, file_bytes); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: http_method, + path, + }), + request_params, + response_writer: None, + middleware, + }) +} + impl FrameworkAdapter for JavaServletAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -68,31 +106,17 @@ impl FrameworkAdapter for JavaServletAdapter { ast: Node<'_>, file_bytes: &[u8], ) -> Option { - if !source_imports_servlet(file_bytes) { - return None; - } - let http_method = servlet_method_for(&summary.name)?; - let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; - let formals = method_formal_types(method, file_bytes); - let extends_servlet = class_extends(class, file_bytes, "HttpServlet") - || class_extends(class, file_bytes, "GenericServlet"); - if !extends_servlet && !formals_look_like_servlet(&formals) { - return None; - } - let path = web_servlet_path(class, file_bytes).unwrap_or_else(|| "/".to_owned()); - let request_params = bind_java_params(&formals, &path); - let middleware = collect_security_annotations(class, method, file_bytes); - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::HttpRoute, - route: Some(RouteShape { - method: http_method, - path, - }), - request_params, - response_writer: None, - middleware, - }) + detect_servlet(summary, None, ast, file_bytes) + } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_servlet(summary, ssa_summary, ast, file_bytes) } } @@ -100,6 +124,7 @@ impl FrameworkAdapter for JavaServletAdapter { mod tests { use super::*; use crate::dynamic::framework::ParamSource; + use crate::summary::CalleeSite; fn parse(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -116,6 +141,23 @@ mod tests { } } + fn summary_with_receiver(name: &str, receiver: &str, callee: &str) -> FuncSummary { + let mut s = summary(name); + s.callees.push(CalleeSite { + name: callee.into(), + receiver: Some(receiver.into()), + ordinal: 0, + ..Default::default() + }); + s + } + + fn ssa_receiver(container: &str) -> SsaFuncSummary { + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, container.to_owned())); + ssa + } + #[test] fn fires_on_extends_http_servlet_doget() { let src: &[u8] = b"import jakarta.servlet.http.HttpServlet;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\n@WebServlet(\"/admin\")\npublic class Admin extends HttpServlet {\n public void doGet(HttpServletRequest req, HttpServletResponse resp) {}\n}\n"; @@ -190,4 +232,30 @@ mod tests { .expect("binding"); assert!(binding.middleware.iter().any(|m| m.name == "@PreAuthorize")); } + + #[test] + fn ssa_rejects_incompatible_response_receiver() { + let src: &[u8] = b"public class V {\n public void doGet(HttpServletRequest req, HttpServletResponse resp) { resp.setHeader(\"X\", \"y\"); }\n}\n"; + let tree = parse(src); + let summary = summary_with_receiver("doGet", "resp", "setHeader"); + let ssa = ssa_receiver("LocalCollection"); + assert!( + JavaServletAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_allows_matching_response_receiver() { + let src: &[u8] = b"public class V {\n public void doGet(HttpServletRequest req, HttpServletResponse resp) { resp.setHeader(\"X\", \"y\"); }\n}\n"; + let tree = parse(src); + let summary = summary_with_receiver("doGet", "resp", "setHeader"); + let ssa = ssa_receiver("HttpResponse"); + assert!( + JavaServletAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_some() + ); + } } diff --git a/src/dynamic/framework/adapters/java_spring.rs b/src/dynamic/framework/adapters/java_spring.rs index b4228a96..15bd5823 100644 --- a/src/dynamic/framework/adapters/java_spring.rs +++ b/src/dynamic/framework/adapters/java_spring.rs @@ -11,13 +11,14 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; use tree_sitter::Node; use super::java_routes::{ annotation_string_arg, bind_java_params, collect_security_annotations, find_class_with_method, - iter_annotations, join_route_path, method_formal_types, request_method_from_args, - source_imports_quarkus, source_imports_spring, + iter_annotations, java_receiver_facts_allow_formals, join_route_path, method_formal_types, + request_method_from_args, source_imports_quarkus, source_imports_spring, }; pub struct JavaSpringAdapter; @@ -77,6 +78,66 @@ fn method_route(method: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, String)> hit } +fn detect_spring( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], +) -> Option { + if !source_imports_spring(file_bytes) { + return None; + } + // Quarkus / JAX-RS files often re-use `@Path` but the brief + // routes those through `java-quarkus`; skip when the file + // looks like Quarkus and is not also a Spring controller. + if source_imports_quarkus(file_bytes) + && !file_bytes.windows(15).any(|w| w == b"@RestController") + && !file_bytes.windows(11).any(|w| w == b"@Controller") + { + return None; + } + let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; + if !class_is_controller(class, file_bytes) { + return None; + } + let class_prefix = class_route_prefix(class, file_bytes); + // Method-level mapping wins. Falls back to (GET, "") when + // the method has no mapping annotation but the enclosing + // class has a `@RequestMapping(prefix)` — Spring routes the + // public method under the class prefix. Skip the binding + // when neither the method nor the class declares a route + // path so a plain `@Controller` helper class does not + // hijack the registry. + let (http_method, method_path) = match method_route(method, file_bytes) { + Some(r) => r, + None => { + if class_prefix.is_empty() { + return None; + } + (HttpMethod::GET, String::new()) + } + }; + let path = join_route_path(&class_prefix, &method_path); + let formals = method_formal_types(method, file_bytes); + if !java_receiver_facts_allow_formals(summary, ssa_summary, &formals) { + return None; + } + let request_params = bind_java_params(&formals, &path); + let middleware = collect_security_annotations(class, method, file_bytes); + + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: http_method, + path, + }), + request_params, + response_writer: None, + middleware, + }) +} + impl FrameworkAdapter for JavaSpringAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -92,55 +153,17 @@ impl FrameworkAdapter for JavaSpringAdapter { ast: Node<'_>, file_bytes: &[u8], ) -> Option { - if !source_imports_spring(file_bytes) { - return None; - } - // Quarkus / JAX-RS files often re-use `@Path` but the brief - // routes those through `java-quarkus`; skip when the file - // looks like Quarkus and is not also a Spring controller. - if source_imports_quarkus(file_bytes) - && !file_bytes.windows(15).any(|w| w == b"@RestController") - && !file_bytes.windows(11).any(|w| w == b"@Controller") - { - return None; - } - let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; - if !class_is_controller(class, file_bytes) { - return None; - } - let class_prefix = class_route_prefix(class, file_bytes); - // Method-level mapping wins. Falls back to (GET, "") when - // the method has no mapping annotation but the enclosing - // class has a `@RequestMapping(prefix)` — Spring routes the - // public method under the class prefix. Skip the binding - // when neither the method nor the class declares a route - // path so a plain `@Controller` helper class does not - // hijack the registry. - let (http_method, method_path) = match method_route(method, file_bytes) { - Some(r) => r, - None => { - if class_prefix.is_empty() { - return None; - } - (HttpMethod::GET, String::new()) - } - }; - let path = join_route_path(&class_prefix, &method_path); - let formals = method_formal_types(method, file_bytes); - let request_params = bind_java_params(&formals, &path); - let middleware = collect_security_annotations(class, method, file_bytes); + detect_spring(summary, None, ast, file_bytes) + } - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::HttpRoute, - route: Some(RouteShape { - method: http_method, - path, - }), - request_params, - response_writer: None, - middleware, - }) + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_spring(summary, ssa_summary, ast, file_bytes) } } @@ -148,6 +171,7 @@ impl FrameworkAdapter for JavaSpringAdapter { mod tests { use super::*; use crate::dynamic::framework::ParamSource; + use crate::summary::CalleeSite; fn parse(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -164,6 +188,23 @@ mod tests { } } + fn summary_with_receiver(name: &str, receiver: &str, callee: &str) -> FuncSummary { + let mut s = summary(name); + s.callees.push(CalleeSite { + name: callee.into(), + receiver: Some(receiver.into()), + ordinal: 0, + ..Default::default() + }); + s + } + + fn ssa_receiver(container: &str) -> SsaFuncSummary { + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, container.to_owned())); + ssa + } + #[test] fn fires_on_get_mapping_with_class_prefix() { let src: &[u8] = b"@RestController\n@RequestMapping(\"/api\")\npublic class Users {\n @GetMapping(\"/{id}\")\n public String show(String id) { return id; }\n}\n"; @@ -299,4 +340,30 @@ mod tests { .expect("binding"); assert!(binding.middleware.is_empty()); } + + #[test] + fn ssa_rejects_incompatible_request_receiver() { + let src: &[u8] = b"@RestController\npublic class C {\n @GetMapping(\"/x\")\n public String x(HttpServletRequest req) { return req.getParameter(\"q\"); }\n}\n"; + let tree = parse(src); + let summary = summary_with_receiver("x", "req", "getParameter"); + let ssa = ssa_receiver("LocalCollection"); + assert!( + JavaSpringAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_allows_matching_request_receiver() { + let src: &[u8] = b"@RestController\npublic class C {\n @GetMapping(\"/x\")\n public String x(HttpServletRequest req) { return req.getParameter(\"q\"); }\n}\n"; + let tree = parse(src); + let summary = summary_with_receiver("x", "req", "getParameter"); + let ssa = ssa_receiver("HttpServletRequest"); + assert!( + JavaSpringAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_some() + ); + } } diff --git a/src/dynamic/framework/adapters/js_express.rs b/src/dynamic/framework/adapters/js_express.rs index 3fe8638d..a9751560 100644 --- a/src/dynamic/framework/adapters/js_express.rs +++ b/src/dynamic/framework/adapters/js_express.rs @@ -15,12 +15,14 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; use tree_sitter::Node; use super::js_routes::{ - bind_path_params, extract_route_middleware, find_function_params, find_route_registration, - function_formal_names, source_imports_express, + JsFrameworkObject, bind_path_params, extract_route_middleware, find_function_params, + find_route_registration, function_formal_names, receiver_origin_allows_framework, + source_imports_express, ssa_receiver_allows_framework, }; pub struct JsExpressAdapter; @@ -52,31 +54,62 @@ impl FrameworkAdapter for JsExpressAdapter { ast: Node<'_>, file_bytes: &[u8], ) -> Option { - if !source_imports_express(file_bytes) { - return None; - } - let recv = receiver_looks_like_express; - let (method, path) = find_route_registration(ast, file_bytes, &summary.name, &recv)?; - let formals = find_function_params(ast, file_bytes, &summary.name) - .map(|p| function_formal_names(p, file_bytes)) - .unwrap_or_default(); - let request_params = bind_path_params(&formals, &path); - let middleware = extract_route_middleware(ast, file_bytes, &summary.name, &recv); - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::HttpRoute, - route: Some(RouteShape { method, path }), - request_params, - response_writer: None, - middleware, - }) + detect_express(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_express(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_express( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], +) -> Option { + if !source_imports_express(file_bytes) { + return None; + } + let recv = |name: &str| { + receiver_looks_like_express(name) + && receiver_origin_allows_framework(ast, file_bytes, name, JsFrameworkObject::Express) + && ssa_receiver_allows_framework( + summary, + ssa_summary, + name, + "*", + JsFrameworkObject::Express, + ) + }; + let (method, path) = find_route_registration(ast, file_bytes, &summary.name, &recv)?; + let formals = find_function_params(ast, file_bytes, &summary.name) + .map(|p| function_formal_names(p, file_bytes)) + .unwrap_or_default(); + let request_params = bind_path_params(&formals, &path); + let middleware = extract_route_middleware(ast, file_bytes, &summary.name, &recv); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { method, path }), + request_params, + response_writer: None, + middleware, + }) } #[cfg(test)] mod tests { use super::*; use crate::dynamic::framework::{HttpMethod, ParamSource}; + use crate::summary::CalleeSite; + use crate::summary::ssa_summary::SsaFuncSummary; fn parse_js(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -209,4 +242,68 @@ mod tests { .is_none() ); } + + #[test] + fn skips_route_registered_on_local_collection_alias() { + let src: &[u8] = b"const express = require('express');\n\ + const app = new Map();\n\ + function handler(req, res) { res.send('ok'); }\n\ + app.get('/x', handler);\n"; + let tree = parse_js(src); + assert!( + JsExpressAdapter + .detect(&summary("handler"), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_rejects_incompatible_route_receiver() { + let src: &[u8] = b"const express = require('express');\n\ + const app = makeApp();\n\ + function handler(req, res) { res.send('ok'); }\n\ + app.get('/x', handler);\n"; + let tree = parse_js(src); + let mut func = summary("handler"); + func.callees.push(CalleeSite { + name: "app.get".to_owned(), + receiver: Some("app".to_owned()), + ordinal: 0, + ..Default::default() + }); + let ssa = SsaFuncSummary { + typed_call_receivers: vec![(0, "Map".to_owned())], + ..Default::default() + }; + assert!( + JsExpressAdapter + .detect_with_context(&func, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_keeps_express_container() { + let src: &[u8] = b"const express = require('express');\n\ + const app = makeApp();\n\ + function handler(req, res) { res.send('ok'); }\n\ + app.get('/x', handler);\n"; + let tree = parse_js(src); + let mut func = summary("handler"); + func.callees.push(CalleeSite { + name: "app.get".to_owned(), + receiver: Some("app".to_owned()), + ordinal: 0, + ..Default::default() + }); + let ssa = SsaFuncSummary { + typed_call_receivers: vec![(0, "ExpressApplication".to_owned())], + ..Default::default() + }; + assert!( + JsExpressAdapter + .detect_with_context(&func, Some(&ssa), tree.root_node(), src) + .is_some() + ); + } } diff --git a/src/dynamic/framework/adapters/js_fastify.rs b/src/dynamic/framework/adapters/js_fastify.rs index d34c9a17..4605a92f 100644 --- a/src/dynamic/framework/adapters/js_fastify.rs +++ b/src/dynamic/framework/adapters/js_fastify.rs @@ -17,12 +17,14 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; use tree_sitter::Node; use super::js_routes::{ - bind_path_params, extract_route_middleware, find_function_params, find_route_registration, - function_formal_names, source_imports_fastify, + JsFrameworkObject, bind_path_params, extract_route_middleware, find_function_params, + find_route_registration, function_formal_names, receiver_origin_allows_framework, + source_imports_fastify, ssa_receiver_allows_framework, }; pub struct JsFastifyAdapter; @@ -54,25 +56,54 @@ impl FrameworkAdapter for JsFastifyAdapter { ast: Node<'_>, file_bytes: &[u8], ) -> Option { - if !source_imports_fastify(file_bytes) { - return None; - } - let recv = receiver_looks_like_fastify; - let (method, path) = find_route_registration(ast, file_bytes, &summary.name, &recv)?; - let formals = find_function_params(ast, file_bytes, &summary.name) - .map(|p| function_formal_names(p, file_bytes)) - .unwrap_or_default(); - let request_params = bind_path_params(&formals, &path); - let middleware = extract_route_middleware(ast, file_bytes, &summary.name, &recv); - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::HttpRoute, - route: Some(RouteShape { method, path }), - request_params, - response_writer: None, - middleware, - }) + detect_fastify(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_fastify(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_fastify( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], +) -> Option { + if !source_imports_fastify(file_bytes) { + return None; + } + let recv = |name: &str| { + receiver_looks_like_fastify(name) + && receiver_origin_allows_framework(ast, file_bytes, name, JsFrameworkObject::Fastify) + && ssa_receiver_allows_framework( + summary, + ssa_summary, + name, + "*", + JsFrameworkObject::Fastify, + ) + }; + let (method, path) = find_route_registration(ast, file_bytes, &summary.name, &recv)?; + let formals = find_function_params(ast, file_bytes, &summary.name) + .map(|p| function_formal_names(p, file_bytes)) + .unwrap_or_default(); + let request_params = bind_path_params(&formals, &path); + let middleware = extract_route_middleware(ast, file_bytes, &summary.name, &recv); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { method, path }), + request_params, + response_writer: None, + middleware, + }) } #[cfg(test)] @@ -202,4 +233,18 @@ mod tests { .is_none() ); } + + #[test] + fn skips_route_registered_on_non_fastify_server_alias() { + let src: &[u8] = b"const fastify = require('fastify');\n\ + const server = new Map();\n\ + async function handler(request, reply) { reply.send('ok'); }\n\ + server.get('/x', handler);\n"; + let tree = parse_js(src); + assert!( + JsFastifyAdapter + .detect(&summary("handler"), tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/js_koa.rs b/src/dynamic/framework/adapters/js_koa.rs index aab8bff3..184857d1 100644 --- a/src/dynamic/framework/adapters/js_koa.rs +++ b/src/dynamic/framework/adapters/js_koa.rs @@ -13,12 +13,14 @@ use crate::dynamic::framework::{ }; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; use tree_sitter::Node; use super::js_routes::{ - bind_path_params, extract_route_middleware, find_function_params, find_route_registration, - function_formal_names, last_segment, source_imports_koa, view_arg_references, + JsFrameworkObject, bind_path_params, extract_route_middleware, find_function_params, + find_route_registration, function_formal_names, last_segment, receiver_origin_allows_framework, + source_imports_koa, ssa_receiver_allows_framework, view_arg_references, }; pub struct JsKoaAdapter; @@ -39,13 +41,24 @@ fn receiver_looks_like_koa(name: &str) -> bool { /// that reference `target`. Returns the matched call node so callers /// can stamp a middleware-shape binding when the verb-based dispatch /// fails to fire. -fn find_use_middleware<'a>(root: Node<'a>, bytes: &[u8], target: &str) -> Option> { +fn find_use_middleware<'a>( + root: Node<'a>, + bytes: &[u8], + target: &str, + receiver_accepts: &dyn Fn(&str) -> bool, +) -> Option> { let mut hit: Option> = None; - walk_for_use(root, bytes, target, &mut hit); + walk_for_use(root, bytes, target, receiver_accepts, &mut hit); hit } -fn walk_for_use<'a>(node: Node<'a>, bytes: &[u8], target: &str, out: &mut Option>) { +fn walk_for_use<'a>( + node: Node<'a>, + bytes: &[u8], + target: &str, + receiver_accepts: &dyn Fn(&str) -> bool, + out: &mut Option>, +) { if out.is_some() { return; } @@ -57,7 +70,7 @@ fn walk_for_use<'a>(node: Node<'a>, bytes: &[u8], target: &str, out: &mut Option && prop_text == "use" && let Some(object) = callee.child_by_field_name("object") && let Some(obj_text) = object.utf8_text(bytes).ok() - && receiver_looks_like_koa(last_segment(obj_text)) + && receiver_accepts(last_segment(obj_text)) && let Some(args) = node.child_by_field_name("arguments") { let mut cur = args.walk(); @@ -70,7 +83,7 @@ fn walk_for_use<'a>(node: Node<'a>, bytes: &[u8], target: &str, out: &mut Option } let mut cur = node.walk(); for child in node.children(&mut cur) { - walk_for_use(child, bytes, target, out); + walk_for_use(child, bytes, target, receiver_accepts, out); } } @@ -89,50 +102,78 @@ impl FrameworkAdapter for JsKoaAdapter { ast: Node<'_>, file_bytes: &[u8], ) -> Option { - if !source_imports_koa(file_bytes) { - return None; - } - let recv = receiver_looks_like_koa; - let formals_for = |path: &str| { - let formals = find_function_params(ast, file_bytes, &summary.name) - .map(|p| function_formal_names(p, file_bytes)) - .unwrap_or_default(); - bind_path_params(&formals, path) - }; - if let Some((method, path)) = find_route_registration(ast, file_bytes, &summary.name, &recv) - { - let request_params = formals_for(&path); - let middleware = extract_route_middleware(ast, file_bytes, &summary.name, &recv); - return Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::HttpRoute, - route: Some(RouteShape { method, path }), - request_params, - response_writer: None, - middleware, - }); - } - // Fall back to `app.use(handler)` middleware registration. No - // verb / path information — record the binding so the harness - // still drives the middleware via a synthetic ctx. - if find_use_middleware(ast, file_bytes, &summary.name).is_some() { - let request_params = formals_for("/"); - return Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::HttpRoute, - route: Some(RouteShape { - method: HttpMethod::GET, - path: "/".to_owned(), - }), - request_params, - response_writer: None, - middleware: vec![MiddlewareShape { - name: "koa.use".to_owned(), - }], - }); - } - None + detect_koa(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_koa(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_koa( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], +) -> Option { + if !source_imports_koa(file_bytes) { + return None; + } + let recv = |name: &str| { + receiver_looks_like_koa(name) + && receiver_origin_allows_framework(ast, file_bytes, name, JsFrameworkObject::Koa) + && ssa_receiver_allows_framework( + summary, + ssa_summary, + name, + "*", + JsFrameworkObject::Koa, + ) + }; + let formals_for = |path: &str| { + let formals = find_function_params(ast, file_bytes, &summary.name) + .map(|p| function_formal_names(p, file_bytes)) + .unwrap_or_default(); + bind_path_params(&formals, path) + }; + if let Some((method, path)) = find_route_registration(ast, file_bytes, &summary.name, &recv) { + let request_params = formals_for(&path); + let middleware = extract_route_middleware(ast, file_bytes, &summary.name, &recv); + return Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { method, path }), + request_params, + response_writer: None, + middleware, + }); + } + // Fall back to `app.use(handler)` middleware registration. No + // verb / path information — record the binding so the harness + // still drives the middleware via a synthetic ctx. + if find_use_middleware(ast, file_bytes, &summary.name, &recv).is_some() { + let request_params = formals_for("/"); + return Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: HttpMethod::GET, + path: "/".to_owned(), + }), + request_params, + response_writer: None, + middleware: vec![MiddlewareShape { + name: "koa.use".to_owned(), + }], + }); + } + None } #[cfg(test)] @@ -240,4 +281,33 @@ mod tests { .is_none() ); } + + #[test] + fn skips_route_registered_on_non_koa_router_alias() { + let src: &[u8] = b"const Koa = require('koa');\n\ + const Router = require('@koa/router');\n\ + const router = new Map();\n\ + async function handler(ctx) { ctx.body = 'ok'; }\n\ + router.get('/x', handler);\n"; + let tree = parse_js(src); + assert!( + JsKoaAdapter + .detect(&summary("handler"), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn skips_use_middleware_on_non_koa_app_alias() { + let src: &[u8] = b"const Koa = require('koa');\n\ + const app = new Set();\n\ + async function logger(ctx, next) { await next(); }\n\ + app.use(logger);\n"; + let tree = parse_js(src); + assert!( + JsKoaAdapter + .detect(&summary("logger"), tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/js_nest.rs b/src/dynamic/framework/adapters/js_nest.rs index 04dff2f3..7e59008a 100644 --- a/src/dynamic/framework/adapters/js_nest.rs +++ b/src/dynamic/framework/adapters/js_nest.rs @@ -24,12 +24,13 @@ use crate::dynamic::framework::{ }; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; use tree_sitter::Node; use super::js_routes::{ bind_path_params, extract_path_placeholders, function_formal_names, http_verb_from_method, - last_segment, source_imports_nest, strip_quotes, + last_segment, source_imports_nest, source_imports_nest_common, strip_quotes, }; pub struct JsNestAdapter; @@ -55,6 +56,16 @@ impl FrameworkAdapter for JsNestAdapter { ) -> Option { detect_nest(summary, ast, file_bytes, JS_ADAPTER_NAME) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + _ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_nest(summary, ast, file_bytes, JS_ADAPTER_NAME) + } } impl FrameworkAdapter for TsNestAdapter { @@ -74,6 +85,16 @@ impl FrameworkAdapter for TsNestAdapter { ) -> Option { detect_nest(summary, ast, file_bytes, TS_ADAPTER_NAME) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + _ssa_summary: Option<&SsaFuncSummary>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_nest(summary, ast, file_bytes, TS_ADAPTER_NAME) + } } fn detect_nest( @@ -82,7 +103,7 @@ fn detect_nest( file_bytes: &[u8], adapter_name: &'static str, ) -> Option { - if !source_imports_nest(file_bytes) { + if !source_imports_nest(file_bytes) || !source_imports_nest_common(file_bytes) { return None; } let (class_node, method_node) = find_class_method(ast, file_bytes, &summary.name)?; @@ -730,4 +751,21 @@ mod tests { .is_none() ); } + + #[test] + fn skips_unrelated_controller_decorator_without_nest_import() { + let src: &[u8] = b"function Controller(path: string) { return function(_: any) {}; }\n\ + function Get(path: string) { return function(_: any, __: string) {}; }\n\ + @Controller('users')\n\ + export class UsersController {\n\ + @Get(':id')\n\ + getUser(id: string) { return id; }\n\ + }\n"; + let tree = parse_ts(src); + assert!( + TsNestAdapter + .detect(&summary("getUser", "typescript"), tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/js_routes.rs b/src/dynamic/framework/adapters/js_routes.rs index c7768ab2..f625fbcd 100644 --- a/src/dynamic/framework/adapters/js_routes.rs +++ b/src/dynamic/framework/adapters/js_routes.rs @@ -10,6 +10,8 @@ //! template. use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource}; +use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use tree_sitter::Node; /// True when `bytes` carries any of the well-known Express import @@ -84,6 +86,26 @@ pub fn source_imports_nest(bytes: &[u8]) -> bool { ) } +/// True when the file imports Nest's decorator package explicitly. +/// +/// A bare `@Controller` token is too weak for receiver/class-shape +/// narrowing: decorator-heavy frontend or DI code can define an unrelated +/// `Controller` decorator. Nest route binding should therefore require +/// the canonical Nest package marker when the adapter classifies a +/// decorator-shaped controller. +pub fn source_imports_nest_common(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"@nestjs/common", + b"require('@nestjs/common')", + b"require(\"@nestjs/common\")", + b"from '@nestjs/common'", + b"from \"@nestjs/common\"", + ], + ) +} + fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool { needles .iter() @@ -98,6 +120,274 @@ pub fn last_segment(callee: &str) -> &str { callee.rsplit_once('.').map(|(_, s)| s).unwrap_or(callee) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JsFrameworkObject { + Express, + Koa, + Fastify, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReceiverOrigin { + Framework(JsFrameworkObject), + NonFramework, + Unknown, +} + +/// Return `true` when the route receiver is either unresolved or proven to +/// originate from the expected framework object. +/// +/// The JS route adapters intentionally accept conventional aliases such as +/// `app`, `router`, and `server`. This helper adds a local declaration +/// check so files that merely import a framework but register a handler on +/// `new Map()` / `new Set()` / a different framework instance do not bind as +/// HTTP routes. Unknown origins remain permissive to keep plugin callback +/// shapes (`fastify.register((instance) => instance.get(...))`) working. +pub fn receiver_origin_allows_framework( + root: Node<'_>, + bytes: &[u8], + receiver: &str, + expected: JsFrameworkObject, +) -> bool { + match find_receiver_origin(root, bytes, receiver) { + ReceiverOrigin::Framework(found) => found == expected, + ReceiverOrigin::NonFramework => false, + ReceiverOrigin::Unknown => true, + } +} + +fn find_receiver_origin(root: Node<'_>, bytes: &[u8], receiver: &str) -> ReceiverOrigin { + let mut out = ReceiverOrigin::Unknown; + walk_for_receiver_origin(root, bytes, receiver, &mut out); + out +} + +fn walk_for_receiver_origin( + node: Node<'_>, + bytes: &[u8], + receiver: &str, + out: &mut ReceiverOrigin, +) { + if *out != ReceiverOrigin::Unknown { + return; + } + match node.kind() { + "variable_declarator" => { + if let Some(name) = node.child_by_field_name("name") + && node_name_matches(name, bytes, receiver) + && let Some(value) = node.child_by_field_name("value") + { + *out = classify_receiver_value(value, bytes); + if *out != ReceiverOrigin::Unknown { + return; + } + } + } + "assignment_expression" => { + if let Some(left) = node.child_by_field_name("left") + && node_name_matches(left, bytes, receiver) + && let Some(right) = node.child_by_field_name("right") + { + *out = classify_receiver_value(right, bytes); + if *out != ReceiverOrigin::Unknown { + return; + } + } + } + _ => {} + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + walk_for_receiver_origin(child, bytes, receiver, out); + if *out != ReceiverOrigin::Unknown { + return; + } + } +} + +fn node_name_matches(node: Node<'_>, bytes: &[u8], receiver: &str) -> bool { + node.utf8_text(bytes) + .ok() + .map(|text| last_segment(text.trim()) == receiver) + .unwrap_or(false) +} + +fn classify_receiver_value(value: Node<'_>, bytes: &[u8]) -> ReceiverOrigin { + let text = value + .utf8_text(bytes) + .unwrap_or("") + .chars() + .filter(|c| !c.is_whitespace()) + .collect::(); + if text.is_empty() { + return ReceiverOrigin::Unknown; + } + if value_is_non_framework(value, &text) { + return ReceiverOrigin::NonFramework; + } + if value_is_express(value, &text, bytes) { + return ReceiverOrigin::Framework(JsFrameworkObject::Express); + } + if value_is_koa(value, &text, bytes) { + return ReceiverOrigin::Framework(JsFrameworkObject::Koa); + } + if value_is_fastify(value, &text, bytes) { + return ReceiverOrigin::Framework(JsFrameworkObject::Fastify); + } + ReceiverOrigin::Unknown +} + +fn value_is_non_framework(value: Node<'_>, compact_text: &str) -> bool { + let leaf = call_leaf(value, compact_text); + matches!( + leaf.as_deref(), + Some("Map") + | Some("Set") + | Some("WeakMap") + | Some("WeakSet") + | Some("Array") + | Some("Object") + | Some("URL") + | Some("Request") + | Some("Response") + | Some("Date") + | Some("Promise") + ) || compact_text == "{}" + || compact_text == "[]" + || compact_text.starts_with("Object.create(") +} + +fn value_is_express(value: Node<'_>, compact_text: &str, bytes: &[u8]) -> bool { + if compact_text.contains("require('express')") + || compact_text.contains("require(\"express\")") + || compact_text.contains("express.Router(") + { + return true; + } + if !source_imports_express(bytes) { + return false; + } + matches!( + call_leaf(value, compact_text).as_deref(), + Some("express" | "Router") + ) +} + +fn value_is_koa(value: Node<'_>, compact_text: &str, bytes: &[u8]) -> bool { + if compact_text.contains("require('koa')") + || compact_text.contains("require(\"koa\")") + || compact_text.contains("require('@koa/router')") + || compact_text.contains("require(\"@koa/router\")") + { + return true; + } + if !source_imports_koa(bytes) { + return false; + } + matches!( + call_leaf(value, compact_text).as_deref(), + Some("Koa" | "Router" | "KoaRouter") + ) +} + +fn value_is_fastify(value: Node<'_>, compact_text: &str, bytes: &[u8]) -> bool { + if compact_text.contains("require('fastify')") || compact_text.contains("require(\"fastify\")") + { + return true; + } + if !source_imports_fastify(bytes) { + return false; + } + matches!( + call_leaf(value, compact_text).as_deref(), + Some("fastify" | "Fastify") + ) +} + +fn call_leaf(_value: Node<'_>, compact_text: &str) -> Option { + let mut text = compact_text.trim(); + while text.starts_with('(') && text.ends_with(')') && text.len() > 2 { + text = &text[1..text.len() - 1]; + } + if let Some(rest) = text.strip_prefix("new") { + text = rest; + } + let callee = text.split('(').next().unwrap_or(text); + if callee.is_empty() { + None + } else { + Some(last_segment(callee).to_owned()) + } +} + +/// Use SSA receiver facts, when the caller supplied them, to reject a route +/// registration whose call receiver is known to be a different container. +/// +/// Most adapter callers still lack SSA for the setup function that owns the +/// route-registration call. In that case this helper deliberately returns +/// `true`, preserving the existing AST-only binding path. When an SSA map +/// and matching call site are present, an incompatible container is a strong +/// signal that a permissive alias such as `app` or `router` is not the +/// framework object after all. +pub fn ssa_receiver_allows_framework( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + receiver: &str, + method: &str, + expected: JsFrameworkObject, +) -> bool { + let Some(ssa_summary) = ssa_summary else { + return true; + }; + for site in &summary.callees { + if !callee_site_matches(site, receiver, method) { + continue; + } + let Some(container) = + container_for_ordinal(&ssa_summary.typed_call_receivers, site.ordinal) + else { + continue; + }; + return typed_container_allows_framework(container, expected); + } + true +} + +fn callee_site_matches(site: &crate::summary::CalleeSite, receiver: &str, method: &str) -> bool { + if let Some(site_receiver) = site.receiver.as_deref() + && last_segment(site_receiver) != receiver + { + return false; + } + let leaf = site + .name + .rsplit(['.', ':']) + .next() + .unwrap_or(site.name.as_str()); + if method == "*" { + return http_verb_from_method(leaf).is_some() || matches!(leaf, "route" | "use"); + } + leaf == method +} + +fn container_for_ordinal(typed: &[(u32, String)], ordinal: u32) -> Option<&str> { + typed + .iter() + .find(|(ord, _)| *ord == ordinal) + .map(|(_, container)| container.as_str()) +} + +fn typed_container_allows_framework(container: &str, expected: JsFrameworkObject) -> bool { + let lc = container.to_ascii_lowercase(); + match expected { + JsFrameworkObject::Express => { + lc.contains("express") || lc == "router" || lc.ends_with("expressrouter") + } + JsFrameworkObject::Koa => lc.contains("koa") || lc == "router" || lc.ends_with("koarouter"), + JsFrameworkObject::Fastify => lc.contains("fastify"), + } +} + /// Map a route-method name (`get` / `post` / `put` / `patch` / /// `delete` / `options` / `head` / `all`) to an [`HttpMethod`]. /// Returns `None` for callees that do not look like an HTTP-verb diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 1898fa75..a63c3ffa 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -2667,6 +2667,7 @@ const _res = {{ // populate _captured after the handler return. Wait up to 3s for a // res.send / res.end / res.json call before flushing stdout. await Promise.race([_responded, new Promise(function (r) {{ setTimeout(r, 3000); }})]); + process.stdout.write('__NYX_SINK_HIT__\n'); process.stdout.write(_captured + '\n'); }} catch (e) {{ process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); @@ -2729,6 +2730,7 @@ if (_kind === 'query') {{ // Wait up to 3s for an async ctx.body assignment (e.g. from a // child_process.exec callback) before flushing stdout. await Promise.race([_responded, new Promise(function (r) {{ setTimeout(r, 3000); }})]); + process.stdout.write('__NYX_SINK_HIT__\n'); process.stdout.write(String(_ctx.body == null ? '' : _ctx.body) + '\n'); }} catch (e) {{ process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); @@ -2854,6 +2856,7 @@ if (_kind === 'query') {{ if (_query) _injectOpts.query = _query; if (_bodyArg !== undefined) _injectOpts.payload = _bodyArg; const _res = await _app.inject(_injectOpts); + process.stdout.write('__NYX_SINK_HIT__\n'); process.stdout.write(String(_res.body == null ? '' : _res.body) + '\n'); }} catch (e) {{ process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); @@ -2954,6 +2957,7 @@ if (_kind === 'env') {{ _req = _req.set('content-type', 'application/json').send(payload); }} const _res = await _req; + process.stdout.write('__NYX_SINK_HIT__\n'); process.stdout.write(String(_res.text == null ? '' : _res.text) + '\n'); if (typeof _app.close === 'function') await _app.close(); }} catch (e) {{ @@ -3925,8 +3929,7 @@ mod tests { #[test] fn emit_json_parse_harness_derives_entry_stem_from_entry_file() { - let h = - emit_json_parse_harness(&make_json_parse_spec("/abs/path/benign.js", "run")); + let h = emit_json_parse_harness(&make_json_parse_spec("/abs/path/benign.js", "run")); assert!(h.source.contains("require('./benign')")); } @@ -3941,10 +3944,7 @@ mod tests { #[test] fn emit_dispatches_to_unauthorized_id_harness_when_cap_is_unauthorized_id() { let h = emit( - &make_unauthorized_id_spec( - "tests/dynamic_fixtures/unauthorized_id/js/vuln.js", - "run", - ), + &make_unauthorized_id_spec("tests/dynamic_fixtures/unauthorized_id/js/vuln.js", "run"), false, ) .unwrap(); @@ -3972,7 +3972,8 @@ mod tests { h.source ); assert!( - h.source.contains("_nyx_idor_probe(_NYX_CALLER_ID, payload)"), + h.source + .contains("_nyx_idor_probe(_NYX_CALLER_ID, payload)"), "harness must emit the IDOR probe with the hard-coded caller and the payload owner_id: {}", h.source ); @@ -4016,10 +4017,8 @@ mod tests { #[test] fn emit_unauthorized_id_harness_derives_entry_stem_from_entry_file() { - let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec( - "/abs/path/benign.js", - "run", - )); + let h = + emit_unauthorized_id_harness(&make_unauthorized_id_spec("/abs/path/benign.js", "run")); assert!(h.source.contains("require('./benign')")); } @@ -4074,7 +4073,8 @@ mod tests { "run", )); assert!( - h.source.contains("global.fetch = async function _nyx_fetch_shim"), + h.source + .contains("global.fetch = async function _nyx_fetch_shim"), "harness must also intercept global.fetch so Node 18+ fixtures that use the WHATWG fetch API are captured: {}", h.source ); diff --git a/tests/dynamic_fixtures/js_frameworks/express/vuln.js b/tests/dynamic_fixtures/js_frameworks/express/vuln.js index 3c8952e3..173f8f8b 100644 --- a/tests/dynamic_fixtures/js_frameworks/express/vuln.js +++ b/tests/dynamic_fixtures/js_frameworks/express/vuln.js @@ -12,7 +12,7 @@ const app = express(); function runCmd(req, res) { const cmd = req.query.cmd || ''; - exec(cmd, (err, stdout) => { + exec('ls ' + cmd, (err, stdout) => { if (err) return res.status(500).send(String(err)); res.send(stdout); }); diff --git a/tests/dynamic_fixtures/js_frameworks/fastify/vuln.js b/tests/dynamic_fixtures/js_frameworks/fastify/vuln.js index 8ab4aacb..b481932b 100644 --- a/tests/dynamic_fixtures/js_frameworks/fastify/vuln.js +++ b/tests/dynamic_fixtures/js_frameworks/fastify/vuln.js @@ -10,7 +10,7 @@ const { exec } = require('child_process'); async function runCmd(request, reply) { const cmd = request.query.cmd || ''; const out = await new Promise((resolve) => { - exec(cmd, (err, stdout) => resolve(err ? String(err) : stdout)); + exec('ls ' + cmd, (err, stdout) => resolve(err ? String(err) : stdout)); }); reply.send(out); } diff --git a/tests/dynamic_fixtures/js_frameworks/koa/vuln.js b/tests/dynamic_fixtures/js_frameworks/koa/vuln.js index d1f458b3..088d8fab 100644 --- a/tests/dynamic_fixtures/js_frameworks/koa/vuln.js +++ b/tests/dynamic_fixtures/js_frameworks/koa/vuln.js @@ -14,7 +14,7 @@ const router = new Router(); async function runCmd(ctx) { const cmd = ctx.query.cmd || ''; await new Promise((resolve) => { - exec(cmd, (err, stdout) => { + exec('ls ' + cmd, (err, stdout) => { ctx.body = err ? String(err) : stdout; resolve(); }); diff --git a/tests/dynamic_fixtures/js_frameworks/nest/vuln.js b/tests/dynamic_fixtures/js_frameworks/nest/vuln.js index f7b559b0..68da5269 100644 --- a/tests/dynamic_fixtures/js_frameworks/nest/vuln.js +++ b/tests/dynamic_fixtures/js_frameworks/nest/vuln.js @@ -17,7 +17,7 @@ class AppController { @Get('run') runCmd(@Query('cmd') cmd) { return new Promise((resolve) => { - exec(cmd || '', (err, stdout) => { + exec('ls ' + (cmd || ''), (err, stdout) => { resolve(err ? String(err) : stdout); }); }); diff --git a/tests/js_frameworks_corpus.rs b/tests/js_frameworks_corpus.rs index 48d70ecc..982d1979 100644 --- a/tests/js_frameworks_corpus.rs +++ b/tests/js_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; @@ -187,3 +189,170 @@ fn express_adapter_runs_before_fastify_for_express_files() { let binding = detect_binding(&summary, tree.root_node(), src, Lang::JavaScript).expect("fires"); assert_eq!(binding.adapter, "js-express"); } + +mod e2e_phase_13 { + use super::{parse_js, summary_for}; + use crate::common::fixture_harness::FIXTURE_LOCK; + use nyx_scanner::dynamic::framework::{FrameworkBinding, 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, 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 detect_framework(entry_file: &str, entry_name: &str) -> FrameworkBinding { + let bytes = std::fs::read(entry_file).expect("fixture copy exists"); + let tree = parse_js(&bytes); + let summary = summary_for(entry_name, entry_file); + detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript) + .expect("JS framework fixture must bind before run_spec") + } + + fn build_spec(fixture_subdir: &str, fixture_file: &str) -> (HarnessSpec, TempDir) { + let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/js_frameworks") + .join(fixture_subdir) + .join(fixture_file); + let tmp = TempDir::new().expect("create tempdir"); + let dst = tmp.path().join(fixture_file); + std::fs::copy(&fixture_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"phase13-e2e-js-framework|"); + digest.update(fixture_subdir.as_bytes()); + digest.update(b"|"); + digest.update(fixture_file.as_bytes()); + let spec_hash = format!("{:016x}", { + let bytes = digest.finalize(); + u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap()) + }); + let framework = Some(detect_framework(&entry_file, "runCmd")); + + let spec = HarnessSpec { + finding_id: spec_hash.clone(), + entry_file: entry_file.clone(), + entry_name: "runCmd".to_owned(), + entry_kind: EntryKind::HttpRoute, + lang: Lang::JavaScript, + toolchain_id: default_toolchain_id(Lang::JavaScript).into(), + payload_slot: PayloadSlot::QueryParam("cmd".to_owned()), + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: entry_file, + sink_line: 1, + spec_hash: spec_hash.clone(), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), + }; + + (spec, tmp) + } + + fn run(fixture_subdir: &str, fixture_file: &str) -> Option { + if !command_available("node") { + eprintln!("SKIP {fixture_subdir}/{fixture_file}: missing node"); + return None; + } + let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (spec, _tmp) = build_spec(fixture_subdir, fixture_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 {fixture_subdir}/{fixture_file}: harness build failed after {attempts} attempts: {stderr}", + ); + None + } + Err(e) => panic!("run_spec({fixture_subdir}/{fixture_file}) errored: {e:?}"), + } + } + + fn assert_confirmed(fixture_subdir: &str) { + let Some(outcome) = run(fixture_subdir, "vuln.js") else { + return; + }; + assert!( + outcome.triggered_by.is_some(), + "{fixture_subdir} JS framework vuln must Confirm via run_spec; got {outcome:?}", + ); + let diff = outcome + .differential + .as_ref() + .expect("Confirmed run must carry a DifferentialOutcome"); + assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + } + + fn assert_not_confirmed(fixture_subdir: &str) { + let Some(outcome) = run(fixture_subdir, "benign.js") else { + return; + }; + assert!( + outcome.triggered_by.is_none(), + "{fixture_subdir} JS framework benign control must not Confirm; got {outcome:?}", + ); + if let Some(diff) = &outcome.differential { + assert_ne!(diff.verdict, DifferentialVerdict::Confirmed); + } + } + + #[test] + fn express_vuln_confirms_via_run_spec() { + assert_confirmed("express"); + } + + #[test] + fn express_benign_does_not_confirm_via_run_spec() { + assert_not_confirmed("express"); + } + + #[test] + fn koa_vuln_confirms_via_run_spec() { + assert_confirmed("koa"); + } + + #[test] + fn koa_benign_does_not_confirm_via_run_spec() { + assert_not_confirmed("koa"); + } + + #[test] + fn fastify_vuln_confirms_via_run_spec() { + assert_confirmed("fastify"); + } + + #[test] + fn fastify_benign_does_not_confirm_via_run_spec() { + assert_not_confirmed("fastify"); + } + + #[test] + fn nest_vuln_confirms_via_run_spec() { + assert_confirmed("nest"); + } + + #[test] + fn nest_benign_does_not_confirm_via_run_spec() { + assert_not_confirmed("nest"); + } +}