"""Audit-Textreport — deterministischer Section-Assembler + MD/PDF-Renderer. 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 ([[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) → 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"} _MODULE_LABEL = { "cookie": "Cookies & Tracking", "impressum": "Impressum", "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 " "Rechtsberatung: Die finale Bewertung und Freigabe treffen Sie gemeinsam " "mit Ihrem Datenschutzbeauftragten bzw. Ihrer Anwältin/Ihrem Anwalt. " "Befunde mit niedriger Konfidenz oder unklarer Datenlage sind als Hinweis " "zu verstehen, nicht als festgestellter Verstoß." ) def _norm_finding(f: Any) -> dict: 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": 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("remediation") or f.get("recommendation") or ""), } def _module_findings(mod: Optional[dict]) -> list[dict]: if not mod: return [] out = [_norm_finding(f) for f in (mod.get("findings") or [])] 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 sorted(out, key=lambda f: _SEV_ORDER.get(f["severity"], 2)) def _sev_counts(findings: list[dict]) -> dict: c = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0} for f in findings: c[f["severity"]] = c.get(f["severity"], 0) + 1 return c 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 c.get("HIGH"): return ("Einzelne Punkte sollten priorisiert geprüft werden; das " "Gesamtbild ist handhabbar.") 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: present = [k for k in ("cookie", "impressum", "dse", "agb", "browser") if 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, "report_version": REPORT_VERSION}, "modules_present": present, "totals": {"findings": len(all_findings), "by_severity": counts}, "sections": _build_sections(meta, modules, present, per_mod, all_findings, counts), } 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 " f"BreakPilot am {when or 'dem angegebenen Datum'}. Ziel ist ein " f"verständlicher Überblick über mögliche datenschutz- und " 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" f"**Vorgehen:** Automatisierte Erfassung der öffentlich erreichbaren " f"Seiteninhalte und des Cookie-/Tracking-Verhaltens, Abgleich gegen eine " f"kuratierte Wissensbasis sowie – beim Cookie-Banner – Messung des " 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"**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.")}) # ── 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: sec.append(_module_section(k, modules.get(k), per_mod[k], link)) # ── 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 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[: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 "", 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 += [s["body"], ""] return "\n".join(out).strip() + "\n" def render_pdf(report: dict) -> bytes: import html import re from io import BytesIO from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import mm 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" buf = BytesIO() doc = SimpleDocTemplate( buf, pagesize=A4, topMargin=20 * mm, bottomMargin=18 * mm, leftMargin=20 * mm, rightMargin=20 * mm, title=f"Compliance-Audit-Bericht — {site}") ss = getSampleStyleSheet() h1 = ParagraphStyle("rh1", parent=ss["Title"], fontSize=18, spaceAfter=4) h2 = ParagraphStyle("rh2", parent=ss["Heading2"], spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#1d4ed8")) 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"\1", t) t = re.sub(r"`(.+?)`", r"\1", t) return re.sub(r"_(.+?)_", r"\1", 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)) story.append(Paragraph( "Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt.", small)) story.append(Spacer(1, 8)) for s in report.get("sections") or []: 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 if line.startswith("- "): story.append(Paragraph("•  " + _inl(line[2:]), body)) else: story.append(Paragraph(_inl(line), body)) doc.build(story) return buf.getvalue()