stamp-subcategory.py (3647B)
1 #!/usr/bin/env python3 2 """ 3 Stamp a `subcategory` onto Active Directory sheets so the category page can 4 group its 130+ entries instead of listing them flat. 5 6 The grouping is not invented — it is the taxonomy the source vault already 7 uses. Every sheet carries a `source: "vault:…"` path, and the AD-Attack set 8 is filed under Category-One … Category-Ten, the ADCS work under 9 ACL-ESC-Techniques, the ticket work under Kerberos. This maps those source 10 folders to kill-chain-ordered names; anything loose in ActiveDirectory/ is 11 tooling and recon. 12 13 Idempotent: re-running rewrites the same `subcategory:` line. Only sheets 14 under src/content/sheets/active-directory are touched. 15 16 Usage: python3 scripts/stamp-subcategory.py 17 """ 18 import os, re 19 20 REPO = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) 21 AD = os.path.join(REPO, "src", "content", "sheets", "active-directory") 22 23 # Source folder → subcategory. The AD-Attack categories collapse a couple of 24 # ways: Category-Four (ESC attacks) merges with the ACL-ESC-Techniques folder, 25 # and Category-Two (tickets/delegation) merges with the Kerberos folder, so a 26 # reader sees one "ADCS & Certificates" section rather than two half-sections. 27 def subcat_for(source): 28 s = source.replace("vault:", "") 29 m = re.search(r"AD-Attack/Category-(\w+)", s) 30 if m: 31 return { 32 "One": "Credential Access", 33 "Two": "Kerberos & Delegation", 34 "Three": "ACL Abuse", 35 "Four": "ADCS & Certificates", 36 "Five": "Domain Controller Attacks", 37 "Six": "Privilege & Group Abuse", 38 "Seven": "Lateral Movement", 39 "Eight": "Persistence", 40 "Nine": "Trust Abuse", 41 "Ten": "Advanced & Post-Exploitation", 42 }.get(m.group(1), "Advanced & Post-Exploitation") 43 if "ACL-ESC-Techniques" in s: 44 return "ADCS & Certificates" 45 if "/Kerberos/" in s: 46 return "Kerberos & Delegation" 47 return "Tooling & Recon" 48 49 def read_source(fm): 50 m = re.search(r'^source:\s*"?(?:source:\s*)?"?([^"\n]+)"?\s*$', fm, flags=re.M) 51 # Some early files double-wrote the key ("source: source: \"vault:…\""); 52 # this tolerates both. Fall back to a looser grab of the vault path. 53 if m and "vault:" in m.group(0): 54 vm = re.search(r"vault:[^\"\n]+", m.group(0)) 55 if vm: 56 return vm.group(0) 57 vm = re.search(r"vault:[^\"\n]+", fm) 58 return vm.group(0) if vm else "" 59 60 def main(): 61 changed = 0 62 counts = {} 63 for f in sorted(os.listdir(AD)): 64 if not f.endswith(".md"): 65 continue 66 path = os.path.join(AD, f) 67 txt = open(path, encoding="utf-8").read() 68 if not txt.startswith("---"): 69 continue 70 end = txt.find("\n---", 3) 71 if end == -1: 72 continue 73 fm, body = txt[:end], txt[end:] 74 75 source = read_source(fm) 76 sub = subcat_for(source) if source else "Tooling & Recon" 77 counts[sub] = counts.get(sub, 0) + 1 78 79 line = f'subcategory: "{sub}"' 80 if re.search(r"^subcategory:.*$", fm, flags=re.M): 81 fm = re.sub(r"^subcategory:.*$", line, fm, count=1, flags=re.M) 82 else: 83 # Insert right after the category line so frontmatter stays tidy. 84 fm = re.sub(r"^(category:.*)$", r"\1\n" + line, fm, count=1, flags=re.M) 85 86 new = fm + body 87 if new != txt: 88 open(path, "w", encoding="utf-8").write(new) 89 changed += 1 90 91 print(f"stamped {changed} AD sheets") 92 for k in sorted(counts): 93 print(f" {k:32} {counts[k]}") 94 95 if __name__ == "__main__": 96 main()