Исправлено: - 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)
136 lines
6.1 KiB
Python
136 lines
6.1 KiB
Python
"""
|
|
Проверка контактных данных оператора: ИНН, ОГРН, DPO.
|
|
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 OperatorInfoCheck(BaseCheck):
|
|
"""Проверяет наличие реквизитов оператора и DPO на сайте."""
|
|
|
|
name = "operator_info"
|
|
title = "Контакты оператора и DPO"
|
|
description = "Проверяет ИНН/ОГРН оператора и контакты ответственного за ПД"
|
|
risk_on_fail = RiskLevel.HIGH
|
|
default_weight = 15
|
|
|
|
# Regex для российских реквизитов
|
|
INN_PATTERN = r"ИНН\s*:?\s*(\d{10}|\d{12})"
|
|
OGRN_PATTERN = r"ОГРН(ИП)?\s*:?\s*(\d{13}|\d{15})"
|
|
DPO_PATTERNS = [
|
|
r"ответственн[ыо][ейм]\s*за\s*(обработку\s*)?(персональных?\s*)?данных?",
|
|
r"(data\s*protection\s*officer|dpo)",
|
|
r"лиц[ое]\s*ответственн[ао][ея]\s*за\s*пд",
|
|
]
|
|
CONTACT_PATTERN = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
|
|
|
|
async def check(self, page: Page, url: str) -> CheckResult:
|
|
try:
|
|
# 1. Главная страница
|
|
page_text = await page.evaluate("() => document.body.innerText")
|
|
|
|
# 2. Ищем ссылку на политику конфиденциальности
|
|
policy_text = ""
|
|
try:
|
|
links = await page.query_selector_all("a[href]")
|
|
policy_patterns = [
|
|
r"политик[аые]\s*конфиденциальн", r"privacy\s*policy",
|
|
r"персональные\s*данные", r"обработк[ае]\s*данных",
|
|
]
|
|
policy_url = None
|
|
for link in links:
|
|
href = await link.get_attribute("href") or ""
|
|
text = (await link.inner_text()).strip()
|
|
combined = (text + " " + href).lower()
|
|
if any(re.search(p, combined, re.UNICODE | re.IGNORECASE) for p in policy_patterns):
|
|
from urllib.parse import urljoin
|
|
policy_url = urljoin(url, href)
|
|
break
|
|
|
|
if policy_url:
|
|
import aiohttp
|
|
from bs4 import BeautifulSoup
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(
|
|
policy_url,
|
|
headers={"User-Agent": "Mozilla/5.0"},
|
|
timeout=aiohttp.ClientTimeout(total=8),
|
|
) as resp:
|
|
if resp.status == 200:
|
|
html = await resp.text()
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
|
tag.decompose()
|
|
policy_text = soup.get_text(separator=" ").strip()
|
|
except Exception:
|
|
policy_text = "" # Ошибка загрузки политики — работаем с главной
|
|
|
|
# 3. Объединяем текст главной + политики
|
|
combined_text = page_text + "\n" + policy_text
|
|
|
|
# 4. Ищем реквизиты
|
|
inn_match = re.search(self.INN_PATTERN, combined_text, re.IGNORECASE)
|
|
inn = inn_match.group(1) if inn_match else None
|
|
|
|
ogrn_match = re.search(self.OGRN_PATTERN, combined_text, re.IGNORECASE)
|
|
ogrn = ogrn_match.group(2) if ogrn_match else None
|
|
|
|
dpo_found = any(
|
|
re.search(pattern, combined_text, re.IGNORECASE)
|
|
for pattern in self.DPO_PATTERNS
|
|
)
|
|
|
|
contacts = re.findall(self.CONTACT_PATTERN, combined_text)
|
|
contacts = [c for c in contacts if not any(
|
|
skip in c.lower() for skip in ("example", "test", "noreply", "no-reply")
|
|
)][:3]
|
|
|
|
# 5. Результат
|
|
has_inn = inn is not None
|
|
has_ogrn = ogrn is not None
|
|
has_dpo = dpo_found
|
|
has_contact = len(contacts) > 0
|
|
present = sum([has_inn, has_ogrn, has_dpo, has_contact])
|
|
|
|
details = {
|
|
"inn": inn, "ogrn": ogrn,
|
|
"dpo_found": dpo_found, "contacts": contacts,
|
|
"policy_checked": bool(policy_text),
|
|
}
|
|
|
|
if present >= 3:
|
|
return self._pass_result(
|
|
message=f"Реквизиты оператора: ИНН={'есть' if has_inn else 'нет'}, "
|
|
f"ОГРН={'есть' if has_ogrn else 'нет'}, "
|
|
f"DPO={'есть' if has_dpo else 'нет'}",
|
|
details=details,
|
|
)
|
|
|
|
if present >= 1:
|
|
missing = []
|
|
if not has_inn: missing.append("ИНН")
|
|
if not has_ogrn: missing.append("ОГРН")
|
|
if not has_dpo: missing.append("контакты DPO")
|
|
return self._fail_result(
|
|
message=f"Реквизиты неполные: отсутствуют {', '.join(missing)}",
|
|
risk_level=RiskLevel.HIGH,
|
|
details=details,
|
|
)
|
|
|
|
return self._fail_result(
|
|
message="Реквизиты оператора не найдены. Отсутствуют ИНН, ОГРН, DPO и контакты.",
|
|
risk_level=RiskLevel.CRITICAL,
|
|
details=details,
|
|
)
|
|
|
|
except Exception as e:
|
|
return self._error_result(f"Ошибка проверки реквизитов: {str(e)}")
|