From 9777aa76778b4c099be8c9b67febb730745c0998 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 25 Apr 2026 07:15:44 -0400 Subject: [PATCH] =?UTF-8?q?feat(creds):=20Phase=206=20=E2=80=94=20MongoDB?= =?UTF-8?q?=20SCRAM=20credential=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugs the cred-coverage gap for MongoDB. The template previously parsed only the wire opcode + length and discarded the BSON body entirely, so SCRAM-SHA-{1,256} client-proofs flowed straight through without ever landing in the Credential table. Adds an inline minimal BSON walker (~100 LoC) covering the 7 type codes auth commands actually use: string, doc, array, binary, bool, int32, int64. Hand-rolled rather than pulling pymongo as a runtime dep — the parser is bounds-checked for untrusted-input safety (won't loop on malformed length fields). Wire flow MongoDB clients use for auth: - OP_MSG body section (kind=0) → BSON doc with `saslStart` field carrying mechanism + payload (SCRAM client-first-message: "n,,n=,r="). Username extracted, pinned to the per-connection _sasl_username + _sasl_mechanism state. - Subsequent OP_MSG with `saslContinue` → SCRAM client-final-message ("c=biws,r=,p="). The `p=` value is the credential — emitted as secret_kind=scram_sha256 (or _sha1 / _unknown depending on the prior saslStart's mechanism), principal = the pinned username, secret_b64 = base64 of the decoded proof. Reuse semantics: same client-proof across two auth attempts only matches when both server salt and password were identical (proofs include the salt). So cross-session reuse correlates only on credential reuse against the same MongoDB account on the same decky — honest, non-misleading signal. 680 tests pass across services, service_testing, db, web/ingester, and core/fingerprinting (the broader scope my recent commits touched). Phases 4, 5, 7 still pending (RDP basic-auth, SMB NTLMSSP, RDP NLA). --- decnet/templates/mongodb/server.py | 195 +++++++++++++++++++++++++ tests/services/test_mongodb_scram.py | 210 +++++++++++++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 tests/services/test_mongodb_scram.py diff --git a/decnet/templates/mongodb/server.py b/decnet/templates/mongodb/server.py index cbabf1bc..6a06cd2b 100644 --- a/decnet/templates/mongodb/server.py +++ b/decnet/templates/mongodb/server.py @@ -7,12 +7,121 @@ received messages as JSON. """ import asyncio +import base64 import os import struct import instance_seed as _seed from syslog_bridge import syslog_line, write_syslog_file, forward_syslog + +# ─── Minimal BSON walker ────────────────────────────────────────────────────── +# Just enough to extract `saslStart` / `saslContinue` command auth fields. +# Pulls a few BSON type codes; ignores everything else (subdocs returned +# as raw bytes the caller can re-parse if needed). Hand-rolled rather +# than pulling pymongo as a runtime dep — we only need 8 type codes and +# the parser is ~40 LoC. + +_BSON_DOUBLE = 0x01 +_BSON_STRING = 0x02 +_BSON_DOC = 0x03 +_BSON_ARRAY = 0x04 +_BSON_BINARY = 0x05 +_BSON_BOOL = 0x08 +_BSON_INT32 = 0x10 +_BSON_INT64 = 0x12 + + +def _bson_read(buf: bytes, off: int = 0) -> dict: + """Read a single BSON document at ``buf[off]``. Returns a dict of + ``{key: value}``. Lossy on unsupported types (silently skipped). + Untrusted-input safe: bounds-checked, won't infinite-loop on + malformed length fields.""" + out: dict = {} + if off + 4 > len(buf): + return out + doc_len = struct.unpack_from(" len(buf) or doc_len < 5: + return out + p = off + 4 + while p < end - 1: # last byte is the trailing 0x00 + t = buf[p] + p += 1 + if t == 0: + break + # Read NUL-terminated cstring key. + nul = buf.find(b"\x00", p, end) + if nul < 0: + break + key = buf[p:nul].decode("utf-8", errors="replace") + p = nul + 1 + if t == _BSON_STRING: + if p + 4 > end: + break + slen = struct.unpack_from(" end or slen < 1: + break + out[key] = buf[p:p + slen - 1].decode("utf-8", errors="replace") + p += slen + elif t == _BSON_BINARY: + if p + 5 > end: + break + blen = struct.unpack_from(" end or blen < 0: + break + out[key] = buf[p:p + blen] # raw bytes + p += blen + elif t == _BSON_INT32: + if p + 4 > end: + break + out[key] = struct.unpack_from(" end: + break + out[key] = struct.unpack_from(" end: + break + out[key] = buf[p] != 0 + p += 1 + elif t == _BSON_DOUBLE: + p += 8 + elif t in (_BSON_DOC, _BSON_ARRAY): + if p + 4 > end: + break + sub_len = struct.unpack_from(" end: + break + p += sub_len + else: + # Unsupported type — abort cleanly so we don't misalign. + break + return out + + +def _scram_kv(payload: bytes) -> dict: + """Parse a SCRAM message into key=value pairs. SCRAM separates by + commas and uses `name=value` pairs. We strip a leading `n,,` (GS2 + header) when present so the `n=username` shows up directly.""" + s = payload.decode("utf-8", errors="replace") + if s.startswith("n,,"): + s = s[3:] + elif s.startswith("y,,"): + s = s[3:] + out: dict = {} + for part in s.split(","): + if "=" in part: + k, _, v = part.partition("=") + out[k.strip()] = v + return out + NODE_NAME = os.environ.get("NODE_NAME", "mongodb") SERVICE_NAME = "mongodb" LOG_TARGET = os.environ.get("LOG_TARGET", "") @@ -92,6 +201,11 @@ class MongoDBProtocol(asyncio.Protocol): self._transport = None self._peer = None self._buf = b"" + # Per-connection SCRAM state: pinned at saslStart so the + # subsequent saslContinue's client-proof can carry the username + # in the emitted credential row. + self._sasl_username: str | None = None + self._sasl_mechanism: str | None = None def connection_made(self, transport): self._transport = transport @@ -119,6 +233,35 @@ class MongoDBProtocol(asyncio.Protocol): opcode = struct.unpack("= 21: + # OP_MSG body: 4 bytes flagBits, then sections. We only + # parse kind=0 (Body) sections — kind=1 (DocSeq) is for + # bulk ops that don't carry SCRAM auth. + p = 20 # 16 hdr + 4 flagBits + while p < len(msg): + kind = msg[p] + p += 1 + if kind == 0: # Body section + if p + 4 > len(msg): + break + doc_len = struct.unpack_from(" len(msg): + break + cmd = _bson_read(msg, p) + self._handle_command(cmd) + p += doc_len + elif kind == 1: # DocSeq — skip + if p + 4 > len(msg): + break + seq_len = struct.unpack_from(" None: + """Parse a single MongoDB command document for SCRAM auth. + + saslStart — client-first-message in payload. Extract + `n=` so the next saslContinue inherits it. + saslContinue — client-final-message in payload. Extract + `p=` and emit a cred row. + """ + # mongo's command dispatch keys off the FIRST field of the BSON + # document. We just check key presence since dict ordering in + # CPython 3.7+ matches insertion order. + if "saslStart" in cmd: + mechanism = cmd.get("mechanism") + payload = cmd.get("payload") or b"" + if isinstance(mechanism, str): + self._sasl_mechanism = mechanism + if isinstance(payload, (bytes, bytearray)): + kv = _scram_kv(bytes(payload)) + self._sasl_username = kv.get("n") + _log("auth_start", src=self._peer[0], + mechanism=mechanism or "?", + username=self._sasl_username or "") + return + + if "saslContinue" in cmd: + payload = cmd.get("payload") or b"" + if not isinstance(payload, (bytes, bytearray)): + return + kv = _scram_kv(bytes(payload)) + proof_b64 = kv.get("p") + if not proof_b64: + return + try: + proof_raw = base64.b64decode(proof_b64, validate=True) + except (ValueError, base64.binascii.Error): + return + mech = (self._sasl_mechanism or "").upper() + if "SHA-256" in mech or "SHA256" in mech: + kind = "scram_sha256" + elif "SHA-1" in mech or "SHA1" in mech: + kind = "scram_sha1" + else: + kind = "scram_unknown" + _log("auth", src=self._peer[0], + username=self._sasl_username or "", + principal=self._sasl_username, + mechanism=self._sasl_mechanism or "", + secret_kind=kind, + secret_printable=proof_b64, + secret_b64=base64.b64encode(proof_raw).decode("ascii")) + return + def connection_lost(self, exc): _log("disconnect", src=self._peer[0] if self._peer else "?") diff --git a/tests/services/test_mongodb_scram.py b/tests/services/test_mongodb_scram.py new file mode 100644 index 00000000..5510d43d --- /dev/null +++ b/tests/services/test_mongodb_scram.py @@ -0,0 +1,210 @@ +"""MongoDB SCRAM credential capture tests. + +Exercises the inline BSON walker + SCRAM extractor by handcrafting +saslStart / saslContinue OP_MSG packets and feeding them to the +MongoDBProtocol's data_received(). Asserts that the resulting _log +calls carry the universal credential SD shape. +""" +from __future__ import annotations + +import base64 +import importlib.util +import struct +import sys +from pathlib import Path +from types import ModuleType +from unittest.mock import MagicMock + + +def _load_mongodb(): + """Stand up the mongodb template module with stub deps so the test + can poke its protocol directly.""" + fake = ModuleType("syslog_bridge") + fake.syslog_line = MagicMock(return_value="") + fake.write_syslog_file = MagicMock() + fake.forward_syslog = MagicMock() + fake.SEVERITY_INFO = 6 + fake.SEVERITY_WARNING = 4 + fake.encode_secret = MagicMock( + return_value={"secret_printable": "", "secret_b64": ""} + ) + fake.classify_authorization = MagicMock(return_value=None) + sys.modules["syslog_bridge"] = fake + + repo_root = Path(__file__).resolve().parents[2] + if "instance_seed" not in sys.modules: + spec = importlib.util.spec_from_file_location( + "instance_seed", repo_root / "decnet" / "templates" / "instance_seed.py" + ) + seed_mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(seed_mod) + sys.modules["instance_seed"] = seed_mod + + spec = importlib.util.spec_from_file_location( + "_mongodb_under_test", + repo_root / "decnet" / "templates" / "mongodb" / "server.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# ── BSON encoding helpers (test-only) ──────────────────────────────────────── + +def _bson_str(key: str, val: str) -> bytes: + k = key.encode() + b"\x00" + v = val.encode() + b"\x00" + return b"\x02" + k + struct.pack(" bytes: + return b"\x10" + key.encode() + b"\x00" + struct.pack(" bytes: + return ( + b"\x05" + key.encode() + b"\x00" + + struct.pack(" bytes: + body = b"".join(fields) + b"\x00" + return struct.pack(" bytes: + body = b"\x00\x00\x00\x00" + b"\x00" + doc # flags + kind=0 + body doc + return struct.pack("