mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +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
298 lines
11 KiB
Python
298 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Security Report Generator
|
||
|
||
Aggregates JSON output from all CI security audit jobs into a single
|
||
Markdown report suitable for PR comments and artifact storage.
|
||
|
||
Expected artifact layout (from actions/download-artifact@v4):
|
||
<artifact-dir>/
|
||
sast-results/
|
||
bandit-results.json
|
||
semgrep-results.json
|
||
dependency-audit-results/
|
||
pip-audit-results.json
|
||
safety-results.json
|
||
secrets-scan-results/
|
||
gitleaks-results.json
|
||
security-proofs-results/
|
||
security-proofs-results.json
|
||
plugin-audit-results/
|
||
plugin-audit-results.json
|
||
|
||
Usage:
|
||
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md
|
||
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md --verbose
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
from datetime import datetime, timezone
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||
|
||
# Gitleaks matches containing these strings are template placeholders, not real secrets
|
||
_GITLEAKS_SUPPRESS = [
|
||
"YOUR_",
|
||
"PLACEHOLDER",
|
||
"_HERE",
|
||
"example.com",
|
||
"config_secrets.template",
|
||
]
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Helpers
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _load(path: Path) -> dict | list | None:
|
||
"""Load JSON file, returning None on any error."""
|
||
try:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
except (json.JSONDecodeError, FileNotFoundError, OSError):
|
||
return None
|
||
|
||
|
||
def _md_table_row(*cells: str) -> str:
|
||
return "| " + " | ".join(str(c) for c in cells) + " |"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Per-tool summarizers
|
||
# Returns: (markdown_lines: list[str], critical_count: int)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _summarize_bandit(artifact_dir: Path) -> tuple[list[str], int]:
|
||
data = _load(artifact_dir / "sast-results" / "bandit-results.json")
|
||
if data is None:
|
||
return ["_bandit results not available_"], 0
|
||
|
||
results = data.get("results", [])
|
||
high = [r for r in results if r.get("issue_severity") == "HIGH"]
|
||
medium = [r for r in results if r.get("issue_severity") == "MEDIUM"]
|
||
low = [r for r in results if r.get("issue_severity") == "LOW"]
|
||
|
||
lines = [
|
||
f"**Bandit**: {len(high)} HIGH · {len(medium)} MEDIUM · {len(low)} LOW"
|
||
]
|
||
|
||
if high:
|
||
lines += [
|
||
"",
|
||
"| Severity | File | Line | Issue |",
|
||
"| --- | --- | --- | --- |",
|
||
]
|
||
for r in high[:10]:
|
||
fname = Path(r.get("filename", "")).name
|
||
lines.append(_md_table_row(
|
||
"HIGH", f"`{fname}`",
|
||
str(r.get("line_number", "?")),
|
||
r.get("issue_text", "")
|
||
))
|
||
if len(high) > 10:
|
||
lines.append(f"_… and {len(high) - 10} more HIGH findings_")
|
||
|
||
return lines, len(high)
|
||
|
||
|
||
def _summarize_pip_audit(artifact_dir: Path) -> tuple[list[str], int]:
|
||
data = _load(artifact_dir / "dependency-audit-results" / "pip-audit-results.json")
|
||
if data is None:
|
||
return ["_pip-audit results not available_"], 0
|
||
|
||
# pip-audit JSON format: {"dependencies": [{"name": ..., "vulns": [...]}]}
|
||
vulns: list[dict] = []
|
||
for dep in data.get("dependencies", []):
|
||
for v in dep.get("vulns", []):
|
||
vulns.append({"package": dep.get("name", "?"), **v})
|
||
|
||
lines = [f"**pip-audit**: {len(vulns)} vulnerabilities found"]
|
||
|
||
if vulns:
|
||
lines += ["", "| Package | ID | Fix |", "| --- | --- | --- |"]
|
||
for v in vulns[:10]:
|
||
fix = v.get("fix_versions", ["none"])
|
||
fix_str = ", ".join(fix) if fix else "none"
|
||
lines.append(_md_table_row(
|
||
v.get("package", "?"),
|
||
v.get("id", "?"),
|
||
fix_str,
|
||
))
|
||
|
||
# Treat known vulnerabilities as warnings, not critical (they may be unavoidable)
|
||
return lines, 0
|
||
|
||
|
||
def _summarize_gitleaks(artifact_dir: Path) -> tuple[list[str], int]:
|
||
data = _load(artifact_dir / "secrets-scan-results" / "gitleaks-results.json")
|
||
if data is None:
|
||
return ["_gitleaks results not available_"], 0
|
||
|
||
if not isinstance(data, list):
|
||
data = []
|
||
|
||
real_findings = []
|
||
suppressed = 0
|
||
for finding in data:
|
||
secret_val = str(finding.get("Secret", "") or finding.get("Match", ""))
|
||
if any(p in secret_val for p in _GITLEAKS_SUPPRESS):
|
||
suppressed += 1
|
||
else:
|
||
real_findings.append(finding)
|
||
|
||
lines = [
|
||
f"**Gitleaks**: {len(real_findings)} finding(s) "
|
||
f"({suppressed} suppressed as template placeholders)"
|
||
]
|
||
|
||
if real_findings:
|
||
lines += ["", "| Rule | File | Line | Description |", "| --- | --- | --- | --- |"]
|
||
for f in real_findings[:10]:
|
||
fname = Path(f.get("File", "")).name
|
||
lines.append(_md_table_row(
|
||
f.get("RuleID", "?"),
|
||
f"`{fname}`",
|
||
str(f.get("StartLine", "?")),
|
||
f.get("Description", ""),
|
||
))
|
||
|
||
critical = len(real_findings) # any real secret is critical
|
||
return lines, critical
|
||
|
||
|
||
def _summarize_security_proofs(artifact_dir: Path) -> tuple[list[str], int]:
|
||
data = _load(artifact_dir / "security-proofs-results" / "security-proofs-results.json")
|
||
if data is None:
|
||
return ["_security proofs results not available_"], 0
|
||
|
||
if not isinstance(data, list):
|
||
data = []
|
||
|
||
critical = [r for r in data if r.get("severity") == "CRITICAL"]
|
||
warnings = [r for r in data if r.get("severity") == "WARNING"]
|
||
passed = [r for r in data if r.get("severity") == "PASS"]
|
||
skipped = [r for r in data if r.get("severity") == "SKIP"]
|
||
|
||
lines = [
|
||
f"**Security Proofs**: "
|
||
f"{len(passed)} PASS · {len(warnings)} WARN · "
|
||
f"{len(critical)} CRITICAL · {len(skipped)} SKIP",
|
||
"",
|
||
]
|
||
|
||
_icon = {"PASS": "✅", "INFO": "ℹ️", "WARNING": "⚠️",
|
||
"CRITICAL": "🚨", "SKIP": "⏭️"}
|
||
for r in data:
|
||
icon = _icon.get(r.get("severity", ""), "❓")
|
||
lines.append(
|
||
f"- {icon} **{r.get('test_id', '?')}**: {r.get('message', '')}"
|
||
)
|
||
if r.get("details") and r.get("severity") in ("CRITICAL", "WARNING"):
|
||
lines.append(f" - _{r['details']}_")
|
||
|
||
return lines, len(critical)
|
||
|
||
|
||
def _summarize_plugin_audit(artifact_dir: Path) -> tuple[list[str], int]:
|
||
data = _load(artifact_dir / "plugin-audit-results" / "plugin-audit-results.json")
|
||
if data is None:
|
||
return ["_plugin audit results not available_"], 0
|
||
|
||
summary = data.get("summary", {})
|
||
findings = data.get("findings", [])
|
||
critical_findings = [f for f in findings if f.get("severity") == "CRITICAL"]
|
||
warning_findings = [f for f in findings if f.get("severity") == "WARNING"]
|
||
|
||
lines = [
|
||
f"**Plugin Audit**: {data.get('plugins_scanned', '?')} plugins scanned — "
|
||
f"{summary.get('critical', 0)} CRITICAL · {summary.get('warnings', 0)} WARNINGS"
|
||
]
|
||
|
||
if critical_findings:
|
||
lines += ["", "| Plugin | File | Line | Rule | Message |",
|
||
"| --- | --- | --- | --- | --- |"]
|
||
for f in critical_findings[:10]:
|
||
fname = Path(f.get("file", "")).name
|
||
lines.append(_md_table_row(
|
||
f.get("plugin_id", "?"),
|
||
f"`{fname}`",
|
||
str(f.get("line", "?")),
|
||
f.get("rule", "?"),
|
||
f.get("message", ""),
|
||
))
|
||
|
||
if warning_findings and not critical_findings:
|
||
lines.append(f"\n_{len(warning_findings)} warning(s) found — see artifact for details_")
|
||
|
||
return lines, summary.get("critical", 0)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Main
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(
|
||
description="Generate consolidated security audit report",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
)
|
||
parser.add_argument("--artifact-dir", required=True,
|
||
help="Directory containing downloaded CI artifacts")
|
||
parser.add_argument("--output", "-o", required=True,
|
||
help="Output Markdown file path")
|
||
parser.add_argument("--verbose", "-v", action="store_true")
|
||
args = parser.parse_args()
|
||
|
||
artifact_dir = Path(args.artifact_dir)
|
||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||
|
||
bandit_lines, bandit_crit = _summarize_bandit(artifact_dir)
|
||
pip_audit_lines, pip_audit_crit = _summarize_pip_audit(artifact_dir)
|
||
gitleaks_lines, gitleaks_crit = _summarize_gitleaks(artifact_dir)
|
||
proofs_lines, proofs_crit = _summarize_security_proofs(artifact_dir)
|
||
plugins_lines, plugins_crit = _summarize_plugin_audit(artifact_dir)
|
||
|
||
total_critical = bandit_crit + pip_audit_crit + gitleaks_crit + proofs_crit + plugins_crit
|
||
overall = "ACTION REQUIRED 🚨" if total_critical > 0 else "PASSED ✅"
|
||
|
||
def section(title: str, lines: list[str]) -> str:
|
||
return f"### {title}\n\n" + "\n".join(lines) + "\n"
|
||
|
||
report = f"""## 🔒 Security Audit — {overall}
|
||
|
||
_Generated: {timestamp}_
|
||
|
||
| Critical | High/Warn | Overall |
|
||
| :---: | :---: | :---: |
|
||
| {'🚨 ' + str(total_critical) if total_critical else '✅ 0'} | ⚠️ see below | {overall} |
|
||
|
||
---
|
||
|
||
{section('SAST — Bandit', bandit_lines)}
|
||
{section('Dependencies — pip-audit', pip_audit_lines)}
|
||
{section('Secrets — Gitleaks', gitleaks_lines)}
|
||
{section('LEDMatrix Security Proofs', proofs_lines)}
|
||
{section('Plugin Security Audit', plugins_lines)}
|
||
---
|
||
|
||
_Total critical findings: **{total_critical}**_
|
||
"""
|
||
|
||
output_path = Path(args.output)
|
||
output_path.write_text(report, encoding="utf-8")
|
||
|
||
if args.verbose:
|
||
print(f" Report written to: {output_path}")
|
||
print(f" Status: {overall}")
|
||
print(f" Critical findings: {total_critical}")
|
||
print(f" bandit={bandit_crit} pip-audit={pip_audit_crit} "
|
||
f"gitleaks={gitleaks_crit} proofs={proofs_crit} plugins={plugins_crit}")
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|