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:
0
tests/services/__init__.py
Normal file
0
tests/services/__init__.py
Normal file
64
tests/services/test_custom_service.py
Normal file
64
tests/services/test_custom_service.py
Normal 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
|
||||
503
tests/services/test_service_isolation.py
Normal file
503
tests/services/test_service_isolation.py
Normal 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]
|
||||
363
tests/services/test_services.py
Normal file
363
tests/services/test_services.py
Normal 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)
|
||||
42
tests/services/test_smtp_relay.py
Normal file
42
tests/services/test_smtp_relay.py
Normal 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"
|
||||
160
tests/services/test_smtp_targets.py
Normal file
160
tests/services/test_smtp_targets.py
Normal 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
458
tests/services/test_ssh.py
Normal 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]
|
||||
143
tests/services/test_ssh_capture_emit.py
Normal file
143
tests/services/test_ssh_capture_emit.py
Normal 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
|
||||
143
tests/services/test_ssh_stealth.py
Normal file
143
tests/services/test_ssh_stealth.py
Normal 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
|
||||
Reference in New Issue
Block a user