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 "
|
||||
"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
|
||||
|
||||
@@ -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 ─────────────────────────
|
||||
|
||||
@@ -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") == []
|
||||
|
||||
Reference in New Issue
Block a user