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.
This commit is contained in:
348
apps/scanner/src/dark_pattern_detector.py
Normal file
348
apps/scanner/src/dark_pattern_detector.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user