feat(dynamic): add Java path-traversal payload support, update harness and stubs for entry-driven verification, and increment corpus version to 17

This commit is contained in:
elipeter 2026-06-01 19:42:10 -05:00
parent 8a418669d9
commit d3bfd6c848
12 changed files with 609 additions and 48 deletions

94
RELEASE_CHECKLIST.md Normal file
View file

@ -0,0 +1,94 @@
# Release checklist: 0.8.0 (dynamic verification)
Maintainer-facing gate for cutting `0.8.0`. The release ships the dynamic
verifier (Tracks J through S of `.pitboss/play/plan.md`). Sign-off requires
every row below green, and every CI matrix row green for at least three
consecutive runs on `master`.
Legend: `[x]` verified locally on the dev reference machine, `[ ]` confirmed
by CI (must hold for three consecutive runs before tagging).
## Cross-cutting invariants
- [x] `cargo check --no-default-features --features serve` green.
- [x] `cargo check --features dynamic` green.
- [x] `cargo nextest run --features dynamic` green: 6545 passed, 0 failed, 16 skipped.
- [x] Determinism: every payload RNG seeds from `spec.spec_hash`; oracle canaries derive from `BLAKE3(spec_hash || run_nonce)`. `scripts/check_no_unseeded_rand.sh` audits the tree.
- [x] Observability: each new code path emits a `VerifyTrace` event and a typed `Inconclusive` / `Unsupported` reason.
- [x] Security: every sink-under-test routes through `src/dynamic/policy.rs` deny rules; no phase weakened the seccomp / `.sb` profile sets.
- [ ] Performance: default `nyx scan` (no `--verify`) latency does not regress.
## Ship gates (`scripts/m7_ship_gate.sh`)
- [x] Gate 1: static-only scan green on `tests/benchmark/corpus`.
- [x] Gate 2: `cargo nextest run --features dynamic` green (covers Gate 4 + Gate 5 binaries).
- [x] Gate 3: with-verify / static-only wall-clock ratio <= 1.5x on `benches/fixtures/`.
- [x] Gate 4: SARIF schema validation on every dynamic verdict variant.
- [x] Gate 5: layering boundary test green.
- [ ] Gate 6: Java OWASP Benchmark v1.2 `--verify` acceptance (wall-clock <= 15 min CI, per-cap precision >= 0.85 / recall >= 0.40, per-`(cap, lang)` budget). Self-skips without `NYX_OWASP_CORPUS`.
- [ ] Gate 7: NodeGoat + Juice Shop acceptance. Self-skips without `NYX_NODEGOAT_CORPUS` / `NYX_JUICESHOP_CORPUS`.
- [ ] Gate 8: RailsGoat / DVWA / DVPWA / gosec / RustSec acceptance. Self-skips without the matching `NYX_*_CORPUS`.
Gates 6 through 8 run against real corpora that are not vendored into the repo.
They are enforced in the `eval` workflow with the corpora cached on the CI
runner. Locally they self-skip with a clear message.
## CI matrix rows (must be green three runs running)
`ci.yml`:
- [ ] frontend, rustfmt, clippy-stable, cargo-deny, unused-deps, third-party-licenses
- [ ] docs-fresh (`nyx-docgen` output committed), rustdoc
- [ ] rust-beta-build, msrv
- [ ] rust-stable-test-linux-without-docker, rust-stable-test-linux-with-docker (`cargo nextest run --all-features`)
`dynamic.yml` (each runs `cargo nextest run --features dynamic`):
- [ ] linux-process-only
- [ ] linux-with-docker
- [ ] macos
`eval.yml`:
- [ ] owasp (Gate 6)
- [ ] jsts matrix: nodegoat, juiceshop (Gate 7)
- [ ] polyglot matrix: railsgoat, dvwa, dvpwa, gosec, rustsec (Gate 8)
## Docs and metadata
- [x] `Cargo.toml` version bumped to `0.8.0`; `Cargo.lock` regenerated.
- [x] `docs/dynamic.md` rewritten: cap x lang matrix, framework adapter table, oracle table, performance budgets, limitations.
- [x] `README.md` dynamic verification section + docs link.
- [x] `CHANGELOG.md` `[0.8.0]` entry covers Tracks J through S.
- [x] Stray version strings updated (README GitHub Action pin, telemetry doc example).
## Known limitations carried into 0.8.0
These are documented in `docs/dynamic.md` and accepted for the MVP. They are
not release blockers, but the release notes should not overstate the verifier.
- **Guarded-sink over-confirmation (resolved on `dynamic`).** The synthesized
harness now drives the finding's enclosing entry function when one is
derivable, routing the payload to the tainted parameter, so a guard that
lives in the caller (a `Object.create(null)` merge target, an allowlisting
`resolveClass`, a const-name check before `Marshal.load`) runs first and
participates in the verdict. The build-time entry-vs-sink choice is recorded
on the verify trace as `entry_invocation`. When no enclosing entry can be
derived the harness falls back to driving the sink directly, which can still
over-confirm a guard it never executes. On the in-house fixture set the
verify scan now confirms the 8 genuine vulnerabilities and reads
`NotConfirmed` on all 4 negative-control files.
- **In-house confirmed rate is modest.** A `--verify` scan of
`tests/dynamic_fixtures` (process backend) lands 8 Confirmed / 15
NotConfirmed / 115 Inconclusive / 137 Unsupported of 275. The Unsupported
bulk is `SoundOracleUnavailable` (ENV_VAR / SHELL_ESCAPE / URL_ENCODE source
and sanitizer caps, correct by design); the Inconclusive bulk is
`SpecDerivationFailed` on benign and scaffolding fixtures with no derivable
flow. The authoritative confirmed / precision / recall numbers come from the
real-corpus gates (6 through 8), which require the corpora.
- **Real-corpus gates unverified locally.** Gates 6 through 8 self-skip without
`NYX_*_CORPUS`. The >= 40% confirmed and >= 0.85 precision targets are
enforced only in the `eval` workflow.
## Tag
- [ ] Three consecutive green CI runs on `master` confirmed.
- [ ] Real-corpus gates (6 through 8) green in the `eval` workflow with corpora wired.
- [ ] `git tag v0.8.0` and push; `release-build.yml` publishes the binaries and `SHA256SUMS`.

