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:
194
decnet_web/src/components/TTPsObservedSection.tsx
Normal file
194
decnet_web/src/components/TTPsObservedSection.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user