from typing import Any from fastapi import APIRouter, Depends, HTTPException from decnet.correlation.event_kinds import bucket_services from decnet.telemetry import traced as _traced from decnet.web.dependencies import require_viewer, repo router = APIRouter() @router.get( "/attackers/{uuid}", tags=["Attacker Profiles"], responses={ 401: {"description": "Could not validate credentials"}, 403: {"description": "Insufficient permissions"}, 404: {"description": "Attacker not found"}, }, ) @_traced("api.get_attacker_detail") async def get_attacker_detail( uuid: str, user: dict = Depends(require_viewer), ) -> dict[str, Any]: """Retrieve a single attacker profile by UUID (with behavior block).""" attacker = await repo.get_attacker_by_uuid(uuid) if not attacker: raise HTTPException(status_code=404, detail="Attacker not found") attacker["behavior"] = await repo.get_attacker_behavior(uuid) # Scanned vs. interacted-with — computed per-request from the log # stream, not persisted. Cheap (DISTINCT bounded by service × # event_type cardinality), and changes to the classifier take effect # immediately without a profiler re-tick. pairs = await repo.get_attacker_service_activity(uuid) attacker["service_activity"] = bucket_services(pairs) # Attribution leaks — XFF / Forwarded / X-Real-IP mismatches captured # by the HTTP bounty extractor. Cap the returned list at 10 so a # rotation attack (100s of forged XFF values) doesn't flood the UI; # `ip_leaks_total` carries the unbounded count so the UI can render # a ROTATION DETECTED badge when the count crosses a threshold. attacker["ip_leaks"] = await repo.get_attacker_ip_leaks(uuid, limit=10) attacker["ip_leaks_total"] = await repo.count_attacker_ip_leaks(uuid) # BEHAVE-SHELL observations — latest value per primitive for this # attacker. Empty dict (rendered as empty list) until the # extractor (DEBT-050) lands and starts writing rows. The frontend # panel that consumes this ships in BEHAVE-INTEGRATION.md Phase 5. latest_per_primitive = await repo.latest_observation_per_primitive(uuid) attacker["observations"] = [ {"primitive": primitive, **payload} for primitive, payload in sorted(latest_per_primitive.items()) ] return attacker