From 662a5e43e82f8d913feb0ad798e1bb9e6bf08878 Mon Sep 17 00:00:00 2001 From: anti Date: Sun, 12 Apr 2026 01:34:16 -0400 Subject: [PATCH] feat(tests): add live subprocess integration test suite for services Spins up each service's server.py in a real subprocess via a free ephemeral port (PORT env var), connects with real protocol clients, and asserts both correct protocol behavior and RFC 5424 log output. - 44 live tests across 10 services: http, ftp, smtp, redis, mqtt, mysql, postgres, mongodb, pop3, imap - Shared conftest.py: _ServiceProcess (bg reader thread + queue), free_port, live_service fixture, assert_rfc5424 helper - PORT env var added to all 10 targeted server.py templates - New pytest marker `live`; excluded from default addopts run - requirements-live-tests.txt: flask, twisted + protocol clients --- pyproject.toml | 4 +- requirements-live-tests.txt | 8 ++ templates/ftp/server.py | 5 +- templates/http/server.py | 11 ++- templates/imap/server.py | 3 +- templates/mongodb/server.py | 3 +- templates/mqtt/server.py | 3 +- templates/mysql/server.py | 3 +- templates/pop3/server.py | 3 +- templates/postgres/server.py | 3 +- templates/redis/server.py | 3 +- templates/smtp/server.py | 3 +- tests/live/__init__.py | 0 tests/live/conftest.py | 160 +++++++++++++++++++++++++++++++ tests/live/test_ftp_live.py | 39 ++++++++ tests/live/test_http_live.py | 41 ++++++++ tests/live/test_imap_live.py | 80 ++++++++++++++++ tests/live/test_mongodb_live.py | 70 ++++++++++++++ tests/live/test_mqtt_live.py | 63 ++++++++++++ tests/live/test_mysql_live.py | 65 +++++++++++++ tests/live/test_pop3_live.py | 58 +++++++++++ tests/live/test_postgres_live.py | 75 +++++++++++++++ tests/live/test_redis_live.py | 44 +++++++++ tests/live/test_smtp_live.py | 39 ++++++++ 24 files changed, 774 insertions(+), 12 deletions(-) create mode 100644 requirements-live-tests.txt create mode 100644 tests/live/__init__.py create mode 100644 tests/live/conftest.py create mode 100644 tests/live/test_ftp_live.py create mode 100644 tests/live/test_http_live.py create mode 100644 tests/live/test_imap_live.py create mode 100644 tests/live/test_mongodb_live.py create mode 100644 tests/live/test_mqtt_live.py create mode 100644 tests/live/test_mysql_live.py create mode 100644 tests/live/test_pop3_live.py create mode 100644 tests/live/test_postgres_live.py create mode 100644 tests/live/test_redis_live.py create mode 100644 tests/live/test_smtp_live.py diff --git a/pyproject.toml b/pyproject.toml index b40ebea..ddc5b38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,9 +43,11 @@ decnet = "decnet.cli:app" [tool.pytest.ini_options] asyncio_mode = "auto" -addopts = "-m 'not fuzz' -v -q -x -n logical" +addopts = "-m 'not fuzz and not live' -v -q -x -n logical" markers = [ "fuzz: hypothesis-based fuzz tests (slow, run with -m fuzz or -m '' for all)", + "live: live subprocess service tests (run with -m live)", + "live_docker: live Docker container tests (requires DECNET_LIVE_DOCKER=1)", ] filterwarnings = [ "ignore::pytest.PytestUnhandledThreadExceptionWarning", diff --git a/requirements-live-tests.txt b/requirements-live-tests.txt new file mode 100644 index 0000000..58f1be2 --- /dev/null +++ b/requirements-live-tests.txt @@ -0,0 +1,8 @@ +requests +redis +pymysql +psycopg2-binary +paho-mqtt +pymongo +flask +twisted diff --git a/templates/ftp/server.py b/templates/ftp/server.py index 9f0ed6f..bc3e8a4 100644 --- a/templates/ftp/server.py +++ b/templates/ftp/server.py @@ -18,6 +18,7 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "ftpserver") SERVICE_NAME = "ftp" LOG_TARGET = os.environ.get("LOG_TARGET", "") +PORT = int(os.environ.get("PORT", "21")) BANNER = os.environ.get("FTP_BANNER", "220 (vsFTPd 3.0.3)") def _log(event_type: str, severity: int = 6, **kwargs) -> None: @@ -71,6 +72,6 @@ class ServerFTPFactory(FTPFactory): if __name__ == "__main__": twisted_log.startLogging(sys.stdout) - _log("startup", msg=f"FTP server starting as {NODE_NAME} on port 21") - reactor.listenTCP(21, ServerFTPFactory()) + _log("startup", msg=f"FTP server starting as {NODE_NAME} on port {PORT}") + reactor.listenTCP(PORT, ServerFTPFactory()) reactor.run() diff --git a/templates/http/server.py b/templates/http/server.py index 3d5bd82..999e27c 100644 --- a/templates/http/server.py +++ b/templates/http/server.py @@ -10,11 +10,13 @@ import os from pathlib import Path from flask import Flask, request, send_from_directory +from werkzeug.serving import make_server, WSGIRequestHandler from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "webserver") SERVICE_NAME = "http" LOG_TARGET = os.environ.get("LOG_TARGET", "") +PORT = int(os.environ.get("PORT", "80")) SERVER_HEADER = os.environ.get("SERVER_HEADER", "Apache/2.4.54 (Debian)") RESPONSE_CODE = int(os.environ.get("RESPONSE_CODE", "403")) FAKE_APP = os.environ.get("FAKE_APP", "") @@ -111,6 +113,13 @@ def catch_all(path): return body, RESPONSE_CODE, headers +class _SilentHandler(WSGIRequestHandler): + """Suppress Werkzeug's Server header so Flask's after_request is the sole source.""" + def version_string(self) -> str: + return "" + + if __name__ == "__main__": _log("startup", msg=f"HTTP server starting as {NODE_NAME}") - app.run(host="0.0.0.0", port=80, debug=False) # nosec B104 + srv = make_server("0.0.0.0", PORT, app, request_handler=_SilentHandler) # nosec B104 + srv.serve_forever() diff --git a/templates/imap/server.py b/templates/imap/server.py index d558b8c..fcc5ded 100644 --- a/templates/imap/server.py +++ b/templates/imap/server.py @@ -17,6 +17,7 @@ from decnet_logging import SEVERITY_WARNING, syslog_line, write_syslog_file, for NODE_NAME = os.environ.get("NODE_NAME", "mailserver") SERVICE_NAME = "imap" LOG_TARGET = os.environ.get("LOG_TARGET", "") +PORT = int(os.environ.get("PORT", "143")) IMAP_BANNER = os.environ.get("IMAP_BANNER", f"* OK Dovecot ready.\r\n") _RAW_USERS = os.environ.get("IMAP_USERS", "admin:admin123,root:toor,mail:mail,user:user") @@ -532,7 +533,7 @@ class IMAPProtocol(asyncio.Protocol): async def main(): _log("startup", msg=f"IMAP server starting as {NODE_NAME}") loop = asyncio.get_running_loop() - server = await loop.create_server(IMAPProtocol, "0.0.0.0", 143) # nosec B104 + server = await loop.create_server(IMAPProtocol, "0.0.0.0", PORT) # nosec B104 async with server: await server.serve_forever() diff --git a/templates/mongodb/server.py b/templates/mongodb/server.py index b4b2035..cc16af5 100644 --- a/templates/mongodb/server.py +++ b/templates/mongodb/server.py @@ -14,6 +14,7 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "mongodb") SERVICE_NAME = "mongodb" LOG_TARGET = os.environ.get("LOG_TARGET", "") +PORT = int(os.environ.get("PORT", "27017")) # Minimal BSON helpers def _bson_str(key: str, val: str) -> bytes: @@ -118,7 +119,7 @@ class MongoDBProtocol(asyncio.Protocol): async def main(): _log("startup", msg=f"MongoDB server starting as {NODE_NAME}") loop = asyncio.get_running_loop() - server = await loop.create_server(MongoDBProtocol, "0.0.0.0", 27017) # nosec B104 + server = await loop.create_server(MongoDBProtocol, "0.0.0.0", PORT) # nosec B104 async with server: await server.serve_forever() diff --git a/templates/mqtt/server.py b/templates/mqtt/server.py index 39f6c08..a73089d 100644 --- a/templates/mqtt/server.py +++ b/templates/mqtt/server.py @@ -17,6 +17,7 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker") SERVICE_NAME = "mqtt" LOG_TARGET = os.environ.get("LOG_TARGET", "") +PORT = int(os.environ.get("PORT", "1883")) MQTT_ACCEPT_ALL = os.environ.get("MQTT_ACCEPT_ALL", "1") == "1" MQTT_PERSONA = os.environ.get("MQTT_PERSONA", "water_plant") MQTT_CUSTOM_TOPICS = os.environ.get("MQTT_CUSTOM_TOPICS", "") @@ -264,7 +265,7 @@ class MQTTProtocol(asyncio.Protocol): async def main(): _log("startup", msg=f"MQTT server starting as {NODE_NAME}") loop = asyncio.get_running_loop() - server = await loop.create_server(MQTTProtocol, "0.0.0.0", 1883) # nosec B104 + server = await loop.create_server(MQTTProtocol, "0.0.0.0", PORT) # nosec B104 async with server: await server.serve_forever() diff --git a/templates/mysql/server.py b/templates/mysql/server.py index 10999d1..812a910 100644 --- a/templates/mysql/server.py +++ b/templates/mysql/server.py @@ -14,6 +14,7 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "dbserver") SERVICE_NAME = "mysql" LOG_TARGET = os.environ.get("LOG_TARGET", "") +PORT = int(os.environ.get("PORT", "3306")) _MYSQL_VER = os.environ.get("MYSQL_VERSION", "5.7.38-log") # Minimal MySQL server greeting (protocol v10) — version string is configurable @@ -102,7 +103,7 @@ class MySQLProtocol(asyncio.Protocol): async def main(): _log("startup", msg=f"MySQL server starting as {NODE_NAME}") loop = asyncio.get_running_loop() - server = await loop.create_server(MySQLProtocol, "0.0.0.0", 3306) # nosec B104 + server = await loop.create_server(MySQLProtocol, "0.0.0.0", PORT) # nosec B104 async with server: await server.serve_forever() diff --git a/templates/pop3/server.py b/templates/pop3/server.py index 90b5176..33bca78 100644 --- a/templates/pop3/server.py +++ b/templates/pop3/server.py @@ -16,6 +16,7 @@ from decnet_logging import SEVERITY_WARNING, syslog_line, write_syslog_file, for NODE_NAME = os.environ.get("NODE_NAME", "mailserver") SERVICE_NAME = "pop3" LOG_TARGET = os.environ.get("LOG_TARGET", "") +PORT = int(os.environ.get("PORT", "110")) POP3_BANNER = os.environ.get("POP3_BANNER", f"+OK {NODE_NAME} Dovecot POP3 ready.") _RAW_USERS = os.environ.get("IMAP_USERS", "admin:admin123,root:toor,mail:mail,user:user") @@ -405,7 +406,7 @@ class POP3Protocol(asyncio.Protocol): async def main(): _log("startup", msg=f"POP3 server starting as {NODE_NAME}") loop = asyncio.get_running_loop() - server = await loop.create_server(POP3Protocol, "0.0.0.0", 110) # nosec B104 + server = await loop.create_server(POP3Protocol, "0.0.0.0", PORT) # nosec B104 async with server: await server.serve_forever() diff --git a/templates/postgres/server.py b/templates/postgres/server.py index fd56feb..45126d7 100644 --- a/templates/postgres/server.py +++ b/templates/postgres/server.py @@ -14,6 +14,7 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "pgserver") SERVICE_NAME = "postgres" LOG_TARGET = os.environ.get("LOG_TARGET", "") +PORT = int(os.environ.get("PORT", "5432")) def _error_response(message: str) -> bytes: body = b"S" + b"FATAL\x00" + b"M" + message.encode() + b"\x00\x00" return b"E" + struct.pack(">I", len(body) + 4) + body @@ -110,7 +111,7 @@ class PostgresProtocol(asyncio.Protocol): async def main(): _log("startup", msg=f"PostgreSQL server starting as {NODE_NAME}") loop = asyncio.get_running_loop() - server = await loop.create_server(PostgresProtocol, "0.0.0.0", 5432) # nosec B104 + server = await loop.create_server(PostgresProtocol, "0.0.0.0", PORT) # nosec B104 async with server: await server.serve_forever() diff --git a/templates/redis/server.py b/templates/redis/server.py index 756803c..9251ce9 100644 --- a/templates/redis/server.py +++ b/templates/redis/server.py @@ -12,6 +12,7 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "cache-server") SERVICE_NAME = "redis" LOG_TARGET = os.environ.get("LOG_TARGET", "") +PORT = int(os.environ.get("PORT", "6379")) _REDIS_VER = os.environ.get("REDIS_VERSION", "7.2.7") _REDIS_OS = os.environ.get("REDIS_OS", "Linux 5.15.0") @@ -185,7 +186,7 @@ class RedisProtocol(asyncio.Protocol): async def main(): _log("startup", msg=f"Redis server starting as {NODE_NAME}") loop = asyncio.get_running_loop() - server = await loop.create_server(RedisProtocol, "0.0.0.0", 6379) # nosec B104 + server = await loop.create_server(RedisProtocol, "0.0.0.0", PORT) # nosec B104 async with server: await server.serve_forever() diff --git a/templates/smtp/server.py b/templates/smtp/server.py index 6bfa8f0..89e40ff 100644 --- a/templates/smtp/server.py +++ b/templates/smtp/server.py @@ -28,6 +28,7 @@ from decnet_logging import SEVERITY_WARNING, syslog_line, write_syslog_file, for NODE_NAME = os.environ.get("NODE_NAME", "mailserver") SERVICE_NAME = "smtp" LOG_TARGET = os.environ.get("LOG_TARGET", "") +PORT = int(os.environ.get("PORT", "25")) OPEN_RELAY = os.environ.get("SMTP_OPEN_RELAY", "0").strip() == "1" _SMTP_BANNER = os.environ.get("SMTP_BANNER", f"220 {NODE_NAME} ESMTP Postfix (Debian/GNU)") @@ -245,7 +246,7 @@ async def main(): mode = "open-relay" if OPEN_RELAY else "credential-harvester" _log("startup", msg=f"SMTP server starting as {NODE_NAME} ({mode})") loop = asyncio.get_running_loop() - server = await loop.create_server(SMTPProtocol, "0.0.0.0", 25) # nosec B104 + server = await loop.create_server(SMTPProtocol, "0.0.0.0", PORT) # nosec B104 async with server: await server.serve_forever() diff --git a/tests/live/__init__.py b/tests/live/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/live/conftest.py b/tests/live/conftest.py new file mode 100644 index 0000000..6457326 --- /dev/null +++ b/tests/live/conftest.py @@ -0,0 +1,160 @@ +""" +Shared fixtures for live subprocess service tests. + +Each fixture starts the real server.py in a subprocess, captures its stdout +(RFC 5424 syslog lines) via a background reader thread, polls the port for +readiness, yields (port, log_drain_fn), then tears down. +""" + +import os +import queue +import re +import socket +import subprocess +import sys +import threading +import time +from collections.abc import Generator +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).parent.parent.parent +_TEMPLATES = _REPO_ROOT / "templates" + +# Prefer the project venv's Python (has Flask, Twisted, etc.) over system Python +_VENV_PYTHON = _REPO_ROOT / ".venv" / "bin" / "python" +_PYTHON = str(_VENV_PYTHON) if _VENV_PYTHON.exists() else sys.executable + +# RFC 5424: 1 TIMESTAMP HOSTNAME APP-NAME - MSGID [SD] MSG? +# Use search (not match) so lines prefixed by Twisted timestamps are handled. +_RFC5424_RE = re.compile(r"<\d+>1 \S+ \S+ \S+ - \S+ ") + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _wait_for_port(port: int, timeout: float = 8.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + socket.create_connection(("127.0.0.1", port), timeout=0.1).close() + return True + except OSError: + time.sleep(0.05) + return False + + +def _drain(q: queue.Queue, timeout: float = 2.0) -> list[str]: + """Drain all lines from the log queue within *timeout* seconds.""" + lines: list[str] = [] + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + lines.append(q.get(timeout=max(0.01, deadline - time.monotonic()))) + except queue.Empty: + break + return lines + + +def assert_rfc5424( + lines: list[str], + *, + service: str | None = None, + event_type: str | None = None, + **fields: str, +) -> str: + """ + Assert that at least one line in *lines* is a valid RFC 5424 log entry + matching the given criteria. Returns the first matching line. + """ + for line in lines: + if not _RFC5424_RE.search(line): + continue + if service and f" {service} " not in line: + continue + if event_type and event_type not in line: + continue + if all(f'{k}="{v}"' in line or f"{k}={v}" in line for k, v in fields.items()): + return line + criteria = {"service": service, "event_type": event_type, **fields} + raise AssertionError( + f"No RFC 5424 line matching {criteria!r} found among {len(lines)} lines:\n" + + "\n".join(f" {l!r}" for l in lines[:20]) + ) + + +class _ServiceProcess: + """Manages a live service subprocess and its stdout log queue.""" + + def __init__(self, service: str, port: int): + template_dir = _TEMPLATES / service + env = { + **os.environ, + "NODE_NAME": "test-node", + "PORT": str(port), + "PYTHONPATH": str(template_dir), + "LOG_TARGET": "", + } + self._proc = subprocess.Popen( + [_PYTHON, str(template_dir / "server.py")], + cwd=str(template_dir), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + text=True, + ) + self._q: queue.Queue = queue.Queue() + self._reader = threading.Thread(target=self._read_loop, daemon=True) + self._reader.start() + + def _read_loop(self) -> None: + assert self._proc.stdout is not None + for line in self._proc.stdout: + self._q.put(line.rstrip("\n")) + + def drain(self, timeout: float = 2.0) -> list[str]: + return _drain(self._q, timeout) + + def stop(self) -> None: + self._proc.terminate() + try: + self._proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait() + + +@pytest.fixture +def live_service() -> Generator: + """ + Factory fixture: call live_service(service_name) to start a server. + + Usage:: + + def test_foo(live_service): + port, drain = live_service("redis") + # connect to 127.0.0.1:port ... + lines = drain() + assert_rfc5424(lines, service="redis", event_type="auth") + """ + started: list[_ServiceProcess] = [] + + def _start(service: str) -> tuple[int, callable]: + port = _free_port() + svc = _ServiceProcess(service, port) + started.append(svc) + if not _wait_for_port(port): + svc.stop() + pytest.fail(f"Service '{service}' did not bind to port {port} within 8s") + # Flush startup noise before the test begins + svc.drain(timeout=0.3) + return port, svc.drain + + yield _start + + for svc in started: + svc.stop() diff --git a/tests/live/test_ftp_live.py b/tests/live/test_ftp_live.py new file mode 100644 index 0000000..86e591c --- /dev/null +++ b/tests/live/test_ftp_live.py @@ -0,0 +1,39 @@ +import ftplib + +import pytest + +from tests.live.conftest import assert_rfc5424 + + +@pytest.mark.live +class TestFTPLive: + def test_banner_received(self, live_service): + port, drain = live_service("ftp") + ftp = ftplib.FTP() + ftp.connect("127.0.0.1", port, timeout=5) + welcome = ftp.getwelcome() + ftp.close() + assert "220" in welcome or "vsFTPd" in welcome or len(welcome) > 0 + + def test_login_logged(self, live_service): + port, drain = live_service("ftp") + ftp = ftplib.FTP() + ftp.connect("127.0.0.1", port, timeout=5) + try: + ftp.login("admin", "hunter2") + except ftplib.all_errors: + pass + finally: + ftp.close() + lines = drain() + assert_rfc5424(lines, service="ftp") + + def test_connect_logged(self, live_service): + port, drain = live_service("ftp") + ftp = ftplib.FTP() + ftp.connect("127.0.0.1", port, timeout=5) + ftp.close() + lines = drain() + # At least one RFC 5424 line from the ftp service + rfc_lines = [l for l in lines if "<" in l and ">1 " in l and "ftp" in l] + assert rfc_lines, f"No ftp RFC 5424 lines found. stdout:\n" + "\n".join(lines[:15]) diff --git a/tests/live/test_http_live.py b/tests/live/test_http_live.py new file mode 100644 index 0000000..2d7cce4 --- /dev/null +++ b/tests/live/test_http_live.py @@ -0,0 +1,41 @@ +import pytest +import requests + +from tests.live.conftest import assert_rfc5424 + + +@pytest.mark.live +class TestHTTPLive: + def test_get_request_logged(self, live_service): + port, drain = live_service("http") + resp = requests.get(f"http://127.0.0.1:{port}/admin", timeout=5) + assert resp.status_code == 403 + lines = drain() + assert_rfc5424(lines, service="http", event_type="request") + + def test_server_header_set(self, live_service): + port, drain = live_service("http") + resp = requests.get(f"http://127.0.0.1:{port}/", timeout=5) + assert "Server" in resp.headers + assert resp.headers["Server"] != "" + + def test_post_body_logged(self, live_service): + port, drain = live_service("http") + requests.post( + f"http://127.0.0.1:{port}/login", + data={"username": "admin", "password": "secret"}, + timeout=5, + ) + lines = drain() + # body field present in log line + assert any("body=" in l for l in lines if "request" in l), ( + f"Expected 'body=' in request log line. Got:\n" + "\n".join(lines[:10]) + ) + + def test_method_and_path_in_log(self, live_service): + port, drain = live_service("http") + requests.get(f"http://127.0.0.1:{port}/secret/file.txt", timeout=5) + lines = drain() + matched = assert_rfc5424(lines, service="http", event_type="request") + assert "GET" in matched or 'method="GET"' in matched + assert "/secret/file.txt" in matched or 'path="/secret/file.txt"' in matched diff --git a/tests/live/test_imap_live.py b/tests/live/test_imap_live.py new file mode 100644 index 0000000..f95eef5 --- /dev/null +++ b/tests/live/test_imap_live.py @@ -0,0 +1,80 @@ +import imaplib + +import pytest + +from tests.live.conftest import assert_rfc5424 + + +@pytest.mark.live +class TestIMAPLive: + def test_banner_received(self, live_service): + port, drain = live_service("imap") + imap = imaplib.IMAP4("127.0.0.1", port) + welcome = imap.welcome.decode() + imap.logout() + assert "OK" in welcome + + def test_connect_logged(self, live_service): + port, drain = live_service("imap") + imap = imaplib.IMAP4("127.0.0.1", port) + imap.logout() + lines = drain() + assert_rfc5424(lines, service="imap", event_type="connect") + + def test_login_logged(self, live_service): + port, drain = live_service("imap") + imap = imaplib.IMAP4("127.0.0.1", port) + try: + imap.login("admin", "wrongpass") + except imaplib.IMAP4.error: + pass + lines = drain() + try: + imap.logout() + except Exception: + pass + lines += drain() + assert_rfc5424(lines, service="imap", event_type="auth") + + def test_auth_success_logged(self, live_service): + port, drain = live_service("imap") + imap = imaplib.IMAP4("127.0.0.1", port) + imap.login("admin", "admin123") # valid cred from IMAP_USERS default + lines = drain() + imap.logout() + lines += drain() + matched = assert_rfc5424(lines, service="imap", event_type="auth") + assert "success" in matched, f"Expected auth success in log. Got:\n{matched!r}" + + def test_auth_fail_logged(self, live_service): + port, drain = live_service("imap") + imap = imaplib.IMAP4("127.0.0.1", port) + try: + imap.login("hacker", "crackedpassword") + except imaplib.IMAP4.error: + pass # expected + lines = drain() + try: + imap.logout() + except Exception: + pass + lines += drain() + matched = assert_rfc5424(lines, service="imap", event_type="auth") + assert "failed" in matched, f"Expected auth failure in log. Got:\n{matched!r}" + + def test_select_inbox_after_login(self, live_service): + port, drain = live_service("imap") + imap = imaplib.IMAP4("127.0.0.1", port) + imap.login("admin", "admin123") + status, data = imap.select("INBOX") + imap.logout() + assert status == "OK", f"SELECT INBOX failed: {data}" + + def test_capability_command(self, live_service): + port, drain = live_service("imap") + imap = imaplib.IMAP4("127.0.0.1", port) + status, caps = imap.capability() + imap.logout() + assert status == "OK" + cap_str = b" ".join(caps).decode() + assert "IMAP4rev1" in cap_str diff --git a/tests/live/test_mongodb_live.py b/tests/live/test_mongodb_live.py new file mode 100644 index 0000000..4466d3d --- /dev/null +++ b/tests/live/test_mongodb_live.py @@ -0,0 +1,70 @@ +import pytest +import pymongo + +from tests.live.conftest import assert_rfc5424 + + +@pytest.mark.live +class TestMongoDBLive: + def test_connect_succeeds(self, live_service): + port, drain = live_service("mongodb") + client = pymongo.MongoClient( + f"mongodb://127.0.0.1:{port}/", + serverSelectionTimeoutMS=5000, + connectTimeoutMS=5000, + ) + # ismaster is handled — should not raise + client.admin.command("ismaster") + client.close() + + def test_connect_logged(self, live_service): + port, drain = live_service("mongodb") + client = pymongo.MongoClient( + f"mongodb://127.0.0.1:{port}/", + serverSelectionTimeoutMS=5000, + connectTimeoutMS=5000, + ) + try: + client.admin.command("ismaster") + except Exception: + pass + finally: + client.close() + lines = drain() + assert_rfc5424(lines, service="mongodb", event_type="connect") + + def test_message_logged(self, live_service): + port, drain = live_service("mongodb") + client = pymongo.MongoClient( + f"mongodb://127.0.0.1:{port}/", + serverSelectionTimeoutMS=5000, + connectTimeoutMS=5000, + ) + try: + client.admin.command("ismaster") + except Exception: + pass + finally: + client.close() + lines = drain() + assert_rfc5424(lines, service="mongodb", event_type="message") + + def test_list_databases(self, live_service): + port, drain = live_service("mongodb") + client = pymongo.MongoClient( + f"mongodb://127.0.0.1:{port}/", + serverSelectionTimeoutMS=5000, + connectTimeoutMS=5000, + ) + try: + # list_database_names triggers OP_MSG + client.list_database_names() + except Exception: + pass + finally: + client.close() + lines = drain() + # At least one message was exchanged + assert any("mongodb" in l for l in lines), ( + "Expected at least one mongodb log line" + ) diff --git a/tests/live/test_mqtt_live.py b/tests/live/test_mqtt_live.py new file mode 100644 index 0000000..002bd05 --- /dev/null +++ b/tests/live/test_mqtt_live.py @@ -0,0 +1,63 @@ +import time + +import pytest +import paho.mqtt.client as mqtt + +from tests.live.conftest import assert_rfc5424 + + +@pytest.mark.live +class TestMQTTLive: + def test_connect_accepted(self, live_service): + port, drain = live_service("mqtt") + connected = [] + client = mqtt.Client(client_id="test-scanner") + client.on_connect = lambda c, u, f, rc: connected.append(rc) + client.connect("127.0.0.1", port, keepalive=5) + client.loop_start() + deadline = time.monotonic() + 5 + while not connected and time.monotonic() < deadline: + time.sleep(0.05) + client.loop_stop() + client.disconnect() + assert connected and connected[0] == 0, f"Expected CONNACK rc=0, got {connected}" + + def test_connect_logged(self, live_service): + port, drain = live_service("mqtt") + client = mqtt.Client(client_id="hax0r") + client.connect("127.0.0.1", port, keepalive=5) + client.loop_start() + time.sleep(0.3) + client.loop_stop() + client.disconnect() + lines = drain() + assert_rfc5424(lines, service="mqtt", event_type="auth") + + def test_client_id_in_log(self, live_service): + port, drain = live_service("mqtt") + client = mqtt.Client(client_id="evil-scanner-9000") + client.connect("127.0.0.1", port, keepalive=5) + client.loop_start() + time.sleep(0.3) + client.loop_stop() + client.disconnect() + lines = drain() + matched = assert_rfc5424(lines, service="mqtt", event_type="auth") + assert "evil-scanner-9000" in matched, ( + f"Expected client_id in log line. Got:\n{matched!r}" + ) + + def test_subscribe_logged(self, live_service): + port, drain = live_service("mqtt") + subscribed = [] + client = mqtt.Client(client_id="sub-test") + client.on_subscribe = lambda c, u, mid, qos: subscribed.append(mid) + client.connect("127.0.0.1", port, keepalive=5) + client.loop_start() + time.sleep(0.2) + client.subscribe("plant/#") + time.sleep(0.3) + client.loop_stop() + client.disconnect() + lines = drain() + assert_rfc5424(lines, service="mqtt", event_type="subscribe") diff --git a/tests/live/test_mysql_live.py b/tests/live/test_mysql_live.py new file mode 100644 index 0000000..d42f1ae --- /dev/null +++ b/tests/live/test_mysql_live.py @@ -0,0 +1,65 @@ +import pytest +import pymysql + +from tests.live.conftest import assert_rfc5424 + + +@pytest.mark.live +class TestMySQLLive: + def test_handshake_received(self, live_service): + port, drain = live_service("mysql") + # Honeypot sends MySQL greeting then denies auth — OperationalError expected + try: + pymysql.connect( + host="127.0.0.1", + port=port, + user="root", + password="password", + connect_timeout=5, + ) + except pymysql.err.OperationalError: + pass # expected: Access denied + + def test_auth_logged(self, live_service): + port, drain = live_service("mysql") + try: + pymysql.connect( + host="127.0.0.1", + port=port, + user="admin", + password="hunter2", + connect_timeout=5, + ) + except pymysql.err.OperationalError: + pass + lines = drain() + assert_rfc5424(lines, service="mysql", event_type="auth") + + def test_username_in_log(self, live_service): + port, drain = live_service("mysql") + try: + pymysql.connect( + host="127.0.0.1", + port=port, + user="dbhacker", + password="letmein", + connect_timeout=5, + ) + except pymysql.err.OperationalError: + pass + lines = drain() + matched = assert_rfc5424(lines, service="mysql", event_type="auth") + assert "dbhacker" in matched, ( + f"Expected username in log line. Got:\n{matched!r}" + ) + + def test_connect_logged(self, live_service): + port, drain = live_service("mysql") + try: + pymysql.connect( + host="127.0.0.1", port=port, user="x", password="y", connect_timeout=5 + ) + except pymysql.err.OperationalError: + pass + lines = drain() + assert_rfc5424(lines, service="mysql", event_type="connect") diff --git a/tests/live/test_pop3_live.py b/tests/live/test_pop3_live.py new file mode 100644 index 0000000..c9855ad --- /dev/null +++ b/tests/live/test_pop3_live.py @@ -0,0 +1,58 @@ +import poplib + +import pytest + +from tests.live.conftest import assert_rfc5424 + + +@pytest.mark.live +class TestPOP3Live: + def test_banner_received(self, live_service): + port, drain = live_service("pop3") + pop = poplib.POP3("127.0.0.1", port) + welcome = pop.getwelcome().decode() + pop.quit() + assert "+OK" in welcome + + def test_connect_logged(self, live_service): + port, drain = live_service("pop3") + pop = poplib.POP3("127.0.0.1", port) + pop.quit() + lines = drain() + assert_rfc5424(lines, service="pop3", event_type="connect") + + def test_user_command_logged(self, live_service): + port, drain = live_service("pop3") + pop = poplib.POP3("127.0.0.1", port) + pop.user("admin") + pop.quit() + lines = drain() + assert_rfc5424(lines, service="pop3", event_type="command") + + def test_auth_success_logged(self, live_service): + port, drain = live_service("pop3") + pop = poplib.POP3("127.0.0.1", port) + pop.user("admin") + pop.pass_("admin123") # valid cred from IMAP_USERS default + lines = drain() + pop.quit() + lines += drain() + matched = assert_rfc5424(lines, service="pop3", event_type="auth") + assert "success" in matched, f"Expected auth success in log. Got:\n{matched!r}" + + def test_auth_fail_logged(self, live_service): + port, drain = live_service("pop3") + pop = poplib.POP3("127.0.0.1", port) + pop.user("root") + try: + pop.pass_("wrongpassword") + except poplib.error_proto: + pass # expected: -ERR Authentication failed + lines = drain() + try: + pop.quit() + except Exception: + pass + lines += drain() + matched = assert_rfc5424(lines, service="pop3", event_type="auth") + assert "failed" in matched, f"Expected auth failure in log. Got:\n{matched!r}" diff --git a/tests/live/test_postgres_live.py b/tests/live/test_postgres_live.py new file mode 100644 index 0000000..e4ca94d --- /dev/null +++ b/tests/live/test_postgres_live.py @@ -0,0 +1,75 @@ +import pytest + +from tests.live.conftest import assert_rfc5424 + + +@pytest.mark.live +class TestPostgresLive: + def test_handshake_received(self, live_service): + port, drain = live_service("postgres") + import psycopg2 + try: + psycopg2.connect( + host="127.0.0.1", + port=port, + user="admin", + password="password", + dbname="production", + connect_timeout=5, + ) + except psycopg2.OperationalError: + pass # expected: honeypot rejects auth + + def test_startup_logged(self, live_service): + port, drain = live_service("postgres") + import psycopg2 + try: + psycopg2.connect( + host="127.0.0.1", + port=port, + user="postgres", + password="secret", + dbname="postgres", + connect_timeout=5, + ) + except psycopg2.OperationalError: + pass + lines = drain() + assert_rfc5424(lines, service="postgres", event_type="startup") + + def test_username_in_log(self, live_service): + port, drain = live_service("postgres") + import psycopg2 + try: + psycopg2.connect( + host="127.0.0.1", + port=port, + user="dbattacker", + password="cracked", + dbname="secrets", + connect_timeout=5, + ) + except psycopg2.OperationalError: + pass + lines = drain() + matched = assert_rfc5424(lines, service="postgres", event_type="startup") + assert "dbattacker" in matched, ( + f"Expected username in log line. Got:\n{matched!r}" + ) + + def test_auth_hash_logged(self, live_service): + port, drain = live_service("postgres") + import psycopg2 + try: + psycopg2.connect( + host="127.0.0.1", + port=port, + user="root", + password="toor", + dbname="prod", + connect_timeout=5, + ) + except psycopg2.OperationalError: + pass + lines = drain() + assert_rfc5424(lines, service="postgres", event_type="auth") diff --git a/tests/live/test_redis_live.py b/tests/live/test_redis_live.py new file mode 100644 index 0000000..358f805 --- /dev/null +++ b/tests/live/test_redis_live.py @@ -0,0 +1,44 @@ +import pytest +import redis + +from tests.live.conftest import assert_rfc5424 + + +@pytest.mark.live +class TestRedisLive: + def test_ping_responds(self, live_service): + port, drain = live_service("redis") + r = redis.Redis(host="127.0.0.1", port=port, socket_timeout=5) + assert r.ping() is True + + def test_connect_logged(self, live_service): + port, drain = live_service("redis") + r = redis.Redis(host="127.0.0.1", port=port, socket_timeout=5) + r.ping() + lines = drain() + assert_rfc5424(lines, service="redis", event_type="connect") + + def test_auth_logged(self, live_service): + port, drain = live_service("redis") + r = redis.Redis( + host="127.0.0.1", port=port, password="wrongpassword", socket_timeout=5 + ) + try: + r.ping() + except Exception: + pass + lines = drain() + assert_rfc5424(lines, service="redis", event_type="auth") + + def test_command_logged(self, live_service): + port, drain = live_service("redis") + r = redis.Redis(host="127.0.0.1", port=port, socket_timeout=5) + r.execute_command("KEYS", "*") + lines = drain() + assert_rfc5424(lines, service="redis", event_type="command") + + def test_keys_returns_bait_data(self, live_service): + port, drain = live_service("redis") + r = redis.Redis(host="127.0.0.1", port=port, socket_timeout=5) + keys = r.keys("*") + assert len(keys) > 0, "Expected bait keys in fake store" diff --git a/tests/live/test_smtp_live.py b/tests/live/test_smtp_live.py new file mode 100644 index 0000000..c5b0576 --- /dev/null +++ b/tests/live/test_smtp_live.py @@ -0,0 +1,39 @@ +import smtplib + +import pytest + +from tests.live.conftest import assert_rfc5424 + + +@pytest.mark.live +class TestSMTPLive: + def test_banner_received(self, live_service): + port, drain = live_service("smtp") + with smtplib.SMTP("127.0.0.1", port, timeout=5) as s: + code, msg = s.ehlo("test.example.com") + assert code == 250 + + def test_ehlo_logged(self, live_service): + port, drain = live_service("smtp") + with smtplib.SMTP("127.0.0.1", port, timeout=5) as s: + s.ehlo("attacker.example.com") + lines = drain() + assert_rfc5424(lines, service="smtp", event_type="ehlo") + + def test_auth_attempt_logged(self, live_service): + port, drain = live_service("smtp") + with smtplib.SMTP("127.0.0.1", port, timeout=5) as s: + s.ehlo("attacker.example.com") + try: + s.login("admin", "password123") + except smtplib.SMTPAuthenticationError: + pass # expected — honeypot rejects auth + lines = drain() + assert_rfc5424(lines, service="smtp", event_type="auth_attempt") + + def test_connect_disconnect_logged(self, live_service): + port, drain = live_service("smtp") + with smtplib.SMTP("127.0.0.1", port, timeout=5) as s: + s.ehlo("scanner.example.com") + lines = drain() + assert_rfc5424(lines, service="smtp", event_type="connect")