From 07a609973b0d99b93537c746d74061eddbccec35 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 1 May 2026 21:05:28 -0400 Subject: [PATCH] feat(ttp): E.3.16 frontend TTP UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- decnet_web/src/components/AttackerDetail.tsx | 4 + decnet_web/src/components/Config.tsx | 8 + decnet_web/src/components/IdentityDetail.tsx | 3 + .../src/components/RuleStateControls.tsx | 172 ++++++++++++++++ .../src/components/TTPsObservedSection.tsx | 194 ++++++++++++++++++ development/TTP_TAGGING.md | 11 +- 6 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 decnet_web/src/components/RuleStateControls.tsx create mode 100644 decnet_web/src/components/TTPsObservedSection.tsx diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 88257903..c541fa42 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -6,6 +6,7 @@ import ArtifactDrawer from './ArtifactDrawer'; import MailDrawer from './MailDrawer'; import SessionDrawer from './SessionDrawer'; import EmptyState from './EmptyState/EmptyState'; +import TTPsObservedSection from './TTPsObservedSection'; import { useIdentityStream } from './useIdentityStream'; import './Dashboard.css'; @@ -1583,6 +1584,9 @@ const AttackerDetail: React.FC = () => { )} + {/* TTPs Observed (per-IP slice) — see TTP_TAGGING.md §"UI surface" */} + + {/* Timestamps */}
toggle('timeline')}>
diff --git a/decnet_web/src/components/Config.tsx b/decnet_web/src/components/Config.tsx index fe438382..fa194020 100644 --- a/decnet_web/src/components/Config.tsx +++ b/decnet_web/src/components/Config.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import api from '../utils/api'; import { Settings, Users, Sliders, Trash2, UserPlus, Key, Save, Shield, AlertTriangle, Palette, Activity, Square, RefreshCw, Play } from '../icons'; import { useToast } from './Toasts/useToast'; +import RuleStateControls from './RuleStateControls'; import './Dashboard.css'; import './Config.css'; @@ -238,6 +239,7 @@ const Config: React.FC = () => { { key: 'globals', label: 'GLOBAL VALUES', icon: }, { key: 'appearance', label: 'APPEARANCE', icon: }, ...(isAdmin ? [{ key: 'workers', label: 'WORKERS', icon: }] : []), + ...(isAdmin ? [{ key: 'ttp', label: 'TTP RULES', icon: }] : []), ]; return ( @@ -494,6 +496,12 @@ const Config: React.FC = () => { )} + {/* TTP RULES TAB — admin only. RuleStateControls also self-gates + on /config?.role so a state leak can't render it. */} + {activeTab === 'ttp' && isAdmin && ( + + )} + {/* APPEARANCE TAB */} {activeTab === 'appearance' && (
diff --git a/decnet_web/src/components/IdentityDetail.tsx b/decnet_web/src/components/IdentityDetail.tsx index 8a46fee7..84474e4a 100644 --- a/decnet_web/src/components/IdentityDetail.tsx +++ b/decnet_web/src/components/IdentityDetail.tsx @@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import { ArrowLeft, Crosshair, Filter, Fingerprint, Globe, Radio } from '../icons'; import api from '../utils/api'; import EmptyState from './EmptyState/EmptyState'; +import TTPsObservedSection from './TTPsObservedSection'; import { useIdentityStream } from './useIdentityStream'; import './Dashboard.css'; @@ -195,6 +196,8 @@ const IdentityDetail: React.FC = () => {
+ + {(ja3List.length > 0 || hasshList.length > 0 || c2List.length > 0) && (
diff --git a/decnet_web/src/components/RuleStateControls.tsx b/decnet_web/src/components/RuleStateControls.tsx new file mode 100644 index 00000000..6bd6b530 --- /dev/null +++ b/decnet_web/src/components/RuleStateControls.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useState } from 'react'; +import { Crosshair, Shield, ShieldOff } from '../icons'; +import api from '../utils/api'; +import EmptyState from './EmptyState/EmptyState'; + +/* + * RuleStateControls — admin-only rule operational state panel. + * + * Server-gated via require_admin on the API; this component is also + * conditionally rendered on the role flag from /config so a non-admin + * never sees the controls. Per feedback_serverside_ui.md the + * client-side gate is a UX nicety, NOT a security boundary — the + * server returns 403 either way. + */ + +interface RuleRow { + rule_id: string; + rule_version: number; + name: string; + description: string; + state: 'enabled' | 'disabled' | 'clipped'; + confidence_max: number | null; + expires_at: string | null; + reason: string | null; + set_by: string | null; + set_at: string | null; +} + +const RuleStateControls: React.FC = () => { + const [rules, setRules] = useState([]); + const [isAdmin, setIsAdmin] = useState(false); + const [loaded, setLoaded] = useState(false); + const [busy, setBusy] = useState(null); + + const refresh = async () => { + try { + const res = await api.get('/ttp/rules'); + setRules(Array.isArray(res.data) ? res.data : []); + } catch { + setRules([]); + } finally { + setLoaded(true); + } + }; + + useEffect(() => { + const probe = async () => { + try { + const cfg = await api.get('/config'); + setIsAdmin(cfg.data?.role === 'admin'); + } catch { + setIsAdmin(false); + } + refresh(); + }; + probe(); + }, []); + + const setState = async ( + ruleId: string, + state: 'enabled' | 'disabled' | 'clipped', + confidence_max?: number, + ) => { + setBusy(ruleId); + try { + await api.post(`/ttp/rules/${ruleId}/state`, { + state, + confidence_max: confidence_max ?? null, + expires_at: null, + reason: null, + }); + await refresh(); + } catch { + // best-effort; failures show on next refresh + } finally { + setBusy(null); + } + }; + + const revert = async (ruleId: string) => { + setBusy(ruleId); + try { + await api.delete(`/ttp/rules/${ruleId}/state`); + await refresh(); + } catch { + // ignored + } finally { + setBusy(null); + } + }; + + if (!isAdmin) { + return null; + } + + return ( +
+
+
+ + RULE STATE — ADMIN +
+
+
+ {!loaded ? null : rules.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {rules.map((r) => ( + + + + + + + + ))} + +
RULENAMESTATECLIPACTIONS
{r.rule_id}{r.name} + + {r.state.toUpperCase()} + + + {r.confidence_max !== null ? r.confidence_max.toFixed(2) : '—'} + + + + +
+ )} +
+
+ ); +}; + +export default RuleStateControls; diff --git a/decnet_web/src/components/TTPsObservedSection.tsx b/decnet_web/src/components/TTPsObservedSection.tsx new file mode 100644 index 00000000..d9cd817f --- /dev/null +++ b/decnet_web/src/components/TTPsObservedSection.tsx @@ -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 = { + 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); + + 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) => ( + + ))} +
+
+ ))} +
+ )} +
+
+ ); +}; + +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 ( +
+ {label} +
+
+
+ ×{row.count} +
+ ); +}; + +export default TTPsObservedSection; diff --git a/development/TTP_TAGGING.md b/development/TTP_TAGGING.md index 03be3f30..f9baf255 100644 --- a/development/TTP_TAGGING.md +++ b/development/TTP_TAGGING.md @@ -3075,7 +3075,16 @@ Order: `AttackerDetail` per-IP slice, Navigator export buttons, rule-state controls (disable / clip / TTL) backed by the `set_state()` API. UI smoke tests via the existing dev-server - flow per project convention. + flow per project convention. ✅ done. + `TTPsObservedSection.tsx` is the shared analyst-facing + component (scope=`identity`|`attacker`); the Identity scope + carries the Navigator export button. `RuleStateControls.tsx` + is the admin-only operational panel — server-gated by + `require_admin` AND client-gated on `/config?.role` so a + non-admin never sees the controls. Wired into Config.tsx as + a new "TTP RULES" admin tab. Empty state literal "NO + TECHNIQUES OBSERVED YET" per the design doc — no spinner. + `tsc --noEmit` + `vite build` clean. 17. **Schemathesis pass** — full API fuzz including the new TTP routes. Document any new 4xx codes per the project's "POST/PUT/PATCH 400" convention.