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

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

0
tests/prober/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,152 @@
"""Tests for the p0f v2 .fp parser (decnet/prober/osfp/p0f/format.py)."""
from __future__ import annotations
from pathlib import Path
import pytest
from decnet.prober.osfp.p0f.format import P0fParseError, _parse_line, parse_p0f_v2
# ─── Line-parser unit tests ──────────────────────────────────────────────────
def test_parse_line_minimal_literal() -> None:
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:2.6.x kernel")
assert sig.os == "Linux"
assert sig.flavor == "2.6.x kernel"
assert sig.ttl == 64
assert sig.df is True
assert sig.wss.kind == "literal" and sig.wss.value == 5840
assert sig.total_len.kind == "literal" and sig.total_len.value == 60
assert len(sig.options) == 5
# First option: MSS=1460
mss_opt = sig.options[0]
assert mss_opt.kind == "M"
assert mss_opt.value is not None and mss_opt.value.value == 1460
assert sig.quirks == frozenset()
assert not sig.is_userland
def test_parse_line_wildcard_window() -> None:
sig = _parse_line("*:128:1:*:M*,S,T,N,W*:.:Windows:XP SP1+")
assert sig.wss.kind == "any"
assert sig.total_len.kind == "any"
assert sig.options[0].kind == "M"
assert sig.options[0].value is not None and sig.options[0].value.kind == "any"
def test_parse_line_mss_multiple_window() -> None:
sig = _parse_line("S4:64:1:60:M*,S,T,N,W*:.:Linux:generic")
assert sig.wss.kind == "mss_mul" and sig.wss.value == 4
def test_parse_line_mtu_multiple_window() -> None:
sig = _parse_line("T3:64:1:60:M*,S,T,N,W*:.:Solaris:10")
assert sig.wss.kind == "mtu_mul" and sig.wss.value == 3
def test_parse_line_modulo_window() -> None:
sig = _parse_line("%8192:64:1:60:M*,S,T,N,W*:.:Linux:probe")
assert sig.wss.kind == "mod" and sig.wss.value == 8192
def test_parse_line_userland_prefix() -> None:
sig = _parse_line("5840:64:1:60:M*,S,T,N,W*:.:-nmap:syn stealth")
assert sig.is_userland is True
assert sig.os == "nmap"
def test_parse_line_combined_prefixes() -> None:
sig = _parse_line("5840:64:1:60:M*:.:-@Windows:fuzzy match")
assert sig.is_userland is True
assert sig.is_approximate is True
assert sig.os == "Windows"
def test_parse_line_quirks_non_empty() -> None:
sig = _parse_line("5840:64:1:60:M*,S,T,N,W*:PZ:Linux:with quirks")
assert sig.quirks == frozenset({"P", "Z"})
def test_parse_line_no_options_sentinel() -> None:
sig = _parse_line("5840:64:1:60:.:.:Linux:barebones")
assert len(sig.options) == 1
assert sig.options[0].kind == "."
def test_parse_line_t0_timestamp_distinct_from_t() -> None:
sig = _parse_line("5840:64:1:60:M*,T0:.:Linux:broken timestamps")
assert sig.options[1].kind == "T0"
def test_parse_line_unknown_option_number() -> None:
sig = _parse_line("5840:64:1:60:M*,?47:.:Weird:stack")
unknown = sig.options[1]
assert unknown.kind == "?"
assert unknown.value is not None and unknown.value.value == 47
def test_parse_line_rejects_too_few_fields() -> None:
with pytest.raises(P0fParseError):
_parse_line("5840:64:1:60")
def test_parse_line_rejects_bad_df() -> None:
with pytest.raises(P0fParseError):
_parse_line("5840:64:X:60:M*:.:Linux:bad")
def test_parse_line_rejects_bad_window_token() -> None:
with pytest.raises(P0fParseError):
_parse_line("Kfoo:64:1:60:M*:.:Linux:bad")
def test_parse_line_rejects_malformed_option() -> None:
with pytest.raises(P0fParseError):
_parse_line("5840:64:1:60:!!!wat:.:Linux:bad")
# ─── File-level tests ────────────────────────────────────────────────────────
def test_parse_file_skips_comments_blanks_bad_lines(tmp_path: Path) -> None:
fp = tmp_path / "test.fp"
fp.write_text(
"# comment\n"
"\n"
"5840:64:1:60:M1460,S,T,N,W7:.:Linux:2.6.x\n"
"# another comment\n"
"garbage line that should skip\n"
"8192:128:1:48:M1460,N,W0,N,N,S:.:Windows:XP\n"
)
sigs = parse_p0f_v2(fp)
assert len(sigs) == 2
assert {s.os for s in sigs} == {"Linux", "Windows"}
def test_parse_vendored_syn_db_fully_loads() -> None:
"""The full vendored p0f.fp MUST parse without losing signatures.
Upstream inventory: 262 SYN signatures. A regression that drops rows
would silently degrade OS-fingerprint coverage."""
data = Path(__file__).resolve().parents[3] / "decnet/prober/osfp/p0f/data/p0f.fp"
sigs = parse_p0f_v2(data)
assert len(sigs) == 262, f"expected 262 SYN sigs, parser returned {len(sigs)}"
def test_parse_vendored_all_four_dbs_fully_load() -> None:
"""Same invariant across all four vendored databases."""
base = Path(__file__).resolve().parents[3] / "decnet/prober/osfp/p0f/data"
expected = {"p0f.fp": 262, "p0fa.fp": 61, "p0fr.fp": 46, "p0fo.fp": 6}
for name, want in expected.items():
sigs = parse_p0f_v2(base / name)
assert len(sigs) == want, f"{name}: expected {want}, got {len(sigs)}"
def test_parse_vendored_specificity_in_range() -> None:
"""Every signature's computed specificity must land in [0, 1]."""
data = Path(__file__).resolve().parents[3] / "decnet/prober/osfp/p0f/data/p0f.fp"
for sig in parse_p0f_v2(data):
assert 0.0 <= sig.specificity <= 1.0, (
f"{sig.os}/{sig.flavor}: specificity out of range ({sig.specificity})"
)

