feat: add HASSHServer and TCP/IP stack fingerprinting to DECNET-PROBER
Extends the prober with two new active probe types alongside JARM: - HASSHServer: SSH server fingerprinting via KEX_INIT algorithm ordering (MD5 hash of kex;enc_s2c;mac_s2c;comp_s2c, pure stdlib) - TCP/IP stack: OS/tool fingerprinting via SYN-ACK analysis using scapy (TTL, window size, DF bit, MSS, TCP options ordering, SHA256 hash) Worker probe cycle now runs three phases per IP with independent per-type port tracking. Ingester extracts bounties for all three fingerprint types.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
Tests for JARM bounty extraction in the ingester.
|
||||
Tests for prober bounty extraction in the ingester.
|
||||
|
||||
Verifies that _extract_bounty() correctly identifies and stores JARM
|
||||
fingerprints from prober events, and ignores JARM fields from other services.
|
||||
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
|
||||
@@ -112,3 +113,131 @@ async def test_jarm_bounty_missing_fields_dict():
|
||||
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"
|
||||
|
||||
357
tests/test_prober_hassh.py
Normal file
357
tests/test_prober_hassh.py
Normal 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
|
||||
349
tests/test_prober_tcpfp.py
Normal file
349
tests/test_prober_tcpfp.py
Normal 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"]
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Tests for the prober worker — target discovery from the log stream and
|
||||
probe cycle behavior.
|
||||
probe cycle behavior (JARM, HASSH, TCP/IP fingerprinting).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,6 +14,8 @@ 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,
|
||||
@@ -103,86 +105,357 @@ class TestDiscoverAttackers:
|
||||
assert "10.0.0.1" in ips
|
||||
|
||||
|
||||
# ─── _probe_cycle ────────────────────────────────────────────────────────────
|
||||
# ─── _probe_cycle: JARM phase ──────────────────────────────────────────────
|
||||
|
||||
class TestProbeCycle:
|
||||
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, tmp_path: Path):
|
||||
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, set[int]] = {}
|
||||
probed: dict[str, dict[str, set[int]]] = {}
|
||||
|
||||
_probe_cycle(targets, probed, [443, 8443], log_path, json_path, timeout=1.0)
|
||||
_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"]
|
||||
assert 8443 in probed["10.0.0.1"]
|
||||
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, tmp_path: Path):
|
||||
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, set[int]] = {"10.0.0.1": {443}}
|
||||
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)
|
||||
_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, tmp_path: Path):
|
||||
"""All-zeros JARM hash (no TLS server) should not be written as a jarm_fingerprint event."""
|
||||
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, set[int]] = {}
|
||||
probed: dict[str, dict[str, set[int]]] = {}
|
||||
|
||||
_probe_cycle(targets, probed, [443], log_path, json_path, timeout=1.0)
|
||||
_probe_cycle(targets, probed, [443], [], [], log_path, json_path, timeout=1.0)
|
||||
|
||||
# Port should be marked as probed
|
||||
assert 443 in probed["10.0.0.1"]
|
||||
# But no jarm_fingerprint event should be written
|
||||
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, tmp_path: Path):
|
||||
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, set[int]] = {}
|
||||
probed: dict[str, dict[str, set[int]]] = {}
|
||||
|
||||
_probe_cycle(targets, probed, [443], log_path, json_path, timeout=1.0)
|
||||
_probe_cycle(targets, probed, [443], [], [], log_path, json_path, timeout=1.0)
|
||||
|
||||
# Port marked as probed to avoid infinite retries
|
||||
assert 443 in probed["10.0.0.1"]
|
||||
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, tmp_path: Path):
|
||||
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, set[int]] = {"10.0.0.1": {443, 8443}}
|
||||
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)
|
||||
_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:
|
||||
|
||||
Reference in New Issue
Block a user