From 86ab2aa74a4a8e5946d60c085569560d71b35d87 Mon Sep 17 00:00:00 2001 From: pitboss Date: Thu, 21 May 2026 18:37:23 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0013 (20260521T201327Z-3848) --- src/dynamic/lang/java.rs | 412 +++++++++++++++++++++++-- src/dynamic/lang/java_servlet_stubs.rs | 78 ++++- src/dynamic/lang/php.rs | 73 ++++- src/dynamic/lang/python.rs | 91 +++++- tests/ldap_corpus.rs | 109 +++++++ 5 files changed, 735 insertions(+), 28 deletions(-) diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 2a46c81f..146dda00 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -1099,8 +1099,13 @@ pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource { let shim = probe_shim(); let source = format!( r#"// Nyx dynamic harness — LDAP_INJECTION LdapTemplate.search (Phase 06 / Track J.4). +import java.io.BufferedReader; import java.io.FileWriter; import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -1132,7 +1137,49 @@ public class NyxHarness {{ return false; }} + /// When `NYX_LDAP_ENDPOINT` is set to `host:port`, route the search + /// through the in-sandbox LDAP stub over its plain-text protocol + /// (`SEARCH \n` → `COUNT \n…`) and return the parsed + /// count. Returns `-1` when the env var is unset, the address + /// fails to parse, or the socket exchange errors — caller falls + /// back to the in-process matcher. + static int nyxLdapCountViaStub(String filter) {{ + String ep = System.getenv("NYX_LDAP_ENDPOINT"); + if (ep == null || ep.isEmpty()) return -1; + int colon = ep.lastIndexOf(':'); + if (colon <= 0 || colon >= ep.length() - 1) return -1; + String host = ep.substring(0, colon); + int port; + try {{ + port = Integer.parseInt(ep.substring(colon + 1)); + }} catch (NumberFormatException nfe) {{ + return -1; + }} + try (Socket sock = new Socket(host, port)) {{ + sock.setSoTimeout(2000); + OutputStream os = sock.getOutputStream(); + os.write(("SEARCH " + filter + "\n").getBytes(StandardCharsets.UTF_8)); + os.flush(); + BufferedReader br = new BufferedReader(new InputStreamReader(sock.getInputStream(), StandardCharsets.UTF_8)); + String line = br.readLine(); + if (line == null || !line.startsWith("COUNT ")) return -1; + try {{ + return Integer.parseInt(line.substring("COUNT ".length()).trim()); + }} catch (NumberFormatException nfe) {{ + return -1; + }} + }} catch (IOException ioe) {{ + return -1; + }} + }} + static int nyxLdapCount(String filter) {{ + int viaStub = nyxLdapCountViaStub(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; @@ -1165,7 +1212,7 @@ public class NyxHarness {{ }} static boolean nyxLdapMatch(String filter, String uid) {{ - return nyxLdapCount(filter) > 0 + return nyxLdapCountLocal(filter) > 0 ? nyxLdapMatchOne(filter, uid) : false; }} @@ -1405,11 +1452,85 @@ public class NyxHarness {{ pub fn emit_header_injection_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(); + 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 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} @@ -1447,17 +1568,8 @@ public class NyxHarness {{ public static void main(String[] args) {{ String payload = System.getenv("NYX_PAYLOAD"); if (payload == null) payload = ""; - String name = "Set-Cookie"; - String value = payload; - nyxHeaderProbe(name, value); +{main_body} System.out.println("__NYX_SINK_HIT__"); - StringBuilder body = new StringBuilder(64); - body.append("{{\"name\":\""); - nyxJsonEscape(name, body); - body.append("\",\"value\":\""); - nyxJsonEscape(value, body); - body.append("\"}}"); - System.out.println(body.toString()); }} }} "# @@ -1487,11 +1599,72 @@ public class NyxHarness {{ 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); + }} else {{ + nyxRedirectProbe(payload, requestHost); + }}"# + ) + } else { + r#" nyxRedirectProbe(payload, requestHost);"#.to_owned() + }; + + 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 — OPEN_REDIRECT HttpServletResponse.sendRedirect (Phase 09 / Track J.7). import java.io.FileWriter; import java.io.IOException; - +{imports} public class NyxHarness {{ {shim} @@ -1528,16 +1701,8 @@ public class NyxHarness {{ String payload = System.getenv("NYX_PAYLOAD"); if (payload == null) payload = ""; String requestHost = "example.com"; - String location = payload; - nyxRedirectProbe(location, requestHost); +{main_body} System.out.println("__NYX_SINK_HIT__"); - StringBuilder body = new StringBuilder(64); - body.append("{{\"location\":\""); - nyxJsonEscape(location, body); - body.append("\",\"request_host\":\""); - nyxJsonEscape(requestHost, body); - body.append("\"}}"); - System.out.println(body.toString()); }} }} "# @@ -3058,4 +3223,207 @@ mod tests { } 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("new Socket(host, port)"), + "Java LDAP harness must open a TCP socket against the stub endpoint", + ); + assert!( + h.source.contains("SEARCH "), + "Java LDAP harness must write SEARCH over the wire", + ); + assert!( + h.source.contains("COUNT "), + "Java LDAP harness must parse the COUNT reply line", + ); + } + + #[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("nyxLdapCountViaStub"), + "Java LDAP harness must dispatch through the 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_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); + } } diff --git a/src/dynamic/lang/java_servlet_stubs.rs b/src/dynamic/lang/java_servlet_stubs.rs index da969d15..9fe5a1b7 100644 --- a/src/dynamic/lang/java_servlet_stubs.rs +++ b/src/dynamic/lang/java_servlet_stubs.rs @@ -249,6 +249,8 @@ import java.io.IOException; import java.io.OutputStream; import java.io.PrintWriter; import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; public class HttpServletResponse {{ public static final int SC_OK = 200; public static final int SC_NOT_FOUND = 404; @@ -257,9 +259,17 @@ public class HttpServletResponse {{ public static final int SC_INTERNAL_SERVER_ERROR = 500; public static final int SC_MOVED_PERMANENTLY = 301; public static final int SC_MOVED_TEMPORARILY = 302; + // Phase 08 (Track J.6): permissive header-capture buffer the Nyx + // harness drains after invoking the fixture's sink call. The buffer + // records every `setHeader` / `addHeader` write verbatim (including + // CRLF metacharacters); it does NOT mimic Tomcat 9+'s CRLF rejection + // so the differential rule can compare a real-fixture sanitiser path + // against an unsanitised one. + private static final List nyxCapturedHeaders = new ArrayList<>(); private final StringWriter sw = new StringWriter(); private final PrintWriter pw = new PrintWriter(sw); private int status = SC_OK; + private String redirectLocation = null; public HttpServletResponse() {{}} public PrintWriter getWriter() throws IOException {{ return pw; }} public String getBody() {{ pw.flush(); return sw.toString(); }} @@ -274,10 +284,32 @@ public class HttpServletResponse {{ public int getStatus() {{ return status; }} public void sendError(int sc) throws IOException {{ this.status = sc; }} public void sendError(int sc, String msg) throws IOException {{ this.status = sc; }} - public void sendRedirect(String location) throws IOException {{ this.status = SC_MOVED_TEMPORARILY; }} + public void sendRedirect(String location) throws IOException {{ + this.status = SC_MOVED_TEMPORARILY; + this.redirectLocation = location; + }} + public String getRedirectedUrl() {{ return redirectLocation; }} public void addCookie(Cookie cookie) {{}} - public void setHeader(String name, String value) {{}} - public void addHeader(String name, String value) {{}} + public void setHeader(String name, String value) {{ + synchronized (nyxCapturedHeaders) {{ + nyxCapturedHeaders.add(new String[]{{ name, value }}); + }} + }} + public void addHeader(String name, String value) {{ + synchronized (nyxCapturedHeaders) {{ + nyxCapturedHeaders.add(new String[]{{ name, value }}); + }} + }} + /** Drain every header recorded by {{@link #setHeader}} / {{@link #addHeader}} since + * the last drain. Used by the Nyx HEADER_INJECTION harness; returns + * pairs in insertion order. */ + public static List nyxDrainHeaders() {{ + synchronized (nyxCapturedHeaders) {{ + List snap = new ArrayList<>(nyxCapturedHeaders); + nyxCapturedHeaders.clear(); + return snap; + }} + }} public void setIntHeader(String name, int value) {{}} public void addIntHeader(String name, int value) {{}} public void setDateHeader(String name, long date) {{}} @@ -432,6 +464,46 @@ mod tests { } } + #[test] + fn http_servlet_response_captures_headers_for_phase_08() { + // Phase 08 (Track J.6): the response stub records every + // setHeader / addHeader write into a static buffer the + // HEADER_INJECTION harness drains after invoking the fixture's + // sink call. Tomcat-style CRLF rejection is intentionally NOT + // modeled so the differential rule can compare a real-fixture + // sanitiser path against an unsanitised one. + for pkg in &["javax.servlet.http", "jakarta.servlet.http"] { + let resp = http_servlet_response(pkg); + for marker in &[ + "nyxCapturedHeaders", + "nyxDrainHeaders", + "synchronized (nyxCapturedHeaders)", + ] { + assert!( + resp.contains(marker), + "{pkg} HttpServletResponse stub missing `{marker}`", + ); + } + } + } + + #[test] + fn http_servlet_response_keeps_redirect_capture_for_phase_09() { + // Phase 09 (Track J.7): the response stub records the most + // recent sendRedirect target so the OPEN_REDIRECT harness can + // mirror the Phase 08 header-capture pattern when the bootstrap + // path lands. Pin the capture wiring up-front so a future + // refactor cannot silently drop it. + for pkg in &["javax.servlet.http", "jakarta.servlet.http"] { + let resp = http_servlet_response(pkg); + assert!( + resp.contains("redirectLocation") + && resp.contains("getRedirectedUrl"), + "{pkg} HttpServletResponse stub missing redirect-capture wiring", + ); + } + } + #[test] fn http_servlet_request_keeps_reflective_hook_setters() { // The Java emitter's SERVLET_HELPER uses reflection to invoke diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index ae166fd5..5fe13243 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -853,7 +853,31 @@ function _nyx_match_one(string $filt, string $uid): bool {{ return _nyx_attr_match($pattern, $uid); }} -function _nyx_ldap_count(string $filt, array $users): int {{ +function _nyx_ldap_count_via_stub(string $filt): ?int {{ + $ep = getenv('NYX_LDAP_ENDPOINT'); + if ($ep === false || $ep === '') return null; + $sep = strrpos($ep, ':'); + if ($sep === false || $sep === 0 || $sep === strlen($ep) - 1) return null; + $host = substr($ep, 0, $sep); + $port = (int) substr($ep, $sep + 1); + if ($port <= 0) return null; + $errno = 0; + $errstr = ''; + $sock = @fsockopen($host, $port, $errno, $errstr, 2.0); + if ($sock === false) return null; + stream_set_timeout($sock, 2); + @fwrite($sock, 'SEARCH ' . $filt . "\n"); + $line = @fgets($sock); + @fclose($sock); + if ($line === false) return null; + $line = rtrim($line, "\r\n"); + if (!str_starts_with($line, 'COUNT ')) return null; + $tail = trim(substr($line, strlen('COUNT '))); + if ($tail === '' || !ctype_digit($tail)) return null; + return (int) $tail; +}} + +function _nyx_ldap_count_local(string $filt, array $users): int {{ $f = trim($filt); if ($f === '') return 0; if (!(str_starts_with($f, '(') && str_ends_with($f, ')'))) return count($users); @@ -866,6 +890,12 @@ function _nyx_ldap_count(string $filt, array $users): int {{ return $count; }} +function _nyx_ldap_count(string $filt, array $users): int {{ + $via_stub = _nyx_ldap_count_via_stub($filt); + if ($via_stub !== null) return $via_stub; + return _nyx_ldap_count_local($filt, $users); +}} + function _nyx_ldap_probe(string $filt, int $entries_returned): void {{ $p = getenv('NYX_PROBE_PATH'); if ($p === false || $p === '') return; @@ -1833,4 +1863,45 @@ mod tests { "probe shim must come before the driver so the shim's helpers are in scope when a sink rewrite splices in" ); } + + 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"), + "PHP LDAP harness must read NYX_LDAP_ENDPOINT to route through the stub", + ); + assert!( + h.source.contains("fsockopen("), + "PHP LDAP harness must open a TCP socket against the stub endpoint", + ); + assert!( + h.source.contains("'SEARCH '"), + "PHP LDAP harness must write SEARCH over the wire", + ); + assert!( + h.source.contains("'COUNT '"), + "PHP LDAP harness must parse the COUNT reply line", + ); + } + + #[test] + fn emit_ldap_harness_retains_local_matcher_fallback() { + let h = emit_ldap_harness(&make_ldap_spec()); + assert!( + h.source.contains("_nyx_ldap_count_local"), + "PHP LDAP harness must keep the in-process matcher as a fallback for hosts without the stub", + ); + assert!( + h.source.contains("_nyx_ldap_count_via_stub"), + "PHP LDAP harness must dispatch through the stub-route helper", + ); + } } diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 76948faa..ef741ea6 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -1571,7 +1571,7 @@ pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource { let body = format!( r#"#!/usr/bin/env python3 """Nyx dynamic harness — LDAP_INJECTION ldap.search_s (Phase 06 / Track J.4).""" -import os, json, sys, time +import os, json, socket, sys, time {probe} @@ -1644,7 +1644,46 @@ def _nyx_match_one(filt, uid): return _nyx_attr_match(pattern, uid) -def _nyx_ldap_count(filt): +def _nyx_ldap_count_via_stub(filt): + """Route through the in-sandbox LDAP stub when NYX_LDAP_ENDPOINT is set. + + Returns the parsed `COUNT ` reply on success, or ``None`` when the + env var is unset, the address fails to parse, or the socket exchange + errors — caller falls back to the in-process matcher. + """ + ep = os.environ.get("NYX_LDAP_ENDPOINT", "") + if not ep: + return None + sep = ep.rfind(":") + if sep <= 0 or sep >= len(ep) - 1: + return None + host = ep[:sep] + try: + port = int(ep[sep + 1:]) + except ValueError: + return None + try: + with socket.create_connection((host, port), timeout=2.0) as sock: + sock.sendall(("SEARCH " + filt + "\n").encode("utf-8")) + buf = sock.makefile("rb") + line = buf.readline() + if not line: + return None + try: + line_s = line.decode("utf-8", "replace").rstrip("\r\n") + except Exception: + return None + if not line_s.startswith("COUNT "): + return None + try: + return int(line_s[len("COUNT "):].strip()) + except ValueError: + return None + except (OSError, socket.timeout): + return None + + +def _nyx_ldap_count_local(filt): f = (filt or "").strip() if not f: return 0 @@ -1655,6 +1694,13 @@ def _nyx_ldap_count(filt): return sum(1 for u in _NYX_LDAP_USERS if _nyx_match_one(f, u)) +def _nyx_ldap_count(filt): + via_stub = _nyx_ldap_count_via_stub(filt) + if via_stub is not None: + return via_stub + return _nyx_ldap_count_local(filt) + + def _nyx_ldap_probe(filt, entries_returned): rec = {{ "sink_callee": "ldap.search_s", @@ -2865,4 +2911,45 @@ mod tests { s.entry_name = name.to_owned(); s } + + 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"), + "Python LDAP harness must read NYX_LDAP_ENDPOINT to route through the stub", + ); + assert!( + h.source.contains("socket.create_connection"), + "Python LDAP harness must open a TCP socket against the stub endpoint", + ); + assert!( + h.source.contains("SEARCH "), + "Python LDAP harness must write SEARCH over the wire", + ); + assert!( + h.source.contains("COUNT "), + "Python LDAP harness must parse the COUNT reply line", + ); + } + + #[test] + fn emit_ldap_harness_retains_local_matcher_fallback() { + let h = emit_ldap_harness(&make_ldap_spec()); + assert!( + h.source.contains("_nyx_ldap_count_local"), + "Python LDAP harness must keep the in-process matcher as a fallback for hosts without the stub", + ); + assert!( + h.source.contains("_nyx_ldap_count_via_stub"), + "Python LDAP harness must dispatch through the stub-route helper", + ); + } } diff --git a/tests/ldap_corpus.rs b/tests/ldap_corpus.rs index c2e9d9b4..ea71e0e1 100644 --- a/tests/ldap_corpus.rs +++ b/tests/ldap_corpus.rs @@ -464,4 +464,113 @@ mod e2e_phase_06 { .expect("Confirmed run must carry a DifferentialOutcome"); assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); } + + // ── Tier (a): socket-route exercise ────────────────────────────── + // + // When `NYX_LDAP_ENDPOINT` is injected into the sandbox env the + // per-language harness must route its `(uid=…)` search through the + // in-sandbox LDAP stub over the documented `SEARCH \n` / + // `COUNT \n…` wire protocol instead of evaluating the filter + // in-process. The fallback inline matcher stays in place so a + // call site that runs without the stub still produces a verdict; + // this test pins the socket-route path itself. + use nyx_scanner::dynamic::stubs::StubProvider; + use nyx_scanner::dynamic::stubs::ldap_server::LdapStub; + + fn run_with_ldap_stub( + lang: Lang, + fixture: &str, + entry_name: &str, + ) -> Option<(RunOutcome, Vec)> { + let bin = toolchain_for(lang); + if !command_available(bin) { + eprintln!("SKIP {lang:?} {fixture}: missing toolchain {bin}"); + return None; + } + let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let stub = LdapStub::start().expect("ldap stub starts"); + let endpoint = stub.endpoint(); + let (mut spec, _tmp) = build_spec(lang, fixture, entry_name); + spec.stubs_required = vec![nyx_scanner::dynamic::stubs::StubKind::Ldap]; + let opts = SandboxOptions { + backend: SandboxBackend::Process, + extra_env: vec![( + nyx_scanner::dynamic::stubs::ldap_server::LDAP_ENDPOINT_ENV_VAR.to_owned(), + endpoint, + )], + ..SandboxOptions::default() + }; + let outcome = match run_spec(&spec, &opts) { + Ok(o) => o, + Err(RunError::BuildFailed { stderr, attempts }) => { + eprintln!( + "SKIP {lang:?} {fixture}: harness build failed after {attempts} attempts: {stderr}", + ); + return None; + } + Err(e) => panic!("run_spec({lang:?} {fixture}) errored: {e:?}"), + }; + let events = stub.drain_events(); + Some((outcome, events)) + } + + #[test] + fn java_vuln_routes_searches_through_stub() { + let Some((outcome, events)) = run_with_ldap_stub(Lang::Java, "Vuln.java", "run") else { + return; + }; + let diff = outcome + .differential + .as_ref() + .expect("Confirmed run must carry a DifferentialOutcome"); + assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + assert!( + !events.is_empty(), + "Java harness must route SEARCH through stub; got no events", + ); + assert!( + events.iter().any(|e| e.summary.starts_with("SEARCH (uid=")), + "Java harness stub events must carry a `(uid=…)` filter; got {events:?}", + ); + } + + #[test] + fn python_vuln_routes_searches_through_stub() { + let Some((outcome, events)) = run_with_ldap_stub(Lang::Python, "vuln.py", "run") else { + return; + }; + let diff = outcome + .differential + .as_ref() + .expect("Confirmed run must carry a DifferentialOutcome"); + assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + assert!( + !events.is_empty(), + "Python harness must route SEARCH through stub; got no events", + ); + assert!( + events.iter().any(|e| e.summary.starts_with("SEARCH (uid=")), + "Python harness stub events must carry a `(uid=…)` filter; got {events:?}", + ); + } + + #[test] + fn php_vuln_routes_searches_through_stub() { + let Some((outcome, events)) = run_with_ldap_stub(Lang::Php, "vuln.php", "run") else { + return; + }; + let diff = outcome + .differential + .as_ref() + .expect("Confirmed run must carry a DifferentialOutcome"); + assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + assert!( + !events.is_empty(), + "PHP harness must route SEARCH through stub; got no events", + ); + assert!( + events.iter().any(|e| e.summary.starts_with("SEARCH (uid=")), + "PHP harness stub events must carry a `(uid=…)` filter; got {events:?}", + ); + } }