-
Notifications
You must be signed in to change notification settings - Fork 42
Expand file tree
/
Copy pathmigrate_to_frontmatter.py
More file actions
129 lines (103 loc) · 4 KB
/
Copy pathmigrate_to_frontmatter.py
File metadata and controls
129 lines (103 loc) · 4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
"""
Migrate hunt markdown files from the legacy 6-cell table format to YAML
frontmatter. Idempotent: re-running on an already-migrated file is a no-op.
Usage:
python scripts/migrate_to_frontmatter.py [--dry-run] [--path PATH]
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
# Bootstrap sys.path so this script works from any CWD.
_REPO_ROOT = str(Path(__file__).resolve().parent.parent)
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
import frontmatter
from scripts.hunt_parser import _parse_legacy_table
from scripts.hunt_schema import validate_hunt
CATEGORY_DIRS = ("Flames", "Embers", "Alchemy")
SKIP_FILENAMES = {"secret.md"} # Easter egg / non-hunt files
_TABLE_BLOCK_RE = re.compile(
r"^\|.*Hunt\s*#.*\n\|.*\n\|.*\n", re.MULTILINE
)
def _build_body(raw: str) -> str:
"""Strip leading HTML comments, the H1, the legacy table, and intro paragraph; keep the rest."""
no_html_comment = re.sub(r"^<!--.*?-->\s*\n", "", raw, count=1, flags=re.DOTALL)
no_table = _TABLE_BLOCK_RE.sub("", no_html_comment, count=1)
no_h1 = re.sub(r"^# .+\n+", "", no_table, count=1)
no_intro = re.sub(r"^[^\n#].*?\n+(?=## )", "", no_h1, count=1, flags=re.DOTALL)
return no_intro.strip() + "\n"
def migrate_file(path: Path, category: str, dry_run: bool) -> bool:
"""Convert a single file. Returns True if a change was (or would be) made."""
if path.name in SKIP_FILENAMES:
return False
raw = path.read_text(encoding="utf-8")
post = frontmatter.loads(raw)
if post.metadata:
return False # already migrated
parsed = _parse_legacy_table(raw, hunt_id=path.stem, category=category)
tags = parsed.get("tags", [])
if not tags:
# Fallback: derive snake_case tags from tactics so migration produces
# a schema-valid file even when the contributor only used MITRE technique
# tags (e.g. M007 had only #T1071.001 #T1203).
tags = [
re.sub(r"[^a-z0-9_]", "", t.strip().lower().replace(" ", "_"))
for t in parsed.get("tactics", [])
]
tags = [t for t in tags if t]
metadata = {
"id": parsed["id"],
"category": category,
"hypothesis": parsed["hypothesis"],
"tactics": parsed["tactics"],
"tags": tags,
"submitter": parsed["submitter"],
}
if parsed.get("techniques"):
metadata["techniques"] = parsed["techniques"]
if parsed.get("notes"):
metadata["notes"] = parsed["notes"]
errors = validate_hunt(metadata)
if errors:
raise ValueError(
f"{path.name}: cannot migrate, validation failed:\n " + "\n ".join(errors)
)
body = _build_body(raw)
new_post = frontmatter.Post(content=body, **metadata)
output = frontmatter.dumps(new_post) + "\n"
if dry_run:
return True
path.write_text(output, encoding="utf-8")
return True
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--path", type=Path, help="Single file (overrides directory scan)")
args = parser.parse_args()
targets: list[tuple[Path, str]] = []
if args.path:
targets.append((args.path, args.path.parent.name))
else:
for d in CATEGORY_DIRS:
for f in sorted(Path(d).glob("*.md")):
targets.append((f, d))
changed = 0
skipped = 0
failed = 0
for path, category in targets:
try:
if migrate_file(path, category, dry_run=args.dry_run):
action = "WOULD MIGRATE" if args.dry_run else "migrated"
print(f" {action}: {path}")
changed += 1
else:
skipped += 1
except Exception as exc:
print(f" FAIL: {path}: {exc}", file=sys.stderr)
failed += 1
print(f"\nTotals: changed={changed} skipped={skipped} failed={failed}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())