reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
335
vendor-new/analyst-ai-pack/tools/lint-skills.py
Normal file
335
vendor-new/analyst-ai-pack/tools/lint-skills.py
Normal file
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint AnalystAIPack SKILL.md files against the repository schema.
|
||||
|
||||
Checks frontmatter fields, kebab-case naming, framework-ID formats, and the required
|
||||
body-section order described in CONTRIBUTING.md.
|
||||
|
||||
Usage:
|
||||
python tools/lint-skills.py skills/my-skill
|
||||
python tools/lint-skills.py --all
|
||||
|
||||
Exit code is non-zero if any skill fails linting. Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
REQUIRED_FIELDS = ["name", "description", "domain", "subdomain", "tags", "version",
|
||||
"author", "license"]
|
||||
|
||||
ALLOWED_SUBDOMAINS = {
|
||||
"lab-foundations",
|
||||
"malware-analysis",
|
||||
"reverse-engineering",
|
||||
"threat-hunting",
|
||||
}
|
||||
|
||||
# Required body sections, in order. "Safety & Handling" is optional (sample-handling only).
|
||||
REQUIRED_SECTIONS = [
|
||||
"When to Use",
|
||||
"Prerequisites",
|
||||
"Workflow",
|
||||
"Validation",
|
||||
"Pitfalls",
|
||||
"References",
|
||||
]
|
||||
|
||||
KEBAB_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
ATTACK_RE = re.compile(r"^T\d{4}(\.\d{3})?$")
|
||||
D3FEND_RE = re.compile(r"^D3-[A-Z]{1,6}$")
|
||||
CAR_RE = re.compile(r"^CAR-\d{4}-\d{2}-\d{3}$")
|
||||
DESCRIPTION_MIN_CHARS = 50
|
||||
|
||||
RED = "\033[91m"
|
||||
GREEN = "\033[92m"
|
||||
YELLOW = "\033[93m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def _supports_color() -> bool:
|
||||
return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
||||
|
||||
|
||||
if not _supports_color():
|
||||
RED = GREEN = YELLOW = RESET = ""
|
||||
|
||||
|
||||
def _find_quote_close(segment: str, quote: str):
|
||||
"""Return the index of the closing quote in ``segment`` or ``None`` if unclosed.
|
||||
|
||||
Honors YAML single-quoted escaping where a literal quote is written ``''``.
|
||||
"""
|
||||
i = 0
|
||||
n = len(segment)
|
||||
while i < n:
|
||||
if segment[i] == quote:
|
||||
if quote == "'" and i + 1 < n and segment[i + 1] == "'":
|
||||
i += 2 # escaped doubled quote; skip both characters
|
||||
continue
|
||||
return i
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def _unescape_quoted(value: str, quote: str) -> str:
|
||||
return value.replace("''", "'") if quote == "'" else value
|
||||
|
||||
|
||||
def read_frontmatter(text: str):
|
||||
"""Parse the leading YAML frontmatter block into a dict.
|
||||
|
||||
Minimal stdlib parser handling the patterns AnalystAIPack SKILL.md files use:
|
||||
scalars, inline ``[a, b]`` lists, block ``- item`` lists, folded ``>-`` scalars, and
|
||||
multi-line single/double-quoted scalars (continuation lines joined with a space).
|
||||
Returns ``(data, error)``; ``data`` is ``None`` when frontmatter is missing/malformed.
|
||||
"""
|
||||
if not text.startswith("---"):
|
||||
return None, "missing frontmatter (file must start with '---')"
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
return None, "unterminated frontmatter (no closing '---')"
|
||||
block = text[3:end].strip("\n")
|
||||
|
||||
data: dict = {}
|
||||
current_key: str | None = None
|
||||
list_values: list[str] = []
|
||||
in_folded = False
|
||||
folded_lines: list[str] = []
|
||||
in_quoted = False
|
||||
quote_char = ""
|
||||
quoted_parts: list[str] = []
|
||||
|
||||
def flush_list():
|
||||
nonlocal current_key, list_values
|
||||
if current_key is not None and list_values:
|
||||
data[current_key] = list_values
|
||||
list_values = []
|
||||
|
||||
def flush_folded():
|
||||
nonlocal current_key, folded_lines, in_folded
|
||||
if in_folded and current_key is not None and folded_lines:
|
||||
data[current_key] = " ".join(folded_lines)
|
||||
in_folded = False
|
||||
folded_lines = []
|
||||
|
||||
def store_quoted():
|
||||
nonlocal current_key, quoted_parts, in_quoted, quote_char
|
||||
if current_key is not None:
|
||||
joined = " ".join(quoted_parts).strip()
|
||||
data[current_key] = _unescape_quoted(joined, quote_char)
|
||||
in_quoted = False
|
||||
quote_char = ""
|
||||
quoted_parts = []
|
||||
current_key = None
|
||||
|
||||
for raw in block.split("\n"):
|
||||
line = raw.rstrip()
|
||||
stripped = line.strip()
|
||||
indented = raw.startswith(" ") or raw.startswith("\t")
|
||||
|
||||
# Multi-line quoted scalar continuation: consume until the closing quote.
|
||||
if in_quoted:
|
||||
close = _find_quote_close(stripped, quote_char)
|
||||
if close is None:
|
||||
quoted_parts.append(stripped)
|
||||
else:
|
||||
quoted_parts.append(stripped[:close])
|
||||
store_quoted()
|
||||
continue
|
||||
|
||||
if in_folded and stripped and not indented:
|
||||
flush_folded()
|
||||
if in_folded:
|
||||
if stripped:
|
||||
folded_lines.append(stripped)
|
||||
continue
|
||||
|
||||
if stripped.startswith("- "):
|
||||
list_values.append(stripped[2:].strip().strip("'\""))
|
||||
continue
|
||||
|
||||
if ":" in stripped and not indented:
|
||||
flush_list()
|
||||
key, _, value = stripped.partition(":")
|
||||
current_key = key.strip()
|
||||
value = value.strip()
|
||||
if value in (">-", ">", "|", "|-"):
|
||||
in_folded = True
|
||||
folded_lines = []
|
||||
elif value.startswith("[") and value.endswith("]"):
|
||||
items = value[1:-1].split(",")
|
||||
data[current_key] = [i.strip().strip("'\"") for i in items if i.strip()]
|
||||
current_key = None
|
||||
elif value and value[0] in "'\"":
|
||||
quote = value[0]
|
||||
rest = value[1:]
|
||||
close = _find_quote_close(rest, quote)
|
||||
if close is None:
|
||||
# Opens a multi-line quoted scalar; continue on following lines.
|
||||
in_quoted = True
|
||||
quote_char = quote
|
||||
quoted_parts = [rest]
|
||||
else:
|
||||
data[current_key] = _unescape_quoted(rest[:close], quote)
|
||||
current_key = None
|
||||
elif value:
|
||||
data[current_key] = value.strip("'\"")
|
||||
current_key = None
|
||||
# else: bare key, expect a block list on following lines
|
||||
continue
|
||||
|
||||
flush_list()
|
||||
flush_folded()
|
||||
if in_quoted:
|
||||
store_quoted()
|
||||
return data, None
|
||||
|
||||
|
||||
def _extract_sections(body: str) -> list[str]:
|
||||
return [m.group(1).strip() for m in re.finditer(r"^##\s+(.+)$", body, re.MULTILINE)]
|
||||
|
||||
|
||||
def lint_skill(skill_dir: str) -> list[str]:
|
||||
"""Return a list of error strings for one skill directory (empty == valid)."""
|
||||
errors: list[str] = []
|
||||
folder = os.path.basename(os.path.normpath(skill_dir))
|
||||
skill_md = os.path.join(skill_dir, "SKILL.md")
|
||||
|
||||
if not os.path.isfile(skill_md):
|
||||
return [f"{folder}: missing SKILL.md"]
|
||||
|
||||
with open(skill_md, encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
|
||||
data, err = read_frontmatter(text)
|
||||
if err:
|
||||
return [f"{folder}: {err}"]
|
||||
|
||||
# Required fields present
|
||||
for field in REQUIRED_FIELDS:
|
||||
if field not in data or data[field] in (None, "", []):
|
||||
errors.append(f"{folder}: missing required field '{field}'")
|
||||
|
||||
# name matches folder and is kebab-case
|
||||
name = data.get("name", "")
|
||||
if name and not KEBAB_RE.match(str(name)):
|
||||
errors.append(f"{folder}: 'name' must be kebab-case (got '{name}')")
|
||||
if name and name != folder:
|
||||
errors.append(f"{folder}: 'name' ({name}) must match folder name ({folder})")
|
||||
|
||||
# domain / subdomain
|
||||
if data.get("domain") != "cybersecurity":
|
||||
errors.append(f"{folder}: 'domain' must be 'cybersecurity'")
|
||||
subdomain = data.get("subdomain")
|
||||
if subdomain and subdomain not in ALLOWED_SUBDOMAINS:
|
||||
errors.append(
|
||||
f"{folder}: 'subdomain' '{subdomain}' not in {sorted(ALLOWED_SUBDOMAINS)}"
|
||||
)
|
||||
|
||||
# description length
|
||||
desc = data.get("description", "")
|
||||
if desc and len(desc) < DESCRIPTION_MIN_CHARS:
|
||||
errors.append(
|
||||
f"{folder}: 'description' too short (<{DESCRIPTION_MIN_CHARS} chars)"
|
||||
)
|
||||
|
||||
# tags is a list of 3-8
|
||||
tags = data.get("tags")
|
||||
if isinstance(tags, list):
|
||||
if not (3 <= len(tags) <= 8):
|
||||
errors.append(f"{folder}: expected 3-8 tags (got {len(tags)})")
|
||||
elif "tags" not in [e.split(":")[0] for e in errors]:
|
||||
errors.append(f"{folder}: 'tags' must be a list")
|
||||
|
||||
# mitre_attack required and well-formed
|
||||
attack = data.get("mitre_attack")
|
||||
if not attack:
|
||||
errors.append(f"{folder}: missing 'mitre_attack' (at least one technique)")
|
||||
elif isinstance(attack, list):
|
||||
for tid in attack:
|
||||
if not ATTACK_RE.match(str(tid)):
|
||||
errors.append(f"{folder}: invalid ATT&CK id '{tid}'")
|
||||
|
||||
# optional framework lists, validated when present
|
||||
for fld, rx, label in (("d3fend", D3FEND_RE, "D3FEND"), ("car", CAR_RE, "CAR")):
|
||||
vals = data.get(fld)
|
||||
if isinstance(vals, list):
|
||||
for v in vals:
|
||||
if not rx.match(str(v)):
|
||||
errors.append(f"{folder}: invalid {label} id '{v}'")
|
||||
|
||||
# body section presence + order
|
||||
body = text[text.find("\n---", 3) + 4:]
|
||||
found = _extract_sections(body)
|
||||
found_required = [s for s in found if s in REQUIRED_SECTIONS]
|
||||
missing = [s for s in REQUIRED_SECTIONS if s not in found]
|
||||
if missing:
|
||||
errors.append(f"{folder}: missing body sections: {', '.join(missing)}")
|
||||
else:
|
||||
order = [REQUIRED_SECTIONS.index(s) for s in found_required]
|
||||
if order != sorted(order):
|
||||
errors.append(f"{folder}: body sections out of order: {found_required}")
|
||||
|
||||
# companion files
|
||||
if not os.path.isfile(os.path.join(skill_dir, "scripts", "analyst.py")):
|
||||
errors.append(f"{folder}: missing scripts/analyst.py")
|
||||
if not os.path.isfile(os.path.join(skill_dir, "references", "api-reference.md")):
|
||||
errors.append(f"{folder}: missing references/api-reference.md")
|
||||
if not os.path.isfile(os.path.join(skill_dir, "LICENSE")):
|
||||
errors.append(f"{folder}: missing LICENSE")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _iter_skill_dirs(root: str):
|
||||
for path in sorted(glob.glob(os.path.join(root, "skills", "*"))):
|
||||
if os.path.isdir(path):
|
||||
yield path
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Lint AnalystAIPack skills")
|
||||
parser.add_argument("paths", nargs="*", help="skill directories to validate")
|
||||
parser.add_argument("--all", action="store_true", help="validate every skill")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
if args.all:
|
||||
targets = list(_iter_skill_dirs(repo_root))
|
||||
elif args.paths:
|
||||
targets = args.paths
|
||||
else:
|
||||
parser.error("provide skill directories or --all")
|
||||
return 2
|
||||
|
||||
if not targets:
|
||||
print(f"{YELLOW}No skills found to validate.{RESET}")
|
||||
return 0
|
||||
|
||||
total_errors = 0
|
||||
for skill_dir in targets:
|
||||
errs = lint_skill(skill_dir)
|
||||
folder = os.path.basename(os.path.normpath(skill_dir))
|
||||
if errs:
|
||||
total_errors += len(errs)
|
||||
print(f"{RED}FAIL{RESET} {folder}")
|
||||
for e in errs:
|
||||
print(f" - {e}")
|
||||
else:
|
||||
print(f"{GREEN}PASS{RESET} {folder}")
|
||||
|
||||
print()
|
||||
if total_errors:
|
||||
print(f"{RED}{total_errors} problem(s) across {len(targets)} skill(s).{RESET}")
|
||||
return 1
|
||||
print(f"{GREEN}All {len(targets)} skill(s) valid.{RESET}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user