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."""