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:
74
decnet/web/router/ttp/api_get_tag_details.py
Normal file
74
decnet/web/router/ttp/api_get_tag_details.py
Normal 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]
|
||||
Reference in New Issue
Block a user