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:
@@ -250,7 +250,9 @@ class TechniqueRollupRow(BaseModel):
|
||||
across the fleet with a count and a most-recent-seen timestamp."""
|
||||
|
||||
technique_id: str
|
||||
technique_name: Optional[str] = None
|
||||
sub_technique_id: Optional[str] = None
|
||||
sub_technique_name: Optional[str] = None
|
||||
tactic: str
|
||||
count: int
|
||||
last_seen: datetime
|
||||
@@ -259,10 +261,18 @@ class TechniqueRollupRow(BaseModel):
|
||||
class IdentityTechniqueRow(BaseModel):
|
||||
"""One row of the by-identity / by-attacker / by-session endpoints —
|
||||
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_name: Optional[str] = None
|
||||
sub_technique_id: Optional[str] = None
|
||||
sub_technique_name: Optional[str] = None
|
||||
tactic: str
|
||||
count: int
|
||||
first_seen: datetime
|
||||
@@ -287,7 +297,9 @@ class TTPTagDetailRow(BaseModel):
|
||||
decky_id: Optional[str] = None
|
||||
tactic: str
|
||||
technique_id: str
|
||||
technique_name: Optional[str] = None
|
||||
sub_technique_id: Optional[str] = None
|
||||
sub_technique_name: Optional[str] = None
|
||||
confidence: float
|
||||
rule_id: str
|
||||
rule_version: int
|
||||
@@ -301,7 +313,9 @@ class CampaignTechniqueRow(BaseModel):
|
||||
across at least one Identity rolled up into the campaign."""
|
||||
|
||||
technique_id: str
|
||||
technique_name: Optional[str] = None
|
||||
sub_technique_id: Optional[str] = None
|
||||
sub_technique_name: Optional[str] = None
|
||||
tactic: str
|
||||
count: int
|
||||
identity_count: int
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import Any
|
||||
from sqlalchemy import func, select
|
||||
from sqlmodel import col
|
||||
|
||||
from decnet.ttp.attack_catalog import technique_name as _technique_name
|
||||
from decnet.web.db.models import (
|
||||
Attacker,
|
||||
AttackerIdentity,
|
||||
@@ -113,7 +114,9 @@ class TTPMixin(_MixinBase):
|
||||
return [
|
||||
IdentityTechniqueRow(
|
||||
technique_id=r.technique_id,
|
||||
technique_name=_technique_name(r.technique_id),
|
||||
sub_technique_id=r.sub_technique_id,
|
||||
sub_technique_name=_technique_name(r.sub_technique_id),
|
||||
tactic=r.tactic,
|
||||
count=r.count,
|
||||
first_seen=r.first_seen,
|
||||
@@ -149,7 +152,9 @@ class TTPMixin(_MixinBase):
|
||||
return [
|
||||
IdentityTechniqueRow(
|
||||
technique_id=r.technique_id,
|
||||
technique_name=_technique_name(r.technique_id),
|
||||
sub_technique_id=r.sub_technique_id,
|
||||
sub_technique_name=_technique_name(r.sub_technique_id),
|
||||
tactic=r.tactic,
|
||||
count=r.count,
|
||||
first_seen=r.first_seen,
|
||||
@@ -191,7 +196,9 @@ class TTPMixin(_MixinBase):
|
||||
return [
|
||||
CampaignTechniqueRow(
|
||||
technique_id=r.technique_id,
|
||||
technique_name=_technique_name(r.technique_id),
|
||||
sub_technique_id=r.sub_technique_id,
|
||||
sub_technique_name=_technique_name(r.sub_technique_id),
|
||||
tactic=r.tactic,
|
||||
count=r.count,
|
||||
identity_count=r.identity_count,
|
||||
@@ -225,7 +232,9 @@ class TTPMixin(_MixinBase):
|
||||
return [
|
||||
IdentityTechniqueRow(
|
||||
technique_id=r.technique_id,
|
||||
technique_name=_technique_name(r.technique_id),
|
||||
sub_technique_id=r.sub_technique_id,
|
||||
sub_technique_name=_technique_name(r.sub_technique_id),
|
||||
tactic=r.tactic,
|
||||
count=r.count,
|
||||
first_seen=r.first_seen,
|
||||
@@ -396,7 +405,9 @@ class TTPMixin(_MixinBase):
|
||||
return [
|
||||
TechniqueRollupRow(
|
||||
technique_id=r.technique_id,
|
||||
technique_name=_technique_name(r.technique_id),
|
||||
sub_technique_id=r.sub_technique_id,
|
||||
sub_technique_name=_technique_name(r.sub_technique_id),
|
||||
tactic=r.tactic,
|
||||
count=r.count,
|
||||
last_seen=r.last_seen,
|
||||
|
||||
@@ -25,6 +25,7 @@ from typing import Any, Literal
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
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.dependencies import repo, require_viewer
|
||||
|
||||
@@ -71,4 +72,11 @@ async def api_ttp_tag_details(
|
||||
sub_technique_id=sub_technique_id,
|
||||
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
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user