""" 4-фазный cookie-аудит по методологии gdpr-cookie-scanner (slashgear). Фазы: 1) запись cookies до действий, 2) поиск consent-баннера, 3) нажатие «Отказаться» + запись cookies после, 4) сравнение. Заменяет примитивную проверку «есть ли баннер?». """ from __future__ import annotations import re from playwright.async_api import Page from scanner.checks.base import BaseCheck from scanner.models import CheckResult, RiskLevel class CookieAuditCheck(BaseCheck): """4-фазный аудит cookie-consent: load→detect→reject→compare.""" name = "cookie_audit" title = "4-фазный cookie-аудит" description = "Проверяет, что отказ от cookies реально отключает трекинговые cookies" risk_on_fail = RiskLevel.CRITICAL default_weight = 25 # Селекторы для поиска кнопки отказа REJECT_SELECTORS = [ 'button:has-text("Отказаться")', 'button:has-text("отказаться")', 'button:has-text("Reject")', 'button:has-text("reject")', 'button:has-text("Только необходимые")', 'button:has-text("only necessary")', 'a:has-text("Отказаться")', 'a:has-text("Reject")', '[id*="reject"]', '[class*="reject"]', '[id*="decline"]', '[class*="decline"]', '[data-action="reject"]', '[aria-label*="Reject"]', 'button:has-text("Запретить")', 'button:has-text("Не согласен")', ] # Домены tracking-cookies (должны отключиться после отказа) TRACKING_DOMAINS = [ "google-analytics", "googletagmanager", "doubleclick", "facebook", "meta", "yandex", "mc.yandex", "twitter", "tiktok", "hotjar", "mixpanel", "amplitude", "segment", "clarity", ] async def check(self, page: Page, url: str) -> CheckResult: try: # Фаза 1: записываем cookies до действий cookies_before = await page.context.cookies() tracking_before = self._filter_tracking(cookies_before) tracking_count_before = len(tracking_before) # Фаза 2: ищем consent-баннер has_banner = await self._detect_banner(page) if not has_banner: # Нет баннера вообще — но проверяем, есть ли tracking-куки if tracking_count_before == 0: return self._pass_result( message="Cookie-баннер не найден, но tracking-cookies отсутствуют. Вероятно, сайт не использует трекинг.", details={"has_banner": False, "tracking_cookies": 0}, ) return self._fail_result( message=f"Cookie-баннер не найден, но обнаружено {tracking_count_before} tracking-cookie(ies) без согласия.", details={ "has_banner": False, "tracking_cookies_before": tracking_count_before, "tracking_domains": [c["domain"] for c in tracking_before], }, ) # Фаза 3: ищем кнопку отказа и нажимаем reject_clicked = await self._click_reject(page) if not reject_clicked: return self._fail_result( message="Cookie-баннер найден, но кнопка отказа не обнаружена (dark pattern: только «Принять»).", risk_level=RiskLevel.CRITICAL, details={ "has_banner": True, "reject_button_found": False, "tracking_cookies_before": tracking_count_before, }, ) # Ждём применения отказа await page.wait_for_timeout(1500) # Фаза 4: сравниваем cookies после отказа cookies_after = await page.context.cookies() tracking_after = self._filter_tracking(cookies_after) tracking_count_after = len(tracking_after) if tracking_count_after == 0: return self._pass_result( message=f"Cookie-аудит пройден: отказ работает. {tracking_count_before} tracking-cookie(ies) отключены.", details={ "has_banner": True, "reject_clicked": True, "cookies_before": tracking_count_before, "cookies_after": tracking_count_after, }, ) # Часть cookies осталась после отказа removed = tracking_count_before - tracking_count_after still_present = [c["domain"] for c in tracking_after] if removed == 0: return self._fail_result( message=f"ОТКАЗ НЕ РАБОТАЕТ: все {tracking_count_before} tracking-cookie(ies) остались после отказа: {', '.join(still_present[:5])}.", risk_level=RiskLevel.CRITICAL, details={ "cookies_before": tracking_count_before, "cookies_after": tracking_count_after, "still_present": still_present, }, ) return self._fail_result( message=f"Отказ частичный: {removed}/{tracking_count_before} cookies отключены. Остались: {', '.join(still_present[:5])}.", risk_level=RiskLevel.HIGH, details={ "cookies_before": tracking_count_before, "cookies_after": tracking_count_after, "removed": removed, "still_present": still_present, }, ) except Exception as e: return self._error_result(f"Ошибка cookie-аудита: {str(e)}") async def _detect_banner(self, page: Page) -> bool: """Фаза 2: поиск consent-баннера.""" selectors = [ '[id*="cookie"]', '[class*="cookie"]', '[id*="consent"]', '[class*="consent"]', '[id*="gdpr"]', '[class*="gdpr"]', '[id*="privacy"]', '[class*="privacy"]', '[aria-label*="cookie" i]', '[aria-label*="consent" i]', ] for sel in selectors: el = await page.query_selector(sel) if el: is_visible = await el.is_visible() if is_visible: return True return False async def _click_reject(self, page: Page) -> bool: """Фаза 3: найти и нажать кнопку отказа.""" for sel in self.REJECT_SELECTORS: try: el = await page.query_selector(sel) if el: is_visible = await el.is_visible() if is_visible: await el.click(timeout=3000) return True except Exception: continue return False def _filter_tracking(self, cookies: list[dict]) -> list[dict]: """Отфильтровать tracking-cookies по доменным паттернам.""" tracking = [] for c in cookies: domain = c.get("domain", "") name = c.get("name", "").lower() # Проверяем домен for td in self.TRACKING_DOMAINS: if td in domain.lower(): tracking.append(c) break else: # Проверяем имя cookie на паттерны трекинга tracking_names = ["_ga", "_gid", "_gat", "_fbp", "_ym_", "amplitude", "mp_", "ajs_", "_hj", "_clck", "_ttp"] if any(name.startswith(tn) for tn in tracking_names): tracking.append(c) return tracking