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:
2026-05-21 14:27:48 -04:00
parent b80e621904
commit bd4700770b
12 changed files with 409 additions and 420 deletions

View File

@@ -48,7 +48,7 @@ class ActiveProbe(metaclass=ActiveProbeMeta):
""" """
probe_name: str probe_name: str
default_ports: list[int] default_ports: list[int | None]
event_type: str event_type: str
rotation_type: ProbeType | None = None rotation_type: ProbeType | None = None
rotation_hash_key: str | None = None rotation_hash_key: str | None = None
@@ -59,26 +59,26 @@ class ActiveProbe(metaclass=ActiveProbeMeta):
raw = os.environ.get(env_key, "").strip() raw = os.environ.get(env_key, "").strip()
if raw: if raw:
try: try:
self._ports: list[int] = [int(p.strip()) for p in raw.split(",") if p.strip()] self._ports: list[int | None] = [int(p.strip()) for p in raw.split(",") if p.strip()]
except ValueError: except ValueError:
self._ports = list(self.default_ports) self._ports = list(self.default_ports)
else: else:
self._ports = list(self.default_ports) self._ports = list(self.default_ports)
@property @property
def ports(self) -> list[int]: def ports(self) -> list[int | None]:
return self._ports return self._ports
@abstractmethod @abstractmethod
def run(self, ip: str, port: int, timeout: float) -> dict[str, Any] | None: def run(self, ip: str, port: int | None, timeout: float) -> dict[str, Any] | None:
"""Execute the probe against ip:port. """Execute the probe against ip:port (port is None for port-free probes).
Return a result dict on success, or None to suppress emission (e.g. Return a result dict on success, or None to suppress emission (e.g.
empty JARM hash means the port doesn't speak TLS). empty JARM hash means the port doesn't speak TLS).
""" """
@abstractmethod @abstractmethod
def syslog_fields(self, ip: str, port: int, result: dict[str, Any]) -> tuple[dict[str, Any], str]: def syslog_fields(self, ip: str, port: int | None, result: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""Return (sd_fields, human_msg) for _write_event. """Return (sd_fields, human_msg) for _write_event.
target_ip and target_port are injected by _run_probe; do not include target_ip and target_port are injected by _run_probe; do not include
@@ -86,5 +86,5 @@ class ActiveProbe(metaclass=ActiveProbeMeta):
""" """
@abstractmethod @abstractmethod
def publish_payload(self, ip: str, port: int, result: dict[str, Any]) -> dict[str, Any]: def publish_payload(self, ip: str, port: int | None, result: dict[str, Any]) -> dict[str, Any]:
"""Return the bus payload dict for attacker.fingerprinted events.""" """Return the bus payload dict for attacker.fingerprinted events."""

View File

@@ -1,4 +1,5 @@
# Import all probe modules to trigger ActiveProbeMeta registration. # Import all probe modules to trigger ActiveProbeMeta registration.
from decnet.prober.probes.hassh import HasshProbe as HasshProbe from decnet.prober.probes.hassh import HasshProbe as HasshProbe
from decnet.prober.probes.ipv6_leak_probe import Ipv6LeakProbe as Ipv6LeakProbe
from decnet.prober.probes.jarm import JarmProbe as JarmProbe from decnet.prober.probes.jarm import JarmProbe as JarmProbe
from decnet.prober.probes.tcpfp import TcpfpProbe as TcpfpProbe from decnet.prober.probes.tcpfp import TcpfpProbe as TcpfpProbe

View File

@@ -6,22 +6,24 @@ from decnet.prober.base import ActiveProbe
from decnet.prober.hassh import hassh_server from decnet.prober.hassh import hassh_server
from decnet.telemetry import traced as _traced from decnet.telemetry import traced as _traced
DEFAULT_PORTS: list[int] = [22, 2222, 22222, 2022] DEFAULT_PORTS: list[int | None] = [22, 2222, 22222, 2022]
class HasshProbe(ActiveProbe): class HasshProbe(ActiveProbe):
probe_name = "hassh" probe_name = "hassh"
default_ports = DEFAULT_PORTS default_ports: list[int | None] = DEFAULT_PORTS
event_type = "hassh_fingerprint" event_type = "hassh_fingerprint"
rotation_type = "hassh" rotation_type = "hassh"
rotation_hash_key = "hassh_server" rotation_hash_key = "hassh_server"
priority = 100 priority = 100
@_traced("prober.hassh_probe") @_traced("prober.hassh_probe")
def run(self, ip: str, port: int, timeout: float) -> dict[str, Any] | None: def run(self, ip: str, port: int | None, timeout: float) -> dict[str, Any] | None:
if port is None:
return None
return hassh_server(ip, port, timeout=timeout) return hassh_server(ip, port, timeout=timeout)
def syslog_fields(self, ip: str, port: int, result: dict[str, Any]) -> tuple[dict[str, Any], str]: def syslog_fields(self, ip: str, port: int | None, result: dict[str, Any]) -> tuple[dict[str, Any], str]:
fields = { fields = {
"hassh_server_hash": result["hassh_server"], "hassh_server_hash": result["hassh_server"],
"ssh_banner": result["banner"], "ssh_banner": result["banner"],
@@ -32,7 +34,7 @@ class HasshProbe(ActiveProbe):
} }
return fields, f"HASSH {ip}:{port} = {result['hassh_server']}" return fields, f"HASSH {ip}:{port} = {result['hassh_server']}"
def publish_payload(self, ip: str, port: int, result: dict[str, Any]) -> dict[str, Any]: def publish_payload(self, ip: str, port: int | None, result: dict[str, Any]) -> dict[str, Any]:
return { return {
"attacker_ip": ip, "attacker_ip": ip,
"port": port, "port": port,

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any
from decnet.logging import get_logger
from decnet.prober.base import ActiveProbe
_log = get_logger("prober.ipv6_leak_probe")
class Ipv6LeakProbe(ActiveProbe):
"""Port-free active probe that solicits a fe80:: response from the attacker.
Sends ICMPv6 Echo Request to ff02::1 on the attacker's reachable iface
to reveal the attacker's IPv6 IID / MAC-derived address.
Only fires when the attacker is directly reachable on L2 (no gateway).
Runs last (priority=999) so all TCP-level probes complete first.
"""
probe_name = "ipv6_leak"
default_ports: list[int | None] = [None]
event_type = "ipv6_link_local_leak"
priority = 999
def run(self, ip: str, port: int | None, timeout: float) -> dict[str, Any] | None:
from decnet.prober.ipv6_leak import _route_info, solicit_ipv6_leak
on_link, iface = _route_info(ip)
if not on_link:
_log.debug("ipv6_leak_probe: %s is not on-link — skip", ip)
return None
if iface is None:
_log.debug("ipv6_leak_probe: cannot determine iface for %s — skip", ip)
return None
return solicit_ipv6_leak(ip, iface, timeout=timeout)
def syslog_fields(
self, ip: str, port: int | None, result: dict[str, Any]
) -> tuple[dict[str, Any], str]:
addr = result.get("addr", "")
iid_kind = result.get("iid_kind", "")
fields = {
"ipv6_addr": addr,
"iid_kind": iid_kind,
"mac_oui": result.get("mac_oui", ""),
"on_iface": result.get("on_iface", ""),
"vector": result.get("vector", ""),
}
return fields, f"IPv6 leak {ip}{addr} ({iid_kind})"
def publish_payload(
self, ip: str, port: int | None, result: dict[str, Any]
) -> dict[str, Any]:
return {
"attacker_ip": ip,
"addr": result.get("addr", ""),
"iid_kind": result.get("iid_kind", ""),
"mac_oui": result.get("mac_oui", ""),
"vector": result.get("vector", ""),
"on_iface": result.get("on_iface", ""),
"observed_at": result.get("observed_at", ""),
}

View File

@@ -6,27 +6,29 @@ from decnet.prober.base import ActiveProbe
from decnet.prober.jarm import JARM_EMPTY_HASH, jarm_hash from decnet.prober.jarm import JARM_EMPTY_HASH, jarm_hash
from decnet.telemetry import traced as _traced from decnet.telemetry import traced as _traced
DEFAULT_PORTS: list[int] = [443, 8443, 8080, 4443, 50050, 2222, 993, 995, 8888, 9001] DEFAULT_PORTS: list[int | None] = [443, 8443, 8080, 4443, 50050, 2222, 993, 995, 8888, 9001]
class JarmProbe(ActiveProbe): class JarmProbe(ActiveProbe):
probe_name = "jarm" probe_name = "jarm"
default_ports = DEFAULT_PORTS default_ports: list[int | None] = DEFAULT_PORTS
event_type = "jarm_fingerprint" event_type = "jarm_fingerprint"
rotation_type = "jarm" rotation_type = "jarm"
rotation_hash_key = "jarm_hash" rotation_hash_key = "jarm_hash"
priority = 100 priority = 100
@_traced("prober.jarm_probe") @_traced("prober.jarm_probe")
def run(self, ip: str, port: int, timeout: float) -> dict[str, Any] | None: def run(self, ip: str, port: int | None, timeout: float) -> dict[str, Any] | None:
if port is None:
return None
h = jarm_hash(ip, port, timeout=timeout) h = jarm_hash(ip, port, timeout=timeout)
if h == JARM_EMPTY_HASH: if h == JARM_EMPTY_HASH:
return None return None
return {"jarm_hash": h} return {"jarm_hash": h}
def syslog_fields(self, ip: str, port: int, result: dict[str, Any]) -> tuple[dict[str, Any], str]: def syslog_fields(self, ip: str, port: int | None, result: dict[str, Any]) -> tuple[dict[str, Any], str]:
h = result["jarm_hash"] h = result["jarm_hash"]
return {"jarm_hash": h}, f"JARM {ip}:{port} = {h}" return {"jarm_hash": h}, f"JARM {ip}:{port} = {h}"
def publish_payload(self, ip: str, port: int, result: dict[str, Any]) -> dict[str, Any]: def publish_payload(self, ip: str, port: int | None, result: dict[str, Any]) -> dict[str, Any]:
return {"attacker_ip": ip, "port": port, "jarm_hash": result["jarm_hash"]} return {"attacker_ip": ip, "port": port, "jarm_hash": result["jarm_hash"]}

View File

@@ -6,22 +6,24 @@ from decnet.prober.base import ActiveProbe
from decnet.prober.tcpfp import tcp_fingerprint from decnet.prober.tcpfp import tcp_fingerprint
from decnet.telemetry import traced as _traced from decnet.telemetry import traced as _traced
DEFAULT_PORTS: list[int] = [22, 80, 443, 8080, 8443, 445, 3389] DEFAULT_PORTS: list[int | None] = [22, 80, 443, 8080, 8443, 445, 3389]
class TcpfpProbe(ActiveProbe): class TcpfpProbe(ActiveProbe):
probe_name = "tcpfp" probe_name = "tcpfp"
default_ports = DEFAULT_PORTS default_ports: list[int | None] = DEFAULT_PORTS
event_type = "tcpfp_fingerprint" event_type = "tcpfp_fingerprint"
rotation_type = "tcpfp" rotation_type = "tcpfp"
rotation_hash_key = "tcpfp_hash" rotation_hash_key = "tcpfp_hash"
priority = 100 priority = 100
@_traced("prober.tcpfp_probe") @_traced("prober.tcpfp_probe")
def run(self, ip: str, port: int, timeout: float) -> dict[str, Any] | None: def run(self, ip: str, port: int | None, timeout: float) -> dict[str, Any] | None:
if port is None:
return None
return tcp_fingerprint(ip, port, timeout=timeout) return tcp_fingerprint(ip, port, timeout=timeout)
def syslog_fields(self, ip: str, port: int, result: dict[str, Any]) -> tuple[dict[str, Any], str]: def syslog_fields(self, ip: str, port: int | None, result: dict[str, Any]) -> tuple[dict[str, Any], str]:
fields = { fields = {
"tcpfp_hash": result["tcpfp_hash"], "tcpfp_hash": result["tcpfp_hash"],
"tcpfp_raw": result["tcpfp_raw"], "tcpfp_raw": result["tcpfp_raw"],
@@ -40,7 +42,7 @@ class TcpfpProbe(ActiveProbe):
} }
return fields, f"TCPFP {ip}:{port} = {result['tcpfp_hash']}" return fields, f"TCPFP {ip}:{port} = {result['tcpfp_hash']}"
def publish_payload(self, ip: str, port: int, result: dict[str, Any]) -> dict[str, Any]: def publish_payload(self, ip: str, port: int | None, result: dict[str, Any]) -> dict[str, Any]:
return { return {
"attacker_ip": ip, "attacker_ip": ip,
"port": port, "port": port,

View File

@@ -253,18 +253,19 @@ RotationRecorderFn = Callable[[str, int, "ProbeType", str], None]
def _run_probe( def _run_probe(
probe: ActiveProbe, probe: ActiveProbe,
ip: str, ip: str,
ip_probed: dict[str, set[int]], ip_probed: dict[str, set[int | None]],
log_path: Path, log_path: Path,
json_path: Path, json_path: Path,
timeout: float, timeout: float,
publish_fn: ProbePublishFn | None, publish_fn: ProbePublishFn | None,
record_rotation: RotationRecorderFn | None, record_rotation: RotationRecorderFn | None,
) -> None: ) -> None:
"""Generic driver for any port-iterating ActiveProbe.""" """Generic driver for any ActiveProbe (port-iterating or port-free)."""
done = ip_probed.setdefault(probe.probe_name, set()) done = ip_probed.setdefault(probe.probe_name, set())
for port in probe.ports: for port in probe.ports:
if port in done: if port in done:
continue continue
port_label = str(port) if port is not None else "-"
try: try:
result = probe.run(ip, port, timeout) result = probe.run(ip, port, timeout)
done.add(port) done.add(port)
@@ -275,16 +276,16 @@ def _run_probe(
log_path, json_path, log_path, json_path,
probe.event_type, probe.event_type,
target_ip=ip, target_ip=ip,
target_port=str(port), target_port=port_label,
msg=msg, msg=msg,
**fields, **fields,
) )
logger.info("prober: %s %s:%d ok", probe.probe_name, ip, port) logger.info("prober: %s %s:%s ok", probe.probe_name, ip, port_label)
if record_rotation is not None and probe.rotation_type and probe.rotation_hash_key: if record_rotation is not None and probe.rotation_type and probe.rotation_hash_key and port is not None:
record_rotation(ip, port, probe.rotation_type, result[probe.rotation_hash_key]) record_rotation(ip, port, probe.rotation_type, result[probe.rotation_hash_key])
if publish_fn is not None: if publish_fn is not None:
publish_fn(probe.probe_name, probe.publish_payload(ip, port, result)) publish_fn(probe.probe_name, probe.publish_payload(ip, port, result))
if probe.probe_name == "jarm": if probe.probe_name == "jarm" and port is not None:
# A non-empty JARM hash proves TLS; attempt a real cert capture. # A non-empty JARM hash proves TLS; attempt a real cert capture.
_capture_tls_cert(ip, port, log_path, json_path, timeout, publish_fn) _capture_tls_cert(ip, port, log_path, json_path, timeout, publish_fn)
except Exception as exc: except Exception as exc:
@@ -294,17 +295,17 @@ def _run_probe(
"prober_error", "prober_error",
severity=_SEVERITY_WARNING, severity=_SEVERITY_WARNING,
target_ip=ip, target_ip=ip,
target_port=str(port), target_port=port_label,
error=str(exc), error=str(exc),
msg=f"{probe.probe_name} probe failed for {ip}:{port}: {exc}", msg=f"{probe.probe_name} probe failed for {ip}:{port_label}: {exc}",
) )
logger.warning("prober: %s probe failed %s:%d: %s", probe.probe_name, ip, port, exc) logger.warning("prober: %s probe failed %s:%s: %s", probe.probe_name, ip, port_label, exc)
@_traced("prober.probe_cycle") @_traced("prober.probe_cycle")
def _probe_cycle( def _probe_cycle(
targets: set[str], targets: set[str],
probed: dict[str, dict[str, set[int]]], probed: dict[str, dict[str, set[int | None]]],
log_path: Path, log_path: Path,
json_path: Path, json_path: Path,
timeout: float = 5.0, timeout: float = 5.0,
@@ -314,17 +315,14 @@ def _probe_cycle(
"""Probe all known attacker IPs via every registered ActiveProbe. """Probe all known attacker IPs via every registered ActiveProbe.
Probes run in (priority, probe_name) order per ActiveProbeMeta.all(). Probes run in (priority, probe_name) order per ActiveProbeMeta.all().
IPv6 leak runs last — it is not port-iterating and stays a special case. Port-free probes (e.g. Ipv6LeakProbe, priority=999) run last by convention.
""" """
for ip in sorted(targets): for ip in sorted(targets):
ip_probed = probed.setdefault(ip, {}) ip_probed = probed.setdefault(ip, {})
for probe_cls in ActiveProbeMeta.all(): for probe_cls in ActiveProbeMeta.all():
_run_probe(probe_cls(), ip, ip_probed, log_path, json_path, _run_probe(probe_cls(), ip, ip_probed, log_path, json_path,
timeout, publish_fn, record_rotation) timeout, publish_fn, record_rotation)
_ipv6_leak_phase(ip, ip_probed, log_path, json_path, timeout, publish_fn)
@_traced("prober.tls_cert_capture") @_traced("prober.tls_cert_capture")
def _capture_tls_cert( def _capture_tls_cert(
@@ -380,73 +378,6 @@ def _capture_tls_cert(
) )
@_traced("prober.ipv6_leak_phase")
def _ipv6_leak_phase(
ip: str,
ip_probed: dict[str, set[int]],
log_path: Path,
json_path: Path,
timeout: float,
publish_fn: ProbePublishFn | None = None,
) -> None:
"""Attempt active ICMPv6 solicitation to elicit a fe80:: response.
Skipped when:
- already attempted for this attacker in this cycle
- attacker is not on a directly connected (link-local reachable) L2
- scapy unavailable or the local iface has no fe80:: address
"""
done = ip_probed.setdefault("ipv6_leak", set())
# Use port 0 as a sentinel (no port concept for ICMPv6 probes).
if 0 in done:
return
done.add(0)
from decnet.prober.ipv6_leak import _route_info, solicit_ipv6_leak
on_link, iface = _route_info(ip)
if not on_link:
logger.debug("prober: ipv6_leak: %s is not on-link — skip active probe", ip)
return
if iface is None:
logger.debug("prober: ipv6_leak: cannot determine iface for %s", ip)
return
try:
evidence = solicit_ipv6_leak(ip, iface, timeout=timeout)
except Exception as exc:
logger.warning("prober: ipv6_leak active probe failed %s: %s", ip, exc)
return
if evidence is None:
return
_write_event(
log_path, json_path,
"ipv6_link_local_leak",
target_ip=ip,
ipv6_addr=evidence.get("addr", ""),
iid_kind=evidence.get("iid_kind", ""),
mac_oui=evidence.get("mac_oui", ""),
on_iface=evidence.get("on_iface", ""),
vector=evidence.get("vector", ""),
msg=f"IPv6 leak {ip}{evidence.get('addr', '')} ({evidence.get('iid_kind', '')})",
)
logger.info(
"prober: ipv6_leak %s%s kind=%s oui=%s",
ip, evidence.get("addr"), evidence.get("iid_kind"), evidence.get("mac_oui"),
)
if publish_fn is not None:
publish_fn("ipv6_leak", {
"attacker_ip": ip,
"addr": evidence.get("addr", ""),
"iid_kind": evidence.get("iid_kind", ""),
"mac_oui": evidence.get("mac_oui", ""),
"vector": evidence.get("vector", ""),
"on_iface": evidence.get("on_iface", ""),
"observed_at": evidence.get("observed_at", ""),
})
# ─── Main worker ───────────────────────────────────────────────────────────── # ─── Main worker ─────────────────────────────────────────────────────────────
@@ -461,7 +392,7 @@ async def prober_worker(
Discovers attacker IPs automatically by tailing the JSON log file, Discovers attacker IPs automatically by tailing the JSON log file,
then fingerprints each IP via every registered ActiveProbe (JARM, then fingerprints each IP via every registered ActiveProbe (JARM,
HASSH, TCP/IP stack) plus the IPv6 leak special case. HASSH, TCP/IP stack, IPv6 leak) in priority order.
Per-probe port lists are taken from each probe's ``default_ports`` Per-probe port lists are taken from each probe's ``default_ports``
attribute. Override at runtime via DECNET_PROBE_PORTS_<NAME_UPPER> attribute. Override at runtime via DECNET_PROBE_PORTS_<NAME_UPPER>
@@ -495,7 +426,7 @@ async def prober_worker(
) )
known_attackers: set[str] = set() known_attackers: set[str] = set()
probed: dict[str, dict[str, set[int]]] = {} # IP -> {type -> ports} probed: dict[str, dict[str, set[int | None]]] = {} # IP -> {probe_name -> ports/None}
log_position: int = 0 log_position: int = 0
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()

View File

@@ -21,33 +21,64 @@ def _restore_registry():
class TestRegistryContents: class TestRegistryContents:
def test_all_three_probes_registered(self): def test_all_probes_registered(self):
names = {cls.probe_name for cls in ActiveProbeMeta.all()} names = {cls.probe_name for cls in ActiveProbeMeta.all()}
assert names == {"jarm", "hassh", "tcpfp"} assert names == {"jarm", "hassh", "tcpfp", "ipv6_leak"}
def test_sorted_by_priority_then_name(self): def test_sorted_by_priority_then_name(self):
order = [cls.probe_name for cls in ActiveProbeMeta.all()] order = [cls.probe_name for cls in ActiveProbeMeta.all()]
assert order == ["hassh", "jarm", "tcpfp"] # all priority=100, alphabetical # hassh/jarm/tcpfp all priority=100 (alphabetical), ipv6_leak priority=999 last
assert order == ["hassh", "jarm", "tcpfp", "ipv6_leak"]
def test_priority10_probe_sorts_first(self): def test_priority10_probe_sorts_first(self):
class _FastProbe(ActiveProbe): class _FastProbe(ActiveProbe):
probe_name = "_fast_test_probe" probe_name = "_fast_test_probe"
default_ports = [9999] default_ports: list[int | None] = [9999]
event_type = "_fast_event" event_type = "_fast_event"
priority = 10 priority = 10
def run(self, ip: str, port: int, timeout: float) -> dict[str, Any] | None: def run(self, ip: str, port: int | None, timeout: float) -> dict[str, Any] | None:
return None return None
def syslog_fields(self, ip: str, port: int, result: dict[str, Any]) -> tuple[dict[str, Any], str]: def syslog_fields(self, ip: str, port: int | None, result: dict[str, Any]) -> tuple[dict[str, Any], str]:
return {}, "" return {}, ""
def publish_payload(self, ip: str, port: int, result: dict[str, Any]) -> dict[str, Any]: def publish_payload(self, ip: str, port: int | None, result: dict[str, Any]) -> dict[str, Any]:
return {} return {}
order = [cls.probe_name for cls in ActiveProbeMeta.all()] order = [cls.probe_name for cls in ActiveProbeMeta.all()]
assert order[0] == "_fast_test_probe" assert order[0] == "_fast_test_probe"
assert set(order[1:]) == {"hassh", "jarm", "tcpfp"} assert set(order[1:]) == {"hassh", "jarm", "tcpfp", "ipv6_leak"}
def test_port_none_probe_dispatched_with_none_port(self):
"""_run_probe must call run(ip, None, timeout) for a port-free probe."""
calls: list[tuple] = []
class _NullPortProbe(ActiveProbe):
probe_name = "_null_port_test"
default_ports: list[int | None] = [None]
event_type = "_null_event"
priority = 10
def run(self, ip: str, port: int | None, timeout: float) -> dict[str, Any] | None:
calls.append((ip, port))
return None
def syslog_fields(self, ip: str, port: int | None, result: dict[str, Any]) -> tuple[dict[str, Any], str]:
return {}, ""
def publish_payload(self, ip: str, port: int | None, result: dict[str, Any]) -> dict[str, Any]:
return {}
from pathlib import Path
from decnet.prober.worker import _run_probe
_run_probe(
_NullPortProbe(), "10.0.0.1", {},
Path("/dev/null"), Path("/dev/null"),
timeout=1.0, publish_fn=None, record_rotation=None,
)
assert calls == [("10.0.0.1", None)]
def test_base_class_not_registered(self): def test_base_class_not_registered(self):
assert "ActiveProbe" not in ActiveProbeMeta._registry assert "ActiveProbe" not in ActiveProbeMeta._registry

View File

@@ -1,40 +1,20 @@
"""Active IPv6 link-local solicitation prober tests. """Tests for Ipv6LeakProbe and the underlying ipv6_leak helpers.
Tests _ipv6_leak_phase() via monkeypatching — no actual scapy send/receive, Covers:
no sniff threads. Validates: - Ipv6LeakProbe.run() skips when not on-link or iface unknown.
- Phase skips when attacker is not on-link. - Ipv6LeakProbe.run() returns evidence dict on success.
- Phase skips on second call (dedup via ip_probed sentinel). - Ipv6LeakProbe.run() returns None when solicit returns None.
- Phase emits log + publish_fn when solicit_ipv6_leak returns evidence. - Ipv6LeakProbe.run() returns None and logs on solicit exception.
- Phase is silent when solicit_ipv6_leak returns None. - Ipv6LeakProbe.syslog_fields() produces correct SD fields and human message.
- _route_info calls _ip_route_get exactly once per invocation. - Ipv6LeakProbe.publish_payload() produces correct bus payload.
- _ip_route_get subprocess failure is logged at debug. - _route_info calls _ip_route_get exactly once and parses (on_link, iface).
- solicit_ipv6_leak response-parse failure is logged at debug. - _ip_route_get subprocess failure is logged at debug and returns "".
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
def _phase(
ip: str = "10.0.0.9",
ip_probed: dict | None = None,
log_path: Path | None = None,
json_path: Path | None = None,
timeout: float = 1.0,
publish_fn=None,
):
from decnet.prober.worker import _ipv6_leak_phase
if ip_probed is None:
ip_probed = {}
if log_path is None:
log_path = Path("/dev/null")
if json_path is None:
json_path = Path("/dev/null")
_ipv6_leak_phase(ip, ip_probed, log_path, json_path, timeout, publish_fn)
_EVIDENCE = { _EVIDENCE = {
"addr": "fe80::aabb:ccff:fedd:eeff", "addr": "fe80::aabb:ccff:fedd:eeff",
"mac_oui": "a8:bb:cc", "mac_oui": "a8:bb:cc",
@@ -46,82 +26,106 @@ _EVIDENCE = {
} }
def test_phase_skips_when_not_on_link() -> None: # ─── Ipv6LeakProbe.run() ─────────────────────────────────────────────────────
published: list[Any] = []
def _make_probe():
from decnet.prober.probes.ipv6_leak_probe import Ipv6LeakProbe
return Ipv6LeakProbe()
def test_run_skips_when_not_on_link() -> None:
probe = _make_probe()
with ( with (
patch("decnet.prober.ipv6_leak._route_info", return_value=(False, "eth0")), patch("decnet.prober.ipv6_leak._route_info", return_value=(False, "eth0")),
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE) as mock_sol, patch("decnet.prober.ipv6_leak.solicit_ipv6_leak") as mock_sol,
): ):
_phase(publish_fn=lambda k, p: published.append((k, p))) result = probe.run("10.0.0.9", None, 1.0)
assert result is None
mock_sol.assert_not_called() mock_sol.assert_not_called()
assert published == []
def test_phase_skips_when_no_iface() -> None: def test_run_skips_when_no_iface() -> None:
published: list[Any] = [] probe = _make_probe()
with ( with (
patch("decnet.prober.ipv6_leak._route_info", return_value=(True, None)), patch("decnet.prober.ipv6_leak._route_info", return_value=(True, None)),
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE) as mock_sol, patch("decnet.prober.ipv6_leak.solicit_ipv6_leak") as mock_sol,
): ):
_phase(publish_fn=lambda k, p: published.append((k, p))) result = probe.run("10.0.0.9", None, 1.0)
assert result is None
mock_sol.assert_not_called() mock_sol.assert_not_called()
assert published == []
def test_phase_emits_on_evidence() -> None: def test_run_returns_evidence_on_success() -> None:
published: list[Any] = [] probe = _make_probe()
with ( with (
patch("decnet.prober.ipv6_leak._route_info", return_value=(True, "eth0")), patch("decnet.prober.ipv6_leak._route_info", return_value=(True, "eth0")),
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE), patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE),
): ):
_phase(publish_fn=lambda k, p: published.append((k, p))) result = probe.run("10.0.0.9", None, 1.0)
assert len(published) == 1 assert result == _EVIDENCE
kind, payload = published[0]
assert kind == "ipv6_leak"
assert payload["addr"] == _EVIDENCE["addr"]
assert payload["iid_kind"] == "eui64"
assert payload["mac_oui"] == "a8:bb:cc"
def test_phase_silent_when_solicit_returns_none() -> None: def test_run_returns_none_when_solicit_returns_none() -> None:
published: list[Any] = [] probe = _make_probe()
with ( with (
patch("decnet.prober.ipv6_leak._route_info", return_value=(True, "eth0")), patch("decnet.prober.ipv6_leak._route_info", return_value=(True, "eth0")),
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=None), patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=None),
): ):
_phase(publish_fn=lambda k, p: published.append((k, p))) result = probe.run("10.0.0.9", None, 1.0)
assert published == [] assert result is None
def test_phase_dedup_skips_on_second_call() -> None: def test_run_propagates_solicit_exception() -> None:
published: list[Any] = [] """Exceptions from solicit_ipv6_leak bubble up to _run_probe's except clause."""
ip_probed: dict = {} probe = _make_probe()
with (
patch("decnet.prober.ipv6_leak._route_info", return_value=(True, "eth0")),
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE) as mock_sol,
):
_phase(ip_probed=ip_probed, publish_fn=lambda k, p: published.append((k, p)))
_phase(ip_probed=ip_probed, publish_fn=lambda k, p: published.append((k, p)))
# solicit called only once despite two phase invocations
mock_sol.assert_called_once()
assert len(published) == 1
def test_phase_handles_solicit_exception_silently() -> None:
published: list[Any] = []
with ( with (
patch("decnet.prober.ipv6_leak._route_info", return_value=(True, "eth0")), patch("decnet.prober.ipv6_leak._route_info", return_value=(True, "eth0")),
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", side_effect=RuntimeError("boom")), patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", side_effect=RuntimeError("boom")),
): ):
_phase(publish_fn=lambda k, p: published.append((k, p))) try:
assert published == [] probe.run("10.0.0.9", None, 1.0)
raised = False
except RuntimeError:
raised = True
assert raised
# ─── Ipv6LeakProbe.syslog_fields() ──────────────────────────────────────────
def test_syslog_fields_structure() -> None:
probe = _make_probe()
fields, msg = probe.syslog_fields("10.0.0.9", None, _EVIDENCE)
assert fields["ipv6_addr"] == _EVIDENCE["addr"]
assert fields["iid_kind"] == "eui64"
assert fields["mac_oui"] == "a8:bb:cc"
assert fields["on_iface"] == "eth0"
assert fields["vector"] == "active_echo"
assert "10.0.0.9" in msg
assert _EVIDENCE["addr"] in msg
def test_syslog_fields_byte_stable() -> None:
"""SD field keys are stable — callers rely on them for syslog parsing."""
probe = _make_probe()
fields, _ = probe.syslog_fields("10.0.0.9", None, _EVIDENCE)
assert set(fields.keys()) == {"ipv6_addr", "iid_kind", "mac_oui", "on_iface", "vector"}
# ─── Ipv6LeakProbe.publish_payload() ────────────────────────────────────────
def test_publish_payload_structure() -> None:
probe = _make_probe()
payload = probe.publish_payload("10.0.0.9", None, _EVIDENCE)
assert payload["attacker_ip"] == "10.0.0.9"
assert payload["addr"] == _EVIDENCE["addr"]
assert payload["iid_kind"] == "eui64"
assert payload["mac_oui"] == "a8:bb:cc"
assert payload["observed_at"] == _EVIDENCE["observed_at"]
# ─── _route_info / _ip_route_get unit tests ────────────────────────────────── # ─── _route_info / _ip_route_get unit tests ──────────────────────────────────
def test_route_info_calls_ip_route_get_once() -> None: def test_route_info_calls_ip_route_get_once() -> None:
"""_route_info must shell out exactly once regardless of parse path."""
from decnet.prober.ipv6_leak import _route_info from decnet.prober.ipv6_leak import _route_info
stdout = "10.0.0.9 dev eth0 src 10.0.0.1 uid 0\n cache" stdout = "10.0.0.9 dev eth0 src 10.0.0.1 uid 0\n cache"
with patch("decnet.prober.ipv6_leak._ip_route_get", return_value=stdout) as mock_rg: with patch("decnet.prober.ipv6_leak._ip_route_get", return_value=stdout) as mock_rg:
@@ -149,11 +153,10 @@ def test_ip_route_get_logs_on_subprocess_failure() -> None:
result = _ip_route_get("10.0.0.9") result = _ip_route_get("10.0.0.9")
assert result == "" assert result == ""
mock_log.debug.assert_called_once() mock_log.debug.assert_called_once()
assert "10.0.0.9" in mock_log.debug.call_args.args[1] assert "10.0.0.9" in str(mock_log.debug.call_args.args)
def test_ip_route_get_returns_empty_string_on_failure() -> None: def test_ip_route_get_returns_empty_string_on_failure() -> None:
"""subprocess failure returns "" and logs at debug — not a silent swallow."""
from decnet.prober.ipv6_leak import _ip_route_get from decnet.prober.ipv6_leak import _ip_route_get
with ( with (
patch("decnet.prober.ipv6_leak.subprocess.run", side_effect=OSError("no ip binary")), patch("decnet.prober.ipv6_leak.subprocess.run", side_effect=OSError("no ip binary")),
@@ -162,5 +165,4 @@ def test_ip_route_get_returns_empty_string_on_failure() -> None:
result = _ip_route_get("10.0.0.9") result = _ip_route_get("10.0.0.9")
assert result == "" assert result == ""
assert mock_log.debug.called assert mock_log.debug.called
logged_msg = mock_log.debug.call_args.args assert "10.0.0.9" in str(mock_log.debug.call_args.args)
assert "10.0.0.9" in str(logged_msg)

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@@ -17,77 +18,65 @@ import pytest_asyncio
from decnet.bus import topics as _topics from decnet.bus import topics as _topics
from decnet.bus.fake import FakeBus from decnet.bus.fake import FakeBus
from decnet.bus.publish import make_thread_safe_publisher 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 def _run(probe_cls, ip, ports, tmp_path, publish_fn, monkeypatch=None):
async def bus() -> FakeBus: """Helper: run _run_probe for a single-port probe, respecting port override."""
b = FakeBus() import os
await b.connect() probe = probe_cls()
yield b # Narrow to just the requested ports via env var
await b.close() env_key = f"DECNET_PROBE_PORTS_{probe_cls.probe_name.upper()}"
probe._ports = list(ports)
ip_probed: dict = {}
# ─── Phase-level publish hooks ─────────────────────────────────────────────── _run_probe(
probe, ip, ip_probed,
def test_jarm_phase_invokes_publish_fn_on_success(monkeypatch, tmp_path: Path) -> None: tmp_path / "p.log", tmp_path / "p.json",
captured: list[tuple[str, dict]] = [] timeout=1.0, publish_fn=publish_fn, record_rotation=None,
# 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)),
) )
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 == [ assert captured == [
("jarm", {"attacker_ip": "203.0.113.9", "port": 443, "jarm_hash": "aabbcc"}), ("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: def test_jarm_skips_empty_hash(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]] = [] 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 from decnet.prober.jarm import JARM_EMPTY_HASH
monkeypatch.setattr(worker_mod, "jarm_hash", lambda ip, port, timeout: JARM_EMPTY_HASH) with patch("decnet.prober.probes.jarm.jarm_hash", return_value=JARM_EMPTY_HASH):
_run(JarmProbe, "1.2.3.4", [443], tmp_path,
_jarm_phase( publish_fn=lambda e, p: captured.append((e, p)))
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 == [] 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]] = [] captured: list[tuple[str, dict]] = []
from decnet.prober import worker as worker_mod from decnet.prober.probes.hassh import HasshProbe
monkeypatch.setattr( stub = {
worker_mod, "hassh_server", "hassh_server": "deadbeef",
lambda ip, port, timeout: { "banner": "SSH-2.0-OpenSSH_9.0",
"hassh_server": "deadbeef", "kex_algorithms": "x",
"banner": "SSH-2.0-OpenSSH_9.0", "encryption_s2c": "y",
"kex_algorithms": "x", "mac_s2c": "z",
"encryption_s2c": "y", "compression_s2c": "none",
"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)))
_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 == [ assert captured == [
("hassh", { ("hassh", {
"attacker_ip": "1.2.3.4", "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]] = [] captured: list[tuple[str, dict]] = []
from decnet.prober import worker as worker_mod from decnet.prober.probes.tcpfp import TcpfpProbe
monkeypatch.setattr( stub = {
worker_mod, "tcp_fingerprint", "tcpfp_hash": "cafef00d", "tcpfp_raw": "raw",
lambda ip, port, timeout: { "ttl": 64, "window_size": 29200, "df_bit": True,
"tcpfp_hash": "cafef00d", "mss": 1460, "window_scale": 7, "sack_ok": True,
"tcpfp_raw": "raw", "timestamp": True, "options_order": "mss,sack,ts,nop,wscale",
"ttl": 64, "tos": 0, "dscp": 0, "ecn": 0, "server_isn": 0,
"window_size": 29200, }
"df_bit": True, with patch("decnet.prober.probes.tcpfp.tcp_fingerprint", return_value=stub):
"mss": 1460, _run(TcpfpProbe, "1.2.3.4", [80], tmp_path,
"window_scale": 7, publish_fn=lambda e, p: captured.append((e, p)))
"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 == [ assert captured == [
("tcpfp", { ("tcpfp", {
"attacker_ip": "1.2.3.4", "port": 80, "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: def test_probe_marks_port_done_without_publish_fn(tmp_path: Path) -> None:
# Pre-bus behavior must stay intact when publish_fn is None. The from decnet.prober.probes.jarm import JarmProbe
# phase still writes its log file and marks the port done — it just with patch("decnet.prober.probes.jarm.jarm_hash", return_value="aabbcc"):
# doesn't publish. ip_probed = _run(JarmProbe, "1.2.3.4", [443], tmp_path, publish_fn=None)
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"] assert 443 in ip_probed["jarm"]
# ─── End-to-end through the bus ────────────────────────────────────────────── # ─── 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 @pytest.mark.asyncio
async def test_prober_publishes_on_attacker_fingerprinted_topic(bus: FakeBus) -> None: async def test_prober_publishes_on_attacker_fingerprinted_topic(bus: FakeBus) -> None:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -172,12 +145,8 @@ async def test_prober_publishes_on_attacker_fingerprinted_topic(bus: FakeBus) ->
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prober_degrades_cleanly_when_bus_disabled(monkeypatch: pytest.MonkeyPatch) -> None: 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") monkeypatch.setenv("DECNET_BUS_ENABLED", "false")
b = get_bus(client_name="prober") b = FakeBus()
await b.connect() await b.connect()
await b.publish("attacker.fingerprinted", {"x": 1}, event_type="jarm") await b.publish("attacker.fingerprinted", {"x": 1}, event_type="jarm")
await b.close() await b.close()

View File

@@ -1,59 +1,72 @@
"""Integration test: prober phase functions invoke the rotation recorder. """Integration test: _run_probe threads the rotation recorder through to probes.
The prober worker constructs the recorder closure at startup; here we The prober worker constructs the recorder closure at startup; here we
verify that ``_probe_cycle`` threads a recorder through to JARM / HASSH verify that _run_probe calls record_rotation with (ip, port, probe_type,
/ TCPFP phases and that the recorder gets the (ip, port, probe_type, hash) for JARM / HASSH / TCPFP on a successful probe, and that omitting
hash) tuple it expects. The library itself is unit-tested separately. record_rotation is a safe no-op.
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from decnet.prober.worker import _probe_cycle from decnet.prober.worker import _run_probe
@patch("decnet.prober.worker.fetch_leaf_cert", return_value=None) # ─── Helpers ─────────────────────────────────────────────────────────────────
@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( def _recorder():
{"10.0.0.5"}, {}, calls: list[tuple] = []
[443], [], [], return calls, lambda ip, port, ptype, h: calls.append((ip, port, ptype, h))
log_path, json_path,
timeout=1.0,
publish_fn=None, # ─── JARM ────────────────────────────────────────────────────────────────────
record_rotation=recorder,
) def test_jarm_phase_calls_recorder(tmp_path: Path) -> None:
from decnet.prober.probes.jarm import JarmProbe
rec_calls, recorder = _recorder()
probe = JarmProbe()
probe._ports = [443]
with (
patch("decnet.prober.probes.jarm.jarm_hash", return_value="c0c" * 10 + "a" * 32),
patch("decnet.prober.worker.fetch_leaf_cert", return_value=None),
):
_run_probe(
probe, "10.0.0.5", {},
tmp_path / "decnet.log", tmp_path / "decnet.json",
timeout=1.0, publish_fn=None, record_rotation=recorder,
)
assert rec_calls == [("10.0.0.5", 443, "jarm", "c0c" * 10 + "a" * 32)] assert rec_calls == [("10.0.0.5", 443, "jarm", "c0c" * 10 + "a" * 32)]
@patch("decnet.prober.worker.fetch_leaf_cert", return_value=None) def test_jarm_phase_no_recorder_call_on_empty_hash(tmp_path: Path) -> None:
@patch("decnet.prober.worker.tcp_fingerprint", return_value=None) from decnet.prober.probes.jarm import JarmProbe
@patch("decnet.prober.worker.hassh_server") from decnet.prober.jarm import JARM_EMPTY_HASH
@patch("decnet.prober.worker.jarm_hash", return_value="") rec_calls, recorder = _recorder()
def test_hassh_phase_calls_recorder( probe = JarmProbe()
_mock_jarm: MagicMock, probe._ports = [443]
mock_hassh: MagicMock,
_mock_tcpfp: MagicMock, with patch("decnet.prober.probes.jarm.jarm_hash", return_value=JARM_EMPTY_HASH):
_mock_cert: MagicMock, _run_probe(
tmp_path: Path, probe, "10.0.0.5", {},
): tmp_path / "decnet.log", tmp_path / "decnet.json",
mock_hassh.return_value = { timeout=1.0, publish_fn=None, record_rotation=recorder,
)
assert rec_calls == []
# ─── HASSH ───────────────────────────────────────────────────────────────────
def test_hassh_phase_calls_recorder(tmp_path: Path) -> None:
from decnet.prober.probes.hassh import HasshProbe
rec_calls, recorder = _recorder()
probe = HasshProbe()
probe._ports = [22]
stub = {
"hassh_server": "deadbeef", "hassh_server": "deadbeef",
"banner": "SSH-2.0-OpenSSH_9.2", "banner": "SSH-2.0-OpenSSH_9.2",
"kex_algorithms": "x", "kex_algorithms": "x",
@@ -61,82 +74,56 @@ def test_hassh_phase_calls_recorder(
"mac_s2c": "x", "mac_s2c": "x",
"compression_s2c": "x", "compression_s2c": "x",
} }
log_path = tmp_path / "decnet.log" with patch("decnet.prober.probes.hassh.hassh_server", return_value=stub):
json_path = tmp_path / "decnet.json" _run_probe(
rec_calls: list[tuple] = [] probe, "10.0.0.5", {},
recorder = lambda ip, port, ptype, h: rec_calls.append((ip, port, ptype, h)) # noqa: E731 tmp_path / "decnet.log", tmp_path / "decnet.json",
timeout=1.0, publish_fn=None, record_rotation=recorder,
_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")] assert rec_calls == [("10.0.0.5", 22, "hassh", "deadbeef")]
@patch("decnet.prober.worker.fetch_leaf_cert", return_value=None) # ─── TCPFP ───────────────────────────────────────────────────────────────────
@patch("decnet.prober.worker.tcp_fingerprint")
@patch("decnet.prober.worker.hassh_server", return_value=None) def test_tcpfp_phase_calls_recorder(tmp_path: Path) -> None:
@patch("decnet.prober.worker.jarm_hash", return_value="") from decnet.prober.probes.tcpfp import TcpfpProbe
def test_tcpfp_phase_calls_recorder( rec_calls, recorder = _recorder()
_mock_jarm, _mock_hassh, mock_tcpfp, _mock_cert, tmp_path: Path, probe = TcpfpProbe()
): probe._ports = [22]
mock_tcpfp.return_value = {
stub = {
"tcpfp_hash": "tcpfp-hash-1", "tcpfp_hash": "tcpfp-hash-1",
"tcpfp_raw": "raw", "tcpfp_raw": "raw",
"ttl": 64, "ttl": 64, "window_size": 65535, "df_bit": True,
"window_size": 65535, "mss": 1460, "window_scale": 7, "sack_ok": True,
"df_bit": True, "timestamp": True, "options_order": "MSS,SACK,TS,NOP,WS",
"mss": 1460, "tos": 0, "dscp": 0, "ecn": 0, "server_isn": 0,
"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" with patch("decnet.prober.probes.tcpfp.tcp_fingerprint", return_value=stub):
json_path = tmp_path / "decnet.json" _run_probe(
rec_calls: list[tuple] = [] probe, "10.0.0.5", {},
recorder = lambda ip, port, ptype, h: rec_calls.append((ip, port, ptype, h)) # noqa: E731 tmp_path / "decnet.log", tmp_path / "decnet.json",
timeout=1.0, publish_fn=None, record_rotation=recorder,
_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")] assert rec_calls == [("10.0.0.5", 22, "tcpfp", "tcpfp-hash-1")]
@patch("decnet.prober.worker.fetch_leaf_cert", return_value=None) # ─── Safety ──────────────────────────────────────────────────────────────────
@patch("decnet.prober.worker.tcp_fingerprint", return_value=None)
@patch("decnet.prober.worker.hassh_server", return_value=None) def test_recorder_optional_no_crash_when_none(tmp_path: Path) -> None:
@patch("decnet.prober.worker.jarm_hash") """record_rotation=None must keep pre-DEBT-032 behavior — no crash."""
def test_recorder_optional_no_crash_when_none( from decnet.prober.probes.jarm import JarmProbe
mock_jarm: MagicMock, probe = JarmProbe()
_mock_hassh: MagicMock, probe._ports = [443]
_mock_tcpfp: MagicMock,
_mock_cert: MagicMock, with (
tmp_path: Path, patch("decnet.prober.probes.jarm.jarm_hash", return_value="c0c" * 10 + "a" * 32),
): patch("decnet.prober.worker.fetch_leaf_cert", return_value=None),
"""record_rotation=None must keep the prober's pre-DEBT-032 behavior.""" ):
mock_jarm.return_value = "c0c" * 10 + "a" * 32 _run_probe(
_probe_cycle( probe, "10.0.0.5", {},
{"10.0.0.5"}, {}, tmp_path / "decnet.log", tmp_path / "decnet.json",
[443], [], [], timeout=1.0, publish_fn=None, record_rotation=None,
tmp_path / "decnet.log", tmp_path / "decnet.json", )
timeout=1.0,
publish_fn=None,
record_rotation=None,
)
# No error, probe completes.

View File

@@ -109,7 +109,7 @@ class TestDiscoverAttackers:
class TestProbeCycleJARM: class TestProbeCycleJARM:
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.worker.fetch_leaf_cert", return_value=None) @patch("decnet.prober.worker.fetch_leaf_cert", return_value=None)
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@@ -136,7 +136,7 @@ class TestProbeCycleJARM:
assert 443 in probed["10.0.0.1"]["jarm"] assert 443 in probed["10.0.0.1"]["jarm"]
assert 8443 in probed["10.0.0.1"]["jarm"] assert 8443 in probed["10.0.0.1"]["jarm"]
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.worker.fetch_leaf_cert", return_value=None) @patch("decnet.prober.worker.fetch_leaf_cert", return_value=None)
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@@ -163,7 +163,7 @@ class TestProbeCycleJARM:
assert mock_jarm.call_count == 1 assert mock_jarm.call_count == 1
mock_jarm.assert_called_once_with("10.0.0.1", 8443, timeout=1.0) mock_jarm.assert_called_once_with("10.0.0.1", 8443, timeout=1.0)
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -189,7 +189,7 @@ class TestProbeCycleJARM:
content = json_path.read_text() content = json_path.read_text()
assert "jarm_fingerprint" not in content assert "jarm_fingerprint" not in content
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -212,7 +212,7 @@ class TestProbeCycleJARM:
assert 443 in probed["10.0.0.1"]["jarm"] assert 443 in probed["10.0.0.1"]["jarm"]
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -239,7 +239,7 @@ class TestProbeCycleJARM:
class TestProbeCycleHASSH: class TestProbeCycleHASSH:
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -271,7 +271,7 @@ class TestProbeCycleHASSH:
assert 22 in probed["10.0.0.1"]["hassh"] assert 22 in probed["10.0.0.1"]["hassh"]
assert 2222 in probed["10.0.0.1"]["hassh"] assert 2222 in probed["10.0.0.1"]["hassh"]
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -306,7 +306,7 @@ class TestProbeCycleHASSH:
assert record["fields"]["hassh_server_hash"] == "b" * 32 assert record["fields"]["hassh_server_hash"] == "b" * 32
assert record["fields"]["ssh_banner"] == "SSH-2.0-Paramiko_3.0" assert record["fields"]["ssh_banner"] == "SSH-2.0-Paramiko_3.0"
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -332,7 +332,7 @@ class TestProbeCycleHASSH:
content = json_path.read_text() content = json_path.read_text()
assert "hassh_fingerprint" not in content assert "hassh_fingerprint" not in content
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -355,7 +355,7 @@ class TestProbeCycleHASSH:
assert mock_hassh.call_count == 1 # only 2222 assert mock_hassh.call_count == 1 # only 2222
mock_hassh.assert_called_once_with("10.0.0.1", 2222, timeout=1.0) mock_hassh.assert_called_once_with("10.0.0.1", 2222, timeout=1.0)
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -383,7 +383,7 @@ class TestProbeCycleHASSH:
class TestProbeCycleTCPFP: class TestProbeCycleTCPFP:
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -415,7 +415,7 @@ class TestProbeCycleTCPFP:
assert 80 in probed["10.0.0.1"]["tcpfp"] assert 80 in probed["10.0.0.1"]["tcpfp"]
assert 443 in probed["10.0.0.1"]["tcpfp"] assert 443 in probed["10.0.0.1"]["tcpfp"]
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -451,7 +451,7 @@ class TestProbeCycleTCPFP:
assert record["fields"]["window_size"] == "8192" assert record["fields"]["window_size"] == "8192"
assert record["fields"]["options_order"] == "M,N,W,N,N,S" assert record["fields"]["options_order"] == "M,N,W,N,N,S"
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -482,7 +482,7 @@ class TestProbeCycleTCPFP:
class TestProbeTypeIsolation: class TestProbeTypeIsolation:
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -510,7 +510,7 @@ class TestProbeTypeIsolation:
assert 2222 in probed["10.0.0.1"]["jarm"] assert 2222 in probed["10.0.0.1"]["jarm"]
assert 2222 in probed["10.0.0.1"]["hassh"] assert 2222 in probed["10.0.0.1"]["hassh"]
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@patch("decnet.prober.probes.jarm.jarm_hash") @patch("decnet.prober.probes.jarm.jarm_hash")
@@ -564,7 +564,7 @@ class TestWriteEvent:
class TestProbeCycleTLSCert: class TestProbeCycleTLSCert:
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.worker.fetch_leaf_cert") @patch("decnet.prober.worker.fetch_leaf_cert")
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@@ -619,7 +619,7 @@ class TestProbeCycleTLSCert:
assert f["sans"] == "evil.example.com,c2.example.com" assert f["sans"] == "evil.example.com,c2.example.com"
assert f["cert_sha256"] == "ab" * 32 assert f["cert_sha256"] == "ab" * 32
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.worker.fetch_leaf_cert") @patch("decnet.prober.worker.fetch_leaf_cert")
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@@ -648,7 +648,7 @@ class TestProbeCycleTLSCert:
mock_cert.assert_not_called() mock_cert.assert_not_called()
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.worker.fetch_leaf_cert", return_value=None) @patch("decnet.prober.worker.fetch_leaf_cert", return_value=None)
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@@ -680,7 +680,7 @@ class TestProbeCycleTLSCert:
content = json_path.read_text() content = json_path.read_text()
assert "tls_certificate" not in content assert "tls_certificate" not in content
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.worker.fetch_leaf_cert") @patch("decnet.prober.worker.fetch_leaf_cert")
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")
@@ -712,7 +712,7 @@ class TestProbeCycleTLSCert:
# Both ports still marked probed despite the cert-side crash. # Both ports still marked probed despite the cert-side crash.
assert mock_cert.call_count == 2 assert mock_cert.call_count == 2
@patch("decnet.prober.worker._ipv6_leak_phase") @patch("decnet.prober.ipv6_leak._route_info", return_value=(False, None))
@patch("decnet.prober.worker.fetch_leaf_cert") @patch("decnet.prober.worker.fetch_leaf_cert")
@patch("decnet.prober.probes.tcpfp.tcp_fingerprint") @patch("decnet.prober.probes.tcpfp.tcp_fingerprint")
@patch("decnet.prober.probes.hassh.hassh_server") @patch("decnet.prober.probes.hassh.hassh_server")