mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
refactor(dynamic): add multi-method support to RouteShape, update framework bindings, and improve test coverage
This commit is contained in:
parent
4bcdec3a1b
commit
ca075a7141
55 changed files with 524 additions and 215 deletions
|
|
@ -545,6 +545,9 @@ pub fn prepare_go(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, Bui
|
|||
|
||||
fn try_build_go_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String> {
|
||||
let go_bin = std::env::var("NYX_GO_BIN").unwrap_or_else(|_| "go".to_owned());
|
||||
let go_cache = std::env::var("GOCACHE")
|
||||
.unwrap_or_else(|_| workdir.join(".gocache").to_string_lossy().into_owned());
|
||||
std::fs::create_dir_all(&go_cache).map_err(|e| format!("create GOCACHE: {e}"))?;
|
||||
let output = Command::new(&go_bin)
|
||||
.args([
|
||||
"build",
|
||||
|
|
@ -556,6 +559,7 @@ fn try_build_go_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String>
|
|||
.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("HOME", std::env::var("HOME").unwrap_or_default())
|
||||
.env("GOCACHE", go_cache)
|
||||
.env(
|
||||
"GOPATH",
|
||||
std::env::var("GOPATH").unwrap_or_else(|_| {
|
||||
|
|
|
|||
|
|
@ -140,7 +140,9 @@ mod tests {
|
|||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "sign".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("MessageDigest.getInstance")],
|
||||
callees: vec![crate::summary::CalleeSite::bare(
|
||||
"MessageDigest.getInstance",
|
||||
)],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
|
|
@ -169,7 +171,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn skips_plain_method() {
|
||||
let src: &[u8] = b"public class Plain { public static int add(int a, int b) { return a + b; } }\n";
|
||||
let src: &[u8] =
|
||||
b"public class Plain { public static int add(int a, int b) { return a + b; } }\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
|
|
|
|||
|
|
@ -122,7 +122,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn fires_on_math_random_key() {
|
||||
let src: &[u8] = b"function run(value) { return Math.random(); }\nmodule.exports = { run };\n";
|
||||
let src: &[u8] =
|
||||
b"function run(value) { return Math.random(); }\nmodule.exports = { run };\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
|
|
|
|||
|
|
@ -128,8 +128,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn fires_on_md5() {
|
||||
let src: &[u8] =
|
||||
b"<?php\nfunction sign($value) {\n return md5($value);\n}\n";
|
||||
let src: &[u8] = b"<?php\nfunction sign($value) {\n return md5($value);\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "sign".into(),
|
||||
|
|
@ -180,8 +179,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn skips_when_sha256_hashing_present() {
|
||||
let src: &[u8] =
|
||||
b"<?php\nfunction sign($value) {\n return hash('sha256', $value);\n}\n";
|
||||
let src: &[u8] = b"<?php\nfunction sign($value) {\n return hash('sha256', $value);\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "sign".into(),
|
||||
|
|
|
|||
|
|
@ -159,7 +159,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn fires_on_md5_compute() {
|
||||
let src: &[u8] = b"use md5;\npub fn sign(value: &[u8]) -> md5::Digest {\n md5::compute(value)\n}\n";
|
||||
let src: &[u8] =
|
||||
b"use md5;\npub fn sign(value: &[u8]) -> md5::Digest {\n md5::compute(value)\n}\n";
|
||||
let tree = parse_rust(src);
|
||||
let summary = FuncSummary {
|
||||
name: "sign".into(),
|
||||
|
|
|
|||
|
|
@ -34,10 +34,7 @@ fn callee_is_outbound_http(name: &str) -> bool {
|
|||
}
|
||||
|
||||
fn source_imports_go_http_client(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"\"net/http\"",
|
||||
b"net/http\"",
|
||||
];
|
||||
const NEEDLES: &[&[u8]] = &[b"\"net/http\"", b"net/http\""];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
|
|
|
|||
|
|
@ -213,7 +213,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn skips_plain_method() {
|
||||
let src: &[u8] = b"public class Plain { public static int add(int a, int b) { return a + b; } }\n";
|
||||
let src: &[u8] =
|
||||
b"public class Plain { public static int add(int a, int b) { return a + b; } }\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
|
|
|
|||
|
|
@ -19,15 +19,7 @@ fn callee_is_outbound_http(name: &str) -> bool {
|
|||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"urlopen"
|
||||
| "get"
|
||||
| "post"
|
||||
| "put"
|
||||
| "patch"
|
||||
| "delete"
|
||||
| "request"
|
||||
| "Request"
|
||||
| "send"
|
||||
"urlopen" | "get" | "post" | "put" | "patch" | "delete" | "request" | "Request" | "send"
|
||||
) || matches!(
|
||||
name,
|
||||
"urllib.request.urlopen"
|
||||
|
|
|
|||
|
|
@ -19,33 +19,31 @@ const ADAPTER_NAME: &str = "data-exfil-ruby";
|
|||
fn callee_is_outbound_http(name: &str) -> bool {
|
||||
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
|
||||
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
|
||||
matches!(
|
||||
last,
|
||||
"get_response" | "post_form" | "request" | "start"
|
||||
) || matches!(
|
||||
name,
|
||||
"Net::HTTP.get"
|
||||
| "Net::HTTP.get_response"
|
||||
| "Net::HTTP.post_form"
|
||||
| "Net::HTTP.start"
|
||||
| "Net::HTTP::Get.new"
|
||||
| "Net::HTTP::Post.new"
|
||||
| "RestClient.get"
|
||||
| "RestClient.post"
|
||||
| "RestClient.put"
|
||||
| "RestClient.delete"
|
||||
| "RestClient::Request.execute"
|
||||
| "HTTParty.get"
|
||||
| "HTTParty.post"
|
||||
| "HTTParty.put"
|
||||
| "HTTParty.delete"
|
||||
| "Faraday.get"
|
||||
| "Faraday.post"
|
||||
| "Faraday.new"
|
||||
| "Faraday::Connection.get"
|
||||
| "URI.open"
|
||||
| "Kernel.open"
|
||||
)
|
||||
matches!(last, "get_response" | "post_form" | "request" | "start")
|
||||
|| matches!(
|
||||
name,
|
||||
"Net::HTTP.get"
|
||||
| "Net::HTTP.get_response"
|
||||
| "Net::HTTP.post_form"
|
||||
| "Net::HTTP.start"
|
||||
| "Net::HTTP::Get.new"
|
||||
| "Net::HTTP::Post.new"
|
||||
| "RestClient.get"
|
||||
| "RestClient.post"
|
||||
| "RestClient.put"
|
||||
| "RestClient.delete"
|
||||
| "RestClient::Request.execute"
|
||||
| "HTTParty.get"
|
||||
| "HTTParty.post"
|
||||
| "HTTParty.put"
|
||||
| "HTTParty.delete"
|
||||
| "Faraday.get"
|
||||
| "Faraday.post"
|
||||
| "Faraday.new"
|
||||
| "Faraday::Connection.get"
|
||||
| "URI.open"
|
||||
| "Kernel.open"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_ruby_http_client(file_bytes: &[u8]) -> bool {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ impl FrameworkAdapter for GoChiAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ impl FrameworkAdapter for GoEchoAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ impl FrameworkAdapter for GoFiberAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ impl FrameworkAdapter for GoGinAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -83,10 +83,7 @@ fn detect_micronaut(
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: http_method,
|
||||
path,
|
||||
}),
|
||||
route: Some(RouteShape::single(http_method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -87,10 +87,7 @@ fn detect_quarkus(
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: http_method,
|
||||
path,
|
||||
}),
|
||||
route: Some(RouteShape::single(http_method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -81,10 +81,7 @@ fn detect_servlet(
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: http_method,
|
||||
path,
|
||||
}),
|
||||
route: Some(RouteShape::single(http_method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -128,10 +128,7 @@ fn detect_spring(
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: http_method,
|
||||
path,
|
||||
}),
|
||||
route: Some(RouteShape::single(http_method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ fn detect_express(
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ fn detect_fastify(
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ fn detect_koa(
|
|||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
@ -162,10 +162,7 @@ fn detect_koa(
|
|||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: HttpMethod::GET,
|
||||
path: "/".to_owned(),
|
||||
}),
|
||||
route: Some(RouteShape::single(HttpMethod::GET, "/")),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: vec![MiddlewareShape {
|
||||
|
|
|
|||
|
|
@ -120,10 +120,7 @@ fn detect_nest(
|
|||
Some(FrameworkBinding {
|
||||
adapter: adapter_name.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method,
|
||||
path: full_path,
|
||||
}),
|
||||
route: Some(RouteShape::single(method, full_path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ impl FrameworkAdapter for PhpCodeIgniterAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -14,15 +14,15 @@
|
|||
|
||||
#[cfg(test)]
|
||||
use crate::dynamic::framework::HttpMethod;
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape};
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
use super::php_routes::{
|
||||
bind_php_path_params, collect_php_middleware, find_laravel_static_route, find_php_function,
|
||||
php_class_name, php_formal_names, source_imports_laravel,
|
||||
bind_php_path_params, collect_php_middleware, find_laravel_static_route_shape,
|
||||
find_php_function, php_class_name, php_formal_names, source_imports_laravel,
|
||||
};
|
||||
|
||||
pub struct PhpLaravelAdapter;
|
||||
|
|
@ -50,16 +50,16 @@ impl FrameworkAdapter for PhpLaravelAdapter {
|
|||
let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?;
|
||||
let controller = class.and_then(|c| php_class_name(c, file_bytes));
|
||||
|
||||
let (method, path) = find_laravel_static_route(ast, file_bytes, &summary.name, controller)?;
|
||||
let route = find_laravel_static_route_shape(ast, file_bytes, &summary.name, controller)?;
|
||||
|
||||
let formals = php_formal_names(func_node, file_bytes);
|
||||
let request_params = bind_php_path_params(&formals, &path);
|
||||
let request_params = bind_php_path_params(&formals, &route.path);
|
||||
let middleware = collect_php_middleware(ast, file_bytes);
|
||||
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(route),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
@ -139,6 +139,22 @@ mod tests {
|
|||
assert_eq!(binding.route.unwrap().method, HttpMethod::DELETE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_match_route_methods() {
|
||||
let src: &[u8] = b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::match(['POST', 'PATCH'], '/jobs/{id}', [JobController::class, 'run']);\nclass JobController {\n public function run($id) { return $id; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let binding = PhpLaravelAdapter
|
||||
.detect(&summary("run"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.method, HttpMethod::POST);
|
||||
assert_eq!(
|
||||
route.reachable_methods(),
|
||||
vec![HttpMethod::POST, HttpMethod::PATCH]
|
||||
);
|
||||
assert_eq!(route.path, "/jobs/{id}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populates_middleware_from_chained_call() {
|
||||
let src: &[u8] = b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::get('/users/{id}', 'UserController@show')->middleware('auth');\nclass UserController {\n public function show($id) { return $id; }\n}\n";
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
//! framework share the same placeholder-binding semantics.
|
||||
|
||||
use crate::dynamic::framework::{
|
||||
HttpMethod, MiddlewareShape, ParamBinding, ParamSource, auth_markers,
|
||||
HttpMethod, MiddlewareShape, ParamBinding, ParamSource, RouteShape, auth_markers,
|
||||
};
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
|
@ -407,7 +407,19 @@ pub fn find_laravel_static_route<'a>(
|
|||
target: &str,
|
||||
controller: Option<&str>,
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
let mut hit: Option<(HttpMethod, String)> = None;
|
||||
find_laravel_static_route_shape(root, bytes, target, controller)
|
||||
.map(|route| (route.method, route.path))
|
||||
}
|
||||
|
||||
/// Laravel route lookup that preserves multi-verb registrations such
|
||||
/// as `Route::any(...)` and `Route::match([...], ...)`.
|
||||
pub fn find_laravel_static_route_shape<'a>(
|
||||
root: Node<'a>,
|
||||
bytes: &'a [u8],
|
||||
target: &str,
|
||||
controller: Option<&str>,
|
||||
) -> Option<RouteShape> {
|
||||
let mut hit: Option<RouteShape> = None;
|
||||
visit_laravel_routes(root, bytes, target, controller, &mut hit);
|
||||
hit
|
||||
}
|
||||
|
|
@ -417,7 +429,7 @@ fn visit_laravel_routes<'a>(
|
|||
bytes: &'a [u8],
|
||||
target: &str,
|
||||
controller: Option<&str>,
|
||||
out: &mut Option<(HttpMethod, String)>,
|
||||
out: &mut Option<RouteShape>,
|
||||
) {
|
||||
if out.is_some() {
|
||||
return;
|
||||
|
|
@ -439,20 +451,81 @@ fn try_laravel_route<'a>(
|
|||
bytes: &'a [u8],
|
||||
target: &str,
|
||||
controller: Option<&str>,
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
) -> Option<RouteShape> {
|
||||
let scope = call.child_by_field_name("scope")?.utf8_text(bytes).ok()?;
|
||||
let scope_leaf = scope.rsplit('\\').next().unwrap_or(scope);
|
||||
if scope_leaf != "Route" {
|
||||
return None;
|
||||
}
|
||||
let verb_node = call.child_by_field_name("name")?.utf8_text(bytes).ok()?;
|
||||
let method = verb_method(verb_node)?;
|
||||
let args = call.child_by_field_name("arguments")?;
|
||||
let path = first_php_string_arg(args, bytes)?;
|
||||
if !laravel_callable_matches(args, bytes, target, controller) {
|
||||
let methods = laravel_route_methods(verb_node, args, bytes)?;
|
||||
let path = laravel_route_path(verb_node, args, bytes)?;
|
||||
if !laravel_callable_matches(verb_node, args, bytes, target, controller) {
|
||||
return None;
|
||||
}
|
||||
Some((method, path))
|
||||
Some(if methods.len() > 1 {
|
||||
RouteShape::multi(methods, path)
|
||||
} else {
|
||||
RouteShape::single(methods[0], path)
|
||||
})
|
||||
}
|
||||
|
||||
fn laravel_route_methods(verb: &str, arguments: Node<'_>, bytes: &[u8]) -> Option<Vec<HttpMethod>> {
|
||||
match verb {
|
||||
"any" => Some(vec![
|
||||
HttpMethod::GET,
|
||||
HttpMethod::HEAD,
|
||||
HttpMethod::POST,
|
||||
HttpMethod::PUT,
|
||||
HttpMethod::PATCH,
|
||||
HttpMethod::DELETE,
|
||||
HttpMethod::OPTIONS,
|
||||
]),
|
||||
"match" => {
|
||||
let first = positional_arg_values(arguments).into_iter().next()?;
|
||||
let mut methods = Vec::new();
|
||||
collect_http_methods(first, bytes, &mut methods);
|
||||
if methods.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(methods)
|
||||
}
|
||||
}
|
||||
other => verb_method(other).map(|method| vec![method]),
|
||||
}
|
||||
}
|
||||
|
||||
fn laravel_route_path(verb: &str, arguments: Node<'_>, bytes: &[u8]) -> Option<String> {
|
||||
if verb != "match" {
|
||||
return first_php_string_arg(arguments, bytes);
|
||||
}
|
||||
positional_arg_values(arguments)
|
||||
.get(1)
|
||||
.and_then(|value| string_content(*value, bytes))
|
||||
}
|
||||
|
||||
fn positional_arg_values<'a>(arguments: Node<'a>) -> Vec<Node<'a>> {
|
||||
let mut cur = arguments.walk();
|
||||
arguments
|
||||
.named_children(&mut cur)
|
||||
.filter(|arg| arg.kind() == "argument" && arg.child_by_field_name("name").is_none())
|
||||
.filter_map(|arg| arg.named_child(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_http_methods(node: Node<'_>, bytes: &[u8], out: &mut Vec<HttpMethod>) {
|
||||
if matches!(node.kind(), "string" | "encapsed_string")
|
||||
&& let Some(raw) = string_content(node, bytes)
|
||||
&& let Some(method) = HttpMethod::from_ident(&raw)
|
||||
&& !out.contains(&method)
|
||||
{
|
||||
out.push(method);
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.named_children(&mut cur) {
|
||||
collect_http_methods(child, bytes, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the second positional arg of a `Route::verb('/x', ...)` call
|
||||
|
|
@ -462,26 +535,15 @@ fn try_laravel_route<'a>(
|
|||
/// - `'Controller@method'` / `'Controller::method'` strings
|
||||
/// - `[ Controller::class, 'method' ]` arrays
|
||||
fn laravel_callable_matches(
|
||||
verb: &str,
|
||||
arguments: Node<'_>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
controller: Option<&str>,
|
||||
) -> bool {
|
||||
let mut cur = arguments.walk();
|
||||
let mut positional: Vec<Node<'_>> = Vec::new();
|
||||
for arg in arguments.named_children(&mut cur) {
|
||||
if arg.kind() != "argument" {
|
||||
continue;
|
||||
}
|
||||
if arg.child_by_field_name("name").is_some() {
|
||||
continue;
|
||||
}
|
||||
positional.push(arg);
|
||||
}
|
||||
let Some(callable_arg) = positional.get(1) else {
|
||||
return false;
|
||||
};
|
||||
let Some(value) = callable_arg.named_child(0) else {
|
||||
let callable_idx = if verb == "match" { 2 } else { 1 };
|
||||
let positional = positional_arg_values(arguments);
|
||||
let Some(value) = positional.get(callable_idx).copied() else {
|
||||
return false;
|
||||
};
|
||||
match value.kind() {
|
||||
|
|
@ -557,7 +619,6 @@ fn verb_method(verb: &str) -> Option<HttpMethod> {
|
|||
"delete" => Some(HttpMethod::DELETE),
|
||||
"options" => Some(HttpMethod::OPTIONS),
|
||||
"head" => Some(HttpMethod::HEAD),
|
||||
"any" | "match" => Some(HttpMethod::GET),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -895,6 +956,46 @@ mod tests {
|
|||
assert_eq!(hit.1, "/users");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_laravel_any_route_with_all_supported_methods() {
|
||||
let src: &[u8] =
|
||||
b"<?php\nRoute::any('/run', 'JobController@run');\nclass JobController { public function run() {} }\n";
|
||||
let tree = parse(src);
|
||||
let route =
|
||||
find_laravel_static_route_shape(tree.root_node(), src, "run", Some("JobController"))
|
||||
.unwrap();
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert_eq!(
|
||||
route.reachable_methods(),
|
||||
vec![
|
||||
HttpMethod::GET,
|
||||
HttpMethod::HEAD,
|
||||
HttpMethod::POST,
|
||||
HttpMethod::PUT,
|
||||
HttpMethod::PATCH,
|
||||
HttpMethod::DELETE,
|
||||
HttpMethod::OPTIONS,
|
||||
]
|
||||
);
|
||||
assert_eq!(route.path, "/run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_laravel_match_route_with_declared_methods() {
|
||||
let src: &[u8] =
|
||||
b"<?php\nRoute::match(['POST', 'PUT'], '/run', [JobController::class, 'run']);\nclass JobController { public function run() {} }\n";
|
||||
let tree = parse(src);
|
||||
let route =
|
||||
find_laravel_static_route_shape(tree.root_node(), src, "run", Some("JobController"))
|
||||
.unwrap();
|
||||
assert_eq!(route.method, HttpMethod::POST);
|
||||
assert_eq!(
|
||||
route.reachable_methods(),
|
||||
vec![HttpMethod::POST, HttpMethod::PUT]
|
||||
);
|
||||
assert_eq!(route.path, "/run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_codeigniter_member_route() {
|
||||
let src: &[u8] = b"<?php\n$routes->get('users/(:num)', 'UserController::show');\n";
|
||||
|
|
|
|||
|
|
@ -89,10 +89,7 @@ impl FrameworkAdapter for PhpSymfonyAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: http_method,
|
||||
path,
|
||||
}),
|
||||
route: Some(RouteShape::single(http_method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ impl FrameworkAdapter for PythonDjangoAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ impl FrameworkAdapter for PythonFastApiAdapter {
|
|||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ impl FrameworkAdapter for PythonFlaskAdapter {
|
|||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ impl FrameworkAdapter for PythonStarletteAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -180,10 +180,7 @@ impl FrameworkAdapter for RubyHanamiAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: http_method,
|
||||
path,
|
||||
}),
|
||||
route: Some(RouteShape::single(http_method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -289,10 +289,7 @@ impl FrameworkAdapter for RubyRailsAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: http_method,
|
||||
path,
|
||||
}),
|
||||
route: Some(RouteShape::single(http_method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -153,10 +153,7 @@ impl FrameworkAdapter for RubySinatraAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: route.method,
|
||||
path: route.path.clone(),
|
||||
}),
|
||||
route: Some(RouteShape::single(route.method, route.path.clone())),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ impl FrameworkAdapter for RustActixAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ impl FrameworkAdapter for RustAxumAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ impl FrameworkAdapter for RustRocketAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ impl FrameworkAdapter for RustWarpAdapter {
|
|||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
route: Some(RouteShape::single(method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
|
|
|
|||
|
|
@ -37,12 +37,56 @@ pub use crate::entry_points::HttpMethod;
|
|||
pub struct RouteShape {
|
||||
/// HTTP verb (`GET`, `POST`, …).
|
||||
pub method: HttpMethod,
|
||||
/// Additional HTTP verbs that reach the same handler. Empty for
|
||||
/// single-verb routes; when populated, [`Self::method`] is the
|
||||
/// first element for backward-compatible callers that still need a
|
||||
/// single representative method.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub methods: Vec<HttpMethod>,
|
||||
/// Route path template as registered with the framework (e.g.
|
||||
/// `"/users/{id}"`). Adapter-specific placeholder syntax is
|
||||
/// preserved verbatim.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl RouteShape {
|
||||
/// Construct a single-method route while preserving the legacy
|
||||
/// empty-`methods` representation.
|
||||
pub fn single(method: HttpMethod, path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
method,
|
||||
methods: Vec::new(),
|
||||
path: path.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a route reachable through multiple HTTP methods.
|
||||
pub fn multi(methods: Vec<HttpMethod>, path: impl Into<String>) -> Self {
|
||||
let mut deduped = Vec::new();
|
||||
for method in methods {
|
||||
if !deduped.contains(&method) {
|
||||
deduped.push(method);
|
||||
}
|
||||
}
|
||||
let method = deduped.first().copied().unwrap_or(HttpMethod::GET);
|
||||
Self {
|
||||
method,
|
||||
methods: deduped,
|
||||
path: path.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return every method that reaches this route. Legacy single-method
|
||||
/// shapes return a one-element vector containing [`Self::method`].
|
||||
pub fn reachable_methods(&self) -> Vec<HttpMethod> {
|
||||
if self.methods.is_empty() {
|
||||
vec![self.method]
|
||||
} else {
|
||||
self.methods.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where on the external surface a function formal originates from.
|
||||
///
|
||||
/// Adapters classify each declared parameter into one of these
|
||||
|
|
@ -532,10 +576,7 @@ mod tests {
|
|||
let original = FrameworkBinding {
|
||||
adapter: "flask".into(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: HttpMethod::POST,
|
||||
path: "/users/{id}".into(),
|
||||
}),
|
||||
route: Some(RouteShape::single(HttpMethod::POST, "/users/{id}")),
|
||||
request_params: vec![ParamBinding {
|
||||
index: 0,
|
||||
name: "id".into(),
|
||||
|
|
|
|||
|
|
@ -1531,11 +1531,7 @@ func nyxJsonParseViaFixture(payload string) (int, bool, bool) {{
|
|||
"##
|
||||
);
|
||||
let invoke = "\tdepth, excessive, fixtureInvoked := nyxJsonParseViaFixture(payload)\n\tif !fixtureInvoked {\n\t\tdepth = 0\n\t\texcessive = false\n\t}\n\tnyxJsonParseProbe(depth, excessive)\n".to_owned();
|
||||
(
|
||||
"\n\t\"nyx-harness/internal/vulnentry\"\n",
|
||||
decl,
|
||||
invoke,
|
||||
)
|
||||
("\n\t\"nyx-harness/internal/vulnentry\"\n", decl, invoke)
|
||||
} else {
|
||||
(
|
||||
"",
|
||||
|
|
@ -1775,10 +1771,10 @@ func nyxInstallHttpTransport() {
|
|||
|
||||
func nyxDataExfilViaFixture(payload string) {
|
||||
defer func() { _ = recover() }()
|
||||
vulnentry."##.to_owned()
|
||||
vulnentry."##
|
||||
.to_owned()
|
||||
+ &format!("{entry_fn}(payload)\n}}\n\n");
|
||||
let invoke =
|
||||
"\tnyxInstallHttpTransport()\n\tnyxDataExfilViaFixture(payload)\n".to_owned();
|
||||
let invoke = "\tnyxInstallHttpTransport()\n\tnyxDataExfilViaFixture(payload)\n".to_owned();
|
||||
(
|
||||
"\t\"bytes\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"nyx-harness/internal/vulnentry\"\n",
|
||||
decl,
|
||||
|
|
@ -1855,9 +1851,15 @@ fn emit_class_method_harness(class: &str, method: &str) -> HarnessSource {
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"reflect"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"nyx-harness/entry"
|
||||
)
|
||||
|
|
@ -1883,6 +1885,11 @@ func nyxPayload() string {{
|
|||
if v := os.Getenv("NYX_PAYLOAD"); v != "" {{
|
||||
return v
|
||||
}}
|
||||
if b64 := os.Getenv("NYX_PAYLOAD_B64"); b64 != "" {{
|
||||
if data, err := base64.StdEncoding.DecodeString(b64); err == nil {{
|
||||
return string(data)
|
||||
}}
|
||||
}}
|
||||
return ""
|
||||
}}
|
||||
|
||||
|
|
@ -2037,7 +2044,7 @@ fn emit_message_handler_harness(spec: &HarnessSpec, queue: &str) -> HarnessSourc
|
|||
if got.Type().AssignableTo(want) {{
|
||||
args[i] = got
|
||||
}} else if want.Kind() == reflect.String {{
|
||||
args[i] = reflect.ValueOf(os.Getenv("NYX_PAYLOAD"))
|
||||
args[i] = reflect.ValueOf(nyxPayload())
|
||||
}} else {{
|
||||
args[i] = reflect.Zero(want)
|
||||
}}
|
||||
|
|
@ -2053,9 +2060,15 @@ fn emit_message_handler_harness(spec: &HarnessSpec, queue: &str) -> HarnessSourc
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"reflect"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"nyx-harness/entry"
|
||||
)
|
||||
|
|
@ -2070,6 +2083,11 @@ func nyxPayload() string {{
|
|||
if v := os.Getenv("NYX_PAYLOAD"); v != "" {{
|
||||
return v
|
||||
}}
|
||||
if b64 := os.Getenv("NYX_PAYLOAD_B64"); b64 != "" {{
|
||||
if data, err := base64.StdEncoding.DecodeString(b64); err == nil {{
|
||||
return string(data)
|
||||
}}
|
||||
}}
|
||||
return ""
|
||||
}}
|
||||
|
||||
|
|
@ -3170,7 +3188,8 @@ mod tests {
|
|||
"Go UNAUTHORIZED_ID harness must pin caller_id to \"alice\"",
|
||||
);
|
||||
assert!(
|
||||
h.source.contains("nyxIdorAccessProbe(_NYX_CALLER_ID, payload)"),
|
||||
h.source
|
||||
.contains("nyxIdorAccessProbe(_NYX_CALLER_ID, payload)"),
|
||||
"Go UNAUTHORIZED_ID harness must call probe with caller_id + payload-as-owner",
|
||||
);
|
||||
}
|
||||
|
|
@ -3182,7 +3201,8 @@ mod tests {
|
|||
"Run",
|
||||
));
|
||||
assert!(
|
||||
h.source.contains("if nyxUnauthorizedIdViaFixture(payload) {"),
|
||||
h.source
|
||||
.contains("if nyxUnauthorizedIdViaFixture(payload) {"),
|
||||
"Go UNAUTHORIZED_ID harness must gate probe emission on a present record so the benign fixture's empty-string rejection clears the predicate",
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -3236,7 +3256,8 @@ mod tests {
|
|||
"fallback path must not stage a vulnentry copy when the fixture cannot be read",
|
||||
);
|
||||
assert!(
|
||||
h.source.contains("nyxIdorAccessProbe(_NYX_CALLER_ID, payload)"),
|
||||
h.source
|
||||
.contains("nyxIdorAccessProbe(_NYX_CALLER_ID, payload)"),
|
||||
"fallback path must still emit an IDOR probe so the universal sink-hit path fires",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2360,7 +2360,10 @@ public class NyxHarness {{
|
|||
".".to_owned(),
|
||||
"NyxHarness".to_owned(),
|
||||
],
|
||||
extra_files: vec![("NyxJsonProbe.java".to_owned(), nyx_json_probe_source().to_owned())],
|
||||
extra_files: vec![(
|
||||
"NyxJsonProbe.java".to_owned(),
|
||||
nyx_json_probe_source().to_owned(),
|
||||
)],
|
||||
entry_subpath: Some(format!("{entry_class}.java")),
|
||||
}
|
||||
}
|
||||
|
|
@ -3572,11 +3575,51 @@ public class NyxHarness {{
|
|||
".".to_owned(),
|
||||
"NyxHarness".to_owned(),
|
||||
],
|
||||
extra_files: vec![],
|
||||
extra_files: message_handler_annotation_stubs(),
|
||||
entry_subpath: Some(format!("{entry_class}.java")),
|
||||
}
|
||||
}
|
||||
|
||||
fn message_handler_annotation_stubs() -> Vec<(String, String)> {
|
||||
vec![
|
||||
(
|
||||
"org/springframework/kafka/annotation/KafkaListener.java".to_owned(),
|
||||
r#"package org.springframework.kafka.annotation;
|
||||
|
||||
public @interface KafkaListener {
|
||||
String[] value() default {};
|
||||
String[] topics() default {};
|
||||
}
|
||||
"#
|
||||
.to_owned(),
|
||||
),
|
||||
(
|
||||
"io/awspring/cloud/sqs/annotation/SqsListener.java".to_owned(),
|
||||
r#"package io.awspring.cloud.sqs.annotation;
|
||||
|
||||
public @interface SqsListener {
|
||||
String[] value() default {};
|
||||
String[] queueNames() default {};
|
||||
String queueName() default "";
|
||||
String queueUrl() default "";
|
||||
}
|
||||
"#
|
||||
.to_owned(),
|
||||
),
|
||||
(
|
||||
"org/springframework/amqp/rabbit/annotation/RabbitListener.java".to_owned(),
|
||||
r#"package org.springframework.amqp.rabbit.annotation;
|
||||
|
||||
public @interface RabbitListener {
|
||||
String[] value() default {};
|
||||
String[] queues() default {};
|
||||
}
|
||||
"#
|
||||
.to_owned(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ── Phase 21 (Track M.3) — synthetic entry-kind harnesses ─────────────────────
|
||||
|
||||
fn emit_scheduled_job_harness(
|
||||
|
|
@ -5363,4 +5406,26 @@ mod tests {
|
|||
"Java DATA_EXFIL harness must catch InvocationTargetException so a fixture-side throw after a partial outbound call still drains CAPTURED_HOSTS",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_message_handler_harness_ships_broker_annotation_stubs() {
|
||||
let mut spec = make_spec(PayloadSlot::Param(0));
|
||||
spec.entry_file = "tests/dynamic_fixtures/message_handler/kafka_java/Vuln.java".to_owned();
|
||||
spec.entry_name = "onMessage".to_owned();
|
||||
spec.entry_kind = EntryKind::MessageHandler {
|
||||
queue: "orders".to_owned(),
|
||||
message_schema: None,
|
||||
};
|
||||
let h = emit(&spec).unwrap();
|
||||
for path in [
|
||||
"org/springframework/kafka/annotation/KafkaListener.java",
|
||||
"io/awspring/cloud/sqs/annotation/SqsListener.java",
|
||||
"org/springframework/amqp/rabbit/annotation/RabbitListener.java",
|
||||
] {
|
||||
assert!(
|
||||
h.extra_files.iter().any(|(name, _)| name == path),
|
||||
"Java MessageHandler harness must stage {path} so annotated broker fixtures compile without real Spring jars",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3253,10 +3253,7 @@ mod tests {
|
|||
spec.framework = Some(FrameworkBinding {
|
||||
adapter: "test-adapter".into(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: HttpMethod::GET,
|
||||
path: route_path.into(),
|
||||
}),
|
||||
route: Some(RouteShape::single(HttpMethod::GET, route_path)),
|
||||
request_params: vec![],
|
||||
response_writer: None,
|
||||
middleware: vec![],
|
||||
|
|
|
|||
|
|
@ -3986,10 +3986,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn emit_unauthorized_id_harness_derives_basename_from_entry_file() {
|
||||
let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec(
|
||||
"/abs/path/benign.php",
|
||||
"run",
|
||||
));
|
||||
let h =
|
||||
emit_unauthorized_id_harness(&make_unauthorized_id_spec("/abs/path/benign.php", "run"));
|
||||
assert!(
|
||||
h.source.contains("\"benign.php\""),
|
||||
"PHP UNAUTHORIZED_ID harness must use the entry-file basename, not a hard-coded literal: {}",
|
||||
|
|
|
|||
|
|
@ -4706,8 +4706,7 @@ mod tests {
|
|||
"run",
|
||||
));
|
||||
assert!(
|
||||
h.source
|
||||
.contains("def _nyx_idor_via_fixture(payload):"),
|
||||
h.source.contains("def _nyx_idor_via_fixture(payload):"),
|
||||
"Python UNAUTHORIZED_ID harness must define the fixture-routing helper",
|
||||
);
|
||||
assert!(h.source.contains("importlib.import_module(\"vuln\")"));
|
||||
|
|
@ -4718,10 +4717,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn emit_unauthorized_id_harness_derives_module_name_from_entry_file() {
|
||||
let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec(
|
||||
"/abs/path/benign.py",
|
||||
"run",
|
||||
));
|
||||
let h =
|
||||
emit_unauthorized_id_harness(&make_unauthorized_id_spec("/abs/path/benign.py", "run"));
|
||||
assert!(h.source.contains("importlib.import_module(\"benign\")"));
|
||||
}
|
||||
|
||||
|
|
@ -4758,7 +4755,10 @@ mod tests {
|
|||
"run",
|
||||
));
|
||||
assert!(h.source.contains("urllib.request.urlopen = _nyx_urlopen"));
|
||||
assert!(h.source.contains("def _nyx_urlopen(url, data=None, timeout=None, *args, **kwargs):"));
|
||||
assert!(
|
||||
h.source
|
||||
.contains("def _nyx_urlopen(url, data=None, timeout=None, *args, **kwargs):")
|
||||
);
|
||||
assert!(
|
||||
h.source.contains("class _NyxFakeResponse(io.BytesIO):"),
|
||||
"harness must return a fake response so the fixture does not block on real network egress",
|
||||
|
|
@ -4806,10 +4806,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn emit_data_exfil_harness_derives_module_name_from_entry_file() {
|
||||
let h = emit_data_exfil_harness(&make_data_exfil_spec(
|
||||
"/abs/path/benign.py",
|
||||
"run",
|
||||
));
|
||||
let h = emit_data_exfil_harness(&make_data_exfil_spec("/abs/path/benign.py", "run"));
|
||||
assert!(h.source.contains("importlib.import_module(\"benign\")"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2864,7 +2864,10 @@ mod tests {
|
|||
"tests/dynamic_fixtures/json_parse_depth/ruby/vuln.rb",
|
||||
"run",
|
||||
));
|
||||
assert!(h.source.contains("_nyx_orig_json_parse = JSON.method(:parse)"));
|
||||
assert!(
|
||||
h.source
|
||||
.contains("_nyx_orig_json_parse = JSON.method(:parse)")
|
||||
);
|
||||
assert!(
|
||||
h.source.contains("JSON.define_singleton_method(:parse)"),
|
||||
"must rebind JSON.parse: {}",
|
||||
|
|
@ -2956,8 +2959,7 @@ 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
|
||||
);
|
||||
|
|
@ -2983,8 +2985,7 @@ mod tests {
|
|||
"run",
|
||||
));
|
||||
assert!(
|
||||
h.source
|
||||
.contains("def _nyx_idor_via_fixture(payload)"),
|
||||
h.source.contains("def _nyx_idor_via_fixture(payload)"),
|
||||
"Ruby UNAUTHORIZED_ID harness must define the fixture-routing helper: {}",
|
||||
h.source
|
||||
);
|
||||
|
|
@ -2996,10 +2997,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn emit_unauthorized_id_harness_derives_entry_basename_from_entry_file() {
|
||||
let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec(
|
||||
"/abs/path/benign.rb",
|
||||
"run",
|
||||
));
|
||||
let h =
|
||||
emit_unauthorized_id_harness(&make_unauthorized_id_spec("/abs/path/benign.rb", "run"));
|
||||
assert!(h.source.contains("require_relative './benign'"));
|
||||
}
|
||||
|
||||
|
|
@ -3019,8 +3018,7 @@ mod tests {
|
|||
))
|
||||
.unwrap();
|
||||
assert!(
|
||||
h.source
|
||||
.contains("Net::HTTP.define_singleton_method(:get)"),
|
||||
h.source.contains("Net::HTTP.define_singleton_method(:get)"),
|
||||
"dispatcher must short-circuit Cap::DATA_EXFIL into emit_data_exfil_harness: {}",
|
||||
h.source
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1593,8 +1593,7 @@ pub fn emit_unauthorized_id_harness(spec: &HarnessSpec) -> HarnessSource {
|
|||
)
|
||||
} else {
|
||||
let extras: Vec<(String, String)> = vec![("Cargo.toml".into(), cargo_toml)];
|
||||
let invoke =
|
||||
" nyx_idor_access_probe(_NYX_CALLER_ID, &payload);\n".to_owned();
|
||||
let invoke = " nyx_idor_access_probe(_NYX_CALLER_ID, &payload);\n".to_owned();
|
||||
(
|
||||
String::new(),
|
||||
invoke,
|
||||
|
|
@ -1700,11 +1699,12 @@ pub fn emit_data_exfil_harness(spec: &HarnessSpec) -> HarnessSource {
|
|||
let extras: Vec<(String, String)> = vec![
|
||||
("Cargo.toml".into(), cargo_toml),
|
||||
("src/entry.rs".into(), rewritten),
|
||||
("src/nyx_http.rs".into(), nyx_http_module_source().to_owned()),
|
||||
(
|
||||
"src/nyx_http.rs".into(),
|
||||
nyx_http_module_source().to_owned(),
|
||||
),
|
||||
];
|
||||
let invoke = format!(
|
||||
" let _ = entry::{entry_fn}(&payload);\n",
|
||||
);
|
||||
let invoke = format!(" let _ = entry::{entry_fn}(&payload);\n",);
|
||||
(
|
||||
"mod entry;\nmod nyx_http;\n".to_owned(),
|
||||
invoke,
|
||||
|
|
@ -3702,8 +3702,7 @@ mod tests {
|
|||
"run",
|
||||
));
|
||||
assert!(
|
||||
h.source
|
||||
.contains("const _NYX_CALLER_ID: &str = \"alice\";"),
|
||||
h.source.contains("const _NYX_CALLER_ID: &str = \"alice\";"),
|
||||
"Rust UNAUTHORIZED_ID harness must pin caller_id to \"alice\"",
|
||||
);
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -134,10 +134,7 @@ mod tests {
|
|||
FrameworkBinding {
|
||||
adapter: "test-adapter".to_string(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: HttpMethod::GET,
|
||||
path: "/x".to_string(),
|
||||
}),
|
||||
route: Some(RouteShape::single(HttpMethod::GET, "/x")),
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: middleware
|
||||
|
|
|
|||
|
|
@ -288,7 +288,18 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
|
|||
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
|
||||
return Err(RunError::BuildFailed { stderr, attempts });
|
||||
}
|
||||
Err(_) => {}
|
||||
Err(build_sandbox::BuildError::Io(e)) => {
|
||||
return Err(RunError::BuildFailed {
|
||||
stderr: format!("prepare go build cache: {e}"),
|
||||
attempts: 1,
|
||||
});
|
||||
}
|
||||
Err(build_sandbox::BuildError::Unsupported) => {
|
||||
return Err(RunError::BuildFailed {
|
||||
stderr: "go build preparation unsupported on this host".to_owned(),
|
||||
attempts: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Lang::Java => {
|
||||
|
|
@ -306,7 +317,18 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
|
|||
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
|
||||
return Err(RunError::BuildFailed { stderr, attempts });
|
||||
}
|
||||
Err(_) => {}
|
||||
Err(build_sandbox::BuildError::Io(e)) => {
|
||||
return Err(RunError::BuildFailed {
|
||||
stderr: format!("prepare java build cache: {e}"),
|
||||
attempts: 1,
|
||||
});
|
||||
}
|
||||
Err(build_sandbox::BuildError::Unsupported) => {
|
||||
return Err(RunError::BuildFailed {
|
||||
stderr: "java build preparation unsupported on this host".to_owned(),
|
||||
attempts: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Lang::Php => {
|
||||
|
|
|
|||
|
|
@ -1616,6 +1616,10 @@ fn run_process(
|
|||
|
||||
// Strip all env and pass only the allowlist + harness env + payload.
|
||||
cmd.env_clear();
|
||||
// Keep a minimal executable search path so harnessed code that launches
|
||||
// common system tools by bare name (notably Go's exec.Command("sh", ...))
|
||||
// exercises the sink instead of failing before the oracle can observe it.
|
||||
cmd.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin");
|
||||
for k in &opts.env_passthrough {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
cmd.env(k, v);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
// Phase 20 (Track M.2) — Kafka Java benign control.
|
||||
// `org.springframework.kafka` adapter marker preserved.
|
||||
|
||||
import org.springframework.kafka.annotation.KafkaListener;
|
||||
|
||||
public class Benign {
|
||||
public Benign() {}
|
||||
|
||||
@KafkaListener(topics = "orders")
|
||||
public void onMessage(String body) throws Exception {
|
||||
new ProcessBuilder("echo", body).inheritIO().start().waitFor();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
// Phase 20 (Track M.2) — Kafka Java vuln fixture.
|
||||
//
|
||||
// Marker line so the kafka-java framework adapter binds:
|
||||
// `org.springframework.kafka` consumer entry point. Annotation is
|
||||
// elided so javac compiles without the Spring jar; the dynamic harness
|
||||
// invokes onMessage reflectively.
|
||||
|
||||
import org.springframework.kafka.annotation.KafkaListener;
|
||||
|
||||
public class Vuln {
|
||||
public Vuln() {}
|
||||
|
||||
@KafkaListener(topics = "orders")
|
||||
public void onMessage(String body) throws Exception {
|
||||
// SINK: tainted body concatenated into shell command
|
||||
new ProcessBuilder("sh", "-c", "echo " + body).inheritIO().start().waitFor();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
// Phase 20 (Track M.2) — RabbitMQ Java benign control.
|
||||
// `org.springframework.amqp.rabbit` adapter marker preserved.
|
||||
|
||||
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||
|
||||
public class Benign {
|
||||
public Benign() {}
|
||||
|
||||
@RabbitListener(queues = "work")
|
||||
public void onMessage(String messageId, String body) throws Exception {
|
||||
new ProcessBuilder("echo", body).inheritIO().start().waitFor();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// Phase 20 (Track M.2) — RabbitMQ Java vuln fixture.
|
||||
// `org.springframework.amqp.rabbit` consumer marker preserved;
|
||||
// annotation elided so javac compiles without the Spring AMQP jar.
|
||||
|
||||
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||
|
||||
public class Vuln {
|
||||
public Vuln() {}
|
||||
|
||||
@RabbitListener(queues = "work")
|
||||
public void onMessage(String messageId, String body) throws Exception {
|
||||
// SINK: tainted body concatenated into shell command
|
||||
new ProcessBuilder("sh", "-c", "echo " + body).inheritIO().start().waitFor();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
// Phase 20 (Track M.2) — SQS Java benign control.
|
||||
// `io.awspring.cloud.sqs` adapter marker preserved.
|
||||
|
||||
import io.awspring.cloud.sqs.annotation.SqsListener;
|
||||
|
||||
public class Benign {
|
||||
public Benign() {}
|
||||
|
||||
@SqsListener("jobs")
|
||||
public void handleMessage(java.util.Map<String, String> env) throws Exception {
|
||||
String body = env != null ? env.getOrDefault("Body", "") : "";
|
||||
new ProcessBuilder("echo", body).inheritIO().start().waitFor();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// Phase 20 (Track M.2) — SQS Java vuln fixture.
|
||||
// `io.awspring.cloud.sqs` consumer entry point — annotation elided so
|
||||
// javac compiles without the Spring Cloud AWS jar.
|
||||
|
||||
import io.awspring.cloud.sqs.annotation.SqsListener;
|
||||
|
||||
public class Vuln {
|
||||
public Vuln() {}
|
||||
|
||||
@SqsListener("jobs")
|
||||
public void handleMessage(java.util.Map<String, String> env) throws Exception {
|
||||
String body = env != null ? env.getOrDefault("Body", "") : "";
|
||||
// SINK: tainted Body concatenated into shell command
|
||||
|
|
|
|||
|
|
@ -183,7 +183,9 @@ mod e2e_json_parse_depth {
|
|||
Lang::Go => "go",
|
||||
Lang::Rust => "rust",
|
||||
Lang::Java => "java",
|
||||
_ => unreachable!("JSON_PARSE depth e2e covers JS / Python / Ruby / PHP / Go / Rust / Java only"),
|
||||
_ => unreachable!(
|
||||
"JSON_PARSE depth e2e covers JS / Python / Ruby / PHP / Go / Rust / Java only"
|
||||
),
|
||||
})
|
||||
.join(fixture);
|
||||
let tmp = TempDir::new().expect("create tempdir");
|
||||
|
|
@ -230,7 +232,9 @@ mod e2e_json_parse_depth {
|
|||
Lang::Go => "go",
|
||||
Lang::Rust => "cargo",
|
||||
Lang::Java => "javac",
|
||||
_ => unreachable!("JSON_PARSE depth e2e covers JS / Python / Ruby / PHP / Go / Rust / Java only"),
|
||||
_ => unreachable!(
|
||||
"JSON_PARSE depth e2e covers JS / Python / Ruby / PHP / Go / Rust / Java only"
|
||||
),
|
||||
};
|
||||
if !command_available(required) {
|
||||
eprintln!("SKIP {lang:?} {fixture}: missing toolchain {required}");
|
||||
|
|
|
|||
|
|
@ -326,8 +326,9 @@ mod e2e_phase_20 {
|
|||
use tempfile::TempDir;
|
||||
|
||||
fn command_available(bin: &str) -> bool {
|
||||
let version_arg = if bin == "go" { "version" } else { "--version" };
|
||||
Command::new(bin)
|
||||
.arg("--version")
|
||||
.arg(version_arg)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
|
|
@ -484,7 +485,10 @@ mod e2e_phase_20 {
|
|||
let Some(outcome) = run(Lang::Python, "sqs_python", "vuln.py", "handler", "jobs") else {
|
||||
return;
|
||||
};
|
||||
assert!(outcome.triggered_by.is_some());
|
||||
assert!(
|
||||
outcome.triggered_by.is_some(),
|
||||
"sqs-python MessageHandler vuln must Confirm via run_spec; got {outcome:?}",
|
||||
);
|
||||
let diff = outcome.differential.as_ref().expect("Confirmed");
|
||||
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
|
||||
}
|
||||
|
|
@ -500,7 +504,10 @@ mod e2e_phase_20 {
|
|||
) else {
|
||||
return;
|
||||
};
|
||||
assert!(outcome.triggered_by.is_some());
|
||||
assert!(
|
||||
outcome.triggered_by.is_some(),
|
||||
"pubsub-python MessageHandler vuln must Confirm via run_spec; got {outcome:?}",
|
||||
);
|
||||
let diff = outcome.differential.as_ref().expect("Confirmed");
|
||||
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
|
||||
}
|
||||
|
|
@ -516,7 +523,10 @@ mod e2e_phase_20 {
|
|||
) else {
|
||||
return;
|
||||
};
|
||||
assert!(outcome.triggered_by.is_some());
|
||||
assert!(
|
||||
outcome.triggered_by.is_some(),
|
||||
"rabbit-python MessageHandler vuln must Confirm via run_spec; got {outcome:?}",
|
||||
);
|
||||
let diff = outcome.differential.as_ref().expect("Confirmed");
|
||||
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
|
||||
}
|
||||
|
|
@ -534,4 +544,71 @@ mod e2e_phase_20 {
|
|||
let diff = outcome.differential.as_ref().expect("Confirmed");
|
||||
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kafka_java_vuln_confirms_via_run_spec() {
|
||||
let Some(outcome) = run(Lang::Java, "kafka_java", "Vuln.java", "onMessage", "orders")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
outcome.triggered_by.is_some(),
|
||||
"kafka-java MessageHandler vuln must Confirm via run_spec; got {outcome:?}",
|
||||
);
|
||||
let diff = outcome.differential.as_ref().expect("Confirmed");
|
||||
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqs_java_vuln_confirms_via_run_spec() {
|
||||
let Some(outcome) = run(Lang::Java, "sqs_java", "Vuln.java", "handleMessage", "jobs")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
outcome.triggered_by.is_some(),
|
||||
"sqs-java MessageHandler vuln must Confirm via run_spec; got {outcome:?}",
|
||||
);
|
||||
let diff = outcome.differential.as_ref().expect("Confirmed");
|
||||
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rabbit_java_vuln_confirms_via_run_spec() {
|
||||
let Some(outcome) = run(Lang::Java, "rabbit_java", "Vuln.java", "onMessage", "work") else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
outcome.triggered_by.is_some(),
|
||||
"rabbit-java MessageHandler vuln must Confirm via run_spec; got {outcome:?}",
|
||||
);
|
||||
let diff = outcome.differential.as_ref().expect("Confirmed");
|
||||
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pubsub_go_vuln_confirms_via_run_spec() {
|
||||
let Some(outcome) = run(Lang::Go, "pubsub_go", "vuln.go", "OnMessage", "my-sub") else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
outcome.triggered_by.is_some(),
|
||||
"pubsub-go MessageHandler vuln must Confirm via run_spec; got {outcome:?}",
|
||||
);
|
||||
let diff = outcome.differential.as_ref().expect("Confirmed");
|
||||
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nats_go_vuln_confirms_via_run_spec() {
|
||||
let Some(outcome) = run(Lang::Go, "nats_go", "vuln.go", "OnMessage", "events") else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
outcome.triggered_by.is_some(),
|
||||
"nats-go MessageHandler vuln must Confirm via run_spec; got {outcome:?}",
|
||||
);
|
||||
let diff = outcome.differential.as_ref().expect("Confirmed");
|
||||
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue