feat(audit-report): Exec-Summary, Top-N je Modul, Statistik, Gesamtanalyse

User-Feedback umgesetzt: Cookie-Titel-Fix (rendern nicht mehr als nacktes
"Befund" — Titel aus cookie/type/vendor), Executive Summary oben, je Modul
Statistik (Counts + Severity-Balken + MCs) + nur Top-3 Befunde + Verweis auf
"N weitere" mit Frontend-Link (snapshot_id) + Zwischenfazit, Browser-Übersicht,
Gesamtanalyse, klarerer "Grenzen"-Satz, Report-Versionsnummer. 6 Tests grün.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-13 15:57:07 +02:00
parent c8a1a40554
commit d1ea54b378
3 changed files with 246 additions and 125 deletions
@@ -231,6 +231,7 @@ async def _gather_report(snapshot_id: str):
if not snap: if not snap:
raise HTTPException(status_code=404, detail="snapshot not found") raise HTTPException(status_code=404, detail="snapshot not found")
meta = { meta = {
"snapshot_id": snapshot_id,
"site_label": snap.get("site_label"), "site_label": snap.get("site_label"),
"site_domain": snap.get("site_domain"), "site_domain": snap.get("site_domain"),
"created_at": snap.get("created_at"), "created_at": snap.get("created_at"),
@@ -1,24 +1,27 @@
"""Audit-Textreport — deterministischer Section-Assembler + Markdown-Renderer. """Audit-Textreport — deterministischer Section-Assembler + MD/PDF-Renderer.
Erzeugt aus den bereits vorhandenen Modul-Ergebnissen eines Snapshots (Cookie-, Erzeugt aus den Modul-Ergebnissen eines Snapshots (Cookie-, Impressum-, DSE-,
Impressum-, DSE-, AGB-Check + Browser-Matrix/Cross-Findings) einen firmen-tauglichen AGB-Check + Browser-Matrix/Cross-Findings) einen firmen-tauglichen Bericht:
Bericht: Einleitung, Testumfang & Methodik, Management-Summary, Detailbefunde je Executive Summary, Testumfang & Methodik, Detailbefunde je Modul (mit Statistik,
Modul, Maßnahmen, Rechtlicher Hinweis. KEIN Re-Crawl, KEIN LLM — reine Aufbereitung. Severity-Balken, Top-Befunden + Verweis auf alle, Zwischenfazit), Browser-
Übersicht, Gesamtanalyse, Maßnahmen, Rechtlicher Hinweis. KEIN Re-Crawl, KEIN LLM.
Leitplanken (siehe [[feedback_breakpilot_tonalitaet]], [[project_compliance_data_model]]): Leitplanken ([[feedback_breakpilot_tonalitaet]], [[project_compliance_data_model]]):
- Co-Pilot-Ton: „Wir analysieren Sie entscheiden mit DSB/Anwalt." Keine Panik, Co-Pilot-Ton, Wahrscheinlichkeit statt Garantie, Applicability ≠ Verstoß,
keine Bußgeld-Drohung als Aufmacher. Wahrscheinlichkeit statt Garantie. Tracking statt Cookie-Rohzahl, keine Normtexte reproduzieren.
- Keine Normtexte reproduzieren — nur Norm-Bezug benennen.
- Applicability ≠ Compliance, Unknown ≠ Fail: Konfidenz + Status transparent.
`assemble_report` ist PUR (dict→dict) → ohne DB/Netz unit-testbar. Erweitern = `assemble_report` ist PUR (dict→dict) → unit-testbar. Renderer iterieren generisch
neue Sektion in `_build_sections` ergänzen. über `sections` → neue Sektion = nur in `_build_sections` ergänzen.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
REPORT_VERSION = "1.1"
_FRONTEND_PATH = "/sdk/agent/snapshots"
_TOP_N = 3
_SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4} _SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
_SEV_LABEL = {"CRITICAL": "Kritisch", "HIGH": "Hoch", "MEDIUM": "Mittel", _SEV_LABEL = {"CRITICAL": "Kritisch", "HIGH": "Hoch", "MEDIUM": "Mittel",
"LOW": "Niedrig", "INFO": "Hinweis"} "LOW": "Niedrig", "INFO": "Hinweis"}
@@ -27,6 +30,16 @@ _MODULE_LABEL = {
"dse": "Datenschutzerklärung", "agb": "AGB", "dse": "Datenschutzerklärung", "agb": "AGB",
"browser": "Browser-Verhalten (Cookie-Banner)", "browser": "Browser-Verhalten (Cookie-Banner)",
} }
_COOKIE_TYPE_LABEL = {
"vague_duration": "Speicherdauer unklar",
"tracker_as_necessary": "Als notwendig deklariert, laut Bibliothek Tracker",
"missing_purpose": "Zweck nicht angegeben",
"excessive_lifetime": "Laufzeit über üblichem Maß",
"missing_retention": "Speicherdauer fehlt",
"missing_opt_out": "Opt-out-Hinweis fehlt",
"undisclosed": "Nicht in der Cookie-Erklärung deklariert",
"category_mismatch": "Kategorie weicht von der Realität ab",
}
_DISCLAIMER = ( _DISCLAIMER = (
"Dieser Bericht wurde von BreakPilot automatisiert erstellt und bewertet " "Dieser Bericht wurde von BreakPilot automatisiert erstellt und bewertet "
"Wahrscheinlichkeiten, keine Rechtsgewissheit. Er ersetzt keine " "Wahrscheinlichkeiten, keine Rechtsgewissheit. Er ersetzt keine "
@@ -38,17 +51,22 @@ _DISCLAIMER = (
def _norm_finding(f: Any) -> dict: def _norm_finding(f: Any) -> dict:
"""Befund (dict/obj) → einheitliche Form für den Report."""
if not isinstance(f, dict): if not isinstance(f, dict):
f = getattr(f, "__dict__", {}) or {} f = getattr(f, "__dict__", {}) or {}
title = f.get("title") or f.get("text") or f.get("message") or ""
if not title and f.get("cookie"):
lbl = _COOKIE_TYPE_LABEL.get(f.get("type", ""),
f.get("type") or "Cookie-Befund")
ven = f" ({f['vendor']})" if f.get("vendor") else ""
title = f"{f['cookie']}{ven}: {lbl}"
sev = (f.get("severity") or "MEDIUM").upper() sev = (f.get("severity") or "MEDIUM").upper()
return { return {
"title": f.get("title") or f.get("text") or f.get("message") or "Befund", "title": title or "Befund",
"severity": sev if sev in _SEV_ORDER else "MEDIUM", "severity": sev if sev in _SEV_ORDER else "MEDIUM",
"status": f.get("status") or "", "status": f.get("status") or "",
"legal_ref": f.get("legal_ref") or f.get("legal_reference") or "", "legal_ref": f.get("legal_ref") or f.get("legal_reference") or "",
"measure": f.get("measure") or f.get("recommendation") or "", "measure": (f.get("measure") or f.get("remediation")
"confidence": f.get("confidence"), or f.get("recommendation") or ""),
} }
@@ -56,13 +74,12 @@ def _module_findings(mod: Optional[dict]) -> list[dict]:
if not mod: if not mod:
return [] return []
out = [_norm_finding(f) for f in (mod.get("findings") or [])] out = [_norm_finding(f) for f in (mod.get("findings") or [])]
# Browser-Modul trägt seine Befunde in cross_findings.
for cf in (mod.get("cross_findings") or []): for cf in (mod.get("cross_findings") or []):
n = _norm_finding(cf) n = _norm_finding(cf)
if cf.get("detail"): if cf.get("detail"):
n["title"] = f"{n['title']}{cf['detail']}" n["title"] = f"{n['title']}{cf['detail']}"
out.append(n) out.append(n)
return out return sorted(out, key=lambda f: _SEV_ORDER.get(f["severity"], 2))
def _sev_counts(findings: list[dict]) -> dict: def _sev_counts(findings: list[dict]) -> dict:
@@ -72,41 +89,84 @@ def _sev_counts(findings: list[dict]) -> dict:
return c return c
def _verdict(counts: dict) -> str: def _counts_line(c: dict) -> str:
if counts.get("CRITICAL") or counts.get("HIGH", 0) >= 3: return " · ".join(f"{_SEV_LABEL[k]}: {c[k]}"
for k in ("CRITICAL", "HIGH", "MEDIUM", "LOW") if c.get(k))
def _sev_bar(c: dict) -> str:
rows = []
for k in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
n = c.get(k, 0)
if n:
rows.append(f"{_SEV_LABEL[k]:7} {n:>4} {'' * min(n, 28)}")
return "\n".join(rows)
def _verdict(c: dict) -> str:
if c.get("CRITICAL") or c.get("HIGH", 0) >= 3:
return ("Es bestehen mehrere Punkte mit erhöhtem Handlungsbedarf — eine " return ("Es bestehen mehrere Punkte mit erhöhtem Handlungsbedarf — eine "
"kurzfristige Klärung mit DSB/Anwalt ist empfehlenswert.") "kurzfristige Klärung mit DSB/Anwalt ist empfehlenswert.")
if counts.get("HIGH"): if c.get("HIGH"):
return ("Einzelne Punkte sollten priorisiert geprüft werden; das " return ("Einzelne Punkte sollten priorisiert geprüft werden; das "
"Gesamtbild ist handhabbar.") "Gesamtbild ist handhabbar.")
if counts.get("MEDIUM"): if c.get("MEDIUM"):
return ("Überwiegend kleinere Hinweise — mit moderatem Aufwand " return "Überwiegend kleinere Hinweise — mit moderatem Aufwand nachschärfbar."
"nachschärfbar.") return "Im Prüfumfang wurden keine wesentlichen Auffälligkeiten festgestellt."
return ("Im Prüfumfang wurden keine wesentlichen Auffälligkeiten "
"festgestellt.")
def _fmt_finding(f: dict) -> str:
extra = []
if f["status"]:
extra.append(f"Status: {f['status']}")
if f["legal_ref"]:
extra.append(f["legal_ref"])
tail = f" _( {' · '.join(extra)} )_" if extra else ""
return f"- **[{_SEV_LABEL[f['severity']]}]** {f['title']}{tail}"
def assemble_report(meta: dict, modules: dict) -> dict: def assemble_report(meta: dict, modules: dict) -> dict:
"""meta: {site_label, site_domain, created_at, check_id, scan_context}.
modules: {cookie|impressum|dse|agb|browser: <modul-ergebnis-dict oder None>}.
Rückgabe: {meta, generated_for, sections:[{title, level, body, findings?}]}."""
present = [k for k in ("cookie", "impressum", "dse", "agb", "browser") present = [k for k in ("cookie", "impressum", "dse", "agb", "browser")
if modules.get(k)] if modules.get(k)]
all_findings = [f for k in present for f in _module_findings(modules.get(k))] per_mod = {k: _module_findings(modules.get(k)) for k in present}
all_findings = [f for k in present for f in per_mod[k]]
counts = _sev_counts(all_findings) counts = _sev_counts(all_findings)
return { return {
"meta": meta, "meta": {**meta, "report_version": REPORT_VERSION},
"modules_present": present, "modules_present": present,
"totals": {"findings": len(all_findings), "by_severity": counts}, "totals": {"findings": len(all_findings), "by_severity": counts},
"sections": _build_sections(meta, modules, present, all_findings, counts), "sections": _build_sections(meta, modules, present, per_mod,
all_findings, counts),
} }
def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]: def _frontend_link(meta: dict) -> str:
sid = meta.get("snapshot_id") or ""
return f"{_FRONTEND_PATH}/{sid}" if sid else _FRONTEND_PATH
def _build_sections(meta, modules, present, per_mod, all_findings, counts):
site = meta.get("site_label") or meta.get("site_domain") or "die Website" site = meta.get("site_label") or meta.get("site_domain") or "die Website"
when = (meta.get("created_at") or "")[:16].replace("T", " ") when = (meta.get("created_at") or "")[:16].replace("T", " ")
link = _frontend_link(meta)
sec: list[dict] = [] sec: list[dict] = []
# ── Executive Summary ────────────────────────────────────────────────
top = [f for f in all_findings if f["severity"] in ("CRITICAL", "HIGH")][:5]
es = [_verdict(counts), "",
f"**Geprüfte Bereiche:** {len(present)} · "
f"**Befunde gesamt:** {len(all_findings)}"
f"{' (' + _counts_line(counts) + ')' if _counts_line(counts) else ''}."]
if top:
es += ["", "**Wichtigste Punkte:**"] + [_fmt_finding(f) for f in top]
es += ["", f"Die vollständigen Befunde sind interaktiv im BreakPilot-Frontend "
f"einsehbar: `{link}`.",
"",
"_Einstufung nach dem Prinzip Anwendbarkeit ≠ Verstoß: Ein Befund "
"markiert einen zu klärenden Punkt, kein automatisches Bußgeldrisiko._"]
sec.append({"title": "Executive Summary", "level": 2, "body": "\n".join(es)})
# ── Einleitung ───────────────────────────────────────────────────────
sec.append({"title": "Einleitung", "level": 2, "body": ( sec.append({"title": "Einleitung", "level": 2, "body": (
f"Dieser Bericht fasst die automatisierte Compliance-Analyse von " f"Dieser Bericht fasst die automatisierte Compliance-Analyse von "
f"**{site}** ({meta.get('site_domain', '')}) zusammen, durchgeführt mit " f"**{site}** ({meta.get('site_domain', '')}) zusammen, durchgeführt mit "
@@ -115,6 +175,7 @@ def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]:
f"informationsrechtliche Handlungsfelder als Grundlage für die " f"informationsrechtliche Handlungsfelder als Grundlage für die "
f"gemeinsame Bewertung mit Ihrem DSB bzw. Ihrer Rechtsberatung.")}) f"gemeinsame Bewertung mit Ihrem DSB bzw. Ihrer Rechtsberatung.")})
# ── Testumfang & Methodik (klarerer Grenzen-Satz) ────────────────────
mods = ", ".join(_MODULE_LABEL[k] for k in present) or "" mods = ", ".join(_MODULE_LABEL[k] for k in present) or ""
sec.append({"title": "Testumfang & Methodik", "level": 2, "body": ( sec.append({"title": "Testumfang & Methodik", "level": 2, "body": (
f"**Geprüfte Bereiche:** {mods}.\n\n" f"**Geprüfte Bereiche:** {mods}.\n\n"
@@ -124,82 +185,116 @@ def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]:
f"tatsächlichen Verhaltens in mehreren Browser-Engines. Bewertet wird " f"tatsächlichen Verhaltens in mehreren Browser-Engines. Bewertet wird "
f"das **nicht-essentielle Tracking** (technisch notwendige Cookies sind " f"das **nicht-essentielle Tracking** (technisch notwendige Cookies sind "
f"ausgenommen, § 25 Abs. 2 TDDDG).\n\n" f"ausgenommen, § 25 Abs. 2 TDDDG).\n\n"
f"**Grenzen:** Geprüft wurde der erfasste Stand zum Analysezeitpunkt; " f"**Was dieser Bericht nicht abdeckt:** Geprüft wurde ausschließlich der "
f"nicht erfasste Bereiche, eingeloggte Strecken oder dynamische Inhalte " f"öffentlich erreichbare Stand zum Analysezeitpunkt. **Nicht** enthalten "
f"sind nicht abschließend abgedeckt. Befunde sind Wahrscheinlichkeiten, " f"sind Bereiche hinter einem Login, erst per Interaktion nachgeladene "
f"keine abschließende rechtliche Feststellung.")}) f"Inhalte sowie Seiten, die nicht erfasst wurden. Die Befunde sind "
f"Einschätzungen mit Wahrscheinlichkeit, keine abschließende rechtliche "
f"Feststellung.")})
sev_line = " · ".join( # ── Detailbefunde je Modul ───────────────────────────────────────────
f"{_SEV_LABEL[k]}: {counts[k]}" for k in ("CRITICAL", "HIGH", "MEDIUM", "LOW") sec.append({"title": "Detailbefunde", "level": 2, "body": (
if counts.get(k)) f"Pro Bereich werden die wichtigsten Befunde gezeigt; die vollständige "
sec.append({"title": "Management-Summary", "level": 2, "body": ( f"Liste ist interaktiv im Frontend einsehbar (`{link}`).")})
f"{_verdict(counts)}\n\n"
f"**Befunde gesamt:** {len(all_findings)}"
f"{' (' + sev_line + ')' if sev_line else ''}.\n\n"
f"Die Einstufung folgt dem Prinzip *Anwendbarkeit ≠ Verstoß*: Ein Befund "
f"markiert einen zu klärenden Punkt, kein automatisches Bußgeldrisiko.")})
det: list[dict] = [{"title": "Detailbefunde", "level": 2, "body": ""}]
for k in present: for k in present:
fs = sorted(_module_findings(modules.get(k)), sec.append(_module_section(k, modules.get(k), per_mod[k], link))
key=lambda f: _SEV_ORDER.get(f["severity"], 2))
if not fs:
det.append({"title": _MODULE_LABEL[k], "level": 3,
"body": "Keine Auffälligkeiten im Prüfumfang."})
continue
lines = []
for f in fs:
extra = []
if f["status"]:
extra.append(f"Status: {f['status']}")
if f["legal_ref"]:
extra.append(f["legal_ref"])
tail = f" _( {' · '.join(extra)} )_" if extra else ""
lines.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['title']}{tail}")
det.append({"title": _MODULE_LABEL[k], "level": 3,
"body": "\n".join(lines)})
sec.extend(det)
measures = [] # ── Browser-Übersicht ────────────────────────────────────────────────
for f in sorted(all_findings, key=lambda f: _SEV_ORDER.get(f["severity"], 2)): bo = _browser_overview(modules.get("browser"))
if f["measure"]: if bo:
measures.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['measure']}") sec.append({"title": "Browser-Übersicht", "level": 2, "body": bo})
# ── Gesamtanalyse ────────────────────────────────────────────────────
worst = max(present, key=lambda k: _sev_counts(per_mod[k]).get("HIGH", 0),
default=None) if present else None
ga = [_verdict(counts), ""]
if worst and _sev_counts(per_mod[worst]).get("HIGH"):
ga.append(f"Der größte Handlungsbedarf liegt im Bereich "
f"**{_MODULE_LABEL[worst]}**.")
ga.append("Empfohlenes Vorgehen: zuerst die als „Hoch“ markierten Punkte mit "
"DSB/Anwalt klären, danach die mittleren Hinweise abarbeiten. Die "
"konkreten Schritte stehen im Abschnitt „Empfohlene Maßnahmen“.")
sec.append({"title": "Gesamtanalyse", "level": 2, "body": "\n".join(ga)})
# ── Maßnahmen ────────────────────────────────────────────────────────
seen, uniq = set(), [] seen, uniq = set(), []
for m in measures: for f in all_findings:
if m not in seen: if f["measure"] and f["measure"] not in seen:
seen.add(m) seen.add(f["measure"])
uniq.append(m) uniq.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['measure']}")
sec.append({"title": "Empfohlene Maßnahmen", "level": 2, "body": ( sec.append({"title": "Empfohlene Maßnahmen", "level": 2, "body": (
"\n".join(uniq) if uniq else "\n".join(uniq[:25]) + ("\n- … weitere im Frontend" if len(uniq) > 25 else "")
"Keine konkreten Maßnahmen erforderlich.")}) if uniq else "Keine konkreten Maßnahmen erforderlich.")})
sec.append({"title": "Rechtlicher Hinweis", "level": 2, "body": _DISCLAIMER}) sec.append({"title": "Rechtlicher Hinweis", "level": 2, "body": _DISCLAIMER})
return sec return sec
def _module_section(key: str, mod: Optional[dict], fs: list[dict],
link: str) -> dict:
c = _sev_counts(fs)
mc = len((mod or {}).get("mc_coverage") or [])
head = [f"**{len(fs)} Befund(e)**"
+ (f"{_counts_line(c)}" if _counts_line(c) else "")
+ (f" · {mc} Anforderungen (MCs) geprüft" if mc else "")]
if _sev_bar(c):
head += ["", "```", _sev_bar(c), "```"]
if not fs:
head.append("\nKeine Auffälligkeiten im Prüfumfang.")
return {"title": _MODULE_LABEL[key], "level": 3, "body": "\n".join(head)}
head += [""] + [_fmt_finding(f) for f in fs[:_TOP_N]]
if len(fs) > _TOP_N:
head.append(f"\n_… und {len(fs) - _TOP_N} weitere Befund(e) — vollständige "
f"Liste im Frontend: `{link}`._")
# Zwischenfazit
if c.get("HIGH"):
fazit = (f"Zwischenfazit: {c['HIGH']} Punkt(e) mit erhöhter Priorität — "
f"vorrangig klären.")
elif c.get("MEDIUM"):
fazit = "Zwischenfazit: überwiegend mittlere Hinweise, planbar nachzuschärfen."
else:
fazit = "Zwischenfazit: nur kleinere Hinweise."
head.append(f"\n_{fazit}_")
return {"title": _MODULE_LABEL[key], "level": 3, "body": "\n".join(head)}
def _browser_overview(mod: Optional[dict]) -> str:
rows = ((mod or {}).get("browser_matrix") or {}).get("browser_matrix") or []
if not rows:
return ""
lines = ["Verhalten je getesteter Browser-Engine:", ""]
for r in rows:
s = r.get("summary") or {}
if not s:
lines.append(f"- **{r.get('label', '?')}**: nicht verfügbar")
continue
tb = (s.get("violations") or {}).get("before_consent", 0)
rej = "respektiert" if s.get("reject_respected") else "**nicht** respektiert"
lines.append(
f"- **{r.get('label', '?')}** — Tracking vor Consent: {tb} · "
f"Ablehnen {rej} · Score {r.get('score', '')}")
return "\n".join(lines)
def render_markdown(report: dict) -> str: def render_markdown(report: dict) -> str:
meta = report.get("meta") or {} meta = report.get("meta") or {}
site = meta.get("site_label") or meta.get("site_domain") or "Website" site = meta.get("site_label") or meta.get("site_domain") or "Website"
when = (meta.get("created_at") or "")[:16].replace("T", " ") when = (meta.get("created_at") or "")[:16].replace("T", " ")
out = [f"# Compliance-Audit-Bericht — {site}", ""] out = [f"# Compliance-Audit-Bericht — {site}", ""]
sub = [meta.get("site_domain") or "", f"Analyse: {when}" if when else "", sub = [meta.get("site_domain") or "", f"Analyse: {when}" if when else "",
f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else ""] f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else "",
out.append(" · ".join(s for s in sub if s)) f"Report v{meta.get('report_version', REPORT_VERSION)}"]
out.append("") out += [" · ".join(s for s in sub if s), "",
out.append("_Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt._") "_Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt._", ""]
out.append("")
for s in report.get("sections") or []: for s in report.get("sections") or []:
out.append(f"{'#' * s.get('level', 2)} {s['title']}") out.append(f"{'#' * s.get('level', 2)} {s['title']}")
out.append("") out.append("")
if s.get("body"): if s.get("body"):
out.append(s["body"]) out += [s["body"], ""]
out.append("")
return "\n".join(out).strip() + "\n" return "\n".join(out).strip() + "\n"
def render_pdf(report: dict) -> bytes: def render_pdf(report: dict) -> bytes:
"""Druckfertiges PDF (reportlab). Wandelt die Sektionen in gestylte Absätze;
unterstützt **fett**, _kursiv_ und Aufzählungen aus den Markdown-Bodies."""
import html import html
import re import re
from io import BytesIO from io import BytesIO
@@ -207,7 +302,7 @@ def render_pdf(report: dict) -> bytes:
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer from reportlab.platypus import Paragraph, Preformatted, SimpleDocTemplate, Spacer
meta = report.get("meta") or {} meta = report.get("meta") or {}
site = meta.get("site_label") or meta.get("site_domain") or "Website" site = meta.get("site_label") or meta.get("site_domain") or "Website"
@@ -223,20 +318,22 @@ def render_pdf(report: dict) -> bytes:
h3 = ParagraphStyle("rh3", parent=ss["Heading3"], spaceBefore=6, spaceAfter=2) h3 = ParagraphStyle("rh3", parent=ss["Heading3"], spaceBefore=6, spaceAfter=2)
body = ParagraphStyle("rbody", parent=ss["BodyText"], fontSize=10, body = ParagraphStyle("rbody", parent=ss["BodyText"], fontSize=10,
leading=14, spaceAfter=4) leading=14, spaceAfter=4)
mono = ParagraphStyle("rmono", parent=ss["Code"], fontSize=8, leading=10)
small = ParagraphStyle("rsmall", parent=ss["BodyText"], fontSize=8, small = ParagraphStyle("rsmall", parent=ss["BodyText"], fontSize=8,
textColor=colors.grey, spaceAfter=2) textColor=colors.grey, spaceAfter=2)
def _inl(t: str) -> str: def _inl(t: str) -> str:
t = html.escape(t) t = html.escape(t)
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t) t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t)
t = re.sub(r"_(.+?)_", r"<i>\1</i>", t) t = re.sub(r"`(.+?)`", r"<font face='Courier'>\1</font>", t)
return t return re.sub(r"_(.+?)_", r"<i>\1</i>", t)
story = [Paragraph(f"Compliance-Audit-Bericht — {html.escape(site)}", h1)] story = [Paragraph(f"Compliance-Audit-Bericht — {html.escape(site)}", h1)]
sub = " · ".join(x for x in [ sub = " · ".join(x for x in [
meta.get("site_domain") or "", meta.get("site_domain") or "",
(meta.get("created_at") or "")[:16].replace("T", " "), (meta.get("created_at") or "")[:16].replace("T", " "),
f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else "", f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else "",
f"Report v{meta.get('report_version', REPORT_VERSION)}",
] if x) ] if x)
if sub: if sub:
story.append(Paragraph(html.escape(sub), small)) story.append(Paragraph(html.escape(sub), small))
@@ -244,9 +341,20 @@ def render_pdf(report: dict) -> bytes:
"Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt.", small)) "Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt.", small))
story.append(Spacer(1, 8)) story.append(Spacer(1, 8))
for s in report.get("sections") or []: for s in report.get("sections") or []:
style = {2: h2, 3: h3}.get(s.get("level", 2), h2) story.append(Paragraph(_inl(s["title"]),
story.append(Paragraph(_inl(s["title"]), style)) {2: h2, 3: h3}.get(s.get("level", 2), h2)))
in_code = False
block: list[str] = []
for line in (s.get("body") or "").split("\n"): for line in (s.get("body") or "").split("\n"):
if line.strip() == "```":
if in_code and block:
story.append(Preformatted("\n".join(block), mono))
block = []
in_code = not in_code
continue
if in_code:
block.append(line)
continue
line = line.strip() line = line.strip()
if not line: if not line:
continue continue
@@ -1,52 +1,72 @@
"""Audit-Report-Assembler (pur) — Sektionen, 4-Status-/Severity-Zählung, """Audit-Report-Assembler (pur) — Sektionen, Cookie-Titel-Fix, Top-N-Deckelung,
Co-Pilot-Tonalität, kein Normtext.""" Severity-Statistik, Co-Pilot-Tonalität, kein Normtext."""
from compliance.services.audit_report import assemble_report, render_markdown from compliance.services.audit_report import assemble_report, render_markdown
META = {"site_label": "BMW", "site_domain": "bmw.de", META = {"snapshot_id": "abc-123", "site_label": "BMW", "site_domain": "bmw.de",
"created_at": "2026-06-11T14:15:00", "check_id": "508983ec"} "created_at": "2026-06-11T14:15:00", "check_id": "508983ec"}
MODULES = { MODULES = {
"cookie": {"findings": [ "cookie": {"findings": [
{"title": "Cookie als notwendig deklariert, real Marketing", # Cookie-Befunde OHNE title-Feld (nur cookie/type/remediation) → Titel-Fix.
"severity": "HIGH", "legal_ref": "§ 25 TDDDG", {"cookie": "cto_bmw", "vendor": "Criteo", "type": "tracker_as_necessary",
"measure": "Als einwilligungspflichtig (§ 25) einstufen."}, "severity": "HIGH", "remediation": "Als einwilligungspflichtig einstufen."},
{"title": "Laufzeit überschreitet Empfehlung", "severity": "LOW"}, {"cookie": "_ga", "vendor": "Google", "type": "excessive_lifetime",
"severity": "LOW", "remediation": "Laufzeit reduzieren."},
{"cookie": "x1", "type": "missing_purpose", "severity": "MEDIUM"},
{"cookie": "x2", "type": "missing_purpose", "severity": "MEDIUM"},
{"cookie": "x3", "type": "missing_purpose", "severity": "MEDIUM"},
]}, ]},
"impressum": {"findings": [ "impressum": {"findings": [
{"title": "Vertretungsberechtigte fehlen", "severity": "MEDIUM", {"title": "Vertretungsberechtigte fehlen", "severity": "MEDIUM",
"status": "APPLICABLE", "recommendation": "Vertretungsberechtigte ergänzen."}, "status": "fail", "recommendation": "Vertretungsberechtigte ergänzen."},
], "status": "APPLICABLE", "confidence": 0.8}, ], "mc_coverage": [1, 2, 3, 4]},
"browser": {"cross_findings": [ "browser": {"browser_matrix": {"browser_matrix": [
{"label": "Chromium", "score": 47,
"summary": {"reject_respected": False, "violations": {"before_consent": 1}}},
]}, "cross_findings": [
{"title": "Tracking vor der Einwilligung — in allen Browsern", {"title": "Tracking vor der Einwilligung — in allen Browsern",
"severity": "HIGH", "detail": "Chrome + Firefox setzen Tracker vor Consent", "severity": "HIGH", "measure": "Tracking erst nach Consent laden."},
"measure": "Tracking-Skripte erst nach aktiver Einwilligung laden."},
]}, ]},
} }
def test_sections_present(): def test_sections_present_incl_exec_and_gesamt():
r = assemble_report(META, MODULES) titles = [s["title"] for s in assemble_report(META, MODULES)["sections"]]
titles = [s["title"] for s in r["sections"]] for t in ["Executive Summary", "Einleitung", "Testumfang & Methodik",
for t in ["Einleitung", "Testumfang & Methodik", "Management-Summary", "Detailbefunde", "Browser-Übersicht", "Gesamtanalyse",
"Detailbefunde", "Empfohlene Maßnahmen", "Rechtlicher Hinweis"]: "Empfohlene Maßnahmen", "Rechtlicher Hinweis"]:
assert t in titles, f"Sektion fehlt: {t}" assert t in titles, f"Sektion fehlt: {t}"
def test_severity_counts(): def test_cookie_title_not_befund_fallback():
md = render_markdown(assemble_report(META, MODULES))
# Positiv: Cookie-Titel korrekt gebaut (Fallback "Befund" käme OHNE cto_bmw).
assert "cto_bmw" in md and "Criteo" in md
assert "Als notwendig deklariert" in md # Typ-Label statt roher type
assert "**[Hoch]** Befund" not in md # kein nackter Fallback-Titel
def test_top_n_cap_and_more_hint():
# cookie hat 5 Befunde → nur Top-3 + Verweis auf „weitere".
md = render_markdown(assemble_report(META, MODULES))
assert "weitere Befund(e)" in md
assert "abc-123" in md # Frontend-Link mit snapshot_id
def test_severity_counts_and_bar():
r = assemble_report(META, MODULES) r = assemble_report(META, MODULES)
c = r["totals"]["by_severity"] c = r["totals"]["by_severity"]
assert c["HIGH"] == 2 and c["MEDIUM"] == 1 and c["LOW"] == 1 assert c["HIGH"] == 2 and c["MEDIUM"] == 4 and c["LOW"] == 1
assert r["totals"]["findings"] == 4 md = render_markdown(r)
assert "" in md # Severity-Balken (Graphik)
assert "Anforderungen (MCs) geprüft" in md # Modul-Statistik (Impressum mc_coverage)
def test_markdown_has_header_findings_and_copilot_disclaimer(): def test_copilot_disclaimer_and_no_normtext():
md = render_markdown(assemble_report(META, MODULES)) md = render_markdown(assemble_report(META, MODULES))
assert "Compliance-Audit-Bericht — BMW" in md assert "DSB" in md and "Anwalt" in md and "Wahrscheinlichkeit" in md
assert "Tracking vor der Einwilligung" in md # Browser-Cross-Finding assert "§ 25 Abs. 2" in md and "nicht-essentielle" in md.lower()
assert "Vertretungsberechtigte ergänzen" in md # Maßnahme aus recommendation assert "BreakPilot" in md and "Browser-Übersicht" in md
assert "DSB" in md and "Anwalt" in md # Co-Pilot-Disclaimer
assert "Wahrscheinlichkeit" in md # keine Garantie
assert "BreakPilot" in md
def test_empty_modules_graceful(): def test_empty_modules_graceful():
@@ -54,12 +74,4 @@ def test_empty_modules_graceful():
assert r["totals"]["findings"] == 0 assert r["totals"]["findings"] == 0
md = render_markdown(r) md = render_markdown(r)
assert "keine wesentlichen auffälligkeiten" in md.lower() assert "keine wesentlichen auffälligkeiten" in md.lower()
# Auch ohne Befunde: Disclaimer + Methodik vorhanden.
assert "Rechtlicher Hinweis" in md assert "Rechtlicher Hinweis" in md
def test_essential_cookie_framing_in_methodik():
# Tonalität/Recht: technisch notwendige Cookies ausgenommen (§ 25 Abs. 2).
md = render_markdown(assemble_report(META, MODULES))
assert "§ 25 Abs. 2" in md
assert "nicht-essentielle" in md.lower()