11740bd2f9
#4 Consent-or-Pay (EDPB Opinion 08/2024): Banner-Text-Signatur (Pur-Abo/ "zustimmen oder bezahlen" + Consent-Kontext) → MEDIUM-Befund "rechtlich umstritten, gesondert prüfen". #5 Google Consent Mode v2: page.evaluate (dataLayer-consent-Events / inline gtag('consent')) → MEDIUM "ist KEINE gültige Einwilligung". #6 CNAME-Cloaking: First-Party-Subdomains per socket.gethostbyname_ex auflösen, CNAME-Kette gegen bekannte Tracker-Infra (Eulerian/Adobe/Webtrekk/…) → HIGH "faktisch Drittanbieter trotz First-Party-Optik". Best-effort, kurze Timeouts. #7 Returning-User: Scanner nutzt by-design frische Browser-Contexts → Hinweis im Kein-Banner-Befund (fehlendes Banner liegt nicht an erinnertem Consent). Tests + py_compile grün. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
808 lines
40 KiB
Python
808 lines
40 KiB
Python
"""
|
|
Banner text legal checks — extracted from consent_scanner.py.
|
|
|
|
11 checks for cookie banner legal compliance:
|
|
1. Impressum link accessible (§5 TMG)
|
|
2. DSE link in banner (Art. 13 DSGVO)
|
|
3. Wrong DSE consent wording (Art. 13 DSGVO)
|
|
4. Reject button visible (§25 TDDDG)
|
|
5. Pre-ticked checkboxes (Planet49)
|
|
6. Dark pattern button size (EDPB 05/2020)
|
|
7. Cookie wall (Phase B check)
|
|
8. Re-access to settings (Art. 7(3) DSGVO)
|
|
9. Third-party DSE link (Art. 13 DSGVO)
|
|
10. Dark-pattern language (EDPB 05/2020)
|
|
11. Non-modal dismiss = consent (Planet49)
|
|
"""
|
|
|
|
import logging
|
|
|
|
from services.script_analyzer import Violation
|
|
from services.banner_advanced_checks import run_advanced_checks
|
|
from services.banner_dom_walkers import (
|
|
SHADOW_BANNER_WALKER_JS,
|
|
FOOTER_LABELS_WALKER_JS,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Cookieless Opt-out-Erkennung (sehr untypischer Sonderfall) ──────────
|
|
# Manche Sites nutzen cookie-FREIE Analyse mit einem reinen Opt-out-Hinweis
|
|
# statt eines Consent-Banners (z.B. bayshore.ai: "Privacy-friendly, cookie-free
|
|
# analytics are currently enabled. You can change your choice at any time."
|
|
# + ein einziger Button "Disable"). Cookieless = kein Zugriff aufs Endgeraet →
|
|
# §25 Abs.1 TDDDG/ePrivacy verlangt KEINE Einwilligung → Opt-out (default an,
|
|
# abschaltbar) ist zulaessig statt Opt-in. Die Standard-Opt-in-Checks (granulare
|
|
# Kategorien, Accept/Reject-Balance, Impressum-im-Banner, Dark-Pattern-Buttons)
|
|
# treffen hier NICHT zu → sonst False Positives.
|
|
_CL_SIGNAL = (
|
|
"cookie-free", "cookieless", "cookie free", "without cookies", "no cookies",
|
|
"ohne cookies", "cookielos", "privacy-friendly analytics",
|
|
"privacy friendly analytics", "cookie-freie analyse", "cookiefreie analyse",
|
|
"datenschutzfreundliche analyse",
|
|
)
|
|
_CL_OPTOUT = (
|
|
"disable", "deaktivieren", "opt-out", "opt out", "abschalten", "ausschalten",
|
|
"currently enabled", "widersprechen", "change your choice",
|
|
)
|
|
# Wenn ein echtes Consent-Banner-Signal da ist, ist es KEIN reiner Opt-out-Hinweis.
|
|
_CL_STD_CONSENT = (
|
|
"alle akzeptieren", "accept all", "alle annehmen", "accept cookies",
|
|
"cookies akzeptieren", "zustimmen", "einwilligen",
|
|
)
|
|
|
|
|
|
def is_cookieless_optout(banner_lower: str) -> bool:
|
|
"""True bei cookie-freier Analyse mit reinem Opt-out-Hinweis (kein
|
|
Consent-Banner). Pure + testbar. Standard-Opt-in-Checks treffen dann nicht zu."""
|
|
return (
|
|
any(s in banner_lower for s in _CL_SIGNAL)
|
|
and any(o in banner_lower for o in _CL_OPTOUT)
|
|
and not any(c in banner_lower for c in _CL_STD_CONSENT)
|
|
)
|
|
|
|
|
|
def _cookieless_finding(has_dse: bool) -> Violation:
|
|
"""Vollstaendiger Erklaer-Befund (LOW) fuer den cookieless-Opt-out-Fall —
|
|
bewusst ausfuehrlich, weil der Fall extrem untypisch ist und ein DSB/Nutzer
|
|
die Einordnung sonst nicht versteht."""
|
|
return Violation(
|
|
service="Cookie-Banner",
|
|
severity="LOW",
|
|
text=(
|
|
"Cookieless Analytics im Opt-out-Modell erkannt — KEIN klassisches "
|
|
"Consent-Banner. Der Hinweis bezieht sich auf datenschutzfreundliche, "
|
|
"cookie-freie Analyse: sie arbeitet OHNE Cookies und OHNE persistente "
|
|
"Kennung (typisch ein taeglich zurueckgesetzter Hash aus IP+Browser), "
|
|
"speichert keine personenbezogenen Daten und verfolgt nicht ueber "
|
|
"Sitzungen oder Geraete hinweg. Weil dabei NICHT auf das Endgeraet "
|
|
"zugegriffen wird, greift die Einwilligungspflicht nach §25 Abs.1 TDDDG "
|
|
"(ePrivacy) NICHT — deshalb ist Opt-out (standardmaessig aktiv, jederzeit "
|
|
"ueber 'Disable' abschaltbar) zulaessig statt Opt-in. Die ueblichen "
|
|
"Opt-in-Pruefungen (granulare Kategorien, Accept/Reject-Gleichwertigkeit, "
|
|
"Impressum-Link im Banner, Dark-Pattern-Buttons) sind hier NICHT anwendbar "
|
|
"und werden bewusst ausgesetzt, um Falschbefunde zu vermeiden. Bewertung: "
|
|
"wahrscheinlich konform und sogar datenschutzfreundlicher als ein Standard-"
|
|
"Cookie-Banner. Zu pruefen bleibt nur: funktioniert der Opt-out wirklich "
|
|
"(Analyse stoppt nach 'Disable') und ist eine Datenschutzerklaerung verlinkt"
|
|
+ ("." if has_dse else " — ein DSE-Link fehlt im Hinweis.")
|
|
),
|
|
legal_ref="§25 Abs. 1 TDDDG (cookieless ohne Endgeraete-Zugriff = keine "
|
|
"Einwilligungspflicht), ErwGr. 30 DSGVO, EDPB Guidelines 05/2020 "
|
|
"(Anwendungsbereich der Einwilligung)",
|
|
)
|
|
|
|
|
|
# ── #1/#2: Kein Banner erkannt → affirmativ einordnen (nicht still) ─────
|
|
def build_no_banner_finding(has_dse: bool) -> Violation:
|
|
"""Wenn KEIN Banner erkannt wurde UND vor Consent kein einwilligungs-
|
|
pflichtiges Tracking lief: kein Banner noetig (statische Seite / nur
|
|
technisch notwendige Cookies, §25 Abs.2 TDDDG) → konform. Inkl. Geo-Caveat.
|
|
Nur aufrufen, wenn before_violations UND before_tracking leer sind."""
|
|
return Violation(
|
|
service="Cookie-Banner",
|
|
severity="LOW",
|
|
text=(
|
|
"Kein Consent-Banner erkannt — und es ist auch keiner erforderlich: "
|
|
"Vor einer Einwilligung wurde kein einwilligungspflichtiges Tracking "
|
|
"geladen (keine Tracking-Skripte/-Pixel). Typisch fuer eine statische "
|
|
"Seite oder eine Seite mit ausschliesslich technisch notwendigen Cookies "
|
|
"(Session, CSRF, Login, Warenkorb, Sprache) — die nach §25 Abs.2 TDDDG / "
|
|
"Art.5(3) ePrivacy KEINE Einwilligung brauchen. Bewertung: konform. Es "
|
|
"bleibt nur die Informationspflicht — eine Datenschutzerklaerung muss "
|
|
"erreichbar sein"
|
|
+ ("." if has_dse else " (DSE-Link wurde hier nicht gefunden — bitte pruefen).")
|
|
+ " 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",
|
|
)
|
|
|
|
|
|
# ── #3: Non-Cookie-Tracking (Pixel/Fingerprinting) — §25 gilt auch ohne Cookies ──
|
|
# Domain-Signaturen, die auf Script-URLs zuverlaessig matchen.
|
|
_NON_COOKIE_TRACKERS = {
|
|
"Meta-Pixel (Facebook)": ("connect.facebook.net", "facebook.com/tr"),
|
|
"TikTok-Pixel": ("analytics.tiktok.com",),
|
|
"LinkedIn Insight Tag": ("snap.licdn.com",),
|
|
"Pinterest-Tag": ("s.pinimg.com/ct",),
|
|
"Microsoft Clarity": ("clarity.ms",),
|
|
"Fingerprinting (FingerprintJS)": ("fingerprintjs", "fpjs.io", "fpcdn.io"),
|
|
"Hotjar": ("static.hotjar.com",),
|
|
"Reddit-Pixel": ("redditstatic.com/ads",),
|
|
"Snapchat-Pixel": ("sc-static.net", "tr.snapchat.com"),
|
|
}
|
|
|
|
|
|
def detect_non_cookie_tracking(scripts: list) -> list:
|
|
"""Erkennt cookieloses/script-basiertes Tracking (Pixel/Fingerprinting) in
|
|
den geladenen Skript-URLs. Pure + testbar. §25 Abs.1 TDDDG/Art.5(3) ist
|
|
technologieneutral → gilt auch ohne Cookies."""
|
|
blob = " ".join(s.lower() for s in (scripts or []) if s)
|
|
return [name for name, sigs in _NON_COOKIE_TRACKERS.items()
|
|
if any(sig in blob for sig in sigs)]
|
|
|
|
|
|
def build_non_cookie_tracking_finding(detected: list) -> Violation:
|
|
return Violation(
|
|
service="Cookie-Banner",
|
|
severity="MEDIUM",
|
|
text=(
|
|
"Non-Cookie-Tracking erkannt: " + ", ".join(detected) + ". "
|
|
"§25 Abs.1 TDDDG / Art.5(3) ePrivacy ist technologieneutral — die "
|
|
"Einwilligungspflicht gilt AUCH ohne Cookies (Pixel, Web-Beacons, "
|
|
"Fingerprinting). Ein reiner Cookie-Check uebersieht das: '0 Cookies' "
|
|
"heisst NICHT 'kein einwilligungspflichtiges Tracking'. Diese Techniken "
|
|
"vor jeder Einwilligung pruefen. (Hinweis: Google Consent Mode v2 ist "
|
|
"KEINE gueltige Einwilligung, sondern sendet nur modellierte Signale.)"
|
|
),
|
|
legal_ref="§25 Abs.1 TDDDG, Art.5(3) ePrivacy, EDPB Guidelines 2/2023 "
|
|
"(Pixel/Fingerprinting/URL-Tracking einwilligungspflichtig)",
|
|
)
|
|
|
|
|
|
# ── #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.
|
|
|
|
1. Impressum link must be accessible even with banner overlay (§5 TMG)
|
|
2. DSE link must be accessible from banner
|
|
3. "Zustimmung zur Datenschutzerklärung" is WRONG — DSE is an information
|
|
obligation (Art. 13 DSGVO), not something users "agree" to
|
|
"""
|
|
violations = []
|
|
has_impressum = False
|
|
has_dse = False
|
|
|
|
try:
|
|
# Get banner text and links
|
|
banner_text = ""
|
|
banner_links = []
|
|
|
|
# Try common banner container selectors
|
|
for selector in [
|
|
"#CybotCookiebotDialog", "#onetrust-banner-sdk", "#didomi-host",
|
|
"#usercentrics-root", ".cky-consent-container", "#cmpbox",
|
|
'[class*="cookie-banner"]', '[class*="consent-banner"]',
|
|
'[class*="cookie-notice"]', '[role="dialog"]',
|
|
]:
|
|
try:
|
|
el = page.locator(selector).first
|
|
if await el.count() > 0:
|
|
banner_text = (await el.text_content() or "").strip()
|
|
# Get links inside banner
|
|
links = await el.locator("a[href]").all()
|
|
for link in links:
|
|
href = await link.get_attribute("href") or ""
|
|
text = (await link.text_content() or "").strip()
|
|
banner_links.append({"href": href.lower(), "text": text.lower()})
|
|
if banner_text:
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
# P28a + P63: Shadow-DOM Web Component CMPs (Mercedes cmm-cookie-banner,
|
|
# BMW cookie-consent-banner). Walker pierces shadow tree + extracts
|
|
# label-based legal links (wb7-link/button/role=link). See
|
|
# banner_dom_walkers.SHADOW_BANNER_WALKER_JS.
|
|
if not banner_text or not banner_links:
|
|
try:
|
|
shadow_data = await page.evaluate(SHADOW_BANNER_WALKER_JS)
|
|
if shadow_data and isinstance(shadow_data, dict):
|
|
if shadow_data.get("text"):
|
|
banner_text = (banner_text + " " + shadow_data["text"]).strip()
|
|
if shadow_data.get("links"):
|
|
banner_links.extend(shadow_data["links"])
|
|
except Exception:
|
|
pass
|
|
|
|
if not banner_text:
|
|
return {"violations": violations, "has_impressum": False, "has_dse": False}
|
|
|
|
banner_lower = banner_text.lower()
|
|
|
|
# Sonderfall cookieless Opt-out (sehr untypisch): erkennen, vollstaendig
|
|
# erklaeren und die Opt-in-Checks aussetzen — statt Falsch-HIGHs zu erzeugen.
|
|
if is_cookieless_optout(banner_lower):
|
|
has_dse = any(
|
|
"datenschutz" in l["href"] or "datenschutz" in l["text"]
|
|
or "privacy" in l["href"] or "privacy" in l["text"]
|
|
or "dsgvo" in l["href"]
|
|
for l in banner_links
|
|
)
|
|
return {"violations": [_cookieless_finding(has_dse)],
|
|
"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
|
|
"imprint" in l["href"] or "legal notice" in l["text"]
|
|
for l in banner_links
|
|
)
|
|
# Also check if impressum is visible behind/around banner
|
|
if not has_impressum:
|
|
try:
|
|
imp_visible = await page.locator('a[href*="impressum"], a[href*="imprint"]').first
|
|
if await imp_visible.count() > 0 and await imp_visible.is_visible():
|
|
has_impressum = True
|
|
except Exception:
|
|
pass
|
|
|
|
if not has_impressum:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="HIGH",
|
|
text="Impressum nicht aus dem Cookie-Banner erreichbar. "
|
|
"Bei ueberlagerndem Banner muss ein Impressum-Link im Banner vorhanden sein (§5 TMG).",
|
|
legal_ref="§5 TMG, LG Rostock Az. 3 O 22/19",
|
|
))
|
|
|
|
# Check 2: DSE link in banner
|
|
has_dse = any(
|
|
"datenschutz" in l["href"] or "datenschutz" in l["text"] or
|
|
"privacy" in l["href"] or "privacy" in l["text"] or
|
|
"dsgvo" in l["href"]
|
|
for l in banner_links
|
|
)
|
|
if not has_dse:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="MEDIUM",
|
|
text="Kein Link zur Datenschutzerklaerung im Cookie-Banner. "
|
|
"Nutzer sollten vor der Einwilligung die DSE einsehen koennen.",
|
|
legal_ref="Art. 13 DSGVO, ErwGr. 42 DSGVO (informierte Einwilligung)",
|
|
))
|
|
|
|
# Check 3: Wrong wording — "Zustimmung zur Datenschutzerklärung"
|
|
wrong_dse_consent_patterns = [
|
|
"stimme der datenschutz",
|
|
"stimme den datenschutz",
|
|
"akzeptiere die datenschutz",
|
|
"akzeptiere die privacy",
|
|
"agree to the privacy policy",
|
|
"accept the privacy",
|
|
"datenschutzerklaerung zustimmen",
|
|
"datenschutzrichtlinie akzeptieren",
|
|
"datenschutzrichtlinie zustimmen",
|
|
"i agree to the privacy",
|
|
"i accept the privacy",
|
|
]
|
|
for pattern in wrong_dse_consent_patterns:
|
|
if pattern in banner_lower:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="HIGH",
|
|
text=f"Falsche Formulierung im Banner: 'Zustimmung zur Datenschutzerklaerung'. "
|
|
f"Die DSE ist eine Informationspflicht (Art. 13 DSGVO) — man kann sie "
|
|
f"nur zur Kenntnis nehmen, nicht 'zustimmen'. "
|
|
f"Korrekt: 'Ich habe die Datenschutzinformationen zur Kenntnis genommen'.",
|
|
legal_ref="Art. 13 DSGVO, ErwGr. 42 (informierte Einwilligung ≠ Zustimmung zur DSE)",
|
|
))
|
|
break
|
|
|
|
# P28b Check 4: Reject mechanism present + explicit-labeled?
|
|
# HIGH = no reject mechanism at all
|
|
# MEDIUM = reject available but not labeled "Ablehnen"/"Reject"
|
|
# (e.g. only "Nur technisch Notwendige" — semantically
|
|
# a reject but EDPB 5/2020 + DSK-OH 2024 prefer explicit
|
|
# labeling so users recognize it as the reject option)
|
|
# P93: EDPB 5/2020 schreibt kein bestimmtes Wort vor — Reject-
|
|
# Mechanismus muss gleichwertig zur Annahme sein. BMW nutzt
|
|
# "Cookies verbieten", andere Sites "Tracking ablehnen" o.ae. —
|
|
# alle rechtlich gleichwertig.
|
|
explicit_reject_texts = [
|
|
"ablehnen", "reject", "alle ablehnen",
|
|
"decline", "alles ablehnen",
|
|
"cookies verbieten", "cookies blockieren",
|
|
"tracking ablehnen", "tracking verbieten",
|
|
"zurueckweisen", "block all", "deny all",
|
|
"alle verweigern", "verweigern",
|
|
]
|
|
implicit_reject_texts = ["nur notwendige", "nur technisch", "nur essenzielle",
|
|
"nur essentielle", "notwendige akzeptieren",
|
|
"essential only", "only necessary",
|
|
"nur erforderliche"]
|
|
has_explicit_reject = any(t in banner_lower for t in explicit_reject_texts)
|
|
has_implicit_reject = any(t in banner_lower for t in implicit_reject_texts)
|
|
if not has_explicit_reject and not has_implicit_reject:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="HIGH",
|
|
text="Kein 'Ablehnen'-Mechanismus im Banner erkannt. "
|
|
"Die Ablehnung muss ebenso einfach sein wie die Zustimmung.",
|
|
legal_ref="§25 Abs. 1 TDDDG, EDPB Guidelines 05/2020 (Consent)",
|
|
))
|
|
elif not has_explicit_reject and has_implicit_reject:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="MEDIUM",
|
|
text="Reject-Moeglichkeit vorhanden ('Nur technisch Notwendige' o.ae.), "
|
|
"aber nicht als 'Ablehnen' beschriftet. Nutzer erkennen 'Ablehnen' "
|
|
"schneller als sprachlich umschriebene Varianten. "
|
|
"Empfehlung: zusaetzlich 'Ablehnen' als Button-Label.",
|
|
legal_ref="EDPB 5/2020 (Consent) + DSK-OH 2024 (Telemedien)",
|
|
))
|
|
|
|
# P100: Granular-Wahl-Pruefung — "Anpassen"/"Einstellungen"-Button
|
|
# im Initial-Banner. Wenn er FEHLT (VW-Pattern), ist die granulare
|
|
# Cookie-Wahl erst nach Akzeptanz/Ablehnung moeglich — faktische
|
|
# Manipulation Richtung "Alle akzeptieren". EDPB 5/2020 §82.
|
|
granular_button_texts = [
|
|
"anpassen", "einstellungen", "cookie-einstellungen",
|
|
"cookies verwalten", "manage cookies", "customize",
|
|
"weitere optionen", "more options", "settings",
|
|
"individuell", "detaillierte einstellungen",
|
|
"praeferenzen", "preferences",
|
|
]
|
|
has_granular_button = any(t in banner_lower for t in granular_button_texts)
|
|
if not has_granular_button:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="HIGH",
|
|
text="Granulare Cookie-Auswahl im Initial-Banner nicht "
|
|
"moeglich (kein 'Anpassen'/'Einstellungen'-Button). "
|
|
"Nutzer koennen nur 'Alle akzeptieren' oder 'Nur "
|
|
"technisch notwendige' waehlen — Detailwahl pro "
|
|
"Kategorie erst nach Akzeptanz/Ablehnung. Das ist "
|
|
"faktische Manipulation Richtung Pauschal-Akzeptanz.",
|
|
legal_ref="EDPB Guidelines 5/2020 §82 (granular consent), "
|
|
"§25 Abs. 1 TDDDG, Art. 4(11) DSGVO (informierte "
|
|
"Einwilligung)",
|
|
))
|
|
|
|
# Check 5: Pre-ticked checkboxes (EuGH Planet49)
|
|
try:
|
|
pre_checked = await page.evaluate("""
|
|
() => {
|
|
const banner = document.querySelector(
|
|
'#CybotCookiebotDialog, #onetrust-banner-sdk, #didomi-host, '
|
|
+ '#usercentrics-root, .cky-consent-container, #cmpbox, '
|
|
+ '[class*="cookie-banner"], [class*="consent-banner"], [role="dialog"]'
|
|
);
|
|
if (!banner) return [];
|
|
const checked = banner.querySelectorAll(
|
|
'input[type="checkbox"]:checked:not([disabled])'
|
|
);
|
|
return [...checked]
|
|
.filter(cb => {
|
|
const label = cb.closest('label')?.textContent || cb.getAttribute('aria-label') || '';
|
|
const isNecessary = /notwendig|necessary|essential|erforderlich/i.test(label);
|
|
return !isNecessary;
|
|
})
|
|
.map(cb => cb.closest('label')?.textContent?.trim() || cb.id || 'unknown');
|
|
}
|
|
""")
|
|
if pre_checked:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="HIGH",
|
|
text=f"Vorausgewaehlte Checkboxen im Banner: {', '.join(pre_checked[:3])}. "
|
|
f"Einwilligung muss durch aktive Handlung erfolgen — vorausgefuellte "
|
|
f"Checkboxen sind ungueltig.",
|
|
legal_ref="Art. 4(11) DSGVO, EuGH C-673/17 (Planet49)",
|
|
))
|
|
except Exception:
|
|
pass
|
|
|
|
# Check 6: Dark Pattern — button size/prominence comparison
|
|
try:
|
|
button_info = await page.evaluate("""
|
|
() => {
|
|
const banner = document.querySelector(
|
|
'#CybotCookiebotDialog, #onetrust-banner-sdk, #didomi-host, '
|
|
+ '#usercentrics-root, .cky-consent-container, #cmpbox, '
|
|
+ '[class*="cookie-banner"], [class*="consent-banner"], [role="dialog"]'
|
|
);
|
|
if (!banner) return null;
|
|
const buttons = [...banner.querySelectorAll('button, a[role="button"], [class*="btn"]')];
|
|
return buttons.slice(0, 6).map(b => {
|
|
const style = window.getComputedStyle(b);
|
|
const rect = b.getBoundingClientRect();
|
|
return {
|
|
text: b.textContent?.trim()?.substring(0, 40) || '',
|
|
width: rect.width,
|
|
height: rect.height,
|
|
area: rect.width * rect.height,
|
|
bgColor: style.backgroundColor,
|
|
fontSize: parseFloat(style.fontSize),
|
|
visible: rect.width > 0 && rect.height > 0,
|
|
};
|
|
});
|
|
}
|
|
""")
|
|
if button_info and len(button_info) >= 2:
|
|
accept_btn = None
|
|
reject_btn = None
|
|
accept_kw = ["akzeptieren", "accept", "zustimmen", "agree", "einverstanden", "ok"]
|
|
reject_kw = ["ablehnen", "reject", "notwendige", "decline", "nein",
|
|
"technisch", "essenzielle", "essential", "erforderliche"]
|
|
|
|
for btn in button_info:
|
|
text_lower = btn["text"].lower()
|
|
if any(k in text_lower for k in accept_kw):
|
|
accept_btn = btn
|
|
elif any(k in text_lower for k in reject_kw):
|
|
reject_btn = btn
|
|
|
|
if accept_btn and reject_btn:
|
|
area_ratio = accept_btn["area"] / max(reject_btn["area"], 1)
|
|
if area_ratio > 2.5:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="MEDIUM",
|
|
text=f"Dark Pattern: 'Akzeptieren'-Button ist {area_ratio:.1f}x groesser als "
|
|
f"'Ablehnen'-Button. Beide Optionen muessen gleichwertig dargestellt werden.",
|
|
legal_ref="EDPB Guidelines 05/2020, §25 TDDDG, DSK Orientierungshilfe Telemedien",
|
|
))
|
|
size_ratio = accept_btn["fontSize"] / max(reject_btn["fontSize"], 1)
|
|
if size_ratio > 1.5:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="MEDIUM",
|
|
text=f"Dark Pattern: Schriftgroesse 'Akzeptieren' ({accept_btn['fontSize']:.0f}px) "
|
|
f"vs. 'Ablehnen' ({reject_btn['fontSize']:.0f}px). "
|
|
f"Unterschiedliche Schriftgroessen sind ein Dark Pattern.",
|
|
legal_ref="EDPB Guidelines 05/2020 (gleichwertige Darstellung)",
|
|
))
|
|
except Exception:
|
|
pass
|
|
|
|
# Check 7: Cookie Wall — does rejecting block the site?
|
|
# (This is checked in Phase B — if after reject the page is not navigable)
|
|
|
|
# P29 Check 8: Re-access to cookie settings (Art. 7(3) DSGVO).
|
|
# Three quality tiers:
|
|
# OK = persistent floating cookie icon OR explicit-labeled
|
|
# footer link ("Cookie-Einstellungen", "Cookie-Richtlinie",
|
|
# "Cookies verwalten", etc.)
|
|
# MEDIUM = re-access only via ambiguous label (e.g. "Einstellungen"
|
|
# alone — could mean theme/language) OR only via
|
|
# cookies.html doc link (not a settings dialog)
|
|
# HIGH = no re-access mechanism found at all
|
|
try:
|
|
has_floating_icon = False
|
|
floating_selectors = [
|
|
".cky-btn-revisit", "#ot-sdk-btn", "#ot-sdk-btn-floating",
|
|
"[class*='ot-floating']", "[class*='cookie-floating']",
|
|
"[id*='cookiebot-renew']", "[class*='cmp-floating']",
|
|
"[id*='cmplz-cookiebanner-status']", ".uc-cookie-settings-trigger",
|
|
"[class*='consent-floating']", "[data-testid*='cookie-revisit']",
|
|
]
|
|
for sel in floating_selectors:
|
|
try:
|
|
if await page.locator(sel).count() > 0:
|
|
has_floating_icon = True
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
# Footer label inspection — distinguish explicit vs ambiguous
|
|
# P64: OEM design-systems (Mercedes wb7-footer, BMW b-footer) don't
|
|
# use <footer>. Scan via evaluate() with multiple candidate roots
|
|
# including page-bottom region as last-ditch fallback.
|
|
footer_labels: list[str] = []
|
|
try:
|
|
footer_labels = await page.evaluate(FOOTER_LABELS_WALKER_JS) or []
|
|
except Exception:
|
|
pass
|
|
|
|
# Explicit, unambiguous cookie/consent labels
|
|
explicit_patterns = [
|
|
"cookie-einstellungen", "cookie einstellungen",
|
|
"cookie-richtlinie", "cookie richtlinie",
|
|
"cookie-praeferenzen", "cookie preferences",
|
|
"cookies verwalten", "manage cookies",
|
|
"datenschutz-einstellungen", "privacy preferences",
|
|
"datenschutzeinstellungen", "datenschutz einstellungen",
|
|
"cookie consent", "consent settings",
|
|
"cookie-banner", "cookies anpassen",
|
|
# P64: OEM-typical labels
|
|
"tracking-einstellungen", "tracking einstellungen",
|
|
"cookie-zustimmung", "consent verwalten",
|
|
]
|
|
has_explicit_footer = any(
|
|
any(p in lbl for p in explicit_patterns)
|
|
for lbl in footer_labels
|
|
)
|
|
# Ambiguous labels — "Einstellungen" alone, generic "Cookies"
|
|
ambiguous_patterns = ["einstellungen", "settings", "cookies"]
|
|
has_ambiguous_footer = (not has_explicit_footer) and any(
|
|
lbl.strip() in ambiguous_patterns
|
|
or any(lbl.strip() == p for p in ambiguous_patterns)
|
|
for lbl in footer_labels
|
|
)
|
|
|
|
if has_floating_icon or has_explicit_footer:
|
|
pass # OK — no violation
|
|
elif has_ambiguous_footer:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="MEDIUM",
|
|
text="Re-Zugang zu Cookie-Einstellungen nur ueber mehrdeutiges "
|
|
"Footer-Label (z.B. 'Einstellungen' oder 'Cookies'). "
|
|
"Empfehlung: persistenten Cookie-Icon-Button (Floating) "
|
|
"oder explizites Footer-Label 'Cookie-Einstellungen', "
|
|
"'Cookie-Richtlinie' o.ae. damit Nutzer den Widerruf "
|
|
"ohne Suchen finden.",
|
|
legal_ref="Art. 7 Abs. 3 DSGVO + EDPB 5/2020 (informierte, "
|
|
"leicht widerrufbare Einwilligung)",
|
|
))
|
|
else:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="HIGH",
|
|
text="Kein erneuter Zugang zu Cookie-Einstellungen gefunden "
|
|
"(weder Floating-Icon noch Footer-Link). Widerruf muss "
|
|
"ebenso einfach sein wie Erteilung.",
|
|
legal_ref="Art. 7 Abs. 3 DSGVO (Widerruf so einfach wie Einwilligung)",
|
|
))
|
|
except Exception:
|
|
pass
|
|
|
|
# Check 9: Third-party DSE link — consent links to external domain DSE
|
|
try:
|
|
page_domain = page.url.split("/")[2].replace("www.", "")
|
|
for link in banner_links:
|
|
href = link["href"]
|
|
if not href.startswith("http"):
|
|
continue
|
|
link_domain = href.split("/")[2].replace("www.", "") if len(href.split("/")) > 2 else ""
|
|
if not link_domain:
|
|
continue
|
|
is_dse_link = any(kw in link["text"] for kw in [
|
|
"datenschutz", "privacy", "dsgvo", "data protection",
|
|
])
|
|
if is_dse_link and link_domain != page_domain:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="HIGH",
|
|
text=f"Consent verweist auf Datenschutzerklaerung von {link_domain} "
|
|
f"statt auf eigene DSE. Der Verantwortliche muss eine eigene "
|
|
f"Datenschutzerklaerung bereitstellen (Art. 13 DSGVO). "
|
|
f"Ein Verweis auf die DSE eines Drittanbieters/Auftragsverarbeiters "
|
|
f"reicht nicht aus.",
|
|
legal_ref="Art. 13 DSGVO (Informationspflichten), Art. 26 DSGVO (gemeinsame Verantwortlichkeit)",
|
|
))
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
# Check 10: Dark-Pattern language — "muessen/erforderlich" for non-essential
|
|
dark_pattern_phrases = [
|
|
("muessen heruntergeladen werden", "heruntergeladen"),
|
|
("muessen akzeptiert werden", "akzeptiert"),
|
|
("muessen gesetzt werden", "gesetzt"),
|
|
("cookies sind erforderlich", "erforderlich"),
|
|
("cookies are required", "required"),
|
|
("must be downloaded", "downloaded"),
|
|
("must be accepted", "accepted"),
|
|
("sind zwingend notwendig", "zwingend"),
|
|
("unbedingt erforderlich", "unbedingt"),
|
|
]
|
|
for phrase, keyword in dark_pattern_phrases:
|
|
if phrase in banner_lower:
|
|
# Check if context is about non-essential cookies
|
|
context_essential = any(kw in banner_lower for kw in [
|
|
"technisch notwendig", "essential", "strictly necessary",
|
|
"unbedingt erforderlich fuer den betrieb",
|
|
])
|
|
if not context_essential:
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="MEDIUM",
|
|
text=f"Dark-Pattern-Sprache: '{phrase}' suggeriert technische "
|
|
f"Notwendigkeit fuer nicht-essentielle Cookies. Nutzer koennten "
|
|
f"den Eindruck gewinnen, eine Zustimmung sei alternativlos.",
|
|
legal_ref="EDPB Guidelines 05/2020 Rn. 70, Art. 7(4) DSGVO (freiwillige Einwilligung)",
|
|
))
|
|
break
|
|
|
|
# Check 11: Modal dismiss = consent (click outside closes + sets consent)
|
|
try:
|
|
dismiss_is_consent = await page.evaluate("""
|
|
() => {
|
|
const dialog = document.querySelector(
|
|
'#CybotCookiebotDialog, #onetrust-banner-sdk, #didomi-host, '
|
|
+ '#usercentrics-root, .cky-consent-container, #cmpbox, '
|
|
+ '[class*="cookie-banner"], [class*="consent-banner"], [role="dialog"]'
|
|
);
|
|
if (!dialog) return { hasOverlay: false, overlayCloses: false };
|
|
// Check for overlay/backdrop elements
|
|
const overlays = document.querySelectorAll(
|
|
'.overlay, .backdrop, .modal-backdrop, '
|
|
+ '[class*="overlay"], [class*="backdrop"], '
|
|
+ '[class*="dimmer"], .cdk-overlay-backdrop'
|
|
);
|
|
let overlayHasClick = false;
|
|
for (const ov of overlays) {
|
|
const listeners = getEventListeners ? getEventListeners(ov) : {};
|
|
if (listeners.click && listeners.click.length > 0) {
|
|
overlayHasClick = true;
|
|
}
|
|
}
|
|
// Alternative: check if dialog is non-modal (no inert on background)
|
|
const isModal = dialog.getAttribute('aria-modal') === 'true' ||
|
|
dialog.hasAttribute('open');
|
|
return {
|
|
hasOverlay: overlays.length > 0,
|
|
overlayHasClick: overlayHasClick,
|
|
isModal: isModal,
|
|
dialogRole: dialog.getAttribute('role'),
|
|
};
|
|
}
|
|
""")
|
|
if dismiss_is_consent and dismiss_is_consent.get("hasOverlay") and not dismiss_is_consent.get("isModal"):
|
|
violations.append(Violation(
|
|
service="Cookie-Banner",
|
|
severity="HIGH",
|
|
text="Consent-Dialog ist nicht modal — Klick auf den Hintergrund kann "
|
|
"das Fenster schliessen und als Einwilligung gewertet werden. "
|
|
"Ein versehentlicher Klick ist keine aktive Einwilligung. "
|
|
"Der Dialog muss modal sein (nur explizite Buttons als Optionen).",
|
|
legal_ref="EuGH C-673/17 Planet49 (aktive Handlung), Art. 7(1) DSGVO (Nachweispflicht), "
|
|
"EDPB Guidelines 05/2020 Rn. 77 (silence/inactivity ≠ consent)",
|
|
))
|
|
except Exception:
|
|
pass
|
|
|
|
# ── Checks 12-20: Advanced checks ──────────────────────
|
|
try:
|
|
advanced = await run_advanced_checks(page, banner_text)
|
|
violations.extend(advanced)
|
|
except Exception as e:
|
|
logger.warning("Advanced banner checks failed: %s", e)
|
|
|
|
except Exception as e:
|
|
logger.warning("Banner text check failed: %s", e)
|
|
|
|
return {"violations": violations, "has_impressum": has_impressum, "has_dse": has_dse}
|