Files
yao-meta-skill/scripts/trust_check.py
T
2026-06-13 12:34:19 +08:00

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