From 3c571cce5a4af1412049ffd071161bb3fa59d626 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 27 Apr 2026 23:02:23 -0400 Subject: [PATCH] fix(correlation): prober events no longer count as attacker traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prober writes events with hostname=decnet-prober and target_ip= . The parser pulls target_ip into attacker_ip (it's one of _IP_FIELDS), which is correct for indexing fingerprints under the attacker — but it had a side effect: every fingerprinted attacker had two distinct deckies on file (the real decoy they touched + decnet-prober) and the correlation engine's traversals() classified that as lateral movement. Live dashboard showed bogus "dmz-gateway -> decnet-prober" paths and TRAVERSAL badges on attackers who'd done nothing but knock on the front door. The prober is internal infrastructure, not a hop. Filter the "decnet-" namespace out of distinct-decky counts and hop paths in the engine. Fingerprints stay attached to the attacker profile via the existing per-IP event index — just no longer as traversal. --- decnet/correlation/engine.py | 29 ++++++++++++++++++++++-- tests/correlation/test_correlation.py | 32 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/decnet/correlation/engine.py b/decnet/correlation/engine.py index 2fc92baf..911f7239 100644 --- a/decnet/correlation/engine.py +++ b/decnet/correlation/engine.py @@ -40,6 +40,24 @@ from decnet.telemetry import traced as _traced, get_tracer as _get_tracer log = get_logger("correlation.engine") +# Decky-name prefix reserved for DECNET's own infrastructure workers +# that log attacker IPs without representing actual decoy hops. The +# prober is the canonical example: when it fingerprints an attacker's +# externally-exposed services, it writes events with +# ``hostname=decnet-prober`` and ``target_ip=``. The parser +# pulls ``target_ip`` into ``attacker_ip`` so the prober event is +# co-indexed with that attacker — but it's outbound recon from the +# master, not the attacker traversing into another decoy. Excluding the +# whole ``decnet-*`` namespace from distinct-decky counts and hop paths +# avoids labelling every fingerprinted attacker as a "traversal." +_INTERNAL_DECKY_PREFIX = "decnet-" + + +def _is_internal_decky(name: str) -> bool: + """True if ``name`` is a DECNET internal worker (prober, etc.) — not a real decoy.""" + return bool(name) and name.startswith(_INTERNAL_DECKY_PREFIX) + + # ``publish_fn(event_type, payload_dict)``. Sync to avoid rippling # ``async`` through every call site of :meth:`CorrelationEngine.ingest`; # the caller wraps bus-publish via @@ -137,11 +155,18 @@ class CorrelationEngine: """ result: list[AttackerTraversal] = [] for ip, events in self._events.items(): - if len({e.decky for e in events}) < min_deckies: + # Exclude internal-infrastructure events (e.g. prober) from + # distinct-decky counting and the hop list. They aren't + # attacker movement — they're outbound recon co-indexed by + # attacker IP. Without this filter every fingerprinted + # attacker shows up as a 2-decky "traversal" with a bogus + # ``dmz-gateway → decnet-prober`` path. + decoy_events = [e for e in events if not _is_internal_decky(e.decky)] + if len({e.decky for e in decoy_events}) < min_deckies: continue hops = sorted( (TraversalHop(e.timestamp, e.decky, e.service, e.event_type) - for e in events), + for e in decoy_events), key=lambda h: h.timestamp, ) # Per-attacker mutation markers: any mutation on a touched diff --git a/tests/correlation/test_correlation.py b/tests/correlation/test_correlation.py index ddff7dc8..b1772201 100644 --- a/tests/correlation/test_correlation.py +++ b/tests/correlation/test_correlation.py @@ -285,6 +285,38 @@ class TestEngineTraversals: assert t[0].attacker_ip == "1.1.1.1" assert t[0].decky_count == 2 + def test_prober_event_does_not_count_as_traversal(self): + """Hit live on first VPS deploy: every fingerprinted attacker + showed up as a 2-decky traversal because the prober's outbound + fingerprint events (decky=decnet-prober, target_ip=) + got co-indexed with the attacker's actual decoy hops. The + prober is internal infrastructure, not a hop — its events + must not bump the distinct-decky count.""" + engine = self._engine_with([ + ("ssh", "dmz-gateway", "conn", "1.1.1.1", _TS), + ("ssh", "decnet-prober", "hassh_fingerprint", "1.1.1.1", _TS2), + ]) + # Only one *real* decky touched — no traversal. + assert engine.traversals() == [] + + def test_prober_excluded_from_traversal_path(self): + """When a real traversal exists, the prober's hops must not + appear in the path or inflate the decky count.""" + engine = self._engine_with([ + ("ssh", "dmz-gateway", "conn", "1.1.1.1", _TS), + ("ssh", "decnet-prober", "hassh_fingerprint", "1.1.1.1", _TS2), + ("http", "decky-internal", "req", "1.1.1.1", _TS3), + ]) + traversals = engine.traversals() + assert len(traversals) == 1 + t = traversals[0] + assert t.decky_count == 2, ( + f"prober should not inflate decky_count; got {t.decky_count}" + ) + assert "decnet-prober" not in t.path, ( + f"prober should not appear in traversal path; got {t.path!r}" + ) + def test_min_deckies_filter(self): engine = self._engine_with([ ("ssh", "decky-01", "conn", "1.1.1.1", _TS),