diff --git a/backend-compliance/compliance/api/agent_compliance_check_routes.py b/backend-compliance/compliance/api/agent_compliance_check_routes.py index 8bee824c..3446037c 100644 --- a/backend-compliance/compliance/api/agent_compliance_check_routes.py +++ b/backend-compliance/compliance/api/agent_compliance_check_routes.py @@ -572,6 +572,33 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): site_name=site_name_for_exec, ) + # P10: Cookie-Policy-Architecture-Detection (BMW-Pattern erkennen) + cookie_arch_html = "" + try: + from compliance.services.cookie_policy_architecture import ( + detect_architecture, build_architecture_html, + ) + cookie_doc_url = "" + cookie_doc_text = doc_texts.get("cookie", "") + cookie_cmp_payloads: list[dict] = [] + for e in doc_entries: + if (e.get("doc_type") or "").lower() in ("cookie", "cookie_policy"): + cookie_doc_url = e.get("url", "") + cookie_cmp_payloads = e.get("cmp_payloads") or [] + break + if cookie_doc_text: + arch = detect_architecture( + doc_url=cookie_doc_url, + doc_text=cookie_doc_text, + cmp_payloads=cookie_cmp_payloads, + homepage_cmp_payloads=cmp_payloads or [], + ) + cookie_arch_html = build_architecture_html(arch) + logger.info("cookie-arch: layer=%s versioned=%s risk=%s", + arch["layer_separation"], arch["versioned"], arch["risk_label"]) + except Exception as e: + logger.warning("cookie-architecture detection failed: %s", e) + # Reihenfolge — Sales-optimiert: # 1) Exec-Summary (KPIs + Saving + CTAs) # 2) summary_html (Konkrete Aufgaben fuer die Geschaeftsfuehrung) @@ -582,7 +609,8 @@ 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 + summary_html + scanned_html + profile_html + exec_summary_html + cookie_arch_html + summary_html + + scanned_html + profile_html + scorecard_html + redundancy_html + providers_html + vvt_html + report_html ) diff --git a/backend-compliance/compliance/services/cookie_policy_architecture.py b/backend-compliance/compliance/services/cookie_policy_architecture.py new file mode 100644 index 00000000..3ce30f7d --- /dev/null +++ b/backend-compliance/compliance/services/cookie_policy_architecture.py @@ -0,0 +1,255 @@ +""" +Cookie-Policy-Architecture-Detection. + +Erkennt vier Diagnose-Punkte zur rechtlichen Bewertung der Cookie-Policy +einer Website. Hintergrund: die DSGVO + TDDDG verlangen ZWEI Layer +(Banner fuer Consent + Cookie-Richtlinie fuer Information), aber lassen +offen ob das in einem oder zwei HTML-Dokumenten umgesetzt wird. + +BMW-Pattern: eine HTML-Seite ist GLEICHZEITIG der Banner-Re-Trigger und +die Cookie-Richtlinie. Mindestanforderung erfuellt, aber kein +versionierter Audit-Trail moeglich -> "gelbes" Risiko. + +Output-Format: + { + "layer_separation": "single" | "separate" | "unknown", + "versioned": bool, + "dynamic_content": bool, + "vendor_count_in_text": int, + "risk_label": "gruen" | "gelb" | "rot", + "recommendation": str, + "signals": [{"src": ..., "detail": ...}], + } +""" + +from __future__ import annotations + +import re +from urllib.parse import urlparse + + +# Regex fuer "Stand vom DD.MM.JJJJ" / "Stand: DD.MM.JJJJ" / "Version X.Y" +_VERSION_PATTERNS = [ + r"stand\s*[:\-]?\s*(?:vom\s+)?\d{1,2}\.\s*\d{1,2}\.\s*\d{4}", + r"stand\s*[:\-]?\s*\d{1,2}\.\s*\w+\s+\d{4}", # "Stand: 1. Mai 2026" + r"letzte\s+(?:aktualisierung|aenderung|änderung)\s*[:\-]?\s*\d{1,2}\.", + r"version\s*[:\-]?\s*\d+(?:\.\d+)?", + r"stand\s+der\s+(?:information|cookie)\w*\s*[:\-]?\s*\d{1,2}\.", + r"(?:gueltig|gültig)\s+ab\s+\d{1,2}\.\s*\d{1,2}\.\s*\d{4}", +] + +# Hinweise auf dynamische Generierung +_DYNAMIC_MARKERS = [ + "wird automatisch aktualisiert", + "wird dynamisch generiert", + "wird laufend angepasst", + "cookie-einstellungen ändern", + "cookie-einstellungen aendern", + "cookie-praeferenzen verwalten", + "cookie-präferenzen verwalten", + "consent aktualisieren", + "einwilligung verwalten", + "einwilligungs-einstellungen", +] + +# CMP-Trigger-Marker (Container-/Button-Texte die typischerweise das +# Banner re-oeffnen) +_BANNER_TRIGGER_MARKERS = [ + "cookie-einstellungen öffnen", + "cookie einstellungen öffnen", + "ihre cookie-präferenzen", + "ihre cookie praeferenzen", + "consent banner", + "datenschutz-einstellungen", + "cookie-banner anzeigen", +] + + +def _normalize_url(u: str) -> str: + if not u: + return "" + if "://" not in u: + u = "https://" + u + p = urlparse(u) + path = p.path.rstrip("/").lower() + host = p.netloc.lower().replace("www.", "") + return f"{host}{path}" + + +def _check_versioned(text_lower: str) -> tuple[bool, str | None]: + for pat in _VERSION_PATTERNS: + m = re.search(pat, text_lower) + if m: + return True, m.group()[:80] + return False, None + + +def _check_dynamic(text_lower: str) -> tuple[bool, str | None]: + for marker in _DYNAMIC_MARKERS: + if marker in text_lower: + return True, marker + return False, None + + +def _check_banner_trigger(text_lower: str) -> tuple[bool, str | None]: + for marker in _BANNER_TRIGGER_MARKERS: + if marker in text_lower: + return True, marker + return False, None + + +def _count_vendor_signals(text_lower: str) -> int: + """Zaehle wieviele Vendor-Namen im Text — Indikator ob die Liste statisch + drinsteht oder dynamisch nachgeladen wird.""" + vendor_signals = [ + "google", "meta", "facebook", "adobe", "microsoft", "linkedin", + "tiktok", "amazon", "hotjar", "cloudflare", "stripe", "salesforce", + "hubspot", "mailchimp", "pinterest", "snapchat", "youtube", "vimeo", + ] + return sum(1 for v in vendor_signals if v in text_lower) + + +def detect_architecture( + doc_url: str, + doc_text: str, + cmp_payloads: list[dict] | None = None, + homepage_cmp_payloads: list[dict] | None = None, +) -> dict: + """Pruefe die Layer-Architektur einer Cookie-Richtlinie. + + Args: + doc_url: URL des erkannten Cookie-Richtlinie-Dokuments + doc_text: Volltext der Cookie-Richtlinie + cmp_payloads: CMP-Capture die WAEHREND des doc-Crawls passiert sind + homepage_cmp_payloads: CMP-Capture vom initialen Homepage-Crawl + """ + text_lower = (doc_text or "").lower() + signals: list[dict] = [] + + # 1. Single- vs Separate-Layer + cmp_on_doc = bool(cmp_payloads) + banner_trigger, trigger_marker = _check_banner_trigger(text_lower) + if cmp_on_doc and banner_trigger: + layer = "single" + signals.append({"src": "cmp+marker", + "detail": f"CMP feuerte auf Doc-URL + Marker '{trigger_marker}'"}) + elif cmp_on_doc: + layer = "single" + signals.append({"src": "cmp", "detail": "CMP-Payload waehrend Doc-Crawl"}) + elif banner_trigger: + layer = "single" + signals.append({"src": "marker", "detail": f"Trigger-Marker: '{trigger_marker}'"}) + elif homepage_cmp_payloads and not cmp_on_doc: + layer = "separate" + signals.append({"src": "topology", + "detail": "Banner triggert nur auf Homepage, Cookie-Doc ist eigene Seite"}) + else: + layer = "unknown" + + # 2. Versionierung + versioned, version_marker = _check_versioned(text_lower) + if versioned: + signals.append({"src": "version", "detail": f"Marker: '{version_marker}'"}) + + # 3. Dynamic content + dynamic, dyn_marker = _check_dynamic(text_lower) + if dynamic or cmp_on_doc: + dynamic = True + if dyn_marker: + signals.append({"src": "dynamic", "detail": dyn_marker}) + + # 4. Vendor-Count (Indikator ob Liste statisch im Text steht) + vendor_count = _count_vendor_signals(text_lower) + + # Risiko-Bewertung + if layer == "unknown" and vendor_count < 3: + risk = "rot" + rec = ( + "Cookie-Richtlinie konnte nicht eindeutig identifiziert oder ist " + "unzureichend. Pruefen Sie ob die Pflicht-Information nach " + "Art. 13 DSGVO + §25 TDDDG ueberhaupt erreichbar ist." + ) + elif layer == "single" and not versioned: + risk = "gelb" + rec = ( + "BMW-Pattern erkannt: Single-Layer-CMP (Banner-Trigger + " + "Info-Layer in einer URL). Mindestanforderung erfuellt, aber " + "OHNE Versionierung. Bei einer Aufsichtsbehoerden-Pruefung " + "kann nicht belegt werden welche Vendor-Liste an einem " + "bestimmten Stichtag aktiv war. Empfehlung: monatlicher " + "Snapshot der dynamischen Vendor-Tabelle als versioniertes " + "PDF im Archiv." + ) + elif layer == "single" and versioned: + risk = "gelb" + rec = ( + "Single-Layer mit Versionierung — gute Mindestloesung. " + "Best Practice waere zusaetzlich eine getrennte statische " + "Vendor-Tabelle die Crawler indexieren koennen." + ) + elif layer == "separate" and versioned: + risk = "gruen" + rec = ( + "Best Practice umgesetzt: separater Banner + versionierte " + "Cookie-Richtlinie." + ) + elif layer == "separate" and not versioned: + risk = "gelb" + rec = ( + "Separate Cookie-Richtlinie vorhanden, aber ohne Versionierung. " + "Snapshot-Archiv empfohlen." + ) + else: + risk = "gelb" + rec = "Cookie-Policy-Architektur uneindeutig — manuelle Pruefung empfohlen." + + return { + "layer_separation": layer, + "versioned": versioned, + "dynamic_content": dynamic, + "vendor_count_in_text": vendor_count, + "risk_label": risk, + "recommendation": rec, + "signals": signals, + "doc_url_normalized": _normalize_url(doc_url), + } + + +def build_architecture_html(arch: dict) -> str: + """Render the architecture block for the executive summary.""" + if not arch: + return "" + risk_colors = { + "gruen": ("#16a34a", "#dcfce7", "#166534"), + "gelb": ("#d97706", "#fef3c7", "#92400e"), + "rot": ("#dc2626", "#fee2e2", "#991b1b"), + } + border, bg, fg = risk_colors.get(arch["risk_label"], ("#94a3b8", "#f1f5f9", "#475569")) + + layer_label = {"single": "Single-Layer (kombiniert)", + "separate": "Separate Layer (Best Practice)", + "unknown": "Nicht eindeutig"}[arch["layer_separation"]] + versioned_lbl = "ja" if arch["versioned"] else "nein" + dynamic_lbl = "ja (CMP-generiert)" if arch["dynamic_content"] else "statisch" + + return ( + f'
' + f'
Cookie-Policy-Architektur
' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'
Layer-Trennung{layer_label}
Versionierung{versioned_lbl}
Vendor-Liste{dynamic_lbl}
Vendor-Namen im Text{arch["vendor_count_in_text"]}
' + f'
' + f'{arch["recommendation"]}
' + f'
' + )