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

@@ -1,4 +1,5 @@
# Import all probe modules to trigger ActiveProbeMeta registration.
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.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.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):
probe_name = "hassh"
default_ports = DEFAULT_PORTS
default_ports: list[int | None] = DEFAULT_PORTS
event_type = "hassh_fingerprint"
rotation_type = "hassh"
rotation_hash_key = "hassh_server"
priority = 100
@_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)
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 = {
"hassh_server_hash": result["hassh_server"],
"ssh_banner": result["banner"],
@@ -32,7 +34,7 @@ class HasshProbe(ActiveProbe):
}
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 {
"attacker_ip": ip,
"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.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):
probe_name = "jarm"
default_ports = DEFAULT_PORTS
default_ports: list[int | None] = DEFAULT_PORTS
event_type = "jarm_fingerprint"
rotation_type = "jarm"
rotation_hash_key = "jarm_hash"
priority = 100
@_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)
if h == JARM_EMPTY_HASH:
return None
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"]
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"]}

View File

@@ -6,22 +6,24 @@ from decnet.prober.base import ActiveProbe
from decnet.prober.tcpfp import tcp_fingerprint
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):
probe_name = "tcpfp"
default_ports = DEFAULT_PORTS
default_ports: list[int | None] = DEFAULT_PORTS
event_type = "tcpfp_fingerprint"
rotation_type = "tcpfp"
rotation_hash_key = "tcpfp_hash"
priority = 100
@_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)
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 = {
"tcpfp_hash": result["tcpfp_hash"],
"tcpfp_raw": result["tcpfp_raw"],
@@ -40,7 +42,7 @@ class TcpfpProbe(ActiveProbe):
}
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 {
"attacker_ip": ip,
"port": port,