diff --git a/backend-compliance/compliance/api/agent_compliance_check_routes.py b/backend-compliance/compliance/api/agent_compliance_check_routes.py index 6ece4bbb..519278ec 100644 --- a/backend-compliance/compliance/api/agent_compliance_check_routes.py +++ b/backend-compliance/compliance/api/agent_compliance_check_routes.py @@ -614,6 +614,9 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): summary_html = build_management_summary(results) scanned_html = build_scanned_urls_html(doc_entries) providers_html = build_provider_list_html(banner_result, vvt_entries) + # P18: Deep-Block mit Phases + Quality-Score + Per-Category-Tracker + from .agent_doc_check_banner import build_banner_deep_html + banner_deep_html = build_banner_deep_html(banner_result) vvt_html = build_vvt_table_html(cmp_vendors) # MC scorecard aggregated across ALL docs in this run (DSGVO/TDDDG/ @@ -676,6 +679,20 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): site_name=site_name_for_exec, ) + # P18: Critical-Findings-Block (rot oben, mit Sofortmassnahmen + + # Quellen + Bussgeld-Praezedenz). Wird nur gerendert wenn echte + # kritische Verstoesse vorliegen. + critical_html = "" + try: + from .agent_doc_check_critical import build_critical_findings_html + critical_html = build_critical_findings_html( + banner_result=banner_result, + scorecard=scorecard, + results=results, + ) + except Exception as e: + logger.warning("Critical-findings block skipped: %s", e) + # P10: Cookie-Policy-Architecture-Detection (BMW-Pattern erkennen) cookie_arch_html = "" try: @@ -726,10 +743,10 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): # 7) providers_html + vvt_html (Vendor-Liste) # 8) report_html (Doc-Pruefung Details) full_html = ( - exec_summary_html + cookie_arch_html + summary_html - + scanned_html + profile_html + critical_html + exec_summary_html + cookie_arch_html + + summary_html + scanned_html + profile_html + scorecard_html + redundancy_html - + providers_html + vvt_html + report_html + + providers_html + banner_deep_html + vvt_html + report_html ) # Step 6: Send email — derive site name primarily from entered URL. @@ -753,12 +770,23 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): "results": [_result_to_dict(r) for r in results], "business_profile": profile_dict, "extracted_profile": extracted_profile, - "banner_result": { - "detected": banner_result.get("banner_detected", False) if banner_result else False, - "provider": banner_result.get("banner_provider", "") if banner_result else "", - "violations": len(banner_result.get("banner_checks", {}).get("violations", [])) if banner_result else 0, + # P18: vollen consent-tester-Output durchreichen statt nur 4 Felder. + # phases (before/after-accept/reject) + banner_checks.violations + + # category_tests werden vom Renderer + Critical-Findings-Block genutzt. + "banner_result": ({ + "detected": banner_result.get("banner_detected", False), + "provider": banner_result.get("banner_provider", ""), + "violations": len((banner_result.get("banner_checks") or {}) + .get("violations", [])), "tcf_vendor_count": len(tcf_vendors), - } if banner_result else None, + "completeness_pct": banner_result.get("completeness_pct"), + "correctness_pct": banner_result.get("correctness_pct"), + "phases": banner_result.get("phases", {}), + "banner_checks": banner_result.get("banner_checks", {}), + "category_tests": banner_result.get("category_tests", []), + "structured_checks": banner_result.get("structured_checks", []), + "summary": banner_result.get("summary", {}), + } if banner_result else None), "tcf_vendors": vvt_entries if tcf_vendors else [], "cmp_vendors": cmp_vendors, "total_documents": len(results), diff --git a/backend-compliance/compliance/api/agent_doc_check_banner.py b/backend-compliance/compliance/api/agent_doc_check_banner.py new file mode 100644 index 00000000..c4edf5d4 --- /dev/null +++ b/backend-compliance/compliance/api/agent_doc_check_banner.py @@ -0,0 +1,201 @@ +""" +P18 — Erweiterter Banner-Block fuer die Email. + +Rendert die Daten aus dem consent-tester die heute weggeworfen wurden: + - 3-Phasen-Cookie-Tabelle (before_consent / after_reject / after_accept) + - Banner-Quality-Score (completeness/correctness/violations) + - Per-Category-Tracker-Listing + - Violations-Liste mit Rechtsgrundlagen +""" + +from __future__ import annotations + + +def _color_for(pct: int) -> str: + return ("#16a34a" if pct >= 80 else + "#d97706" if pct >= 50 else "#dc2626") + + +def _short_phase_label(key: str) -> str: + return { + "before_consent": "Vor Consent", + "after_reject": "Nach Ablehnung", + "after_accept": "Nach Annahme", + }.get(key, key) + + +def _phase_color(key: str, cookie_count: int) -> str: + if key == "before_consent": + return "#16a34a" if cookie_count == 0 else "#dc2626" + if key == "after_reject": + return "#16a34a" if cookie_count <= 1 else "#d97706" + return "#94a3b8" + + +def build_banner_deep_html(banner_result: dict | None) -> str: + """Render: Banner-Quality + Phases + Violations. + + Konsumiert das volle consent-tester-Response. Komplementiert + `build_provider_list_html` (das nur Summary + TCF-Vendor-Tabelle macht). + """ + if not banner_result: + return "" + + parts: list[str] = [ + '
' + '