View File

@@ -0,0 +1,177 @@
"""Integration tests for P0fV2Provider against the vendored .fp data."""
from __future__ import annotations
from pathlib import Path
import pytest
from decnet.prober.osfp import factory, get_all_providers, get_provider
from decnet.prober.osfp.base import OsMatch
from decnet.prober.osfp.p0f.provider import P0fV2Provider
@pytest.fixture(autouse=True)
def _reset_factory_cache():
"""Clean singleton between tests so env overrides take effect."""
factory.reset_cache()
yield
factory.reset_cache()
# ─── Provider-level end-to-end ───────────────────────────────────────────────
def test_provider_loads_all_four_contexts() -> None:
p = P0fV2Provider()
counts = p.signature_counts()
assert counts["syn"] == 262, counts
assert counts["synack"] == 61, counts
assert counts["rst"] == 46, counts
assert counts["stray"] == 6, counts
def test_match_known_linux_26_signature() -> None:
"""Linux 2.6 with window=5840, MSS=1460, wscale=7 is in the
vendored p0f.fp — must resolve to a Linux match."""
p = P0fV2Provider()
obs = {
"window": 5840, "ttl": 64, "df": True, "total_len": 60,
"options_sig": "M1460,S,T,N,W7", "quirks": frozenset(),
"mss": 1460, "wscale": 7, "context": "syn",
}
match = p.match(obs)
assert match is not None
assert match.os == "Linux"
assert match.provider == "p0f-v2"
assert match.confidence > 0.5
def test_match_returns_none_for_unmatchable_observation() -> None:
p = P0fV2Provider()
# Ridiculous values with no corresponding signature.
obs = {
"window": 999999, "ttl": 64, "df": True, "total_len": 9999,
"options_sig": "?255,?254", "quirks": frozenset(),
"mss": 9999, "wscale": 99, "context": "syn",
}
assert p.match(obs) is None
def test_match_unknown_context_returns_none() -> None:
p = P0fV2Provider()
obs = {"window": 5840, "ttl": 64, "df": True, "total_len": 60,
"options_sig": "M1460", "quirks": frozenset(),
"mss": 1460, "context": "impossible"}
assert p.match(obs) is None
def test_match_missing_context_defaults_to_syn() -> None:
p = P0fV2Provider()
obs = {
"window": 5840, "ttl": 64, "df": True, "total_len": 60,
"options_sig": "M1460,S,T,N,W7", "quirks": frozenset(),
"mss": 1460, "wscale": 7,
# no 'context' key
}
match = p.match(obs)
assert match is not None
assert match.os == "Linux"
def test_match_synack_context_uses_p0fa() -> None:
"""Sanity: active-probe SYN-ACK observations resolve against the
61-sig p0fa.fp list, not the 262-sig p0f.fp list.
Targeting "S22:64:1:60:M*,S,T,N,W0:AT:Linux:2.2" from p0fa.fp
(ACK quirk + second-timestamp quirk are characteristic of SYN-ACK
responses, distinguishing these sigs from the plain-SYN DB)."""
p = P0fV2Provider()
obs = {
"window": 22 * 1460, "ttl": 64, "df": True, "total_len": 60,
"options_sig": "M1460,S,T,N,W0",
"quirks": frozenset({"A", "T"}), # ACK-nonzero + T2-nonzero
"mss": 1460, "wscale": 0, "context": "synack",
}
match = p.match(obs)
assert match is not None
assert match.os == "Linux"
def test_match_returns_highest_specificity_not_first() -> None:
"""When multiple signatures can fire, the provider must pick the
most-specific one. Proxy for this: a Linux-style observation that
could be caught by an @generic fallback AND a literal-Linux sig must
resolve to the literal one (higher confidence)."""
p = P0fV2Provider()
obs = {
"window": 5840, "ttl": 64, "df": True, "total_len": 60,
"options_sig": "M1460,S,T,N,W7", "quirks": frozenset(),
"mss": 1460, "wscale": 7, "context": "syn",
}
match = p.match(obs)
# An @generic match would carry is_approximate=True on the underlying
# signature — we can't check that through OsMatch directly, but we can
# check confidence: literal-heavy sigs score notably higher than the
# wildcard-heavy @-fallbacks, so a healthy match is ≥ 0.6.
assert match is not None
assert match.confidence >= 0.6
# ─── Factory dispatch ───────────────────────────────────────────────────────
def test_factory_default_is_p0f_v2() -> None:
p = get_provider()
assert p.name == "p0f-v2"
assert isinstance(p, P0fV2Provider)
def test_factory_is_memoised() -> None:
assert get_provider() is get_provider()
def test_factory_get_all_providers_returns_list() -> None:
providers = get_all_providers()
assert len(providers) >= 1
assert providers[0].name == "p0f-v2"
def test_factory_env_override_chain(monkeypatch: pytest.MonkeyPatch) -> None:
"""Multi-provider chain must preserve declared order."""
monkeypatch.setenv("DECNET_OSFP_PROVIDERS", "p0f-v2")
factory.reset_cache()
providers = get_all_providers()
assert [p.name for p in providers] == ["p0f-v2"]
def test_factory_unsupported_name_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DECNET_OSFP_PROVIDERS", "nonexistent-source")
factory.reset_cache()
with pytest.raises(ValueError):
get_provider()
def test_factory_reserved_names_raise_not_implemented(monkeypatch: pytest.MonkeyPatch) -> None:
"""nmap-osdb and decnet-observed are reserved for future work; the
factory must fail loud rather than silently."""
for reserved in ("nmap-osdb", "decnet-observed"):
monkeypatch.setenv("DECNET_OSFP_PROVIDERS", reserved)
factory.reset_cache()
with pytest.raises(NotImplementedError):
get_provider()
# ─── OsMatch surface ────────────────────────────────────────────────────────
def test_osmatch_str_shows_provider() -> None:
match = OsMatch(os="Linux", flavor="2.6", confidence=0.8, provider="p0f-v2")
s = str(match)
assert "Linux" in s and "2.6" in s and "p0f-v2" in s
def test_osmatch_userland_flag_marks_scanner() -> None:
match = OsMatch(os="nmap", flavor="syn-stealth", confidence=0.9,
provider="p0f-v2", is_userland=True)
assert match.is_userland
assert "userland" in str(match).lower()

