When the prober observes a NEW hash for an (attacker_uuid, port, probe_type) triple it has seen before — VPS rotation, SSH server rebuild, TLS cert swap — emit a derived attacker.fingerprint_rotated event carrying both old and new hash. Detection is a small library (decnet.correlation.fingerprint_rotation) called inline from the prober at each of the three emit sites (JARM/HASSH/TCPFP). No new daemon. New AttackerFingerprintState table holds per-triple last-hash state; Attacker.rotation_count and Attacker.last_rotation_at are stamped on every diff. Library is sync, fully unit-tested via injected publish_fn / syslog_fn callbacks.
143 lines
4.4 KiB
Python
143 lines
4.4 KiB
Python
"""Integration test: prober phase functions invoke the rotation recorder.
|
|
|
|
The prober worker constructs the recorder closure at startup; here we
|
|
verify that ``_probe_cycle`` threads a recorder through to JARM / HASSH
|
|
/ TCPFP phases and that the recorder gets the (ip, port, probe_type,
|
|
hash) tuple it expects. The library itself is unit-tested separately.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from decnet.prober.worker import _probe_cycle
|
|
|
|
|
|
@patch("decnet.prober.worker.fetch_leaf_cert", return_value=None)
|
|
@patch("decnet.prober.worker.tcp_fingerprint", return_value=None)
|
|
@patch("decnet.prober.worker.hassh_server", return_value=None)
|
|
@patch("decnet.prober.worker.jarm_hash")
|
|
def test_jarm_phase_calls_recorder(
|
|
mock_jarm: MagicMock,
|
|
_mock_hassh: MagicMock,
|
|
_mock_tcpfp: MagicMock,
|
|
_mock_cert: MagicMock,
|
|
tmp_path: Path,
|
|
):
|
|
mock_jarm.return_value = "c0c" * 10 + "a" * 32
|
|
log_path = tmp_path / "decnet.log"
|
|
json_path = tmp_path / "decnet.json"
|
|
rec_calls: list[tuple] = []
|
|
recorder = lambda ip, port, ptype, h: rec_calls.append((ip, port, ptype, h)) # noqa: E731
|
|
|
|
_probe_cycle(
|
|
{"10.0.0.5"}, {},
|
|
[443], [], [],
|
|
log_path, json_path,
|
|
timeout=1.0,
|
|
publish_fn=None,
|
|
record_rotation=recorder,
|
|
)
|
|
|
|
assert rec_calls == [("10.0.0.5", 443, "jarm", "c0c" * 10 + "a" * 32)]
|
|
|
|
|
|
@patch("decnet.prober.worker.fetch_leaf_cert", return_value=None)
|
|
@patch("decnet.prober.worker.tcp_fingerprint", return_value=None)
|
|
@patch("decnet.prober.worker.hassh_server")
|
|
@patch("decnet.prober.worker.jarm_hash", return_value="")
|
|
def test_hassh_phase_calls_recorder(
|
|
_mock_jarm: MagicMock,
|
|
mock_hassh: MagicMock,
|
|
_mock_tcpfp: MagicMock,
|
|
_mock_cert: MagicMock,
|
|
tmp_path: Path,
|
|
):
|
|
mock_hassh.return_value = {
|
|
"hassh_server": "deadbeef",
|
|
"banner": "SSH-2.0-OpenSSH_9.2",
|
|
"kex_algorithms": "x",
|
|
"encryption_s2c": "x",
|
|
"mac_s2c": "x",
|
|
"compression_s2c": "x",
|
|
}
|
|
log_path = tmp_path / "decnet.log"
|
|
json_path = tmp_path / "decnet.json"
|
|
rec_calls: list[tuple] = []
|
|
recorder = lambda ip, port, ptype, h: rec_calls.append((ip, port, ptype, h)) # noqa: E731
|
|
|
|
_probe_cycle(
|
|
{"10.0.0.5"}, {},
|
|
[], [22], [],
|
|
log_path, json_path,
|
|
timeout=1.0,
|
|
publish_fn=None,
|
|
record_rotation=recorder,
|
|
)
|
|
|
|
assert rec_calls == [("10.0.0.5", 22, "hassh", "deadbeef")]
|
|
|
|
|
|
@patch("decnet.prober.worker.fetch_leaf_cert", return_value=None)
|
|
@patch("decnet.prober.worker.tcp_fingerprint")
|
|
@patch("decnet.prober.worker.hassh_server", return_value=None)
|
|
@patch("decnet.prober.worker.jarm_hash", return_value="")
|
|
def test_tcpfp_phase_calls_recorder(
|
|
_mock_jarm, _mock_hassh, mock_tcpfp, _mock_cert, tmp_path: Path,
|
|
):
|
|
mock_tcpfp.return_value = {
|
|
"tcpfp_hash": "tcpfp-hash-1",
|
|
"tcpfp_raw": "raw",
|
|
"ttl": 64,
|
|
"window_size": 65535,
|
|
"df_bit": True,
|
|
"mss": 1460,
|
|
"window_scale": 7,
|
|
"sack_ok": True,
|
|
"timestamp": True,
|
|
"options_order": "MSS,SACK,TS,NOP,WS",
|
|
"tos": 0,
|
|
"dscp": 0,
|
|
"ecn": 0,
|
|
"server_isn": 0,
|
|
}
|
|
log_path = tmp_path / "decnet.log"
|
|
json_path = tmp_path / "decnet.json"
|
|
rec_calls: list[tuple] = []
|
|
recorder = lambda ip, port, ptype, h: rec_calls.append((ip, port, ptype, h)) # noqa: E731
|
|
|
|
_probe_cycle(
|
|
{"10.0.0.5"}, {},
|
|
[], [], [22],
|
|
log_path, json_path,
|
|
timeout=1.0,
|
|
publish_fn=None,
|
|
record_rotation=recorder,
|
|
)
|
|
|
|
assert rec_calls == [("10.0.0.5", 22, "tcpfp", "tcpfp-hash-1")]
|
|
|
|
|
|
@patch("decnet.prober.worker.fetch_leaf_cert", return_value=None)
|
|
@patch("decnet.prober.worker.tcp_fingerprint", return_value=None)
|
|
@patch("decnet.prober.worker.hassh_server", return_value=None)
|
|
@patch("decnet.prober.worker.jarm_hash")
|
|
def test_recorder_optional_no_crash_when_none(
|
|
mock_jarm: MagicMock,
|
|
_mock_hassh: MagicMock,
|
|
_mock_tcpfp: MagicMock,
|
|
_mock_cert: MagicMock,
|
|
tmp_path: Path,
|
|
):
|
|
"""record_rotation=None must keep the prober's pre-DEBT-032 behavior."""
|
|
mock_jarm.return_value = "c0c" * 10 + "a" * 32
|
|
_probe_cycle(
|
|
{"10.0.0.5"}, {},
|
|
[443], [], [],
|
|
tmp_path / "decnet.log", tmp_path / "decnet.json",
|
|
timeout=1.0,
|
|
publish_fn=None,
|
|
record_rotation=None,
|
|
)
|
|
# No error, probe completes.
|