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:
Benjamin Admin
2026-06-12 20:45:20 +02:00
parent 2b928dcb33
commit 11740bd2f9
3 changed files with 170 additions and 10 deletions
+129 -3
View File
@@ -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
+16 -7
View File
@@ -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") == []