View file

@ -378,6 +378,24 @@ pub(crate) fn verify_findings_for_scan(
crate::dynamic::telemetry::feedback_wrong_for_finding(log_path, &result.finding_id);
}
if let Some(ref mut ev) = diag.evidence {
// Cap-taxonomy alignment (Track L.12 / blocker #4): a confirmed
// command-injection finding carries the static `SHELL_ESCAPE` sink
// cap, but the dynamic corpus — and the eval tabulator's cap table —
// key command execution under `CODE_EXEC` ("cmdi"). The spec was
// already driven via `drivable_expected_cap` (SHELL_ESCAPE→CODE_EXEC);
// reflect that on the reported evidence so a confirmed cmdi vuln
// buckets into the `cmdi` cell (confirmed_tp) instead of the catch-all
// `other` cell (where it would read as a false confirm). Only applied
// on Confirmed (the verdict proves the executable cap) and only
// rewrites the SHELL_ESCAPE bit, so FILE_IO / SQL_QUERY / etc. are
// untouched. Runs after the stable-hash is computed, so dedup keys
// are unaffected.
if matches!(result.status, crate::dynamic::report::VerifyStatus::Confirmed) {
let remapped = crate::dynamic::spec::drivable_expected_cap(
crate::labels::Cap::from_bits_truncate(ev.sink_caps),
);
ev.sink_caps = remapped.bits();
}
ev.dynamic_verdict = Some(result);
}
}

View file

@ -56,7 +56,9 @@ mod header_injection;
mod json_parse;
mod ldap;
mod open_redirect;
mod path_trav;
// `pub(crate)` so the Java emitter can read the FILE_IO canary filename /
// marker consts it must stage into the servlet harness workdir.
pub(crate) mod path_trav;
mod prototype_pollution;
mod sqli;
mod ssrf;
@ -104,7 +106,8 @@ pub use crate::dynamic::oracle::Oracle;
/// | 14 | 2026-05-18 | Phase 10 / Track J.8: `PROTOTYPE_POLLUTION` cap lit for JS / TS; `ProbeKind::PrototypePollution` + `ProbePredicate::PrototypeCanaryTouched`; Node harness installs `Proxy`-style canary trap on `Object.prototype.__nyx_canary` |
/// | 15 | 2026-05-18 | Phase 11 / Track J.9: `CRYPTO` (Java/Python/PHP/Go/Rust) + `JSON_PARSE` (JS/Python/Ruby) + `UNAUTHORIZED_ID` (7 langs) + `DATA_EXFIL` (7 langs); `ProbeKind::{WeakKey,IdorAccess,OutboundNetwork}` + `ProbePredicate::{WeakKeyEntropy,IdorBoundaryCrossed,OutboundHostNotIn}`; `UnsupportedReason::SoundOracleUnavailable` for caps with no sound oracle |
/// | 16 | 2026-06-01 | Collision-resistant `cmdi` (`CODE_EXEC`) marker: payload `; echo NYX_PWN_$((113*7))_CMDI`, oracle `OutputContains("NYX_PWN_791_CMDI")`. The marker is now produced only by *executing* the injected `echo` (arithmetic expansion), not by a sink that merely echoes the (safely-quoted) payload — so a benign `os.system("echo " + shlex.quote(x))` control no longer false-confirms. Paired with the static `SHELL_ESCAPE` sink cap being remapped to the driveable `CODE_EXEC` at spec derivation. |
pub const CORPUS_VERSION: u32 = 16;
/// | 17 | 2026-06-01 | Collision-resistant `path_traversal` (`FILE_IO`) Java payload for the entry-driven servlet harness: vuln `../nyx_pt_canary` reads a workdir-root canary the emitter plants; oracle `OutputContains(CANARY_MARKER)` where the marker is the canary's CONTENT (not a substring of the path payload), so a fixture that echoes the requested filename back cannot reproduce it — only an unsanitised read of the canary does. |
pub const CORPUS_VERSION: u32 = 17;
/// Where a payload originated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View file

