feat(consent-tester): 4 weitere Edge-Cases — Consent-or-Pay, Consent Mode, CNAME-Cloaking, Returning-User
#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>
This commit is contained in:
@@ -113,9 +113,10 @@ def build_no_banner_finding(has_dse: bool) -> Violation:
|
|||||||
"bleibt nur die Informationspflicht — eine Datenschutzerklaerung muss "
|
"bleibt nur die Informationspflicht — eine Datenschutzerklaerung muss "
|
||||||
"erreichbar sein"
|
"erreichbar sein"
|
||||||
+ ("." if has_dse else " (DSE-Link wurde hier nicht gefunden — bitte pruefen).")
|
+ ("." if has_dse else " (DSE-Link wurde hier nicht gefunden — bitte pruefen).")
|
||||||
+ " METHODEN-HINWEIS: Falls die Seite Geo-Targeting nutzt, sieht ein Scan "
|
+ " METHODEN-HINWEIS: Der Scan nutzt ein frisches Browser-Profil — ein "
|
||||||
"ausserhalb der EU evtl. keinen Banner — den Scanner-Standort (EU-IP) bei "
|
"fehlendes Banner liegt also NICHT an einer bereits erteilten Einwilligung. "
|
||||||
"diesem Befund mitdenken."
|
"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, "
|
legal_ref="§25 Abs. 2 TDDDG (Ausnahme technisch notwendig), Art. 5(3) ePrivacy, "
|
||||||
"EDPB Guidelines 2/2023",
|
"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:
|
async def check_banner_text(page) -> dict:
|
||||||
"""Check cookie banner text for legal issues.
|
"""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,
|
"has_impressum": False, "has_dse": has_dse,
|
||||||
"cookieless_optout": True}
|
"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
|
# Check 1: Impressum link in or accessible through banner
|
||||||
has_impressum = any(
|
has_impressum = any(
|
||||||
"impressum" in l["href"] or "impressum" in l["text"] or
|
"impressum" in l["href"] or "impressum" in l["text"] or
|
||||||
|
|||||||
@@ -83,25 +83,34 @@ class ConsentTestResult:
|
|||||||
banner_screenshot_b64: str = ""
|
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
|
"""Edge-Case-Befunde nach dem Scan — an ALLEN Return-Pfaden aufrufen (auch
|
||||||
im no-banner-Fruehreturn): #1/#2 kein-Banner-affirmativ (statisch / nur
|
im no-banner-Fruehreturn): #1/#2 kein-Banner-affirmativ (konform, Geo-/
|
||||||
technisch notwendig → konform, inkl. Geo-Caveat) + #3 Non-Cookie-Tracking
|
Returning-User-Caveat), #3 Non-Cookie-Tracking (Pixel/Fingerprinting),
|
||||||
(Pixel/Fingerprinting; §25 gilt auch ohne Cookies)."""
|
#6 CNAME-Cloaking (First-Party-Subdomain → Tracker-Infra)."""
|
||||||
try:
|
try:
|
||||||
from services.banner_text_checker import (
|
from services.banner_text_checker import (
|
||||||
build_no_banner_finding, detect_non_cookie_tracking,
|
build_no_banner_finding, detect_non_cookie_tracking,
|
||||||
build_non_cookie_tracking_finding,
|
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
|
if (not result.banner_detected and not result.before_violations
|
||||||
and not result.before_tracking):
|
and not result.before_tracking):
|
||||||
result.banner_text_violations.append(
|
result.banner_text_violations.append(
|
||||||
build_no_banner_finding(result.banner_has_dse_link))
|
build_no_banner_finding(result.banner_has_dse_link))
|
||||||
nct = detect_non_cookie_tracking(
|
nct = detect_non_cookie_tracking(all_scripts)
|
||||||
(result.before_scripts or []) + (result.accept_scripts or []))
|
|
||||||
if nct:
|
if nct:
|
||||||
result.banner_text_violations.append(
|
result.banner_text_violations.append(
|
||||||
build_non_cookie_tracking_finding(nct))
|
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:
|
except Exception as e:
|
||||||
logger.warning("Edge-case findings skipped: %s", e)
|
logger.warning("Edge-case findings skipped: %s", e)
|
||||||
|
|
||||||
@@ -240,7 +249,7 @@ async def run_consent_test(
|
|||||||
if not banner.detected:
|
if not banner.detected:
|
||||||
logger.info("No consent banner detected — skipping Phase B/C")
|
logger.info("No consent banner detected — skipping Phase B/C")
|
||||||
await browser.close()
|
await browser.close()
|
||||||
_apply_edge_case_findings(result)
|
_apply_edge_case_findings(result, url)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Phase B: After rejecting ─────────────────────────
|
# ── Phase B: After rejecting ─────────────────────────
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from services.banner_text_checker import (
|
|||||||
is_cookieless_optout,
|
is_cookieless_optout,
|
||||||
detect_non_cookie_tracking,
|
detect_non_cookie_tracking,
|
||||||
build_no_banner_finding,
|
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():
|
def test_no_banner_finding_flags_missing_dse():
|
||||||
v = build_no_banner_finding(has_dse=False)
|
v = build_no_banner_finding(has_dse=False)
|
||||||
assert "dse" in v.text.lower() or "datenschutzerkl" in v.text.lower()
|
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") == []
|
||||||
|
|||||||
Reference in New Issue
Block a user