mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
* chore(ci): add security-audit workflow and plugin security-proof scripts - scripts/prove_security.py, audit_plugins.py, generate_report.py -- automated checks (dangerous eval()/exec() calls, dependency scanning, report generation) for plugins. - .github/workflows/security-audit.yml + bandit.yaml -- CI wiring for the above plus gitleaks secret scanning and bandit static analysis. - .github/workflows/tests.yml -- pytest matrix across Python 3.10-3.12. Also fixes two Codacy findings while these files are freshly landing: - prove_security.py: dropped a pointless f-string prefix with no placeholders. - security-audit.yml: pinned gitleaks/gitleaks-action to a full commit SHA (matching this repo's existing pinning convention in test.yml) instead of the floating v2 tag. Split out of the original chore/dead-code-removal commit, which had accidentally bundled this in alongside unrelated dead-code deletions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * chore: drop workflow files -- pushed separately (needs workflow OAuth scope) * fix(security-tooling): address PR review findings across bandit.yaml, audit_plugins.py, generate_report.py, prove_security.py bandit.yaml: - Removed scripts/prove_security.py's file-level exclusion. Ran bandit directly to get ground truth: the real false positive is B105 (dict key "PASS" misread as password-like), not the eval/exec pattern the old comment claimed. Added a targeted # nosec B105 there, and found+fixed the identical pattern already present in generate_report.py. - Left the repo-wide B607 skip as-is: confirmed via AST scan that properly narrowing it touches 100+ bare-name subprocess call sites across wifi_manager.py, store_manager.py, permission_utils.py, app.py, and start.py -- none of which are part of this PR. Out of proportion to fix here; flagged as a dedicated follow-up. scripts/audit_plugins.py: - SyntaxError/OSError while scanning a plugin file now report CRITICAL (blocking) instead of WARNING/INFO -- a file that couldn't be parsed or read was never actually checked for danger, so it must not silently pass the audit. - --plugin <name> now tracks whether the requested plugin was found across all PLUGIN_BASE_DIRS and exits 1 with a clear error if not, instead of silently scanning zero plugins and reporting success. - The AST visitor now tracks import aliases (import subprocess as sp; from builtins import eval as e) and resolves them before checking against dangerous APIs, closing a straightforward evasion of every PLUGIN-001 through PLUGIN-005 check. Verified against both aliased and unaliased evasion patterns. scripts/generate_report.py: - _md_table_row now escapes pipe characters and normalizes newlines in every cell, so scanner-controlled content (a matched secret, a bandit issue_text) can't corrupt the Markdown table structure. - _load now distinguishes "artifact missing/malformed" from "valid empty result": each summarizer returns an availability flag, and main() now reports INCOMPLETE (not PASSED) with exit code 1 when any artifact is unavailable, instead of silently folding it in as 0 findings. - Gitleaks suppression now uses exact-match placeholder values (pulled from the actual config_secrets.template.json) plus a template-path allowlist, replacing broad substring checks that could hide a real secret containing something like "example.com" as part of its value. scripts/prove_security.py: - T1b (dangerous plugin calls): a file that fails to parse/read now reports CRITICAL with the exception details instead of being silently swallowed by `except (SyntaxError, OSError): pass`. - T6 (Docker hardening): base images must now be pinned to an @sha256 digest; a specific tag like python:3.12 is mutable and is now correctly flagged as unpinned, not just missing tags or :latest. - T2a (API surface): no config mechanism for enforcing local-only access exists in this codebase today (app.py hardcodes host='0.0.0.0'), so the "environment-aware" check as described isn't buildable without adding new config infrastructure -- out of scope here. Applied the achievable part: upgraded from INFO to WARNING, since enforcement can never currently be confirmed. - T1a (zip-slip): replaced the whole-file substring check with an AST walk that finds every extract()/extractall() call and confirms an is_relative_to() guard + "Zip-slip detected" log precede it in the same function. Verified it still passes on the real store_manager.py (both the per-member and validate-then-bulk-extract call sites) and correctly flags a synthetic unguarded extractall(). - T3a (hardcoded secrets): violation details no longer include the matched credential text -- only file, line, pattern type, and a redacted SHA-256 fingerprint, so a real finding doesn't get published into CI logs/artifacts/PR comments with wider exposure than the original leak. Verified with a synthetic secret that no raw content reaches the output. Validated: all four files compile; bandit scans all three scripts clean (2 legitimate targeted suppressions, 0 unaddressed findings); each new/ changed code path exercised directly (alias evasion, unmatched --plugin, missing/malformed/valid-empty artifacts, digest-pinning, zip-slip guard/no-guard, secret redaction); full audit_plugins.py -> prove_security.py -> generate_report.py pipeline run end-to-end producing a correct report. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(security-tooling): address follow-up review findings on PR #414 scripts/generate_report.py: - Added an explicit `object` type annotation to _md_sanitize_cell's value parameter -- it deliberately accepts any stringifiable value (calls str(value) unconditionally), so `object` reflects its actual contract more accurately than leaving it untyped. scripts/prove_security.py: - Dockerfile FROM-line parsing: renamed the comprehension variable `l` to `line` (ambiguous single-letter name). More importantly, fixed a real false-positive: `FROM --platform=<platform> <image>` was reading the --platform= flag itself as the image token, so a properly digest-pinned image behind a platform flag was incorrectly reported as unpinned. Verified against platform+digest, platform+tag-only, and digest+AS-alias Dockerfiles. scripts/audit_plugins.py: - Consolidated visit_Call's dangerous-API detection: previously, alias resolution only covered ast.Name calls for eval/exec/compile and ast.Attribute calls for subprocess/os.system, missing from-imported subprocess/os functions called as bare names (from subprocess import run as prun; prun(cmd, shell=True) or from os import system as s; s(cmd)). Added _resolve_call_target() to resolve both call shapes to a single fully-qualified target, then run all five PLUGIN-00x checks against that one resolved value. Verified against 10 evasion combinations (from-import aliases, direct/attribute calls, aliased module imports) and confirmed zero false positives on benign os/ subprocess usage without shell=True. Validated: all three files compile, bandit scans clean (same 2 legitimate suppressions as before, 0 new findings), audit_plugins.py/prove_security.py re-run against the real repo with no regressions from the prior fix pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
345 lines
14 KiB
Python
345 lines
14 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] = []
|
||
# Local name -> real dotted path, so aliased imports and from-imports
|
||
# of dangerous APIs (import subprocess as sp; from builtins import
|
||
# eval as e) are still recognized in visit_Call below.
|
||
self._aliases: dict[str, str] = {}
|
||
|
||
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 _resolve(self, local_name: str) -> str:
|
||
"""Resolve a local name through recorded import aliases to its real
|
||
dotted path (e.g. "sp" -> "subprocess"); unresolved names pass through
|
||
unchanged."""
|
||
return self._aliases.get(local_name, local_name)
|
||
|
||
def _resolve_call_target(self, func: ast.expr) -> str | None:
|
||
"""Resolve a Call's func node to a fully-qualified dotted target,
|
||
covering a direct name (bare builtin, aliased import, or
|
||
from-import: from builtins import eval as e; from subprocess
|
||
import run; from os import system as s) and module-attribute
|
||
access (subprocess.run, sp.run, os.system, o.system) uniformly.
|
||
Returns None for call shapes this doesn't attempt to resolve."""
|
||
if isinstance(func, ast.Name):
|
||
return self._resolve(func.id)
|
||
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
||
base = self._resolve(func.value.id)
|
||
return f"{base}.{func.attr}"
|
||
return None
|
||
|
||
def visit_Call(self, node: ast.Call) -> None:
|
||
target = self._resolve_call_target(node.func)
|
||
if target is None:
|
||
self.generic_visit(node)
|
||
return
|
||
|
||
leaf = target.rsplit(".", 1)[-1]
|
||
|
||
# eval() / exec() / compile() — arbitrary code execution, whether a
|
||
# bare call, an aliased import, or a from-import
|
||
# (from builtins import eval as e; e(...))
|
||
if leaf == "eval":
|
||
self._add(node, "CRITICAL", "PLUGIN-001",
|
||
"eval() call — arbitrary code execution risk")
|
||
elif leaf == "exec":
|
||
self._add(node, "CRITICAL", "PLUGIN-002",
|
||
"exec() call — arbitrary code execution risk")
|
||
elif leaf == "compile":
|
||
self._add(node, "WARNING", "PLUGIN-003",
|
||
"compile() call — dynamic code compilation")
|
||
|
||
# subprocess.*(shell=True), whether subprocess.run(...), sp.run(...),
|
||
# or a from-import (from subprocess import run; run(..., shell=True))
|
||
if target in {
|
||
"subprocess.run", "subprocess.call", "subprocess.Popen",
|
||
"subprocess.check_call", "subprocess.check_output",
|
||
}:
|
||
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.{leaf}(shell=True) — "
|
||
f"shell injection risk if args include user input")
|
||
|
||
# os.system(), whether os.system(...), o.system(...), or a
|
||
# from-import (from os import system as s; s(...))
|
||
if target == "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:
|
||
if alias.asname:
|
||
local, real = alias.asname, alias.name
|
||
else:
|
||
# `import os.path` binds the top-level name `os`, not `os.path`
|
||
local = real = alias.name.split(".")[0]
|
||
self._aliases[local] = real
|
||
self._check_import(node, alias.name)
|
||
self.generic_visit(node)
|
||
|
||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||
if node.module:
|
||
for alias in node.names:
|
||
local = alias.asname or alias.name
|
||
self._aliases[local] = f"{node.module}.{alias.name}"
|
||
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:
|
||
# A file the visitor can't even parse is a file we can't verify
|
||
# is safe -- this must block the audit, not just warn.
|
||
findings.append(Finding(
|
||
plugin_id=plugin_id,
|
||
file=str(py_file.relative_to(PROJECT_ROOT)),
|
||
line=getattr(exc, "lineno", 0) or 0,
|
||
severity="CRITICAL",
|
||
rule="PLUGIN-030",
|
||
message=f"Python syntax error — cannot be parsed: {exc}",
|
||
))
|
||
except OSError as exc:
|
||
# Same reasoning as SyntaxError: an unreadable file was never
|
||
# actually scanned, so it must block rather than pass silently.
|
||
findings.append(Finding(
|
||
plugin_id=plugin_id,
|
||
file=str(py_file.relative_to(PROJECT_ROOT)),
|
||
line=0,
|
||
severity="CRITICAL",
|
||
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
|
||
plugin_found = args.plugin is None
|
||
|
||
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
|
||
if args.plugin:
|
||
plugin_found = True
|
||
|
||
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}")
|
||
|
||
if args.plugin and not plugin_found:
|
||
print(f"\n 🚨 Plugin '{args.plugin}' not found in any of "
|
||
f"{[str(d.relative_to(PROJECT_ROOT)) for d in PLUGIN_BASE_DIRS]} — "
|
||
f"nothing was audited")
|
||
return 1
|
||
|
||
# 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())
|