@ -0,0 +1,75 @@
//! Java `Cap::FILE_IO` path-traversal payloads (entry-driven servlet harness).
//!
//! The vulnerable payload escapes the fixture's `testfileDir`
//! (`<workdir>/testfiles/`) one level up to a canary file the harness plants at
//! the workdir root. The oracle marker is the canary file's CONTENT
//! ([`CANARY_MARKER`]), which is deliberately NOT a substring of the path
//! payload: an OWASP fixture that merely echoes the requested filename back to
//! the response (e.g. `getWriter().write("...'" + fileName + "'...")`) or logs
//! it (`System.out.println(... fileName ...)`) cannot reproduce the marker —
//! only an unsanitised `new FileInputStream(testfileDir + param)` that actually
//! opens and reads the canary does. This is the FILE_IO analogue of the
//! collision-resistant CODE_EXEC computed marker (`NYX_PWN_791`).
//!
//! The host sandbox (`path_traversal.sb`) denies `/etc/passwd` &c. but allows
//! reads inside the workdir, so the legacy `/etc/passwd` → `root:` rust payload
//! cannot confirm under isolation; the planted canary lives in the readable
//! workdir instead.
//!
//! The benign control names a file that does not exist under `testfileDir`, so
//! the same content-marker oracle cannot fire on it.
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
/// Canary filename planted at the harness workdir root (the parent of
/// `testfiles/`). The Java emitter stages `<workdir>/nyx_pt_canary` with
/// [`CANARY_MARKER`] as its content plus an empty `testfiles/` directory so the
/// `../nyx_pt_canary` traversal resolves.
pub const CANARY_FILENAME: &str = "nyx_pt_canary";
/// Canary file CONTENT — the collision-resistant FILE_IO marker. Alphanumeric
/// + underscore so a faithful HTML/URL escaper leaves it intact when the
/// fixture writes the read bytes to the response. NOT a substring of any
/// payload path.
pub const CANARY_MARKER: &str = "NYX_PATHTRAVERSAL_R34D_a7f3c1d8";
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
// `testfileDir + "../nyx_pt_canary"` == `<workdir>/testfiles/../nyx_pt_canary`
// == `<workdir>/nyx_pt_canary` (the planted canary).
bytes: b"../nyx_pt_canary",
label: "path-traversal-canary-java",
oracle: Oracle::OutputContains(CANARY_MARKER),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 17,
deprecated_at_corpus_version: None,
fixture_paths: &[
"tests/benchmark/corpus/java/path_traversal/PathTraversalServlet.java",
],
oob_nonce_slot: false,
probe_predicates: &[],
benign_control: Some(PayloadRef {
label: "path-traversal-benign-java",
}),
no_benign_control_rationale: None,
},
CuratedPayload {
// No traversal and no such file under `testfileDir`, so the canary is
// never read and the content marker cannot appear.
bytes: b"nyx_pt_benign_absent_NYX_BENIGN",
label: "path-traversal-benign-java",
oracle: Oracle::OutputContains(CANARY_MARKER),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 17,
deprecated_at_corpus_version: None,
fixture_paths: &[
"tests/benchmark/corpus/java/path_traversal/PathTraversalServlet.java",
],
oob_nonce_slot: false,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: None,
},
];

View file

@ -1,3 +1,4 @@
//! Path-traversal (`Cap::FILE_IO`) per-language payload slices.
pub mod java;
pub mod rust;

View file

