""" P35 + P77 + P78 — Post-hoc Textsignal-Checks auf den geladenen Dokumenten-Texten (DSE / Cookie-Richtlinie / Banner-Text). P35 — "Speichern" als mehrdeutiges Reject-Label im Banner. Wenn das einzige Schliess-Element nur "Speichern" heisst (statt "Alle ablehnen" / "Nur notwendige"), ist das ein MEDIUM-Finding, weil der Nutzer nicht versteht ob er gerade akzeptiert oder abgelehnt hat. P77 — Cookie-Doc-Architecture: wenn keine eigene Cookie-Richtlinie ausgeliefert wurde, aber die DSE einen prominent benannten Cookie-Abschnitt enthaelt (mit Vendor-Liste + Speicherdauer), ist das ein gleichwertiger OEM-Pattern. Liefert positives Signal statt MEDIUM-Finding "Cookie-Richtlinie fehlt". P78 — JC-Detection in DSE-Text: erkennt 'gemeinsam Verantwortliche'- Klauseln (Art. 26 DSGVO) im DSE-Text. Liefert positives Signal "JC-Konstrukt dokumentiert" — verhindert False-Positive "JC nicht erwaehnt obwohl Kooperation mit Konzern-Schwester". Alle drei liefern dict shape {"severity": ...} oder positive-signal-dict. """ from __future__ import annotations import logging import re logger = logging.getLogger(__name__) _REJECT_LABEL_KEYS = ( "alle ablehnen", "ablehnen", "reject all", "deny all", "nur notwendige", "nur essenzielle", "nur erforderliche", "essentials only", "verweigern", "block all", ) _SAVE_ONLY_KEYS = ( "speichern", "auswahl speichern", "save selection", "auswahl bestaetigen", ) _COOKIE_SECTION_HEADINGS = ( "cookies und tracking", "cookies und vergleichbare technologien", "cookies und aehnliche technologien", "verwendung von cookies", "informationen zu cookies", "uebersicht der cookies", "eingesetzte cookies", "cookies im einsatz", ) _VENDOR_HINTS = ( "speicherdauer", "lebensdauer", "anbieter", "drittanbieter", "datenempfaenger", "datenkategorie", "rechtsgrundlage", ) _JC_PATTERNS = ( "gemeinsam verantwortlich", "joint controller", "gemeinsame verantwortung", "art. 26 dsgvo", "art 26 dsgvo", "vereinbarung gemaess art. 26", "joint-controller-vereinbarung", "gemeinsame verarbeitung", ) def check_save_only_reject(banner_result: dict) -> dict | None: """P35 — Banner hat keinen klaren Reject, nur "Speichern".""" initial = ((banner_result or {}).get("phases") or {}).get("initial") or {} if not isinstance(initial, dict): return None btext = (initial.get("banner_text") or "").lower() if not btext or len(btext) < 30: return None has_clear_reject = any(k in btext for k in _REJECT_LABEL_KEYS) has_save_only = any(k in btext for k in _SAVE_ONLY_KEYS) if has_clear_reject or not has_save_only: return None return { "severity": "MEDIUM", "code": "save_label_ambiguous", "label": ( 'Banner verwendet "Speichern" ohne erkennbares "Ablehnen" ' '— mehrdeutig fuer den Nutzer' ), "detail": ( 'Der Button "Speichern" laesst offen, ob die aktuelle ' 'Vorauswahl (oft alles aktiv) bestaetigt oder nur die ' 'getroffene Auswahl uebernommen wird. EDPB 03/2022 empfiehlt ' 'eindeutige Labels: "Alle akzeptieren" + "Alle ablehnen".' ), "legal_basis": "Art. 7 (1) DSGVO + EDPB 03/2022 Guidelines on " "deceptive design patterns.", } def check_cookies_in_dse( doc_texts: dict[str, str], cookie_doc_missing: bool, ) -> dict | None: """P77 — DSE hat eigenen Cookie-Abschnitt mit Vendor-Hints.""" if not cookie_doc_missing: return None dse = (doc_texts or {}).get("dse") or "" if len(dse) < 1000: return None dse_lower = dse.lower() has_heading = any(h in dse_lower for h in _COOKIE_SECTION_HEADINGS) if not has_heading: return None vendor_hint_count = sum(1 for h in _VENDOR_HINTS if h in dse_lower) if vendor_hint_count < 3: return None # zu wenig substanziell return { "severity": "INFO", # Positives Signal, kein Finding "code": "cookies_in_dse_accepted", "label": ( "Cookie-Informationen sind im Datenschutz-Dokument enthalten " "(eigener Abschnitt mit Vendor-Hinweisen)" ), "detail": ( "Die Praxis vieler OEM-Sites, Cookies als eigenen Abschnitt " 'in der DSE zu fuehren (statt als separate Datei), wird als ' "gleichwertig akzeptiert. Empfehlung trotzdem: separate " "Cookie-Richtlinie erleichtert kuenftige Aenderungen und " "Versionierung." ), "legal_basis": "Art. 13(1)(c) DSGVO — Form ist nicht vorgegeben, " "Inhalt muss vollstaendig sein.", } def check_jc_clause_in_dse(doc_texts: dict[str, str]) -> dict | None: """P78 — DSE enthaelt Art. 26 JC-Klausel.""" dse = (doc_texts or {}).get("dse") or "" if not dse: return None dse_lower = dse.lower() matches = [p for p in _JC_PATTERNS if p in dse_lower] if not matches: return None return { "severity": "INFO", "code": "jc_clause_documented", "label": "Gemeinsame Verantwortlichkeit (Art. 26 DSGVO) im " "DSE-Text dokumentiert", "detail": ( f'Erkannte Signale: {", ".join(sorted(set(matches))[:3])}. ' 'Das verhindert das False-Positive "JC-Konstrukt nicht ' 'erwaehnt" bei Sites mit Konzern-Schwesterunternehmen.' ), "legal_basis": "Art. 26 DSGVO + EDPB 7/2020 Guidelines on the " "concepts of controller and processor.", } def run_all( banner_result: dict | None, doc_texts: dict[str, str] | None, cookie_doc_missing: bool = False, ) -> list[dict]: findings: list[dict] = [] try: f = check_save_only_reject(banner_result or {}) if f: findings.append(f) except Exception as e: logger.warning("P35 save_only_reject failed: %s", e) try: f = check_cookies_in_dse(doc_texts or {}, cookie_doc_missing) if f: findings.append(f) except Exception as e: logger.warning("P77 cookies_in_dse failed: %s", e) try: f = check_jc_clause_in_dse(doc_texts or {}) if f: findings.append(f) except Exception as e: logger.warning("P78 jc_clause failed: %s", e) return findings def build_signals_block_html(findings: list[dict]) -> str: if not findings: return "" pos = [f for f in findings if f.get("severity") == "INFO"] neg = [f for f in findings if f.get("severity") != "INFO"] items: list[str] = [] for f in neg + pos: sev = f.get("severity", "MEDIUM") if sev == "INFO": color = "#16a34a" tag = "✓ POSITIV" elif sev == "HIGH": color = "#dc2626" tag = "HOCH" else: color = "#d97706" tag = "MITTEL" items.append( f'
  • ' f'[{tag}] {f.get("label","")}' f'
    {f.get("detail","")}
    ' f'
    ' f'{f.get("legal_basis","")}
  • ' ) return ( '
    ' '
    ' 'Weitere Textsignale
    ' '
    ' )