""" Cookie-Compliance-Audit — 3-Quellen-Vergleich. DAS ist der eigentliche Mehrwert des Tools: * A. Was in der Cookie-Richtlinie DEKLARIERT ist (Text-Parse) * B. Was im Browser TATSAECHLICH GELADEN wurde (after_accept) * C. Was unsere LIBRARY ueber den Cookie weiss (Vendor, Kategorie) Daraus 3 Listen: 1. ✓ deklariert + geladen + library-bekannt → compliant 2. ❌ geladen aber NICHT deklariert → HIGH-Verstoss (Art. 13(1)(c) DSGVO) 3. ⚠️ deklariert aber NICHT geladen → Tabelle veraltet (LOW) 4. 🔍 deklariert + Library-Kategorie weicht ab → Pruefanlass """ from __future__ import annotations import logging import re from typing import Iterable from sqlalchemy import text as sa_text from sqlalchemy.orm import Session logger = logging.getLogger(__name__) def _normalize_cookie_name(name: str) -> str: """Wildcard-Cookies wie 'AMCV_*', 'pm_sess_NNN' werden auf Prefix reduziert damit '_ga' und '_ga_GTM-XXX' als ein Cookie zaehlen.""" if not name: return "" s = name.strip() # AMCV_*, sc_v44, etc. s = re.sub(r"[<\[].*?[>\]]", "", s) # entferne , [...] s = s.rstrip("*").rstrip("_") s = re.sub(r"_NNN$|_\d+$", "", s) return s.lower() def _extract_declared_cookies(cookie_doc_text: str | None) -> set[str]: """Liest Cookie-Namen aus dem Cookie-Richtlinien-Text. Nutzt zuerst parse_cookie_table (Block/Tab-Format), dann parse_flat_cookie_text (Anchor-Pattern). """ if not cookie_doc_text: return set() declared: set[str] = set() try: from compliance.services.cookies_table_parser import ( parse_cookie_table, parse_flat_cookie_text, ) for v in parse_cookie_table(cookie_doc_text): for c in (v.get("cookies") or []): if isinstance(c, dict) and c.get("name"): declared.add(_normalize_cookie_name(c["name"])) for v in parse_flat_cookie_text(cookie_doc_text): for c in (v.get("cookies") or []): if isinstance(c, dict) and c.get("name"): declared.add(_normalize_cookie_name(c["name"])) except Exception as e: logger.warning("declared-cookie-extract failed: %s", e) return {n for n in declared if n} def _extract_browser_cookies(banner_result: dict | None) -> set[str]: """Liest Cookie-Namen aus banner_result.phases.after_accept.cookies.""" out: set[str] = set() if not isinstance(banner_result, dict): return out phases = banner_result.get("phases") or {} for ph_name in ("after_accept", "before_consent", "after_reject"): ph = phases.get(ph_name) or {} if not isinstance(ph, dict): continue for c in (ph.get("cookies") or []): if isinstance(c, str): out.add(_normalize_cookie_name(c)) elif isinstance(c, dict) and c.get("name"): out.add(_normalize_cookie_name(c["name"])) return {n for n in out if n} def _lookup_library(db: Session, names: Iterable[str]) -> dict[str, dict]: """Liefert {normalized_name: {category, vendor}} aus cookie_library.""" nl = [n for n in names if n] if not nl: return {} try: rows = db.execute(sa_text( "SELECT cookie_name, actual_category, vendor_name " "FROM compliance.cookie_library " "WHERE LOWER(cookie_name) = ANY(:lc)" ), {"lc": nl}).fetchall() return {r[0].lower(): {"category": r[1], "vendor": r[2]} for r in rows} except Exception as e: logger.warning("library lookup failed: %s", e) return {} def audit_cookie_compliance( db: Session | None, cookie_doc_text: str | None, banner_result: dict | None, ) -> dict: """Hauptfunktion: liefert dict mit 4 Listen + counts.""" declared = _extract_declared_cookies(cookie_doc_text) browser = _extract_browser_cookies(banner_result) all_names = declared | browser library = _lookup_library(db, all_names) if db else {} declared_only = declared - browser browser_only = browser - declared both = declared & browser return { "declared_count": len(declared), "browser_count": len(browser), "library_count": len(library), "compliant": sorted(both), "undeclared_in_browser": sorted(browser_only), "declared_not_loaded": sorted(declared_only), "library_metadata": library, "high_findings": len(browser_only), "low_findings": len(declared_only), } def build_cookie_audit_block_html(audit: dict) -> str: """Rendert den 3-Spalten-Vergleichs-Block in die Mail.""" if not audit: return "" n_dec = audit.get("declared_count", 0) n_brw = audit.get("browser_count", 0) n_undecl = len(audit.get("undeclared_in_browser") or []) n_dec_only = len(audit.get("declared_not_loaded") or []) n_both = len(audit.get("compliant") or []) sev_color = "#dc2626" if n_undecl else "#16a34a" undecl_html = "" if audit.get("undeclared_in_browser"): undecl_html = ( '
' f'❌ {n_undecl} Cookie' f'{"s" if n_undecl != 1 else ""} im Browser geladen, ' 'aber NICHT in der Cookie-Richtlinie deklariert:' '
' + ", ".join(audit["undeclared_in_browser"][:50]) + (f' ... +{n_undecl - 50} weitere' if n_undecl > 50 else '') + '
' '
Art. 13(1)(c) DSGVO + § 25 TDDDG — ' 'die Empfaengerliste muss vollstaendig sein. Diese Cookies ' 'sind potenziell ungenannte Verarbeitungen.
' '
' ) dec_only_html = "" if audit.get("declared_not_loaded"): dec_only_html = ( '
' f'⚠️ {n_dec_only} Cookie' f'{"s" if n_dec_only != 1 else ""} in der Richtlinie ' 'deklariert, aber bei diesem Audit NICHT im Browser gesehen:' '
' + ", ".join(audit["declared_not_loaded"][:50]) + (f' ... +{n_dec_only - 50} weitere' if n_dec_only > 50 else '') + '
' '
Kein direkter Verstoss — die Cookies ' 'koennen nur in bestimmten User-Journeys / Geo-Regionen / ' 'eingeloggten Zustaenden geladen werden. Empfehlung: ' 'pruefen ob die Cookie-Richtlinie veraltet ist.
' '
' ) compliant_html = "" if audit.get("compliant"): compliant_html = ( '
' f'✓ {n_both} Cookie' f'{"s" if n_both != 1 else ""} sowohl deklariert als auch geladen ' '(compliant):' '
' + ", ".join(audit["compliant"][:50]) + (f' ... +{n_both - 50} weitere' if n_both > 50 else '') + '
' '
' ) return ( '
' f'
' 'Cookie-Compliance-Audit — 3-Quellen-Vergleich
' '

' f'{n_dec} in Richtlinie · {n_brw} im Browser · ' f'{n_both} compliant · {n_undecl} undokumentiert · ' f'{n_dec_only} nicht geladen

' '

' 'Wir vergleichen die in der Cookie-Richtlinie genannten Cookies ' 'mit dem was der Browser nach Akzeptieren tatsaechlich laed. ' 'Undokumentierte Cookies im Browser sind ein direkter Verstoss ' 'gegen die DSGVO-Informationspflicht.' '

' + undecl_html + dec_only_html + compliant_html + '
' )