Files
neo b9db935389 v2.0: исправлены критические баги, добавлены 4 новые проверки, 4-фазный cookie-аудит, retry-логика, YAML-словари, LLM-анализ, тесты
Исправлено:
- 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)
2026-06-10 00:49:07 +05:00

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