mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-27 20:29:39 +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::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape};
|
||||||
use crate::evidence::EntryKind;
|
use crate::evidence::EntryKind;
|
||||||
use crate::summary::FuncSummary;
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||||
use crate::symbol::Lang;
|
use crate::symbol::Lang;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
use super::java_routes::{
|
use super::java_routes::{
|
||||||
annotation_string_arg, bind_java_params, collect_security_annotations, find_class_with_method,
|
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;
|
pub struct JavaMicronautAdapter;
|
||||||
|
|
@ -59,6 +61,38 @@ fn method_verb_and_path(method: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, S
|
||||||
hit
|
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 {
|
impl FrameworkAdapter for JavaMicronautAdapter {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
ADAPTER_NAME
|
ADAPTER_NAME
|
||||||
|
|
@ -74,27 +108,17 @@ impl FrameworkAdapter for JavaMicronautAdapter {
|
||||||
ast: Node<'_>,
|
ast: Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
if !source_imports_micronaut(file_bytes) {
|
detect_micronaut(summary, None, ast, file_bytes)
|
||||||
return None;
|
}
|
||||||
}
|
|
||||||
let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?;
|
fn detect_with_context(
|
||||||
let class_prefix = class_path_prefix(class, file_bytes)?;
|
&self,
|
||||||
let (http_method, method_path) = method_verb_and_path(method, file_bytes)?;
|
summary: &FuncSummary,
|
||||||
let path = join_route_path(&class_prefix, &method_path);
|
ssa_summary: Option<&SsaFuncSummary>,
|
||||||
let formals = method_formal_types(method, file_bytes);
|
ast: Node<'_>,
|
||||||
let request_params = bind_java_params(&formals, &path);
|
file_bytes: &[u8],
|
||||||
let middleware = collect_security_annotations(class, method, file_bytes);
|
) -> Option<FrameworkBinding> {
|
||||||
Some(FrameworkBinding {
|
detect_micronaut(summary, ssa_summary, ast, file_bytes)
|
||||||
adapter: ADAPTER_NAME.to_owned(),
|
|
||||||
kind: EntryKind::HttpRoute,
|
|
||||||
route: Some(RouteShape {
|
|
||||||
method: http_method,
|
|
||||||
path,
|
|
||||||
}),
|
|
||||||
request_params,
|
|
||||||
response_writer: None,
|
|
||||||
middleware,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,6 +126,7 @@ impl FrameworkAdapter for JavaMicronautAdapter {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::dynamic::framework::ParamSource;
|
use crate::dynamic::framework::ParamSource;
|
||||||
|
use crate::summary::CalleeSite;
|
||||||
|
|
||||||
fn parse(src: &[u8]) -> tree_sitter::Tree {
|
fn parse(src: &[u8]) -> tree_sitter::Tree {
|
||||||
let mut parser = tree_sitter::Parser::new();
|
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]
|
#[test]
|
||||||
fn fires_on_controller_plus_get() {
|
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";
|
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");
|
.expect("binding");
|
||||||
assert!(binding.middleware.iter().any(|m| m.name == "@Secured"));
|
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::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape};
|
||||||
use crate::evidence::EntryKind;
|
use crate::evidence::EntryKind;
|
||||||
use crate::summary::FuncSummary;
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||||
use crate::symbol::Lang;
|
use crate::symbol::Lang;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
use super::java_routes::{
|
use super::java_routes::{
|
||||||
annotation_string_arg, bind_java_params, collect_security_annotations, find_class_with_method,
|
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;
|
pub struct JavaQuarkusAdapter;
|
||||||
|
|
@ -63,6 +65,38 @@ fn method_verb_and_path(method: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, S
|
||||||
Some((verb?, path))
|
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 {
|
impl FrameworkAdapter for JavaQuarkusAdapter {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
ADAPTER_NAME
|
ADAPTER_NAME
|
||||||
|
|
@ -78,27 +112,17 @@ impl FrameworkAdapter for JavaQuarkusAdapter {
|
||||||
ast: Node<'_>,
|
ast: Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
if !source_imports_quarkus(file_bytes) {
|
detect_quarkus(summary, None, ast, file_bytes)
|
||||||
return None;
|
}
|
||||||
}
|
|
||||||
let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?;
|
fn detect_with_context(
|
||||||
let (http_method, method_path) = method_verb_and_path(method, file_bytes)?;
|
&self,
|
||||||
let class_prefix = class_path_prefix(class, file_bytes);
|
summary: &FuncSummary,
|
||||||
let path = join_route_path(&class_prefix, &method_path);
|
ssa_summary: Option<&SsaFuncSummary>,
|
||||||
let formals = method_formal_types(method, file_bytes);
|
ast: Node<'_>,
|
||||||
let request_params = bind_java_params(&formals, &path);
|
file_bytes: &[u8],
|
||||||
let middleware = collect_security_annotations(class, method, file_bytes);
|
) -> Option<FrameworkBinding> {
|
||||||
Some(FrameworkBinding {
|
detect_quarkus(summary, ssa_summary, ast, file_bytes)
|
||||||
adapter: ADAPTER_NAME.to_owned(),
|
|
||||||
kind: EntryKind::HttpRoute,
|
|
||||||
route: Some(RouteShape {
|
|
||||||
method: http_method,
|
|
||||||
path,
|
|
||||||
}),
|
|
||||||
request_params,
|
|
||||||
response_writer: None,
|
|
||||||
middleware,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,6 +130,7 @@ impl FrameworkAdapter for JavaQuarkusAdapter {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::dynamic::framework::ParamSource;
|
use crate::dynamic::framework::ParamSource;
|
||||||
|
use crate::summary::CalleeSite;
|
||||||
|
|
||||||
fn parse(src: &[u8]) -> tree_sitter::Tree {
|
fn parse(src: &[u8]) -> tree_sitter::Tree {
|
||||||
let mut parser = tree_sitter::Parser::new();
|
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]
|
#[test]
|
||||||
fn fires_on_class_path_plus_method_get() {
|
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";
|
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");
|
.expect("binding");
|
||||||
assert!(binding.middleware.iter().any(|m| m.name == "@RolesAllowed"));
|
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::auth_markers;
|
||||||
use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource};
|
use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource};
|
||||||
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||||
use crate::symbol::Lang;
|
use crate::symbol::Lang;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
|
|
@ -374,6 +376,98 @@ pub fn bind_java_params(formals: &[(String, String)], path: &str) -> Vec<ParamBi
|
||||||
.collect()
|
.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 {
|
fn is_implicit_type(ty: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
ty,
|
ty,
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,14 @@
|
||||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape};
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape};
|
||||||
use crate::evidence::EntryKind;
|
use crate::evidence::EntryKind;
|
||||||
use crate::summary::FuncSummary;
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||||
use crate::symbol::Lang;
|
use crate::symbol::Lang;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
use super::java_routes::{
|
use super::java_routes::{
|
||||||
annotation_string_arg, bind_java_params, class_extends, collect_security_annotations,
|
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;
|
pub struct JavaServletAdapter;
|
||||||
|
|
@ -53,6 +55,42 @@ fn formals_look_like_servlet(formals: &[(String, String)]) -> bool {
|
||||||
.any(|(ty, _)| ty == "HttpServletRequest" || ty == "ServletRequest")
|
.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 {
|
impl FrameworkAdapter for JavaServletAdapter {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
ADAPTER_NAME
|
ADAPTER_NAME
|
||||||
|
|
@ -68,31 +106,17 @@ impl FrameworkAdapter for JavaServletAdapter {
|
||||||
ast: Node<'_>,
|
ast: Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
if !source_imports_servlet(file_bytes) {
|
detect_servlet(summary, None, ast, file_bytes)
|
||||||
return None;
|
}
|
||||||
}
|
|
||||||
let http_method = servlet_method_for(&summary.name)?;
|
fn detect_with_context(
|
||||||
let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?;
|
&self,
|
||||||
let formals = method_formal_types(method, file_bytes);
|
summary: &FuncSummary,
|
||||||
let extends_servlet = class_extends(class, file_bytes, "HttpServlet")
|
ssa_summary: Option<&SsaFuncSummary>,
|
||||||
|| class_extends(class, file_bytes, "GenericServlet");
|
ast: Node<'_>,
|
||||||
if !extends_servlet && !formals_look_like_servlet(&formals) {
|
file_bytes: &[u8],
|
||||||
return None;
|
) -> Option<FrameworkBinding> {
|
||||||
}
|
detect_servlet(summary, ssa_summary, ast, file_bytes)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,6 +124,7 @@ impl FrameworkAdapter for JavaServletAdapter {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::dynamic::framework::ParamSource;
|
use crate::dynamic::framework::ParamSource;
|
||||||
|
use crate::summary::CalleeSite;
|
||||||
|
|
||||||
fn parse(src: &[u8]) -> tree_sitter::Tree {
|
fn parse(src: &[u8]) -> tree_sitter::Tree {
|
||||||
let mut parser = tree_sitter::Parser::new();
|
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]
|
#[test]
|
||||||
fn fires_on_extends_http_servlet_doget() {
|
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";
|
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");
|
.expect("binding");
|
||||||
assert!(binding.middleware.iter().any(|m| m.name == "@PreAuthorize"));
|
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::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape};
|
||||||
use crate::evidence::EntryKind;
|
use crate::evidence::EntryKind;
|
||||||
use crate::summary::FuncSummary;
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||||
use crate::symbol::Lang;
|
use crate::symbol::Lang;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
use super::java_routes::{
|
use super::java_routes::{
|
||||||
annotation_string_arg, bind_java_params, collect_security_annotations, find_class_with_method,
|
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,
|
iter_annotations, java_receiver_facts_allow_formals, join_route_path, method_formal_types,
|
||||||
source_imports_quarkus, source_imports_spring,
|
request_method_from_args, source_imports_quarkus, source_imports_spring,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct JavaSpringAdapter;
|
pub struct JavaSpringAdapter;
|
||||||
|
|
@ -77,6 +78,66 @@ fn method_route(method: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, String)>
|
||||||
hit
|
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 {
|
impl FrameworkAdapter for JavaSpringAdapter {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
ADAPTER_NAME
|
ADAPTER_NAME
|
||||||
|
|
@ -92,55 +153,17 @@ impl FrameworkAdapter for JavaSpringAdapter {
|
||||||
ast: Node<'_>,
|
ast: Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
if !source_imports_spring(file_bytes) {
|
detect_spring(summary, None, ast, 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);
|
|
||||||
|
|
||||||
Some(FrameworkBinding {
|
fn detect_with_context(
|
||||||
adapter: ADAPTER_NAME.to_owned(),
|
&self,
|
||||||
kind: EntryKind::HttpRoute,
|
summary: &FuncSummary,
|
||||||
route: Some(RouteShape {
|
ssa_summary: Option<&SsaFuncSummary>,
|
||||||
method: http_method,
|
ast: Node<'_>,
|
||||||
path,
|
file_bytes: &[u8],
|
||||||
}),
|
) -> Option<FrameworkBinding> {
|
||||||
request_params,
|
detect_spring(summary, ssa_summary, ast, file_bytes)
|
||||||
response_writer: None,
|
|
||||||
middleware,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,6 +171,7 @@ impl FrameworkAdapter for JavaSpringAdapter {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::dynamic::framework::ParamSource;
|
use crate::dynamic::framework::ParamSource;
|
||||||
|
use crate::summary::CalleeSite;
|
||||||
|
|
||||||
fn parse(src: &[u8]) -> tree_sitter::Tree {
|
fn parse(src: &[u8]) -> tree_sitter::Tree {
|
||||||
let mut parser = tree_sitter::Parser::new();
|
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]
|
#[test]
|
||||||
fn fires_on_get_mapping_with_class_prefix() {
|
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";
|
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");
|
.expect("binding");
|
||||||
assert!(binding.middleware.is_empty());
|
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::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape};
|
||||||
use crate::evidence::EntryKind;
|
use crate::evidence::EntryKind;
|
||||||
use crate::summary::FuncSummary;
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||||
use crate::symbol::Lang;
|
use crate::symbol::Lang;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
use super::js_routes::{
|
use super::js_routes::{
|
||||||
bind_path_params, extract_route_middleware, find_function_params, find_route_registration,
|
JsFrameworkObject, bind_path_params, extract_route_middleware, find_function_params,
|
||||||
function_formal_names, source_imports_express,
|
find_route_registration, function_formal_names, receiver_origin_allows_framework,
|
||||||
|
source_imports_express, ssa_receiver_allows_framework,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct JsExpressAdapter;
|
pub struct JsExpressAdapter;
|
||||||
|
|
@ -52,31 +54,62 @@ impl FrameworkAdapter for JsExpressAdapter {
|
||||||
ast: Node<'_>,
|
ast: Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
if !source_imports_express(file_bytes) {
|
detect_express(summary, None, ast, 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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::dynamic::framework::{HttpMethod, ParamSource};
|
use crate::dynamic::framework::{HttpMethod, ParamSource};
|
||||||
|
use crate::summary::CalleeSite;
|
||||||
|
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||||
|
|
||||||
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
|
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
|
||||||
let mut parser = tree_sitter::Parser::new();
|
let mut parser = tree_sitter::Parser::new();
|
||||||
|
|
@ -209,4 +242,68 @@ mod tests {
|
||||||
.is_none()
|
.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::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape};
|
||||||
use crate::evidence::EntryKind;
|
use crate::evidence::EntryKind;
|
||||||
use crate::summary::FuncSummary;
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||||
use crate::symbol::Lang;
|
use crate::symbol::Lang;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
use super::js_routes::{
|
use super::js_routes::{
|
||||||
bind_path_params, extract_route_middleware, find_function_params, find_route_registration,
|
JsFrameworkObject, bind_path_params, extract_route_middleware, find_function_params,
|
||||||
function_formal_names, source_imports_fastify,
|
find_route_registration, function_formal_names, receiver_origin_allows_framework,
|
||||||
|
source_imports_fastify, ssa_receiver_allows_framework,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct JsFastifyAdapter;
|
pub struct JsFastifyAdapter;
|
||||||
|
|
@ -54,25 +56,54 @@ impl FrameworkAdapter for JsFastifyAdapter {
|
||||||
ast: Node<'_>,
|
ast: Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
if !source_imports_fastify(file_bytes) {
|
detect_fastify(summary, None, ast, 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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -202,4 +233,18 @@ mod tests {
|
||||||
.is_none()
|
.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::evidence::EntryKind;
|
||||||
use crate::summary::FuncSummary;
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||||
use crate::symbol::Lang;
|
use crate::symbol::Lang;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
use super::js_routes::{
|
use super::js_routes::{
|
||||||
bind_path_params, extract_route_middleware, find_function_params, find_route_registration,
|
JsFrameworkObject, bind_path_params, extract_route_middleware, find_function_params,
|
||||||
function_formal_names, last_segment, source_imports_koa, view_arg_references,
|
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;
|
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
|
/// that reference `target`. Returns the matched call node so callers
|
||||||
/// can stamp a middleware-shape binding when the verb-based dispatch
|
/// can stamp a middleware-shape binding when the verb-based dispatch
|
||||||
/// fails to fire.
|
/// 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;
|
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
|
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() {
|
if out.is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +70,7 @@ fn walk_for_use<'a>(node: Node<'a>, bytes: &[u8], target: &str, out: &mut Option
|
||||||
&& prop_text == "use"
|
&& prop_text == "use"
|
||||||
&& let Some(object) = callee.child_by_field_name("object")
|
&& let Some(object) = callee.child_by_field_name("object")
|
||||||
&& let Some(obj_text) = object.utf8_text(bytes).ok()
|
&& 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 Some(args) = node.child_by_field_name("arguments")
|
||||||
{
|
{
|
||||||
let mut cur = args.walk();
|
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();
|
let mut cur = node.walk();
|
||||||
for child in node.children(&mut cur) {
|
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<'_>,
|
ast: Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
if !source_imports_koa(file_bytes) {
|
detect_koa(summary, None, ast, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -240,4 +281,33 @@ mod tests {
|
||||||
.is_none()
|
.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::evidence::EntryKind;
|
||||||
use crate::summary::FuncSummary;
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||||
use crate::symbol::Lang;
|
use crate::symbol::Lang;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
use super::js_routes::{
|
use super::js_routes::{
|
||||||
bind_path_params, extract_path_placeholders, function_formal_names, http_verb_from_method,
|
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;
|
pub struct JsNestAdapter;
|
||||||
|
|
@ -55,6 +56,16 @@ impl FrameworkAdapter for JsNestAdapter {
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
detect_nest(summary, ast, file_bytes, JS_ADAPTER_NAME)
|
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 {
|
impl FrameworkAdapter for TsNestAdapter {
|
||||||
|
|
@ -74,6 +85,16 @@ impl FrameworkAdapter for TsNestAdapter {
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
detect_nest(summary, ast, file_bytes, TS_ADAPTER_NAME)
|
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(
|
fn detect_nest(
|
||||||
|
|
@ -82,7 +103,7 @@ fn detect_nest(
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
adapter_name: &'static str,
|
adapter_name: &'static str,
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
if !source_imports_nest(file_bytes) {
|
if !source_imports_nest(file_bytes) || !source_imports_nest_common(file_bytes) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let (class_node, method_node) = find_class_method(ast, file_bytes, &summary.name)?;
|
let (class_node, method_node) = find_class_method(ast, file_bytes, &summary.name)?;
|
||||||
|
|
@ -730,4 +751,21 @@ mod tests {
|
||||||
.is_none()
|
.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.
|
//! template.
|
||||||
|
|
||||||
use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource};
|
use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource};
|
||||||
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
/// True when `bytes` carries any of the well-known Express import
|
/// 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 {
|
fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool {
|
||||||
needles
|
needles
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -98,6 +120,274 @@ pub fn last_segment(callee: &str) -> &str {
|
||||||
callee.rsplit_once('.').map(|(_, s)| s).unwrap_or(callee)
|
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` /
|
/// Map a route-method name (`get` / `post` / `put` / `patch` /
|
||||||
/// `delete` / `options` / `head` / `all`) to an [`HttpMethod`].
|
/// `delete` / `options` / `head` / `all`) to an [`HttpMethod`].
|
||||||
/// Returns `None` for callees that do not look like an HTTP-verb
|
/// 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
|
// populate _captured after the handler return. Wait up to 3s for a
|
||||||
// res.send / res.end / res.json call before flushing stdout.
|
// res.send / res.end / res.json call before flushing stdout.
|
||||||
await Promise.race([_responded, new Promise(function (r) {{ setTimeout(r, 3000); }})]);
|
await Promise.race([_responded, new Promise(function (r) {{ setTimeout(r, 3000); }})]);
|
||||||
|
process.stdout.write('__NYX_SINK_HIT__\n');
|
||||||
process.stdout.write(_captured + '\n');
|
process.stdout.write(_captured + '\n');
|
||||||
}} catch (e) {{
|
}} catch (e) {{
|
||||||
process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n');
|
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
|
// Wait up to 3s for an async ctx.body assignment (e.g. from a
|
||||||
// child_process.exec callback) before flushing stdout.
|
// child_process.exec callback) before flushing stdout.
|
||||||
await Promise.race([_responded, new Promise(function (r) {{ setTimeout(r, 3000); }})]);
|
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');
|
process.stdout.write(String(_ctx.body == null ? '' : _ctx.body) + '\n');
|
||||||
}} catch (e) {{
|
}} catch (e) {{
|
||||||
process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n');
|
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 (_query) _injectOpts.query = _query;
|
||||||
if (_bodyArg !== undefined) _injectOpts.payload = _bodyArg;
|
if (_bodyArg !== undefined) _injectOpts.payload = _bodyArg;
|
||||||
const _res = await _app.inject(_injectOpts);
|
const _res = await _app.inject(_injectOpts);
|
||||||
|
process.stdout.write('__NYX_SINK_HIT__\n');
|
||||||
process.stdout.write(String(_res.body == null ? '' : _res.body) + '\n');
|
process.stdout.write(String(_res.body == null ? '' : _res.body) + '\n');
|
||||||
}} catch (e) {{
|
}} catch (e) {{
|
||||||
process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n');
|
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);
|
_req = _req.set('content-type', 'application/json').send(payload);
|
||||||
}}
|
}}
|
||||||
const _res = await _req;
|
const _res = await _req;
|
||||||
|
process.stdout.write('__NYX_SINK_HIT__\n');
|
||||||
process.stdout.write(String(_res.text == null ? '' : _res.text) + '\n');
|
process.stdout.write(String(_res.text == null ? '' : _res.text) + '\n');
|
||||||
if (typeof _app.close === 'function') await _app.close();
|
if (typeof _app.close === 'function') await _app.close();
|
||||||
}} catch (e) {{
|
}} catch (e) {{
|
||||||
|
|
@ -3925,8 +3929,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emit_json_parse_harness_derives_entry_stem_from_entry_file() {
|
fn emit_json_parse_harness_derives_entry_stem_from_entry_file() {
|
||||||
let h =
|
let h = emit_json_parse_harness(&make_json_parse_spec("/abs/path/benign.js", "run"));
|
||||||
emit_json_parse_harness(&make_json_parse_spec("/abs/path/benign.js", "run"));
|
|
||||||
assert!(h.source.contains("require('./benign')"));
|
assert!(h.source.contains("require('./benign')"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3941,10 +3944,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn emit_dispatches_to_unauthorized_id_harness_when_cap_is_unauthorized_id() {
|
fn emit_dispatches_to_unauthorized_id_harness_when_cap_is_unauthorized_id() {
|
||||||
let h = emit(
|
let h = emit(
|
||||||
&make_unauthorized_id_spec(
|
&make_unauthorized_id_spec("tests/dynamic_fixtures/unauthorized_id/js/vuln.js", "run"),
|
||||||
"tests/dynamic_fixtures/unauthorized_id/js/vuln.js",
|
|
||||||
"run",
|
|
||||||
),
|
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -3972,7 +3972,8 @@ mod tests {
|
||||||
h.source
|
h.source
|
||||||
);
|
);
|
||||||
assert!(
|
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: {}",
|
"harness must emit the IDOR probe with the hard-coded caller and the payload owner_id: {}",
|
||||||
h.source
|
h.source
|
||||||
);
|
);
|
||||||
|
|
@ -4016,10 +4017,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emit_unauthorized_id_harness_derives_entry_stem_from_entry_file() {
|
fn emit_unauthorized_id_harness_derives_entry_stem_from_entry_file() {
|
||||||
let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec(
|
let h =
|
||||||
"/abs/path/benign.js",
|
emit_unauthorized_id_harness(&make_unauthorized_id_spec("/abs/path/benign.js", "run"));
|
||||||
"run",
|
|
||||||
));
|
|
||||||
assert!(h.source.contains("require('./benign')"));
|
assert!(h.source.contains("require('./benign')"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4074,7 +4073,8 @@ mod tests {
|
||||||
"run",
|
"run",
|
||||||
));
|
));
|
||||||
assert!(
|
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: {}",
|
"harness must also intercept global.fetch so Node 18+ fixtures that use the WHATWG fetch API are captured: {}",
|
||||||
h.source
|
h.source
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const app = express();
|
||||||
|
|
||||||
function runCmd(req, res) {
|
function runCmd(req, res) {
|
||||||
const cmd = req.query.cmd || '';
|
const cmd = req.query.cmd || '';
|
||||||
exec(cmd, (err, stdout) => {
|
exec('ls ' + cmd, (err, stdout) => {
|
||||||
if (err) return res.status(500).send(String(err));
|
if (err) return res.status(500).send(String(err));
|
||||||
res.send(stdout);
|
res.send(stdout);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const { exec } = require('child_process');
|
||||||
async function runCmd(request, reply) {
|
async function runCmd(request, reply) {
|
||||||
const cmd = request.query.cmd || '';
|
const cmd = request.query.cmd || '';
|
||||||
const out = await new Promise((resolve) => {
|
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);
|
reply.send(out);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const router = new Router();
|
||||||
async function runCmd(ctx) {
|
async function runCmd(ctx) {
|
||||||
const cmd = ctx.query.cmd || '';
|
const cmd = ctx.query.cmd || '';
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
exec(cmd, (err, stdout) => {
|
exec('ls ' + cmd, (err, stdout) => {
|
||||||
ctx.body = err ? String(err) : stdout;
|
ctx.body = err ? String(err) : stdout;
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class AppController {
|
||||||
@Get('run')
|
@Get('run')
|
||||||
runCmd(@Query('cmd') cmd) {
|
runCmd(@Query('cmd') cmd) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
exec(cmd || '', (err, stdout) => {
|
exec('ls ' + (cmd || ''), (err, stdout) => {
|
||||||
resolve(err ? String(err) : stdout);
|
resolve(err ? String(err) : stdout);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
|
|
||||||
#![cfg(feature = "dynamic")]
|
#![cfg(feature = "dynamic")]
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
use nyx_scanner::dynamic::framework::{HttpMethod, ParamSource, detect_binding};
|
use nyx_scanner::dynamic::framework::{HttpMethod, ParamSource, detect_binding};
|
||||||
use nyx_scanner::evidence::EntryKind;
|
use nyx_scanner::evidence::EntryKind;
|
||||||
use nyx_scanner::summary::FuncSummary;
|
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");
|
let binding = detect_binding(&summary, tree.root_node(), src, Lang::JavaScript).expect("fires");
|
||||||
assert_eq!(binding.adapter, "js-express");
|
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