refactor(intel): re-key attacker_intel on attacker_uuid (closes DEBT-041)

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:
- <IntelPanel uuid={id!} /> 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).
This commit is contained in:
2026-04-26 05:35:29 -04:00
parent a009549326
commit 3eb67c9400
10 changed files with 161 additions and 97 deletions

View File

@@ -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:

View File

@@ -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 ─────────────────────────────────────────────

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -956,6 +956,7 @@ const LeakedIPsRow: React.FC<LeakedIPsRowProps> = ({ 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<{
</div>
);
const IntelPanel: React.FC<{ ip: string }> = ({ ip }) => {
const IntelPanel: React.FC<{ uuid: string }> = ({ uuid }) => {
const [intel, setIntel] = useState<IntelRow | null>(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. */}
<Section
title={<><Globe size={14} style={{ verticalAlign: 'middle', marginRight: '6px' }} />THREAT INTEL</>}
open={openSections.intel}
onToggle={() => toggle('intel')}
>
<IntelPanel ip={attacker.ip} />
<IntelPanel uuid={id!} />
</Section>
{/* Captured Artifacts */}

View File

@@ -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. `<IntelPanel uuid={...} />` 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` (`<IntelPanel ip={attacker.ip} />`).
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 `<IntelPanel ip=...>` — 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.

View File

@@ -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

View File

@@ -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"]

View File

@@ -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