feat(ttp): show canonical ATT&CK technique names in the TTPs UI

"T1595" alone is opaque; "T1595 — Active Scanning" tells you the
story at a glance. The names come from a backend-side static catalogue
pinned to the same ATT&CK release as the rule engine
(_ATTACK_RELEASE = "v15.1") — names are the canonical MITRE labels,
not author-supplied strings on rules, so a rule author can't typo a
name and the entire fleet sees the typo.

- New `decnet/ttp/attack_catalog.py` with `TECHNIQUE_NAMES` covering
  every technique_id + sub_technique_id emitted by `rules/ttp/`
  (R0001..R0058 → 69 IDs in the v0 pack).
- `IdentityTechniqueRow` / `TechniqueRollupRow` / `CampaignTechniqueRow`
  / `TTPTagDetailRow` gain optional `technique_name` /
  `sub_technique_name` fields. Repo + router populate them from the
  catalogue at row-construction time. None when an ID isn't in the
  catalogue — UI falls back to the bare ID.
- Coverage test (`tests/ttp/test_attack_catalog.py`) walks every
  YAML rule and asserts every emitted ID has a catalogue entry, so
  a future rule author who forgets to update the catalogue gets a
  loud failure rather than a silent UI fallback.

Frontend:
- `TTPsObservedSection` shows "T1595.002 — Active Scanning:
  Vulnerability Scanning" instead of just the ID, with overflow
  ellipsis + tooltip for narrow viewports. Inspector header /
  TECHNIQUE row also surface the names.
This commit is contained in:
2026-05-02 03:10:07 -04:00
parent 42e9492118
commit 84699f89da
7 changed files with 247 additions and 10 deletions

View File

@@ -26,7 +26,9 @@ export interface TTPTagDetailRow {
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;
@@ -42,6 +44,8 @@ interface Props {
uuid: string;
techniqueId: string;
subTechniqueId: string | null;
techniqueName: string | null;
subTechniqueName: string | null;
tactic: string;
count: number;
confidenceMax: number;
@@ -49,7 +53,8 @@ interface Props {
}
const TTPInspector: React.FC<Props> = ({
scope, uuid, techniqueId, subTechniqueId, tactic, count, confidenceMax, onClose,
scope, uuid, techniqueId, subTechniqueId, techniqueName, subTechniqueName,
tactic, count, confidenceMax, onClose,
}) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true);
@@ -90,7 +95,9 @@ const TTPInspector: React.FC<Props> = ({
return () => { cancelled = true; };
}, [scope, uuid, techniqueId, subTechniqueId]);
const label = subTechniqueId ?? techniqueId;
const id = subTechniqueId ?? techniqueId;
const name = subTechniqueName ?? techniqueName;
const headerLabel = name ? `${id}${name}` : id;
return (
<div
@@ -101,7 +108,7 @@ const TTPInspector: React.FC<Props> = ({
<div className="bd-head">
<h3>
<Crosshair size={14} />
<span>{label}</span>
<span>{headerLabel}</span>
</h3>
<button className="close-btn" onClick={onClose} aria-label="Close">
<X size={16} />
@@ -117,7 +124,14 @@ const TTPInspector: React.FC<Props> = ({
<div className="k" style={{ color: 'var(--dim-color)' }}>TACTIC</div>
<div className="v">{tactic}</div>
<div className="k" style={{ color: 'var(--dim-color)' }}>TECHNIQUE</div>
<div className="v">{techniqueId}{subTechniqueId ? ` / ${subTechniqueId}` : ''}</div>
<div className="v">
{techniqueId}{techniqueName ? `${techniqueName}` : ''}
{subTechniqueId && (
<div style={{ marginTop: 2 }}>
{subTechniqueId}{subTechniqueName ? `${subTechniqueName}` : ''}
</div>
)}
</div>
<div className="k" style={{ color: 'var(--dim-color)' }}>FIRES</div>
<div className="v">{count}</div>
<div className="k" style={{ color: 'var(--dim-color)' }}>MAX CONF</div>