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.
@@ -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