diff --git a/backend-compliance/compliance/api/snapshot_check_routes.py b/backend-compliance/compliance/api/snapshot_check_routes.py index 2ae84461..864b9698 100644 --- a/backend-compliance/compliance/api/snapshot_check_routes.py +++ b/backend-compliance/compliance/api/snapshot_check_routes.py @@ -231,6 +231,7 @@ async def _gather_report(snapshot_id: str): if not snap: raise HTTPException(status_code=404, detail="snapshot not found") meta = { + "snapshot_id": snapshot_id, "site_label": snap.get("site_label"), "site_domain": snap.get("site_domain"), "created_at": snap.get("created_at"), diff --git a/backend-compliance/compliance/services/audit_report.py b/backend-compliance/compliance/services/audit_report.py index 9b61b2d0..cd6bc62b 100644 --- a/backend-compliance/compliance/services/audit_report.py +++ b/backend-compliance/compliance/services/audit_report.py @@ -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-, -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. +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 (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. +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) → ohne DB/Netz unit-testbar. Erweitern = -neue Sektion in `_build_sections` ergänzen. +`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"} @@ -27,6 +30,16 @@ _MODULE_LABEL = { "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 " @@ -38,17 +51,22 @@ _DISCLAIMER = ( 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 {} + 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": 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", "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"), + "measure": (f.get("measure") or f.get("remediation") + or f.get("recommendation") or ""), } @@ -56,13 +74,12 @@ 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 + return sorted(out, key=lambda f: _SEV_ORDER.get(f["severity"], 2)) def _sev_counts(findings: list[dict]) -> dict: @@ -72,41 +89,84 @@ def _sev_counts(findings: list[dict]) -> dict: return c -def _verdict(counts: dict) -> str: - if counts.get("CRITICAL") or counts.get("HIGH", 0) >= 3: +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 counts.get("HIGH"): + if c.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.") + 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: - """meta: {site_label, site_domain, created_at, check_id, scan_context}. - modules: {cookie|impressum|dse|agb|browser: }. - 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))] + 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, + "meta": {**meta, "report_version": REPORT_VERSION}, "modules_present": present, "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" 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 " @@ -115,6 +175,7 @@ def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]: 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" @@ -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"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.")}) + 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.")}) - 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": ""}] + # ── 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: - 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) + sec.append(_module_section(k, modules.get(k), per_mod[k], link)) - 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']}") + # ── 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 m in measures: - if m not in seen: - seen.add(m) - uniq.append(m) + 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) if uniq else - "Keine konkreten Maßnahmen erforderlich.")}) + "\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 ""] - out.append(" · ".join(s for s in sub if s)) - out.append("") - out.append("_Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt._") - out.append("") + 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.append(s["body"]) - out.append("") + out += [s["body"], ""] 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 @@ -207,7 +302,7 @@ def render_pdf(report: dict) -> bytes: 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 + 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" @@ -223,20 +318,22 @@ def render_pdf(report: dict) -> bytes: 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"\1", t) - t = re.sub(r"_(.+?)_", r"\1", t) - return t + t = re.sub(r"`(.+?)`", r"\1", t) + return re.sub(r"_(.+?)_", r"\1", 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)) @@ -244,9 +341,20 @@ def render_pdf(report: dict) -> bytes: "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)) + 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 diff --git a/backend-compliance/compliance/tests/test_audit_report.py b/backend-compliance/compliance/tests/test_audit_report.py index 26aecadd..145a3f2b 100644 --- a/backend-compliance/compliance/tests/test_audit_report.py +++ b/backend-compliance/compliance/tests/test_audit_report.py @@ -1,52 +1,72 @@ -"""Audit-Report-Assembler (pur) — Sektionen, 4-Status-/Severity-Zählung, -Co-Pilot-Tonalität, kein Normtext.""" +"""Audit-Report-Assembler (pur) — Sektionen, Cookie-Titel-Fix, Top-N-Deckelung, +Severity-Statistik, Co-Pilot-Tonalität, kein Normtext.""" 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"} MODULES = { "cookie": {"findings": [ - {"title": "Cookie als notwendig deklariert, real Marketing", - "severity": "HIGH", "legal_ref": "§ 25 TDDDG", - "measure": "Als einwilligungspflichtig (§ 25) einstufen."}, - {"title": "Laufzeit überschreitet Empfehlung", "severity": "LOW"}, + # Cookie-Befunde OHNE title-Feld (nur cookie/type/remediation) → Titel-Fix. + {"cookie": "cto_bmw", "vendor": "Criteo", "type": "tracker_as_necessary", + "severity": "HIGH", "remediation": "Als einwilligungspflichtig einstufen."}, + {"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": [ {"title": "Vertretungsberechtigte fehlen", "severity": "MEDIUM", - "status": "APPLICABLE", "recommendation": "Vertretungsberechtigte ergänzen."}, - ], "status": "APPLICABLE", "confidence": 0.8}, - "browser": {"cross_findings": [ + "status": "fail", "recommendation": "Vertretungsberechtigte ergänzen."}, + ], "mc_coverage": [1, 2, 3, 4]}, + "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", - "severity": "HIGH", "detail": "Chrome + Firefox setzen Tracker vor Consent", - "measure": "Tracking-Skripte erst nach aktiver Einwilligung laden."}, + "severity": "HIGH", "measure": "Tracking erst nach Consent laden."}, ]}, } -def test_sections_present(): - r = assemble_report(META, MODULES) - titles = [s["title"] for s in r["sections"]] - for t in ["Einleitung", "Testumfang & Methodik", "Management-Summary", - "Detailbefunde", "Empfohlene Maßnahmen", "Rechtlicher Hinweis"]: +def test_sections_present_incl_exec_and_gesamt(): + titles = [s["title"] for s in assemble_report(META, MODULES)["sections"]] + for t in ["Executive Summary", "Einleitung", "Testumfang & Methodik", + "Detailbefunde", "Browser-Übersicht", "Gesamtanalyse", + "Empfohlene Maßnahmen", "Rechtlicher Hinweis"]: 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) c = r["totals"]["by_severity"] - assert c["HIGH"] == 2 and c["MEDIUM"] == 1 and c["LOW"] == 1 - assert r["totals"]["findings"] == 4 + assert c["HIGH"] == 2 and c["MEDIUM"] == 4 and c["LOW"] == 1 + 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)) - assert "Compliance-Audit-Bericht — BMW" in md - assert "Tracking vor der Einwilligung" in md # Browser-Cross-Finding - assert "Vertretungsberechtigte ergänzen" in md # Maßnahme aus recommendation - assert "DSB" in md and "Anwalt" in md # Co-Pilot-Disclaimer - assert "Wahrscheinlichkeit" in md # keine Garantie - assert "BreakPilot" in md + assert "DSB" in md and "Anwalt" in md and "Wahrscheinlichkeit" in md + assert "§ 25 Abs. 2" in md and "nicht-essentielle" in md.lower() + assert "BreakPilot" in md and "Browser-Übersicht" in md def test_empty_modules_graceful(): @@ -54,12 +74,4 @@ def test_empty_modules_graceful(): assert r["totals"]["findings"] == 0 md = render_markdown(r) assert "keine wesentlichen auffälligkeiten" in md.lower() - # Auch ohne Befunde: Disclaimer + Methodik vorhanden. 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()