View File

@@ -0,0 +1,140 @@
"""Tests for signature matching + scoring."""
from __future__ import annotations
import pytest
from decnet.prober.osfp.p0f.format import _parse_line
def _obs(**overrides):
"""Baseline observation (Linux 2.6 on Ethernet), overridable."""
base = {
"window": 5840,
"ttl": 64,
"df": True,
"total_len": 60,
"options_sig": "M1460,S,T,N,W7",
"quirks": frozenset(),
"mss": 1460,
"wscale": 7,
}
base.update(overrides)
return base
# ─── Match / no-match ────────────────────────────────────────────────────────
def test_score_exact_match_is_high() -> None:
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:2.6.x literal")
score = sig.score(_obs())
assert score is not None
assert score >= 0.9, f"literal-fields signature should score high, got {score}"
def test_score_wildcard_match_is_lower_than_literal() -> None:
literal = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:literal")
wildcard = _parse_line("*:64:1:*:M*,S,T,N,W*:.:Linux:wildcard")
obs = _obs()
ls = literal.score(obs)
ws = wildcard.score(obs)
assert ls is not None and ws is not None
assert ls > ws, f"literal ({ls}) should outscore wildcard ({ws})"
def test_score_window_mismatch_returns_none() -> None:
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:fixed")
assert sig.score(_obs(window=64240)) is None
def test_score_ttl_mismatch_returns_none() -> None:
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:ttl64")
assert sig.score(_obs(ttl=128)) is None
def test_score_df_mismatch_returns_none() -> None:
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:df-required")
assert sig.score(_obs(df=False)) is None
def test_score_df_wildcard_on_signature_matches_either() -> None:
sig = _parse_line("5840:64:*:60:M1460,S,T,N,W7:.:Linux:any-df")
assert sig.score(_obs(df=True)) is not None
assert sig.score(_obs(df=False)) is not None
def test_score_df_none_on_observation_is_soft_skip() -> None:
"""When the observation lacks df (sniffer doesn't emit it today),
a signature with a specific df constraint must still match rather
than hard-reject. Rationale in the score() docstring."""
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:df-required")
assert sig.score(_obs(df=None)) is not None
def test_score_total_len_none_on_observation_is_soft_skip() -> None:
"""Same soft-field semantics for total_len — the profiler adapter
passes None when the sniffer / prober didn't capture it."""
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:len-specific")
assert sig.score(_obs(total_len=None)) is not None
def test_score_options_order_mismatch_returns_none() -> None:
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:ordered")
# Same tokens, different order — must NOT match.
assert sig.score(_obs(options_sig="S,T,M1460,N,W7")) is None
def test_score_options_missing_token_returns_none() -> None:
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:5opts")
assert sig.score(_obs(options_sig="M1460,S,T,N")) is None
def test_score_quirks_must_match_as_set() -> None:
sig = _parse_line("5840:64:1:60:M*,S,T,N,W*:PZ:Linux:with PZ")
assert sig.score(_obs(quirks=frozenset({"P", "Z"}))) is not None
assert sig.score(_obs(quirks=frozenset({"P"}))) is None # missing Z
assert sig.score(_obs(quirks=frozenset({"P", "Z", "I"}))) is None # extra I
def test_score_mss_multiple_window() -> None:
# S4 = 4 * MSS. With MSS=1460 → window=5840.
sig = _parse_line("S4:64:1:60:M1460,S,T,N,W7:.:Linux:S4")
assert sig.score(_obs(window=5840, mss=1460)) is not None
# With MSS=536 → S4 expects window=2144
assert sig.score(_obs(window=2144, mss=536)) is not None
assert sig.score(_obs(window=5840, mss=536)) is None
def test_score_modulo_window() -> None:
sig = _parse_line("%8192:64:1:60:M1460,S,T,N,W7:.:Linux:mod8192")
assert sig.score(_obs(window=32768)) is not None
assert sig.score(_obs(window=40960)) is not None
assert sig.score(_obs(window=32769)) is None
def test_score_no_options_sentinel() -> None:
sig = _parse_line("5840:64:1:60:.:.:Linux:no-opts")
assert sig.score(_obs(options_sig="")) is not None
assert sig.score(_obs(options_sig=None)) is not None
assert sig.score(_obs(options_sig="M1460")) is None
def test_score_missing_observation_fields_returns_none() -> None:
"""A signature that requires a specific window can't match when the
observation has no window. This is the safety invariant —
sniffer_rollup may call score() with partial data."""
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:strict")
assert sig.score(_obs(window=None)) is None
assert sig.score(_obs(ttl=None)) is None
def test_score_option_value_wildcard_matches_any_literal() -> None:
sig = _parse_line("5840:64:1:60:M*,S,T,N,W*:.:Linux:wild-mss-wscale")
assert sig.score(_obs(options_sig="M1460,S,T,N,W7")) is not None
assert sig.score(_obs(options_sig="M536,S,T,N,W2")) is not None
def test_score_option_value_modulo() -> None:
sig = _parse_line("5840:64:1:60:M%4,S,T,N,W7:.:Linux:mss-mod-4")
assert sig.score(_obs(options_sig="M1460,S,T,N,W7")) is not None # 1460 % 4 == 0
assert sig.score(_obs(options_sig="M1461,S,T,N,W7")) is None

