feat(ttp): inspector drawer surfaces evidence + rule_id behind each technique

The TTPsObservedSection rollup tells the operator "we saw T1059" but
not why. Click any technique row → side drawer opens listing every
ttp_tag row in scope with the persisted evidence JSON, firing
rule_id / rule_version, source_kind / source_id, confidence, and
created_at. Mirrors the CredentialReuseInspector / BountyInspector
pattern (drawer-backdrop + bd-head/bd-body + kvs grid).

Backend:
- New `GET /api/v1/ttp/tags/by-{scope}/{uuid}/{technique_id}`
  (`scope ∈ {identity, attacker, session}`, optional
  `?sub_technique_id=`, `?limit=` capped to 1000). Returns raw
  TTPTag rows newest-first.
- New `TTPTagDetailRow` Pydantic model + re-export.
- New repo method `list_tags_by_scope_and_technique` on
  TTPMixin (+ abstract on BaseRepository) — single query branched
  on scope; identity scope projects through `Attacker.identity_id`
  the same way `list_techniques_by_identity` does.
- Tests: evidence round-trips, sub_technique filter, JWT-required,
  empty scope, unknown scope rejected.

Frontend:
- New `TTPInspector.tsx` + `TTPInspector.css` (violet accent, slide
  animation, focus-trapped panel matching the existing inspector
  family).
- `TTPsObservedSection`'s TechniqueBar is now click+keyboard
  activatable; clicking opens the inspector for that
  (technique, sub_technique) tuple.

mypy clean. 532 passed in the targeted sweep.
This commit is contained in:
2026-05-02 02:55:05 -04:00
parent c4e29e3bf9
commit 42e9492118
11 changed files with 661 additions and 2 deletions

View File

@@ -200,6 +200,7 @@ from .ttp import (
TTPRule,
TTPRuleState,
TTPTag,
TTPTagDetailRow,
TechniqueRollupRow,
compute_tag_uuid,
)
@@ -369,6 +370,7 @@ __all__ = [
"CommandEvidence",
"EmailEvidence",
"IdentityTechniqueRow",
"TTPTagDetailRow",
"IntelEvidence",
"NavigatorLayer",
"NavigatorTechnique",

View File

@@ -270,6 +270,32 @@ class IdentityTechniqueRow(BaseModel):
confidence_max: float
class TTPTagDetailRow(BaseModel):
"""One row of ``GET /api/v1/ttp/tags/by-{scope}/{uuid}/{technique_id}`` —
a single ``ttp_tag`` row exposing the rule-engine's reasoning
(rule_id / source_kind / source_id / evidence) so the operator UI
can show *why* the engine flagged a technique, not just *that* it
did. Mirrors the persisted shape of :class:`TTPTag` minus the
NULL-anchor guard fields the consumer doesn't need."""
uuid: str
source_kind: str
source_id: str
attacker_uuid: Optional[str] = None
identity_uuid: Optional[str] = None
session_id: Optional[str] = None
decky_id: Optional[str] = None
tactic: str
technique_id: str
sub_technique_id: Optional[str] = None
confidence: float
rule_id: str
rule_version: int
evidence: dict[str, Any] = Field(default_factory=dict)
attack_release: str
created_at: datetime
class CampaignTechniqueRow(BaseModel):
"""One row of /api/v1/ttp/by-campaign/{uuid} — a technique observed
across at least one Identity rolled up into the campaign."""

View File

@@ -1377,6 +1377,25 @@ class BaseRepository(ABC):
"""Session-scoped TTP timeline."""
raise NotImplementedError
@abstractmethod
async def list_tags_by_scope_and_technique(
self,
*,
scope: str,
uuid: str,
technique_id: str,
sub_technique_id: Optional[str] = None,
limit: int = 200,
) -> list[dict[str, Any]]:
"""Raw ``ttp_tag`` rows backing the operator inspector.
Surfaces evidence + rule_id / source_kind / source_id so the
UI can show *why* the engine flagged a technique. Filtered by
scope (identity / attacker / session) + technique
(+ optional sub_technique). Newest-first.
"""
raise NotImplementedError
@abstractmethod
async def list_distinct_techniques(self) -> list[TechniqueRollupRow]:
"""Fleet-wide distinct-technique rollup."""

View File

@@ -279,6 +279,55 @@ class TTPMixin(_MixinBase):
for r in res.all()
]
async def list_tags_by_scope_and_technique(
self,
*,
scope: str,
uuid: str,
technique_id: str,
sub_technique_id: str | None = None,
limit: int = 200,
) -> list[dict[str, Any]]:
"""Return raw ``ttp_tag`` rows for a scope + technique pair.
Powers the operator-facing inspector that explains *why* the
rule engine flagged a technique. Three scopes:
* ``scope="identity"`` — tags directly anchored on the identity
AND tags on Attackers projecting up to the identity.
* ``scope="attacker"`` — tags anchored on this attacker_uuid.
* ``scope="session"`` — tags anchored on this session_id.
Newest-first; capped at ``limit`` rows so a heavily-tagged
attacker doesn't sink the inspector.
"""
async with self._session() as session:
stmt: Any = select(TTPTag)
if scope == "identity":
attacker_uuids_subq = (
select(col(Attacker.uuid))
.where(col(Attacker.identity_id) == uuid)
.scalar_subquery()
)
stmt = stmt.where(
(col(TTPTag.identity_uuid) == uuid)
| (col(TTPTag.attacker_uuid).in_(attacker_uuids_subq))
)
elif scope == "attacker":
stmt = stmt.where(col(TTPTag.attacker_uuid) == uuid)
elif scope == "session":
stmt = stmt.where(col(TTPTag.session_id) == uuid)
else:
raise ValueError(f"unknown scope: {scope!r}")
stmt = stmt.where(col(TTPTag.technique_id) == technique_id)
if sub_technique_id is not None:
stmt = stmt.where(
col(TTPTag.sub_technique_id) == sub_technique_id,
)
stmt = stmt.order_by(col(TTPTag.created_at).desc()).limit(limit)
res = await session.execute(stmt)
return [r.model_dump(mode="json") for r in res.scalars().all()]
# ── Backfill iterators (E.4) ────────────────────────────────────
#
# Read-only iterators consumed by ``decnet ttp backfill`` to replay

