merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/prober/__init__.py
Normal file
0
tests/prober/__init__.py
Normal file
0
tests/prober/osfp/__init__.py
Normal file
0
tests/prober/osfp/__init__.py
Normal file
152
tests/prober/osfp/test_format.py
Normal file
152
tests/prober/osfp/test_format.py
Normal 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})"
|
||||
)
|
||||
177
tests/prober/osfp/test_provider.py
Normal file
177
tests/prober/osfp/test_provider.py
Normal 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()
|
||||
140
tests/prober/osfp/test_signature.py
Normal file
140
tests/prober/osfp/test_signature.py
Normal 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
|
||||
356
tests/prober/test_prober_bounty.py
Normal file
356
tests/prober/test_prober_bounty.py
Normal 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"]
|
||||
183
tests/prober/test_prober_bus.py
Normal file
183
tests/prober/test_prober_bus.py
Normal 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()
|
||||
357
tests/prober/test_prober_hassh.py
Normal file
357
tests/prober/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
|
||||
272
tests/prober/test_prober_jarm.py
Normal file
272
tests/prober/test_prober_jarm.py
Normal 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
|
||||
393
tests/prober/test_prober_tcpfp.py
Normal file
393
tests/prober/test_prober_tcpfp.py
Normal 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"]
|
||||
165
tests/prober/test_prober_tlscert.py
Normal file
165
tests/prober/test_prober_tlscert.py
Normal 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
|
||||
659
tests/prober/test_prober_worker.py
Normal file
659
tests/prober/test_prober_worker.py
Normal 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
|
||||
Reference in New Issue
Block a user