View File

@@ -0,0 +1,356 @@
"""
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"
# ─── TLS certificate bounty extraction (active prober) ─────────────────────
@pytest.mark.asyncio
async def test_tls_certificate_bounty_extracted_from_prober():
"""Prober event with subject_cn should create a tls_certificate bounty
against the probe target IP (not the prober's own attacker_ip)."""
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",
"subject_cn": "evil.example.com",
"issuer": "CN=evil.example.com",
"self_signed": True,
"not_before": "2026-01-01T00:00:00Z",
"not_after": "2027-01-01T00:00:00Z",
"sans": ["evil.example.com", "c2.example.com"],
"cert_sha256": "ab" * 32,
},
"msg": "JARM+cert 10.0.0.1:443",
}
await _extract_bounty(repo, log_data)
cert_calls = [
c for c in repo.add_bounty.call_args_list
if c[0][0].get("payload", {}).get("fingerprint_type") == "tls_certificate"
]
assert len(cert_calls) == 1
bounty = cert_calls[0][0][0]
assert bounty["service"] == "prober"
assert bounty["attacker_ip"] == "10.0.0.1"
payload = bounty["payload"]
assert payload["subject_cn"] == "evil.example.com"
assert payload["issuer"] == "CN=evil.example.com"
assert payload["self_signed"] is True
assert payload["not_before"] == "2026-01-01T00:00:00Z"
assert payload["not_after"] == "2027-01-01T00:00:00Z"
assert payload["sans"] == ["evil.example.com", "c2.example.com"]
assert payload["cert_sha256"] == "ab" * 32
assert payload["target_ip"] == "10.0.0.1"
assert payload["target_port"] == "443"
@pytest.mark.asyncio
async def test_tls_certificate_bounty_not_extracted_without_subject_cn():
"""Prober event without subject_cn should not produce a tls_certificate
bounty (e.g. JARM-only run on a non-TLS port or handshake failure)."""
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": "c" * 62,
},
"msg": "JARM only",
}
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") != "tls_certificate"
@pytest.mark.asyncio
async def test_tls_certificate_prober_clause_disjoint_from_sniffer():
"""The prober clause must not steal sniffer-side cert events: a sniffer
log carrying subject_cn must still be attributed to the sniffer
(attacker_ip from the top-level field, not target_ip)."""
repo = _make_repo()
log_data = {
"decky": "decky-01",
"service": "sniffer",
"event_type": "tls_certificate",
"attacker_ip": "192.168.1.50",
"fields": {
"subject_cn": "real-attacker-cert.example",
"issuer": "Self",
"self_signed": True,
"not_before": "2026-01-01T00:00:00Z",
"not_after": "2027-01-01T00:00:00Z",
"sans": [],
"sni": "victim.local",
},
"msg": "",
}
await _extract_bounty(repo, log_data)
cert_calls = [
c for c in repo.add_bounty.call_args_list
if c[0][0].get("payload", {}).get("fingerprint_type") == "tls_certificate"
]
# Exactly one — the sniffer clause, not duplicated by the prober clause.
assert len(cert_calls) == 1
bounty = cert_calls[0][0][0]
assert bounty["service"] == "sniffer"
assert bounty["attacker_ip"] == "192.168.1.50"
# Sniffer payload carries `sni`; prober payload does not.
assert bounty["payload"].get("sni") == "victim.local"
assert "cert_sha256" not in bounty["payload"]

