merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/services/__init__.py
Normal file
0
tests/services/__init__.py
Normal file
447
tests/services/test_cred_emitters.py
Normal file
447
tests/services/test_cred_emitters.py
Normal file
@@ -0,0 +1,447 @@
|
||||
"""Per-service credential-emitter integration tests.
|
||||
|
||||
Each test simulates the SD-block a migrated emitter produces, hands it
|
||||
to the ingester, and asserts the resulting Credential row carries the
|
||||
universal shape (principal + secret_sha256 + secret_b64 + outcome).
|
||||
|
||||
Closes the silent-loss bug for Redis (no username) and LDAP (dn-keyed)
|
||||
by exercising the full ingester native-shape path for each.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _native_log(service: str, *, principal: str | None, password: str,
|
||||
outcome: str | None = None, extra: dict | None = None) -> dict:
|
||||
"""Build a parsed-log dict in the shape `_extract_bounty` consumes,
|
||||
matching what a migrated emitter writes to the wire."""
|
||||
raw = password.encode("utf-8", errors="replace")
|
||||
fields: dict[str, str] = {
|
||||
"secret_b64": base64.b64encode(raw).decode("ascii"),
|
||||
"secret_printable": "".join(
|
||||
chr(b) if 0x20 <= b < 0x7f else "?" for b in raw
|
||||
),
|
||||
}
|
||||
if principal is not None:
|
||||
fields["principal"] = principal
|
||||
if outcome is not None:
|
||||
fields["outcome"] = outcome
|
||||
if extra:
|
||||
fields.update(extra)
|
||||
return {
|
||||
"decky": "decky-01",
|
||||
"service": service,
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": fields,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ftp_native_shape():
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
await _extract_bounty(repo, _native_log(
|
||||
"ftp", principal="anonymous", password="test@example.com",
|
||||
))
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "ftp"
|
||||
assert cred["principal"] == "anonymous"
|
||||
assert cred["secret_sha256"] == hashlib.sha256(b"test@example.com").hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pop3_outcome_mapped():
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
await _extract_bounty(repo, _native_log(
|
||||
"pop3", principal="alice", password="hunter2", outcome="failure",
|
||||
))
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "pop3"
|
||||
assert cred["outcome"] == "failure"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_imap_native_shape():
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
await _extract_bounty(repo, _native_log(
|
||||
"imap", principal="bob", password="letmein", outcome="success",
|
||||
))
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["principal"] == "bob"
|
||||
assert cred["outcome"] == "success"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_smtp_auth_native_shape():
|
||||
"""SMTP AUTH PLAIN/LOGIN — principal=SASL username."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
await _extract_bounty(repo, _native_log(
|
||||
"smtp", principal="postmaster@acme.com", password="abc123",
|
||||
))
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "smtp"
|
||||
assert cred["principal"] == "postmaster@acme.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_smtp_mail_from_is_not_a_credential():
|
||||
"""`event_type=mail_from` must NOT trigger a credential write —
|
||||
even if the SD-block carries a `domain` field, no `secret_b64`
|
||||
means the native branch never fires and the legacy branch needs
|
||||
a `password` it'll never see for this event."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
repo.add_bounty = AsyncMock()
|
||||
log_data = {
|
||||
"decky": "decky-01",
|
||||
"service": "smtp",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {
|
||||
"value": "<spoof@evil.com>",
|
||||
"mail_from": "spoof@evil.com",
|
||||
"domain": "evil.com",
|
||||
},
|
||||
}
|
||||
await _extract_bounty(repo, log_data)
|
||||
repo.upsert_credential.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_principal_none_lands():
|
||||
"""Redis legacy AUTH `<password>` — no username, principal stays
|
||||
None. This was silently dropped by the legacy adapter pre-migration."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
await _extract_bounty(repo, _native_log(
|
||||
"redis", principal=None, password="hunter2",
|
||||
))
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "redis"
|
||||
assert cred["principal"] is None
|
||||
assert cred["secret_sha256"] == hashlib.sha256(b"hunter2").hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_acl_two_arg_principal_present():
|
||||
"""Redis 6+ `AUTH <user> <pw>` — principal carries the ACL user."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
await _extract_bounty(repo, _native_log(
|
||||
"redis", principal="default", password="hunter2",
|
||||
))
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["principal"] == "default"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ldap_principal_is_dn():
|
||||
"""LDAP bind — the DN itself is the principal."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
await _extract_bounty(repo, _native_log(
|
||||
"ldap", principal="cn=admin,dc=acme,dc=com", password="rootpw",
|
||||
))
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "ldap"
|
||||
assert cred["principal"] == "cn=admin,dc=acme,dc=com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_native_shape():
|
||||
"""MQTT CONNECT — username + password decoded from the wire,
|
||||
emitted as principal + secret_b64. Was silently dropped between
|
||||
Phase 3 (legacy adapter removed) and the MQTT migration commit."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
await _extract_bounty(repo, _native_log(
|
||||
"mqtt", principal="iotuser", password="iotpass",
|
||||
))
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "mqtt"
|
||||
assert cred["principal"] == "iotuser"
|
||||
assert cred["secret_sha256"] == hashlib.sha256(b"iotpass").hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgres_hash_credential():
|
||||
"""Postgres MD5 challenge-response — plaintext irrecoverable, lands
|
||||
as secret_kind=postgres_md5_challenge with the raw hash bytes."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
pw_hash = "md5" + "ab" * 16 # 32 hex chars after the "md5" prefix
|
||||
raw = bytes.fromhex("ab" * 16)
|
||||
log_data = {
|
||||
"decky": "decky-01",
|
||||
"service": "postgres",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {
|
||||
"username": "postgres",
|
||||
"principal": "postgres",
|
||||
"pw_hash": pw_hash,
|
||||
"secret_kind": "postgres_md5_challenge",
|
||||
"secret_printable": pw_hash,
|
||||
"secret_b64": base64.b64encode(raw).decode("ascii"),
|
||||
},
|
||||
}
|
||||
await _extract_bounty(repo, log_data)
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "postgres"
|
||||
assert cred["secret_kind"] == "postgres_md5_challenge"
|
||||
assert cred["secret_sha256"] == hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vnc_hash_credential():
|
||||
"""VNC DES-encrypted challenge response — same shape, different kind."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
raw = bytes(range(16))
|
||||
log_data = {
|
||||
"decky": "decky-01",
|
||||
"service": "vnc",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {
|
||||
"response": raw.hex(),
|
||||
"secret_kind": "vnc_des_response",
|
||||
"secret_printable": raw.hex(),
|
||||
"secret_b64": base64.b64encode(raw).decode("ascii"),
|
||||
},
|
||||
}
|
||||
await _extract_bounty(repo, log_data)
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "vnc"
|
||||
assert cred["secret_kind"] == "vnc_des_response"
|
||||
assert cred["secret_sha256"] == hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snmp_community_native_shape():
|
||||
"""SNMP v1/v2c community string lands as secret_kind=snmp_community,
|
||||
principal=None (no per-user identity in v1/v2c)."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
raw = b"public"
|
||||
log_data = {
|
||||
"decky": "decky-01",
|
||||
"service": "snmp",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {
|
||||
"version": 1,
|
||||
"community": "public",
|
||||
"secret_kind": "snmp_community",
|
||||
"secret_printable": "public",
|
||||
"secret_b64": base64.b64encode(raw).decode("ascii"),
|
||||
},
|
||||
}
|
||||
await _extract_bounty(repo, log_data)
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "snmp"
|
||||
assert cred["secret_kind"] == "snmp_community"
|
||||
assert cred["principal"] is None
|
||||
assert cred["secret_sha256"] == hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_basic_native_shape():
|
||||
"""HTTP Basic via classify_authorization → principal+plaintext."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
log_data = {
|
||||
"decky": "decky-01",
|
||||
"service": "http",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {
|
||||
"method": "GET",
|
||||
"path": "/admin",
|
||||
"principal": "admin",
|
||||
"secret_kind": "plaintext",
|
||||
"secret_printable": "hunter2",
|
||||
"secret_b64": base64.b64encode(b"hunter2").decode("ascii"),
|
||||
},
|
||||
}
|
||||
await _extract_bounty(repo, log_data)
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "http"
|
||||
assert cred["principal"] == "admin"
|
||||
assert cred["secret_kind"] == "plaintext"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_bearer_native_shape():
|
||||
"""HTTP Bearer — principal=None, secret_kind=http_bearer, opaque."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
token = b"eyJhbGciOiJIUzI1NiJ9.foo.bar"
|
||||
log_data = {
|
||||
"decky": "decky-01",
|
||||
"service": "k8s",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {
|
||||
"method": "GET",
|
||||
"path": "/api/v1/secrets",
|
||||
"principal": None,
|
||||
"secret_kind": "http_bearer",
|
||||
"secret_printable": token.decode(),
|
||||
"secret_b64": base64.b64encode(token).decode("ascii"),
|
||||
},
|
||||
}
|
||||
await _extract_bounty(repo, log_data)
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["secret_kind"] == "http_bearer"
|
||||
assert cred["principal"] is None
|
||||
assert cred["secret_sha256"] == hashlib.sha256(token).hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sip_digest_native_shape():
|
||||
"""SIP Digest via classify_authorization → response hash captured."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
response_hash = "d41d8cd98f00b204e9800998ecf8427e"
|
||||
log_data = {
|
||||
"decky": "decky-01",
|
||||
"service": "sip",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {
|
||||
"method": "REGISTER",
|
||||
"principal": "alice",
|
||||
"secret_kind": "http_digest_md5",
|
||||
"secret_printable": response_hash,
|
||||
"secret_b64": base64.b64encode(response_hash.encode()).decode("ascii"),
|
||||
},
|
||||
}
|
||||
await _extract_bounty(repo, log_data)
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "sip"
|
||||
assert cred["secret_kind"] == "http_digest_md5"
|
||||
assert cred["principal"] == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mysql_native_password_hash():
|
||||
"""MySQL handshake auth-response: 20-byte sha1 chain hash. Plaintext
|
||||
irrecoverable; lands as secret_kind=mysql_native_password."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
raw = bytes(range(20)) # arbitrary 20-byte "hash"
|
||||
log_data = {
|
||||
"decky": "decky-01",
|
||||
"service": "mysql",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {
|
||||
"username": "root",
|
||||
"principal": "root",
|
||||
"secret_kind": "mysql_native_password",
|
||||
"secret_printable": raw.hex(),
|
||||
"secret_b64": base64.b64encode(raw).decode("ascii"),
|
||||
},
|
||||
}
|
||||
await _extract_bounty(repo, log_data)
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "mysql"
|
||||
assert cred["secret_kind"] == "mysql_native_password"
|
||||
assert cred["principal"] == "root"
|
||||
assert cred["secret_sha256"] == hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mssql_login7_plaintext():
|
||||
"""MSSQL Login7 password is XOR/nibble-obfuscated but plaintext-
|
||||
recoverable. Lands as secret_kind=plaintext after deobfuscation."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
log_data = {
|
||||
"decky": "decky-01",
|
||||
"service": "mssql",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {
|
||||
"username": "sa",
|
||||
"principal": "sa",
|
||||
"secret_kind": "plaintext",
|
||||
"secret_printable": "hunter2",
|
||||
"secret_b64": base64.b64encode(b"hunter2").decode("ascii"),
|
||||
},
|
||||
}
|
||||
await _extract_bounty(repo, log_data)
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "mssql"
|
||||
assert cred["principal"] == "sa"
|
||||
assert cred["secret_printable"] == "hunter2"
|
||||
|
||||
|
||||
def test_mssql_deobfuscate_roundtrip():
|
||||
"""Direct unit test of the MSSQL Login7 deobfuscation against a
|
||||
handcrafted obfuscated buffer. Exercises the algorithm itself."""
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock
|
||||
# Stand up a fake syslog_bridge so the template imports cleanly,
|
||||
# then load the mssql module and test the static helper.
|
||||
fake = ModuleType("syslog_bridge")
|
||||
fake.syslog_line = MagicMock(return_value="")
|
||||
fake.write_syslog_file = MagicMock()
|
||||
fake.forward_syslog = MagicMock()
|
||||
fake.SEVERITY_INFO = 6
|
||||
fake.SEVERITY_WARNING = 4
|
||||
fake.encode_secret = MagicMock(return_value={"secret_printable": "", "secret_b64": ""})
|
||||
fake.classify_authorization = MagicMock(return_value=None)
|
||||
sys.modules["syslog_bridge"] = fake
|
||||
# Load the real instance_seed so the mssql module's top-level
|
||||
# _seed.pick(...) tuple-unpack works. MagicMock returns sentinels
|
||||
# that don't satisfy iterable unpacking.
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
if "instance_seed" not in sys.modules:
|
||||
seed_spec = importlib.util.spec_from_file_location(
|
||||
"instance_seed", repo_root / "decnet" / "templates" / "instance_seed.py"
|
||||
)
|
||||
seed_mod = importlib.util.module_from_spec(seed_spec)
|
||||
seed_spec.loader.exec_module(seed_mod)
|
||||
sys.modules["instance_seed"] = seed_mod
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_mssql_under_test", repo_root / "decnet" / "templates" / "mssql" / "server.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
# Build the obfuscated form of "abc": each byte → swap nibbles, XOR 0xa5.
|
||||
plain = "abc".encode("utf-16-le") # 6 bytes
|
||||
obfuscated = bytes(
|
||||
(((b & 0x0f) << 4) | ((b & 0xf0) >> 4)) ^ 0xa5
|
||||
for b in plain
|
||||
)
|
||||
decoded = mod.MSSQLProtocol._deobfuscate_login7_password(obfuscated)
|
||||
assert decoded == "abc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lossless_b64_survives_nonprintable_password():
|
||||
"""Even when secret_printable is sanitized, secret_b64 still decodes
|
||||
to the original bytes — the cross-service reuse hash matches across
|
||||
sanitized and non-sanitized representations."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
raw = b"\x1b[31mbad\xff\x00trail"
|
||||
repo = MagicMock(); repo.upsert_credential = AsyncMock()
|
||||
log_data = {
|
||||
"decky": "decky-01",
|
||||
"service": "ftp",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {
|
||||
"principal": "user",
|
||||
"secret_printable": "?[31mbad??trail",
|
||||
"secret_b64": base64.b64encode(raw).decode("ascii"),
|
||||
},
|
||||
}
|
||||
await _extract_bounty(repo, log_data)
|
||||
cred = repo.upsert_credential.call_args[0][0]
|
||||
assert base64.b64decode(cred["secret_b64"]) == raw
|
||||
assert cred["secret_sha256"] == hashlib.sha256(raw).hexdigest()
|
||||
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
|
||||
210
tests/services/test_mongodb_scram.py
Normal file
210
tests/services/test_mongodb_scram.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""MongoDB SCRAM credential capture tests.
|
||||
|
||||
Exercises the inline BSON walker + SCRAM extractor by handcrafting
|
||||
saslStart / saslContinue OP_MSG packets and feeding them to the
|
||||
MongoDBProtocol's data_received(). Asserts that the resulting _log
|
||||
calls carry the universal credential SD shape.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _load_mongodb():
|
||||
"""Stand up the mongodb template module with stub deps so the test
|
||||
can poke its protocol directly."""
|
||||
fake = ModuleType("syslog_bridge")
|
||||
fake.syslog_line = MagicMock(return_value="")
|
||||
fake.write_syslog_file = MagicMock()
|
||||
fake.forward_syslog = MagicMock()
|
||||
fake.SEVERITY_INFO = 6
|
||||
fake.SEVERITY_WARNING = 4
|
||||
fake.encode_secret = MagicMock(
|
||||
return_value={"secret_printable": "", "secret_b64": ""}
|
||||
)
|
||||
fake.classify_authorization = MagicMock(return_value=None)
|
||||
sys.modules["syslog_bridge"] = fake
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
if "instance_seed" not in sys.modules:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"instance_seed", repo_root / "decnet" / "templates" / "instance_seed.py"
|
||||
)
|
||||
seed_mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(seed_mod)
|
||||
sys.modules["instance_seed"] = seed_mod
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_mongodb_under_test",
|
||||
repo_root / "decnet" / "templates" / "mongodb" / "server.py",
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ── BSON encoding helpers (test-only) ────────────────────────────────────────
|
||||
|
||||
def _bson_str(key: str, val: str) -> bytes:
|
||||
k = key.encode() + b"\x00"
|
||||
v = val.encode() + b"\x00"
|
||||
return b"\x02" + k + struct.pack("<i", len(v)) + v
|
||||
|
||||
|
||||
def _bson_int32(key: str, val: int) -> bytes:
|
||||
return b"\x10" + key.encode() + b"\x00" + struct.pack("<i", val)
|
||||
|
||||
|
||||
def _bson_binary(key: str, val: bytes) -> bytes:
|
||||
return (
|
||||
b"\x05" + key.encode() + b"\x00"
|
||||
+ struct.pack("<i", len(val))
|
||||
+ b"\x00" # subtype 0 = generic
|
||||
+ val
|
||||
)
|
||||
|
||||
|
||||
def _bson_doc(*fields: bytes) -> bytes:
|
||||
body = b"".join(fields) + b"\x00"
|
||||
return struct.pack("<i", len(body) + 4) + body
|
||||
|
||||
|
||||
def _op_msg(request_id: int, doc: bytes) -> bytes:
|
||||
body = b"\x00\x00\x00\x00" + b"\x00" + doc # flags + kind=0 + body doc
|
||||
return struct.pack("<iiii", 16 + len(body), request_id, 0, 2013) + body
|
||||
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_bson_walker_basic_types():
|
||||
mod = _load_mongodb()
|
||||
doc = _bson_doc(
|
||||
_bson_str("greeting", "hello"),
|
||||
_bson_int32("answer", 42),
|
||||
_bson_binary("blob", b"\x00\x01\xff"),
|
||||
)
|
||||
parsed = mod._bson_read(doc)
|
||||
assert parsed["greeting"] == "hello"
|
||||
assert parsed["answer"] == 42
|
||||
assert parsed["blob"] == b"\x00\x01\xff"
|
||||
|
||||
|
||||
def test_bson_walker_malformed_safe():
|
||||
mod = _load_mongodb()
|
||||
# Garbage bytes — must not raise or loop.
|
||||
assert mod._bson_read(b"\x05\x00\x00\x00\x00") == {} # 5-byte empty doc
|
||||
assert mod._bson_read(b"\x00" * 4) == {} # too short
|
||||
assert mod._bson_read(b"\xff" * 64) == {} # invalid length
|
||||
|
||||
|
||||
def test_scram_kv_strips_gs2_header():
|
||||
mod = _load_mongodb()
|
||||
payload = b"n,,n=alice,r=clientNonce123"
|
||||
parsed = mod._scram_kv(payload)
|
||||
assert parsed["n"] == "alice"
|
||||
assert parsed["r"] == "clientNonce123"
|
||||
|
||||
|
||||
def test_sasl_start_pins_username():
|
||||
"""saslStart sets per-connection username + mechanism state for the
|
||||
subsequent saslContinue to inherit."""
|
||||
mod = _load_mongodb()
|
||||
proto = mod.MongoDBProtocol()
|
||||
proto._transport = MagicMock()
|
||||
proto._peer = ("10.0.0.5", 51234)
|
||||
|
||||
payload = b"n,,n=alice,r=cnonce"
|
||||
cmd = _bson_doc(
|
||||
_bson_int32("saslStart", 1),
|
||||
_bson_str("$db", "admin"),
|
||||
_bson_str("mechanism", "SCRAM-SHA-256"),
|
||||
_bson_binary("payload", payload),
|
||||
)
|
||||
pkt = _op_msg(request_id=1, doc=cmd)
|
||||
proto.data_received(pkt)
|
||||
|
||||
assert proto._sasl_username == "alice"
|
||||
assert proto._sasl_mechanism == "SCRAM-SHA-256"
|
||||
|
||||
|
||||
def _capture_log(mod):
|
||||
"""Replace mod._log with a list-collector; returns (captured, restore)."""
|
||||
captured: list = []
|
||||
orig = mod._log
|
||||
mod._log = lambda et, severity=6, **kw: captured.append((et, kw))
|
||||
return captured, lambda: setattr(mod, "_log", orig)
|
||||
|
||||
|
||||
def test_sasl_continue_emits_cred():
|
||||
"""saslContinue → emits a _log call with secret_kind="scram_sha256"
|
||||
and secret_b64 = b64(decoded_proof). The _sasl_username pinned in
|
||||
the prior saslStart attaches as principal."""
|
||||
mod = _load_mongodb()
|
||||
proto = mod.MongoDBProtocol()
|
||||
proto._transport = MagicMock()
|
||||
proto._peer = ("10.0.0.5", 51234)
|
||||
proto._sasl_username = "alice"
|
||||
proto._sasl_mechanism = "SCRAM-SHA-256"
|
||||
|
||||
proof = b"\xab" * 32
|
||||
proof_b64 = base64.b64encode(proof).decode("ascii")
|
||||
final_payload = f"c=biws,r=combined,p={proof_b64}".encode()
|
||||
cmd = _bson_doc(
|
||||
_bson_int32("saslContinue", 1),
|
||||
_bson_str("$db", "admin"),
|
||||
_bson_int32("conversationId", 1),
|
||||
_bson_binary("payload", final_payload),
|
||||
)
|
||||
pkt = _op_msg(request_id=2, doc=cmd)
|
||||
|
||||
captured, restore = _capture_log(mod)
|
||||
try:
|
||||
proto.data_received(pkt)
|
||||
finally:
|
||||
restore()
|
||||
|
||||
auth_events = [e for e in captured if e[0] == "auth"]
|
||||
assert len(auth_events) == 1
|
||||
fields = auth_events[0][1]
|
||||
assert fields["secret_kind"] == "scram_sha256"
|
||||
assert fields["principal"] == "alice"
|
||||
assert fields["username"] == "alice"
|
||||
assert base64.b64decode(fields["secret_b64"]) == proof
|
||||
|
||||
|
||||
def test_sasl_continue_unknown_mechanism():
|
||||
"""When mechanism doesn't advertise SHA-{1,256}, fall back to
|
||||
scram_unknown so the row still lands."""
|
||||
mod = _load_mongodb()
|
||||
proto = mod.MongoDBProtocol()
|
||||
proto._transport = MagicMock()
|
||||
proto._peer = ("10.0.0.5", 0)
|
||||
proto._sasl_username = "bob"
|
||||
proto._sasl_mechanism = "PLAIN"
|
||||
|
||||
final_payload = (
|
||||
b"c=biws,r=x,p="
|
||||
+ base64.b64encode(b"proof").decode("ascii").encode()
|
||||
)
|
||||
cmd = _bson_doc(
|
||||
_bson_int32("saslContinue", 1),
|
||||
_bson_str("$db", "admin"),
|
||||
_bson_binary("payload", final_payload),
|
||||
)
|
||||
pkt = _op_msg(request_id=3, doc=cmd)
|
||||
|
||||
captured, restore = _capture_log(mod)
|
||||
try:
|
||||
proto.data_received(pkt)
|
||||
finally:
|
||||
restore()
|
||||
|
||||
auth = [e for e in captured if e[0] == "auth"]
|
||||
assert len(auth) == 1
|
||||
assert auth[0][1]["secret_kind"] == "scram_unknown"
|
||||
154
tests/services/test_ntlmssp_parser.py
Normal file
154
tests/services/test_ntlmssp_parser.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""NTLMSSP Type 3 parser tests.
|
||||
|
||||
Builds Type 3 buffers field-by-field per MS-NLMP §2.2.1.3 and asserts
|
||||
the parser returns the universal Credential SD shape. Shared
|
||||
infrastructure for SMB and RDP-NLA cred capture.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_ntlmssp():
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
path = repo / "decnet" / "templates" / "_shared" / "ntlmssp.py"
|
||||
spec = importlib.util.spec_from_file_location("_ntlmssp_under_test", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ntlmssp():
|
||||
return _load_ntlmssp()
|
||||
|
||||
|
||||
def _build_type3(
|
||||
*,
|
||||
username: str,
|
||||
domain: str,
|
||||
nt_response: bytes,
|
||||
unicode: bool = True,
|
||||
) -> bytes:
|
||||
"""Build a syntactically-valid NTLMSSP Type 3 message."""
|
||||
if unicode:
|
||||
u = username.encode("utf-16-le")
|
||||
d = domain.encode("utf-16-le")
|
||||
flags = 0x00000001 # NEGOTIATE_UNICODE
|
||||
else:
|
||||
u = username.encode("ascii")
|
||||
d = domain.encode("ascii")
|
||||
flags = 0x00000000
|
||||
|
||||
# Layout: 8 sig + 4 type + 6×8 field records + 4 flags = 64 bytes
|
||||
# of header, then payload (concat of nt_response, domain, username).
|
||||
header_size = 64
|
||||
nt_off = header_size
|
||||
dom_off = nt_off + len(nt_response)
|
||||
user_off = dom_off + len(d)
|
||||
|
||||
hdr = bytearray(header_size)
|
||||
hdr[0:8] = b"NTLMSSP\x00"
|
||||
struct.pack_into("<I", hdr, 8, 3) # message type 3
|
||||
# LmChallengeResponse (unused — empty)
|
||||
struct.pack_into("<HHI", hdr, 12, 0, 0, 0)
|
||||
# NtChallengeResponse
|
||||
struct.pack_into("<HHI", hdr, 20, len(nt_response), len(nt_response), nt_off)
|
||||
# DomainName
|
||||
struct.pack_into("<HHI", hdr, 28, len(d), len(d), dom_off)
|
||||
# UserName
|
||||
struct.pack_into("<HHI", hdr, 36, len(u), len(u), user_off)
|
||||
# Workstation (unused)
|
||||
struct.pack_into("<HHI", hdr, 44, 0, 0, 0)
|
||||
# EncryptedRandomSessionKey (unused)
|
||||
struct.pack_into("<HHI", hdr, 52, 0, 0, 0)
|
||||
# NegotiateFlags
|
||||
struct.pack_into("<I", hdr, 60, flags)
|
||||
|
||||
return bytes(hdr) + nt_response + d + u
|
||||
|
||||
|
||||
def test_parse_type3_ntlmv2(ntlmssp):
|
||||
"""NTLMv2 NTChallengeResponse is variable-length (>= 28 bytes in
|
||||
practice). Parser flags this as secret_kind=ntlmssp_v2."""
|
||||
nt_response = b"\xab" * 16 + b"\x01\x01\x00\x00" + b"\x00" * 28 # ~48 bytes
|
||||
blob = _build_type3(
|
||||
username="alice", domain="ACME", nt_response=nt_response,
|
||||
)
|
||||
cred = ntlmssp.parse_type3(blob)
|
||||
assert cred is not None
|
||||
assert cred["username"] == "alice"
|
||||
assert cred["domain"] == "ACME"
|
||||
assert cred["principal"] == "ACME\\alice"
|
||||
assert cred["secret_kind"] == "ntlmssp_v2"
|
||||
assert base64.b64decode(cred["secret_b64"]) == nt_response
|
||||
|
||||
|
||||
def test_parse_type3_ntlmv1(ntlmssp):
|
||||
"""NTLMv1 NTChallengeResponse is exactly 24 bytes."""
|
||||
nt_response = b"\xcd" * 24
|
||||
blob = _build_type3(
|
||||
username="bob", domain="WORKGROUP", nt_response=nt_response,
|
||||
)
|
||||
cred = ntlmssp.parse_type3(blob)
|
||||
assert cred["secret_kind"] == "ntlmssp_v1"
|
||||
assert cred["principal"] == "WORKGROUP\\bob"
|
||||
|
||||
|
||||
def test_parse_type3_no_domain(ntlmssp):
|
||||
nt_response = b"\xff" * 24
|
||||
blob = _build_type3(
|
||||
username="lonely", domain="", nt_response=nt_response,
|
||||
)
|
||||
cred = ntlmssp.parse_type3(blob)
|
||||
assert cred["domain"] == ""
|
||||
assert cred["principal"] == "lonely"
|
||||
|
||||
|
||||
def test_parse_type3_oem_strings(ntlmssp):
|
||||
"""Older clients without NEGOTIATE_UNICODE send ASCII strings."""
|
||||
nt_response = b"\x11" * 24
|
||||
blob = _build_type3(
|
||||
username="ascii_user",
|
||||
domain="WIN2000",
|
||||
nt_response=nt_response,
|
||||
unicode=False,
|
||||
)
|
||||
cred = ntlmssp.parse_type3(blob)
|
||||
assert cred["username"] == "ascii_user"
|
||||
assert cred["domain"] == "WIN2000"
|
||||
|
||||
|
||||
def test_parse_type3_rejects_non_signature(ntlmssp):
|
||||
assert ntlmssp.parse_type3(b"NotNtlmssp") is None
|
||||
assert ntlmssp.parse_type3(b"") is None
|
||||
# Right magic but wrong message type:
|
||||
blob = bytearray(64)
|
||||
blob[0:8] = b"NTLMSSP\x00"
|
||||
struct.pack_into("<I", blob, 8, 1) # Type 1, not 3
|
||||
assert ntlmssp.parse_type3(bytes(blob)) is None
|
||||
|
||||
|
||||
def test_parse_type3_rejects_anonymous(ntlmssp):
|
||||
"""Empty NT response (anonymous bind) → no credential to record."""
|
||||
blob = _build_type3(username="", domain="", nt_response=b"")
|
||||
assert ntlmssp.parse_type3(blob) is None
|
||||
|
||||
|
||||
def test_find_ntlmssp_inside_outer_blob(ntlmssp):
|
||||
"""SPNEGO-wrapped Type 3 — caller can locate the signature first
|
||||
and slice from there. Tests the find_ntlmssp helper."""
|
||||
nt_response = b"\xee" * 32
|
||||
inner = _build_type3(
|
||||
username="x", domain="y", nt_response=nt_response,
|
||||
)
|
||||
outer = b"\x60\x82\x01\x00" + b"\x00" * 16 + inner + b"\xff" * 8
|
||||
off = ntlmssp.find_ntlmssp(outer)
|
||||
assert off >= 0
|
||||
cred = ntlmssp.parse_type3(outer[off:])
|
||||
assert cred["username"] == "x"
|
||||
508
tests/services/test_service_isolation.py
Normal file
508
tests/services/test_service_isolation.py
Normal file
@@ -0,0 +1,508 @@
|
||||
"""
|
||||
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 contextlib
|
||||
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)
|
||||
# Collector now retries on event-stream errors instead of
|
||||
# exiting; it should still be running (i.e. surviving) here.
|
||||
assert not task.done()
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
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
|
||||
475
tests/services/test_ssh.py
Normal file
475
tests/services/test_ssh.py
Normal file
@@ -0,0 +1,475 @@
|
||||
"""
|
||||
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_drops_sshd_native_chatter():
|
||||
"""sshd's native syslog (`Failed password`, `Connection from`, …) and
|
||||
the pam_unix lines emitted from sshd's PAM stack add no signal — the
|
||||
auth-helper writes structured login_attempt events out-of-band. The
|
||||
rsyslog config must drop them via a `:programname, isequal, "sshd" stop`
|
||||
rule that comes BEFORE the forwarding actions. sudo / login pam_unix
|
||||
lines must still flow (different programname)."""
|
||||
df = _dockerfile_text()
|
||||
stop_rule = ':programname, isequal, "sshd" stop'
|
||||
assert stop_rule in df, "sshd drop rule missing from rsyslog config"
|
||||
# Order matters: stop must precede the forwarding actions inside the
|
||||
# same printf block, otherwise rsyslog forwards before evaluating it.
|
||||
stop_idx = df.index(stop_rule)
|
||||
fwd_idx = df.index("auth,authpriv.* /proc/1/fd/1;RFC5424fmt")
|
||||
assert stop_idx < fwd_idx, "stop rule must come before forwarding action"
|
||||
|
||||
|
||||
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
|
||||
184
tests/services/test_syslog_bridge_helpers.py
Normal file
184
tests/services/test_syslog_bridge_helpers.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Tests for shared emitter helpers in templates/syslog_bridge.py.
|
||||
|
||||
The canonical file is what gets propagated into per-template build
|
||||
contexts via ``_sync_logging_helper``. This test file imports it
|
||||
directly (not a per-service synced copy) so a regression in the
|
||||
canonical surfaces immediately.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_canonical():
|
||||
"""Load the canonical templates/syslog_bridge.py as a module.
|
||||
|
||||
The file isn't a package member (it lives under templates/, not
|
||||
decnet/), so we import via spec-from-path.
|
||||
"""
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
path = repo / "decnet" / "templates" / "syslog_bridge.py"
|
||||
spec = importlib.util.spec_from_file_location("_canonical_syslog_bridge", path)
|
||||
assert spec and spec.loader
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def syslog_bridge():
|
||||
return _load_canonical()
|
||||
|
||||
|
||||
def test_encode_secret_ascii_passthrough(syslog_bridge):
|
||||
out = syslog_bridge.encode_secret("hunter2")
|
||||
assert out["secret_printable"] == "hunter2"
|
||||
assert base64.b64decode(out["secret_b64"]) == b"hunter2"
|
||||
|
||||
|
||||
def test_encode_secret_collapses_nonprintables(syslog_bridge):
|
||||
"""ANSI escape, NUL, 0xff bytes → '?' in printable form."""
|
||||
secret = "\x1b[31mbad\x00\xff trail"
|
||||
out = syslog_bridge.encode_secret(secret)
|
||||
# Original utf-8 bytes survive losslessly in b64.
|
||||
assert base64.b64decode(out["secret_b64"]) == secret.encode("utf-8", errors="replace")
|
||||
# Printable form has no control / high bytes.
|
||||
for ch in out["secret_printable"]:
|
||||
assert 0x20 <= ord(ch) < 0x7f
|
||||
|
||||
|
||||
def test_encode_secret_empty(syslog_bridge):
|
||||
out = syslog_bridge.encode_secret("")
|
||||
assert out == {"secret_printable": "", "secret_b64": ""}
|
||||
|
||||
|
||||
def test_encode_secret_preserves_rfc5424_specials(syslog_bridge):
|
||||
"""Backslash / quote / bracket pass through to printable; sd_escape
|
||||
upstream is responsible for the literal RFC 5424 escape on the wire."""
|
||||
secret = 'a\\b"c]d'
|
||||
out = syslog_bridge.encode_secret(secret)
|
||||
assert out["secret_printable"] == 'a\\b"c]d'
|
||||
assert base64.b64decode(out["secret_b64"]) == secret.encode("utf-8")
|
||||
|
||||
|
||||
def test_classify_authorization_basic(syslog_bridge):
|
||||
"""HTTP Basic — base64(user:pw) decodes to plaintext credential."""
|
||||
cred = syslog_bridge.classify_authorization("Basic YWRtaW46aHVudGVyMg==")
|
||||
assert cred is not None
|
||||
assert cred["principal"] == "admin"
|
||||
assert cred["secret_kind"] == "plaintext"
|
||||
assert base64.b64decode(cred["secret_b64"]) == b"hunter2"
|
||||
assert cred["secret_printable"] == "hunter2"
|
||||
|
||||
|
||||
def test_classify_authorization_bearer(syslog_bridge):
|
||||
cred = syslog_bridge.classify_authorization("Bearer eyJhbGciOiJIUzI1NiJ9.foo.bar")
|
||||
assert cred["principal"] is None
|
||||
assert cred["secret_kind"] == "http_bearer"
|
||||
assert base64.b64decode(cred["secret_b64"]) == b"eyJhbGciOiJIUzI1NiJ9.foo.bar"
|
||||
|
||||
|
||||
def test_classify_authorization_token_alias(syslog_bridge):
|
||||
"""`Token <opaque>` = same shape as Bearer (Kubernetes service accounts)."""
|
||||
cred = syslog_bridge.classify_authorization("Token sa-jwt-token-abc")
|
||||
assert cred["secret_kind"] == "http_bearer"
|
||||
|
||||
|
||||
def test_classify_authorization_digest(syslog_bridge):
|
||||
"""RFC 7616 Digest — extract username + response hash."""
|
||||
header = ('Digest username="alice", realm="example.com", '
|
||||
'nonce="abc123", uri="/", response="d41d8cd98f00b204e9800998ecf8427e"')
|
||||
cred = syslog_bridge.classify_authorization(header)
|
||||
assert cred["principal"] == "alice"
|
||||
assert cred["secret_kind"] == "http_digest_md5"
|
||||
assert cred["secret_printable"] == "d41d8cd98f00b204e9800998ecf8427e"
|
||||
|
||||
|
||||
def test_classify_authorization_unknown_scheme(syslog_bridge):
|
||||
"""NTLM, AWS4-HMAC-…, Negotiate — all return None for now."""
|
||||
assert syslog_bridge.classify_authorization("NTLM TlRMTVNTUAA=") is None
|
||||
assert syslog_bridge.classify_authorization("AWS4-HMAC-SHA256 Credential=…") is None
|
||||
|
||||
|
||||
def test_extract_form_credentials_wordpress(syslog_bridge):
|
||||
"""wp-login.php uses `log` for username and `pwd` for password."""
|
||||
body = "log=admin&pwd=hunter2&wp-submit=Log+In"
|
||||
cred = syslog_bridge.extract_form_credentials(
|
||||
body, "application/x-www-form-urlencoded"
|
||||
)
|
||||
assert cred["principal"] == "admin"
|
||||
assert cred["secret_kind"] == "plaintext"
|
||||
assert cred["secret_printable"] == "hunter2"
|
||||
|
||||
|
||||
def test_extract_form_credentials_standard(syslog_bridge):
|
||||
body = "username=admin&password=hunter2"
|
||||
cred = syslog_bridge.extract_form_credentials(
|
||||
body, "application/x-www-form-urlencoded"
|
||||
)
|
||||
assert cred["principal"] == "admin"
|
||||
assert cred["secret_kind"] == "plaintext"
|
||||
assert cred["secret_printable"] == "hunter2"
|
||||
|
||||
|
||||
def test_extract_form_credentials_secret_without_principal(syslog_bridge):
|
||||
"""Secret-only forms (rare but seen — password reset confirms,
|
||||
auto-fill abuse) still capture as a credential. principal=None
|
||||
means we couldn't pin down the user, but the secret hash is still
|
||||
cross-correlatable for reuse analytics."""
|
||||
body = "password=hunter2&csrf=abc"
|
||||
cred = syslog_bridge.extract_form_credentials(
|
||||
body, "application/x-www-form-urlencoded"
|
||||
)
|
||||
assert cred is not None
|
||||
assert cred["principal"] is None
|
||||
assert cred["secret_printable"] == "hunter2"
|
||||
|
||||
|
||||
def test_extract_form_credentials_alternate_keys(syslog_bridge):
|
||||
cred = syslog_bridge.extract_form_credentials(
|
||||
"user=alice&pwd=h%40ck", "application/x-www-form-urlencoded"
|
||||
)
|
||||
assert cred["principal"] == "alice"
|
||||
assert cred["secret_printable"] == "h@ck" # %40 decoded
|
||||
|
||||
|
||||
def test_extract_form_credentials_wrong_content_type(syslog_bridge):
|
||||
"""Don't try to parse JSON / multipart / etc bodies."""
|
||||
assert syslog_bridge.extract_form_credentials(
|
||||
"username=admin&password=x", "application/json"
|
||||
) is None
|
||||
assert syslog_bridge.extract_form_credentials(
|
||||
"username=admin&password=x", None
|
||||
) is None
|
||||
|
||||
|
||||
def test_extract_form_credentials_no_secret(syslog_bridge):
|
||||
"""Username only → no cred row (need both principal + secret)."""
|
||||
cred = syslog_bridge.extract_form_credentials(
|
||||
"username=admin&csrf_token=xyz", "application/x-www-form-urlencoded"
|
||||
)
|
||||
assert cred is None
|
||||
|
||||
|
||||
def test_classify_authorization_malformed(syslog_bridge):
|
||||
assert syslog_bridge.classify_authorization(None) is None
|
||||
assert syslog_bridge.classify_authorization("") is None
|
||||
assert syslog_bridge.classify_authorization("Basic !!not-base64!!") is None
|
||||
assert syslog_bridge.classify_authorization("Basic dXNlcg==") is None # no colon
|
||||
assert syslog_bridge.classify_authorization("Digest no-response-here") is None
|
||||
|
||||
|
||||
def test_encode_secret_unicode_replaced(syslog_bridge):
|
||||
"""Non-ASCII unicode encodes via utf-8, then printable strips the
|
||||
multi-byte sequence to '?' chars (one per raw byte)."""
|
||||
out = syslog_bridge.encode_secret("café")
|
||||
raw = "café".encode("utf-8") # b'caf\xc3\xa9' — 5 bytes
|
||||
assert base64.b64decode(out["secret_b64"]) == raw
|
||||
# printable: 'c', 'a', 'f', '?', '?' — the two trailing utf-8 bytes
|
||||
# both fall outside [0x20, 0x7f).
|
||||
assert out["secret_printable"] == "caf??"
|
||||
Reference in New Issue
Block a user