275 lines
11 KiB
Python
275 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import ast
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError: # pragma: no cover
|
|
yaml = None
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SCAN_DIRS = ["agents", "docs", "evals", "references", "runtime", "scripts", "security", "skill-ir", "templates"]
|
|
ROOT_FILES = ["SKILL.md", "README.md", "manifest.json", "requirements-ci.txt", "Makefile"]
|
|
TEXT_SUFFIXES = {".md", ".json", ".jsonl", ".yaml", ".yml", ".py", ".sh", ".txt", ".toml"}
|
|
SECRET_PATTERNS = [
|
|
("private_key", re.compile(r"-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----")),
|
|
("github_token", re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b")),
|
|
("slack_token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b")),
|
|
("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
|
("openai_api_key", re.compile(r"\bsk-[A-Za-z0-9]{32,}\b")),
|
|
]
|
|
|
|
|
|
def display_path(path: Path) -> str:
|
|
try:
|
|
return str(path.resolve().relative_to(ROOT.resolve()))
|
|
except ValueError:
|
|
return str(path.resolve())
|
|
|
|
|
|
def load_json(path: Path) -> dict[str, Any]:
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
|
|
def load_yaml(path: Path) -> dict[str, Any]:
|
|
if not path.exists() or yaml is None:
|
|
return {}
|
|
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
|
|
def iter_scan_files(skill_dir: Path) -> list[Path]:
|
|
files = []
|
|
for rel in ROOT_FILES:
|
|
path = skill_dir / rel
|
|
if path.exists() and path.is_file():
|
|
files.append(path)
|
|
for rel in SCAN_DIRS:
|
|
folder = skill_dir / rel
|
|
if not folder.exists():
|
|
continue
|
|
for path in sorted(folder.rglob("*")):
|
|
if not path.is_file() or path.is_symlink():
|
|
continue
|
|
if path.suffix in TEXT_SUFFIXES and path.stat().st_size <= 1_000_000:
|
|
files.append(path)
|
|
return sorted(set(files))
|
|
|
|
|
|
def relpath(skill_dir: Path, path: Path) -> str:
|
|
return str(path.relative_to(skill_dir))
|
|
|
|
|
|
def scan_secrets(skill_dir: Path, files: list[Path]) -> list[dict[str, Any]]:
|
|
findings = []
|
|
for path in files:
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
for name, pattern in SECRET_PATTERNS:
|
|
for match in pattern.finditer(text):
|
|
line = text.count("\n", 0, match.start()) + 1
|
|
findings.append({"type": name, "path": relpath(skill_dir, path), "line": line})
|
|
return findings
|
|
|
|
|
|
def script_inventory(skill_dir: Path) -> list[dict[str, Any]]:
|
|
scripts_dir = skill_dir / "scripts"
|
|
if not scripts_dir.exists():
|
|
return []
|
|
inventory = []
|
|
for path in sorted(scripts_dir.glob("*.py")):
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
flags = script_flags(text)
|
|
inventory.append(
|
|
{
|
|
"path": relpath(skill_dir, path),
|
|
"has_argparse": "argparse" in text,
|
|
"has_main_guard": 'if __name__ == "__main__"' in text,
|
|
"uses_input": flags["uses_input"],
|
|
"uses_network": flags["uses_network"],
|
|
"uses_subprocess": flags["uses_subprocess"],
|
|
}
|
|
)
|
|
return inventory
|
|
|
|
|
|
def call_name(node: ast.AST) -> str:
|
|
if isinstance(node, ast.Name):
|
|
return node.id
|
|
if isinstance(node, ast.Attribute):
|
|
base = call_name(node.value)
|
|
return f"{base}.{node.attr}" if base else node.attr
|
|
return ""
|
|
|
|
|
|
def script_flags(text: str) -> dict[str, bool]:
|
|
try:
|
|
tree = ast.parse(text)
|
|
except SyntaxError:
|
|
return {
|
|
"uses_input": bool(re.search(r"\b(?:input|getpass)\s*\(", text)),
|
|
"uses_network": bool(re.search(r"\b(?:urlopen|Request)\s*\(|\brequests\.", text)),
|
|
"uses_subprocess": "subprocess." in text,
|
|
}
|
|
calls = [call_name(node.func) for node in ast.walk(tree) if isinstance(node, ast.Call)]
|
|
return {
|
|
"uses_input": any(name in {"input", "getpass.getpass"} or name.endswith(".getpass") for name in calls),
|
|
"uses_network": any(name in {"urlopen", "Request"} or name.startswith("requests.") for name in calls),
|
|
"uses_subprocess": any(name.startswith("subprocess.") for name in calls),
|
|
}
|
|
|
|
|
|
def dependency_status(skill_dir: Path) -> dict[str, Any]:
|
|
candidates = ["requirements-ci.txt", "requirements.txt", "pyproject.toml", "package-lock.json", "uv.lock", "poetry.lock"]
|
|
present = [name for name in candidates if (skill_dir / name).exists()]
|
|
pinned = []
|
|
unpinned = []
|
|
requirements = skill_dir / "requirements-ci.txt"
|
|
if requirements.exists():
|
|
for line in requirements.read_text(encoding="utf-8").splitlines():
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
if "==" in stripped or " @ " in stripped:
|
|
pinned.append(stripped)
|
|
else:
|
|
unpinned.append(stripped)
|
|
return {"present": present, "pinned": pinned, "unpinned": unpinned}
|
|
|
|
|
|
def interface_trust(skill_dir: Path) -> dict[str, Any]:
|
|
interface = load_yaml(skill_dir / "agents" / "interface.yaml")
|
|
trust = interface.get("compatibility", {}).get("trust", {})
|
|
return {
|
|
"source_tier": trust.get("source_tier", ""),
|
|
"remote_inline_execution": trust.get("remote_inline_execution", ""),
|
|
"remote_metadata_policy": trust.get("remote_metadata_policy", ""),
|
|
}
|
|
|
|
|
|
def package_digest(files: list[Path], skill_dir: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
for path in files:
|
|
rel = relpath(skill_dir, path)
|
|
digest.update(rel.encode("utf-8"))
|
|
digest.update(b"\0")
|
|
digest.update(hashlib.sha256(path.read_bytes()).hexdigest().encode("ascii"))
|
|
digest.update(b"\0")
|
|
return digest.hexdigest()
|
|
|
|
|
|
def build_trust_report(skill_dir: Path) -> dict[str, Any]:
|
|
skill_dir = skill_dir.resolve()
|
|
files = iter_scan_files(skill_dir)
|
|
secrets = scan_secrets(skill_dir, files)
|
|
scripts = script_inventory(skill_dir)
|
|
deps = dependency_status(skill_dir)
|
|
trust = interface_trust(skill_dir)
|
|
failures = []
|
|
warnings = []
|
|
|
|
if secrets:
|
|
failures.append(f"High-risk secret patterns found: {len(secrets)}")
|
|
if trust.get("remote_inline_execution") not in {"forbid", "deny", "false"}:
|
|
failures.append("remote_inline_execution must be forbid for governed release")
|
|
if deps["unpinned"]:
|
|
warnings.append(f"Unpinned dependency entries: {', '.join(deps['unpinned'])}")
|
|
if not deps["present"]:
|
|
warnings.append("No dependency or lock file detected")
|
|
missing_help = [item["path"] for item in scripts if not item["has_argparse"]]
|
|
if missing_help:
|
|
warnings.append(f"Scripts without argparse/help surface: {', '.join(missing_help[:8])}")
|
|
interactive = [item["path"] for item in scripts if item["uses_input"]]
|
|
if interactive:
|
|
warnings.append(f"Interactive scripts require reviewer awareness: {', '.join(interactive[:8])}")
|
|
network = [item["path"] for item in scripts if item["uses_network"]]
|
|
if network:
|
|
warnings.append(f"Network-capable scripts require bounded host policy: {', '.join(network[:8])}")
|
|
|
|
summary = {
|
|
"scanned_files": len(files),
|
|
"script_count": len(scripts),
|
|
"secret_findings": len(secrets),
|
|
"dependency_files": deps["present"],
|
|
"network_script_count": len(network),
|
|
"interactive_script_count": len(interactive),
|
|
"package_sha256": package_digest(files, skill_dir),
|
|
}
|
|
return {
|
|
"ok": not failures,
|
|
"skill_dir": display_path(skill_dir),
|
|
"summary": summary,
|
|
"failures": failures,
|
|
"warnings": warnings,
|
|
"secrets": secrets,
|
|
"scripts": scripts,
|
|
"dependencies": deps,
|
|
"trust_metadata": trust,
|
|
}
|
|
|
|
|
|
def render_markdown(payload: dict[str, Any]) -> str:
|
|
summary = payload["summary"]
|
|
lines = [
|
|
"# Security Trust Report",
|
|
"",
|
|
f"- OK: `{payload['ok']}`",
|
|
f"- Scanned files: `{summary['scanned_files']}`",
|
|
f"- Scripts: `{summary['script_count']}`",
|
|
f"- Secret findings: `{summary['secret_findings']}`",
|
|
f"- Network-capable scripts: `{summary['network_script_count']}`",
|
|
f"- Interactive scripts: `{summary['interactive_script_count']}`",
|
|
f"- Package SHA256: `{summary['package_sha256']}`",
|
|
"",
|
|
"## Failures",
|
|
"",
|
|
]
|
|
lines.extend([f"- {item}" for item in payload["failures"]] or ["- None"])
|
|
lines.extend(["", "## Warnings", ""])
|
|
lines.extend([f"- {item}" for item in payload["warnings"]] or ["- None"])
|
|
lines.extend(["", "## Dependency Evidence", ""])
|
|
lines.append(f"- Files: `{', '.join(payload['dependencies']['present']) or 'none'}`")
|
|
lines.append(f"- Pinned entries: `{len(payload['dependencies']['pinned'])}`")
|
|
lines.append(f"- Unpinned entries: `{len(payload['dependencies']['unpinned'])}`")
|
|
lines.extend(["", "## Script Surface", "", "| Script | Argparse | Main Guard | Input | Network | Subprocess |", "| --- | --- | --- | --- | --- | --- |"])
|
|
for item in payload["scripts"]:
|
|
lines.append(
|
|
f"| {item['path']} | {item['has_argparse']} | {item['has_main_guard']} | {item['uses_input']} | {item['uses_network']} | {item['uses_subprocess']} |"
|
|
)
|
|
return "\n".join(lines).strip() + "\n"
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Run Skill OS trust and security checks.")
|
|
parser.add_argument("skill_dir", nargs="?", default=".")
|
|
parser.add_argument("--output-json")
|
|
parser.add_argument("--output-md")
|
|
args = parser.parse_args()
|
|
|
|
skill_dir = Path(args.skill_dir).resolve()
|
|
output_json = Path(args.output_json).resolve() if args.output_json else skill_dir / "reports" / "security_trust_report.json"
|
|
output_md = Path(args.output_md).resolve() if args.output_md else skill_dir / "reports" / "security_trust_report.md"
|
|
payload = build_trust_report(skill_dir)
|
|
payload["artifacts"] = {"json": display_path(output_json), "markdown": display_path(output_md)}
|
|
output_json.parent.mkdir(parents=True, exist_ok=True)
|
|
output_md.parent.mkdir(parents=True, exist_ok=True)
|
|
output_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
output_md.write_text(render_markdown(payload), encoding="utf-8")
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
raise SystemExit(0 if payload["ok"] else 2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|