feat(intel): decnet enrich CLI + GET /attackers/{ip}/intel endpoint

CLI command mirrors the reuse-correlate shape (--poll-interval, --ttl-hours,
--daemon). Run it under systemd as a sibling worker.

The API endpoint returns the most recent cached row for an attacker IP
or 404. Auth-gated via require_viewer like every other attacker route.

Also extends the worker test with a real FakeBus so the
attacker.intel.enriched publish path is exercised end-to-end (no longer
a no-op against NullBus).
This commit is contained in:
2026-04-26 05:17:25 -04:00
parent cd70136d09
commit d3d9bd5aa7
5 changed files with 202 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ from .attackers.api_get_attacker_artifacts import router as attacker_artifacts_r
from .attackers.api_get_attacker_transcripts import router as attacker_transcripts_router
from .attackers.api_get_attacker_smtp_targets import router as attacker_smtp_targets_router
from .attackers.api_get_attacker_mail import router as attacker_mail_router
from .attackers.api_get_attacker_intel import router as attacker_intel_router
from .transcripts import transcripts_router
from .config.api_get_config import router as config_get_router
from .config.api_update_config import router as config_update_router
@@ -81,6 +82,7 @@ api_router.include_router(attacker_artifacts_router)
api_router.include_router(attacker_transcripts_router)
api_router.include_router(attacker_smtp_targets_router)
api_router.include_router(attacker_mail_router)
api_router.include_router(attacker_intel_router)
# Observability
api_router.include_router(stats_router)

View File

@@ -0,0 +1,36 @@
"""GET /api/v1/attackers/{ip}/intel — latest threat-intel row for an IP."""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import repo, require_viewer
router = APIRouter()
@router.get(
"/attackers/{ip}/intel",
tags=["Attacker Profiles"],
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "No intel cached for this IP"},
},
)
@_traced("api.get_attacker_intel")
async def get_attacker_intel(
ip: str,
user: dict = Depends(require_viewer),
) -> dict[str, Any]:
"""Return the most recent cached threat-intel verdict for ``ip``.
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.
"""
record = await repo.get_attacker_intel_by_ip(ip)
if not record:
raise HTTPException(status_code=404, detail="No intel cached for this IP")
return record