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; technique_name: string | null; sub_technique_id: string | null; sub_technique_name: 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; techniqueName: string | null; subTechniqueName: string | null; tactic: string; count: number; confidenceMax: number; onClose: () => void; } const TTPInspector: React.FC = ({ scope, uuid, techniqueId, subTechniqueId, techniqueName, subTechniqueName, 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 id = subTechniqueId ?? techniqueId; const name = subTechniqueName ?? techniqueName; const headerLabel = name ? `${id} — ${name}` : id; return (
{ if (e.target === e.currentTarget) onClose(); }} >

{headerLabel}

TACTIC
{tactic}
TECHNIQUE
{techniqueId}{techniqueName ? ` — ${techniqueName}` : ''} {subTechniqueId && (
↳ {subTechniqueId}{subTechniqueName ? ` — ${subTechniqueName}` : ''}
)}
FIRES
{count}
MAX CONF
{confidenceMax.toFixed(2)}
EVIDENCE
{!loaded ? null : error ? (
{error}
) : rows.length === 0 ? (
No tag rows in scope.
) : (
{rows.map((row) => ( ))}
)}
); }; // Evidence keys we promote to the top of the per-card key/value // table for shell-command tags. Order matters — these render in // the listed order; everything else goes after, alphabetically. const _EVIDENCE_PRIMARY_ORDER = [ 'uid', 'user', 'src', 'pwd', 'cmd', 'command', 'command_text', ]; const _EVIDENCE_LABEL: Record = { uid: 'UID', user: 'USER', src: 'SRC', pwd: 'PWD', cmd: 'CMD', command: 'CMD', command_text: 'CMD', }; interface EvidenceRow { key: string; label: string; value: string; } function flattenEvidence(evidence: Record): EvidenceRow[] { const seen = new Set(); const rows: EvidenceRow[] = []; const stringify = (v: unknown): string => { if (v === null || v === undefined) return '—'; if (typeof v === 'string') return v; if (typeof v === 'number' || typeof v === 'boolean') return String(v); return JSON.stringify(v); }; for (const k of _EVIDENCE_PRIMARY_ORDER) { if (k in evidence && !seen.has(k)) { seen.add(k); rows.push({ key: k, label: _EVIDENCE_LABEL[k] ?? k.toUpperCase(), value: stringify(evidence[k]), }); } } const remaining = Object.keys(evidence) .filter((k) => !seen.has(k)) .sort(); for (const k of remaining) { rows.push({ key: k, label: _EVIDENCE_LABEL[k] ?? k.toUpperCase(), value: stringify(evidence[k]), }); } return rows; } const TTPTagCard: React.FC<{ row: TTPTagDetailRow }> = ({ row }) => { const evidenceRows = flattenEvidence(row.evidence ?? {}); 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}
{evidenceRows.length === 0 ? (
) : (
{evidenceRows.map((r) => (
{r.label}
{r.value}
))}
)}
); }; export default TTPInspector;