#!/usr/bin/env python3 """ LEDMatrix Plugin Security Auditor Performs AST-based security analysis of all Python files in plugin directories. Designed to run in CI — exits non-zero on CRITICAL findings only. Usage: python scripts/audit_plugins.py python scripts/audit_plugins.py --verbose python scripts/audit_plugins.py --plugin hello-world python scripts/audit_plugins.py --output results.json """ import ast import argparse import json import sys from dataclasses import dataclass, asdict from pathlib import Path from datetime import datetime, timezone PROJECT_ROOT = Path(__file__).resolve().parent.parent PLUGIN_BASE_DIRS = [ PROJECT_ROOT / "plugins", PROJECT_ROOT / "plugin-repos", ] # ───────────────────────────────────────────────────────────────────────────── # Finding dataclass # ───────────────────────────────────────────────────────────────────────────── @dataclass class Finding: plugin_id: str file: str line: int severity: str # CRITICAL | WARNING | INFO rule: str message: str def to_dict(self) -> dict: return asdict(self) # ───────────────────────────────────────────────────────────────────────────── # AST visitor # ───────────────────────────────────────────────────────────────────────────── class _PluginVisitor(ast.NodeVisitor): """Collect security findings from a single plugin Python file.""" def __init__(self, filepath: Path, plugin_id: str): self.filepath = filepath self.plugin_id = plugin_id self.findings: list[Finding] = [] def _add(self, node: ast.AST, severity: str, rule: str, message: str) -> None: self.findings.append(Finding( plugin_id=self.plugin_id, file=str(self.filepath.relative_to(PROJECT_ROOT)), line=getattr(node, "lineno", 0), severity=severity, rule=rule, message=message, )) def visit_Call(self, node: ast.Call) -> None: # eval() / exec() — arbitrary code execution if isinstance(node.func, ast.Name): if node.func.id == "eval": self._add(node, "CRITICAL", "PLUGIN-001", "eval() call — arbitrary code execution risk") elif node.func.id == "exec": self._add(node, "CRITICAL", "PLUGIN-002", "exec() call — arbitrary code execution risk") elif node.func.id == "compile": self._add(node, "WARNING", "PLUGIN-003", "compile() call — dynamic code compilation") # subprocess.*(shell=True) if isinstance(node.func, ast.Attribute): is_subprocess = ( isinstance(node.func.value, ast.Name) and node.func.value.id == "subprocess" and node.func.attr in ("run", "call", "Popen", "check_call", "check_output") ) if is_subprocess: for kw in node.keywords: if (kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True): self._add(node, "WARNING", "PLUGIN-004", f"subprocess.{node.func.attr}(shell=True) — " f"shell injection risk if args include user input") # os.system() — shell execution is_os_system = ( isinstance(node.func.value, ast.Name) and node.func.value.id == "os" and node.func.attr == "system" ) if is_os_system: self._add(node, "WARNING", "PLUGIN-005", "os.system() call — prefer subprocess with list args") self.generic_visit(node) def visit_Import(self, node: ast.Import) -> None: for alias in node.names: self._check_import(node, alias.name) self.generic_visit(node) def visit_ImportFrom(self, node: ast.ImportFrom) -> None: if node.module: self._check_import(node, node.module) self.generic_visit(node) def _check_import(self, node: ast.AST, module_name: str) -> None: dangerous = { "ctypes": ("WARNING", "PLUGIN-010", "ctypes import — native code execution"), "cffi": ("WARNING", "PLUGIN-011", "cffi import — native code execution"), "pickle": ("WARNING", "PLUGIN-012", "pickle import — deserialization can execute arbitrary code"), "marshal": ("WARNING", "PLUGIN-013", "marshal import — deserialization risk"), } for mod, (severity, rule, msg) in dangerous.items(): if module_name == mod or module_name.startswith(mod + "."): self._add(node, severity, rule, msg) # ───────────────────────────────────────────────────────────────────────────── # Per-plugin audit # ───────────────────────────────────────────────────────────────────────────── def audit_plugin(plugin_dir: Path) -> list[Finding]: """Audit a single plugin directory. Returns all findings.""" findings: list[Finding] = [] plugin_id = plugin_dir.name # Check for required files for required_file, rule, msg in [ ("manifest.json", "PLUGIN-020", "manifest.json missing — plugin may be incomplete"), ("config_schema.json", "PLUGIN-021", "config_schema.json missing — no input validation schema declared"), ]: if not (plugin_dir / required_file).exists(): findings.append(Finding( plugin_id=plugin_id, file=str((plugin_dir / required_file).relative_to(PROJECT_ROOT)), line=0, severity="WARNING", rule=rule, message=msg, )) # AST analysis of all Python files for py_file in sorted(plugin_dir.rglob("*.py")): try: source = py_file.read_text(encoding="utf-8") tree = ast.parse(source, filename=str(py_file)) visitor = _PluginVisitor(py_file, plugin_id) visitor.visit(tree) findings.extend(visitor.findings) except SyntaxError as exc: findings.append(Finding( plugin_id=plugin_id, file=str(py_file.relative_to(PROJECT_ROOT)), line=getattr(exc, "lineno", 0) or 0, severity="WARNING", rule="PLUGIN-030", message=f"Python syntax error — cannot be parsed: {exc}", )) except OSError as exc: findings.append(Finding( plugin_id=plugin_id, file=str(py_file.relative_to(PROJECT_ROOT)), line=0, severity="INFO", rule="PLUGIN-031", message=f"Could not read file: {exc}", )) return findings # ───────────────────────────────────────────────────────────────────────────── # Main # ───────────────────────────────────────────────────────────────────────────── def main() -> int: parser = argparse.ArgumentParser( description="LEDMatrix plugin security auditor", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--plugin", "-p", default=None, help="Audit a specific plugin ID only") parser.add_argument("--output", "-o", default=None, help="Write JSON results to this file") parser.add_argument("--verbose", "-v", action="store_true", help="Show all findings, not just summary") args = parser.parse_args() print("=" * 60) print("LEDMatrix Plugin Security Audit") print(f"Project root: {PROJECT_ROOT}") print("=" * 60) all_findings: list[Finding] = [] plugins_scanned = 0 for base_dir in PLUGIN_BASE_DIRS: if not base_dir.exists(): if args.verbose: print(f" ⏭️ Skipping {base_dir.name}/ (directory not found)") continue base_label = base_dir.relative_to(PROJECT_ROOT) print(f"\n Scanning {base_label}/") for plugin_dir in sorted(base_dir.iterdir()): if not plugin_dir.is_dir(): continue if plugin_dir.name.startswith((".", "_")): continue if args.plugin and plugin_dir.name != args.plugin: continue findings = audit_plugin(plugin_dir) all_findings.extend(findings) plugins_scanned += 1 critical = [f for f in findings if f.severity == "CRITICAL"] warnings = [f for f in findings if f.severity == "WARNING"] if critical: icon, label = "🚨", "CRITICAL" elif warnings: icon, label = "⚠️ ", "WARN " else: icon, label = "✅", "PASS " print(f" {icon} [{label}] {plugin_dir.name}" f" — {len(critical)} critical, {len(warnings)} warnings") if args.verbose: for f in findings: severity_icon = {"CRITICAL": "🚨", "WARNING": "⚠️ ", "INFO": "ℹ️ "}.get( f.severity, " " ) print(f" {severity_icon} {f.rule} {f.file}:{f.line} — {f.message}") # Summary critical_findings = [f for f in all_findings if f.severity == "CRITICAL"] warning_findings = [f for f in all_findings if f.severity == "WARNING"] print(f"\n{'=' * 60}") print(f" Plugins scanned : {plugins_scanned}") print(f" CRITICAL : {len(critical_findings)}") print(f" WARNING : {len(warning_findings)}") if critical_findings: print("\n 🚨 CRITICAL findings:") for f in critical_findings: print(f" {f.plugin_id} | {Path(f.file).name}:{f.line} | {f.message}") # Write JSON output if args.output: output_data = { "timestamp": datetime.now(timezone.utc).isoformat(), "plugins_scanned": plugins_scanned, "summary": { "critical": len(critical_findings), "warnings": len(warning_findings), }, "findings": [f.to_dict() for f in all_findings], } Path(args.output).write_text( json.dumps(output_data, indent=2), encoding="utf-8" ) print(f"\n Results written to: {args.output}") if critical_findings: print("\n 🚨 Blocking — CRITICAL issues must be resolved") return 1 print("\n ✅ No critical issues found") return 0 if __name__ == "__main__": sys.exit(main())