245 lines
8.7 KiB
Python
245 lines
8.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Scan a subset of skills (those changed in a pull request) and emit a PR comment.
|
|
|
|
Usage:
|
|
python scan_pr_skills.py [--output FILE] [--fail-on SEVERITY] SKILL_DIR ...
|
|
|
|
Each ``SKILL_DIR`` should be a directory containing a ``SKILL.md``. Directories
|
|
without a ``SKILL.md`` (e.g. deleted skills) are skipped gracefully.
|
|
|
|
The script writes a markdown report intended to be posted as a sticky comment
|
|
on the pull request. It exits non-zero when any scanned skill has a finding at
|
|
``--fail-on`` severity or higher (default: ``CRITICAL``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from dotenv import load_dotenv
|
|
from skill_scanner.core.loader import SkillLoadError
|
|
from skill_scanner.core.models import Report
|
|
|
|
from scan_skills import build_scanner, severity_badge
|
|
|
|
load_dotenv()
|
|
|
|
SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "SAFE"]
|
|
COMMENT_MARKER = "<!-- skill-security-scan -->"
|
|
|
|
|
|
def _sev_str(obj) -> str:
|
|
sev = getattr(obj, "max_severity", None) or getattr(obj, "severity", None)
|
|
if sev is None:
|
|
return "SAFE"
|
|
return sev.value if hasattr(sev, "value") else str(sev)
|
|
|
|
|
|
def scan_skill_dirs(scanner, skill_dirs: list[Path]) -> Report:
|
|
report = Report()
|
|
loaded_skills = []
|
|
|
|
for skill_dir in skill_dirs:
|
|
name = skill_dir.name
|
|
print(f" Scanning {name} ...", end="", flush=True)
|
|
t0 = time.time()
|
|
try:
|
|
skill = scanner.loader.load_skill(skill_dir)
|
|
result = scanner._scan_single_skill(skill, skill_dir)
|
|
report.add_scan_result(result)
|
|
loaded_skills.append(skill)
|
|
elapsed = time.time() - t0
|
|
n = len(result.findings)
|
|
tag = severity_badge(_sev_str(result))
|
|
print(f" {tag} — {n} finding{'s' if n != 1 else ''} ({elapsed:.1f}s)")
|
|
except SkillLoadError as exc:
|
|
elapsed = time.time() - t0
|
|
print(f" ⚠️ SKIP ({exc}) ({elapsed:.1f}s)")
|
|
report.skills_skipped.append({"skill": str(skill_dir), "reason": str(exc)})
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
elapsed = time.time() - t0
|
|
print(f" ❌ ERROR ({exc}) ({elapsed:.1f}s)")
|
|
report.skills_skipped.append({"skill": str(skill_dir), "reason": str(exc)})
|
|
|
|
if len(loaded_skills) > 1:
|
|
print("\n Running cross-skill overlap analysis ...", end="", flush=True)
|
|
t0 = time.time()
|
|
try:
|
|
from skill_scanner.core.analyzers.cross_skill_scanner import CrossSkillScanner
|
|
|
|
overlap = scanner._check_description_overlap(loaded_skills) or []
|
|
cross = CrossSkillScanner().analyze_skill_set(loaded_skills) or []
|
|
all_cross = [*overlap, *cross]
|
|
if scanner.policy.disabled_rules:
|
|
all_cross = [f for f in all_cross if f.rule_id not in scanner.policy.disabled_rules]
|
|
if all_cross:
|
|
scanner._apply_severity_overrides(all_cross)
|
|
report.add_cross_skill_findings(all_cross)
|
|
elapsed = time.time() - t0
|
|
print(f" {len(all_cross)} finding{'s' if len(all_cross) != 1 else ''} ({elapsed:.1f}s)")
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
print(f" error: {exc}")
|
|
|
|
return report
|
|
|
|
|
|
def _loc(finding) -> str | None:
|
|
if not finding.file_path:
|
|
return None
|
|
loc = finding.file_path
|
|
if finding.line_number:
|
|
loc += f":{finding.line_number}"
|
|
return loc
|
|
|
|
|
|
def format_comment(report: Report, scanned_dirs: list[Path]) -> str:
|
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
lines: list[str] = [COMMENT_MARKER, "## 🛡️ Skill Security Scan", ""]
|
|
lines.append(f"_Generated at {now}_")
|
|
lines.append("")
|
|
|
|
if not scanned_dirs:
|
|
lines.append("No skill directories with a `SKILL.md` were changed in this PR — nothing to scan.")
|
|
return "\n".join(lines)
|
|
|
|
lines.append(f"**Skills scanned:** {report.total_skills_scanned} ")
|
|
lines.append(f"**Total findings:** {report.total_findings} ")
|
|
lines.append(
|
|
f"**Critical:** {report.critical_count} | "
|
|
f"**High:** {report.high_count} | "
|
|
f"**Safe:** {report.safe_count}/{report.total_skills_scanned}"
|
|
)
|
|
lines.append("")
|
|
|
|
if report.scan_results:
|
|
lines.append("| Skill | Severity | Findings | Safe |")
|
|
lines.append("|-------|----------|----------|------|")
|
|
sorted_results = sorted(
|
|
report.scan_results,
|
|
key=lambda r: SEVERITY_ORDER.index(_sev_str(r)),
|
|
)
|
|
for result in sorted_results:
|
|
sev = _sev_str(result)
|
|
safe = "✅" if result.is_safe else "❌"
|
|
lines.append(
|
|
f"| `{result.skill_name}` | {severity_badge(sev)} | {len(result.findings)} | {safe} |"
|
|
)
|
|
lines.append("")
|
|
|
|
flagged = [r for r in sorted_results if r.findings]
|
|
if flagged:
|
|
lines.append("### Findings")
|
|
lines.append("")
|
|
for result in flagged:
|
|
sev = _sev_str(result)
|
|
n = len(result.findings)
|
|
lines.append(
|
|
f"<details><summary><code>{result.skill_name}</code> — "
|
|
f"{severity_badge(sev)} ({n} finding{'s' if n != 1 else ''})</summary>"
|
|
)
|
|
lines.append("")
|
|
for finding in result.findings:
|
|
fsev = _sev_str(finding)
|
|
lines.append(
|
|
f"- **{severity_badge(fsev)}** `{finding.rule_id}` — {finding.title}"
|
|
)
|
|
if finding.description:
|
|
lines.append(f" > {finding.description}")
|
|
loc = _loc(finding)
|
|
if loc:
|
|
lines.append(f" > File: `{loc}`")
|
|
if finding.remediation:
|
|
lines.append(f" > **Remediation:** {finding.remediation}")
|
|
lines.append("")
|
|
lines.append("</details>")
|
|
lines.append("")
|
|
|
|
if report.skills_skipped:
|
|
lines.append("### Skipped")
|
|
lines.append("")
|
|
for entry in report.skills_skipped:
|
|
lines.append(f"- `{entry['skill']}`: {entry['reason']}")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _should_block(report: Report, fail_on: str) -> tuple[bool, str | None]:
|
|
if fail_on == "NEVER":
|
|
return False, None
|
|
blocking_ranks = SEVERITY_ORDER[: SEVERITY_ORDER.index(fail_on) + 1]
|
|
for result in report.scan_results:
|
|
sev = _sev_str(result)
|
|
if sev in blocking_ranks:
|
|
return True, f"{sev} finding(s) in {result.skill_name}"
|
|
return False, None
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("skill_dirs", nargs="*", help="Skill directories to scan")
|
|
parser.add_argument(
|
|
"--output",
|
|
default="pr_scan_comment.md",
|
|
help="Path to write the PR comment markdown (default: pr_scan_comment.md)",
|
|
)
|
|
parser.add_argument(
|
|
"--fail-on",
|
|
default="CRITICAL",
|
|
choices=["CRITICAL", "HIGH", "MEDIUM", "LOW", "NEVER"],
|
|
help="Exit non-zero if any scanned skill has a finding at this severity or higher",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
scan_targets: list[Path] = []
|
|
for raw in args.skill_dirs:
|
|
p = Path(raw)
|
|
if not p.is_dir():
|
|
print(f" SKIP {raw} (not a directory)")
|
|
continue
|
|
if not (p / "SKILL.md").exists():
|
|
print(f" SKIP {raw} (no SKILL.md)")
|
|
continue
|
|
scan_targets.append(p)
|
|
|
|
if not scan_targets:
|
|
print("No skill directories to scan — writing no-op comment.")
|
|
md = format_comment(Report(), [])
|
|
Path(args.output).write_text(md)
|
|
return 0
|
|
|
|
print("Building scanner (LLM + behavioral + trigger + balanced policy)...")
|
|
scanner = build_scanner()
|
|
print(f"Analyzers: {scanner.list_analyzers()}\n")
|
|
|
|
print(f"Scanning {len(scan_targets)} skill(s):")
|
|
for d in scan_targets:
|
|
print(f" - {d}")
|
|
print()
|
|
|
|
report = scan_skill_dirs(scanner, scan_targets)
|
|
|
|
print(
|
|
f"\nResults: {report.total_skills_scanned} skills, {report.total_findings} findings "
|
|
f"(Critical: {report.critical_count} High: {report.high_count} Safe: {report.safe_count})"
|
|
)
|
|
|
|
md = format_comment(report, scan_targets)
|
|
Path(args.output).write_text(md)
|
|
print(f"Comment written to {args.output}")
|
|
|
|
blocked, reason = _should_block(report, args.fail_on)
|
|
if blocked:
|
|
print(f"\n❌ Blocking: {reason} (--fail-on {args.fail_on})")
|
|
return 1
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|