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,30 @@
|
|
|
1
|
+
"""attack-mapper: map your Sigma detection rules onto the MITRE ATT&CK matrix."""
|
|
2
|
+
|
|
3
|
+
from .attack_loader import AttackDB, load_attack
|
|
4
|
+
from .coverage import CoverageReport, build_report
|
|
5
|
+
from .sigma_parser import (
|
|
6
|
+
ParsedRule,
|
|
7
|
+
parse_rule_file,
|
|
8
|
+
parse_rule_text,
|
|
9
|
+
parse_rules,
|
|
10
|
+
)
|
|
11
|
+
from .renderers import render_terminal, render_html, STYLES
|
|
12
|
+
from .plugins import render_badge, render_json
|
|
13
|
+
|
|
14
|
+
__version__ = "0.4.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AttackDB",
|
|
18
|
+
"load_attack",
|
|
19
|
+
"CoverageReport",
|
|
20
|
+
"build_report",
|
|
21
|
+
"ParsedRule",
|
|
22
|
+
"parse_rule_file",
|
|
23
|
+
"parse_rule_text",
|
|
24
|
+
"parse_rules",
|
|
25
|
+
"render_terminal",
|
|
26
|
+
"render_html",
|
|
27
|
+
"STYLES",
|
|
28
|
+
"render_badge",
|
|
29
|
+
"render_json",
|
|
30
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Load MITRE ATT&CK data for coverage mapping.
|
|
2
|
+
|
|
3
|
+
Two modes are supported:
|
|
4
|
+
|
|
5
|
+
* **Bundled compact DB** (``data/attack_db.json``) — generated by
|
|
6
|
+
:mod:`attack_mapper.build_db` from the official MITRE CTI STIX bundle. This
|
|
7
|
+
is the default because it is small (``~180 KB``) and needs no network.
|
|
8
|
+
* **Full STIX bundle** (``enterprise-attack.json``) — if you point
|
|
9
|
+
``ATTACK_MAPPER_STIX`` at the raw STIX 2.0/2.1 bundle, it is parsed directly
|
|
10
|
+
so you always have the latest techniques/tactics.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
DEFAULT_DB_PATH = os.path.join(os.path.dirname(__file__), "data", "attack_db.json")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AttackDB:
|
|
25
|
+
"""In-memory view of ATT&CK tactics and techniques."""
|
|
26
|
+
|
|
27
|
+
version: str = ""
|
|
28
|
+
tactics: Dict[str, dict] = field(default_factory=dict)
|
|
29
|
+
techniques: Dict[str, dict] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
def technique_name(self, tid: str) -> Optional[str]:
|
|
32
|
+
t = self.techniques.get(tid.upper())
|
|
33
|
+
return t["name"] if t else None
|
|
34
|
+
|
|
35
|
+
def tactic_name(self, shortname: str) -> Optional[str]:
|
|
36
|
+
t = self.tactics.get(shortname)
|
|
37
|
+
return t["name"] if t else shortname
|
|
38
|
+
|
|
39
|
+
def valid_technique(self, tid: str) -> bool:
|
|
40
|
+
return tid.upper() in self.techniques
|
|
41
|
+
|
|
42
|
+
def resolve_tactic_token(self, token: str) -> str:
|
|
43
|
+
"""Map a TA tactic ID (e.g. ``TA0002``) to its shortname.
|
|
44
|
+
|
|
45
|
+
Returns the token unchanged if it isn't a known TA id, so scope
|
|
46
|
+
tokens can be technique IDs, shortnames, or TA ids interchangeably.
|
|
47
|
+
Older compact DBs may lack the ``id`` field — fall back to parsing
|
|
48
|
+
the TA id out of the tactic URL.
|
|
49
|
+
"""
|
|
50
|
+
wanted = token.strip().upper()
|
|
51
|
+
if not wanted.startswith("TA"):
|
|
52
|
+
return token
|
|
53
|
+
for shortname, tac in self.tactics.items():
|
|
54
|
+
taid = tac.get("id")
|
|
55
|
+
if not taid:
|
|
56
|
+
url = tac.get("url") or ""
|
|
57
|
+
if "/tactics/" in url:
|
|
58
|
+
taid = url.rstrip("/").rsplit("/", 1)[-1]
|
|
59
|
+
if taid and taid.upper() == wanted:
|
|
60
|
+
return shortname
|
|
61
|
+
return token
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _load_compact(path: str) -> AttackDB:
|
|
65
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
66
|
+
d = json.load(fh)
|
|
67
|
+
return AttackDB(
|
|
68
|
+
version=d.get("version", ""),
|
|
69
|
+
tactics=d.get("tactics", {}),
|
|
70
|
+
techniques=d.get("techniques", {}),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_stix(path: str) -> AttackDB:
|
|
75
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
76
|
+
bundle = json.load(fh)
|
|
77
|
+
tactics: Dict[str, dict] = {}
|
|
78
|
+
techniques: Dict[str, dict] = {}
|
|
79
|
+
version = ""
|
|
80
|
+
for o in bundle.get("objects", []):
|
|
81
|
+
t = o.get("type")
|
|
82
|
+
# Revoked/deprecated objects are kept in the STIX bundle for history,
|
|
83
|
+
# but they are no longer part of ATT&CK — including them would inflate
|
|
84
|
+
# the coverage denominator with techniques that don't exist anymore.
|
|
85
|
+
if o.get("revoked") or o.get("x_mitre_deprecated"):
|
|
86
|
+
continue
|
|
87
|
+
if t == "x-mitre-matrix":
|
|
88
|
+
# The bundle has no version object; the matrix name + last-modified
|
|
89
|
+
# date is the most honest snapshot identifier available.
|
|
90
|
+
modified = (o.get("modified") or "")[:10]
|
|
91
|
+
version = f'{o.get("name", "ATT&CK")} ({modified})' if modified else o.get("name", "")
|
|
92
|
+
elif t == "x-mitre-tactic":
|
|
93
|
+
sn = o.get("x_mitre_shortname")
|
|
94
|
+
ref = next(
|
|
95
|
+
(e for e in o.get("external_references", []) if e.get("source_name") == "mitre-attack"),
|
|
96
|
+
{},
|
|
97
|
+
)
|
|
98
|
+
tactics[sn] = {
|
|
99
|
+
"id": ref.get("external_id"),
|
|
100
|
+
"name": o.get("name"),
|
|
101
|
+
"shortname": sn,
|
|
102
|
+
"url": ref.get("url"),
|
|
103
|
+
}
|
|
104
|
+
elif t == "attack-pattern":
|
|
105
|
+
refs = [e for e in o.get("external_references", []) if e.get("source_name") == "mitre-attack"]
|
|
106
|
+
if not refs:
|
|
107
|
+
continue
|
|
108
|
+
ext = refs[0].get("external_id")
|
|
109
|
+
if not (ext and ext.startswith("T")):
|
|
110
|
+
continue
|
|
111
|
+
techniques[ext] = {
|
|
112
|
+
"id": ext,
|
|
113
|
+
"name": o.get("name"),
|
|
114
|
+
"url": refs[0].get("url"),
|
|
115
|
+
"tactics": [p.get("phase_name") for p in o.get("kill_chain_phases", [])],
|
|
116
|
+
"is_subtechnique": "." in ext,
|
|
117
|
+
}
|
|
118
|
+
return AttackDB(version=version or "stix", tactics=tactics, techniques=techniques)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_attack(db_path: Optional[str] = None) -> AttackDB:
|
|
122
|
+
"""Load ATT&CK data.
|
|
123
|
+
|
|
124
|
+
Resolution order:
|
|
125
|
+
1. ``db_path`` argument if provided.
|
|
126
|
+
2. ``ATTACK_MAPPER_STIX`` env var -> full STIX bundle.
|
|
127
|
+
3. ``ATTACK_MAPPER_DB`` env var -> compact DB.
|
|
128
|
+
4. Bundled compact DB shipped with the package.
|
|
129
|
+
"""
|
|
130
|
+
stix = os.environ.get("ATTACK_MAPPER_STIX")
|
|
131
|
+
compact = db_path or os.environ.get("ATTACK_MAPPER_DB") or os.path.abspath(DEFAULT_DB_PATH)
|
|
132
|
+
|
|
133
|
+
if stix and os.path.isfile(stix):
|
|
134
|
+
return _load_stix(stix)
|
|
135
|
+
if os.path.isfile(compact):
|
|
136
|
+
return _load_compact(compact)
|
|
137
|
+
raise FileNotFoundError(
|
|
138
|
+
f"No ATT&CK database found. Looked for compact DB at {compact} "
|
|
139
|
+
f"and STIX at {stix}. Run `python -m attack_mapper.build_db` to generate one."
|
|
140
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Generate data/attack_db.json from a MITRE CTI STIX bundle.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
ATTACK_MAPPER_STIX=path/to/enterprise-attack.json \
|
|
5
|
+
python -m attack_mapper.build_db
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from .attack_loader import _load_stix # noqa: F401 (reuse the parser)
|
|
14
|
+
|
|
15
|
+
DEFAULT_OUT = os.path.join(os.path.dirname(__file__), "data", "attack_db.json")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> int:
|
|
19
|
+
stix = os.environ.get("ATTACK_MAPPER_STIX")
|
|
20
|
+
if not stix or not os.path.isfile(stix):
|
|
21
|
+
print(
|
|
22
|
+
"Set ATTACK_MAPPER_STIX to a downloaded enterprise-attack.json "
|
|
23
|
+
"(e.g. https://github.com/mitre/cti).",
|
|
24
|
+
file=__import__("sys").stderr,
|
|
25
|
+
)
|
|
26
|
+
return 1
|
|
27
|
+
db = _load_stix(stix)
|
|
28
|
+
out = os.path.abspath(DEFAULT_OUT)
|
|
29
|
+
compact = {
|
|
30
|
+
"version": db.version,
|
|
31
|
+
"tactics": db.tactics,
|
|
32
|
+
"techniques": db.techniques,
|
|
33
|
+
}
|
|
34
|
+
with open(out, "w", encoding="utf-8") as fh:
|
|
35
|
+
json.dump(compact, fh, indent=1)
|
|
36
|
+
print(
|
|
37
|
+
f"Wrote {len(db.techniques)} techniques / {len(db.tactics)} tactics "
|
|
38
|
+
f"to {out}"
|
|
39
|
+
)
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
raise SystemExit(main())
|
attack_mapper/cli.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""CLI entry point for attack-mapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .attack_loader import load_attack
|
|
10
|
+
from .coverage import build_report
|
|
11
|
+
from .renderers import render_terminal, render_html, STYLES
|
|
12
|
+
from .sigma_parser import parse_rules
|
|
13
|
+
from .plugins import render_badge, render_json
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
p = argparse.ArgumentParser(
|
|
18
|
+
prog="attack-mapper",
|
|
19
|
+
description="Map your Sigma detection rules onto the MITRE ATT&CK matrix "
|
|
20
|
+
"and find coverage gaps.",
|
|
21
|
+
)
|
|
22
|
+
p.add_argument(
|
|
23
|
+
"rules",
|
|
24
|
+
nargs="+",
|
|
25
|
+
help="Sigma rule file(s), directory(ies), or glob pattern(s).",
|
|
26
|
+
)
|
|
27
|
+
p.add_argument("--db", default=None, help="Path to a compact ATT&CK DB JSON.")
|
|
28
|
+
p.add_argument(
|
|
29
|
+
"--html", metavar="PATH", default=None,
|
|
30
|
+
help="Write a standalone HTML coverage report to PATH.",
|
|
31
|
+
)
|
|
32
|
+
p.add_argument(
|
|
33
|
+
"--style", choices=STYLES, default="matrix",
|
|
34
|
+
help="HTML layout: matrix (cards), rows (bars), or heat (dense).",
|
|
35
|
+
)
|
|
36
|
+
p.add_argument(
|
|
37
|
+
"--include", nargs="*", default=None, metavar="TOKEN",
|
|
38
|
+
help="Only count/show techniques or tactics matching these tokens "
|
|
39
|
+
"(e.g. T1059 TA0002). Others are treated as out-of-scope.",
|
|
40
|
+
)
|
|
41
|
+
p.add_argument(
|
|
42
|
+
"--ignore", nargs="*", default=None, metavar="TOKEN",
|
|
43
|
+
help="Exclude these techniques/tactics from scope (e.g. T1003 Reconnaissance).",
|
|
44
|
+
)
|
|
45
|
+
p.add_argument(
|
|
46
|
+
"--json", metavar="PATH", default=None,
|
|
47
|
+
help="Also write a machine-readable JSON summary to PATH.",
|
|
48
|
+
)
|
|
49
|
+
p.add_argument(
|
|
50
|
+
"--badge", metavar="PATH", default=None,
|
|
51
|
+
help="Also write an SVG coverage badge to PATH (great for a README).",
|
|
52
|
+
)
|
|
53
|
+
p.add_argument("--quiet", action="store_true", help="Suppress the terminal table.")
|
|
54
|
+
p.add_argument("--version", action="version", version="%(prog)s 0.4.0")
|
|
55
|
+
return p
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(argv=None) -> int:
|
|
59
|
+
args = build_parser().parse_args(argv)
|
|
60
|
+
try:
|
|
61
|
+
db = load_attack(args.db)
|
|
62
|
+
except FileNotFoundError as exc:
|
|
63
|
+
print(str(exc), file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
|
|
66
|
+
rules = parse_rules(args.rules)
|
|
67
|
+
if not rules:
|
|
68
|
+
print("No Sigma rules found at the given path(s).", file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
|
|
71
|
+
report = build_report(rules, db, include=args.include, ignore=args.ignore)
|
|
72
|
+
|
|
73
|
+
if not args.quiet:
|
|
74
|
+
print(render_terminal(report))
|
|
75
|
+
|
|
76
|
+
if args.html:
|
|
77
|
+
out = render_html(report, args.html, style=args.style)
|
|
78
|
+
print(f"\nHTML report written to: {os.path.abspath(out)}")
|
|
79
|
+
|
|
80
|
+
if args.json:
|
|
81
|
+
with open(args.json, "w", encoding="utf-8") as fh:
|
|
82
|
+
fh.write(render_json(report))
|
|
83
|
+
print(f"JSON summary written to: {os.path.abspath(args.json)}")
|
|
84
|
+
|
|
85
|
+
if args.badge:
|
|
86
|
+
with open(args.badge, "w", encoding="utf-8") as fh:
|
|
87
|
+
fh.write(render_badge(report))
|
|
88
|
+
print(f"Badge written to: {os.path.abspath(args.badge)}")
|
|
89
|
+
|
|
90
|
+
return 0 if report.covered else 1
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Compute detection coverage and gaps against the ATT&CK matrix.
|
|
2
|
+
|
|
3
|
+
Supports *scope filters*:
|
|
4
|
+
|
|
5
|
+
* ``include`` — restrict the analysis to a set of techniques / tactics. Only
|
|
6
|
+
those are counted toward totals and shown as in-scope.
|
|
7
|
+
* ``ignore`` — explicitly exclude techniques / tactics (e.g. deprecated or
|
|
8
|
+
out-of-scope for your environment). Ignored items are reported separately.
|
|
9
|
+
|
|
10
|
+
A token in either set matches if it equals a technique ID, is a prefix of a
|
|
11
|
+
technique ID (so ``T1059`` covers ``T1059.001``), or equals a tactic
|
|
12
|
+
shortname. An empty ``include`` means "everything is in scope".
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Dict, Iterable, List, Optional, Set
|
|
20
|
+
|
|
21
|
+
from .attack_loader import AttackDB
|
|
22
|
+
from .sigma_parser import ParsedRule
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _matches(token: str, include: Set[str]) -> bool:
|
|
26
|
+
"""True if ``token`` (technique id or tactic) is covered by ``include``."""
|
|
27
|
+
token = token.upper()
|
|
28
|
+
for inc in include:
|
|
29
|
+
inc = inc.upper()
|
|
30
|
+
if token == inc or token.startswith(inc + "."):
|
|
31
|
+
return True
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_scope(tokens: Optional[Iterable[str]], db: AttackDB) -> Set[str]:
|
|
36
|
+
"""Uppercase scope tokens, mapping TA tactic IDs to tactic shortnames.
|
|
37
|
+
|
|
38
|
+
``TA0002`` → ``EXECUTION`` so it matches technique tactic lists the same
|
|
39
|
+
way the shortname form does. Unknown tokens pass through unchanged.
|
|
40
|
+
"""
|
|
41
|
+
return {db.resolve_tactic_token(s).upper() for s in (tokens or [])}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class CoverageReport:
|
|
46
|
+
"""Result of mapping a set of rules onto ATT&CK, optionally scoped."""
|
|
47
|
+
|
|
48
|
+
db: AttackDB
|
|
49
|
+
covered: Dict[str, List[str]] = field(default_factory=dict) # technique_id -> [rule paths]
|
|
50
|
+
unknown: List[str] = field(default_factory=list) # technique ids not in ATT&CK
|
|
51
|
+
unmapped_rules: List[ParsedRule] = field(default_factory=list)
|
|
52
|
+
rule_count: int = 0
|
|
53
|
+
include: Set[str] = field(default_factory=set)
|
|
54
|
+
ignore: Set[str] = field(default_factory=set)
|
|
55
|
+
ignored: List[str] = field(default_factory=list) # in-scope tokens explicitly ignored
|
|
56
|
+
out_of_scope: List[str] = field(default_factory=list) # not matched by include
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def covered_techniques(self) -> List[str]:
|
|
60
|
+
return sorted(self.covered.keys(), key=lambda t: (len(t), t))
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def in_scope_techniques(self) -> List[str]:
|
|
64
|
+
"""All ATT&CK techniques that are in scope (after include/ignore)."""
|
|
65
|
+
result = []
|
|
66
|
+
for tid, tech in self.db.techniques.items():
|
|
67
|
+
if self._in_scope(tid, tech):
|
|
68
|
+
result.append(tid)
|
|
69
|
+
return sorted(result, key=lambda t: (len(t), t))
|
|
70
|
+
|
|
71
|
+
def _in_scope(self, tid: str, tech: dict) -> bool:
|
|
72
|
+
if self.include and not (
|
|
73
|
+
_matches(tid, self.include)
|
|
74
|
+
or any(_matches(t, self.include) for t in tech.get("tactics", []))
|
|
75
|
+
):
|
|
76
|
+
return False
|
|
77
|
+
if self.ignore and (
|
|
78
|
+
_matches(tid, self.ignore)
|
|
79
|
+
or any(_matches(t, self.ignore) for t in tech.get("tactics", []))
|
|
80
|
+
):
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def covered_in_scope(self) -> List[str]:
|
|
86
|
+
"""Covered techniques that are also in scope (the ratio numerator).
|
|
87
|
+
|
|
88
|
+
A technique detected by a rule but excluded via ``--include`` /
|
|
89
|
+
``--ignore`` is still *recorded* in ``covered`` (you did detect it),
|
|
90
|
+
but it must not count toward the scoped coverage ratio.
|
|
91
|
+
"""
|
|
92
|
+
in_scope = set(self.in_scope_techniques)
|
|
93
|
+
return [t for t in self.covered_techniques if t in in_scope]
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def total_techniques(self) -> int:
|
|
97
|
+
"""In-scope techniques (the denominator for the coverage ratio)."""
|
|
98
|
+
return len(self.in_scope_techniques)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def coverage_ratio(self) -> float:
|
|
102
|
+
total = self.total_techniques
|
|
103
|
+
return (len(self.covered_in_scope) / total) if total else 0.0
|
|
104
|
+
|
|
105
|
+
def tactics_coverage(self) -> Dict[str, dict]:
|
|
106
|
+
"""Per-tactic coverage: how many in-scope techniques covered vs total."""
|
|
107
|
+
total_by_tactic: Dict[str, int] = defaultdict(int)
|
|
108
|
+
covered_by_tactic: Dict[str, int] = defaultdict(int)
|
|
109
|
+
|
|
110
|
+
for tid, tech in self.db.techniques.items():
|
|
111
|
+
if not self._in_scope(tid, tech):
|
|
112
|
+
continue
|
|
113
|
+
for tactic in tech.get("tactics", []):
|
|
114
|
+
total_by_tactic[tactic] += 1
|
|
115
|
+
# Count a covered technique in EVERY tactic it belongs to.
|
|
116
|
+
# Each tactic row has its own denominator, so this is not
|
|
117
|
+
# double counting — it's how the ATT&CK matrix itself works:
|
|
118
|
+
# covering T1053.005 protects Execution AND Persistence AND
|
|
119
|
+
# Privilege Escalation, and all three bars should say so.
|
|
120
|
+
if tid in self.covered:
|
|
121
|
+
covered_by_tactic[tactic] += 1
|
|
122
|
+
|
|
123
|
+
result: Dict[str, dict] = {}
|
|
124
|
+
for tactic, total in sorted(total_by_tactic.items(), key=lambda x: -x[1]):
|
|
125
|
+
c = covered_by_tactic.get(tactic, 0)
|
|
126
|
+
result[tactic] = {
|
|
127
|
+
"name": self.db.tactic_name(tactic),
|
|
128
|
+
"covered": c,
|
|
129
|
+
"total": total,
|
|
130
|
+
"ratio": (c / total) if total else 0.0,
|
|
131
|
+
}
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_report(
|
|
136
|
+
rules: List[ParsedRule],
|
|
137
|
+
db: AttackDB,
|
|
138
|
+
include: Optional[Iterable[str]] = None,
|
|
139
|
+
ignore: Optional[Iterable[str]] = None,
|
|
140
|
+
) -> CoverageReport:
|
|
141
|
+
"""Map parsed Sigma rules onto ATT&CK and produce a coverage report."""
|
|
142
|
+
report = CoverageReport(
|
|
143
|
+
db=db,
|
|
144
|
+
rule_count=len(rules),
|
|
145
|
+
include=_normalize_scope(include, db),
|
|
146
|
+
ignore=_normalize_scope(ignore, db),
|
|
147
|
+
)
|
|
148
|
+
covered: Dict[str, List[str]] = defaultdict(list)
|
|
149
|
+
seen_unknown: set = set()
|
|
150
|
+
|
|
151
|
+
for rule in rules:
|
|
152
|
+
if not rule.technique_ids:
|
|
153
|
+
report.unmapped_rules.append(rule)
|
|
154
|
+
continue
|
|
155
|
+
for tid in rule.technique_ids:
|
|
156
|
+
if db.valid_technique(tid):
|
|
157
|
+
covered[tid].append(rule.path)
|
|
158
|
+
else:
|
|
159
|
+
seen_unknown.add(tid)
|
|
160
|
+
|
|
161
|
+
# Inherit coverage to parent techniques so the number shown ("N covered")
|
|
162
|
+
# matches the green cells in the HTML: a covered sub-technique also lights
|
|
163
|
+
# up its parent technique.
|
|
164
|
+
for tid in list(covered.keys()):
|
|
165
|
+
if "." in tid:
|
|
166
|
+
parent = tid.split(".", 1)[0]
|
|
167
|
+
if parent in db.techniques and parent not in covered:
|
|
168
|
+
covered[parent] = list(covered[tid])
|
|
169
|
+
|
|
170
|
+
report.covered = dict(covered)
|
|
171
|
+
report.unknown = sorted(seen_unknown, key=lambda t: (len(t), t))
|
|
172
|
+
|
|
173
|
+
# Classify each ATT&CK technique as ignored or out-of-scope (for reporting).
|
|
174
|
+
for tid, tech in db.techniques.items():
|
|
175
|
+
if tid in report.covered or tid in seen_unknown:
|
|
176
|
+
continue
|
|
177
|
+
is_ignored = bool(report.ignore) and (
|
|
178
|
+
_matches(tid, report.ignore)
|
|
179
|
+
or any(_matches(t, report.ignore) for t in tech.get("tactics", []))
|
|
180
|
+
)
|
|
181
|
+
if is_ignored:
|
|
182
|
+
report.ignored.append(tid)
|
|
183
|
+
elif report.include and not (
|
|
184
|
+
_matches(tid, report.include)
|
|
185
|
+
or any(_matches(t, report.include) for t in tech.get("tactics", []))
|
|
186
|
+
):
|
|
187
|
+
report.out_of_scope.append(tid)
|
|
188
|
+
report.ignored.sort(key=lambda t: (len(t), t))
|
|
189
|
+
report.out_of_scope.sort(key=lambda t: (len(t), t))
|
|
190
|
+
return report
|