Skip to content

Commit be9397f

Browse files
committed
Add Phase 2 ATT&CK Navigator layer export endpoint and dashboard integration
- app.py: GET /api/navigator-layer maps current alert categories to MITRE technique IDs (brute_force→T1110.001, port_scan→T1046, malware→T1059, phishing→T1566, anomaly→T1078) and returns a Navigator 4.9 layer JSON scored by highest observed alert severity - dashboard.html: "Export ATT&CK Layer" button right-aligned in the quick-filter row - dashboard.js: downloadNavigatorLayer() fetches the layer and triggers a browser file download as navigator_layer.json - 18 new tests covering auth boundary, JSON structure, all fixture category→technique mappings, and per-severity scores
1 parent e2e89bb commit be9397f

4 files changed

Lines changed: 209 additions & 1 deletion

File tree

app.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,23 @@ def load_user(user_id):
187187
# Severity ordering used for queue sorting (CRITICAL first).
188188
SEVERITY_RANK = {"CRITICAL": 1, "HIGH": 2, "MEDIUM": 3, "LOW": 4}
189189

190+
# ATT&CK Navigator: map SOC alert categories to MITRE technique IDs.
191+
_CATEGORY_MITRE: dict[str, str] = {
192+
"brute_force": "T1110.001",
193+
"port_scan": "T1046",
194+
"malware": "T1059",
195+
"phishing": "T1566",
196+
"anomaly": "T1078",
197+
}
198+
199+
# Score used in the Navigator layer; higher severity → higher heat.
200+
_NAVIGATOR_SCORE: dict[str, int] = {
201+
"CRITICAL": 100,
202+
"HIGH": 75,
203+
"MEDIUM": 50,
204+
"LOW": 25,
205+
}
206+
190207
# Map a classify action to the resulting alert status.
191208
ACTION_TO_STATUS = {
192209
"classify_tp": "true_positive",
@@ -848,6 +865,68 @@ def api_add_note(alert_id):
848865
return jsonify({"id": row["id"], "created_at": row["created_at"].isoformat()}), 201
849866

850867

868+
# --------------------------------------------------------------------------- #
869+
# ATT&CK Navigator layer export
870+
# --------------------------------------------------------------------------- #
871+
@app.route("/api/navigator-layer")
872+
@login_required
873+
def api_navigator_layer():
874+
"""Return the current alert corpus as an ATT&CK Navigator 4.9 layer JSON.
875+
876+
Groups alerts by category → MITRE technique, scoring each entry by the
877+
highest observed severity. The JSON can be imported directly at
878+
https://mitre-attack.github.io/attack-navigator/.
879+
"""
880+
with get_conn() as conn, conn.cursor() as cur:
881+
cur.execute(
882+
"SELECT category, severity, count(*) AS c "
883+
"FROM alerts GROUP BY category, severity"
884+
)
885+
rows = cur.fetchall()
886+
887+
techniques: dict[str, dict] = {}
888+
total = 0
889+
for row in rows:
890+
cat = row["category"]
891+
sev = row["severity"]
892+
count = row["c"]
893+
total += count
894+
tid = _CATEGORY_MITRE.get(cat)
895+
if not tid:
896+
continue
897+
score = _NAVIGATOR_SCORE.get(sev, 25)
898+
if tid not in techniques or score > techniques[tid]["score"]:
899+
techniques[tid] = {
900+
"techniqueID": tid,
901+
"score": score,
902+
"color": "",
903+
"comment": f"{cat}: {count} alert(s), {sev}",
904+
"enabled": True,
905+
}
906+
907+
layer = {
908+
"name": "SOC Dashboard Alert Coverage",
909+
"versions": {"attack": "14", "navigator": "4.9", "layer": "4.5"},
910+
"domain": "enterprise-attack",
911+
"description": f"ATT&CK coverage derived from {total} SOC alert(s).",
912+
"techniques": list(techniques.values()),
913+
"gradient": {
914+
"colors": ["#ffd700", "#ff6600", "#cc0000"],
915+
"minValue": 0,
916+
"maxValue": 100,
917+
},
918+
"legendItems": [
919+
{"label": "CRITICAL (100)", "color": "#cc0000"},
920+
{"label": "HIGH (75)", "color": "#ff6600"},
921+
{"label": "MEDIUM (50)", "color": "#ffd700"},
922+
{"label": "LOW (25)", "color": "#33cc66"},
923+
],
924+
"showTacticRowBackground": False,
925+
"selectTechniquesAcrossTactics": True,
926+
}
927+
return jsonify(layer)
928+
929+
851930
# --------------------------------------------------------------------------- #
852931
# Server-Sent Events
853932
# --------------------------------------------------------------------------- #

static/dashboard.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,20 @@ function initSSE() {
407407
};
408408
}
409409

