Files
DECNET/decnet/templates/ldap/server.py
anti abb4dd9fc0 feat(templates): migrate six cred emitters to native shape
Phase 2/3 of DEBT-039. Switches FTP, POP3, IMAP, SMTP, Redis, and
LDAP from the legacy `username=` + `password=` SD-block shape to the
universal credential shape (`principal=` + `secret_printable=` +
`secret_b64=`) the new Credential storage model expects.

Pattern is uniform across all six services:

    _log("auth_attempt", username=u, principal=u, **encode_secret(pw))

Each service emits the canonical SD keys. The ingester's native-shape
branch (introduced in 2f47f67) now writes their cred attempts
directly without going through the legacy adapter. Once Phase 3
removes the adapter the contract becomes single-shape.

Per-service notes:
- POP3 / IMAP — `status="success"|"failed"` renamed to
  `outcome="success"|"failure"` to match Credential.outcome's
  vocabulary; the ingester reads outcome directly.
- SMTP — AUTH path migrated; in addition the existing mail_from
  event now exposes a parsed `domain=` field alongside the original
  `value=` so future "what domains do attackers spoof from" analytics
  have an indexed field. Not stored in Credential — regular Log row.
- Redis — was silently dropped by the legacy adapter (no `username`
  field). Native branch handles `principal=None` correctly. BONUS
  FIX: the Redis 6+ ACL syntax `AUTH <user> <pw>` now captures the
  ACL username as principal (was previously discarded).
- LDAP — was silently dropped by the legacy adapter (no `password`
  recognition for the `bind` event). Now lands as
  `principal=<dn>`. BONUS FIX.

Tests (tests/services/test_cred_emitters.py, 9 cases):
- per-service native-shape ingest path produces correct Credential
  rows; outcome maps for POP3/IMAP; principal=None for legacy Redis
  AUTH; principal=dn for LDAP.
- mail_from event does NOT trigger a credential write (it's a
  Log-only observation, not auth).
- 0xff/NUL/ANSI bytes in passwords survive losslessly through
  secret_b64 even when secret_printable is sanitized.

Phase 3 deletes the legacy adapter once all migrations land — the
adapter has no live emitters to handle anymore.
2026-04-25 05:43:51 -04:00

