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)
This commit is contained in:
1 parent
7fb417dad5
commit
b9db935389
24 files changed
+1725
-286
No files matched your search
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
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
|
||||
Reference in new issue
Block a user