Implement ICS/SCADA and IMAP Bait features

This commit is contained in:
2026-04-10 01:50:08 -04:00
parent 63fb477e1f
commit 08242a4d84
112 changed files with 3239 additions and 764 deletions

View File

@@ -1,7 +1,5 @@
import json
import pytest
from fastapi.testclient import TestClient
from decnet.web.api import app
from hypothesis import given, strategies as st, settings
import httpx
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD

View File

@@ -10,6 +10,7 @@ from hypothesis import HealthCheck
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
import os as _os
# Must be set before any decnet import touches decnet.env
os.environ["DECNET_JWT_SECRET"] = "test-secret-key-at-least-32-chars-long!!"
@@ -123,7 +124,6 @@ def mock_state_file(patch_state_file: Path):
# Share fuzz settings across API tests
# FUZZ_EXAMPLES: keep low for dev speed; bump via HYPOTHESIS_MAX_EXAMPLES env var in CI
import os as _os
_FUZZ_EXAMPLES = int(_os.environ.get("HYPOTHESIS_MAX_EXAMPLES", "10"))
_FUZZ_SETTINGS: dict[str, Any] = {
"max_examples": _FUZZ_EXAMPLES,

View File

@@ -1,7 +1,6 @@
import pytest
import httpx
from hypothesis import given, settings, strategies as st
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
from ..conftest import _FUZZ_SETTINGS
@pytest.mark.anyio

View File

@@ -1,7 +1,6 @@
import pytest
import httpx
from typing import Any, Optional
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
from ..conftest import _FUZZ_SETTINGS
from hypothesis import given, strategies as st, settings

View File

@@ -5,7 +5,6 @@ freeze_time controls Python's datetime.now() so we can compute
explicit bucket timestamps deterministically, then pass them to
add_log and verify SQLite groups them into the right buckets.
"""
import json
import pytest
from datetime import datetime, timedelta
from freezegun import freeze_time

View File

@@ -1,7 +1,5 @@
import pytest
import httpx
from typing import Any
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
from ..conftest import _FUZZ_SETTINGS
from hypothesis import given, strategies as st, settings

View File

@@ -0,0 +1,89 @@
"""
Tests for templates/imap/server.py
Exercises IMAP state machine, auth, and negative tests.
"""
import importlib.util
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
def _make_fake_decnet_logging() -> ModuleType:
mod = ModuleType("decnet_logging")
mod.syslog_line = MagicMock(return_value="")
mod.write_syslog_file = MagicMock()
mod.forward_syslog = MagicMock()
mod.SEVERITY_WARNING = 4
mod.SEVERITY_INFO = 6
return mod
def _load_imap():
env = {
"NODE_NAME": "testhost",
"IMAP_USERS": "admin:admin123,root:toor",
"IMAP_BANNER": "* OK [testhost] Dovecot ready."
}
for key in list(sys.modules):
if key in ("imap_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("imap_server", "templates/imap/server.py")
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", env, clear=False):
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
proto = mod.IMAPProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
written.clear()
return proto, transport, written
def _send(proto, data: str) -> None:
proto.data_received(data.encode() + b"\r\n")
@pytest.fixture
def imap_mod():
return _load_imap()
def test_imap_login_success(imap_mod):
proto, transport, written = _make_protocol(imap_mod)
_send(proto, 'A1 LOGIN admin admin123')
assert b"A1 OK" in b"".join(written)
assert proto._state == "AUTHENTICATED"
def test_imap_login_fail(imap_mod):
proto, transport, written = _make_protocol(imap_mod)
_send(proto, 'A1 LOGIN admin wrongpass')
assert b"A1 NO" in b"".join(written)
assert proto._state == "NOT_AUTHENTICATED"
def test_imap_select_before_auth(imap_mod):
proto, transport, written = _make_protocol(imap_mod)
_send(proto, 'A2 SELECT INBOX')
assert b"A2 BAD" in b"".join(written)
def test_imap_fetch_after_select(imap_mod):
proto, transport, written = _make_protocol(imap_mod)
_send(proto, 'A1 LOGIN admin admin123')
written.clear()
_send(proto, 'A2 SELECT INBOX')
written.clear()
_send(proto, 'A3 FETCH 1 RFC822')
combined = b"".join(written)
assert b"A3 OK" in combined
assert b"AKIAIOSFODNN7EXAMPLE" in combined
def test_imap_invalid_command(imap_mod):
proto, transport, written = _make_protocol(imap_mod)
_send(proto, 'A1 INVALID')
assert b"A1 BAD" in b"".join(written)

View File

@@ -0,0 +1,195 @@
"""
Tests for templates/mqtt/server.py
Exercises behavior with MQTT_ACCEPT_ALL=1 and customizable topics.
Uses asyncio transport/protocol directly.
"""
import importlib.util
import json
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_fake_decnet_logging() -> ModuleType:
mod = ModuleType("decnet_logging")
mod.syslog_line = MagicMock(return_value="")
mod.write_syslog_file = MagicMock()
mod.forward_syslog = MagicMock()
mod.SEVERITY_WARNING = 4
mod.SEVERITY_INFO = 6
return mod
def _load_mqtt(accept_all: bool = True, custom_topics: str = "", persona: str = "water_plant"):
env = {
"MQTT_ACCEPT_ALL": "1" if accept_all else "0",
"NODE_NAME": "testhost",
"MQTT_PERSONA": persona,
"MQTT_CUSTOM_TOPICS": custom_topics,
}
for key in list(sys.modules):
if key in ("mqtt_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("mqtt_server", "templates/mqtt/server.py")
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", env, clear=False):
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
proto = mod.MQTTProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
written.clear()
return proto, transport, written
def _send(proto, data: bytes) -> None:
proto.data_received(data)
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def mqtt_mod():
return _load_mqtt()
@pytest.fixture
def mqtt_no_auth_mod():
return _load_mqtt(accept_all=False)
# ── Packet Helpers ────────────────────────────────────────────────────────────
def _connect_packet() -> bytes:
# 0x10, len 14, 00 04 MQTT 04 02 00 3c 00 02 id
return b"\x10\x0e\x00\x04MQTT\x04\x02\x00\x3c\x00\x02id"
def _subscribe_packet(topic: str, pid: int = 1) -> bytes:
topic_bytes = topic.encode()
payload = pid.to_bytes(2, "big") + len(topic_bytes).to_bytes(2, "big") + topic_bytes + b"\x01" # qos 1
return bytes([0x82, len(payload)]) + payload
def _publish_packet(topic: str, payload: str, qos: int = 1, pid: int = 1) -> bytes:
topic_bytes = topic.encode()
payload_bytes = payload.encode()
flags = qos << 1
byte0 = 0x30 | flags
if qos > 0:
packet_payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + pid.to_bytes(2, "big") + payload_bytes
else:
packet_payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + payload_bytes
return bytes([byte0, len(packet_payload)]) + packet_payload
def _pingreq_packet() -> bytes:
return b"\xc0\x00"
def _disconnect_packet() -> bytes:
return b"\xe0\x00"
# ── Tests ─────────────────────────────────────────────────────────────────────
def test_connect_accept(mqtt_mod):
proto, transport, written = _make_protocol(mqtt_mod)
_send(proto, _connect_packet())
assert len(written) == 1
assert written[0] == b"\x20\x02\x00\x00"
assert proto._auth is True
def test_connect_reject(mqtt_no_auth_mod):
proto, transport, written = _make_protocol(mqtt_no_auth_mod)
_send(proto, _connect_packet())
assert len(written) == 1
assert written[0] == b"\x20\x02\x00\x05"
assert transport.close.called
def test_pingreq(mqtt_mod):
proto, _, written = _make_protocol(mqtt_mod)
_send(proto, _pingreq_packet())
assert written[0] == b"\xd0\x00"
def test_subscribe_wildcard_retained(mqtt_mod):
proto, _, written = _make_protocol(mqtt_mod)
_send(proto, _connect_packet())
written.clear()
_send(proto, _subscribe_packet("plant/#"))
assert len(written) >= 2 # At least SUBACK + some publishes
assert written[0].startswith(b"\x90") # SUBACK
combined = b"".join(written[1:])
# Should contain some water plant topics
assert b"plant/water/tank1/level" in combined
def test_publish_qos1_returns_puback(mqtt_mod):
proto, _, written = _make_protocol(mqtt_mod)
_send(proto, _connect_packet())
written.clear()
_send(proto, _publish_packet("target/topic", "malicious_payload", qos=1, pid=42))
assert len(written) == 1
# PUBACK (0x40), len=2, pid=42
assert written[0] == b"\x40\x02\x00\x2a"
def test_custom_topics():
custom = {"custom/1": "val1", "custom/2": "val2"}
mod = _load_mqtt(custom_topics=json.dumps(custom))
proto, _, written = _make_protocol(mod)
_send(proto, _connect_packet())
written.clear()
_send(proto, _subscribe_packet("custom/1"))
assert len(written) > 1
combined = b"".join(written[1:])
assert b"custom/1" in combined
assert b"val1" in combined
# ── Negative Tests ────────────────────────────────────────────────────────────
def test_subscribe_before_auth_closes(mqtt_mod):
proto, transport, written = _make_protocol(mqtt_mod)
_send(proto, _subscribe_packet("plant/#"))
assert transport.close.called
def test_publish_before_auth_closes(mqtt_mod):
proto, transport, written = _make_protocol(mqtt_mod)
_send(proto, _publish_packet("test", "test", qos=0))
assert transport.close.called
def test_malformed_connect_len(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
_send(proto, b"\x10\x05\x00\x04MQT")
# buffer handles it
_send(proto, b"\x10\x02\x00\x04")
# No crash
def test_bad_packet_type_closer(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
_send(proto, b"\xf0\x00") # Reserved type 15
assert transport.close.called
def test_invalid_json_config():
mod = _load_mqtt(custom_topics="{invalid: json}")
proto, _, _ = _make_protocol(mod)
assert len(proto._topics) > 0 # fell back to persona
def test_disconnect_packet(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
_send(proto, _connect_packet())
_send(proto, _disconnect_packet())
assert transport.close.called

View File

@@ -0,0 +1,98 @@
"""
Tests for templates/pop3/server.py
Exercises POP3 state machine, auth, and negative tests.
"""
import importlib.util
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
def _make_fake_decnet_logging() -> ModuleType:
mod = ModuleType("decnet_logging")
mod.syslog_line = MagicMock(return_value="")
mod.write_syslog_file = MagicMock()
mod.forward_syslog = MagicMock()
mod.SEVERITY_WARNING = 4
mod.SEVERITY_INFO = 6
return mod
def _load_pop3():
env = {
"NODE_NAME": "testhost",
"IMAP_USERS": "admin:admin123,root:toor",
"IMAP_BANNER": "+OK [testhost] Dovecot ready."
}
for key in list(sys.modules):
if key in ("pop3_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("pop3_server", "templates/pop3/server.py")
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", env, clear=False):
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
proto = mod.POP3Protocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
written.clear()
return proto, transport, written
def _send(proto, data: str) -> None:
proto.data_received(data.encode() + b"\r\n")
@pytest.fixture
def pop3_mod():
return _load_pop3()
def test_pop3_login_success(pop3_mod):
proto, transport, written = _make_protocol(pop3_mod)
_send(proto, 'USER admin')
assert b"+OK" in b"".join(written)
written.clear()
_send(proto, 'PASS admin123')
assert b"+OK Logged in" in b"".join(written)
assert proto._state == "TRANSACTION"
def test_pop3_login_fail(pop3_mod):
proto, transport, written = _make_protocol(pop3_mod)
_send(proto, 'USER admin')
written.clear()
_send(proto, 'PASS wrongpass')
assert b"-ERR" in b"".join(written)
assert proto._state == "AUTHORIZATION"
def test_pop3_pass_before_user(pop3_mod):
proto, transport, written = _make_protocol(pop3_mod)
_send(proto, 'PASS admin123')
assert b"-ERR" in b"".join(written)
def test_pop3_stat_before_auth(pop3_mod):
proto, transport, written = _make_protocol(pop3_mod)
_send(proto, 'STAT')
assert b"-ERR" in b"".join(written)
def test_pop3_retr_after_auth(pop3_mod):
proto, transport, written = _make_protocol(pop3_mod)
_send(proto, 'USER admin')
_send(proto, 'PASS admin123')
written.clear()
_send(proto, 'RETR 1')
combined = b"".join(written)
assert b"+OK" in combined
assert b"AKIAIOSFODNN7EXAMPLE" in combined
def test_pop3_invalid_command(pop3_mod):
proto, transport, written = _make_protocol(pop3_mod)
_send(proto, 'INVALID')
assert b"-ERR" in b"".join(written)

View File

@@ -0,0 +1,148 @@
"""
Tests for templates/snmp/server.py
Exercises behavior with SNMP_ARCHETYPE modifications.
Uses asyncio DatagramProtocol directly.
"""
import importlib.util
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_fake_decnet_logging() -> ModuleType:
mod = ModuleType("decnet_logging")
def syslog_line(*args, **kwargs):
print("LOG:", args, kwargs)
return ""
mod.syslog_line = syslog_line
mod.write_syslog_file = MagicMock()
mod.forward_syslog = MagicMock()
mod.SEVERITY_WARNING = 4
mod.SEVERITY_INFO = 6
return mod
def _load_snmp(archetype: str = "default"):
env = {
"NODE_NAME": "testhost",
"SNMP_ARCHETYPE": archetype,
}
for key in list(sys.modules):
if key in ("snmp_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("snmp_server", "templates/snmp/server.py")
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", env, clear=False):
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
proto = mod.SNMPProtocol()
transport = MagicMock()
sent: list[tuple] = []
def sendto(data, addr):
sent.append((data, addr))
transport.sendto = sendto
proto.connection_made(transport)
sent.clear()
return proto, transport, sent
def _send(proto, data: bytes, addr=("127.0.0.1", 12345)) -> None:
proto.datagram_received(data, addr)
# ── Packet Helpers ────────────────────────────────────────────────────────────
def _ber_tlv(tag: int, value: bytes) -> bytes:
length = len(value)
if length < 0x80:
return bytes([tag, length]) + value
elif length < 0x100:
return bytes([tag, 0x81, length]) + value
else:
return bytes([tag, 0x82]) + int.to_bytes(length, 2, "big") + value
def _get_request_packet(community: str, request_id: int, oid_enc: bytes) -> bytes:
# Build a simple GetRequest for a single OID
varbind = _ber_tlv(0x30, _ber_tlv(0x06, oid_enc) + _ber_tlv(0x05, b"")) # 0x05 is NULL
varbind_list = _ber_tlv(0x30, varbind)
req_id_tlv = _ber_tlv(0x02, request_id.to_bytes(4, "big"))
err_stat = _ber_tlv(0x02, b"\x00")
err_idx = _ber_tlv(0x02, b"\x00")
pdu = _ber_tlv(0xa0, req_id_tlv + err_stat + err_idx + varbind_list)
ver = _ber_tlv(0x02, b"\x01") # v2c
comm = _ber_tlv(0x04, community.encode())
return _ber_tlv(0x30, ver + comm + pdu)
# 1.3.6.1.2.1.1.1.0 = b"\x2b\x06\x01\x02\x01\x01\x01\x00"
SYS_DESCR_OID_ENC = b"\x2b\x06\x01\x02\x01\x01\x01\x00"
# ── Tests ─────────────────────────────────────────────────────────────────────
@pytest.fixture
def snmp_default():
return _load_snmp()
@pytest.fixture
def snmp_water_plant():
return _load_snmp("water_plant")
def test_sysdescr_default(snmp_default):
proto, transport, sent = _make_protocol(snmp_default)
packet = _get_request_packet("public", 1, SYS_DESCR_OID_ENC)
_send(proto, packet)
assert len(sent) == 1
resp, addr = sent[0]
assert addr == ("127.0.0.1", 12345)
# default sysDescr has "Ubuntu SMP" in it
assert b"Ubuntu SMP" in resp
def test_sysdescr_water_plant(snmp_water_plant):
proto, transport, sent = _make_protocol(snmp_water_plant)
packet = _get_request_packet("public", 2, SYS_DESCR_OID_ENC)
_send(proto, packet)
assert len(sent) == 1
resp, _ = sent[0]
assert b"Debian" in resp
# ── Negative Tests ────────────────────────────────────────────────────────────
def test_invalid_asn1_sequence(snmp_default):
proto, transport, sent = _make_protocol(snmp_default)
# 0x31 instead of 0x30
_send(proto, b"\x31\x02\x00\x00")
assert len(sent) == 0 # Caught and logged
def test_truncated_packet(snmp_default):
proto, transport, sent = _make_protocol(snmp_default)
packet = _get_request_packet("public", 3, SYS_DESCR_OID_ENC)
_send(proto, packet[:10]) # chop it
assert len(sent) == 0
def test_invalid_pdu_type(snmp_default):
proto, transport, sent = _make_protocol(snmp_default)
packet = _get_request_packet("public", 4, SYS_DESCR_OID_ENC).replace(b"\xa0", b"\xa3", 1)
_send(proto, packet)
assert len(sent) == 0
def test_bad_oid_encoding(snmp_default):
proto, transport, sent = _make_protocol(snmp_default)
_send(proto, b"\x30\x84\xff\xff\xff\xff")
assert len(sent) == 0

View File

@@ -23,10 +23,10 @@ BUILD_SERVICES = [
"ssh", "http", "rdp", "smb", "ftp", "smtp", "elasticsearch",
"pop3", "imap", "mysql", "mssql", "redis", "mongodb", "postgres",
"ldap", "vnc", "docker_api", "k8s", "sip",
"mqtt", "llmnr", "snmp", "tftp",
"mqtt", "llmnr", "snmp", "tftp", "conpot"
]
UPSTREAM_SERVICES = ["telnet", "conpot"]
UPSTREAM_SERVICES = ["telnet"]
def _make_config(services, distro="debian", base_image=None, build_base=None):

View File

@@ -2,10 +2,7 @@
Tests for decnet.config — Pydantic models, save/load/clear state.
Covers the uncovered lines: validators, save_state, load_state, clear_state.
"""
import json
import pytest
from pathlib import Path
from unittest.mock import patch
import decnet.config as config_module
from decnet.config import (

View File

@@ -1,7 +1,6 @@
"""
Tests for decnet.custom_service — BYOS (bring-your-own-service) support.
"""
import pytest
from decnet.custom_service import CustomService

View File

@@ -1,7 +1,6 @@
"""
Tests for decnet.logging.forwarder — parse_log_target, probe_log_target.
"""
import socket
from unittest.mock import MagicMock, patch
import pytest

View File

@@ -5,7 +5,7 @@ All subprocess and state I/O is mocked; no Docker or filesystem access.
import subprocess
import time
from pathlib import Path
from unittest.mock import MagicMock, call, patch
from unittest.mock import MagicMock, patch
import pytest

View File

@@ -33,7 +33,6 @@ def _is_build_service(name: str) -> bool:
UPSTREAM_SERVICES = {
"telnet": ("cowrie/cowrie", [23]),
"conpot": ("honeynet/conpot", [502, 161, 80]),
}
# ---------------------------------------------------------------------------
@@ -64,6 +63,7 @@ BUILD_SERVICES = {
"llmnr": ([5355, 5353], "llmnr"),
"snmp": ([161], "snmp"),
"tftp": ([69], "tftp"),
"conpot": ([502, 161, 80], "conpot"),
}
ALL_SERVICE_NAMES = list(UPSTREAM_SERVICES) + list(BUILD_SERVICES)