feat: Deep consent verification — DataLayer, Storage, GCM, TCF

5 verification layers added to the 3-phase banner test:

1. DataLayer/GTM Interception: Proxy on window.dataLayer captures
   all push() events. Distinguishes safe lifecycle events (gtm.js,
   gtm.dom) from tracking events (page_view, conversion, purchase).
   Flags tracking events before consent as violations.

2. localStorage/sessionStorage Monitoring: Intercepts setItem() to
   detect tracking keys (_ga, _fbp, amplitude, mixpanel, etc.)
   written before consent.

3. Google Consent Mode v2 Runtime Verification: Reads actual GCM
   state (analytics_storage, ad_storage) per phase. Verifies
   default=denied before consent, stays denied after reject,
   switches to granted after accept.

4. TCF v2.2 State: Reads __tcfapi('getTCData') if available.
   Verifies consent purpose states match user choice.

5. Cookie Attribute Analysis: Domain (1st vs 3rd party), expires
   (>13 months), secure flag for tracking cookies.

10 new L2 checks with expert hints (EDPB, CNIL, §25 TDDDG).
All interceptor calls wrapped in try/except for graceful fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-10 08:58:44 +02:00
parent 99ef9873ad
commit d2dc0c9fe4
4 changed files with 499 additions and 0 deletions
+210
View File
@@ -705,4 +705,214 @@ BANNER_CHECKLIST = [
"Ablehnung eine leere Seite oder Redirect auf Fehlerseite."
),
},
# =====================================================================
# Deep Verification L2 Checks (consent interceptor data)
# =====================================================================
{
"id": "datalayer_events_before",
"label": "Keine DataLayer-Tracking-Events vor Consent",
"level": 2,
"parent": "banner_pre_consent",
"check_key": "datalayer_events_before",
"severity": "HIGH",
"hint": (
"ss25 Abs. 1 TDDDG: Jeder DataLayer-Push, der ein Tracking-Event "
"ausloest (z.B. page_view, purchase, conversion, gtm.click), "
"stellt einen Zugriff auf das Endgeraet dar, weil dabei "
"personenbezogene Daten (Client-ID, Session-Daten, URL, Referrer) "
"an Drittanbieter-Server uebermittelt werden. Die CNIL hat in "
"ihrer Google-Entscheidung (SAN-2021-023) explizit bestaetigt, "
"dass bereits das Ausloesen eines GA4-Events vor Consent einen "
"Verstoss darstellt. Pruefung: DataLayer auf Tracking-Events "
"wie page_view, add_to_cart, conversion etc. vor jeder Banner-"
"Interaktion pruefen. Ausnahme: gtm.js, gtm.dom, consent_update "
"sind technisch notwendig und zulaessig."
),
},
{
"id": "localstorage_tracking_before",
"label": "Keine Tracking-Keys in localStorage vor Consent",
"level": 2,
"parent": "banner_pre_consent",
"check_key": "localstorage_tracking_before",
"severity": "MEDIUM",
"hint": (
"ss25 Abs. 1 TDDDG, Art. 5(3) ePrivacy-RL: localStorage und "
"sessionStorage sind funktional aequivalent zu Cookies — der "
"Zugriff auf den lokalen Speicher des Endgeraets erfordert "
"dieselbe Einwilligung. Die EDPB Guidelines 05/2020, Rn. 10-11 "
"stellen klar, dass 'any information stored on the terminal "
"equipment' erfasst ist, unabhaengig von der technischen "
"Implementierung. Bekannte Tracking-Keys: _ga, _gid, _fbp, "
"_hjSession, _clck, amplitude_*, mixpanel_*. Pruefung: "
"Storage.setItem()-Aufrufe vor Consent auf bekannte Tracking-"
"Praefix-Muster ueberpruefen."
),
},
{
"id": "gcm_runtime_denied",
"label": "Google Consent Mode Runtime = denied vor Consent",
"level": 2,
"parent": "banner_pre_consent",
"check_key": "gcm_runtime_denied",
"severity": "HIGH",
"hint": (
"Google Consent Mode v2 (GCM): Die Laufzeit-Werte fuer "
"analytics_storage, ad_storage, ad_user_data und "
"ad_personalization muessen nach dem gtag('consent','default') "
"Aufruf tatsaechlich auf 'denied' stehen. Diese Pruefung geht "
"ueber den statischen Quelltext hinaus und verifiziert den "
"effektiven Runtime-Zustand im Browser. Haeufiger Fehler: Der "
"CMP sendet gtag('consent','default',{...}) korrekt, aber ein "
"spaeterer gtag('consent','update',{...}) ueberschreibt die "
"Werte zu 'granted' noch VOR der Nutzer-Interaktion. Auch "
"Region-basierte Defaults (z.B. 'granted' fuer Nicht-EU) "
"koennen bei fehlerhafter Geo-Erkennung zu einem Verstoss "
"gegen ss25 TDDDG fuehren."
),
},
{
"id": "datalayer_events_after_reject",
"label": "Keine neuen DataLayer-Events nach Ablehnung",
"level": 2,
"parent": "banner_post_reject",
"check_key": "datalayer_events_after_reject",
"severity": "CRITICAL",
"hint": (
"ss25 Abs. 1 TDDDG, CNIL SAN-2022-009 (Criteo, 40 Mio. EUR): "
"Wenn nach ausdruecklicher Ablehnung weiterhin DataLayer-"
"Tracking-Events gefeuert werden (z.B. page_view, conversion), "
"liegt ein schwerwiegender Verstoss vor. Der Nutzer hat seinen "
"Willen unmissverstaendlich erklaert — jedes weitere Tracking-"
"Event ist rechtswidrig. Haeufiger Fehler: Der CMP setzt den "
"Consent-Status korrekt, aber GTM-Container-Tags pruefen den "
"Status nicht oder verwenden veraltete Trigger-Konfigurationen. "
"Pruefung: DataLayer nach dem Reject-Klick auf neue Tracking-"
"Events ueberwachen."
),
},
{
"id": "gcm_stays_denied",
"label": "Consent Mode bleibt denied nach Ablehnung",
"level": 2,
"parent": "banner_post_reject",
"check_key": "gcm_stays_denied",
"severity": "CRITICAL",
"hint": (
"Google Consent Mode v2: Nach Ablehnung MUSS der CMP den "
"Befehl gtag('consent','update',{analytics_storage:'denied', "
"ad_storage:'denied', ...}) senden. Wenn der Consent Mode "
"nach Reject auf 'granted' steht oder unveraendert bleibt, "
"sendet GA4 weiterhin vollstaendige Hits statt consent-"
"reduzierter Pings. Die CNIL Leitlinie (Okt. 2020) und "
"EDPB Guidelines 05/2020, Rn. 112 fordern, dass technische "
"Massnahmen die Ablehnung 'effektiv umsetzen'. Pruefung: "
"Runtime-Werte von analytics_storage, ad_storage, "
"ad_user_data, ad_personalization nach Reject verifizieren."
),
},
{
"id": "storage_cleared_after_reject",
"label": "Tracking-Storage nach Ablehnung geleert",
"level": 2,
"parent": "banner_post_reject",
"check_key": "storage_cleared_after_reject",
"severity": "MEDIUM",
"hint": (
"CNIL Leitlinie (Okt. 2020), Rn. 23: Der Verantwortliche muss "
"sicherstellen, dass 'le refus est effectivement mis en oeuvre'. "
"Wenn nach Ablehnung weiterhin Tracking-Schluesse in "
"localStorage/sessionStorage geschrieben werden (z.B. _ga, "
"_hjSession, _clck), ist die Ablehnung nicht wirksam umgesetzt. "
"Auch bestehende Tracking-Eintraege sollten idealerweise "
"bereinigt werden. Pruefung: Storage.setItem()-Aufrufe nach "
"dem Reject-Klick auf bekannte Tracking-Keys ueberpruefen. "
"Haeufiger Fehler: CMP loescht Cookies, vergisst aber "
"localStorage-Eintraege von Hotjar, Clarity oder Amplitude."
),
},
{
"id": "cookie_domain_check",
"label": "Keine 3rd-Party-Tracking-Cookies vor Consent",
"level": 2,
"parent": "banner_pre_consent",
"check_key": "cookie_domain_check",
"severity": "HIGH",
"hint": (
"ss25 Abs. 1 TDDDG, EuGH C-673/17 (Planet49), Rn. 61: "
"Tracking-Cookies wie _ga, _gid, _fbp, _fbc, IDE, _gcl_*, "
"_tt_*, _pin_*, li_sugr, _hj* duerfen erst NACH expliziter "
"Einwilligung geschrieben werden. Diese Pruefung ueberwacht "
"document.cookie-Schreibvorgaenge in Echtzeit und erkennt "
"Tracking-Cookie-Patterns bereits beim Setzen — nicht erst "
"beim nachtraeglichen Cookie-Scan. Haeufiger Fehler: CMP "
"konfiguriert Consent-Default auf 'granted', wodurch GA4 "
"sofort _ga/_gid setzt und erst bei Ablehnung loescht — "
"zu diesem Zeitpunkt wurde der Zugriff aber bereits "
"rechtswidrig durchgefuehrt."
),
},
{
"id": "cookie_expires_check",
"label": "Tracking-Cookies nicht ueber 13 Monate",
"level": 2,
"parent": "banner_consent_valid",
"check_key": "cookie_expires_check",
"severity": "MEDIUM",
"hint": (
"CNIL Leitlinie (01.10.2020), Art. 5: Die Gueltigkeitsdauer "
"von Tracking-Cookies darf 13 Monate (ca. 395 Tage) nicht "
"uebersteigen. Auch die DSK-Orientierungshilfe Telemedien "
"(Dez. 2021) empfiehlt diese Obergrenze. Pruefung: Das "
"Expires/Max-Age-Feld der per document.cookie geschriebenen "
"Tracking-Cookies auswerten. Haeufiger Fehler: GA4 setzt "
"_ga mit Standardablauf von 2 Jahren (730 Tage) — das "
"ueberschreitet die CNIL-Empfehlung deutlich. Loesung: "
"Cookie-Lebensdauer in der GA4-Konfiguration auf maximal "
"13 Monate begrenzen."
),
},
{
"id": "tcf_consent_valid",
"label": "TCF v2.2 Consent-Status korrekt",
"level": 2,
"parent": "banner_consent_valid",
"check_key": "tcf_consent_valid",
"severity": "MEDIUM",
"hint": (
"IAB TCF v2.2 Specification, ss4.1: Wenn ein CMP das "
"Transparency and Consent Framework implementiert, muss die "
"__tcfapi('getTCData') Antwort valide sein — insbesondere "
"gdprApplies, purpose.consents und vendor.consents muessen "
"den tatsaechlichen Consent-Status widerspiegeln. Die "
"belgische DPA hat im TCF-Entscheid (02/2022) festgestellt, "
"dass fehlerhafte TC-Strings die gesamte Consent-Kette "
"ungueltig machen. Pruefung: __tcfapi verfuegbar, tcString "
"nicht leer, gdprApplies korrekt gesetzt. Haeufiger Fehler: "
"CMP meldet gdprApplies=false fuer EU-Nutzer wegen "
"fehlerhafter GeoIP-Erkennung."
),
},
{
"id": "response_blocked_before",
"label": "Tracking-Requests werden vor Consent blockiert",
"level": 2,
"parent": "banner_pre_consent",
"check_key": "response_blocked_before",
"severity": "MEDIUM",
"hint": (
"ss25 Abs. 1 TDDDG, EDPB Guidelines 05/2020, Rn. 10: Auch "
"navigator.sendBeacon()-Aufrufe an Tracking-Domains stellen "
"einen Zugriff auf das Endgeraet dar, weil dabei Nutzer-"
"Informationen (URL, Referrer, Timing-Daten) uebermittelt "
"werden. Diese Methode wird haeufig fuer Analytics-Pings "
"verwendet (GA4 Measurement Protocol, Meta CAPI). Pruefung: "
"sendBeacon-Aufrufe vor Consent auf bekannte Tracking-"
"Domains (google-analytics.com, facebook.com/tr, "
"analytics.tiktok.com etc.) ueberpruefen. Haeufiger Fehler: "
"Web-Vitals-Library sendet Metriken per sendBeacon an "
"Google Analytics noch bevor der CMP geladen ist."
),
},
]
+50
View File
@@ -145,6 +145,24 @@ _TEXT_TO_CODE: list[tuple[str, str]] = [
("drittanbieter.*dse", "third_party_dse_link"),
("ohne vorherige einwilligung", "tracking_before_consent"),
("trotz ablehnung", "tracking_after_reject"),
("datalayer.*vor consent", "datalayer_events_before"),
("datalayer.*vor einwilligung", "datalayer_events_before"),
("localstorage.*tracking", "localstorage_tracking_before"),
("storage.*tracking.*vor", "localstorage_tracking_before"),
("consent mode.*runtime.*denied", "gcm_runtime_denied"),
("gcm.*nicht denied", "gcm_runtime_denied"),
("datalayer.*nach ablehnung", "datalayer_events_after_reject"),
("consent mode.*bleibt", "gcm_stays_denied"),
("gcm.*nach reject", "gcm_stays_denied"),
("storage.*nach ablehnung", "storage_cleared_after_reject"),
("tracking-cookie.*vor consent", "cookie_domain_check"),
("cookie.*geschrieben.*vor", "cookie_domain_check"),
("cookie.*13 monate", "cookie_expires_check"),
("cookie.*ablauf.*ueber", "cookie_expires_check"),
("tcf.*consent", "tcf_consent_valid"),
("__tcfapi", "tcf_consent_valid"),
("sendbeacon.*tracking", "response_blocked_before"),
("beacon.*vor consent", "response_blocked_before"),
]
@@ -198,6 +216,17 @@ def _collect_violation_codes(scan: dict) -> dict[str, str]:
if new_tracking_b and "tracking_after_reject" not in codes:
codes["tracking_after_reject"] = ", ".join(new_tracking_b[:5])
# Deep verification violations (from consent interceptor)
deep = scan.get("deep_verification", {})
for phase_key in ("before_consent", "after_reject"):
for v in deep.get(phase_key, {}).get("violations", []):
raw_code = v.get("code", "")
if not raw_code:
continue
# Map interceptor codes to banner check_keys
check_key = _INTERCEPTOR_CODE_MAP.get(raw_code, raw_code)
codes[check_key] = v.get("text", "")[:120]
return codes
@@ -224,6 +253,16 @@ def _collect_pass_codes(scan: dict) -> dict[str, str]:
return passes
# Map consent_interceptor violation codes → banner check_keys
_INTERCEPTOR_CODE_MAP: dict[str, str] = {
"DL_TRACK_BEFORE_CONSENT": "datalayer_events_before",
"STORAGE_TRACK_BEFORE_CONSENT": "localstorage_tracking_before",
"GCM_NOT_DENIED_BEFORE_CONSENT": "gcm_runtime_denied",
"DL_TRACK_AFTER_REJECT": "datalayer_events_after_reject",
"GCM_NOT_DENIED_AFTER_REJECT": "gcm_stays_denied",
"STORAGE_TRACK_AFTER_REJECT": "storage_cleared_after_reject",
}
# Checks where absence of a violation means PASS (not "untested")
# These are phase-based checks: if no tracking was detected, that's good.
_ABSENCE_IS_PASS = {
@@ -233,6 +272,17 @@ _ABSENCE_IS_PASS = {
"google_consent_mode_defaults",
"banner_language_mismatch",
"cookie_wall",
# Deep verification checks (absence = no violation found = PASS)
"datalayer_events_before",
"localstorage_tracking_before",
"gcm_runtime_denied",
"datalayer_events_after_reject",
"gcm_stays_denied",
"storage_cleared_after_reject",
"cookie_domain_check",
"cookie_expires_check",
"tcf_consent_valid",
"response_blocked_before",
}
_TRACKING_COOKIE_PREFIXES = (