"""Audit-Textreport — deterministischer Section-Assembler + Markdown-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. 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. `assemble_report` ist PUR (dict→dict) → ohne DB/Netz unit-testbar. Erweitern = neue Sektion in `_build_sections` ergänzen. """ from __future__ import annotations from typing import Any, Optional _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)", } _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: """Befund (dict/obj) → einheitliche Form für den Report.""" if not isinstance(f, dict): f = getattr(f, "__dict__", {}) or {} sev = (f.get("severity") or "MEDIUM").upper() return { "title": f.get("title") or f.get("text") or f.get("message") 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"), } 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 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 _verdict(counts: dict) -> str: if counts.get("CRITICAL") or counts.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"): 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.") 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: }. 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))] counts = _sev_counts(all_findings) return { "meta": meta, "modules_present": present, "totals": {"findings": len(all_findings), "by_severity": counts}, "sections": _build_sections(meta, modules, present, all_findings, counts), } def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]: site = meta.get("site_label") or meta.get("site_domain") or "die Website" when = (meta.get("created_at") or "")[:16].replace("T", " ") sec: list[dict] = [] 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.")}) 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"**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.")}) 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": ""}] 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) 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']}") seen, uniq = set(), [] for m in measures: if m not in seen: seen.add(m) uniq.append(m) sec.append({"title": "Empfohlene Maßnahmen", "level": 2, "body": ( "\n".join(uniq) if uniq else "Keine konkreten Maßnahmen erforderlich.")}) sec.append({"title": "Rechtlicher Hinweis", "level": 2, "body": _DISCLAIMER}) return sec 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("") 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("") 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 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, 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) 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 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 "", ] 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 []: style = {2: h2, 3: h3}.get(s.get("level", 2), h2) story.append(Paragraph(_inl(s["title"]), style)) for line in (s.get("body") or "").split("\n"): 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()