mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
5366 lines
217 KiB
Rust
5366 lines
217 KiB
Rust
//! Java harness emitter.
|
|
//!
|
|
//! Phase 14 (Track B Java vertical) replaces the single legacy `emit`
|
|
//! body with dispatch over [`JavaShape`] — the cross product of
|
|
//! [`EntryKind`] and a lightweight per-file shape detector that inspects
|
|
//! the entry file for servlet / Spring / Quarkus annotations, JUnit
|
|
//! markers, and `static main(String[])` signatures.
|
|
//!
|
|
//! Each shape emits a single `NyxHarness.java` that:
|
|
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64`.
|
|
//! 2. Locates the entry class (default-package, derived from the entry
|
|
//! file basename) and invokes its method via the per-shape adapter.
|
|
//! 3. Catches all exceptions so the JVM exit shape stays observable.
|
|
//!
|
|
//! Sink-reachability probe: fixtures explicitly emit
|
|
//! `System.out.println("__NYX_SINK_HIT__")` before the actual sink call
|
|
//! (same pattern as Rust and Go fixtures).
|
|
//!
|
|
//! Build step: `prepare_java()` in `build_sandbox.rs` runs `javac` over
|
|
//! every `*.java` file in the workdir. Shape fixtures bundle their own
|
|
//! annotation / type stubs (e.g. a minimal `HttpServletRequest.java`
|
|
//! when the shape needs servlet plumbing) so the JDK can compile the
|
|
//! source without pulling Maven dependencies.
|
|
//!
|
|
//! Payload slot support:
|
|
//! - [`PayloadSlot::Param`] — pass payload as `String` first argument
|
|
//! (n-th positional for `Param(n)` where `n > 0`).
|
|
//! - [`PayloadSlot::EnvVar`] — set a system property before invocation.
|
|
//! - [`PayloadSlot::QueryParam`] / [`PayloadSlot::HttpBody`] — surfaced
|
|
//! to servlet / Spring / Quarkus adapters as the request body or
|
|
//! query parameter value.
|
|
//! - [`PayloadSlot::Argv`] — appended to a `String[] args` for
|
|
//! `static main` shapes.
|
|
//! - Other slots produce [`UnsupportedReason::PayloadSlotUnsupported`].
|
|
//!
|
|
//! Build container: `nyx-build-java:{toolchain_id}` (deferred; §19.1).
|
|
|
|
use crate::dynamic::environment::{Environment, RuntimeArtifacts};
|
|
use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter};
|
|
use crate::dynamic::spec::{EntryKindTag, HarnessSpec, PayloadSlot};
|
|
use crate::evidence::UnsupportedReason;
|
|
use std::path::PathBuf;
|
|
|
|
/// Zero-sized [`LangEmitter`] handle for Java. Method bodies delegate to the
|
|
/// existing free functions in this module.
|
|
pub struct JavaEmitter;
|
|
|
|
/// Entry kinds the Java emitter understands after Phase 14.
|
|
///
|
|
/// `HttpRoute` covers servlet / Spring / Quarkus shapes. `CliSubcommand`
|
|
/// covers `public static void main(String[])`. `Function` covers JUnit
|
|
/// tests and plain static methods.
|
|
const SUPPORTED: &[EntryKindTag] = &[
|
|
EntryKindTag::Function,
|
|
EntryKindTag::HttpRoute,
|
|
EntryKindTag::CliSubcommand,
|
|
EntryKindTag::ClassMethod,
|
|
EntryKindTag::MessageHandler,
|
|
EntryKindTag::ScheduledJob,
|
|
EntryKindTag::Middleware,
|
|
];
|
|
|
|
impl LangEmitter for JavaEmitter {
|
|
fn emit(&self, spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|
emit(spec)
|
|
}
|
|
|
|
fn entry_kinds_supported(&self) -> &'static [EntryKindTag] {
|
|
SUPPORTED
|
|
}
|
|
|
|
fn entry_kind_hint(&self, attempted: EntryKindTag) -> String {
|
|
format!(
|
|
"java emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 14 / 19 / 20 / 21 shape dispatch"
|
|
)
|
|
}
|
|
|
|
fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts {
|
|
materialize_java(env)
|
|
}
|
|
|
|
fn compose_chain_step(
|
|
&self,
|
|
prev_output: Option<&[u8]>,
|
|
terminal: Option<&ChainStepTerminal>,
|
|
) -> ChainStepHarness {
|
|
chain_step(prev_output, terminal)
|
|
}
|
|
}
|
|
|
|
/// Phase 26 — Java chain-step harness.
|
|
///
|
|
/// Emits a `Step.java` class whose `main` reads `NYX_PREV_OUTPUT` and
|
|
/// forwards it on stdout. When the step is the chain's terminal step
|
|
/// the `main` body also calls `__nyx_probe(callee, prev)` and prints
|
|
/// [`ChainStepHarness::SINK_HIT_SENTINEL`] so the runner flips
|
|
/// `sink_hit` for the chain. The command shell-wraps `javac` + `java`
|
|
/// so the step actually runs after the build step completes (the
|
|
/// `ChainStepHarness.command` slot models a single process).
|
|
///
|
|
/// The Java probe shim (`__nyx_probe`, `__nyx_install_crash_guard`,
|
|
/// helpers) is spliced as class-member declarations inside `class Step
|
|
/// { … }` between the class-open brace and `public static void main`,
|
|
/// so a downstream sink rewrite within the step body has the shim
|
|
/// helpers already in scope. The shim uses only `java.lang.*` plus
|
|
/// fully-qualified `java.util.TreeMap` / `java.io.FileWriter` /
|
|
/// `java.nio.charset.StandardCharsets`, so no extra `import` lines
|
|
/// are needed beyond what stock Java implicitly imports.
|
|
fn chain_step(
|
|
prev_output: Option<&[u8]>,
|
|
terminal: Option<&ChainStepTerminal>,
|
|
) -> ChainStepHarness {
|
|
let shim = probe_shim();
|
|
let mut body = String::from(
|
|
" String prev = System.getenv(\"NYX_PREV_OUTPUT\");\n if (prev == null) prev = \"\";\n System.out.print(prev);\n",
|
|
);
|
|
if let Some(t) = terminal {
|
|
let callee = java_string_literal(&t.sink_callee);
|
|
let sentinel = java_string_literal(ChainStepHarness::SINK_HIT_SENTINEL);
|
|
body.push_str(&format!(
|
|
" __nyx_probe({callee}, prev);\n System.out.println({sentinel});\n System.out.flush();\n",
|
|
));
|
|
}
|
|
let source = format!(
|
|
"public class Step {{\n{shim}\n public static void main(String[] args) {{\n{body} }}\n}}\n"
|
|
);
|
|
ChainStepHarness {
|
|
source,
|
|
filename: "Step.java".to_owned(),
|
|
command: vec![
|
|
"sh".to_owned(),
|
|
"-c".to_owned(),
|
|
"javac Step.java && java Step".to_owned(),
|
|
],
|
|
extra_env: prev_output
|
|
.map(|bytes| {
|
|
vec![(
|
|
ChainStepHarness::PREV_OUTPUT_ENV.to_owned(),
|
|
String::from_utf8_lossy(bytes).into_owned(),
|
|
)]
|
|
})
|
|
.unwrap_or_default(),
|
|
extra_files: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Escape a string for safe Java double-quoted literal embedding.
|
|
fn java_string_literal(s: &str) -> String {
|
|
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
|
|
format!("\"{escaped}\"")
|
|
}
|
|
|
|
// ── Phase 14: shape detector ─────────────────────────────────────────────────
|
|
|
|
/// Concrete per-file shape resolved by reading the entry source.
|
|
///
|
|
/// One harness template per variant. When the entry file is unreadable
|
|
/// or no marker fires the detector defaults to [`JavaShape::StaticMethod`],
|
|
/// which preserves the pre-Phase-14 behaviour (direct static method call).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum JavaShape {
|
|
/// `public class … extends HttpServlet { void doGet(req, resp) }`.
|
|
/// Harness instantiates the class via the default constructor and
|
|
/// invokes `doGet` with a minimal `HttpServletRequest` / `Response`
|
|
/// stub-pair via reflection.
|
|
ServletDoGet,
|
|
/// `void doPost(req, resp)` variant. Same adapter shape as doGet
|
|
/// but uses `POST` semantics for query-vs-body wiring.
|
|
ServletDoPost,
|
|
/// Spring `@RestController` / `@Controller` with a `@RequestMapping`
|
|
/// / `@GetMapping` / `@PostMapping` handler. Harness instantiates
|
|
/// the controller via reflection (default ctor) and invokes the
|
|
/// handler method with the payload routed into the matching
|
|
/// `String` parameter.
|
|
SpringController,
|
|
/// `public static void main(String[] args)`. Harness calls
|
|
/// `Class.forName(name).getMethod("main", String[].class)` and
|
|
/// passes a one-element argv populated from the payload.
|
|
StaticMain,
|
|
/// JUnit 4 (`@Test`) or JUnit 5 (`@Test` from `org.junit.jupiter.api`).
|
|
/// Harness instantiates the test class and invokes the annotated
|
|
/// method via reflection — no JUnit runner needed since we drive a
|
|
/// single test method.
|
|
JunitTest,
|
|
/// Quarkus reactive route: `@Path("/foo")` + `@GET`/`@POST` on a
|
|
/// method. Harness invokes the method via reflection like Spring.
|
|
QuarkusRoute,
|
|
/// Micronaut route: `@Controller("/api")` + `@Get`/`@Post`/`@Put`
|
|
/// /`@Delete` on a method. Harness invokes the method via
|
|
/// reflection like Spring / Quarkus (the brief specifies an
|
|
/// `EmbeddedServer.start` bootstrap, deferred behind the existing
|
|
/// synthetic-harness pattern in [`deferred.md`]).
|
|
MicronautRoute,
|
|
/// Plain static method — legacy default behaviour from before
|
|
/// Phase 14. Harness directly calls `{Class}.{method}(payload)`.
|
|
StaticMethod,
|
|
}
|
|
|
|
impl JavaShape {
|
|
/// Detect the shape from `(spec, source)`. `source` is the literal
|
|
/// bytes of the entry file (best-effort — if it could not be read,
|
|
/// pass an empty string and the function returns
|
|
/// [`Self::StaticMethod`]).
|
|
///
|
|
/// Framework / annotation detection wins over the [`EntryKind`]
|
|
/// axis: when the source clearly imports a servlet or Spring
|
|
/// controller the shape is selected even if the spec derivation
|
|
/// pipeline tagged the entry kind as [`EntryKind::Function`].
|
|
pub fn detect(spec: &HarnessSpec, source: &str) -> Self {
|
|
let entry = spec.entry_name.as_str();
|
|
let kind = spec.entry_kind.tag();
|
|
|
|
let has_servlet = source.contains("HttpServlet")
|
|
|| source.contains("javax.servlet")
|
|
|| source.contains("jakarta.servlet");
|
|
let has_spring_controller = source.contains("@RestController")
|
|
|| source.contains("@Controller")
|
|
|| source.contains("@RequestMapping")
|
|
|| source.contains("@GetMapping")
|
|
|| source.contains("@PostMapping");
|
|
let has_quarkus = source.contains("@Path(")
|
|
|| source.contains("io.quarkus")
|
|
|| source.contains("jakarta.ws.rs");
|
|
let has_micronaut = source.contains("io.micronaut");
|
|
let has_junit = source.contains("@Test")
|
|
&& (source.contains("org.junit") || source.contains("junit.framework"));
|
|
let has_main = entry == "main" || source.contains("static void main(");
|
|
|
|
// Servlet beats Spring when both fire (e.g. a Spring app that
|
|
// mounts a raw servlet) — the doGet/doPost signature is more
|
|
// specific.
|
|
if has_servlet {
|
|
if entry == "doPost" || source.contains("void doPost(") {
|
|
return Self::ServletDoPost;
|
|
}
|
|
if entry == "doGet" || source.contains("void doGet(") {
|
|
return Self::ServletDoGet;
|
|
}
|
|
return Self::ServletDoGet;
|
|
}
|
|
// Micronaut comes before Quarkus / Spring: Micronaut sources
|
|
// re-use `@Controller` (collides with Spring) and `@Path` is
|
|
// not part of the Micronaut surface (so the Quarkus check
|
|
// does not fire for typical Micronaut files). Picking
|
|
// Micronaut on a clear `io.micronaut` import is the safest
|
|
// disambiguation.
|
|
if has_micronaut {
|
|
return Self::MicronautRoute;
|
|
}
|
|
if has_quarkus {
|
|
return Self::QuarkusRoute;
|
|
}
|
|
if has_spring_controller {
|
|
return Self::SpringController;
|
|
}
|
|
if has_main {
|
|
return Self::StaticMain;
|
|
}
|
|
if has_junit {
|
|
return Self::JunitTest;
|
|
}
|
|
|
|
if kind == EntryKindTag::CliSubcommand {
|
|
return Self::StaticMain;
|
|
}
|
|
if kind == EntryKindTag::HttpRoute {
|
|
return Self::SpringController;
|
|
}
|
|
Self::StaticMethod
|
|
}
|
|
}
|
|
|
|
// (Helper retired in Phase 14 — the shape detector now uses direct
|
|
// `source.contains` matches against the method-signature head because
|
|
// the JDK accepts whitespace / newline / modifier variation that no
|
|
// single template captures.)
|
|
|
|
// ── Probe shim (Phase 06 + Phase 08) ─────────────────────────────────────────
|
|
|
|
/// Source of the `__nyx_probe` shim for the Java harness (Phase 06 —
|
|
/// Track C.1).
|
|
///
|
|
/// Splices into the generated harness class as a `static void __nyx_probe(...)`
|
|
/// method. Hand-rolled JSON keeps the shim free of org.json / jackson
|
|
/// dependencies; matches the
|
|
/// [`crate::dynamic::probe::SinkProbe`] wire format.
|
|
pub fn probe_shim() -> &'static str {
|
|
r##"
|
|
// ── __nyx_probe shim (Phase 06 — Track C.1, Phase 08 — Track C.4 + C.5) ──
|
|
private static final String[] __NYX_DENY = {
|
|
"TOKEN","SECRET","PASSWORD","PASSWD","API_KEY","APIKEY","PRIVATE_KEY",
|
|
"CREDENTIAL","SESSION","COOKIE","AUTH","BEARER","AWS_ACCESS","AWS_SESSION",
|
|
"GH_TOKEN","GITHUB_TOKEN","NPM_TOKEN","PYPI_TOKEN","DOCKER_PASS"
|
|
};
|
|
private static final int __NYX_PAYLOAD_LIMIT = 16 * 1024;
|
|
private static final String __NYX_REDACTED = "<redacted-by-nyx-policy>";
|
|
|
|
private static boolean nyxIsDeniedKey(String k) {
|
|
String ku = k.toUpperCase();
|
|
for (String n : __NYX_DENY) {
|
|
if (ku.contains(n)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static String nyxWitnessJson(String sinkCallee, String[] args) {
|
|
StringBuilder out = new StringBuilder(256);
|
|
out.append("{\"env_snapshot\":{");
|
|
boolean first = true;
|
|
java.util.TreeMap<String,String> envSorted = new java.util.TreeMap<>(System.getenv());
|
|
for (java.util.Map.Entry<String,String> e : envSorted.entrySet()) {
|
|
if (!first) out.append(',');
|
|
first = false;
|
|
out.append('"'); nyxJsonEscape(e.getKey(), out); out.append("\":\"");
|
|
if (nyxIsDeniedKey(e.getKey())) {
|
|
out.append(__NYX_REDACTED);
|
|
} else {
|
|
nyxJsonEscape(e.getValue() == null ? "" : e.getValue(), out);
|
|
}
|
|
out.append('"');
|
|
}
|
|
out.append("},\"cwd\":\"");
|
|
nyxJsonEscape(System.getProperty("user.dir", ""), out);
|
|
out.append("\",\"payload_bytes\":[");
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload != null) {
|
|
byte[] pb = payload.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
|
int cap = Math.min(pb.length, __NYX_PAYLOAD_LIMIT);
|
|
for (int i = 0; i < cap; i++) {
|
|
if (i > 0) out.append(',');
|
|
out.append(((int) pb[i]) & 0xff);
|
|
}
|
|
}
|
|
out.append("],\"callee\":\""); nyxJsonEscape(sinkCallee, out);
|
|
out.append("\",\"args_repr\":[");
|
|
if (args != null) {
|
|
for (int i = 0; i < args.length; i++) {
|
|
if (i > 0) out.append(',');
|
|
out.append('"'); nyxJsonEscape(args[i] == null ? "" : args[i], out); out.append('"');
|
|
}
|
|
}
|
|
out.append("]}");
|
|
return out.toString();
|
|
}
|
|
|
|
private static void nyxEmit(String line) {
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
try (java.io.FileWriter fw = new java.io.FileWriter(p, true)) {
|
|
fw.write(line);
|
|
} catch (java.io.IOException e) {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
static void __nyx_probe(String sinkCallee, String... args) {
|
|
long now = System.nanoTime();
|
|
String payloadId = System.getenv("NYX_PAYLOAD_ID");
|
|
if (payloadId == null) payloadId = "";
|
|
StringBuilder line = new StringBuilder(256);
|
|
line.append("{\"sink_callee\":\"");
|
|
nyxJsonEscape(sinkCallee, line);
|
|
line.append("\",\"args\":[");
|
|
for (int i = 0; i < args.length; i++) {
|
|
if (i > 0) line.append(',');
|
|
line.append("{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(args[i] == null ? "" : args[i], line);
|
|
line.append("\"}");
|
|
}
|
|
line.append("],\"captured_at_ns\":").append(now).append(",\"payload_id\":\"");
|
|
nyxJsonEscape(payloadId, line);
|
|
line.append("\",\"kind\":{\"kind\":\"Normal\"},\"witness\":");
|
|
line.append(nyxWitnessJson(sinkCallee, args));
|
|
line.append("}\n");
|
|
nyxEmit(line.toString());
|
|
}
|
|
|
|
// Phase 08: install a sink-site Throwable handler. Java cannot catch
|
|
// SIGSEGV / SIGFPE directly (JVM aborts), but it can intercept the
|
|
// uncaught-exception path which fires for any Error / RuntimeException
|
|
// escaping the sink call. Map them onto SIGABRT for the oracle.
|
|
static void __nyx_install_crash_guard(String sinkCallee) {
|
|
Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
|
|
long now = System.nanoTime();
|
|
String payloadId = System.getenv("NYX_PAYLOAD_ID");
|
|
if (payloadId == null) payloadId = "";
|
|
StringBuilder line = new StringBuilder(256);
|
|
line.append("{\"sink_callee\":\"");
|
|
nyxJsonEscape(sinkCallee, line);
|
|
line.append("\",\"args\":[],\"captured_at_ns\":").append(now)
|
|
.append(",\"payload_id\":\"");
|
|
nyxJsonEscape(payloadId, line);
|
|
line.append("\",\"kind\":{\"kind\":\"Crash\",\"signal\":\"SIGABRT\"},\"witness\":");
|
|
line.append(nyxWitnessJson(sinkCallee, new String[0]));
|
|
line.append("}\n");
|
|
nyxEmit(line.toString());
|
|
System.exit(134);
|
|
});
|
|
}
|
|
|
|
private static void nyxJsonEscape(String s, StringBuilder out) {
|
|
for (int i = 0; i < s.length(); i++) {
|
|
char c = s.charAt(i);
|
|
switch (c) {
|
|
case '"': out.append("\\\""); break;
|
|
case '\\': out.append("\\\\"); break;
|
|
case '\n': out.append("\\n"); break;
|
|
case '\r': out.append("\\r"); break;
|
|
case '\t': out.append("\\t"); break;
|
|
default:
|
|
if (c < 0x20) {
|
|
out.append(String.format("\\u%04x", (int) c));
|
|
} else {
|
|
out.append(c);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 10 (Track D.3) HTTP recording helper. When the verifier spawned an
|
|
// HttpStub it publishes the side-channel log path through NYX_HTTP_LOG; a
|
|
// sink call site whose outbound request never reaches the on-the-wire
|
|
// listener (DNS-mocked, network-isolated sandbox, pre-flight check) can
|
|
// call this helper to surface the attempted call. Format matches the
|
|
// Python / Node / PHP / Go / Ruby siblings so the host-side HttpStub
|
|
// log-line merger parses all six streams identically. No-op when
|
|
// NYX_HTTP_LOG is unset so the same harness still runs cleanly under
|
|
// modes that did not spawn a stub. The hash prefix is emitted via
|
|
// String.valueOf('#') so this method body contains no literal hash-after-
|
|
// double-quote sequence that would terminate the surrounding Rust raw
|
|
// string.
|
|
static void __nyx_stub_http_record(String method, String url, String body, java.util.Map<String,String> detail) {
|
|
String p = System.getenv("NYX_HTTP_LOG");
|
|
if (p == null || p.isEmpty()) return;
|
|
String hashSp = String.valueOf('#') + " ";
|
|
try (java.io.FileWriter fw = new java.io.FileWriter(p, true)) {
|
|
fw.write(hashSp + "method: " + method + "\n");
|
|
fw.write(hashSp + "url: " + url + "\n");
|
|
if (body != null) {
|
|
fw.write(hashSp + "body: " + body + "\n");
|
|
}
|
|
if (detail != null) {
|
|
for (java.util.Map.Entry<String,String> e : detail.entrySet()) {
|
|
fw.write(hashSp + e.getKey() + ": " + e.getValue() + "\n");
|
|
}
|
|
}
|
|
fw.write(method + " " + url + "\n");
|
|
} catch (java.io.IOException e) {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
// Phase 10 (Track D.3) SQL recording helper. When the verifier spawned a
|
|
// SqlStub it publishes the side-channel log path through NYX_SQL_LOG; a
|
|
// sink call site whose query never reaches the on-the-wire SQLite engine
|
|
// (e.g. classpath lacks sqlite-jdbc, or the harness pre-flights the SQL
|
|
// string before opening the connection) can call this helper to surface
|
|
// the attempted query. Hash-prefixed detail lines followed by the query
|
|
// line so SqlStub::drain_events parses every language stream identically.
|
|
// Same hash-via-String.valueOf trick as __nyx_stub_http_record so this
|
|
// method body contains no literal `"#` sequence that would terminate the
|
|
// surrounding Rust raw string.
|
|
static void __nyx_stub_sql_record(String query, java.util.Map<String,String> detail) {
|
|
String p = System.getenv("NYX_SQL_LOG");
|
|
if (p == null || p.isEmpty()) return;
|
|
String hashSp = String.valueOf('#') + " ";
|
|
try (java.io.FileWriter fw = new java.io.FileWriter(p, true)) {
|
|
if (detail != null) {
|
|
for (java.util.Map.Entry<String,String> e : detail.entrySet()) {
|
|
fw.write(hashSp + e.getKey() + ": " + e.getValue() + "\n");
|
|
}
|
|
}
|
|
fw.write(query);
|
|
if (!query.endsWith("\n")) {
|
|
fw.write("\n");
|
|
}
|
|
} catch (java.io.IOException e) {
|
|
// best-effort
|
|
}
|
|
}
|
|
"##
|
|
}
|
|
|
|
// ── Runtime / pom.xml synthesis (Phase 09) ──────────────────────────────────
|
|
|
|
/// Phase 09 — Track D.2: synthesise a minimal `pom.xml` that pins the
|
|
/// Java toolchain and lists the direct dep top-level packages as
|
|
/// dependencies. Each direct dep maps to `<groupId>{pkg}</groupId>`
|
|
/// with an artifact id matching the package name; this is a best-effort
|
|
/// stub and Phase 10 corpus expansion will introduce a known-good
|
|
/// group→artifact registry.
|
|
pub fn materialize_java(env: &Environment) -> RuntimeArtifacts {
|
|
let mut artifacts = RuntimeArtifacts::new();
|
|
let java_version = env
|
|
.toolchain
|
|
.version_string
|
|
.split('.')
|
|
.next()
|
|
.unwrap_or("21")
|
|
.to_owned();
|
|
let mut deps: Vec<String> = Vec::new();
|
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
for d in &env.direct_deps {
|
|
if is_java_stdlib(d) {
|
|
continue;
|
|
}
|
|
if seen.insert(d.clone()) {
|
|
deps.push(d.clone());
|
|
}
|
|
}
|
|
deps.sort_unstable();
|
|
|
|
let mut body = String::with_capacity(256);
|
|
body.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
|
body.push_str("<project xmlns=\"http://maven.apache.org/POM/4.0.0\">\n");
|
|
body.push_str(" <modelVersion>4.0.0</modelVersion>\n");
|
|
body.push_str(" <groupId>nyx</groupId>\n");
|
|
body.push_str(" <artifactId>harness</artifactId>\n");
|
|
body.push_str(" <version>0.0.1</version>\n");
|
|
body.push_str(" <properties>\n");
|
|
body.push_str(&format!(
|
|
" <maven.compiler.source>{java_version}</maven.compiler.source>\n"
|
|
));
|
|
body.push_str(&format!(
|
|
" <maven.compiler.target>{java_version}</maven.compiler.target>\n"
|
|
));
|
|
body.push_str(" </properties>\n");
|
|
if !deps.is_empty() {
|
|
body.push_str(" <dependencies>\n");
|
|
for d in &deps {
|
|
body.push_str(" <dependency>\n");
|
|
body.push_str(&format!(" <groupId>{d}</groupId>\n"));
|
|
body.push_str(&format!(" <artifactId>{d}</artifactId>\n"));
|
|
body.push_str(" <version>LATEST</version>\n");
|
|
body.push_str(" </dependency>\n");
|
|
}
|
|
body.push_str(" </dependencies>\n");
|
|
}
|
|
body.push_str("</project>\n");
|
|
artifacts.push("pom.xml", body);
|
|
artifacts
|
|
}
|
|
|
|
fn is_java_stdlib(name: &str) -> bool {
|
|
// Best-effort: only `java` / `javax` / `sun` are guaranteed JDK.
|
|
// `jakarta` ships separately under Jakarta EE so it stays out.
|
|
// Top-level segments `com` / `org` cover both JDK (`com.sun`) and
|
|
// third-party (`com.google`, `org.springframework`) — the import
|
|
// extractor only keeps the first segment, so a richer registry has
|
|
// to land before we can pin a meaningful Maven artifact from these.
|
|
// Phase 10 corpus expansion ships that registry.
|
|
matches!(name, "java" | "javax" | "sun" | "com" | "org" | "jakarta")
|
|
}
|
|
|
|
// ── Public entry: emit() ────────────────────────────────────────────────────
|
|
|
|
/// Emit a Java harness for `spec`.
|
|
///
|
|
/// Reads `spec.entry_file` from disk (best-effort), resolves the
|
|
/// concrete [`JavaShape`] via [`JavaShape::detect`], and dispatches to
|
|
/// the matching per-shape emitter. When the file cannot be read the
|
|
/// dispatcher falls back to [`JavaShape::StaticMethod`], preserving the
|
|
/// pre-Phase-14 behaviour.
|
|
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|
match &spec.payload_slot {
|
|
PayloadSlot::Param(_)
|
|
| PayloadSlot::EnvVar(_)
|
|
| PayloadSlot::QueryParam(_)
|
|
| PayloadSlot::HttpBody
|
|
| PayloadSlot::Argv(_) => {}
|
|
PayloadSlot::Stdin => return Err(UnsupportedReason::PayloadSlotUnsupported),
|
|
}
|
|
|
|
if spec.expected_cap == crate::labels::Cap::DESERIALIZE {
|
|
return Ok(emit_deserialize_harness(spec));
|
|
}
|
|
if spec.expected_cap == crate::labels::Cap::SSTI {
|
|
return Ok(emit_ssti_harness(spec));
|
|
}
|
|
if spec.expected_cap == crate::labels::Cap::XXE {
|
|
return Ok(emit_xxe_harness(spec));
|
|
}
|
|
if spec.expected_cap == crate::labels::Cap::LDAP_INJECTION {
|
|
return Ok(emit_ldap_harness(spec));
|
|
}
|
|
if spec.expected_cap == crate::labels::Cap::XPATH_INJECTION {
|
|
return Ok(emit_xpath_harness(spec));
|
|
}
|
|
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
|
return Ok(emit_header_injection_harness(spec));
|
|
}
|
|
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
|
|
return Ok(emit_open_redirect_harness(spec));
|
|
}
|
|
|
|
// Phase 11 (Track J.9): CRYPTO weak-RNG short-circuit. The Java
|
|
// harness reflectively loads the fixture class, invokes its
|
|
// declared method with the payload, and reduces the produced key
|
|
// into a `ProbeKind::WeakKey { key_int }` record (byte[] →
|
|
// `ByteBuffer.wrap(zero-padded[8]).order(BIG_ENDIAN).getLong()`;
|
|
// `Number` subclasses → `longValue()`). A weak
|
|
// `java.util.Random.nextBytes(new byte[2])` reduces to a sub-2^16
|
|
// key_int; a `SecureRandom.nextBytes(new byte[32])` head-8 byte
|
|
// view overshoots the 16-bit budget.
|
|
if spec.expected_cap == crate::labels::Cap::CRYPTO {
|
|
return Ok(emit_crypto_harness(spec));
|
|
}
|
|
|
|
// Phase 11 (Track J.9): JSON_PARSE depth-bomb short-circuit. The
|
|
// Java harness reflectively loads the fixture class, invokes its
|
|
// declared method with the payload, walks the returned tree
|
|
// iteratively via `NyxJsonProbe.countDepth`, and emits a
|
|
// [`crate::dynamic::probe::ProbeKind::JsonParse`] probe. The
|
|
// hand-rolled `NyxJsonProbe` helper is shipped as a sibling
|
|
// `.java` file so the build path never reaches for Jackson /
|
|
// Gson.
|
|
if spec.expected_cap == crate::labels::Cap::JSON_PARSE {
|
|
return Ok(emit_json_parse_harness(spec));
|
|
}
|
|
|
|
// Phase 11 (Track J.9): UNAUTHORIZED_ID IDOR boundary harness.
|
|
// Reflectively loads the fixture entry class, invokes the named
|
|
// static method with the payload as `owner_id`, and emits a
|
|
// `ProbeKind::IdorAccess { caller_id, owner_id }` probe only when
|
|
// the fixture returns a non-`null` record. The benign fixture's
|
|
// `if (!CALLER.equals(ownerId)) return null;` rejection clears the
|
|
// probe; the vuln fixture's unguarded `STORE.get(ownerId)` always
|
|
// materialises a record so the probe fires for every cross-tenant
|
|
// payload.
|
|
if spec.expected_cap == crate::labels::Cap::UNAUTHORIZED_ID {
|
|
return Ok(emit_unauthorized_id_harness(spec));
|
|
}
|
|
|
|
// Phase 11 (Track J.9): DATA_EXFIL outbound-network harness. Java
|
|
// has no stdlib monkey-patch hook, so the harness ships a sibling
|
|
// `NyxMockHttp.java` helper the fixture calls into in place of
|
|
// `HttpURLConnection.openConnection().connect()`. `NyxMockHttp.get`
|
|
// captures the destination host into a shared list without
|
|
// initiating real wire I/O; the harness then drains the list and
|
|
// emits a `ProbeKind::OutboundNetwork { host }` probe per call.
|
|
if spec.expected_cap == crate::labels::Cap::DATA_EXFIL {
|
|
return Ok(emit_data_exfil_harness(spec));
|
|
}
|
|
|
|
// Phase 19 (Track M.1): ClassMethod short-circuit. Routes through
|
|
// the existing `invokeReflective` helper so the harness instantiates
|
|
// the receiver via its no-arg constructor (or null-fills primitive
|
|
// / null-safe-object formals) before dispatching `method(payload)`.
|
|
if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind {
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
let entry_class = derive_entry_class(&entry_source);
|
|
return Ok(emit_class_method_harness(spec, class, method, &entry_class));
|
|
}
|
|
|
|
// Phase 20 (Track M.2): MessageHandler short-circuit. Mounts the
|
|
// in-process broker loopback declared by `broker_{kafka,sqs,rabbit}`
|
|
// and dispatches the payload synchronously to the named handler.
|
|
if let crate::evidence::EntryKind::MessageHandler { queue, .. } = &spec.entry_kind {
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
let entry_class = derive_entry_class(&entry_source);
|
|
return Ok(emit_message_handler_harness(spec, queue, &entry_class));
|
|
}
|
|
|
|
// Phase 21 (Track M.3): ScheduledJob short-circuit (Quartz).
|
|
if let crate::evidence::EntryKind::ScheduledJob { schedule } = &spec.entry_kind {
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
let entry_class = derive_entry_class(&entry_source);
|
|
return Ok(emit_scheduled_job_harness(
|
|
spec,
|
|
schedule.as_deref(),
|
|
&entry_class,
|
|
));
|
|
}
|
|
|
|
// Phase 21 (Track M.3): Middleware short-circuit (Spring HandlerInterceptor / Filter).
|
|
if let crate::evidence::EntryKind::Middleware { name } = &spec.entry_kind {
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
let entry_class = derive_entry_class(&entry_source);
|
|
return Ok(emit_middleware_harness(spec, name, &entry_class));
|
|
}
|
|
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
let shape = JavaShape::detect(spec, &entry_source);
|
|
let entry_class = derive_entry_class(&entry_source);
|
|
let entry_qualifier = derive_entry_qualifier(&entry_source, &entry_class);
|
|
let source = generate_harness_java(spec, shape, &entry_qualifier);
|
|
let mut extra_files = match shape {
|
|
// Real-world servlet sources import `javax.servlet.*` or
|
|
// `jakarta.servlet.*`; without those symbols on the classpath
|
|
// `javac` reports `package javax.servlet does not exist` and the
|
|
// verifier flips to `BuildFailed`. Stage minimal stubs alongside
|
|
// the harness so the build step links.
|
|
JavaShape::ServletDoGet | JavaShape::ServletDoPost => {
|
|
crate::dynamic::lang::java_servlet_stubs::servlet_stub_files()
|
|
}
|
|
_ => vec![],
|
|
};
|
|
// OWASP Benchmark v1.2 fixtures and other Spring-flavoured Java
|
|
// entry sources reach for `org.owasp.benchmark.helpers.*`,
|
|
// `org.owasp.esapi.*`, and a small Spring surface (RowMapper,
|
|
// SqlRowSet, DataAccessException, HtmlUtils). Stage the matching
|
|
// stub bundle when the entry source signals one of those imports;
|
|
// non-OWASP harnesses pay zero workdir cost.
|
|
if crate::dynamic::lang::java_owasp_stubs::entry_needs_owasp_stubs(&entry_source) {
|
|
extra_files.extend(crate::dynamic::lang::java_owasp_stubs::owasp_stub_files());
|
|
}
|
|
|
|
Ok(HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files,
|
|
// Stage the entry file under the public-class-derived filename
|
|
// so javac's filename-vs-public-class invariant holds for both
|
|
// the legacy `public class Entry` fixtures (which keep being
|
|
// copied to `workdir/Entry.java`) and the Phase 14 shape
|
|
// fixtures (where `public class Vuln` lives in `Vuln.java`).
|
|
entry_subpath: Some(format!("{entry_class}.java")),
|
|
})
|
|
}
|
|
|
|
/// Phase 03 — Track J.1 deserialize harness for Java.
|
|
///
|
|
/// Forges a minimal valid Java serialization stream for the marker
|
|
/// class name carried by `NYX_PAYLOAD`, then runs it through a
|
|
/// `RestrictedObjectInputStream` subclass whose `resolveClass` override
|
|
/// enforces a static allowlist (`java.lang.Integer`, `java.lang.String`).
|
|
/// When `resolveClass` sees a non-allowlisted class it writes a
|
|
/// [`crate::dynamic::probe::ProbeKind::Deserialize`] probe with
|
|
/// `gadget_chain_invoked: true` and throws `InvalidClassException` to
|
|
/// abort — matching the JEP-290 / Look-Ahead-OIS hardening pattern
|
|
/// real applications use. The blob is built from raw stream bytes
|
|
/// (TC_OBJECT → TC_CLASSDESC → class name → SUID → flags → no
|
|
/// fields → TC_ENDBLOCKDATA → TC_NULL super) so the resolveClass
|
|
/// boundary fires for both vuln and benign payloads; downstream
|
|
/// instantiation failures (e.g. `serialVersionUID` mismatch on the
|
|
/// allow-listed payload) are caught and treated as non-probe paths.
|
|
pub fn emit_deserialize_harness(_spec: &HarnessSpec) -> HarnessSource {
|
|
let shim = probe_shim();
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — deserialize (Phase 03 / Track J.1).
|
|
import java.io.ByteArrayInputStream;
|
|
import java.io.ByteArrayOutputStream;
|
|
import java.io.DataOutputStream;
|
|
import java.io.FileWriter;
|
|
import java.io.IOException;
|
|
import java.io.InputStream;
|
|
import java.io.InvalidClassException;
|
|
import java.io.ObjectInputStream;
|
|
import java.io.ObjectStreamClass;
|
|
import java.util.Arrays;
|
|
import java.util.HashSet;
|
|
import java.util.Set;
|
|
|
|
public class NyxHarness {{
|
|
{shim}
|
|
|
|
static final Set<String> NYX_ALLOWLIST =
|
|
new HashSet<>(Arrays.asList("java.lang.Integer", "java.lang.String"));
|
|
|
|
static void nyxDeserializeProbe(boolean invoked) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(256);
|
|
line.append("{{\"sink_callee\":\"ObjectInputStream.resolveClass\",\"args\":[],");
|
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"Deserialize\",\"gadget_chain_invoked\":").append(invoked ? "true" : "false").append("}},");
|
|
line.append("\"witness\":");
|
|
line.append(nyxWitnessJson("ObjectInputStream.resolveClass", new String[0]));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
static class NyxRestrictedOIS extends ObjectInputStream {{
|
|
NyxRestrictedOIS(InputStream in) throws IOException {{ super(in); }}
|
|
@Override
|
|
protected Class<?> resolveClass(ObjectStreamClass desc)
|
|
throws IOException, ClassNotFoundException {{
|
|
String name = desc.getName();
|
|
if (!NYX_ALLOWLIST.contains(name)) {{
|
|
nyxDeserializeProbe(true);
|
|
throw new InvalidClassException(
|
|
"Nyx restricted-OIS blocked " + name);
|
|
}}
|
|
return super.resolveClass(desc);
|
|
}}
|
|
}}
|
|
|
|
static byte[] nyxForgeClassDescriptor(String className) throws IOException {{
|
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
|
DataOutputStream dos = new DataOutputStream(baos);
|
|
dos.writeShort((short) 0xACED); // STREAM_MAGIC
|
|
dos.writeShort((short) 0x0005); // STREAM_VERSION
|
|
dos.writeByte(0x73); // TC_OBJECT
|
|
dos.writeByte(0x72); // TC_CLASSDESC
|
|
dos.writeUTF(className);
|
|
dos.writeLong(0L); // serialVersionUID
|
|
dos.writeByte(0x02); // SC_SERIALIZABLE
|
|
dos.writeShort(0); // 0 fields
|
|
dos.writeByte(0x78); // TC_ENDBLOCKDATA
|
|
dos.writeByte(0x70); // TC_NULL (no super class)
|
|
return baos.toByteArray();
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload == null) payload = "";
|
|
String prefix = "NYX_GADGET_CLASS:";
|
|
if (payload.startsWith(prefix)) {{
|
|
String cls = payload.substring(prefix.length());
|
|
try {{
|
|
byte[] blob = nyxForgeClassDescriptor(cls);
|
|
NyxRestrictedOIS ois = new NyxRestrictedOIS(
|
|
new ByteArrayInputStream(blob));
|
|
try {{
|
|
ois.readObject();
|
|
}} finally {{
|
|
try {{ ois.close(); }} catch (IOException ignored) {{}}
|
|
}}
|
|
}} catch (InvalidClassException e) {{
|
|
// Restricted block — probe already written above.
|
|
}} catch (Throwable t) {{
|
|
// Allow-listed but downstream instantiation fails (the
|
|
// minimal stream omits the field bytes the real class
|
|
// expects). resolveClass already fired; treat as a
|
|
// non-probe path.
|
|
}}
|
|
}}
|
|
// Sink-reachability sentinel — runner's `vuln_fired && sink_hit`
|
|
// gate consumes this; without it differential confirmation cannot
|
|
// fire even when the probe was written.
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
}}
|
|
}}
|
|
"#
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files: Vec::new(),
|
|
entry_subpath: None,
|
|
}
|
|
}
|
|
|
|
/// Phase 04 — Track J.2 SSTI harness for Java (Thymeleaf).
|
|
///
|
|
/// Reads `NYX_PAYLOAD`, simulates Thymeleaf's `[[${expr}]]` inlined-
|
|
/// output evaluation, and writes `{"render":"<result>"}` plus the
|
|
/// sink-hit sentinel. Synthetic renderer keeps the corpus
|
|
/// deterministic without bundling Thymeleaf jars in the sandbox.
|
|
pub fn emit_ssti_harness(_spec: &HarnessSpec) -> HarnessSource {
|
|
let shim = probe_shim();
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — SSTI Thymeleaf (Phase 04 / Track J.2).
|
|
//
|
|
// Routes `NYX_PAYLOAD` through the real `org.thymeleaf.TemplateEngine`
|
|
// dependency. The corpus vuln payload `[[${{7*7}}]]` reaches
|
|
// Thymeleaf's SpEL evaluator and renders as `49`; the benign
|
|
// control `7*7` has no `[[${{ ... }}]]` markers so the engine echoes
|
|
// it verbatim.
|
|
//
|
|
// 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;
|
|
import org.thymeleaf.context.Context;
|
|
|
|
public class NyxHarness {{
|
|
{shim}
|
|
|
|
static String nyxThymeleafRender(String payload) {{
|
|
try {{
|
|
TemplateEngine engine = new TemplateEngine();
|
|
Context ctx = new Context();
|
|
return engine.process(payload, ctx);
|
|
}} catch (RuntimeException e) {{
|
|
return "<thymeleaf-error:" + e.getClass().getSimpleName() + ">";
|
|
}}
|
|
}}
|
|
|
|
static void nyxSstiProbe(String rendered) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(256);
|
|
line.append("{{\"sink_callee\":\"TemplateEngine.process\",\"args\":[{{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(rendered, line);
|
|
line.append("\"}}],");
|
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"Normal\"}},");
|
|
line.append("\"witness\":");
|
|
line.append(nyxWitnessJson("TemplateEngine.process", new String[]{{rendered}}));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload == null) payload = "";
|
|
String rendered = nyxThymeleafRender(payload);
|
|
nyxSstiProbe(rendered);
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
StringBuilder body = new StringBuilder(64);
|
|
body.append("{{\"render\":\"");
|
|
nyxJsonEscape(rendered, body);
|
|
body.append("\"}}");
|
|
System.out.println(body.toString());
|
|
}}
|
|
}}
|
|
"#
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".:lib/*".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
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#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
<modelVersion>4.0.0</modelVersion>
|
|
<groupId>com.nyx</groupId>
|
|
<artifactId>nyx-harness-thymeleaf</artifactId>
|
|
<version>0.0.1</version>
|
|
<packaging>jar</packaging>
|
|
<properties>
|
|
<maven.compiler.source>11</maven.compiler.source>
|
|
<maven.compiler.target>11</maven.compiler.target>
|
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
</properties>
|
|
<dependencies>
|
|
<dependency>
|
|
<groupId>org.thymeleaf</groupId>
|
|
<artifactId>thymeleaf</artifactId>
|
|
<version>3.1.2.RELEASE</version>
|
|
</dependency>
|
|
</dependencies>
|
|
</project>
|
|
"#
|
|
}
|
|
|
|
/// Phase 05 — Track J.3 XXE harness for Java (`DocumentBuilderFactory`).
|
|
///
|
|
/// Reads `NYX_PAYLOAD`, parses it with `javax.xml.parsers.DocumentBuilder`
|
|
/// (JDK stdlib) configured with a custom `EntityResolver` that records
|
|
/// every `resolveEntity` invocation. The resolver returns an empty
|
|
/// `InputSource` so the harness never actually fetches the SYSTEM
|
|
/// resource, but the resolution boundary fires at the real parser
|
|
/// hook the brief calls out. Writes a `ProbeKind::Xxe` probe whose
|
|
/// `entity_expanded` flag tracks whether the resolver fired.
|
|
pub fn emit_xxe_harness(_spec: &HarnessSpec) -> HarnessSource {
|
|
let shim = probe_shim();
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — XXE DocumentBuilderFactory (Phase 05 / Track J.3).
|
|
import java.io.FileWriter;
|
|
import java.io.IOException;
|
|
import java.io.StringReader;
|
|
import java.net.HttpURLConnection;
|
|
import java.net.URL;
|
|
import javax.xml.parsers.DocumentBuilder;
|
|
import javax.xml.parsers.DocumentBuilderFactory;
|
|
import org.xml.sax.EntityResolver;
|
|
import org.xml.sax.InputSource;
|
|
import org.xml.sax.SAXException;
|
|
|
|
public class NyxHarness {{
|
|
{shim}
|
|
|
|
static boolean nyxLastExpanded = false;
|
|
|
|
// Build the XML document fed into the parser. Two shapes (Phase 05
|
|
// OOB closure, 2026-05-21):
|
|
// - URL-form NYX_PAYLOAD (`http://...` / `https://...`): treat as
|
|
// the SYSTEM URL of an external entity and wrap into a canonical
|
|
// XXE DTD. The entity-resolver hook will perform the loopback
|
|
// GET so the OOB listener observes the per-finding nonce.
|
|
// - Anything else: treat as the full XML document (existing shape).
|
|
static String nyxBuildXxeDocument(String payload) {{
|
|
if (payload.startsWith("http://") || payload.startsWith("https://")) {{
|
|
String escaped = payload.replace("&", "&").replace("\"", """).replace("<", "<");
|
|
return "<?xml version=\"1.0\"?>\n<!DOCTYPE data [\n <!ENTITY xxe SYSTEM \"" + escaped + "\">\n]>\n<data>&xxe;</data>";
|
|
}}
|
|
return payload;
|
|
}}
|
|
|
|
static void nyxXmlParse(String payload) {{
|
|
nyxLastExpanded = false;
|
|
try {{
|
|
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
|
// Mirror the brief's "DocumentBuilderFactory with external
|
|
// entity resolution enabled" target: leave the factory at
|
|
// default settings (which historically permit doctype +
|
|
// external entities) and rely on the EntityResolver hook
|
|
// to control fetch behaviour.
|
|
DocumentBuilder db = dbf.newDocumentBuilder();
|
|
db.setEntityResolver(new EntityResolver() {{
|
|
public InputSource resolveEntity(String publicId, String systemId) {{
|
|
// Real parser hook: fired by the SAX/DOM parser for
|
|
// every `<!ENTITY x SYSTEM "...">` reference. Mark
|
|
// expanded. When the SYSTEM URL points at loopback
|
|
// HTTP, perform a real GET so the OOB listener can
|
|
// observe the callback (Phase 05 OOB closure). Any
|
|
// other scheme returns an empty replacement (no fetch).
|
|
nyxLastExpanded = true;
|
|
if (systemId != null && (systemId.startsWith("http://127.0.0.1")
|
|
|| systemId.startsWith("http://host-gateway")
|
|
|| systemId.startsWith("http://localhost"))) {{
|
|
try {{
|
|
HttpURLConnection conn = (HttpURLConnection) new URL(systemId).openConnection();
|
|
conn.setConnectTimeout(2000);
|
|
conn.setReadTimeout(2000);
|
|
conn.getInputStream().close();
|
|
conn.disconnect();
|
|
}} catch (Exception ignored) {{
|
|
// best-effort OOB fetch
|
|
}}
|
|
}}
|
|
return new InputSource(new StringReader(""));
|
|
}}
|
|
}});
|
|
try {{
|
|
String doc = nyxBuildXxeDocument(payload);
|
|
db.parse(new InputSource(new StringReader(doc)));
|
|
}} catch (SAXException | IOException e) {{
|
|
// Malformed XML still counts as a parser invocation;
|
|
// expanded flag reflects whatever the hook saw before
|
|
// the error.
|
|
}}
|
|
}} catch (Exception e) {{
|
|
// builder construction failed — leave expanded=false
|
|
}}
|
|
}}
|
|
|
|
static void nyxXxeProbe(String payload, boolean expanded) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(256);
|
|
line.append("{{\"sink_callee\":\"DocumentBuilder.parse\",\"args\":[{{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(payload, line);
|
|
line.append("\"}}],");
|
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"Xxe\",\"entity_expanded\":").append(expanded ? "true" : "false").append("}},");
|
|
line.append("\"witness\":");
|
|
line.append(nyxWitnessJson("DocumentBuilder.parse", new String[]{{payload}}));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload == null) payload = "";
|
|
nyxXmlParse(payload);
|
|
nyxXxeProbe(payload, nyxLastExpanded);
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
StringBuilder body = new StringBuilder(64);
|
|
body.append("{{\"entity_expanded\":").append(nyxLastExpanded ? "true" : "false").append("}}");
|
|
System.out.println(body.toString());
|
|
}}
|
|
}}
|
|
"#
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files: Vec::new(),
|
|
entry_subpath: None,
|
|
}
|
|
}
|
|
|
|
/// Phase 06 — Track J.4 LDAP-injection harness for Java
|
|
/// (`LdapTemplate.search` / `DirContext.search`).
|
|
///
|
|
/// Reads `NYX_PAYLOAD`, splices it into a `(uid=<payload>)` filter
|
|
/// template, and dispatches the resulting filter against the
|
|
/// in-sandbox LDAP stub via `javax.naming.directory.InitialDirContext`
|
|
/// over the real LDAPv3 BER wire (the stub's accept loop at
|
|
/// [`crate::dynamic::stubs::ldap_server::accept_loop`] auto-detects
|
|
/// the `0x30 SEQUENCE` lead byte and routes through the BER
|
|
/// reader/writer at [`crate::dynamic::stubs::ldap_ber`]). Falls back
|
|
/// to an in-process RFC 4515 subset matcher against three canonical
|
|
/// users (`alice`, `bob`, `carol`) when the env var is unset or JNDI
|
|
/// bind/search fails, so the harness still produces a verdict on
|
|
/// hosts that exercise it outside the stub-backed corpus. Writes a
|
|
/// `ProbeKind::Ldap { entries_returned }` probe whose `n` is the
|
|
/// count the directory returned. The JNDI provider ships with the
|
|
/// JDK (`com.sun.jndi.ldap.LdapCtxFactory`) so no extra classpath dep
|
|
/// is required.
|
|
pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource {
|
|
let shim = probe_shim();
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — LDAP_INJECTION DirContext.search (Phase 06 / Track J.4).
|
|
import java.io.FileWriter;
|
|
import java.io.IOException;
|
|
import java.util.ArrayList;
|
|
import java.util.Hashtable;
|
|
import java.util.List;
|
|
|
|
import javax.naming.Context;
|
|
import javax.naming.NamingEnumeration;
|
|
import javax.naming.NamingException;
|
|
import javax.naming.directory.DirContext;
|
|
import javax.naming.directory.InitialDirContext;
|
|
import javax.naming.directory.SearchControls;
|
|
import javax.naming.directory.SearchResult;
|
|
|
|
public class NyxHarness {{
|
|
{shim}
|
|
|
|
static final String[] NYX_LDAP_USERS = new String[] {{ "alice", "bob", "carol" }};
|
|
|
|
static boolean nyxAttrMatch(String pattern, String uid) {{
|
|
if (pattern.equals("*")) return true;
|
|
int star = pattern.indexOf('*');
|
|
if (star < 0) return pattern.equals(uid);
|
|
String prefix = pattern.substring(0, star);
|
|
String suffix = pattern.substring(star + 1);
|
|
return uid.startsWith(prefix) && uid.endsWith(suffix);
|
|
}}
|
|
|
|
static boolean nyxInnerHasBreak(String inner) {{
|
|
int depth = 0;
|
|
for (int i = 0; i < inner.length(); i++) {{
|
|
char c = inner.charAt(i);
|
|
if (c == '(') depth++;
|
|
else if (c == ')') {{
|
|
depth--;
|
|
if (depth < 0) return true;
|
|
}}
|
|
}}
|
|
return false;
|
|
}}
|
|
|
|
/// When `NYX_LDAP_ENDPOINT` is set to `host:port`, route the search
|
|
/// through the in-sandbox LDAP stub via
|
|
/// `javax.naming.directory.InitialDirContext` over the real LDAPv3
|
|
/// BER wire and return the count of returned entries. Returns
|
|
/// `-1` when the env var is unset or JNDI fails to bind/search —
|
|
/// caller falls back to the in-process matcher.
|
|
static int nyxLdapCountViaJndi(String filter) {{
|
|
String ep = System.getenv("NYX_LDAP_ENDPOINT");
|
|
if (ep == null || ep.isEmpty()) return -1;
|
|
Hashtable<String, String> env = new Hashtable<>();
|
|
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
|
|
env.put(Context.PROVIDER_URL, "ldap://" + ep + "/");
|
|
env.put(Context.SECURITY_AUTHENTICATION, "none");
|
|
env.put("com.sun.jndi.ldap.connect.timeout", "2000");
|
|
env.put("com.sun.jndi.ldap.read.timeout", "2000");
|
|
DirContext ctx = null;
|
|
try {{
|
|
ctx = new InitialDirContext(env);
|
|
SearchControls controls = new SearchControls();
|
|
controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
|
|
controls.setReturningAttributes(new String[0]);
|
|
controls.setTimeLimit(2000);
|
|
NamingEnumeration<SearchResult> results = ctx.search("", filter, controls);
|
|
int count = 0;
|
|
try {{
|
|
while (results.hasMore()) {{
|
|
results.next();
|
|
count++;
|
|
}}
|
|
}} finally {{
|
|
try {{ results.close(); }} catch (NamingException ne) {{ /* best-effort */ }}
|
|
}}
|
|
return count;
|
|
}} catch (NamingException ne) {{
|
|
return -1;
|
|
}} finally {{
|
|
if (ctx != null) {{
|
|
try {{ ctx.close(); }} catch (NamingException ne) {{ /* best-effort */ }}
|
|
}}
|
|
}}
|
|
}}
|
|
|
|
static int nyxLdapCount(String filter) {{
|
|
int viaStub = nyxLdapCountViaJndi(filter);
|
|
if (viaStub >= 0) return viaStub;
|
|
return nyxLdapCountLocal(filter);
|
|
}}
|
|
|
|
static int nyxLdapCountLocal(String filter) {{
|
|
String f = filter == null ? "" : filter.trim();
|
|
if (f.isEmpty()) return 0;
|
|
if (!f.startsWith("(") || !f.endsWith(")")) return NYX_LDAP_USERS.length;
|
|
String inner = f.substring(1, f.length() - 1);
|
|
if (nyxInnerHasBreak(inner)) return NYX_LDAP_USERS.length;
|
|
if (inner.startsWith("&") || inner.startsWith("|")) {{
|
|
List<String> clauses = nyxSplitClauses(inner.substring(1));
|
|
int total = 0;
|
|
for (String u : NYX_LDAP_USERS) {{
|
|
boolean ok = inner.startsWith("&");
|
|
for (String c : clauses) {{
|
|
boolean m = nyxLdapMatch(c, u);
|
|
ok = inner.startsWith("&") ? (ok && m) : (ok || m);
|
|
}}
|
|
if (clauses.isEmpty()) ok = false;
|
|
if (ok) total++;
|
|
}}
|
|
return total;
|
|
}}
|
|
int eq = inner.indexOf('=');
|
|
if (eq < 0) return NYX_LDAP_USERS.length;
|
|
String attr = inner.substring(0, eq);
|
|
String pattern = inner.substring(eq + 1);
|
|
if (!attr.equalsIgnoreCase("uid") && !attr.equalsIgnoreCase("cn")) return NYX_LDAP_USERS.length;
|
|
int total = 0;
|
|
for (String u : NYX_LDAP_USERS) {{
|
|
if (nyxAttrMatch(pattern, u)) total++;
|
|
}}
|
|
return total;
|
|
}}
|
|
|
|
static boolean nyxLdapMatch(String filter, String uid) {{
|
|
return nyxLdapCountLocal(filter) > 0
|
|
? nyxLdapMatchOne(filter, uid)
|
|
: false;
|
|
}}
|
|
|
|
static boolean nyxLdapMatchOne(String filter, String uid) {{
|
|
String f = filter.trim();
|
|
if (!f.startsWith("(") || !f.endsWith(")")) return true;
|
|
String inner = f.substring(1, f.length() - 1);
|
|
if (nyxInnerHasBreak(inner)) return true;
|
|
if (inner.startsWith("&") || inner.startsWith("|")) {{
|
|
List<String> clauses = nyxSplitClauses(inner.substring(1));
|
|
if (clauses.isEmpty()) return false;
|
|
boolean ok = inner.startsWith("&");
|
|
for (String c : clauses) {{
|
|
boolean m = nyxLdapMatchOne(c, uid);
|
|
ok = inner.startsWith("&") ? (ok && m) : (ok || m);
|
|
}}
|
|
return ok;
|
|
}}
|
|
int eq = inner.indexOf('=');
|
|
if (eq < 0) return true;
|
|
String attr = inner.substring(0, eq);
|
|
String pattern = inner.substring(eq + 1);
|
|
if (!attr.equalsIgnoreCase("uid") && !attr.equalsIgnoreCase("cn")) return true;
|
|
return nyxAttrMatch(pattern, uid);
|
|
}}
|
|
|
|
static List<String> nyxSplitClauses(String src) {{
|
|
List<String> out = new ArrayList<>();
|
|
int i = 0;
|
|
while (i < src.length()) {{
|
|
if (src.charAt(i) != '(') {{ i++; continue; }}
|
|
int depth = 0;
|
|
int start = i;
|
|
while (i < src.length()) {{
|
|
char c = src.charAt(i);
|
|
if (c == '(') depth++;
|
|
else if (c == ')') {{
|
|
depth--;
|
|
if (depth == 0) {{ i++; break; }}
|
|
}}
|
|
i++;
|
|
}}
|
|
out.add(src.substring(start, i));
|
|
}}
|
|
return out;
|
|
}}
|
|
|
|
static void nyxLdapProbe(String filter, int entriesReturned) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(256);
|
|
line.append("{{\"sink_callee\":\"LdapTemplate.search\",\"args\":[{{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(filter, line);
|
|
line.append("\"}}],");
|
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"Ldap\",\"entries_returned\":").append(entriesReturned).append("}},");
|
|
line.append("\"witness\":");
|
|
line.append(nyxWitnessJson("LdapTemplate.search", new String[]{{filter}}));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload == null) payload = "";
|
|
String filter = "(uid=" + payload + ")";
|
|
int count = nyxLdapCount(filter);
|
|
nyxLdapProbe(filter, count);
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
StringBuilder body = new StringBuilder(64);
|
|
body.append("{{\"filter\":\"");
|
|
nyxJsonEscape(filter, body);
|
|
body.append("\",\"entries_returned\":").append(count).append("}}");
|
|
System.out.println(body.toString());
|
|
}}
|
|
}}
|
|
"#
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files: Vec::new(),
|
|
entry_subpath: None,
|
|
}
|
|
}
|
|
|
|
/// Phase 07 — Track J.5 XPath-injection harness for Java
|
|
/// (`javax.xml.xpath.XPath.evaluate`).
|
|
///
|
|
/// Reads `NYX_PAYLOAD` and (tier (a)) reflectively invokes the entry
|
|
/// class's static `run(String)` method, which itself calls
|
|
/// `javax.xml.xpath.XPath.evaluate` against the canonical staged
|
|
/// document. The harness counts nodes by casting the returned
|
|
/// `NodeList` and writes a `ProbeKind::Xpath { nodes_returned }`
|
|
/// probe. When the entry source does not import
|
|
/// `javax.xml.xpath` (or reflective invocation fails for any reason)
|
|
/// the harness falls back to the legacy in-process matcher so the
|
|
/// verdict path stays intact on hosts that exercise the harness
|
|
/// outside the fixture corpus.
|
|
pub fn emit_xpath_harness(spec: &HarnessSpec) -> HarnessSource {
|
|
let shim = probe_shim();
|
|
let corpus_filename = crate::dynamic::stubs::xpath_document::XPATH_CORPUS_FILENAME;
|
|
let corpus_xml = crate::dynamic::stubs::xpath_document::XPATH_CORPUS_XML;
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
let entry_class = derive_entry_class(&entry_source);
|
|
let entry_fqn = derive_entry_qualifier(&entry_source, &entry_class);
|
|
let entry_method = if spec.entry_name.is_empty() {
|
|
"run".to_owned()
|
|
} else {
|
|
spec.entry_name.clone()
|
|
};
|
|
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — XPATH_INJECTION javax.xml.xpath.XPath.evaluate (Phase 07 / Track J.5).
|
|
import java.io.FileWriter;
|
|
import java.io.IOException;
|
|
import java.lang.reflect.InvocationTargetException;
|
|
import java.lang.reflect.Method;
|
|
import org.w3c.dom.NodeList;
|
|
|
|
public class NyxHarness {{
|
|
{shim}
|
|
|
|
static void nyxXpathProbe(String expr, int nodesReturned) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(256);
|
|
line.append("{{\"sink_callee\":\"javax.xml.xpath.XPath.evaluate\",\"args\":[{{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(expr, line);
|
|
line.append("\"}}],");
|
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"Xpath\",\"nodes_returned\":").append(nodesReturned).append("}},");
|
|
line.append("\"witness\":");
|
|
line.append(nyxWitnessJson("javax.xml.xpath.XPath.evaluate", new String[]{{expr}}));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload == null) payload = "";
|
|
String expr = "//user[@name='" + payload + "']";
|
|
// Phase 07 tier-(a): reflectively invoke the fixture's
|
|
// `run(String)` so the real `javax.xml.xpath.XPath.evaluate`
|
|
// call against the staged corpus document runs, then count
|
|
// the returned `NodeList` nodes. Missing `javax.xml.xpath`
|
|
// / `org.w3c.dom` on the JDK is the only structural reason
|
|
// the reflective lookup fails; in that case we emit the
|
|
// conventional `NYX_IMPORT_ERROR:` stderr marker plus
|
|
// `System.exit(77)` so the runner maps the outcome to
|
|
// `RunError::BuildFailed` and the e2e SKIP branch fires.
|
|
int count;
|
|
try {{
|
|
Class<?> entry = Class.forName("{entry_fqn}");
|
|
Method m = entry.getDeclaredMethod("{entry_method}", String.class);
|
|
m.setAccessible(true);
|
|
Object result = m.invoke(null, payload);
|
|
if (result instanceof NodeList) {{
|
|
count = ((NodeList) result).getLength();
|
|
}} else {{
|
|
count = 0;
|
|
}}
|
|
}} catch (ClassNotFoundException | NoSuchMethodException
|
|
| IllegalAccessException e) {{
|
|
System.err.println("NYX_IMPORT_ERROR: " + e.getClass().getName() + ": " + e.getMessage());
|
|
System.exit(77);
|
|
return;
|
|
}} catch (InvocationTargetException ite) {{
|
|
// The fixture itself threw (malformed XPath, parse error,
|
|
// ...); treat as a 0-node return so a benign fixture that
|
|
// rejects the payload stays NotConfirmed.
|
|
count = 0;
|
|
}}
|
|
System.out.println("__NYX_XPATH_TIER_A__");
|
|
nyxXpathProbe(expr, count);
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
StringBuilder body = new StringBuilder(64);
|
|
body.append("{{\"expr\":\"");
|
|
nyxJsonEscape(expr, body);
|
|
body.append("\",\"nodes_returned\":").append(count).append("}}");
|
|
System.out.println(body.toString());
|
|
}}
|
|
}}
|
|
"#
|
|
);
|
|
let extra_files = vec![(corpus_filename.to_owned(), corpus_xml.to_owned())];
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files,
|
|
entry_subpath: None,
|
|
}
|
|
}
|
|
|
|
/// Phase 08 — Track J.6 header-injection harness for Java
|
|
/// (`HttpServletResponse.setHeader`).
|
|
///
|
|
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
|
/// `response.setHeader("Set-Cookie", value)` shim that records the
|
|
/// *unmodified* value bytes (including any embedded `\r\n`) via a
|
|
/// `ProbeKind::HeaderEmit` probe. Mirrors the synthetic-harness
|
|
/// pattern used by Phase 03 / 04 / 05 / 06 / 07.
|
|
pub fn emit_header_injection_harness(spec: &HarnessSpec) -> HarnessSource {
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
if entry_source_uses_raw_socket(&entry_source) {
|
|
return emit_header_injection_wire_frame_harness(spec, &entry_source);
|
|
}
|
|
let shim = probe_shim();
|
|
let extra_files = servlet_stubs_for_entry(&spec.entry_file);
|
|
let servlet_pkg = if entry_source.contains("jakarta.servlet") {
|
|
"jakarta.servlet.http"
|
|
} else {
|
|
"javax.servlet.http"
|
|
};
|
|
let entry_class = derive_entry_class(&entry_source);
|
|
let entry_fqn = derive_entry_qualifier(&entry_source, &entry_class);
|
|
let entry_method = if spec.entry_name.is_empty() {
|
|
"run".to_owned()
|
|
} else {
|
|
spec.entry_name.clone()
|
|
};
|
|
let has_servlet_stubs = !extra_files.is_empty();
|
|
let header_name = "Set-Cookie";
|
|
|
|
// Tier-(a) path drives the fixture's real `setHeader` call through
|
|
// the captured-header buffer on the servlet stub. When the entry
|
|
// file does not import a servlet API the stub is not shipped and
|
|
// we fall back to the legacy synthetic probe so the harness still
|
|
// produces a verdict on hosts that do not link the stub.
|
|
let main_body = if has_servlet_stubs {
|
|
format!(
|
|
r#" // Phase 08 tier-(a): instantiate the captured-header response
|
|
// wrapper, reflectively invoke the fixture's sink call, then
|
|
// drain every recorded (name, value) pair and emit one
|
|
// ProbeKind::HeaderEmit per pair so the oracle sees the bytes
|
|
// the fixture actually passed to setHeader/addHeader.
|
|
{servlet_pkg}.HttpServletResponse response = new {servlet_pkg}.HttpServletResponse();
|
|
boolean fixtureInvoked = false;
|
|
try {{
|
|
Class<?> entry = Class.forName("{entry_fqn}");
|
|
Method m = entry.getDeclaredMethod(
|
|
"{entry_method}",
|
|
{servlet_pkg}.HttpServletResponse.class,
|
|
String.class);
|
|
m.setAccessible(true);
|
|
m.invoke(null, response, payload);
|
|
fixtureInvoked = true;
|
|
}} catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException e) {{
|
|
// Fixture shape did not match (response, value) — fall
|
|
// through to the synthetic probe so the verdict path stays
|
|
// intact for legacy entry shapes.
|
|
}} catch (InvocationTargetException ite) {{
|
|
// The fixture itself threw; treat that as evidence the sink
|
|
// path was reached and continue to drain captured headers.
|
|
fixtureInvoked = true;
|
|
}}
|
|
java.util.List<String[]> captured =
|
|
{servlet_pkg}.HttpServletResponse.nyxDrainHeaders();
|
|
if (fixtureInvoked && !captured.isEmpty()) {{
|
|
for (String[] pair : captured) {{
|
|
nyxHeaderProbe(pair[0], pair[1]);
|
|
}}
|
|
}} else {{
|
|
// Fixture either rejected the invocation or set no
|
|
// headers — fall back to the synthetic probe so a benign
|
|
// fixture that strips CRLF still produces a verdict.
|
|
nyxHeaderProbe("{header_name}", payload);
|
|
}}"#
|
|
)
|
|
} else {
|
|
format!(
|
|
r#" // No servlet stub available — synthetic probe path.
|
|
nyxHeaderProbe("{header_name}", payload);"#
|
|
)
|
|
};
|
|
|
|
let imports = if has_servlet_stubs {
|
|
"import java.lang.reflect.InvocationTargetException;\nimport java.lang.reflect.Method;\n"
|
|
} else {
|
|
""
|
|
};
|
|
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — HEADER_INJECTION HttpServletResponse.setHeader (Phase 08 / Track J.6).
|
|
import java.io.FileWriter;
|
|
import java.io.IOException;
|
|
{imports}
|
|
public class NyxHarness {{
|
|
{shim}
|
|
|
|
static void nyxHeaderProbe(String name, String value) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(256);
|
|
line.append("{{\"sink_callee\":\"HttpServletResponse.setHeader\",\"args\":[");
|
|
line.append("{{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(name, line);
|
|
line.append("\"}},{{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(value, line);
|
|
line.append("\"}}],");
|
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"HeaderEmit\",\"name\":\"");
|
|
nyxJsonEscape(name, line);
|
|
line.append("\",\"value\":\"");
|
|
nyxJsonEscape(value, line);
|
|
line.append("\",\"protocol\":\"in-process\"}},");
|
|
line.append("\"witness\":");
|
|
line.append(nyxWitnessJson("HttpServletResponse.setHeader", new String[]{{name, value}}));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload == null) payload = "";
|
|
{main_body}
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
}}
|
|
}}
|
|
"#
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files,
|
|
entry_subpath: None,
|
|
}
|
|
}
|
|
|
|
/// Phase 08 tier-(b) gate: route to the wire-frame harness when the
|
|
/// entry file exposes the raw-socket fixture API (`createServer` +
|
|
/// `runOnce` + `setCookieValue`) driven by `java.net.ServerSocket`.
|
|
/// The triple-token check keeps the gate firing only on the curated
|
|
/// `java_raw` fixture shape and never on the canonical
|
|
/// `HttpServletResponse.setHeader` fixture above.
|
|
fn entry_source_uses_raw_socket(src: &str) -> bool {
|
|
src.contains("java.net.ServerSocket") && src.contains("setCookieValue")
|
|
}
|
|
|
|
/// Phase 08 — Track J.6 tier-(b) wire-frame harness for Java.
|
|
/// Drives the fixture's `createServer` / `runOnce` API on a worker
|
|
/// thread while the harness opens a client `java.net.Socket` against
|
|
/// the bound port, issues one `GET / HTTP/1.0`, and reads the bytes
|
|
/// the fixture wrote to the response socket up to the `\r\n\r\n`
|
|
/// boundary. The captured header block is emitted as a
|
|
/// `ProbeKind::HeaderWireFrame` probe; per-`Set-Cookie` lines are
|
|
/// also emitted as `ProbeKind::HeaderEmit` records so the tier-(a)
|
|
/// `HeaderInjected` predicate fires on the same pass. Prints a
|
|
/// `wire_frame_len` stdout marker so e2e tests can pin the branch.
|
|
///
|
|
/// Reflective dispatch via `Class.forName(entry_fqn)
|
|
/// .getDeclaredMethod("setCookieValue", byte[].class)` etc. mirrors
|
|
/// the Phase 06 LDAP Java tier-(b) pattern. Avoids any external
|
|
/// jar bundling — only `java.net.*` + `java.io.*` (JDK built-ins).
|
|
fn emit_header_injection_wire_frame_harness(
|
|
_spec: &HarnessSpec,
|
|
entry_source: &str,
|
|
) -> HarnessSource {
|
|
let shim = probe_shim();
|
|
let entry_class = derive_entry_class(entry_source);
|
|
let entry_fqn = derive_entry_qualifier(entry_source, &entry_class);
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — HEADER_INJECTION raw-socket wire frame (Phase 08 / Track J.6).
|
|
import java.io.ByteArrayOutputStream;
|
|
import java.io.FileWriter;
|
|
import java.io.IOException;
|
|
import java.io.InputStream;
|
|
import java.io.OutputStream;
|
|
import java.lang.reflect.InvocationTargetException;
|
|
import java.lang.reflect.Method;
|
|
import java.net.InetAddress;
|
|
import java.net.ServerSocket;
|
|
import java.net.Socket;
|
|
import java.nio.charset.StandardCharsets;
|
|
|
|
public class NyxHarness {{
|
|
{shim}
|
|
|
|
static void nyxWireFrameHeaderProbe(String name, String value) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(256);
|
|
line.append("{{\"sink_callee\":\"Socket.getOutputStream().write\",\"args\":[");
|
|
line.append("{{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(name, line);
|
|
line.append("\"}},{{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(value, line);
|
|
line.append("\"}}],");
|
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"HeaderEmit\",\"name\":\"");
|
|
nyxJsonEscape(name, line);
|
|
line.append("\",\"value\":\"");
|
|
nyxJsonEscape(value, line);
|
|
line.append("\",\"protocol\":\"wire\"}},");
|
|
line.append("\"witness\":");
|
|
line.append(nyxWitnessJson("Socket.getOutputStream().write", new String[]{{name, value}}));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
static void nyxWireFrameProbe(byte[] rawBytes) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(256 + rawBytes.length * 4);
|
|
line.append("{{\"sink_callee\":\"Socket.getOutputStream().write\",\"args\":[],");
|
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"HeaderWireFrame\",\"raw_bytes\":[");
|
|
for (int i = 0; i < rawBytes.length; i++) {{
|
|
if (i > 0) line.append(',');
|
|
line.append(((int) rawBytes[i]) & 0xff);
|
|
}}
|
|
line.append("]}},");
|
|
line.append("\"witness\":");
|
|
line.append(nyxWitnessJson("Socket.getOutputStream().write", new String[0]));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
// Phase 08 tier-(b): install the cookie value on the fixture,
|
|
// boot its `ServerSocket` on 127.0.0.1:0, drive `runOnce` on a
|
|
// worker thread, then issue one raw-socket GET from the harness
|
|
// and read the bytes the fixture wrote to the response socket up
|
|
// to the CRLF-CRLF boundary. Returns `null` on reflection / boot
|
|
// / read failure so the caller can fall back to the synthetic
|
|
// probe path and keep the differential oracle live.
|
|
static byte[] nyxWireFrameViaFixture(String payload) {{
|
|
Class<?> entry;
|
|
try {{
|
|
entry = Class.forName("{entry_fqn}");
|
|
}} catch (ClassNotFoundException e) {{
|
|
return null;
|
|
}}
|
|
byte[] payloadBytes = payload.getBytes(StandardCharsets.ISO_8859_1);
|
|
Method setCookie;
|
|
Method createServer;
|
|
Method runOnce;
|
|
try {{
|
|
setCookie = entry.getDeclaredMethod("setCookieValue", byte[].class);
|
|
setCookie.setAccessible(true);
|
|
createServer = entry.getDeclaredMethod("createServer");
|
|
createServer.setAccessible(true);
|
|
runOnce = entry.getDeclaredMethod("runOnce", ServerSocket.class);
|
|
runOnce.setAccessible(true);
|
|
}} catch (NoSuchMethodException e) {{
|
|
return null;
|
|
}}
|
|
try {{
|
|
setCookie.invoke(null, (Object) payloadBytes);
|
|
}} catch (IllegalAccessException | InvocationTargetException e) {{
|
|
return null;
|
|
}}
|
|
ServerSocket server;
|
|
try {{
|
|
Object srv = createServer.invoke(null);
|
|
if (!(srv instanceof ServerSocket)) {{
|
|
return null;
|
|
}}
|
|
server = (ServerSocket) srv;
|
|
}} catch (IllegalAccessException | InvocationTargetException e) {{
|
|
return null;
|
|
}}
|
|
final ServerSocket serverFinal = server;
|
|
final Method runOnceFinal = runOnce;
|
|
Thread worker = new Thread(() -> {{
|
|
try {{
|
|
runOnceFinal.invoke(null, serverFinal);
|
|
}} catch (IllegalAccessException | InvocationTargetException ignored) {{
|
|
// ignore fixture errors so the harness can still capture
|
|
// whatever bytes were already written before the throw.
|
|
}}
|
|
}}, "nyx-wire-frame-worker");
|
|
worker.setDaemon(true);
|
|
worker.start();
|
|
int port = server.getLocalPort();
|
|
ByteArrayOutputStream raw = new ByteArrayOutputStream(4096);
|
|
Socket client = null;
|
|
try {{
|
|
client = new Socket(InetAddress.getByName("127.0.0.1"), port);
|
|
client.setSoTimeout(2000);
|
|
OutputStream out = client.getOutputStream();
|
|
out.write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"
|
|
.getBytes(StandardCharsets.ISO_8859_1));
|
|
out.flush();
|
|
InputStream in = client.getInputStream();
|
|
byte[] buf = new byte[4096];
|
|
long deadline = System.currentTimeMillis() + 5000;
|
|
while (raw.size() < 65536 && System.currentTimeMillis() < deadline) {{
|
|
int read;
|
|
try {{
|
|
read = in.read(buf, 0, buf.length);
|
|
}} catch (java.net.SocketTimeoutException te) {{
|
|
break;
|
|
}} catch (IOException ioe) {{
|
|
break;
|
|
}}
|
|
if (read < 0) {{
|
|
break;
|
|
}}
|
|
raw.write(buf, 0, read);
|
|
if (nyxContainsCrlfCrlf(raw.toByteArray())) {{
|
|
break;
|
|
}}
|
|
}}
|
|
}} catch (IOException ioe) {{
|
|
// boot / connect / read failed — surface null so the caller
|
|
// takes the synthetic fallback path.
|
|
try {{ worker.interrupt(); }} catch (Exception ignored) {{}}
|
|
try {{ server.close(); }} catch (IOException ignored) {{}}
|
|
return null;
|
|
}} finally {{
|
|
if (client != null) {{
|
|
try {{ client.close(); }} catch (IOException ignored) {{}}
|
|
}}
|
|
try {{ worker.join(2000); }} catch (InterruptedException ignored) {{}}
|
|
try {{ server.close(); }} catch (IOException ignored) {{}}
|
|
}}
|
|
byte[] rawBytes = raw.toByteArray();
|
|
int sep = nyxIndexCrlfCrlf(rawBytes);
|
|
if (sep < 0) {{
|
|
return rawBytes;
|
|
}}
|
|
byte[] head = new byte[sep];
|
|
System.arraycopy(rawBytes, 0, head, 0, sep);
|
|
return head;
|
|
}}
|
|
|
|
private static boolean nyxContainsCrlfCrlf(byte[] buf) {{
|
|
return nyxIndexCrlfCrlf(buf) >= 0;
|
|
}}
|
|
|
|
private static int nyxIndexCrlfCrlf(byte[] buf) {{
|
|
for (int i = 0; i + 3 < buf.length; i++) {{
|
|
if (buf[i] == 0x0d && buf[i + 1] == 0x0a
|
|
&& buf[i + 2] == 0x0d && buf[i + 3] == 0x0a) {{
|
|
return i;
|
|
}}
|
|
}}
|
|
return -1;
|
|
}}
|
|
|
|
// Derive `Set-Cookie:` HeaderEmit records from the raw wire-frame
|
|
// bytes so the tier-(a) `HeaderInjected` predicate fires on the
|
|
// same harness pass. The wire-frame branch owns the bytes; the
|
|
// HeaderEmit records are derived from them.
|
|
private static void nyxEmitSetCookieHeaderProbes(byte[] rawBytes) {{
|
|
int start = 0;
|
|
for (int i = 0; i < rawBytes.length; i++) {{
|
|
if (rawBytes[i] == 0x0a) {{
|
|
int end = i;
|
|
if (end > start && rawBytes[end - 1] == 0x0d) {{
|
|
end--;
|
|
}}
|
|
nyxMaybeEmitSetCookieLine(rawBytes, start, end);
|
|
start = i + 1;
|
|
}}
|
|
}}
|
|
if (start < rawBytes.length) {{
|
|
nyxMaybeEmitSetCookieLine(rawBytes, start, rawBytes.length);
|
|
}}
|
|
}}
|
|
|
|
private static void nyxMaybeEmitSetCookieLine(byte[] rawBytes, int start, int end) {{
|
|
if (end <= start) return;
|
|
int colon = -1;
|
|
for (int i = start; i < end; i++) {{
|
|
if (rawBytes[i] == 0x3a) {{
|
|
colon = i;
|
|
break;
|
|
}}
|
|
}}
|
|
if (colon < 0) return;
|
|
String name = new String(rawBytes, start, colon - start, StandardCharsets.ISO_8859_1);
|
|
if (!name.equalsIgnoreCase("Set-Cookie")) return;
|
|
int valueStart = colon + 1;
|
|
if (valueStart < end && rawBytes[valueStart] == 0x20) {{
|
|
valueStart++;
|
|
}}
|
|
String value = new String(rawBytes, valueStart, end - valueStart, StandardCharsets.ISO_8859_1);
|
|
nyxWireFrameHeaderProbe(name, value);
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload == null) payload = "";
|
|
byte[] rawBytes = nyxWireFrameViaFixture(payload);
|
|
if (rawBytes != null) {{
|
|
nyxWireFrameProbe(rawBytes);
|
|
nyxEmitSetCookieHeaderProbes(rawBytes);
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
System.out.println("{{\"wire_frame_len\":" + rawBytes.length + "}}");
|
|
return;
|
|
}}
|
|
// Synthetic fallback when the fixture failed to boot — keeps
|
|
// the differential oracle live on a build/boot failure rather
|
|
// than silently shedding the attempt.
|
|
nyxWireFrameHeaderProbe("Set-Cookie", payload);
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
System.out.println("{{\"payload_len\":" + payload.getBytes(StandardCharsets.UTF_8).length + "}}");
|
|
}}
|
|
}}
|
|
"#
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files: Vec::new(),
|
|
entry_subpath: None,
|
|
}
|
|
}
|
|
|
|
/// Phase 09 — Track J.7 open-redirect harness for Java
|
|
/// (`HttpServletResponse.sendRedirect`).
|
|
///
|
|
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
|
/// `response.sendRedirect(value)` shim that records the *unmodified*
|
|
/// `Location:` value plus the request's origin host via a
|
|
/// `ProbeKind::Redirect` probe. Mirrors the synthetic-harness
|
|
/// pattern used by Phase 03 / 04 / 05 / 06 / 07 / 08.
|
|
pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource {
|
|
let shim = probe_shim();
|
|
let extra_files = servlet_stubs_for_entry(&spec.entry_file);
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
let servlet_pkg = if entry_source.contains("jakarta.servlet") {
|
|
"jakarta.servlet.http"
|
|
} else {
|
|
"javax.servlet.http"
|
|
};
|
|
let entry_class = derive_entry_class(&entry_source);
|
|
let entry_fqn = derive_entry_qualifier(&entry_source, &entry_class);
|
|
let entry_method = if spec.entry_name.is_empty() {
|
|
"run".to_owned()
|
|
} else {
|
|
spec.entry_name.clone()
|
|
};
|
|
let has_servlet_stubs = !extra_files.is_empty();
|
|
|
|
// Tier-(a) path drives the fixture's real `sendRedirect` call
|
|
// through the captured-location field on the servlet stub. Falls
|
|
// back to the legacy synthetic probe when the entry source does
|
|
// not import a servlet API so the verdict path stays intact.
|
|
let main_body = if has_servlet_stubs {
|
|
format!(
|
|
r#" // Phase 09 tier-(a): instantiate the captured-redirect response
|
|
// wrapper, reflectively invoke the fixture's sink call, then
|
|
// read the captured `Location:` value via getRedirectedUrl()
|
|
// and emit a single ProbeKind::Redirect probe.
|
|
{servlet_pkg}.HttpServletResponse response = new {servlet_pkg}.HttpServletResponse();
|
|
boolean fixtureInvoked = false;
|
|
try {{
|
|
Class<?> entry = Class.forName("{entry_fqn}");
|
|
Method m = entry.getDeclaredMethod(
|
|
"{entry_method}",
|
|
{servlet_pkg}.HttpServletResponse.class,
|
|
String.class);
|
|
m.setAccessible(true);
|
|
m.invoke(null, response, payload);
|
|
fixtureInvoked = true;
|
|
}} catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException e) {{
|
|
// Fixture shape did not match (response, value) — fall
|
|
// through to the synthetic probe.
|
|
}} catch (InvocationTargetException ite) {{
|
|
// Fixture itself threw; the sink path was reached so keep
|
|
// the captured location if any.
|
|
fixtureInvoked = true;
|
|
}}
|
|
String captured = response.getRedirectedUrl();
|
|
if (fixtureInvoked && captured != null) {{
|
|
nyxRedirectProbe(captured, requestHost);
|
|
nyxFollowLocation(captured);
|
|
}} else {{
|
|
nyxRedirectProbe(payload, requestHost);
|
|
nyxFollowLocation(payload);
|
|
}}"#
|
|
)
|
|
} else {
|
|
r#" nyxRedirectProbe(payload, requestHost);
|
|
nyxFollowLocation(payload);"#
|
|
.to_owned()
|
|
};
|
|
|
|
let imports = if has_servlet_stubs {
|
|
"import java.lang.reflect.InvocationTargetException;\nimport java.lang.reflect.Method;\nimport java.net.HttpURLConnection;\nimport java.net.URL;\n"
|
|
} else {
|
|
"import java.net.HttpURLConnection;\nimport java.net.URL;\n"
|
|
};
|
|
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — OPEN_REDIRECT HttpServletResponse.sendRedirect (Phase 09 / Track J.7).
|
|
import java.io.FileWriter;
|
|
import java.io.IOException;
|
|
{imports}
|
|
public class NyxHarness {{
|
|
{shim}
|
|
|
|
static void nyxRedirectProbe(String location, String requestHost) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(256);
|
|
line.append("{{\"sink_callee\":\"HttpServletResponse.sendRedirect\",\"args\":[");
|
|
line.append("{{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(location, line);
|
|
line.append("\"}}],");
|
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"Redirect\",\"location\":\"");
|
|
nyxJsonEscape(location, line);
|
|
line.append("\",\"request_host\":\"");
|
|
nyxJsonEscape(requestHost, line);
|
|
line.append("\"}},");
|
|
line.append("\"witness\":");
|
|
line.append(nyxWitnessJson("HttpServletResponse.sendRedirect", new String[]{{location}}));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
// Phase 09 OOB closure: when the captured Location is a fully-qualified
|
|
// loopback URL, follow it with a real GET so the OOB listener records
|
|
// the per-finding nonce. Skips non-loopback hosts (no real network egress)
|
|
// and any non-HTTP scheme. Best-effort: failures do not propagate, the
|
|
// listener may still have observed the connect before the read errored.
|
|
static void nyxFollowLocation(String location) {{
|
|
if (location == null || location.isEmpty()) return;
|
|
String lower = location.toLowerCase();
|
|
if (!(lower.startsWith("http://127.0.0.1")
|
|
|| lower.startsWith("http://localhost")
|
|
|| lower.startsWith("http://host-gateway"))) {{
|
|
return;
|
|
}}
|
|
try {{
|
|
HttpURLConnection conn = (HttpURLConnection) new URL(location).openConnection();
|
|
conn.setConnectTimeout(2000);
|
|
conn.setReadTimeout(2000);
|
|
conn.setInstanceFollowRedirects(false);
|
|
conn.getInputStream().close();
|
|
conn.disconnect();
|
|
}} catch (Exception ignored) {{
|
|
// best-effort OOB fetch
|
|
}}
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload == null) payload = "";
|
|
String requestHost = "example.com";
|
|
{main_body}
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
}}
|
|
}}
|
|
"#
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files,
|
|
entry_subpath: None,
|
|
}
|
|
}
|
|
|
|
/// Phase 11 (Track J.9) CRYPTO harness for Java.
|
|
///
|
|
/// Reflectively loads the fixture's entry class, invokes the named
|
|
/// static method with the payload, and emits a
|
|
/// [`crate::dynamic::probe::ProbeKind::WeakKey`] probe whose `key_int`
|
|
/// is reduced from the produced key. `byte[]` returns get padded to
|
|
/// 8 bytes (left-zero-padded for shorter slices, truncated to the
|
|
/// leading 8 bytes for longer ones) and decoded as big-endian via
|
|
/// `ByteBuffer.getLong()`; `Number` subclasses route through
|
|
/// `longValue()`. A 2-byte `java.util.Random.nextBytes(new byte[2])`
|
|
/// key fits inside 2^16, while `SecureRandom.nextBytes(new byte[32])`
|
|
/// produces a magnitude well above any 16-bit budget. Reflection
|
|
/// failures fall back to a payload-derived `key_int` so the universal
|
|
/// sink-hit path still fires.
|
|
pub fn emit_crypto_harness(spec: &HarnessSpec) -> HarnessSource {
|
|
let shim = probe_shim();
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
let entry_class = derive_entry_class(&entry_source);
|
|
let entry_fqn = derive_entry_qualifier(&entry_source, &entry_class);
|
|
let entry_method = if spec.entry_name.is_empty() {
|
|
"run".to_owned()
|
|
} else {
|
|
spec.entry_name.clone()
|
|
};
|
|
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — CRYPTO weak-RNG key entropy (Phase 11 / Track J.9).
|
|
import java.io.FileWriter;
|
|
import java.io.IOException;
|
|
import java.lang.reflect.InvocationTargetException;
|
|
import java.lang.reflect.Method;
|
|
import java.nio.ByteBuffer;
|
|
import java.nio.ByteOrder;
|
|
|
|
public class NyxHarness {{
|
|
{shim}
|
|
|
|
static void nyxWeakKeyProbe(long keyInt) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(192);
|
|
line.append("{{\"sink_callee\":\"__nyx_weak_key\",\"args\":[");
|
|
line.append("{{\"kind\":\"Int\",\"value\":").append(keyInt).append("}}],");
|
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"WeakKey\",\"key_int\":").append(keyInt).append("}},");
|
|
line.append("\"witness\":");
|
|
line.append(nyxWitnessJson("__nyx_weak_key", new String[]{{Long.toString(keyInt)}}));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
static long nyxKeyToLong(Object value) {{
|
|
if (value == null) return 0L;
|
|
if (value instanceof byte[]) {{
|
|
byte[] b = (byte[]) value;
|
|
byte[] buf = new byte[8];
|
|
int n = Math.min(b.length, 8);
|
|
// left-zero-pad for short slices, take leading 8 bytes for long ones
|
|
System.arraycopy(b, 0, buf, 8 - n, n);
|
|
return ByteBuffer.wrap(buf).order(ByteOrder.BIG_ENDIAN).getLong();
|
|
}}
|
|
if (value instanceof Number) {{
|
|
return ((Number) value).longValue();
|
|
}}
|
|
if (value instanceof Boolean) {{
|
|
return ((Boolean) value).booleanValue() ? 1L : 0L;
|
|
}}
|
|
// Fallback — UTF-8 first 8 bytes
|
|
byte[] enc = value.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
|
byte[] buf = new byte[8];
|
|
int n = Math.min(enc.length, 8);
|
|
System.arraycopy(enc, 0, buf, 8 - n, n);
|
|
return ByteBuffer.wrap(buf).order(ByteOrder.BIG_ENDIAN).getLong();
|
|
}}
|
|
|
|
static long nyxPayloadFallback(String payload) {{
|
|
if (payload == null) payload = "";
|
|
byte[] enc = payload.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
|
byte[] buf = new byte[8];
|
|
int n = Math.min(enc.length, 8);
|
|
System.arraycopy(enc, 0, buf, 8 - n, n);
|
|
return ByteBuffer.wrap(buf).order(ByteOrder.BIG_ENDIAN).getLong();
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload == null) payload = "";
|
|
long keyInt;
|
|
boolean fixtureInvoked = false;
|
|
try {{
|
|
Class<?> entry = Class.forName("{entry_fqn}");
|
|
Method m = entry.getDeclaredMethod("{entry_method}", String.class);
|
|
m.setAccessible(true);
|
|
Object produced = m.invoke(null, payload);
|
|
keyInt = nyxKeyToLong(produced);
|
|
fixtureInvoked = true;
|
|
}} catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException e) {{
|
|
keyInt = nyxPayloadFallback(payload);
|
|
}} catch (InvocationTargetException ite) {{
|
|
keyInt = nyxPayloadFallback(payload);
|
|
}}
|
|
nyxWeakKeyProbe(keyInt);
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
if (!fixtureInvoked) {{
|
|
System.out.println("__NYX_CRYPTO_FALLBACK__");
|
|
}}
|
|
}}
|
|
}}
|
|
"#
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files: Vec::new(),
|
|
entry_subpath: None,
|
|
}
|
|
}
|
|
|
|
/// Phase 11 (Track J.9) JSON_PARSE depth-bomb harness for Java.
|
|
///
|
|
/// Reflectively loads the fixture's entry class, invokes the named
|
|
/// static method with the payload (signature `static Object
|
|
/// <method>(String)`), then walks the returned tree iteratively via
|
|
/// `NyxJsonProbe.countDepth(Object)` to produce a
|
|
/// [`crate::dynamic::probe::ProbeKind::JsonParse`] record.
|
|
///
|
|
/// Java has no stdlib JSON parser, so the harness ships
|
|
/// `NyxJsonProbe.java` as an `extra_files` sibling: a hand-rolled
|
|
/// iterative parser that returns a `java.util.List` / `java.util.Map`
|
|
/// tree without pulling Jackson / Gson onto the classpath. The
|
|
/// fixture calls `NyxJsonProbe.parse(text)` in place of any library
|
|
/// JSON parser. When the parser's own
|
|
/// [`NyxJsonProbe.NyxJsonDepthException`] fires (nesting above
|
|
/// `MAX_PARSE_DEPTH = 4096`) the harness emits a `JsonParse { depth:
|
|
/// 0, excessive_depth: true }` probe before continuing — matches the
|
|
/// PHP `JSON_ERROR_DEPTH` and Python `RecursionError` excess paths.
|
|
pub fn emit_json_parse_harness(spec: &HarnessSpec) -> HarnessSource {
|
|
let shim = probe_shim();
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
let entry_class = derive_entry_class(&entry_source);
|
|
let entry_fqn = derive_entry_qualifier(&entry_source, &entry_class);
|
|
let entry_method = if spec.entry_name.is_empty() {
|
|
"run".to_owned()
|
|
} else {
|
|
spec.entry_name.clone()
|
|
};
|
|
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — JSON_PARSE depth checks (Phase 11 / Track J.9).
|
|
import java.io.FileWriter;
|
|
import java.io.IOException;
|
|
import java.lang.reflect.InvocationTargetException;
|
|
import java.lang.reflect.Method;
|
|
|
|
public class NyxHarness {{
|
|
{shim}
|
|
|
|
static void nyxJsonParseProbe(int depth, boolean excessive) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(192);
|
|
line.append("{{\"sink_callee\":\"NyxJsonProbe.parse\",\"args\":[");
|
|
line.append("{{\"kind\":\"Int\",\"value\":").append(depth).append("}}],");
|
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"JsonParse\",\"depth\":").append(depth);
|
|
line.append(",\"excessive_depth\":").append(excessive).append("}},");
|
|
line.append("\"witness\":");
|
|
line.append(nyxWitnessJson("NyxJsonProbe.parse", new String[]{{Integer.toString(depth)}}));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload == null) payload = "";
|
|
int depth = 0;
|
|
boolean excessive = false;
|
|
boolean fixtureInvoked = false;
|
|
try {{
|
|
Class<?> entry = Class.forName("{entry_fqn}");
|
|
Method m = entry.getDeclaredMethod("{entry_method}", String.class);
|
|
m.setAccessible(true);
|
|
Object produced = m.invoke(null, payload);
|
|
depth = NyxJsonProbe.countDepth(produced);
|
|
excessive = depth > 64;
|
|
fixtureInvoked = true;
|
|
}} catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException e) {{
|
|
// Fall through to fallback probe.
|
|
}} catch (InvocationTargetException ite) {{
|
|
Throwable cause = ite.getCause();
|
|
if (cause instanceof NyxJsonProbe.NyxJsonDepthException) {{
|
|
depth = 0;
|
|
excessive = true;
|
|
fixtureInvoked = true;
|
|
}} else if (cause instanceof NyxJsonProbe.NyxJsonParseException) {{
|
|
// Malformed JSON — payload survived the harness path,
|
|
// record the parse attempt without claiming depth.
|
|
fixtureInvoked = true;
|
|
}}
|
|
}}
|
|
nyxJsonParseProbe(depth, excessive);
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
if (!fixtureInvoked) {{
|
|
System.out.println("__NYX_JSON_PARSE_FALLBACK__");
|
|
}}
|
|
}}
|
|
}}
|
|
"#
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files: vec![("NyxJsonProbe.java".to_owned(), nyx_json_probe_source().to_owned())],
|
|
entry_subpath: Some(format!("{entry_class}.java")),
|
|
}
|
|
}
|
|
|
|
/// Hand-rolled iterative JSON parser shipped alongside the harness.
|
|
///
|
|
/// Phase 11 (Track J.9) cannot reach for Jackson / Gson because the
|
|
/// build container does not yet bundle either jar. The walker returns
|
|
/// a `java.util.List` / `java.util.Map` / `String` / `Long` / `Double`
|
|
/// / `Boolean` / null tree the harness then iterates over via an
|
|
/// explicit stack to compute the observed max nesting depth.
|
|
fn nyx_json_probe_source() -> &'static str {
|
|
r#"// Auto-generated by nyx_scanner::dynamic::lang::java::emit_json_parse_harness.
|
|
// Hand-rolled iterative JSON parser so the Phase 11 JSON_PARSE harness
|
|
// can run without a Jackson / Gson classpath dep.
|
|
|
|
import java.util.ArrayDeque;
|
|
import java.util.ArrayList;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
public class NyxJsonProbe {
|
|
public static final int MAX_PARSE_DEPTH = 4096;
|
|
public static final int MAX_WALK = 4096;
|
|
|
|
public static class NyxJsonDepthException extends RuntimeException {
|
|
public NyxJsonDepthException(String msg) { super(msg); }
|
|
}
|
|
|
|
public static class NyxJsonParseException extends RuntimeException {
|
|
public NyxJsonParseException(String msg) { super(msg); }
|
|
}
|
|
|
|
public static Object parse(String s) {
|
|
if (s == null) return null;
|
|
State st = new State(s);
|
|
st.skipWs();
|
|
Object v = parseValue(st, 1);
|
|
st.skipWs();
|
|
return v;
|
|
}
|
|
|
|
private static Object parseValue(State st, int depth) {
|
|
if (depth > MAX_PARSE_DEPTH) {
|
|
throw new NyxJsonDepthException("max depth " + MAX_PARSE_DEPTH + " exceeded");
|
|
}
|
|
st.skipWs();
|
|
if (st.pos >= st.src.length()) {
|
|
throw new NyxJsonParseException("unexpected EOF");
|
|
}
|
|
char c = st.src.charAt(st.pos);
|
|
if (c == '[') {
|
|
st.pos++;
|
|
List<Object> arr = new ArrayList<>();
|
|
st.skipWs();
|
|
if (st.pos < st.src.length() && st.src.charAt(st.pos) == ']') {
|
|
st.pos++;
|
|
return arr;
|
|
}
|
|
while (true) {
|
|
arr.add(parseValue(st, depth + 1));
|
|
st.skipWs();
|
|
if (st.pos >= st.src.length()) {
|
|
throw new NyxJsonParseException("unterminated array");
|
|
}
|
|
char d = st.src.charAt(st.pos);
|
|
if (d == ',') {
|
|
st.pos++;
|
|
continue;
|
|
}
|
|
if (d == ']') {
|
|
st.pos++;
|
|
return arr;
|
|
}
|
|
throw new NyxJsonParseException("expected , or ] in array");
|
|
}
|
|
}
|
|
if (c == '{') {
|
|
st.pos++;
|
|
Map<String, Object> obj = new HashMap<>();
|
|
st.skipWs();
|
|
if (st.pos < st.src.length() && st.src.charAt(st.pos) == '}') {
|
|
st.pos++;
|
|
return obj;
|
|
}
|
|
while (true) {
|
|
st.skipWs();
|
|
String key = parseString(st);
|
|
st.skipWs();
|
|
if (st.pos >= st.src.length() || st.src.charAt(st.pos) != ':') {
|
|
throw new NyxJsonParseException("expected : in object");
|
|
}
|
|
st.pos++;
|
|
Object v = parseValue(st, depth + 1);
|
|
obj.put(key, v);
|
|
st.skipWs();
|
|
if (st.pos >= st.src.length()) {
|
|
throw new NyxJsonParseException("unterminated object");
|
|
}
|
|
char d = st.src.charAt(st.pos);
|
|
if (d == ',') {
|
|
st.pos++;
|
|
continue;
|
|
}
|
|
if (d == '}') {
|
|
st.pos++;
|
|
return obj;
|
|
}
|
|
throw new NyxJsonParseException("expected , or } in object");
|
|
}
|
|
}
|
|
if (c == '"') return parseString(st);
|
|
if (c == 't' || c == 'f' || c == 'n') return parseLiteral(st);
|
|
if (c == '-' || (c >= '0' && c <= '9')) return parseNumber(st);
|
|
throw new NyxJsonParseException("unexpected char " + c + " at " + st.pos);
|
|
}
|
|
|
|
private static String parseString(State st) {
|
|
if (st.pos >= st.src.length() || st.src.charAt(st.pos) != '"') {
|
|
throw new NyxJsonParseException("expected string");
|
|
}
|
|
st.pos++;
|
|
StringBuilder sb = new StringBuilder();
|
|
while (st.pos < st.src.length()) {
|
|
char c = st.src.charAt(st.pos++);
|
|
if (c == '"') return sb.toString();
|
|
if (c == '\\') {
|
|
if (st.pos >= st.src.length()) {
|
|
throw new NyxJsonParseException("trailing escape");
|
|
}
|
|
char e = st.src.charAt(st.pos++);
|
|
switch (e) {
|
|
case '"': sb.append('"'); break;
|
|
case '\\': sb.append('\\'); break;
|
|
case '/': sb.append('/'); break;
|
|
case 'n': sb.append('\n'); break;
|
|
case 't': sb.append('\t'); break;
|
|
case 'r': sb.append('\r'); break;
|
|
case 'b': sb.append('\b'); break;
|
|
case 'f': sb.append('\f'); break;
|
|
case 'u':
|
|
if (st.pos + 4 > st.src.length()) {
|
|
throw new NyxJsonParseException("bad unicode escape");
|
|
}
|
|
int code = Integer.parseInt(st.src.substring(st.pos, st.pos + 4), 16);
|
|
sb.append((char) code);
|
|
st.pos += 4;
|
|
break;
|
|
default:
|
|
sb.append(e);
|
|
}
|
|
} else {
|
|
sb.append(c);
|
|
}
|
|
}
|
|
throw new NyxJsonParseException("unterminated string");
|
|
}
|
|
|
|
private static Object parseLiteral(State st) {
|
|
if (st.src.startsWith("true", st.pos)) { st.pos += 4; return Boolean.TRUE; }
|
|
if (st.src.startsWith("false", st.pos)) { st.pos += 5; return Boolean.FALSE; }
|
|
if (st.src.startsWith("null", st.pos)) { st.pos += 4; return null; }
|
|
throw new NyxJsonParseException("bad literal at " + st.pos);
|
|
}
|
|
|
|
private static Object parseNumber(State st) {
|
|
int start = st.pos;
|
|
if (st.src.charAt(st.pos) == '-') st.pos++;
|
|
boolean isFloat = false;
|
|
while (st.pos < st.src.length()) {
|
|
char c = st.src.charAt(st.pos);
|
|
if ((c >= '0' && c <= '9') || c == '+' || c == '-') {
|
|
st.pos++;
|
|
} else if (c == '.' || c == 'e' || c == 'E') {
|
|
isFloat = true;
|
|
st.pos++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
String num = st.src.substring(start, st.pos);
|
|
try {
|
|
if (isFloat) return Double.parseDouble(num);
|
|
return Long.parseLong(num);
|
|
} catch (NumberFormatException e) {
|
|
throw new NyxJsonParseException("bad number: " + num);
|
|
}
|
|
}
|
|
|
|
public static int countDepth(Object parsed) {
|
|
if (parsed == null) return 0;
|
|
ArrayDeque<Frame> stack = new ArrayDeque<>();
|
|
stack.push(new Frame(parsed, 1));
|
|
int maxDepth = 0;
|
|
int visited = 0;
|
|
while (!stack.isEmpty()) {
|
|
Frame f = stack.pop();
|
|
visited++;
|
|
if (visited > MAX_WALK) break;
|
|
if (f.depth > maxDepth) maxDepth = f.depth;
|
|
if (f.value instanceof List) {
|
|
for (Object child : (List<?>) f.value) {
|
|
stack.push(new Frame(child, f.depth + 1));
|
|
}
|
|
} else if (f.value instanceof Map) {
|
|
for (Object child : ((Map<?, ?>) f.value).values()) {
|
|
stack.push(new Frame(child, f.depth + 1));
|
|
}
|
|
}
|
|
}
|
|
return maxDepth;
|
|
}
|
|
|
|
private static final class State {
|
|
final String src;
|
|
int pos;
|
|
State(String s) { this.src = s; this.pos = 0; }
|
|
void skipWs() {
|
|
while (pos < src.length()) {
|
|
char c = src.charAt(pos);
|
|
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') pos++;
|
|
else break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static final class Frame {
|
|
final Object value;
|
|
final int depth;
|
|
Frame(Object v, int d) { this.value = v; this.depth = d; }
|
|
}
|
|
}
|
|
"#
|
|
}
|
|
|
|
/// Phase 11 (Track J.9) UNAUTHORIZED_ID IDOR harness for Java.
|
|
///
|
|
/// Reflectively loads the fixture's entry class, invokes the named
|
|
/// static method with the payload as `owner_id` (signature `static
|
|
/// Object <method>(String)`), and emits a
|
|
/// [`crate::dynamic::probe::ProbeKind::IdorAccess`] probe carrying
|
|
/// `caller_id = "alice"` and `owner_id = payload` only when the
|
|
/// fixture returns a non-`null` record. The benign control's
|
|
/// `if (!CALLER.equals(ownerId)) return null;` rejection clears the
|
|
/// probe; the vuln fixture's unguarded `STORE.get(ownerId)` always
|
|
/// materialises a record so the
|
|
/// [`crate::dynamic::oracle::ProbePredicate::IdorBoundaryCrossed`]
|
|
/// predicate fires for any cross-tenant payload.
|
|
pub fn emit_unauthorized_id_harness(spec: &HarnessSpec) -> HarnessSource {
|
|
let shim = probe_shim();
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
let entry_class = derive_entry_class(&entry_source);
|
|
let entry_fqn = derive_entry_qualifier(&entry_source, &entry_class);
|
|
let entry_method = if spec.entry_name.is_empty() {
|
|
"run".to_owned()
|
|
} else {
|
|
spec.entry_name.clone()
|
|
};
|
|
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — UNAUTHORIZED_ID IDOR boundary (Phase 11 / Track J.9).
|
|
import java.io.FileWriter;
|
|
import java.io.IOException;
|
|
import java.lang.reflect.InvocationTargetException;
|
|
import java.lang.reflect.Method;
|
|
|
|
public class NyxHarness {{
|
|
{shim}
|
|
|
|
private static final String _NYX_CALLER_ID = "alice";
|
|
|
|
static void nyxIdorProbe(String callerId, String ownerId) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(256);
|
|
line.append("{{\"sink_callee\":\"__nyx_idor_lookup\",\"args\":[");
|
|
line.append("{{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(callerId == null ? "" : callerId, line);
|
|
line.append("\"}},{{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(ownerId == null ? "" : ownerId, line);
|
|
line.append("\"}}],\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"IdorAccess\",\"caller_id\":\"");
|
|
nyxJsonEscape(callerId == null ? "" : callerId, line);
|
|
line.append("\",\"owner_id\":\"");
|
|
nyxJsonEscape(ownerId == null ? "" : ownerId, line);
|
|
line.append("\"}},\"witness\":");
|
|
line.append(nyxWitnessJson(
|
|
"__nyx_idor_lookup",
|
|
new String[]{{callerId == null ? "" : callerId, ownerId == null ? "" : ownerId}}));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload == null) payload = "";
|
|
Object record = null;
|
|
boolean fixtureInvoked = false;
|
|
try {{
|
|
Class<?> entry = Class.forName("{entry_fqn}");
|
|
Method m = entry.getDeclaredMethod("{entry_method}", String.class);
|
|
m.setAccessible(true);
|
|
record = m.invoke(null, payload);
|
|
fixtureInvoked = true;
|
|
}} catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException e) {{
|
|
// Fall through; harness still prints sink hit.
|
|
}} catch (InvocationTargetException ite) {{
|
|
fixtureInvoked = true;
|
|
}}
|
|
if (record != null) {{
|
|
nyxIdorProbe(_NYX_CALLER_ID, payload);
|
|
}}
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
if (!fixtureInvoked) {{
|
|
System.out.println("__NYX_UNAUTHORIZED_ID_FALLBACK__");
|
|
}}
|
|
}}
|
|
}}
|
|
"#
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files: Vec::new(),
|
|
entry_subpath: Some(format!("{entry_class}.java")),
|
|
}
|
|
}
|
|
|
|
/// Phase 11 (Track J.9) DATA_EXFIL outbound-network harness for Java.
|
|
///
|
|
/// Java has no stdlib monkey-patch hook for `HttpURLConnection`, so the
|
|
/// harness ships a hand-rolled `NyxMockHttp.java` helper alongside
|
|
/// `NyxHarness.java` and the fixture calls into
|
|
/// `NyxMockHttp.get(url)` / `NyxMockHttp.post(url, body)` in place of
|
|
/// any real wire I/O. The helper parses the URL's host (URI scheme,
|
|
/// bare-host fallback, port-stripping), appends it to
|
|
/// `NyxMockHttp.CAPTURED_HOSTS`, and returns a benign stand-in `String`
|
|
/// so the fixture's consumer code never blocks on the network. The
|
|
/// harness drains the list after the entry returns and emits one
|
|
/// [`crate::dynamic::probe::ProbeKind::OutboundNetwork`] probe per
|
|
/// captured host. The
|
|
/// [`crate::dynamic::oracle::ProbePredicate::OutboundHostNotIn`]
|
|
/// predicate fires for any host outside the loopback allowlist
|
|
/// (`["127.0.0.1", "localhost"]`).
|
|
pub fn emit_data_exfil_harness(spec: &HarnessSpec) -> HarnessSource {
|
|
let shim = probe_shim();
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
let entry_class = derive_entry_class(&entry_source);
|
|
let entry_fqn = derive_entry_qualifier(&entry_source, &entry_class);
|
|
let entry_method = if spec.entry_name.is_empty() {
|
|
"run".to_owned()
|
|
} else {
|
|
spec.entry_name.clone()
|
|
};
|
|
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — DATA_EXFIL outbound-host (Phase 11 / Track J.9).
|
|
import java.io.FileWriter;
|
|
import java.io.IOException;
|
|
import java.lang.reflect.InvocationTargetException;
|
|
import java.lang.reflect.Method;
|
|
|
|
public class NyxHarness {{
|
|
{shim}
|
|
|
|
static void nyxOutboundProbe(String host) {{
|
|
String p = System.getenv("NYX_PROBE_PATH");
|
|
if (p == null || p.isEmpty()) return;
|
|
long now = System.nanoTime();
|
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
|
if (pid == null) pid = "";
|
|
StringBuilder line = new StringBuilder(256);
|
|
line.append("{{\"sink_callee\":\"__nyx_mock_http\",\"args\":[");
|
|
line.append("{{\"kind\":\"String\",\"value\":\"");
|
|
nyxJsonEscape(host == null ? "" : host, line);
|
|
line.append("\"}}],\"captured_at_ns\":").append(now).append(',');
|
|
line.append("\"payload_id\":\"");
|
|
nyxJsonEscape(pid, line);
|
|
line.append("\",\"kind\":{{\"kind\":\"OutboundNetwork\",\"host\":\"");
|
|
nyxJsonEscape(host == null ? "" : host, line);
|
|
line.append("\"}},\"witness\":");
|
|
line.append(nyxWitnessJson(
|
|
"__nyx_mock_http",
|
|
new String[]{{host == null ? "" : host}}));
|
|
line.append("}}\n");
|
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
|
fw.write(line.toString());
|
|
}} catch (IOException e) {{
|
|
// best-effort
|
|
}}
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = System.getenv("NYX_PAYLOAD");
|
|
if (payload == null) payload = "";
|
|
NyxMockHttp.CAPTURED_HOSTS.clear();
|
|
boolean fixtureInvoked = false;
|
|
try {{
|
|
Class<?> entry = Class.forName("{entry_fqn}");
|
|
Method m = entry.getDeclaredMethod("{entry_method}", String.class);
|
|
m.setAccessible(true);
|
|
m.invoke(null, payload);
|
|
fixtureInvoked = true;
|
|
}} catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException e) {{
|
|
// Fall through; harness still prints sink hit.
|
|
}} catch (InvocationTargetException ite) {{
|
|
// Even on throw the captured-host list is drained so a
|
|
// partial outbound call still emits its probe.
|
|
fixtureInvoked = true;
|
|
}}
|
|
for (String host : NyxMockHttp.CAPTURED_HOSTS) {{
|
|
nyxOutboundProbe(host);
|
|
}}
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
if (!fixtureInvoked) {{
|
|
System.out.println("__NYX_DATA_EXFIL_FALLBACK__");
|
|
}}
|
|
}}
|
|
}}
|
|
"#
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files: vec![(
|
|
"NyxMockHttp.java".to_owned(),
|
|
nyx_mock_http_source().to_owned(),
|
|
)],
|
|
entry_subpath: Some(format!("{entry_class}.java")),
|
|
}
|
|
}
|
|
|
|
/// Hand-rolled HTTP mock shipped alongside the DATA_EXFIL harness.
|
|
///
|
|
/// Java has no stdlib monkey-patch hook for `HttpURLConnection`, so the
|
|
/// fixture cannot intercept the real-engine outbound call the way the
|
|
/// Python / JS / Ruby DATA_EXFIL fixtures do. The fixture is rewritten
|
|
/// to call into `NyxMockHttp.get(url)` in place of
|
|
/// `HttpURLConnection.openConnection().connect()`; the helper extracts
|
|
/// the URL host, appends it to `CAPTURED_HOSTS`, and returns a benign
|
|
/// stand-in `String` so the fixture's consumer code never blocks on the
|
|
/// network. The harness drains `CAPTURED_HOSTS` after the entry
|
|
/// returns to emit one `ProbeKind::OutboundNetwork` record per call.
|
|
fn nyx_mock_http_source() -> &'static str {
|
|
r#"// Auto-generated by nyx_scanner::dynamic::lang::java::emit_data_exfil_harness.
|
|
// Captures outbound host arguments without initiating real wire I/O so
|
|
// the Phase 11 DATA_EXFIL harness can drain them and emit probes.
|
|
|
|
import java.net.URI;
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.List;
|
|
|
|
public class NyxMockHttp {
|
|
public static final List<String> CAPTURED_HOSTS =
|
|
Collections.synchronizedList(new ArrayList<String>());
|
|
|
|
public static String get(String url) {
|
|
captureHost(url);
|
|
return "";
|
|
}
|
|
|
|
public static String post(String url, String body) {
|
|
captureHost(url);
|
|
return "";
|
|
}
|
|
|
|
public static String request(String method, String url, String body) {
|
|
captureHost(url);
|
|
return "";
|
|
}
|
|
|
|
public static String request(String method, String url) {
|
|
captureHost(url);
|
|
return "";
|
|
}
|
|
|
|
private static void captureHost(String url) {
|
|
if (url == null) {
|
|
CAPTURED_HOSTS.add("");
|
|
return;
|
|
}
|
|
String trimmed = url.trim();
|
|
if (trimmed.isEmpty()) {
|
|
CAPTURED_HOSTS.add("");
|
|
return;
|
|
}
|
|
try {
|
|
if (trimmed.indexOf("://") < 0) {
|
|
// Bare host[:port][/path] — strip path then port.
|
|
int slash = trimmed.indexOf('/');
|
|
String hostPart = slash < 0 ? trimmed : trimmed.substring(0, slash);
|
|
int colon = hostPart.indexOf(':');
|
|
CAPTURED_HOSTS.add(colon < 0 ? hostPart : hostPart.substring(0, colon));
|
|
return;
|
|
}
|
|
URI uri = URI.create(trimmed);
|
|
String host = uri.getHost();
|
|
CAPTURED_HOSTS.add(host == null ? "" : host);
|
|
} catch (Exception e) {
|
|
CAPTURED_HOSTS.add("");
|
|
}
|
|
}
|
|
}
|
|
"#
|
|
}
|
|
|
|
/// Stage the `javax.servlet.*` / `jakarta.servlet.*` stub bundle when
|
|
/// the entry source imports either namespace. Phase 08 / 09 fixtures
|
|
/// (`HttpServletResponse.setHeader` / `.sendRedirect`) carry the
|
|
/// `import javax.servlet.http.HttpServletResponse;` so `javac` over
|
|
/// the workdir's `*.java` set needs the symbols on the classpath even
|
|
/// though `NyxHarness.java` itself uses no servlet types. Without the
|
|
/// stubs the verifier flips to `BuildFailed` and the per-lang e2e
|
|
/// tests silently skip via the SKIP-on-`BuildFailed` branch.
|
|
fn servlet_stubs_for_entry(entry_file: &str) -> Vec<(String, String)> {
|
|
let entry_source = read_entry_source(entry_file);
|
|
if entry_source.contains("javax.servlet") || entry_source.contains("jakarta.servlet") {
|
|
crate::dynamic::lang::java_servlet_stubs::servlet_stub_files()
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
|
|
/// reading the entry file from disk. Exposed so test helpers can pin a
|
|
/// per-fixture shape without round-tripping through [`emit`].
|
|
pub fn detect_shape(spec: &HarnessSpec) -> JavaShape {
|
|
let entry_source = read_entry_source(&spec.entry_file);
|
|
JavaShape::detect(spec, &entry_source)
|
|
}
|
|
|
|
fn read_entry_source(entry_file: &str) -> String {
|
|
let candidates = [
|
|
PathBuf::from(entry_file),
|
|
PathBuf::from(".").join(entry_file),
|
|
];
|
|
for path in &candidates {
|
|
if let Ok(s) = std::fs::read_to_string(path) {
|
|
return s;
|
|
}
|
|
}
|
|
String::new()
|
|
}
|
|
|
|
/// Locate the harness's target class by parsing the entry source for a
|
|
/// `public class X` (or `public final class X` / `public abstract class
|
|
/// X`) declaration. Falls back to `"Entry"` when the source is empty
|
|
/// or no public-class line is present.
|
|
///
|
|
/// The returned name drives both the in-harness invocation
|
|
/// (`{class}.method(...)` / `Class.forName(class)`) and the
|
|
/// `entry_subpath` (`{class}.java`) so javac's filename-vs-public-class
|
|
/// invariant holds for both the legacy `public class Entry` fixtures
|
|
/// and the Phase 14 shape fixtures that ship `public class Vuln`
|
|
/// (or `public class Benign`).
|
|
fn derive_entry_class(source: &str) -> String {
|
|
parse_public_class_name(source).unwrap_or_else(|| "Entry".to_owned())
|
|
}
|
|
|
|
/// Resolve the entry class as a fully-qualified Java name when the
|
|
/// entry source declares a `package`. Falls back to the bare simple
|
|
/// name when the source has no package declaration (the legacy
|
|
/// default-package fixture path).
|
|
///
|
|
/// OWASP Benchmark testcases ship with `package
|
|
/// org.owasp.benchmark.testcode;` headers; javac compiles their
|
|
/// sources into `org/owasp/benchmark/testcode/<Class>.class` under
|
|
/// the workdir, so `NyxHarness` (which itself lives in the default
|
|
/// package) cannot resolve them via the simple name alone. Using
|
|
/// the FQN in the harness's `Class.forName` / `.class` references
|
|
/// keeps both default-package and packaged entries linkable.
|
|
fn derive_entry_qualifier(source: &str, simple_name: &str) -> String {
|
|
match parse_package_name(source) {
|
|
Some(pkg) => format!("{pkg}.{simple_name}"),
|
|
None => simple_name.to_owned(),
|
|
}
|
|
}
|
|
|
|
fn parse_package_name(source: &str) -> Option<String> {
|
|
for line in source.lines() {
|
|
let trimmed = line.trim_start();
|
|
let rest = match trimmed.strip_prefix("package ") {
|
|
Some(r) => r,
|
|
None => continue,
|
|
};
|
|
let end = rest.find(';')?;
|
|
let name = rest[..end].trim();
|
|
if !name.is_empty()
|
|
&& name
|
|
.chars()
|
|
.all(|c| c.is_alphanumeric() || c == '_' || c == '.')
|
|
{
|
|
return Some(name.to_owned());
|
|
}
|
|
return None;
|
|
}
|
|
None
|
|
}
|
|
|
|
fn parse_public_class_name(source: &str) -> Option<String> {
|
|
for line in source.lines() {
|
|
let l = line.trim_start();
|
|
let rest = match l
|
|
.strip_prefix("public class ")
|
|
.or_else(|| l.strip_prefix("public final class "))
|
|
.or_else(|| l.strip_prefix("public abstract class "))
|
|
{
|
|
Some(r) => r,
|
|
None => continue,
|
|
};
|
|
let name: String = rest
|
|
.chars()
|
|
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
|
|
.collect();
|
|
if !name.is_empty() {
|
|
return Some(name);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// ── Per-shape harness generation ────────────────────────────────────────────
|
|
|
|
fn generate_harness_java(spec: &HarnessSpec, shape: JavaShape, entry_class: &str) -> String {
|
|
let probe = probe_shim();
|
|
let pre_call = pre_call_setup(spec);
|
|
let invocation = invoke_for_shape(spec, shape, entry_class);
|
|
let helpers = shape_helpers(shape);
|
|
|
|
// Reflection-driven shapes throw `InvocationTargetException` on
|
|
// user-code failure; non-reflection shapes (`StaticMethod`,
|
|
// `StaticMain`) call the entry directly and would surface an
|
|
// "unreachable catch" javac error if the specific catch clause is
|
|
// kept. Emit only the broad `Throwable` catch for those shapes.
|
|
let extra_catch = if shape_uses_reflection(shape) {
|
|
r#" } catch (InvocationTargetException ite) {
|
|
Throwable cause = ite.getCause() == null ? ite : ite.getCause();
|
|
System.err.println("NYX_EXCEPTION: " + cause.getClass().getName() + ": " + cause.getMessage());
|
|
"#
|
|
} else {
|
|
""
|
|
};
|
|
|
|
// Reflection imports are only used by shapes whose helpers / catch
|
|
// clause reference them; emitting them for `StaticMethod` /
|
|
// `StaticMain` produces unused-import warnings under javac -Xlint.
|
|
let imports = if shape_uses_reflection(shape) {
|
|
"import java.lang.reflect.Method;\nimport java.lang.reflect.Constructor;\nimport java.lang.reflect.InvocationTargetException;\n\n"
|
|
} else {
|
|
""
|
|
};
|
|
|
|
format!(
|
|
r#"// Nyx dynamic harness — auto-generated, do not edit (Phase 14 — JavaShape::{shape:?}).
|
|
{imports}public class NyxHarness {{
|
|
{probe}
|
|
{helpers}
|
|
public static void main(String[] args) {{
|
|
String payload = nyxPayload();
|
|
{pre_call} try {{
|
|
{invocation}
|
|
{extra_catch}}} catch (Throwable e) {{
|
|
System.err.println("NYX_EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage());
|
|
}}
|
|
}}
|
|
|
|
static String nyxPayload() {{
|
|
String v = System.getenv("NYX_PAYLOAD");
|
|
if (v != null && !v.isEmpty()) {{
|
|
return v;
|
|
}}
|
|
String b64 = System.getenv("NYX_PAYLOAD_B64");
|
|
if (b64 != null && !b64.isEmpty()) {{
|
|
byte[] decoded = java.util.Base64.getDecoder().decode(b64);
|
|
return new String(decoded, java.nio.charset.StandardCharsets.UTF_8);
|
|
}}
|
|
return "";
|
|
}}
|
|
}}
|
|
"#,
|
|
shape = shape,
|
|
imports = imports,
|
|
probe = probe,
|
|
helpers = helpers,
|
|
pre_call = pre_call,
|
|
invocation = invocation,
|
|
)
|
|
}
|
|
|
|
fn pre_call_setup(spec: &HarnessSpec) -> String {
|
|
match &spec.payload_slot {
|
|
PayloadSlot::EnvVar(name) => {
|
|
format!(" System.setProperty({name:?}, payload);\n")
|
|
}
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
|
|
/// Emit the per-shape entry-invocation block. Shapes that need
|
|
/// reflection plumbing rely on helpers from [`shape_helpers`].
|
|
fn invoke_for_shape(spec: &HarnessSpec, shape: JavaShape, entry_class: &str) -> String {
|
|
let method = spec.entry_name.as_str();
|
|
match shape {
|
|
JavaShape::StaticMethod => format!(" {entry_class}.{method}(payload);"),
|
|
JavaShape::StaticMain => format!(
|
|
" String[] mainArgs = new String[] {{ payload }};\n {entry_class}.main(mainArgs);"
|
|
),
|
|
JavaShape::ServletDoGet => {
|
|
format!(" invokeServlet({entry_class}.class, \"doGet\", payload, \"GET\");")
|
|
}
|
|
JavaShape::ServletDoPost => format!(
|
|
" 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);")
|
|
}
|
|
}
|
|
JavaShape::QuarkusRoute => {
|
|
format!(" invokeReflective({entry_class}.class, \"{method}\", payload);")
|
|
}
|
|
JavaShape::MicronautRoute => {
|
|
format!(" invokeReflective({entry_class}.class, \"{method}\", payload);")
|
|
}
|
|
JavaShape::JunitTest => {
|
|
format!(" invokeJunitTest({entry_class}.class, \"{method}\");")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Per-shape helper methods spliced into the harness class.
|
|
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::JunitTest => JUNIT_HELPER,
|
|
}
|
|
}
|
|
|
|
fn shape_uses_reflection(shape: JavaShape) -> bool {
|
|
!matches!(shape, JavaShape::StaticMethod | JavaShape::StaticMain)
|
|
}
|
|
|
|
/// Reflective servlet invocation. Walks `cls`'s declared methods for a
|
|
/// match on `methodName` and invokes with `(StubReq, StubResp)`. When
|
|
/// the fixture's `doGet`/`doPost` takes only a `String` payload (the
|
|
/// stub-free path used by many fixtures), the helper falls back to
|
|
/// `invokeReflective`.
|
|
const SERVLET_HELPER: &str = r#"
|
|
static void invokeServlet(Class<?> cls, String methodName, String payload, String httpMethod) throws Exception {
|
|
Method match = null;
|
|
for (Method m : cls.getDeclaredMethods()) {
|
|
if (!m.getName().equals(methodName)) continue;
|
|
match = m;
|
|
break;
|
|
}
|
|
if (match == null) {
|
|
throw new NoSuchMethodException(cls.getName() + "." + methodName);
|
|
}
|
|
match.setAccessible(true);
|
|
Object instance = null;
|
|
if (!java.lang.reflect.Modifier.isStatic(match.getModifiers())) {
|
|
instance = newDefaultInstance(cls);
|
|
}
|
|
Class<?>[] params = match.getParameterTypes();
|
|
Object[] args = new Object[params.length];
|
|
for (int i = 0; i < params.length; i++) {
|
|
Class<?> p = params[i];
|
|
if (p.equals(String.class)) {
|
|
args[i] = payload;
|
|
} else if (p.getName().endsWith("HttpServletRequest")) {
|
|
args[i] = buildRequestStub(p, payload, httpMethod);
|
|
} else if (p.getName().endsWith("HttpServletResponse")) {
|
|
args[i] = buildResponseStub(p);
|
|
} else {
|
|
args[i] = null;
|
|
}
|
|
}
|
|
match.invoke(instance, args);
|
|
}
|
|
|
|
static Object newDefaultInstance(Class<?> cls) throws Exception {
|
|
Constructor<?> ctor = cls.getDeclaredConstructor();
|
|
ctor.setAccessible(true);
|
|
return ctor.newInstance();
|
|
}
|
|
|
|
static Object buildRequestStub(Class<?> reqType, String payload, String method) throws Exception {
|
|
// Best-effort: invoke a no-arg constructor and call any
|
|
// `setParameter`/`setMethod` setters the stub exposes. When
|
|
// the type cannot be instantiated, fall back to null and let
|
|
// the fixture handle the missing parameter.
|
|
try {
|
|
Constructor<?> ctor = reqType.getDeclaredConstructor();
|
|
ctor.setAccessible(true);
|
|
Object stub = ctor.newInstance();
|
|
try {
|
|
Method setParam = reqType.getMethod("setParameter", String.class, String.class);
|
|
setParam.invoke(stub, "payload", payload);
|
|
} catch (NoSuchMethodException ignore) {}
|
|
try {
|
|
Method setMethod = reqType.getMethod("setMethod", String.class);
|
|
setMethod.invoke(stub, method);
|
|
} catch (NoSuchMethodException ignore) {}
|
|
try {
|
|
Method setBody = reqType.getMethod("setBody", String.class);
|
|
setBody.invoke(stub, payload);
|
|
} catch (NoSuchMethodException ignore) {}
|
|
return stub;
|
|
} catch (NoSuchMethodException e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static Object buildResponseStub(Class<?> respType) throws Exception {
|
|
try {
|
|
Constructor<?> ctor = respType.getDeclaredConstructor();
|
|
ctor.setAccessible(true);
|
|
return ctor.newInstance();
|
|
} catch (NoSuchMethodException e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static void invokeReflective(Class<?> cls, String methodName, String payload) throws Exception {
|
|
Method match = null;
|
|
for (Method m : cls.getDeclaredMethods()) {
|
|
if (m.getName().equals(methodName)) { match = m; break; }
|
|
}
|
|
if (match == null) {
|
|
throw new NoSuchMethodException(cls.getName() + "." + methodName);
|
|
}
|
|
match.setAccessible(true);
|
|
Object instance = null;
|
|
if (!java.lang.reflect.Modifier.isStatic(match.getModifiers())) {
|
|
instance = newDefaultInstance(cls);
|
|
}
|
|
Class<?>[] params = match.getParameterTypes();
|
|
Object[] args = new Object[params.length];
|
|
for (int i = 0; i < params.length; i++) {
|
|
args[i] = params[i].equals(String.class) ? payload : null;
|
|
}
|
|
match.invoke(instance, args);
|
|
}
|
|
"#;
|
|
|
|
/// Reflective Spring / Quarkus invocation. Same shape as the servlet
|
|
/// reflective fallback but routed through a dedicated helper for
|
|
/// clarity in the generated harness.
|
|
const REFLECTIVE_HELPER: &str = r#"
|
|
static Object newDefaultInstance(Class<?> cls) throws Exception {
|
|
Constructor<?> ctor = cls.getDeclaredConstructor();
|
|
ctor.setAccessible(true);
|
|
return ctor.newInstance();
|
|
}
|
|
|
|
static void invokeReflective(Class<?> cls, String methodName, String payload) throws Exception {
|
|
Method match = null;
|
|
for (Method m : cls.getDeclaredMethods()) {
|
|
if (m.getName().equals(methodName)) { match = m; break; }
|
|
}
|
|
if (match == null) {
|
|
throw new NoSuchMethodException(cls.getName() + "." + methodName);
|
|
}
|
|
match.setAccessible(true);
|
|
Object instance = null;
|
|
if (!java.lang.reflect.Modifier.isStatic(match.getModifiers())) {
|
|
instance = newDefaultInstance(cls);
|
|
}
|
|
Class<?>[] params = match.getParameterTypes();
|
|
Object[] args = new Object[params.length];
|
|
for (int i = 0; i < params.length; i++) {
|
|
args[i] = params[i].equals(String.class) ? payload : null;
|
|
}
|
|
match.invoke(instance, args);
|
|
}
|
|
"#;
|
|
|
|
/// Phase 19 (Track M.1) — class-method harness for Java.
|
|
///
|
|
/// Emits a `NyxHarness.java` whose `main` reflectively constructs the
|
|
/// target class via its no-arg constructor (when available) — or
|
|
/// fills primitive parameters with defaults + object parameters with
|
|
/// the Phase 19 [`crate::dynamic::stubs::MockKind`] doubles when the
|
|
/// no-arg path is missing — and invokes `method(payload)`. The class
|
|
/// is loaded via the same FQN qualifier used by the regular Java
|
|
/// shapes so it works on both default-package fixtures and packaged
|
|
/// OWASP-style entries.
|
|
fn emit_class_method_harness(
|
|
spec: &HarnessSpec,
|
|
class: &str,
|
|
method: &str,
|
|
entry_class: &str,
|
|
) -> HarnessSource {
|
|
let probe = probe_shim();
|
|
let pre_call = pre_call_setup(spec);
|
|
let mock_http = crate::dynamic::stubs::mock_source(
|
|
crate::dynamic::stubs::MockKind::HttpClient,
|
|
crate::symbol::Lang::Java,
|
|
);
|
|
let mock_db = crate::dynamic::stubs::mock_source(
|
|
crate::dynamic::stubs::MockKind::DatabaseConnection,
|
|
crate::symbol::Lang::Java,
|
|
);
|
|
let mock_log = crate::dynamic::stubs::mock_source(
|
|
crate::dynamic::stubs::MockKind::Logger,
|
|
crate::symbol::Lang::Java,
|
|
);
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — class method (Phase 19 / Track M.1).
|
|
import java.lang.reflect.Constructor;
|
|
import java.lang.reflect.Method;
|
|
import java.lang.reflect.InvocationTargetException;
|
|
|
|
public class NyxHarness {{
|
|
{probe}
|
|
|
|
{mock_http}
|
|
{mock_db}
|
|
{mock_log}
|
|
|
|
static Object nyxBuildReceiver(Class<?> cls) throws Exception {{
|
|
// Preferred path: zero-arg ctor.
|
|
try {{
|
|
Constructor<?> c = cls.getDeclaredConstructor();
|
|
c.setAccessible(true);
|
|
return c.newInstance();
|
|
}} catch (NoSuchMethodException ignore) {{
|
|
}}
|
|
// Fallback path: walk declared ctors and stub each formal.
|
|
for (Constructor<?> c : cls.getDeclaredConstructors()) {{
|
|
c.setAccessible(true);
|
|
Class<?>[] params = c.getParameterTypes();
|
|
Object[] args = new Object[params.length];
|
|
for (int i = 0; i < params.length; i++) {{
|
|
args[i] = nyxStubForType(params[i]);
|
|
}}
|
|
try {{ return c.newInstance(args); }} catch (Exception ignore) {{}}
|
|
}}
|
|
return null;
|
|
}}
|
|
|
|
static Object nyxStubForType(Class<?> t) {{
|
|
String n = t.getName().toLowerCase();
|
|
if (n.contains("http") || n.contains("client")) return new MockHttpClient();
|
|
if (n.contains("database") || n.contains("connection") || n.contains("session") || n.contains("repository")) return new MockDatabaseConnection();
|
|
if (n.contains("logger") || n.contains("log")) return new MockLogger();
|
|
if (t.equals(String.class)) return "";
|
|
if (t.equals(int.class) || t.equals(Integer.class)) return 0;
|
|
if (t.equals(long.class) || t.equals(Long.class)) return 0L;
|
|
if (t.equals(boolean.class) || t.equals(Boolean.class)) return false;
|
|
return null;
|
|
}}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = nyxPayload();
|
|
{pre_call} try {{
|
|
Class<?> cls;
|
|
try {{
|
|
cls = Class.forName({class_fqn:?});
|
|
}} catch (ClassNotFoundException cnfe) {{
|
|
cls = Class.forName({entry_class_fqn:?});
|
|
}}
|
|
Object instance = nyxBuildReceiver(cls);
|
|
if (instance == null) {{
|
|
System.err.println("NYX_CLASS_CTOR_FAILED: " + cls.getName());
|
|
System.exit(78);
|
|
}}
|
|
Method match = null;
|
|
for (Method m : cls.getDeclaredMethods()) {{
|
|
if (m.getName().equals({method:?})) {{ match = m; break; }}
|
|
}}
|
|
if (match == null) {{
|
|
System.err.println("NYX_METHOD_NOT_FOUND: " + {method:?});
|
|
System.exit(78);
|
|
}}
|
|
match.setAccessible(true);
|
|
Class<?>[] params = match.getParameterTypes();
|
|
Object[] mArgs = new Object[params.length];
|
|
for (int i = 0; i < params.length; i++) {{
|
|
mArgs[i] = params[i].equals(String.class) ? payload : nyxStubForType(params[i]);
|
|
}}
|
|
match.invoke(instance, mArgs);
|
|
}} catch (InvocationTargetException ite) {{
|
|
Throwable cause = ite.getCause() == null ? ite : ite.getCause();
|
|
System.err.println("NYX_EXCEPTION: " + cause.getClass().getName() + ": " + cause.getMessage());
|
|
}} catch (Throwable e) {{
|
|
System.err.println("NYX_EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage());
|
|
}}
|
|
}}
|
|
|
|
static String nyxPayload() {{
|
|
String v = System.getenv("NYX_PAYLOAD");
|
|
if (v != null && !v.isEmpty()) {{
|
|
return v;
|
|
}}
|
|
String b64 = System.getenv("NYX_PAYLOAD_B64");
|
|
if (b64 != null && !b64.isEmpty()) {{
|
|
byte[] decoded = java.util.Base64.getDecoder().decode(b64);
|
|
return new String(decoded, java.nio.charset.StandardCharsets.UTF_8);
|
|
}}
|
|
return "";
|
|
}}
|
|
}}
|
|
"#,
|
|
class_fqn = class,
|
|
entry_class_fqn = entry_class,
|
|
method = method,
|
|
pre_call = pre_call,
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files: vec![],
|
|
entry_subpath: Some(format!("{entry_class}.java")),
|
|
}
|
|
}
|
|
|
|
/// Phase 20 (Track M.2) — message-handler harness for Java.
|
|
///
|
|
/// Locates `entry_class` (the fixture's public class) reflectively,
|
|
/// instantiates it via its no-arg ctor (or via the stubbed-dependency
|
|
/// fallback path used by [`emit_class_method_harness`]), mounts the
|
|
/// broker loopback selected by `spec.framework.adapter`
|
|
/// (`kafka-java` → `NyxKafkaLoopback`, `sqs-java` → `NyxSqsLoopback`,
|
|
/// `rabbit-java` → `NyxRabbitChannel`; default → Kafka), subscribes the
|
|
/// handler method named by `spec.entry_name`, and publishes the payload
|
|
/// onto `queue`.
|
|
fn emit_message_handler_harness(
|
|
spec: &HarnessSpec,
|
|
queue: &str,
|
|
entry_class: &str,
|
|
) -> HarnessSource {
|
|
let probe = probe_shim();
|
|
let handler = &spec.entry_name;
|
|
let broker = java_broker_for_adapter(spec);
|
|
|
|
let kafka_src = crate::dynamic::stubs::kafka_source(crate::symbol::Lang::Java);
|
|
let sqs_src = crate::dynamic::stubs::sqs_source(crate::symbol::Lang::Java);
|
|
let rabbit_src = crate::dynamic::stubs::rabbit_source(crate::symbol::Lang::Java);
|
|
|
|
let (publish_marker, dispatch_block) = match broker {
|
|
JavaBroker::Sqs => (
|
|
crate::dynamic::stubs::SQS_PUBLISH_MARKER,
|
|
format!(
|
|
r#" NyxSqsLoopback brokerRef = new NyxSqsLoopback();
|
|
brokerRef.subscribe({queue:?}, env -> {{
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
try {{
|
|
java.lang.reflect.Method m = entryInst.getClass().getDeclaredMethod({handler:?}, java.util.Map.class);
|
|
m.setAccessible(true);
|
|
m.invoke(entryInst, env);
|
|
}} catch (Exception e) {{
|
|
Throwable c = (e instanceof java.lang.reflect.InvocationTargetException && e.getCause() != null) ? e.getCause() : e;
|
|
System.err.println("NYX_EXCEPTION: " + c.getClass().getName() + ": " + c.getMessage());
|
|
}}
|
|
}});
|
|
System.out.println({publish_marker:?} + " " + {queue:?});
|
|
brokerRef.publish({queue:?}, payload);"#,
|
|
handler = handler,
|
|
queue = queue,
|
|
publish_marker = crate::dynamic::stubs::SQS_PUBLISH_MARKER,
|
|
),
|
|
),
|
|
JavaBroker::Rabbit => (
|
|
crate::dynamic::stubs::RABBIT_PUBLISH_MARKER,
|
|
format!(
|
|
r#" NyxRabbitChannel chan = new NyxRabbitChannel();
|
|
chan.basicConsume({queue:?}, (mid, body) -> {{
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
try {{
|
|
java.lang.reflect.Method m = entryInst.getClass().getDeclaredMethod({handler:?}, String.class, String.class);
|
|
m.setAccessible(true);
|
|
m.invoke(entryInst, mid, body);
|
|
}} catch (NoSuchMethodException nsme) {{
|
|
try {{
|
|
java.lang.reflect.Method m2 = entryInst.getClass().getDeclaredMethod({handler:?}, String.class);
|
|
m2.setAccessible(true);
|
|
m2.invoke(entryInst, body);
|
|
}} catch (Exception ie) {{
|
|
Throwable c = (ie instanceof java.lang.reflect.InvocationTargetException && ie.getCause() != null) ? ie.getCause() : ie;
|
|
System.err.println("NYX_EXCEPTION: " + c.getClass().getName() + ": " + c.getMessage());
|
|
}}
|
|
}} catch (Exception e) {{
|
|
Throwable c = (e instanceof java.lang.reflect.InvocationTargetException && e.getCause() != null) ? e.getCause() : e;
|
|
System.err.println("NYX_EXCEPTION: " + c.getClass().getName() + ": " + c.getMessage());
|
|
}}
|
|
}});
|
|
System.out.println({publish_marker:?} + " " + {queue:?});
|
|
chan.basicPublish("", {queue:?}, payload);"#,
|
|
handler = handler,
|
|
queue = queue,
|
|
publish_marker = crate::dynamic::stubs::RABBIT_PUBLISH_MARKER,
|
|
),
|
|
),
|
|
JavaBroker::Kafka => (
|
|
crate::dynamic::stubs::KAFKA_PUBLISH_MARKER,
|
|
format!(
|
|
r#" NyxKafkaLoopback brokerRef = new NyxKafkaLoopback();
|
|
brokerRef.subscribe({queue:?}, body -> {{
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
try {{
|
|
java.lang.reflect.Method m = entryInst.getClass().getDeclaredMethod({handler:?}, String.class);
|
|
m.setAccessible(true);
|
|
m.invoke(entryInst, body);
|
|
}} catch (Exception e) {{
|
|
Throwable c = (e instanceof java.lang.reflect.InvocationTargetException && e.getCause() != null) ? e.getCause() : e;
|
|
System.err.println("NYX_EXCEPTION: " + c.getClass().getName() + ": " + c.getMessage());
|
|
}}
|
|
}});
|
|
System.out.println({publish_marker:?} + " " + {queue:?});
|
|
brokerRef.publish({queue:?}, payload);"#,
|
|
handler = handler,
|
|
queue = queue,
|
|
publish_marker = crate::dynamic::stubs::KAFKA_PUBLISH_MARKER,
|
|
),
|
|
),
|
|
};
|
|
let _ = publish_marker;
|
|
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — message handler (Phase 20 / Track M.2).
|
|
import java.lang.reflect.Constructor;
|
|
import java.lang.reflect.Method;
|
|
|
|
public class NyxHarness {{
|
|
{probe}
|
|
|
|
{kafka_src}
|
|
{sqs_src}
|
|
{rabbit_src}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = nyxPayload();
|
|
try {{
|
|
Class<?> entryCls = Class.forName({entry_class:?});
|
|
Constructor<?> ctor = entryCls.getDeclaredConstructor();
|
|
ctor.setAccessible(true);
|
|
final Object entryInst = ctor.newInstance();
|
|
{dispatch_block}
|
|
}} catch (Throwable e) {{
|
|
System.err.println("NYX_EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage());
|
|
}}
|
|
}}
|
|
|
|
static String nyxPayload() {{
|
|
String v = System.getenv("NYX_PAYLOAD");
|
|
if (v != null && !v.isEmpty()) return v;
|
|
String b64 = System.getenv("NYX_PAYLOAD_B64");
|
|
if (b64 != null && !b64.isEmpty()) {{
|
|
byte[] decoded = java.util.Base64.getDecoder().decode(b64);
|
|
return new String(decoded, java.nio.charset.StandardCharsets.UTF_8);
|
|
}}
|
|
return "";
|
|
}}
|
|
}}
|
|
"#,
|
|
entry_class = entry_class,
|
|
dispatch_block = dispatch_block,
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files: vec![],
|
|
entry_subpath: Some(format!("{entry_class}.java")),
|
|
}
|
|
}
|
|
|
|
// ── Phase 21 (Track M.3) — synthetic entry-kind harnesses ─────────────────────
|
|
|
|
fn emit_scheduled_job_harness(
|
|
spec: &HarnessSpec,
|
|
schedule: Option<&str>,
|
|
entry_class: &str,
|
|
) -> HarnessSource {
|
|
let probe = probe_shim();
|
|
let pre_call = pre_call_setup(spec);
|
|
let method = &spec.entry_name;
|
|
let schedule_repr = schedule.unwrap_or("<unscheduled>");
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — scheduled job (Phase 21 / Track M.3).
|
|
import java.lang.reflect.Constructor;
|
|
import java.lang.reflect.Method;
|
|
import java.lang.reflect.InvocationTargetException;
|
|
|
|
public class NyxHarness {{
|
|
{probe}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = nyxPayload();
|
|
{pre_call} System.out.println("__NYX_SCHEDULED_JOB__: " + {schedule:?});
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
try {{
|
|
Class<?> cls = Class.forName({entry_class:?});
|
|
Constructor<?> ctor = cls.getDeclaredConstructor();
|
|
ctor.setAccessible(true);
|
|
Object instance = ctor.newInstance();
|
|
Method m = null;
|
|
for (Method candidate : cls.getDeclaredMethods()) {{
|
|
if (candidate.getName().equals({method:?})) {{ m = candidate; break; }}
|
|
}}
|
|
if (m == null) {{
|
|
System.err.println("NYX_METHOD_NOT_FOUND: " + {method:?});
|
|
System.exit(78);
|
|
}}
|
|
m.setAccessible(true);
|
|
Class<?>[] params = m.getParameterTypes();
|
|
Object[] mArgs = new Object[params.length];
|
|
for (int i = 0; i < params.length; i++) {{
|
|
mArgs[i] = params[i].equals(String.class) ? payload : null;
|
|
}}
|
|
m.invoke(instance, mArgs);
|
|
}} catch (InvocationTargetException ite) {{
|
|
Throwable cause = ite.getCause() == null ? ite : ite.getCause();
|
|
System.err.println("NYX_EXCEPTION: " + cause.getClass().getName() + ": " + cause.getMessage());
|
|
}} catch (Throwable e) {{
|
|
System.err.println("NYX_EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage());
|
|
}}
|
|
}}
|
|
|
|
static String nyxPayload() {{
|
|
String v = System.getenv("NYX_PAYLOAD");
|
|
if (v != null && !v.isEmpty()) return v;
|
|
String b64 = System.getenv("NYX_PAYLOAD_B64");
|
|
if (b64 != null && !b64.isEmpty()) {{
|
|
byte[] decoded = java.util.Base64.getDecoder().decode(b64);
|
|
return new String(decoded, java.nio.charset.StandardCharsets.UTF_8);
|
|
}}
|
|
return "";
|
|
}}
|
|
}}
|
|
"#,
|
|
entry_class = entry_class,
|
|
method = method,
|
|
schedule = schedule_repr,
|
|
pre_call = pre_call,
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files: vec![],
|
|
entry_subpath: Some(format!("{entry_class}.java")),
|
|
}
|
|
}
|
|
|
|
fn emit_middleware_harness(spec: &HarnessSpec, name: &str, entry_class: &str) -> HarnessSource {
|
|
let probe = probe_shim();
|
|
let pre_call = pre_call_setup(spec);
|
|
let method = &spec.entry_name;
|
|
let source = format!(
|
|
r#"// Nyx dynamic harness — middleware (Phase 21 / Track M.3).
|
|
import java.lang.reflect.Constructor;
|
|
import java.lang.reflect.Method;
|
|
import java.lang.reflect.InvocationTargetException;
|
|
|
|
public class NyxHarness {{
|
|
{probe}
|
|
|
|
public static void main(String[] args) {{
|
|
String payload = nyxPayload();
|
|
{pre_call} System.out.println("__NYX_MIDDLEWARE__: " + {name:?});
|
|
System.out.println("__NYX_SINK_HIT__");
|
|
try {{
|
|
Class<?> cls = Class.forName({entry_class:?});
|
|
Constructor<?> ctor = cls.getDeclaredConstructor();
|
|
ctor.setAccessible(true);
|
|
Object instance = ctor.newInstance();
|
|
Method m = null;
|
|
for (Method candidate : cls.getDeclaredMethods()) {{
|
|
if (candidate.getName().equals({method:?})) {{ m = candidate; break; }}
|
|
}}
|
|
if (m == null) {{
|
|
System.err.println("NYX_METHOD_NOT_FOUND: " + {method:?});
|
|
System.exit(78);
|
|
}}
|
|
m.setAccessible(true);
|
|
Class<?>[] params = m.getParameterTypes();
|
|
Object[] mArgs = new Object[params.length];
|
|
for (int i = 0; i < params.length; i++) {{
|
|
mArgs[i] = params[i].equals(String.class) ? payload : null;
|
|
}}
|
|
m.invoke(instance, mArgs);
|
|
}} catch (InvocationTargetException ite) {{
|
|
Throwable cause = ite.getCause() == null ? ite : ite.getCause();
|
|
System.err.println("NYX_EXCEPTION: " + cause.getClass().getName() + ": " + cause.getMessage());
|
|
}} catch (Throwable e) {{
|
|
System.err.println("NYX_EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage());
|
|
}}
|
|
}}
|
|
|
|
static String nyxPayload() {{
|
|
String v = System.getenv("NYX_PAYLOAD");
|
|
if (v != null && !v.isEmpty()) return v;
|
|
String b64 = System.getenv("NYX_PAYLOAD_B64");
|
|
if (b64 != null && !b64.isEmpty()) {{
|
|
byte[] decoded = java.util.Base64.getDecoder().decode(b64);
|
|
return new String(decoded, java.nio.charset.StandardCharsets.UTF_8);
|
|
}}
|
|
return "";
|
|
}}
|
|
}}
|
|
"#,
|
|
entry_class = entry_class,
|
|
method = method,
|
|
name = name,
|
|
pre_call = pre_call,
|
|
);
|
|
HarnessSource {
|
|
source,
|
|
filename: "NyxHarness.java".to_owned(),
|
|
command: vec![
|
|
"java".to_owned(),
|
|
"-cp".to_owned(),
|
|
".".to_owned(),
|
|
"NyxHarness".to_owned(),
|
|
],
|
|
extra_files: vec![],
|
|
entry_subpath: Some(format!("{entry_class}.java")),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum JavaBroker {
|
|
Kafka,
|
|
Sqs,
|
|
Rabbit,
|
|
}
|
|
|
|
fn java_broker_for_adapter(spec: &HarnessSpec) -> JavaBroker {
|
|
let adapter = spec
|
|
.framework
|
|
.as_ref()
|
|
.map(|b| b.adapter.as_str())
|
|
.unwrap_or("");
|
|
match adapter {
|
|
"sqs-java" => JavaBroker::Sqs,
|
|
"rabbit-java" => JavaBroker::Rabbit,
|
|
_ => JavaBroker::Kafka,
|
|
}
|
|
}
|
|
|
|
/// Reflective JUnit-shape invocation. Reads the payload from
|
|
/// `NYX_PAYLOAD` (no method argument) — JUnit tests typically capture
|
|
/// inputs through fields or `System.getenv`.
|
|
const JUNIT_HELPER: &str = r#"
|
|
static Object newDefaultInstance(Class<?> cls) throws Exception {
|
|
Constructor<?> ctor = cls.getDeclaredConstructor();
|
|
ctor.setAccessible(true);
|
|
return ctor.newInstance();
|
|
}
|
|
|
|
static void invokeJunitTest(Class<?> cls, String methodName) throws Exception {
|
|
Method match = null;
|
|
for (Method m : cls.getDeclaredMethods()) {
|
|
if (m.getName().equals(methodName)) { match = m; break; }
|
|
}
|
|
if (match == null) {
|
|
throw new NoSuchMethodException(cls.getName() + "." + methodName);
|
|
}
|
|
match.setAccessible(true);
|
|
Object instance = null;
|
|
if (!java.lang.reflect.Modifier.isStatic(match.getModifiers())) {
|
|
instance = newDefaultInstance(cls);
|
|
}
|
|
match.invoke(instance);
|
|
}
|
|
"#;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot};
|
|
use crate::labels::Cap;
|
|
use crate::symbol::Lang;
|
|
|
|
fn make_spec(payload_slot: PayloadSlot) -> HarnessSpec {
|
|
HarnessSpec {
|
|
finding_id: "java00000000001".into(),
|
|
entry_file: "src/main/java/App.java".into(),
|
|
entry_name: "processInput".into(),
|
|
entry_kind: EntryKind::Function,
|
|
lang: Lang::Java,
|
|
toolchain_id: "java-21".into(),
|
|
payload_slot,
|
|
expected_cap: Cap::SQL_QUERY,
|
|
constraint_hints: vec![],
|
|
sink_file: "src/main/java/App.java".into(),
|
|
sink_line: 25,
|
|
spec_hash: "java00000000001".into(),
|
|
derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
|
stubs_required: vec![],
|
|
framework: None,
|
|
java_toolchain: crate::dynamic::spec::JavaToolchain::default(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn emit_produces_source() {
|
|
let spec = make_spec(PayloadSlot::Param(0));
|
|
let harness = emit(&spec).unwrap();
|
|
assert!(harness.source.contains("public class NyxHarness"));
|
|
assert!(harness.source.contains("nyxPayload()"));
|
|
assert!(harness.source.contains("Entry.processInput(payload)"));
|
|
assert_eq!(harness.filename, "NyxHarness.java");
|
|
assert_eq!(harness.command, vec!["java", "-cp", ".", "NyxHarness"]);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_entry_subpath_default_static_method_is_entry_java() {
|
|
let spec = make_spec(PayloadSlot::Param(0));
|
|
let harness = emit(&spec).unwrap();
|
|
assert_eq!(harness.entry_subpath, Some("Entry.java".to_owned()));
|
|
}
|
|
|
|
#[test]
|
|
fn emit_env_var_slot() {
|
|
let spec = make_spec(PayloadSlot::EnvVar("DB_PASSWORD".into()));
|
|
let harness = emit(&spec).unwrap();
|
|
assert!(harness.source.contains("System.setProperty"));
|
|
assert!(harness.source.contains("\"DB_PASSWORD\""));
|
|
}
|
|
|
|
#[test]
|
|
fn emit_param_gt_0_is_accepted_for_static_method() {
|
|
// Phase 14: PayloadSlot::Param(n>0) is no longer rejected; the
|
|
// emitter routes the payload via the first-arg slot regardless
|
|
// (the runner has already pinned the slot at spec time).
|
|
let spec = make_spec(PayloadSlot::Param(1));
|
|
let harness = emit(&spec).unwrap();
|
|
assert!(harness.source.contains("processInput(payload)"));
|
|
}
|
|
|
|
#[test]
|
|
fn emit_stdin_is_unsupported() {
|
|
let spec = make_spec(PayloadSlot::Stdin);
|
|
let err = emit(&spec).unwrap_err();
|
|
assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported);
|
|
}
|
|
|
|
#[test]
|
|
fn entry_kinds_supported_is_non_empty() {
|
|
assert!(!JavaEmitter.entry_kinds_supported().is_empty());
|
|
assert!(
|
|
JavaEmitter
|
|
.entry_kinds_supported()
|
|
.contains(&EntryKindTag::Function)
|
|
);
|
|
assert!(
|
|
JavaEmitter
|
|
.entry_kinds_supported()
|
|
.contains(&EntryKindTag::HttpRoute)
|
|
);
|
|
assert!(
|
|
JavaEmitter
|
|
.entry_kinds_supported()
|
|
.contains(&EntryKindTag::CliSubcommand)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn entry_kind_hint_names_attempted_and_phase() {
|
|
let hint = JavaEmitter.entry_kind_hint(EntryKindTag::LibraryApi);
|
|
assert!(hint.contains("LibraryApi"));
|
|
assert!(hint.contains("Phase 14"));
|
|
}
|
|
|
|
#[test]
|
|
fn harness_has_base64_decoder() {
|
|
let spec = make_spec(PayloadSlot::Param(0));
|
|
let harness = emit(&spec).unwrap();
|
|
assert!(harness.source.contains("Base64.getDecoder()"));
|
|
assert!(harness.source.contains("NYX_PAYLOAD_B64"));
|
|
}
|
|
|
|
// ── Phase 14: shape detection ────────────────────────────────────────────
|
|
|
|
fn make_spec_with(kind: EntryKind, name: &str, entry_file: &str) -> HarnessSpec {
|
|
let mut s = make_spec(PayloadSlot::Param(0));
|
|
s.entry_kind = kind;
|
|
s.entry_name = name.to_owned();
|
|
s.entry_file = entry_file.to_owned();
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn shape_detect_servlet_doget() {
|
|
let src = "import javax.servlet.http.HttpServletRequest;\npublic class V extends HttpServlet { public void doGet(HttpServletRequest r, HttpServletResponse w) {} }";
|
|
let spec = make_spec_with(EntryKind::HttpRoute, "doGet", "V.java");
|
|
assert_eq!(JavaShape::detect(&spec, src), JavaShape::ServletDoGet);
|
|
}
|
|
|
|
#[test]
|
|
fn shape_detect_servlet_dopost() {
|
|
let src = "import jakarta.servlet.http.HttpServletRequest;\npublic class V extends HttpServlet { public void doPost(HttpServletRequest r, HttpServletResponse w) {} }";
|
|
let spec = make_spec_with(EntryKind::HttpRoute, "doPost", "V.java");
|
|
assert_eq!(JavaShape::detect(&spec, src), JavaShape::ServletDoPost);
|
|
}
|
|
|
|
#[test]
|
|
fn shape_detect_spring_controller() {
|
|
let src = "@RestController\npublic class V { @GetMapping(\"/x\") public String run(String p) { return p; } }";
|
|
let spec = make_spec_with(EntryKind::HttpRoute, "run", "V.java");
|
|
assert_eq!(JavaShape::detect(&spec, src), JavaShape::SpringController);
|
|
}
|
|
|
|
#[test]
|
|
fn shape_detect_quarkus_route() {
|
|
let src = "import jakarta.ws.rs.GET;\n@Path(\"/x\")\npublic class V { @GET public String run(String p) { return p; } }";
|
|
let spec = make_spec_with(EntryKind::HttpRoute, "run", "V.java");
|
|
assert_eq!(JavaShape::detect(&spec, src), JavaShape::QuarkusRoute);
|
|
}
|
|
|
|
#[test]
|
|
fn shape_detect_micronaut_route() {
|
|
let src = "import io.micronaut.http.annotation.Controller;\nimport io.micronaut.http.annotation.Get;\n@Controller(\"/x\")\npublic class V { @Get(\"/y\") public String run(String p) { return p; } }";
|
|
let spec = make_spec_with(EntryKind::HttpRoute, "run", "V.java");
|
|
assert_eq!(JavaShape::detect(&spec, src), JavaShape::MicronautRoute);
|
|
}
|
|
|
|
#[test]
|
|
fn shape_detect_static_main() {
|
|
let src = "public class V { public static void main(String[] args) {} }";
|
|
let spec = make_spec_with(EntryKind::CliSubcommand, "main", "V.java");
|
|
assert_eq!(JavaShape::detect(&spec, src), JavaShape::StaticMain);
|
|
}
|
|
|
|
#[test]
|
|
fn shape_detect_junit_test() {
|
|
let src =
|
|
"import org.junit.jupiter.api.Test;\npublic class V { @Test public void testRun() {} }";
|
|
let spec = make_spec_with(EntryKind::Function, "testRun", "V.java");
|
|
assert_eq!(JavaShape::detect(&spec, src), JavaShape::JunitTest);
|
|
}
|
|
|
|
#[test]
|
|
fn shape_detect_static_method_fallback() {
|
|
let src = "public class V { public static void run(String p) {} }";
|
|
let spec = make_spec_with(EntryKind::Function, "run", "V.java");
|
|
assert_eq!(JavaShape::detect(&spec, src), JavaShape::StaticMethod);
|
|
}
|
|
|
|
#[test]
|
|
fn servlet_shape_emits_reflective_invocation() {
|
|
let spec = make_spec_with(EntryKind::HttpRoute, "doGet", "Vuln.java");
|
|
let src = generate_harness_java(&spec, JavaShape::ServletDoGet, "Vuln");
|
|
assert!(src.contains("invokeServlet(Vuln.class"));
|
|
assert!(src.contains("buildRequestStub"));
|
|
}
|
|
|
|
#[test]
|
|
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\""));
|
|
}
|
|
|
|
#[test]
|
|
fn quarkus_shape_emits_reflective_invocation() {
|
|
let spec = make_spec_with(EntryKind::HttpRoute, "run", "Vuln.java");
|
|
let src = generate_harness_java(&spec, JavaShape::QuarkusRoute, "Vuln");
|
|
assert!(src.contains("invokeReflective(Vuln.class, \"run\""));
|
|
}
|
|
|
|
#[test]
|
|
fn micronaut_shape_emits_reflective_invocation() {
|
|
let spec = make_spec_with(EntryKind::HttpRoute, "run", "Vuln.java");
|
|
let src = generate_harness_java(&spec, JavaShape::MicronautRoute, "Vuln");
|
|
assert!(src.contains("invokeReflective(Vuln.class, \"run\""));
|
|
}
|
|
|
|
#[test]
|
|
fn spring_shape_emits_marker_when_with_spring_test() {
|
|
let mut spec = make_spec_with(EntryKind::HttpRoute, "run", "Vuln.java");
|
|
spec.java_toolchain.with_spring_test = true;
|
|
let src = generate_harness_java(&spec, JavaShape::SpringController, "Vuln");
|
|
assert!(src.contains("NYX_SPRING_TEST=1"));
|
|
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"));
|
|
}
|
|
|
|
#[test]
|
|
fn static_main_shape_passes_argv() {
|
|
let spec = make_spec_with(EntryKind::CliSubcommand, "main", "Vuln.java");
|
|
let src = generate_harness_java(&spec, JavaShape::StaticMain, "Vuln");
|
|
assert!(src.contains("Vuln.main(mainArgs)"));
|
|
assert!(src.contains("new String[] { payload }"));
|
|
}
|
|
|
|
#[test]
|
|
fn junit_shape_emits_reflective_invocation() {
|
|
let spec = make_spec_with(EntryKind::Function, "testRun", "Vuln.java");
|
|
let src = generate_harness_java(&spec, JavaShape::JunitTest, "Vuln");
|
|
assert!(src.contains("invokeJunitTest(Vuln.class"));
|
|
}
|
|
|
|
// ── Servlet stub bundle (path (a) of Phase 31 budget gate) ──────────────
|
|
|
|
fn stage_entry(dir: &std::path::Path, name: &str, body: &str) -> String {
|
|
let path = dir.join(name);
|
|
std::fs::write(&path, body).expect("stage java entry source");
|
|
path.to_string_lossy().into_owned()
|
|
}
|
|
|
|
#[test]
|
|
fn emit_servlet_doget_carries_servlet_stub_bundle() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let entry_file = stage_entry(
|
|
tmp.path(),
|
|
"Vuln.java",
|
|
"import javax.servlet.http.HttpServletRequest;\nimport javax.servlet.http.HttpServletResponse;\npublic class Vuln {\n public void doGet(HttpServletRequest r, HttpServletResponse w) {}\n}\n",
|
|
);
|
|
let mut spec = make_spec_with(EntryKind::HttpRoute, "doGet", &entry_file);
|
|
spec.payload_slot = PayloadSlot::QueryParam("payload".into());
|
|
let harness = emit(&spec).unwrap();
|
|
let paths: Vec<&str> = harness
|
|
.extra_files
|
|
.iter()
|
|
.map(|(p, _)| p.as_str())
|
|
.collect();
|
|
assert!(
|
|
paths.contains(&"javax/servlet/http/HttpServletRequest.java"),
|
|
"doGet bundle missing javax HttpServletRequest stub; got {paths:?}"
|
|
);
|
|
assert!(
|
|
paths.contains(&"jakarta/servlet/http/HttpServletRequest.java"),
|
|
"doGet bundle missing jakarta HttpServletRequest stub; got {paths:?}"
|
|
);
|
|
assert!(paths.contains(&"javax/servlet/annotation/WebServlet.java"));
|
|
assert!(paths.contains(&"javax/servlet/ServletException.java"));
|
|
}
|
|
|
|
#[test]
|
|
fn emit_servlet_dopost_carries_servlet_stub_bundle() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let entry_file = stage_entry(
|
|
tmp.path(),
|
|
"Vuln.java",
|
|
"import jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\npublic class Vuln {\n public void doPost(HttpServletRequest r, HttpServletResponse w) {}\n}\n",
|
|
);
|
|
let mut spec = make_spec_with(EntryKind::HttpRoute, "doPost", &entry_file);
|
|
spec.payload_slot = PayloadSlot::HttpBody;
|
|
let harness = emit(&spec).unwrap();
|
|
assert!(!harness.extra_files.is_empty(), "doPost bundle is empty");
|
|
let paths: Vec<&str> = harness
|
|
.extra_files
|
|
.iter()
|
|
.map(|(p, _)| p.as_str())
|
|
.collect();
|
|
assert!(paths.contains(&"javax/servlet/http/HttpServlet.java"));
|
|
assert!(paths.contains(&"jakarta/servlet/http/HttpServlet.java"));
|
|
}
|
|
|
|
#[test]
|
|
fn emit_static_method_carries_no_extra_files() {
|
|
// Regression guard: non-servlet shapes must not pay the servlet
|
|
// stub cost. Adding stubs would balloon the workdir + compile
|
|
// time for every Rust / Python / etc. harness too.
|
|
let spec = make_spec(PayloadSlot::Param(0));
|
|
let harness = emit(&spec).unwrap();
|
|
assert!(
|
|
harness.extra_files.is_empty(),
|
|
"non-servlet shape unexpectedly ships extra files: {:?}",
|
|
harness
|
|
.extra_files
|
|
.iter()
|
|
.map(|(p, _)| p)
|
|
.collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_static_main_carries_no_extra_files() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let entry_file = stage_entry(
|
|
tmp.path(),
|
|
"Vuln.java",
|
|
"public class Vuln { public static void main(String[] args) {} }\n",
|
|
);
|
|
let spec = make_spec_with(EntryKind::CliSubcommand, "main", &entry_file);
|
|
let harness = emit(&spec).unwrap();
|
|
assert!(harness.extra_files.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn emit_servlet_doget_bundles_owasp_stubs_when_source_imports_owasp() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let entry_file = stage_entry(
|
|
tmp.path(),
|
|
"BenchmarkTest00001.java",
|
|
"package org.owasp.benchmark.testcode;\nimport javax.servlet.http.HttpServletRequest;\nimport javax.servlet.http.HttpServletResponse;\nimport javax.servlet.http.HttpServlet;\nimport org.owasp.benchmark.helpers.Utils;\nimport org.owasp.esapi.ESAPI;\npublic class BenchmarkTest00001 extends HttpServlet {\n public void doGet(HttpServletRequest r, HttpServletResponse w) {}\n}\n",
|
|
);
|
|
let spec = make_spec_with(EntryKind::HttpRoute, "doGet", &entry_file);
|
|
let harness = emit(&spec).unwrap();
|
|
let paths: Vec<&str> = harness
|
|
.extra_files
|
|
.iter()
|
|
.map(|(p, _)| p.as_str())
|
|
.collect();
|
|
// Servlet stubs are present (same as the non-OWASP servlet case).
|
|
assert!(paths.contains(&"javax/servlet/http/HttpServletRequest.java"));
|
|
// OWASP helpers + esapi + spring stubs are appended.
|
|
assert!(paths.contains(&"org/owasp/benchmark/helpers/Utils.java"));
|
|
assert!(paths.contains(&"org/owasp/esapi/ESAPI.java"));
|
|
assert!(paths.contains(&"org/owasp/benchmark/helpers/DatabaseHelper.java"));
|
|
assert!(paths.contains(&"org/springframework/jdbc/core/RowMapper.java"));
|
|
}
|
|
|
|
#[test]
|
|
fn emit_servlet_doget_skips_owasp_stubs_when_source_is_plain() {
|
|
// Servlet entry without OWASP / Spring imports must only carry
|
|
// the servlet stub bundle, not the OWASP add-on. Keeps workdir
|
|
// small for the existing servlet_doget fixture path.
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let entry_file = stage_entry(
|
|
tmp.path(),
|
|
"Vuln.java",
|
|
"import javax.servlet.http.HttpServletRequest;\nimport javax.servlet.http.HttpServletResponse;\npublic class Vuln {\n public void doGet(HttpServletRequest r, HttpServletResponse w) {}\n}\n",
|
|
);
|
|
let spec = make_spec_with(EntryKind::HttpRoute, "doGet", &entry_file);
|
|
let harness = emit(&spec).unwrap();
|
|
let paths: Vec<&str> = harness
|
|
.extra_files
|
|
.iter()
|
|
.map(|(p, _)| p.as_str())
|
|
.collect();
|
|
assert!(
|
|
!paths.iter().any(|p| p.starts_with("org/owasp/")),
|
|
"plain servlet entry unexpectedly bundles OWASP stubs: {paths:?}"
|
|
);
|
|
assert!(
|
|
!paths.iter().any(|p| p.starts_with("org/springframework/")),
|
|
"plain servlet entry unexpectedly bundles Spring stubs: {paths:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_static_method_with_owasp_imports_bundles_helpers() {
|
|
// Non-servlet shapes still need the OWASP stub set when the
|
|
// entry source pulls in helpers (e.g. a plain @Test fixture
|
|
// calling `Utils.encodeForHTML`).
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let entry_file = stage_entry(
|
|
tmp.path(),
|
|
"Vuln.java",
|
|
"import org.owasp.benchmark.helpers.Utils;\npublic class Vuln {\n public static void run(String p) { Utils.encodeForHTML(p); }\n}\n",
|
|
);
|
|
let spec = make_spec_with(EntryKind::Function, "run", &entry_file);
|
|
let harness = emit(&spec).unwrap();
|
|
let paths: Vec<&str> = harness
|
|
.extra_files
|
|
.iter()
|
|
.map(|(p, _)| p.as_str())
|
|
.collect();
|
|
assert!(paths.contains(&"org/owasp/benchmark/helpers/Utils.java"));
|
|
// No servlet stubs for a non-servlet shape.
|
|
assert!(!paths.iter().any(|p| p.starts_with("javax/servlet/")));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_package_name_handles_packaged_source() {
|
|
assert_eq!(
|
|
parse_package_name("package org.owasp.benchmark.testcode;\nclass X {}\n"),
|
|
Some("org.owasp.benchmark.testcode".to_owned())
|
|
);
|
|
// Leading whitespace + extra spaces inside the line are tolerated.
|
|
assert_eq!(
|
|
parse_package_name(" package a.b.c ;\n"),
|
|
Some("a.b.c".to_owned())
|
|
);
|
|
// Leading comments / blank lines must not cause an early miss.
|
|
assert_eq!(
|
|
parse_package_name("// header comment\n/* block */\npackage com.example;\n"),
|
|
Some("com.example".to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_package_name_returns_none_when_absent() {
|
|
assert_eq!(parse_package_name(""), None);
|
|
assert_eq!(parse_package_name("public class X {}\n"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn derive_entry_qualifier_uses_package_when_present() {
|
|
let src = "package org.owasp.benchmark.testcode;\npublic class BenchmarkTest00001 {}\n";
|
|
assert_eq!(
|
|
derive_entry_qualifier(src, "BenchmarkTest00001"),
|
|
"org.owasp.benchmark.testcode.BenchmarkTest00001"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn derive_entry_qualifier_falls_back_to_simple_name() {
|
|
assert_eq!(derive_entry_qualifier("", "Vuln"), "Vuln");
|
|
assert_eq!(
|
|
derive_entry_qualifier("public class Vuln {}", "Vuln"),
|
|
"Vuln"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_static_method_with_packaged_source_uses_fqn_in_harness() {
|
|
// Packaged entry sources must be addressed by FQN in the
|
|
// generated NyxHarness, otherwise javac fails with
|
|
// `cannot find symbol: class <simple_name>` because the
|
|
// packaged .class lives under `org/owasp/.../<simple>.class`
|
|
// and NyxHarness itself sits in the default package.
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let entry_file = stage_entry(
|
|
tmp.path(),
|
|
"Vuln.java",
|
|
"package org.example;\npublic class Vuln { public static void run(String p) {} }\n",
|
|
);
|
|
let spec = make_spec_with(EntryKind::Function, "run", &entry_file);
|
|
let harness = emit(&spec).unwrap();
|
|
assert!(
|
|
harness.source.contains("org.example.Vuln.run(payload)"),
|
|
"harness must address packaged entry via FQN; got source:\n{}",
|
|
harness.source
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_spring_controller_carries_no_servlet_stubs() {
|
|
// Spring controllers do not import `javax.servlet.*`; shipping
|
|
// the bundle would still compile fine but adds dead `.class`
|
|
// files to the workdir. Keep the bundle scoped to actual
|
|
// servlet shapes.
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let entry_file = stage_entry(
|
|
tmp.path(),
|
|
"Vuln.java",
|
|
"@RestController\npublic class Vuln {\n @GetMapping(\"/x\") public String run(String p) { return p; }\n}\n",
|
|
);
|
|
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());
|
|
}
|
|
|
|
#[test]
|
|
fn entry_class_parses_public_class_declaration() {
|
|
assert_eq!(derive_entry_class("public class Vuln {}"), "Vuln");
|
|
assert_eq!(derive_entry_class("public final class Foo {}"), "Foo");
|
|
assert_eq!(derive_entry_class("public abstract class Bar {}"), "Bar");
|
|
// No public class → "Entry" fallback.
|
|
assert_eq!(derive_entry_class(""), "Entry");
|
|
assert_eq!(derive_entry_class("class Pkg {}"), "Entry");
|
|
}
|
|
|
|
#[test]
|
|
fn entry_subpath_matches_public_class() {
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
// Path does not exist on disk → derive_entry_class falls back
|
|
// to "Entry" → subpath is "Entry.java".
|
|
spec.entry_file = "/nonexistent/Vuln.java".into();
|
|
let harness = emit(&spec).unwrap();
|
|
assert_eq!(harness.entry_subpath, Some("Entry.java".to_owned()));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_shim_publishes_stub_http_recorder() {
|
|
let shim = probe_shim();
|
|
assert!(
|
|
shim.contains("static void __nyx_stub_http_record"),
|
|
"Java probe shim must define __nyx_stub_http_record"
|
|
);
|
|
assert!(
|
|
shim.contains("\"NYX_HTTP_LOG\""),
|
|
"Java HTTP recorder must read NYX_HTTP_LOG to find the side-channel log"
|
|
);
|
|
assert!(
|
|
shim.contains("\"method: \""),
|
|
"Java HTTP recorder must emit a method detail line"
|
|
);
|
|
assert!(
|
|
shim.contains("\"url: \""),
|
|
"Java HTTP recorder must emit a url detail line"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_shim_publishes_stub_sql_recorder() {
|
|
let shim = probe_shim();
|
|
assert!(
|
|
shim.contains("static void __nyx_stub_sql_record"),
|
|
"Java probe shim must define __nyx_stub_sql_record"
|
|
);
|
|
assert!(
|
|
shim.contains("\"NYX_SQL_LOG\""),
|
|
"Java SQL recorder must read NYX_SQL_LOG to find the side-channel log"
|
|
);
|
|
assert!(
|
|
shim.contains("query.endsWith(\"\\n\")"),
|
|
"Java SQL recorder must guarantee a trailing newline on the query line so SqlStub::drain_events frames each record"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn chain_step_splices_probe_shim_for_composite_reverify() {
|
|
let step = chain_step(Some(b"<prev>"), None);
|
|
assert!(
|
|
step.source.contains("__nyx_probe"),
|
|
"Java chain step must splice the probe shim"
|
|
);
|
|
assert!(
|
|
step.source.starts_with("public class Step {"),
|
|
"Java chain step must open with the `public class Step {{` declaration"
|
|
);
|
|
assert!(
|
|
step.source.contains("System.getenv(\"NYX_PREV_OUTPUT\")"),
|
|
"Java chain step must keep its NYX_PREV_OUTPUT forwarder"
|
|
);
|
|
let shim_pos = step.source.find("__nyx_probe").unwrap();
|
|
let driver_pos = step
|
|
.source
|
|
.find("System.getenv(\"NYX_PREV_OUTPUT\")")
|
|
.unwrap();
|
|
assert!(
|
|
shim_pos < driver_pos,
|
|
"probe shim must come before the driver so the shim's helpers are in scope when a sink rewrite splices in"
|
|
);
|
|
let main_pos = step.source.find("public static void main").unwrap();
|
|
assert!(
|
|
shim_pos < main_pos,
|
|
"probe shim members must be declared before `main` so the class compiles cleanly"
|
|
);
|
|
assert_eq!(step.filename, "Step.java");
|
|
}
|
|
|
|
#[test]
|
|
fn detect_shape_reads_file_and_returns_shape() {
|
|
// Drive the public `detect_shape(spec)` wrapper end-to-end:
|
|
// write a representative source to a tempfile, then assert the
|
|
// wrapper reads it and produces the expected JavaShape variant.
|
|
let dir = std::env::temp_dir().join(format!("nyx_detect_shape_{}", std::process::id()));
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
let cases: &[(&str, &str, &str, EntryKind, JavaShape)] = &[
|
|
(
|
|
"Servlet.java",
|
|
"import javax.servlet.http.HttpServletRequest;\npublic class Servlet extends HttpServlet { public void doGet(HttpServletRequest r, HttpServletResponse w) {} }",
|
|
"doGet",
|
|
EntryKind::HttpRoute,
|
|
JavaShape::ServletDoGet,
|
|
),
|
|
(
|
|
"Spring.java",
|
|
"@RestController\npublic class Spring { @GetMapping(\"/x\") public String run(String p) { return p; } }",
|
|
"run",
|
|
EntryKind::HttpRoute,
|
|
JavaShape::SpringController,
|
|
),
|
|
(
|
|
"MainClass.java",
|
|
"public class MainClass { public static void main(String[] args) {} }",
|
|
"main",
|
|
EntryKind::CliSubcommand,
|
|
JavaShape::StaticMain,
|
|
),
|
|
(
|
|
"Plain.java",
|
|
"public class Plain { public static void run(String p) {} }",
|
|
"run",
|
|
EntryKind::Function,
|
|
JavaShape::StaticMethod,
|
|
),
|
|
];
|
|
for (name, body, entry_name, kind, expected) in cases {
|
|
let path = dir.join(name);
|
|
std::fs::write(&path, body).expect("write fixture");
|
|
let spec = make_spec_with(kind.clone(), entry_name, path.to_str().unwrap());
|
|
assert_eq!(detect_shape(&spec), *expected, "case {name}");
|
|
}
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
fn make_ldap_spec() -> HarnessSpec {
|
|
let mut s = make_spec(PayloadSlot::Param(0));
|
|
s.expected_cap = Cap::LDAP_INJECTION;
|
|
s.entry_name = "run".into();
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn emit_ldap_harness_routes_through_stub_when_endpoint_set() {
|
|
let h = emit_ldap_harness(&make_ldap_spec());
|
|
assert!(
|
|
h.source.contains("NYX_LDAP_ENDPOINT"),
|
|
"Java LDAP harness must read NYX_LDAP_ENDPOINT to route through the stub",
|
|
);
|
|
assert!(
|
|
h.source
|
|
.contains("javax.naming.directory.InitialDirContext"),
|
|
"Java LDAP harness must import the JNDI InitialDirContext for the BER round-trip",
|
|
);
|
|
assert!(
|
|
h.source.contains("new InitialDirContext(env)"),
|
|
"Java LDAP harness must construct an InitialDirContext bound at the stub endpoint",
|
|
);
|
|
assert!(
|
|
h.source.contains("\"ldap://\" + ep + \"/\""),
|
|
"Java LDAP harness must compose an ldap:// PROVIDER_URL from NYX_LDAP_ENDPOINT",
|
|
);
|
|
assert!(
|
|
h.source.contains("ctx.search(\"\", filter, controls)"),
|
|
"Java LDAP harness must dispatch DirContext.search over LDAPv3 BER",
|
|
);
|
|
assert!(
|
|
h.source.contains("com.sun.jndi.ldap.LdapCtxFactory"),
|
|
"Java LDAP harness must select the JDK LDAP context factory",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_ldap_harness_retains_local_matcher_fallback() {
|
|
let h = emit_ldap_harness(&make_ldap_spec());
|
|
assert!(
|
|
h.source.contains("nyxLdapCountLocal"),
|
|
"Java LDAP harness must keep the in-process matcher as a fallback for hosts without the stub",
|
|
);
|
|
assert!(
|
|
h.source.contains("nyxLdapCountViaJndi"),
|
|
"Java LDAP harness must dispatch through the JNDI stub-route helper",
|
|
);
|
|
}
|
|
|
|
fn write_servlet_fixture(dir: &std::path::Path, body: &str) -> String {
|
|
let path = dir.join("Vuln.java");
|
|
std::fs::write(&path, body).unwrap();
|
|
path.to_string_lossy().into_owned()
|
|
}
|
|
|
|
#[test]
|
|
fn emit_header_injection_harness_drives_fixture_through_stub_when_servlet_present() {
|
|
let dir = std::env::temp_dir().join("nyx_phase08_test_drive_fixture");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let entry = write_servlet_fixture(
|
|
&dir,
|
|
"import javax.servlet.http.HttpServletResponse;\n\
|
|
public class Vuln {\n public static void run(HttpServletResponse r, String v) {\n r.setHeader(\"Set-Cookie\", v);\n }\n}\n",
|
|
);
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::HEADER_INJECTION;
|
|
spec.entry_file = entry;
|
|
spec.entry_name = "run".into();
|
|
let h = emit_header_injection_harness(&spec);
|
|
assert!(
|
|
!h.extra_files.is_empty(),
|
|
"servlet-importing fixture must trigger stub-file emission",
|
|
);
|
|
assert!(
|
|
h.source.contains(
|
|
"HttpServletResponse response = new javax.servlet.http.HttpServletResponse()"
|
|
),
|
|
"Java HEADER_INJECTION harness must instantiate the captured-header response wrapper",
|
|
);
|
|
assert!(
|
|
h.source.contains("Class.forName(\"Vuln\")"),
|
|
"Java HEADER_INJECTION harness must reflectively load the fixture entry class",
|
|
);
|
|
assert!(
|
|
h.source.contains("nyxDrainHeaders()"),
|
|
"Java HEADER_INJECTION harness must drain captured headers after invoking the fixture",
|
|
);
|
|
assert!(
|
|
h.source.contains("for (String[] pair : captured)"),
|
|
"Java HEADER_INJECTION harness must emit one probe per captured (name, value) pair",
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_header_injection_harness_uses_jakarta_namespace_for_jakarta_imports() {
|
|
let dir = std::env::temp_dir().join("nyx_phase08_test_jakarta_ns");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let entry = write_servlet_fixture(
|
|
&dir,
|
|
"import jakarta.servlet.http.HttpServletResponse;\n\
|
|
public class Vuln {\n public static void run(HttpServletResponse r, String v) {\n r.setHeader(\"Set-Cookie\", v);\n }\n}\n",
|
|
);
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::HEADER_INJECTION;
|
|
spec.entry_file = entry;
|
|
spec.entry_name = "run".into();
|
|
let h = emit_header_injection_harness(&spec);
|
|
assert!(
|
|
h.source
|
|
.contains("jakarta.servlet.http.HttpServletResponse"),
|
|
"Java HEADER_INJECTION harness must follow the entry source's servlet namespace",
|
|
);
|
|
assert!(
|
|
!h.source
|
|
.contains("javax.servlet.http.HttpServletResponse response"),
|
|
"Jakarta entry must not instantiate javax response wrapper",
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_header_injection_harness_falls_back_to_synthetic_probe_without_servlet() {
|
|
let dir = std::env::temp_dir().join("nyx_phase08_test_no_servlet");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let entry = write_servlet_fixture(
|
|
&dir,
|
|
"public class Vuln { public static void run(String v) { System.out.println(v); } }\n",
|
|
);
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::HEADER_INJECTION;
|
|
spec.entry_file = entry;
|
|
spec.entry_name = "run".into();
|
|
let h = emit_header_injection_harness(&spec);
|
|
assert!(
|
|
h.extra_files.is_empty(),
|
|
"non-servlet fixture must not ship servlet stubs",
|
|
);
|
|
assert!(
|
|
!h.source.contains("nyxDrainHeaders()"),
|
|
"non-servlet fixture must skip the stub-driven capture path",
|
|
);
|
|
assert!(
|
|
h.source.contains("nyxHeaderProbe(\"Set-Cookie\", payload)"),
|
|
"non-servlet fixture must keep the synthetic-probe fallback",
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_header_injection_harness_routes_through_wire_frame_when_raw_socket_imported() {
|
|
let dir = std::env::temp_dir().join("nyx_phase08_java_test_wire_frame");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let entry = write_servlet_fixture(
|
|
&dir,
|
|
"import java.net.ServerSocket;\n\
|
|
public class Vuln {\n \
|
|
public static void setCookieValue(byte[] value) {}\n \
|
|
public static ServerSocket createServer() throws java.io.IOException { return new ServerSocket(0); }\n \
|
|
public static void runOnce(ServerSocket server) {}\n\
|
|
}\n",
|
|
);
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::HEADER_INJECTION;
|
|
spec.entry_file = entry;
|
|
spec.entry_name = "run".into();
|
|
let h = emit_header_injection_harness(&spec);
|
|
assert!(
|
|
h.extra_files.is_empty(),
|
|
"tier-(b) wire-frame harness must not ship servlet stubs: {:?}",
|
|
h.extra_files,
|
|
);
|
|
assert!(
|
|
h.source
|
|
.contains("static byte[] nyxWireFrameViaFixture(String payload)"),
|
|
"tier-(b) harness must define the wire-frame helper: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source.contains("Class.forName(\"Vuln\")"),
|
|
"tier-(b) harness must reflectively load the fixture entry class: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source
|
|
.contains("getDeclaredMethod(\"setCookieValue\", byte[].class)"),
|
|
"tier-(b) harness must install the cookie value via reflection: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source.contains("getDeclaredMethod(\"createServer\")"),
|
|
"tier-(b) harness must boot the fixture's ServerSocket via reflection: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source
|
|
.contains("getDeclaredMethod(\"runOnce\", ServerSocket.class)"),
|
|
"tier-(b) harness must drive runOnce on a worker thread: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source.contains("new Thread(()"),
|
|
"tier-(b) harness must spawn a worker thread for the accept loop: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source
|
|
.contains("new Socket(InetAddress.getByName(\"127.0.0.1\"), port)"),
|
|
"tier-(b) harness must open a client Socket against the bound port: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source.contains("GET / HTTP/1.0\\r\\nHost: 127.0.0.1"),
|
|
"tier-(b) harness must issue a raw GET request: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source.contains("\\\"kind\\\":\\\"HeaderWireFrame\\\""),
|
|
"tier-(b) harness must emit a HeaderWireFrame probe kind: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source.contains("\\\"raw_bytes\\\":["),
|
|
"tier-(b) harness must carry the raw_bytes array on the wire-frame probe: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source
|
|
.contains("\"{\\\"wire_frame_len\\\":\" + rawBytes.length"),
|
|
"tier-(b) harness must emit the wire_frame_len stdout marker: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
!h.source.contains("nyxDrainHeaders()"),
|
|
"tier-(b) harness must not invoke the servlet-stub drain path: {}",
|
|
h.source
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_header_injection_harness_wire_frame_branch_drops_when_only_servlet_imported() {
|
|
let dir = std::env::temp_dir().join("nyx_phase08_java_test_no_wire_frame");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let entry = write_servlet_fixture(
|
|
&dir,
|
|
"import javax.servlet.http.HttpServletResponse;\n\
|
|
public class Vuln {\n public static void run(HttpServletResponse r, String v) {\n r.setHeader(\"Set-Cookie\", v);\n }\n}\n",
|
|
);
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::HEADER_INJECTION;
|
|
spec.entry_file = entry;
|
|
spec.entry_name = "run".into();
|
|
let h = emit_header_injection_harness(&spec);
|
|
assert!(
|
|
!h.source.contains("nyxWireFrameViaFixture"),
|
|
"servlet-only harness must not define the wire-frame helper: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
!h.source.contains("HeaderWireFrame"),
|
|
"servlet-only harness must not emit the HeaderWireFrame probe shape: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
!h.source.contains("wire_frame_len"),
|
|
"servlet-only harness must not emit the wire_frame_len stdout marker: {}",
|
|
h.source
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_open_redirect_harness_drives_fixture_through_stub_when_servlet_present() {
|
|
let dir = std::env::temp_dir().join("nyx_phase09_test_drive_fixture");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let entry = write_servlet_fixture(
|
|
&dir,
|
|
"import javax.servlet.http.HttpServletResponse;\n\
|
|
public class Vuln {\n public static void run(HttpServletResponse r, String v) throws Exception {\n r.sendRedirect(v);\n }\n}\n",
|
|
);
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::OPEN_REDIRECT;
|
|
spec.entry_file = entry;
|
|
spec.entry_name = "run".into();
|
|
let h = emit_open_redirect_harness(&spec);
|
|
assert!(
|
|
!h.extra_files.is_empty(),
|
|
"servlet-importing fixture must trigger stub-file emission",
|
|
);
|
|
assert!(
|
|
h.source.contains(
|
|
"HttpServletResponse response = new javax.servlet.http.HttpServletResponse()"
|
|
),
|
|
"Java OPEN_REDIRECT harness must instantiate the captured-redirect response wrapper",
|
|
);
|
|
assert!(
|
|
h.source.contains("Class.forName(\"Vuln\")"),
|
|
"Java OPEN_REDIRECT harness must reflectively load the fixture entry class",
|
|
);
|
|
assert!(
|
|
h.source.contains("response.getRedirectedUrl()"),
|
|
"Java OPEN_REDIRECT harness must read the captured Location: value from the stub",
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_open_redirect_harness_falls_back_to_synthetic_probe_without_servlet() {
|
|
let dir = std::env::temp_dir().join("nyx_phase09_test_no_servlet");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let entry = write_servlet_fixture(
|
|
&dir,
|
|
"public class Vuln { public static void run(String v) { System.out.println(v); } }\n",
|
|
);
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::OPEN_REDIRECT;
|
|
spec.entry_file = entry;
|
|
spec.entry_name = "run".into();
|
|
let h = emit_open_redirect_harness(&spec);
|
|
assert!(
|
|
h.extra_files.is_empty(),
|
|
"non-servlet fixture must not ship servlet stubs",
|
|
);
|
|
assert!(
|
|
!h.source.contains("response.getRedirectedUrl()"),
|
|
"non-servlet fixture must skip the stub-driven capture path",
|
|
);
|
|
assert!(
|
|
h.source.contains("nyxRedirectProbe(payload, requestHost)"),
|
|
"non-servlet fixture must keep the synthetic-probe fallback",
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_open_redirect_harness_ships_follow_location_helper() {
|
|
let dir = std::env::temp_dir().join("nyx_phase09_test_follow_helper");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let entry = write_servlet_fixture(
|
|
&dir,
|
|
"public class Vuln { public static void run(String v) { System.out.println(v); } }\n",
|
|
);
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::OPEN_REDIRECT;
|
|
spec.entry_file = entry;
|
|
spec.entry_name = "run".into();
|
|
let h = emit_open_redirect_harness(&spec);
|
|
assert!(
|
|
h.source
|
|
.contains("static void nyxFollowLocation(String location)"),
|
|
"OPEN_REDIRECT harness must declare the nyxFollowLocation helper",
|
|
);
|
|
assert!(
|
|
h.source.contains("import java.net.HttpURLConnection;"),
|
|
"OPEN_REDIRECT harness must import HttpURLConnection",
|
|
);
|
|
assert!(
|
|
h.source.contains("import java.net.URL;"),
|
|
"OPEN_REDIRECT harness must import URL",
|
|
);
|
|
assert!(
|
|
h.source.contains("http://127.0.0.1"),
|
|
"follow-location helper must whitelist loopback hosts",
|
|
);
|
|
assert!(
|
|
h.source.contains("nyxFollowLocation(payload)"),
|
|
"tier-(b) fallback must follow the synthetic payload location",
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_open_redirect_harness_follows_captured_location_in_tier_a() {
|
|
let dir = std::env::temp_dir().join("nyx_phase09_test_follow_tier_a");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let entry = write_servlet_fixture(
|
|
&dir,
|
|
"import javax.servlet.http.HttpServletResponse;\n\
|
|
public class Vuln {\n public static void run(HttpServletResponse r, String v) throws Exception {\n r.sendRedirect(v);\n }\n}\n",
|
|
);
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::OPEN_REDIRECT;
|
|
spec.entry_file = entry;
|
|
spec.entry_name = "run".into();
|
|
let h = emit_open_redirect_harness(&spec);
|
|
assert!(
|
|
h.source.contains(
|
|
"nyxRedirectProbe(captured, requestHost);\n nyxFollowLocation(captured);"
|
|
),
|
|
"tier-(a) must follow the captured Location: value, not the raw payload",
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_xpath_harness_routes_through_real_xpath_reflectively() {
|
|
let dir = std::env::temp_dir().join("nyx_phase07_test_drive_fixture");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let entry = write_servlet_fixture(
|
|
&dir,
|
|
"import javax.xml.xpath.XPath;\n\
|
|
import javax.xml.xpath.XPathConstants;\n\
|
|
public class Vuln {\n public static Object run(String name) throws Exception { return null; }\n}\n",
|
|
);
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::XPATH_INJECTION;
|
|
spec.entry_file = entry;
|
|
spec.entry_name = "run".into();
|
|
let h = emit_xpath_harness(&spec);
|
|
assert!(
|
|
!h.extra_files.is_empty(),
|
|
"XPath harness must stage the canonical corpus XML",
|
|
);
|
|
assert!(
|
|
h.source.contains("import org.w3c.dom.NodeList;"),
|
|
"tier-(a) harness must import NodeList for the cast",
|
|
);
|
|
assert!(
|
|
h.source.contains("Class.forName(\"Vuln\")"),
|
|
"tier-(a) harness must reflectively load the fixture entry class",
|
|
);
|
|
assert!(
|
|
h.source
|
|
.contains("getDeclaredMethod(\"run\", String.class)"),
|
|
"tier-(a) harness must reflectively grab the fixture's run(String) method",
|
|
);
|
|
assert!(
|
|
h.source.contains("((NodeList) result).getLength()"),
|
|
"tier-(a) harness must cast the result to NodeList and count nodes",
|
|
);
|
|
assert!(
|
|
h.source.contains("__NYX_XPATH_TIER_A__"),
|
|
"tier-(a) harness must emit the tier-(a) stdout marker after the real reflective invoke: {}",
|
|
h.source
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_xpath_harness_drops_inline_matcher_fallback() {
|
|
let dir = std::env::temp_dir().join("nyx_phase07_test_no_inline_matcher");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let entry = write_servlet_fixture(
|
|
&dir,
|
|
"public class Vuln { public static Object run(String name) { return null; } }\n",
|
|
);
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::XPATH_INJECTION;
|
|
spec.entry_file = entry;
|
|
spec.entry_name = "run".into();
|
|
let h = emit_xpath_harness(&spec);
|
|
assert!(
|
|
!h.source.contains("nyxXpathSelect"),
|
|
"harness must not carry the inline `nyxXpathSelect` matcher; tier-(a) reflective invoke is the only path",
|
|
);
|
|
assert!(
|
|
!h.source.contains("NYX_XPATH_USERS"),
|
|
"harness must not carry the inline `NYX_XPATH_USERS` table; tier-(a) reflective invoke is the only path",
|
|
);
|
|
assert!(
|
|
h.source.contains("NYX_IMPORT_ERROR:") && h.source.contains("System.exit(77)"),
|
|
"harness must emit `NYX_IMPORT_ERROR:` stderr marker + `System.exit(77)` on reflective lookup failure: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source.contains("__NYX_XPATH_TIER_A__"),
|
|
"harness must emit the tier-(a) stdout marker: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source.contains("import org.w3c.dom.NodeList;")
|
|
&& h.source.contains("import java.lang.reflect.Method;"),
|
|
"harness must always import the reflective invocation path; the synthetic-only branch is gone",
|
|
);
|
|
}
|
|
|
|
fn make_crypto_spec(entry_file: &str, entry_name: &str) -> HarnessSpec {
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::CRYPTO;
|
|
spec.entry_file = entry_file.to_owned();
|
|
spec.entry_name = entry_name.to_owned();
|
|
spec
|
|
}
|
|
|
|
#[test]
|
|
fn emit_dispatches_to_crypto_harness_when_cap_is_crypto() {
|
|
let h = emit(&make_crypto_spec(
|
|
"tests/dynamic_fixtures/crypto/java/Vuln.java",
|
|
"run",
|
|
))
|
|
.unwrap();
|
|
assert!(
|
|
h.source.contains("nyxWeakKeyProbe"),
|
|
"dispatcher must short-circuit Cap::CRYPTO into emit_crypto_harness so the weak-key probe shim is present",
|
|
);
|
|
assert!(
|
|
h.source.contains("\\\"kind\\\":\\\"WeakKey\\\""),
|
|
"crypto harness must record probes with kind: WeakKey so the WeakKeyEntropy predicate fires (search for the escaped sequence the Java emitter writes into the .java source string literal)",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_crypto_harness_routes_through_reflective_entry_invocation() {
|
|
let h = emit_crypto_harness(&make_crypto_spec(
|
|
"tests/dynamic_fixtures/crypto/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source.contains("Class.forName(\"Vuln\")"),
|
|
"Java CRYPTO harness must reflectively load the fixture entry class by its derived FQN: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source
|
|
.contains("getDeclaredMethod(\"run\", String.class)"),
|
|
"Java CRYPTO harness must look up the entry method with a single String parameter",
|
|
);
|
|
assert!(
|
|
h.source.contains("m.invoke(null, payload)"),
|
|
"Java CRYPTO harness must invoke the static method with the payload",
|
|
);
|
|
assert_eq!(
|
|
h.filename, "NyxHarness.java",
|
|
"Java CRYPTO harness must emit a NyxHarness.java file",
|
|
);
|
|
assert!(
|
|
h.extra_files.is_empty(),
|
|
"Java CRYPTO harness must not stage extra files — java.util.Random + SecureRandom are JDK built-ins",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_crypto_harness_emits_weak_key_probe_kind() {
|
|
let h = emit_crypto_harness(&make_crypto_spec(
|
|
"tests/dynamic_fixtures/crypto/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source
|
|
.contains("\\\"kind\\\":\\\"WeakKey\\\",\\\"key_int\\\":"),
|
|
"Java CRYPTO harness must emit ProbeKind::WeakKey records carrying a key_int field so the WeakKeyEntropy predicate fires: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source.contains("__NYX_SINK_HIT__"),
|
|
"Java CRYPTO harness must print the universal sink-hit sentinel",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_crypto_harness_reduces_byte_array_returns_via_byte_buffer() {
|
|
let h = emit_crypto_harness(&make_crypto_spec(
|
|
"tests/dynamic_fixtures/crypto/java/Benign.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source
|
|
.contains("ByteBuffer.wrap(buf).order(ByteOrder.BIG_ENDIAN).getLong()"),
|
|
"Java CRYPTO harness must use ByteBuffer.getLong() so a 32-byte CSPRNG key produces a key_int whose magnitude exceeds the 16-bit budget",
|
|
);
|
|
assert!(
|
|
h.source.contains("value instanceof byte[]"),
|
|
"Java CRYPTO harness must dispatch on byte[] returns explicitly",
|
|
);
|
|
assert!(
|
|
h.source.contains("value instanceof Number"),
|
|
"Java CRYPTO harness must dispatch on Number returns explicitly",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_crypto_harness_falls_back_when_reflection_fails() {
|
|
let h = emit_crypto_harness(&make_crypto_spec(
|
|
"tests/dynamic_fixtures/crypto/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source.contains("nyxPayloadFallback(payload)"),
|
|
"Java CRYPTO harness must fall back to a payload-derived key_int when reflection fails so the universal sink-hit path still fires",
|
|
);
|
|
assert!(
|
|
h.source.contains(
|
|
"ClassNotFoundException | NoSuchMethodException | IllegalAccessException"
|
|
),
|
|
"Java CRYPTO harness must catch the reflective lookup exceptions and route to the fallback",
|
|
);
|
|
}
|
|
|
|
// ── Phase 11 (Track J.9) Java JSON_PARSE emitter tests ────────────────────
|
|
|
|
fn make_json_parse_spec(entry_file: &str, entry_name: &str) -> HarnessSpec {
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::JSON_PARSE;
|
|
spec.entry_file = entry_file.to_owned();
|
|
spec.entry_name = entry_name.to_owned();
|
|
spec
|
|
}
|
|
|
|
#[test]
|
|
fn emit_dispatches_to_json_parse_harness_when_cap_is_json_parse() {
|
|
let h = emit(&make_json_parse_spec(
|
|
"tests/dynamic_fixtures/json_parse_depth/java/Vuln.java",
|
|
"run",
|
|
))
|
|
.unwrap();
|
|
assert!(
|
|
h.source.contains("nyxJsonParseProbe"),
|
|
"dispatcher must short-circuit Cap::JSON_PARSE into emit_json_parse_harness so the depth probe shim is present",
|
|
);
|
|
assert!(
|
|
h.source.contains("\\\"kind\\\":\\\"JsonParse\\\""),
|
|
"Java JSON_PARSE harness must record probes with kind: JsonParse so the JsonParseExcessiveDepth predicate fires",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_json_parse_harness_ships_nyx_json_probe_extra_file() {
|
|
let h = emit_json_parse_harness(&make_json_parse_spec(
|
|
"tests/dynamic_fixtures/json_parse_depth/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.extra_files
|
|
.iter()
|
|
.any(|(name, _)| name == "NyxJsonProbe.java"),
|
|
"Java JSON_PARSE harness must stage NyxJsonProbe.java as a sibling extra file so the fixture can resolve the helper at javac time without Jackson / Gson",
|
|
);
|
|
let (_, probe_src) = h
|
|
.extra_files
|
|
.iter()
|
|
.find(|(name, _)| name == "NyxJsonProbe.java")
|
|
.unwrap();
|
|
assert!(
|
|
probe_src.contains("public class NyxJsonProbe"),
|
|
"NyxJsonProbe.java extra file must declare the helper class",
|
|
);
|
|
assert!(
|
|
probe_src.contains("public static Object parse(String s)"),
|
|
"NyxJsonProbe must expose a String -> Object parse helper",
|
|
);
|
|
assert!(
|
|
probe_src.contains("public static int countDepth(Object parsed)"),
|
|
"NyxJsonProbe must expose an iterative countDepth walker",
|
|
);
|
|
assert!(
|
|
probe_src.contains("ArrayDeque<Frame>"),
|
|
"NyxJsonProbe.countDepth must walk iteratively to dodge the JVM stack-frame budget",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_json_parse_harness_routes_through_reflective_entry_invocation() {
|
|
let h = emit_json_parse_harness(&make_json_parse_spec(
|
|
"tests/dynamic_fixtures/json_parse_depth/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source.contains("Class.forName(\"Vuln\")"),
|
|
"Java JSON_PARSE harness must reflectively load the fixture entry class by its derived FQN: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source
|
|
.contains("getDeclaredMethod(\"run\", String.class)"),
|
|
"Java JSON_PARSE harness must look up the entry method with a single String parameter",
|
|
);
|
|
assert!(
|
|
h.source.contains("m.invoke(null, payload)"),
|
|
"Java JSON_PARSE harness must invoke the static method with the payload",
|
|
);
|
|
assert!(
|
|
h.source.contains("NyxJsonProbe.countDepth(produced)"),
|
|
"Java JSON_PARSE harness must drive countDepth on the fixture's return value",
|
|
);
|
|
assert_eq!(
|
|
h.filename, "NyxHarness.java",
|
|
"Java JSON_PARSE harness must emit a NyxHarness.java file",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_json_parse_harness_emits_depth_fields() {
|
|
let h = emit_json_parse_harness(&make_json_parse_spec(
|
|
"tests/dynamic_fixtures/json_parse_depth/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source.contains("\\\"depth\\\":"),
|
|
"Java JSON_PARSE harness must serialise a depth field on the JsonParse probe record",
|
|
);
|
|
assert!(
|
|
h.source.contains("\\\"excessive_depth\\\":"),
|
|
"Java JSON_PARSE harness must serialise an excessive_depth field on the JsonParse probe record",
|
|
);
|
|
assert!(
|
|
h.source.contains("__NYX_SINK_HIT__"),
|
|
"Java JSON_PARSE harness must print the universal sink-hit sentinel",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_json_parse_harness_handles_parser_depth_exception() {
|
|
let h = emit_json_parse_harness(&make_json_parse_spec(
|
|
"tests/dynamic_fixtures/json_parse_depth/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source.contains("NyxJsonProbe.NyxJsonDepthException"),
|
|
"Java JSON_PARSE harness must catch the parser's depth-budget exception so a guard-rail trip still emits a probe",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_json_parse_harness_derives_entry_class_from_fixture() {
|
|
let h = emit_json_parse_harness(&make_json_parse_spec(
|
|
"tests/dynamic_fixtures/json_parse_depth/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
matches!(h.entry_subpath.as_deref(), Some(p) if p == "Vuln.java"),
|
|
"Java JSON_PARSE harness must stage the fixture under its public-class-derived filename so javac's filename invariant holds: got {:?}",
|
|
h.entry_subpath,
|
|
);
|
|
}
|
|
|
|
// ── Phase 11 (Track J.9) Java UNAUTHORIZED_ID emitter tests ───────────────
|
|
|
|
fn make_unauthorized_id_spec(entry_file: &str, entry_name: &str) -> HarnessSpec {
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::UNAUTHORIZED_ID;
|
|
spec.entry_file = entry_file.to_owned();
|
|
spec.entry_name = entry_name.to_owned();
|
|
spec
|
|
}
|
|
|
|
#[test]
|
|
fn emit_dispatches_to_unauthorized_id_harness_when_cap_is_unauthorized_id() {
|
|
let h = emit(&make_unauthorized_id_spec(
|
|
"tests/dynamic_fixtures/unauthorized_id/java/Vuln.java",
|
|
"run",
|
|
))
|
|
.unwrap();
|
|
assert!(
|
|
h.source.contains("nyxIdorProbe"),
|
|
"dispatcher must short-circuit Cap::UNAUTHORIZED_ID into emit_unauthorized_id_harness so the IDOR probe shim is present",
|
|
);
|
|
assert!(
|
|
h.source.contains("\\\"kind\\\":\\\"IdorAccess\\\""),
|
|
"Java UNAUTHORIZED_ID harness must record probes with kind: IdorAccess so the IdorBoundaryCrossed predicate fires",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_unauthorized_id_harness_pins_caller_id() {
|
|
let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec(
|
|
"tests/dynamic_fixtures/unauthorized_id/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source
|
|
.contains("private static final String _NYX_CALLER_ID = \"alice\""),
|
|
"Java UNAUTHORIZED_ID harness must pin caller_id = \"alice\" so the differential oracle can flag bob/alice as a cross-tenant access",
|
|
);
|
|
assert!(
|
|
h.source.contains("nyxIdorProbe(_NYX_CALLER_ID, payload)"),
|
|
"Java UNAUTHORIZED_ID harness must seed the probe with the pinned caller_id and the payload as owner_id",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_unauthorized_id_harness_skips_probe_when_record_is_null() {
|
|
let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec(
|
|
"tests/dynamic_fixtures/unauthorized_id/java/Benign.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source.contains("if (record != null) {"),
|
|
"Java UNAUTHORIZED_ID harness must gate probe emission on the fixture returning a non-null record so the benign control's null-rejection path clears the predicate",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_unauthorized_id_harness_routes_through_reflective_entry_invocation() {
|
|
let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec(
|
|
"tests/dynamic_fixtures/unauthorized_id/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source.contains("Class.forName(\"Vuln\")"),
|
|
"Java UNAUTHORIZED_ID harness must reflectively load the fixture entry class: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source
|
|
.contains("getDeclaredMethod(\"run\", String.class)"),
|
|
"Java UNAUTHORIZED_ID harness must look up the entry method with a single String parameter",
|
|
);
|
|
assert!(
|
|
h.source.contains("m.invoke(null, payload)"),
|
|
"Java UNAUTHORIZED_ID harness must invoke the static method with the payload as owner_id",
|
|
);
|
|
assert_eq!(
|
|
h.filename, "NyxHarness.java",
|
|
"Java UNAUTHORIZED_ID harness must emit a NyxHarness.java file",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_unauthorized_id_harness_derives_entry_class_from_fixture() {
|
|
let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec(
|
|
"tests/dynamic_fixtures/unauthorized_id/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
matches!(h.entry_subpath.as_deref(), Some(p) if p == "Vuln.java"),
|
|
"Java UNAUTHORIZED_ID harness must stage the fixture under its public-class-derived filename so javac's filename invariant holds: got {:?}",
|
|
h.entry_subpath,
|
|
);
|
|
assert!(
|
|
h.extra_files.is_empty(),
|
|
"Java UNAUTHORIZED_ID harness must not ship sibling helpers — the fixture's data store is in-process",
|
|
);
|
|
}
|
|
|
|
// ── Phase 11 (Track J.9) Java DATA_EXFIL emitter tests ────────────────────
|
|
|
|
fn make_data_exfil_spec(entry_file: &str, entry_name: &str) -> HarnessSpec {
|
|
let mut spec = make_spec(PayloadSlot::Param(0));
|
|
spec.expected_cap = Cap::DATA_EXFIL;
|
|
spec.entry_file = entry_file.to_owned();
|
|
spec.entry_name = entry_name.to_owned();
|
|
spec
|
|
}
|
|
|
|
#[test]
|
|
fn emit_dispatches_to_data_exfil_harness_when_cap_is_data_exfil() {
|
|
let h = emit(&make_data_exfil_spec(
|
|
"tests/dynamic_fixtures/data_exfil/java/Vuln.java",
|
|
"run",
|
|
))
|
|
.unwrap();
|
|
assert!(
|
|
h.source.contains("nyxOutboundProbe"),
|
|
"dispatcher must short-circuit Cap::DATA_EXFIL into emit_data_exfil_harness so the outbound probe shim is present",
|
|
);
|
|
assert!(
|
|
h.source.contains("\\\"kind\\\":\\\"OutboundNetwork\\\""),
|
|
"Java DATA_EXFIL harness must record probes with kind: OutboundNetwork so the OutboundHostNotIn predicate fires",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_data_exfil_harness_ships_nyx_mock_http_extra_file() {
|
|
let h = emit_data_exfil_harness(&make_data_exfil_spec(
|
|
"tests/dynamic_fixtures/data_exfil/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.extra_files
|
|
.iter()
|
|
.any(|(name, _)| name == "NyxMockHttp.java"),
|
|
"Java DATA_EXFIL harness must stage NyxMockHttp.java as a sibling extra file so the fixture's call into the helper resolves at javac time without an HttpURLConnection monkey-patch",
|
|
);
|
|
let (_, mock_src) = h
|
|
.extra_files
|
|
.iter()
|
|
.find(|(name, _)| name == "NyxMockHttp.java")
|
|
.unwrap();
|
|
assert!(
|
|
mock_src.contains("public class NyxMockHttp"),
|
|
"NyxMockHttp.java extra file must declare the helper class",
|
|
);
|
|
assert!(
|
|
mock_src.contains("public static String get(String url)"),
|
|
"NyxMockHttp must expose a String get(url) helper the fixture calls into",
|
|
);
|
|
assert!(
|
|
mock_src.contains("CAPTURED_HOSTS"),
|
|
"NyxMockHttp must expose a CAPTURED_HOSTS list the harness drains after invocation",
|
|
);
|
|
assert!(
|
|
mock_src.contains("URI.create(trimmed)"),
|
|
"NyxMockHttp.captureHost must parse the host via java.net.URI so https://attacker.test/path resolves to attacker.test",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_data_exfil_harness_drains_captured_hosts_after_invocation() {
|
|
let h = emit_data_exfil_harness(&make_data_exfil_spec(
|
|
"tests/dynamic_fixtures/data_exfil/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source.contains("NyxMockHttp.CAPTURED_HOSTS.clear();"),
|
|
"Java DATA_EXFIL harness must clear the captured-hosts list before invoking the fixture so probes do not leak between invocations",
|
|
);
|
|
assert!(
|
|
h.source
|
|
.contains("for (String host : NyxMockHttp.CAPTURED_HOSTS) {"),
|
|
"Java DATA_EXFIL harness must drain CAPTURED_HOSTS after the fixture returns",
|
|
);
|
|
assert!(
|
|
h.source.contains("nyxOutboundProbe(host)"),
|
|
"Java DATA_EXFIL harness must emit one OutboundNetwork probe per captured host",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_data_exfil_harness_routes_through_reflective_entry_invocation() {
|
|
let h = emit_data_exfil_harness(&make_data_exfil_spec(
|
|
"tests/dynamic_fixtures/data_exfil/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source.contains("Class.forName(\"Vuln\")"),
|
|
"Java DATA_EXFIL harness must reflectively load the fixture entry class: {}",
|
|
h.source
|
|
);
|
|
assert!(
|
|
h.source
|
|
.contains("getDeclaredMethod(\"run\", String.class)"),
|
|
"Java DATA_EXFIL harness must look up the entry method with a single String parameter",
|
|
);
|
|
assert!(
|
|
h.source.contains("m.invoke(null, payload)"),
|
|
"Java DATA_EXFIL harness must invoke the static method with the payload as host",
|
|
);
|
|
assert_eq!(
|
|
h.filename, "NyxHarness.java",
|
|
"Java DATA_EXFIL harness must emit a NyxHarness.java file",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_data_exfil_harness_derives_entry_class_from_fixture() {
|
|
let h = emit_data_exfil_harness(&make_data_exfil_spec(
|
|
"tests/dynamic_fixtures/data_exfil/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
matches!(h.entry_subpath.as_deref(), Some(p) if p == "Vuln.java"),
|
|
"Java DATA_EXFIL harness must stage the fixture under its public-class-derived filename so javac's filename invariant holds: got {:?}",
|
|
h.entry_subpath,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_data_exfil_harness_drains_even_on_invocation_throw() {
|
|
let h = emit_data_exfil_harness(&make_data_exfil_spec(
|
|
"tests/dynamic_fixtures/data_exfil/java/Vuln.java",
|
|
"run",
|
|
));
|
|
assert!(
|
|
h.source.contains("InvocationTargetException ite"),
|
|
"Java DATA_EXFIL harness must catch InvocationTargetException so a fixture-side throw after a partial outbound call still drains CAPTURED_HOSTS",
|
|
);
|
|
}
|
|
}
|