| """Pro HTML report generator. Self-contained, print-friendly."""
|
| import html
|
| import json
|
|
|
| from .styles import REPORT_CSS, FP_GUIDE_HTML
|
|
|
|
|
| def _sev_class(sev: str) -> str:
|
| """Map raw severity to CSS class name."""
|
| return {
|
| "CRITICAL": "critical",
|
| "ERROR": "error", "HIGH": "error",
|
| "WARNING": "warning", "MEDIUM": "warning",
|
| "INFO": "info", "LOW": "info",
|
| }.get(sev.upper(), "info")
|
|
|
|
|
| def _render_finding(f: dict) -> str:
|
| sev = f["severity"].upper()
|
| sc = _sev_class(sev)
|
|
|
| owasp_html = " ".join(
|
| f'<span class="badge owasp">{html.escape(o)}</span>'
|
| for o in f["owasp"] if o != "UNMAPPED"
|
| )
|
| rem_html = ""
|
| if f["remediation"]:
|
| rem_html = (f'<div class="remediation"><strong>Fix:</strong> '
|
| f'{html.escape(f["remediation"])}</div>')
|
|
|
| return f"""
|
| <div class="finding {sc}">
|
| <div class="finding-header">
|
| <span class="badge {sc}">{html.escape(sev)}</span>
|
| <span class="badge {f['confidence']}">{f['confidence'].upper()}</span>
|
| <span class="badge category-{f['category']}">{f['category'].upper()}</span>
|
| <span class="badge tool">{html.escape(f['tool'])}/{html.escape(f['rule'])}</span>
|
| {owasp_html}
|
| </div>
|
| <div class="message">{html.escape(f['message'])}</div>
|
| <code>{html.escape(f['file'])}:{f['line']}</code>
|
| {rem_html}
|
| </div>
|
| """
|
|
|
|
|
| def _aggregate(findings: list[dict]) -> dict:
|
| by_sev: dict[str, int] = {}
|
| by_conf = {"confirmed": 0, "likely": 0, "possible": 0}
|
| by_cat: dict[str, int] = {}
|
| by_owasp = {}
|
|
|
| for f in findings:
|
| s = f["severity"].upper()
|
| by_sev[s] = by_sev.get(s, 0) + 1
|
| by_conf[f["confidence"]] = by_conf.get(f["confidence"], 0) + 1
|
| cat = f.get("category", "security")
|
| by_cat[cat] = by_cat.get(cat, 0) + 1
|
| for o in f["owasp"]:
|
| if o != "UNMAPPED":
|
| by_owasp[o] = by_owasp.get(o, 0) + 1
|
|
|
| return {"severity": by_sev, "confidence": by_conf,
|
| "category": by_cat, "owasp": by_owasp}
|
|
|
|
|
| def generate_html_report(findings: list[dict], scan_meta: dict) -> str:
|
| """Build a self-contained HTML report."""
|
| agg = _aggregate(findings)
|
|
|
| sec_findings = [f for f in findings if f["category"] == "security"]
|
| perf_findings = [f for f in findings if f["category"] == "performance"]
|
| ml_findings = [f for f in findings if f["category"] == "ml-security"]
|
| llm_findings = [f for f in findings if f["category"] in ("llm", "llm-security")]
|
| sc_findings = [f for f in findings if f["category"] == "supply-chain"]
|
|
|
| other_cats = {"security", "performance", "ml-security", "llm", "llm-security", "supply-chain"}
|
| other_findings = [f for f in findings if f["category"] not in other_cats]
|
|
|
| sec_html = "\n".join(_render_finding(f) for f in sec_findings)
|
| perf_html = "\n".join(_render_finding(f) for f in perf_findings)
|
| ml_html = "\n".join(_render_finding(f) for f in ml_findings)
|
| llm_html = "\n".join(_render_finding(f) for f in llm_findings)
|
| sc_html = "\n".join(_render_finding(f) for f in sc_findings)
|
| other_html = "\n".join(_render_finding(f) for f in other_findings)
|
|
|
| owasp_html = "".join(
|
| f'<div class="card"><div class="label">{html.escape(k)}</div>'
|
| f'<div class="value">{v}</div></div>'
|
| for k, v in sorted(agg["owasp"].items(), key=lambda x: -x[1])[:8]
|
| )
|
| owasp_section = (
|
| f'<section><h2>Top OWASP Categories</h2>'
|
| f'<div class="summary-grid">{owasp_html}</div></section>'
|
| if owasp_html else ''
|
| )
|
|
|
|
|
| cat_order = ["security", "ml-security", "supply-chain", "llm", "llm-security",
|
| "performance"]
|
| cat_label = {
|
| "security": "Security", "ml-security": "ML Security",
|
| "supply-chain": "Supply Chain", "llm": "LLM / AI",
|
| "llm-security": "LLM Security", "performance": "Performance",
|
| }
|
|
|
| ordered_cats = [c for c in cat_order if agg["category"].get(c, 0) > 0]
|
| extra_cats = [c for c in agg["category"] if c not in cat_order and agg["category"][c] > 0]
|
| cat_cards = "".join(
|
| f'<div class="card"><div class="label">{html.escape(cat_label.get(c, c.title()))}</div>'
|
| f'<div class="value">{agg["category"][c]}</div></div>'
|
| for c in ordered_cats + extra_cats
|
| )
|
|
|
| ml_section = (
|
| f'<section><h2>π€ ML / Model Security Findings ({len(ml_findings)})</h2>'
|
| f'{ml_html}</section>'
|
| ) if ml_findings or any(t == "ml-security" for t in agg["category"]) else ""
|
|
|
| _no_llm_html = '<p style="color:#6b7280">No LLM findings.</p>'
|
| llm_section = (
|
| f'<section><h2>π§ LLM / AI Findings ({len(llm_findings)})</h2>'
|
| f'{llm_html or _no_llm_html}</section>'
|
| ) if llm_findings else ""
|
|
|
| _no_sc_html = '<p style="color:#6b7280">No supply-chain findings.</p>'
|
| sc_section = (
|
| f'<section><h2>π Supply Chain Findings ({len(sc_findings)})</h2>'
|
| f'{sc_html or _no_sc_html}</section>'
|
| ) if sc_findings else ""
|
|
|
| other_section = (
|
| f'<section><h2>π Other Findings ({len(other_findings)})</h2>'
|
| f'{other_html}</section>'
|
| ) if other_findings else ""
|
|
|
| footer_text = scan_meta.get("footer", "HF Security Scanner")
|
|
|
| return f"""<!DOCTYPE html>
|
| <html lang="en">
|
| <head>
|
| <meta charset="UTF-8">
|
| <title>Security & Performance Report β {html.escape(scan_meta.get('target','?'))}</title>
|
| <style>{REPORT_CSS}</style>
|
| </head>
|
| <body>
|
| <div class="container">
|
| <header class="report-header">
|
| <h1>π Security & Performance Audit Report</h1>
|
| <div class="meta">
|
| <span><strong>Target:</strong> {html.escape(scan_meta.get('target','?'))}</span>
|
| <span><strong>Targets scanned:</strong> {scan_meta.get('n_targets',1)}</span>
|
| <span><strong>Generated:</strong> {scan_meta.get('timestamp','?')}</span>
|
| </div>
|
| </header>
|
|
|
| <section>
|
| <h2>Executive Summary</h2>
|
| <div class="summary-grid">
|
| <div class="card critical"><div class="label">Critical</div><div class="value">{agg['severity'].get('CRITICAL',0)}</div></div>
|
| <div class="card error"><div class="label">High / Error</div><div class="value">{agg['severity'].get('ERROR',0) + agg['severity'].get('HIGH',0)}</div></div>
|
| <div class="card warning"><div class="label">Medium / Warning</div><div class="value">{agg['severity'].get('WARNING',0) + agg['severity'].get('MEDIUM',0)}</div></div>
|
| <div class="card info"><div class="label">Low / Info</div><div class="value">{agg['severity'].get('INFO',0) + agg['severity'].get('LOW',0)}</div></div>
|
| <div class="card confirmed"><div class="label">Confirmed</div><div class="value">{agg['confidence'].get('confirmed',0)}</div></div>
|
| <div class="card likely"><div class="label">Likely</div><div class="value">{agg['confidence'].get('likely',0)}</div></div>
|
| <div class="card possible"><div class="label">Possible FP</div><div class="value">{agg['confidence'].get('possible',0)}</div></div>
|
| {cat_cards}
|
| </div>
|
| </section>
|
|
|
| {owasp_section}
|
|
|
| {FP_GUIDE_HTML}
|
|
|
| <section>
|
| <h2>π Security Findings ({len(sec_findings)})</h2>
|
| {sec_html or '<p style="color:#6b7280">No security findings.</p>'}
|
| </section>
|
|
|
| {ml_section}
|
|
|
| {sc_section}
|
|
|
| {llm_section}
|
|
|
| <section>
|
| <h2>β‘ Performance Findings ({len(perf_findings)})</h2>
|
| {perf_html or '<p style="color:#6b7280">No performance findings.</p>'}
|
| </section>
|
|
|
| {other_section}
|
|
|
| <footer>{html.escape(footer_text)}</footer>
|
| </div>
|
| </body>
|
| </html>
|
| """
|
|
|