Исправлено: - 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)
121 lines
5.7 KiB
Python
121 lines
5.7 KiB
Python
"""
|
|
Проверка наличия ссылки на Политику конфиденциальности.
|
|
Согласно 152-ФЗ, оператор обязан опубликовать документ,
|
|
определяющий его политику в отношении обработки персональных данных.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from urllib.parse import urljoin
|
|
|
|
from playwright.async_api import Page
|
|
|
|
from scanner.checks.base import BaseCheck
|
|
from scanner.models import CheckResult, RiskLevel
|
|
|
|
|
|
class PrivacyLinkCheck(BaseCheck):
|
|
"""Проверяет наличие и доступность ссылки на Политику конфиденциальности."""
|
|
|
|
name = "privacy_link"
|
|
title = "Ссылка на Политику конфиденциальности"
|
|
description = "Проверяет, есть ли на сайте ссылка на политику конфиденциальности"
|
|
risk_on_fail = RiskLevel.HIGH
|
|
default_weight = 15
|
|
|
|
PRIVACY_PATTERNS = [
|
|
r"privacy",
|
|
r"политик[аые]",
|
|
r"конфиденциальн[аыо]",
|
|
r"персональные\s*данные",
|
|
r"personal\s*data",
|
|
r"data\s*policy",
|
|
r"обработка\s*данных",
|
|
r"user\s*privacy",
|
|
]
|
|
|
|
async def check(self, page: Page, url: str) -> CheckResult:
|
|
try:
|
|
# Получаем все ссылки на странице
|
|
links = await page.query_selector_all("a[href]")
|
|
link_texts = []
|
|
|
|
for link in links:
|
|
href = await link.get_attribute("href") or ""
|
|
text = (await link.inner_text()).strip()
|
|
if text:
|
|
link_texts.append({"text": text, "href": href})
|
|
|
|
# Ищем ссылки на политику
|
|
found_links = []
|
|
for lt in link_texts:
|
|
combined = (lt["text"] + " " + lt["href"]).lower()
|
|
if any(re.search(p, combined, re.UNICODE) for p in self.PRIVACY_PATTERNS):
|
|
full_url = urljoin(url, lt["href"])
|
|
found_links.append({"text": lt["text"], "url": full_url})
|
|
|
|
if not found_links:
|
|
return self._fail_result(
|
|
message="Ссылка на Политику конфиденциальности не найдена.",
|
|
details={"total_links": len(link_texts), "page_text_sample": link_texts[:3]},
|
|
)
|
|
|
|
# Проверяем доступность страницы политики через aiohttp (не page.goto — гонка данных!)
|
|
policy_url = found_links[0]["url"]
|
|
try:
|
|
import aiohttp
|
|
|
|
doc_extensions = ['.pdf', '.docx', '.doc', '.xlsx', '.xls', '.rtf', '.txt']
|
|
is_document = any(policy_url.lower().endswith(ext) for ext in doc_extensions)
|
|
|
|
if is_document:
|
|
return self._pass_result(
|
|
message=f"Найдена ссылка на Политику конфиденциальности ({policy_url})",
|
|
details={
|
|
"policy_url": policy_url,
|
|
"link_text": found_links[0]["text"],
|
|
"is_document": True,
|
|
},
|
|
)
|
|
|
|
# Для HTML-страниц — проверяем через aiohttp (не меняет URL страницы!)
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(policy_url, timeout=aiohttp.ClientTimeout(total=8)) as resp:
|
|
if resp.status != 200:
|
|
return self._fail_result(
|
|
message=f"Страница политики недоступна (HTTP {resp.status}): {policy_url}",
|
|
details={"policy_url": policy_url, "http_status": resp.status},
|
|
)
|
|
html = await resp.text()
|
|
|
|
# Извлекаем текст из HTML
|
|
from bs4 import BeautifulSoup
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
|
tag.decompose()
|
|
policy_text = soup.get_text(separator=" ").strip()
|
|
|
|
if len(policy_text) < 50:
|
|
return self._fail_result(
|
|
message=f"Страница политики ({policy_url}) слишком короткая ({len(policy_text)} символов).",
|
|
details={"policy_url": policy_url, "char_count": len(policy_text)},
|
|
)
|
|
|
|
return self._pass_result(
|
|
message=f"Найдена ссылка на Политику конфиденциальности ({policy_url})",
|
|
details={
|
|
"policy_url": policy_url,
|
|
"link_text": found_links[0]["text"],
|
|
"char_count": len(policy_text),
|
|
},
|
|
)
|
|
|
|
except Exception as e:
|
|
return self._fail_result(
|
|
message=f"Страница политики не доступна: {str(e)}",
|
|
details={"policy_url": policy_url},
|
|
)
|
|
|
|
except Exception as e:
|
|
return self._error_result(message=f"Ошибка при проверке ссылки на политику: {str(e)}")
|