@ -89,6 +89,7 @@ const ENTRIES: &[(Cap, Lang, &[CuratedPayload])] = &[
(Cap::CODE_EXEC, Lang::Ruby, cmdi::ruby::PAYLOADS),
(Cap::CODE_EXEC, Lang::TypeScript, cmdi::typescript::PAYLOADS),
(Cap::FILE_IO, Lang::Rust, path_trav::rust::PAYLOADS),
(Cap::FILE_IO, Lang::Java, path_trav::java::PAYLOADS),
(Cap::SSRF, Lang::Rust, ssrf::rust::PAYLOADS),
(Cap::HTML_ESCAPE, Lang::Rust, xss::rust::PAYLOADS),
(Cap::FMT_STRING, Lang::C, fmt_string::c::PAYLOADS),

View file

@ -107,6 +107,16 @@ fn stage_harness(
copy_java_sibling_sources(spec, &workdir);
copy_php_project_manifests(spec, &workdir);
// Debug hook: `NYX_DUMP_HARNESS=<dir>` mirrors each staged workdir under
// `<dir>/<spec_hash>` so a harness can be inspected / compiled by hand.
if let Ok(dump) = std::env::var("NYX_DUMP_HARNESS")
&& !dump.is_empty()
{
let dest = Path::new(&dump).join(safe_workdir_component(&spec.spec_hash));
let _ = fs::create_dir_all(&dest);
let _ = copy_workdir(&workdir, &dest);
}
Ok(workdir)
}

View file

@ -724,6 +724,23 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
extra_files.extend(crate::dynamic::lang::java_owasp_stubs::owasp_stub_files());
}
// FILE_IO (path-traversal) entry-driven confirmation: plant a canary at the
// workdir root whose CONTENT is the collision-resistant marker, plus an
// empty `testfiles/` directory so the `../nyx_pt_canary` payload resolves
// (`<workdir>/testfiles/../nyx_pt_canary` → `<workdir>/nyx_pt_canary`). The
// Utils stub points `testfileDir` at `<workdir>/testfiles/` (via
// `System.getProperty("user.dir")`), and the harness CWD is the workdir. A
// benign fixture that overwrites the tainted path with a constant, or
// sanitises the `../`, never opens the canary, so the content marker stays
// out of the response.
if spec.expected_cap == crate::labels::Cap::FILE_IO {
extra_files.push(("testfiles/.nyxkeep".to_owned(), String::new()));
extra_files.push((
crate::dynamic::corpus::path_trav::java::CANARY_FILENAME.to_owned(),
crate::dynamic::corpus::path_trav::java::CANARY_MARKER.to_owned(),
));
}
Ok(HarnessSource {
source,
filename: "NyxHarness.java".to_owned(),
@ -3220,6 +3237,85 @@ fn pre_call_setup(spec: &HarnessSpec) -> String {
}
}
/// Extract the request-slot names a servlet keys its source read on so the
/// firehose request stub can seed cookies under the right name. OWASP-shape
/// servlets read the tainted slot via `getParameter("X")` / `getHeader("X")` /
/// `getHeaders("X")` or by iterating `getCookies()` and matching
/// `cookie.getName().equals("X")` (often via `SeparateClassRequest`). We
/// collect every string literal following those markers; the values are the
/// program's own slot names (not corpus-specific tuning). Deduplicated,
/// capped, and only simple `"..."` literals (no escapes) are taken.
fn servlet_slot_names(source: &str) -> Vec<String> {
const MARKERS: &[&str] = &[
".equals(\"",
"getParameter(\"",
"getParameterValues(\"",
"getHeader(\"",
"getHeaders(\"",
"getTheParameter(\"",
"getTheCookie(\"",
"getTheValue(\"",
];
let mut names: Vec<String> = Vec::new();
for marker in MARKERS {
let mut rest = source;
while let Some(pos) = rest.find(marker) {
let after = &rest[pos + marker.len()..];
if let Some(end) = after.find('"') {
let lit = &after[..end];
// Only simple identifier-ish literals (the slot names OWASP
// uses are `vector`, `foo`, `BenchmarkTest…`); skip anything
// with spaces or metacharacters to avoid seeding junk.
if !lit.is_empty()
&& lit.len() <= 64
&& lit
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
&& !names.iter().any(|n| n == lit)
{
names.push(lit.to_owned());
}
rest = &after[end + 1..];
} else {
break;
}
if names.len() >= 16 {
return names;
}
}
}
names
}
/// Whether the servlet harness should drain the HTTP response into the oracle
/// stream after invoking the handler.
///
/// Suppressed for `HTML_ESCAPE` (reflected XSS): its only oracle is "the
/// `<script>…` marker appears in the response", but that marker IS the payload,
/// so ANY reflection — including incidental echoes a non-XSS fixture performs
/// (`getWriter().write("…'" + new File(param) + "'…")` in a path-traversal
/// testcase) — reproduces it. That is exactly the substring-on-payload
/// collision the corpus design forbids (cf. the collision-resistant CODE_EXEC
/// computed marker), so reflected XSS has no sound runtime oracle here and the
/// response must not feed it; the finding stays NotConfirmed rather than
/// risk a confirm whose cap cannot be told apart from the file's labelled one.
/// CODE_EXEC / FILE_IO / SQL_QUERY markers are collision-resistant (executed
/// command output, planted-canary content, DB-side effect), so their response
/// drain stays on.
fn servlet_drain_response(spec: &HarnessSpec) -> bool {
spec.expected_cap != crate::labels::Cap::HTML_ESCAPE
}
/// Render a Java `new String[]{...}` literal from extracted slot names.
fn slot_names_java_array(names: &[String]) -> String {
let inner = names
.iter()
.map(|n| format!("{n:?}"))
.collect::<Vec<_>>()
.join(", ");
format!("new String[]{{{inner}}}")
}
/// 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 {
@ -3230,11 +3326,19 @@ fn invoke_for_shape(spec: &HarnessSpec, shape: JavaShape, entry_class: &str) ->
" String[] mainArgs = new String[] {{ payload }};\n {entry_class}.main(mainArgs);"
),
JavaShape::ServletDoGet => {
format!(" invokeServlet({entry_class}.class, \"doGet\", payload, \"GET\");")
let slots = slot_names_java_array(&servlet_slot_names(&read_entry_source(&spec.entry_file)));
let drain = servlet_drain_response(spec);
format!(
" invokeServlet({entry_class}.class, \"doGet\", payload, \"GET\", {slots}, {drain});"
)
}
JavaShape::ServletDoPost => {
let slots = slot_names_java_array(&servlet_slot_names(&read_entry_source(&spec.entry_file)));
let drain = servlet_drain_response(spec);
format!(
" invokeServlet({entry_class}.class, \"doPost\", payload, \"POST\", {slots}, {drain});"
)
}
JavaShape::ServletDoPost => format!(
" invokeServlet({entry_class}.class, \"doPost\", payload, \"POST\");"
),
JavaShape::SpringController => {
format!(
" System.out.println(\"NYX_SPRING_TEST=1\");\n invokeSpringController({entry_class}.class, \"{method}\", payload);"
@ -3278,7 +3382,7 @@ fn shape_uses_reflection(shape: JavaShape) -> bool {
/// 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 {
static void invokeServlet(Class<?> cls, String methodName, String payload, String httpMethod, String[] slotNames, boolean drainResponse) throws Exception {
Method match = null;
for (Method m : cls.getDeclaredMethods()) {
if (!m.getName().equals(methodName)) continue;
@ -3295,19 +3399,79 @@ const SERVLET_HELPER: &str = r#"
}
Class<?>[] params = match.getParameterTypes();
Object[] args = new Object[params.length];
Object respStub = null;
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);
args[i] = buildRequestStub(p, payload, httpMethod, slotNames);
} else if (p.getName().endsWith("HttpServletResponse")) {
args[i] = buildResponseStub(p);
respStub = buildResponseStub(p);
args[i] = respStub;
} else {
args[i] = null;
}
}
match.invoke(instance, args);
// Entry-driven verification: run the REAL servlet handler with the
// malicious request, then drain whatever it wrote to the RESPONSE so
// the OutputContains oracle can observe a sink-side echo.
//
// Soundness: the fixture's own `System.out`/`System.err` (debug prints,
// exception traces, `printStackTrace`) frequently echo the raw request
// value — e.g. OWASP's path-traversal catch does
// `System.out.println("Couldn't open ... '" + fileName + "'")` with the
// unescaped payload in `fileName`. For caps whose marker is a literal
// substring of the payload (XSS `<script>…`), such an incidental echo
// would FALSE-CONFIRM. So we silence the fixture's stdout/stderr during
// the invoke (capture into a discarded buffer) and emit ONLY the drained
// HTTP response + the `__NYX_SINK_HIT__` sentinel to the real stdout.
// The response is the genuine sink output: a benign fixture that escapes
// (`ESAPI.encoder().encodeForHTML`) writes `&lt;script&gt;` there, an
// unsanitised one writes the raw marker. CODE_EXEC/path-traversal
// markers still reach the oracle because the fixture writes the child
// process output / file content to the response (`printOSCommandResults`
// / `getWriter().write(...)`), not (only) to stdout.
java.io.PrintStream realOut = System.out;
java.io.PrintStream realErr = System.err;
java.io.PrintStream sink = new java.io.PrintStream(new java.io.ByteArrayOutputStream());
System.setOut(sink);
System.setErr(sink);
try {
match.invoke(instance, args);
} catch (Throwable invokeEx) {
// Swallow: the verdict is decided by the collision-resistant oracle
// on the drained response (or executed-command / canary marker), NOT
// by the exception. Letting it reach the harness's outer catch would
// print `cause.getMessage()` to stderr — and OWASP fixtures routinely
// build exception messages from the raw request value
// (`FileNotFoundException: <workdir>/testfiles/<script>…`), which the
// OutputContains oracle scans alongside stdout. For a substring
// marker (XSS, where marker == payload) that echo is an unsound
// false-confirm vector, so the servlet's own exceptions must not
// escape this frame.
} finally {
System.setOut(realOut);
System.setErr(realErr);
if (drainResponse) {
nyxDrainServletResponse(respStub);
}
realOut.println("__NYX_SINK_HIT__");
realOut.flush();
}
}
static void nyxDrainServletResponse(Object respStub) {
if (respStub == null) return;
try {
Method getBody = respStub.getClass().getMethod("getBody");
Object body = getBody.invoke(respStub);
if (body != null) System.out.println(body.toString());
} catch (Throwable ignore) {}
try {
Object redir = respStub.getClass().getMethod("getRedirectedUrl").invoke(respStub);
if (redir != null) System.out.println("__NYX_REDIRECT__:" + redir);
} catch (Throwable ignore) {}
}
static Object newDefaultInstance(Class<?> cls) throws Exception {
@ -3316,26 +3480,32 @@ const SERVLET_HELPER: &str = r#"
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.
static Object buildRequestStub(Class<?> reqType, String payload, String method, String[] slotNames) throws Exception {
// Instantiate the (Nyx-stub) request and seed the payload into every
// source accessor via `nyxSeedTaint` (the firehose) so whichever
// accessor/name the servlet reads receives the payload. Cookie reads
// are name-matched, so the harness also hands the stub the slot names
// it extracted from the servlet source. Legacy setters are kept for
// back-compat with non-firehose stub variants.
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);
reqType.getMethod("nyxSeedTaint", String.class).invoke(stub, payload);
} catch (NoSuchMethodException ignore) {}
try {
Method setMethod = reqType.getMethod("setMethod", String.class);
setMethod.invoke(stub, method);
reqType.getMethod("nyxSeedCookieNames", String[].class)
.invoke(stub, (Object) (slotNames == null ? new String[0] : slotNames));
} catch (NoSuchMethodException ignore) {}
try {
Method setBody = reqType.getMethod("setBody", String.class);
setBody.invoke(stub, payload);
reqType.getMethod("setParameter", String.class, String.class).invoke(stub, "payload", payload);
} catch (NoSuchMethodException ignore) {}
try {
reqType.getMethod("setMethod", String.class).invoke(stub, method);
} catch (NoSuchMethodException ignore) {}
try {
reqType.getMethod("setBody", String.class).invoke(stub, payload);
} catch (NoSuchMethodException ignore) {}
return stub;
} catch (NoSuchMethodException e) {

View file

@ -96,6 +96,10 @@ pub fn owasp_stub_files() -> Vec<(String, String)> {
"org/springframework/web/util/HtmlUtils.java".to_owned(),
html_utils_stub(),
),
(
"org/apache/commons/lang/StringEscapeUtils.java".to_owned(),
string_escape_utils_stub(),
),
]
}
@ -110,25 +114,78 @@ pub fn entry_needs_owasp_stubs(source: &str) -> bool {
|| source.contains("org.springframework.dao.")
|| source.contains("org.springframework.jdbc.")
|| source.contains("org.springframework.web.util.")
|| source.contains("org.apache.commons.lang.StringEscapeUtils")
}
fn utils_stub() -> String {
// FIDELITY (Track L.12): the real `Utils.encodeForHTML` /
// `htmlEscape` / `escapeHtml` delegate to a genuine HTML encoder
// (`ESAPI.encoder().encodeForHTML` / Spring `HtmlUtils.htmlEscape`).
// The verifier drives the real servlet with a live `<script>` payload,
// so the stub MUST escape faithfully: a benign fixture that routes the
// tainted value through one of these helpers neutralises the payload,
// and a no-op stub would echo the marker raw and FALSE-CONFIRM. Only
// an unsanitised raw write (`getWriter().print(param)`) reaches the
// response with the live marker. `printOSCommandResults` streams the
// child process's stdout/stderr to the response writer (and stdout) so
// a CODE_EXEC marker echoed by an injected `echo` reaches the
// OutputContains oracle.
r#"package org.owasp.benchmark.helpers;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
public class Utils {
public static String testfileDir = "/tmp/testfiles/";
public static String encodeForHTML(String s) { return s == null ? "" : s; }
public static String escapeHtml(String s) { return s == null ? "" : s; }
public static String htmlEscape(String s) { return s == null ? "" : s; }
// Faithful to the real helper (`user.dir + /testfiles/`); resolves to the
// harness workdir's `testfiles/` because the Java harness runs with CWD =
// workdir. The FILE_IO path-traversal payload `../nyx_pt_canary` escapes
// this one level to the workdir-root canary the emitter plants.
public static String testfileDir =
System.getProperty("user.dir") + java.io.File.separator + "testfiles" + java.io.File.separator;
static String nyxEscapeHtml(String s) {
if (s == null) return "";
StringBuilder o = new StringBuilder(s.length() + 16);
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
switch (ch) {
case '&': o.append("&amp;"); break;
case '<': o.append("&lt;"); break;
case '>': o.append("&gt;"); break;
case '"': o.append("&quot;"); break;
case '\'': o.append("&#x27;"); break;
case '/': o.append("&#x2f;"); break;
default: o.append(ch);
}
}
return o.toString();
}
public static String encodeForHTML(Object param) {
return param == null ? "" : nyxEscapeHtml(String.valueOf(param));
}
public static String escapeHtml(String s) { return nyxEscapeHtml(s); }
public static String htmlEscape(String s) { return nyxEscapeHtml(s); }
public static String getFileFromClasspath(String name, ClassLoader cl) { return name; }
public static String getInsecureOSCommandString(ClassLoader cl) { return "/bin/sh"; }
public static String getOSCommandString(String cmd) { return cmd == null ? "/bin/sh" : cmd; }
public static void printOSCommandResults(Process p, Object response) {
try {
InputStream is = p.getInputStream();
if (is != null) { is.close(); }
StringBuilder out = new StringBuilder();
try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
String line;
while ((line = r.readLine()) != null) { out.append(line).append('\n'); }
} catch (IOException ignore) {}
try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getErrorStream()))) {
String line;
while ((line = r.readLine()) != null) { out.append(line).append('\n'); }
} catch (IOException ignore) {}
String text = out.toString();
System.out.print(text);
if (response != null) {
try {
Object w = response.getClass().getMethod("getWriter").invoke(response);
w.getClass().getMethod("write", String.class).invoke(w, text);
} catch (Exception ignore) {}
}
try { p.waitFor(); } catch (InterruptedException ignore) {}
}
}
"#
@ -194,15 +251,33 @@ public class LDAPManager {
}
fn separate_class_request_stub() -> String {
// Real SeparateClassRequest wraps a servlet request and delegates
// getTheParameter / getTheValue through to it; the stub keeps the
// public surface but discards the request reference.
// FIDELITY (Track L.12): the real `SeparateClassRequest` wraps the
// servlet request. `getTheParameter` / `getTheCookie` are TAINTED
// sources (delegate to the request, which the Nyx stub firehoses with
// the payload); `getTheValue` is a SAFE source — the real helper
// returns the constant `"bar"` regardless of input, so benign fixtures
// that read through `getTheValue` must NOT receive the payload (else
// they false-confirm). Delegating to the (firehosed) request keeps the
// tainted accessors live while the constant keeps the safe one safe.
r#"package org.owasp.benchmark.helpers;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
public class SeparateClassRequest {
public SeparateClassRequest(HttpServletRequest request) {}
public String getTheParameter(String name) { return null; }
public String getTheValue(String name) { return null; }
private HttpServletRequest request;
public SeparateClassRequest(HttpServletRequest request) { this.request = request; }
public String getTheParameter(String p) { return request == null ? null : request.getParameter(p); }
public String getTheCookie(String c) {
if (request == null) return "";
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(c)) { return cookie.getValue(); }
}
}
return "";
}
// Safe source: the real helper hard-codes this return.
public String getTheValue(String p) { return "bar"; }
}
"#
.to_owned()
@ -231,10 +306,33 @@ public interface ThingInterface {
}
fn esapi_stub() -> String {
// FIDELITY (Track L.12): `ESAPI.encoder().encodeForHTML` is a REAL
// HTML encoder in upstream ESAPI. OWASP benign fixtures (and the
// incidental `getWriter().write(ESAPI.encoder().encodeForHTML(fileName))`
// echoes in path-traversal / crypto fixtures) rely on it neutralising
// metacharacters. A no-op stub would let a firehosed `<script>` marker
// through and FALSE-CONFIRM xss on those files, so the stub escapes
// `& < > " ' /` exactly like the real encoder's HTML context.
r#"package org.owasp.esapi;
public class ESAPI {
private static final Encoder ENCODER = new Encoder() {
@Override public String encodeForHTML(String s) { return s == null ? "" : s; }
@Override public String encodeForHTML(String s) {
if (s == null) return "";
StringBuilder o = new StringBuilder(s.length() + 16);
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
switch (ch) {
case '&': o.append("&amp;"); break;
case '<': o.append("&lt;"); break;
case '>': o.append("&gt;"); break;
case '"': o.append("&quot;"); break;
case '\'': o.append("&#x27;"); break;
case '/': o.append("&#x2f;"); break;
default: o.append(ch);
}
}
return o.toString();
}
@Override public String encodeForBase64(byte[] b, boolean wrap) {
return b == null ? "" : java.util.Base64.getEncoder().encodeToString(b);
}
@ -255,6 +353,35 @@ public interface Encoder {
.to_owned()
}
fn string_escape_utils_stub() -> String {
// FIDELITY (Track L.12): Apache Commons Lang `StringEscapeUtils.escapeHtml`
// is a real HTML encoder used as the XSS defence in benign OWASP fixtures
// (`org.apache.commons.lang.StringEscapeUtils.escapeHtml(param)`, inline
// FQN). Without the stub javac reports the package missing → BuildFailed;
// with a faithful escape a benign escape path neutralises the marker.
r#"package org.apache.commons.lang;
public class StringEscapeUtils {
public static String escapeHtml(String s) {
if (s == null) return null;
StringBuilder o = new StringBuilder(s.length() + 16);
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
switch (ch) {
case '&': o.append("&amp;"); break;
case '<': o.append("&lt;"); break;
case '>': o.append("&gt;"); break;
case '"': o.append("&quot;"); break;
default: o.append(ch);
}
}
return o.toString();
}
public static String unescapeHtml(String s) { return s; }
}
"#
.to_owned()
}
fn data_access_exception_stub() -> String {
r#"package org.springframework.dao;
public class DataAccessException extends RuntimeException {
@ -293,9 +420,29 @@ public interface SqlRowSet {
}
fn html_utils_stub() -> String {
// FIDELITY (Track L.12): Spring `HtmlUtils.htmlEscape` is a real HTML
// encoder; benign OWASP fixtures use it as the XSS defence. Escape
// faithfully so a firehosed `<script>` marker cannot survive a benign
// escape path and false-confirm.
r#"package org.springframework.web.util;
public class HtmlUtils {
public static String htmlEscape(String s) { return s == null ? "" : s; }
public static String htmlEscape(String s) {
if (s == null) return "";
StringBuilder o = new StringBuilder(s.length() + 16);
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
switch (ch) {
case '&': o.append("&amp;"); break;
case '<': o.append("&lt;"); break;
case '>': o.append("&gt;"); break;
case '"': o.append("&quot;"); break;
case '\'': o.append("&#39;"); break;
default: o.append(ch);
}
}
return o.toString();
}
public static String htmlEscape(String s, String enc) { return htmlEscape(s); }
public static String htmlUnescape(String s) { return s == null ? "" : s; }
}
"#
@ -354,6 +501,7 @@ mod tests {
"org/springframework/jdbc/core/RowMapper.java",
"org/springframework/jdbc/support/rowset/SqlRowSet.java",
"org/springframework/web/util/HtmlUtils.java",
"org/apache/commons/lang/StringEscapeUtils.java",
] {
assert!(
paths.iter().any(|p| p == required),
@ -451,8 +599,8 @@ mod tests {
let files = owasp_stub_files();
assert_eq!(
files.len(),
13,
"expected 9 owasp + 4 springframework stubs"
14,
"expected 9 owasp + 4 springframework + 1 commons-lang stub"
);
}
}

View file

@ -165,23 +165,45 @@ import {pkg}.ServletInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public class HttpServletRequest {{
private final Map<String, String> params = new HashMap<>();
private String method = "GET";
private String body = "";
// ── Nyx taint-firehose seeding (Track L.12) ──────────────────────────────
// The dynamic verifier drives the real servlet `doPost`/`doGet` with a
// malicious request. Real OWASP-shape servlets read the tainted slot
// through one of many accessors (`getParameter`, `getHeader`,
// `getHeaders`, `getCookies`, `getQueryString`, `getReader`,
// `getInputStream`) keyed on a name the harness does not know a priori.
// Rather than guess the slot, the stub returns the seeded payload from
// EVERY source accessor (the "firehose"): whichever accessor/name the
// servlet reads, it receives the payload. This is sound — the servlet's
// OWN sanitiser / branch logic still runs on the firehosed value, so a
// benign fixture that neutralises the input (constant-overwrite, real
// escaper, validator) still produces no marker; only an unsanitised flow
// reaches the sink with the live payload. Cookie slots are name-matched
// (`cookie.getName().equals("X")`), so the harness seeds the candidate
// names it extracted from the servlet source into `nyxCookieNames`.
private String nyxTaint = null;
private String[] nyxCookieNames = new String[0];
public HttpServletRequest() {{}}
public void setParameter(String name, String value) {{ params.put(name, value); }}
public void setMethod(String m) {{ this.method = m; }}
public void setBody(String b) {{ this.body = b == null ? "" : b; }}
public void setBody(String b) {{ this.body = b == null ? "" : b; if (b != null && nyxTaint == null) nyxTaint = b; }}
public void nyxSeedTaint(String v) {{ this.nyxTaint = v; if (v != null) this.body = v; }}
public void nyxSeedCookieNames(String[] names) {{ if (names != null) this.nyxCookieNames = names; }}
public String getBody() {{ return body; }}
public String getParameter(String name) {{ return params.get(name); }}
public String getParameter(String name) {{ String v = params.get(name); return v != null ? v : nyxTaint; }}
public String[] getParameterValues(String name) {{
String v = params.get(name);
if (v == null) v = nyxTaint;
return v == null ? null : new String[] {{ v }};
}}
public Map<String, String[]> getParameterMap() {{
@ -192,18 +214,37 @@ public class HttpServletRequest {{
return m;
}}
public Enumeration<String> getParameterNames() {{ return Collections.enumeration(params.keySet()); }}
public String getHeader(String name) {{ return null; }}
public Enumeration<String> getHeaders(String name) {{ return Collections.emptyEnumeration(); }}
public String getHeader(String name) {{ return nyxTaint; }}
public Enumeration<String> getHeaders(String name) {{
return nyxTaint == null
? Collections.<String>emptyEnumeration()
: Collections.enumeration(Collections.singletonList(nyxTaint));
}}
public Enumeration<String> getHeaderNames() {{ return Collections.emptyEnumeration(); }}
public int getIntHeader(String name) {{ return -1; }}
public long getDateHeader(String name) {{ return -1L; }}
public Cookie[] getCookies() {{ return null; }}
public Cookie[] getCookies() {{
if (nyxTaint == null) return null;
List<Cookie> cs = new ArrayList<>();
for (String n : nyxCookieNames) {{ if (n != null) cs.add(new Cookie(n, nyxTaint)); }}
if (cs.isEmpty()) cs.add(new Cookie("vector", nyxTaint));
return cs.toArray(new Cookie[0]);
}}
public HttpSession getSession() {{ return new HttpSession(); }}
public HttpSession getSession(boolean create) {{ return new HttpSession(); }}
public ServletInputStream getInputStream() throws IOException {{ return null; }}
public BufferedReader getReader() throws IOException {{ return new BufferedReader(new StringReader(body)); }}
public ServletInputStream getInputStream() throws IOException {{
final byte[] data = (nyxTaint != null ? nyxTaint : body)
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
return new ServletInputStream() {{
private int pos = 0;
@Override public int read() throws IOException {{ return pos < data.length ? (data[pos++] & 0xff) : -1; }}
}};
}}
public BufferedReader getReader() throws IOException {{
return new BufferedReader(new StringReader(nyxTaint != null ? nyxTaint : body));
}}
public String getMethod() {{ return method; }}
public String getQueryString() {{ return null; }}
public String getQueryString() {{ return nyxTaint; }}
public StringBuffer getRequestURL() {{ return new StringBuffer(); }}
public String getRequestURI() {{ return ""; }}
public String getRemoteAddr() {{ return "127.0.0.1"; }}

View file

@ -1700,7 +1700,7 @@ fn cap_for_rule_category(category: &str) -> Option<Cap> {
/// `os.system("echo " + shlex.quote(x))` control no longer false-confirms).
/// Other set bits are preserved so a multi-cap sink keeps its other
/// (already-driveable) capabilities.
fn drivable_expected_cap(cap: Cap) -> Cap {
pub(crate) fn drivable_expected_cap(cap: Cap) -> Cap {
if cap.contains(Cap::SHELL_ESCAPE) {
(cap - Cap::SHELL_ESCAPE) | Cap::CODE_EXEC
} else {

View file

@ -59,7 +59,7 @@ pub const NYX_VERSION: &str = env!("CARGO_PKG_VERSION");
/// [`crate::dynamic::corpus::CORPUS_VERSION`]; the compile-time assertion
/// below + the [`corpus_version_const_matches_corpus_module`] runtime test
/// jointly guard drift.
pub const CORPUS_VERSION: &str = "16";
pub const CORPUS_VERSION: &str = "17";
/// Compile-time guard that pins [`CORPUS_VERSION`] (this module) to the
/// textual form of [`crate::dynamic::corpus::CORPUS_VERSION`]. Bumping the