Исправлено: - engine: _calculate_score() при ошибке загрузки (score не 100 для мёртвых сайтов) - privacy_link: замена page.goto() на aiohttp (гонка данных) - form_checkbox: 3-уровневый поиск label + dark-pattern detection - server_location: синхронный urllib → aiohttp - api/server: перепутанные request/response модели в batch - main.py: csv --checks флаг - engine: фазовая модель (cookie_audit отдельно, остальные параллельно) - engine: retry-логика (3 попытки, exponential backoff) - PDF: emoji заменены на текст (WeasyPrint) Добавлено: - consent_doc: проверка отдельного согласия на обработку ПД - operator_info: проверка ИНН/ОГРН/DPO (читает главную + политику) - cookie_audit: 4-фазный аудит (load→detect→reject→compare) - llm_policy: опциональный LLM-анализ 16 разделов (--deep) - external_scripts: YAML-словари trackers.yaml (critical/warning/russian) - logging (output.py), .gitignore, pyproject.toml - smoke-тесты (16/16)
188 lines
8.4 KiB
Python
188 lines
8.4 KiB
Python
"""
|
|
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
|