Files
breakpilot-compliance/backend-compliance/compliance/services/audit_report.py
T
Benjamin Admin d720db07dd feat(audit-report): deterministischer Textreport je Audit (MD + PDF) + Bericht-Tab
Firmen-tauglicher Bericht aus den Snapshot-Modulergebnissen (kein Re-Crawl, kein
LLM): Einleitung, Testumfang+Methodik, Management-Summary (4-Status), Detail-
befunde je Modul, Maßnahmen, Rechtlicher Hinweis. Co-Pilot-Tonalität, Tracking-
statt Cookie-Rohzahl, Norm nur referenziert (kein Normtext).
- audit_report.py: assemble_report (pur) + render_markdown + render_pdf (reportlab)
- snapshot_check_routes: GET /report (struktur+md) + GET /report.pdf
- Frontend: AuditReportTab + Proxys (report, report/pdf) + "Bericht"-Tab
- Tests: 5 Assembler (compliance/tests → CI-geprüft) + 1 Vitest

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 14:50:45 +02:00

259 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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: <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))]
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"<b>\1</b>", t)
t = re.sub(r"_(.+?)_", r"<i>\1</i>", 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("•&nbsp;&nbsp;" + _inl(line[2:]), body))
else:
story.append(Paragraph(_inl(line), body))
doc.build(story)
return buf.getvalue()