feat(ttp/ipv6_leak): wire Ipv6LeakLifter into composite tagger and worker

- Add "ipv6_leak" to KNOWN_SOURCE_KINDS in ttp/base.py
- Register Ipv6LeakLifter(store) in factory.py get_tagger()
- Subscribe worker to attacker.fingerprinted; route by Event.type
  so JARM/HASSH/ipv6_leak share the topic without source_kind collision
- Add bump_attacker_ipv6_leak() to BaseRepository (abstract) +
  TTPMixin (implementation): increments ipv6_leak_count, sets last_ipv6_*
  denorm fields, appends-with-dedup to AttackerIdentity.ipv6_link_local_iids
- Call bump_attacker_ipv6_leak from _process_event after insert_tags
- Add DummyRepo stub + coverage call in tests/db/test_base_repo.py
This commit is contained in:
2026-05-17 20:41:55 -04:00
parent 11d9273c99
commit 3977f06374
6 changed files with 135 additions and 7 deletions

View File

@@ -41,6 +41,7 @@ KNOWN_SOURCE_KINDS: Final[frozenset[str]] = frozenset({
"session",
"http_request",
"http_fingerprint",
"ipv6_leak",
})

View File

@@ -165,6 +165,7 @@ def get_tagger() -> Tagger:
from decnet.ttp.impl.http_fingerprint_lifter import HttpFingerprintLifter
from decnet.ttp.impl.identity_lifter import IdentityLifter
from decnet.ttp.impl.intel_lifter import IntelLifter
from decnet.ttp.impl.ipv6_leak_lifter import Ipv6LeakLifter
from decnet.ttp.impl.rule_engine import RuleEngineTagger
from decnet.ttp.store.factory import get_rule_store
store = get_rule_store()
@@ -182,6 +183,7 @@ def get_tagger() -> Tagger:
IdentityLifter(store),
CredentialLifter(store),
HttpFingerprintLifter(store),
Ipv6LeakLifter(store),
])
raise ValueError(
f"Unknown tagger: {name!r}. Known: {_KNOWN}"

View File

@@ -60,6 +60,10 @@ _TOPICS: tuple[str, ...] = (
_topics.attacker(_topics.ATTACKER_SESSION_ENDED),
_topics.attacker(_topics.ATTACKER_OBSERVED),
_topics.attacker(_topics.ATTACKER_INTEL_ENRICHED),
# attacker.fingerprinted carries JARM/HASSH/tcpfp/ipv6_leak results from
# the prober and sniffer. Event.type discriminates the kind; lifters that
# don't recognise the source_kind derived from Event.type are no-ops.
_topics.attacker(_topics.ATTACKER_FINGERPRINTED),
_topics.identity(_topics.IDENTITY_FORMED),
_topics.identity(_topics.IDENTITY_MERGED),
_topics.credential(_topics.CREDENTIAL_REUSE_DETECTED),
@@ -113,7 +117,9 @@ def _span(name: str, **attrs: Any) -> Iterator[Any]:
yield span
def _build_events(topic: str, payload: dict[str, Any]) -> list[TaggerEvent]:
def _build_events(
topic: str, payload: dict[str, Any], event_type: str = "",
) -> list[TaggerEvent]:
"""Translate one bus payload into one OR MORE :class:`TaggerEvent`s.
A single ``attacker.session.ended`` event carries a *bag* of commands
@@ -134,8 +140,12 @@ def _build_events(topic: str, payload: dict[str, Any]) -> list[TaggerEvent]:
* ``commands: list[{"command_text": str, "id": str?, ...}]`` — dicts
with at least a ``command_text`` field; any ``id`` / ``uuid`` /
``command_id`` becomes the ``source_id`` for idempotency.
*event_type* is forwarded from ``Event.type``; used by multiplex
topics (``attacker.fingerprinted``) where the kind discriminator lives
in the envelope rather than the topic path.
"""
base = _build_event(topic, payload)
base = _build_event(topic, payload, event_type=event_type)
if base is None:
return []
out = [base]
@@ -183,7 +193,9 @@ def _build_command_event(
)
def _build_event(topic: str, payload: dict[str, Any]) -> TaggerEvent | None:
def _build_event(
topic: str, payload: dict[str, Any], event_type: str = "",
) -> TaggerEvent | None:
"""Translate one bus payload into a :class:`TaggerEvent`.
Returns ``None`` if the topic isn't one we know how to dispatch
@@ -197,10 +209,18 @@ def _build_event(topic: str, payload: dict[str, Any]) -> TaggerEvent | None:
same :func:`compute_tag_uuid` and the ``INSERT OR IGNORE`` write
becomes a no-op the second time around. The order below is the
same priority list the lifters use internally.
*event_type* is used as ``source_kind`` when ``_source_kind_for``
has no static mapping for *topic* — this covers multiplex topics
such as ``attacker.fingerprinted`` where the kind discriminator is
carried in ``Event.type`` rather than the topic path itself.
"""
source_kind = _source_kind_for(topic)
if source_kind is None:
return None
if event_type:
source_kind = event_type
else:
return None
source_id = (
payload.get("source_id")
or payload.get("session_id")
@@ -466,7 +486,7 @@ async def _process_event(
# requires at least one anchor, so emitting any tag would
# raise. Drop the event with one log line per cold IP.
return
tagger_events = _build_events(topic, payload)
tagger_events = _build_events(topic, payload, event_type=event.type)
if not tagger_events:
return
# Intel catch-up: on session.ended, read the persisted intel row (if
@@ -514,10 +534,41 @@ async def _process_event(
# Idempotent re-eval — the loop-prevention invariant
# forbids publishing here.
return
await _bump_ipv6_leak_denorm(repo, all_tags)
if bus is not None:
await _publish_tagged(bus, all_tags)
async def _bump_ipv6_leak_denorm(
repo: BaseRepository, tags: list[TTPTag],
) -> None:
"""Update Attacker / AttackerIdentity denorm columns for ipv6_leak tags.
Called once per successful insert_tags batch. Takes the first tag
per attacker_uuid (all tags in a batch share the same attacker context).
Silently skips if the repo method is unavailable (pre-migration DBs).
"""
ipv6_tags = [t for t in tags if t.source_kind == "ipv6_leak"]
if not ipv6_tags:
return
seen: set[str] = set()
for tag in ipv6_tags:
if tag.attacker_uuid is None or tag.attacker_uuid in seen:
continue
seen.add(tag.attacker_uuid)
try:
await repo.bump_attacker_ipv6_leak(
attacker_uuid=tag.attacker_uuid,
identity_uuid=tag.identity_uuid,
evidence=tag.evidence or {},
)
except Exception: # noqa: BLE001
log.warning(
"ttp worker: bump_attacker_ipv6_leak failed for "
"attacker_uuid=%r", tag.attacker_uuid,
)
async def _publish_tagged(bus: BaseBus, tags: list[TTPTag]) -> None:
"""Publish ``ttp.tagged`` + per-technique ``ttp.rule.fired.*``.