diff --git a/decnet/web/db/models/__init__.py b/decnet/web/db/models/__init__.py index 80778f46..2db36826 100644 --- a/decnet/web/db/models/__init__.py +++ b/decnet/web/db/models/__init__.py @@ -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", diff --git a/decnet/web/db/models/ttp.py b/decnet/web/db/models/ttp.py index c1d9dfa9..8dbf7d69 100644 --- a/decnet/web/db/models/ttp.py +++ b/decnet/web/db/models/ttp.py @@ -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.""" diff --git a/decnet/web/db/repository.py b/decnet/web/db/repository.py index b3b12822..241d1bb1 100644 --- a/decnet/web/db/repository.py +++ b/decnet/web/db/repository.py @@ -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.""" diff --git a/decnet/web/db/sqlmodel_repo/ttp.py b/decnet/web/db/sqlmodel_repo/ttp.py index 47950ce7..f8b88efb 100644 --- a/decnet/web/db/sqlmodel_repo/ttp.py +++ b/decnet/web/db/sqlmodel_repo/ttp.py @@ -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 diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py index fafcbe3e..6ef44db3 100644 --- a/decnet/web/router/__init__.py +++ b/decnet/web/router/__init__.py @@ -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) diff --git a/decnet/web/router/ttp/api_get_tag_details.py b/decnet/web/router/ttp/api_get_tag_details.py new file mode 100644 index 00000000..0d624e7c --- /dev/null +++ b/decnet/web/router/ttp/api_get_tag_details.py @@ -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] diff --git a/decnet_web/src/components/TTPInspector.css b/decnet_web/src/components/TTPInspector.css new file mode 100644 index 00000000..1bf690fd --- /dev/null +++ b/decnet_web/src/components/TTPInspector.css @@ -0,0 +1,140 @@ +/* + * TTPInspector — sidebar drawer that explains *why* the rule engine + * flagged a technique. Mirrors CredentialReuseInspector / BountyInspector + * geometry and tokens; only the colour accent differs (violet for the + * TTP family). + */ + +.ttp-drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + justify-content: flex-end; + z-index: 1000; + animation: ttp-fade 0.15s ease; +} +@keyframes ttp-fade { from { opacity: 0; } to { opacity: 1; } } + +.ttp-drawer { + width: min(680px, 100%); + height: 100%; + background: var(--bg); + border-left: 1px solid var(--violet); + box-shadow: -12px 0 40px rgba(155, 135, 245, 0.12); + overflow-y: auto; + display: flex; + flex-direction: column; + animation: ttp-slide 0.2s ease; +} +@keyframes ttp-slide { + from { transform: translateX(30px); opacity: 0.6; } + to { transform: none; opacity: 1; } +} + +.ttp-drawer .bd-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} +.ttp-drawer .bd-head h3 { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + letter-spacing: 3px; + color: var(--violet); + margin: 0; +} +.ttp-drawer .close-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--matrix); + display: flex; + padding: 4px; + cursor: pointer; +} +.ttp-drawer .close-btn:hover { border-color: var(--accent); } + +.ttp-drawer .bd-body { + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.ttp-drawer .type-label { + font-size: 0.7rem; + letter-spacing: 2px; + color: var(--dim-color); + text-transform: uppercase; + margin-bottom: 6px; +} + +.ttp-tag-card { + border: 1px solid var(--border); + border-radius: 4px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; + background: rgba(255, 255, 255, 0.015); +} + +.ttp-tag-card .ttp-card-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + font-size: 0.78rem; +} + +.ttp-tag-card .ttp-rule-id { + color: var(--violet); + letter-spacing: 1px; +} + +.ttp-tag-card .ttp-confidence { + font-variant-numeric: tabular-nums; + color: var(--matrix); +} + +.ttp-tag-card .ttp-meta { + display: grid; + grid-template-columns: 110px 1fr; + gap: 4px 12px; + font-size: 0.75rem; +} +.ttp-tag-card .ttp-meta .k { + color: var(--dim-color); + text-transform: uppercase; + letter-spacing: 1.5px; + font-size: 0.7rem; +} +.ttp-tag-card .ttp-meta .v { + word-break: break-all; +} + +.ttp-evidence { + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--border); + border-radius: 3px; + padding: 8px 10px; + font-family: var(--mono, ui-monospace, monospace); + font-size: 0.72rem; + white-space: pre-wrap; + word-break: break-all; + color: var(--matrix); + max-height: 280px; + overflow-y: auto; +} + +.ttp-empty { + padding: 24px; + text-align: center; + color: var(--dim-color); + font-size: 0.8rem; + letter-spacing: 1px; +} diff --git a/decnet_web/src/components/TTPInspector.tsx b/decnet_web/src/components/TTPInspector.tsx new file mode 100644 index 00000000..72e0c59b --- /dev/null +++ b/decnet_web/src/components/TTPInspector.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { X, Crosshair } from '../icons'; +import api from '../utils/api'; +import { useEscapeKey } from '../hooks/useEscapeKey'; +import { useFocusTrap } from '../hooks/useFocusTrap'; +import './TTPInspector.css'; + +/* + * TTPInspector — sidebar that explains *why* the rule engine flagged a + * technique. Renders one card per `ttp_tag` row hitting the + * (scope, uuid, technique_id, sub_technique_id?) selector, including + * the rule_id, source_kind / source_id, confidence, and the persisted + * `evidence` JSON the engine attached at fire time. + * + * Click target is :class:`TechniqueBar` in TTPsObservedSection. Drawer + * geometry mirrors CredentialReuseInspector / BountyInspector. + */ + +export interface TTPTagDetailRow { + uuid: string; + source_kind: string; + source_id: string; + attacker_uuid: string | null; + identity_uuid: string | null; + session_id: string | null; + decky_id: string | null; + tactic: string; + technique_id: string; + sub_technique_id: string | null; + confidence: number; + rule_id: string; + rule_version: number; + evidence: Record; + attack_release: string; + created_at: string; +} + +export type TTPInspectorScope = 'identity' | 'attacker' | 'session'; + +interface Props { + scope: TTPInspectorScope; + uuid: string; + techniqueId: string; + subTechniqueId: string | null; + tactic: string; + count: number; + confidenceMax: number; + onClose: () => void; +} + +const TTPInspector: React.FC = ({ + scope, uuid, techniqueId, subTechniqueId, tactic, count, confidenceMax, onClose, +}) => { + const panelRef = useRef(null); + useEscapeKey(onClose, true); + useFocusTrap(panelRef, true); + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, []); + + const [rows, setRows] = useState([]); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const fetch = async () => { + try { + const params: Record = {}; + if (subTechniqueId) params.sub_technique_id = subTechniqueId; + const path = `/ttp/tags/by-${scope}/${uuid}/${techniqueId}`; + const res = await api.get(path, { params }); + if (cancelled) return; + setRows(Array.isArray(res.data) ? res.data : []); + setError(null); + } catch (err: any) { + if (cancelled) return; + setRows([]); + setError( + err?.response?.status === 403 ? 'Insufficient role for tag detail.' : + 'Failed to load tag detail.', + ); + } finally { + if (!cancelled) setLoaded(true); + } + }; + fetch(); + return () => { cancelled = true; }; + }, [scope, uuid, techniqueId, subTechniqueId]); + + const label = subTechniqueId ?? techniqueId; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
+

+ + {label} +

+ +
+
+
+
TACTIC
+
{tactic}
+
TECHNIQUE
+
{techniqueId}{subTechniqueId ? ` / ${subTechniqueId}` : ''}
+
FIRES
+
{count}
+
MAX CONF
+
{confidenceMax.toFixed(2)}
+
+ +
+
EVIDENCE
+ {!loaded ? null : error ? ( +
{error}
+ ) : rows.length === 0 ? ( +
No tag rows in scope.
+ ) : ( +
+ {rows.map((row) => ( + + ))} +
+ )} +
+
+
+
+ ); +}; + +const TTPTagCard: React.FC<{ row: TTPTagDetailRow }> = ({ row }) => { + const evidenceText = JSON.stringify(row.evidence ?? {}, null, 2); + return ( +
+
+ {row.rule_id} v{row.rule_version} + conf {row.confidence.toFixed(2)} +
+
+
SOURCE
+
{row.source_kind} / {row.source_id}
+ {row.session_id && ( + <> +
SESSION
+
{row.session_id}
+ + )} + {row.decky_id && ( + <> +
DECKY
+
{row.decky_id}
+ + )} +
SEEN
+
{new Date(row.created_at).toLocaleString()}
+
ATT&CK
+
{row.attack_release}
+
+
{evidenceText}
+
+ ); +}; + +export default TTPInspector; diff --git a/decnet_web/src/components/TTPsObservedSection.tsx b/decnet_web/src/components/TTPsObservedSection.tsx index d9cd817f..8acd3eff 100644 --- a/decnet_web/src/components/TTPsObservedSection.tsx +++ b/decnet_web/src/components/TTPsObservedSection.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { Crosshair, Download, Target } from '../icons'; import api from '../utils/api'; import EmptyState from './EmptyState/EmptyState'; +import TTPInspector from './TTPInspector'; /* * TTPsObservedSection — shared between IdentityDetail (primary) and @@ -59,6 +60,7 @@ const TTPsObservedSection: React.FC = ({ scope, uuid }) => { const [rows, setRows] = useState([]); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(null); + const [selected, setSelected] = useState(null); useEffect(() => { let cancelled = false; @@ -141,7 +143,11 @@ const TTPsObservedSection: React.FC = ({ scope, uuid }) => {
{byTactic[tid].map((r) => ( - + setSelected(r)} + /> ))}
@@ -149,11 +155,26 @@ const TTPsObservedSection: React.FC = ({ scope, uuid }) => { )} + {selected && ( + setSelected(null)} + /> + )} ); }; -const TechniqueBar: React.FC<{ row: TechniqueRow }> = ({ row }) => { +const TechniqueBar: React.FC<{ + row: TechniqueRow; + onClick: () => void; +}> = ({ row, onClick }) => { // Confidence bar: 0..1 mapped to 0..100% width. Values below 0.3 // can never appear (repo confidence floor) so the bar always shows // some non-trivial fill. @@ -161,12 +182,27 @@ const TechniqueBar: React.FC<{ row: TechniqueRow }> = ({ row }) => { const label = row.sub_technique_id ?? row.technique_id; return (
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + title="Click to inspect underlying tags + evidence" style={{ display: 'grid', gridTemplateColumns: '160px 1fr 60px', gap: 8, alignItems: 'center', + cursor: 'pointer', + padding: '2px 4px', + borderRadius: 2, }} + onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(155,135,245,0.06)'; }} + onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} > {label}
TTPTag: + return TTPTag( + uuid=f"tag-{rule_id}-{technique_id}-{source_id}", + source_kind="command", + source_id=source_id, + attacker_uuid=attacker_uuid, + identity_uuid=None, + session_id=None, + decky_id=None, + tactic="TA0006", + technique_id=technique_id, + sub_technique_id=sub_technique_id, + confidence=0.95, + rule_id=rule_id, + rule_version=1, + evidence=evidence or {"command_text": "cat /etc/shadow"}, + attack_release="v15.1", + created_at=datetime.now(tz=timezone.utc), + ) + + +@pytest.mark.asyncio +async def test_tag_details_returns_evidence_for_attacker_scope( + client: httpx.AsyncClient, auth_token: str, ) -> None: + tags = [_make_tag(rule_id="R0014", source_id=f"cmd-{i}") for i in range(3)] + await _repo.insert_tags(tags) + + path = TAG_DETAILS.format( + scope="attacker", uuid="att-1", technique_id="T1059", + ) + res = await client.get(path, headers=hdr(auth_token)) + assert res.status_code == 200, res.text + body: list[dict[str, Any]] = res.json() + assert len(body) == 3 + row = body[0] + # The evidence dict must round-trip — that's the whole point of + # the inspector. + assert row["evidence"]["command_text"] == "cat /etc/shadow" + assert row["rule_id"] == "R0014" + assert row["technique_id"] == "T1059" + assert row["source_kind"] == "command" + assert "source_id" in row + assert "created_at" in row + + +@pytest.mark.asyncio +async def test_tag_details_filters_by_sub_technique( + client: httpx.AsyncClient, auth_token: str, ) -> None: + await _repo.insert_tags([ + _make_tag(rule_id="R0014", source_id="a", sub_technique_id="T1059.001"), + _make_tag(rule_id="R0014", source_id="b", sub_technique_id="T1059.004"), + ]) + path = TAG_DETAILS.format( + scope="attacker", uuid="att-1", technique_id="T1059", + ) + res = await client.get( + path + "?sub_technique_id=T1059.004", + headers=hdr(auth_token), + ) + assert res.status_code == 200 + body = res.json() + assert len(body) == 1 + assert body[0]["sub_technique_id"] == "T1059.004" + + +@pytest.mark.asyncio +async def test_tag_details_unknown_scope_400( + client: httpx.AsyncClient, auth_token: str, +) -> None: + res = await client.get( + TAG_DETAILS.format(scope="bogus", uuid="att-1", technique_id="T1059"), + headers=hdr(auth_token), + ) + # Pydantic Literal validation rejects this at body-parse time, + # which surfaces as 422 in FastAPI's default config; either 4xx + # is fine for the contract — we just want non-2xx. + assert 400 <= res.status_code < 500 + + +@pytest.mark.asyncio +async def test_tag_details_requires_jwt( + client: httpx.AsyncClient, +) -> None: + path = TAG_DETAILS.format( + scope="attacker", uuid="att-1", technique_id="T1059", + ) + res = await client.get(path) + assert res.status_code == 401, res.text + + +@pytest.mark.asyncio +async def test_tag_details_empty_when_no_tags( + client: httpx.AsyncClient, auth_token: str, +) -> None: + path = TAG_DETAILS.format( + scope="attacker", uuid="never-existed", technique_id="T9999", + ) + res = await client.get(path, headers=hdr(auth_token)) + assert res.status_code == 200 + assert res.json() == []