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;
|
font-size: 0.8rem;
|
||||||
letter-spacing: 1px;
|
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 React, { useEffect, useRef, useState } from 'react';
|
||||||
import { X, Crosshair } from '../icons';
|
import { X, Crosshair } from '../icons';
|
||||||
import api from '../utils/api';
|
|
||||||
import { useEscapeKey } from '../hooks/useEscapeKey';
|
import { useEscapeKey } from '../hooks/useEscapeKey';
|
||||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
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';
|
import './TTPInspector.css';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -16,28 +18,10 @@ import './TTPInspector.css';
|
|||||||
* geometry mirrors CredentialReuseInspector / BountyInspector.
|
* geometry mirrors CredentialReuseInspector / BountyInspector.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface TTPTagDetailRow {
|
// Re-export so existing imports of TTPTagDetailRow from this file keep working.
|
||||||
uuid: string;
|
export type { TTPTagDetailRow } from '../types/ttp';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TTPInspectorScope = 'identity' | 'attacker' | 'session';
|
export type TTPInspectorScope = TTPScope;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
scope: TTPInspectorScope;
|
scope: TTPInspectorScope;
|
||||||
@@ -49,12 +33,35 @@ interface Props {
|
|||||||
tactic: string;
|
tactic: string;
|
||||||
count: number;
|
count: number;
|
||||||
confidenceMax: number;
|
confidenceMax: number;
|
||||||
|
mitre_url?: string | null;
|
||||||
onClose: () => void;
|
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> = ({
|
const TTPInspector: React.FC<Props> = ({
|
||||||
scope, uuid, techniqueId, subTechniqueId, techniqueName, subTechniqueName,
|
scope, uuid, techniqueId, subTechniqueId, techniqueName, subTechniqueName,
|
||||||
tactic, count, confidenceMax, onClose,
|
tactic, count, confidenceMax, mitre_url, onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
useEscapeKey(onClose, true);
|
useEscapeKey(onClose, true);
|
||||||
@@ -71,30 +78,43 @@ const TTPInspector: React.FC<Props> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const fetch = async () => {
|
fetchTagsForTechnique(scope, uuid, techniqueId, subTechniqueId)
|
||||||
try {
|
.then((data) => { if (!cancelled) { setRows(data); setError(null); } })
|
||||||
const params: Record<string, string> = {};
|
.catch((err: any) => {
|
||||||
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) {
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRows([]);
|
setRows([]);
|
||||||
setError(
|
setError(
|
||||||
err?.response?.status === 403 ? 'Insufficient role for tag detail.' :
|
err?.response?.status === 403 ? 'Insufficient role for tag detail.' :
|
||||||
'Failed to load tag detail.',
|
'Failed to load tag detail.',
|
||||||
);
|
);
|
||||||
} finally {
|
})
|
||||||
if (!cancelled) setLoaded(true);
|
.finally(() => { if (!cancelled) setLoaded(true); });
|
||||||
}
|
|
||||||
};
|
|
||||||
fetch();
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [scope, uuid, techniqueId, subTechniqueId]);
|
}, [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 id = subTechniqueId ?? techniqueId;
|
||||||
const name = subTechniqueName ?? techniqueName;
|
const name = subTechniqueName ?? techniqueName;
|
||||||
const headerLabel = name ? `${id} — ${name}` : id;
|
const headerLabel = name ? `${id} — ${name}` : id;
|
||||||
@@ -125,10 +145,12 @@ const TTPInspector: React.FC<Props> = ({
|
|||||||
<div className="v">{tactic}</div>
|
<div className="v">{tactic}</div>
|
||||||
<div className="k" style={{ color: 'var(--dim-color)' }}>TECHNIQUE</div>
|
<div className="k" style={{ color: 'var(--dim-color)' }}>TECHNIQUE</div>
|
||||||
<div className="v">
|
<div className="v">
|
||||||
{techniqueId}{techniqueName ? ` — ${techniqueName}` : ''}
|
<MitreLink tid={techniqueId} href={subTechniqueId ? undefined : mitre_url} />
|
||||||
|
{techniqueName ? ` — ${techniqueName}` : ''}
|
||||||
{subTechniqueId && (
|
{subTechniqueId && (
|
||||||
<div style={{ marginTop: 2 }}>
|
<div style={{ marginTop: 2 }}>
|
||||||
↳ {subTechniqueId}{subTechniqueName ? ` — ${subTechniqueName}` : ''}
|
↳ <MitreLink tid={subTechniqueId} href={mitre_url} />
|
||||||
|
{subTechniqueName ? ` — ${subTechniqueName}` : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -152,6 +174,43 @@ const TTPInspector: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +298,19 @@ const TTPTagCard: React.FC<{ row: TTPTagDetailRow }> = ({ row }) => {
|
|||||||
<div className="k">SEEN</div>
|
<div className="k">SEEN</div>
|
||||||
<div className="v">{new Date(row.created_at).toLocaleString()}</div>
|
<div className="v">{new Date(row.created_at).toLocaleString()}</div>
|
||||||
<div className="k">ATT&CK</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>
|
</div>
|
||||||
{evidenceRows.length === 0 ? (
|
{evidenceRows.length === 0 ? (
|
||||||
<div className="ttp-empty" style={{ padding: '8px' }}>—</div>
|
<div className="ttp-empty" style={{ padding: '8px' }}>—</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Crosshair, Download, Target } from '../icons';
|
|||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import EmptyState from './EmptyState/EmptyState';
|
import EmptyState from './EmptyState/EmptyState';
|
||||||
import TTPInspector from './TTPInspector';
|
import TTPInspector from './TTPInspector';
|
||||||
|
import type { TechniqueRow } from '../types/ttp';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* TTPsObservedSection — shared between IdentityDetail (primary) and
|
* TTPsObservedSection — shared between IdentityDetail (primary) and
|
||||||
@@ -16,18 +17,6 @@ import TTPInspector from './TTPInspector';
|
|||||||
* operator rule administration.
|
* 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> = {
|
const TACTIC_LABEL: Record<string, string> = {
|
||||||
TA0043: 'RECONNAISSANCE',
|
TA0043: 'RECONNAISSANCE',
|
||||||
TA0042: 'RESOURCE DEVELOPMENT',
|
TA0042: 'RESOURCE DEVELOPMENT',
|
||||||
@@ -168,6 +157,7 @@ const TTPsObservedSection: React.FC<Props> = ({ scope, uuid }) => {
|
|||||||
tactic={selected.tactic}
|
tactic={selected.tactic}
|
||||||
count={selected.count}
|
count={selected.count}
|
||||||
confidenceMax={selected.confidence_max}
|
confidenceMax={selected.confidence_max}
|
||||||
|
mitre_url={selected.mitre_url}
|
||||||
onClose={() => setSelected(null)}
|
onClose={() => setSelected(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -221,7 +211,20 @@ const TechniqueBar: React.FC<{
|
|||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
}}
|
}}
|
||||||
title={label}
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 6,
|
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