' + 'Cookie-Banner — technische Analyse

' + ] + + # 1) Quality-Score-Cards + compl = banner_result.get("completeness_pct") + corr = banner_result.get("correctness_pct") + summary = banner_result.get("summary") or {} + n_critical = summary.get("critical", 0) + n_high = summary.get("high", 0) + if compl is not None or corr is not None: + parts.append( + '' + ) + if compl is not None: + c = _color_for(int(compl)) + parts.append( + f'' + ) + if corr is not None: + c = _color_for(int(corr)) + parts.append( + f'' + ) + viol_c = ("#dc2626" if n_critical + n_high > 0 else + "#d97706" if (summary.get("total_violations") or 0) > 0 else + "#16a34a") + parts.append( + f'' + ) + parts.append('
' + f'
' + f'Vollstaendigkeit
' + f'
{compl}%
' + f'
' + f'
' + f'Korrektheit
' + f'
{corr}%
' + f'
' + f'
' + f'Verstoesse
' + f'
' + f'{summary.get("total_violations", 0)}' + f'' + f'(crit:{n_critical} high:{n_high})
') + + # 2) 3-Phasen-Tabelle + phases = banner_result.get("phases") or {} + if phases: + parts.append( + '
Cookie-Setzungen pro Phase ' + '(echter Browser-Test):
' + '' + '' + '' + '' + '' + '' + '' + ) + for key in ("before_consent", "after_reject", "after_accept"): + ph = phases.get(key) or {} + if not isinstance(ph, dict): continue + cookies = ph.get("cookies") or [] + trackers = ph.get("tracking_services") or [] + new_track = ph.get("new_tracking") or [] + violations = ph.get("violations") or [] + undoc = ph.get("undocumented") or [] + color = _phase_color(key, len(cookies)) + issues_parts = [] + if violations: issues_parts.append(f"{len(violations)} Verstoss") + if new_track: issues_parts.append(f"{len(new_track)} neue Tracker") + if undoc: issues_parts.append(f"{len(undoc)} undokumentiert") + issues_str = ", ".join(issues_parts) or "—" + parts.append( + f'' + f'' + f'' + f'' + f'' + f'' + ) + parts.append('
PhaseCookiesTrackerAuffaelligkeiten
' + f'' + f'{_short_phase_label(key)}{len(cookies)}{len(trackers)}{issues_str}
') + + # 3) Per-Category-Tracker + cats = banner_result.get("category_tests") or [] + if cats: + non_essential = [c for c in cats if c.get("category") != "necessary"] + if non_essential: + parts.append( + '
Provider-Listing pro Banner-Kategorie:
' + '' + '' + '' + '' + '' + '' + ) + for c in non_essential: + n = len(c.get("tracking_services") or []) + label = c.get("category_label") or c.get("category", "?") + if n == 0: + color = "#dc2626" + hint = ("Keine Anbieter sichtbar — Nutzer kann nicht " + "informiert einwilligen (Art. 7 DSGVO)") + else: + color = "#16a34a" + hint = "" + parts.append( + f'' + f'' + f'' + f'' + ) + parts.append('
KategorieAnbieterHinweis
{label}{n}' + f'{hint}
') + + # 4) Violations mit Rechtsgrundlage + violations = (banner_result.get("banner_checks") or {}).get("violations", []) + if violations: + parts.append( + '
Erkannte Banner-Verstoesse:
' + '') + + parts.append('
') + return "".join(parts) diff --git a/backend-compliance/compliance/api/agent_doc_check_critical.py b/backend-compliance/compliance/api/agent_doc_check_critical.py new file mode 100644 index 00000000..a11c65c7 --- /dev/null +++ b/backend-compliance/compliance/api/agent_doc_check_critical.py @@ -0,0 +1,208 @@ +""" +P18 — Critical-Findings-Block fuer die Executive-Summary. + +Analysiert die echten Daten (banner_checks, phases, scorecard, results) und +rendert einen ROTEN Sofortmassnahmen-Block GANZ OBEN in der Email — mit +Quellenangaben (DSK, EDPB, EuGH, Behoerden-Buessgeld-Faelle) und konkreten +Sofortmassnahmen. + +Regel: Block wird nur gerendert wenn echte kritische Verstoesse vorliegen. +Bei sauberen Sites bleibt er weg. +""" + +from __future__ import annotations + + +# Bekannte Buessgeld-Praezedenzfaelle als Quellen-Hint +_BUSSGELD_REFS = { + "no_provider_per_category": "CNIL France 2023 — TikTok 5 Mio EUR (fehlende Vendor-Transparenz)", + "dse_unvollstaendig": "BayLDA 2024 — diverse Mittelstand-Faelle, 5k–50k EUR", + "cookie_doc_missing": "LfDI BW 2023 — fehlende Cookie-Erklaerung, 30k EUR", + "dark_pattern_reject": "EDPB Guidelines 3/2022 + DSK 2024 — Bussgeldrahmen Art. 83 DSGVO", + "schrems_ii": "EuGH C-311/18 (Schrems II) — Bussgeldrahmen bis 4% Konzern-Umsatz", + "impressum_im_banner": "LG Rostock 3 O 22/19 — Impressum-Pflicht ueberlagernder Banner", +} + + +def _detect_critical_issues( + banner_result: dict | None, + scorecard: dict | None, + results: list, +) -> list[dict]: + """Erkenne kritische Verstoesse aus den vorliegenden Daten.""" + issues: list[dict] = [] + br = banner_result or {} + sc = scorecard or {} + + # 1) Banner-Violations (HIGH/CRITICAL) aus consent-tester + for v in (br.get("banner_checks") or {}).get("violations", []): + sev = (v.get("severity") or "").upper() + if sev in ("CRITICAL", "HIGH"): + issues.append({ + "key": "banner_violation", + "title": v.get("text", "")[:120], + "severity": sev, + "action": _action_for_banner_violation(v), + "source": v.get("legal_ref", ""), + "bussgeld": _BUSSGELD_REFS.get("impressum_im_banner") + if "impressum" in (v.get("text") or "").lower() + else _BUSSGELD_REFS.get("dark_pattern_reject"), + }) + + # 2) Category-Tests: leere Vendor-Liste pro Kategorie (Safetykon-Pattern) + cat_tests = br.get("category_tests") or [] + empty_cats = [c for c in cat_tests + if not c.get("tracking_services") + and c.get("category") != "necessary"] + if empty_cats and len(cat_tests) >= 2: + cats = ", ".join(c.get("category_label", c.get("category", "?")) + for c in empty_cats) + issues.append({ + "key": "no_provider_per_category", + "title": f"Cookie-Banner Kategorien ({cats}) ohne Provider-Listing", + "severity": "HIGH", + "action": ("Pro Banner-Kategorie eine Liste der eingebundenen " + "Anbieter + Cookie-Details (Name, Zweck, Speicherdauer, " + "Drittlandtransfer) ergaenzen. Sonst ist die " + "Einwilligung nicht 'informiert' nach Art. 7 DSGVO."), + "source": "Art. 7 DSGVO, EDPB Guidelines 2/2023, DSK 2024", + "bussgeld": _BUSSGELD_REFS["no_provider_per_category"], + }) + + # 3) DSGVO/TDDDG-Score < 30%: DSE rechtswidrig + pct = int((sc.get("totals") or {}).get("pct", 100)) + if pct and pct < 30: + issues.append({ + "key": "dse_unvollstaendig", + "title": f"Datenschutzerklaerung erfuellt nur {pct}% der Pflichten", + "severity": "HIGH", + "action": ("Vollstaendig nach Art. 13 DSGVO ueberarbeiten: " + "Verantwortlicher, Zwecke, Rechtsgrundlage, " + "Speicherdauer, Drittland-Transfers, alle Betroffenen-" + "rechte, konkrete Aufsichtsbehoerde."), + "source": "Art. 13 DSGVO + Art. 14 (alternativ), DSK-OH Telemedien 2024", + "bussgeld": _BUSSGELD_REFS["dse_unvollstaendig"], + }) + + # 4) Cookie-Richtlinie fehlt komplett (nicht erreichbar) + cookie_missing = any( + (r.doc_type == "cookie" if hasattr(r, "doc_type") else + r.get("doc_type") == "cookie") + and ((r.error if hasattr(r, "error") else r.get("error", "")) or "") + .startswith("Auf der Website nicht gefunden") + for r in (results or []) + ) + cookie_deduped = any( + (r.doc_type == "cookie" if hasattr(r, "doc_type") else + r.get("doc_type") == "cookie") + and "Nicht separat vorhanden" in + ((r.error if hasattr(r, "error") else r.get("error", "")) or "") + for r in (results or []) + ) + if cookie_missing or cookie_deduped: + issues.append({ + "key": "cookie_doc_missing", + "title": ("Keine eigenstaendige Cookie-Richtlinie" + if cookie_deduped + else "Cookie-Richtlinie nicht auffindbar"), + "severity": "HIGH", + "action": ("Separate Cookie-Richtlinie-Seite erstellen mit " + "tabellarischer Auflistung aller Cookies (Name, " + "Anbieter, Zweck, Speicherdauer, Drittlandtransfer). " + "Direkt aus dem Banner verlinken."), + "source": "Art. 13 DSGVO, §25 TDDDG, DSK-OH Telemedien 2024", + "bussgeld": _BUSSGELD_REFS["cookie_doc_missing"], + }) + + # 5) Schrems-II-Risiko: Google/Meta/Microsoft im Banner, aber keine SCC/DPF + # Detection: pre-/post-consent-cookies in den phases enthalten US-Tracker + phases = br.get("phases") or {} + has_us_tracker = False + for ph in phases.values(): + if not isinstance(ph, dict): + continue + for t in (ph.get("tracking_services") or []): + if isinstance(t, dict): + name = (t.get("name", "") or "").lower() + else: + name = str(t).lower() + if any(w in name for w in ("google", "meta", "facebook", + "microsoft", "linkedin", "tiktok")): + has_us_tracker = True + break + if has_us_tracker: + issues.append({ + "key": "schrems_ii", + "title": "US-Tracker geladen — Schrems-II-Risiko", + "severity": "HIGH", + "action": ("Pro Drittland-Anbieter dokumentieren: SCC (Art. 46 " + "DSGVO) ODER DPF-Zertifizierung pruefen + in der " + "Datenschutzerklaerung explizit benennen."), + "source": "Art. 44 ff. DSGVO, EuGH C-311/18 (Schrems II)", + "bussgeld": _BUSSGELD_REFS["schrems_ii"], + }) + + return issues + + +def _action_for_banner_violation(v: dict) -> str: + text = (v.get("text") or "").lower() + if "impressum" in text: + return ("Impressum-Link direkt im Banner ergaenzen — bei " + "ueberlagerndem Banner Pflicht nach §5 TMG.") + if "ablehnen" in text or "dark pattern" in text: + return ("'Ablehnen'-Button visuell gleichwertig zu 'Akzeptieren' " + "gestalten (gleiche Groesse, Farbe, Position).") + if "widerruf" in text or "cookie-einstellungen" in text: + return ("Floating-Icon oder Footer-Link 'Cookie-Einstellungen' " + "permanent einblenden — Widerruf so einfach wie Erteilung.") + return ("Banner-Verstoss beheben gemaess der genannten Rechtsgrundlage.") + + +def build_critical_findings_html( + banner_result: dict | None, + scorecard: dict | None, + results: list, +) -> str: + """Render der Critical-Findings-Box. Leerer String wenn keine Issues.""" + issues = _detect_critical_issues(banner_result, scorecard, results) + if not issues: + return "" + + items = [] + for i in issues: + items.append( + f'
' + f'
' + f'{i["severity"]}{i["title"]}
' + f'
' + f'Sofortmassnahme: {i["action"]}
' + f'
Rechtsgrundlage: {i.get("source","")}' + + (f' · Praezedenz: {i["bussgeld"]}' + if i.get("bussgeld") else "") + + f'
' + ) + + n = len(issues) + return ( + '
' + '
' + '🚨 Sofortmassnahmen erforderlich
' + f'

' + f'{n} kritische Compliance-Risiken mit Bussgeldpotenzial

' + '

' + 'Die folgenden Verstoesse sind durch Tool-Analyse belegt und ' + 'erfordern Sofortmassnahmen. Bussgeldrahmen nach Art. 83 DSGVO: ' + 'bis 4% des weltweiten Jahresumsatzes.

' + + "".join(items) + + '
' + )