View File

@@ -59,6 +59,7 @@ from .ttp.api_get_by_attacker import router as ttp_by_attacker_router
from .ttp.api_get_by_campaign import router as ttp_by_campaign_router
from .ttp.api_get_by_session import router as ttp_by_session_router
from .ttp.api_get_rules import router as ttp_rules_router
from .ttp.api_get_tag_details import router as ttp_tag_details_router
from .ttp.api_export_navigator import router as ttp_navigator_router
api_router = APIRouter(
@@ -180,4 +181,5 @@ api_router.include_router(ttp_by_attacker_router)
api_router.include_router(ttp_by_campaign_router)
api_router.include_router(ttp_by_session_router)
api_router.include_router(ttp_rules_router)
api_router.include_router(ttp_tag_details_router)
api_router.include_router(ttp_navigator_router)

View File

@@ -0,0 +1,74 @@
"""``GET /api/v1/ttp/tags/by-{scope}/{uuid}/{technique_id}``.
Backs the operator-facing TTP inspector — when a user clicks a
technique row in :class:`TTPsObservedSection`, the UI fetches the raw
:class:`TTPTag` rows that produced the rollup and renders the
``evidence`` JSON, ``rule_id`` / ``rule_version``, ``source_kind`` /
``source_id``, ``confidence``, and ``created_at`` in a side drawer.
The point is to answer "what made the engine flag this technique?"
without exposing the operator to a SQL prompt.
Three scopes mirror the rollup endpoints:
* ``identity`` — tags on the identity OR on attackers projecting up.
* ``attacker`` — tags on the attacker (per-IP).
* ``session`` — tags on the session.
Filtered by ``technique_id`` (path) and an optional
``sub_technique_id`` (query). Capped at 200 newest-first rows so a
busy attacker doesn't hose the drawer.
"""
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from decnet.telemetry import traced as _traced
from decnet.web.db.models import TTPTagDetailRow
from decnet.web.dependencies import repo, require_viewer
router = APIRouter()
_Scope = Literal["identity", "attacker", "session"]
@router.get(
"/ttp/tags/by-{scope}/{uuid}/{technique_id}",
tags=["TTP Tagging"],
response_model=list[TTPTagDetailRow],
responses={
400: {"description": "Bad Request (invalid scope or pagination)"},
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Scope target not found"},
},
)
@_traced("api.ttp.tag_details")
async def api_ttp_tag_details(
scope: _Scope,
uuid: str,
technique_id: str,
sub_technique_id: str | None = None,
limit: int = 200,
user: dict[str, Any] = Depends(require_viewer),
) -> list[TTPTagDetailRow]:
"""Return raw ``ttp_tag`` rows for the (scope, uuid, technique) tuple."""
if scope not in ("identity", "attacker", "session"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"unknown scope {scope!r}",
)
if limit < 1 or limit > 1000:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="limit must be in [1, 1000]",
)
rows = await repo.list_tags_by_scope_and_technique(
scope=scope,
uuid=uuid,
technique_id=technique_id,
sub_technique_id=sub_technique_id,
limit=limit,
)
return [TTPTagDetailRow(**row) for row in rows]