refactor(tests): move flat tests/*.py into per-subsystem subfolders

Groups every flat test_*.py under the module it exercises, matching the
existing tests/{profiler,sniffer,prober,collector,correlation,cli,web,
topology,swarm,bus,updater,api,docker,geoip,...} layout. New folders:
services/, fleet/, config/, logging/, db/ (+ db/mysql/), telemetry/,
mutator/, core/.

Path-dependent __file__ references bumped an extra .parent in three
files that moved one level deeper:
- tests/sniffer/test_sniffer_ja3.py   (template path)
- tests/services/test_ssh_capture_emit.py (template path)
- tests/cli/test_mode_gating.py  (REPO root)
- tests/web/test_env_lazy_jwt.py (repo var)

Also drops two SQLite runtime artifacts (test_decnet.db-{shm,wal}) that
were leaking into the repo from a previous test run.

Fixes two test_service_isolation cases that patched asyncio.sleep (no
longer on the profiler main-loop hot path — same pre-existing bug I
fixed earlier in test_attacker_worker.py) by patching asyncio.wait_for
and passing interval=0.
This commit is contained in:
2026-04-23 21:34:25 -04:00
parent 21e6820714
commit ea95a009df
78 changed files with 18 additions and 10 deletions

View File

View File

@@ -0,0 +1,64 @@
"""
Tests for decnet.custom_service — BYOS (bring-your-own-service) support.
"""
from decnet.custom_service import CustomService
class TestCustomServiceComposeFragment:
def _svc(self, name="my-tool", image="myrepo/mytool:latest",
exec_cmd="", ports=None):
return CustomService(name=name, image=image,
exec_cmd=exec_cmd, ports=ports)
def test_basic_fragment_structure(self):
svc = self._svc()
frag = svc.compose_fragment("decky-01")
assert frag["image"] == "myrepo/mytool:latest"
assert frag["container_name"] == "decky-01-my-tool"
assert frag["restart"] == "unless-stopped"
assert frag["environment"]["NODE_NAME"] == "decky-01"
def test_underscores_in_name_become_dashes(self):
svc = self._svc(name="my_custom_tool")
frag = svc.compose_fragment("decky-01")
assert frag["container_name"] == "decky-01-my-custom-tool"
def test_exec_cmd_is_split_into_list(self):
svc = self._svc(exec_cmd="/usr/bin/server --port 8080")
frag = svc.compose_fragment("decky-01")
assert frag["command"] == ["/usr/bin/server", "--port", "8080"]
def test_empty_exec_cmd_omits_command_key(self):
svc = self._svc(exec_cmd="")
frag = svc.compose_fragment("decky-01")
assert "command" not in frag
def test_log_target_injected_into_environment(self):
svc = self._svc()
frag = svc.compose_fragment("decky-01", log_target="10.0.0.5:5140")
assert frag["environment"]["LOG_TARGET"] == "10.0.0.5:5140"
def test_no_log_target_omits_key(self):
svc = self._svc()
frag = svc.compose_fragment("decky-01", log_target=None)
assert "LOG_TARGET" not in frag["environment"]
def test_service_cfg_is_accepted_without_error(self):
svc = self._svc()
# service_cfg is accepted but not used by CustomService
frag = svc.compose_fragment("decky-01", service_cfg={"key": "val"})
assert frag is not None
def test_ports_stored_on_instance(self):
svc = CustomService("tool", "img", "", ports=[8080, 9090])
assert svc.ports == [8080, 9090]
def test_no_ports_defaults_to_empty_list(self):
svc = CustomService("tool", "img", "")
assert svc.ports == []
class TestCustomServiceDockerfileContext:
def test_returns_none(self):
svc = CustomService("tool", "img", "cmd")
assert svc.dockerfile_context() is None

View File

@@ -0,0 +1,503 @@
"""
Service isolation tests.
Verifies that each background worker handles missing dependencies gracefully
and that failures in one service do not cascade to others.
Dependency graph under test:
Collector → (Docker SDK, state file, log file)
Ingester → (Collector's JSON output, DB repo)
Attacker → (DB repo)
Sniffer → (MACVLAN interface, scapy, state file)
API → (DB init, all workers)
Each test disables or breaks one dependency and asserts the affected
worker degrades gracefully while unrelated workers remain unaffected.
"""
import asyncio
import json
import os
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ─── Collector isolation ─────────────────────────────────────────────────────
class TestCollectorIsolation:
"""Collector depends on Docker SDK and state file."""
@pytest.mark.asyncio
async def test_collector_survives_docker_unavailable(self):
"""Collector must not crash when Docker daemon is not running."""
import docker as docker_mod
from decnet.collector.worker import log_collector_worker
original_from_env = docker_mod.from_env
with patch.object(docker_mod, "from_env",
side_effect=Exception("Cannot connect to Docker daemon")):
task = asyncio.create_task(log_collector_worker("/tmp/decnet-test-collector.log"))
await asyncio.sleep(0.1)
assert task.done()
exc = task.exception()
assert exc is None # Should not propagate exceptions
@pytest.mark.asyncio
async def test_collector_survives_no_state_file(self):
"""Collector must handle missing state file (no deckies deployed)."""
from decnet.collector.worker import _load_service_container_names
with patch("decnet.config.load_state", return_value=None):
result = _load_service_container_names()
assert result == set()
@pytest.mark.asyncio
async def test_collector_survives_empty_fleet(self):
"""Collector runs but finds no matching containers when fleet is empty."""
import docker as docker_mod
from decnet.collector.worker import log_collector_worker
mock_client = MagicMock()
mock_client.containers.list.return_value = []
mock_client.events.side_effect = Exception("connection closed")
with patch.object(docker_mod, "from_env", return_value=mock_client):
with patch("decnet.config.load_state", return_value=None):
task = asyncio.create_task(log_collector_worker("/tmp/decnet-test-collector.log"))
await asyncio.sleep(0.1)
assert task.done()
assert task.exception() is None
def test_collector_container_filter_with_unknown_containers(self):
"""is_service_container must reject containers not in state."""
from decnet.collector.worker import is_service_container
with patch("decnet.collector.worker._load_service_container_names",
return_value={"decky-01-ssh", "decky-01-http"}):
assert is_service_container("decky-01-ssh") is True
assert is_service_container("random-container") is False
assert is_service_container("decky-99-ftp") is False
# ─── Ingester isolation ──────────────────────────────────────────────────────
class TestIngesterIsolation:
"""Ingester depends on collector's JSON output and DB repo."""
@pytest.mark.asyncio
async def test_ingester_survives_missing_log_file(self):
"""Ingester must wait patiently when JSON log file doesn't exist yet."""
from decnet.web.ingester import log_ingestion_worker
mock_repo = MagicMock()
mock_repo.add_logs = AsyncMock()
mock_repo.get_state = AsyncMock(return_value=None)
mock_repo.set_state = AsyncMock()
iterations = 0
async def _controlled_sleep(seconds):
nonlocal iterations
iterations += 1
if iterations >= 3:
raise asyncio.CancelledError()
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": "/tmp/nonexistent-decnet-test.log"}):
with patch("decnet.web.ingester.asyncio.sleep", side_effect=_controlled_sleep):
task = asyncio.create_task(log_ingestion_worker(mock_repo))
with pytest.raises(asyncio.CancelledError):
await task
# Should have waited at least 2 iterations without crashing
assert iterations >= 2
mock_repo.add_logs.assert_not_called()
@pytest.mark.asyncio
async def test_ingester_survives_no_log_file_env(self):
"""Ingester must exit gracefully when DECNET_INGEST_LOG_FILE is unset."""
from decnet.web.ingester import log_ingestion_worker
mock_repo = MagicMock()
with patch.dict(os.environ, {}, clear=False):
# Remove the env var if it exists
os.environ.pop("DECNET_INGEST_LOG_FILE", None)
await log_ingestion_worker(mock_repo)
# Should return immediately without error
mock_repo.add_log.assert_not_called()
@pytest.mark.asyncio
async def test_ingester_survives_malformed_json(self, tmp_path):
"""Ingester must skip malformed JSON lines without crashing."""
from decnet.web.ingester import log_ingestion_worker
json_file = tmp_path / "test.json"
json_file.write_text("not valid json\n{also broken\n")
mock_repo = MagicMock()
mock_repo.add_log = AsyncMock()
mock_repo.add_logs = AsyncMock()
mock_repo.get_state = AsyncMock(return_value=None)
mock_repo.set_state = AsyncMock()
iterations = 0
async def _controlled_sleep(seconds):
nonlocal iterations
iterations += 1
if iterations >= 3:
raise asyncio.CancelledError()
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": str(tmp_path / "test.log")}):
with patch("decnet.web.ingester.asyncio.sleep", side_effect=_controlled_sleep):
task = asyncio.create_task(log_ingestion_worker(mock_repo))
with pytest.raises(asyncio.CancelledError):
await task
mock_repo.add_logs.assert_not_called()
@pytest.mark.asyncio
async def test_ingester_exits_on_db_fatal_error(self, tmp_path):
"""Ingester must exit cleanly on fatal DB errors (table missing, connection closed)."""
from decnet.web.ingester import log_ingestion_worker
json_file = tmp_path / "test.json"
valid_record = {
"timestamp": "2026-01-01 00:00:00",
"decky": "decky-01",
"service": "ssh",
"event_type": "login_attempt",
"attacker_ip": "10.0.0.1",
"fields": {},
"msg": "",
"raw_line": "<134>1 2026-01-01T00:00:00Z decky-01 ssh - login_attempt -",
}
json_file.write_text(json.dumps(valid_record) + "\n")
mock_repo = MagicMock()
mock_repo.add_log = AsyncMock()
mock_repo.add_logs = AsyncMock(side_effect=Exception("no such table: logs"))
mock_repo.get_state = AsyncMock(return_value=None)
mock_repo.set_state = AsyncMock()
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": str(tmp_path / "test.log")}):
# Worker should exit the loop on fatal DB error
await log_ingestion_worker(mock_repo)
# Should have attempted to bulk-add before dying
mock_repo.add_logs.assert_awaited_once()
# ─── Attacker worker isolation ───────────────────────────────────────────────
class TestAttackerWorkerIsolation:
"""Attacker worker depends on DB repo."""
@pytest.mark.asyncio
async def test_attacker_worker_survives_db_error(self):
"""Attacker worker must catch DB errors and continue looping."""
from decnet.profiler import attacker_profile_worker
mock_repo = MagicMock()
mock_repo.get_all_logs_raw = AsyncMock(side_effect=Exception("DB is locked"))
mock_repo.get_max_log_id = AsyncMock(return_value=0)
mock_repo.get_state = AsyncMock(return_value=None)
mock_repo.set_state = AsyncMock()
iterations = 0
real_wait_for = asyncio.wait_for
async def _controlled_wait_for(awaitable, timeout):
nonlocal iterations
iterations += 1
if iterations >= 3:
if asyncio.iscoroutine(awaitable):
awaitable.close()
raise asyncio.CancelledError()
return await real_wait_for(awaitable, timeout)
with patch("decnet.profiler.worker.asyncio.wait_for", side_effect=_controlled_wait_for):
task = asyncio.create_task(attacker_profile_worker(mock_repo, interval=0))
with pytest.raises(asyncio.CancelledError):
await task
# Worker should have retried at least twice before we cancelled
assert iterations >= 2
@pytest.mark.asyncio
async def test_attacker_worker_survives_empty_db(self):
"""Attacker worker must handle an empty database gracefully."""
from decnet.profiler.worker import _WorkerState, _incremental_update
mock_repo = MagicMock()
mock_repo.get_logs_after_id = AsyncMock(return_value=[])
mock_repo.set_state = AsyncMock()
state = _WorkerState()
await _incremental_update(mock_repo, state)
assert state.initialized is True
assert state.last_log_id == 0
# ─── Sniffer isolation ───────────────────────────────────────────────────────
class TestSnifferIsolation:
"""Sniffer depends on MACVLAN interface, scapy, and state file."""
@pytest.mark.asyncio
async def test_sniffer_survives_missing_interface(self):
"""Sniffer must exit gracefully when MACVLAN interface doesn't exist."""
from decnet.sniffer.worker import sniffer_worker
with patch("decnet.sniffer.worker._interface_exists", return_value=False):
await sniffer_worker("/tmp/decnet-test-sniffer.log")
# Should return without error
@pytest.mark.asyncio
async def test_sniffer_survives_no_state(self):
"""Sniffer must exit gracefully when no deckies are deployed."""
from decnet.sniffer.worker import sniffer_worker
with patch("decnet.sniffer.worker._interface_exists", return_value=True):
with patch("decnet.config.load_state", return_value=None):
await sniffer_worker("/tmp/decnet-test-sniffer.log")
# Should return without error
@pytest.mark.asyncio
async def test_sniffer_survives_scapy_import_error(self):
"""Sniffer must handle missing scapy library gracefully."""
from decnet.sniffer.worker import _sniff_loop
import threading
stop = threading.Event()
with patch("decnet.config.load_state", return_value=None):
with patch.dict("sys.modules", {"scapy": None, "scapy.sendrecv": None}):
# Should exit gracefully (no deckies = early return before scapy import)
_sniff_loop("fake0", Path("/tmp/test.log"), Path("/tmp/test.json"), stop)
@pytest.mark.asyncio
async def test_sniffer_survives_scapy_crash(self):
"""Sniffer must handle scapy runtime errors without crashing the API."""
from decnet.sniffer.worker import sniffer_worker
mock_state = MagicMock()
mock_config = MagicMock()
mock_config.deckies = [MagicMock(ip="192.168.1.10", name="decky-01")]
with patch("decnet.sniffer.worker._interface_exists", return_value=True):
with patch("decnet.config.load_state", return_value=(mock_config, Path("/tmp"))):
with patch("decnet.sniffer.worker.asyncio.to_thread",
side_effect=Exception("scapy segfault")):
# Should catch and log, not raise
await sniffer_worker("/tmp/decnet-test-sniffer.log")
def test_sniffer_engine_ignores_non_decky_traffic(self):
"""Engine must silently skip packets not involving any known decky."""
from decnet.sniffer.fingerprint import SnifferEngine
written: list[str] = []
engine = SnifferEngine(
ip_to_decky={"192.168.1.10": "decky-01"},
write_fn=written.append,
)
# Simulate a packet between two unknown IPs
pkt = MagicMock()
pkt.haslayer.return_value = True
ip_layer = MagicMock()
ip_layer.src = "10.0.0.1"
ip_layer.dst = "10.0.0.2"
tcp_layer = MagicMock()
tcp_layer.sport = 12345
tcp_layer.dport = 443
tcp_layer.flags = MagicMock(value=0x10)
tcp_layer.payload = b""
pkt.__getitem__ = lambda self, cls: ip_layer if cls.__name__ == "IP" else tcp_layer
# Import layers for haslayer check
from scapy.layers.inet import IP, TCP
pkt.haslayer.side_effect = lambda layer: True
engine.on_packet(pkt)
assert written == [] # Nothing written for non-decky traffic
# ─── API lifespan isolation ──────────────────────────────────────────────────
class TestApiLifespanIsolation:
"""API lifespan must survive individual worker failures."""
@pytest.mark.asyncio
async def test_api_survives_all_workers_failing(self):
"""API must start and serve requests even if every worker fails to start."""
from decnet.web.api import lifespan
mock_app = MagicMock()
mock_repo = MagicMock()
mock_repo.initialize = AsyncMock()
with patch("decnet.web.api.repo", mock_repo):
with patch("decnet.web.api.log_ingestion_worker",
side_effect=Exception("ingester exploded")):
with patch("decnet.web.api.log_collector_worker",
side_effect=Exception("collector exploded")):
with patch("decnet.web.api.attacker_profile_worker",
side_effect=Exception("attacker exploded")):
with patch("decnet.sniffer.sniffer_worker",
side_effect=Exception("sniffer exploded")):
# API should still start
async with lifespan(mock_app):
mock_repo.initialize.assert_awaited_once()
@pytest.mark.asyncio
async def test_api_survives_db_init_failure(self):
"""API must survive even if DB never initializes (5 failed attempts)."""
from decnet.web.api import lifespan
mock_app = MagicMock()
mock_repo = MagicMock()
mock_repo.initialize = AsyncMock(side_effect=Exception("DB locked"))
with patch("decnet.web.api.repo", mock_repo):
with patch("decnet.web.api.asyncio.sleep", new_callable=AsyncMock):
with patch("decnet.web.api.log_ingestion_worker", return_value=asyncio.sleep(0)):
with patch("decnet.web.api.log_collector_worker", return_value=asyncio.sleep(0)):
with patch("decnet.web.api.attacker_profile_worker", return_value=asyncio.sleep(0)):
async with lifespan(mock_app):
# DB init failed 5 times but API is running
assert mock_repo.initialize.await_count == 5
@pytest.mark.asyncio
async def test_api_survives_sniffer_import_failure(self):
"""API must start even if the sniffer module cannot be imported."""
from decnet.web.api import lifespan
mock_app = MagicMock()
mock_repo = MagicMock()
mock_repo.initialize = AsyncMock()
with patch("decnet.web.api.repo", mock_repo):
with patch("decnet.web.api.log_ingestion_worker", return_value=asyncio.sleep(0)):
with patch("decnet.web.api.log_collector_worker", return_value=asyncio.sleep(0)):
with patch("decnet.web.api.attacker_profile_worker", return_value=asyncio.sleep(0)):
# Simulate sniffer import failure
import builtins
real_import = builtins.__import__
def _mock_import(name, *args, **kwargs):
if name == "decnet.sniffer":
raise ImportError("No module named 'scapy'")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=_mock_import):
async with lifespan(mock_app):
mock_repo.initialize.assert_awaited_once()
@pytest.mark.asyncio
async def test_shutdown_handles_already_dead_tasks(self):
"""Shutdown must not crash when tasks have already completed or failed."""
from decnet.web.api import lifespan
mock_app = MagicMock()
mock_repo = MagicMock()
mock_repo.initialize = AsyncMock()
# Workers that complete immediately
async def _instant_worker(*args):
return
with patch("decnet.web.api.repo", mock_repo):
with patch("decnet.web.api.log_ingestion_worker", side_effect=_instant_worker):
with patch("decnet.web.api.log_collector_worker", side_effect=_instant_worker):
with patch("decnet.web.api.attacker_profile_worker", side_effect=_instant_worker):
async with lifespan(mock_app):
# Let tasks finish
await asyncio.sleep(0.05)
# Shutdown should handle already-done tasks gracefully
# ─── Cross-service cascade tests ────────────────────────────────────────────
class TestCascadeIsolation:
"""Verify that failure in one service does not cascade to others."""
@pytest.mark.asyncio
async def test_collector_failure_does_not_kill_ingester(self, tmp_path):
"""When collector dies, ingester must keep waiting (not crash)."""
from decnet.web.ingester import log_ingestion_worker
json_file = tmp_path / "cascade.json"
# File doesn't exist — simulates collector never writing
mock_repo = MagicMock()
mock_repo.add_log = AsyncMock()
mock_repo.get_state = AsyncMock(return_value=None)
mock_repo.set_state = AsyncMock()
iterations = 0
async def _controlled_sleep(seconds):
nonlocal iterations
iterations += 1
if iterations >= 5:
raise asyncio.CancelledError()
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": str(tmp_path / "cascade.log")}):
with patch("decnet.web.ingester.asyncio.sleep", side_effect=_controlled_sleep):
task = asyncio.create_task(log_ingestion_worker(mock_repo))
with pytest.raises(asyncio.CancelledError):
await task
# Ingester should have been patiently waiting, not crashing
assert iterations >= 4
mock_repo.add_log.assert_not_called()
@pytest.mark.asyncio
async def test_ingester_failure_does_not_kill_attacker(self):
"""When ingester dies, attacker worker must keep running independently."""
from decnet.profiler import attacker_profile_worker
mock_repo = MagicMock()
mock_repo.get_all_logs_raw = AsyncMock(return_value=[])
mock_repo.get_max_log_id = AsyncMock(return_value=0)
mock_repo.get_state = AsyncMock(return_value=None)
mock_repo.set_state = AsyncMock()
mock_repo.get_logs_after_id = AsyncMock(return_value=[])
iterations = 0
real_wait_for = asyncio.wait_for
async def _controlled_wait_for(awaitable, timeout):
nonlocal iterations
iterations += 1
if iterations >= 3:
if asyncio.iscoroutine(awaitable):
awaitable.close()
raise asyncio.CancelledError()
return await real_wait_for(awaitable, timeout)
with patch("decnet.profiler.worker.asyncio.wait_for", side_effect=_controlled_wait_for):
task = asyncio.create_task(attacker_profile_worker(mock_repo, interval=0))
with pytest.raises(asyncio.CancelledError):
await task
# Attacker should have run independently
assert iterations >= 2
@pytest.mark.asyncio
async def test_sniffer_crash_does_not_affect_collector(self):
"""Sniffer crash must not affect collector operation."""
from decnet.collector.worker import is_service_container, is_service_event
# These should work regardless of sniffer state
with patch("decnet.collector.worker._load_service_container_names",
return_value={"decky-01-ssh"}):
assert is_service_container("decky-01-ssh") is True
assert is_service_event({"name": "decky-01-ssh"}) is True
@pytest.mark.asyncio
async def test_db_failure_does_not_crash_sniffer(self):
"""Sniffer has no DB dependency — must be completely unaffected by DB issues."""
from decnet.sniffer.fingerprint import SnifferEngine
written: list[str] = []
engine = SnifferEngine(
ip_to_decky={"192.168.1.10": "decky-01"},
write_fn=written.append,
)
# Engine should work with zero DB interaction
engine._log("decky-01", "tls_client_hello", src_ip="10.0.0.1", ja3="abc", ja4="def")
assert len(written) == 1
assert "decky-01" in written[0]

View File

@@ -0,0 +1,363 @@
"""
Tests for all 25 DECNET service plugins.
Covers:
- Service registration via the plugin registry
- compose_fragment structure (container_name, restart, image/build)
- LOG_TARGET propagation for custom-build services
- dockerfile_context returns Path for build services, None for upstream-image services
- Per-service persona config (service_cfg) propagation
"""
import pytest
from pathlib import Path
from decnet.services.registry import all_services, get_service
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fragment(name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
return get_service(name).compose_fragment("test-decky", log_target, service_cfg)
def _is_build_service(name: str) -> bool:
svc = get_service(name)
return svc.default_image == "build"
# ---------------------------------------------------------------------------
# Tier 1: upstream-image services (non-build)
# ---------------------------------------------------------------------------
UPSTREAM_SERVICES: dict = {}
# ---------------------------------------------------------------------------
# Tier 2: custom-build services (including ssh, which now uses build)
# ---------------------------------------------------------------------------
BUILD_SERVICES = {
"ssh": ([22], "ssh"),
"telnet": ([23], "telnet"),
"http": ([80, 443], "http"),
"rdp": ([3389], "rdp"),
"smb": ([445, 139], "smb"),
"ftp": ([21], "ftp"),
"smtp": ([25, 587], "smtp"),
"elasticsearch": ([9200], "elasticsearch"),
"pop3": ([110, 995], "pop3"),
"imap": ([143, 993], "imap"),
"mysql": ([3306], "mysql"),
"mssql": ([1433], "mssql"),
"redis": ([6379], "redis"),
"mongodb": ([27017], "mongodb"),
"postgres": ([5432], "postgres"),
"ldap": ([389, 636], "ldap"),
"vnc": ([5900], "vnc"),
"docker_api": ([2375, 2376], "docker_api"),
"k8s": ([6443, 8080], "k8s"),
"sip": ([5060], "sip"),
"mqtt": ([1883], "mqtt"),
"llmnr": ([5355, 5353], "llmnr"),
"snmp": ([161], "snmp"),
"tftp": ([69], "tftp"),
"conpot": ([502, 161, 80], "conpot"),
}
ALL_SERVICE_NAMES = list(UPSTREAM_SERVICES) + list(BUILD_SERVICES)
# ---------------------------------------------------------------------------
# Registration tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name", ALL_SERVICE_NAMES)
def test_service_registered(name):
"""Every service must appear in the registry."""
registry = all_services()
assert name in registry, f"Service '{name}' not found in registry"
@pytest.mark.parametrize("name", ALL_SERVICE_NAMES)
def test_service_ports_defined(name):
"""Every service must declare at least one port."""
svc = get_service(name)
assert isinstance(svc.ports, list)
assert len(svc.ports) >= 1
# ---------------------------------------------------------------------------
# Upstream-image service tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name,expected", [
(n, (img, ports)) for n, (img, ports) in UPSTREAM_SERVICES.items()
])
def test_upstream_image(name, expected):
expected_image, _ = expected
frag = _fragment(name)
assert frag.get("image") == expected_image
@pytest.mark.parametrize("name", UPSTREAM_SERVICES)
def test_upstream_no_dockerfile_context(name):
assert get_service(name).dockerfile_context() is None
@pytest.mark.parametrize("name", UPSTREAM_SERVICES)
def test_upstream_container_name(name):
frag = _fragment(name)
assert frag["container_name"] == f"test-decky-{name.replace('_', '-')}"
@pytest.mark.parametrize("name", UPSTREAM_SERVICES)
def test_upstream_restart_policy(name):
frag = _fragment(name)
assert frag.get("restart") == "unless-stopped"
# ---------------------------------------------------------------------------
# Build-service tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name", BUILD_SERVICES)
def test_build_service_uses_build(name):
frag = _fragment(name)
assert "build" in frag, f"Service '{name}' fragment missing 'build' key"
assert "context" in frag["build"]
@pytest.mark.parametrize("name", BUILD_SERVICES)
def test_build_service_dockerfile_context_is_path(name):
ctx = get_service(name).dockerfile_context()
assert isinstance(ctx, Path), f"Service '{name}' dockerfile_context should return a Path"
@pytest.mark.parametrize("name", BUILD_SERVICES)
def test_build_service_dockerfile_exists(name):
ctx = get_service(name).dockerfile_context()
dockerfile = ctx / "Dockerfile"
assert dockerfile.exists(), f"Dockerfile missing at {dockerfile}"
@pytest.mark.parametrize("name", BUILD_SERVICES)
def test_build_service_container_name(name):
frag = _fragment(name)
slug = name.replace("_", "-")
assert frag["container_name"] == f"test-decky-{slug}"
@pytest.mark.parametrize("name", BUILD_SERVICES)
def test_build_service_restart_policy(name):
frag = _fragment(name)
assert frag.get("restart") == "unless-stopped"
_RSYSLOG_SERVICES = {"ssh", "real_ssh", "telnet"}
_NODE_NAME_SERVICES = [n for n in BUILD_SERVICES if n not in _RSYSLOG_SERVICES]
@pytest.mark.parametrize("name", _NODE_NAME_SERVICES)
def test_build_service_node_name_env(name):
frag = _fragment(name)
env = frag.get("environment", {})
assert "NODE_NAME" in env
assert env["NODE_NAME"] == "test-decky"
# ssh, real_ssh, and telnet do not use LOG_TARGET (rsyslog handles log forwarding inside the container)
_LOG_TARGET_SERVICES = [n for n in BUILD_SERVICES if n not in _RSYSLOG_SERVICES]
@pytest.mark.parametrize("name", _LOG_TARGET_SERVICES)
def test_build_service_log_target_propagated(name):
frag = _fragment(name, log_target="10.0.0.1:5140")
env = frag.get("environment", {})
assert env.get("LOG_TARGET") == "10.0.0.1:5140"
@pytest.mark.parametrize("name", _LOG_TARGET_SERVICES)
def test_build_service_no_log_target_by_default(name):
frag = _fragment(name)
env = frag.get("environment", {})
assert "LOG_TARGET" not in env
def test_ssh_no_log_target_env():
"""SSH uses rsyslog internally — no LOG_TARGET or COWRIE_* vars."""
env = _fragment("ssh", log_target="10.0.0.1:5140").get("environment", {})
assert "LOG_TARGET" not in env
assert not any(k.startswith("COWRIE_") for k in env)
# ---------------------------------------------------------------------------
# Port coverage tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name,expected", [
(n, ports) for n, (ports, _) in BUILD_SERVICES.items()
])
def test_build_service_ports(name, expected):
svc = get_service(name)
assert svc.ports == expected
@pytest.mark.parametrize("name,expected", [
(n, ports) for n, (_, ports) in UPSTREAM_SERVICES.items()
])
def test_upstream_service_ports(name, expected):
svc = get_service(name)
assert svc.ports == expected
# ---------------------------------------------------------------------------
# Registry completeness
# ---------------------------------------------------------------------------
def test_total_service_count():
"""Sanity check: at least 25 services registered."""
assert len(all_services()) >= 25
# ---------------------------------------------------------------------------
# Per-service persona config (service_cfg)
# ---------------------------------------------------------------------------
# HTTP -----------------------------------------------------------------------
def test_http_default_no_extra_env():
"""No service_cfg → none of the new env vars should appear."""
env = _fragment("http").get("environment", {})
for key in ("SERVER_HEADER", "RESPONSE_CODE", "FAKE_APP", "EXTRA_HEADERS", "CUSTOM_BODY", "FILES_DIR"):
assert key not in env, f"Expected {key} absent by default"
def test_http_server_header():
env = _fragment("http", service_cfg={"server_header": "nginx/1.18.0"}).get("environment", {})
assert env.get("SERVER_HEADER") == "nginx/1.18.0"
def test_http_response_code():
env = _fragment("http", service_cfg={"response_code": 200}).get("environment", {})
assert env.get("RESPONSE_CODE") == "200"
def test_http_fake_app():
env = _fragment("http", service_cfg={"fake_app": "wordpress"}).get("environment", {})
assert env.get("FAKE_APP") == "wordpress"
def test_http_extra_headers():
import json
env = _fragment("http", service_cfg={"extra_headers": {"X-Frame-Options": "SAMEORIGIN"}}).get("environment", {})
assert "EXTRA_HEADERS" in env
assert json.loads(env["EXTRA_HEADERS"]) == {"X-Frame-Options": "SAMEORIGIN"}
def test_http_custom_body():
env = _fragment("http", service_cfg={"custom_body": "<html>hi</html>"}).get("environment", {})
assert env.get("CUSTOM_BODY") == "<html>hi</html>"
def test_http_empty_service_cfg_no_extra_env():
env = _fragment("http", service_cfg={}).get("environment", {})
assert "SERVER_HEADER" not in env
# SSH ------------------------------------------------------------------------
def test_ssh_default_env():
env = _fragment("ssh").get("environment", {})
assert env.get("SSH_ROOT_PASSWORD") == "admin"
assert not any(k.startswith("COWRIE_") for k in env)
# SSH propagates NODE_NAME for log attribution / artifact bind-mount paths.
assert env.get("NODE_NAME") == "test-decky"
def test_ssh_custom_password():
env = _fragment("ssh", service_cfg={"password": "h4x!"}).get("environment", {})
assert env.get("SSH_ROOT_PASSWORD") == "h4x!"
def test_ssh_custom_hostname():
env = _fragment("ssh", service_cfg={"hostname": "prod-db"}).get("environment", {})
assert env.get("SSH_HOSTNAME") == "prod-db"
def test_ssh_no_hostname_by_default():
env = _fragment("ssh").get("environment", {})
assert "SSH_HOSTNAME" not in env
# SMTP -----------------------------------------------------------------------
def test_smtp_banner():
env = _fragment("smtp", service_cfg={"banner": "220 mail.corp.local ESMTP Sendmail"}).get("environment", {})
assert env.get("SMTP_BANNER") == "220 mail.corp.local ESMTP Sendmail"
def test_smtp_mta():
env = _fragment("smtp", service_cfg={"mta": "mail.corp.local"}).get("environment", {})
assert env.get("SMTP_MTA") == "mail.corp.local"
def test_smtp_default_no_extra_env():
env = _fragment("smtp").get("environment", {})
assert "SMTP_BANNER" not in env
assert "SMTP_MTA" not in env
# MySQL ----------------------------------------------------------------------
def test_mysql_version():
env = _fragment("mysql", service_cfg={"version": "8.0.33"}).get("environment", {})
assert env.get("MYSQL_VERSION") == "8.0.33"
def test_mysql_default_no_version_env():
env = _fragment("mysql").get("environment", {})
assert "MYSQL_VERSION" not in env
# Redis ----------------------------------------------------------------------
def test_redis_version():
env = _fragment("redis", service_cfg={"version": "6.2.14"}).get("environment", {})
assert env.get("REDIS_VERSION") == "6.2.14"
def test_redis_os_string():
env = _fragment("redis", service_cfg={"os_string": "Linux 4.19.0"}).get("environment", {})
assert env.get("REDIS_OS") == "Linux 4.19.0"
def test_redis_default_no_extra_env():
env = _fragment("redis").get("environment", {})
assert "REDIS_VERSION" not in env
assert "REDIS_OS" not in env
# Telnet ---------------------------------------------------------------------
def test_telnet_uses_build_context():
"""Telnet uses a build context (no Cowrie image)."""
frag = _fragment("telnet")
assert "build" in frag
assert "image" not in frag
def test_telnet_default_password():
env = _fragment("telnet").get("environment", {})
assert env.get("TELNET_ROOT_PASSWORD") == "admin"
def test_telnet_custom_password():
env = _fragment("telnet", service_cfg={"password": "s3cr3t"}).get("environment", {})
assert env.get("TELNET_ROOT_PASSWORD") == "s3cr3t"
def test_telnet_no_cowrie_env_vars():
"""Ensure no Cowrie env vars bleed into the real telnet service."""
env = _fragment("telnet").get("environment", {})
assert not any(k.startswith("COWRIE_") for k in env)

View File

@@ -0,0 +1,42 @@
"""
Tests for SMTP Relay service.
"""
from decnet.services.smtp_relay import SMTPRelayService
def test_smtp_relay_compose_fragment():
svc = SMTPRelayService()
fragment = svc.compose_fragment("test-decky", log_target="log-server")
assert fragment["container_name"] == "test-decky-smtp_relay"
assert fragment["environment"]["SMTP_OPEN_RELAY"] == "1"
assert fragment["environment"]["LOG_TARGET"] == "log-server"
def test_smtp_relay_custom_cfg():
svc = SMTPRelayService()
fragment = svc.compose_fragment(
"test-decky",
service_cfg={"banner": "Welcome", "mta": "Postfix"}
)
assert fragment["environment"]["SMTP_BANNER"] == "Welcome"
assert fragment["environment"]["SMTP_MTA"] == "Postfix"
def test_smtp_relay_dockerfile_context():
svc = SMTPRelayService()
ctx = svc.dockerfile_context()
assert ctx.name == "smtp"
assert ctx.is_dir()
def test_smtp_relay_quarantine_bind_mount():
"""Full-message capture: each decky gets its own host quarantine dir
bind-mounted into the container, and the in-container path is exposed
via SMTP_QUARANTINE_DIR so the server can write .eml files."""
svc = SMTPRelayService()
fragment = svc.compose_fragment("test-decky")
volumes = fragment["volumes"]
assert len(volumes) == 1
host, container, mode = volumes[0].split(":")
assert host.endswith("/test-decky/smtp")
assert container == fragment["environment"]["SMTP_QUARANTINE_DIR"]
assert mode == "rw"

View File

@@ -0,0 +1,160 @@
"""
Tests for SMTP victim-domain tracking (SmtpTarget table + profiler ingestion).
Two surfaces under test:
* Repo upsert / list / aggregate-seen helpers.
* The profiler's `_extract_smtp_domains` + `_normalize_smtp_domain`
parsers — pure functions exercised directly without running the
full worker loop.
"""
from datetime import datetime, timezone
import pytest
from decnet.web.db.factory import get_repository
from decnet.correlation.parser import LogEvent
from decnet.profiler.worker import _extract_smtp_domains, _normalize_smtp_domain
@pytest.fixture
async def repo(tmp_path):
r = get_repository(db_path=str(tmp_path / "smtp_targets.db"))
await r.initialize()
return r
def _smtp_event(event_type: str, **fields) -> LogEvent:
return LogEvent(
timestamp=datetime.now(timezone.utc),
decky="decky-01",
service="smtp",
event_type=event_type,
attacker_ip="1.2.3.4",
fields=fields,
raw="",
)
# ── Domain normalization ─────────────────────────────────────────────────────
@pytest.mark.parametrize("raw, expected", [
("<john@corp1.com>", "corp1.com"),
("JOHN@CORP1.COM", "corp1.com"),
("<alice@mail.corp.io>", "mail.corp.io"),
# Empty / malformed → None
("", None),
("notanemail", None),
("@nouser.com", None),
("user@", None),
# Blocked TLDs
("admin@foo.invalid", None),
("test@bar.test", None),
("x@local.example", None),
# Punctuation / angle-bracket forms the RCPT parser already validated
("RCPT TO:<c@d.com>", "d.com"),
])
def test_normalize_smtp_domain(raw, expected):
assert _normalize_smtp_domain(raw) == expected
# ── Event → domain extraction ────────────────────────────────────────────────
def test_extract_from_rcpt_to():
events = [
_smtp_event("rcpt_to", value="<bob@target.com>"),
_smtp_event("rcpt_to", value="<alice@other.com>"),
]
assert _extract_smtp_domains(events) == {"target.com", "other.com"}
def test_extract_from_rcpt_denied():
events = [_smtp_event("rcpt_denied", value="<carol@corp.net>")]
assert _extract_smtp_domains(events) == {"corp.net"}
def test_extract_from_message_accepted_splits_recipients():
"""`message_accepted.rcpt_to` is a comma-joined list, not a single addr."""
events = [_smtp_event(
"message_accepted",
rcpt_to="<a@one.com>,<b@two.com>,<c@one.com>",
mail_from="<spam@evil.com>",
)]
assert _extract_smtp_domains(events) == {"one.com", "two.com"}
def test_extract_ignores_non_smtp_events():
"""Identical `value` fields on non-smtp services must not leak in."""
events = [
LogEvent(
timestamp=datetime.now(timezone.utc),
decky="decky-01", service="ssh", event_type="rcpt_to",
attacker_ip="1.2.3.4",
fields={"value": "<x@wrong.com>"}, raw="",
),
]
assert _extract_smtp_domains(events) == set()
def test_extract_dedupes_within_batch():
events = [
_smtp_event("rcpt_to", value="<a@corp.com>"),
_smtp_event("rcpt_to", value="<b@corp.com>"),
_smtp_event("rcpt_to", value="<c@corp.com>"),
]
assert _extract_smtp_domains(events) == {"corp.com"}
# ── Repo: increment + list + seen ────────────────────────────────────────────
@pytest.mark.anyio
async def test_increment_creates_then_bumps(repo):
await repo.increment_smtp_target("uuid-1", "corp.com")
rows = await repo.list_smtp_targets("uuid-1")
assert len(rows) == 1
assert rows[0]["domain"] == "corp.com"
assert rows[0]["count"] == 1
first_seen_1 = rows[0]["first_seen"]
# Second hit bumps count + last_seen, preserves first_seen.
await repo.increment_smtp_target("uuid-1", "corp.com")
rows = await repo.list_smtp_targets("uuid-1")
assert rows[0]["count"] == 2
assert rows[0]["first_seen"] == first_seen_1
@pytest.mark.anyio
async def test_increment_isolates_per_attacker(repo):
await repo.increment_smtp_target("uuid-a", "corp.com")
await repo.increment_smtp_target("uuid-b", "corp.com")
assert len(await repo.list_smtp_targets("uuid-a")) == 1
assert len(await repo.list_smtp_targets("uuid-b")) == 1
@pytest.mark.anyio
async def test_list_orders_by_last_seen_desc(repo):
await repo.increment_smtp_target("uuid-1", "older.com")
await repo.increment_smtp_target("uuid-1", "newer.com")
rows = await repo.list_smtp_targets("uuid-1")
# Second call (newer.com) has a later last_seen → first row.
assert [r["domain"] for r in rows] == ["newer.com", "older.com"]
@pytest.mark.anyio
async def test_smtp_target_seen_aggregates_across_attackers(repo):
await repo.increment_smtp_target("uuid-a", "corp.com")
await repo.increment_smtp_target("uuid-a", "corp.com")
await repo.increment_smtp_target("uuid-b", "corp.com")
agg = await repo.smtp_target_seen("corp.com")
assert agg["seen"] is True
assert agg["count"] == 3 # 2 + 1
assert agg["first_seen"] is not None
assert agg["last_seen"] is not None
@pytest.mark.anyio
async def test_smtp_target_seen_unknown_domain(repo):
agg = await repo.smtp_target_seen("never-targeted.org")
assert agg["seen"] is False
assert agg["count"] == 0
assert agg["first_seen"] is None
assert agg["last_seen"] is None

458
tests/services/test_ssh.py Normal file
View File

@@ -0,0 +1,458 @@
"""
Tests for the SSHService plugin (real OpenSSH, Cowrie removed).
"""
from decnet.services.registry import all_services, get_service
from decnet.archetypes import get_archetype
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fragment(service_cfg: dict | None = None, log_target: str | None = None) -> dict:
return get_service("ssh").compose_fragment(
"test-decky", log_target=log_target, service_cfg=service_cfg
)
def _dockerfile_text() -> str:
return (get_service("ssh").dockerfile_context() / "Dockerfile").read_text()
def _entrypoint_text() -> str:
return (get_service("ssh").dockerfile_context() / "entrypoint.sh").read_text()
def _capture_script_path():
return get_service("ssh").dockerfile_context() / "capture.sh"
def _capture_text() -> str:
return _capture_script_path().read_text()
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def test_ssh_registered():
assert "ssh" in all_services()
def test_real_ssh_not_registered():
assert "real_ssh" not in all_services()
def test_ssh_ports():
assert get_service("ssh").ports == [22]
def test_ssh_is_build_service():
assert get_service("ssh").default_image == "build"
def test_ssh_dockerfile_context_exists():
svc = get_service("ssh")
ctx = svc.dockerfile_context()
assert ctx.is_dir(), f"Dockerfile context missing: {ctx}"
assert (ctx / "Dockerfile").exists()
assert (ctx / "entrypoint.sh").exists()
# ---------------------------------------------------------------------------
# No Cowrie env vars
# ---------------------------------------------------------------------------
def test_no_cowrie_vars():
"""The old Cowrie emulation is gone — no COWRIE_* env should leak in.
NODE_NAME is intentionally present: it pins the decky identifier used
by rsyslog (HOSTNAME field) and capture.sh (_hostname for file_captured
events), so the /artifacts/{decky}/... URL lines up with the bind mount.
"""
env = _fragment()["environment"]
cowrie_keys = [k for k in env if k.startswith("COWRIE_")]
assert cowrie_keys == [], f"Unexpected Cowrie vars: {cowrie_keys}"
def test_node_name_matches_decky():
"""SSH must propagate decky_name via NODE_NAME so logs/artifacts key on it."""
frag = _fragment()
assert frag["environment"]["NODE_NAME"] == "test-decky"
# ---------------------------------------------------------------------------
# compose_fragment structure
# ---------------------------------------------------------------------------
def test_fragment_has_build():
frag = _fragment()
assert "build" in frag and "context" in frag["build"]
def test_fragment_container_name():
assert _fragment()["container_name"] == "test-decky-ssh"
def test_fragment_restart_policy():
assert _fragment()["restart"] == "unless-stopped"
def test_fragment_cap_add():
assert "NET_BIND_SERVICE" in _fragment().get("cap_add", [])
def test_default_password():
assert _fragment()["environment"]["SSH_ROOT_PASSWORD"] == "admin"
def test_custom_password():
assert _fragment(service_cfg={"password": "h4x!"})["environment"]["SSH_ROOT_PASSWORD"] == "h4x!"
def test_custom_hostname():
assert _fragment(service_cfg={"hostname": "prod-db-01"})["environment"]["SSH_HOSTNAME"] == "prod-db-01"
def test_no_hostname_by_default():
assert "SSH_HOSTNAME" not in _fragment()["environment"]
def test_no_log_target_in_env():
assert "LOG_TARGET" not in _fragment(log_target="10.0.0.1:5140").get("environment", {})
# ---------------------------------------------------------------------------
# Logging pipeline wiring (Dockerfile + entrypoint)
# ---------------------------------------------------------------------------
def test_dockerfile_has_rsyslog():
assert "rsyslog" in _dockerfile_text()
def test_dockerfile_runs_as_root():
lines = [line.strip() for line in _dockerfile_text().splitlines()]
user_lines = [line for line in lines if line.startswith("USER ")]
assert user_lines == [], f"Unexpected USER directive(s): {user_lines}"
def test_dockerfile_rsyslog_conf_created():
df = _dockerfile_text()
assert "50-journal-forward.conf" in df
assert "RFC5424fmt" in df
def test_dockerfile_sudoers_syslog():
df = _dockerfile_text()
assert "syslog=auth" in df
assert "log_input" in df
assert "log_output" in df
def test_dockerfile_prompt_command_logger():
df = _dockerfile_text()
assert "PROMPT_COMMAND" in df
assert "logger" in df
def test_entrypoint_has_no_named_pipe():
# Named pipes in the container are a liability — readable and writable
# by any root process. The log bridge must not rely on one.
ep = _entrypoint_text()
assert "mkfifo" not in ep
assert "syslog-relay" not in ep
def test_entrypoint_has_no_relay_cat():
# No intermediate cat relay either (removed together with the pipe).
ep = _entrypoint_text()
assert "systemd-journal-fwd" not in ep
def test_dockerfile_rsyslog_targets_pid1_stdout():
df = _dockerfile_text()
# rsyslog writes straight to /proc/1/fd/1 — no pipe file on disk.
assert "/proc/1/fd/1" in df
assert "syslog-relay" not in df
assert "decnet-logs" not in df
def test_dockerfile_disables_rsyslog_privdrop():
# rsyslogd must stay root so it can write to PID 1's stdout fd.
# Dropping to the syslog user makes every auth/user line silently fail.
df = _dockerfile_text()
assert "#$PrivDropToUser" in df
assert "#$PrivDropToGroup" in df
def test_entrypoint_starts_rsyslogd():
assert "rsyslogd" in _entrypoint_text()
def test_entrypoint_sshd_no_dash_e():
ep = _entrypoint_text()
assert "sshd -D" in ep
assert "sshd -D -e" not in ep
# ---------------------------------------------------------------------------
# Deaddeck archetype
# ---------------------------------------------------------------------------
def test_deaddeck_uses_ssh():
arch = get_archetype("deaddeck")
assert "ssh" in arch.services
assert "real_ssh" not in arch.services
def test_deaddeck_nmap_os():
assert get_archetype("deaddeck").nmap_os == "linux"
def test_deaddeck_preferred_distros_not_empty():
assert len(get_archetype("deaddeck").preferred_distros) >= 1
# ---------------------------------------------------------------------------
# File-catcher: Dockerfile wiring
# ---------------------------------------------------------------------------
def test_dockerfile_installs_inotify_tools():
assert "inotify-tools" in _dockerfile_text()
def test_dockerfile_installs_attribution_tools():
df = _dockerfile_text()
for pkg in ("psmisc", "iproute2", "jq"):
assert pkg in df, f"missing {pkg} in Dockerfile"
def test_dockerfile_installs_default_recon_tools():
df = _dockerfile_text()
# Attacker-facing baseline: a lived-in box has these.
for pkg in ("iputils-ping", "ca-certificates", "nmap"):
assert pkg in df, f"missing {pkg} in Dockerfile"
def test_dockerfile_stages_capture_script_for_inlining():
df = _dockerfile_text()
# capture.sh is no longer COPY'd to a runtime path; it's staged under
# /tmp/build and folded into /entrypoint.sh as an XOR+gzip+base64 blob
# by _build_stealth.py, then the staging dir is wiped in the same layer.
assert "capture.sh" in df
assert "/tmp/build/" in df
assert "_build_stealth.py" in df
assert "rm -rf /tmp/build" in df
# The old visible install path must be gone.
assert "/usr/libexec/udev/journal-relay" not in df
def test_dockerfile_masks_inotifywait_as_kmsg_watch():
df = _dockerfile_text()
# Symlink so inotifywait invocations show as the plausible binary name.
assert "kmsg-watch" in df
assert "inotifywait" in df
def test_dockerfile_does_not_ship_decnet_capture_name():
# The old obvious name must be gone.
assert "decnet-capture" not in _dockerfile_text()
def test_dockerfile_creates_quarantine_dir():
df = _dockerfile_text()
# In-container path masquerades as the real systemd-coredump dir.
assert "/var/lib/systemd/coredump" in df
assert "chmod 700" in df
def test_dockerfile_ssh_loglevel_verbose():
assert "LogLevel VERBOSE" in _dockerfile_text()
def test_dockerfile_prompt_command_logs_ssh_client():
df = _dockerfile_text()
assert "PROMPT_COMMAND" in df
assert "SSH_CLIENT" in df
# ---------------------------------------------------------------------------
# File-catcher: capture.sh semantics
# ---------------------------------------------------------------------------
def test_capture_script_exists_and_executable():
import os
p = _capture_script_path()
assert p.exists(), f"capture.sh missing: {p}"
assert os.access(p, os.X_OK), "capture.sh must be executable"
def test_capture_script_uses_close_write_and_moved_to():
body = _capture_text()
assert "close_write" in body
assert "moved_to" in body
assert "inotifywait" in body
def test_capture_script_skips_quarantine_path():
body = _capture_text()
# Must not loop on its own writes — quarantine lives under /var/lib/systemd.
assert "/var/lib/systemd/" in body
def test_capture_script_resolves_writer_pid():
body = _capture_text()
assert "fuser" in body
# walks PPid to find sshd session leader
assert "PPid" in body
assert "/proc/" in body
def test_capture_script_snapshots_ss_and_utmp():
body = _capture_text()
assert "ss " in body or "ss -" in body
assert "who " in body or "who --" in body
def test_capture_script_no_longer_writes_sidecar():
body = _capture_text()
# The old .meta.json sidecar was replaced by a single syslog event that
# carries the same metadata — see emit_capture.py.
assert ".meta.json" not in body
def test_capture_script_pipes_to_emit_capture():
body = _capture_text()
# capture.sh builds the event JSON with jq and pipes to python3 reading
# from an fd that carries the in-memory emit_capture source; no on-disk
# emit_capture.py exists in the running container anymore.
assert "EMIT_CAPTURE_PY" in body
assert "python3" in body
assert "/opt/emit_capture.py" not in body
assert "file_captured" in body
for key in ("attribution", "sha256", "src_ip", "ssh_user", "writer_cmdline"):
assert key in body, f"capture field {key} missing from capture.sh"
def test_ssh_dockerfile_ships_capture_emitter():
df = _dockerfile_text()
# Python sources are staged for the build-time inlining step, not COPY'd
# to /opt (which would leave them world-readable for any attacker shell).
assert "syslog_bridge.py" in df
assert "emit_capture.py" in df
assert "/opt/emit_capture.py" not in df
assert "/opt/syslog_bridge.py" not in df
# python3 is needed to run the emitter; python3-minimal keeps the image small.
assert "python3" in df
def test_capture_script_enforces_size_cap():
body = _capture_text()
assert "CAPTURE_MAX_BYTES" in body
# ---------------------------------------------------------------------------
# File-catcher: entrypoint wiring
# ---------------------------------------------------------------------------
def test_entrypoint_starts_capture_watcher():
ep = _entrypoint_text()
# Invokes the udev-disguised path, not the old obvious name.
assert "journal-relay" in ep
assert "decnet-capture" not in ep
# Started before sshd so drops during first login are caught.
assert ep.index("journal-relay") < ep.index("exec /usr/sbin/sshd")
def test_capture_script_uses_masked_inotify_bin():
body = _capture_text()
assert "INOTIFY_BIN" in body
assert "kmsg-watch" in body
# ---------------------------------------------------------------------------
# argv_zap LD_PRELOAD shim (hides inotifywait args from ps)
# ---------------------------------------------------------------------------
def test_argv_zap_source_shipped():
ctx = get_service("ssh").dockerfile_context()
src = ctx / "argv_zap.c"
assert src.exists(), "argv_zap.c missing from SSH template context"
body = src.read_text()
assert "__libc_start_main" in body
assert "PR_SET_NAME" in body
def test_dockerfile_compiles_argv_zap():
df = _dockerfile_text()
assert "argv_zap.c" in df
# The installed .so is disguised as a multiarch udev-companion library
# (sits next to real libudev.so.1). The old argv_zap.so name was a tell.
assert "/usr/lib/x86_64-linux-gnu/libudev-shared.so.1" in df
assert "argv_zap.so" not in df
# gcc must be installed AND purged in the same layer (image-size hygiene).
assert "gcc" in df
assert "apt-get purge" in df
def test_capture_script_preloads_argv_zap():
body = _capture_text()
assert "LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libudev-shared.so.1" in body
assert "argv_zap.so" not in body
def test_capture_script_sets_argv_zap_comm():
body = _capture_text()
# Comm must mirror argv[0] for the inotify invocation.
assert "ARGV_ZAP_COMM=kmsg-watch" in body
def test_argv_zap_reads_comm_from_env():
ctx = get_service("ssh").dockerfile_context()
src = (ctx / "argv_zap.c").read_text()
assert "ARGV_ZAP_COMM" in src
assert "getenv" in src
def test_entrypoint_watcher_bash_uses_argv_zap():
ep = _entrypoint_text()
# The bash that runs the capture loop must be LD_PRELOADed so the
# (large) bash -c argument doesn't leak via /proc/PID/cmdline.
assert "LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libudev-shared.so.1" in ep
assert "ARGV_ZAP_COMM=journal-relay" in ep
assert "argv_zap.so" not in ep
def test_capture_script_header_is_sanitized():
body = _capture_text()
# Header should not betray the honeypot if an attacker `cat`s the file.
first_lines = "\n".join(body.splitlines()[:20])
assert "honeypot" not in first_lines.lower()
assert "attacker" not in first_lines.lower()
# ---------------------------------------------------------------------------
# File-catcher: compose_fragment volume
# ---------------------------------------------------------------------------
def test_fragment_mounts_quarantine_volume():
frag = _fragment()
vols = frag.get("volumes", [])
assert any(
v.endswith(":/var/lib/systemd/coredump:rw") for v in vols
), f"quarantine volume missing: {vols}"
def test_fragment_quarantine_host_path_layout():
vols = _fragment()["volumes"]
host = vols[0].split(":", 1)[0]
assert host == "/var/lib/decnet/artifacts/test-decky/ssh"
def test_fragment_quarantine_path_per_decky():
frag_a = get_service("ssh").compose_fragment("decky-01")
frag_b = get_service("ssh").compose_fragment("decky-02")
assert frag_a["volumes"] != frag_b["volumes"]
assert "decky-01" in frag_a["volumes"][0]
assert "decky-02" in frag_b["volumes"][0]

View File

@@ -0,0 +1,143 @@
"""
Round-trip tests for decnet/templates/ssh/emit_capture.py.
emit_capture reads a JSON event from stdin and writes one RFC 5424 line
to stdout. The collector's parse_rfc5424 must then recover the same
fields — flat ones as top-level SD params, bulky nested ones packed into
a single base64-encoded `meta_json_b64` SD param.
"""
from __future__ import annotations
import base64
import json
import subprocess
import sys
from pathlib import Path
import pytest
from decnet.collector.worker import parse_rfc5424
_TEMPLATE_DIR = Path(__file__).resolve().parent.parent.parent / "decnet" / "templates" / "ssh"
_EMIT_SCRIPT = _TEMPLATE_DIR / "emit_capture.py"
def _run_emit(event: dict) -> str:
"""Run emit_capture.py as a subprocess with `event` on stdin; return stdout."""
result = subprocess.run( # nosec B603 B607 — hardcoded args to test fixture
[sys.executable, str(_EMIT_SCRIPT)],
input=json.dumps(event),
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def _baseline_event() -> dict:
return {
"_hostname": "test-decky-01",
"_service": "ssh",
"_event_type": "file_captured",
"stored_as": "2026-04-18T02:22:56Z_abc123def456_payload.bin",
"sha256": "deadbeef" * 8,
"size": 4096,
"orig_path": "/root/payload.bin",
"src_ip": "198.51.100.7",
"src_port": "55342",
"ssh_user": "root",
"ssh_pid": "1234",
"attribution": "pid-chain",
"writer_pid": "1234",
"writer_comm": "scp",
"writer_uid": "0",
"mtime": "2026-04-18 02:22:56.000000000 +0000",
"writer_cmdline": "scp -t /root/payload.bin",
"writer_loginuid": "0",
"concurrent_sessions": [
{"user": "root", "tty": "pts/0", "login_at": "2026-04-18 02:22", "src_ip": "198.51.100.7"}
],
"ss_snapshot": [
{"pid": 1234, "src_ip": "198.51.100.7", "src_port": 55342}
],
}
def test_emit_script_exists():
assert _EMIT_SCRIPT.exists(), f"emit_capture.py missing: {_EMIT_SCRIPT}"
def test_emit_produces_parseable_rfc5424_line():
line = _run_emit(_baseline_event())
assert line.startswith("<"), f"expected <PRI>, got: {line[:20]!r}"
parsed = parse_rfc5424(line)
assert parsed is not None, f"collector could not parse: {line}"
def test_flat_fields_land_as_sd_params():
event = _baseline_event()
line = _run_emit(event)
parsed = parse_rfc5424(line)
assert parsed is not None
fields = parsed["fields"]
for key in ("stored_as", "sha256", "size", "orig_path", "src_ip",
"ssh_user", "attribution", "writer_pid", "writer_comm"):
assert fields.get(key) == str(event[key]), f"mismatch on {key}: {fields.get(key)!r} vs {event[key]!r}"
def test_event_type_and_service_propagate():
line = _run_emit(_baseline_event())
parsed = parse_rfc5424(line)
assert parsed["service"] == "ssh"
assert parsed["event_type"] == "file_captured"
assert parsed["decky"] == "test-decky-01"
# src_ip should populate attacker_ip via the collector's _IP_FIELDS lookup.
assert parsed["attacker_ip"] == "198.51.100.7"
def test_meta_json_b64_roundtrips():
event = _baseline_event()
line = _run_emit(event)
parsed = parse_rfc5424(line)
b64 = parsed["fields"].get("meta_json_b64")
assert b64, "meta_json_b64 missing from SD params"
decoded = json.loads(base64.b64decode(b64).decode("utf-8"))
assert decoded["writer_cmdline"] == event["writer_cmdline"]
assert decoded["writer_loginuid"] == event["writer_loginuid"]
assert decoded["concurrent_sessions"] == event["concurrent_sessions"]
assert decoded["ss_snapshot"] == event["ss_snapshot"]
def test_meta_survives_awkward_characters():
"""Payload filenames and cmdlines can contain `]`, `"`, `\\` — all of
which must round-trip via the base64 packing even though the raw SD
format can't handle them."""
event = _baseline_event()
event["writer_cmdline"] = 'sh -c "echo ] \\"evil\\" > /tmp/x"'
event["concurrent_sessions"] = [{"note": 'has ] and " and \\ chars'}]
line = _run_emit(event)
parsed = parse_rfc5424(line)
assert parsed is not None
b64 = parsed["fields"].get("meta_json_b64")
decoded = json.loads(base64.b64decode(b64).decode("utf-8"))
assert decoded["writer_cmdline"] == event["writer_cmdline"]
assert decoded["concurrent_sessions"] == event["concurrent_sessions"]
def test_empty_stdin_exits_nonzero():
result = subprocess.run( # nosec B603 B607
[sys.executable, str(_EMIT_SCRIPT)],
input="",
capture_output=True,
text=True,
)
assert result.returncode != 0
def test_no_sidecar_path_referenced():
"""emit_capture must never touch the filesystem — no meta.json, no
CAPTURE_DIR writes. Proved by static source inspection."""
src = _EMIT_SCRIPT.read_text()
assert ".meta.json" not in src
assert "open(" not in src # stdin/stdout only

View File

@@ -0,0 +1,143 @@
"""
Stealth-hardening assertions for the SSH honeypot template.
The three capture artifacts — syslog_bridge.py, emit_capture.py, capture.sh —
used to land as plaintext files in the container (world-readable by the
attacker, who is root in-container). They are now packed into /entrypoint.sh
as XOR+gzip+base64 blobs at image-build time by _build_stealth.py.
These tests pin the stealth contract at the source-template level so
regressions surface without needing a docker build.
"""
from __future__ import annotations
import base64
import gzip
import importlib.util
import sys
from pathlib import Path
from decnet.services.registry import get_service
def _ctx() -> Path:
return get_service("ssh").dockerfile_context()
def _load_build_stealth():
path = _ctx() / "_build_stealth.py"
spec = importlib.util.spec_from_file_location("_build_stealth", path)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod
# ---------------------------------------------------------------------------
# Build helper exists and is wired into the Dockerfile
# ---------------------------------------------------------------------------
def test_build_stealth_helper_shipped():
helper = _ctx() / "_build_stealth.py"
assert helper.exists(), "_build_stealth.py missing from SSH template"
body = helper.read_text()
assert "__STEALTH_KEY__" in body
assert "__EMIT_CAPTURE_B64__" in body
assert "__JOURNAL_RELAY_B64__" in body
def test_dockerfile_invokes_build_stealth():
df = (_ctx() / "Dockerfile").read_text()
assert "_build_stealth.py" in df
assert "python3 /tmp/build/_build_stealth.py" in df
# ---------------------------------------------------------------------------
# Entrypoint template shape
# ---------------------------------------------------------------------------
def test_entrypoint_is_template_with_placeholders():
ep = (_ctx() / "entrypoint.sh").read_text()
# Pre-build template — placeholders must be present; the Docker build
# stage substitutes them.
assert "__STEALTH_KEY__" in ep
assert "__EMIT_CAPTURE_B64__" in ep
assert "__JOURNAL_RELAY_B64__" in ep
def test_entrypoint_decodes_via_xor():
ep = (_ctx() / "entrypoint.sh").read_text()
# XOR-then-gunzip layering: base64 -> xor -> gunzip
assert "base64 -d" in ep
assert "gunzip" in ep
# The decoded vars drive the capture loop.
assert "EMIT_CAPTURE_PY" in ep
assert "export EMIT_CAPTURE_PY" in ep
def test_entrypoint_no_plaintext_python_path():
ep = (_ctx() / "entrypoint.sh").read_text()
assert "/opt/emit_capture.py" not in ep
assert "/opt/syslog_bridge.py" not in ep
assert "/usr/libexec/udev/journal-relay" not in ep
# ---------------------------------------------------------------------------
# End-to-end: pack + round-trip
# ---------------------------------------------------------------------------
def test_build_stealth_merge_and_pack_roundtrip(tmp_path, monkeypatch):
"""Merge the real sources, pack them, and decode — assert semantic equality."""
mod = _load_build_stealth()
build = tmp_path / "build"
build.mkdir()
ctx = _ctx()
for name in ("syslog_bridge.py", "emit_capture.py", "capture.sh", "entrypoint.sh"):
(build / name).write_text((ctx / name).read_text())
monkeypatch.setattr(mod, "BUILD", build)
out_dir = tmp_path / "out"
out_dir.mkdir()
# Redirect the write target so we don't touch /entrypoint.sh.
import pathlib
real_path = pathlib.Path
def fake_path(arg, *a, **kw):
if arg == "/entrypoint.sh":
return real_path(out_dir) / "entrypoint.sh"
return real_path(arg, *a, **kw)
monkeypatch.setattr(mod, "Path", fake_path)
rc = mod.main()
assert rc == 0
rendered = (out_dir / "entrypoint.sh").read_text()
for marker in ("__STEALTH_KEY__", "__EMIT_CAPTURE_B64__", "__JOURNAL_RELAY_B64__"):
assert marker not in rendered, f"{marker} left in rendered entrypoint"
# Extract key + blobs and decode.
import re
key = int(re.search(r"_STEALTH_KEY=(\d+)", rendered).group(1))
emit_b64 = re.search(r"_EMIT_CAPTURE_B64='([^']+)'", rendered).group(1)
relay_b64 = re.search(r"_JOURNAL_RELAY_B64='([^']+)'", rendered).group(1)
def unpack(s: str) -> str:
xored = base64.b64decode(s)
gz = bytes(b ^ key for b in xored)
return gzip.decompress(gz).decode("utf-8")
emit_src = unpack(emit_b64)
relay_src = unpack(relay_b64)
# Merged python must contain both module bodies, with the import hack stripped.
assert "def syslog_line(" in emit_src
assert "def main() -> int:" in emit_src
assert "from syslog_bridge import" not in emit_src
assert "sys.path.insert" not in emit_src
# Capture loop must reference the in-memory python var, not the old path.
assert "EMIT_CAPTURE_PY" in relay_src
assert "/opt/emit_capture.py" not in relay_src
assert "inotifywait" in relay_src or "INOTIFY_BIN" in relay_src