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:
115
decnet/ttp/attack_catalog.py
Normal file
115
decnet/ttp/attack_catalog.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""ATT&CK technique-id → display-name catalogue.
|
||||||
|
|
||||||
|
Pinned to the same ATT&CK release the rule engine emits on
|
||||||
|
(``v15.1`` per ``decnet/ttp/impl/rule_engine.py:_ATTACK_RELEASE``). The
|
||||||
|
operator UI uses these names to render "T1595 — Active Scanning"
|
||||||
|
instead of just "T1595" in the TTPs-observed rollup and the per-tag
|
||||||
|
inspector. Names are the canonical MITRE labels, not author-supplied
|
||||||
|
strings on rules — keeping them here means a rule author can't typo a
|
||||||
|
technique name and the entire fleet sees the typo.
|
||||||
|
|
||||||
|
Bumping ``_ATTACK_RELEASE`` requires reviewing this file: any
|
||||||
|
techniques that were renamed need their entries updated in the same
|
||||||
|
commit. See TTP_TAGGING.md §"Hard parts §8 ATT&CK matrix drift".
|
||||||
|
|
||||||
|
Coverage policy: every technique_id / sub_technique_id appearing in
|
||||||
|
``rules/ttp/`` MUST have an entry here. The
|
||||||
|
``tests/ttp/test_attack_catalog.py`` coverage test enforces this so a
|
||||||
|
rule author who adds a new technique gets a loud failure rather than
|
||||||
|
a silent UI fallback.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
# Top-level techniques + sub-techniques referenced by `rules/ttp/`
|
||||||
|
# (R0001..R0058). Names from MITRE ATT&CK Enterprise v15.1.
|
||||||
|
TECHNIQUE_NAMES: Final[dict[str, str]] = {
|
||||||
|
# ── Top-level techniques ─────────────────────────────────────────
|
||||||
|
"T1003": "OS Credential Dumping",
|
||||||
|
"T1016": "System Network Configuration Discovery",
|
||||||
|
"T1027": "Obfuscated Files or Information",
|
||||||
|
"T1029": "Scheduled Transfer",
|
||||||
|
"T1033": "System Owner/User Discovery",
|
||||||
|
"T1036": "Masquerading",
|
||||||
|
"T1046": "Network Service Discovery",
|
||||||
|
"T1049": "System Network Connections Discovery",
|
||||||
|
"T1053": "Scheduled Task/Job",
|
||||||
|
"T1059": "Command and Scripting Interpreter",
|
||||||
|
"T1070": "Indicator Removal",
|
||||||
|
"T1071": "Application Layer Protocol",
|
||||||
|
"T1078": "Valid Accounts",
|
||||||
|
"T1082": "System Information Discovery",
|
||||||
|
"T1083": "File and Directory Discovery",
|
||||||
|
"T1087": "Account Discovery",
|
||||||
|
"T1090": "Proxy",
|
||||||
|
"T1098": "Account Manipulation",
|
||||||
|
"T1105": "Ingress Tool Transfer",
|
||||||
|
"T1110": "Brute Force",
|
||||||
|
"T1135": "Network Share Discovery",
|
||||||
|
"T1136": "Create Account",
|
||||||
|
"T1190": "Exploit Public-Facing Application",
|
||||||
|
"T1204": "User Execution",
|
||||||
|
"T1213": "Data from Information Repositories",
|
||||||
|
"T1482": "Domain Trust Discovery",
|
||||||
|
"T1485": "Data Destruction",
|
||||||
|
"T1486": "Data Encrypted for Impact",
|
||||||
|
"T1496": "Resource Hijacking",
|
||||||
|
"T1505": "Server Software Component",
|
||||||
|
"T1548": "Abuse Elevation Control Mechanism",
|
||||||
|
"T1552": "Unsecured Credentials",
|
||||||
|
"T1557": "Adversary-in-the-Middle",
|
||||||
|
"T1566": "Phishing",
|
||||||
|
"T1567": "Exfiltration Over Web Service",
|
||||||
|
"T1586": "Compromise Accounts",
|
||||||
|
"T1588": "Obtain Capabilities",
|
||||||
|
"T1595": "Active Scanning",
|
||||||
|
"T1602": "Data from Configuration Repository",
|
||||||
|
"T1611": "Escape to Host",
|
||||||
|
# ── Sub-techniques ───────────────────────────────────────────────
|
||||||
|
"T1003.008": "OS Credential Dumping: /etc/passwd and /etc/shadow",
|
||||||
|
"T1036.005": "Masquerading: Match Legitimate Name or Location",
|
||||||
|
"T1053.003": "Scheduled Task/Job: Cron",
|
||||||
|
"T1059.004": "Command and Scripting Interpreter: Unix Shell",
|
||||||
|
"T1070.003": "Indicator Removal: Clear Command History",
|
||||||
|
"T1071.001": "Application Layer Protocol: Web Protocols",
|
||||||
|
"T1071.003": "Application Layer Protocol: Mail Protocols",
|
||||||
|
"T1078.001": "Valid Accounts: Default Accounts",
|
||||||
|
"T1087.002": "Account Discovery: Domain Account",
|
||||||
|
"T1098.004": "Account Manipulation: SSH Authorized Keys",
|
||||||
|
"T1110.001": "Brute Force: Password Guessing",
|
||||||
|
"T1110.003": "Brute Force: Password Spraying",
|
||||||
|
"T1110.004": "Brute Force: Credential Stuffing",
|
||||||
|
"T1136.001": "Create Account: Local Account",
|
||||||
|
"T1204.002": "User Execution: Malicious File",
|
||||||
|
"T1505.003": "Server Software Component: Web Shell",
|
||||||
|
"T1548.001": "Abuse Elevation Control Mechanism: Setuid and Setgid",
|
||||||
|
"T1548.003": "Abuse Elevation Control Mechanism: Sudo and Sudo Caching",
|
||||||
|
"T1552.001": "Unsecured Credentials: Credentials In Files",
|
||||||
|
"T1552.007": "Unsecured Credentials: Container API",
|
||||||
|
"T1557.001": "Adversary-in-the-Middle: LLMNR/NBT-NS Poisoning and SMB Relay",
|
||||||
|
"T1566.001": "Phishing: Spearphishing Attachment",
|
||||||
|
"T1566.002": "Phishing: Spearphishing Link",
|
||||||
|
"T1566.003": "Phishing: Spearphishing via Service",
|
||||||
|
"T1586.002": "Compromise Accounts: Email Accounts",
|
||||||
|
"T1588.001": "Obtain Capabilities: Malware",
|
||||||
|
"T1588.002": "Obtain Capabilities: Tool",
|
||||||
|
"T1595.002": "Active Scanning: Vulnerability Scanning",
|
||||||
|
"T1602.002": "Data from Configuration Repository: Network Device Configuration Dump",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def technique_name(technique_id: str | None) -> str | None:
|
||||||
|
"""Return the canonical ATT&CK display name for *technique_id*.
|
||||||
|
|
||||||
|
``None`` for unknown IDs — the UI falls back to showing the bare
|
||||||
|
ID. Adding a rule that emits an unknown technique should be a
|
||||||
|
deploy-time loud failure (see ``tests/ttp/test_attack_catalog.py``)
|
||||||
|
rather than a silent UI fallback in production.
|
||||||
|
"""
|
||||||
|
if not technique_id:
|
||||||
|
return None
|
||||||
|
return TECHNIQUE_NAMES.get(technique_id)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["TECHNIQUE_NAMES", "technique_name"]
|
||||||
@@ -250,7 +250,9 @@ class TechniqueRollupRow(BaseModel):
|
|||||||
across the fleet with a count and a most-recent-seen timestamp."""
|
across the fleet with a count and a most-recent-seen timestamp."""
|
||||||
|
|
||||||
technique_id: str
|
technique_id: str
|
||||||
|
technique_name: Optional[str] = None
|
||||||
sub_technique_id: Optional[str] = None
|
sub_technique_id: Optional[str] = None
|
||||||
|
sub_technique_name: Optional[str] = None
|
||||||
tactic: str
|
tactic: str
|
||||||
count: int
|
count: int
|
||||||
last_seen: datetime
|
last_seen: datetime
|
||||||
@@ -259,10 +261,18 @@ class TechniqueRollupRow(BaseModel):
|
|||||||
class IdentityTechniqueRow(BaseModel):
|
class IdentityTechniqueRow(BaseModel):
|
||||||
"""One row of the by-identity / by-attacker / by-session endpoints —
|
"""One row of the by-identity / by-attacker / by-session endpoints —
|
||||||
a distinct (technique, sub_technique) tuple within the requested
|
a distinct (technique, sub_technique) tuple within the requested
|
||||||
scope, with an aggregate count and first/last-seen timestamps."""
|
scope, with an aggregate count and first/last-seen timestamps.
|
||||||
|
|
||||||
|
``technique_name`` / ``sub_technique_name`` come from
|
||||||
|
:mod:`decnet.ttp.attack_catalog` (canonical ATT&CK labels for the
|
||||||
|
pinned release). ``None`` when the ID isn't in the catalogue —
|
||||||
|
the UI falls back to showing the bare ID.
|
||||||
|
"""
|
||||||
|
|
||||||
technique_id: str
|
technique_id: str
|
||||||
|
technique_name: Optional[str] = None
|
||||||
sub_technique_id: Optional[str] = None
|
sub_technique_id: Optional[str] = None
|
||||||
|
sub_technique_name: Optional[str] = None
|
||||||
tactic: str
|
tactic: str
|
||||||
count: int
|
count: int
|
||||||
first_seen: datetime
|
first_seen: datetime
|
||||||
@@ -287,7 +297,9 @@ class TTPTagDetailRow(BaseModel):
|
|||||||
decky_id: Optional[str] = None
|
decky_id: Optional[str] = None
|
||||||
tactic: str
|
tactic: str
|
||||||
technique_id: str
|
technique_id: str
|
||||||
|
technique_name: Optional[str] = None
|
||||||
sub_technique_id: Optional[str] = None
|
sub_technique_id: Optional[str] = None
|
||||||
|
sub_technique_name: Optional[str] = None
|
||||||
confidence: float
|
confidence: float
|
||||||
rule_id: str
|
rule_id: str
|
||||||
rule_version: int
|
rule_version: int
|
||||||
@@ -301,7 +313,9 @@ class CampaignTechniqueRow(BaseModel):
|
|||||||
across at least one Identity rolled up into the campaign."""
|
across at least one Identity rolled up into the campaign."""
|
||||||
|
|
||||||
technique_id: str
|
technique_id: str
|
||||||
|
technique_name: Optional[str] = None
|
||||||
sub_technique_id: Optional[str] = None
|
sub_technique_id: Optional[str] = None
|
||||||
|
sub_technique_name: Optional[str] = None
|
||||||
tactic: str
|
tactic: str
|
||||||
count: int
|
count: int
|
||||||
identity_count: int
|
identity_count: int
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from typing import Any
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlmodel import col
|
from sqlmodel import col
|
||||||
|
|
||||||
|
from decnet.ttp.attack_catalog import technique_name as _technique_name
|
||||||
from decnet.web.db.models import (
|
from decnet.web.db.models import (
|
||||||
Attacker,
|
Attacker,
|
||||||
AttackerIdentity,
|
AttackerIdentity,
|
||||||
@@ -113,7 +114,9 @@ class TTPMixin(_MixinBase):
|
|||||||
return [
|
return [
|
||||||
IdentityTechniqueRow(
|
IdentityTechniqueRow(
|
||||||
technique_id=r.technique_id,
|
technique_id=r.technique_id,
|
||||||
|
technique_name=_technique_name(r.technique_id),
|
||||||
sub_technique_id=r.sub_technique_id,
|
sub_technique_id=r.sub_technique_id,
|
||||||
|
sub_technique_name=_technique_name(r.sub_technique_id),
|
||||||
tactic=r.tactic,
|
tactic=r.tactic,
|
||||||
count=r.count,
|
count=r.count,
|
||||||
first_seen=r.first_seen,
|
first_seen=r.first_seen,
|
||||||
@@ -149,7 +152,9 @@ class TTPMixin(_MixinBase):
|
|||||||
return [
|
return [
|
||||||
IdentityTechniqueRow(
|
IdentityTechniqueRow(
|
||||||
technique_id=r.technique_id,
|
technique_id=r.technique_id,
|
||||||
|
technique_name=_technique_name(r.technique_id),
|
||||||
sub_technique_id=r.sub_technique_id,
|
sub_technique_id=r.sub_technique_id,
|
||||||
|
sub_technique_name=_technique_name(r.sub_technique_id),
|
||||||
tactic=r.tactic,
|
tactic=r.tactic,
|
||||||
count=r.count,
|
count=r.count,
|
||||||
first_seen=r.first_seen,
|
first_seen=r.first_seen,
|
||||||
@@ -191,7 +196,9 @@ class TTPMixin(_MixinBase):
|
|||||||
return [
|
return [
|
||||||
CampaignTechniqueRow(
|
CampaignTechniqueRow(
|
||||||
technique_id=r.technique_id,
|
technique_id=r.technique_id,
|
||||||
|
technique_name=_technique_name(r.technique_id),
|
||||||
sub_technique_id=r.sub_technique_id,
|
sub_technique_id=r.sub_technique_id,
|
||||||
|
sub_technique_name=_technique_name(r.sub_technique_id),
|
||||||
tactic=r.tactic,
|
tactic=r.tactic,
|
||||||
count=r.count,
|
count=r.count,
|
||||||
identity_count=r.identity_count,
|
identity_count=r.identity_count,
|
||||||
@@ -225,7 +232,9 @@ class TTPMixin(_MixinBase):
|
|||||||
return [
|
return [
|
||||||
IdentityTechniqueRow(
|
IdentityTechniqueRow(
|
||||||
technique_id=r.technique_id,
|
technique_id=r.technique_id,
|
||||||
|
technique_name=_technique_name(r.technique_id),
|
||||||
sub_technique_id=r.sub_technique_id,
|
sub_technique_id=r.sub_technique_id,
|
||||||
|
sub_technique_name=_technique_name(r.sub_technique_id),
|
||||||
tactic=r.tactic,
|
tactic=r.tactic,
|
||||||
count=r.count,
|
count=r.count,
|
||||||
first_seen=r.first_seen,
|
first_seen=r.first_seen,
|
||||||
@@ -396,7 +405,9 @@ class TTPMixin(_MixinBase):
|
|||||||
return [
|
return [
|
||||||
TechniqueRollupRow(
|
TechniqueRollupRow(
|
||||||
technique_id=r.technique_id,
|
technique_id=r.technique_id,
|
||||||
|
technique_name=_technique_name(r.technique_id),
|
||||||
sub_technique_id=r.sub_technique_id,
|
sub_technique_id=r.sub_technique_id,
|
||||||
|
sub_technique_name=_technique_name(r.sub_technique_id),
|
||||||
tactic=r.tactic,
|
tactic=r.tactic,
|
||||||
count=r.count,
|
count=r.count,
|
||||||
last_seen=r.last_seen,
|
last_seen=r.last_seen,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from typing import Any, Literal
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
from decnet.telemetry import traced as _traced
|
from decnet.telemetry import traced as _traced
|
||||||
|
from decnet.ttp.attack_catalog import technique_name as _technique_name
|
||||||
from decnet.web.db.models import TTPTagDetailRow
|
from decnet.web.db.models import TTPTagDetailRow
|
||||||
from decnet.web.dependencies import repo, require_viewer
|
from decnet.web.dependencies import repo, require_viewer
|
||||||
|
|
||||||
@@ -71,4 +72,11 @@ async def api_ttp_tag_details(
|
|||||||
sub_technique_id=sub_technique_id,
|
sub_technique_id=sub_technique_id,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
return [TTPTagDetailRow(**row) for row in rows]
|
return [
|
||||||
|
TTPTagDetailRow(
|
||||||
|
**row,
|
||||||
|
technique_name=_technique_name(row.get("technique_id")),
|
||||||
|
sub_technique_name=_technique_name(row.get("sub_technique_id")),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export interface TTPTagDetailRow {
|
|||||||
decky_id: string | null;
|
decky_id: string | null;
|
||||||
tactic: string;
|
tactic: string;
|
||||||
technique_id: string;
|
technique_id: string;
|
||||||
|
technique_name: string | null;
|
||||||
sub_technique_id: string | null;
|
sub_technique_id: string | null;
|
||||||
|
sub_technique_name: string | null;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
rule_id: string;
|
rule_id: string;
|
||||||
rule_version: number;
|
rule_version: number;
|
||||||
@@ -42,6 +44,8 @@ interface Props {
|
|||||||
uuid: string;
|
uuid: string;
|
||||||
techniqueId: string;
|
techniqueId: string;
|
||||||
subTechniqueId: string | null;
|
subTechniqueId: string | null;
|
||||||
|
techniqueName: string | null;
|
||||||
|
subTechniqueName: string | null;
|
||||||
tactic: string;
|
tactic: string;
|
||||||
count: number;
|
count: number;
|
||||||
confidenceMax: number;
|
confidenceMax: number;
|
||||||
@@ -49,7 +53,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TTPInspector: React.FC<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);
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
useEscapeKey(onClose, true);
|
useEscapeKey(onClose, true);
|
||||||
@@ -90,7 +95,9 @@ const TTPInspector: React.FC<Props> = ({
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [scope, uuid, techniqueId, subTechniqueId]);
|
}, [scope, uuid, techniqueId, subTechniqueId]);
|
||||||
|
|
||||||
const label = subTechniqueId ?? techniqueId;
|
const id = subTechniqueId ?? techniqueId;
|
||||||
|
const name = subTechniqueName ?? techniqueName;
|
||||||
|
const headerLabel = name ? `${id} — ${name}` : id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -101,7 +108,7 @@ const TTPInspector: React.FC<Props> = ({
|
|||||||
<div className="bd-head">
|
<div className="bd-head">
|
||||||
<h3>
|
<h3>
|
||||||
<Crosshair size={14} />
|
<Crosshair size={14} />
|
||||||
<span>{label}</span>
|
<span>{headerLabel}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<button className="close-btn" onClick={onClose} aria-label="Close">
|
<button className="close-btn" onClick={onClose} aria-label="Close">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
@@ -117,7 +124,14 @@ const TTPInspector: React.FC<Props> = ({
|
|||||||
<div className="k" style={{ color: 'var(--dim-color)' }}>TACTIC</div>
|
<div className="k" style={{ color: 'var(--dim-color)' }}>TACTIC</div>
|
||||||
<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">{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="k" style={{ color: 'var(--dim-color)' }}>FIRES</div>
|
||||||
<div className="v">{count}</div>
|
<div className="v">{count}</div>
|
||||||
<div className="k" style={{ color: 'var(--dim-color)' }}>MAX CONF</div>
|
<div className="k" style={{ color: 'var(--dim-color)' }}>MAX CONF</div>
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import TTPInspector from './TTPInspector';
|
|||||||
|
|
||||||
interface TechniqueRow {
|
interface TechniqueRow {
|
||||||
technique_id: string;
|
technique_id: string;
|
||||||
|
technique_name: string | null;
|
||||||
sub_technique_id: string | null;
|
sub_technique_id: string | null;
|
||||||
|
sub_technique_name: string | null;
|
||||||
tactic: string;
|
tactic: string;
|
||||||
count: number;
|
count: number;
|
||||||
first_seen: string;
|
first_seen: string;
|
||||||
@@ -161,6 +163,8 @@ const TTPsObservedSection: React.FC<Props> = ({ scope, uuid }) => {
|
|||||||
uuid={uuid}
|
uuid={uuid}
|
||||||
techniqueId={selected.technique_id}
|
techniqueId={selected.technique_id}
|
||||||
subTechniqueId={selected.sub_technique_id}
|
subTechniqueId={selected.sub_technique_id}
|
||||||
|
techniqueName={selected.technique_name}
|
||||||
|
subTechniqueName={selected.sub_technique_name}
|
||||||
tactic={selected.tactic}
|
tactic={selected.tactic}
|
||||||
count={selected.count}
|
count={selected.count}
|
||||||
confidenceMax={selected.confidence_max}
|
confidenceMax={selected.confidence_max}
|
||||||
@@ -179,7 +183,12 @@ const TechniqueBar: React.FC<{
|
|||||||
// can never appear (repo confidence floor) so the bar always shows
|
// can never appear (repo confidence floor) so the bar always shows
|
||||||
// some non-trivial fill.
|
// some non-trivial fill.
|
||||||
const pct = Math.round(Math.max(0, Math.min(1, row.confidence_max)) * 100);
|
const pct = Math.round(Math.max(0, Math.min(1, row.confidence_max)) * 100);
|
||||||
const label = row.sub_technique_id ?? row.technique_id;
|
// Prefer the sub-technique label if present (more specific). Each
|
||||||
|
// half is "T#### — Name" when the catalogue has a name, falling
|
||||||
|
// back to the bare ID for techniques not yet catalogued.
|
||||||
|
const id = row.sub_technique_id ?? row.technique_id;
|
||||||
|
const name = row.sub_technique_name ?? row.technique_name;
|
||||||
|
const label = name ? `${id} — ${name}` : id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -194,8 +203,8 @@ const TechniqueBar: React.FC<{
|
|||||||
title="Click to inspect underlying tags + evidence"
|
title="Click to inspect underlying tags + evidence"
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '160px 1fr 60px',
|
gridTemplateColumns: 'minmax(280px, 2fr) 1fr 60px',
|
||||||
gap: 8,
|
gap: 10,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: '2px 4px',
|
padding: '2px 4px',
|
||||||
@@ -204,7 +213,15 @@ const TechniqueBar: React.FC<{
|
|||||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(155,135,245,0.06)'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(155,135,245,0.06)'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||||
>
|
>
|
||||||
<span className="matrix-text">{label}</span>
|
<span
|
||||||
|
className="matrix-text"
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
>{label}</span>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 6,
|
height: 6,
|
||||||
|
|||||||
58
tests/ttp/test_attack_catalog.py
Normal file
58
tests/ttp/test_attack_catalog.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""ATT&CK technique-name catalogue covers every ID emitted by the rule pack.
|
||||||
|
|
||||||
|
A rule author who adds a new technique to ``rules/ttp/`` must also
|
||||||
|
update ``decnet/ttp/attack_catalog.py`` in the same commit. Without
|
||||||
|
this test the UI silently falls back to the bare ID for unknown
|
||||||
|
techniques.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from decnet.ttp.attack_catalog import TECHNIQUE_NAMES, technique_name
|
||||||
|
|
||||||
|
|
||||||
|
_RULES_DIR = Path(__file__).resolve().parents[2] / "rules" / "ttp"
|
||||||
|
|
||||||
|
|
||||||
|
def _all_technique_ids_in_rule_pack() -> set[str]:
|
||||||
|
ids: set[str] = set()
|
||||||
|
for path in sorted(_RULES_DIR.glob("R*.yaml")):
|
||||||
|
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
for emit in doc.get("emits", []) or []:
|
||||||
|
tid = emit.get("technique_id")
|
||||||
|
if isinstance(tid, str) and tid:
|
||||||
|
ids.add(tid)
|
||||||
|
sub = emit.get("sub_technique_id")
|
||||||
|
if isinstance(sub, str) and sub:
|
||||||
|
ids.add(sub)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_rule_pack_technique_has_a_catalogue_entry() -> None:
|
||||||
|
rule_ids = _all_technique_ids_in_rule_pack()
|
||||||
|
missing = sorted(rule_ids - TECHNIQUE_NAMES.keys())
|
||||||
|
assert not missing, (
|
||||||
|
"rules/ttp/ emits techniques absent from "
|
||||||
|
"decnet/ttp/attack_catalog.py: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_technique_name_returns_canonical_label() -> None:
|
||||||
|
assert technique_name("T1595") == "Active Scanning"
|
||||||
|
assert technique_name("T1595.002") == "Active Scanning: Vulnerability Scanning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_technique_name_unknown_id_returns_none() -> None:
|
||||||
|
assert technique_name("T9999") is None
|
||||||
|
assert technique_name(None) is None
|
||||||
|
assert technique_name("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalogue_entries_are_non_empty_strings() -> None:
|
||||||
|
for tid, name in TECHNIQUE_NAMES.items():
|
||||||
|
assert isinstance(name, str) and name.strip(), (
|
||||||
|
f"empty / non-string name for {tid!r}: {name!r}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user