"""Threat-intel enrichment row — one per attacker IP, TTL-cached.""" from datetime import datetime, timezone from typing import Optional from sqlalchemy import Column from sqlmodel import Field, SQLModel from ._base import _BIG_TEXT class AttackerIntel(SQLModel, table=True): """Aggregated threat-intel verdict for a single attacker IP. Populated by the ``decnet enrich`` worker, which queries multiple free-tier intel providers (GreyNoise Community, AbuseIPDB, abuse.ch Feodo Tracker + ThreatFox) and writes one row per attacker IP. The row is TTL-cached via ``expires_at`` so re-firings inside the cache window short-circuit before any HTTP egress. Per-provider columns are nullable until each provider has answered; the enrichment pass writes whichever providers succeeded and leaves the rest unchanged on a partial failure. ``schema_version`` is committed to storage from day one — federation gossip in v2/v3 requires cross-operator compatibility, and retrofitting a version column after rows exist is painful. Mirrors the rationale on :class:`SessionProfile`. """ __tablename__ = "attacker_intel" uuid: str = Field(primary_key=True) # uuid.uuid4().hex, generated by writer # Canonical key. One intel row per attacker UUID; FK guarantees no orphan # rows when an attacker is deleted, and UNIQUE keeps upserts honest. attacker_uuid: str = Field( foreign_key="attackers.uuid", unique=True, index=True, ) # DENORMALISED — NOT a key. The IP the worker queried providers with at # write time. Useful for SIEM payloads and audit lookups; updated on every # upsert if the attacker rotates IPs. Never use this column as a lookup # key; ``attacker_uuid`` is the only canonical identifier here. attacker_ip: str = Field(index=True) schema_version: int = Field(default=1) # ── GreyNoise Community ───────────────────────────────────────────── # classification ∈ {"benign", "malicious", "suspicious", "unknown"} greynoise_classification: Optional[str] = Field(default=None, max_length=32) greynoise_raw: str = Field( default="{}", sa_column=Column("greynoise_raw", _BIG_TEXT, nullable=False, default="{}"), ) greynoise_queried_at: Optional[datetime] = Field(default=None) # ── AbuseIPDB ──────────────────────────────────────────────────────── # 0..100 abuse confidence score abuseipdb_score: Optional[int] = Field(default=None) abuseipdb_raw: str = Field( default="{}", sa_column=Column("abuseipdb_raw", _BIG_TEXT, nullable=False, default="{}"), ) abuseipdb_queried_at: Optional[datetime] = Field(default=None) # ── abuse.ch Feodo Tracker ─────────────────────────────────────────── feodo_listed: Optional[bool] = Field(default=None) feodo_raw: str = Field( default="{}", sa_column=Column("feodo_raw", _BIG_TEXT, nullable=False, default="{}"), ) feodo_queried_at: Optional[datetime] = Field(default=None) # ── abuse.ch ThreatFox ─────────────────────────────────────────────── threatfox_listed: Optional[bool] = Field(default=None) threatfox_raw: str = Field( default="{}", sa_column=Column("threatfox_raw", _BIG_TEXT, nullable=False, default="{}"), ) threatfox_queried_at: Optional[datetime] = Field(default=None) # ── Aggregate verdict ──────────────────────────────────────────────── # Synthesised from per-provider columns. ∈ {"malicious", "suspicious", # "benign", "unknown"}. Used by the dashboard and webhook consumers # that don't want to reason over four provider columns. aggregate_verdict: Optional[str] = Field( default=None, max_length=32, index=True ) # ── TTL bookkeeping ────────────────────────────────────────────────── cached_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), index=True ) expires_at: datetime = Field(index=True)