[pitboss/grind] deferred session-0014 (20260521T201327Z-3848)

This commit is contained in:
pitboss 2026-05-21 19:02:47 -05:00
parent 86ab2aa74a
commit c95dcc00fb
3 changed files with 350 additions and 14 deletions

View file

@ -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='<payload>']`
/// expression, counts matching `<user>` 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",
);
}
}

View file

@ -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#"<?php
// Nyx dynamic harness — XPATH_INJECTION DOMXPath::query (Phase 07 / Track J.5).
@ -1033,9 +1052,46 @@ function _nyx_xpath_probe(string $expr, int $nodes_returned): void {{
@file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND);
}}
function _nyx_xpath_via_fixture(string $payload, string $entry_basename, string $entry_name): ?int {{
// Phase 07 tier-(a): require the fixture file and call its
// `$entry_name` function so the real `DOMXPath::query` runs
// against the staged corpus document. Returns the result-set
// length, or `null` when the require / call fails so the caller
// can fall back to the inline matcher.
$candidate = __DIR__ . DIRECTORY_SEPARATOR . $entry_basename;
if (!is_file($candidate)) {{
return null;
}}
try {{
require_once $candidate;
}} catch (\Throwable $_) {{
return null;
}}
if (!function_exists($entry_name)) {{
return null;
}}
try {{
$result = $entry_name($payload);
}} catch (\Throwable $_) {{
// Malformed XPath / parse error — treat as a 0-node return so
// a benign fixture that rejects the payload stays NotConfirmed.
return 0;
}}
if ($result instanceof DOMNodeList) {{
return $result->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",
);
}
}

View file

@ -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",
);
}
}