mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
refactor(dynamic): replace Spring annotation stubs with real dependencies, integrate MockMvc-based invocation for Spring controllers, and enhance runtime classpath logic
This commit is contained in:
parent
c57cd233fc
commit
61bfc0cf96
16 changed files with 214 additions and 98 deletions
|
|
@ -217,8 +217,8 @@ fn rewrite_go_package(src: &str, target: &str) -> String {
|
|||
if replaced { out } else { src.to_owned() }
|
||||
}
|
||||
|
||||
/// Java shape fixtures often keep tiny annotation / framework stubs next to
|
||||
/// `Vuln.java` or `Benign.java`. Stage those siblings with the entry file so
|
||||
/// Java shape fixtures often keep helper sources and a build manifest next to
|
||||
/// `Vuln.java` or `Benign.java`. Stage those siblings with the entry file so
|
||||
/// each unique workdir is self-contained, while skipping the opposite fixture
|
||||
/// variant to avoid duplicate public-class declarations in corpus tests.
|
||||
fn copy_java_sibling_sources(spec: &HarnessSpec, workdir: &Path) {
|
||||
|
|
@ -242,12 +242,16 @@ fn copy_java_sibling_sources(spec: &HarnessSpec, workdir: &Path) {
|
|||
};
|
||||
for item in entries.flatten() {
|
||||
let p = item.path();
|
||||
if !p.extension().map(|e| e == "java").unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if name == "pom.xml" {
|
||||
let _ = fs::copy(&p, workdir.join(name));
|
||||
continue;
|
||||
}
|
||||
if !p.extension().map(|e| e == "java").unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if name == entry_name || name == alt_name {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -385,6 +389,7 @@ mod tests {
|
|||
fs::write(&vuln, "public class Vuln {}\n").unwrap();
|
||||
fs::write(tmp.path().join("Helper.java"), "class Helper {}\n").unwrap();
|
||||
fs::write(tmp.path().join("Benign.java"), "public class Benign {}\n").unwrap();
|
||||
fs::write(tmp.path().join("pom.xml"), "<project />\n").unwrap();
|
||||
|
||||
let spec = HarnessSpec {
|
||||
finding_id: "0000000000000001".into(),
|
||||
|
|
@ -408,6 +413,7 @@ mod tests {
|
|||
let harness = build(&spec).unwrap();
|
||||
assert!(harness.workdir.join("Vuln.java").exists());
|
||||
assert!(harness.workdir.join("Helper.java").exists());
|
||||
assert!(harness.workdir.join("pom.xml").exists());
|
||||
assert!(!harness.workdir.join("Benign.java").exists());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -711,7 +711,7 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
command: vec![
|
||||
"java".to_owned(),
|
||||
"-cp".to_owned(),
|
||||
".".to_owned(),
|
||||
".:lib/*".to_owned(),
|
||||
"NyxHarness".to_owned(),
|
||||
],
|
||||
extra_files,
|
||||
|
|
@ -3118,19 +3118,9 @@ fn invoke_for_shape(spec: &HarnessSpec, shape: JavaShape, entry_class: &str) ->
|
|||
" invokeServlet({entry_class}.class, \"doPost\", payload, \"POST\");"
|
||||
),
|
||||
JavaShape::SpringController => {
|
||||
if spec.java_toolchain.with_spring_test {
|
||||
// Phase 14 (Track L.12) — `with_spring_test`-enabled
|
||||
// Spring shape: the v1 implementation still drives the
|
||||
// reflective path because the synthetic harness does
|
||||
// not bundle SpringBoot test deps. The flag flips a
|
||||
// marker on stdout so the verifier can confirm the
|
||||
// toolchain knob propagated.
|
||||
format!(
|
||||
" System.out.println(\"NYX_SPRING_TEST=1\");\n invokeReflective({entry_class}.class, \"{method}\", payload);"
|
||||
)
|
||||
} else {
|
||||
format!(" invokeReflective({entry_class}.class, \"{method}\", payload);")
|
||||
}
|
||||
format!(
|
||||
" System.out.println(\"NYX_SPRING_TEST=1\");\n invokeSpringController({entry_class}.class, \"{method}\", payload);"
|
||||
)
|
||||
}
|
||||
JavaShape::QuarkusRoute => {
|
||||
format!(" invokeReflective({entry_class}.class, \"{method}\", payload);")
|
||||
|
|
@ -3149,9 +3139,8 @@ fn shape_helpers(shape: JavaShape) -> &'static str {
|
|||
match shape {
|
||||
JavaShape::StaticMethod | JavaShape::StaticMain => "",
|
||||
JavaShape::ServletDoGet | JavaShape::ServletDoPost => SERVLET_HELPER,
|
||||
JavaShape::SpringController | JavaShape::QuarkusRoute | JavaShape::MicronautRoute => {
|
||||
REFLECTIVE_HELPER
|
||||
}
|
||||
JavaShape::SpringController => SPRING_MOCKMVC_HELPER,
|
||||
JavaShape::QuarkusRoute | JavaShape::MicronautRoute => REFLECTIVE_HELPER,
|
||||
JavaShape::JunitTest => JUNIT_HELPER,
|
||||
}
|
||||
}
|
||||
|
|
@ -3263,6 +3252,101 @@ const SERVLET_HELPER: &str = r#"
|
|||
}
|
||||
"#;
|
||||
|
||||
/// Spring MVC request replay through `MockMvc`. This keeps Spring's
|
||||
/// annotation mapping and request-parameter binding in the execution
|
||||
/// path instead of invoking the controller method directly.
|
||||
const SPRING_MOCKMVC_HELPER: &str = r#"
|
||||
static void invokeSpringController(Class<?> cls, String methodName, String payload) throws Exception {
|
||||
Object controller = newDefaultInstance(cls);
|
||||
String[] candidatePaths = springCandidatePaths(cls, methodName);
|
||||
org.springframework.test.web.servlet.MockMvc mvc =
|
||||
org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build();
|
||||
Throwable last = null;
|
||||
for (String path : candidatePaths) {
|
||||
try {
|
||||
org.springframework.test.web.servlet.MvcResult result = mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.get(path)
|
||||
.param("payload", payload)
|
||||
.param("id", payload)
|
||||
.content(payload))
|
||||
.andReturn();
|
||||
int status = result.getResponse().getStatus();
|
||||
if (status < 400) {
|
||||
return;
|
||||
}
|
||||
last = new RuntimeException("Spring MockMvc returned HTTP " + status + " for " + path);
|
||||
} catch (Throwable t) {
|
||||
last = t;
|
||||
}
|
||||
}
|
||||
if (last instanceof Exception) {
|
||||
throw (Exception) last;
|
||||
}
|
||||
if (last != null) {
|
||||
throw new RuntimeException(last);
|
||||
}
|
||||
throw new NoSuchMethodException(cls.getName() + "." + methodName);
|
||||
}
|
||||
|
||||
static Object newDefaultInstance(Class<?> cls) throws Exception {
|
||||
Constructor<?> ctor = cls.getDeclaredConstructor();
|
||||
ctor.setAccessible(true);
|
||||
return ctor.newInstance();
|
||||
}
|
||||
|
||||
static String[] springCandidatePaths(Class<?> cls, String methodName) throws Exception {
|
||||
String classPath = springPathFromAnnotation(cls.getAnnotations());
|
||||
String methodPath = "";
|
||||
for (Method m : cls.getDeclaredMethods()) {
|
||||
if (!m.getName().equals(methodName)) continue;
|
||||
methodPath = springPathFromAnnotation(m.getAnnotations());
|
||||
break;
|
||||
}
|
||||
String joined = springJoinPath(classPath, methodPath);
|
||||
if (!joined.equals("/")) {
|
||||
String fallback = classPath.isEmpty() ? "/" : springJoinPath(classPath, "");
|
||||
return new String[] { joined, fallback, "/" };
|
||||
}
|
||||
return new String[] { "/" };
|
||||
}
|
||||
|
||||
static String springPathFromAnnotation(java.lang.annotation.Annotation[] annotations) throws Exception {
|
||||
for (java.lang.annotation.Annotation ann : annotations) {
|
||||
String n = ann.annotationType().getName();
|
||||
if (!n.startsWith("org.springframework.web.bind.annotation.")) continue;
|
||||
String p = springAnnotationStringArrayValue(ann, "path");
|
||||
if (p == null || p.isEmpty()) p = springAnnotationStringArrayValue(ann, "value");
|
||||
if (p != null && !p.isEmpty()) return p;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static String springAnnotationStringArrayValue(java.lang.annotation.Annotation ann, String name) throws Exception {
|
||||
try {
|
||||
Object value = ann.annotationType().getMethod(name).invoke(ann);
|
||||
if (value instanceof String[]) {
|
||||
String[] arr = (String[]) value;
|
||||
return arr.length == 0 ? "" : arr[0];
|
||||
}
|
||||
if (value instanceof String) {
|
||||
return (String) value;
|
||||
}
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static String springJoinPath(String a, String b) {
|
||||
String left = a == null || a.isEmpty() ? "" : a;
|
||||
String right = b == null || b.isEmpty() ? "" : b;
|
||||
if (left.isEmpty() && right.isEmpty()) return "/";
|
||||
String joined = (left + "/" + right).replaceAll("/+", "/");
|
||||
if (!joined.startsWith("/")) joined = "/" + joined;
|
||||
if (joined.length() > 1 && joined.endsWith("/")) joined = joined.substring(0, joined.length() - 1);
|
||||
return joined;
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Reflective Spring / Quarkus invocation. Same shape as the servlet
|
||||
/// reflective fallback but routed through a dedicated helper for
|
||||
/// clarity in the generated harness.
|
||||
|
|
@ -4067,7 +4151,8 @@ mod tests {
|
|||
fn spring_shape_emits_reflective_invocation() {
|
||||
let spec = make_spec_with(EntryKind::HttpRoute, "run", "Vuln.java");
|
||||
let src = generate_harness_java(&spec, JavaShape::SpringController, "Vuln");
|
||||
assert!(src.contains("invokeReflective(Vuln.class, \"run\""));
|
||||
assert!(src.contains("invokeSpringController(Vuln.class, \"run\""));
|
||||
assert!(src.contains("MockMvcBuilders.standaloneSetup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -4093,7 +4178,8 @@ mod tests {
|
|||
let mut off = make_spec_with(EntryKind::HttpRoute, "run", "Vuln.java");
|
||||
off.java_toolchain.with_spring_test = false;
|
||||
let src_off = generate_harness_java(&off, JavaShape::SpringController, "Vuln");
|
||||
assert!(!src_off.contains("NYX_SPRING_TEST=1"));
|
||||
assert!(src_off.contains("NYX_SPRING_TEST=1"));
|
||||
assert!(src_off.contains("invokeSpringController"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -4353,7 +4439,17 @@ mod tests {
|
|||
let mut spec = make_spec_with(EntryKind::HttpRoute, "run", &entry_file);
|
||||
spec.payload_slot = PayloadSlot::Param(0);
|
||||
let harness = emit(&spec).unwrap();
|
||||
assert!(harness.extra_files.is_empty());
|
||||
assert!(
|
||||
harness.extra_files.iter().all(
|
||||
|(p, _)| !p.starts_with("javax/servlet/") && !p.starts_with("jakarta/servlet/")
|
||||
),
|
||||
"spring controller unexpectedly ships servlet stubs: {:?}",
|
||||
harness
|
||||
.extra_files
|
||||
.iter()
|
||||
.map(|(p, _)| p)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@
|
|||
//! paths, not the build-time stubs.
|
||||
//!
|
||||
//! Detection gate ([`entry_needs_owasp_stubs`]) checks the entry
|
||||
//! source for substring hits on `org.owasp.benchmark` /
|
||||
//! `org.owasp.esapi` / `org.springframework`. Non-OWASP harnesses
|
||||
//! pay zero workdir cost.
|
||||
//! source for substring hits on `org.owasp.benchmark`,
|
||||
//! `org.owasp.esapi`, or the narrow Spring helper packages used by
|
||||
//! OWASP. Non-OWASP harnesses pay zero workdir cost.
|
||||
|
||||
/// Returns `(workdir_relative_path, file_content)` pairs ready to
|
||||
/// drop into [`crate::dynamic::lang::HarnessSource::extra_files`].
|
||||
|
|
@ -101,14 +101,15 @@ pub fn owasp_stub_files() -> Vec<(String, String)> {
|
|||
|
||||
/// Substring probe to decide whether an entry source pulls in the
|
||||
/// OWASP Benchmark helper set. Matches `org.owasp.benchmark` /
|
||||
/// `org.owasp.esapi` / `org.springframework` references, including
|
||||
/// import statements and inline FQNs. Conservative on false
|
||||
/// positives: a fixture that only mentions one of these in a comment
|
||||
/// will still get the stubs staged, which is harmless javac work.
|
||||
/// `org.owasp.esapi` references, and the small Spring helper packages
|
||||
/// used by OWASP. Do not match generic Spring MVC annotations here:
|
||||
/// real Spring controller fixtures bring those classes from Maven.
|
||||
pub fn entry_needs_owasp_stubs(source: &str) -> bool {
|
||||
source.contains("org.owasp.benchmark")
|
||||
|| source.contains("org.owasp.esapi")
|
||||
|| source.contains("org.springframework")
|
||||
|| source.contains("org.springframework.dao.")
|
||||
|| source.contains("org.springframework.jdbc.")
|
||||
|| source.contains("org.springframework.web.util.")
|
||||
}
|
||||
|
||||
fn utils_stub() -> String {
|
||||
|
|
|
|||
|
|
@ -315,11 +315,17 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
|
|||
// Compile NyxHarness.java + Entry.java with javac.
|
||||
match build_sandbox::prepare_java(spec, &harness.workdir) {
|
||||
Ok(_) => {
|
||||
// Update classpath to absolute workdir path for Docker compatibility.
|
||||
// Update classpath to absolute workdir paths for Docker
|
||||
// compatibility. Include Maven-staged jars too; framework
|
||||
// harnesses compile with `lib/*` and need the same jars at
|
||||
// runtime.
|
||||
let workdir_cp = harness.workdir.to_string_lossy();
|
||||
let lib_cp = harness.workdir.join("lib/*");
|
||||
let cp = format!("{workdir_cp}:{}", lib_cp.to_string_lossy());
|
||||
harness.command = vec![
|
||||
"java".to_owned(),
|
||||
"-cp".to_owned(),
|
||||
harness.workdir.to_string_lossy().into_owned(),
|
||||
cp,
|
||||
"NyxHarness".to_owned(),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1107,7 +1107,11 @@ fn build_container_exec_args(command: &[String]) -> Vec<String> {
|
|||
if command[i] == "-cp" || command[i] == "-classpath" {
|
||||
args.push(command[i].clone());
|
||||
i += 1;
|
||||
args.push(docker::WORK_MOUNT_PATH.to_owned());
|
||||
args.push(format!(
|
||||
"{}:{}/lib/*",
|
||||
docker::WORK_MOUNT_PATH,
|
||||
docker::WORK_MOUNT_PATH
|
||||
));
|
||||
i += 1;
|
||||
} else {
|
||||
args.push(command[i].clone());
|
||||
|
|
@ -2043,7 +2047,7 @@ mod tests {
|
|||
];
|
||||
assert_eq!(
|
||||
build_container_exec_args(&cmd),
|
||||
vec!["java", "-cp", "/work", "NyxHarness"]
|
||||
vec!["java", "-cp", "/work:/work/lib/*", "NyxHarness"]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue