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>
This commit is contained in:
@@ -231,6 +231,7 @@ async def _gather_report(snapshot_id: str):
|
|||||||
if not snap:
|
if not snap:
|
||||||
raise HTTPException(status_code=404, detail="snapshot not found")
|
raise HTTPException(status_code=404, detail="snapshot not found")
|
||||||
meta = {
|
meta = {
|
||||||
|
"snapshot_id": snapshot_id,
|
||||||
"site_label": snap.get("site_label"),
|
"site_label": snap.get("site_label"),
|
||||||
"site_domain": snap.get("site_domain"),
|
"site_domain": snap.get("site_domain"),
|
||||||
"created_at": snap.get("created_at"),
|
"created_at": snap.get("created_at"),
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
"""Audit-Textreport — deterministischer Section-Assembler + Markdown-Renderer.
|
"""Audit-Textreport — deterministischer Section-Assembler + MD/PDF-Renderer.
|
||||||
|
|
||||||
Erzeugt aus den bereits vorhandenen Modul-Ergebnissen eines Snapshots (Cookie-,
|
Erzeugt aus den Modul-Ergebnissen eines Snapshots (Cookie-, Impressum-, DSE-,
|
||||||
Impressum-, DSE-, AGB-Check + Browser-Matrix/Cross-Findings) einen firmen-tauglichen
|
AGB-Check + Browser-Matrix/Cross-Findings) einen firmen-tauglichen Bericht:
|
||||||
Bericht: Einleitung, Testumfang & Methodik, Management-Summary, Detailbefunde je
|
Executive Summary, Testumfang & Methodik, Detailbefunde je Modul (mit Statistik,
|
||||||
Modul, Maßnahmen, Rechtlicher Hinweis. KEIN Re-Crawl, KEIN LLM — reine Aufbereitung.
|
Severity-Balken, Top-Befunden + Verweis auf alle, Zwischenfazit), Browser-
|
||||||
|
Übersicht, Gesamtanalyse, Maßnahmen, Rechtlicher Hinweis. KEIN Re-Crawl, KEIN LLM.
|
||||||
|
|
||||||
Leitplanken (siehe [[feedback_breakpilot_tonalitaet]], [[project_compliance_data_model]]):
|
Leitplanken ([[feedback_breakpilot_tonalitaet]], [[project_compliance_data_model]]):
|
||||||
- Co-Pilot-Ton: „Wir analysieren – Sie entscheiden mit DSB/Anwalt." Keine Panik,
|
Co-Pilot-Ton, Wahrscheinlichkeit statt Garantie, Applicability ≠ Verstoß,
|
||||||
keine Bußgeld-Drohung als Aufmacher. Wahrscheinlichkeit statt Garantie.
|
Tracking statt Cookie-Rohzahl, keine Normtexte reproduzieren.
|
||||||
- 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 =
|
`assemble_report` ist PUR (dict→dict) → unit-testbar. Renderer iterieren generisch
|
||||||
neue Sektion in `_build_sections` ergänzen.
|
über `sections` → neue Sektion = nur in `_build_sections` ergänzen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Optional
|
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_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
|
||||||
_SEV_LABEL = {"CRITICAL": "Kritisch", "HIGH": "Hoch", "MEDIUM": "Mittel",
|
_SEV_LABEL = {"CRITICAL": "Kritisch", "HIGH": "Hoch", "MEDIUM": "Mittel",
|
||||||
"LOW": "Niedrig", "INFO": "Hinweis"}
|
"LOW": "Niedrig", "INFO": "Hinweis"}
|
||||||
@@ -27,6 +30,16 @@ _MODULE_LABEL = {
|
|||||||
"dse": "Datenschutzerklärung", "agb": "AGB",
|
"dse": "Datenschutzerklärung", "agb": "AGB",
|
||||||
"browser": "Browser-Verhalten (Cookie-Banner)",
|
"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 = (
|
_DISCLAIMER = (
|
||||||
"Dieser Bericht wurde von BreakPilot automatisiert erstellt und bewertet "
|
"Dieser Bericht wurde von BreakPilot automatisiert erstellt und bewertet "
|
||||||
"Wahrscheinlichkeiten, keine Rechtsgewissheit. Er ersetzt keine "
|
"Wahrscheinlichkeiten, keine Rechtsgewissheit. Er ersetzt keine "
|
||||||
@@ -38,17 +51,22 @@ _DISCLAIMER = (
|
|||||||
|
|
||||||
|
|
||||||
def _norm_finding(f: Any) -> dict:
|
def _norm_finding(f: Any) -> dict:
|
||||||
"""Befund (dict/obj) → einheitliche Form für den Report."""
|
|
||||||
if not isinstance(f, dict):
|
if not isinstance(f, dict):
|
||||||
f = getattr(f, "__dict__", {}) or {}
|
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()
|
sev = (f.get("severity") or "MEDIUM").upper()
|
||||||
return {
|
return {
|
||||||
"title": f.get("title") or f.get("text") or f.get("message") or "Befund",
|
"title": title or "Befund",
|
||||||
"severity": sev if sev in _SEV_ORDER else "MEDIUM",
|
"severity": sev if sev in _SEV_ORDER else "MEDIUM",
|
||||||
"status": f.get("status") or "",
|
"status": f.get("status") or "",
|
||||||
"legal_ref": f.get("legal_ref") or f.get("legal_reference") or "",
|
"legal_ref": f.get("legal_ref") or f.get("legal_reference") or "",
|
||||||
"measure": f.get("measure") or f.get("recommendation") or "",
|
"measure": (f.get("measure") or f.get("remediation")
|
||||||
"confidence": f.get("confidence"),
|
or f.get("recommendation") or ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -56,13 +74,12 @@ def _module_findings(mod: Optional[dict]) -> list[dict]:
|
|||||||
if not mod:
|
if not mod:
|
||||||
return []
|
return []
|
||||||
out = [_norm_finding(f) for f in (mod.get("findings") or [])]
|
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 []):
|
for cf in (mod.get("cross_findings") or []):
|
||||||
n = _norm_finding(cf)
|
n = _norm_finding(cf)
|
||||||
if cf.get("detail"):
|
if cf.get("detail"):
|
||||||
n["title"] = f"{n['title']} — {cf['detail']}"
|
n["title"] = f"{n['title']} — {cf['detail']}"
|
||||||
out.append(n)
|
out.append(n)
|
||||||
return out
|
return sorted(out, key=lambda f: _SEV_ORDER.get(f["severity"], 2))
|
||||||
|
|
||||||
|
|
||||||
def _sev_counts(findings: list[dict]) -> dict:
|
def _sev_counts(findings: list[dict]) -> dict:
|
||||||
@@ -72,41 +89,84 @@ def _sev_counts(findings: list[dict]) -> dict:
|
|||||||
return c
|
return c
|
||||||
|
|
||||||
|
|
||||||
def _verdict(counts: dict) -> str:
|
def _counts_line(c: dict) -> str:
|
||||||
if counts.get("CRITICAL") or counts.get("HIGH", 0) >= 3:
|
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 "
|
return ("Es bestehen mehrere Punkte mit erhöhtem Handlungsbedarf — eine "
|
||||||
"kurzfristige Klärung mit DSB/Anwalt ist empfehlenswert.")
|
"kurzfristige Klärung mit DSB/Anwalt ist empfehlenswert.")
|
||||||
if counts.get("HIGH"):
|
if c.get("HIGH"):
|
||||||
return ("Einzelne Punkte sollten priorisiert geprüft werden; das "
|
return ("Einzelne Punkte sollten priorisiert geprüft werden; das "
|
||||||
"Gesamtbild ist handhabbar.")
|
"Gesamtbild ist handhabbar.")
|
||||||
if counts.get("MEDIUM"):
|
if c.get("MEDIUM"):
|
||||||
return ("Überwiegend kleinere Hinweise — mit moderatem Aufwand "
|
return "Überwiegend kleinere Hinweise — mit moderatem Aufwand nachschärfbar."
|
||||||
"nachschärfbar.")
|
return "Im Prüfumfang wurden keine wesentlichen Auffälligkeiten festgestellt."
|
||||||
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:
|
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")
|
present = [k for k in ("cookie", "impressum", "dse", "agb", "browser")
|
||||||
if modules.get(k)]
|
if modules.get(k)]
|
||||||
all_findings = [f for k in present for f in _module_findings(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)
|
counts = _sev_counts(all_findings)
|
||||||
return {
|
return {
|
||||||
"meta": meta,
|
"meta": {**meta, "report_version": REPORT_VERSION},
|
||||||
"modules_present": present,
|
"modules_present": present,
|
||||||
"totals": {"findings": len(all_findings), "by_severity": counts},
|
"totals": {"findings": len(all_findings), "by_severity": counts},
|
||||||
"sections": _build_sections(meta, modules, present, all_findings, counts),
|
"sections": _build_sections(meta, modules, present, per_mod,
|
||||||
|
all_findings, counts),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]:
|
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"
|
site = meta.get("site_label") or meta.get("site_domain") or "die Website"
|
||||||
when = (meta.get("created_at") or "")[:16].replace("T", " ")
|
when = (meta.get("created_at") or "")[:16].replace("T", " ")
|
||||||
|
link = _frontend_link(meta)
|
||||||
sec: list[dict] = []
|
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": (
|
sec.append({"title": "Einleitung", "level": 2, "body": (
|
||||||
f"Dieser Bericht fasst die automatisierte Compliance-Analyse von "
|
f"Dieser Bericht fasst die automatisierte Compliance-Analyse von "
|
||||||
f"**{site}** ({meta.get('site_domain', '')}) zusammen, durchgeführt mit "
|
f"**{site}** ({meta.get('site_domain', '')}) zusammen, durchgeführt mit "
|
||||||
@@ -115,6 +175,7 @@ def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]:
|
|||||||
f"informationsrechtliche Handlungsfelder als Grundlage für die "
|
f"informationsrechtliche Handlungsfelder als Grundlage für die "
|
||||||
f"gemeinsame Bewertung mit Ihrem DSB bzw. Ihrer Rechtsberatung.")})
|
f"gemeinsame Bewertung mit Ihrem DSB bzw. Ihrer Rechtsberatung.")})
|
||||||
|
|
||||||
|
# ── Testumfang & Methodik (klarerer Grenzen-Satz) ────────────────────
|
||||||
mods = ", ".join(_MODULE_LABEL[k] for k in present) or "—"
|
mods = ", ".join(_MODULE_LABEL[k] for k in present) or "—"
|
||||||
sec.append({"title": "Testumfang & Methodik", "level": 2, "body": (
|
sec.append({"title": "Testumfang & Methodik", "level": 2, "body": (
|
||||||
f"**Geprüfte Bereiche:** {mods}.\n\n"
|
f"**Geprüfte Bereiche:** {mods}.\n\n"
|
||||||
@@ -124,82 +185,116 @@ def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]:
|
|||||||
f"tatsächlichen Verhaltens in mehreren Browser-Engines. Bewertet wird "
|
f"tatsächlichen Verhaltens in mehreren Browser-Engines. Bewertet wird "
|
||||||
f"das **nicht-essentielle Tracking** (technisch notwendige Cookies sind "
|
f"das **nicht-essentielle Tracking** (technisch notwendige Cookies sind "
|
||||||
f"ausgenommen, § 25 Abs. 2 TDDDG).\n\n"
|
f"ausgenommen, § 25 Abs. 2 TDDDG).\n\n"
|
||||||
f"**Grenzen:** Geprüft wurde der erfasste Stand zum Analysezeitpunkt; "
|
f"**Was dieser Bericht nicht abdeckt:** Geprüft wurde ausschließlich der "
|
||||||
f"nicht erfasste Bereiche, eingeloggte Strecken oder dynamische Inhalte "
|
f"öffentlich erreichbare Stand zum Analysezeitpunkt. **Nicht** enthalten "
|
||||||
f"sind nicht abschließend abgedeckt. Befunde sind Wahrscheinlichkeiten, "
|
f"sind Bereiche hinter einem Login, erst per Interaktion nachgeladene "
|
||||||
f"keine abschließende rechtliche Feststellung.")})
|
f"Inhalte sowie Seiten, die nicht erfasst wurden. Die Befunde sind "
|
||||||
|
f"Einschätzungen mit Wahrscheinlichkeit, keine abschließende rechtliche "
|
||||||
|
f"Feststellung.")})
|
||||||
|
|
||||||
sev_line = " · ".join(
|
# ── Detailbefunde je Modul ───────────────────────────────────────────
|
||||||
f"{_SEV_LABEL[k]}: {counts[k]}" for k in ("CRITICAL", "HIGH", "MEDIUM", "LOW")
|
sec.append({"title": "Detailbefunde", "level": 2, "body": (
|
||||||
if counts.get(k))
|
f"Pro Bereich werden die wichtigsten Befunde gezeigt; die vollständige "
|
||||||
sec.append({"title": "Management-Summary", "level": 2, "body": (
|
f"Liste ist interaktiv im Frontend einsehbar (`{link}`).")})
|
||||||
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:
|
for k in present:
|
||||||
fs = sorted(_module_findings(modules.get(k)),
|
sec.append(_module_section(k, modules.get(k), per_mod[k], link))
|
||||||
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 = []
|
# ── Browser-Übersicht ────────────────────────────────────────────────
|
||||||
for f in sorted(all_findings, key=lambda f: _SEV_ORDER.get(f["severity"], 2)):
|
bo = _browser_overview(modules.get("browser"))
|
||||||
if f["measure"]:
|
if bo:
|
||||||
measures.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['measure']}")
|
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(), []
|
seen, uniq = set(), []
|
||||||
for m in measures:
|
for f in all_findings:
|
||||||
if m not in seen:
|
if f["measure"] and f["measure"] not in seen:
|
||||||
seen.add(m)
|
seen.add(f["measure"])
|
||||||
uniq.append(m)
|
uniq.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['measure']}")
|
||||||
sec.append({"title": "Empfohlene Maßnahmen", "level": 2, "body": (
|
sec.append({"title": "Empfohlene Maßnahmen", "level": 2, "body": (
|
||||||
"\n".join(uniq) if uniq else
|
"\n".join(uniq[:25]) + ("\n- … weitere im Frontend" if len(uniq) > 25 else "")
|
||||||
"Keine konkreten Maßnahmen erforderlich.")})
|
if uniq else "Keine konkreten Maßnahmen erforderlich.")})
|
||||||
|
|
||||||
sec.append({"title": "Rechtlicher Hinweis", "level": 2, "body": _DISCLAIMER})
|
sec.append({"title": "Rechtlicher Hinweis", "level": 2, "body": _DISCLAIMER})
|
||||||
return sec
|
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:
|
def render_markdown(report: dict) -> str:
|
||||||
meta = report.get("meta") or {}
|
meta = report.get("meta") or {}
|
||||||
site = meta.get("site_label") or meta.get("site_domain") or "Website"
|
site = meta.get("site_label") or meta.get("site_domain") or "Website"
|
||||||
when = (meta.get("created_at") or "")[:16].replace("T", " ")
|
when = (meta.get("created_at") or "")[:16].replace("T", " ")
|
||||||
out = [f"# Compliance-Audit-Bericht — {site}", ""]
|
out = [f"# Compliance-Audit-Bericht — {site}", ""]
|
||||||
sub = [meta.get("site_domain") or "", f"Analyse: {when}" if when else "",
|
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"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else "",
|
||||||
out.append(" · ".join(s for s in sub if s))
|
f"Report v{meta.get('report_version', REPORT_VERSION)}"]
|
||||||
out.append("")
|
out += [" · ".join(s for s in sub if s), "",
|
||||||
out.append("_Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt._")
|
"_Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt._", ""]
|
||||||
out.append("")
|
|
||||||
for s in report.get("sections") or []:
|
for s in report.get("sections") or []:
|
||||||
out.append(f"{'#' * s.get('level', 2)} {s['title']}")
|
out.append(f"{'#' * s.get('level', 2)} {s['title']}")
|
||||||
out.append("")
|
out.append("")
|
||||||
if s.get("body"):
|
if s.get("body"):
|
||||||
out.append(s["body"])
|
out += [s["body"], ""]
|
||||||
out.append("")
|
|
||||||
return "\n".join(out).strip() + "\n"
|
return "\n".join(out).strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
def render_pdf(report: dict) -> bytes:
|
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 html
|
||||||
import re
|
import re
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@@ -207,7 +302,7 @@ def render_pdf(report: dict) -> bytes:
|
|||||||
from reportlab.lib.pagesizes import A4
|
from reportlab.lib.pagesizes import A4
|
||||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
from reportlab.lib.units import mm
|
from reportlab.lib.units import mm
|
||||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
from reportlab.platypus import Paragraph, Preformatted, SimpleDocTemplate, Spacer
|
||||||
|
|
||||||
meta = report.get("meta") or {}
|
meta = report.get("meta") or {}
|
||||||
site = meta.get("site_label") or meta.get("site_domain") or "Website"
|
site = meta.get("site_label") or meta.get("site_domain") or "Website"
|
||||||
@@ -223,20 +318,22 @@ def render_pdf(report: dict) -> bytes:
|
|||||||
h3 = ParagraphStyle("rh3", parent=ss["Heading3"], spaceBefore=6, spaceAfter=2)
|
h3 = ParagraphStyle("rh3", parent=ss["Heading3"], spaceBefore=6, spaceAfter=2)
|
||||||
body = ParagraphStyle("rbody", parent=ss["BodyText"], fontSize=10,
|
body = ParagraphStyle("rbody", parent=ss["BodyText"], fontSize=10,
|
||||||
leading=14, spaceAfter=4)
|
leading=14, spaceAfter=4)
|
||||||
|
mono = ParagraphStyle("rmono", parent=ss["Code"], fontSize=8, leading=10)
|
||||||
small = ParagraphStyle("rsmall", parent=ss["BodyText"], fontSize=8,
|
small = ParagraphStyle("rsmall", parent=ss["BodyText"], fontSize=8,
|
||||||
textColor=colors.grey, spaceAfter=2)
|
textColor=colors.grey, spaceAfter=2)
|
||||||
|
|
||||||
def _inl(t: str) -> str:
|
def _inl(t: str) -> str:
|
||||||
t = html.escape(t)
|
t = html.escape(t)
|
||||||
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t)
|
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t)
|
||||||
t = re.sub(r"_(.+?)_", r"<i>\1</i>", t)
|
t = re.sub(r"`(.+?)`", r"<font face='Courier'>\1</font>", t)
|
||||||
return t
|
return re.sub(r"_(.+?)_", r"<i>\1</i>", t)
|
||||||
|
|
||||||
story = [Paragraph(f"Compliance-Audit-Bericht — {html.escape(site)}", h1)]
|
story = [Paragraph(f"Compliance-Audit-Bericht — {html.escape(site)}", h1)]
|
||||||
sub = " · ".join(x for x in [
|
sub = " · ".join(x for x in [
|
||||||
meta.get("site_domain") or "",
|
meta.get("site_domain") or "",
|
||||||
(meta.get("created_at") or "")[:16].replace("T", " "),
|
(meta.get("created_at") or "")[:16].replace("T", " "),
|
||||||
f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else "",
|
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 x)
|
||||||
if sub:
|
if sub:
|
||||||
story.append(Paragraph(html.escape(sub), small))
|
story.append(Paragraph(html.escape(sub), small))
|
||||||
@@ -244,9 +341,20 @@ def render_pdf(report: dict) -> bytes:
|
|||||||
"Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt.", small))
|
"Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt.", small))
|
||||||
story.append(Spacer(1, 8))
|
story.append(Spacer(1, 8))
|
||||||
for s in report.get("sections") or []:
|
for s in report.get("sections") or []:
|
||||||
style = {2: h2, 3: h3}.get(s.get("level", 2), h2)
|
story.append(Paragraph(_inl(s["title"]),
|
||||||
story.append(Paragraph(_inl(s["title"]), style))
|
{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"):
|
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()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,52 +1,72 @@
|
|||||||
"""Audit-Report-Assembler (pur) — Sektionen, 4-Status-/Severity-Zählung,
|
"""Audit-Report-Assembler (pur) — Sektionen, Cookie-Titel-Fix, Top-N-Deckelung,
|
||||||
Co-Pilot-Tonalität, kein Normtext."""
|
Severity-Statistik, Co-Pilot-Tonalität, kein Normtext."""
|
||||||
|
|
||||||
from compliance.services.audit_report import assemble_report, render_markdown
|
from compliance.services.audit_report import assemble_report, render_markdown
|
||||||
|
|
||||||
META = {"site_label": "BMW", "site_domain": "bmw.de",
|
META = {"snapshot_id": "abc-123", "site_label": "BMW", "site_domain": "bmw.de",
|
||||||
"created_at": "2026-06-11T14:15:00", "check_id": "508983ec"}
|
"created_at": "2026-06-11T14:15:00", "check_id": "508983ec"}
|
||||||
MODULES = {
|
MODULES = {
|
||||||
"cookie": {"findings": [
|
"cookie": {"findings": [
|
||||||
{"title": "Cookie als notwendig deklariert, real Marketing",
|
# Cookie-Befunde OHNE title-Feld (nur cookie/type/remediation) → Titel-Fix.
|
||||||
"severity": "HIGH", "legal_ref": "§ 25 TDDDG",
|
{"cookie": "cto_bmw", "vendor": "Criteo", "type": "tracker_as_necessary",
|
||||||
"measure": "Als einwilligungspflichtig (§ 25) einstufen."},
|
"severity": "HIGH", "remediation": "Als einwilligungspflichtig einstufen."},
|
||||||
{"title": "Laufzeit überschreitet Empfehlung", "severity": "LOW"},
|
{"cookie": "_ga", "vendor": "Google", "type": "excessive_lifetime",
|
||||||
|
"severity": "LOW", "remediation": "Laufzeit reduzieren."},
|
||||||
|
{"cookie": "x1", "type": "missing_purpose", "severity": "MEDIUM"},
|
||||||
|
{"cookie": "x2", "type": "missing_purpose", "severity": "MEDIUM"},
|
||||||
|
{"cookie": "x3", "type": "missing_purpose", "severity": "MEDIUM"},
|
||||||
]},
|
]},
|
||||||
"impressum": {"findings": [
|
"impressum": {"findings": [
|
||||||
{"title": "Vertretungsberechtigte fehlen", "severity": "MEDIUM",
|
{"title": "Vertretungsberechtigte fehlen", "severity": "MEDIUM",
|
||||||
"status": "APPLICABLE", "recommendation": "Vertretungsberechtigte ergänzen."},
|
"status": "fail", "recommendation": "Vertretungsberechtigte ergänzen."},
|
||||||
], "status": "APPLICABLE", "confidence": 0.8},
|
], "mc_coverage": [1, 2, 3, 4]},
|
||||||
"browser": {"cross_findings": [
|
"browser": {"browser_matrix": {"browser_matrix": [
|
||||||
|
{"label": "Chromium", "score": 47,
|
||||||
|
"summary": {"reject_respected": False, "violations": {"before_consent": 1}}},
|
||||||
|
]}, "cross_findings": [
|
||||||
{"title": "Tracking vor der Einwilligung — in allen Browsern",
|
{"title": "Tracking vor der Einwilligung — in allen Browsern",
|
||||||
"severity": "HIGH", "detail": "Chrome + Firefox setzen Tracker vor Consent",
|
"severity": "HIGH", "measure": "Tracking erst nach Consent laden."},
|
||||||
"measure": "Tracking-Skripte erst nach aktiver Einwilligung laden."},
|
|
||||||
]},
|
]},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_sections_present():
|
def test_sections_present_incl_exec_and_gesamt():
|
||||||
r = assemble_report(META, MODULES)
|
titles = [s["title"] for s in assemble_report(META, MODULES)["sections"]]
|
||||||
titles = [s["title"] for s in r["sections"]]
|
for t in ["Executive Summary", "Einleitung", "Testumfang & Methodik",
|
||||||
for t in ["Einleitung", "Testumfang & Methodik", "Management-Summary",
|
"Detailbefunde", "Browser-Übersicht", "Gesamtanalyse",
|
||||||
"Detailbefunde", "Empfohlene Maßnahmen", "Rechtlicher Hinweis"]:
|
"Empfohlene Maßnahmen", "Rechtlicher Hinweis"]:
|
||||||
assert t in titles, f"Sektion fehlt: {t}"
|
assert t in titles, f"Sektion fehlt: {t}"
|
||||||
|
|
||||||
|
|
||||||
def test_severity_counts():
|
def test_cookie_title_not_befund_fallback():
|
||||||
|
md = render_markdown(assemble_report(META, MODULES))
|
||||||
|
# Positiv: Cookie-Titel korrekt gebaut (Fallback "Befund" käme OHNE cto_bmw).
|
||||||
|
assert "cto_bmw" in md and "Criteo" in md
|
||||||
|
assert "Als notwendig deklariert" in md # Typ-Label statt roher type
|
||||||
|
assert "**[Hoch]** Befund" not in md # kein nackter Fallback-Titel
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_n_cap_and_more_hint():
|
||||||
|
# cookie hat 5 Befunde → nur Top-3 + Verweis auf „weitere".
|
||||||
|
md = render_markdown(assemble_report(META, MODULES))
|
||||||
|
assert "weitere Befund(e)" in md
|
||||||
|
assert "abc-123" in md # Frontend-Link mit snapshot_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_severity_counts_and_bar():
|
||||||
r = assemble_report(META, MODULES)
|
r = assemble_report(META, MODULES)
|
||||||
c = r["totals"]["by_severity"]
|
c = r["totals"]["by_severity"]
|
||||||
assert c["HIGH"] == 2 and c["MEDIUM"] == 1 and c["LOW"] == 1
|
assert c["HIGH"] == 2 and c["MEDIUM"] == 4 and c["LOW"] == 1
|
||||||
assert r["totals"]["findings"] == 4
|
md = render_markdown(r)
|
||||||
|
assert "▇" in md # Severity-Balken (Graphik)
|
||||||
|
assert "Anforderungen (MCs) geprüft" in md # Modul-Statistik (Impressum mc_coverage)
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_has_header_findings_and_copilot_disclaimer():
|
def test_copilot_disclaimer_and_no_normtext():
|
||||||
md = render_markdown(assemble_report(META, MODULES))
|
md = render_markdown(assemble_report(META, MODULES))
|
||||||
assert "Compliance-Audit-Bericht — BMW" in md
|
assert "DSB" in md and "Anwalt" in md and "Wahrscheinlichkeit" in md
|
||||||
assert "Tracking vor der Einwilligung" in md # Browser-Cross-Finding
|
assert "§ 25 Abs. 2" in md and "nicht-essentielle" in md.lower()
|
||||||
assert "Vertretungsberechtigte ergänzen" in md # Maßnahme aus recommendation
|
assert "BreakPilot" in md and "Browser-Übersicht" in md
|
||||||
assert "DSB" in md and "Anwalt" in md # Co-Pilot-Disclaimer
|
|
||||||
assert "Wahrscheinlichkeit" in md # keine Garantie
|
|
||||||
assert "BreakPilot" in md
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_modules_graceful():
|
def test_empty_modules_graceful():
|
||||||
@@ -54,12 +74,4 @@ def test_empty_modules_graceful():
|
|||||||
assert r["totals"]["findings"] == 0
|
assert r["totals"]["findings"] == 0
|
||||||
md = render_markdown(r)
|
md = render_markdown(r)
|
||||||
assert "keine wesentlichen auffälligkeiten" in md.lower()
|
assert "keine wesentlichen auffälligkeiten" in md.lower()
|
||||||
# Auch ohne Befunde: Disclaimer + Methodik vorhanden.
|
|
||||||
assert "Rechtlicher Hinweis" in md
|
assert "Rechtlicher Hinweis" in md
|
||||||
|
|
||||||
|
|
||||||
def test_essential_cookie_framing_in_methodik():
|
|
||||||
# Tonalität/Recht: technisch notwendige Cookies ausgenommen (§ 25 Abs. 2).
|
|
||||||
md = render_markdown(assemble_report(META, MODULES))
|
|
||||||
assert "§ 25 Abs. 2" in md
|
|
||||||
assert "nicht-essentielle" in md.lower()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user