From e2940fc3ccb442365e3f30aed6e16ba655a3dda3 Mon Sep 17 00:00:00 2001 From: pitboss Date: Fri, 22 May 2026 02:24:44 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0007 (20260522T043516Z-29b8) --- src/dynamic/lang/php.rs | 319 ++++++++++++++++++++++++++++++--- src/dynamic/lang/python.rs | 352 +++++++++++++++++++++++++++++++++---- tests/ldap_corpus.rs | 22 +++ 3 files changed, 636 insertions(+), 57 deletions(-) diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 1fc2be47..77ca8bea 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -782,12 +782,20 @@ echo json_encode(["entity_expanded" => $expanded]) . "\n"; /// Phase 06 — Track J.4 LDAP-injection harness for PHP (`ldap_search`). /// /// Reads `NYX_PAYLOAD`, splices it into a `(uid=)` filter, -/// evaluates the filter against the in-sandbox LDAP directory (three -/// users: `alice`, `bob`, `carol`) using the same RFC-4515 subset the -/// [`crate::dynamic::stubs::ldap_server`] stub implements, and writes -/// a `ProbeKind::Ldap { entries_returned }` probe whose `n` is the -/// count the directory returned. Mirrors the synthetic-harness -/// pattern used by Phase 03 / 04 / 05. +/// and — when `NYX_LDAP_ENDPOINT` is set — routes the search through +/// the in-sandbox LDAP stub over the real LDAPv3 BER wire (the stub's +/// accept loop at [`crate::dynamic::stubs::ldap_server::accept_loop`] +/// auto-detects the `0x30 SEQUENCE` lead byte and routes through the +/// reader/writer at [`crate::dynamic::stubs::ldap_ber`]). Falls back +/// to an in-process RFC 4515 subset matcher against three canonical +/// users (`alice`, `bob`, `carol`) when the env var is unset, the +/// filter does not parse as a supported RFC 4515 shape, or the socket +/// exchange errors, so the harness still produces a verdict on hosts +/// that exercise it outside the stub-backed corpus. Writes a +/// `ProbeKind::Ldap { entries_returned }` probe whose `n` is the +/// count the directory returned. The BER client is core-PHP only +/// (`fsockopen` / `fwrite` / `fread`) so no `ext-ldap` extension is +/// required. pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource { let shim = probe_shim(); let body = format!( @@ -866,7 +874,235 @@ function _nyx_match_one(string $filt, string $uid): bool {{ return _nyx_attr_match($pattern, $uid); }} -function _nyx_ldap_count_via_stub(string $filt): ?int {{ +// --- LDAPv3 BER client (zero-dep, core PHP only) ------------------------- +// Tags this client emits / consumes. Mirrors `src/dynamic/stubs/ldap_ber.rs`. +const _NYX_BER_BOOLEAN = 0x01; +const _NYX_BER_INTEGER = 0x02; +const _NYX_BER_OCTET_STRING = 0x04; +const _NYX_BER_ENUMERATED = 0x0A; +const _NYX_BER_SEQUENCE = 0x30; +const _NYX_BER_BIND_REQUEST = 0x60; +const _NYX_BER_BIND_RESPONSE = 0x61; +const _NYX_BER_SEARCH_REQUEST = 0x63; +const _NYX_BER_SEARCH_RESULT_ENTRY = 0x64; +const _NYX_BER_SEARCH_RESULT_DONE = 0x65; +const _NYX_BER_AUTH_SIMPLE = 0x80; +const _NYX_BER_FILTER_AND = 0xA0; +const _NYX_BER_FILTER_OR = 0xA1; +const _NYX_BER_FILTER_EQUALITY = 0xA3; +const _NYX_BER_FILTER_SUBSTRINGS = 0xA4; +const _NYX_BER_FILTER_PRESENT = 0x87; +const _NYX_BER_SUBSTR_INITIAL = 0x80; +const _NYX_BER_SUBSTR_ANY = 0x81; +const _NYX_BER_SUBSTR_FINAL = 0x82; + +function _nyx_ber_length(int $n): string {{ + if ($n < 0x80) return chr($n); + $tmp = ''; + while ($n > 0) {{ + $tmp = chr($n & 0xFF) . $tmp; + $n >>= 8; + }} + return chr(0x80 | strlen($tmp)) . $tmp; +}} + +function _nyx_ber_tlv(int $tag, string $body): string {{ + return chr($tag) . _nyx_ber_length(strlen($body)) . $body; +}} + +function _nyx_ber_int(int $n): ?string {{ + if ($n < 0) return null; + if ($n === 0) {{ + $body = "\x00"; + }} else {{ + $tmp = ''; + while ($n > 0) {{ + $tmp = chr($n & 0xFF) . $tmp; + $n >>= 8; + }} + if (ord($tmp[0]) & 0x80) {{ + $tmp = "\x00" . $tmp; + }} + $body = $tmp; + }} + return _nyx_ber_tlv(_NYX_BER_INTEGER, $body); +}} + +function _nyx_ber_enum(int $n): string {{ + return _nyx_ber_tlv(_NYX_BER_ENUMERATED, chr($n & 0xFF)); +}} + +function _nyx_ber_octstr(string $s): string {{ + return _nyx_ber_tlv(_NYX_BER_OCTET_STRING, $s); +}} + +function _nyx_ber_bool(bool $b): string {{ + return _nyx_ber_tlv(_NYX_BER_BOOLEAN, $b ? "\xFF" : "\x00"); +}} + +function _nyx_ber_seq(string $body): string {{ + return _nyx_ber_tlv(_NYX_BER_SEQUENCE, $body); +}} + +function _nyx_valid_attr(string $a): bool {{ + if ($a === '') return false; + $n = strlen($a); + for ($i = 0; $i < $n; $i++) {{ + $c = $a[$i]; + if (!(ctype_alnum($c) || $c === '-' || $c === '_' || $c === '.')) return false; + }} + return true; +}} + +function _nyx_split_paren_children(string $s): ?array {{ + $out = []; + $i = 0; + $n = strlen($s); + while ($i < $n) {{ + if ($s[$i] !== '(') return null; + $depth = 0; + $start = $i; + while ($i < $n) {{ + $c = $s[$i]; + if ($c === '(') $depth++; + elseif ($c === ')') {{ + $depth--; + if ($depth === 0) {{ $i++; break; }} + }} + $i++; + }} + if ($depth !== 0) return null; + $out[] = substr($s, $start, $i - $start); + }} + return $out; +}} + +function _nyx_encode_filter(string $filt): ?string {{ + $s = trim($filt); + if (!str_starts_with($s, '(') || !str_ends_with($s, ')')) return null; + $depth = 0; + $n = strlen($s); + for ($i = 0; $i < $n; $i++) {{ + $c = $s[$i]; + if ($c === '(') $depth++; + elseif ($c === ')') {{ + $depth--; + if ($depth < 0) return null; + if ($depth === 0 && $i !== $n - 1) return null; + }} + }} + if ($depth !== 0) return null; + $inner = substr($s, 1, strlen($s) - 2); + if ($inner === '') return null; + $head = $inner[0]; + if ($head === '&' || $head === '|') {{ + $children = _nyx_split_paren_children(substr($inner, 1)); + if ($children === null || empty($children)) return null; + $parts = ''; + foreach ($children as $c) {{ + $sub = _nyx_encode_filter($c); + if ($sub === null) return null; + $parts .= $sub; + }} + $tag = $head === '&' ? _NYX_BER_FILTER_AND : _NYX_BER_FILTER_OR; + return _nyx_ber_tlv($tag, $parts); + }} + $eq = strpos($inner, '='); + if ($eq === false) return null; + $attr = substr($inner, 0, $eq); + $val = substr($inner, $eq + 1); + if (!_nyx_valid_attr($attr)) return null; + if ($val === '*') {{ + return _nyx_ber_tlv(_NYX_BER_FILTER_PRESENT, $attr); + }} + if (strpos($val, '*') !== false) {{ + $parts = explode('*', $val); + $last = count($parts) - 1; + $seq = ''; + if ($parts[0] !== '') {{ + $seq .= _nyx_ber_tlv(_NYX_BER_SUBSTR_INITIAL, $parts[0]); + }} + for ($i = 1; $i < $last; $i++) {{ + if ($parts[$i] !== '') {{ + $seq .= _nyx_ber_tlv(_NYX_BER_SUBSTR_ANY, $parts[$i]); + }} + }} + if ($parts[$last] !== '') {{ + $seq .= _nyx_ber_tlv(_NYX_BER_SUBSTR_FINAL, $parts[$last]); + }} + $body = _nyx_ber_octstr($attr) . _nyx_ber_seq($seq); + return _nyx_ber_tlv(_NYX_BER_FILTER_SUBSTRINGS, $body); + }} + $body = _nyx_ber_octstr($attr) . _nyx_ber_octstr($val); + return _nyx_ber_tlv(_NYX_BER_FILTER_EQUALITY, $body); +}} + +function _nyx_read_n($sock, int $n): ?string {{ + $out = ''; + while (strlen($out) < $n) {{ + $chunk = @fread($sock, $n - strlen($out)); + if ($chunk === false || $chunk === '') return null; + $out .= $chunk; + }} + return $out; +}} + +function _nyx_read_ber_message($sock): ?string {{ + $head = _nyx_read_n($sock, 2); + if ($head === null || ord($head[0]) !== _NYX_BER_SEQUENCE) return null; + $first_len = ord($head[1]); + if (($first_len & 0x80) === 0) {{ + $body_len = $first_len; + $length_bytes = ''; + }} else {{ + $nl = $first_len & 0x7F; + if ($nl === 0 || $nl > 4) return null; + $length_bytes = _nyx_read_n($sock, $nl); + if ($length_bytes === null) return null; + $body_len = 0; + for ($i = 0; $i < $nl; $i++) {{ + $body_len = ($body_len << 8) | ord($length_bytes[$i]); + }} + }} + if ($body_len > 64 * 1024) return null; + $body = _nyx_read_n($sock, $body_len); + if ($body === null) return null; + return $head . $length_bytes . $body; +}} + +function _nyx_decode_tlv(string $buf, int $offset): ?array {{ + if ($offset + 2 > strlen($buf)) return null; + $tag = ord($buf[$offset]); + $first_len = ord($buf[$offset + 1]); + if (($first_len & 0x80) === 0) {{ + $body_len = $first_len; + $body_start = $offset + 2; + }} else {{ + $nl = $first_len & 0x7F; + if ($nl === 0 || $nl > 4 || $offset + 2 + $nl > strlen($buf)) return null; + $body_len = 0; + for ($i = 0; $i < $nl; $i++) {{ + $body_len = ($body_len << 8) | ord($buf[$offset + 2 + $i]); + }} + $body_start = $offset + 2 + $nl; + }} + $body_end = $body_start + $body_len; + if ($body_end > strlen($buf)) return null; + return [$tag, substr($buf, $body_start, $body_len), $body_end]; +}} + +function _nyx_decode_ldap_op(string $msg): ?array {{ + $outer = _nyx_decode_tlv($msg, 0); + if ($outer === null || $outer[0] !== _NYX_BER_SEQUENCE) return null; + $inner = $outer[1]; + $msg_id_tlv = _nyx_decode_tlv($inner, 0); + if ($msg_id_tlv === null || $msg_id_tlv[0] !== _NYX_BER_INTEGER) return null; + $op_tlv = _nyx_decode_tlv($inner, $msg_id_tlv[2]); + if ($op_tlv === null) return null; + return [$op_tlv[0], $op_tlv[1]]; +}} + +function _nyx_ldap_count_via_ber(string $filt): ?int {{ $ep = getenv('NYX_LDAP_ENDPOINT'); if ($ep === false || $ep === '') return null; $sep = strrpos($ep, ':'); @@ -874,20 +1110,47 @@ function _nyx_ldap_count_via_stub(string $filt): ?int {{ $host = substr($ep, 0, $sep); $port = (int) substr($ep, $sep + 1); if ($port <= 0) return null; + $filter_bytes = _nyx_encode_filter($filt); + if ($filter_bytes === null) 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; + $bind_body = _nyx_ber_int(3) . _nyx_ber_octstr('') . _nyx_ber_tlv(_NYX_BER_AUTH_SIMPLE, ''); + $bind_msg = _nyx_ber_seq(_nyx_ber_int(1) . _nyx_ber_tlv(_NYX_BER_BIND_REQUEST, $bind_body)); + if (@fwrite($sock, $bind_msg) === false) {{ @fclose($sock); return null; }} + $resp = _nyx_read_ber_message($sock); + if ($resp === null) {{ @fclose($sock); return null; }} + $decoded = _nyx_decode_ldap_op($resp); + if ($decoded === null || $decoded[0] !== _NYX_BER_BIND_RESPONSE) {{ @fclose($sock); return null; }} + $search_body = _nyx_ber_octstr('') + . _nyx_ber_enum(2) + . _nyx_ber_enum(0) + . _nyx_ber_int(0) + . _nyx_ber_int(2) + . _nyx_ber_bool(false) + . $filter_bytes + . _nyx_ber_seq(''); + $search_msg = _nyx_ber_seq(_nyx_ber_int(2) . _nyx_ber_tlv(_NYX_BER_SEARCH_REQUEST, $search_body)); + if (@fwrite($sock, $search_msg) === false) {{ @fclose($sock); return null; }} + $count = 0; + while (true) {{ + $resp = _nyx_read_ber_message($sock); + if ($resp === null) {{ @fclose($sock); return null; }} + $decoded = _nyx_decode_ldap_op($resp); + if ($decoded === null) {{ @fclose($sock); return null; }} + $op_tag = $decoded[0]; + if ($op_tag === _NYX_BER_SEARCH_RESULT_ENTRY) {{ + $count++; + }} elseif ($op_tag === _NYX_BER_SEARCH_RESULT_DONE) {{ + @fclose($sock); + return $count; + }} else {{ + @fclose($sock); + return $count; + }} + }} }} function _nyx_ldap_count_local(string $filt, array $users): int {{ @@ -904,8 +1167,8 @@ function _nyx_ldap_count_local(string $filt, array $users): int {{ }} function _nyx_ldap_count(string $filt, array $users): int {{ - $via_stub = _nyx_ldap_count_via_stub($filt); - if ($via_stub !== null) return $via_stub; + $via_ber = _nyx_ldap_count_via_ber($filt); + if ($via_ber !== null) return $via_ber; return _nyx_ldap_count_local($filt, $users); }} @@ -2164,12 +2427,20 @@ mod tests { "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", + h.source.contains("_NYX_BER_BIND_REQUEST = 0x60"), + "PHP LDAP harness must compose an LDAPv3 BindRequest (BER tag 0x60)", ); assert!( - h.source.contains("'COUNT '"), - "PHP LDAP harness must parse the COUNT reply line", + h.source.contains("_NYX_BER_SEARCH_REQUEST = 0x63"), + "PHP LDAP harness must compose an LDAPv3 SearchRequest (BER tag 0x63)", + ); + assert!( + h.source.contains("_nyx_encode_filter"), + "PHP LDAP harness must encode the RFC 4515 filter string into BER bytes", + ); + assert!( + !h.source.contains("'SEARCH '"), + "PHP LDAP harness must no longer write the plaintext SEARCH tier-(a) framing", ); } @@ -2181,8 +2452,8 @@ mod tests { "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", + h.source.contains("_nyx_ldap_count_via_ber"), + "PHP LDAP harness must dispatch through the BER stub-route helper", ); } diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 12fd12c0..62378696 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -1560,12 +1560,19 @@ if __name__ == "__main__": /// (`ldap.search_s`). /// /// Reads `NYX_PAYLOAD`, splices it into a `(uid=)` filter, -/// evaluates the filter against the in-sandbox LDAP directory (three -/// users: `alice`, `bob`, `carol`) using the same RFC-4515 subset the -/// [`crate::dynamic::stubs::ldap_server`] stub implements, and writes -/// a `ProbeKind::Ldap { entries_returned }` probe whose `n` is the -/// count the directory returned. Mirrors the synthetic-harness -/// pattern used by Phase 03 / 04 / 05. +/// and — when `NYX_LDAP_ENDPOINT` is set — routes the search through +/// the in-sandbox LDAP stub over the real LDAPv3 BER wire (the stub's +/// accept loop at [`crate::dynamic::stubs::ldap_server::accept_loop`] +/// auto-detects the `0x30 SEQUENCE` lead byte and routes through the +/// reader/writer at [`crate::dynamic::stubs::ldap_ber`]). Falls back +/// to an in-process RFC 4515 subset matcher against three canonical +/// users (`alice`, `bob`, `carol`) when the env var is unset, the +/// filter does not parse as a supported RFC 4515 shape, or the socket +/// exchange errors, so the harness still produces a verdict on hosts +/// that exercise it outside the stub-backed corpus. Writes a +/// `ProbeKind::Ldap { entries_returned }` probe whose `n` is the +/// count the directory returned. The BER client is pure-stdlib (just +/// `socket`) so no extra pip dep is required. pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource { let probe = probe_shim(); let body = format!( @@ -1644,12 +1651,250 @@ def _nyx_match_one(filt, uid): return _nyx_attr_match(pattern, uid) -def _nyx_ldap_count_via_stub(filt): - """Route through the in-sandbox LDAP stub when NYX_LDAP_ENDPOINT is set. +# --- LDAPv3 BER client (zero-dep, pure stdlib) ---------------------------- +# Tags this client emits / consumes. Mirrors `src/dynamic/stubs/ldap_ber.rs`. +_NYX_BER_BOOLEAN = 0x01 +_NYX_BER_INTEGER = 0x02 +_NYX_BER_OCTET_STRING = 0x04 +_NYX_BER_ENUMERATED = 0x0A +_NYX_BER_SEQUENCE = 0x30 +_NYX_BER_BIND_REQUEST = 0x60 +_NYX_BER_BIND_RESPONSE = 0x61 +_NYX_BER_SEARCH_REQUEST = 0x63 +_NYX_BER_SEARCH_RESULT_ENTRY = 0x64 +_NYX_BER_SEARCH_RESULT_DONE = 0x65 +_NYX_BER_AUTH_SIMPLE = 0x80 +_NYX_BER_FILTER_AND = 0xA0 +_NYX_BER_FILTER_OR = 0xA1 +_NYX_BER_FILTER_EQUALITY = 0xA3 +_NYX_BER_FILTER_SUBSTRINGS = 0xA4 +_NYX_BER_FILTER_PRESENT = 0x87 +_NYX_BER_SUBSTR_INITIAL = 0x80 +_NYX_BER_SUBSTR_ANY = 0x81 +_NYX_BER_SUBSTR_FINAL = 0x82 - 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. + +def _nyx_ber_length(n): + if n < 0x80: + return bytes([n]) + tmp = [] + while n: + tmp.append(n & 0xFF) + n >>= 8 + tmp.reverse() + return bytes([0x80 | len(tmp)]) + bytes(tmp) + + +def _nyx_ber_tlv(tag, body): + return bytes([tag]) + _nyx_ber_length(len(body)) + body + + +def _nyx_ber_int(n): + if n < 0: + return None + if n == 0: + body = b"\x00" + else: + tmp = [] + x = n + while x > 0: + tmp.append(x & 0xFF) + x >>= 8 + tmp.reverse() + body = bytes(tmp) + if body[0] & 0x80: + body = b"\x00" + body + return _nyx_ber_tlv(_NYX_BER_INTEGER, body) + + +def _nyx_ber_enum(n): + return _nyx_ber_tlv(_NYX_BER_ENUMERATED, bytes([n & 0xFF])) + + +def _nyx_ber_octstr(s): + if isinstance(s, str): + s = s.encode("utf-8") + return _nyx_ber_tlv(_NYX_BER_OCTET_STRING, s) + + +def _nyx_ber_bool(b): + return _nyx_ber_tlv(_NYX_BER_BOOLEAN, b"\xFF" if b else b"\x00") + + +def _nyx_ber_seq(body): + return _nyx_ber_tlv(_NYX_BER_SEQUENCE, body) + + +def _nyx_valid_attr(a): + if not a: + return False + for ch in a: + if not (ch.isalnum() or ch in "-_."): + return False + return True + + +def _nyx_split_paren_children(s): + """Split a string of concatenated `(...)(...)` groups into a list.""" + out = [] + i = 0 + n = len(s) + while i < n: + if s[i] != "(": + return None + depth = 0 + start = i + while i < n: + c = s[i] + if c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0: + i += 1 + break + i += 1 + if depth != 0: + return None + out.append(s[start:i]) + return out + + +def _nyx_encode_filter(filt): + """RFC 4515 (subset) -> BER bytes. Returns ``None`` for invalid / + unsupported filter shapes; caller falls back to the local matcher.""" + s = filt.strip() + if not s.startswith("(") or not s.endswith(")"): + return None + depth = 0 + for i, c in enumerate(s): + if c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth < 0: + return None + if depth == 0 and i != len(s) - 1: + return None + if depth != 0: + return None + inner = s[1:-1] + if not inner: + return None + head = inner[0] + if head in ("&", "|"): + children = _nyx_split_paren_children(inner[1:]) + if not children: + return None + parts = b"" + for c in children: + sub = _nyx_encode_filter(c) + if sub is None: + return None + parts += sub + tag = _NYX_BER_FILTER_AND if head == "&" else _NYX_BER_FILTER_OR + return _nyx_ber_tlv(tag, parts) + if "=" not in inner: + return None + attr, _, val = inner.partition("=") + if not _nyx_valid_attr(attr): + return None + if val == "*": + return _nyx_ber_tlv(_NYX_BER_FILTER_PRESENT, attr.encode("utf-8")) + if "*" in val: + parts = val.split("*") + seq = b"" + if parts[0]: + seq += _nyx_ber_tlv(_NYX_BER_SUBSTR_INITIAL, parts[0].encode("utf-8")) + for p in parts[1:-1]: + if p: + seq += _nyx_ber_tlv(_NYX_BER_SUBSTR_ANY, p.encode("utf-8")) + if parts[-1]: + seq += _nyx_ber_tlv(_NYX_BER_SUBSTR_FINAL, parts[-1].encode("utf-8")) + body = _nyx_ber_octstr(attr) + _nyx_ber_seq(seq) + return _nyx_ber_tlv(_NYX_BER_FILTER_SUBSTRINGS, body) + body = _nyx_ber_octstr(attr) + _nyx_ber_octstr(val) + return _nyx_ber_tlv(_NYX_BER_FILTER_EQUALITY, body) + + +def _nyx_read_n(sock, n): + out = b"" + while len(out) < n: + chunk = sock.recv(n - len(out)) + if not chunk: + return None + out += chunk + return out + + +def _nyx_read_ber_message(sock): + head = _nyx_read_n(sock, 2) + if head is None or head[0] != _NYX_BER_SEQUENCE: + return None + if head[1] & 0x80 == 0: + body_len = head[1] + length_bytes = b"" + else: + nl = head[1] & 0x7F + if nl == 0 or nl > 4: + return None + length_bytes = _nyx_read_n(sock, nl) + if length_bytes is None: + return None + body_len = 0 + for b in length_bytes: + body_len = (body_len << 8) | b + if body_len > 64 * 1024: + return None + body = _nyx_read_n(sock, body_len) + if body is None: + return None + return head + length_bytes + body + + +def _nyx_decode_tlv(buf, offset): + if offset + 2 > len(buf): + return None + tag = buf[offset] + first_len = buf[offset + 1] + if first_len & 0x80 == 0: + body_len = first_len + body_start = offset + 2 + else: + nl = first_len & 0x7F + if nl == 0 or nl > 4 or offset + 2 + nl > len(buf): + return None + body_len = 0 + for b in buf[offset + 2:offset + 2 + nl]: + body_len = (body_len << 8) | b + body_start = offset + 2 + nl + body_end = body_start + body_len + if body_end > len(buf): + return None + return (tag, buf[body_start:body_end], body_end) + + +def _nyx_decode_ldap_op(msg): + """Return ``(op_tag, op_body)`` for an LDAPMessage byte slice.""" + outer = _nyx_decode_tlv(msg, 0) + if outer is None or outer[0] != _NYX_BER_SEQUENCE: + return None + inner = outer[1] + msg_id_tlv = _nyx_decode_tlv(inner, 0) + if msg_id_tlv is None or msg_id_tlv[0] != _NYX_BER_INTEGER: + return None + op_tlv = _nyx_decode_tlv(inner, msg_id_tlv[2]) + if op_tlv is None: + return None + return (op_tlv[0], op_tlv[1]) + + +def _nyx_ldap_count_via_ber(filt): + """Route through the in-sandbox LDAP stub via real LDAPv3 BER when + `NYX_LDAP_ENDPOINT` is set. Returns the entry count on success, or + ``None`` when the env var is unset, the filter is not a supported + RFC 4515 shape, the address fails to parse, the bind fails, or the + socket exchange errors — caller falls back to the in-process matcher. """ ep = os.environ.get("NYX_LDAP_ENDPOINT", "") if not ep: @@ -1662,23 +1907,56 @@ def _nyx_ldap_count_via_stub(filt): port = int(ep[sep + 1:]) except ValueError: return None + filter_bytes = _nyx_encode_filter(filt) + if filter_bytes is None: + 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: + sock.settimeout(2.0) + bind_body = ( + _nyx_ber_int(3) + + _nyx_ber_octstr(b"") + + _nyx_ber_tlv(_NYX_BER_AUTH_SIMPLE, b"") + ) + bind_msg = _nyx_ber_seq( + _nyx_ber_int(1) + _nyx_ber_tlv(_NYX_BER_BIND_REQUEST, bind_body) + ) + sock.sendall(bind_msg) + resp = _nyx_read_ber_message(sock) + if resp is None: 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: + decoded = _nyx_decode_ldap_op(resp) + if decoded is None or decoded[0] != _NYX_BER_BIND_RESPONSE: return None + search_body = ( + _nyx_ber_octstr(b"") + + _nyx_ber_enum(2) + + _nyx_ber_enum(0) + + _nyx_ber_int(0) + + _nyx_ber_int(2) + + _nyx_ber_bool(False) + + filter_bytes + + _nyx_ber_seq(b"") + ) + search_msg = _nyx_ber_seq( + _nyx_ber_int(2) + _nyx_ber_tlv(_NYX_BER_SEARCH_REQUEST, search_body) + ) + sock.sendall(search_msg) + count = 0 + while True: + resp = _nyx_read_ber_message(sock) + if resp is None: + return None + decoded = _nyx_decode_ldap_op(resp) + if decoded is None: + return None + op_tag = decoded[0] + if op_tag == _NYX_BER_SEARCH_RESULT_ENTRY: + count += 1 + elif op_tag == _NYX_BER_SEARCH_RESULT_DONE: + return count + else: + return count except (OSError, socket.timeout): return None @@ -1695,9 +1973,9 @@ def _nyx_ldap_count_local(filt): def _nyx_ldap_count(filt): - via_stub = _nyx_ldap_count_via_stub(filt) - if via_stub is not None: - return via_stub + via_ber = _nyx_ldap_count_via_ber(filt) + if via_ber is not None: + return via_ber return _nyx_ldap_count_local(filt) @@ -3150,12 +3428,20 @@ mod tests { "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", + h.source.contains("_NYX_BER_BIND_REQUEST = 0x60"), + "Python LDAP harness must compose an LDAPv3 BindRequest (BER tag 0x60)", ); assert!( - h.source.contains("COUNT "), - "Python LDAP harness must parse the COUNT reply line", + h.source.contains("_NYX_BER_SEARCH_REQUEST = 0x63"), + "Python LDAP harness must compose an LDAPv3 SearchRequest (BER tag 0x63)", + ); + assert!( + h.source.contains("_nyx_encode_filter"), + "Python LDAP harness must encode the RFC 4515 filter string into BER bytes", + ); + assert!( + !h.source.contains("\"SEARCH \""), + "Python LDAP harness must no longer write the plaintext SEARCH tier-(a) framing", ); } @@ -3167,8 +3453,8 @@ mod tests { "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", + h.source.contains("_nyx_ldap_count_via_ber"), + "Python LDAP harness must dispatch through the BER stub-route helper", ); } diff --git a/tests/ldap_corpus.rs b/tests/ldap_corpus.rs index 72cfa210..59f1f47f 100644 --- a/tests/ldap_corpus.rs +++ b/tests/ldap_corpus.rs @@ -562,6 +562,17 @@ mod e2e_phase_06 { events.iter().any(|e| e.summary.starts_with("SEARCH (uid=")), "Python harness stub events must carry a `(uid=…)` filter; got {events:?}", ); + // The Python emitter now dispatches via a pure-stdlib LDAPv3 BER + // client, so the stub's BER handler must record `protocol=ldapv3` + // on at least one event — pins the tier-(b) wire format and + // prevents a regression that silently falls back to the plaintext + // path. + assert!( + events + .iter() + .any(|e| e.detail.get("protocol").map(String::as_str) == Some("ldapv3")), + "Python harness must exercise the LDAPv3 BER path; got {events:?}", + ); } #[test] @@ -582,5 +593,16 @@ mod e2e_phase_06 { events.iter().any(|e| e.summary.starts_with("SEARCH (uid=")), "PHP harness stub events must carry a `(uid=…)` filter; got {events:?}", ); + // The PHP emitter now dispatches via a core-PHP LDAPv3 BER client + // (no `ext-ldap` dep), so the stub's BER handler must record + // `protocol=ldapv3` on at least one event — pins the tier-(b) wire + // format and prevents a regression that silently falls back to the + // plaintext path. + assert!( + events + .iter() + .any(|e| e.detail.get("protocol").map(String::as_str) == Some("ldapv3")), + "PHP harness must exercise the LDAPv3 BER path; got {events:?}", + ); } }