mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Deletions, each re-verified with a fresh repo-wide grep (core, web, scripts, docs, plugin monorepo) immediately before removal: Modules with zero live importers: - src/background_cache_mixin.py + src/generic_cache_mixin.py (134+150 LOC — referenced only by each other) - src/font_test_manager.py (134 LOC) - src/image_utils.py (22 LOC, self-documented deprecated) - src/layout_manager.py (408 LOC — only its own test imported it) + test/test_layout_manager.py - src/common/basketball_plugin_example.py (328 LOC sample) requirements.txt entries with zero importers in core (pre-plugin-era manager deps): icalevents, geopy, timezonefinder, unidecode. Plus the google-auth trio (google-auth-oauthlib, google-auth-httplib2, google-api-python-client): their only importer is the calendar PLUGIN, which declares all three in its own requirements.txt (verified in the monorepo and on an installed copy) — the plugin dependency installer owns them. Existing venvs are unaffected (removal doesn't uninstall); fresh installs get them when calendar is installed. Two stale references cleaned (a comment in test_pillow_compat.py, a directory listing in HOW_TO_RUN_TESTS.md). Full suite green except the two documented pre-existing failures (circuit_breaker mock drift, fixed in #400; clock-simple 64x32 overflow, pre-dates this series); all core entry modules verified importing cleanly under the emulator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
297 lines
12 KiB
Python
297 lines
12 KiB
Python
#!/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())
|