refactor(prober): ActiveProbe ABC + ActiveProbeMeta registry
Replace _jarm_phase / _hassh_phase / _tcpfp_phase boilerplate (3×~50
lines of identical port-iteration logic) with a metaclass-registered ABC.
Adding a new port-iterating active probe is now one class + three methods.
- decnet/prober/base.py: ActiveProbeMeta auto-registers subclasses by
probe_name; ActiveProbe ABC enforces run/syslog_fields/publish_payload
with env-driven DECNET_PROBE_PORTS_<NAME> port override.
- decnet/prober/probes/{jarm,hassh,tcpfp}.py: concrete probe classes.
- decnet/prober/worker.py: single _run_probe driver replaces the three
phase functions; _probe_cycle iterates ActiveProbeMeta.all(); drops
the ports=/ssh_ports=/tcpfp_ports= kwargs from prober_worker.
- IPv6 leak and TLS cert capture stay as special cases (different call
shapes; intentionally outside the registry).
- tests/prober/test_active_probe_registry.py: registry contents, sort
order, priority-10 override, ABC contract per probe class.
- tests/prober/test_run_probe_driver.py: dedup, success, None-skip,
exception, rotation, publish paths for _run_probe.
- tests/prober/test_prober_worker.py: updated patch targets and
_probe_cycle call sites; port control via monkeypatch.setattr.
This commit is contained in:
90
decnet/prober/base.py
Normal file
90
decnet/prober/base.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
ActiveProbe ABC and metaclass registry for port-iterating active probes.
|
||||
|
||||
Adding a new active probe = one class with three methods.
|
||||
IPv6 leak and TLS cert capture are NOT part of this registry (different
|
||||
call shapes); they stay as special cases in prober/worker.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from decnet.correlation.fingerprint_rotation import ProbeType
|
||||
|
||||
|
||||
class ActiveProbeMeta(ABCMeta):
|
||||
"""Metaclass that auto-registers every ActiveProbe subclass by probe_name."""
|
||||
|
||||
_registry: dict[str, type[ActiveProbe]] = {}
|
||||
|
||||
def __new__(
|
||||
mcs,
|
||||
name: str,
|
||||
bases: tuple[type, ...],
|
||||
namespace: dict[str, Any],
|
||||
) -> ActiveProbeMeta:
|
||||
cls = super().__new__(mcs, name, bases, namespace)
|
||||
if bases and getattr(cls, "probe_name", None):
|
||||
mcs._registry[cls.probe_name] = cls # type: ignore[attr-defined,assignment]
|
||||
return cls
|
||||
|
||||
@classmethod
|
||||
def all(mcs) -> list[type[ActiveProbe]]:
|
||||
"""Return registered probes sorted by (priority asc, probe_name asc)."""
|
||||
return sorted(mcs._registry.values(), key=lambda c: (c.priority, c.probe_name))
|
||||
|
||||
|
||||
class ActiveProbe(metaclass=ActiveProbeMeta):
|
||||
"""Base class for all port-iterating active probes.
|
||||
|
||||
Subclasses declare class-level attributes and implement three methods.
|
||||
Registration is automatic via ActiveProbeMeta.
|
||||
|
||||
Port override: set DECNET_PROBE_PORTS_<NAME_UPPER> (comma-separated) to
|
||||
override default_ports at runtime without touching the class.
|
||||
"""
|
||||
|
||||
probe_name: str
|
||||
default_ports: list[int]
|
||||
event_type: str
|
||||
rotation_type: ProbeType | None = None
|
||||
rotation_hash_key: str | None = None
|
||||
priority: int = 100
|
||||
|
||||
def __init__(self) -> None:
|
||||
env_key = f"DECNET_PROBE_PORTS_{self.probe_name.upper()}"
|
||||
raw = os.environ.get(env_key, "").strip()
|
||||
if raw:
|
||||
try:
|
||||
self._ports: list[int] = [int(p.strip()) for p in raw.split(",") if p.strip()]
|
||||
except ValueError:
|
||||
self._ports = list(self.default_ports)
|
||||
else:
|
||||
self._ports = list(self.default_ports)
|
||||
|
||||
@property
|
||||
def ports(self) -> list[int]:
|
||||
return self._ports
|
||||
|
||||
@abstractmethod
|
||||
def run(self, ip: str, port: int, timeout: float) -> dict[str, Any] | None:
|
||||
"""Execute the probe against ip:port.
|
||||
|
||||
Return a result dict on success, or None to suppress emission (e.g.
|
||||
empty JARM hash means the port doesn't speak TLS).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def syslog_fields(self, ip: str, port: int, result: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""Return (sd_fields, human_msg) for _write_event.
|
||||
|
||||
target_ip and target_port are injected by _run_probe; do not include
|
||||
them in sd_fields.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def publish_payload(self, ip: str, port: int, result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the bus payload dict for attacker.fingerprinted events."""
|
||||
Reference in New Issue
Block a user