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:
neo committed 2026-06-10 00:49:07 +05:00
1 parent 7fb417dad5
commit b9db935389
24 files changed
+1725 -286

No files matched your search

+20 -7
View File
@@ -4,22 +4,35 @@ from scanner.checks.privacy_link import PrivacyLinkCheck
from scanner.checks.form_checkbox import FormCheckboxCheck
from scanner.checks.server_location import ServerLocationCheck
from scanner.checks.external_scripts import ExternalScriptsCheck
from scanner.checks.consent_doc import ConsentDocCheck
from scanner.checks.operator_info import OperatorInfoCheck
from scanner.checks.cookie_audit import CookieAuditCheck
from scanner.checks.llm_policy import LLMPolicyCheck
# Реестр всех проверок — новые проверки автоматически регистрируются здесь
# Реестр всех проверок
ALL_CHECKS: dict[str, type[BaseCheck]] = {
"cookie_banner": CookieBannerCheck,
"cookie_audit": CookieAuditCheck,
"privacy_link": PrivacyLinkCheck,
"form_checkbox": FormCheckboxCheck,
"server_location": ServerLocationCheck,
"external_scripts": ExternalScriptsCheck,
"consent_doc": ConsentDocCheck,
"operator_info": OperatorInfoCheck,
"llm_policy": LLMPolicyCheck, # Опциональный, запускается отдельно
}
QUICK_CHECKS = [
"cookie_audit", "privacy_link", "form_checkbox",
"server_location", "external_scripts",
"consent_doc", "operator_info",
]
"""Быстрые проверки без LLM — запускаются по умолчанию."""
__all__ = [
"BaseCheck",
"CookieBannerCheck",
"PrivacyLinkCheck",
"FormCheckboxCheck",
"ServerLocationCheck",
"ExternalScriptsCheck",
"ALL_CHECKS",
"CookieBannerCheck", "PrivacyLinkCheck", "FormCheckboxCheck",
"ServerLocationCheck", "ExternalScriptsCheck",
"ConsentDocCheck", "OperatorInfoCheck", "LLMPolicyCheck",
"ALL_CHECKS", "QUICK_CHECKS",
]
+76
View File
@@ -0,0 +1,76 @@
"""
Проверка наличия отдельного документа «Согласие на обработку персональных данных».
С 01.09.2025 согласие — отдельный документ (нельзя включать в оферту/договор).
"""
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 ConsentDocCheck(BaseCheck):
"""Проверяет наличие отдельной страницы/документа согласия на обработку ПД."""
name = "consent_doc"
title = "Документ согласия на обработку ПД"
description = "Проверяет, есть ли на сайте отдельный документ согласия (не в составе оферты)"
risk_on_fail = RiskLevel.CRITICAL
default_weight = 20
CONSENT_LINK_PATTERNS = [
r"согласи[ея]\s*на\s*обработку\s*(персональных)?\s*данных",
r"согласи[ея]\s*на\s*обработку\s*пд",
r"consent\s*(to|for)\s*(personal\s*)?data\s*processing",
r"согласи[ея]\s*субъекта",
]
async def check(self, page: Page, url: str) -> CheckResult:
try:
links = await page.query_selector_all("a[href]")
found = []
for link in links:
href = await link.get_attribute("href") or ""
text = (await link.inner_text()).strip()
combined = (text + " " + href).lower()
if not text or len(text) < 3:
continue
if any(re.search(p, combined, re.UNICODE | re.IGNORECASE) for p in self.CONSENT_LINK_PATTERNS):
# Не учитываем ссылки на политику конфиденциальности
if re.search(r"политик[аеу]|privacy\s*policy|конфиденциальн", combined, re.IGNORECASE):
continue
# Не учитываем текст внутри чекбоксов форм (это проверяет form_checkbox)
full_url = urljoin(url, href)
found.append({"text": text[:100], "url": full_url})
if not found:
return self._fail_result(
message=(
"Отдельный документ «Согласие на обработку ПД» не найден. "
"С 01.09.2025 согласие — отдельный документ, не может быть частью оферты."
),
details={"total_links": len(links)},
)
# Проверяем, что хотя бы одна ссылка ведёт на отдельную страницу (не PDF)
for item in found:
if not item["url"].lower().endswith(('.pdf', '.doc', '.docx')):
return self._pass_result(
message=f"Найден документ согласия: {item['text'][:80]}",
details={"found_links": found},
)
return self._pass_result(
message=f"Найден документ согласия (PDF/DOC): {found[0]['text'][:80]}",
details={"found_links": found, "format": "document"},
)
except Exception as e:
return self._error_result(f"Ошибка проверки согласия: {str(e)}")
+187
View File
@@ -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
+61 -72
View File
@@ -1,110 +1,99 @@
"""
Проверка внешних скриптов, собирающих данные пользователей.
Google Analytics, Facebook Pixel, Яндекс.Метрика и другие —
отправляют данные на сервера за пределами РФ.
Загружает словари трекеров из dictionaries/trackers.yaml.
Разделяет на категории: CRITICAL (запрещены), WARNING (рискованны), RUSSIAN (допустимы).
"""
from __future__ import annotations
import re
from pathlib import Path
import yaml
from playwright.async_api import Page
from scanner.checks.base import BaseCheck
from scanner.models import CheckResult, RiskLevel
# Путь к словарю трекеров
TRACKERS_YAML = Path(__file__).parent.parent.parent / "dictionaries" / "trackers.yaml"
class ExternalScriptsCheck(BaseCheck):
"""Проверяет наличие внешних скриптов, передающих данные за пределы РФ."""
name = "external_scripts"
title = "Внешние скрипты аналитики"
description = "Ищет скрипты Google Analytics, Facebook Pixel и другие иностранные сервисы"
description = "Ищет скрипты Google Analytics, Meta Pixel и другие иностранные сервисы"
risk_on_fail = RiskLevel.HIGH
default_weight = 15
EXTERNAL_PATTERNS = [
r"google-analytics\.com",
r"googletagmanager\.com",
r"googlesyndication\.com",
r"googleadservices\.com",
r"googleapis\.com",
r"google\.com/recaptcha",
r"facebook\.com/tr",
r"fbcdn\.net",
r"connect\.facebook\.net",
r"mc\.yandex\.ru",
r"monitoring\.yandex\.ru",
r"top\.mail\.ru",
r"clarity\.microsoft\.com",
r"hotjar\.com",
r"mixpanel\.com",
r"segment\.com",
r"amplitude\.com",
r"zendesk\.com",
r"intercom\.io",
r"chatra\.com",
r"tilda\.cdn",
r"wix\.com",
r"wordpress\.com",
r"shopify\.com",
]
def _load_trackers(self) -> dict:
"""Загрузить словарь трекеров из YAML."""
if not TRACKERS_YAML.exists():
# Fallback: базовый набор
return {
"critical": [{"domain": "google-analytics.com"}, {"domain": "facebook.com/tr"}],
"warning": [{"domain": "fonts.googleapis.com"}, {"domain": "google.com/recaptcha"}],
"russian": [{"domain": "mc.yandex.ru"}, {"domain": "vk.com/rtrg"}],
}
with open(TRACKERS_YAML, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
async def check(self, page: Page, url: str) -> CheckResult:
try:
html = await page.evaluate("() => document.documentElement.outerHTML")
trackers = self._load_trackers()
found_scripts = []
for pattern in self.EXTERNAL_PATTERNS:
matches = re.findall(pattern, html, re.IGNORECASE)
if matches:
found_scripts.append({
"pattern": pattern,
"matches": len(matches),
})
found = {"critical": [], "warning": [], "russian": []}
if not found_scripts:
for category in ("critical", "warning", "russian"):
for entry in trackers.get(category, []):
domain = entry["domain"]
if re.search(domain, html, re.IGNORECASE):
found[category].append(entry)
if not any(found.values()):
return self._pass_result(
message="Внешние скрипты аналитики не найдены.",
)
critical_services = [
"google-analytics",
"googletagmanager",
"facebook",
"clarity",
"segment",
"amplitude",
]
# Формируем сообщение
parts = []
if found["critical"]:
domains = [e["domain"] for e in found["critical"]]
parts.append(f"ЗАПРЕЩЁННЫЕ ({len(found['critical'])}): {', '.join(domains)}")
if found["warning"]:
domains = [e["domain"] for e in found["warning"]]
parts.append(f"РИСКОВАННЫЕ ({len(found['warning'])}): {', '.join(domains)}")
if found["russian"]:
domains = [e["domain"] for e in found["russian"]]
parts.append(f"Российские ({len(found['russian'])}): {', '.join(domains)}")
has_critical = False
for script in found_scripts:
if any(re.search(c, script["pattern"]) for c in critical_services):
has_critical = True
break
message = "Найдены внешние скрипты: " + " | ".join(parts)
if has_critical:
return self._fail_result(
message=(
f"Найдены внешние скрипты передачи данных: "
f"{', '.join(s['pattern'] for s in found_scripts)}. "
f"Они передают персональные данные за пределы РФ."
),
details={
"scripts_found": found_scripts,
"has_critical": True,
},
# Только российские → PASS с уведомлением
if not found["critical"] and not found["warning"]:
return self._pass_result(
message=f"Найдены только российские сервисы: {message}",
details={"found": found},
)
# Есть критические → FAIL
if found["critical"]:
actions = [f"{e['domain']}: {e.get('action', 'Удалить')}" for e in found["critical"]]
message += f"\nРекомендации: {'; '.join(actions)}"
return self._fail_result(
message=message,
risk_level=RiskLevel.CRITICAL,
details={"found": found},
)
# Только warning → FAIL с HIGH
return self._fail_result(
message=(
f"Найдены внешние скрипты: "
f"{', '.join(s['pattern'] for s in found_scripts)}. "
f"Требуется согласие пользователя."
),
details={
"scripts_found": found_scripts,
"has_critical": False,
},
message=message,
risk_level=RiskLevel.HIGH,
details={"found": found},
)
except Exception as e:
return self._error_result(message=f"Ошибка: {str(e)}")
return self._error_result(message=f"Ошибка при проверке внешних скриптов: {str(e)}")
+79 -51
View File
@@ -18,7 +18,7 @@ class FormCheckboxCheck(BaseCheck):
name = "form_checkbox"
title = "Чекбокс согласия в формах"
description = "Проверяет, есть ли чекбокс согласия на обработку ПД в формах"
description = "Проверяет, есть ли чекбокс согласия на обработку ПД в формах, не предзаполнен ли он"
risk_on_fail = RiskLevel.CRITICAL
default_weight = 20
@@ -31,85 +31,113 @@ class FormCheckboxCheck(BaseCheck):
r"я\s*принимаю\s*условия",
r"processing\s*of\s*personal\s*data",
r"обработк[аею]\s*персональных?\s*данных?",
r"политик[ууе]?\s*конфиденциальност[иы]",
r"политик[уе]?\s*конфиденциальност[иы]",
]
async def check(self, page: Page, url: str) -> CheckResult:
try:
# Получаем все формы на странице
forms = await page.query_selector_all("form")
if not forms:
return self._skip_result(
message="Формы на странице не найдены.",
)
return self._skip_result(message="Формы на странице не найдены.")
forms_needing_consent = 0
total_forms = 0
forms_with_consent = 0
dark_patterns = [] # Предзаполненные чекбоксы
forms_without_consent = []
for form_idx, form in enumerate(forms):
# Проверяем, есть ли чекбокс в форме
for form in forms:
total_forms += 1
checkboxes = await form.query_selector_all('input[type="checkbox"]')
if not checkboxes:
forms_without_consent.append({"reason": "нет чекбоксов"})
continue
has_consent = False
checkbox_texts = []
form_has_consent = False
form_has_dark_pattern = False
for cb in checkboxes:
cb_text = await cb.inner_text() if not await cb.get_attribute("value") else await cb.get_attribute("value")
cb_label = await form.evaluate(
f'(el) => {{\n'
f' const cb = el.closest("label") || el.parentElement;\n'
f' return cb ? cb.innerText : "";\n'
f'}}', form
)
# Правильная детекция текста чекбокса:
# 1. Ищем связанный <label for="checkbox_id">
# 2. Если нет — родительский <label>
# 3. Если нет — соседние текстовые узлы
checkbox_id = await cb.get_attribute("id")
label_text = ""
checkbox_texts.append(cb_text or cb_label or "без текста")
if checkbox_id:
# Метод 1: label[for="id"]
label_el = await page.query_selector(f'label[for="{checkbox_id}"]')
if label_el:
label_text = (await label_el.inner_text()).strip()
if not label_text:
# Метод 2: родительский label (чекбокс внутри label)
parent_label = await cb.evaluate("""
(el) => {
let parent = el.parentElement;
while (parent) {
if (parent.tagName === 'LABEL') return parent.innerText;
parent = parent.parentElement;
}
return '';
}
""")
label_text = parent_label.strip() if parent_label else ""
combined = f"{cb_text} {cb_label}".lower()
if not label_text:
# Метод 3: aria-label / aria-labelledby
aria_labelledby = await cb.get_attribute("aria-labelledby")
if aria_labelledby:
ref_el = await page.query_selector(f'#{aria_labelledby}')
if ref_el:
label_text = (await ref_el.inner_text()).strip()
# Проверяем dark pattern: предзаполненный чекбокс
is_checked = await cb.is_checked()
if is_checked:
form_has_dark_pattern = True
# Проверяем текст на паттерны согласия
combined = label_text.lower()
if any(re.search(p, combined, re.UNICODE | re.IGNORECASE) for p in self.CONSENT_PATTERNS):
has_consent = True
form_has_consent = True
forms_needing_consent += 1
if has_consent:
if form_has_consent:
forms_with_consent += 1
if form_has_dark_pattern:
dark_patterns.append({"form_index": total_forms, "issue": "предзаполненный чекбокс согласия"})
else:
forms_without_consent.append({
"form_index": form_idx,
"checkboxes": checkbox_texts[:3],
})
forms_without_consent.append({"reason": "нет текста согласия"})
if forms_with_consent == forms_needing_consent:
# Формируем результат
if total_forms == forms_with_consent and not dark_patterns:
return self._pass_result(
message=f"Все формы ({forms_with_consent}) содержат чекбокс согласия.",
details={
"forms_count": forms_with_consent,
"checked_forms": forms_with_consent,
},
message=f"Все формы ({forms_with_consent}) содержат чекбокс согласия, dark patterns не найдены.",
details={"forms_count": forms_with_consent, "dark_patterns": 0},
)
# Собираем сообщение
issues = []
if forms_with_consent < total_forms:
missing = total_forms - forms_with_consent
issues.append(f"{missing} форм без чекбокса согласия")
if dark_patterns:
issues.append(f"{len(dark_patterns)} предзаполненных чекбоксов (dark pattern)")
msg = f"Нарушения в формах: {', '.join(issues)}. "
if forms_with_consent == 0:
return self._fail_result(
message=(
f"Формы ({forms_needing_consent}) не содержат чекбокс согласия на обработку ПД. "
f"Это прямое нарушение 152-ФЗ."
),
details={
"total_forms": forms_needing_consent,
"without_consent": forms_without_consent,
"sample_texts": [f["checkboxes"][0] if f["checkboxes"] else "без чекбоксов" for f in forms_without_consent[:3]],
},
)
msg += "Это прямое нарушение 152-ФЗ."
# При dark patterns — повышаем риск
risk = RiskLevel.CRITICAL if dark_patterns else self.risk_on_fail
# Частичное покрытие
return self._fail_result(
message=(
f"Только {forms_with_consent} из {forms_needing_consent} форм содержат чекбокс согласия."
),
message=msg,
risk_level=risk,
details={
"total_forms": forms_needing_consent,
"total_forms": total_forms,
"with_consent": forms_with_consent,
"without_consent_count": forms_needing_consent - forms_with_consent,
"without_consent": forms_without_consent,
"dark_patterns": dark_patterns,
},
)
+233
View File
@@ -0,0 +1,233 @@
"""
Опциональный LLM-анализ политики конфиденциальности.
Запускается ПОСЛЕ основных проверок, только если явно запрошен.
Проверяет 16 обязательных разделов политики по 152-ФЗ.
"""
from __future__ import annotations
import re
import json
from pathlib import Path
from typing import Any
import aiohttp
from bs4 import BeautifulSoup
from scanner.checks.base import BaseCheck
from scanner.models import CheckResult, CheckStatus, RiskLevel
# 16 обязательных разделов политики конфиденциальности (Reova, 2026)
REQUIRED_SECTIONS = [
{"key": "general", "name": "Общие положения", "article": "Ст. 3"},
{"key": "operator", "name": "Наименование оператора", "article": "Ст. 18.1"},
{"key": "purposes", "name": "Цели обработки", "article": "Ст. 5 ч.2"},
{"key": "categories", "name": "Категории ПД", "article": "Ст. 18.1"},
{"key": "subjects", "name": "Категории субъектов", "article": "Ст. 18.1"},
{"key": "basis", "name": "Правовые основания", "article": "Ст. 6"},
{"key": "methods", "name": "Способы обработки", "article": "Ст. 3"},
{"key": "storage", "name": "Сроки хранения", "article": "Ст. 5 ч.7"},
{"key": "destruction","name": "Порядок уничтожения", "article": "Ст. 21"},
{"key": "rights", "name": "Права субъекта", "article": "Ст. 14, 15"},
{"key": "security", "name": "Меры безопасности", "article": "Ст. 19"},
{"key": "transfer", "name": "Трансграничная передача", "article": "Ст. 12"},
{"key": "thirdparty", "name": "Передача третьим лицам", "article": "Ст. 6 ч.3"},
{"key": "cookies", "name": "Cookies", "article": "Разъяснения РКН"},
{"key": "dpo", "name": "Ответственный за ПД (DPO)", "article": "Ст. 22.1"},
{"key": "disputes", "name": "Порядок разрешения споров", "article": "Ст. 17"},
]
class LLMPolicyCheck(BaseCheck):
"""Глубокий LLM-анализ текста политики конфиденциальности."""
name = "llm_policy_analysis"
title = "LLM-анализ политики конфиденциальности"
description = "Проверяет 16 обязательных разделов политики через LLM"
risk_on_fail = RiskLevel.HIGH
default_weight = 20
def __init__(self, llm_url: str = "http://192.168.1.152:80/v1/chat/completions",
llm_model: str = "Qwen3.6-35B-A3B-UD-IQ4_NL.gguf"):
self.llm_url = llm_url
self.llm_model = llm_model
async def check(self, page, url: str) -> CheckResult:
"""Этот метод вызывается через API, не через Playwright."""
return self._skip_result(message="LLM-анализ запускается отдельно через analyze_policy()")
async def analyze_policy(self, policy_url: str) -> CheckResult:
"""
Загрузить страницу политики и проанализировать через LLM.
Args:
policy_url: URL страницы политики конфиденциальности
Returns:
CheckResult с детальным анализом 16 разделов
"""
try:
# 1. Скачиваем текст политики
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(
policy_url,
headers={"User-Agent": "Mozilla/5.0"},
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
return self._error_result(f"Не удалось загрузить политику: HTTP {resp.status}")
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()
if len(policy_text) < 100:
return self._fail_result(
message="Текст политики слишком короткий для анализа (<100 символов).",
details={"char_count": len(policy_text)},
)
# 2. Отправляем на LLM-анализ
sections_list = "\n".join(
f"- {s['key']}: {s['name']} ({s['article']})"
for s in REQUIRED_SECTIONS
)
system_prompt = (
"Ты юридический аудитор. Проанализируй текст политики конфиденциальности "
"на соответствие 152-ФЗ. Проверь наличие каждого из 16 обязательных разделов. "
"Отвечай СТРОГО в формате JSON без markdown-обёрток."
)
user_prompt = f"""Проверь, какие из этих разделов присутствуют в тексте политики:
{sections_list}
Для каждого раздела укажи:
- present: true/false (присутствует ли)
- evidence: короткая цитата из текста (до 100 символов), если present=true
- quality: "full"/"partial"/"missing"
Также дай:
- overall_score: общая оценка от 0 до 100
- summary: краткое резюме на русском (2-3 предложения)
Текст политики (первые 8000 символов):
{policy_text[:8000]}
Ответь ТОЛЬКО JSON, без markdown:"""
analysis = await self._call_llm(system_prompt, user_prompt)
if not analysis:
return self._error_result("LLM не вернул результат анализа.")
# 3. Формируем результат
present_count = sum(1 for s in analysis.get("sections", []) if s.get("present"))
missing_sections = [s["name"] for s in analysis.get("sections", []) if not s.get("present")]
partial_sections = [s["name"] for s in analysis.get("sections", []) if s.get("quality") == "partial"]
overall = analysis.get("overall_score", 0)
summary = analysis.get("summary", "Анализ завершён.")
if present_count >= 14 and not missing_sections:
return self._pass_result(
message=f"Политика содержит {present_count}/16 обязательных разделов. {summary}",
details={
"present_count": present_count,
"total_sections": 16,
"overall_score": overall,
"missing": missing_sections,
"partial": partial_sections,
"llm_summary": summary,
},
)
if present_count >= 10:
return self._fail_result(
message=f"Политика неполная: {present_count}/16 разделов. Отсутствуют: {', '.join(missing_sections[:5])}. {summary}",
risk_level=RiskLevel.HIGH,
details={
"present_count": present_count,
"total_sections": 16,
"overall_score": overall,
"missing": missing_sections,
"partial": partial_sections,
"llm_summary": summary,
},
)
return self._fail_result(
message=f"Политика критически неполная: {present_count}/16 разделов. Отсутствуют: {', '.join(missing_sections[:8])}. {summary}",
risk_level=RiskLevel.CRITICAL,
details={
"present_count": present_count,
"total_sections": 16,
"overall_score": overall,
"missing": missing_sections,
"partial": partial_sections,
"llm_summary": summary,
},
)
except Exception as e:
return self._error_result(f"Ошибка LLM-анализа: {str(e)}")
async def _call_llm(self, system_prompt: str, user_prompt: str) -> dict | None:
"""Вызвать LLM для анализа текста политики."""
try:
import aiohttp
payload = {
"model": self.llm_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.1,
"max_tokens": 2048,
}
async with aiohttp.ClientSession() as session:
async with session.post(
self.llm_url,
json=payload,
timeout=aiohttp.ClientTimeout(total=60),
) as resp:
if resp.status != 200:
return None
data = await resp.json()
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
# Извлекаем JSON из ответа (LLM может обернуть в ```json)
content = content.strip()
if content.startswith("```"):
content = re.sub(r"^```(?:json)?\s*", "", content)
content = re.sub(r"\s*```$", "", content)
return json.loads(content)
except Exception:
return None
async def run_llm_analysis(policy_url: str, llm_url: str | None = None) -> dict[str, Any]:
"""
Удобная функция для запуска LLM-анализа из CLI/API.
Returns:
Словарь с результатом check и деталями
"""
checker = LLMPolicyCheck()
if llm_url:
checker.llm_url = llm_url
result = await checker.analyze_policy(policy_url)
return {
"status": result.status.value,
"title": result.title,
"message": result.message,
"risk_level": result.risk_level.value,
"details": result.details,
}
+135
View File
@@ -0,0 +1,135 @@
"""
Проверка контактных данных оператора: ИНН, ОГРН, 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)}")
+23 -11
View File
@@ -60,15 +60,15 @@ class PrivacyLinkCheck(BaseCheck):
details={"total_links": len(link_texts), "page_text_sample": link_texts[:3]},
)
# Проверяем, что ссылка ведет на рабочую страницу
# Проверяем доступность страницы политики через aiohttp (не page.goto — гонка данных!)
policy_url = found_links[0]["url"]
try:
# Проверяем, не является ли URL документом (PDF, DOCX, XLSX)
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={
@@ -77,18 +77,30 @@ class PrivacyLinkCheck(BaseCheck):
"is_document": True,
},
)
# Для HTML-страниц — проверяем доступность
await page.goto(policy_url, timeout=5000)
policy_text = await page.evaluate("() => document.body.innerText")
await page.goto(url, timeout=5000) # Возвращаемся на главную
# Для 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={
@@ -97,7 +109,7 @@ class PrivacyLinkCheck(BaseCheck):
"char_count": len(policy_text),
},
)
except Exception as e:
return self._fail_result(
message=f"Страница политики не доступна: {str(e)}",
+21 -26
View File
@@ -79,39 +79,34 @@ class ServerLocationCheck(BaseCheck):
return self._error_result(message=f"Ошибка при проверке геолокации: {str(e)}")
async def _get_country(self, ip: str) -> str | None:
"""Получает страну по IP через публичный API."""
"""Получает страну по IP через публичный API (асинхронно)."""
try:
import urllib.request
import json
import aiohttp
# Пробуем несколько бесплатных API
apis = [
f"https://ipapi.co/{ip}/json/",
f"https://ip-api.com/json/{ip}?fields=country",
f"https://api.ipgeolocation.io/ipgeo?ip={ip}",
]
for api_url in apis:
try:
req = urllib.request.Request(
api_url,
headers={"User-Agent": "Mozilla/5.0"}
)
with urllib.request.urlopen(req, timeout=5) as response:
data = json.loads(response.read().decode())
# ipapi.co формат
if "country_name" in data:
return data["country_name"]
# ip-api.com формат
if "country" in data:
return data["country"]
# ipgeolocation формат
if "country_name" in data:
return data["country_name"]
except Exception:
continue
async with aiohttp.ClientSession() as session:
for api_url in apis:
try:
async with session.get(
api_url,
headers={"User-Agent": "Mozilla/5.0"},
timeout=aiohttp.ClientTimeout(total=5),
) as response:
if response.status != 200:
continue
data = await response.json()
# ipapi.co формат
if "country_name" in data:
return data["country_name"]
# ip-api.com формат
if "country" in data:
return data["country"]
except Exception:
continue
return None