209 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""
LDAPserver.
Parses BER-encoded BindRequest messages, logs DN and password, returns an
invalidCredentials error. Logs all interactions as JSON.
"""
import asyncio
import os
import re
import instance_seed as _seed
from syslog_bridge import (
encode_secret,
forward_syslog,
syslog_line,
write_syslog_file,
)
NODE_NAME = os.environ.get("NODE_NAME", "ldapserver")
SERVICE_NAME = "ldap"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
# RFC 4514 distinguished-name grammar: DN is a sequence of comma-separated
# RDNs like "cn=foo,ou=people,dc=example,dc=com". Each RDN is
# attribute=value, attribute matches [A-Za-z][A-Za-z0-9-]*. We keep this
# check loose on value contents (commas can be escaped etc.) but tight on
# shape, so garbage like `"abc"` or `\x00\x00` gets rejected with
# invalidDNSyntax (34) instead of invalidCredentials (49) — that's how a
# real OpenLDAP replies.
_RDN_RE = re.compile(r"^[A-Za-z][A-Za-z0-9-]*=.+$")
def _is_valid_dn(dn: str) -> bool:
"""True for empty (anonymous bind) or RFC 4514-shaped DN."""
if dn == "":
return True
if len(dn) > 1024:
return False
# Split on unescaped commas. Not perfect, but catches the obvious
# "not a DN" inputs (missing '=' in some RDN, empty segments, etc.).
parts: list[str] = []
buf = ""
escape = False
for ch in dn:
if escape:
buf += ch
escape = False
continue
if ch == "\\":
buf += ch
escape = True
continue
if ch == ",":
parts.append(buf)
buf = ""
continue
buf += ch
parts.append(buf)
return all(_RDN_RE.match(p.strip()) for p in parts)
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
write_syslog_file(line)
forward_syslog(line, LOG_TARGET)
def _ber_length(data: bytes, pos: int):
"""Return (length, next_pos)."""
b = data[pos]
if b < 0x80:
return b, pos + 1
n = b & 0x7f
length = int.from_bytes(data[pos + 1:pos + 1 + n], "big")
return length, pos + 1 + n
def _ber_string(data: bytes, pos: int):
"""Skip tag byte, read BER length, return (string, next_pos)."""
pos += 1 # skip tag
length, pos = _ber_length(data, pos)
return data[pos:pos + length].decode(errors="replace"), pos + length
def _parse_bind_request(msg: bytes):
"""Best-effort extraction of (dn, password) from a raw LDAPMessage."""
try:
pos = 0
# LDAPMessage SEQUENCE
assert msg[pos] == 0x30 # nosec B101
pos += 1
_, pos = _ber_length(msg, pos)
# messageID INTEGER
assert msg[pos] == 0x02 # nosec B101
pos += 1
id_len, pos = _ber_length(msg, pos)
pos += id_len
# BindRequest [APPLICATION 0]
assert msg[pos] == 0x60 # nosec B101
pos += 1
_, pos = _ber_length(msg, pos)
# version INTEGER
assert msg[pos] == 0x02 # nosec B101
pos += 1
v_len, pos = _ber_length(msg, pos)
pos += v_len
# name LDAPDN (OCTET STRING)
dn, pos = _ber_string(msg, pos)
# authentication CHOICE — simple [0] OCTET STRING
if msg[pos] == 0x80:
pos += 1
pw_len, pos = _ber_length(msg, pos)
password = msg[pos:pos + pw_len].decode(errors="replace")
else:
password = "<sasl_or_unknown>" # nosec B105
return dn, password
except Exception:
return "<parse_error>", "<parse_error>"
def _bind_error_response(message_id: int, result_code: int = 49, error_text: str = "") -> bytes:
"""BindResponse with a configurable resultCode + diagnosticMessage.
49 = invalidCredentials, 34 = invalidDNSyntax, 53 = unwillingToPerform."""
err_bytes = error_text.encode()
result_enc = bytes([0x0a, 0x01, result_code & 0xff])
matched_dn = bytes([0x04, 0x00])
error_msg = bytes([0x04, len(err_bytes)]) + err_bytes
bind_resp_body = result_enc + matched_dn + error_msg
bind_resp = bytes([0x61, len(bind_resp_body)]) + bind_resp_body
msg_id_enc = bytes([0x02, 0x01, message_id & 0xff])
ldap_msg_body = msg_id_enc + bind_resp
return bytes([0x30, len(ldap_msg_body)]) + ldap_msg_body
class LDAPProtocol(asyncio.Protocol):
def __init__(self):
self._transport = None
self._peer = None
self._buf = b""
def connection_made(self, transport):
self._transport = transport
self._peer = transport.get_extra_info("peername", ("?", 0))
_log("connect", src=self._peer[0], src_port=self._peer[1])
def data_received(self, data):
self._buf += data
self._process()
def _process(self):
while len(self._buf) >= 2:
if self._buf[0] != 0x30:
self._buf = b""
return
if self._buf[1] < 0x80:
msg_len = self._buf[1] + 2
elif self._buf[1] == 0x81:
if len(self._buf) < 3:
return
msg_len = self._buf[2] + 3
else:
self._buf = b""
return
if len(self._buf) < msg_len:
return
msg = self._buf[:msg_len]
self._buf = self._buf[msg_len:]
self._handle_message(msg)
def _handle_message(self, msg: bytes):
# Extract messageID for the response
try:
message_id = msg[4] if len(msg) > 4 else 1
except Exception:
message_id = 1
dn, password = _parse_bind_request(msg)
_log("bind", src=self._peer[0], dn=dn, principal=dn,
**encode_secret(password))
_seed.jitter_sync(10, 60)
if dn and not _is_valid_dn(dn):
# OpenLDAP returns invalidDNSyntax (34) for malformed DNs, with
# a diagnostic like: "invalid DN syntax". Matching that exactly
# keeps the decoy consistent with what a scanner expects.
self._transport.write(_bind_error_response(
message_id, result_code=34,
error_text="invalid DN"
))
else:
self._transport.write(_bind_error_response(message_id))
def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?")
async def main():
_log("startup", msg=f"LDAP server starting as {NODE_NAME}")
loop = asyncio.get_running_loop()
server = await loop.create_server(LDAPProtocol, "0.0.0.0", 389) # nosec B104
async with server:
await server.serve_forever()
if __name__ == "__main__":
asyncio.run(main())