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

@@ -382,6 +382,15 @@ class BaseRepository(ABC):
"""Retrieve a single attacker profile by UUID."""
pass
@abstractmethod
async def get_attacker_uuid_by_ip(self, ip: str) -> Optional[str]:
"""Return the :class:`Attacker` UUID for *ip*, or ``None``.
Used by the TTP worker to resolve ``attacker_uuid`` from the
``attacker_ip`` carried by collector-side bus events.
"""
raise NotImplementedError
@abstractmethod
async def get_attackers(
self,

View File

@@ -48,6 +48,20 @@ class AttackersCoreMixin(_MixinBase):
await session.commit()
return row_uuid
async def get_attacker_uuid_by_ip(self, ip: str) -> Optional[str]:
"""Return the :class:`Attacker` UUID for *ip*, or ``None``.
Used by the TTP worker to resolve ``attacker_uuid`` from the
``attacker_ip`` carried by collector-side bus events
(``attacker.session.ended`` etc.). Cheaper than
:meth:`get_attacker_by_uuid` because it scalars a single column.
"""
async with self._session() as session:
result = await session.execute(
select(col(Attacker.uuid)).where(Attacker.ip == ip)
)
return result.scalar_one_or_none()
async def get_attacker_by_uuid(self, uuid: str) -> Optional[dict[str, Any]]:
async with self._session() as session:
result = await session.execute(