From 3eb67c9400fb9c87c0444eaaac5ec29eea136243 Mon Sep 17 00:00:00 2001 From: anti Date: Sun, 26 Apr 2026 05:35:29 -0400 Subject: [PATCH] refactor(intel): re-key attacker_intel on attacker_uuid (closes DEBT-041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The threat-intel surface was IP-keyed on day one as an expedient — the worker is woken by IP-bearing bus events. ANTI's call: don't carry that debt. NO IPs as primary keys anywhere on the attacker-intel surface. Schema: - attacker_uuid is now the canonical key — UNIQUE + FK to attackers.uuid. - attacker_ip stays as a denormalised, indexed, NON-UNIQUE value column. Updated on every upsert; useful for SIEM payloads and audit lookups, but explicitly NOT a key. Model docstring says so. - Pre-v1, no Alembic migration needed. SQLModel.metadata.create_all() builds the new shape on fresh DBs. Repo: - upsert_attacker_intel now keys on attacker_uuid. - get_attacker_intel_by_ip → get_attacker_intel_by_uuid. - get_unenriched_attacker_ips → get_unenriched_attackers, returning [{uuid, ip}] tuples so the worker writes by UUID and dispatches provider calls by IP without a second round-trip. Worker: - _enrich_one(uuid, ip, ...) — UUID lands on the row, IP rides for provider egress. - attacker.intel.enriched bus payload gains attacker_uuid alongside attacker_ip — webhook → SIEM consumers benefit; no removal. API: - GET /api/v1/attackers/{ip}/intel deleted outright (rip-and-replace, never deployed beyond dev). - GET /api/v1/attackers/{uuid}/intel is the only public route, matching every other /attackers/* route. Frontend: - uses the URL param directly, fetches in parallel with the rest of AttackerDetail rather than waiting on attacker.ip. Tests: re-keyed in place, 39 passed (same coverage as before the refactor). Provider-impl tests untouched. DEBT-041: closed in DEBT.md (entry preserved as historical rationale, summary table flipped to ✅, remaining-open list shortened by one). --- decnet/intel/worker.py | 23 ++++-- decnet/web/db/models/attacker_intel.py | 14 +++- decnet/web/db/repository.py | 22 ++++-- decnet/web/db/sqlmodel_repo.py | 33 ++++++--- .../attackers/api_get_attacker_intel.py | 18 +++-- decnet_web/src/components/AttackerDetail.tsx | 11 +-- development/DEBT.md | 8 +- tests/intel/test_attacker_intel_repo.py | 74 +++++++++++-------- tests/intel/test_worker.py | 43 ++++++----- tests/web/test_api_attacker_intel.py | 12 +-- 10 files changed, 161 insertions(+), 97 deletions(-) diff --git a/decnet/intel/worker.py b/decnet/intel/worker.py index cfb5e1b4..777941af 100644 --- a/decnet/intel/worker.py +++ b/decnet/intel/worker.py @@ -60,11 +60,17 @@ def _aggregate(verdicts: list[Optional[str]]) -> Optional[str]: async def _enrich_one( + attacker_uuid: str, ip: str, providers: list[IntelProvider], ttl_hours: int, ) -> dict[str, Any]: - """Fan out across providers for a single IP and assemble the row update.""" + """Fan out across providers for a single attacker and assemble the row. + + Keyed on ``attacker_uuid`` for the eventual upsert; the IP is the wire + value the providers see and is denormalised onto the row for SIEM / + audit consumers. + """ results: list[IntelResult] = await asyncio.gather( *(p.lookup(ip) for p in providers), return_exceptions=False, # providers contractually never raise @@ -72,6 +78,7 @@ async def _enrich_one( now = datetime.now(timezone.utc) row: dict[str, Any] = { + "attacker_uuid": attacker_uuid, "attacker_ip": ip, "cached_at": now, "expires_at": now + timedelta(hours=ttl_hours), @@ -144,7 +151,7 @@ async def run_intel_loop( try: while not shutdown.is_set(): try: - pending = await repo.get_unenriched_attacker_ips( + pending = await repo.get_unenriched_attackers( limit=backfill_batch, ) except Exception: # noqa: BLE001 @@ -152,16 +159,21 @@ async def run_intel_loop( pending = [] if pending and providers: - for ip in pending: + for entry in pending: if shutdown.is_set(): break + attacker_uuid = entry["uuid"] + ip = entry["ip"] try: - row = await _enrich_one(ip, providers, ttl_hours) + row = await _enrich_one( + attacker_uuid, ip, providers, ttl_hours, + ) await repo.upsert_attacker_intel(row) await publish_safely( bus, _topics.attacker(_topics.ATTACKER_INTEL_ENRICHED), { + "attacker_uuid": attacker_uuid, "attacker_ip": ip, "aggregate_verdict": row.get("aggregate_verdict"), "providers": [p.name for p in providers], @@ -170,7 +182,8 @@ async def run_intel_loop( ) except Exception: # noqa: BLE001 log.exception( - "intel worker: enrichment failed for ip=%s", ip, + "intel worker: enrichment failed for uuid=%s ip=%s", + attacker_uuid, ip, ) try: diff --git a/decnet/web/db/models/attacker_intel.py b/decnet/web/db/models/attacker_intel.py index 4454b835..42087221 100644 --- a/decnet/web/db/models/attacker_intel.py +++ b/decnet/web/db/models/attacker_intel.py @@ -30,8 +30,18 @@ class AttackerIntel(SQLModel, table=True): __tablename__ = "attacker_intel" uuid: str = Field(primary_key=True) # uuid.uuid4().hex, generated by writer - attacker_uuid: Optional[str] = Field(default=None, index=True) - attacker_ip: str = Field(index=True, unique=True) + # 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 ───────────────────────────────────────────── diff --git a/decnet/web/db/repository.py b/decnet/web/db/repository.py index 9414aacc..17cdc336 100644 --- a/decnet/web/db/repository.py +++ b/decnet/web/db/repository.py @@ -280,23 +280,31 @@ class BaseRepository(ABC): @abstractmethod async def upsert_attacker_intel(self, data: dict[str, Any]) -> str: - """Insert or update the threat-intel row for an attacker IP. + """Insert or update the threat-intel row for an attacker UUID. - ``data`` MUST include ``attacker_ip`` and ``expires_at``. Returns - the row UUID. Used by the ``decnet enrich`` worker. + ``data`` MUST include ``attacker_uuid``, ``attacker_ip`` and + ``expires_at``. Returns the row UUID. Keyed on ``attacker_uuid`` + (UNIQUE + FK to ``attackers.uuid``); ``attacker_ip`` is denormalised + — it gets overwritten on every upsert if the attacker rotates IPs. """ pass @abstractmethod - async def get_attacker_intel_by_ip(self, ip: str) -> Optional[dict[str, Any]]: - """Return the threat-intel row for ``ip`` or ``None`` if missing.""" + async def get_attacker_intel_by_uuid(self, uuid: str) -> Optional[dict[str, Any]]: + """Return the threat-intel row for ``uuid`` or ``None`` if missing.""" pass @abstractmethod - async def get_unenriched_attacker_ips(self, limit: int = 100) -> list[str]: - """List attacker IPs with no intel row OR whose row is past expires_at. + async def get_unenriched_attackers( + self, limit: int = 100, + ) -> list[dict[str, Any]]: + """List ``{"uuid", "ip"}`` pairs for attackers with no intel row OR + whose row is past ``expires_at``. Used by the enrich worker to backfill on startup and on each wake. + Returns both fields so the worker can write keyed on UUID without + a second per-attacker DB round-trip to resolve the IP for outbound + provider calls. """ pass diff --git a/decnet/web/db/sqlmodel_repo.py b/decnet/web/db/sqlmodel_repo.py index 77e25c28..1a2f1721 100644 --- a/decnet/web/db/sqlmodel_repo.py +++ b/decnet/web/db/sqlmodel_repo.py @@ -1197,10 +1197,12 @@ class SQLModelRepository(BaseRepository): return row.model_dump(mode="json") async def upsert_attacker_intel(self, data: dict[str, Any]) -> str: - ip = data["attacker_ip"] + attacker_uuid_value = data["attacker_uuid"] async with self._session() as session: result = await session.execute( - select(AttackerIntel).where(AttackerIntel.attacker_ip == ip) + select(AttackerIntel).where( + AttackerIntel.attacker_uuid == attacker_uuid_value, + ) ) existing = result.scalar_one_or_none() if existing: @@ -1214,13 +1216,13 @@ class SQLModelRepository(BaseRepository): await session.commit() return row_uuid - async def get_attacker_intel_by_ip( + async def get_attacker_intel_by_uuid( self, - ip: str, + uuid: str, ) -> Optional[dict[str, Any]]: async with self._session() as session: result = await session.execute( - select(AttackerIntel).where(AttackerIntel.attacker_ip == ip) + select(AttackerIntel).where(AttackerIntel.attacker_uuid == uuid) ) row = result.scalar_one_or_none() if not row: @@ -1240,17 +1242,23 @@ class SQLModelRepository(BaseRepository): pass return d - async def get_unenriched_attacker_ips(self, limit: int = 100) -> list[str]: - """IPs in ``attackers`` with no intel row OR a stale (expired) one. + async def get_unenriched_attackers( + self, limit: int = 100, + ) -> list[dict[str, Any]]: + """``{"uuid", "ip"}`` pairs with no intel row OR a stale (expired) one. Stale = ``expires_at < now``. Ordered by ``attackers.last_seen`` desc - so the worker prioritises recent activity on backfill. + so the worker prioritises recent activity on backfill. Both columns + are projected so the worker can write keyed on UUID and dispatch + provider calls keyed on IP without a second round-trip. """ now = datetime.now(timezone.utc) async with self._session() as session: stmt = ( - select(Attacker.ip) - .outerjoin(AttackerIntel, AttackerIntel.attacker_ip == Attacker.ip) + select(Attacker.uuid, Attacker.ip) + .outerjoin( + AttackerIntel, AttackerIntel.attacker_uuid == Attacker.uuid, + ) .where( or_( AttackerIntel.uuid.is_(None), @@ -1261,7 +1269,10 @@ class SQLModelRepository(BaseRepository): .limit(limit) ) result = await session.execute(stmt) - return [row for row in result.scalars().all()] + return [ + {"uuid": uuid_, "ip": ip} + for uuid_, ip in result.all() + ] async def increment_smtp_target(self, attacker_uuid: str, domain: str) -> None: """Upsert an (attacker_uuid, domain) pair and bump count + last_seen. diff --git a/decnet/web/router/attackers/api_get_attacker_intel.py b/decnet/web/router/attackers/api_get_attacker_intel.py index 93d57b6c..cce2317b 100644 --- a/decnet/web/router/attackers/api_get_attacker_intel.py +++ b/decnet/web/router/attackers/api_get_attacker_intel.py @@ -1,4 +1,4 @@ -"""GET /api/v1/attackers/{ip}/intel — latest threat-intel row for an IP.""" +"""GET /api/v1/attackers/{uuid}/intel — latest threat-intel row for an attacker.""" from typing import Any from fastapi import APIRouter, Depends, HTTPException @@ -10,27 +10,29 @@ router = APIRouter() @router.get( - "/attackers/{ip}/intel", + "/attackers/{uuid}/intel", tags=["Attacker Profiles"], responses={ 401: {"description": "Could not validate credentials"}, 403: {"description": "Insufficient permissions"}, - 404: {"description": "No intel cached for this IP"}, + 404: {"description": "No intel cached for this attacker"}, }, ) @_traced("api.get_attacker_intel") async def get_attacker_intel( - ip: str, + uuid: str, user: dict = Depends(require_viewer), ) -> dict[str, Any]: - """Return the most recent cached threat-intel verdict for ``ip``. + """Return the most recent cached threat-intel verdict for an attacker. The row is populated out-of-band by the ``decnet enrich`` worker (typically within seconds of first observation, sub-second when the bus is healthy). 404 means either the worker has not run yet or the - IP has never been observed by DECNET. + UUID does not correspond to an attacker DECNET has seen. """ - record = await repo.get_attacker_intel_by_ip(ip) + record = await repo.get_attacker_intel_by_uuid(uuid) if not record: - raise HTTPException(status_code=404, detail="No intel cached for this IP") + raise HTTPException( + status_code=404, detail="No intel cached for this attacker", + ) return record diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index fb6393ea..6a426bc5 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -956,6 +956,7 @@ const LeakedIPsRow: React.FC = ({ leaks, total }) => { // fields plus null gaps where a provider hasn't answered yet. We treat // every column as optional on the wire. type IntelRow = { + attacker_uuid: string; attacker_ip: string; schema_version?: number; aggregate_verdict?: 'malicious' | 'suspicious' | 'benign' | 'unknown' | null; @@ -1013,7 +1014,7 @@ const ProviderRow: React.FC<{ ); -const IntelPanel: React.FC<{ ip: string }> = ({ ip }) => { +const IntelPanel: React.FC<{ uuid: string }> = ({ uuid }) => { const [intel, setIntel] = useState(null); const [state, setState] = useState<'loading' | 'absent' | 'ok' | 'error'>('loading'); @@ -1022,7 +1023,7 @@ const IntelPanel: React.FC<{ ip: string }> = ({ ip }) => { const load = async () => { setState('loading'); try { - const res = await api.get(`/attackers/${encodeURIComponent(ip)}/intel`); + const res = await api.get(`/attackers/${encodeURIComponent(uuid)}/intel`); if (!cancelled) { setIntel(res.data); setState('ok'); @@ -1039,7 +1040,7 @@ const IntelPanel: React.FC<{ ip: string }> = ({ ip }) => { }; load(); return () => { cancelled = true; }; - }, [ip]); + }, [uuid]); if (state === 'loading') { return ( @@ -1756,13 +1757,13 @@ const AttackerDetail: React.FC = () => { ); })()} - {/* Threat-Intel Enrichment — keyed by attacker.ip (see DEBT-041) */} + {/* Threat-Intel Enrichment — UUID-keyed, fetches in parallel with the parent. */}
THREAT INTEL} open={openSections.intel} onToggle={() => toggle('intel')} > - +
{/* Captured Artifacts */} diff --git a/development/DEBT.md b/development/DEBT.md index 6e2a786e..540a5ff6 100644 --- a/development/DEBT.md +++ b/development/DEBT.md @@ -409,7 +409,9 @@ Shared prep landed in commit 1: `_sync_ntlmssp_sources()` in `decnet/engine/depl - Full `TS_INFO_PACKET` (basic-RDP plaintext password) — see scope-down note in commit 2. Re-open as a follow-up DEBT if attacker telemetry actually shows traffic on `PROTOCOL_RDP` without NLA. - Pubkey / Kerberos auth paths — out of scope; mirrors DEBT-038's deferral on the SSH side. -### DEBT-041 — Intel API + UI keyed by attacker.ip, not attacker.uuid +### ~~DEBT-041 — Intel API + UI keyed by attacker.ip, not attacker.uuid~~ ✅ RESOLVED +Closed by re-key commit on `dev`. `attacker_intel.attacker_uuid` is now the canonical key (UNIQUE + FK to `attackers.uuid`); `attacker_ip` stays as a denormalised value column (indexed, not unique). `GET /api/v1/attackers/{uuid}/intel` is the only public route — the IP-keyed alias was deleted, not deprecated. Bus event `attacker.intel.enriched` payload gains `attacker_uuid` alongside `attacker_ip` for SIEM consumers. `` swaps to UUID. The ticket text below is preserved as the original rationale. + **Files:** `decnet/web/router/attackers/api_get_attacker_intel.py`, `decnet/web/db/sqlmodel_repo.py:upsert_attacker_intel`, `decnet/web/db/models/attacker_intel.py`, `decnet_web/src/components/AttackerDetail.tsx` (``). The threat-intel enrichment surface (DEBT-N/A: `feat(intel)` series) keys every public surface — `GET /api/v1/attackers/{ip}/intel`, the row's `attacker_ip` UNIQUE, and the React `` — on the attacker's IP rather than the canonical `attacker.uuid` we use for every other attacker-detail route. The decision was deliberate in v1: the enricher is woken by `attacker.observed` / `attacker.scored` events whose payload is naturally IP-keyed, the row models a *one-row-per-IP* TTL cache, and standing up a parallel UUID lookup endpoint would have added a join hop with no consumer. @@ -516,7 +518,7 @@ The prober already computes JARM (`worker.py:286`), HASSH (`worker.py:334`), and | DEBT-038 | 🟡 Medium | Honeypot / SSH cred capture | open (document-only) | | ~~DEBT-039~~ | ✅ | Honeypot / Cred emitters | resolved | | ~~DEBT-040~~ | ✅ | Honeypot / RDP+SMB cred framers | resolved | -| DEBT-041 | 🟡 Medium | API / UI / Threat-intel keying | open | +| ~~DEBT-041~~ | ✅ | API / UI / Threat-intel keying | resolved | -**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store), DEBT-028 (deploy endpoint tests), DEBT-032 (fingerprint rotation detection), DEBT-033 (transcript shard rotation), DEBT-035 (artifacts uid/gid alignment), DEBT-036 (session-profile ingester), DEBT-037 (webhook delivery hardening), DEBT-038 (SSH PAM cred-capture limitations — document-only), DEBT-041 (intel API/UI keyed by IP, not UUID). +**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store), DEBT-028 (deploy endpoint tests), DEBT-032 (fingerprint rotation detection), DEBT-033 (transcript shard rotation), DEBT-035 (artifacts uid/gid alignment), DEBT-036 (session-profile ingester), DEBT-037 (webhook delivery hardening), DEBT-038 (SSH PAM cred-capture limitations — document-only). **Estimated remaining effort:** ~21 hours. DEBT-030 Phase B (optimistic staged-buffer editor) is a follow-up, not debt. diff --git a/tests/intel/test_attacker_intel_repo.py b/tests/intel/test_attacker_intel_repo.py index d17c57fb..6c6122bd 100644 --- a/tests/intel/test_attacker_intel_repo.py +++ b/tests/intel/test_attacker_intel_repo.py @@ -2,11 +2,12 @@ Round-trip tests for the ``attacker_intel`` table and its repo helpers. Covers: -* empty-write upsert path -* per-provider partial update +* empty-write upsert path (attacker_uuid as canonical key) +* per-provider partial update preserves untouched columns * JSON-blob deserialization on read * TTL bookkeeping (cached_at + expires_at) round-trips intact -* ``get_unenriched_attacker_ips`` selects fresh + stale, skips cached +* ``get_unenriched_attackers`` returns ``{"uuid", "ip"}`` pairs and + selects fresh + stale rows while skipping cached ones """ from __future__ import annotations @@ -24,9 +25,20 @@ async def repo(tmp_path): return r -def _intel_payload(ip: str, *, ttl_hours: int = 24, **overrides) -> dict: +async def _seed_attacker(repo, ip: str) -> str: + """Seed an attackers row and return its UUID (the FK target).""" + now = datetime.now(timezone.utc) + return await repo.upsert_attacker( + {"ip": ip, "first_seen": now, "last_seen": now, "event_count": 1} + ) + + +def _intel_payload( + *, attacker_uuid: str, ip: str, ttl_hours: int = 24, **overrides +) -> dict: now = datetime.now(timezone.utc) base = { + "attacker_uuid": attacker_uuid, "attacker_ip": ip, "cached_at": now, "expires_at": now + timedelta(hours=ttl_hours), @@ -37,11 +49,15 @@ def _intel_payload(ip: str, *, ttl_hours: int = 24, **overrides) -> dict: @pytest.mark.anyio async def test_empty_upsert_writes_minimal_row(repo): - row_uuid = await repo.upsert_attacker_intel(_intel_payload("1.2.3.4")) + a_uuid = await _seed_attacker(repo, "1.2.3.4") + row_uuid = await repo.upsert_attacker_intel( + _intel_payload(attacker_uuid=a_uuid, ip="1.2.3.4") + ) assert row_uuid - row = await repo.get_attacker_intel_by_ip("1.2.3.4") + row = await repo.get_attacker_intel_by_uuid(a_uuid) assert row is not None + assert row["attacker_uuid"] == a_uuid assert row["attacker_ip"] == "1.2.3.4" assert row["uuid"] == row_uuid assert row["schema_version"] == 1 @@ -55,10 +71,11 @@ async def test_empty_upsert_writes_minimal_row(repo): @pytest.mark.anyio async def test_partial_provider_update_preserves_others(repo): + a_uuid = await _seed_attacker(repo, "9.9.9.9") # First pass: GreyNoise responds, others lag. first_uuid = await repo.upsert_attacker_intel( _intel_payload( - "9.9.9.9", + attacker_uuid=a_uuid, ip="9.9.9.9", greynoise_classification="malicious", greynoise_raw='{"classification":"malicious"}', greynoise_queried_at=datetime.now(timezone.utc), @@ -68,15 +85,15 @@ async def test_partial_provider_update_preserves_others(repo): # columns — the worker passes only the new fields. second_uuid = await repo.upsert_attacker_intel( _intel_payload( - "9.9.9.9", + attacker_uuid=a_uuid, ip="9.9.9.9", abuseipdb_score=85, abuseipdb_raw='{"abuseConfidenceScore":85}', abuseipdb_queried_at=datetime.now(timezone.utc), ) ) - assert first_uuid == second_uuid # same row + assert first_uuid == second_uuid # same row keyed on attacker_uuid - row = await repo.get_attacker_intel_by_ip("9.9.9.9") + row = await repo.get_attacker_intel_by_uuid(a_uuid) assert row["greynoise_classification"] == "malicious" assert row["greynoise_raw"] == {"classification": "malicious"} assert row["abuseipdb_score"] == 85 @@ -85,29 +102,28 @@ async def test_partial_provider_update_preserves_others(repo): @pytest.mark.anyio async def test_get_missing_returns_none(repo): - assert await repo.get_attacker_intel_by_ip("0.0.0.0") is None + assert await repo.get_attacker_intel_by_uuid("nonexistent-uuid") is None @pytest.mark.anyio -async def test_unenriched_selects_fresh_and_stale_ips(repo): - # Seed three attackers via upsert_attacker. - now = datetime.now(timezone.utc) - for ip in ("10.0.0.1", "10.0.0.2", "10.0.0.3"): - await repo.upsert_attacker( - { - "ip": ip, - "first_seen": now, - "last_seen": now, - "event_count": 1, - } - ) +async def test_unenriched_returns_uuid_ip_pairs(repo): + fresh_uuid = await _seed_attacker(repo, "10.0.0.1") + stale_uuid = await _seed_attacker(repo, "10.0.0.2") + new_uuid = await _seed_attacker(repo, "10.0.0.3") + # 10.0.0.1 has fresh intel (not due for refresh). - await repo.upsert_attacker_intel(_intel_payload("10.0.0.1", ttl_hours=24)) + await repo.upsert_attacker_intel( + _intel_payload(attacker_uuid=fresh_uuid, ip="10.0.0.1", ttl_hours=24) + ) # 10.0.0.2 has stale intel (already expired). - await repo.upsert_attacker_intel(_intel_payload("10.0.0.2", ttl_hours=-1)) + await repo.upsert_attacker_intel( + _intel_payload(attacker_uuid=stale_uuid, ip="10.0.0.2", ttl_hours=-1) + ) # 10.0.0.3 has no intel row at all. - pending = await repo.get_unenriched_attacker_ips(limit=10) - assert "10.0.0.1" not in pending # fresh, skipped - assert "10.0.0.2" in pending # stale, queue it - assert "10.0.0.3" in pending # never enriched + pending = await repo.get_unenriched_attackers(limit=10) + by_uuid = {entry["uuid"]: entry["ip"] for entry in pending} + + assert fresh_uuid not in by_uuid # fresh, skipped + assert by_uuid.get(stale_uuid) == "10.0.0.2" # stale, queue it + assert by_uuid.get(new_uuid) == "10.0.0.3" # never enriched diff --git a/tests/intel/test_worker.py b/tests/intel/test_worker.py index 05a76d99..53b20cde 100644 --- a/tests/intel/test_worker.py +++ b/tests/intel/test_worker.py @@ -7,7 +7,7 @@ Covers — without any real provider impls — that the loop: * fans out across fake providers and writes the aggregate row * aggregate_verdict picks the strongest provider verdict * a provider returning ``error`` is logged but does not poison the row -* gates IPs through ``get_unenriched_attacker_ips`` (TTL respected) +* gates attackers through ``get_unenriched_attackers`` (TTL respected) """ from __future__ import annotations @@ -92,12 +92,17 @@ async def test_loop_exits_on_shutdown_signal(repo): await asyncio.wait_for(task, timeout=2.0) +async def _seed_attacker(repo, ip: str) -> str: + """Seed an attackers row and return its UUID.""" + now = datetime.now(timezone.utc) + return await repo.upsert_attacker( + {"ip": ip, "first_seen": now, "last_seen": now, "event_count": 1} + ) + + @pytest.mark.anyio async def test_no_providers_skips_enrichment(repo): - now = datetime.now(timezone.utc) - await repo.upsert_attacker( - {"ip": "1.1.1.1", "first_seen": now, "last_seen": now, "event_count": 1} - ) + a_uuid = await _seed_attacker(repo, "1.1.1.1") shutdown = asyncio.Event() task = asyncio.create_task( run_intel_loop( @@ -110,16 +115,13 @@ async def test_no_providers_skips_enrichment(repo): await asyncio.sleep(0.15) shutdown.set() await asyncio.wait_for(task, timeout=2.0) - # No row written for 1.1.1.1. - assert await repo.get_attacker_intel_by_ip("1.1.1.1") is None + # No row written for the seeded attacker. + assert await repo.get_attacker_intel_by_uuid(a_uuid) is None @pytest.mark.anyio async def test_fan_out_writes_aggregate_row(repo): - now = datetime.now(timezone.utc) - await repo.upsert_attacker( - {"ip": "2.2.2.2", "first_seen": now, "last_seen": now, "event_count": 1} - ) + a_uuid = await _seed_attacker(repo, "2.2.2.2") gn = _FakeProvider( "greynoise", @@ -154,23 +156,22 @@ async def test_fan_out_writes_aggregate_row(repo): shutdown.set() await asyncio.wait_for(task, timeout=2.0) - row = await repo.get_attacker_intel_by_ip("2.2.2.2") + row = await repo.get_attacker_intel_by_uuid(a_uuid) assert row is not None + assert row["attacker_uuid"] == a_uuid + assert row["attacker_ip"] == "2.2.2.2" assert row["greynoise_classification"] == "benign" assert row["abuseipdb_score"] == 90 # Strongest verdict wins. assert row["aggregate_verdict"] == "malicious" - # Both providers were queried. + # Both providers were queried by IP. assert gn.calls == ["2.2.2.2"] assert aip.calls == ["2.2.2.2"] @pytest.mark.anyio async def test_provider_error_does_not_poison_row(repo): - now = datetime.now(timezone.utc) - await repo.upsert_attacker( - {"ip": "3.3.3.3", "first_seen": now, "last_seen": now, "event_count": 1} - ) + a_uuid = await _seed_attacker(repo, "3.3.3.3") good = _FakeProvider( "greynoise", @@ -196,7 +197,7 @@ async def test_provider_error_does_not_poison_row(repo): shutdown.set() await asyncio.wait_for(task, timeout=2.0) - row = await repo.get_attacker_intel_by_ip("3.3.3.3") + row = await repo.get_attacker_intel_by_uuid(a_uuid) assert row is not None assert row["greynoise_classification"] == "benign" # Broken provider's columns stay null; row is still written. @@ -226,10 +227,7 @@ async def test_intel_enriched_event_published_to_bus(repo, monkeypatch): sub = shared_bus.subscribe(attacker(ATTACKER_INTEL_ENRICHED)) await sub.__aenter__() - now = datetime.now(timezone.utc) - await repo.upsert_attacker( - {"ip": "4.4.4.4", "first_seen": now, "last_seen": now, "event_count": 1} - ) + a_uuid = await _seed_attacker(repo, "4.4.4.4") provider = _FakeProvider( "greynoise", @@ -258,6 +256,7 @@ async def test_intel_enriched_event_published_to_bus(repo, monkeypatch): await sub.__aexit__(None, None, None) payload = event.payload + assert payload["attacker_uuid"] == a_uuid assert payload["attacker_ip"] == "4.4.4.4" assert payload["aggregate_verdict"] == "malicious" assert payload["providers"] == ["greynoise"] diff --git a/tests/web/test_api_attacker_intel.py b/tests/web/test_api_attacker_intel.py index b7b31405..fb674f4e 100644 --- a/tests/web/test_api_attacker_intel.py +++ b/tests/web/test_api_attacker_intel.py @@ -1,4 +1,4 @@ -"""Tests for GET /api/v1/attackers/{ip}/intel.""" +"""Tests for GET /api/v1/attackers/{uuid}/intel.""" from __future__ import annotations from unittest.mock import AsyncMock, patch @@ -14,6 +14,7 @@ async def test_returns_cached_intel_row(): ) fake_row = { + "attacker_uuid": "att-uuid-xyz", "attacker_ip": "1.2.3.4", "aggregate_verdict": "malicious", "greynoise_classification": "malicious", @@ -24,11 +25,12 @@ async def test_returns_cached_intel_row(): with patch( "decnet.web.router.attackers.api_get_attacker_intel.repo" ) as mock_repo: - mock_repo.get_attacker_intel_by_ip = AsyncMock(return_value=fake_row) + mock_repo.get_attacker_intel_by_uuid = AsyncMock(return_value=fake_row) result = await get_attacker_intel( - ip="1.2.3.4", + uuid="att-uuid-xyz", user={"uuid": "viewer", "role": "viewer"}, ) + assert result["attacker_uuid"] == "att-uuid-xyz" assert result["aggregate_verdict"] == "malicious" assert result["abuseipdb_score"] == 92 @@ -42,10 +44,10 @@ async def test_404_when_no_row_cached(): with patch( "decnet.web.router.attackers.api_get_attacker_intel.repo" ) as mock_repo: - mock_repo.get_attacker_intel_by_ip = AsyncMock(return_value=None) + mock_repo.get_attacker_intel_by_uuid = AsyncMock(return_value=None) with pytest.raises(HTTPException) as excinfo: await get_attacker_intel( - ip="0.0.0.0", + uuid="missing-uuid", user={"uuid": "viewer", "role": "viewer"}, ) assert excinfo.value.status_code == 404