merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

View 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()

View File

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

View File

@@ -0,0 +1,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"

View 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"

View 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]

View File

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

View File

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

View File

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

475
tests/services/test_ssh.py Normal file
View 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]

View File

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

View File

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

View 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??"