Spaces:
Sleeping
Sleeping
File size: 6,113 Bytes
6c59ea7 3d002b7 6c59ea7 3d002b7 6c59ea7 3d002b7 6c59ea7 3d002b7 6c59ea7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | """
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)
|