View File

@@ -0,0 +1,183 @@
"""Bus wiring for the attacker prober (DEBT-031, worker 2).
The prober fingerprints observed attackers (JARM / HASSH / TCPfp) in a
``to_thread`` worker. On each successful probe it publishes an
``attacker.fingerprinted`` event under the shared attacker root; the
probe family (jarm/hassh/tcpfp) goes in ``event.type`` so a single
subscription to ``attacker.fingerprinted`` covers all three.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
import pytest_asyncio
from decnet.bus import topics as _topics
from decnet.bus.fake import FakeBus
from decnet.bus.publish import make_thread_safe_publisher
from decnet.prober.worker import _jarm_phase, _hassh_phase, _tcpfp_phase
@pytest_asyncio.fixture
async def bus() -> FakeBus:
b = FakeBus()
await b.connect()
yield b
await b.close()
# ─── Phase-level publish hooks ───────────────────────────────────────────────
def test_jarm_phase_invokes_publish_fn_on_success(monkeypatch, tmp_path: Path) -> None:
captured: list[tuple[str, dict]] = []
# Stub jarm_hash so the test doesn't touch the network.
from decnet.prober import worker as worker_mod
monkeypatch.setattr(worker_mod, "jarm_hash", lambda ip, port, timeout: "aabbcc")
_jarm_phase(
ip="203.0.113.9",
ip_probed={},
ports=[443],
log_path=tmp_path / "p.log",
json_path=tmp_path / "p.json",
timeout=1.0,
publish_fn=lambda event_type, payload: captured.append((event_type, payload)),
)
assert captured == [
("jarm", {"attacker_ip": "203.0.113.9", "port": 443, "jarm_hash": "aabbcc"}),
]
def test_jarm_phase_skips_empty_hash(monkeypatch, tmp_path: Path) -> None:
# JARM's empty-hash sentinel means "target didn't negotiate TLS" — not
# an observation worth publishing.
captured: list[tuple[str, dict]] = []
from decnet.prober import worker as worker_mod
from decnet.prober.jarm import JARM_EMPTY_HASH
monkeypatch.setattr(worker_mod, "jarm_hash", lambda ip, port, timeout: JARM_EMPTY_HASH)
_jarm_phase(
ip="1.2.3.4", ip_probed={}, ports=[443],
log_path=tmp_path / "p.log", json_path=tmp_path / "p.json", timeout=1.0,
publish_fn=lambda event_type, payload: captured.append((event_type, payload)),
)
assert captured == []
def test_hassh_phase_invokes_publish_fn_on_success(monkeypatch, tmp_path: Path) -> None:
captured: list[tuple[str, dict]] = []
from decnet.prober import worker as worker_mod
monkeypatch.setattr(
worker_mod, "hassh_server",
lambda ip, port, timeout: {
"hassh_server": "deadbeef",
"banner": "SSH-2.0-OpenSSH_9.0",
"kex_algorithms": "x",
"encryption_s2c": "y",
"mac_s2c": "z",
"compression_s2c": "none",
},
)
_hassh_phase(
ip="1.2.3.4", ip_probed={}, ports=[22],
log_path=tmp_path / "p.log", json_path=tmp_path / "p.json", timeout=1.0,
publish_fn=lambda event_type, payload: captured.append((event_type, payload)),
)
assert captured == [
("hassh", {
"attacker_ip": "1.2.3.4",
"port": 22,
"hassh_server": "deadbeef",
"ssh_banner": "SSH-2.0-OpenSSH_9.0",
}),
]
def test_tcpfp_phase_invokes_publish_fn_on_success(monkeypatch, tmp_path: Path) -> None:
captured: list[tuple[str, dict]] = []
from decnet.prober import worker as worker_mod
monkeypatch.setattr(
worker_mod, "tcp_fingerprint",
lambda ip, port, timeout: {
"tcpfp_hash": "cafef00d",
"tcpfp_raw": "raw",
"ttl": 64,
"window_size": 29200,
"df_bit": True,
"mss": 1460,
"window_scale": 7,
"sack_ok": True,
"timestamp": True,
"options_order": "mss,sack,ts,nop,wscale",
"tos": 0,
"dscp": 0,
"ecn": 0,
"server_isn": 0,
},
)
_tcpfp_phase(
ip="1.2.3.4", ip_probed={}, ports=[80],
log_path=tmp_path / "p.log", json_path=tmp_path / "p.json", timeout=1.0,
publish_fn=lambda event_type, payload: captured.append((event_type, payload)),
)
assert captured == [
("tcpfp", {
"attacker_ip": "1.2.3.4", "port": 80,
"tcpfp_hash": "cafef00d", "ttl": 64, "mss": 1460,
}),
]
def test_phases_run_unchanged_without_publish_fn(monkeypatch, tmp_path: Path) -> None:
# Pre-bus behavior must stay intact when publish_fn is None. The
# phase still writes its log file and marks the port done — it just
# doesn't publish.
from decnet.prober import worker as worker_mod
monkeypatch.setattr(worker_mod, "jarm_hash", lambda ip, port, timeout: "aabbcc")
ip_probed: dict[str, set[int]] = {}
_jarm_phase(
ip="1.2.3.4", ip_probed=ip_probed, ports=[443],
log_path=tmp_path / "p.log", json_path=tmp_path / "p.json", timeout=1.0,
publish_fn=None,
)
assert 443 in ip_probed["jarm"]
# ─── End-to-end through the bus ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_prober_publishes_on_attacker_fingerprinted_topic(bus: FakeBus) -> None:
loop = asyncio.get_running_loop()
raw = make_thread_safe_publisher(bus, loop)
def publish(event_type: str, payload: dict) -> None:
raw(_topics.attacker(_topics.ATTACKER_FINGERPRINTED), payload, event_type)
sub = bus.subscribe("attacker.fingerprinted")
async with sub:
publish("jarm", {"attacker_ip": "1.2.3.4", "port": 443, "jarm_hash": "h"})
event = await asyncio.wait_for(sub.__anext__(), timeout=2.0)
assert event.topic == "attacker.fingerprinted"
assert event.type == "jarm"
assert event.payload == {"attacker_ip": "1.2.3.4", "port": 443, "jarm_hash": "h"}
@pytest.mark.asyncio
async def test_prober_degrades_cleanly_when_bus_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
# DECNET_BUS_ENABLED=false returns NullBus; connect() + publish() must
# be no-op and never raise.
from decnet.bus.factory import get_bus
monkeypatch.setenv("DECNET_BUS_ENABLED", "false")
b = get_bus(client_name="prober")
await b.connect()
await b.publish("attacker.fingerprinted", {"x": 1}, event_type="jarm")
await b.close()

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,393 @@
"""
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,
tos: int = 0,
window: int = 65535,
tcp_flags: int = 0x12, # SYN-ACK
options: list | None = None,
ack: int = 1,
seq: int = 0,
) -> 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,
seq=seq,
)
ip_layer = SimpleNamespace(
ttl=ttl,
flags=flags,
id=ip_id,
tos=tos,
)
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_tos_default_zero(self):
resp = _make_synack()
result = _parse_synack(resp)
assert result["tos"] == 0
assert result["dscp"] == 0
assert result["ecn"] == 0
def test_tos_dscp_af11_ecn_ect0(self):
# AF11 = DSCP 10 (0b001010); ECT(0) = 0b10 → ToS byte 0b00101010 = 0x2A
resp = _make_synack(tos=0x2A)
result = _parse_synack(resp)
assert result["tos"] == 0x2A
assert result["dscp"] == 10
assert result["ecn"] == 2
def test_server_isn_captured(self):
resp = _make_synack(seq=0xDEADBEEF)
result = _parse_synack(resp)
assert result["server_isn"] == 0xDEADBEEF
def test_tos_ce_marked(self):
# ECN CE bit set, no DSCP marking → ToS = 0x03
resp = _make_synack(tos=0x03)
result = _parse_synack(resp)
assert result["dscp"] == 0
assert result["ecn"] == 3
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",
"dscp": 0, "ecn": 0,
}
raw, _ = _compute_fingerprint(fields)
assert raw == "64:65535:1:1460:7:1:1:M,N,W:0:0"
def test_dscp_changes_hash(self):
base = {
"ttl": 64, "window_size": 65535, "df_bit": 1,
"mss": 1460, "window_scale": 7, "sack_ok": 1,
"timestamp": 1, "options_order": "M,N,W",
"dscp": 0, "ecn": 0,
}
marked = dict(base, dscp=46) # EF
_, h_base = _compute_fingerprint(base)
_, h_marked = _compute_fingerprint(marked)
assert h_base != h_marked
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,165 @@
"""Unit tests for ``decnet.prober.tlscert``.
DER fixtures are synthesized at runtime via ``cryptography`` so we don't
ship a binary blob; failure modes (truncated DER, missing extensions)
are exercised against those fixtures.
"""
from __future__ import annotations
import datetime as dt
import hashlib
import socket
import ssl
from unittest.mock import MagicMock, patch
import pytest
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from decnet.prober.tlscert import fetch_leaf_cert, parse_leaf_cert
def _build_self_signed_der(
cn: str = "evil.example.com",
sans: list[str] | None = None,
issuer_cn: str | None = None,
) -> bytes:
"""Build a fresh self-signed DER cert. ``issuer_cn`` defaults to ``cn``
(true self-signed); pass a different value to simulate a CA-issued cert."""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
issuer = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, issuer_cn or cn),
])
builder = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(1)
.not_valid_before(dt.datetime(2026, 1, 1))
.not_valid_after(dt.datetime(2027, 1, 1))
)
if sans:
builder = builder.add_extension(
x509.SubjectAlternativeName([x509.DNSName(s) for s in sans]),
critical=False,
)
cert = builder.sign(key, hashes.SHA256())
return cert.public_bytes(serialization.Encoding.DER)
class TestParseLeafCert:
def test_self_signed_cert_with_sans(self):
der = _build_self_signed_der(
cn="evil.example.com",
sans=["evil.example.com", "c2.example.com"],
)
result = parse_leaf_cert(der)
assert result is not None
assert result["subject_cn"] == "evil.example.com"
assert "evil.example.com" in result["issuer"]
assert result["self_signed"] is True
assert result["not_before"] == "2026-01-01T00:00:00Z"
assert result["not_after"] == "2027-01-01T00:00:00Z"
assert set(result["sans"]) == {"evil.example.com", "c2.example.com"}
assert result["cert_sha256"] == hashlib.sha256(der).hexdigest()
def test_cert_without_sans(self):
der = _build_self_signed_der(cn="single.example", sans=None)
result = parse_leaf_cert(der)
assert result is not None
assert result["sans"] == []
def test_ca_issued_cert_not_self_signed(self):
der = _build_self_signed_der(cn="leaf.example", issuer_cn="ca.example")
result = parse_leaf_cert(der)
assert result is not None
assert result["self_signed"] is False
def test_garbage_der_returns_none(self):
assert parse_leaf_cert(b"\x00\x01\x02\x03 not a cert") is None
def test_empty_bytes_returns_none(self):
assert parse_leaf_cert(b"") is None
class TestFetchLeafCert:
@patch("decnet.prober.tlscert.ssl.create_default_context")
@patch("decnet.prober.tlscert.socket.create_connection")
def test_returns_parsed_cert_on_success(
self, mock_conn: MagicMock, mock_ctx_factory: MagicMock,
):
der = _build_self_signed_der(cn="ok.example", sans=["ok.example"])
# Mock the socket context manager
mock_socket = MagicMock()
mock_socket.__enter__ = MagicMock(return_value=mock_socket)
mock_socket.__exit__ = MagicMock(return_value=False)
mock_conn.return_value = mock_socket
# Mock the SSLSocket returned by wrap_socket
mock_tls = MagicMock()
mock_tls.__enter__ = MagicMock(return_value=mock_tls)
mock_tls.__exit__ = MagicMock(return_value=False)
mock_tls.getpeercert = MagicMock(return_value=der)
mock_ctx = MagicMock()
mock_ctx.wrap_socket = MagicMock(return_value=mock_tls)
mock_ctx_factory.return_value = mock_ctx
result = fetch_leaf_cert("10.0.0.1", 443, timeout=1.0)
assert result is not None
assert result["subject_cn"] == "ok.example"
@patch("decnet.prober.tlscert.socket.create_connection")
def test_connect_failure_returns_none(self, mock_conn: MagicMock):
mock_conn.side_effect = OSError("Connection refused")
assert fetch_leaf_cert("10.0.0.1", 443, timeout=1.0) is None
@patch("decnet.prober.tlscert.socket.create_connection")
def test_handshake_timeout_returns_none(self, mock_conn: MagicMock):
mock_conn.side_effect = socket.timeout()
assert fetch_leaf_cert("10.0.0.1", 443, timeout=1.0) is None
@patch("decnet.prober.tlscert.ssl.create_default_context")
@patch("decnet.prober.tlscert.socket.create_connection")
def test_ssl_error_returns_none(
self, mock_conn: MagicMock, mock_ctx_factory: MagicMock,
):
mock_socket = MagicMock()
mock_socket.__enter__ = MagicMock(return_value=mock_socket)
mock_socket.__exit__ = MagicMock(return_value=False)
mock_conn.return_value = mock_socket
mock_ctx = MagicMock()
mock_ctx.wrap_socket = MagicMock(side_effect=ssl.SSLError("handshake failed"))
mock_ctx_factory.return_value = mock_ctx
assert fetch_leaf_cert("10.0.0.1", 443, timeout=1.0) is None
@patch("decnet.prober.tlscert.ssl.create_default_context")
@patch("decnet.prober.tlscert.socket.create_connection")
def test_empty_peer_cert_returns_none(
self, mock_conn: MagicMock, mock_ctx_factory: MagicMock,
):
mock_socket = MagicMock()
mock_socket.__enter__ = MagicMock(return_value=mock_socket)
mock_socket.__exit__ = MagicMock(return_value=False)
mock_conn.return_value = mock_socket
mock_tls = MagicMock()
mock_tls.__enter__ = MagicMock(return_value=mock_tls)
mock_tls.__exit__ = MagicMock(return_value=False)
mock_tls.getpeercert = MagicMock(return_value=b"")
mock_ctx = MagicMock()
mock_ctx.wrap_socket = MagicMock(return_value=mock_tls)
mock_ctx_factory.return_value = mock_ctx
assert fetch_leaf_cert("10.0.0.1", 443, timeout=1.0) is None

