""" reporting.py: Turn a :class:`ScanResult` into deliverables. Two output formats, both written from the same result object: * ``report.json``: the machine-readable record (CI gates, dashboards, diffing runs over time). * ``report.html``: a polished, fully self-contained page (inline CSS, no external assets) so it can be emailed or attached to an audit as-is. The HTML is rendered with Jinja2 and autoescaping on, so model responses, which are attacker-controlled and may contain markup, cannot inject script into the report. """ from __future__ import annotations import json from pathlib import Path from typing import Dict, List from jinja2 import Environment, FileSystemLoader, select_autoescape from .governance import _category_stats, _framework_for from .models import ScanResult, Severity _TEMPLATE_DIR = Path(__file__).parent / "templates" # Order severities high-to-low so dashboards and chart legends read top-down. _SEVERITY_ORDER = [ Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, ] # Hex colors for the CSS-only donut (conic-gradient). Chosen to read clearly on # both the light and dark report backgrounds. _SEVERITY_HEX = { Severity.CRITICAL: "#dc2626", # red-600 Severity.HIGH: "#ea580c", # orange-600 Severity.MEDIUM: "#d97706", # amber-600 Severity.LOW: "#0d9488", # teal-600 } def write_json_report(result: ScanResult, path: Path) -> Path: path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(result.to_dict(), indent=2), encoding="utf-8") return path def _category_rows(result: ScanResult) -> List[Dict[str, object]]: """Per-category coverage: probe count, finding count, and OWASP tag.""" counts: Dict[str, Dict[str, object]] = {} for outcome in result.outcomes: cat = outcome.probe.category row = counts.setdefault( cat, {"name": cat, "owasp": outcome.probe.owasp, "probes": 0, "findings": 0} ) row["probes"] = int(row["probes"]) + 1 if not row["owasp"] and outcome.probe.owasp: row["owasp"] = outcome.probe.owasp for finding in result.findings: if finding.category in counts: row = counts[finding.category] row["findings"] = int(row["findings"]) + 1 return [counts[k] for k in sorted(counts)] def _compliance_rows(result: ScanResult) -> List[Dict[str, object]]: """One row per probe category that maps it to its NIST AI RMF function, the ISO/IEC 42001 Annex A control area, and the observed coverage. Reuses the governance mapping tables so the recruiter-facing HTML report and the auditor-facing ``model_card.md`` never drift apart. """ stats = _category_stats(result) cat_owasp = {o.probe.category: o.probe.owasp for o in result.outcomes} rows: List[Dict[str, object]] = [] for category in sorted(stats): s = stats[category] fw = _framework_for(category) worst: Severity = s["worst"] # type: ignore[assignment] rows.append( { "category": category, "owasp": cat_owasp.get(category, "") or "", "probes": int(s["probes"]), "findings": int(s["findings"]), "worst": worst.name if worst else "", "nist": fw["nist"], "iso": fw["iso"], "owner": fw["owner"], } ) return rows def _donut_segments(result: ScanResult) -> Dict[str, object]: """Pre-compute the severity breakdown as conic-gradient stops so the report can draw a CSS-only donut chart (no JS, no external chart library). Returns the ordered per-severity segments (with their sweep angles), the ready-to-use ``conic-gradient(...)`` string, and the total finding count used for the donut's center label. """ sc = result.severity_counts() total = result.total_findings segments: List[Dict[str, object]] = [] stops: List[str] = [] start = 0.0 for sev in _SEVERITY_ORDER: count = sc[sev.name] sweep = (count / total * 360.0) if total else 0.0 end = start + sweep if count: stops.append( f"{_SEVERITY_HEX[sev]} {start:.3f}deg {end:.3f}deg" ) segments.append( { "name": sev.name, "label": sev.name.title(), "count": count, "pct": round((count / total * 100), 1) if total else 0.0, } ) start = end gradient = ( f"conic-gradient({', '.join(stops)})" if stops else "conic-gradient(rgb(var(--border)) 0deg 360deg)" ) return {"segments": segments, "total": total, "gradient": gradient} def render_html_report(result: ScanResult) -> str: env = Environment( loader=FileSystemLoader(str(_TEMPLATE_DIR)), autoescape=select_autoescape(["html", "xml", "j2"]), trim_blocks=True, lstrip_blocks=True, ) template = env.get_template("report.html.j2") donut = _donut_segments(result) return template.render( result=result, categories=_category_rows(result), compliance=_compliance_rows(result), donut=donut, donut_gradient=donut["gradient"], version=result.scanner_version, ) def write_html_report(result: ScanResult, path: Path) -> Path: path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(render_html_report(result), encoding="utf-8") return path def summary_table(result: ScanResult) -> str: """A compact severity table for terminal / Markdown output.""" sc = result.severity_counts() lines = [ "| Severity | Findings |", "|----------|----------|", f"| Critical | {sc['CRITICAL']} |", f"| High | {sc['HIGH']} |", f"| Medium | {sc['MEDIUM']} |", f"| Low | {sc['LOW']} |", f"| **Total**| **{result.total_findings}** |", ] return "\n".join(lines)