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>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
"""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("• " + _inl(line[2:]), body))
|
||||
else:
|
||||
story.append(Paragraph(_inl(line), body))
|
||||
doc.build(story)
|
||||
return buf.getvalue()
|
||||
Reference in New Issue
Block a user