Files
breakpilot-compliance/backend-compliance/compliance/services/audit_report.py
T
Benjamin Admin d1ea54b378 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>
2026-06-13 15:57:07 +02:00

367 lines
17 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 + 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"<b>\1</b>", 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))
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("•&nbsp;&nbsp;" + _inl(line[2:]), body))
else:
story.append(Paragraph(_inl(line), body))
doc.build(story)
return buf.getvalue()