// SPDX-License-Identifier: AGPL-3.0-or-later 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'; import type { TechniqueRow } from '../types/ttp'; /* * TTPsObservedSection — shared between IdentityDetail (primary) and * AttackerDetail (per-IP slice). Renders the tactic → technique tree * with counts and confidence-weighted bars per TTP_TAGGING.md * §"UI surface". Empty state is the literal "No techniques observed * yet." per the design doc — no spinner, no fallback list. * * Admin-only rule-state controls live in :class:`RuleStateControls`, * not here — the analyst-facing rollup is a separate concern from * operator rule administration. */ const TACTIC_LABEL: Record = { TA0043: 'RECONNAISSANCE', TA0042: 'RESOURCE DEVELOPMENT', TA0001: 'INITIAL ACCESS', TA0002: 'EXECUTION', TA0003: 'PERSISTENCE', TA0004: 'PRIVILEGE ESCALATION', TA0005: 'DEFENSE EVASION', TA0006: 'CREDENTIAL ACCESS', TA0007: 'DISCOVERY', TA0008: 'LATERAL MOVEMENT', TA0009: 'COLLECTION', TA0011: 'COMMAND AND CONTROL', TA0010: 'EXFILTRATION', TA0040: 'IMPACT', }; const tacticOrder = (id: string): number => { const order = ['TA0043', 'TA0042', 'TA0001', 'TA0002', 'TA0003', 'TA0004', 'TA0005', 'TA0006', 'TA0007', 'TA0008', 'TA0009', 'TA0011', 'TA0010', 'TA0040']; const idx = order.indexOf(id); return idx >= 0 ? idx : 99; }; interface Props { scope: 'identity' | 'attacker'; uuid: string; } 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; const fetchRollup = async () => { try { const res = await api.get(`/ttp/by-${scope}/${uuid}`); if (cancelled) return; setRows(Array.isArray(res.data) ? res.data : []); setError(null); } catch { if (cancelled) return; setRows([]); setError('FAILED TO LOAD TTPs'); } finally { if (!cancelled) setLoaded(true); } }; fetchRollup(); return () => { cancelled = true; }; }, [scope, uuid]); // Group by tactic in fixed UKC-aligned order. const byTactic = rows.reduce>((acc, r) => { (acc[r.tactic] ??= []).push(r); return acc; }, {}); const tacticIds = Object.keys(byTactic).sort( (a, b) => tacticOrder(a) - tacticOrder(b), ); const handleNavigatorExport = async () => { if (scope !== 'identity') return; try { const res = await api.get(`/ttp/export/navigator/identity/${uuid}`); const blob = new Blob([JSON.stringify(res.data, null, 2)], { type: 'application/json' }); const href = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = href; a.download = `navigator-identity-${uuid.slice(0, 8)}.json`; a.click(); URL.revokeObjectURL(href); } catch { // best-effort download; surface nothing } }; return (
TTPs OBSERVED
{scope === 'identity' && rows.length > 0 && ( )}
{!loaded ? null : error ? ( ) : rows.length === 0 ? ( // Literal empty-state text from TTP_TAGGING.md §"UI surface // — Empty state": "No techniques observed yet." No spinner. ) : (
{tacticIds.map((tid) => (
{TACTIC_LABEL[tid] ?? tid}
{byTactic[tid].map((r) => ( setSelected(r)} /> ))}
))}
)}
{selected && ( setSelected(null)} /> )}
); }; 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. const pct = Math.round(Math.max(0, Math.min(1, row.confidence_max)) * 100); // Prefer the sub-technique label if present (more specific). Each // half is "T#### — Name" when the catalogue has a name, falling // back to the bare ID for techniques not yet catalogued. const id = row.sub_technique_id ?? row.technique_id; const name = row.sub_technique_name ?? row.technique_name; const label = name ? `${id} — ${name}` : id; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }} title="Click to inspect underlying tags + evidence" style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 2fr) 1fr 60px', gap: 10, 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'; }} > {row.mitre_url ? ( <> e.stopPropagation()} >{id} ↗ {name ? ` — ${name}` : ''} ) : label}
×{row.count}
); }; export default TTPsObservedSection;