diff --git a/consent-tester/services/banner_text_checker.py b/consent-tester/services/banner_text_checker.py index 1a75f167..5967392e 100644 --- a/consent-tester/services/banner_text_checker.py +++ b/consent-tester/services/banner_text_checker.py @@ -113,9 +113,10 @@ def build_no_banner_finding(has_dse: bool) -> Violation: "bleibt nur die Informationspflicht — eine Datenschutzerklaerung muss " "erreichbar sein" + ("." if has_dse else " (DSE-Link wurde hier nicht gefunden — bitte pruefen).") - + " METHODEN-HINWEIS: Falls die Seite Geo-Targeting nutzt, sieht ein Scan " - "ausserhalb der EU evtl. keinen Banner — den Scanner-Standort (EU-IP) bei " - "diesem Befund mitdenken." + + " METHODEN-HINWEIS: Der Scan nutzt ein frisches Browser-Profil — ein " + "fehlendes Banner liegt also NICHT an einer bereits erteilten Einwilligung. " + "Falls die Seite jedoch Geo-Targeting nutzt, sieht ein Scan ausserhalb der EU " + "evtl. keinen Banner — den Scanner-Standort (EU-IP) bei diesem Befund mitdenken." ), legal_ref="§25 Abs. 2 TDDDG (Ausnahme technisch notwendig), Art. 5(3) ePrivacy, " "EDPB Guidelines 2/2023", @@ -164,6 +165,120 @@ def build_non_cookie_tracking_finding(detected: list) -> Violation: ) +# ── #4: "Consent or Pay" / "Pay or OK" (EDPB Opinion 08/2024, umstritten) ── +_PAY_SIGNALS = ( + "pur-abo", "pur abo", "consent or pay", "pay or consent", "accept or subscribe", + "zustimmen oder abonnieren", "zustimmen oder bezahlen", "kostenpflichtiges abo", + "werbefreies abo", "ohne werbung weiterlesen", "mit abo werbefrei", + "subscribe to continue", "jetzt abonnieren", "pro monat", "/monat", "im monat", +) +_PAY_CONTEXT = ("cookie", "werbung", "tracking", "einwillig", "consent", "zustimm", + "akzeptier", "advert") + + +def detect_consent_or_pay(banner_lower: str) -> bool: + """Consent-or-Pay-Modell im Banner (Einwilligung ODER Bezahl-Abo). Pure + testbar.""" + return (any(p in banner_lower for p in _PAY_SIGNALS) + and any(c in banner_lower for c in _PAY_CONTEXT)) + + +def build_consent_or_pay_finding() -> Violation: + return Violation( + service="Cookie-Banner", severity="MEDIUM", + text=("'Consent or Pay'-Modell erkannt (Einwilligung ODER Bezahl-Abo statt " + "echter Wahl). Rechtlich stark umstritten: Die EDPB-Opinion 08/2024 haelt " + "es bei grossen Plattformen meist fuer NICHT DSGVO-konform, weil die " + "Einwilligung dann nicht 'freiwillig' i.S.v. Art. 4(11)/7(4) DSGVO ist " + "(Koppelung). Gesondert juristisch pruefen — kein Standard-Banner."), + legal_ref="EDPB Opinion 08/2024 (Consent or Pay), Art. 7(4) + 4(11) DSGVO (Freiwilligkeit)", + ) + + +# JS: Google Consent Mode (v2) erkennen — dataLayer-consent-Events / inline gtag('consent'). +CONSENT_MODE_JS = """ +() => { + try { + const dl = window.dataLayer || []; + const ev = dl.some(e => Array.isArray(e) && e[0] === 'consent'); + const inline = [...document.scripts].some( + s => !s.src && /gtag\\(\\s*['"]consent['"]/.test(s.textContent || '')); + return ev || inline; + } catch (e) { return false; } +} +""" + + +def build_consent_mode_finding() -> Violation: + return Violation( + service="Cookie-Banner", severity="MEDIUM", + text=("Google Consent Mode (v2) erkannt. WICHTIG: Consent Mode ist KEINE " + "gueltige Einwilligung — bei Ablehnung sendet es nur modellierte, " + "cookielose Signale an Google. Es ersetzt NICHT die Einwilligung nach " + "Art. 5(3) ePrivacy/§25 TDDDG. Pruefen: werden Tags wirklich erst NACH " + "echter Einwilligung gesetzt (Consent Mode allein genuegt nicht)."), + legal_ref="Art. 5(3) ePrivacy, §25 Abs.1 TDDDG, EDPB Guidelines 2/2023", + ) + + +# ── #6: CNAME-Cloaking — First-Party-Subdomain zeigt per DNS auf Tracker-Infra ── +_CNAME_CLOAK_TARGETS = ( + "eulerian.net", "wt-eu02.net", "webtrekk.net", "mapp.com", "2o7.net", + "omtrdc.net", "adobedc.net", "demdex.net", "everesttech.net", "krxd.net", + "agkn.com", "pardot.com", "act-on.net", "tagcommander.com", "commander1.com", + "acxiom-online.com", "keros.io", +) + + +def detect_cname_cloaking(scripts: list, site_domain: str) -> list: + """First-Party-aussehende Subdomains, deren DNS-CNAME auf bekannte Tracker-Infra + zeigt (umgeht Safari ITP — faktisch Drittanbieter-Tracking). socket statt + dnspython. Best-effort: kurze Timeouts, max 8 Lookups.""" + import socket + from urllib.parse import urlparse + site_domain = (site_domain or "").lower().lstrip(".") + if not site_domain: + return [] + hosts = set() + for s in (scripts or []): + try: + h = (urlparse(s).hostname or "").lower() + except Exception: + continue + if h and h.endswith("." + site_domain) and h != site_domain: + hosts.add(h) + found = [] + old_to = socket.getdefaulttimeout() + socket.setdefaulttimeout(1.5) + try: + for h in list(hosts)[:8]: + try: + _n, aliases, _ip = socket.gethostbyname_ex(h) + except Exception: + continue + chain = " ".join(aliases).lower() + for t in _CNAME_CLOAK_TARGETS: + if t in chain: + found.append((h, t)) + break + finally: + socket.setdefaulttimeout(old_to) + return found + + +def build_cname_cloaking_finding(found: list) -> Violation: + pairs = ", ".join(f"{h} -> {t}" for h, t in found[:4]) + return Violation( + service="Cookie-Banner", severity="HIGH", + text=("CNAME-Cloaking erkannt: " + pairs + ". Eine First-Party-aussehende " + "Subdomain zeigt per DNS-CNAME auf die Infrastruktur eines Tracking-" + "Anbieters. Der Browser behandelt deren Cookies als First-Party (umgeht " + "Safari ITP / Cookie-Blocker) — faktisch ist es Drittanbieter-Tracking und " + "muss als solches behandelt werden: Einwilligung + Drittland/Auftrags-" + "verarbeitung pruefen. 'First-Party' ist hier eine technische Tarnung."), + legal_ref="§25 TDDDG, Art. 5(3) ePrivacy, Art. 13/28/44 DSGVO", + ) + + async def check_banner_text(page) -> dict: """Check cookie banner text for legal issues. @@ -236,6 +351,17 @@ async def check_banner_text(page) -> dict: "has_impressum": False, "has_dse": has_dse, "cookieless_optout": True} + # #4: Consent-or-Pay-Modell (zusaetzlicher Befund — bleibt ein Consent-Banner). + if detect_consent_or_pay(banner_lower): + violations.append(build_consent_or_pay_finding()) + + # #5: Google Consent Mode (v2) — KEINE gueltige Einwilligung. + try: + if await page.evaluate(CONSENT_MODE_JS): + violations.append(build_consent_mode_finding()) + except Exception: + pass + # Check 1: Impressum link in or accessible through banner has_impressum = any( "impressum" in l["href"] or "impressum" in l["text"] or diff --git a/consent-tester/services/consent_scanner.py b/consent-tester/services/consent_scanner.py index 63f84ae1..53c91f6a 100644 --- a/consent-tester/services/consent_scanner.py +++ b/consent-tester/services/consent_scanner.py @@ -83,25 +83,34 @@ class ConsentTestResult: banner_screenshot_b64: str = "" -def _apply_edge_case_findings(result) -> None: +def _apply_edge_case_findings(result, url: str = "") -> None: """Edge-Case-Befunde nach dem Scan — an ALLEN Return-Pfaden aufrufen (auch - im no-banner-Fruehreturn): #1/#2 kein-Banner-affirmativ (statisch / nur - technisch notwendig → konform, inkl. Geo-Caveat) + #3 Non-Cookie-Tracking - (Pixel/Fingerprinting; §25 gilt auch ohne Cookies).""" + im no-banner-Fruehreturn): #1/#2 kein-Banner-affirmativ (konform, Geo-/ + Returning-User-Caveat), #3 Non-Cookie-Tracking (Pixel/Fingerprinting), + #6 CNAME-Cloaking (First-Party-Subdomain → Tracker-Infra).""" try: from services.banner_text_checker import ( build_no_banner_finding, detect_non_cookie_tracking, build_non_cookie_tracking_finding, + detect_cname_cloaking, build_cname_cloaking_finding, ) + all_scripts = (result.before_scripts or []) + (result.accept_scripts or []) if (not result.banner_detected and not result.before_violations and not result.before_tracking): result.banner_text_violations.append( build_no_banner_finding(result.banner_has_dse_link)) - nct = detect_non_cookie_tracking( - (result.before_scripts or []) + (result.accept_scripts or [])) + nct = detect_non_cookie_tracking(all_scripts) if nct: result.banner_text_violations.append( build_non_cookie_tracking_finding(nct)) + if url: + from urllib.parse import urlparse + dom = (urlparse(url).hostname or "").lower() + dom = dom[4:] if dom.startswith("www.") else dom + cc = detect_cname_cloaking(all_scripts, dom) + if cc: + result.banner_text_violations.append( + build_cname_cloaking_finding(cc)) except Exception as e: logger.warning("Edge-case findings skipped: %s", e) @@ -240,7 +249,7 @@ async def run_consent_test( if not banner.detected: logger.info("No consent banner detected — skipping Phase B/C") await browser.close() - _apply_edge_case_findings(result) + _apply_edge_case_findings(result, url) return result # ── Phase B: After rejecting ───────────────────────── diff --git a/consent-tester/tests/test_cookieless_optout.py b/consent-tester/tests/test_cookieless_optout.py index 6fd9c4bd..cbb2b189 100644 --- a/consent-tester/tests/test_cookieless_optout.py +++ b/consent-tester/tests/test_cookieless_optout.py @@ -9,6 +9,8 @@ from services.banner_text_checker import ( is_cookieless_optout, detect_non_cookie_tracking, build_no_banner_finding, + detect_consent_or_pay, + detect_cname_cloaking, ) @@ -68,3 +70,26 @@ def test_no_banner_finding_is_low_and_compliant(): def test_no_banner_finding_flags_missing_dse(): v = build_no_banner_finding(has_dse=False) assert "dse" in v.text.lower() or "datenschutzerkl" in v.text.lower() + + +# ── #4: Consent-or-Pay ───────────────────────────────────────────────── +def test_consent_or_pay_detected(): + assert detect_consent_or_pay( + "akzeptieren oder pur-abo abschliessen. cookies & werbung") is True + + +def test_consent_or_pay_not_on_standard_banner(): + assert not detect_consent_or_pay("alle akzeptieren ablehnen einstellungen cookies") + + +def test_consent_or_pay_needs_consent_context(): + # 'Abo' ohne Consent-Kontext (z.B. normale Paywall) ist kein Consent-or-Pay. + assert not detect_consent_or_pay("jetzt abonnieren fuer 5 pro monat") + + +# ── #6: CNAME-Cloaking (nur DNS-freie Faelle) ────────────────────────── +def test_cname_cloaking_empty_and_no_subdomain(): + assert detect_cname_cloaking([], "example.com") == [] + assert detect_cname_cloaking(["https://example.com/app.js"], "") == [] + # Script auf der Hauptdomain (keine Subdomain) → kein Lookup → [] + assert detect_cname_cloaking(["https://example.com/app.js"], "example.com") == []