feat(ttp): E.3.16 frontend TTP UI

TTPsObservedSection.tsx: shared analyst-facing rollup. scope=
identity drives /ttp/by-identity/{uuid} (primary, with Navigator
export download); scope=attacker drives /ttp/by-attacker/{uuid}
(per-IP slice). Tactic → technique tree in fixed UKC-aligned order,
counts and confidence-weighted bars. Literal "NO TECHNIQUES
OBSERVED YET" empty state per TTP_TAGGING.md §"UI surface — Empty
state": no spinner, no fallback list.

RuleStateControls.tsx: admin-only rule operational state panel
backed by POST/DELETE /ttp/rules/{rule_id}/state. Server-gated by
require_admin AND client-gated on /config?.role so a non-admin
never sees the controls (per feedback_serverside_ui.md the client
gate is UX, not security — the server returns 403 either way).
Wired into Config.tsx as a new "TTP RULES" admin tab.

Wired TTPsObservedSection into IdentityDetail (above fingerprints)
and AttackerDetail (above TIMELINE). DeckyFleet/PersonaGeneration
vocabulary throughout (logs-section / section-header / btn /
matrix-text / dim-chip).

tsc --noEmit and vite build clean.

The dev-server browser smoke is deferred per the "can't reliably
exercise UI from this harness" reality — typecheck + build is the
correctness gate, not feature verification.
This commit is contained in:
2026-05-01 21:05:28 -04:00
parent 403d83faba
commit 07a609973b
6 changed files with 391 additions and 1 deletions

View File

@@ -0,0 +1,194 @@
import React, { useEffect, useState } from 'react';
import { Crosshair, Download, Target } from '../icons';
import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
/*
* 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.
*/
interface TechniqueRow {
technique_id: string;
sub_technique_id: string | null;
tactic: string;
count: number;
first_seen: string;
last_seen: string;
confidence_max: number;
}
const TACTIC_LABEL: Record<string, string> = {
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<Props> = ({ scope, uuid }) => {
const [rows, setRows] = useState<TechniqueRow[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(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<Record<string, TechniqueRow[]>>((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 (
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Target size={14} />
<span>TTPs OBSERVED</span>
</div>
{scope === 'identity' && rows.length > 0 && (
<button
type="button"
className="btn"
onClick={handleNavigatorExport}
title="Download MITRE ATT&CK Navigator JSON layer for this Identity"
>
<Download size={12} />
<span style={{ marginLeft: 6 }}>NAVIGATOR</span>
</button>
)}
</div>
<div className="logs-table-container" style={{ padding: 12 }}>
{!loaded ? null : error ? (
<EmptyState icon={Crosshair} title={error} />
) : rows.length === 0 ? (
// Literal empty-state text from TTP_TAGGING.md §"UI surface
// — Empty state": "No techniques observed yet." No spinner.
<EmptyState icon={Crosshair} title="NO TECHNIQUES OBSERVED YET" />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{tacticIds.map((tid) => (
<div key={tid} className="fp-group">
<div className="fp-group-label">
<span>{TACTIC_LABEL[tid] ?? tid}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{byTactic[tid].map((r) => (
<TechniqueBar key={`${r.technique_id}-${r.sub_technique_id ?? ''}`} row={r} />
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
const TechniqueBar: React.FC<{ row: TechniqueRow }> = ({ row }) => {
// 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);
const label = row.sub_technique_id ?? row.technique_id;
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '160px 1fr 60px',
gap: 8,
alignItems: 'center',
}}
>
<span className="matrix-text">{label}</span>
<div
style={{
height: 6,
background: 'var(--surface-2, #1a1a1a)',
borderRadius: 2,
overflow: 'hidden',
}}
>
<div
style={{
width: `${pct}%`,
height: '100%',
background: 'var(--violet-accent, #9b87f5)',
}}
title={`confidence ${row.confidence_max.toFixed(2)}`}
/>
</div>
<span className="dim" style={{ textAlign: 'right' }}>×{row.count}</span>
</div>
);
};
export default TTPsObservedSection;