View File

@@ -0,0 +1,659 @@
"""
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.fetch_leaf_cert", return_value=None)
@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, mock_cert: 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.fetch_leaf_cert", return_value=None)
@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, mock_cert: 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",
"tos": 0, "dscp": 0, "ecn": 0, "server_isn": 0,
}
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",
"tos": 0, "dscp": 0, "ecn": 0, "server_isn": 0,
}
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"
# ─── _probe_cycle: TLS certificate capture (after JARM) ───────────────────
class TestProbeCycleTLSCert:
@patch("decnet.prober.worker.fetch_leaf_cert")
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_cert_event_emitted_after_successful_jarm(
self,
mock_jarm: MagicMock,
mock_hassh: MagicMock,
mock_tcpfp: MagicMock,
mock_cert: MagicMock,
tmp_path: Path,
):
"""A non-empty JARM hash should trigger a follow-up cert fetch and
write a tls_certificate event with all parsed fields."""
mock_jarm.return_value = "c0c" * 10 + "a" * 32
mock_hassh.return_value = None
mock_tcpfp.return_value = None
mock_cert.return_value = {
"subject_cn": "evil.example.com",
"issuer": "CN=evil.example.com",
"self_signed": True,
"not_before": "2026-01-01T00:00:00Z",
"not_after": "2027-01-01T00:00:00Z",
"sans": ["evil.example.com", "c2.example.com"],
"cert_sha256": "ab" * 32,
}
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
_probe_cycle({"10.0.0.1"}, {}, [443], [], [], log_path, json_path, timeout=1.0)
mock_cert.assert_called_once_with("10.0.0.1", 443, timeout=1.0)
records = [
json.loads(line)
for line in json_path.read_text().splitlines() if line
]
cert_records = [r for r in records if r["event_type"] == "tls_certificate"]
assert len(cert_records) == 1
f = cert_records[0]["fields"]
assert f["target_ip"] == "10.0.0.1"
assert f["target_port"] == "443"
assert f["subject_cn"] == "evil.example.com"
assert f["issuer"] == "CN=evil.example.com"
assert f["self_signed"] == "true"
assert f["not_before"] == "2026-01-01T00:00:00Z"
assert f["not_after"] == "2027-01-01T00:00:00Z"
assert f["sans"] == "evil.example.com,c2.example.com"
assert f["cert_sha256"] == "ab" * 32
@patch("decnet.prober.worker.fetch_leaf_cert")
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_cert_fetch_skipped_on_empty_jarm(
self,
mock_jarm: MagicMock,
mock_hassh: MagicMock,
mock_tcpfp: MagicMock,
mock_cert: MagicMock,
tmp_path: Path,
):
"""JARM_EMPTY_HASH means the port doesn't speak TLS; skip cert fetch."""
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"
_probe_cycle({"10.0.0.1"}, {}, [443], [], [], log_path, json_path, timeout=1.0)
mock_cert.assert_not_called()
@patch("decnet.prober.worker.fetch_leaf_cert", return_value=None)
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_cert_fetch_failure_silent(
self,
mock_jarm: MagicMock,
mock_hassh: MagicMock,
mock_tcpfp: MagicMock,
mock_cert: MagicMock,
tmp_path: Path,
):
"""fetch_leaf_cert returning None must not write a cert event."""
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"
_probe_cycle({"10.0.0.1"}, {}, [443], [], [], log_path, json_path, timeout=1.0)
mock_cert.assert_called_once_with("10.0.0.1", 443, timeout=1.0)
if json_path.exists():
content = json_path.read_text()
assert "tls_certificate" not in content
@patch("decnet.prober.worker.fetch_leaf_cert")
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_cert_fetch_crash_does_not_break_phase(
self,
mock_jarm: MagicMock,
mock_hassh: MagicMock,
mock_tcpfp: MagicMock,
mock_cert: MagicMock,
tmp_path: Path,
):
"""If fetch_leaf_cert throws despite its contract, the JARM phase
must keep moving to the next port without crashing."""
mock_jarm.return_value = "c0c" * 10 + "a" * 32
mock_hassh.return_value = None
mock_tcpfp.return_value = None
mock_cert.side_effect = RuntimeError("unexpected")
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
_probe_cycle({"10.0.0.1"}, {}, [443, 8443], [], [], log_path, json_path, timeout=1.0)
# Both ports still marked probed despite the cert-side crash.
from decnet.prober.worker import _probe_cycle as _ # re-import safety
assert mock_cert.call_count == 2
@patch("decnet.prober.worker.fetch_leaf_cert")
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server")
@patch("decnet.prober.worker.jarm_hash")
def test_cert_publish_fn_called(
self,
mock_jarm: MagicMock,
mock_hassh: MagicMock,
mock_tcpfp: MagicMock,
mock_cert: MagicMock,
tmp_path: Path,
):
"""publish_fn must receive a 'tls_certificate' event when capture succeeds."""
mock_jarm.return_value = "c0c" * 10 + "a" * 32
mock_hassh.return_value = None
mock_tcpfp.return_value = None
mock_cert.return_value = {
"subject_cn": "cn",
"issuer": "CN=cn",
"self_signed": True,
"not_before": "2026-01-01T00:00:00Z",
"not_after": "2027-01-01T00:00:00Z",
"sans": [],
"cert_sha256": "cd" * 32,
}
published: list[tuple[str, dict]] = []
def publish(kind: str, payload: dict) -> None:
published.append((kind, payload))
_probe_cycle(
{"10.0.0.1"}, {}, [443], [], [],
tmp_path / "decnet.log", tmp_path / "decnet.json",
timeout=1.0, publish_fn=publish,
)
cert_pubs = [p for p in published if p[0] == "tls_certificate"]
assert len(cert_pubs) == 1
assert cert_pubs[0][1]["attacker_ip"] == "10.0.0.1"
assert cert_pubs[0][1]["port"] == 443
assert cert_pubs[0][1]["cert_sha256"] == "cd" * 32
assert cert_pubs[0][1]["self_signed"] is True