diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index 2dffd5a5..76a32dd7 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -545,6 +545,9 @@ pub fn prepare_go(spec: &HarnessSpec, workdir: &Path) -> Result 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(|_| { diff --git a/src/dynamic/framework/adapters/crypto_java.rs b/src/dynamic/framework/adapters/crypto_java.rs index 0bb53d73..3136cb27 100644 --- a/src/dynamic/framework/adapters/crypto_java.rs +++ b/src/dynamic/framework/adapters/crypto_java.rs @@ -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(), diff --git a/src/dynamic/framework/adapters/crypto_js.rs b/src/dynamic/framework/adapters/crypto_js.rs index ef8eafe6..84266cd1 100644 --- a/src/dynamic/framework/adapters/crypto_js.rs +++ b/src/dynamic/framework/adapters/crypto_js.rs @@ -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(), diff --git a/src/dynamic/framework/adapters/crypto_php.rs b/src/dynamic/framework/adapters/crypto_php.rs index 159f77b4..1523507c 100644 --- a/src/dynamic/framework/adapters/crypto_php.rs +++ b/src/dynamic/framework/adapters/crypto_php.rs @@ -128,8 +128,7 @@ mod tests { #[test] fn fires_on_md5() { - let src: &[u8] = - b" 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(), diff --git a/src/dynamic/framework/adapters/data_exfil_go.rs b/src/dynamic/framework/adapters/data_exfil_go.rs index e5564f3f..b80a6ce0 100644 --- a/src/dynamic/framework/adapters/data_exfil_go.rs +++ b/src/dynamic/framework/adapters/data_exfil_go.rs @@ -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)) diff --git a/src/dynamic/framework/adapters/data_exfil_java.rs b/src/dynamic/framework/adapters/data_exfil_java.rs index 1b8ffeb0..088557d1 100644 --- a/src/dynamic/framework/adapters/data_exfil_java.rs +++ b/src/dynamic/framework/adapters/data_exfil_java.rs @@ -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(), diff --git a/src/dynamic/framework/adapters/data_exfil_python.rs b/src/dynamic/framework/adapters/data_exfil_python.rs index 25b69f11..9171ccda 100644 --- a/src/dynamic/framework/adapters/data_exfil_python.rs +++ b/src/dynamic/framework/adapters/data_exfil_python.rs @@ -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" diff --git a/src/dynamic/framework/adapters/data_exfil_ruby.rs b/src/dynamic/framework/adapters/data_exfil_ruby.rs index 7d70a1e2..77d09252 100644 --- a/src/dynamic/framework/adapters/data_exfil_ruby.rs +++ b/src/dynamic/framework/adapters/data_exfil_ruby.rs @@ -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 { diff --git a/src/dynamic/framework/adapters/go_chi.rs b/src/dynamic/framework/adapters/go_chi.rs index a7856bc1..aafb6035 100644 --- a/src/dynamic/framework/adapters/go_chi.rs +++ b/src/dynamic/framework/adapters/go_chi.rs @@ -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, diff --git a/src/dynamic/framework/adapters/go_echo.rs b/src/dynamic/framework/adapters/go_echo.rs index bdaced55..99f1eb17 100644 --- a/src/dynamic/framework/adapters/go_echo.rs +++ b/src/dynamic/framework/adapters/go_echo.rs @@ -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, diff --git a/src/dynamic/framework/adapters/go_fiber.rs b/src/dynamic/framework/adapters/go_fiber.rs index 63c2ecc3..a74e77d4 100644 --- a/src/dynamic/framework/adapters/go_fiber.rs +++ b/src/dynamic/framework/adapters/go_fiber.rs @@ -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, diff --git a/src/dynamic/framework/adapters/go_gin.rs b/src/dynamic/framework/adapters/go_gin.rs index b8c3bb77..8bbd89d3 100644 --- a/src/dynamic/framework/adapters/go_gin.rs +++ b/src/dynamic/framework/adapters/go_gin.rs @@ -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, diff --git a/src/dynamic/framework/adapters/java_micronaut.rs b/src/dynamic/framework/adapters/java_micronaut.rs index 96336088..1168896e 100644 --- a/src/dynamic/framework/adapters/java_micronaut.rs +++ b/src/dynamic/framework/adapters/java_micronaut.rs @@ -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, diff --git a/src/dynamic/framework/adapters/java_quarkus.rs b/src/dynamic/framework/adapters/java_quarkus.rs index 266e03f5..94d0cdaf 100644 --- a/src/dynamic/framework/adapters/java_quarkus.rs +++ b/src/dynamic/framework/adapters/java_quarkus.rs @@ -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, diff --git a/src/dynamic/framework/adapters/java_servlet.rs b/src/dynamic/framework/adapters/java_servlet.rs index fb9ef782..da4502a3 100644 --- a/src/dynamic/framework/adapters/java_servlet.rs +++ b/src/dynamic/framework/adapters/java_servlet.rs @@ -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, diff --git a/src/dynamic/framework/adapters/java_spring.rs b/src/dynamic/framework/adapters/java_spring.rs index 15bd5823..a04ccfe7 100644 --- a/src/dynamic/framework/adapters/java_spring.rs +++ b/src/dynamic/framework/adapters/java_spring.rs @@ -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, diff --git a/src/dynamic/framework/adapters/js_express.rs b/src/dynamic/framework/adapters/js_express.rs index a9751560..20f7e8b4 100644 --- a/src/dynamic/framework/adapters/js_express.rs +++ b/src/dynamic/framework/adapters/js_express.rs @@ -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, diff --git a/src/dynamic/framework/adapters/js_fastify.rs b/src/dynamic/framework/adapters/js_fastify.rs index 4605a92f..2d8683c5 100644 --- a/src/dynamic/framework/adapters/js_fastify.rs +++ b/src/dynamic/framework/adapters/js_fastify.rs @@ -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, diff --git a/src/dynamic/framework/adapters/js_koa.rs b/src/dynamic/framework/adapters/js_koa.rs index 184857d1..3288cdd8 100644 --- a/src/dynamic/framework/adapters/js_koa.rs +++ b/src/dynamic/framework/adapters/js_koa.rs @@ -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 { diff --git a/src/dynamic/framework/adapters/js_nest.rs b/src/dynamic/framework/adapters/js_nest.rs index 7e59008a..8b38bd34 100644 --- a/src/dynamic/framework/adapters/js_nest.rs +++ b/src/dynamic/framework/adapters/js_nest.rs @@ -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, diff --git a/src/dynamic/framework/adapters/php_codeigniter.rs b/src/dynamic/framework/adapters/php_codeigniter.rs index a786f649..952548cb 100644 --- a/src/dynamic/framework/adapters/php_codeigniter.rs +++ b/src/dynamic/framework/adapters/php_codeigniter.rs @@ -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, diff --git a/src/dynamic/framework/adapters/php_laravel.rs b/src/dynamic/framework/adapters/php_laravel.rs index d010533f..30d46099 100644 --- a/src/dynamic/framework/adapters/php_laravel.rs +++ b/src/dynamic/framework/adapters/php_laravel.rs @@ -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"middleware('auth');\nclass UserController {\n public function show($id) { return $id; }\n}\n"; diff --git a/src/dynamic/framework/adapters/php_routes.rs b/src/dynamic/framework/adapters/php_routes.rs index 011d63e4..c69ad6e3 100644 --- a/src/dynamic/framework/adapters/php_routes.rs +++ b/src/dynamic/framework/adapters/php_routes.rs @@ -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 { + let mut hit: Option = 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, ) { 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 { 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> { + 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 { + 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> { + 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) { + 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> = 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 { "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"get('users/(:num)', 'UserController::show');\n"; diff --git a/src/dynamic/framework/adapters/php_symfony.rs b/src/dynamic/framework/adapters/php_symfony.rs index 4bad094d..6b05dc04 100644 --- a/src/dynamic/framework/adapters/php_symfony.rs +++ b/src/dynamic/framework/adapters/php_symfony.rs @@ -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, diff --git a/src/dynamic/framework/adapters/python_django.rs b/src/dynamic/framework/adapters/python_django.rs index 07f2d321..2b970080 100644 --- a/src/dynamic/framework/adapters/python_django.rs +++ b/src/dynamic/framework/adapters/python_django.rs @@ -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(), diff --git a/src/dynamic/framework/adapters/python_fastapi.rs b/src/dynamic/framework/adapters/python_fastapi.rs index d64bc50a..835513af 100644 --- a/src/dynamic/framework/adapters/python_fastapi.rs +++ b/src/dynamic/framework/adapters/python_fastapi.rs @@ -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(), diff --git a/src/dynamic/framework/adapters/python_flask.rs b/src/dynamic/framework/adapters/python_flask.rs index 55dcdb3b..3b3aafbe 100644 --- a/src/dynamic/framework/adapters/python_flask.rs +++ b/src/dynamic/framework/adapters/python_flask.rs @@ -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(), diff --git a/src/dynamic/framework/adapters/python_starlette.rs b/src/dynamic/framework/adapters/python_starlette.rs index 4542b7fa..ea8e1f64 100644 --- a/src/dynamic/framework/adapters/python_starlette.rs +++ b/src/dynamic/framework/adapters/python_starlette.rs @@ -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(), diff --git a/src/dynamic/framework/adapters/ruby_hanami.rs b/src/dynamic/framework/adapters/ruby_hanami.rs index 8fc241b8..35a264e8 100644 --- a/src/dynamic/framework/adapters/ruby_hanami.rs +++ b/src/dynamic/framework/adapters/ruby_hanami.rs @@ -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, diff --git a/src/dynamic/framework/adapters/ruby_rails.rs b/src/dynamic/framework/adapters/ruby_rails.rs index 194a29ee..98871b71 100644 --- a/src/dynamic/framework/adapters/ruby_rails.rs +++ b/src/dynamic/framework/adapters/ruby_rails.rs @@ -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, diff --git a/src/dynamic/framework/adapters/ruby_sinatra.rs b/src/dynamic/framework/adapters/ruby_sinatra.rs index 6d158728..20c428ec 100644 --- a/src/dynamic/framework/adapters/ruby_sinatra.rs +++ b/src/dynamic/framework/adapters/ruby_sinatra.rs @@ -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, diff --git a/src/dynamic/framework/adapters/rust_actix.rs b/src/dynamic/framework/adapters/rust_actix.rs index b70bf7a1..7d8f02ba 100644 --- a/src/dynamic/framework/adapters/rust_actix.rs +++ b/src/dynamic/framework/adapters/rust_actix.rs @@ -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, diff --git a/src/dynamic/framework/adapters/rust_axum.rs b/src/dynamic/framework/adapters/rust_axum.rs index f09f8153..71077a48 100644 --- a/src/dynamic/framework/adapters/rust_axum.rs +++ b/src/dynamic/framework/adapters/rust_axum.rs @@ -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, diff --git a/src/dynamic/framework/adapters/rust_rocket.rs b/src/dynamic/framework/adapters/rust_rocket.rs index 4742837c..92c9a394 100644 --- a/src/dynamic/framework/adapters/rust_rocket.rs +++ b/src/dynamic/framework/adapters/rust_rocket.rs @@ -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, diff --git a/src/dynamic/framework/adapters/rust_warp.rs b/src/dynamic/framework/adapters/rust_warp.rs index 2d55a0dd..4bdbeb04 100644 --- a/src/dynamic/framework/adapters/rust_warp.rs +++ b/src/dynamic/framework/adapters/rust_warp.rs @@ -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, diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index d8adb824..3548eebe 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -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, /// 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) -> Self { + Self { + method, + methods: Vec::new(), + path: path.into(), + } + } + + /// Construct a route reachable through multiple HTTP methods. + pub fn multi(methods: Vec, path: impl Into) -> 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 { + 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(), diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index d7e0314e..0a049d3c 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -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", ); } diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 8deea4ad..6351eee9 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -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", + ); + } + } } diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index a63c3ffa..ec48108c 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -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![], diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index d6a288a5..b0c341dc 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -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: {}", diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 6970a34f..b27b5e2e 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -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\")")); } } diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 6212f2dc..0415216d 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -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 ); diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index ad704d56..a13de9cc 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -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!( diff --git a/src/dynamic/middleware_demotion.rs b/src/dynamic/middleware_demotion.rs index 3ab365a3..bd819aa0 100644 --- a/src/dynamic/middleware_demotion.rs +++ b/src/dynamic/middleware_demotion.rs @@ -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 diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs index c341ec48..e4dbf144 100644 --- a/src/dynamic/runner.rs +++ b/src/dynamic/runner.rs @@ -288,7 +288,18 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result { 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 { 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 => { diff --git a/src/dynamic/sandbox/mod.rs b/src/dynamic/sandbox/mod.rs index edada166..50fe41da 100644 --- a/src/dynamic/sandbox/mod.rs +++ b/src/dynamic/sandbox/mod.rs @@ -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); diff --git a/tests/dynamic_fixtures/message_handler/kafka_java/Benign.java b/tests/dynamic_fixtures/message_handler/kafka_java/Benign.java index 07470173..6fe7d3c2 100644 --- a/tests/dynamic_fixtures/message_handler/kafka_java/Benign.java +++ b/tests/dynamic_fixtures/message_handler/kafka_java/Benign.java @@ -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(); } diff --git a/tests/dynamic_fixtures/message_handler/kafka_java/Vuln.java b/tests/dynamic_fixtures/message_handler/kafka_java/Vuln.java index 70bd7e78..412ecfc5 100644 --- a/tests/dynamic_fixtures/message_handler/kafka_java/Vuln.java +++ b/tests/dynamic_fixtures/message_handler/kafka_java/Vuln.java @@ -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(); diff --git a/tests/dynamic_fixtures/message_handler/rabbit_java/Benign.java b/tests/dynamic_fixtures/message_handler/rabbit_java/Benign.java index e53f618d..08b8066b 100644 --- a/tests/dynamic_fixtures/message_handler/rabbit_java/Benign.java +++ b/tests/dynamic_fixtures/message_handler/rabbit_java/Benign.java @@ -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(); } diff --git a/tests/dynamic_fixtures/message_handler/rabbit_java/Vuln.java b/tests/dynamic_fixtures/message_handler/rabbit_java/Vuln.java index 0142fd4e..6a1576ae 100644 --- a/tests/dynamic_fixtures/message_handler/rabbit_java/Vuln.java +++ b/tests/dynamic_fixtures/message_handler/rabbit_java/Vuln.java @@ -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(); diff --git a/tests/dynamic_fixtures/message_handler/sqs_java/Benign.java b/tests/dynamic_fixtures/message_handler/sqs_java/Benign.java index b0108f7c..a5b5c5c1 100644 --- a/tests/dynamic_fixtures/message_handler/sqs_java/Benign.java +++ b/tests/dynamic_fixtures/message_handler/sqs_java/Benign.java @@ -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 env) throws Exception { String body = env != null ? env.getOrDefault("Body", "") : ""; new ProcessBuilder("echo", body).inheritIO().start().waitFor(); diff --git a/tests/dynamic_fixtures/message_handler/sqs_java/Vuln.java b/tests/dynamic_fixtures/message_handler/sqs_java/Vuln.java index 211e494a..57917016 100644 --- a/tests/dynamic_fixtures/message_handler/sqs_java/Vuln.java +++ b/tests/dynamic_fixtures/message_handler/sqs_java/Vuln.java @@ -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 env) throws Exception { String body = env != null ? env.getOrDefault("Body", "") : ""; // SINK: tainted Body concatenated into shell command diff --git a/tests/json_parse_corpus.rs b/tests/json_parse_corpus.rs index ace85007..8dd18474 100644 --- a/tests/json_parse_corpus.rs +++ b/tests/json_parse_corpus.rs @@ -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}"); diff --git a/tests/message_handler_corpus.rs b/tests/message_handler_corpus.rs index dfa7a89c..5899b06f 100644 --- a/tests/message_handler_corpus.rs +++ b/tests/message_handler_corpus.rs @@ -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); + } }