From c4d6eb5bb3b129952fea12dead5495412072dc67 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 06:57:10 -0400 Subject: [PATCH] feat(web): mitre_url deeplinks + lazy groups subpanel in TTPInspector Every technique_id in TechniqueBar and TTPInspector now links to its canonical attack.mitre.org page. The inspector drawer gains a GROUPS subpanel that lazy-fetches the new /ttp/techniques/{id}/groups endpoint and renders each MITRE-tracked intrusion-set with deeplink and aliases. Centralizes TTP row interfaces into src/types/ttp.ts and API wrappers into src/utils/ttpApi.ts to give the new GroupRef type a clean home and avoid a third inline fetch declaration. --- decnet_web/src/components/TTPInspector.css | 45 ++++++ decnet_web/src/components/TTPInspector.tsx | 153 +++++++++++++----- .../src/components/TTPsObservedSection.tsx | 29 ++-- decnet_web/src/types/ttp.ts | 41 +++++ decnet_web/src/utils/ttpApi.ts | 26 +++ 5 files changed, 240 insertions(+), 54 deletions(-) create mode 100644 decnet_web/src/types/ttp.ts create mode 100644 decnet_web/src/utils/ttpApi.ts diff --git a/decnet_web/src/components/TTPInspector.css b/decnet_web/src/components/TTPInspector.css index d8a48cae..da669dd3 100644 --- a/decnet_web/src/components/TTPInspector.css +++ b/decnet_web/src/components/TTPInspector.css @@ -170,3 +170,48 @@ font-size: 0.8rem; letter-spacing: 1px; } + +.ttp-mitre-link { + color: var(--dim-color); + text-decoration: none; + border-bottom: 1px dotted var(--dim-color); + font-variant-numeric: tabular-nums; + letter-spacing: 0.5px; +} +.ttp-mitre-link:hover { + color: var(--violet); + border-bottom-color: var(--violet); +} + +.ttp-group-row { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 0.78rem; + padding: 3px 4px; + border-radius: 2px; +} +.ttp-group-row:hover { + background: rgba(155, 135, 245, 0.05); +} + +.ttp-group-id { + color: var(--dim-color); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.ttp-group-name { + color: var(--matrix); + white-space: nowrap; +} + +.ttp-group-aliases { + color: var(--dim-color); + font-size: 0.72rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; +} diff --git a/decnet_web/src/components/TTPInspector.tsx b/decnet_web/src/components/TTPInspector.tsx index b1eda3b9..4728c54d 100644 --- a/decnet_web/src/components/TTPInspector.tsx +++ b/decnet_web/src/components/TTPInspector.tsx @@ -1,8 +1,10 @@ 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 { fetchTagsForTechnique, fetchGroupsForTechnique } from '../utils/ttpApi'; +import type { TTPScope } from '../utils/ttpApi'; +import type { GroupRef, TTPTagDetailRow } from '../types/ttp'; import './TTPInspector.css'; /* @@ -16,28 +18,10 @@ import './TTPInspector.css'; * 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; -} +// Re-export so existing imports of TTPTagDetailRow from this file keep working. +export type { TTPTagDetailRow } from '../types/ttp'; -export type TTPInspectorScope = 'identity' | 'attacker' | 'session'; +export type TTPInspectorScope = TTPScope; interface Props { scope: TTPInspectorScope; @@ -49,12 +33,35 @@ interface Props { tactic: string; count: number; confidenceMax: number; + mitre_url?: string | null; onClose: () => void; } +function mitreUrlForId(tid: string): string { + const [parent, sub] = tid.split('.'); + return sub + ? `https://attack.mitre.org/techniques/${parent}/${sub}` + : `https://attack.mitre.org/techniques/${parent}`; +} + +const MitreLink: React.FC<{ tid: string; href?: string | null }> = ({ tid, href }) => { + const url = href ?? mitreUrlForId(tid); + return ( + e.stopPropagation()} + > + {tid} ↗ + + ); +}; + const TTPInspector: React.FC = ({ scope, uuid, techniqueId, subTechniqueId, techniqueName, subTechniqueName, - tactic, count, confidenceMax, onClose, + tactic, count, confidenceMax, mitre_url, onClose, }) => { const panelRef = useRef(null); useEscapeKey(onClose, true); @@ -71,30 +78,43 @@ const TTPInspector: React.FC = ({ 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) { + fetchTagsForTechnique(scope, uuid, techniqueId, subTechniqueId) + .then((data) => { if (!cancelled) { setRows(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(); + }) + .finally(() => { if (!cancelled) setLoaded(true); }); return () => { cancelled = true; }; }, [scope, uuid, techniqueId, subTechniqueId]); + const [groups, setGroups] = useState(null); + const [groupsLoading, setGroupsLoading] = useState(false); + const [groupsError, setGroupsError] = useState(null); + + const groupTarget = subTechniqueId ?? techniqueId; + useEffect(() => { + let cancelled = false; + setGroups(null); + setGroupsError(null); + setGroupsLoading(true); + fetchGroupsForTechnique(groupTarget) + .then((data) => { if (!cancelled) setGroups(data); }) + .catch((err: any) => { + if (cancelled) return; + setGroupsError( + err?.response?.status === 404 ? 'Technique not found in ATT&CK bundle.' : + 'Failed to load groups.', + ); + }) + .finally(() => { if (!cancelled) setGroupsLoading(false); }); + return () => { cancelled = true; }; + }, [groupTarget]); + const id = subTechniqueId ?? techniqueId; const name = subTechniqueName ?? techniqueName; const headerLabel = name ? `${id} — ${name}` : id; @@ -125,10 +145,12 @@ const TTPInspector: React.FC = ({
{tactic}
TECHNIQUE
- {techniqueId}{techniqueName ? ` — ${techniqueName}` : ''} + + {techniqueName ? ` — ${techniqueName}` : ''} {subTechniqueId && (
- ↳ {subTechniqueId}{subTechniqueName ? ` — ${subTechniqueName}` : ''} + ↳ + {subTechniqueName ? ` — ${subTechniqueName}` : ''}
)}
@@ -152,6 +174,43 @@ const TTPInspector: React.FC = ({ )} + +
+
GROUPS
+ {groupsLoading ? ( +
Loading groups…
+ ) : groupsError ? ( +
{groupsError}
+ ) : groups === null ? null : groups.length === 0 ? ( +
No MITRE-tracked groups documented for this technique.
+ ) : ( +
+ {groups.map((g) => ( +
+ {g.mitre_url ? ( + e.stopPropagation()} + > + {g.group_id} ↗ + + ) : ( + {g.group_id} + )} + {g.name} + {g.aliases.length > 0 && ( + + {g.aliases.join(', ')} + + )} +
+ ))} +
+ )} +
@@ -239,7 +298,19 @@ const TTPTagCard: React.FC<{ row: TTPTagDetailRow }> = ({ row }) => {
SEEN
{new Date(row.created_at).toLocaleString()}
ATT&CK
-
{row.attack_release}
+
+ {row.mitre_url ? ( + + {row.sub_technique_id ?? row.technique_id} ↗ + + ) : (row.sub_technique_id ?? row.technique_id)} + {' '}{row.attack_release} +
{evidenceRows.length === 0 ? (
diff --git a/decnet_web/src/components/TTPsObservedSection.tsx b/decnet_web/src/components/TTPsObservedSection.tsx index 2dd4c087..b85a1a24 100644 --- a/decnet_web/src/components/TTPsObservedSection.tsx +++ b/decnet_web/src/components/TTPsObservedSection.tsx @@ -3,6 +3,7 @@ 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 @@ -16,18 +17,6 @@ import TTPInspector from './TTPInspector'; * operator rule administration. */ -interface TechniqueRow { - technique_id: string; - technique_name: string | null; - sub_technique_id: string | null; - sub_technique_name: string | null; - tactic: string; - count: number; - first_seen: string; - last_seen: string; - confidence_max: number; -} - const TACTIC_LABEL: Record = { TA0043: 'RECONNAISSANCE', TA0042: 'RESOURCE DEVELOPMENT', @@ -168,6 +157,7 @@ const TTPsObservedSection: React.FC = ({ scope, uuid }) => { tactic={selected.tactic} count={selected.count} confidenceMax={selected.confidence_max} + mitre_url={selected.mitre_url} onClose={() => setSelected(null)} /> )} @@ -221,7 +211,20 @@ const TechniqueBar: React.FC<{ textOverflow: 'ellipsis', }} title={label} - >{label} + > + {row.mitre_url ? ( + <> + e.stopPropagation()} + >{id} ↗ + {name ? ` — ${name}` : ''} + + ) : label} +
; + attack_release: string; + created_at: string; + mitre_url?: string | null; +} + +export interface GroupRef { + group_id: string; + name: string; + aliases: string[]; + mitre_url: string | null; +} diff --git a/decnet_web/src/utils/ttpApi.ts b/decnet_web/src/utils/ttpApi.ts new file mode 100644 index 00000000..0762fbc5 --- /dev/null +++ b/decnet_web/src/utils/ttpApi.ts @@ -0,0 +1,26 @@ +import api from './api'; +import type { GroupRef, TechniqueRow, TTPTagDetailRow } from '../types/ttp'; + +export type TTPScope = 'identity' | 'attacker' | 'session'; + +export async function fetchTechniques(scope: TTPScope, uuid: string): Promise { + const res = await api.get(`/ttp/by-${scope}/${uuid}`); + return Array.isArray(res.data) ? res.data : []; +} + +export async function fetchTagsForTechnique( + scope: TTPScope, + uuid: string, + techniqueId: string, + subTechniqueId?: string | null, +): Promise { + const params: Record = {}; + if (subTechniqueId) params.sub_technique_id = subTechniqueId; + const res = await api.get(`/ttp/tags/by-${scope}/${uuid}/${techniqueId}`, { params }); + return Array.isArray(res.data) ? res.data : []; +} + +export async function fetchGroupsForTechnique(techniqueId: string): Promise { + const res = await api.get(`/ttp/techniques/${techniqueId}/groups`); + return Array.isArray(res.data) ? res.data : []; +}