d1ea54b378
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>
367 lines
17 KiB
Python
367 lines
17 KiB
Python
"""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("• " + _inl(line[2:]), body))
|
||
else:
|
||
story.append(Paragraph(_inl(line), body))
|
||
doc.build(story)
|
||
return buf.getvalue()
|