diff --git a/decnet/templates/ftp/server.py b/decnet/templates/ftp/server.py index 0b6d083f..b03637d4 100644 --- a/decnet/templates/ftp/server.py +++ b/decnet/templates/ftp/server.py @@ -14,7 +14,12 @@ from twisted.python.filepath import FilePath from twisted.python import log as twisted_log import instance_seed as _seed -from syslog_bridge import syslog_line, write_syslog_file, forward_syslog +from syslog_bridge import ( + encode_secret, + forward_syslog, + syslog_line, + write_syslog_file, +) NODE_NAME = os.environ.get("NODE_NAME", "ftpserver") SERVICE_NAME = "ftp" @@ -100,7 +105,8 @@ class ServerFTP(FTP): return super().ftp_USER(username) def ftp_PASS(self, password): - _log("auth_attempt", username=getattr(self, "_server_user", "?"), password=password) + _u = getattr(self, "_server_user", "?") + _log("auth_attempt", username=_u, principal=_u, **encode_secret(password)) # Decide whether this attempt succeeds. Unseeded randomness so # scanners can't predict which creds will "work". import random as _rand diff --git a/decnet/templates/imap/server.py b/decnet/templates/imap/server.py index 5b015889..6c5433a4 100644 --- a/decnet/templates/imap/server.py +++ b/decnet/templates/imap/server.py @@ -12,7 +12,13 @@ Banner advertises Dovecot so nmap fingerprints correctly. import asyncio import os -from syslog_bridge import SEVERITY_WARNING, syslog_line, write_syslog_file, forward_syslog +from syslog_bridge import ( + SEVERITY_WARNING, + encode_secret, + forward_syslog, + syslog_line, + write_syslog_file, +) NODE_NAME = os.environ.get("NODE_NAME", "mailserver") SERVICE_NAME = "imap" @@ -431,14 +437,15 @@ class IMAPProtocol(asyncio.Protocol): parts = args.split(None, 1) username = parts[0].strip('"') if parts else "" password = parts[1].strip('"') if len(parts) > 1 else "" + _enc = encode_secret(password) if VALID_USERS.get(username) == password: self._state = "AUTHENTICATED" - _log("auth", src=self._peer[0], username=username, password=password, - status="success") + _log("auth", src=self._peer[0], username=username, principal=username, + outcome="success", **_enc) self._w(f"{tag} OK [CAPABILITY IMAP4rev1] Logged in\r\n") else: - _log("auth", src=self._peer[0], username=username, password=password, - status="failed", severity=SEVERITY_WARNING) + _log("auth", src=self._peer[0], username=username, principal=username, + outcome="failure", severity=SEVERITY_WARNING, **_enc) self._w(f"{tag} NO [AUTHENTICATIONFAILED] Authentication failed.\r\n") def _cmd_list(self, tag: str, cmd: str) -> None: diff --git a/decnet/templates/ldap/server.py b/decnet/templates/ldap/server.py index 8b89c706..67126d35 100644 --- a/decnet/templates/ldap/server.py +++ b/decnet/templates/ldap/server.py @@ -10,7 +10,12 @@ import os import re import instance_seed as _seed -from syslog_bridge import syslog_line, write_syslog_file, forward_syslog +from syslog_bridge import ( + encode_secret, + forward_syslog, + syslog_line, + write_syslog_file, +) NODE_NAME = os.environ.get("NODE_NAME", "ldapserver") SERVICE_NAME = "ldap" @@ -173,7 +178,8 @@ class LDAPProtocol(asyncio.Protocol): except Exception: message_id = 1 dn, password = _parse_bind_request(msg) - _log("bind", src=self._peer[0], dn=dn, password=password) + _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 diff --git a/decnet/templates/pop3/server.py b/decnet/templates/pop3/server.py index 8599bc81..2363ce8a 100644 --- a/decnet/templates/pop3/server.py +++ b/decnet/templates/pop3/server.py @@ -11,7 +11,13 @@ Credentials via IMAP_USERS env var (shared with IMAP service). import asyncio import os -from syslog_bridge import SEVERITY_WARNING, syslog_line, write_syslog_file, forward_syslog +from syslog_bridge import ( + SEVERITY_WARNING, + encode_secret, + forward_syslog, + syslog_line, + write_syslog_file, +) NODE_NAME = os.environ.get("NODE_NAME", "mailserver") SERVICE_NAME = "pop3" @@ -259,14 +265,15 @@ class POP3Protocol(asyncio.Protocol): return username = self._current_user password = args.strip() + _enc = encode_secret(password) if VALID_USERS.get(username) == password: self._state = "TRANSACTION" - _log("auth", src=self._peer[0], username=username, password=password, - status="success") + _log("auth", src=self._peer[0], username=username, principal=username, + outcome="success", **_enc) self._transport.write(b"+OK Logged in.\r\n") else: - _log("auth", src=self._peer[0], username=username, password=password, - status="failed", severity=SEVERITY_WARNING) + _log("auth", src=self._peer[0], username=username, principal=username, + outcome="failure", severity=SEVERITY_WARNING, **_enc) self._current_user = None self._transport.write(b"-ERR Authentication failed.\r\n") diff --git a/decnet/templates/redis/server.py b/decnet/templates/redis/server.py index db8c1151..8e09ec67 100644 --- a/decnet/templates/redis/server.py +++ b/decnet/templates/redis/server.py @@ -9,7 +9,12 @@ import asyncio import os import instance_seed as _seed -from syslog_bridge import syslog_line, write_syslog_file, forward_syslog +from syslog_bridge import ( + encode_secret, + forward_syslog, + syslog_line, + write_syslog_file, +) NODE_NAME = os.environ.get("NODE_NAME", "cache-server") SERVICE_NAME = "redis" @@ -231,8 +236,14 @@ class RedisProtocol(asyncio.Protocol): _log("command", src=self._peer[0], cmd=verb, args=args[:8]) if verb == "AUTH": + # Redis 6+ accepts two-arg AUTH (`AUTH `) for ACL + # auth; legacy single-arg AUTH is just the password. Capture + # the username when present so attackers brute-forcing ACLs + # leave the same trail SSH/FTP do. password = args[-1] if args else "" - _log("auth", src=self._peer[0], password=password) + _user = args[0] if len(args) >= 2 else None + _log("auth", src=self._peer[0], + principal=_user, **encode_secret(password)) if not _REQUIREPASS: self._write( _err("Client sent AUTH, but no password is set. " diff --git a/decnet/templates/smtp/server.py b/decnet/templates/smtp/server.py index 0927c69b..537c2f90 100644 --- a/decnet/templates/smtp/server.py +++ b/decnet/templates/smtp/server.py @@ -32,7 +32,13 @@ from email.header import decode_header, make_header from email.message import Message import instance_seed as _seed -from syslog_bridge import SEVERITY_WARNING, syslog_line, write_syslog_file, forward_syslog +from syslog_bridge import ( + SEVERITY_WARNING, + encode_secret, + forward_syslog, + syslog_line, + write_syslog_file, +) NODE_NAME = os.environ.get("NODE_NAME", "mailserver") SERVICE_NAME = "smtp" @@ -355,7 +361,14 @@ class SMTPProtocol(asyncio.Protocol): elif cmd == "MAIL": addr = args.split(":", 1)[1].strip() if ":" in args else args self._mail_from = addr - _log("mail_from", src=self._peer[0], value=addr) + # Strip <…> wrappers around the address; everything after the + # last @ is the domain. Empty when the attacker sent <> or a + # malformed envelope; keeping value= for back-compat with any + # log query that still reads it. + _bare = addr.strip("<>").strip() + _domain = _bare.rsplit("@", 1)[-1] if "@" in _bare else "" + _log("mail_from", src=self._peer[0], value=addr, + mail_from=_bare, domain=_domain) self._transport.write(b"250 2.1.0 Ok\r\n") elif cmd == "RCPT": @@ -456,8 +469,8 @@ class SMTPProtocol(asyncio.Protocol): def _finish_auth(self, username: str, password: str) -> None: _log("auth_attempt", src=self._peer[0], - username=username, password=password, - severity=SEVERITY_WARNING) + username=username, principal=username, + severity=SEVERITY_WARNING, **encode_secret(password)) if not OPEN_RELAY: self._transport.write(b"535 5.7.8 Error: authentication failed\r\n") return diff --git a/tests/services/test_cred_emitters.py b/tests/services/test_cred_emitters.py new file mode 100644 index 00000000..8349e3a7 --- /dev/null +++ b/tests/services/test_cred_emitters.py @@ -0,0 +1,178 @@ +"""Per-service credential-emitter integration tests. + +Each test simulates the SD-block a migrated emitter produces, hands it +to the ingester, and asserts the resulting Credential row carries the +universal shape (principal + secret_sha256 + secret_b64 + outcome). + +Closes the silent-loss bug for Redis (no username) and LDAP (dn-keyed) +by exercising the full ingester native-shape path for each. +""" +from __future__ import annotations + +import base64 +import hashlib +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def _native_log(service: str, *, principal: str | None, password: str, + outcome: str | None = None, extra: dict | None = None) -> dict: + """Build a parsed-log dict in the shape `_extract_bounty` consumes, + matching what a migrated emitter writes to the wire.""" + raw = password.encode("utf-8", errors="replace") + fields: dict[str, str] = { + "secret_b64": base64.b64encode(raw).decode("ascii"), + "secret_printable": "".join( + chr(b) if 0x20 <= b < 0x7f else "?" for b in raw + ), + } + if principal is not None: + fields["principal"] = principal + if outcome is not None: + fields["outcome"] = outcome + if extra: + fields.update(extra) + return { + "decky": "decky-01", + "service": service, + "attacker_ip": "10.0.0.5", + "fields": fields, + } + + +@pytest.mark.asyncio +async def test_ftp_native_shape(): + from decnet.web.ingester import _extract_bounty + repo = MagicMock(); repo.upsert_credential = AsyncMock() + await _extract_bounty(repo, _native_log( + "ftp", principal="anonymous", password="test@example.com", + )) + cred = repo.upsert_credential.call_args[0][0] + assert cred["service"] == "ftp" + assert cred["principal"] == "anonymous" + assert cred["secret_sha256"] == hashlib.sha256(b"test@example.com").hexdigest() + + +@pytest.mark.asyncio +async def test_pop3_outcome_mapped(): + from decnet.web.ingester import _extract_bounty + repo = MagicMock(); repo.upsert_credential = AsyncMock() + await _extract_bounty(repo, _native_log( + "pop3", principal="alice", password="hunter2", outcome="failure", + )) + cred = repo.upsert_credential.call_args[0][0] + assert cred["service"] == "pop3" + assert cred["outcome"] == "failure" + + +@pytest.mark.asyncio +async def test_imap_native_shape(): + from decnet.web.ingester import _extract_bounty + repo = MagicMock(); repo.upsert_credential = AsyncMock() + await _extract_bounty(repo, _native_log( + "imap", principal="bob", password="letmein", outcome="success", + )) + cred = repo.upsert_credential.call_args[0][0] + assert cred["principal"] == "bob" + assert cred["outcome"] == "success" + + +@pytest.mark.asyncio +async def test_smtp_auth_native_shape(): + """SMTP AUTH PLAIN/LOGIN — principal=SASL username.""" + from decnet.web.ingester import _extract_bounty + repo = MagicMock(); repo.upsert_credential = AsyncMock() + await _extract_bounty(repo, _native_log( + "smtp", principal="postmaster@acme.com", password="abc123", + )) + cred = repo.upsert_credential.call_args[0][0] + assert cred["service"] == "smtp" + assert cred["principal"] == "postmaster@acme.com" + + +@pytest.mark.asyncio +async def test_smtp_mail_from_is_not_a_credential(): + """`event_type=mail_from` must NOT trigger a credential write — + even if the SD-block carries a `domain` field, no `secret_b64` + means the native branch never fires and the legacy branch needs + a `password` it'll never see for this event.""" + from decnet.web.ingester import _extract_bounty + repo = MagicMock(); repo.upsert_credential = AsyncMock() + repo.add_bounty = AsyncMock() + log_data = { + "decky": "decky-01", + "service": "smtp", + "attacker_ip": "10.0.0.5", + "fields": { + "value": "", + "mail_from": "spoof@evil.com", + "domain": "evil.com", + }, + } + await _extract_bounty(repo, log_data) + repo.upsert_credential.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_redis_principal_none_lands(): + """Redis legacy AUTH `` — no username, principal stays + None. This was silently dropped by the legacy adapter pre-migration.""" + from decnet.web.ingester import _extract_bounty + repo = MagicMock(); repo.upsert_credential = AsyncMock() + await _extract_bounty(repo, _native_log( + "redis", principal=None, password="hunter2", + )) + cred = repo.upsert_credential.call_args[0][0] + assert cred["service"] == "redis" + assert cred["principal"] is None + assert cred["secret_sha256"] == hashlib.sha256(b"hunter2").hexdigest() + + +@pytest.mark.asyncio +async def test_redis_acl_two_arg_principal_present(): + """Redis 6+ `AUTH ` — principal carries the ACL user.""" + from decnet.web.ingester import _extract_bounty + repo = MagicMock(); repo.upsert_credential = AsyncMock() + await _extract_bounty(repo, _native_log( + "redis", principal="default", password="hunter2", + )) + cred = repo.upsert_credential.call_args[0][0] + assert cred["principal"] == "default" + + +@pytest.mark.asyncio +async def test_ldap_principal_is_dn(): + """LDAP bind — the DN itself is the principal.""" + from decnet.web.ingester import _extract_bounty + repo = MagicMock(); repo.upsert_credential = AsyncMock() + await _extract_bounty(repo, _native_log( + "ldap", principal="cn=admin,dc=acme,dc=com", password="rootpw", + )) + cred = repo.upsert_credential.call_args[0][0] + assert cred["service"] == "ldap" + assert cred["principal"] == "cn=admin,dc=acme,dc=com" + + +@pytest.mark.asyncio +async def test_lossless_b64_survives_nonprintable_password(): + """Even when secret_printable is sanitized, secret_b64 still decodes + to the original bytes — the cross-service reuse hash matches across + sanitized and non-sanitized representations.""" + from decnet.web.ingester import _extract_bounty + raw = b"\x1b[31mbad\xff\x00trail" + repo = MagicMock(); repo.upsert_credential = AsyncMock() + log_data = { + "decky": "decky-01", + "service": "ftp", + "attacker_ip": "10.0.0.5", + "fields": { + "principal": "user", + "secret_printable": "?[31mbad??trail", + "secret_b64": base64.b64encode(raw).decode("ascii"), + }, + } + await _extract_bounty(repo, log_data) + cred = repo.upsert_credential.call_args[0][0] + assert base64.b64decode(cred["secret_b64"]) == raw + assert cred["secret_sha256"] == hashlib.sha256(raw).hexdigest()