diff --git a/consent-tester/checks/__init__.py b/consent-tester/checks/__init__.py new file mode 100644 index 0000000..2c939f2 --- /dev/null +++ b/consent-tester/checks/__init__.py @@ -0,0 +1,16 @@ +""" +checks — Cookie-banner compliance checkers (L1/L2 hierarchy). + +Provides a structured checklist for verifying cookie banner compliance +against EDPB guidelines, CNIL enforcement, EuGH rulings, and national law. + +Two check levels: + L1 — "Does the banner meet this fundamental requirement?" + L2 — "Is the specific sub-requirement fulfilled correctly?" +""" + +from .banner_checks import BANNER_CHECKLIST + +__all__ = [ + "BANNER_CHECKLIST", +] diff --git a/consent-tester/checks/banner_checks.py b/consent-tester/checks/banner_checks.py new file mode 100644 index 0000000..9610540 --- /dev/null +++ b/consent-tester/checks/banner_checks.py @@ -0,0 +1,708 @@ +""" +Cookie-Banner Compliance Checks — L1/L2 Hierarchy. + +6 L1 checks (fundamental requirements) with 30 L2 detail checks. +Each check_key maps to an existing check in banner_text_checker.py +or banner_advanced_checks.py. + +Legal references: EDPB Guidelines 3/2022 (Deceptive Design Patterns), +EDPB Guidelines 05/2020 (Consent), CNIL Leitlinien, EuGH C-673/17 +(Planet49), §25 TDDDG, Art. 5(3) ePrivacy-RL, Art. 7/12/13 DSGVO. +""" + +BANNER_CHECKLIST = [ + # ===================================================================== + # L1-1: Banner vorhanden + # ===================================================================== + { + "id": "banner_present", + "label": "Cookie-Banner vorhanden und funktional", + "level": 1, + "parent": None, + "check_key": "banner_detected", + "severity": "CRITICAL", + "hint": ( + "Wer nicht-essentielle Cookies oder Tracking einsetzt, braucht eine " + "Einwilligungsabfrage BEVOR der Zugriff auf das Endgeraet erfolgt " + "(ss25 Abs. 1 TDDDG, Art. 5(3) ePrivacy-RL). Ohne Banner ist jedes " + "gesetzte Tracking-Cookie rechtswidrig. Ausnahme: Rein technisch " + "notwendige Cookies (Session-ID, Warenkorb, Load-Balancer) benoetigen " + "kein Banner (ss25 Abs. 2 TDDDG). Haeufiger Fehler: Website setzt " + "Google Analytics, hat aber kein Banner — das ist ein Verstoss ab dem " + "ersten Seitenaufruf." + ), + }, + # ── L2 unter banner_present ────────────────────────────────────── + { + "id": "banner_language_match", + "label": "Banner-Sprache entspricht Seitensprache", + "level": 2, + "parent": "banner_present", + "check_key": "banner_language_mismatch", + "severity": "MEDIUM", + "hint": ( + "Art. 12(1) DSGVO: Informationen muessen in 'klarer und einfacher " + "Sprache' bereitgestellt werden. Ein englisches Banner auf einer " + "deutschen Seite (oder umgekehrt) erfuellt dieses Kriterium nicht, " + "weil Nutzer die Tragweite ihrer Einwilligung nicht verstehen koennen " + "(ErwGr. 39, 42 DSGVO). Haeufiger Fehler: CMP-Standardsprache ist " + "Englisch und wurde nie auf die Seitensprache umgestellt. Pruefung: " + " vs. Banner-Text-Sprache vergleichen." + ), + }, + { + "id": "banner_provider_identifiable", + "label": "CMP-Anbieter identifizierbar", + "level": 2, + "parent": "banner_present", + "check_key": "banner_provider_named", + "severity": "LOW", + "hint": ( + "Obwohl gesetzlich nicht explizit gefordert, erleichtert die " + "Identifizierbarkeit des CMP-Anbieters (Cookiebot, OneTrust, " + "Usercentrics etc.) die Pruefung, ob der Banner korrekt " + "konfiguriert ist. Viele CMP-Anbieter sind Auftragsverarbeiter " + "nach Art. 28 DSGVO — in diesem Fall muss ein AV-Vertrag " + "vorliegen. Haeufiger Fehler: CMP sendet Consent-Signale an " + "eigene Server ohne AV-Vertrag." + ), + }, + { + "id": "banner_visible_on_load", + "label": "Banner erscheint beim ersten Seitenaufruf", + "level": 2, + "parent": "banner_present", + "check_key": "banner_detected", + "severity": "HIGH", + "hint": ( + "ss25 Abs. 1 TDDDG: Die Einwilligung muss VOR dem Zugriff " + "auf das Endgeraet eingeholt werden. Wenn der Banner erst nach " + "Scrollen, nach einer Verzoegerung oder gar nicht erscheint, " + "aber gleichzeitig Tracking-Scripts geladen werden, liegt ein " + "Verstoss vor. Haeufiger Fehler: CMP ist installiert, aber " + "der Banner wird wegen eines JavaScript-Fehlers oder einer " + "fehlerhaften Geolocation-Einstellung (z.B. 'nur fuer EU-" + "Nutzer') nicht angezeigt. Auch Caching-Probleme koennen " + "dazu fuehren, dass der Banner bei wiederholtem Besuch " + "nicht geladen wird, obwohl kein Consent vorliegt." + ), + }, + + # ===================================================================== + # L1-2: Wahlmoeglichkeit (Akzeptieren + Ablehnen) + # ===================================================================== + { + "id": "banner_choices", + "label": "Wahlmoeglichkeit (Akzeptieren + Ablehnen)", + "level": 1, + "parent": None, + "check_key": "reject_button_visible", + "severity": "HIGH", + "hint": ( + "EDPB Guidelines 05/2020, Rn. 41-42: Eine gueltige Einwilligung " + "erfordert eine echte Wahlmoeglichkeit. Der Nutzer muss Cookies " + "ebenso einfach ablehnen koennen wie annehmen. Die CNIL hat am " + "31.12.2021 gegen Google (150 Mio. EUR) und Facebook (60 Mio. EUR) " + "Bussgelder verhaengt, u.a. weil Ablehnung nicht gleichwertig " + "moeglich war (CNIL SAN-2021-023/024). Ein Banner ohne Ablehnen-" + "Option ist keine gueltige Einwilligungsabfrage." + ), + }, + # ── L2 unter banner_choices ────────────────────────────────────── + { + "id": "reject_visible", + "label": "Ablehnen-Button auf erster Ebene sichtbar", + "level": 2, + "parent": "banner_choices", + "check_key": "reject_button_visible", + "severity": "HIGH", + "hint": ( + "ss25 TDDDG i.V.m. EDPB Guidelines 05/2020, Rn. 86: Der " + "Ablehnen-Button muss auf der ersten Ebene des Banners sichtbar " + "sein — nicht hinter 'Einstellungen' oder 'Mehr Informationen' " + "versteckt. Die franzoesische CNIL hat dies in ihrem Google-" + "Entscheid (SAN-2021-023) explizit geruegt: 'refuser devait etre " + "aussi facile qu'accepter'. Haeufiger Fehler: Banner zeigt nur " + "'Akzeptieren' + 'Einstellungen', Ablehnung erst im Untermenue." + ), + }, + { + "id": "reject_same_clicks", + "label": "Gleiche Klickanzahl fuer Akzeptieren und Ablehnen", + "level": 2, + "parent": "banner_choices", + "check_key": "click_count_asymmetry", + "severity": "HIGH", + "hint": ( + "CNIL SAN-2021-023 (Google, 150 Mio. EUR): 'Le refus necessitait " + "plusieurs clics alors que l'acceptation pouvait se faire en un " + "seul clic.' Ablehnung und Zustimmung muessen mit identischer " + "Klickanzahl erreichbar sein. 1 Klick Accept vs. 2+ Klicks Reject " + "ist ein Verstoss. Auch die oesterreichische DSB hat dies in " + "Bescheid D155.520 bestaetigt. Haeufiger Fehler: Accept = 1 Klick, " + "Reject = Einstellungen oeffnen + Toggle deaktivieren + Speichern " + "= 3 Klicks." + ), + }, + { + "id": "reject_same_size", + "label": "Gleiche Button-Groesse (kein Dark Pattern)", + "level": 2, + "parent": "banner_choices", + "check_key": "dark_pattern_button_size", + "severity": "MEDIUM", + "hint": ( + "EDPB Guidelines 3/2022 (Deceptive Design Patterns), Rn. 62: " + "Akzeptieren und Ablehnen muessen 'gleichwertig praesentiert' " + "werden. Konkret: Gleiche Schriftgroesse, Buttongroesse und " + "Farbprominenz. Ein ueberproportional grosser Accept-Button " + "(area ratio > 2.5x) gegenueber einem kleinen Reject-Link ist " + "ein klassisches Dark Pattern. Die CNIL hat Google und Meta " + "u.a. wegen solcher Klick-Asymmetrie bestraft. Pruefung: " + "Button-Flaeche und Schriftgroesse beider Optionen vergleichen." + ), + }, + { + "id": "reject_same_prominence", + "label": "Gleiche Farbgebung/Kontrast (kein Contrast-Trick)", + "level": 2, + "parent": "banner_choices", + "check_key": "color_contrast_dark_pattern", + "severity": "MEDIUM", + "hint": ( + "EDPB Guidelines 3/2022, Rn. 63-65 (Interface Interference): " + "Wenn der Accept-Button farblich hervorgehoben ist und der " + "Reject-Button die gleiche Farbe wie der Hintergrund hat " + "(transparent oder kaum sichtbar), liegt ein Deceptive Design " + "Pattern vom Typ 'Hidden in Plain Sight' vor. Beispiel: " + "Gruener Accept-Button vs. grauer Text-Link 'Ablehnen' der " + "im Hintergrund verschwindet. Pruefung: background-color des " + "Reject-Buttons darf nicht identisch mit Banner-Hintergrund sein." + ), + }, + { + "id": "reject_no_scroll", + "label": "Ablehnen ohne Scrollen erreichbar", + "level": 2, + "parent": "banner_choices", + "check_key": "nudging_reject_hidden", + "severity": "HIGH", + "hint": ( + "EDPB Guidelines 3/2022, Rn. 48-50 (Hindering): Der Ablehnen-" + "Button darf nicht ausserhalb des sichtbaren Banner-Bereichs " + "platziert werden, sodass Nutzer scrollen muessen. Dies ist ein " + "'Longer than necessary'-Pattern. Der BGH hat in seiner " + "Planet49-Nachfolgeentscheidung (I ZR 7/16) klargestellt, dass " + "die Ablehnung 'ohne unzumutbaren Aufwand' moeglich sein muss. " + "Haeufiger Fehler: Banner mit langem Text, Reject-Button erst " + "am Ende sichtbar." + ), + }, + { + "id": "reject_no_nudging", + "label": "Keine manipulative Sprache (Stirring/Nudging)", + "level": 2, + "parent": "banner_choices", + "check_key": "stirring_emotional_language", + "severity": "LOW", + "hint": ( + "EDPB Guidelines 3/2022, Rn. 55-59 (Emotional Steering): " + "Formulierungen wie 'eingeschraenkte Funktionen', 'bestmoegliches " + "Erlebnis' oder 'Website funktioniert moeglicherweise nicht " + "richtig' erzeugen emotionalen Druck und beeintraechtigen die " + "Freiwilligkeit der Einwilligung (Art. 7(4) DSGVO). Auch " + "sogenannte 'Confirmshaming'-Texte auf dem Ablehnen-Button " + "(z.B. 'Nein, ich moechte kein gutes Erlebnis') sind unzulaessig. " + "Korrekt: Neutral formulieren, z.B. 'Alle akzeptieren' / " + "'Nur Notwendige'." + ), + }, + + # ===================================================================== + # L1-3: Rechtliche Links (Impressum + DSE) + # ===================================================================== + { + "id": "banner_legal_links", + "label": "Rechtliche Links (Impressum + DSE erreichbar)", + "level": 1, + "parent": None, + "check_key": "impressum_link", + "severity": "HIGH", + "hint": ( + "ss5 TMG / ss5 DDG (ab 2025): Das Impressum muss 'leicht erkennbar, " + "unmittelbar erreichbar und staendig verfuegbar' sein — auch wenn " + "ein Cookie-Banner die Seite ueberlagert. LG Rostock (Az. 3 O " + "22/19): Ein ueberlagernder Banner, der das Impressum verdeckt, " + "ohne selbst einen Link zu enthalten, verstoesst gegen die " + "Impressumspflicht. Gleichzeitig verlangt Art. 13 DSGVO, dass " + "die Datenschutzerklaerung VOR der Einwilligung einsehbar ist " + "(informierte Einwilligung, ErwGr. 42)." + ), + }, + # ── L2 unter banner_legal_links ────────────────────────────────── + { + "id": "impressum_accessible", + "label": "Impressum trotz Banner-Overlay erreichbar", + "level": 2, + "parent": "banner_legal_links", + "check_key": "impressum_link", + "severity": "HIGH", + "hint": ( + "ss5 TMG / ss5 DDG, LG Rostock Az. 3 O 22/19: Wenn ein modaler " + "Cookie-Banner die Seite ueberlagert, MUSS ein Impressum-Link " + "entweder im Banner selbst oder hinter dem Banner sichtbar sein. " + "Ohne erreichbares Impressum droht eine Abmahnung nach UWG " + "(ss3a UWG i.V.m. ss5 TMG). Haeufiger Fehler: Banner ueberlagert " + "den gesamten Viewport, Impressum-Link im Footer ist nicht " + "anklickbar. Loesung: Impressum-Link direkt in den Banner " + "integrieren." + ), + }, + { + "id": "dse_link_present", + "label": "Link zur Datenschutzerklaerung im Banner", + "level": 2, + "parent": "banner_legal_links", + "check_key": "dse_link", + "severity": "MEDIUM", + "hint": ( + "Art. 13 DSGVO i.V.m. ErwGr. 42: Eine 'informierte Einwilligung' " + "setzt voraus, dass der Nutzer die Datenschutzinformationen VOR " + "seiner Entscheidung einsehen kann. Ohne DSE-Link im Banner fehlt " + "die Informationsgrundlage — die Einwilligung kann unwirksam sein. " + "Die belgische DPA hat im TCF-Entscheid (21-02-2022) festgestellt, " + "dass fehlender Zugang zur DSE vor Consent die Einwilligung " + "nichtig macht. Haeufiger Fehler: DSE-Link nur im Footer, der " + "vom Banner verdeckt wird." + ), + }, + { + "id": "dse_link_own_domain", + "label": "DSE-Link zeigt auf eigene Domain (nicht Drittanbieter)", + "level": 2, + "parent": "banner_legal_links", + "check_key": "third_party_dse_link", + "severity": "HIGH", + "hint": ( + "Art. 13/14 DSGVO: Jeder Verantwortliche muss eine EIGENE " + "Datenschutzerklaerung bereitstellen. Ein Verweis auf die DSE " + "des CMP-Anbieters, des Hosters oder einer Muttergesellschaft " + "genuegt nicht, wenn der Website-Betreiber selbst Verantwortlicher " + "ist. Bei gemeinsamer Verantwortlichkeit (Art. 26 DSGVO) muss die " + "Vereinbarung offengelegt werden. Haeufiger Fehler: Shopify-Shop " + "verlinkt auf Shopify-DSE statt eigene." + ), + }, + { + "id": "dse_readable_before_consent", + "label": "DSE vor Einwilligung einsehbar (nicht hinter Consent-Gate)", + "level": 2, + "parent": "banner_legal_links", + "check_key": "dse_link", + "severity": "MEDIUM", + "hint": ( + "ErwGr. 42 DSGVO: 'Damit die Einwilligung in Kenntnis der " + "Sachlage erteilt wird, sollte die betroffene Person mindestens " + "wissen, wer der Verantwortliche ist und fuer welche Zwecke ihre " + "personenbezogenen Daten verarbeitet werden sollen.' Wenn die DSE-" + "Seite selbst erst nach Cookie-Akzeptanz ladbar ist (z.B. weil " + "sie hinter einem Cookie-Wall liegt), ist die Einwilligung nicht " + "informiert und damit unwirksam. Pruefung: DSE-Link im Banner " + "muss ohne vorherige Consent-Entscheidung oeffenbar sein." + ), + }, + + # ===================================================================== + # L1-4: Gueltige Einwilligung (keine Planet49-Verstoesse) + # ===================================================================== + { + "id": "banner_consent_valid", + "label": "Gueltige Einwilligung (DSGVO-konform)", + "level": 1, + "parent": None, + "check_key": "pre_ticked_checkboxes", + "severity": "HIGH", + "hint": ( + "EuGH C-673/17 (Planet49, 01.10.2019): Eine gueltige Einwilligung " + "erfordert eine 'aktive Handlung' — vorausgefuellte Checkboxen, " + "Weitersurfen oder Inaktivitaet genuegen nicht. Art. 4(11) DSGVO " + "definiert Einwilligung als 'freiwillig fuer den bestimmten Fall, " + "in informierter Weise und unmisstdeutig abgegebene " + "Willensbekundung'. Jeder dieser vier Bestandteile muss erfuellt " + "sein — fehlt einer, ist die gesamte Einwilligung unwirksam." + ), + }, + # ── L2 unter banner_consent_valid ──────────────────────────────── + { + "id": "no_pre_ticked", + "label": "Keine vorausgewaehlten Checkboxen (Planet49)", + "level": 2, + "parent": "banner_consent_valid", + "check_key": "pre_ticked_checkboxes", + "severity": "HIGH", + "hint": ( + "EuGH C-673/17 (Planet49), Rn. 52-58: 'Ein voreingestelltes " + "Ankreuzkaestchen genuegt nicht.' Der BGH hat dies in der " + "Folgeentscheidung (I ZR 7/16) bestaetigt. Konkret: Checkboxen " + "fuer Marketing, Analytics oder Drittanbieter-Cookies duerfen " + "NICHT vorangehakt sein. Nur technisch notwendige Kategorien " + "(die keiner Einwilligung beduerfen) duerfen vorausgewaehlt und " + "ausgegraut sein. Haeufiger Fehler: CMP setzt 'Funktionale " + "Cookies' vorab auf aktiv — wenn diese Kategorie Drittanbieter " + "enthaelt, ist das ein Planet49-Verstoss." + ), + }, + { + "id": "no_wrong_dse_wording", + "label": "Keine falsche Formulierung ('Zustimmung zur DSE')", + "level": 2, + "parent": "banner_consent_valid", + "check_key": "wrong_dse_consent", + "severity": "HIGH", + "hint": ( + "Art. 13 DSGVO: Die Datenschutzerklaerung ist eine " + "Informationspflicht des Verantwortlichen — der Nutzer kann sie " + "nur 'zur Kenntnis nehmen', nicht 'zustimmen' oder 'akzeptieren'. " + "Formulierungen wie 'Ich stimme der Datenschutzerklaerung zu' " + "oder 'Ich akzeptiere die Privacy Policy' sind rechtlich falsch " + "und koennen die Einwilligung anfechtbar machen. Korrekt: 'Ich " + "habe die Datenschutzinformationen zur Kenntnis genommen.' " + "Haeufiger Fehler: CMP-Standardtexte verwenden 'agree to privacy " + "policy' und werden nicht angepasst." + ), + }, + { + "id": "no_modal_dismiss", + "label": "Klick ausserhalb des Banners ist keine Einwilligung", + "level": 2, + "parent": "banner_consent_valid", + "check_key": "non_modal_dismiss", + "severity": "HIGH", + "hint": ( + "EuGH C-673/17 (Planet49), Rn. 49-50: 'Stillschweigen, bereits " + "angekreuzte Kaestchen oder Untaetigkeit der betroffenen Person " + "stellen keine Einwilligung dar.' EDPB Guidelines 05/2020, " + "Rn. 77: Inaktivitaet, Weitersurfen oder Wegklicken eines " + "Dialogs darf NICHT als Einwilligung gewertet werden. Wenn ein " + "nicht-modaler Dialog bei Klick auf den Hintergrund verschwindet " + "und dabei Consent gesetzt wird, ist diese Einwilligung nichtig. " + "Loesung: Dialog muss modal sein (aria-modal='true'), nur " + "explizite Button-Klicks zaehlen." + ), + }, + { + "id": "no_coupling", + "label": "Kein Koppelungsverbot-Verstoss (Art. 7(4))", + "level": 2, + "parent": "banner_consent_valid", + "check_key": "registration_consent_coupling", + "severity": "HIGH", + "hint": ( + "Art. 7(4) DSGVO, ErwGr. 43: Wenn die Erfuellung eines Vertrags " + "(z.B. Registrierung, Kauf) von einer Einwilligung abhaengt, die " + "fuer die Vertragserfuellung nicht erforderlich ist, besteht ein " + "Koppelungsverbot-Verstoss. Beispiel: Login-Button erteilt " + "gleichzeitig Marketing-Einwilligung ohne separate Checkbox. " + "Die oesterreichische DSB hat in Bescheid 2021-0.586.257 " + "klargestellt, dass 'bundling' verschiedener Zwecke in einer " + "einzigen Einwilligung die Freiwilligkeit ausschliesst. " + "Pruefung: Login-/Registrierungsformulare auf versteckte " + "Consent-Kopplung pruefen." + ), + }, + { + "id": "no_emotional_language", + "label": "Keine manipulative/emotionale Sprache (Stirring)", + "level": 2, + "parent": "banner_consent_valid", + "check_key": "stirring_emotional_language", + "severity": "LOW", + "hint": ( + "EDPB Guidelines 3/2022, Rn. 55-59 (Emotional Steering): " + "Formulierungen die Angst, Schuldgefuehle oder Verlustangst " + "erzeugen, beeintraechtigen die Freiwilligkeit nach Art. 7(4) " + "DSGVO. Typische Muster: 'Ohne Cookies koennen wir Ihnen kein " + "optimales Erlebnis bieten', 'Einige Funktionen stehen nicht zur " + "Verfuegung'. Die spanische AEPD hat in Entscheid PS/00543/2021 " + "emotionale Sprache in Cookie-Bannern als Verstoss gewertet. " + "Korrekt: Sachliche Beschreibung der Zwecke ohne Wertung." + ), + }, + { + "id": "no_false_necessity", + "label": "Keine falsche Notwendigkeits-Behauptung (Dark Pattern Language)", + "level": 2, + "parent": "banner_consent_valid", + "check_key": "dark_pattern_language", + "severity": "MEDIUM", + "hint": ( + "EDPB Guidelines 05/2020, Rn. 70, Art. 7(4) DSGVO: " + "Formulierungen wie 'Cookies muessen akzeptiert werden', " + "'Cookies sind erforderlich' oder 'must be downloaded' fuer " + "nicht-essentielle Cookies suggerieren eine technische " + "Notwendigkeit, die nicht besteht. Dies untermieniert die " + "Freiwilligkeit der Einwilligung. Abzugrenzen: Die Aussage " + "'Technisch notwendige Cookies sind erforderlich fuer den " + "Betrieb' ist zulaessig — aber nur wenn sie sich klar auf die " + "essentielle Kategorie bezieht. Haeufiger Fehler: Pauschale " + "Formulierung 'Alle Cookies sind fuer den Betrieb notwendig' " + "obwohl Analytics- und Marketing-Cookies enthalten sind." + ), + }, + { + "id": "consent_revocable", + "label": "Einstellungen erneut zugaenglich (Widerruf, Art. 7(3))", + "level": 2, + "parent": "banner_consent_valid", + "check_key": "re_access_settings", + "severity": "MEDIUM", + "hint": ( + "Art. 7(3) DSGVO: 'Der Widerruf der Einwilligung muss so einfach " + "wie die Erteilung der Einwilligung sein.' Konkret muss ein " + "persistenter Link zu den Cookie-Einstellungen vorhanden sein — " + "typischerweise im Footer, als schwebendes Icon oder ueber ein " + "Fingerprint-Symbol. Die DSK-Orientierungshilfe Telemedien " + "(Dez. 2021) fordert dies explizit. Haeufiger Fehler: Nach " + "Schliessung des Banners gibt es keinen Weg zurueck zu den " + "Einstellungen — der Nutzer muesste Cookies manuell loeschen. " + "Loesung: Permanenter Footer-Link oder CMP-Widget." + ), + }, + { + "id": "consent_expiry_13m", + "label": "Consent-Cookie max. 13 Monate gueltig (CNIL)", + "level": 2, + "parent": "banner_consent_valid", + "check_key": "consent_cookie_expiry_13m", + "severity": "MEDIUM", + "hint": ( + "CNIL-Leitlinie (01.10.2020), Art. 5: 'La validite du " + "consentement est de 13 mois maximum.' Die Consent-Entscheidung " + "darf maximal 13 Monate (ca. 395 Tage) gespeichert werden — " + "danach muss der Nutzer erneut gefragt werden. Auch die DSK-" + "Orientierungshilfe Telemedien empfiehlt dies. Haeufiger Fehler: " + "CMP-Default ist 12 Monate (365 Tage) — das ist im Rahmen. " + "Aber manche setzen 2 Jahre oder 'Session' (kein Ablauf). " + "Pruefung: Consent-Cookie Expiry-Datum vs. aktuelles Datum, " + "Differenz darf 395 Tage nicht uebersteigen." + ), + }, + + # ===================================================================== + # L1-5: Keine Vorab-Cookies/Tracking vor Consent + # ===================================================================== + { + "id": "banner_pre_consent", + "label": "Kein Tracking vor Einwilligung (Phase A)", + "level": 1, + "parent": None, + "check_key": "cookies_before_consent", + "severity": "CRITICAL", + "hint": ( + "ss25 Abs. 1 TDDDG (vormals ss15(3) TMG): Der Zugriff auf das " + "Endgeraet des Nutzers (Cookie setzen, Script laden, " + "Fingerprinting) ist NUR zulaessig, wenn der Nutzer zuvor " + "eingewilligt hat. Tracking vor jeder Banner-Interaktion ist ein " + "klarer Verstoss. EuGH C-673/17 (Planet49) und BGH (I ZR 7/16) " + "bestaetigen: Die Einwilligung muss VOR dem Cookie-Setzen " + "vorliegen. Haeufiger Fehler: Google Tag Manager laedt GA4 " + "bereits beim Seitenaufruf — GTM selbst ist zulaessig, aber " + "die darin konfigurierten Tags muessen consent-gesteuert sein." + ), + }, + # ── L2 unter banner_pre_consent ────────────────────────────────── + { + "id": "no_tracking_scripts_before", + "label": "Keine Tracking-Scripts vor Consent geladen", + "level": 2, + "parent": "banner_pre_consent", + "check_key": "tracking_before_consent", + "severity": "CRITICAL", + "hint": ( + "ss25 Abs. 1 TDDDG, Art. 5(3) ePrivacy-RL: Auch das blosse " + "Laden eines Tracking-Scripts (ohne Cookie) ist ein 'Zugriff " + "auf das Endgeraet', weil dabei Informationen (IP, User-Agent, " + "Viewport) uebermittelt werden. Dies gilt fuer GA4, Meta Pixel, " + "TikTok Pixel, LinkedIn Insight Tag etc. Pruefung: Netzwerk-" + "Requests in Phase A (vor Consent) auf Tracking-Domains pruefen. " + "Haeufiger Fehler: CMP-Integration ueber Google Tag Manager, " + "aber Consent Mode nicht korrekt konfiguriert — Tags feuern " + "trotzdem." + ), + }, + { + "id": "no_tracking_cookies_before", + "label": "Keine Tracking-Cookies vor Consent gesetzt", + "level": 2, + "parent": "banner_pre_consent", + "check_key": "cookies_before_consent", + "severity": "CRITICAL", + "hint": ( + "EuGH C-673/17 (Planet49), Rn. 61: Die Einwilligung muss 'vor " + "dem Speichern von Informationen' eingeholt werden. Cookies wie " + "_ga, _gid, _fbp, _fbc, IDE, _gcl_*, fr, _pin_*, _tt_*, " + "li_sugr, _hj* duerfen erst NACH expliziter Einwilligung " + "gesetzt werden. Pruefung: document.cookie in Phase A auf " + "bekannte Tracking-Cookie-Patterns pruefen. Haeufiger Fehler: " + "CMP setzt Consent-Default auf 'granted' — dann werden Cookies " + "sofort gesetzt und erst bei Ablehnung geloescht (zu spaet)." + ), + }, + { + "id": "gcm_default_denied", + "label": "Google Consent Mode Default = denied", + "level": 2, + "parent": "banner_pre_consent", + "check_key": "google_consent_mode_defaults", + "severity": "CRITICAL", + "hint": ( + "Google Consent Mode v2: Die Standardwerte fuer analytics_storage, " + "ad_storage, ad_user_data und ad_personalization muessen auf " + "'denied' stehen, bis der Nutzer explizit einwilligt. Ein Default " + "von 'granted' bedeutet, dass Google sofort Daten erhebt — das " + "ist ein Verstoss gegen ss25 TDDDG. Pruefung: gtag('consent', " + "'default', {...}) im Quelltext suchen und pruefen ob " + "analytics_storage oder ad_storage auf 'granted' steht. " + "Haeufiger Fehler: CMP-Plugin nicht korrekt konfiguriert, " + "Default-Werte werden nicht ueberschrieben." + ), + }, + { + "id": "no_facebook_pixel_before", + "label": "Kein Meta/Facebook Pixel vor Consent", + "level": 2, + "parent": "banner_pre_consent", + "check_key": "tracking_before_consent", + "severity": "CRITICAL", + "hint": ( + "Das Meta Pixel (ehem. Facebook Pixel) uebermittelt bei jedem " + "Seitenaufruf personenbezogene Daten (IP, User-Agent, fbp/fbc-" + "Cookies) an Meta Platforms Ireland Ltd. — einen Empfaenger in " + "einem Land, das keinen Angemessenheitsbeschluss hat (Meta " + "Schrems-II-Problematik). OeVGH (W211 2249247-1, 'noyb vs. " + "Meta'): Uebermittlung an Meta ohne Einwilligung ist rechtswidrig. " + "Das Pixel darf ERST nach expliziter Einwilligung geladen werden. " + "Pruefung: Netzwerk-Requests an connect.facebook.net oder " + "facebook.com/tr in Phase A." + ), + }, + { + "id": "no_google_analytics_before", + "label": "Kein Google Analytics vor Consent", + "level": 2, + "parent": "banner_pre_consent", + "check_key": "tracking_before_consent", + "severity": "CRITICAL", + "hint": ( + "Mehrere EU-Datenschutzbehoerden haben den Einsatz von Google " + "Analytics ohne Einwilligung fuer rechtswidrig erklaert: " + "oesterreichische DSB (Bescheid 2021-0.586.257, 'noyb-Beschwerde'), " + "franzoesische CNIL (Mise en demeure, Feb. 2022), italienische " + "GPDP (Provvedimento 9782890, Juni 2022). GA4 darf erst NACH " + "Consent geladen werden. Auch mit Server-Side-Tagging oder " + "IP-Anonymisierung bleibt ein personenbezogener Datentransfer " + "bestehen. Pruefung: Requests an googletagmanager.com oder " + "google-analytics.com in Phase A." + ), + }, + + # ===================================================================== + # L1-6: Ablehnung wird respektiert (Phase B) + # ===================================================================== + { + "id": "banner_post_reject", + "label": "Ablehnung wird respektiert (Phase B)", + "level": 1, + "parent": None, + "check_key": "tracking_after_reject", + "severity": "CRITICAL", + "hint": ( + "ss25 Abs. 1 TDDDG: Wird die Einwilligung verweigert, darf " + "KEIN nicht-essentieller Zugriff auf das Endgeraet erfolgen. " + "Tracking, das nach Ablehnung weiterlaeuft, ist ein schwerer " + "Verstoss — der Nutzer hat seinen Willen ausdruecklich erklaert. " + "Die franzoesische CNIL hat in mehreren Entscheiden (u.a. " + "SAN-2022-009, Criteo, 40 Mio. EUR) geruegt, dass Tracking " + "trotz Ablehnung fortgesetzt wurde. Pruefung: Phase A vs. " + "Phase B vergleichen — neue Tracking-Scripts oder Cookies " + "nach Reject sind ein Verstoss." + ), + }, + # ── L2 unter banner_post_reject ────────────────────────────────── + { + "id": "tracking_stops_after_reject", + "label": "Tracking-Scripts werden nach Ablehnung entfernt", + "level": 2, + "parent": "banner_post_reject", + "check_key": "tracking_after_reject", + "severity": "CRITICAL", + "hint": ( + "Nach Ablehnung duerfen keine neuen Tracking-Scripts geladen " + "werden. Bestehende Scripts sollten deaktiviert oder entfernt " + "werden. ss25 TDDDG kennt keinen 'Bestandsschutz' fuer bereits " + "geladene Tracker — auch wenn ein Script in Phase A (vor Consent) " + "fehlerhaft geladen wurde, muss es nach Reject gestoppt werden. " + "Pruefung: Netzwerk-Requests in Phase B auf neue Tracking-Domains " + "pruefen. Haeufiger Fehler: GA4 Enhanced Measurement sendet " + "weiterhin scroll/outbound-Events trotz Ablehnung, weil der " + "Consent Mode 'update'-Befehl nicht korrekt feuert." + ), + }, + { + "id": "cookies_removed_after_reject", + "label": "Tracking-Cookies nach Ablehnung entfernt", + "level": 2, + "parent": "banner_post_reject", + "check_key": "tracking_after_reject", + "severity": "HIGH", + "hint": ( + "Wenn in Phase A faelschlicherweise Tracking-Cookies gesetzt " + "wurden, muessen diese nach Ablehnung geloescht werden. Ein CMP " + "sollte bei Reject ein 'Cookie-Cleanup' durchfuehren: _ga, _gid, " + "_fbp etc. entfernen. Die CNIL-Leitlinie (Okt. 2020), Rn. 23 " + "verlangt, dass 'der Verantwortliche sicherstellt, dass die " + "Ablehnung effektiv umgesetzt wird'. Pruefung: Cookie-Liste vor " + "und nach Reject vergleichen — Tracking-Cookies sollten " + "verschwunden sein." + ), + }, + { + "id": "no_new_tracking_after_reject", + "label": "Keine neuen Tracker nach Ablehnung", + "level": 2, + "parent": "banner_post_reject", + "check_key": "tracking_after_reject", + "severity": "CRITICAL", + "hint": ( + "Der gravierendste Verstoss: NACH expliziter Ablehnung werden " + "NEUE Tracking-Services geladen, die vorher nicht aktiv waren. " + "Dies kann passieren, wenn das CMP den Reject-Status nicht " + "korrekt an den Tag Manager weitergibt oder wenn hardcoded " + "Scripts im HTML stehen, die nicht consent-gesteuert sind. " + "CNIL SAN-2022-009 (Criteo, 40 Mio. EUR): 'Des traceurs " + "continuaient d'etre deposés malgre le refus de l'utilisateur.' " + "Pruefung: Diff der Tracking-Services zwischen Phase A und " + "Phase B — neue Eintraege sind ein Verstoss." + ), + }, + { + "id": "site_functional_after_reject", + "label": "Seite bleibt nutzbar nach Ablehnung (kein Cookie-Wall)", + "level": 2, + "parent": "banner_post_reject", + "check_key": "cookie_wall", + "severity": "HIGH", + "hint": ( + "EDPB Guidelines 05/2020, Rn. 39: 'Access to services and " + "functionalities must not be made conditional on the consent " + "of a user to the storing of information.' Eine sogenannte " + "'Cookie Wall', die den Zugang zur Website nach Ablehnung " + "vollstaendig blockiert, macht die Einwilligung unfreiwillig. " + "Ausnahme: 'Consent or Pay'-Modelle (EDPB Opinion 08/2024) " + "sind unter engen Bedingungen zulaessig — dafuer muss die " + "Bezahlalternative 'angemessen' sein und darf nicht " + "ueberzogen sein. Haeufiger Fehler: Website zeigt nach " + "Ablehnung eine leere Seite oder Redirect auf Fehlerseite." + ), + }, +] diff --git a/consent-tester/services/dsi_discovery.py b/consent-tester/services/dsi_discovery.py index f1d34f3..75be505 100644 --- a/consent-tester/services/dsi_discovery.py +++ b/consent-tester/services/dsi_discovery.py @@ -220,6 +220,40 @@ async def discover_dsi_documents( await page.goto(url, wait_until="networkidle", timeout=60000) await page.wait_for_timeout(2000) + # Step 1b: Self-extraction — if the URL itself is a DSI page, + # extract its full text as the first document. This handles the + # case where the user provides the DSE URL directly (e.g. + # example.com/datenschutz) instead of the homepage. + current_url_path = urlparse(url).path.lower() + is_self_dsi, self_lang = _matches_dsi_keyword(current_url_path) + if not is_self_dsi: + # Also check the page title + page_title = await page.title() or "" + is_self_dsi, self_lang = _matches_dsi_keyword(page_title) + if is_self_dsi: + try: + self_text = await page.evaluate("""() => { + const main = document.querySelector('main, article, [role="main"], .content, #content, .bodytext') + || document.body; + return main ? main.innerText : document.body.innerText; + }""") + self_wc = len(self_text.split()) if self_text else 0 + if self_wc >= 100: + page_title = await page.title() or url + result.documents.append(DiscoveredDSI( + title=page_title.strip(), + url=url, + source_url=url, + language=self_lang or "de", + doc_type="html_full_page", + text=self_text.strip(), + word_count=self_wc, + )) + seen_urls.add(url) + logger.info("Self-extracted %d words from %s", self_wc, url) + except Exception as e: + logger.warning("Self-extraction failed for %s: %s", url, e) + # Step 2: Find DSI links in current page links = await _find_dsi_links(page, base_domain) logger.info("Found %d DSI links on %s", len(links), url) @@ -360,8 +394,9 @@ async def discover_dsi_documents( return result # Nav elements, not real documents +# NOTE: "datenschutz" was removed — it's a legitimate document title NOISE_TITLES = {"drucken", "print", "nach oben", "back to top", "teilen", "share", - "kontakt", "contact", "suche", "search", "menü", "menu", "home", "datenschutz"} + "kontakt", "contact", "suche", "search", "menü", "menu", "home"} def _deduplicate_documents(docs: list[DiscoveredDSI]) -> list[DiscoveredDSI]: """Remove duplicate and noise documents."""