From c95dcc00fb741815569e8ec3818652e5e428b0b3 Mon Sep 17 00:00:00 2001 From: pitboss Date: Thu, 21 May 2026 19:02:47 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0014 (20260521T201327Z-3848) --- src/dynamic/lang/java.rs | 151 ++++++++++++++++++++++++++++++++++--- src/dynamic/lang/php.rs | 112 ++++++++++++++++++++++++++- src/dynamic/lang/python.rs | 101 ++++++++++++++++++++++++- 3 files changed, 350 insertions(+), 14 deletions(-) diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 146dda00..9b7c5659 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -1318,17 +1318,77 @@ public class NyxHarness {{ /// Phase 07 — Track J.5 XPath-injection harness for Java /// (`javax.xml.xpath.XPath.evaluate`). /// -/// Reads `NYX_PAYLOAD`, splices it into a `//user[@name='']` -/// expression, counts matching `` nodes against the canonical -/// staged document, and writes a `ProbeKind::Xpath { nodes_returned }` -/// probe whose `n` is the count returned. Mirrors the -/// synthetic-harness pattern used by Phase 03 / 04 / 05 / 06; a -/// future structural fix will link real `javax.xml.xpath` via the -/// staged document. -pub fn emit_xpath_harness(_spec: &HarnessSpec) -> HarnessSource { +/// 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 uses_real_xpath = entry_source.contains("javax.xml.xpath"); + + let main_body = if uses_real_xpath { + format!( + r#" // 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. Falls back to the inline + // matcher when reflection fails so the harness still produces + // a verdict on a fixture whose `run` signature does not match. + int count = -1; + 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(); + }} + }} catch (ClassNotFoundException | NoSuchMethodException + | IllegalAccessException e) {{ + // Fixture shape did not match (String) -> NodeList — fall + // through to the synthetic matcher below. + }} 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; + }} + if (count < 0) {{ + count = nyxXpathSelect(expr); + }}"# + ) + } else { + r#" // No real javax.xml.xpath import on the entry source — fall + // back to the inline matcher path so the harness still + // produces a verdict. + int count = nyxXpathSelect(expr);"# + .to_owned() + }; + + let imports = if uses_real_xpath { + "import java.lang.reflect.InvocationTargetException;\n\ + import java.lang.reflect.Method;\n\ + import org.w3c.dom.NodeList;\n" + } else { + "" + }; + let source = format!( r#"// Nyx dynamic harness — XPATH_INJECTION javax.xml.xpath.XPath.evaluate (Phase 07 / Track J.5). import java.io.FileWriter; @@ -1337,7 +1397,7 @@ import java.util.Arrays; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; - +{imports} public class NyxHarness {{ {shim} @@ -1414,7 +1474,7 @@ public class NyxHarness {{ String payload = System.getenv("NYX_PAYLOAD"); if (payload == null) payload = ""; String expr = "//user[@name='" + payload + "']"; - int count = nyxXpathSelect(expr); +{main_body} nyxXpathProbe(expr, count); System.out.println("__NYX_SINK_HIT__"); StringBuilder body = new StringBuilder(64); @@ -3426,4 +3486,75 @@ mod tests { ); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn emit_xpath_harness_drives_fixture_through_real_xpath_when_imported() { + 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("count = nyxXpathSelect(expr);"), + "tier-(a) harness must preserve the inline matcher as a fallback", + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn emit_xpath_harness_falls_back_to_inline_matcher_without_xpath_import() { + let dir = std::env::temp_dir().join("nyx_phase07_test_no_xpath_import"); + 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("import org.w3c.dom.NodeList;"), + "fallback path must not import NodeList", + ); + assert!( + !h.source.contains("Class.forName(\"Vuln\")"), + "fallback path must not invoke the fixture reflectively", + ); + assert!( + h.source.contains("int count = nyxXpathSelect(expr);"), + "fallback path must keep the inline matcher as the primary count source", + ); + } } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 5fe13243..edb4e5e1 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -272,6 +272,19 @@ fn read_entry_source(entry_file: &str) -> String { String::new() } +/// Map an entry file path like `tests/.../vuln.php` to the basename +/// (`vuln.php`) the harness will `require_once`. Falls back to +/// `vuln.php` when the path is unusable so the harness still attempts +/// the require (the fallback inline matcher fires when the require +/// fails). +fn derive_php_entry_basename(entry_file: &str) -> String { + PathBuf::from(entry_file) + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_owned()) + .unwrap_or_else(|| "vuln.php".to_owned()) +} + /// Phase 09 — Track D.2: synthesise a `composer.json` with the captured /// PHP version pin and (where known) the framework deps. pub fn materialize_php(env: &Environment) -> RuntimeArtifacts { @@ -939,10 +952,16 @@ echo json_encode(['filter' => $filt, 'entries_returned' => $count]) . "\n"; /// synthetic-harness pattern used by Phase 03 / 04 / 05 / 06; a /// future structural fix will link real `DOMXPath` via the staged /// document. -pub fn emit_xpath_harness(_spec: &HarnessSpec) -> HarnessSource { +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_basename = derive_php_entry_basename(&spec.entry_file); + let entry_name = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; let body = format!( r#"length; + }} + if (is_array($result)) {{ + return count($result); + }} + return null; +}} + $payload = (string) (getenv('NYX_PAYLOAD') ?: ''); $expr = "//user[@name='" . $payload . "']"; -$nodes = _nyx_xpath_select($expr, $NYX_XPATH_USERS); +$nodes = _nyx_xpath_via_fixture($payload, "{entry_basename}", "{entry_name}"); +if ($nodes === null) {{ + $nodes = _nyx_xpath_select($expr, $NYX_XPATH_USERS); +}} _nyx_xpath_probe($expr, $nodes); echo "__NYX_SINK_HIT__\n"; echo json_encode(['expr' => $expr, 'nodes_returned' => $nodes]) . "\n"; @@ -1904,4 +1960,56 @@ mod tests { "PHP LDAP harness must dispatch through the stub-route helper", ); } + + fn make_xpath_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::XPATH_INJECTION; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + #[test] + fn emit_xpath_harness_routes_through_fixture_require() { + let h = emit_xpath_harness(&make_xpath_spec( + "tests/dynamic_fixtures/xpath_injection/php/vuln.php", + "run", + )); + assert_eq!(h.extra_files.len(), 1); + assert_eq!(h.extra_files[0].0, "xpath_corpus.xml"); + assert!( + h.source.contains("function _nyx_xpath_via_fixture("), + "PHP XPath harness must define the fixture-routing helper", + ); + assert!( + h.source.contains("require_once $candidate"), + "PHP XPath harness must require the entry fixture before invoking it", + ); + assert!( + h.source.contains("\"vuln.php\""), + "PHP XPath harness must pass the entry basename to the helper", + ); + assert!( + h.source.contains("\"run\""), + "PHP XPath harness must pass the entry function name to the helper", + ); + assert!( + h.source.contains("$result instanceof DOMNodeList"), + "PHP XPath harness must check the result against DOMNodeList", + ); + assert!( + h.source + .contains("$nodes = _nyx_xpath_select($expr, $NYX_XPATH_USERS);"), + "PHP XPath harness must keep the inline matcher as a fallback", + ); + } + + #[test] + fn emit_xpath_harness_derives_basename_from_entry_file() { + let h = emit_xpath_harness(&make_xpath_spec("/abs/path/benign.php", "run")); + assert!( + h.source.contains("\"benign.php\""), + "PHP XPath harness must use the entry-file basename, not a hard-coded literal", + ); + } } diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index ef741ea6..0dea1f1e 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -1744,13 +1744,20 @@ if __name__ == "__main__": /// staged document, and writes a `ProbeKind::Xpath { nodes_returned }` /// probe whose `n` is the count returned. Mirrors the /// synthetic-harness pattern used by Phase 03 / 04 / 05 / 06. -pub fn emit_xpath_harness(_spec: &HarnessSpec) -> HarnessSource { +pub fn emit_xpath_harness(spec: &HarnessSpec) -> HarnessSource { let probe = 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 module_name = derive_module_name(&spec.entry_file); + let entry_name = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; let body = format!( r#"#!/usr/bin/env python3 """Nyx dynamic harness — XPATH_INJECTION lxml.etree.xpath (Phase 07 / Track J.5).""" +import importlib import json import os import re @@ -1790,6 +1797,34 @@ def _nyx_xpath_select(expr): return len(_NYX_XPATH_USERS) +def _nyx_xpath_via_fixture(payload): + # Phase 07 tier-(a): import the fixture and call its + # `{entry_name}` so the real `lxml.etree.xpath` (or other + # XPath evaluator the fixture chooses) runs against the staged + # corpus document. Returns the node count, or `None` when the + # import or call fails (e.g. lxml is not installed on the host) + # so the caller can fall back to the inline matcher. + sys.path.insert(0, ".") + try: + mod = importlib.import_module("{module_name}") + except Exception: + return None + fn = getattr(mod, "{entry_name}", None) + if fn is None: + return None + try: + result = fn(payload) + except Exception: + # Malformed XPath / parse error / etc. — treat as a 0-node + # return so a benign fixture that rejects the payload stays + # NotConfirmed. + return 0 + try: + return len(result) + except TypeError: + return 0 + + def _nyx_xpath_probe(expr, nodes_returned): rec = {{ "sink_callee": "lxml.etree.xpath", @@ -1805,7 +1840,9 @@ def _nyx_xpath_probe(expr, nodes_returned): def _nyx_run(): payload = os.environ.get("NYX_PAYLOAD", "") expr = "//user[@name='" + payload + "']" - nodes = _nyx_xpath_select(expr) + nodes = _nyx_xpath_via_fixture(payload) + if nodes is None: + nodes = _nyx_xpath_select(expr) _nyx_xpath_probe(expr, nodes) print("__NYX_SINK_HIT__", flush=True) sys.stdout.write(json.dumps({{"expr": expr, "nodes_returned": nodes}}) + "\n") @@ -1826,6 +1863,19 @@ if __name__ == "__main__": } } +/// Map an entry file path like `tests/.../vuln.py` to the Python +/// module name `vuln` the harness will `importlib.import_module(...)`. +/// Falls back to `vuln` when the path is unusable so the harness still +/// attempts an import (the fallback inline matcher fires when the +/// import fails). +fn derive_module_name(entry_file: &str) -> String { + PathBuf::from(entry_file) + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_owned()) + .unwrap_or_else(|| "vuln".to_owned()) +} + /// Phase 08 — Track J.6 header-injection harness for Python (Flask /// `Response.headers.__setitem__`). /// @@ -2952,4 +3002,51 @@ mod tests { "Python LDAP harness must dispatch through the stub-route helper", ); } + + fn make_xpath_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::XPATH_INJECTION; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + #[test] + fn emit_xpath_harness_routes_through_fixture_import() { + let h = emit_xpath_harness(&make_xpath_spec( + "tests/dynamic_fixtures/xpath_injection/python/vuln.py", + "run", + )); + assert_eq!(h.extra_files.len(), 1); + assert_eq!(h.extra_files[0].0, "xpath_corpus.xml"); + assert!( + h.source.contains("def _nyx_xpath_via_fixture(payload):"), + "Python XPath harness must define the fixture-routing helper", + ); + assert!( + h.source.contains("importlib.import_module(\"vuln\")"), + "Python XPath harness must import the entry module by its file stem", + ); + assert!( + h.source.contains("getattr(mod, \"run\", None)"), + "Python XPath harness must look up the entry function by name", + ); + assert!( + h.source.contains("nodes = _nyx_xpath_via_fixture(payload)"), + "Python XPath harness main must call the fixture-routing helper first", + ); + assert!( + h.source.contains("nodes = _nyx_xpath_select(expr)"), + "Python XPath harness must keep the inline matcher as a fallback", + ); + } + + #[test] + fn emit_xpath_harness_derives_module_name_from_entry_file() { + let h = emit_xpath_harness(&make_xpath_spec("/abs/path/benign.py", "run")); + assert!( + h.source.contains("importlib.import_module(\"benign\")"), + "module name must come from the entry-file stem, not a hard-coded literal", + ); + } }