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:
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user