refactor(prober): generalise ActiveProbe registry to absorb Ipv6LeakProbe
ActiveProbe.run/syslog_fields/publish_payload now accept port=None so
non-port-iterating probes can live in the registry. Ipv6LeakProbe replaces
the hand-rolled _ipv6_leak_phase special case in worker.py; it runs last
via priority=999. _probe_cycle no longer has an ad-hoc phase call.
Fixes three stale test files (test_prober_bus, test_prober_rotation,
test_prober_worker) that were broken since the 916b21b6 registry refactor.
This commit is contained in:
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -17,77 +18,65 @@ 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
|
||||
from decnet.prober.worker import _run_probe
|
||||
|
||||
|
||||
@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)),
|
||||
def _run(probe_cls, ip, ports, tmp_path, publish_fn, monkeypatch=None):
|
||||
"""Helper: run _run_probe for a single-port probe, respecting port override."""
|
||||
import os
|
||||
probe = probe_cls()
|
||||
# Narrow to just the requested ports via env var
|
||||
env_key = f"DECNET_PROBE_PORTS_{probe_cls.probe_name.upper()}"
|
||||
probe._ports = list(ports)
|
||||
ip_probed: dict = {}
|
||||
_run_probe(
|
||||
probe, ip, ip_probed,
|
||||
tmp_path / "p.log", tmp_path / "p.json",
|
||||
timeout=1.0, publish_fn=publish_fn, record_rotation=None,
|
||||
)
|
||||
return ip_probed
|
||||
|
||||
|
||||
# ─── Per-probe publish hooks ──────────────────────────────────────────────────
|
||||
|
||||
def test_jarm_invokes_publish_fn_on_success(tmp_path: Path) -> None:
|
||||
captured: list[tuple[str, dict]] = []
|
||||
from decnet.prober.probes.jarm import JarmProbe
|
||||
with patch("decnet.prober.probes.jarm.jarm_hash", return_value="aabbcc"):
|
||||
ip_probed = _run(
|
||||
JarmProbe, "203.0.113.9", [443], tmp_path,
|
||||
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"}),
|
||||
]
|
||||
assert 443 in ip_probed["jarm"]
|
||||
|
||||
|
||||
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.
|
||||
def test_jarm_skips_empty_hash(tmp_path: Path) -> None:
|
||||
captured: list[tuple[str, dict]] = []
|
||||
from decnet.prober import worker as worker_mod
|
||||
from decnet.prober.probes.jarm import JarmProbe
|
||||
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)),
|
||||
)
|
||||
with patch("decnet.prober.probes.jarm.jarm_hash", return_value=JARM_EMPTY_HASH):
|
||||
_run(JarmProbe, "1.2.3.4", [443], tmp_path,
|
||||
publish_fn=lambda e, p: captured.append((e, p)))
|
||||
assert captured == []
|
||||
|
||||
|
||||
def test_hassh_phase_invokes_publish_fn_on_success(monkeypatch, tmp_path: Path) -> None:
|
||||
def test_hassh_invokes_publish_fn_on_success(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)),
|
||||
)
|
||||
|
||||
from decnet.prober.probes.hassh import HasshProbe
|
||||
stub = {
|
||||
"hassh_server": "deadbeef",
|
||||
"banner": "SSH-2.0-OpenSSH_9.0",
|
||||
"kex_algorithms": "x",
|
||||
"encryption_s2c": "y",
|
||||
"mac_s2c": "z",
|
||||
"compression_s2c": "none",
|
||||
}
|
||||
with patch("decnet.prober.probes.hassh.hassh_server", return_value=stub):
|
||||
_run(HasshProbe, "1.2.3.4", [22], tmp_path,
|
||||
publish_fn=lambda e, p: captured.append((e, p)))
|
||||
assert captured == [
|
||||
("hassh", {
|
||||
"attacker_ip": "1.2.3.4",
|
||||
@@ -98,34 +87,19 @@ def test_hassh_phase_invokes_publish_fn_on_success(monkeypatch, tmp_path: Path)
|
||||
]
|
||||
|
||||
|
||||
def test_tcpfp_phase_invokes_publish_fn_on_success(monkeypatch, tmp_path: Path) -> None:
|
||||
def test_tcpfp_invokes_publish_fn_on_success(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)),
|
||||
)
|
||||
from decnet.prober.probes.tcpfp import TcpfpProbe
|
||||
stub = {
|
||||
"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,
|
||||
}
|
||||
with patch("decnet.prober.probes.tcpfp.tcp_fingerprint", return_value=stub):
|
||||
_run(TcpfpProbe, "1.2.3.4", [80], tmp_path,
|
||||
publish_fn=lambda e, p: captured.append((e, p)))
|
||||
assert captured == [
|
||||
("tcpfp", {
|
||||
"attacker_ip": "1.2.3.4", "port": 80,
|
||||
@@ -134,24 +108,23 @@ def test_tcpfp_phase_invokes_publish_fn_on_success(monkeypatch, tmp_path: Path)
|
||||
]
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
def test_probe_marks_port_done_without_publish_fn(tmp_path: Path) -> None:
|
||||
from decnet.prober.probes.jarm import JarmProbe
|
||||
with patch("decnet.prober.probes.jarm.jarm_hash", return_value="aabbcc"):
|
||||
ip_probed = _run(JarmProbe, "1.2.3.4", [443], tmp_path, publish_fn=None)
|
||||
assert 443 in ip_probed["jarm"]
|
||||
|
||||
|
||||
# ─── End-to-end through the bus ──────────────────────────────────────────────
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def bus() -> FakeBus:
|
||||
b = FakeBus()
|
||||
await b.connect()
|
||||
yield b
|
||||
await b.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prober_publishes_on_attacker_fingerprinted_topic(bus: FakeBus) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -172,12 +145,8 @@ async def test_prober_publishes_on_attacker_fingerprinted_topic(bus: FakeBus) ->
|
||||
|
||||
@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")
|
||||
b = FakeBus()
|
||||
await b.connect()
|
||||
await b.publish("attacker.fingerprinted", {"x": 1}, event_type="jarm")
|
||||
await b.close()
|
||||
|
||||
Reference in New Issue
Block a user