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

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

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

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

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

View File

@@ -0,0 +1,243 @@
"""
Tests for prober bounty extraction in the ingester.
Verifies that _extract_bounty() correctly identifies and stores JARM,
HASSH, and TCP/IP fingerprints from prober events, and ignores these
fields when they come from other services.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from decnet.web.ingester import _extract_bounty
def _make_repo() -> MagicMock:
repo = MagicMock()
repo.add_bounty = AsyncMock()
return repo
@pytest.mark.asyncio
async def test_jarm_bounty_extracted():
"""Prober event with jarm_hash should create a fingerprint bounty."""
repo = _make_repo()
log_data = {
"decky": "decnet-prober",
"service": "prober",
"event_type": "jarm_fingerprint",
"attacker_ip": "Unknown",
"fields": {
"target_ip": "10.0.0.1",
"target_port": "443",
"jarm_hash": "c0cc0cc0cc0cc0cc0cc0cc0cc0cc0cabcdef1234567890abcdef1234567890ab",
},
"msg": "JARM 10.0.0.1:443 = ...",
}
await _extract_bounty(repo, log_data)
repo.add_bounty.assert_called()
call_args = repo.add_bounty.call_args[0][0]
assert call_args["service"] == "prober"
assert call_args["bounty_type"] == "fingerprint"
assert call_args["attacker_ip"] == "10.0.0.1"
assert call_args["payload"]["fingerprint_type"] == "jarm"
assert call_args["payload"]["hash"] == "c0cc0cc0cc0cc0cc0cc0cc0cc0cc0cabcdef1234567890abcdef1234567890ab"
assert call_args["payload"]["target_ip"] == "10.0.0.1"
assert call_args["payload"]["target_port"] == "443"
@pytest.mark.asyncio
async def test_jarm_bounty_not_extracted_from_other_services():
"""A non-prober event with jarm_hash field should NOT trigger extraction."""
repo = _make_repo()
log_data = {
"decky": "decky-01",
"service": "sniffer",
"event_type": "tls_client_hello",
"attacker_ip": "192.168.1.50",
"fields": {
"jarm_hash": "fake_hash_from_different_service",
},
"msg": "",
}
await _extract_bounty(repo, log_data)
# Should NOT have been called for JARM — sniffer has its own bounty types
for call in repo.add_bounty.call_args_list:
payload = call[0][0].get("payload", {})
assert payload.get("fingerprint_type") != "jarm"
@pytest.mark.asyncio
async def test_jarm_bounty_not_extracted_without_hash():
"""Prober event without jarm_hash should not create a bounty."""
repo = _make_repo()
log_data = {
"decky": "decnet-prober",
"service": "prober",
"event_type": "prober_startup",
"attacker_ip": "Unknown",
"fields": {
"target_count": "5",
"interval": "300",
},
"msg": "DECNET-PROBER started",
}
await _extract_bounty(repo, log_data)
for call in repo.add_bounty.call_args_list:
payload = call[0][0].get("payload", {})
assert payload.get("fingerprint_type") != "jarm"
@pytest.mark.asyncio
async def test_jarm_bounty_missing_fields_dict():
"""Log data without 'fields' dict should not crash."""
repo = _make_repo()
log_data = {
"decky": "decnet-prober",
"service": "prober",
"event_type": "jarm_fingerprint",
"attacker_ip": "Unknown",
}
await _extract_bounty(repo, log_data)
# No bounty calls for JARM
for call in repo.add_bounty.call_args_list:
payload = call[0][0].get("payload", {})
assert payload.get("fingerprint_type") != "jarm"
# ─── HASSH bounty extraction ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_hassh_bounty_extracted():
"""Prober event with hassh_server_hash should create a fingerprint bounty."""
repo = _make_repo()
log_data = {
"decky": "decnet-prober",
"service": "prober",
"event_type": "hassh_fingerprint",
"attacker_ip": "Unknown",
"fields": {
"target_ip": "10.0.0.1",
"target_port": "22",
"hassh_server_hash": "a" * 32,
"ssh_banner": "SSH-2.0-OpenSSH_8.9p1",
"kex_algorithms": "curve25519-sha256",
"encryption_s2c": "aes256-gcm@openssh.com",
"mac_s2c": "hmac-sha2-256-etm@openssh.com",
"compression_s2c": "none",
},
"msg": "HASSH 10.0.0.1:22 = ...",
}
await _extract_bounty(repo, log_data)
# Find the HASSH bounty call
hassh_calls = [
c for c in repo.add_bounty.call_args_list
if c[0][0].get("payload", {}).get("fingerprint_type") == "hassh_server"
]
assert len(hassh_calls) == 1
payload = hassh_calls[0][0][0]["payload"]
assert payload["hash"] == "a" * 32
assert payload["ssh_banner"] == "SSH-2.0-OpenSSH_8.9p1"
assert payload["kex_algorithms"] == "curve25519-sha256"
assert payload["encryption_s2c"] == "aes256-gcm@openssh.com"
assert payload["mac_s2c"] == "hmac-sha2-256-etm@openssh.com"
assert payload["compression_s2c"] == "none"
@pytest.mark.asyncio
async def test_hassh_bounty_not_extracted_from_other_services():
"""A non-prober event with hassh_server_hash should NOT trigger extraction."""
repo = _make_repo()
log_data = {
"decky": "decky-01",
"service": "ssh",
"event_type": "login_attempt",
"attacker_ip": "192.168.1.50",
"fields": {
"hassh_server_hash": "fake_hash",
},
"msg": "",
}
await _extract_bounty(repo, log_data)
for call in repo.add_bounty.call_args_list:
payload = call[0][0].get("payload", {})
assert payload.get("fingerprint_type") != "hassh_server"
# ─── TCP/IP fingerprint bounty extraction ──────────────────────────────────
@pytest.mark.asyncio
async def test_tcpfp_bounty_extracted():
"""Prober event with tcpfp_hash should create a fingerprint bounty."""
repo = _make_repo()
log_data = {
"decky": "decnet-prober",
"service": "prober",
"event_type": "tcpfp_fingerprint",
"attacker_ip": "Unknown",
"fields": {
"target_ip": "10.0.0.1",
"target_port": "443",
"tcpfp_hash": "d" * 32,
"tcpfp_raw": "64:65535:1:1460:7:1:1:M,N,W,N,N,T,S,E",
"ttl": "64",
"window_size": "65535",
"df_bit": "1",
"mss": "1460",
"window_scale": "7",
"sack_ok": "1",
"timestamp": "1",
"options_order": "M,N,W,N,N,T,S,E",
},
"msg": "TCPFP 10.0.0.1:443 = ...",
}
await _extract_bounty(repo, log_data)
tcpfp_calls = [
c for c in repo.add_bounty.call_args_list
if c[0][0].get("payload", {}).get("fingerprint_type") == "tcpfp"
]
assert len(tcpfp_calls) == 1
payload = tcpfp_calls[0][0][0]["payload"]
assert payload["hash"] == "d" * 32
assert payload["raw"] == "64:65535:1:1460:7:1:1:M,N,W,N,N,T,S,E"
assert payload["ttl"] == "64"
assert payload["window_size"] == "65535"
assert payload["options_order"] == "M,N,W,N,N,T,S,E"
@pytest.mark.asyncio
async def test_tcpfp_bounty_not_extracted_from_other_services():
"""A non-prober event with tcpfp_hash should NOT trigger extraction."""
repo = _make_repo()
log_data = {
"decky": "decky-01",
"service": "sniffer",
"event_type": "something",
"attacker_ip": "192.168.1.50",
"fields": {
"tcpfp_hash": "fake_hash",
},
"msg": "",
}
await _extract_bounty(repo, log_data)
for call in repo.add_bounty.call_args_list:
payload = call[0][0].get("payload", {})
assert payload.get("fingerprint_type") != "tcpfp"

View File

@@ -0,0 +1,357 @@
"""
Unit tests for the HASSHServer SSH fingerprinting module.
Tests cover KEX_INIT parsing, HASSH hash computation, SSH connection
handling, and end-to-end hassh_server() with mocked sockets.
"""
from __future__ import annotations
import hashlib
import socket
import struct
from unittest.mock import MagicMock, patch
import pytest
from decnet.prober.hassh import (
_CLIENT_BANNER,
_SSH_MSG_KEXINIT,
_compute_hassh,
_parse_kex_init,
_read_banner,
_read_ssh_packet,
hassh_server,
)
# ─── Helpers ────────────────────────────────────────────────────────────────
def _build_name_list(value: str) -> bytes:
"""Encode a single SSH name-list (uint32 length + utf-8 string)."""
encoded = value.encode("utf-8")
return struct.pack("!I", len(encoded)) + encoded
def _build_kex_init(
kex: str = "curve25519-sha256,diffie-hellman-group14-sha256",
host_key: str = "ssh-ed25519,rsa-sha2-512",
enc_c2s: str = "aes256-gcm@openssh.com,aes128-gcm@openssh.com",
enc_s2c: str = "aes256-gcm@openssh.com,chacha20-poly1305@openssh.com",
mac_c2s: str = "hmac-sha2-256-etm@openssh.com",
mac_s2c: str = "hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com",
comp_c2s: str = "none,zlib@openssh.com",
comp_s2c: str = "none,zlib@openssh.com",
lang_c2s: str = "",
lang_s2c: str = "",
cookie: bytes | None = None,
) -> bytes:
"""Build a complete SSH_MSG_KEXINIT payload for testing."""
if cookie is None:
cookie = b"\x00" * 16
payload = struct.pack("B", _SSH_MSG_KEXINIT) + cookie
for value in [kex, host_key, enc_c2s, enc_s2c, mac_c2s, mac_s2c,
comp_c2s, comp_s2c, lang_c2s, lang_s2c]:
payload += _build_name_list(value)
# first_kex_packet_follows (bool) + reserved (uint32)
payload += struct.pack("!BI", 0, 0)
return payload
def _wrap_ssh_packet(payload: bytes) -> bytes:
"""Wrap payload into an SSH binary packet (header only, no MAC)."""
# Padding to 8-byte boundary (minimum 4 bytes)
block_size = 8
padding_needed = block_size - ((1 + len(payload)) % block_size)
if padding_needed < 4:
padding_needed += block_size
padding = b"\x00" * padding_needed
packet_length = 1 + len(payload) + len(padding) # padding_length(1) + payload + padding
return struct.pack("!IB", packet_length, padding_needed) + payload + padding
def _make_socket_with_data(data: bytes) -> MagicMock:
"""Create a mock socket that yields data byte-by-byte or in chunks."""
sock = MagicMock()
pos = [0]
def recv(n):
if pos[0] >= len(data):
return b""
chunk = data[pos[0] : pos[0] + n]
pos[0] += n
return chunk
sock.recv = recv
return sock
# ─── _parse_kex_init ────────────────────────────────────────────────────────
class TestParseKexInit:
def test_parses_all_ten_fields(self):
payload = _build_kex_init()
result = _parse_kex_init(payload)
assert result is not None
assert len(result) == 10
def test_extracts_correct_field_values(self):
payload = _build_kex_init(
kex="curve25519-sha256",
enc_s2c="chacha20-poly1305@openssh.com",
mac_s2c="hmac-sha2-512-etm@openssh.com",
comp_s2c="none",
)
result = _parse_kex_init(payload)
assert result["kex_algorithms"] == "curve25519-sha256"
assert result["encryption_server_to_client"] == "chacha20-poly1305@openssh.com"
assert result["mac_server_to_client"] == "hmac-sha2-512-etm@openssh.com"
assert result["compression_server_to_client"] == "none"
def test_extracts_hassh_server_fields_at_correct_indices(self):
"""HASSHServer uses indices 0(kex), 3(enc_s2c), 5(mac_s2c), 7(comp_s2c)."""
payload = _build_kex_init(
kex="KEX_FIELD",
host_key="HOSTKEY_FIELD",
enc_c2s="ENC_C2S_FIELD",
enc_s2c="ENC_S2C_FIELD",
mac_c2s="MAC_C2S_FIELD",
mac_s2c="MAC_S2C_FIELD",
comp_c2s="COMP_C2S_FIELD",
comp_s2c="COMP_S2C_FIELD",
)
result = _parse_kex_init(payload)
# Indices used by HASSHServer
assert result["kex_algorithms"] == "KEX_FIELD" # index 0
assert result["encryption_server_to_client"] == "ENC_S2C_FIELD" # index 3
assert result["mac_server_to_client"] == "MAC_S2C_FIELD" # index 5
assert result["compression_server_to_client"] == "COMP_S2C_FIELD" # index 7
def test_empty_name_lists(self):
payload = _build_kex_init(
kex="", host_key="", enc_c2s="", enc_s2c="",
mac_c2s="", mac_s2c="", comp_c2s="", comp_s2c="",
)
result = _parse_kex_init(payload)
assert result is not None
assert result["kex_algorithms"] == ""
def test_truncated_payload_returns_none(self):
# Just the type byte and cookie, no name-lists
payload = struct.pack("B", _SSH_MSG_KEXINIT) + b"\x00" * 16
assert _parse_kex_init(payload) is None
def test_truncated_name_list_returns_none(self):
# Type + cookie + length says 100 but only 2 bytes follow
payload = struct.pack("B", _SSH_MSG_KEXINIT) + b"\x00" * 16
payload += struct.pack("!I", 100) + b"ab"
assert _parse_kex_init(payload) is None
def test_too_short_returns_none(self):
assert _parse_kex_init(b"") is None
assert _parse_kex_init(b"\x14") is None
def test_large_algorithm_lists(self):
long_kex = ",".join(f"algo-{i}" for i in range(50))
payload = _build_kex_init(kex=long_kex)
result = _parse_kex_init(payload)
assert result is not None
assert result["kex_algorithms"] == long_kex
# ─── _compute_hassh ─────────────────────────────────────────────────────────
class TestComputeHashh:
def test_md5_correctness(self):
kex = "curve25519-sha256"
enc = "aes256-gcm@openssh.com"
mac = "hmac-sha2-256-etm@openssh.com"
comp = "none"
raw = f"{kex};{enc};{mac};{comp}"
expected = hashlib.md5(raw.encode("utf-8")).hexdigest()
assert _compute_hassh(kex, enc, mac, comp) == expected
def test_hash_length_is_32(self):
result = _compute_hassh("a", "b", "c", "d")
assert len(result) == 32
def test_deterministic(self):
r1 = _compute_hassh("kex1", "enc1", "mac1", "comp1")
r2 = _compute_hassh("kex1", "enc1", "mac1", "comp1")
assert r1 == r2
def test_different_inputs_different_hashes(self):
r1 = _compute_hassh("kex1", "enc1", "mac1", "comp1")
r2 = _compute_hassh("kex2", "enc2", "mac2", "comp2")
assert r1 != r2
def test_empty_fields(self):
result = _compute_hassh("", "", "", "")
expected = hashlib.md5(b";;;").hexdigest()
assert result == expected
def test_semicolon_delimiter(self):
"""The delimiter is semicolon, not comma."""
result = _compute_hassh("a", "b", "c", "d")
expected = hashlib.md5(b"a;b;c;d").hexdigest()
assert result == expected
# ─── _read_banner ───────────────────────────────────────────────────────────
class TestReadBanner:
def test_reads_banner_with_crlf(self):
sock = _make_socket_with_data(b"SSH-2.0-OpenSSH_8.9p1\r\n")
result = _read_banner(sock)
assert result == "SSH-2.0-OpenSSH_8.9p1"
def test_reads_banner_with_lf(self):
sock = _make_socket_with_data(b"SSH-2.0-OpenSSH_8.9p1\n")
result = _read_banner(sock)
assert result == "SSH-2.0-OpenSSH_8.9p1"
def test_empty_data_returns_none(self):
sock = _make_socket_with_data(b"")
result = _read_banner(sock)
assert result is None
def test_no_newline_within_limit(self):
# 256 bytes with no newline — should stop at limit
sock = _make_socket_with_data(b"A" * 256)
result = _read_banner(sock)
assert result == "A" * 256
# ─── _read_ssh_packet ───────────────────────────────────────────────────────
class TestReadSSHPacket:
def test_reads_valid_packet(self):
payload = b"\x14" + b"\x00" * 20 # type 20 + some data
packet_data = _wrap_ssh_packet(payload)
sock = _make_socket_with_data(packet_data)
result = _read_ssh_packet(sock)
assert result is not None
assert result[0] == 0x14 # SSH_MSG_KEXINIT
def test_empty_socket_returns_none(self):
sock = _make_socket_with_data(b"")
assert _read_ssh_packet(sock) is None
def test_truncated_header_returns_none(self):
sock = _make_socket_with_data(b"\x00\x00")
assert _read_ssh_packet(sock) is None
def test_oversized_packet_returns_none(self):
# packet_length = 40000 (over limit)
sock = _make_socket_with_data(struct.pack("!I", 40000))
assert _read_ssh_packet(sock) is None
def test_zero_length_returns_none(self):
sock = _make_socket_with_data(struct.pack("!I", 0))
assert _read_ssh_packet(sock) is None
# ─── hassh_server (end-to-end with mocked sockets) ─────────────────────────
class TestHasshServerE2E:
@patch("decnet.prober.hassh._ssh_connect")
def test_success(self, mock_connect: MagicMock):
payload = _build_kex_init(
kex="curve25519-sha256",
enc_s2c="aes256-gcm@openssh.com",
mac_s2c="hmac-sha2-256-etm@openssh.com",
comp_s2c="none",
)
mock_connect.return_value = ("SSH-2.0-OpenSSH_8.9p1", payload)
result = hassh_server("10.0.0.1", 22, timeout=1.0)
assert result is not None
assert len(result["hassh_server"]) == 32
assert result["banner"] == "SSH-2.0-OpenSSH_8.9p1"
assert result["kex_algorithms"] == "curve25519-sha256"
assert result["encryption_s2c"] == "aes256-gcm@openssh.com"
assert result["mac_s2c"] == "hmac-sha2-256-etm@openssh.com"
assert result["compression_s2c"] == "none"
@patch("decnet.prober.hassh._ssh_connect")
def test_connection_failure_returns_none(self, mock_connect: MagicMock):
mock_connect.return_value = None
assert hassh_server("10.0.0.1", 22, timeout=1.0) is None
@patch("decnet.prober.hassh._ssh_connect")
def test_truncated_kex_init_returns_none(self, mock_connect: MagicMock):
# Payload too short to parse
payload = struct.pack("B", _SSH_MSG_KEXINIT) + b"\x00" * 16
mock_connect.return_value = ("SSH-2.0-OpenSSH_8.9p1", payload)
assert hassh_server("10.0.0.1", 22, timeout=1.0) is None
@patch("decnet.prober.hassh._ssh_connect")
def test_hash_is_deterministic(self, mock_connect: MagicMock):
payload = _build_kex_init()
mock_connect.return_value = ("SSH-2.0-OpenSSH_8.9p1", payload)
r1 = hassh_server("10.0.0.1", 22)
r2 = hassh_server("10.0.0.1", 22)
assert r1["hassh_server"] == r2["hassh_server"]
@patch("decnet.prober.hassh._ssh_connect")
def test_different_servers_different_hashes(self, mock_connect: MagicMock):
p1 = _build_kex_init(kex="curve25519-sha256", enc_s2c="aes256-gcm@openssh.com")
p2 = _build_kex_init(kex="diffie-hellman-group14-sha1", enc_s2c="aes128-cbc")
mock_connect.return_value = ("SSH-2.0-OpenSSH_8.9p1", p1)
r1 = hassh_server("10.0.0.1", 22)
mock_connect.return_value = ("SSH-2.0-Paramiko_3.0", p2)
r2 = hassh_server("10.0.0.2", 22)
assert r1["hassh_server"] != r2["hassh_server"]
@patch("decnet.prober.hassh.socket.create_connection")
def test_full_socket_mock(self, mock_create: MagicMock):
"""Full integration: mock at socket level, verify banner exchange."""
kex_payload = _build_kex_init()
kex_packet = _wrap_ssh_packet(kex_payload)
banner_bytes = b"SSH-2.0-OpenSSH_8.9p1\r\n"
all_data = banner_bytes + kex_packet
mock_sock = _make_socket_with_data(all_data)
mock_sock.sendall = MagicMock()
mock_sock.settimeout = MagicMock()
mock_sock.close = MagicMock()
mock_create.return_value = mock_sock
result = hassh_server("10.0.0.1", 22, timeout=2.0)
assert result is not None
assert result["banner"] == "SSH-2.0-OpenSSH_8.9p1"
assert len(result["hassh_server"]) == 32
# Verify we sent our client banner
mock_sock.sendall.assert_called_once_with(_CLIENT_BANNER)
@patch("decnet.prober.hassh.socket.create_connection")
def test_non_ssh_banner_returns_none(self, mock_create: MagicMock):
mock_sock = _make_socket_with_data(b"HTTP/1.1 200 OK\r\n")
mock_sock.sendall = MagicMock()
mock_sock.settimeout = MagicMock()
mock_sock.close = MagicMock()
mock_create.return_value = mock_sock
assert hassh_server("10.0.0.1", 80, timeout=1.0) is None
@patch("decnet.prober.hassh.socket.create_connection")
def test_connection_refused(self, mock_create: MagicMock):
mock_create.side_effect = ConnectionRefusedError
assert hassh_server("10.0.0.1", 22, timeout=1.0) is None
@patch("decnet.prober.hassh.socket.create_connection")
def test_timeout(self, mock_create: MagicMock):
mock_create.side_effect = socket.timeout("timed out")
assert hassh_server("10.0.0.1", 22, timeout=1.0) is None

View File

@@ -0,0 +1,272 @@
"""
Unit tests for the JARM fingerprinting module.
Tests cover ClientHello construction, ServerHello parsing, hash computation,
and end-to-end jarm_hash() with mocked sockets.
"""
from __future__ import annotations
import hashlib
import struct
from unittest.mock import MagicMock, patch
import pytest
from decnet.prober.jarm import (
JARM_EMPTY_HASH,
_build_client_hello,
_compute_jarm,
_middle_out,
_parse_server_hello,
_send_probe,
_version_to_str,
jarm_hash,
)
# ─── _build_client_hello ─────────────────────────────────────────────────────
class TestBuildClientHello:
@pytest.mark.parametrize("probe_index", range(10))
def test_produces_valid_tls_record(self, probe_index: int):
data = _build_client_hello(probe_index, host="example.com")
assert isinstance(data, bytes)
assert len(data) > 5
# TLS record header: content_type = 0x16 (Handshake)
assert data[0] == 0x16
@pytest.mark.parametrize("probe_index", range(10))
def test_handshake_type_is_client_hello(self, probe_index: int):
data = _build_client_hello(probe_index, host="example.com")
# Byte 5 is the handshake type (after 5-byte record header)
assert data[5] == 0x01 # ClientHello
@pytest.mark.parametrize("probe_index", range(10))
def test_record_length_matches(self, probe_index: int):
data = _build_client_hello(probe_index, host="example.com")
record_len = struct.unpack_from("!H", data, 3)[0]
assert len(data) == 5 + record_len
def test_sni_contains_hostname(self):
data = _build_client_hello(0, host="target.evil.com")
assert b"target.evil.com" in data
def test_tls13_probes_include_supported_versions(self):
"""Probes 3, 4, 5, 6 should include supported_versions extension."""
for idx in (3, 4, 5, 6):
data = _build_client_hello(idx, host="example.com")
# supported_versions extension type = 0x002B
assert b"\x00\x2b" in data, f"Probe {idx} missing supported_versions"
def test_probe_9_includes_alpn_http11(self):
data = _build_client_hello(9, host="example.com")
assert b"http/1.1" in data
def test_probe_3_includes_alpn_h2(self):
data = _build_client_hello(3, host="example.com")
assert b"h2" in data
def test_all_probes_produce_distinct_payloads(self):
"""All 10 probes should produce different ClientHellos."""
payloads = set()
for i in range(10):
data = _build_client_hello(i, host="example.com")
payloads.add(data)
assert len(payloads) == 10
def test_record_layer_version(self):
"""Record layer version should be TLS 1.0 (0x0301) for all probes."""
for i in range(10):
data = _build_client_hello(i, host="example.com")
record_version = struct.unpack_from("!H", data, 1)[0]
assert record_version == 0x0301
# ─── _parse_server_hello ─────────────────────────────────────────────────────
def _make_server_hello(
cipher: int = 0xC02F,
version: int = 0x0303,
extensions: bytes = b"",
) -> bytes:
"""Build a minimal ServerHello TLS record for testing."""
# ServerHello body
body = struct.pack("!H", version) # server_version
body += b"\x00" * 32 # random
body += b"\x00" # session_id length = 0
body += struct.pack("!H", cipher) # cipher_suite
body += b"\x00" # compression_method = null
if extensions:
body += struct.pack("!H", len(extensions)) + extensions
# Handshake wrapper
hs = struct.pack("B", 0x02) + struct.pack("!I", len(body))[1:] + body
# TLS record
record = struct.pack("B", 0x16) + struct.pack("!H", 0x0303) + struct.pack("!H", len(hs)) + hs
return record
class TestParseServerHello:
def test_basic_parse(self):
data = _make_server_hello(cipher=0xC02F, version=0x0303)
result = _parse_server_hello(data)
assert "c02f" in result
assert "tls12" in result
def test_tls13_via_supported_versions(self):
"""When supported_versions extension says TLS 1.3, version should be tls13."""
ext = struct.pack("!HHH", 0x002B, 2, 0x0304)
data = _make_server_hello(cipher=0x1301, version=0x0303, extensions=ext)
result = _parse_server_hello(data)
assert "1301" in result
assert "tls13" in result
def test_tls10(self):
data = _make_server_hello(cipher=0x002F, version=0x0301)
result = _parse_server_hello(data)
assert "002f" in result
assert "tls10" in result
def test_empty_data_returns_separator(self):
assert _parse_server_hello(b"") == "|||"
def test_non_handshake_returns_separator(self):
assert _parse_server_hello(b"\x15\x03\x03\x00\x02\x02\x00") == "|||"
def test_truncated_data_returns_separator(self):
assert _parse_server_hello(b"\x16\x03\x03") == "|||"
def test_non_server_hello_returns_separator(self):
"""A Certificate message (type 0x0B) should not parse as ServerHello."""
body = b"\x00" * 40
hs = struct.pack("B", 0x0B) + struct.pack("!I", len(body))[1:] + body
record = struct.pack("B", 0x16) + struct.pack("!H", 0x0303) + struct.pack("!H", len(hs)) + hs
assert _parse_server_hello(record) == "|||"
def test_extensions_in_output(self):
ext = struct.pack("!HH", 0x0017, 0) # extended_master_secret, no data
data = _make_server_hello(cipher=0xC02F, version=0x0303, extensions=ext)
result = _parse_server_hello(data)
parts = result.split("|")
assert len(parts) == 3
assert "0017" in parts[2]
# ─── _compute_jarm ───────────────────────────────────────────────────────────
class TestComputeJarm:
def test_all_failures_returns_empty_hash(self):
responses = ["|||"] * 10
assert _compute_jarm(responses) == JARM_EMPTY_HASH
def test_hash_length_is_62(self):
responses = ["c02f|tls12|0017"] * 10
result = _compute_jarm(responses)
assert len(result) == 62
def test_deterministic(self):
responses = ["c02f|tls12|0017-002b"] * 10
r1 = _compute_jarm(responses)
r2 = _compute_jarm(responses)
assert r1 == r2
def test_different_inputs_different_hashes(self):
r1 = _compute_jarm(["c02f|tls12|0017"] * 10)
r2 = _compute_jarm(["1301|tls13|002b"] * 10)
assert r1 != r2
def test_partial_failure(self):
"""Some probes fail, some succeed — should not be empty hash."""
responses = ["c02f|tls12|0017"] * 5 + ["|||"] * 5
result = _compute_jarm(responses)
assert result != JARM_EMPTY_HASH
assert len(result) == 62
def test_first_30_chars_are_raw_components(self):
responses = ["c02f|tls12|0017"] * 10
result = _compute_jarm(responses)
# "c02f" cipher → first 2 chars "c0", version tls12 → "c"
# So each probe contributes "c0c" (3 chars), 10 probes = 30 chars
raw_part = result[:30]
assert raw_part == "c0c" * 10
def test_last_32_chars_are_sha256(self):
responses = ["c02f|tls12|0017"] * 10
result = _compute_jarm(responses)
ext_str = ",".join(["0017"] * 10)
expected_hash = hashlib.sha256(ext_str.encode()).hexdigest()[:32]
assert result[30:] == expected_hash
# ─── _version_to_str ─────────────────────────────────────────────────────────
class TestVersionToStr:
@pytest.mark.parametrize("version,expected", [
(0x0304, "tls13"),
(0x0303, "tls12"),
(0x0302, "tls11"),
(0x0301, "tls10"),
(0x0300, "ssl30"),
(0x9999, "9999"),
])
def test_version_mapping(self, version: int, expected: str):
assert _version_to_str(version) == expected
# ─── _middle_out ──────────────────────────────────────────────────────────────
class TestMiddleOut:
def test_preserves_all_elements(self):
original = list(range(10))
result = _middle_out(original)
assert sorted(result) == sorted(original)
def test_starts_from_middle(self):
original = list(range(10))
result = _middle_out(original)
assert result[0] == 5 # mid element
# ─── jarm_hash (end-to-end with mocked sockets) ─────────────────────────────
class TestJarmHashE2E:
@patch("decnet.prober.jarm._send_probe")
def test_all_probes_fail(self, mock_send: MagicMock):
mock_send.return_value = None
result = jarm_hash("1.2.3.4", 443, timeout=1.0)
assert result == JARM_EMPTY_HASH
assert mock_send.call_count == 10
@patch("decnet.prober.jarm._send_probe")
def test_all_probes_succeed(self, mock_send: MagicMock):
server_hello = _make_server_hello(cipher=0xC02F, version=0x0303)
mock_send.return_value = server_hello
result = jarm_hash("1.2.3.4", 443, timeout=1.0)
assert result != JARM_EMPTY_HASH
assert len(result) == 62
assert mock_send.call_count == 10
@patch("decnet.prober.jarm._send_probe")
def test_mixed_results(self, mock_send: MagicMock):
server_hello = _make_server_hello(cipher=0x1301, version=0x0303)
mock_send.side_effect = [server_hello, None] * 5
result = jarm_hash("1.2.3.4", 443, timeout=1.0)
assert result != JARM_EMPTY_HASH
assert len(result) == 62
@patch("decnet.prober.jarm.time.sleep")
@patch("decnet.prober.jarm._send_probe")
def test_inter_probe_delay(self, mock_send: MagicMock, mock_sleep: MagicMock):
mock_send.return_value = None
jarm_hash("1.2.3.4", 443, timeout=1.0)
# Should sleep 9 times (between probes, not after last)
assert mock_sleep.call_count == 9

View File

@@ -0,0 +1,349 @@
"""
Unit tests for the TCP/IP stack fingerprinting module.
Tests cover SYN-ACK parsing, options extraction, fingerprint computation,
and end-to-end tcp_fingerprint() with mocked scapy packets.
"""
from __future__ import annotations
import hashlib
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from decnet.prober.tcpfp import (
_compute_fingerprint,
_extract_options_order,
_parse_synack,
tcp_fingerprint,
)
# ─── Helpers ────────────────────────────────────────────────────────────────
def _make_synack(
ttl: int = 64,
flags: int = 0x02, # IP flags (DF = 0x02)
ip_id: int = 0,
window: int = 65535,
tcp_flags: int = 0x12, # SYN-ACK
options: list | None = None,
ack: int = 1,
) -> SimpleNamespace:
"""Build a fake scapy-like SYN-ACK packet for testing."""
if options is None:
options = [
("MSS", 1460),
("NOP", None),
("WScale", 7),
("NOP", None),
("NOP", None),
("Timestamp", (12345, 0)),
("SAckOK", b""),
("EOL", None),
]
tcp_layer = SimpleNamespace(
flags=tcp_flags,
window=window,
options=options,
dport=12345,
ack=ack,
)
ip_layer = SimpleNamespace(
ttl=ttl,
flags=flags,
id=ip_id,
)
class FakePacket:
def __init__(self):
self._layers = {"IP": ip_layer, "TCP": tcp_layer}
self.ack = ack
def __getitem__(self, key):
# Support both class and string access
name = key.__name__ if hasattr(key, "__name__") else str(key)
return self._layers[name]
def haslayer(self, key):
name = key.__name__ if hasattr(key, "__name__") else str(key)
return name in self._layers
return FakePacket()
# ─── _extract_options_order ─────────────────────────────────────────────────
class TestExtractOptionsOrder:
def test_standard_linux_options(self):
options = [
("MSS", 1460), ("NOP", None), ("WScale", 7),
("NOP", None), ("NOP", None), ("Timestamp", (0, 0)),
("SAckOK", b""), ("EOL", None),
]
assert _extract_options_order(options) == "M,N,W,N,N,T,S,E"
def test_windows_options(self):
options = [
("MSS", 1460), ("NOP", None), ("WScale", 8),
("NOP", None), ("NOP", None), ("SAckOK", b""),
]
assert _extract_options_order(options) == "M,N,W,N,N,S"
def test_empty_options(self):
assert _extract_options_order([]) == ""
def test_mss_only(self):
assert _extract_options_order([("MSS", 536)]) == "M"
def test_unknown_option(self):
options = [("MSS", 1460), ("UnknownOpt", 42)]
assert _extract_options_order(options) == "M,?"
def test_sack_variant(self):
options = [("SAck", (100, 200))]
assert _extract_options_order(options) == "S"
# ─── _parse_synack ──────────────────────────────────────────────────────────
class TestParseSynack:
def test_linux_64_ttl(self):
resp = _make_synack(ttl=64)
result = _parse_synack(resp)
assert result["ttl"] == 64
def test_windows_128_ttl(self):
resp = _make_synack(ttl=128)
result = _parse_synack(resp)
assert result["ttl"] == 128
def test_df_bit_set(self):
resp = _make_synack(flags=0x02) # DF set
result = _parse_synack(resp)
assert result["df_bit"] == 1
def test_df_bit_unset(self):
resp = _make_synack(flags=0x00)
result = _parse_synack(resp)
assert result["df_bit"] == 0
def test_window_size(self):
resp = _make_synack(window=29200)
result = _parse_synack(resp)
assert result["window_size"] == 29200
def test_mss_extraction(self):
resp = _make_synack(options=[("MSS", 1460)])
result = _parse_synack(resp)
assert result["mss"] == 1460
def test_window_scale(self):
resp = _make_synack(options=[("WScale", 7)])
result = _parse_synack(resp)
assert result["window_scale"] == 7
def test_sack_ok(self):
resp = _make_synack(options=[("SAckOK", b"")])
result = _parse_synack(resp)
assert result["sack_ok"] == 1
def test_no_sack(self):
resp = _make_synack(options=[("MSS", 1460)])
result = _parse_synack(resp)
assert result["sack_ok"] == 0
def test_timestamp_present(self):
resp = _make_synack(options=[("Timestamp", (12345, 0))])
result = _parse_synack(resp)
assert result["timestamp"] == 1
def test_no_timestamp(self):
resp = _make_synack(options=[("MSS", 1460)])
result = _parse_synack(resp)
assert result["timestamp"] == 0
def test_options_order(self):
resp = _make_synack(options=[
("MSS", 1460), ("NOP", None), ("WScale", 7),
("SAckOK", b""), ("Timestamp", (0, 0)),
])
result = _parse_synack(resp)
assert result["options_order"] == "M,N,W,S,T"
def test_ip_id(self):
resp = _make_synack(ip_id=12345)
result = _parse_synack(resp)
assert result["ip_id"] == 12345
def test_empty_options(self):
resp = _make_synack(options=[])
result = _parse_synack(resp)
assert result["mss"] == 0
assert result["window_scale"] == -1
assert result["sack_ok"] == 0
assert result["timestamp"] == 0
assert result["options_order"] == ""
def test_full_linux_fingerprint(self):
"""Typical Linux 5.x+ SYN-ACK."""
resp = _make_synack(
ttl=64, flags=0x02, window=65535,
options=[
("MSS", 1460), ("NOP", None), ("WScale", 7),
("NOP", None), ("NOP", None), ("Timestamp", (0, 0)),
("SAckOK", b""), ("EOL", None),
],
)
result = _parse_synack(resp)
assert result["ttl"] == 64
assert result["df_bit"] == 1
assert result["window_size"] == 65535
assert result["mss"] == 1460
assert result["window_scale"] == 7
assert result["sack_ok"] == 1
assert result["timestamp"] == 1
assert result["options_order"] == "M,N,W,N,N,T,S,E"
# ─── _compute_fingerprint ──────────────────────────────────────────────────
class TestComputeFingerprint:
def test_hash_length_is_32(self):
fields = {
"ttl": 64, "window_size": 65535, "df_bit": 1,
"mss": 1460, "window_scale": 7, "sack_ok": 1,
"timestamp": 1, "options_order": "M,N,W,N,N,T,S,E",
}
raw, h = _compute_fingerprint(fields)
assert len(h) == 32
def test_deterministic(self):
fields = {
"ttl": 64, "window_size": 65535, "df_bit": 1,
"mss": 1460, "window_scale": 7, "sack_ok": 1,
"timestamp": 1, "options_order": "M,N,W,S,T",
}
_, h1 = _compute_fingerprint(fields)
_, h2 = _compute_fingerprint(fields)
assert h1 == h2
def test_different_inputs_different_hashes(self):
f1 = {
"ttl": 64, "window_size": 65535, "df_bit": 1,
"mss": 1460, "window_scale": 7, "sack_ok": 1,
"timestamp": 1, "options_order": "M,N,W,S,T",
}
f2 = {
"ttl": 128, "window_size": 8192, "df_bit": 1,
"mss": 1460, "window_scale": 8, "sack_ok": 1,
"timestamp": 0, "options_order": "M,N,W,N,N,S",
}
_, h1 = _compute_fingerprint(f1)
_, h2 = _compute_fingerprint(f2)
assert h1 != h2
def test_raw_format(self):
fields = {
"ttl": 64, "window_size": 65535, "df_bit": 1,
"mss": 1460, "window_scale": 7, "sack_ok": 1,
"timestamp": 1, "options_order": "M,N,W",
}
raw, _ = _compute_fingerprint(fields)
assert raw == "64:65535:1:1460:7:1:1:M,N,W"
def test_sha256_correctness(self):
fields = {
"ttl": 64, "window_size": 65535, "df_bit": 1,
"mss": 1460, "window_scale": 7, "sack_ok": 1,
"timestamp": 1, "options_order": "M,N,W",
}
raw, h = _compute_fingerprint(fields)
expected = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
assert h == expected
# ─── tcp_fingerprint (end-to-end with mocked scapy) ────────────────────────
class TestTcpFingerprintE2E:
@patch("decnet.prober.tcpfp._send_syn")
def test_success(self, mock_send: MagicMock):
mock_send.return_value = _make_synack(
ttl=64, flags=0x02, window=65535,
options=[
("MSS", 1460), ("NOP", None), ("WScale", 7),
("SAckOK", b""), ("Timestamp", (0, 0)),
],
)
result = tcp_fingerprint("10.0.0.1", 443, timeout=1.0)
assert result is not None
assert len(result["tcpfp_hash"]) == 32
assert result["ttl"] == 64
assert result["window_size"] == 65535
assert result["df_bit"] == 1
assert result["mss"] == 1460
assert result["window_scale"] == 7
assert result["sack_ok"] == 1
assert result["timestamp"] == 1
assert result["options_order"] == "M,N,W,S,T"
@patch("decnet.prober.tcpfp._send_syn")
def test_no_response_returns_none(self, mock_send: MagicMock):
mock_send.return_value = None
assert tcp_fingerprint("10.0.0.1", 443, timeout=1.0) is None
@patch("decnet.prober.tcpfp._send_syn")
def test_windows_fingerprint(self, mock_send: MagicMock):
mock_send.return_value = _make_synack(
ttl=128, flags=0x02, window=8192,
options=[
("MSS", 1460), ("NOP", None), ("WScale", 8),
("NOP", None), ("NOP", None), ("SAckOK", b""),
],
)
result = tcp_fingerprint("10.0.0.1", 443, timeout=1.0)
assert result is not None
assert result["ttl"] == 128
assert result["window_size"] == 8192
assert result["window_scale"] == 8
@patch("decnet.prober.tcpfp._send_syn")
def test_embedded_device_fingerprint(self, mock_send: MagicMock):
"""Embedded devices often have TTL=255, small window, no options."""
mock_send.return_value = _make_synack(
ttl=255, flags=0x00, window=4096,
options=[("MSS", 536)],
)
result = tcp_fingerprint("10.0.0.1", 80, timeout=1.0)
assert result is not None
assert result["ttl"] == 255
assert result["df_bit"] == 0
assert result["window_size"] == 4096
assert result["mss"] == 536
assert result["window_scale"] == -1
assert result["sack_ok"] == 0
@patch("decnet.prober.tcpfp._send_syn")
def test_result_contains_raw_and_hash(self, mock_send: MagicMock):
mock_send.return_value = _make_synack()
result = tcp_fingerprint("10.0.0.1", 443)
assert "tcpfp_hash" in result
assert "tcpfp_raw" in result
assert ":" in result["tcpfp_raw"]
@patch("decnet.prober.tcpfp._send_syn")
def test_deterministic(self, mock_send: MagicMock):
pkt = _make_synack(ttl=64, window=65535)
mock_send.return_value = pkt
r1 = tcp_fingerprint("10.0.0.1", 443)
r2 = tcp_fingerprint("10.0.0.1", 443)
assert r1["tcpfp_hash"] == r2["tcpfp_hash"]
assert r1["tcpfp_raw"] == r2["tcpfp_raw"]

View File

@@ -0,0 +1,480 @@
"""
Tests for the prober worker — target discovery from the log stream and
probe cycle behavior (JARM, HASSH, TCP/IP fingerprinting).
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from decnet.prober.jarm import JARM_EMPTY_HASH
from decnet.prober.worker import (
DEFAULT_PROBE_PORTS,
DEFAULT_SSH_PORTS,
DEFAULT_TCPFP_PORTS,
_discover_attackers,
_probe_cycle,
_write_event,
)
# ─── _discover_attackers ─────────────────────────────────────────────────────
class TestDiscoverAttackers:
def test_discovers_unique_ips(self, tmp_path: Path):
json_file = tmp_path / "decnet.json"
records = [
{"service": "sniffer", "event_type": "tls_client_hello", "attacker_ip": "10.0.0.1", "fields": {}},
{"service": "ssh", "event_type": "login_attempt", "attacker_ip": "10.0.0.2", "fields": {}},
{"service": "sniffer", "event_type": "tls_client_hello", "attacker_ip": "10.0.0.1", "fields": {}}, # dup
]
json_file.write_text("\n".join(json.dumps(r) for r in records) + "\n")
ips, pos = _discover_attackers(json_file, 0)
assert ips == {"10.0.0.1", "10.0.0.2"}
assert pos > 0
def test_skips_prober_events(self, tmp_path: Path):
json_file = tmp_path / "decnet.json"
records = [
{"service": "prober", "event_type": "jarm_fingerprint", "attacker_ip": "10.0.0.99", "fields": {}},
{"service": "ssh", "event_type": "login_attempt", "attacker_ip": "10.0.0.1", "fields": {}},
]
json_file.write_text("\n".join(json.dumps(r) for r in records) + "\n")
ips, _ = _discover_attackers(json_file, 0)
assert "10.0.0.99" not in ips
assert "10.0.0.1" in ips
def test_skips_unknown_ips(self, tmp_path: Path):
json_file = tmp_path / "decnet.json"
records = [
{"service": "sniffer", "event_type": "startup", "attacker_ip": "Unknown", "fields": {}},
]
json_file.write_text("\n".join(json.dumps(r) for r in records) + "\n")
ips, _ = _discover_attackers(json_file, 0)
assert len(ips) == 0
def test_handles_missing_file(self, tmp_path: Path):
json_file = tmp_path / "nonexistent.json"
ips, pos = _discover_attackers(json_file, 0)
assert len(ips) == 0
assert pos == 0
def test_resumes_from_position(self, tmp_path: Path):
json_file = tmp_path / "decnet.json"
line1 = json.dumps({"service": "ssh", "attacker_ip": "10.0.0.1", "fields": {}}) + "\n"
json_file.write_text(line1)
_, pos1 = _discover_attackers(json_file, 0)
# Append more
with open(json_file, "a") as f:
f.write(json.dumps({"service": "ssh", "attacker_ip": "10.0.0.2", "fields": {}}) + "\n")
ips, pos2 = _discover_attackers(json_file, pos1)
assert ips == {"10.0.0.2"} # only the new one
assert pos2 > pos1
def test_handles_file_rotation(self, tmp_path: Path):
json_file = tmp_path / "decnet.json"
# Write enough data to push position well ahead
lines = [json.dumps({"service": "ssh", "attacker_ip": f"10.0.0.{i}", "fields": {}}) + "\n" for i in range(10)]
json_file.write_text("".join(lines))
_, pos = _discover_attackers(json_file, 0)
assert pos > 0
# Simulate rotation — new file is smaller than the old position
json_file.write_text(json.dumps({"service": "ssh", "attacker_ip": "10.0.0.99", "fields": {}}) + "\n")
assert json_file.stat().st_size < pos
ips, new_pos = _discover_attackers(json_file, pos)
assert "10.0.0.99" in ips
def test_handles_malformed_json(self, tmp_path: Path):
json_file = tmp_path / "decnet.json"
json_file.write_text("not valid json\n" + json.dumps({"service": "ssh", "attacker_ip": "10.0.0.1", "fields": {}}) + "\n")
ips, _ = _discover_attackers(json_file, 0)
assert "10.0.0.1" in ips
# ─── _probe_cycle: JARM phase ──────────────────────────────────────────────
class TestProbeCycleJARM:
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_probes_new_ips(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.return_value = "c0c" * 10 + "a" * 32 # fake 62-char hash
mock_hassh.return_value = None
mock_tcpfp.return_value = None
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {}
_probe_cycle(targets, probed, [443, 8443], [], [], log_path, json_path, timeout=1.0)
assert mock_jarm.call_count == 2 # two ports
assert 443 in probed["10.0.0.1"]["jarm"]
assert 8443 in probed["10.0.0.1"]["jarm"]
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_skips_already_probed_ports(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.return_value = "c0c" * 10 + "a" * 32
mock_hassh.return_value = None
mock_tcpfp.return_value = None
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {"10.0.0.1": {"jarm": {443}}}
_probe_cycle(targets, probed, [443, 8443], [], [], log_path, json_path, timeout=1.0)
# Should only probe 8443 (443 already done)
assert mock_jarm.call_count == 1
mock_jarm.assert_called_once_with("10.0.0.1", 8443, timeout=1.0)
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_empty_hash_not_logged(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.return_value = JARM_EMPTY_HASH
mock_hassh.return_value = None
mock_tcpfp.return_value = None
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {}
_probe_cycle(targets, probed, [443], [], [], log_path, json_path, timeout=1.0)
assert 443 in probed["10.0.0.1"]["jarm"]
if json_path.exists():
content = json_path.read_text()
assert "jarm_fingerprint" not in content
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_exception_marks_port_probed(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.side_effect = OSError("Connection refused")
mock_hassh.return_value = None
mock_tcpfp.return_value = None
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {}
_probe_cycle(targets, probed, [443], [], [], log_path, json_path, timeout=1.0)
assert 443 in probed["10.0.0.1"]["jarm"]
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_skips_ip_with_all_ports_done(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {
"10.0.0.1": {"jarm": {443, 8443}, "hassh": set(), "tcpfp": set()},
}
_probe_cycle(targets, probed, [443, 8443], [], [], log_path, json_path, timeout=1.0)
assert mock_jarm.call_count == 0
# ─── _probe_cycle: HASSH phase ─────────────────────────────────────────────
class TestProbeCycleHASSH:
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_probes_ssh_ports(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.return_value = JARM_EMPTY_HASH
mock_hassh.return_value = {
"hassh_server": "a" * 32,
"banner": "SSH-2.0-OpenSSH_8.9p1",
"kex_algorithms": "curve25519-sha256",
"encryption_s2c": "aes256-gcm@openssh.com",
"mac_s2c": "hmac-sha2-256-etm@openssh.com",
"compression_s2c": "none",
}
mock_tcpfp.return_value = None
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {}
_probe_cycle(targets, probed, [], [22, 2222], [], log_path, json_path, timeout=1.0)
assert mock_hassh.call_count == 2
assert 22 in probed["10.0.0.1"]["hassh"]
assert 2222 in probed["10.0.0.1"]["hassh"]
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_hassh_writes_event(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.return_value = JARM_EMPTY_HASH
mock_hassh.return_value = {
"hassh_server": "b" * 32,
"banner": "SSH-2.0-Paramiko_3.0",
"kex_algorithms": "diffie-hellman-group14-sha1",
"encryption_s2c": "aes128-cbc",
"mac_s2c": "hmac-sha1",
"compression_s2c": "none",
}
mock_tcpfp.return_value = None
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {}
_probe_cycle(targets, probed, [], [22], [], log_path, json_path, timeout=1.0)
assert json_path.exists()
content = json_path.read_text()
assert "hassh_fingerprint" in content
record = json.loads(content.strip())
assert record["fields"]["hassh_server_hash"] == "b" * 32
assert record["fields"]["ssh_banner"] == "SSH-2.0-Paramiko_3.0"
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_hassh_none_not_logged(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.return_value = JARM_EMPTY_HASH
mock_hassh.return_value = None # No SSH server
mock_tcpfp.return_value = None
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {}
_probe_cycle(targets, probed, [], [22], [], log_path, json_path, timeout=1.0)
assert 22 in probed["10.0.0.1"]["hassh"]
if json_path.exists():
content = json_path.read_text()
assert "hassh_fingerprint" not in content
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_hassh_skips_already_probed(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.return_value = JARM_EMPTY_HASH
mock_tcpfp.return_value = None
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {"10.0.0.1": {"hassh": {22}}}
_probe_cycle(targets, probed, [], [22, 2222], [], log_path, json_path, timeout=1.0)
assert mock_hassh.call_count == 1 # only 2222
mock_hassh.assert_called_once_with("10.0.0.1", 2222, timeout=1.0)
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_hassh_exception_marks_probed(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.return_value = JARM_EMPTY_HASH
mock_hassh.side_effect = OSError("Connection refused")
mock_tcpfp.return_value = None
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {}
_probe_cycle(targets, probed, [], [22], [], log_path, json_path, timeout=1.0)
assert 22 in probed["10.0.0.1"]["hassh"]
# ─── _probe_cycle: TCPFP phase ─────────────────────────────────────────────
class TestProbeCycleTCPFP:
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_probes_tcpfp_ports(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.return_value = JARM_EMPTY_HASH
mock_hassh.return_value = None
mock_tcpfp.return_value = {
"tcpfp_hash": "d" * 32,
"tcpfp_raw": "64:65535:1:1460:7:1:1:M,N,W,N,N,T,S,E",
"ttl": 64, "window_size": 65535, "df_bit": 1,
"mss": 1460, "window_scale": 7, "sack_ok": 1,
"timestamp": 1, "options_order": "M,N,W,N,N,T,S,E",
}
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {}
_probe_cycle(targets, probed, [], [], [80, 443], log_path, json_path, timeout=1.0)
assert mock_tcpfp.call_count == 2
assert 80 in probed["10.0.0.1"]["tcpfp"]
assert 443 in probed["10.0.0.1"]["tcpfp"]
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_tcpfp_writes_event_with_all_fields(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.return_value = JARM_EMPTY_HASH
mock_hassh.return_value = None
mock_tcpfp.return_value = {
"tcpfp_hash": "e" * 32,
"tcpfp_raw": "128:8192:1:1460:8:1:0:M,N,W,N,N,S",
"ttl": 128, "window_size": 8192, "df_bit": 1,
"mss": 1460, "window_scale": 8, "sack_ok": 1,
"timestamp": 0, "options_order": "M,N,W,N,N,S",
}
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {}
_probe_cycle(targets, probed, [], [], [443], log_path, json_path, timeout=1.0)
content = json_path.read_text()
assert "tcpfp_fingerprint" in content
record = json.loads(content.strip())
assert record["fields"]["tcpfp_hash"] == "e" * 32
assert record["fields"]["ttl"] == "128"
assert record["fields"]["window_size"] == "8192"
assert record["fields"]["options_order"] == "M,N,W,N,N,S"
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_tcpfp_none_not_logged(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.return_value = JARM_EMPTY_HASH
mock_hassh.return_value = None
mock_tcpfp.return_value = None
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {}
_probe_cycle(targets, probed, [], [], [443], log_path, json_path, timeout=1.0)
assert 443 in probed["10.0.0.1"]["tcpfp"]
if json_path.exists():
content = json_path.read_text()
assert "tcpfp_fingerprint" not in content
# ─── Probe type isolation ───────────────────────────────────────────────────
class TestProbeTypeIsolation:
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_jarm_does_not_mark_hassh(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
"""JARM probing port 2222 should not mark HASSH port 2222 as done."""
mock_jarm.return_value = JARM_EMPTY_HASH
mock_hassh.return_value = None
mock_tcpfp.return_value = None
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {}
# Probe with JARM on 2222 and HASSH on 2222
_probe_cycle(targets, probed, [2222], [2222], [], log_path, json_path, timeout=1.0)
# Both should be called
assert mock_jarm.call_count == 1
assert mock_hassh.call_count == 1
assert 2222 in probed["10.0.0.1"]["jarm"]
assert 2222 in probed["10.0.0.1"]["hassh"]
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_all_three_probes_run(self, mock_jarm: MagicMock, mock_hassh: MagicMock,
mock_tcpfp: MagicMock, tmp_path: Path):
mock_jarm.return_value = JARM_EMPTY_HASH
mock_hassh.return_value = None
mock_tcpfp.return_value = None
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, dict[str, set[int]]] = {}
_probe_cycle(targets, probed, [443], [22], [80], log_path, json_path, timeout=1.0)
assert mock_jarm.call_count == 1
assert mock_hassh.call_count == 1
assert mock_tcpfp.call_count == 1
# ─── _write_event ────────────────────────────────────────────────────────────
class TestWriteEvent:
def test_writes_rfc5424_and_json(self, tmp_path: Path):
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
_write_event(log_path, json_path, "test_event", target_ip="10.0.0.1", msg="test")
assert log_path.exists()
assert json_path.exists()
log_content = log_path.read_text()
assert "test_event" in log_content
assert "relay@55555" in log_content
json_content = json_path.read_text()
record = json.loads(json_content.strip())
assert record["event_type"] == "test_event"
assert record["service"] == "prober"
assert record["fields"]["target_ip"] == "10.0.0.1"