Files
k-dense-ai--scientific-agen…/scan_pr_skills.py
T
2026-07-13 12:12:01 +08:00

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())