feat: 9 new banner checks (12-20), total 20 compliance checks
CI / branch-name (push) Has been skipped
CI / test-python-dsms-gateway (push) Successful in 21s
CI / validate-canonical-controls (push) Successful in 13s
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 15s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m38s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 45s
CI / test-python-backend (push) Successful in 52s
CI / test-python-document-crawler (push) Successful in 30s

Check 12: Click count — reject requires more clicks than accept (CNIL 150M EUR)
Check 13: Color contrast — reject button invisible (same bg as banner)
Check 14: Google Consent Mode — analytics_storage 'granted' as default
Check 15: Pre-consent cookies — tracking cookies set before any interaction
Check 16: Registration coupling — login button = consent (Art. 7(4) DSGVO)
Check 17: Language mismatch — banner vs page language (all 26 EU languages)
Check 18: Consent cookie expiry — >13 months violates CNIL guidelines
Check 19: Nudging — reject button below fold / requires scrolling
Check 20: Emotional language (Stirring) — "volle Funktionalitaet" etc.

Language detection covers: BG, CS, DA, DE, EL, EN, ES, ET, FI, FR, GA,
HR, HU, IS, IT, LT, LV, MT, NL, NO, PL, PT, RO, SK, SL, SV

New file: banner_advanced_checks.py (396 LOC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-04 08:38:46 +02:00
parent 7fc43a3f1f
commit 129849aa21
2 changed files with 404 additions and 0 deletions
@@ -0,0 +1,396 @@
"""
Banner advanced legal checks (12-20).
12. Click count: reject vs. accept (CNIL enforcement)
13. Color contrast dark pattern (EDPB 3/2022)
14. Google Consent Mode default values
15. Consent before cookies (pre-banner tracking)
16. Registration-consent coupling (Art. 7(4) DSGVO)
17. Banner language vs. page language (all EU languages)
18. Consent cookie expiry > 13 months (CNIL)
19. Nudging/scrolling to find reject (EDPB 3/2022)
20. Emotional/manipulative language (EDPB 3/2022 Stirring)
"""
import logging
import re
from services.script_analyzer import Violation
logger = logging.getLogger(__name__)
# All EU/EEA official languages for Check 17
EU_LANGUAGES = {
"bg": {"name": "Bulgarian", "cookie_words": ["бисквитки", "съгласие"]},
"cs": {"name": "Czech", "cookie_words": ["cookies", "souhlas", "soubory cookie"]},
"da": {"name": "Danish", "cookie_words": ["cookies", "samtykke", "cookiepolitik"]},
"de": {"name": "German", "cookie_words": ["cookies", "einwilligung", "datenschutz", "zustimm"]},
"el": {"name": "Greek", "cookie_words": ["cookies", "συναίνεση", "απορρήτου"]},
"en": {"name": "English", "cookie_words": ["cookies", "consent", "privacy", "accept"]},
"es": {"name": "Spanish", "cookie_words": ["cookies", "consentimiento", "privacidad", "aceptar"]},
"et": {"name": "Estonian", "cookie_words": ["küpsised", "nõusolek", "privaatsus"]},
"fi": {"name": "Finnish", "cookie_words": ["evästeet", "suostumus", "tietosuoja"]},
"fr": {"name": "French", "cookie_words": ["cookies", "consentement", "confidentialité", "accepter"]},
"ga": {"name": "Irish", "cookie_words": ["fianáin", "toiliú", "príobháideachais"]},
"hr": {"name": "Croatian", "cookie_words": ["kolačići", "pristanak", "privatnost"]},
"hu": {"name": "Hungarian", "cookie_words": ["sütik", "hozzájárulás", "adatvédel"]},
"is": {"name": "Icelandic", "cookie_words": ["vafrakökur", "samþykki", "persónuvernd"]},
"it": {"name": "Italian", "cookie_words": ["cookie", "consenso", "privacy", "accett"]},
"lt": {"name": "Lithuanian", "cookie_words": ["slapukai", "sutikimas", "privatumas"]},
"lv": {"name": "Latvian", "cookie_words": ["sīkdatnes", "piekrišana", "privātums"]},
"mt": {"name": "Maltese", "cookie_words": ["cookies", "kunsens", "privatezza"]},
"nl": {"name": "Dutch", "cookie_words": ["cookies", "toestemming", "privacy", "accepter"]},
"no": {"name": "Norwegian", "cookie_words": ["informasjonskapsler", "samtykke", "personvern"]},
"pl": {"name": "Polish", "cookie_words": ["ciasteczka", "zgoda", "prywatność", "akceptuj"]},
"pt": {"name": "Portuguese", "cookie_words": ["cookies", "consentimento", "privacidade", "aceitar"]},
"ro": {"name": "Romanian", "cookie_words": ["cookie-uri", "consimțământ", "confidențialitate"]},
"sk": {"name": "Slovak", "cookie_words": ["cookies", "súhlas", "súkromie"]},
"sl": {"name": "Slovenian", "cookie_words": ["piškotki", "soglasje", "zasebnost"]},
"sv": {"name": "Swedish", "cookie_words": ["kakor", "samtycke", "integritet", "godkänn"]},
}
def _detect_language(text: str) -> str | None:
"""Detect language of text using cookie-specific keywords."""
text_lower = text.lower()
scores: dict[str, int] = {}
for lang, info in EU_LANGUAGES.items():
score = sum(1 for w in info["cookie_words"] if w in text_lower)
if score > 0:
scores[lang] = score
if not scores:
return None
return max(scores, key=scores.get) # type: ignore[arg-type]
def _detect_page_language(page_html: str, page_lang_attr: str) -> str | None:
"""Detect page language from <html lang=> or meta tags."""
if page_lang_attr:
return page_lang_attr[:2].lower()
# Fallback: check meta content-language
match = re.search(r'content=["\']([a-z]{2})', page_html[:2000].lower())
return match.group(1) if match else None
async def run_advanced_checks(page, banner_text: str) -> list[Violation]:
"""Run checks 12-20 on the banner."""
violations: list[Violation] = []
banner_lower = banner_text.lower()
# ── Check 12: Click count reject vs. accept ────────────────
try:
click_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"]')];
const acceptKw = ['akzeptieren','accept','zustimmen','agree','einverstanden','alle akzeptieren','accept all'];
const rejectKw = ['ablehnen','reject','notwendige','decline','nur notwendige','reject all','alle ablehnen'];
const settingsKw = ['einstellungen','settings','anpassen','customize','details','mehr'];
let acceptClicks = 0, rejectClicks = 0;
for (const b of buttons) {
const t = (b.textContent || '').trim().toLowerCase();
if (acceptKw.some(k => t.includes(k))) { acceptClicks = 1; }
if (rejectKw.some(k => t.includes(k))) { rejectClicks = 1; }
}
// If no direct reject but settings button exists → 2 clicks to reject
if (rejectClicks === 0) {
for (const b of buttons) {
const t = (b.textContent || '').trim().toLowerCase();
if (settingsKw.some(k => t.includes(k))) { rejectClicks = 2; break; }
}
}
return { acceptClicks, rejectClicks };
}
""")
if click_info and click_info["rejectClicks"] > click_info["acceptClicks"]:
violations.append(Violation(
service="Cookie-Banner",
severity="HIGH",
text=f"Ablehnung erfordert {click_info['rejectClicks']} Klick(s), "
f"Zustimmung nur {click_info['acceptClicks']}. "
f"Beides muss mit gleicher Anzahl Klicks erreichbar sein. "
f"Die CNIL hat hierfuer bereits Bussgelder verhaengt (Google: 150 Mio. EUR).",
legal_ref="§25 TDDDG, CNIL Deliberation SAN-2021-024, EDPB Guidelines 05/2020",
))
except Exception:
pass
# ── Check 13: Color contrast dark pattern ──────────────────
try:
color_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 bannerBg = window.getComputedStyle(banner).backgroundColor;
const buttons = [...banner.querySelectorAll('button, a[role="button"], [class*="btn"]')];
const acceptKw = ['akzeptieren','accept','zustimmen','agree','einverstanden'];
const rejectKw = ['ablehnen','reject','notwendige','decline','nein'];
let acceptColor = null, rejectColor = null;
for (const b of buttons) {
const t = (b.textContent || '').trim().toLowerCase();
const style = window.getComputedStyle(b);
if (acceptKw.some(k => t.includes(k))) {
acceptColor = { bg: style.backgroundColor, color: style.color, border: style.borderColor };
}
if (rejectKw.some(k => t.includes(k))) {
rejectColor = { bg: style.backgroundColor, color: style.color, border: style.borderColor };
}
}
return { bannerBg, acceptColor, rejectColor };
}
""")
if color_info and color_info.get("acceptColor") and color_info.get("rejectColor"):
accept_bg = color_info["acceptColor"]["bg"]
reject_bg = color_info["rejectColor"]["bg"]
banner_bg = color_info["bannerBg"]
# If reject button bg matches banner bg (invisible)
if reject_bg == banner_bg or reject_bg in ("transparent", "rgba(0, 0, 0, 0)"):
violations.append(Violation(
service="Cookie-Banner",
severity="MEDIUM",
text="Dark Pattern: 'Ablehnen'-Button hat gleiche Hintergrundfarbe wie der Banner "
"oder ist transparent — optisch kaum sichtbar. Beide Optionen muessen "
"visuell gleichwertig dargestellt werden.",
legal_ref="EDPB Guidelines 3/2022 (Deceptive Design Patterns), §25 TDDDG",
))
except Exception:
pass
# ── Check 14: Google Consent Mode defaults ─────────────────
try:
gcm_info = await page.evaluate("""
() => {
const scripts = document.querySelectorAll('script');
let foundDefault = false;
let grantedBeforeConsent = false;
for (const s of scripts) {
const text = s.textContent || '';
if (text.includes('consent') && text.includes('default')) {
foundDefault = true;
// Check for analytics_storage or ad_storage granted as default
const grantedMatch = text.match(/analytics_storage['"\\s:]*['"]granted/);
const adGranted = text.match(/ad_storage['"\\s:]*['"]granted/);
if (grantedMatch || adGranted) {
grantedBeforeConsent = true;
}
}
}
return { foundDefault, grantedBeforeConsent };
}
""")
if gcm_info and gcm_info.get("grantedBeforeConsent"):
violations.append(Violation(
service="Google Consent Mode",
severity="CRITICAL",
text="Google Consent Mode: analytics_storage oder ad_storage ist als "
"Default auf 'granted' gesetzt BEVOR der Nutzer zugestimmt hat. "
"Default muss 'denied' sein bis explizite Einwilligung vorliegt.",
legal_ref="§25 TDDDG, Art. 5(3) ePrivacy-RL, Google Consent Mode v2 Docs",
))
except Exception:
pass
# ── Check 15: Cookies set before consent interaction ───────
try:
pre_consent_tracking = await page.evaluate("""
() => {
const cookies = document.cookie.split(';').map(c => c.trim().split('=')[0]);
const trackingPatterns = ['_ga', '_gid', '_fbp', '_fbc', 'IDE', '_gcl', 'fr', '_pin',
'_tt_', 'li_sugr', '_hj', 'mp_', 'ajs_', '_clck', '_clsk'];
return cookies.filter(name =>
trackingPatterns.some(p => name.startsWith(p))
);
}
""")
if pre_consent_tracking and len(pre_consent_tracking) > 0:
violations.append(Violation(
service="Cookie-Banner",
severity="CRITICAL",
text=f"Tracking-Cookies vor Consent gesetzt: {', '.join(pre_consent_tracking[:5])}. "
f"Nicht-essentielle Cookies duerfen erst NACH expliziter Einwilligung gesetzt werden.",
legal_ref="§25 Abs. 1 TDDDG, Art. 5(3) ePrivacy-RL, EuGH C-673/17 Planet49",
))
except Exception:
pass
# ── Check 16: Registration/Login consent coupling ──────────
try:
coupling_info = await page.evaluate("""
() => {
const forms = document.querySelectorAll('form');
const results = [];
for (const form of forms) {
const buttons = form.querySelectorAll('button[type="submit"], input[type="submit"]');
for (const btn of buttons) {
const text = (btn.textContent || btn.value || '').toLowerCase();
const isLoginRegister = /anmelden|registrieren|login|sign.?up|register|einloggen/.test(text);
if (!isLoginRegister) continue;
// Check surrounding text for consent coupling
const formText = form.textContent.toLowerCase();
const hasCoupling = /klicken.*stimmen.*zu|clicking.*agree|accept.*terms|akzeptieren.*bedingungen/.test(formText);
const hasCheckbox = form.querySelectorAll('input[type="checkbox"]').length;
if (hasCoupling && hasCheckbox === 0) {
results.push({ button: text.trim().substring(0, 30), hasCoupling: true });
}
}
}
return results;
}
""")
if coupling_info and len(coupling_info) > 0:
violations.append(Violation(
service="Registration/Login",
severity="HIGH",
text=f"Koppelungsverbot: '{coupling_info[0]['button']}'-Button erteilt gleichzeitig "
f"Datenschutz-Einwilligung ohne separate Checkbox. Einwilligung darf nicht "
f"an Registrierung/Login gekoppelt werden.",
legal_ref="Art. 7(4) DSGVO (Koppelungsverbot), ErwGr. 43, EuGH Planet49",
))
except Exception:
pass
# ── Check 17: Banner language vs. page language ────────────
try:
page_lang = await page.evaluate("() => document.documentElement.lang || ''")
page_lang_code = (page_lang or "")[:2].lower()
banner_lang = _detect_language(banner_text)
if page_lang_code and banner_lang and page_lang_code != banner_lang:
page_lang_name = EU_LANGUAGES.get(page_lang_code, {}).get("name", page_lang_code)
banner_lang_name = EU_LANGUAGES.get(banner_lang, {}).get("name", banner_lang)
violations.append(Violation(
service="Cookie-Banner",
severity="MEDIUM",
text=f"Banner-Sprache ({banner_lang_name}) stimmt nicht mit Seitensprache "
f"({page_lang_name}) ueberein. Der Cookie-Banner muss in der Sprache "
f"der Website verfasst sein, damit Nutzer eine informierte Entscheidung "
f"treffen koennen.",
legal_ref="Art. 12(1) DSGVO (klare und einfache Sprache), ErwGr. 39",
))
except Exception:
pass
# ── Check 18: Consent cookie expiry > 13 months ────────────
try:
consent_cookies = await page.evaluate("""
() => {
const consentNames = ['CookieConsent', 'cookieconsent_status', 'cc_cookie',
'eupubconsent', 'eupubconsent-v2', 'OptanonConsent', 'OptanonAlertBoxClosed',
'didomi_token', 'uc_settings', 'cky-consent', 'bp_consent',
'cmplz_consent_status', 'borlabs-cookie', 'cookie_notice_accepted'];
const all = document.cookie.split(';').map(c => c.trim().split('=')[0]);
return all.filter(name => consentNames.some(cn =>
name.toLowerCase().includes(cn.toLowerCase())
));
}
""")
if consent_cookies:
# Check expiry via cookie details (Playwright gives us this)
cookies = await page.context.cookies()
for cookie in cookies:
name = cookie.get("name", "")
if not any(cn.lower() in name.lower() for cn in [
"consent", "cookie", "optanon", "didomi", "uc_settings",
"cky-consent", "borlabs", "cmplz",
]):
continue
expires = cookie.get("expires", 0)
if expires > 0:
import time
days_until_expiry = (expires - time.time()) / 86400
if days_until_expiry > 395: # 13 months ≈ 395 days
violations.append(Violation(
service="Cookie-Banner",
severity="MEDIUM",
text=f"Consent-Cookie '{name}' laeuft erst in {int(days_until_expiry)} Tagen ab. "
f"Die CNIL empfiehlt maximal 13 Monate (395 Tage). "
f"Danach muss erneut um Einwilligung gebeten werden.",
legal_ref="CNIL Leitlinien (max. 13 Monate), EDPB Guidelines 05/2020",
))
break
except Exception:
pass
# ── Check 19: Nudging — reject only visible after scrolling ─
try:
nudge_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 bannerRect = banner.getBoundingClientRect();
const buttons = [...banner.querySelectorAll('button, a[role="button"], [class*="btn"]')];
const rejectKw = ['ablehnen','reject','notwendige','decline','nein','alle ablehnen'];
for (const b of buttons) {
const t = (b.textContent || '').trim().toLowerCase();
if (rejectKw.some(k => t.includes(k))) {
const btnRect = b.getBoundingClientRect();
// Button is below visible banner area
const isBelow = btnRect.top > bannerRect.bottom;
const isHidden = btnRect.height === 0 || btnRect.width === 0;
return { found: true, isBelow, isHidden, btnTop: btnRect.top, bannerBottom: bannerRect.bottom };
}
}
return { found: false };
}
""")
if nudge_info and nudge_info.get("found"):
if nudge_info.get("isBelow") or nudge_info.get("isHidden"):
violations.append(Violation(
service="Cookie-Banner",
severity="HIGH",
text="Nudging: Der 'Ablehnen'-Button ist nicht im sichtbaren Bereich des Banners "
"— Nutzer muessen scrollen um ihn zu finden. Der Ablehn-Button muss ohne "
"Scrollen sichtbar sein.",
legal_ref="EDPB Guidelines 3/2022 (Deceptive Design: Hindering), §25 TDDDG",
))
except Exception:
pass
# ── Check 20: Emotional/manipulative language (Stirring) ───
stirring_patterns = [
# German
("erleben sie das volle potenzial", "Stirring: 'Erleben Sie das volle Potenzial'"),
("bestmoegliches erlebnis", "Stirring: 'bestmoegliches Erlebnis'"),
("optimale nutzung", "Stirring: 'optimale Nutzung'"),
("eingeschraenkte funktionen", "Stirring: 'eingeschraenkte Funktionen' bei Ablehnung"),
("eingeschraenkt weiter", "Stirring: 'eingeschraenkt weiter'"),
("ohne cookies eingeschraenkt", "Stirring: 'ohne Cookies eingeschraenkt'"),
("volle funktionalitaet", "Stirring: 'volle Funktionalitaet'"),
("nur mit cookies moeglich", "Stirring: 'nur mit Cookies moeglich'"),
# English
("best possible experience", "Stirring: 'best possible experience'"),
("full experience", "Stirring: 'full experience'"),
("limited functionality", "Stirring: 'limited functionality' if rejected"),
("enhanced experience", "Stirring: 'enhanced experience'"),
("may not work properly", "Stirring: 'may not work properly'"),
("some features may not", "Stirring: 'some features may not'"),
# French
("meilleure experience", "Stirring: 'meilleure experience'"),
("experience optimale", "Stirring: 'experience optimale'"),
]
for pattern, label in stirring_patterns:
if pattern in banner_lower:
violations.append(Violation(
service="Cookie-Banner",
severity="LOW",
text=f"Emotionale Sprache im Banner: {label}. "
f"Solche Formulierungen koennen als 'Stirring' (emotionale Manipulation) "
f"gewertet werden und die Freiwilligkeit der Einwilligung beeintraechtigen.",
legal_ref="EDPB Guidelines 3/2022 (Deceptive Design: Stirring), Art. 7(4) DSGVO",
))
break # One finding is enough
return violations