reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
143
vendor-new/analyst-ai-pack/tools/build-catalog.py
Normal file
143
vendor-new/analyst-ai-pack/tools/build-catalog.py
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate CATALOG.md: a browsable index of every AnalystAIPack skill.
|
||||
|
||||
Scans skill frontmatter and writes a Markdown catalog grouped by subdomain, with each skill's
|
||||
description, ATT&CK techniques, and a link to its SKILL.md. Run after adding or editing skills.
|
||||
|
||||
Usage:
|
||||
python tools/build-catalog.py # write CATALOG.md
|
||||
python tools/build-catalog.py --check # fail if CATALOG.md is out of date (CI)
|
||||
|
||||
Reuses read_frontmatter from lint-skills.py so parsing stays consistent. Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CATALOG_PATH = os.path.join(REPO_ROOT, "CATALOG.md")
|
||||
|
||||
SUBDOMAIN_ORDER = ["lab-foundations", "malware-analysis", "reverse-engineering", "threat-hunting"]
|
||||
SUBDOMAIN_TITLES = {
|
||||
"lab-foundations": "Lab Foundations",
|
||||
"malware-analysis": "Malware Analysis",
|
||||
"reverse-engineering": "Reverse Engineering",
|
||||
"threat-hunting": "Threat Hunting",
|
||||
}
|
||||
|
||||
|
||||
def _load_parser():
|
||||
path = os.path.join(REPO_ROOT, "tools", "lint-skills.py")
|
||||
spec = importlib.util.spec_from_file_location("lint_skills", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module.read_frontmatter
|
||||
|
||||
|
||||
read_frontmatter = _load_parser()
|
||||
|
||||
|
||||
def _first_sentence(desc: str) -> str:
|
||||
desc = " ".join(desc.split())
|
||||
# Trim at the activation-trigger sentence to keep catalog rows concise.
|
||||
for marker in (". Activates for", ". Activates when", ". Use when", ". Use this"):
|
||||
idx = desc.find(marker)
|
||||
if idx != -1:
|
||||
return desc[:idx + 1]
|
||||
return desc
|
||||
|
||||
|
||||
def collect() -> dict[str, list[dict]]:
|
||||
by_subdomain: dict[str, list[dict]] = defaultdict(list)
|
||||
for skill_dir in sorted(glob.glob(os.path.join(REPO_ROOT, "skills", "*"))):
|
||||
skill_md = os.path.join(skill_dir, "SKILL.md")
|
||||
if not os.path.isfile(skill_md):
|
||||
continue
|
||||
with open(skill_md, encoding="utf-8") as fh:
|
||||
data, err = read_frontmatter(fh.read())
|
||||
if err or not data:
|
||||
print(f"warning: skipping {os.path.basename(skill_dir)}: {err}", file=sys.stderr)
|
||||
continue
|
||||
rel = os.path.relpath(skill_dir, REPO_ROOT).replace(os.sep, "/")
|
||||
by_subdomain[data.get("subdomain", "")].append({
|
||||
"name": data.get("name", os.path.basename(skill_dir)),
|
||||
"summary": _first_sentence(data.get("description", "")),
|
||||
"attack": data.get("mitre_attack", []) or [],
|
||||
"path": f"{rel}/SKILL.md",
|
||||
})
|
||||
for skills in by_subdomain.values():
|
||||
skills.sort(key=lambda s: s["name"])
|
||||
return by_subdomain
|
||||
|
||||
|
||||
def build_catalog() -> str:
|
||||
by_subdomain = collect()
|
||||
total = sum(len(v) for v in by_subdomain.values())
|
||||
lines = [
|
||||
"# AnalystAIPack Skill Catalog",
|
||||
"",
|
||||
f"Auto-generated index of all **{total}** skills. Regenerate with "
|
||||
"`python tools/build-catalog.py`.",
|
||||
"",
|
||||
"| Subdomain | Skills |",
|
||||
"|---|---|",
|
||||
]
|
||||
ordered = [s for s in SUBDOMAIN_ORDER if s in by_subdomain]
|
||||
ordered += [s for s in sorted(by_subdomain) if s not in SUBDOMAIN_ORDER]
|
||||
for sub in ordered:
|
||||
title = SUBDOMAIN_TITLES.get(sub, sub)
|
||||
anchor = title.lower().replace(" ", "-")
|
||||
lines.append(f"| [{title}](#{anchor}) | {len(by_subdomain[sub])} |")
|
||||
lines.append("")
|
||||
|
||||
for sub in ordered:
|
||||
title = SUBDOMAIN_TITLES.get(sub, sub)
|
||||
skills = by_subdomain[sub]
|
||||
lines.append(f"## {title}")
|
||||
lines.append("")
|
||||
lines.append(f"{len(skills)} skills.")
|
||||
lines.append("")
|
||||
lines.append("| Skill | What it does | ATT&CK |")
|
||||
lines.append("|---|---|---|")
|
||||
for s in skills:
|
||||
attack = ", ".join(s["attack"]) if s["attack"] else "—"
|
||||
summary = s["summary"].replace("|", "\\|")
|
||||
lines.append(f"| [`{s['name']}`]({s['path']}) | {summary} | {attack} |")
|
||||
lines.append("")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Build CATALOG.md")
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="verify CATALOG.md is current without writing")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
fresh = build_catalog()
|
||||
if args.check:
|
||||
if not os.path.isfile(CATALOG_PATH):
|
||||
print("CATALOG.md missing; run: python tools/build-catalog.py")
|
||||
return 1
|
||||
with open(CATALOG_PATH, encoding="utf-8") as fh:
|
||||
existing = fh.read()
|
||||
if existing != fresh:
|
||||
print("CATALOG.md is out of date; run: python tools/build-catalog.py")
|
||||
return 1
|
||||
print("CATALOG.md is current.")
|
||||
return 0
|
||||
|
||||
with open(CATALOG_PATH, "w", encoding="utf-8") as fh:
|
||||
fh.write(fresh)
|
||||
count = fresh.count("| [`")
|
||||
print(f"Wrote CATALOG.md with {count} skill row(s).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user