|
| 1 | +"""Build an ATT&CK Navigator layer JSON from a folder of Sigma rules. |
| 2 | +
|
| 3 | +Walks every .yml under --rules-dir, harvests `attack.tXXXX[.YYY]` tags, then |
| 4 | +emits a Navigator layer where each technique's score is the count of rules |
| 5 | +covering it. Higher score = more redundancy / depth. |
| 6 | +""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import json |
| 11 | +import re |
| 12 | +import sys |
| 13 | +from collections import Counter |
| 14 | +from pathlib import Path |
| 15 | + |
| 16 | +import click |
| 17 | +import yaml |
| 18 | +from rich.console import Console |
| 19 | +from rich.table import Table |
| 20 | + |
| 21 | +console = Console() |
| 22 | + |
| 23 | +TECH_RE = re.compile(r"^attack\.(t\d{4}(?:\.\d{3})?)$") |
| 24 | + |
| 25 | + |
| 26 | +def _iter_techniques(rules_dir: Path): |
| 27 | + for p in rules_dir.rglob("*.yml"): |
| 28 | + with p.open(encoding="utf-8") as f: |
| 29 | + rule = yaml.safe_load(f) or {} |
| 30 | + for tag in rule.get("tags", []) or []: |
| 31 | + m = TECH_RE.match(tag) |
| 32 | + if m: |
| 33 | + yield m.group(1).upper(), p |
| 34 | + |
| 35 | + |
| 36 | +@click.command() |
| 37 | +@click.option("--rules-dir", type=click.Path(exists=True, path_type=Path), required=True, |
| 38 | + help="Root directory containing Sigma .yml files (recurses).") |
| 39 | +@click.option("--name", default="Sigma Coverage", |
| 40 | + help="Layer name shown in Navigator.") |
| 41 | +@click.option("--description", default="Generated from local Sigma rules", |
| 42 | + help="Layer description.") |
| 43 | +@click.option("--out", "out_path", type=click.Path(path_type=Path), default=None, |
| 44 | + help="Write JSON to this file. If omitted, prints to stdout.") |
| 45 | +@click.option("--table", is_flag=True, |
| 46 | + help="Also print a coverage table to stderr.") |
| 47 | +def cli(rules_dir: Path, name: str, description: str, out_path: Path | None, table: bool) -> None: |
| 48 | + """Output an ATT&CK Navigator layer JSON summarizing rule coverage.""" |
| 49 | + counts: Counter[str] = Counter() |
| 50 | + by_tech: dict[str, list[str]] = {} |
| 51 | + |
| 52 | + for tech, path in _iter_techniques(rules_dir): |
| 53 | + counts[tech] += 1 |
| 54 | + by_tech.setdefault(tech, []).append(path.name) |
| 55 | + |
| 56 | + layer = { |
| 57 | + "name": name, |
| 58 | + "versions": {"attack": "14", "navigator": "5.0.0", "layer": "4.5"}, |
| 59 | + "domain": "enterprise-attack", |
| 60 | + "description": description, |
| 61 | + "techniques": [ |
| 62 | + { |
| 63 | + "techniqueID": tech, |
| 64 | + "score": cnt, |
| 65 | + "color": "", |
| 66 | + "comment": "; ".join(by_tech[tech]), |
| 67 | + "enabled": True, |
| 68 | + } |
| 69 | + for tech, cnt in counts.most_common() |
| 70 | + ], |
| 71 | + "gradient": { |
| 72 | + "colors": ["#a4d3f5", "#1e6091"], |
| 73 | + "minValue": 1, |
| 74 | + "maxValue": max(counts.values()) if counts else 1, |
| 75 | + }, |
| 76 | + "legendItems": [], |
| 77 | + "showTacticRowBackground": False, |
| 78 | + "tacticRowBackground": "#dddddd", |
| 79 | + "selectTechniquesAcrossTactics": True, |
| 80 | + } |
| 81 | + |
| 82 | + output = json.dumps(layer, indent=2) |
| 83 | + if out_path: |
| 84 | + out_path.write_text(output, encoding="utf-8") |
| 85 | + console.print(f"[green]wrote[/green] {out_path}", file=sys.stderr) |
| 86 | + else: |
| 87 | + sys.stdout.write(output + "\n") |
| 88 | + |
| 89 | + if table: |
| 90 | + t = Table(title="Technique coverage") |
| 91 | + t.add_column("Technique") |
| 92 | + t.add_column("Rules", justify="right") |
| 93 | + t.add_column("Files") |
| 94 | + for tech, cnt in counts.most_common(): |
| 95 | + t.add_row(tech, str(cnt), ", ".join(by_tech[tech])) |
| 96 | + console.print(t, file=sys.stderr) |
| 97 | + |
| 98 | + |
| 99 | +if __name__ == "__main__": |
| 100 | + cli() |
0 commit comments