feat(web): expose attribution state on AttackerDetail backend (Phase 6)

GET /api/v1/attackers/{uuid}/attribution

Returns the merger output for an attacker's identity:

    {
        "identity_uuid": "abc..." | null,
        "primitives": [
            {primitive, current_value, state, confidence,
             observation_count, last_change_ts, last_observation_ts},
            ...
        ]
    }

Pre-attribution-worker: identity_uuid=null, primitives=[]. Surfacing
identity_uuid keeps the cross-attacker rollup story visible to the
frontend ahead of v1's clusterer landing.

api_events SSE relay also subscribes to attribution.> and forwards
to the AttackerDetail page filtered on payload.identity_uuid (the
identity is resolved at stream open from the URL's attacker_uuid;
attribution payloads are identity-keyed, not attacker-keyed). New
SSE event names: attribution.state_changed,
attribution.multi_actor_suspected.

Frontend (AttackerDetail.tsx badge rendering, useAttackerStream
consumer) deferred — there's already WIP on AttackerDetail.tsx in
the working tree; merging the badge logic is a separate commit
once that lands.

Tests: 4 endpoint scenarios — 401 unauth, 404 unknown attacker,
200 empty (no stub), 200 with primitive-ordered rows.
This commit is contained in:
2026-05-09 02:21:59 -04:00
parent e2c7e16793
commit 33f7d5a9ff
4 changed files with 248 additions and 10 deletions

View File

@@ -23,6 +23,7 @@ from .attackers.api_get_attacker_transcripts import router as attacker_transcrip
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 .attackers.api_get_attacker_attribution import router as attacker_attribution_router
from .identities.api_list_identities import router as identities_list_router
from .identities.api_get_identity_detail import router as identity_detail_router
from .identities.api_list_identity_observations import router as identity_observations_router
@@ -111,6 +112,7 @@ 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)
api_router.include_router(attacker_attribution_router)
# Identity Resolution (read-only; populated by the clusterer worker —
# see development/IDENTITY_RESOLUTION.md). Empty until the clusterer

View File

