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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ttp-mitre-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{tid} ↗
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const TTPInspector: React.FC<Props> = ({
|
||||
scope, uuid, techniqueId, subTechniqueId, techniqueName, subTechniqueName,
|
||||
tactic, count, confidenceMax, onClose,
|
||||
tactic, count, confidenceMax, mitre_url, onClose,
|
||||
}) => {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscapeKey(onClose, true);
|
||||
@@ -71,30 +78,43 @@ const TTPInspector: React.FC<Props> = ({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetch = async () => {
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
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<GroupRef[] | null>(null);
|
||||
const [groupsLoading, setGroupsLoading] = useState(false);
|
||||
const [groupsError, setGroupsError] = useState<string | null>(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<Props> = ({
|
||||
<div className="v">{tactic}</div>
|
||||
<div className="k" style={{ color: 'var(--dim-color)' }}>TECHNIQUE</div>
|
||||
<div className="v">
|
||||
{techniqueId}{techniqueName ? ` — ${techniqueName}` : ''}
|
||||
<MitreLink tid={techniqueId} href={subTechniqueId ? undefined : mitre_url} />
|
||||
{techniqueName ? ` — ${techniqueName}` : ''}
|
||||
{subTechniqueId && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
↳ {subTechniqueId}{subTechniqueName ? ` — ${subTechniqueName}` : ''}
|
||||
↳ <MitreLink tid={subTechniqueId} href={mitre_url} />
|
||||
{subTechniqueName ? ` — ${subTechniqueName}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -152,6 +174,43 @@ const TTPInspector: React.FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="type-label">GROUPS</div>
|
||||
{groupsLoading ? (
|
||||
<div className="ttp-empty">Loading groups…</div>
|
||||
) : groupsError ? (
|
||||
<div className="ttp-empty">{groupsError}</div>
|
||||
) : groups === null ? null : groups.length === 0 ? (
|
||||
<div className="ttp-empty">No MITRE-tracked groups documented for this technique.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{groups.map((g) => (
|
||||
<div key={g.group_id} className="ttp-group-row">
|
||||
{g.mitre_url ? (
|
||||
<a
|
||||
href={g.mitre_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ttp-mitre-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{g.group_id} ↗
|
||||
</a>
|
||||
) : (
|
||||
<span className="ttp-group-id">{g.group_id}</span>
|
||||
)}
|
||||
<span className="ttp-group-name">{g.name}</span>
|
||||
{g.aliases.length > 0 && (
|
||||
<span className="ttp-group-aliases" title={g.aliases.join(', ')}>
|
||||
{g.aliases.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,7 +298,19 @@ const TTPTagCard: React.FC<{ row: TTPTagDetailRow }> = ({ row }) => {
|
||||
<div className="k">SEEN</div>
|
||||
<div className="v">{new Date(row.created_at).toLocaleString()}</div>
|
||||
<div className="k">ATT&CK</div>
|
||||
<div className="v">{row.attack_release}</div>
|
||||
<div className="v">
|
||||
{row.mitre_url ? (
|
||||
<a
|
||||
href={row.mitre_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ttp-mitre-link"
|
||||
>
|
||||
{row.sub_technique_id ?? row.technique_id} ↗
|
||||
</a>
|
||||
) : (row.sub_technique_id ?? row.technique_id)}
|
||||
{' '}{row.attack_release}
|
||||
</div>
|
||||
</div>
|
||||
{evidenceRows.length === 0 ? (
|
||||
<div className="ttp-empty" style={{ padding: '8px' }}>—</div>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
TA0043: 'RECONNAISSANCE',
|
||||
TA0042: 'RESOURCE DEVELOPMENT',
|
||||
@@ -168,6 +157,7 @@ const TTPsObservedSection: React.FC<Props> = ({ 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}</span>
|
||||
>
|
||||
{row.mitre_url ? (
|
||||
<>
|
||||
<a
|
||||
href={row.mitre_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ttp-mitre-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>{id} ↗</a>
|
||||
{name ? ` — ${name}` : ''}
|
||||
</>
|
||||
) : label}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
height: 6,
|
||||
|
||||
41
decnet_web/src/types/ttp.ts
Normal file
41
decnet_web/src/types/ttp.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export 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;
|
||||
mitre_url?: string | null;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
attack_release: string;
|
||||
created_at: string;
|
||||
mitre_url?: string | null;
|
||||
}
|
||||
|
||||
export interface GroupRef {
|
||||
group_id: string;
|
||||
name: string;
|
||||
aliases: string[];
|
||||
mitre_url: string | null;
|
||||
}
|
||||
26
decnet_web/src/utils/ttpApi.ts
Normal file
26
decnet_web/src/utils/ttpApi.ts
Normal file
@@ -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<TechniqueRow[]> {
|
||||
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<TTPTagDetailRow[]> {
|
||||
const params: Record<string, string> = {};
|
||||
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<GroupRef[]> {
|
||||
const res = await api.get(`/ttp/techniques/${techniqueId}/groups`);
|
||||
return Array.isArray(res.data) ? res.data : [];
|
||||
}
|
||||
Reference in New Issue
Block a user