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.
209 lines
6.6 KiB
Python
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())
|