fix(ttp): resolve attacker_uuid from attacker_ip on bus-event consume

The collector's `attacker.session.ended` envelope carries
`attacker_uuid: null` and `attacker_ip: <ip>` because the collector
doesn't talk to the DB. The TTP worker passed that null straight
through, and `TTPTag.__init__` raised the documented invariant:

    ValueError: ttp_tag requires at least one of attacker_uuid /
                identity_uuid; both NULL is not a valid anchor.

The worker now resolves `attacker_uuid` from `attacker_ip` via
`BaseRepository.get_attacker_uuid_by_ip` before fanning out the
event. When the IP isn't in the DB yet (profiler hasn't ingested
the row), the event is dropped with one log line — better than
exploding mid-tag.

- New `get_attacker_uuid_by_ip(ip) -> str | None` on the repo
  (BaseRepository abstract + AttackersCoreMixin impl).
- `_resolve_attacker_uuid` helper in `decnet/ttp/worker.py` runs
  before `_build_events`. Short-circuits when the payload already
  has either anchor; drops the event when neither anchor is
  resolvable.
- Tests pin: short-circuit on existing uuid/identity, repo lookup,
  drop on unknown IP, drop on "Unknown" sentinel, drop on
  no-anchor payload, drop on repo failure.
This commit is contained in:
2026-05-02 02:44:30 -04:00
parent f9901befc4
commit c4e29e3bf9
4 changed files with 174 additions and 1 deletions

View File

@@ -344,6 +344,47 @@ async def run_ttp_worker_loop(
await bus.close()
async def _resolve_attacker_uuid(
repo: BaseRepository, payload: dict[str, Any],
) -> dict[str, Any] | None:
"""Inject ``attacker_uuid`` into *payload* via repo lookup if missing.
Collector-side producers (notably ``attacker.session.ended`` from
the session aggregator) carry ``attacker_ip`` but cannot fill
``attacker_uuid`` because the collector doesn't talk to the DB.
The TTP worker resolves it here so ``compute_tag_uuid`` and the
``ttp_tag_has_anchor`` model invariant always have something to
work with.
Returns the (possibly mutated) payload, or ``None`` if neither
``attacker_uuid`` nor ``identity_uuid`` could be set — emitting a
tag with both NULL would raise inside :class:`TTPTag.__init__`.
"""
if payload.get("attacker_uuid") or payload.get("identity_uuid"):
return payload
ip = payload.get("attacker_ip")
if not isinstance(ip, str) or not ip or ip == "Unknown":
log.debug(
"ttp worker: dropping event with no anchor "
"(no attacker_uuid / identity_uuid / attacker_ip)",
)
return None
try:
resolved = await repo.get_attacker_uuid_by_ip(ip)
except Exception: # noqa: BLE001
log.exception(
"ttp worker: get_attacker_uuid_by_ip(%r) failed", ip,
)
return None
if not resolved:
log.info(
"ttp worker: no Attacker row for ip=%r yet; "
"skipping until profiler catches up", ip,
)
return None
return {**payload, "attacker_uuid": resolved}
async def _process_event(
topic: str,
event: Event,
@@ -358,7 +399,14 @@ async def _process_event(
replay of the same upstream event hits the idempotent
``INSERT OR IGNORE`` and writes zero rows → publishes zero events.
"""
tagger_events = _build_events(topic, event.payload)
payload = await _resolve_attacker_uuid(repo, event.payload)
if payload is None:
# Both attacker_uuid and identity_uuid are missing and we
# couldn't resolve from attacker_ip — the TTPTag invariant
# 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)
if not tagger_events:
return
# Aggregate tags across the session-level event AND any per-command