@@ -59,8 +59,10 @@ def _sse_name_for(topic: str) -> str:
register 37+ listeners or know the registry. Single event name
keeps the EventSource handler shape uniform.)
``attacker.fingerprint_rotated`` → ``fingerprint.rotated``
``attacker.scored`` → ``attacker.scored``
``attacker.fingerprint_rotated`` → ``fingerprint.rotated``
``attacker.scored`` → ``attacker.scored``
``attribution.profile.state_changed`` → ``attribution.state_changed``
``attribution.profile.multi_actor_suspected`` → ``attribution.multi_actor_suspected``
Anything else passes through unchanged so a future ``attacker.*``
family doesn't silently collapse onto a generic bucket.
@@ -71,6 +73,12 @@ def _sse_name_for(topic: str) -> str:
return "fingerprint.rotated"
if topic == f"{_topics.ATTACKER}.{_topics.ATTACKER_SCORED}":
return "attacker.scored"
if topic == _topics.attribution(_topics.ATTRIBUTION_PROFILE_STATE_CHANGED):
return "attribution.state_changed"
if topic == _topics.attribution(
_topics.ATTRIBUTION_PROFILE_MULTI_ACTOR_SUSPECTED,
):
return "attribution.multi_actor_suspected"
return topic
@@ -95,7 +103,12 @@ async def api_attacker_events(
user: dict = Depends(require_stream_viewer),
) -> StreamingResponse:
# 404-after-auth so an existence probe can't enumerate attacker UUIDs.
await get_attacker_or_404(attacker_uuid)
attacker = await get_attacker_or_404(attacker_uuid)
# Pre-resolve the identity_uuid so attribution.profile.* events
# (keyed on identity_uuid, not attacker_uuid) can be filtered
# without a per-event repo lookup. None until the attribution
# worker stamps a stub on first observation.
identity_uuid = attacker.get("identity_id") if isinstance(attacker, dict) else None
snapshot_per_primitive = await repo.latest_observation_per_primitive(
attacker_uuid,
@@ -129,9 +142,10 @@ async def api_attacker_events(
yield ": keepalive\n\n"
return
# Three subscriptions, merged through one queue. Per-attacker
# filter on payload["attacker_uuid"] — the profiler worker
# stamps it on every published payload (Phase 5 amendment).
# Five subscriptions, merged through one queue. Filter on
# payload["attacker_uuid"] for attacker.* events; on
# payload["identity_uuid"] (resolved at stream open) for
# attribution.profile.* events.
obs_sub = bus.subscribe(f"{_topics.ATTACKER}.{_topics.ATTACKER_OBSERVATION_PREFIX}.>")
fp_sub = bus.subscribe(
f"{_topics.ATTACKER}.{_topics.ATTACKER_FINGERPRINT_ROTATED}",
@@ -139,9 +153,10 @@ async def api_attacker_events(
score_sub = bus.subscribe(
f"{_topics.ATTACKER}.{_topics.ATTACKER_SCORED}",
)
attribution_sub = bus.subscribe(f"{_topics.ATTRIBUTION}.>")
queue: asyncio.Queue = asyncio.Queue(maxsize=_QUEUE_MAX)
async def _pump(sub) -> None:
async def _pump_by_attacker(sub) -> None:
async with sub:
async for ev in sub:
payload = ev.payload or {}
@@ -155,10 +170,26 @@ async def api_attacker_events(
# cover any gap a slow consumer creates.
pass
async def _pump_by_identity(sub) -> None:
async with sub:
async for ev in sub:
payload = ev.payload or {}
# If the attacker has no stub identity yet,
# there's nothing to filter on — skip silently.
if identity_uuid is None:
continue
if payload.get("identity_uuid") != identity_uuid:
continue
try:
queue.put_nowait(ev)
except asyncio.QueueFull:
pass
tasks = [
asyncio.create_task(_pump(obs_sub)),
asyncio.create_task(_pump(fp_sub)),
asyncio.create_task(_pump(score_sub)),
asyncio.create_task(_pump_by_attacker(obs_sub)),
asyncio.create_task(_pump_by_attacker(fp_sub)),
asyncio.create_task(_pump_by_attacker(score_sub)),
asyncio.create_task(_pump_by_identity(attribution_sub)),
]
try:
while True:

View File

@@ -0,0 +1,92 @@
"""GET /api/v1/attackers/{uuid}/attribution — per-primitive
attribution state for one attacker.
Returns the merger output produced by
:mod:`decnet.correlation.attribution_worker` over the observations
linked to this attacker's identity. Pre-clusterer (v0), every
attacker has a 1:1 stub identity, so the returned set is the merger
output for the single attacker; v1's clusterer makes the rollup
cross-attacker.
Empty ``primitives`` is the honest answer when:
- The attribution worker has not yet processed an observation for
this attacker (race with first-sight + behave-shell ingest), OR
- The attacker has fewer than ``MIN_OBSERVATIONS_FOR_STATE``
observations of any primitive — every state row would be ``unknown``,
and the worker writes those, so the empty case is genuinely "engine
hasn't run yet".
The response includes ``identity_uuid`` so AttackerDetail can render
a "rolls up to identity X" hint ahead of the v1 IdentityDetail wire-
up — we don't pretend the keying is per-attacker.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import repo, require_viewer
from ._guards import get_attacker_or_404
router = APIRouter()
@router.get(
"/attackers/{uuid}/attribution",
tags=["Attacker Profiles"],
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Attacker not found"},
},
)
@_traced("api.get_attacker_attribution")
async def get_attacker_attribution(
uuid: str,
user: dict = Depends(require_viewer),
) -> dict[str, Any]:
"""Return per-primitive attribution state for an attacker.
Shape::
{
"identity_uuid": "abc123..." | null,
"primitives": [
{
"primitive": "motor.input_modality",
"current_value": "pasted",
"state": "stable",
"confidence": 0.91,
"observation_count": 7,
"last_change_ts": 1714521660.456,
"last_observation_ts": 1714521660.456
},
...
]
}
"""
attacker = await get_attacker_or_404(uuid)
identity_uuid = attacker.get("identity_id") if isinstance(attacker, dict) else None
if not identity_uuid:
# Attacker exists but the attribution worker has not yet
# stamped a stub identity on first observation.
return {"identity_uuid": None, "primitives": []}
rows = await repo.get_attribution_state_for_identity(identity_uuid)
primitives = [
{
"primitive": row["primitive"],
"current_value": row["current_value"],
"state": row["state"],
"confidence": row["confidence"],
"observation_count": row["observation_count"],
"last_change_ts": row["last_change_ts"],
"last_observation_ts": row["last_observation_ts"],
}
for row in rows
]
return {"identity_uuid": identity_uuid, "primitives": primitives}