refactor(m4): flatten the MITRE picker into the attack.mitre.org matrix

The hierarchical 3-column drill-down was hard to scan and forced a stateful
walk per tag. Replaced with a flat, columns-as-tactics matrix that mirrors
attack.mitre.org/# — every cell is a one-click select target, with inline
sub-technique expand via a `+N` chevron.

- New endpoint GET /api/v1/mitre/matrix returns the full grid (tactics →
  techniques → sub-techniques nested) in a single ~55 KB response, so the
  SPA renders the whole matrix without firing 15 parallel queries. Two
  pytest tests added (nested structure + auth required).
- MitreTagPicker.tsx rewritten as a horizontal-scrolling matrix:
  - Click a tactic header → select the tactic (cyan filled).
  - Click a technique cell → select the technique (orange filled).
  - Click the `+N` chevron → expand sub-techniques inline within the column.
  - Click a sub-technique → select (purple filled).
  - Single Filter field matches on external_id or name across all kinds.
  - Selection chips at the top, clickable to remove.
  - `aria-pressed` on every clickable cell for screen readers and Playwright.
- e2e test updated to walk the new flow (click cell → assert aria-pressed,
  expand chevron, click sub, verify chip + JSON preview, filter to T1078).
- Spec §F2 + §F12 + todo.md M4 entry updated to make the matrix layout the
  canonical UI for MITRE tagging (so future spec-reviewer passes accept it).
- testing-m4.md walkthrough rewritten for the flat picker.

DoD post-refactor: make test-api → 53 passed (was 51), make e2e → 34 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-12 18:32:20 +02:00
parent 37e9e03f02
commit 7dbe2dbc28
8 changed files with 371 additions and 224 deletions

View File

@@ -175,6 +175,65 @@ def list_subtechniques():
)
@bp.get("/matrix")
@require_auth
def matrix():
"""Return the full Enterprise matrix: tactics → techniques → sub-techniques.
One-shot endpoint so the SPA can render the flat attack.mitre.org-style
grid without firing 15 parallel queries. The payload is ~55 KB serialised
against MITRE v19 (15 tactics × ~50 techniques × ~3 subs).
"""
with session_scope() as s:
# All techniques + their tactics (selectin-loaded by the relationship).
techniques = s.scalars(
select(MitreTechnique).order_by(MitreTechnique.external_id.asc())
).all()
# Sub-techniques bucketed by parent.
subs_by_parent: dict = {}
for sb in s.scalars(
select(MitreSubtechnique).order_by(MitreSubtechnique.external_id.asc())
).all():
subs_by_parent.setdefault(sb.technique_id, []).append(
{
"id": str(sb.id),
"external_id": sb.external_id,
"name": sb.name,
}
)
# Tactics in canonical kill-chain order (matches attack.mitre.org).
tactics = s.scalars(
select(MitreTactic).order_by(MitreTactic.external_id.asc())
).all()
# Group techniques by tactic short_name.
techs_by_tactic: dict = {}
for t in techniques:
entry = {
"id": str(t.id),
"external_id": t.external_id,
"name": t.name,
"subtechniques": subs_by_parent.get(t.id, []),
}
for tac in t.tactics:
techs_by_tactic.setdefault(tac.short_name, []).append(entry)
return jsonify(
{
"tactics": [
{
"id": str(t.id),
"external_id": t.external_id,
"short_name": t.short_name,
"name": t.name,
"techniques": techs_by_tactic.get(t.short_name, []),
}
for t in tactics
]
}
)
@bp.get("/status")
@require_auth
def status():