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.
349 lines
12 KiB
Python
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
|