feat(profiler): track SMTP victim domains per attacker

New SmtpTarget table records each (attacker, domain) pair observed via
the SMTP honeypots. Only the domain is stored — local-parts are dropped
at ingestion, so this table holds no user-identifying data beyond the
target organisation's identity.

The profiler worker extracts domains from rcpt_to / rcpt_denied /
message_accepted events, normalizes them (lowercase, strip local-part,
drop blocked TLDs), and upserts one row per pair with a running count +
first_seen / last_seen.

Three repo methods shipped:
  * increment_smtp_target(attacker, domain) — upsert + bump
  * list_smtp_targets(attacker) — per-attacker view
  * smtp_target_seen(domain) — cross-attacker aggregate, shaped as the
    federation-gossip RPC that V2 will expose.

The gossip-query shape is load-bearing: each operator can answer
"have any of your attackers targeted corp1.com?" without leaking
which attackers or when — the aggregate returns a bool + total count
+ first/last seen, nothing else.
This commit is contained in:
2026-04-22 22:23:27 -04:00
parent c50448995b
commit d43303251d
7 changed files with 360 additions and 1 deletions

View File

@@ -170,6 +170,35 @@ class BaseRepository(ABC):
"""Retrieve the keystroke-dynamics profile row for a session."""
pass
@abstractmethod
async def increment_smtp_target(self, attacker_uuid: str, domain: str) -> None:
"""
Record that ``attacker_uuid`` targeted ``domain`` via SMTP.
Upserts the (attacker_uuid, domain) row: inserts with count=1 +
first_seen=now on first sight, bumps count + last_seen on every
subsequent hit. Callers must pre-normalize ``domain`` (lowercase,
local-part stripped).
"""
pass
@abstractmethod
async def list_smtp_targets(self, attacker_uuid: str) -> list[dict[str, Any]]:
"""Return SmtpTarget rows for an attacker, ordered by most-recent first."""
pass
@abstractmethod
async def smtp_target_seen(self, domain: str) -> dict[str, Any]:
"""
Cross-attacker aggregate for a victim domain.
Returns ``{seen: bool, count: int, first_seen: datetime|None,
last_seen: datetime|None}``. Shaped as the federation-gossip RPC
that V2 will expose — each operator can answer "have any of your
attackers targeted this domain?" without leaking attacker identity.
"""
pass
@abstractmethod
async def get_attacker_by_uuid(self, uuid: str) -> Optional[dict[str, Any]]:
"""Retrieve a single attacker profile by UUID."""