attack-mapper 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- attack_mapper/__init__.py +30 -0
- attack_mapper/attack_loader.py +140 -0
- attack_mapper/build_db.py +44 -0
- attack_mapper/cli.py +94 -0
- attack_mapper/coverage.py +190 -0
- attack_mapper/data/attack_db.json +6545 -0
- attack_mapper/plugins.py +72 -0
- attack_mapper/renderers.py +652 -0
- attack_mapper/sigma_parser.py +128 -0
- attack_mapper-0.4.0.dist-info/METADATA +191 -0
- attack_mapper-0.4.0.dist-info/RECORD +15 -0
- attack_mapper-0.4.0.dist-info/WHEEL +5 -0
- attack_mapper-0.4.0.dist-info/entry_points.txt +2 -0
- attack_mapper-0.4.0.dist-info/licenses/LICENSE +21 -0
- attack_mapper-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"""Render a CoverageReport as terminal text or a standalone HTML page.
|
|
2
|
+
|
|
3
|
+
The HTML renderer offers three visual *styles* (see ``render_html`` ``style``
|
|
4
|
+
argument): ``matrix`` (default card grid), ``rows`` (compact tactic rows), and
|
|
5
|
+
``heat`` (dense heatmap). All styles share the same data and legend, show the
|
|
6
|
+
active include/ignore scope, and include interactive controls:
|
|
7
|
+
|
|
8
|
+
* a **search box** (works in every style) that dims non-matching techniques,
|
|
9
|
+
* **collapsible tactic areas** (click the header / label),
|
|
10
|
+
* an **eye toggle** to hide/show a whole tactic area — the button stays visible
|
|
11
|
+
so you can always bring the area back.
|
|
12
|
+
|
|
13
|
+
Interactivity is plain JavaScript embedded in the file, so the report is fully
|
|
14
|
+
self-contained (no network, no dependencies).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from typing import Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from .coverage import CoverageReport
|
|
23
|
+
|
|
24
|
+
STYLES = ("matrix", "rows", "heat", "report")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_terminal(report: CoverageReport) -> str:
|
|
28
|
+
"""Human-friendly terminal output with per-tactic coverage bars."""
|
|
29
|
+
lines: List[str] = []
|
|
30
|
+
db = report.db
|
|
31
|
+
pct = report.coverage_ratio * 100
|
|
32
|
+
|
|
33
|
+
lines.append("=" * 60)
|
|
34
|
+
lines.append(" MITRE ATT&CK DETECTION COVERAGE")
|
|
35
|
+
lines.append("=" * 60)
|
|
36
|
+
n_scoped = len(report.covered_in_scope)
|
|
37
|
+
lines.append(
|
|
38
|
+
f" Covered techniques : {n_scoped} / {report.total_techniques}"
|
|
39
|
+
f" ({pct:.1f}%)"
|
|
40
|
+
)
|
|
41
|
+
if n_scoped != len(report.covered):
|
|
42
|
+
lines.append(
|
|
43
|
+
f" Covered (any scope): {len(report.covered)}"
|
|
44
|
+
" (some detections fall outside the current scope)"
|
|
45
|
+
)
|
|
46
|
+
lines.append(f" Rules analyzed : {report.rule_count}")
|
|
47
|
+
lines.append(f" ATT&CK version : {db.version}")
|
|
48
|
+
if report.include:
|
|
49
|
+
lines.append(f" Scope (include) : {', '.join(sorted(report.include))}")
|
|
50
|
+
if report.ignore:
|
|
51
|
+
lines.append(f" Ignored : {', '.join(sorted(report.ignore))}")
|
|
52
|
+
if report.unknown:
|
|
53
|
+
lines.append(f" Unknown IDs (ignored): {', '.join(report.unknown)}")
|
|
54
|
+
if report.unmapped_rules:
|
|
55
|
+
lines.append(f" Rules w/o ATT&CK : {len(report.unmapped_rules)}")
|
|
56
|
+
lines.append("")
|
|
57
|
+
|
|
58
|
+
lines.append(" Per-tactic coverage")
|
|
59
|
+
lines.append(" " + "-" * 54)
|
|
60
|
+
for tactic, info in report.tactics_coverage().items():
|
|
61
|
+
bar_len = 20
|
|
62
|
+
filled = int(round(info["ratio"] * bar_len))
|
|
63
|
+
bar = "#" * filled + "-" * (bar_len - filled)
|
|
64
|
+
name = (info["name"] or tactic)[:22].ljust(22)
|
|
65
|
+
lines.append(
|
|
66
|
+
f" {name} [{bar}] {info['covered']:>3}/{info['total']:<3} {info['ratio']*100:5.1f}%"
|
|
67
|
+
)
|
|
68
|
+
lines.append("")
|
|
69
|
+
|
|
70
|
+
if report.covered_techniques:
|
|
71
|
+
lines.append(" Covered techniques")
|
|
72
|
+
lines.append(" " + "-" * 54)
|
|
73
|
+
for tid in report.covered_techniques:
|
|
74
|
+
name = db.technique_name(tid) or tid
|
|
75
|
+
rules = report.covered[tid]
|
|
76
|
+
lines.append(f" {tid:<9} {name[:40]:<40} x{len(rules)}")
|
|
77
|
+
lines.append("=" * 60)
|
|
78
|
+
return "\n".join(lines)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _scope_note(report: CoverageReport) -> str:
|
|
82
|
+
bits = []
|
|
83
|
+
if report.include:
|
|
84
|
+
bits.append(
|
|
85
|
+
f"Scope: only {len(report.in_scope_techniques)} in-scope techniques "
|
|
86
|
+
f"(filtered by {', '.join(sorted(report.include))})"
|
|
87
|
+
)
|
|
88
|
+
if report.ignore:
|
|
89
|
+
bits.append(
|
|
90
|
+
f"Ignored: {len(report.ignored)} techniques "
|
|
91
|
+
f"({', '.join(sorted(report.ignore))})"
|
|
92
|
+
)
|
|
93
|
+
if report.out_of_scope:
|
|
94
|
+
bits.append(f"Out of scope: {len(report.out_of_scope)} techniques hidden")
|
|
95
|
+
return " · ".join(bits)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _legend_html() -> str:
|
|
99
|
+
return (
|
|
100
|
+
'<div class="legend">'
|
|
101
|
+
'<span class="cov">Covered</span>'
|
|
102
|
+
'<span class="gap">Gap</span>'
|
|
103
|
+
'<span class="ign">Ignored</span>'
|
|
104
|
+
"</div>"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _cell(tid: str, name: str, state: str, url: str) -> str:
|
|
109
|
+
# data-tid / data-name let the search box match without parsing the DOM text.
|
|
110
|
+
# title shows "T1059.001 — PowerShell" as a hover tooltip.
|
|
111
|
+
safe = name.replace(chr(34), "")
|
|
112
|
+
tip = f"{tid} — {safe}"
|
|
113
|
+
return (
|
|
114
|
+
f'<li class="{state}" data-tid="{tid}" data-name="{safe}" title="{tip}">'
|
|
115
|
+
f'<a href="{url}" target="_blank">{tid}</a></li>'
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Shared JavaScript: search (dim non-matches), collapse areas, toggle (hide/show).
|
|
120
|
+
INTERACTIVE_JS = """
|
|
121
|
+
<script>
|
|
122
|
+
function setup(){
|
|
123
|
+
var q = document.getElementById('search');
|
|
124
|
+
var status = document.getElementById('searchstatus');
|
|
125
|
+
if(q){
|
|
126
|
+
q.addEventListener('input', function(){
|
|
127
|
+
var term = q.value.trim().toLowerCase();
|
|
128
|
+
var nodes = document.querySelectorAll('[data-tid], [data-tids]');
|
|
129
|
+
var hits = 0;
|
|
130
|
+
nodes.forEach(function(n){
|
|
131
|
+
var t = (n.getAttribute('data-tid')||'') + ' ' + (n.getAttribute('data-tids')||'') + ' ' + (n.getAttribute('data-name')||'');
|
|
132
|
+
t = t.toLowerCase();
|
|
133
|
+
var match = term === '' || t.indexOf(term) !== -1;
|
|
134
|
+
n.classList.toggle('dim', !match);
|
|
135
|
+
if(match && term !== '') hits++;
|
|
136
|
+
});
|
|
137
|
+
if(status) status.textContent = term === '' ? '' : (hits + ' match(es)');
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// collapse / expand the body of a tactic area
|
|
141
|
+
document.querySelectorAll('.collapse-trigger').forEach(function(h){
|
|
142
|
+
h.style.cursor = 'pointer';
|
|
143
|
+
h.addEventListener('click', function(){
|
|
144
|
+
var body = document.getElementById(h.getAttribute('data-target'));
|
|
145
|
+
if(!body) return;
|
|
146
|
+
var hidden = body.style.display === 'none';
|
|
147
|
+
body.style.display = hidden ? '' : 'none';
|
|
148
|
+
h.classList.toggle('collapsed', !hidden);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
// hide/show a whole tactic area; the eye button lives OUTSIDE the area so it
|
|
152
|
+
// always stays clickable and can restore the area.
|
|
153
|
+
document.querySelectorAll('[data-toggle]').forEach(function(btn){
|
|
154
|
+
btn.addEventListener('click', function(){
|
|
155
|
+
var area = document.getElementById(btn.getAttribute('data-toggle'));
|
|
156
|
+
if(!area) return;
|
|
157
|
+
var hidden = area.style.display === 'none';
|
|
158
|
+
area.style.display = hidden ? '' : 'none';
|
|
159
|
+
btn.textContent = hidden ? '👁' : '🚫'; // eye / no-entry
|
|
160
|
+
btn.classList.toggle('off', !hidden);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
// Export an ATT&CK Navigator layer JSON from the embedded coverage data.
|
|
164
|
+
var layerBtn = document.getElementById('exportlayer');
|
|
165
|
+
if (layerBtn) {
|
|
166
|
+
layerBtn.addEventListener('click', function () {
|
|
167
|
+
var data = JSON.parse(document.getElementById('layerdata').textContent);
|
|
168
|
+
var text = JSON.stringify(data, null, 2);
|
|
169
|
+
var a = document.createElement('a');
|
|
170
|
+
a.download = 'attack-coverage-layer.json';
|
|
171
|
+
document.body.appendChild(a);
|
|
172
|
+
try {
|
|
173
|
+
var blob = new Blob([text], { type: 'application/json' });
|
|
174
|
+
var url = URL.createObjectURL(blob);
|
|
175
|
+
a.href = url;
|
|
176
|
+
a.click();
|
|
177
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
// Fallback: data-URI works even if Blob/URL is unavailable.
|
|
180
|
+
a.href = 'data:application/json;charset=utf-8,' + encodeURIComponent(text);
|
|
181
|
+
a.click();
|
|
182
|
+
}
|
|
183
|
+
document.body.removeChild(a);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// Toggle the gaps/covered card between "what's missing" and "what I have".
|
|
187
|
+
// gaps-list starts visible, cov-list starts hidden.
|
|
188
|
+
var gapToggle = document.getElementById('gaptoggle');
|
|
189
|
+
if (gapToggle) {
|
|
190
|
+
gapToggle.addEventListener('click', function () {
|
|
191
|
+
var g = document.getElementById('gaps-list');
|
|
192
|
+
var c = document.getElementById('cov-list');
|
|
193
|
+
var gh = document.getElementById('gaps-hint');
|
|
194
|
+
var ch = document.getElementById('cov-hint');
|
|
195
|
+
var showingCov = c.style.display === ''; // cov currently visible?
|
|
196
|
+
g.style.display = showingCov ? '' : 'none';
|
|
197
|
+
c.style.display = showingCov ? 'none' : '';
|
|
198
|
+
gh.style.display = showingCov ? '' : 'none';
|
|
199
|
+
ch.style.display = showingCov ? 'none' : '';
|
|
200
|
+
gapToggle.innerHTML = showingCov
|
|
201
|
+
? 'show: what I have ▸ what’s missing'
|
|
202
|
+
: 'show: what’s missing ▸ what I have';
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// Sync the top scrollbar with the horizontally scrolling column rack.
|
|
206
|
+
var ts = document.getElementById('topscroll');
|
|
207
|
+
var rack = document.getElementById('rack');
|
|
208
|
+
if (ts && rack) {
|
|
209
|
+
var spacer = ts.firstElementChild;
|
|
210
|
+
var size = function(){ spacer.style.width = rack.scrollWidth + 'px'; };
|
|
211
|
+
size();
|
|
212
|
+
window.addEventListener('resize', size);
|
|
213
|
+
var lock = false;
|
|
214
|
+
ts.addEventListener('scroll', function(){
|
|
215
|
+
if (lock) return; lock = true; rack.scrollLeft = ts.scrollLeft; lock = false;
|
|
216
|
+
});
|
|
217
|
+
rack.addEventListener('scroll', function(){
|
|
218
|
+
if (lock) return; lock = true; ts.scrollLeft = rack.scrollLeft; lock = false;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
document.addEventListener('DOMContentLoaded', setup);
|
|
224
|
+
</script>
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def render_html(
|
|
229
|
+
report: CoverageReport,
|
|
230
|
+
out_path: str,
|
|
231
|
+
style: str = "matrix",
|
|
232
|
+
) -> str:
|
|
233
|
+
"""Write a self-contained HTML report and return its path.
|
|
234
|
+
|
|
235
|
+
``style`` is one of: ``matrix``, ``rows``, ``heat``.
|
|
236
|
+
"""
|
|
237
|
+
if style not in STYLES:
|
|
238
|
+
raise ValueError(f"style must be one of {STYLES}, got {style!r}")
|
|
239
|
+
|
|
240
|
+
db = report.db
|
|
241
|
+
tactics = report.tactics_coverage()
|
|
242
|
+
scope = _scope_note(report)
|
|
243
|
+
|
|
244
|
+
# Single source of truth: report.covered already includes inherited parents.
|
|
245
|
+
# state: 'cov' if covered, 'ign' if ignored, else 'gap'.
|
|
246
|
+
# A technique appears under EVERY tactic it belongs to — exactly like the
|
|
247
|
+
# official ATT&CK matrix — so each tactic's chips match its covered/total
|
|
248
|
+
# counts from tactics_coverage().
|
|
249
|
+
in_scope = set(report.in_scope_techniques)
|
|
250
|
+
by_tactic: Dict[str, list] = {t: [] for t in tactics}
|
|
251
|
+
for tid, tech in db.techniques.items():
|
|
252
|
+
if tid not in in_scope:
|
|
253
|
+
continue
|
|
254
|
+
if tid in report.covered:
|
|
255
|
+
state = "cov"
|
|
256
|
+
elif tid in report.ignored:
|
|
257
|
+
state = "ign"
|
|
258
|
+
else:
|
|
259
|
+
state = "gap"
|
|
260
|
+
url = tech.get("url", "#")
|
|
261
|
+
for tactic in tech.get("tactics", []):
|
|
262
|
+
by_tactic.setdefault(tactic, []).append((tid, tech["name"], state, url))
|
|
263
|
+
|
|
264
|
+
base_css = """
|
|
265
|
+
body{font-family:system-ui,Arial,sans-serif;background:#0f1117;color:#e6e6e6;margin:0;padding:24px}
|
|
266
|
+
h1{font-size:20px;margin:0 0 4px}
|
|
267
|
+
.summary{color:#9aa;margin:0 0 14px}
|
|
268
|
+
.controls{display:flex;align-items:center;gap:12px;margin:0 0 18px;flex-wrap:wrap}
|
|
269
|
+
#search{padding:10px 14px;border-radius:10px;border:1px solid #2a2f3a;
|
|
270
|
+
background:#161a22;color:#e6e6e6;font-size:14px;min-width:280px;outline:none;
|
|
271
|
+
transition:border-color .15s, box-shadow .15s}
|
|
272
|
+
#search::placeholder{color:#6b7280}
|
|
273
|
+
#search:focus{border-color:#27ae60;box-shadow:0 0 0 3px rgba(39,174,96,.25)}
|
|
274
|
+
#searchstatus{color:#9aa;font-size:12px}
|
|
275
|
+
.legend{margin:0;font-size:12px;color:#9aa}
|
|
276
|
+
.legend span{padding:3px 9px;border-radius:4px;margin-right:8px;font-weight:600}
|
|
277
|
+
li.cov, .cov{background:#1f6f3f;color:#dfffe9}
|
|
278
|
+
li.gap, .gap{background:#3a2030;color:#ffb3c8}
|
|
279
|
+
li.ign, .ign{background:#2c2c2c;color:#9aa}
|
|
280
|
+
a{text-decoration:none;color:inherit}
|
|
281
|
+
.dim{opacity:.10}
|
|
282
|
+
.collapsed{opacity:.6}
|
|
283
|
+
.eye{font-size:14px;background:rgba(255,255,255,.10);border:1px solid #2a2f3a;
|
|
284
|
+
border-radius:6px;cursor:pointer;padding:3px 8px;line-height:1}
|
|
285
|
+
.eye.off{background:#3a2030;border-color:#5a2a3f}
|
|
286
|
+
.exportbtn{font-size:13px;font-weight:600;color:#dfffe9;cursor:pointer;
|
|
287
|
+
background:linear-gradient(90deg,#27ae60,#2ecc71);border:none;border-radius:8px;
|
|
288
|
+
padding:9px 14px;line-height:1;box-shadow:0 1px 4px rgba(39,174,96,.35);
|
|
289
|
+
transition:transform .12s, box-shadow .12s}
|
|
290
|
+
.exportbtn:hover{transform:translateY(-1px);box-shadow:0 3px 10px rgba(39,174,96,.45)}
|
|
291
|
+
.exportbtn:active{transform:translateY(0)}
|
|
292
|
+
.gaps-card{background:#1b1f2a;border:1px solid #2a2f3a;border-radius:10px;
|
|
293
|
+
padding:14px 16px;margin:0 0 18px;max-width:560px}
|
|
294
|
+
.gaps-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
|
|
295
|
+
.gaps-title{font-weight:700;font-size:14px;color:#e6e6e6}
|
|
296
|
+
.gaptoggle{font-size:11px;color:#cdd;cursor:pointer;background:#161a22;
|
|
297
|
+
border:1px solid #2a2f3a;border-radius:6px;padding:5px 9px;line-height:1;
|
|
298
|
+
transition:border-color .15s, color .15s}
|
|
299
|
+
.gaptoggle:hover{border-color:#27ae60;color:#dfffe9}
|
|
300
|
+
.gaps-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px}
|
|
301
|
+
.gaps-list li{display:flex;align-items:center;gap:10px;font-size:12px}
|
|
302
|
+
.gname{width:150px;color:#cdd;flex:0 0 auto}
|
|
303
|
+
.gbar{flex:1;height:8px;background:#2a2f3a;border-radius:5px;overflow:hidden}
|
|
304
|
+
.gbar > i{display:block;height:100%}
|
|
305
|
+
.gcnt{flex:0 0 auto;color:#9aa;width:80px;text-align:right}
|
|
306
|
+
.gaps-hint{margin-top:10px;font-size:11px;color:#6b7280}
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
if style == "matrix":
|
|
310
|
+
css = base_css + """
|
|
311
|
+
.matrix{display:flex;flex-wrap:wrap;gap:12px}
|
|
312
|
+
.tactic-wrap{display:flex;flex-direction:column;gap:4px}
|
|
313
|
+
.tactic{background:#1b1f2a;border:1px solid #2a2f3a;border-radius:8px;overflow:hidden;width:230px}
|
|
314
|
+
.thead{padding:8px;font-weight:700;color:#fff;font-size:13px;display:flex;justify-content:space-between;align-items:center;gap:6px}
|
|
315
|
+
.thead .tname{flex:1}
|
|
316
|
+
.cnt{font-weight:400;opacity:.9}
|
|
317
|
+
.cells{list-style:none;margin:0;padding:8px;display:flex;flex-wrap:wrap;gap:4px}
|
|
318
|
+
.cells li{font-size:11px;padding:2px 5px;border-radius:4px}
|
|
319
|
+
"""
|
|
320
|
+
blocks = []
|
|
321
|
+
for tactic, info in tactics.items():
|
|
322
|
+
ratio = info["ratio"] * 100
|
|
323
|
+
color = (
|
|
324
|
+
"#c0392b" if ratio < 25 else "#e67e22" if ratio < 60
|
|
325
|
+
else "#27ae60" if ratio > 0 else "#7f8c8d"
|
|
326
|
+
)
|
|
327
|
+
rows = "".join(
|
|
328
|
+
_cell(t, n, s, u)
|
|
329
|
+
for (t, n, s, u) in sorted(by_tactic.get(tactic, []), key=lambda x: x[0])
|
|
330
|
+
)
|
|
331
|
+
body_id = f"body-{tactic}"
|
|
332
|
+
area_id = f"area-{tactic}"
|
|
333
|
+
blocks.append(
|
|
334
|
+
f'<div class="tactic-wrap">'
|
|
335
|
+
f'<button class="eye" data-toggle="{area_id}" title="hide/show area">👁</button>'
|
|
336
|
+
f'<div class="tactic" id="{area_id}">'
|
|
337
|
+
f'<div class="thead collapse-trigger" data-target="{body_id}" style="background:{color}">'
|
|
338
|
+
f'<span class="tname">{info["name"]} '
|
|
339
|
+
f'<span class="cnt">{info["covered"]}/{info["total"]}</span></span>'
|
|
340
|
+
f'</div><ul class="cells" id="{body_id}">{rows}</ul></div></div>'
|
|
341
|
+
)
|
|
342
|
+
body_html = f'<div class="matrix">{"" .join(blocks)}</div>'
|
|
343
|
+
|
|
344
|
+
elif style == "rows":
|
|
345
|
+
css = base_css + """
|
|
346
|
+
.row{display:flex;align-items:center;gap:12px;margin-bottom:8px;background:#1b1f2a;
|
|
347
|
+
border:1px solid #2a2f3a;border-radius:8px;padding:8px 12px}
|
|
348
|
+
.rname{width:220px;font-weight:600;font-size:13px;flex:0 0 auto;display:flex;justify-content:space-between;align-items:center;gap:6px}
|
|
349
|
+
.rname .et{display:flex;align-items:center;gap:6px}
|
|
350
|
+
.bar{flex:1;height:16px;background:#2a2f3a;border-radius:8px;overflow:hidden}
|
|
351
|
+
.bar > i{display:block;height:100%;background:linear-gradient(90deg,#27ae60,#2ecc71)}
|
|
352
|
+
.rcnt{flex:0 0 auto;font-size:12px;color:#9aa;width:70px;text-align:right}
|
|
353
|
+
"""
|
|
354
|
+
rows = []
|
|
355
|
+
for tactic, info in tactics.items():
|
|
356
|
+
pctw = int(info["ratio"] * 100)
|
|
357
|
+
ids = " ".join(t for (t, n, s, u) in by_tactic.get(tactic, []))
|
|
358
|
+
body_id = f"body-{tactic}"
|
|
359
|
+
area_id = f"area-{tactic}"
|
|
360
|
+
rows.append(
|
|
361
|
+
f'<div class="row-wrap">'
|
|
362
|
+
f'<button class="eye" data-toggle="{area_id}" title="hide/show area">👁</button>'
|
|
363
|
+
f'<div class="row" id="{area_id}">'
|
|
364
|
+
f'<div class="rname collapse-trigger" data-target="{body_id}" data-tids="{ids}">'
|
|
365
|
+
f'<span class="et"><span>{info["name"]}</span></span></div>'
|
|
366
|
+
f'<div class="bar"><i style="width:{pctw}%"></i></div>'
|
|
367
|
+
f'<div class="rcnt">{info["covered"]}/{info["total"]}</div></div></div>'
|
|
368
|
+
)
|
|
369
|
+
body_html = "".join(rows)
|
|
370
|
+
|
|
371
|
+
elif style == "report":
|
|
372
|
+
# A printable "detection engineering dossier". Design intent:
|
|
373
|
+
# * Vertical tactic columns in the canonical ATT&CK matrix
|
|
374
|
+
# orientation, so anyone who knows the framework reads it at a
|
|
375
|
+
# glance (the other three styles are horizontal dashboards).
|
|
376
|
+
# * Sub-techniques nested under their parent with a tree rule —
|
|
377
|
+
# the parent/sub relationship is real information the chip and
|
|
378
|
+
# heat views flatten away.
|
|
379
|
+
# * Light "paper & ink" theme + @media print rules: the report can
|
|
380
|
+
# be printed/PDF'd and attached to an audit or a portfolio.
|
|
381
|
+
css = """
|
|
382
|
+
:root{--paper:#f6f4ee;--ink:#22261f;--cov:#1e5f4e;--gap:#a3313a;
|
|
383
|
+
--muted:#8b877c;--line:#d8d4c8;--covbg:#e3ece7;--gapbg:#f2e4e4}
|
|
384
|
+
*{box-sizing:border-box}
|
|
385
|
+
body{font-family:system-ui,'Segoe UI',sans-serif;background:var(--paper);
|
|
386
|
+
color:var(--ink);margin:0;padding:32px 36px}
|
|
387
|
+
.mono{font-family:ui-monospace,'Cascadia Mono',Menlo,Consolas,monospace}
|
|
388
|
+
.masthead{display:flex;justify-content:space-between;align-items:flex-end;
|
|
389
|
+
gap:24px;border-bottom:3px double var(--ink);padding-bottom:14px;margin-bottom:14px}
|
|
390
|
+
.eyebrow{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}
|
|
391
|
+
h1{font-size:26px;margin:2px 0 6px;font-weight:750;letter-spacing:-.01em}
|
|
392
|
+
.summary{color:var(--muted);margin:0;font-size:13px;max-width:640px}
|
|
393
|
+
.stamp{border:2px solid var(--cov);color:var(--cov);border-radius:4px;
|
|
394
|
+
padding:10px 16px;text-align:center;flex:0 0 auto}
|
|
395
|
+
.stamp b{display:block;font-size:30px;line-height:1;font-weight:800}
|
|
396
|
+
.stamp span{font-size:10px;letter-spacing:.18em;text-transform:uppercase}
|
|
397
|
+
.controls{display:flex;align-items:center;gap:12px;margin:16px 0;flex-wrap:wrap}
|
|
398
|
+
#search{padding:9px 13px;border:1px solid var(--line);border-radius:4px;
|
|
399
|
+
background:#fff;color:var(--ink);font-size:14px;min-width:280px;outline:none}
|
|
400
|
+
#search:focus{border-color:var(--cov);box-shadow:0 0 0 3px rgba(30,95,78,.15)}
|
|
401
|
+
#searchstatus{color:var(--muted);font-size:12px}
|
|
402
|
+
.exportbtn{font-size:13px;font-weight:600;color:#fff;cursor:pointer;
|
|
403
|
+
background:var(--cov);border:none;border-radius:4px;padding:9px 14px}
|
|
404
|
+
.exportbtn:hover{background:#17493c}
|
|
405
|
+
.gaps-card{background:#fff;border:1px solid var(--line);border-radius:4px;
|
|
406
|
+
padding:14px 16px;margin:0 0 18px;max-width:560px}
|
|
407
|
+
.gaps-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
|
|
408
|
+
.gaps-title{font-weight:700;font-size:13px;letter-spacing:.06em;text-transform:uppercase}
|
|
409
|
+
.gaptoggle{font-size:11px;color:var(--ink);cursor:pointer;background:var(--paper);
|
|
410
|
+
border:1px solid var(--line);border-radius:4px;padding:5px 9px}
|
|
411
|
+
.gaptoggle:hover{border-color:var(--cov)}
|
|
412
|
+
.gaps-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px}
|
|
413
|
+
.gaps-list li{display:flex;align-items:center;gap:10px;font-size:12px}
|
|
414
|
+
.gname{width:150px;flex:0 0 auto}
|
|
415
|
+
.gbar{flex:1;height:8px;background:var(--line);border-radius:4px;overflow:hidden}
|
|
416
|
+
.gbar > i{display:block;height:100%}
|
|
417
|
+
.gcnt{flex:0 0 auto;color:var(--muted);width:80px;text-align:right}
|
|
418
|
+
.gaps-hint{margin-top:10px;font-size:11px;color:var(--muted)}
|
|
419
|
+
.topscroll{overflow-x:auto;overflow-y:hidden;height:14px;margin-bottom:2px}
|
|
420
|
+
.topscroll > div{height:1px}
|
|
421
|
+
.rack{display:flex;gap:10px;overflow-x:auto;align-items:flex-start;padding-bottom:12px}
|
|
422
|
+
.col{flex:0 0 190px;background:#fff;border:1px solid var(--line);border-radius:4px}
|
|
423
|
+
.colhead{padding:10px 10px 8px;border-bottom:2px solid var(--ink)}
|
|
424
|
+
.colhead .tname{font-weight:750;font-size:13px;line-height:1.2}
|
|
425
|
+
.colhead .tmeta{display:flex;justify-content:space-between;align-items:baseline;
|
|
426
|
+
margin-top:4px;font-size:11px;color:var(--muted)}
|
|
427
|
+
.inkbar{height:4px;background:var(--line);border-radius:2px;margin-top:6px;overflow:hidden}
|
|
428
|
+
.inkbar > i{display:block;height:100%;background:var(--cov)}
|
|
429
|
+
.colbody{list-style:none;margin:0;padding:6px 0}
|
|
430
|
+
.colbody li{font-size:11.5px;line-height:1.35;padding:3px 10px;display:flex;gap:7px;align-items:baseline}
|
|
431
|
+
.xt{margin-left:auto;flex:0 0 auto;font-size:10px;color:var(--muted);cursor:help}
|
|
432
|
+
.colbody li.sub{padding-left:22px;border-left:2px solid var(--line);margin-left:14px}
|
|
433
|
+
.tick{flex:0 0 auto;width:8px;height:8px;border-radius:2px;border:1.5px solid var(--muted);
|
|
434
|
+
position:relative;top:0}
|
|
435
|
+
li.cov{background:var(--covbg)} li.cov .tick{background:var(--cov);border-color:var(--cov)}
|
|
436
|
+
li.gap .tick{border-color:var(--gap)}
|
|
437
|
+
li.ign{color:var(--muted);text-decoration:line-through}
|
|
438
|
+
li a{color:inherit;text-decoration:none}
|
|
439
|
+
li .tid{font-weight:650;letter-spacing:.01em}
|
|
440
|
+
li .nm{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
441
|
+
li.cov .nm{color:var(--ink)}
|
|
442
|
+
.legend{margin:0;font-size:11px;color:var(--muted)}
|
|
443
|
+
.legend span{padding:3px 9px;border:1px solid var(--line);border-radius:3px;margin-right:8px;font-weight:600}
|
|
444
|
+
.legend .cov{background:var(--covbg);color:var(--cov);border-color:var(--cov)}
|
|
445
|
+
.legend .gap{background:var(--gapbg);color:var(--gap);border-color:var(--gap)}
|
|
446
|
+
.legend .ign{color:var(--muted)}
|
|
447
|
+
.dim{opacity:.14}
|
|
448
|
+
.collapsed{opacity:.55}
|
|
449
|
+
.eye{display:none}
|
|
450
|
+
@media print{
|
|
451
|
+
body{padding:0}
|
|
452
|
+
.controls,.exportbtn,.gaptoggle,.topscroll{display:none}
|
|
453
|
+
.rack{flex-wrap:wrap;overflow:visible}
|
|
454
|
+
.col{break-inside:avoid}
|
|
455
|
+
}
|
|
456
|
+
"""
|
|
457
|
+
n_tactics = {tid: len(t.get("tactics", [])) for tid, t in db.techniques.items()}
|
|
458
|
+
|
|
459
|
+
def _report_li(t: str, n: str, s: str, u: str, sub: bool) -> str:
|
|
460
|
+
safe = n.replace(chr(34), "")
|
|
461
|
+
extra = ""
|
|
462
|
+
k = n_tactics.get(t, 1)
|
|
463
|
+
if k > 1:
|
|
464
|
+
extra = (
|
|
465
|
+
f'<span class="xt" title="This technique spans {k} tactics '
|
|
466
|
+
f'and appears in each of their columns">⧉</span>'
|
|
467
|
+
)
|
|
468
|
+
cls = f"{s} sub" if sub else s
|
|
469
|
+
return (
|
|
470
|
+
f'<li class="{cls}" data-tid="{t}" data-name="{safe}" title="{t} — {safe}">'
|
|
471
|
+
f'<span class="tick"></span>'
|
|
472
|
+
f'<a href="{u}" target="_blank" class="mono tid">{t}</a>'
|
|
473
|
+
f'<span class="nm">{safe}</span>{extra}</li>'
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
cols = []
|
|
477
|
+
for tactic, info in tactics.items():
|
|
478
|
+
items = by_tactic.get(tactic, [])
|
|
479
|
+
# Group sub-techniques under their parent, preserving ID order.
|
|
480
|
+
parents: Dict[str, list] = {}
|
|
481
|
+
direct: Dict[str, tuple] = {}
|
|
482
|
+
for (t, n, s, u) in sorted(items, key=lambda x: x[0]):
|
|
483
|
+
root = t.split(".", 1)[0]
|
|
484
|
+
if "." in t:
|
|
485
|
+
parents.setdefault(root, []).append((t, n, s, u))
|
|
486
|
+
else:
|
|
487
|
+
direct[root] = (t, n, s, u)
|
|
488
|
+
parents.setdefault(root, [])
|
|
489
|
+
lis = []
|
|
490
|
+
for root in sorted(parents):
|
|
491
|
+
subs = parents[root]
|
|
492
|
+
if root in direct:
|
|
493
|
+
t, n, s, u = direct[root]
|
|
494
|
+
lis.append(_report_li(t, n, s, u, sub=False))
|
|
495
|
+
for (t, n, s, u) in subs:
|
|
496
|
+
lis.append(_report_li(t, n, s, u, sub=True))
|
|
497
|
+
body_id = f"body-{tactic}"
|
|
498
|
+
pctw = int(info["ratio"] * 100)
|
|
499
|
+
cols.append(
|
|
500
|
+
f'<div class="col">'
|
|
501
|
+
f'<div class="colhead collapse-trigger" data-target="{body_id}">'
|
|
502
|
+
f'<div class="tname">{info["name"]}</div>'
|
|
503
|
+
f'<div class="tmeta"><span class="mono">{info["covered"]}/{info["total"]}</span>'
|
|
504
|
+
f'<span>{info["ratio"]*100:.0f}%</span></div>'
|
|
505
|
+
f'<div class="inkbar"><i style="width:{pctw}%"></i></div>'
|
|
506
|
+
f'</div><ul class="colbody" id="{body_id}">{"".join(lis)}</ul></div>'
|
|
507
|
+
)
|
|
508
|
+
body_html = (
|
|
509
|
+
'<div class="topscroll" id="topscroll" aria-hidden="true"><div></div></div>'
|
|
510
|
+
f'<div class="rack" id="rack">{"".join(cols)}</div>'
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
else: # heat
|
|
514
|
+
css = base_css + """
|
|
515
|
+
.heat{display:flex;flex-direction:column;gap:3px}
|
|
516
|
+
.hrow{display:flex;align-items:center;gap:3px}
|
|
517
|
+
.hlabel{width:210px;font-size:12px;color:#cdd;flex:0 0 auto;display:flex;justify-content:space-between;align-items:center;gap:6px}
|
|
518
|
+
.hcells{display:flex;flex-wrap:wrap;gap:3px}
|
|
519
|
+
.hcell{width:16px;height:16px;border-radius:3px}
|
|
520
|
+
.hcell.cov{background:#27ae60}
|
|
521
|
+
.hcell.gap{background:#7f1d2e}
|
|
522
|
+
.hcell.ign{background:#3a3a3a}
|
|
523
|
+
"""
|
|
524
|
+
rows = []
|
|
525
|
+
for tactic, info in tactics.items():
|
|
526
|
+
cells = "".join(
|
|
527
|
+
f'<span class="hcell {s}" data-tid="{t}" data-name="{n.replace(chr(34), "")}" title="{t} — {n}"></span>'
|
|
528
|
+
for (t, n, s, u) in sorted(by_tactic.get(tactic, []), key=lambda x: x[0])
|
|
529
|
+
)
|
|
530
|
+
body_id = f"body-{tactic}"
|
|
531
|
+
area_id = f"area-{tactic}"
|
|
532
|
+
rows.append(
|
|
533
|
+
f'<div class="hrow-wrap">'
|
|
534
|
+
f'<button class="eye" data-toggle="{area_id}" title="hide/show area">👁</button>'
|
|
535
|
+
f'<div class="hrow" id="{area_id}">'
|
|
536
|
+
f'<div class="hlabel collapse-trigger" data-target="{body_id}">'
|
|
537
|
+
f'<span>{info["name"]}</span></div>'
|
|
538
|
+
f'<div class="hcells" id="{body_id}">{cells}</div></div></div>'
|
|
539
|
+
)
|
|
540
|
+
body_html = f'<div class="heat">{"" .join(rows)}</div>'
|
|
541
|
+
|
|
542
|
+
# ATT&CK Navigator layer JSON (embedded for the Export button).
|
|
543
|
+
# "layer" / "navigator" versions are required for Navigator to accept the
|
|
544
|
+
# file; the ATT&CK content version string is informative.
|
|
545
|
+
layer = {
|
|
546
|
+
"name": "attack-mapper coverage",
|
|
547
|
+
"versions": {"attack": db.version, "navigator": "5.1.0", "layer": "4.5"},
|
|
548
|
+
"domain": "enterprise-attack",
|
|
549
|
+
"techniques": [
|
|
550
|
+
{
|
|
551
|
+
"techniqueID": tid,
|
|
552
|
+
"color": "#27ae60" if tid in report.covered else "#7f1d2e",
|
|
553
|
+
"comment": (report.db.technique_name(tid) or ""),
|
|
554
|
+
"enabled": tid not in report.ignored,
|
|
555
|
+
}
|
|
556
|
+
for tid in report.in_scope_techniques
|
|
557
|
+
],
|
|
558
|
+
}
|
|
559
|
+
layer_json = json.dumps(layer)
|
|
560
|
+
|
|
561
|
+
# Two ranked lists:
|
|
562
|
+
# - TOP GAPS: tactics with the most uncovered techniques (biggest blind spot).
|
|
563
|
+
# - TOP COVERED: tactics with the most covered techniques (what you HAVE).
|
|
564
|
+
uncovered = lambda info: info["total"] - info["covered"]
|
|
565
|
+
gaps_ranked = sorted(
|
|
566
|
+
(kv for kv in tactics.items() if uncovered(kv[1]) > 0),
|
|
567
|
+
key=lambda kv: (uncovered(kv[1]), -kv[1]["total"]),
|
|
568
|
+
reverse=True,
|
|
569
|
+
)
|
|
570
|
+
covered_ranked = sorted(
|
|
571
|
+
(kv for kv in tactics.items() if kv[1]["covered"] > 0),
|
|
572
|
+
key=lambda kv: (kv[1]["covered"], kv[1]["ratio"]),
|
|
573
|
+
reverse=True,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def _items(ranked, mode):
|
|
577
|
+
out = []
|
|
578
|
+
for t, info in ranked[:5]:
|
|
579
|
+
if mode == "gaps":
|
|
580
|
+
pct = int((1 - info["ratio"]) * 100)
|
|
581
|
+
label = f'{uncovered(info)} gaps'
|
|
582
|
+
color = "linear-gradient(90deg,#c0392b,#e67e22)"
|
|
583
|
+
else:
|
|
584
|
+
pct = int(info["ratio"] * 100)
|
|
585
|
+
label = f'{info["covered"]}/{info["total"]}'
|
|
586
|
+
color = "linear-gradient(90deg,#1f6f3f,#2ecc71)"
|
|
587
|
+
out.append(
|
|
588
|
+
f'<li><span class="gname">{info["name"]}</span>'
|
|
589
|
+
f'<span class="gbar"><i style="width:{pct}%;background:{color}"></i></span>'
|
|
590
|
+
f'<span class="gcnt">{label}</span></li>'
|
|
591
|
+
)
|
|
592
|
+
return "".join(out)
|
|
593
|
+
|
|
594
|
+
gap_items = _items(gaps_ranked, "gaps")
|
|
595
|
+
cov_items = _items(covered_ranked, "covered")
|
|
596
|
+
gaps_html = (
|
|
597
|
+
'<div class="gaps-card">'
|
|
598
|
+
'<div class="gaps-head">'
|
|
599
|
+
'<span class="gaps-title">Top coverage gaps</span>'
|
|
600
|
+
'<button id="gaptoggle" class="gaptoggle" title="Switch between gaps and covered">'
|
|
601
|
+
"show: what’s missing ▸ what I have</button>"
|
|
602
|
+
"</div>"
|
|
603
|
+
f'<ul class="gaps-list" id="gaps-list">{gap_items}</ul>'
|
|
604
|
+
f'<ul class="gaps-list" id="cov-list" style="display:none">{cov_items}</ul>'
|
|
605
|
+
'<div class="gaps-hint" id="gaps-hint">Tactics with the most uncovered techniques '
|
|
606
|
+
"— best candidates for new detections.</div>"
|
|
607
|
+
'<div class="gaps-hint" id="cov-hint" style="display:none">Tactics with the most '
|
|
608
|
+
"covered techniques — your current detection strengths.</div>"
|
|
609
|
+
"</div>"
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
n_cov = len(report.covered_in_scope)
|
|
613
|
+
meta = (
|
|
614
|
+
f"{n_cov}/{report.total_techniques} techniques covered "
|
|
615
|
+
f"({report.coverage_ratio*100:.1f}%) · {report.rule_count} rules · "
|
|
616
|
+
f'{db.version}{(" · " + scope) if scope else ""}'
|
|
617
|
+
)
|
|
618
|
+
if style == "report":
|
|
619
|
+
header_html = (
|
|
620
|
+
'<div class="masthead"><div>'
|
|
621
|
+
'<div class="eyebrow">Detection engineering · coverage dossier</div>'
|
|
622
|
+
"<h1>MITRE ATT&CK Detection Coverage</h1>"
|
|
623
|
+
f'<p class="summary">{meta}</p>'
|
|
624
|
+
"</div>"
|
|
625
|
+
f'<div class="stamp mono"><b>{report.coverage_ratio*100:.1f}%</b>'
|
|
626
|
+
f"<span>{n_cov} of {report.total_techniques} in scope</span></div>"
|
|
627
|
+
"</div>"
|
|
628
|
+
)
|
|
629
|
+
else:
|
|
630
|
+
header_html = (
|
|
631
|
+
"<h1>MITRE ATT&CK Detection Coverage</h1>"
|
|
632
|
+
f'<p class="summary">{meta}</p>'
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
html = f"""<!DOCTYPE html>
|
|
636
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
637
|
+
<title>ATT&CK Coverage Report</title><style>{css}</style></head><body>
|
|
638
|
+
{header_html}
|
|
639
|
+
<div class="controls">
|
|
640
|
+
<input id="search" type="text" placeholder="Search technique (e.g. T1059 or PowerShell)" />
|
|
641
|
+
<span id="searchstatus"></span>
|
|
642
|
+
<button id="exportlayer" class="exportbtn" title="Export ATT&CK Navigator layer (.json)">↓ Export layer</button>
|
|
643
|
+
{_legend_html()}
|
|
644
|
+
</div>
|
|
645
|
+
{gaps_html}
|
|
646
|
+
<script type="application/json" id="layerdata">{layer_json}</script>
|
|
647
|
+
{body_html}
|
|
648
|
+
{INTERACTIVE_JS}
|
|
649
|
+
</body></html>"""
|
|
650
|
+
with open(out_path, "w", encoding="utf-8") as fh:
|
|
651
|
+
fh.write(html)
|
|
652
|
+
return out_path
|