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
@@ -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-,
Impressum-, DSE-, AGB-Check + Browser-Matrix/Cross-Findings) einen firmen-tauglichen
Bericht: Einleitung, Testumfang & Methodik, Management-Summary, Detailbefunde je
Modul, Maßnahmen, Rechtlicher Hinweis. KEIN Re-Crawl, KEIN LLM — reine Aufbereitung.
Erzeugt aus den Modul-Ergebnissen eines Snapshots (Cookie-, Impressum-, DSE-,
AGB-Check + Browser-Matrix/Cross-Findings) einen firmen-tauglichen Bericht:
Executive Summary, Testumfang & Methodik, Detailbefunde je Modul (mit Statistik,
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]]):
- Co-Pilot-Ton: „Wir analysieren Sie entscheiden mit DSB/Anwalt." Keine Panik,
keine Bußgeld-Drohung als Aufmacher. Wahrscheinlichkeit statt Garantie.
- Keine Normtexte reproduzieren — nur Norm-Bezug benennen.
- Applicability ≠ Compliance, Unknown ≠ Fail: Konfidenz + Status transparent.
Leitplanken ([[feedback_breakpilot_tonalitaet]], [[project_compliance_data_model]]):
Co-Pilot-Ton, Wahrscheinlichkeit statt Garantie, Applicability ≠ Verstoß,
Tracking statt Cookie-Rohzahl, keine Normtexte reproduzieren.
`assemble_report` ist PUR (dict→dict) → ohne DB/Netz unit-testbar. Erweitern =
neue Sektion in `_build_sections` ergänzen.
`assemble_report` ist PUR (dict→dict) → unit-testbar. Renderer iterieren generisch
über `sections` → neue Sektion = nur in `_build_sections` ergänzen.
"""
from __future__ import annotations
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_LABEL = {"CRITICAL": "Kritisch", "HIGH": "Hoch", "MEDIUM": "Mittel",
"LOW": "Niedrig", "INFO": "Hinweis"}
@@ -27,6 +30,16 @@ _MODULE_LABEL = {
"dse": "Datenschutzerklärung", "agb": "AGB",
"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 = (
"Dieser Bericht wurde von BreakPilot automatisiert erstellt und bewertet "
"Wahrscheinlichkeiten, keine Rechtsgewissheit. Er ersetzt keine "
@@ -38,17 +51,22 @@ _DISCLAIMER = (
def _norm_finding(f: Any) -> dict:
"""Befund (dict/obj) → einheitliche Form für den Report."""
if not isinstance(f, dict):
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()
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",
"status": f.get("status") or "",
"legal_ref": f.get("legal_ref") or f.get("legal_reference") or "",
"measure": f.get("measure") or f.get("recommendation") or "",
"confidence": f.get("confidence"),
"measure": (f.get("measure") or f.get("remediation")
or f.get("recommendation") or ""),
}
@@ -56,13 +74,12 @@ def _module_findings(mod: Optional[dict]) -> list[dict]:
if not mod:
return []
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 []):
n = _norm_finding(cf)
if cf.get("detail"):
n["title"] = f"{n['title']}{cf['detail']}"
out.append(n)
return out
return sorted(out, key=lambda f: _SEV_ORDER.get(f["severity"], 2))
def _sev_counts(findings: list[dict]) -> dict:
@@ -72,41 +89,84 @@ def _sev_counts(findings: list[dict]) -> dict:
return c
def _verdict(counts: dict) -> str:
if counts.get("CRITICAL") or counts.get("HIGH", 0) >= 3:
def _counts_line(c: dict) -> str:
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 "
"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 "
"Gesamtbild ist handhabbar.")
if counts.get("MEDIUM"):
return ("Überwiegend kleinere Hinweise — mit moderatem Aufwand "
"nachschärfbar.")
return ("Im Prüfumfang wurden keine wesentlichen Auffälligkeiten "
"festgestellt.")
if c.get("MEDIUM"):
return "Überwiegend kleinere Hinweise — mit moderatem Aufwand nachschärfbar."
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:
"""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")
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)
return {
"meta": meta,
"meta": {**meta, "report_version": REPORT_VERSION},
"modules_present": present,
"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"
when = (meta.get("created_at") or "")[:16].replace("T", " ")
link = _frontend_link(meta)
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": (
f"Dieser Bericht fasst die automatisierte Compliance-Analyse von "
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"gemeinsame Bewertung mit Ihrem DSB bzw. Ihrer Rechtsberatung.")})
# ── Testumfang & Methodik (klarerer Grenzen-Satz) ────────────────────
mods = ", ".join(_MODULE_LABEL[k] for k in present) or ""
sec.append({"title": "Testumfang & Methodik", "level": 2, "body": (
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"das **nicht-essentielle Tracking** (technisch notwendige Cookies sind "
f"ausgenommen, § 25 Abs. 2 TDDDG).\n\n"
f"**Grenzen:** Geprüft wurde der erfasste Stand zum Analysezeitpunkt; "
f"nicht erfasste Bereiche, eingeloggte Strecken oder dynamische Inhalte "
f"sind nicht abschließend abgedeckt. Befunde sind Wahrscheinlichkeiten, "
f"keine abschließende rechtliche Feststellung.")})
f"**Was dieser Bericht nicht abdeckt:** Geprüft wurde ausschließlich der "
f"öffentlich erreichbare Stand zum Analysezeitpunkt. **Nicht** enthalten "
f"sind Bereiche hinter einem Login, erst per Interaktion nachgeladene "
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(
f"{_SEV_LABEL[k]}: {counts[k]}" for k in ("CRITICAL", "HIGH", "MEDIUM", "LOW")
if counts.get(k))
sec.append({"title": "Management-Summary", "level": 2, "body": (
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": ""}]
# ── Detailbefunde je Modul ───────────────────────────────────────────
sec.append({"title": "Detailbefunde", "level": 2, "body": (
f"Pro Bereich werden die wichtigsten Befunde gezeigt; die vollständige "
f"Liste ist interaktiv im Frontend einsehbar (`{link}`).")})
for k in present:
fs = sorted(_module_findings(modules.get(k)),
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)
sec.append(_module_section(k, modules.get(k), per_mod[k], link))
measures = []
for f in sorted(all_findings, key=lambda f: _SEV_ORDER.get(f["severity"], 2)):
if f["measure"]:
measures.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['measure']}")
# ── Browser-Übersicht ────────────────────────────────────────────────
bo = _browser_overview(modules.get("browser"))
if bo:
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(), []
for m in measures:
if m not in seen:
seen.add(m)
uniq.append(m)
for f in all_findings:
if f["measure"] and f["measure"] not in seen:
seen.add(f["measure"])
uniq.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['measure']}")
sec.append({"title": "Empfohlene Maßnahmen", "level": 2, "body": (
"\n".join(uniq) if uniq else
"Keine konkreten Maßnahmen erforderlich.")})
"\n".join(uniq[:25]) + ("\n- … weitere im Frontend" if len(uniq) > 25 else "")
if uniq else "Keine konkreten Maßnahmen erforderlich.")})
sec.append({"title": "Rechtlicher Hinweis", "level": 2, "body": _DISCLAIMER})
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:
meta = report.get("meta") or {}
site = meta.get("site_label") or meta.get("site_domain") or "Website"
when = (meta.get("created_at") or "")[:16].replace("T", " ")
out = [f"# Compliance-Audit-Bericht — {site}", ""]
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 ""]
out.append(" · ".join(s for s in sub if s))
out.append("")
out.append("_Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt._")
out.append("")
f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else "",
f"Report v{meta.get('report_version', REPORT_VERSION)}"]
out += [" · ".join(s for s in sub if s), "",
"_Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt._", ""]
for s in report.get("sections") or []:
out.append(f"{'#' * s.get('level', 2)} {s['title']}")
out.append("")
if s.get("body"):
out.append(s["body"])
out.append("")
out += [s["body"], ""]
return "\n".join(out).strip() + "\n"
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 re
from io import BytesIO
@@ -207,7 +302,7 @@ def render_pdf(report: dict) -> bytes:
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
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 {}
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)
body = ParagraphStyle("rbody", parent=ss["BodyText"], fontSize=10,
leading=14, spaceAfter=4)
mono = ParagraphStyle("rmono", parent=ss["Code"], fontSize=8, leading=10)
small = ParagraphStyle("rsmall", parent=ss["BodyText"], fontSize=8,
textColor=colors.grey, spaceAfter=2)
def _inl(t: str) -> str:
t = html.escape(t)
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t)
t = re.sub(r"_(.+?)_", r"<i>\1</i>", t)
return t
t = re.sub(r"`(.+?)`", r"<font face='Courier'>\1</font>", t)
return re.sub(r"_(.+?)_", r"<i>\1</i>", t)
story = [Paragraph(f"Compliance-Audit-Bericht — {html.escape(site)}", h1)]
sub = " · ".join(x for x in [
meta.get("site_domain") or "",
(meta.get("created_at") or "")[:16].replace("T", " "),
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 sub:
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))
story.append(Spacer(1, 8))
for s in report.get("sections") or []:
style = {2: h2, 3: h3}.get(s.get("level", 2), h2)
story.append(Paragraph(_inl(s["title"]), style))
story.append(Paragraph(_inl(s["title"]),
{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"):
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()
if not line:
continue