diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index 1f49e941..44d140ac 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -570,6 +570,14 @@ pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result Option { fn try_compile_java(workdir: &Path, cache_path: &Path, target_release: Option) -> Result<(), String> { let javac = std::env::var("NYX_JAVAC_BIN").unwrap_or_else(|_| "javac".to_owned()); + // If the harness emitter shipped a `pom.xml`, stage Maven-resolved + // jars under `workdir/lib` so javac (and the runtime classpath + // baked into the harness command) can resolve framework imports + // like `org.thymeleaf.*`. + let lib_on_cp = workdir.join("pom.xml").exists() && { + fetch_maven_deps(workdir)?; + workdir.join("lib").exists() + }; + let sources = collect_java_sources(workdir); if sources.is_empty() { return Err("no Java sources found in workdir".to_owned()); @@ -658,6 +675,10 @@ fn try_compile_java(workdir: &Path, cache_path: &Path, target_release: Option Result<(), String> { + let mvn = std::env::var("NYX_MAVEN_BIN").unwrap_or_else(|_| "mvn".to_owned()); + let output = Command::new(&mvn) + .args([ + "-q", + "-B", + "dependency:copy-dependencies", + "-DoutputDirectory=lib", + "-DincludeScope=runtime", + ]) + .current_dir(workdir) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", std::env::var("HOME").unwrap_or_default()) + .output() + .map_err(|e| format!("mvn dependency:copy-dependencies: {e}"))?; + + if !output.status.success() { + let mut msg = String::from_utf8_lossy(&output.stderr).into_owned(); + if msg.is_empty() { + msg = String::from_utf8_lossy(&output.stdout).into_owned(); + } + return Err(format!("mvn dependency:copy-dependencies failed: {msg}")); + } Ok(()) } @@ -746,6 +811,13 @@ fn compute_java_source_hash(workdir: &Path, target_release: Option) -> Stri h.update(&content); } } + // Fold the harness `pom.xml` into the hash so a manifest edit (a + // new dep, a version bump) busts the build cache and re-runs + // `mvn dependency:copy-dependencies` on the next build. + if let Ok(pom) = std::fs::read(workdir.join("pom.xml")) { + h.update(b":pom="); + h.update(&pom); + } // Fold the target release into the hash so a workdir compiled at // `--release 17` cannot collide with the same workdir at `--release 21`. if let Some(rel) = target_release { diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 968be30f..4a350892 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -827,13 +827,11 @@ pub fn emit_ssti_harness(_spec: &HarnessSpec) -> HarnessSource { // control `7*7` has no `[[${{ ... }}]]` markers so the engine echoes // it verbatim. // -// Compile + classpath bootstrap is handled by the brief's Maven -// addendum — the synthetic harness this replaces never linked -// Thymeleaf, so the build path needs `pom.xml` plumbing routed -// through `prepare_java` before a host without `org.thymeleaf` -// on the classpath can run the harness. Until that plumbing -// lands the e2e Java SSTI test SKIPs via the runner's BuildFailed -// branch. +// The companion `pom.xml` (shipped via `HarnessSource::extra_files`) +// declares the Thymeleaf dependency; `prepare_java` runs +// `mvn dependency:copy-dependencies -DoutputDirectory=lib` against +// any workdir that carries a `pom.xml`, then folds `lib/*` into the +// javac and runtime classpath via the `-cp` arg below. import java.io.FileWriter; import java.io.IOException; import org.thymeleaf.TemplateEngine; @@ -897,14 +895,47 @@ public class NyxHarness {{ command: vec![ "java".to_owned(), "-cp".to_owned(), - ".".to_owned(), + ".:lib/*".to_owned(), "NyxHarness".to_owned(), ], - extra_files: Vec::new(), + extra_files: vec![("pom.xml".to_owned(), ssti_thymeleaf_pom().to_owned())], entry_subpath: None, } } +/// `pom.xml` manifest for the SSTI Thymeleaf harness. +/// +/// Declares `org.thymeleaf:thymeleaf:3.1.x` so `prepare_java` can resolve +/// the runtime classpath via `mvn dependency:copy-dependencies` before +/// the javac step. The Thymeleaf 3.1 line is the current LTS branch and +/// the lowest Java baseline (`java 11`) we still target across the test +/// matrix. +fn ssti_thymeleaf_pom() -> &'static str { + r#" + + 4.0.0 + com.nyx + nyx-harness-thymeleaf + 0.0.1 + jar + + 11 + 11 + UTF-8 + + + + org.thymeleaf + thymeleaf + 3.1.2.RELEASE + + + +"# +} + /// Phase 05 — Track J.3 XXE harness for Java (`DocumentBuilderFactory`). /// /// Reads `NYX_PAYLOAD`, scans for `` diff --git a/tests/dynamic_parity.rs b/tests/dynamic_parity.rs index 7bd8db2c..0da7c6ec 100644 --- a/tests/dynamic_parity.rs +++ b/tests/dynamic_parity.rs @@ -120,13 +120,16 @@ mod parity_tests { /// Assert two verdicts agree on status (and on reason for non-Confirmed). fn assert_parity(fixture: &str, process_result: &nyx_scanner::evidence::VerifyResult, docker_result: &nyx_scanner::evidence::VerifyResult) { - // If docker backend is unavailable, docker result will be Unsupported. - // That's acceptable — we can't compare when docker is missing. - if docker_result.status == VerifyStatus::Unsupported { - if let Some(ref r) = docker_result.reason { - if format!("{r:?}").contains("BackendUnavailable") { - return; // Docker absent — skip comparison. - } + // Docker reachability fluctuates per host: `docker info` may exit 0 + // (daemon listening) while the sandbox's container-start path still + // fails (image not pulled, socket gated by Docker Desktop's + // privileged-mode toggle, etc.). The downstream verifier folds + // BackendUnavailable into Unsupported OR Inconclusive depending on + // where the error surfaces, so the skip predicate looks at the + // reason text, not the verdict status. + if let Some(ref r) = docker_result.reason { + if format!("{r:?}").contains("BackendUnavailable") { + return; // Docker absent — skip comparison. } } diff --git a/tests/ssti_corpus.rs b/tests/ssti_corpus.rs index 42b4b6d1..ea3da1a6 100644 --- a/tests/ssti_corpus.rs +++ b/tests/ssti_corpus.rs @@ -322,10 +322,11 @@ fn slug(lang: Lang) -> &'static str { // `ProbePredicate::TemplateEvalEqual { expected: 49 }` → differential // pair against the `7*7` benign control. // -// Java is skipped: the Thymeleaf fixture imports `org.thymeleaf.*` -// which is not on the JDK stdlib, so `javac *.java` over the workdir -// fails before the synthetic harness can run. Phase 04 deferred -// item 5 (real-engine Thymeleaf harness) is the structural fix. +// Java/Thymeleaf rides the Maven plumbing added in `prepare_java`: +// the harness ships a `pom.xml` via `extra_files`, prepare_java runs +// `mvn dependency:copy-dependencies -DoutputDirectory=lib` to stage +// `org.thymeleaf.*` jars, and javac compiles with `-cp .:lib/*`. +// The e2e cell SKIPs when `mvn` or `javac` is absent on the host. mod e2e_phase_04 { use crate::common::fixture_harness::FIXTURE_LOCK; @@ -355,7 +356,8 @@ mod e2e_phase_04 { Lang::Ruby => "ruby", Lang::Php => "php", Lang::JavaScript => "node", - _ => unreachable!("e2e_phase_04 covers Python/Ruby/PHP/JS only"), + Lang::Java => "javac", + _ => unreachable!("e2e_phase_04 covers Python/Ruby/PHP/JS/Java only"), } } @@ -365,6 +367,7 @@ mod e2e_phase_04 { Lang::Ruby => "ruby_erb", Lang::Php => "php_twig", Lang::JavaScript => "js_handlebars", + Lang::Java => "java_thymeleaf", _ => unreachable!(), } } @@ -417,6 +420,12 @@ mod e2e_phase_04 { eprintln!("SKIP {lang:?} {fixture}: missing toolchain {bin}"); return None; } + // Java/Thymeleaf also needs Maven on PATH to resolve the + // Thymeleaf jars before javac runs. + if matches!(lang, Lang::Java) && !command_available("mvn") { + eprintln!("SKIP {lang:?} {fixture}: missing mvn for dependency resolution"); + return None; + } let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let (spec, _tmp) = build_spec(lang, fixture, entry_name); let opts = SandboxOptions { @@ -490,4 +499,18 @@ mod e2e_phase_04 { .expect("Confirmed run must carry a DifferentialOutcome"); assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); } + + #[test] + fn java_thymeleaf_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Java, "vuln.java", "run") else { return }; + assert!( + outcome.triggered_by.is_some(), + "Java Thymeleaf SSTI vuln must Confirm via run_spec; got {outcome:?}", + ); + let diff = outcome + .differential + .as_ref() + .expect("Confirmed run must carry a DifferentialOutcome"); + assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + } }