mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
refactor(dynamic): ensure unique workdir names to avoid conflicts, improve Java sibling stub handling, and enhance comments
This commit is contained in:
parent
1e5f27f56d
commit
4bcdec3a1b
16 changed files with 1267 additions and 228 deletions
|
|
@ -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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ParamBi
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Role carried by a Java framework-injected request/response formal.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JavaReceiverRole {
|
||||
Request,
|
||||
Response,
|
||||
}
|
||||
|
||||
/// Use SSA receiver facts, when supplied, to reject framework bindings whose
|
||||
/// request/response formal is proven to be a different receiver class.
|
||||
///
|
||||
/// Most callers still reach adapters without SSA or without a receiver fact for
|
||||
/// a given call ordinal. Those cases remain permissive. Only a matching
|
||||
/// `summary.callees` receiver plus an incompatible
|
||||
/// `SsaFuncSummary::typed_call_receivers` entry is strong enough to suppress
|
||||
/// the binding.
|
||||
pub fn java_receiver_facts_allow_formals(
|
||||
summary: &FuncSummary,
|
||||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
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<JavaReceiverRole> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
detect_express(summary, ssa_summary, ast, file_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_express(
|
||||
summary: &FuncSummary,
|
||||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
detect_fastify(summary, ssa_summary, ast, file_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_fastify(
|
||||
summary: &FuncSummary,
|
||||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Node<'a>> {
|
||||
fn find_use_middleware<'a>(
|
||||
root: Node<'a>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
receiver_accepts: &dyn Fn(&str) -> bool,
|
||||
) -> Option<Node<'a>> {
|
||||
let mut hit: Option<Node<'a>> = 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<Node<'a>>) {
|
||||
fn walk_for_use<'a>(
|
||||
node: Node<'a>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
receiver_accepts: &dyn Fn(&str) -> bool,
|
||||
out: &mut Option<Node<'a>>,
|
||||
) {
|
||||
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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
detect_koa(summary, ssa_summary, ast, file_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_koa(
|
||||
summary: &FuncSummary,
|
||||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
detect_nest(summary, ast, file_bytes, JS_ADAPTER_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for TsNestAdapter {
|
||||
|
|
@ -74,6 +85,16 @@ impl FrameworkAdapter for TsNestAdapter {
|
|||
) -> Option<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
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<FrameworkBinding> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<String>();
|
||||
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<String> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<RunOutcome> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue