336 lines
11 KiB
Python
336 lines
11 KiB
Python
#!/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())
|