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:
@@ -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():
|
||||
|
||||
@@ -358,3 +358,32 @@ def test_search_filter_on_name(app, admin_credentials, fixture_bundle_path):
|
||||
assert r.status_code == 200
|
||||
ext_ids = [t["external_id"] for t in r.get_json()["items"]]
|
||||
assert ext_ids == ["T1078"]
|
||||
|
||||
|
||||
def test_matrix_endpoint_returns_nested_grid(app, admin_credentials, fixture_bundle_path):
|
||||
"""GET /mitre/matrix returns the flat tactic→technique→subtechnique grid."""
|
||||
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
|
||||
with app.test_client() as c:
|
||||
access = _login(c, admin_credentials["email"], admin_credentials["password"])
|
||||
r = c.get("/api/v1/mitre/matrix", headers={"Authorization": f"Bearer {access}"})
|
||||
assert r.status_code == 200
|
||||
body = r.get_json()
|
||||
tactics = body["tactics"]
|
||||
assert {t["external_id"] for t in tactics} == {"TA0001", "TA0002"}
|
||||
|
||||
# TA0001 has T1059 (multi-tactic) + T1078; T1059 carries its sub.
|
||||
ta0001 = next(t for t in tactics if t["external_id"] == "TA0001")
|
||||
techs = {t["external_id"]: t for t in ta0001["techniques"]}
|
||||
assert set(techs.keys()) == {"T1059", "T1078"}
|
||||
assert techs["T1059"]["subtechniques"][0]["external_id"] == "T1059.001"
|
||||
assert techs["T1078"]["subtechniques"] == []
|
||||
|
||||
# TA0002 only carries T1059 (no T1078).
|
||||
ta0002 = next(t for t in tactics if t["external_id"] == "TA0002")
|
||||
assert [t["external_id"] for t in ta0002["techniques"]] == ["T1059"]
|
||||
|
||||
|
||||
def test_matrix_endpoint_requires_auth(app, fixture_bundle_path):
|
||||
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
|
||||
with app.test_client() as c:
|
||||
assert c.get("/api/v1/mitre/matrix").status_code == 401
|
||||
|
||||
Reference in New Issue
Block a user