410+
// ----- ATT&CK Navigator export --------------------------------------------- //
411+
function downloadNavigatorLayer() {
412+
fetch("/api/navigator-layer")
413+
.then(r => r.blob())
414+
.then(blob => {
415+
const url = URL.createObjectURL(blob);
416+
const a = document.createElement("a");
417+
a.href = url;
418+
a.download = "navigator_layer.json";
419+
a.click();
420+
URL.revokeObjectURL(url);
421+
});
422+
}
423+
410424
// ----- boot ---------------------------------------------------------------- //
411425
document.addEventListener("DOMContentLoaded", () => {
412426
initAnalystInput();

templates/dashboard.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,14 @@
5656
</div>
5757
</div>
5858

59-
<!-- Quick filter presets -->
59+
<!-- Quick filter presets + export controls -->
6060
<div class="d-flex flex-wrap gap-2 mb-3" id="preset-buttons">
6161
<span class="text-secondary small align-self-center me-1">Quick filters:</span>
6262
<button class="btn btn-sm btn-outline-info" onclick="applyPreset('my-queue')">My Queue</button>
6363
<button class="btn btn-sm btn-outline-danger" onclick="applyPreset('critical-today')">Critical Today</button>
6464
<button class="btn btn-sm btn-outline-warning" onclick="applyPreset('escalated')">Escalated</button>
6565
<button class="btn btn-sm btn-outline-secondary" onclick="applyPreset('all-open')">All Open</button>
66+
<button class="btn btn-sm btn-outline-light ms-auto" onclick="downloadNavigatorLayer()" title="Export ATT&amp;CK Navigator layer JSON">Export ATT&amp;CK Layer</button>
6667
</div>
6768

6869
<!-- Filter bar: drives the charts and the alert queue below -->

tests/test_navigator.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Tests for GET /api/navigator-layer.
2+
3+
Uses the same `client` and `anon_client` fixtures from conftest.py.
4+
The FIXTURES data (4 alerts: brute_force/CRITICAL, malware/CRITICAL,
5+
anomaly/LOW, phishing/HIGH) drives all assertions about technique IDs and scores.
6+
"""
7+
import json
8+
9+
10+
def test_navigator_layer_requires_auth(anon_client):
11+
rv = anon_client.get("/api/navigator-layer")
12+
assert rv.status_code in (401, 302)
13+
14+
15+
def test_navigator_layer_authenticated_returns_200(client):
16+
rv = client.get("/api/navigator-layer")
17+
assert rv.status_code == 200
18+
19+
20+
def test_navigator_layer_content_type_json(client):
21+
rv = client.get("/api/navigator-layer")
22+
assert rv.content_type.startswith("application/json")
23+
24+
25+
def test_navigator_layer_domain(client):
26+
data = client.get("/api/navigator-layer").get_json()
27+
assert data["domain"] == "enterprise-attack"
28+
29+
30+
def test_navigator_layer_version_fields(client):
31+
data = client.get("/api/navigator-layer").get_json()
32+
assert data["versions"]["layer"] == "4.5"
33+
assert data["versions"]["navigator"] == "4.9"
34+
assert data["versions"]["attack"] == "14"
35+
36+
37+
def test_navigator_layer_has_techniques_list(client):
38+
data = client.get("/api/navigator-layer").get_json()
39+
assert isinstance(data["techniques"], list)
40+
41+
42+
def test_navigator_layer_brute_force_maps_to_t1110(client):
43+
data = client.get("/api/navigator-layer").get_json()
44+
ids = {t["techniqueID"] for t in data["techniques"]}
45+
assert "T1110.001" in ids
46+
47+
48+
def test_navigator_layer_brute_force_critical_scores_100(client):
49+
data = client.get("/api/navigator-layer").get_json()
50+
bf = next(t for t in data["techniques"] if t["techniqueID"] == "T1110.001")
51+
assert bf["score"] == 100
52+
53+
54+
def test_navigator_layer_phishing_maps_to_t1566(client):
55+
data = client.get("/api/navigator-layer").get_json()
56+
ids = {t["techniqueID"] for t in data["techniques"]}
57+
assert "T1566" in ids
58+
59+
60+
def test_navigator_layer_phishing_high_scores_75(client):
61+
data = client.get("/api/navigator-layer").get_json()
62+
phish = next(t for t in data["techniques"] if t["techniqueID"] == "T1566")
63+
assert phish["score"] == 75
64+
65+
66+
def test_navigator_layer_anomaly_maps_to_t1078(client):
67+
data = client.get("/api/navigator-layer").get_json()
68+
ids = {t["techniqueID"] for t in data["techniques"]}
69+
assert "T1078" in ids
70+
71+
72+
def test_navigator_layer_anomaly_low_scores_25(client):
73+
data = client.get("/api/navigator-layer").get_json()
74+
anomly = next(t for t in data["techniques"] if t["techniqueID"] == "T1078")
75+
assert anomly["score"] == 25
76+
77+
78+
def test_navigator_layer_malware_maps_to_t1059(client):
79+
data = client.get("/api/navigator-layer").get_json()
80+
ids = {t["techniqueID"] for t in data["techniques"]}
81+
assert "T1059" in ids
82+
83+
84+
def test_navigator_layer_technique_has_required_keys(client):
85+
data = client.get("/api/navigator-layer").get_json()
86+
for t in data["techniques"]:
87+
assert "techniqueID" in t
88+
assert "score" in t
89+
assert "enabled" in t
90+
assert "comment" in t
91+
92+
93+
def test_navigator_layer_has_gradient(client):
94+
data = client.get("/api/navigator-layer").get_json()
95+
g = data["gradient"]
96+
assert g["minValue"] == 0
97+
assert g["maxValue"] == 100
98+
99+
100+
def test_navigator_layer_has_legend_items(client):
101+
data = client.get("/api/navigator-layer").get_json()
102+
assert len(data["legendItems"]) == 4
103+
104+
105+
def test_navigator_layer_description_mentions_alert_count(client):
106+
data = client.get("/api/navigator-layer").get_json()
107+
# 4 alerts in fixtures
108+
assert "4" in data["description"]
109+
110+
111+
def test_navigator_layer_no_duplicate_technique_ids(client):
112+
data = client.get("/api/navigator-layer").get_json()
113+
ids = [t["techniqueID"] for t in data["techniques"]]
114+
assert len(ids) == len(set(ids))

0 commit comments

Comments
 (0)