Files
consentos/apps/scanner/src/dark_pattern_detector.py
James Cottrill fbf26453f2 feat: initial public release
ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
2026-04-14 09:18:18 +00:00

349 lines
12 KiB
Python

"""Dark pattern detection — CSS and DOM analysis of consent banners.
Detects manipulative UI patterns in cookie consent banners:
- Unequal button prominence (Accept bigger/brighter than Reject)
- Pre-ticked category checkboxes
- Missing first-layer Reject button (CNIL violation)
- Cookie walls (blocking page content)
- Dismiss-on-scroll (not valid consent under GDPR)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from playwright.async_api import Page
logger = logging.getLogger(__name__)
@dataclass
class DarkPatternIssue:
"""A detected dark pattern in the consent banner."""
pattern: str
severity: str # critical, warning, info
message: str
recommendation: str
details: dict = field(default_factory=dict)
@dataclass
class DarkPatternResult:
"""Result of dark pattern analysis."""
url: str
issues: list[DarkPatternIssue] = field(default_factory=list)
banner_found: bool = False
error: str | None = None
# Common selectors for consent banner elements
BANNER_SELECTORS = [
"[id*='cookie']",
"[id*='consent']",
"[class*='cookie']",
"[class*='consent']",
"[id*='cmp']",
"[class*='cmp']",
"[role='dialog'][aria-label*='cookie' i]",
"[role='dialog'][aria-label*='consent' i]",
]
ACCEPT_BUTTON_SELECTORS = [
"button:has-text('Accept')",
"button:has-text('Accept All')",
"button:has-text('Allow')",
"button:has-text('Allow All')",
"button:has-text('I Agree')",
"button:has-text('OK')",
"button:has-text('Got it')",
"[data-action='accept']",
"[id*='accept']",
]
REJECT_BUTTON_SELECTORS = [
"button:has-text('Reject')",
"button:has-text('Reject All')",
"button:has-text('Decline')",
"button:has-text('Deny')",
"button:has-text('Refuse')",
"button:has-text('Tout refuser')",
"[data-action='reject']",
"[id*='reject']",
]
async def _find_banner(page: Page) -> bool:
"""Check if a consent banner is visible on the page."""
for selector in BANNER_SELECTORS:
try:
elements = await page.query_selector_all(selector)
for el in elements:
if await el.is_visible():
return True
except Exception:
continue
return False
async def _find_button(page: Page, selectors: list[str]) -> dict | None:
"""Find a visible button matching one of the selectors, return its computed styles."""
for selector in selectors:
try:
elements = await page.query_selector_all(selector)
for el in elements:
if await el.is_visible():
styles = await el.evaluate("""(el) => {
const cs = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
area: rect.width * rect.height,
backgroundColor: cs.backgroundColor,
color: cs.color,
fontSize: parseFloat(cs.fontSize),
fontWeight: cs.fontWeight,
padding: cs.padding,
text: el.textContent.trim(),
visible: true,
};
}""")
return styles
except Exception:
continue
return None
async def check_button_prominence(page: Page) -> list[DarkPatternIssue]:
"""Compare Accept and Reject button sizes and visual weight."""
issues: list[DarkPatternIssue] = []
accept_btn = await _find_button(page, ACCEPT_BUTTON_SELECTORS)
reject_btn = await _find_button(page, REJECT_BUTTON_SELECTORS)
if not accept_btn:
return issues # No accept button found — nothing to compare
if not reject_btn:
issues.append(
DarkPatternIssue(
pattern="missing_reject_button",
severity="critical",
message="No visible Reject/Decline button found on the first layer.",
recommendation=(
"Add a clearly visible 'Reject All' button on the first layer "
"of the consent banner, as required by GDPR and CNIL."
),
)
)
return issues
# Compare button areas
accept_area = accept_btn.get("area", 0)
reject_area = reject_btn.get("area", 0)
if reject_area > 0 and accept_area > 0:
ratio = accept_area / reject_area
if ratio > 1.5:
issues.append(
DarkPatternIssue(
pattern="unequal_button_size",
severity="warning",
message=(
f"Accept button is {ratio:.1f}x larger than Reject button. "
"Buttons should have equal prominence."
),
recommendation=(
"Make the Accept and Reject buttons the same size and visual weight."
),
details={
"accept_area": accept_area,
"reject_area": reject_area,
"ratio": round(ratio, 2),
},
)
)
# Compare font sizes
accept_font = accept_btn.get("fontSize", 0)
reject_font = reject_btn.get("fontSize", 0)
if reject_font > 0 and accept_font > reject_font * 1.3:
issues.append(
DarkPatternIssue(
pattern="unequal_font_size",
severity="warning",
message=(
f"Accept button font ({accept_font}px) is larger than "
f"Reject button font ({reject_font}px)."
),
recommendation="Use the same font size for both Accept and Reject buttons.",
details={
"accept_font_size": accept_font,
"reject_font_size": reject_font,
},
)
)
return issues
async def check_pre_ticked_boxes(page: Page) -> list[DarkPatternIssue]:
"""Check for pre-ticked non-essential category checkboxes."""
issues: list[DarkPatternIssue] = []
try:
pre_ticked = await page.evaluate("""() => {
const checkboxes = document.querySelectorAll(
'input[type="checkbox"][checked], input[type="checkbox"]:checked'
);
const results = [];
for (const cb of checkboxes) {
// Skip if it looks like an "essential" checkbox (often disabled)
if (cb.disabled) continue;
const label = cb.closest('label')?.textContent?.trim()
|| cb.getAttribute('aria-label')
|| cb.name
|| 'unknown';
// Skip checkboxes that appear to be for essential/necessary
const labelLower = label.toLowerCase();
if (labelLower.includes('essential') || labelLower.includes('necessary')
|| labelLower.includes('required') || labelLower.includes('strictly')) {
continue;
}
results.push({ name: cb.name || cb.id, label: label });
}
return results;
}""")
if pre_ticked:
labels = [pt["label"][:50] for pt in pre_ticked]
issues.append(
DarkPatternIssue(
pattern="pre_ticked_checkboxes",
severity="critical",
message=(
f"{len(pre_ticked)} non-essential category checkbox(es) are pre-ticked: "
f"{', '.join(labels[:3])}"
),
recommendation=(
"Non-essential category checkboxes must default to unchecked. "
"Pre-ticked boxes do not constitute valid consent under GDPR."
),
details={"checkboxes": pre_ticked},
)
)
except Exception as exc:
logger.debug("Pre-ticked checkbox check failed: %s", exc)
return issues
async def check_cookie_wall(page: Page) -> list[DarkPatternIssue]:
"""Check if a cookie wall blocks access to page content."""
issues: list[DarkPatternIssue] = []
try:
is_wall = await page.evaluate("""() => {
// Check for full-screen overlays blocking content
const overlays = document.querySelectorAll(
'[class*="overlay"], [class*="modal"], [class*="wall"]'
);
for (const overlay of overlays) {
const cs = window.getComputedStyle(overlay);
const rect = overlay.getBoundingClientRect();
// Full-viewport overlay with high z-index suggests a cookie wall
if (rect.width >= window.innerWidth * 0.9
&& rect.height >= window.innerHeight * 0.9
&& parseInt(cs.zIndex) > 100) {
return true;
}
}
// Check if body/main is hidden or has overflow hidden
const body = document.body;
const bodyStyle = window.getComputedStyle(body);
if (bodyStyle.overflow === 'hidden' && bodyStyle.position === 'fixed') {
return true;
}
return false;
}""")
if is_wall:
issues.append(
DarkPatternIssue(
pattern="cookie_wall",
severity="critical",
message="Cookie wall detected — page content appears blocked until consent.",
recommendation=(
"Remove the cookie wall. Users must be able to access the site "
"without being forced to consent to non-essential cookies."
),
)
)
except Exception as exc:
logger.debug("Cookie wall check failed: %s", exc)
return issues
async def check_scroll_dismissal(page: Page) -> list[DarkPatternIssue]:
"""Check if scrolling dismisses the consent banner (not valid consent)."""
issues: list[DarkPatternIssue] = []
try:
# Check if banner is visible before scroll
banner_visible_before = await _find_banner(page)
if not banner_visible_before:
return issues
# Scroll down
await page.evaluate("window.scrollBy(0, 500)")
await page.wait_for_timeout(1000)
# Check if banner disappeared
banner_visible_after = await _find_banner(page)
if banner_visible_before and not banner_visible_after:
issues.append(
DarkPatternIssue(
pattern="scroll_dismissal",
severity="critical",
message="Consent banner dismissed on scroll — this is not valid consent.",
recommendation=(
"Disable dismiss-on-scroll. Under GDPR, scrolling does not "
"constitute valid consent. The banner must remain until the user "
"makes an explicit choice."
),
)
)
except Exception as exc:
logger.debug("Scroll dismissal check failed: %s", exc)
return issues
async def detect_dark_patterns(page: Page) -> DarkPatternResult:
"""Run all dark pattern checks on the current page."""
url = page.url
result = DarkPatternResult(url=url)
try:
result.banner_found = await _find_banner(page)
if not result.banner_found:
return result
# Run all checks
result.issues.extend(await check_button_prominence(page))
result.issues.extend(await check_pre_ticked_boxes(page))
result.issues.extend(await check_cookie_wall(page))
result.issues.extend(await check_scroll_dismissal(page))
except Exception as exc:
result.error = str(exc)
logger.warning("Dark pattern detection failed for %s: %s", url, exc)
return result