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:
@@ -30,6 +30,7 @@ from .attackers import (
|
||||
AttackerBehavior,
|
||||
AttackersResponse,
|
||||
SessionProfile,
|
||||
SmtpTarget,
|
||||
)
|
||||
from .deploy import (
|
||||
DeployIniRequest,
|
||||
@@ -137,6 +138,7 @@ __all__ = [
|
||||
"AttackerBehavior",
|
||||
"AttackersResponse",
|
||||
"SessionProfile",
|
||||
"SmtpTarget",
|
||||
# deploy
|
||||
"DeployIniRequest",
|
||||
"MutateIntervalRequest",
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import Column, Text
|
||||
from sqlalchemy import Column, Text, UniqueConstraint
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from ._base import _BIG_TEXT
|
||||
@@ -143,6 +143,36 @@ class SessionProfile(SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
class SmtpTarget(SQLModel, table=True):
|
||||
"""
|
||||
Per-attacker list of victim domains observed via the SMTP honeypots.
|
||||
|
||||
Each row is one (attacker_uuid, domain) pair — an attacker who relays
|
||||
mail to 500 addresses at acme.com collapses into a single row with
|
||||
count=500. Only the *domain* is stored; local-parts (the bit before
|
||||
`@`) are dropped at ingestion, so this table contains no PII beyond
|
||||
the target organisation's identity.
|
||||
|
||||
Shape is designed for future V2 federation gossip: the
|
||||
`smtp_target_seen(domain)` query returns aggregate counts with zero
|
||||
cross-org attacker leakage — each operator can answer "have you seen
|
||||
this domain being targeted?" without exposing *which* attackers did.
|
||||
"""
|
||||
__tablename__ = "smtp_targets"
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
attacker_uuid: str = Field(foreign_key="attackers.uuid", index=True)
|
||||
domain: str = Field(index=True)
|
||||
first_seen: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
last_seen: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), index=True
|
||||
)
|
||||
# Aggregate counter — one rcpt_to / message_accepted recipient bumps this.
|
||||
count: int = Field(default=1)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("attacker_uuid", "domain", name="uq_smtp_targets_attacker_domain"),
|
||||
)
|
||||
|
||||
|
||||
class AttackersResponse(BaseModel):
|
||||
total: int
|
||||
limit: int
|
||||
|
||||
Reference in New Issue
Block a user