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
+1710 -271

No files matched your search

+11 -8
View File
@@ -1,15 +1,17 @@
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*.pyo
*.egg-info/ *.egg-info/
dist/ dist/
build/ build/
.eggs/ .eggs/
*.egg *.egg
# Virtual env # Virtual environment
.venv/ .venv/
venv/ venv/
env/
# IDE # IDE
.vscode/ .vscode/
@@ -17,14 +19,15 @@ venv/
*.swp *.swp
*.swo *.swo
# Data # Project output
data/output/results.json data/output/
data/output/reports/audit_*.pdf logs/
# Env
.env
.env.local
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Secrets
.env
*.pem
*.key
+346
View File
@@ -0,0 +1,346 @@
# 152-ФЗ Scanner: Стратегия эффективного compliance-сервиса
> Исследование проведено 10.06.2026. Проанализированы: 13 российских конкурентов,
> 4 международных open-source сканера, 5 чек-листов 152-ФЗ, технические best practices.
---
## 1. Рыночный контекст: почему сейчас
### 1.1. Ужесточение законодательства
| Дата | Изменение |
|------|-----------|
| 30.05.2025 | Штрафы ×10-20. Оборотные штрафы до 3% выручки, до 500 млн ₽ |
| 01.07.2025 | Запрет сбора ПД на зарубежных серверах — GA, reCAPTCHA, Google Fonts |
| 01.09.2025 | Согласие — отдельный документ. Нельзя включать в оферту |
| 11.12.2024 | Ст. 272.1 УК РФ — уголовная ответственность до 10 лет |
| 2026 | РКН: проактивный ИИ-мониторинг 24/7, 84% нарушений — автофиксация |
**Ключевое:** РКН перешёл от реактивной модели (жалобы) к проактивной (ИИ-мониторинг). 1000+ сайтов ежедневно. 83% проверенных сайтов имеют нарушения [1].
### 1.2. Размер рынка
- Каждый сайт с формой/аналитикой/cookies — оператор ПД
- Это 99% коммерческих сайтов в РФ
- Только отелей в Уфе: 50+ (протестировано в рамках 152-fz-scanner)
- Всего юрлиц в РФ: ~3 млн. Сайтов у них: ~500K+
---
## 2. Конкурентный анализ
### 2.1. Российские сканеры
| Сервис | Проверок | Цена | Фишки | Слабые места |
|--------|----------|------|-------|-------------|
| **Toolfox.ru** | 134 (11 законов) | Бесплатно | AI (Claude), PDF, баллы, ФЗ-53 | Закрытый, нет API |
| **scanetika.ru** | 38+ | Подписка | Мониторинг, 200K+ сайтов, B2B | Нет self-hosted |
| **EdLegal** | 21 | freemium | AI-анализ политики, расчёт штрафов | Мало проверок |
| **152-audit.ru** | ~15 | Бесплатно | Быстро, 3 мин | Базовый |
| **help152.ru** | 30+ законов | Бесплатно | Широкий охват | Слабый UI |
| **audit-pd.ru** | 15+ | Бесплатно | 30 сек | Базовый |
| **proverito.ru** | 16 | Бесплатно | Нейросеть, PDF | Мало проверок |
| **fzscan.ru** | ~10 | Бесплатно | 152 + 168 | Слабый |
| **reactortech.ru** | ~15 | Платно | Скриншоты, конкретика | Дорого |
| **nima-labs.ru** | ~12 | Платно | Генератор документов | Не сканер |
### 2.2. Международные open-source (GDPR)
| Проект | Правил | Технологии | Что можно заимствовать |
|--------|--------|-----------|----------------------|
| **ComplyTest** [2] | 70 (GDPR+WCAG+Security+DSA) | Node.js, Playwright, npx | CI/CD, SARIF, multi-format |
| **gdpr-cookie-scanner** [3] | 4D scoring | CLI, Playwright, Docker | 4-фазный скан, dark patterns |
| **GDPR Scanner** | ~10 | Python/Flask | Dashboard, scheduled scans |
| **EDPB Tool** | ~20 | EU official | Методология аудита |
| **LawCheck (РФ)** [4] | 152-ФЗ | FastAPI+Playwright+PostgreSQL+Redis+RQ | Архитектура, очереди |
### 2.3. Ниши без конкуренции
1. **API-first сканер** — все конкуренты form-based. Никто не даёт REST API для интеграции
2. **Self-hosted / on-premise** — LawCheck open-source, но без мониторинга и PDF
3. **White-label для агентств** — scanetika имеет B2B, но без white-label
4. **Пакетные проверки для хостингов/регистраторов** — не покрыто
5. **CI/CD интеграция** — для devops-команд (как ComplyTest, но для 152-ФЗ)
---
## 3. Полный список проверок для эффективного сканера
На основе синтеза чек-листов Reova (37 пунктов) [1], Proverito (16), EdLegal (21), Toolfox (134 включая смежные законы), LawCheck и международных best practices.
### 3.1. Категория A: Политика конфиденциальности (8 проверок)
| # | Проверка | Метод | Риск |
|---|----------|-------|------|
| A1 | Наличие страницы политики | Playwright: поиск ссылки → проверка 200 OK | HIGH |
| A2 | Доступность без авторизации | Проверка HTTP-статуса (не 401/403) | CRITICAL |
| A3 | Отдельная HTML-страница (не PDF) | Content-Type != application/pdf | HIGH |
| A4 | Ссылка в футере каждой страницы | DOM-анализ footer > a[href] | HIGH |
| A5 | Ссылка рядом с каждой формой | Поиск <form> → соседний <a> с политикой | HIGH |
| A6 | 16 обязательных разделов (LLM) | LLM-анализ текста политики: цели, категории, сроки, права, DPO, etc. | MEDIUM |
| A7 | Дата последнего обновления | Regex: дата в формате DD.MM.YYYY | MEDIUM |
| A8 | Реквизиты оператора (ИНН, адрес) | Regex ИНН (10/12 цифр) + сверка с ЕГРЮЛ | CRITICAL |
### 3.2. Категория B: Cookie-баннер и согласие (5 проверок)
| # | Проверка | Метод | Риск |
|---|----------|-------|------|
| B1 | Наличие баннера | CSS-селекторы + текстовые паттерны | HIGH |
| B2 | Баннер до установки tracking-cookies | 4-фазный метод (gdpr-cookie-scanner): загрузить без действий → посмотреть cookies | CRITICAL |
| B3 | Кнопка «Отказаться» (не только «Принять») | Dark-pattern detection: асимметрия кнопок | CRITICAL |
| B4 | Отказ отключает tracking-cookies | Фаза 3: нажать «Отказ» → проверить cookies | CRITICAL |
| B5 | Ссылка на политику в баннере | Поиск <a> внутри cookie-баннера | MEDIUM |
### 3.3. Категория C: Формы и чекбоксы (5 проверок)
| # | Проверка | Метод | Риск |
|---|----------|-------|------|
| C1 | Чекбокс в каждой форме | Поиск <form> → input[type=checkbox] | CRITICAL |
| C2 | Чекбокс НЕ предзаполнен | checked=false (dark pattern detection) | CRITICAL |
| C3 | Текст согласия содержит обязательные элементы | Regex: «согласие», «обработка», «персональные данные» + ссылка на политику | CRITICAL |
| C4 | Форма передаётся по HTTPS | Проверка form[action] → HTTPS | HIGH |
| C5 | Минимизация полей (только необходимые) | Подсчёт полей → оценка избыточности | MEDIUM |
### 3.4. Категория D: Сервер и инфраструктура (4 проверки)
| # | Проверка | Метод | Риск |
|---|----------|-------|------|
| D1 | Сервер в РФ | GeoIP: ip-api.com + ipapi.co (fallback) | CRITICAL |
| D2 | HTTPS / валидный SSL | Проверка сертификата (expiry, issuer) | CRITICAL |
| D3 | HSTS-заголовок | HTTP-ответ: Strict-Transport-Security | HIGH |
| D4 | CSP / X-Frame-Options | HTTP-заголовки безопасности | MEDIUM |
### 3.5. Категория E: Внешние скрипты и трекеры (7 проверок)
| # | Проверка | Метод | Риск |
|---|----------|-------|------|
| E1 | Google Analytics (запрещён) | Regex HTML: google-analytics.com | CRITICAL |
| E2 | Meta Pixel (запрещён) | Regex HTML: facebook.com/tr, connect.facebook.net | CRITICAL |
| E3 | Google reCAPTCHA (запрещена) | Regex: google.com/recaptcha | HIGH |
| E4 | Google Fonts CDN (IP утечка) | Regex: fonts.googleapis.com | HIGH |
| E5 | Яндекс.Метрика (допустима) | Regex: mc.yandex.ru | INFO |
| E6 | VK Pixel (допустим) | Regex: vk.com/rtrg | INFO |
| E7 | Прочие зарубежные трекеры | Словарь: Hotjar, Mixpanel, Amplitude, Segment, Clarity, etc. | HIGH |
### 3.6. Категория F: Права субъекта (3 проверки)
| # | Проверка | Метод | Риск |
|---|----------|-------|------|
| F1 | Механизм отзыва согласия | LLM: поиск в политике «отозвать согласие», «отказаться» | HIGH |
| F2 | Порядок удаления данных | LLM: поиск «удалить», «уничтожить», email/форма для запроса | HIGH |
| F3 | Контакты DPO (Data Protection Officer) | Regex: email/телефон + контекст «ответственный за ПД» | MEDIUM |
### 3.7. Категория G: Смежные законы (4 проверки)
| # | Проверка | Метод | Риск |
|---|----------|-------|------|
| G1 | Англицизмы в CTA (ФЗ-53) | Словарь «красных» слов: sale, click here, buy now, etc. | MEDIUM |
| G2 | Возрастная маркировка (436-ФЗ) | Поиск меток 0+/6+/12+/16+/18+ | MEDIUM |
| G3 | Реквизиты юрлица (ИНН/ОГРН) | Regex + сверка с ЕГРЮЛ | MEDIUM |
| G4 | Маркировка рекламы erid (ОРД) | Поиск erid в HTML | LOW |
**Итого: 36 проверок в 7 категориях** — покрытие основных требований 152-ФЗ + смежных законов.
---
## 4. Архитектура эффективного сканера
### 4.1. Ключевой принцип: 4 фазы как у gdpr-cookie-scanner [3]
```
Фаза 1: LOAD — загрузка без взаимодействия
→ Сохранить ВСЕ cookies и network requests ДО consent
→ Это baseline: что сайт ставит без согласия
Фаза 2: DETECT — поиск consent-механизма
→ CMP-селекторы → DOM-эвристики
→ Извлечь кнопки с visual properties (размер, цвет)
→ Dark-pattern detection: асимметрия, скрытая кнопка отказа
Фаза 3: REJECT — отказ и capture
→ Нажать «Отказаться»
→ Сравнить cookies с baseline
→ Что осталось после отказа? → нарушение
Фаза 4: ACCEPT — чистый браузер, принять
→ Новый контекст (нет cookies)
→ Принять согласие
→ Дельта cookies = что consent реально меняет
```
Это **принципиально сильнее** текущего подхода (просто поиск элемента на странице).
### 4.2. Архитектура правил (Rule Engine)
По образцу LawCheck [4] — YAML-словари вместо хардкода:
```yaml
# dictionaries/trackers.yaml
foreign_critical:
- domain: google-analytics.com
law: "152-ФЗ ст. 18 ч. 5"
fine: "до 6 млн ₽"
action: "Удалить, заменить на Yandex Metrica"
- domain: facebook.com/tr
law: "152-ФЗ ст. 12"
fine: "до 6 млн ₽"
action: "Удалить, заменить на VK Pixel"
foreign_warning:
- domain: fonts.googleapis.com
law: "152-ФЗ (IP-утечка)"
fine: "до 300 тыс ₽"
action: "Скачать и раздавать self-hosted"
russian_allowed:
- domain: mc.yandex.ru
- domain: vk.com/rtrg
```
**Преимущества:**
- Обновление правил без перекомпиляции
- Не-разработчики могут добавлять трекеры
- Аудит изменений через Git
### 4.3. Модульная архитектура
```
152-fz-scanner/
├── main.py # CLI (scan, csv, server)
├── scanner/
│ ├── engine.py # Оркестратор: 4-фазный скан + asyncio.gather
│ ├── models.py # Pydantic: CheckResult, SiteScan, Score
│ ├── rules_engine.py # YAML-движок: загрузка словарей, выполнение правил
│ └── checks/
│ ├── base.py # Abstract BaseCheck
│ ├── consent/ # B-проверки (cookie + consent)
│ │ ├── banner.py
│ │ └── dark_patterns.py
│ ├── forms/ # C-проверки
│ │ ├── checkbox.py
│ │ └── fields.py
│ ├── infrastructure/ # D-проверки
│ │ ├── server.py
│ │ └── headers.py
│ ├── policy/ # A-проверки
│ │ ├── presence.py
│ │ └── llm_analysis.py
│ ├── trackers/ # E-проверки
│ │ └── scripts.py
│ └── rights/ # F-проверки
│ └── subject.py
├── dictionaries/ # YAML-словари (правила без кода)
│ ├── trackers.yaml
│ ├── cookie_patterns.yaml
│ ├── consent_texts.yaml
│ └── anglicisms.yaml # Для ФЗ-53
├── api/
│ ├── server.py # FastAPI
│ ├── routes.py
│ └── schemas.py
├── reports/
│ ├── generator.py # PDF + HTML + JSON
│ └── templates/
│ ├── report.html.j2 # Для сайта
│ └── batch_report.html.j2 # Сводка для агентства
├── workers/ # Очередь для batch
│ └── queue.py # RQ/ARQ воркеры
├── tests/
│ ├── test_checks/
│ └── fixtures/
└── data/
├── input/ # CSV с URL
└── output/ # JSON + PDF
```
### 4.4. Технологический стек
| Компонент | Технология | Почему |
|-----------|-----------|--------|
| **Браузерная автоматизация** | Playwright (Python) | Стандарт индустрии: ComplyTest, LawCheck, gdpr-cookie-scanner |
| **API** | FastAPI + Pydantic v2 | Быстрый, async, авто-документация |
| **Очередь (batch)** | ARQ (async) или RQ | LawCheck использует RQ; ARQ — более современный async |
| **PDF** | WeasyPrint + Jinja2 | Уже работает; альтернатива — Playwright PDF |
| **LLM-анализ** | Qwen35B / Gemma4 (локально) | Анализ политики: 16 разделов, права субъекта |
| **GeoIP** | ip-api.com + ipapi.co (fallback) | Бесплатные API |
| **Хранение** | SQLite (MVP) → PostgreSQL (prod) | LawCheck: SQLite в dev, PG в prod |
| **CI/CD** | GitHub Actions + SARIF | Формат от ComplyTest — стандарт для security tools |
---
## 5. Ключевые дифференциаторы
### 5.1. Что делает сервис уникальным
1. **4-фазный cookie-аудит** — никто в РФ не делает. Все проверяют только «наличие баннера»
2. **Dark-pattern detection** — асимметрия кнопок, скрытый отказ, предзаполненные чекбоксы
3. **LLM-анализ политики** — 16 разделов, а не просто «есть/нет»
4. **API-first** — REST API для интеграции (агентства, хостинги, CRM)
5. **Batch + white-label PDF** — пакетная проверка 100+ сайтов с брендированными отчётами
6. **CI/CD интеграция** — GitHub Actions, GitLab CI, выход с exit code 1 при CRITICAL
7. **Self-hosted** — можно развернуть на своём сервере (конфиденциальность данных клиента)
### 5.2. Модели монетизации
| Модель | Цена | ЦА |
|--------|------|-----|
| **Pay-per-scan** | 500-1500 ₽/сайт | Разовые проверки |
| **Подписка (мониторинг)** | 3000-15000 ₽/мес | Агентства, крупные сайты |
| **White-label** | 30 000-100 000 ₽/мес | Агентства (ресейл) |
| **On-premise лицензия** | 150 000-500 000 ₽/год | Корпорации, банки |
| **API (pay-per-request)** | 50-200 ₽/скан | Интеграции |
---
## 6. Дорожная карта
### Фаза 1: Исправить критические баги (сейчас)
- [x] Аудит проведён
- [ ] `engine.py` — `_calculate_score()` при ошибке загрузки
- [ ] `privacy_link.py` — убрать `page.goto()`, проверять через aiohttp
- [ ] `form_checkbox.py` — переписать детекцию label
- [ ] `server_location.py` — заменить urllib на aiohttp
### Фаза 2: Довести до production-quality (1-2 недели)
- [ ] 4-фазный cookie-аудит (замена текущего cookie_banner.py)
- [ ] YAML-словари для трекеров
- [ ] Dark-pattern detection
- [ ] LLM-анализ политики (16 разделов)
- [ ] API с rate limiting и авторизацией
- [ ] CI/CD mode (exit codes, SARIF)
### Фаза 3: B2B-фичи (2-4 недели)
- [ ] Batch-мониторинг (очередь + уведомления)
- [ ] White-label PDF
- [ ] Подписки и биллинг
- [ ] Личный кабинет
### Фаза 4: Экспансия (1-2 месяца)
- [ ] On-premise поставка (Docker)
- [ ] Интеграции: GitHub App, GitLab, Bitrix24
- [ ] API-ключи для партнёров
---
## 7. Почему это будет работать
1. **Рынок растёт** — РКН перешёл на проактивный мониторинг. Каждый сайт под угрозой
2. **Конкуренты слабые** — form-based, без API, без deep checks. Никто не делает 4-фазный cookie-аудит
3. **Штрафы пугающие** — до 500 млн ₽. Стоимость проверки 500-1500 ₽ — копейки по сравнению
4. **Техническое преимущество** — Playwright 4-фазный + LLM + YAML-правила = лучшая архитектура
5. **Модель с низким порогом входа** — pay-per-scan без подписки
---
## Источники
[1] Reova — «Персональные данные на сайте: чек-лист из 37 пунктов (2026)» — https://www.reova.ru/blog/personalnye-dannye-na-sajte.html
[2] ComplyTest — Open-source GDPR/WCAG/Security scanner (70 правил) — https://complytest.com/
[3] gdpr-cookie-scanner — Open-source CLI, 4-фазный cookie-аудит — https://gdpr.slashgear.dev/
[4] LawCheck — Open-source 152-ФЗ сканер (FastAPI+Playwright+RQ) — https://github.com/lawcheck/lawcheck
[5] Toolfox — 134 проверки по 11 законам — https://toolfox.ru/tools/proverka-sajta-fz-152
[6] Сканэтика — 38+ критериев, мониторинг — https://scanetika.ru/
[7] EdLegal — AI-анализ политики, расчёт штрафов — https://edlegal.ru/help/scanner-152fz-guide/
[8] Proverito — Чек-лист 152-ФЗ (16 пунктов) — https://proverito.ru/chek-list-152-fz
[9] EDPB Website Auditing Tool — EU official — https://www.edpb.europa.eu/our-work-tools/our-documents/support-pool-experts-projects/edpb-website-auditing-tool_en
+3 -4
View File
@@ -7,6 +7,7 @@ from scanner.engine import ScannerEngine
from api.schemas import ( from api.schemas import (
HealthResponse, HealthResponse,
ScanRequest, ScanRequest,
CSVScanRequest,
ScanResultResponse, ScanResultResponse,
BatchScanResultResponse, BatchScanResultResponse,
) )
@@ -55,16 +56,14 @@ async def scan_single(request: ScanRequest):
@app.post("/api/scan/batch", tags=["Scan"]) @app.post("/api/scan/batch", tags=["Scan"])
async def scan_batch(request: BatchScanResultResponse): async def scan_batch(request: CSVScanRequest):
""" """
Просканировать несколько URL. Просканировать несколько URL.
Запрос: {"urls": ["https://site1.ru", "https://site2.ru"], "checks": [...]} Запрос: {"urls": ["https://site1.ru", "https://site2.ru"], "checks": [...]}
""" """
from scanner.engine import scanner_engine
try: try:
results = await scanner_engine.scan_urls_batch(request.urls, request.checks) results = await scanner.scan_urls_batch(request.urls, request.checks)
scan_results = [] scan_results = []
for r in results: for r in results:
+1
View File
@@ -0,0 +1 @@
name,address,url,phone
1 name address url phone
+3
View File
@@ -0,0 +1,3 @@
url
https://hotel-astoria.ru
https://posadskaya-hotel.ru
1 url
2 https://hotel-astoria.ru
3 https://posadskaya-hotel.ru
+84
View File
@@ -0,0 +1,84 @@
# Словарь трекеров и внешних сервисов для 152-ФЗ
# Категории: critical (запрещены), warning (рискованны), info (допустимы)
critical:
- domain: google-analytics.com
law: "152-ФЗ ст. 18 ч. 5"
fine: "до 6 млн ₽"
action: "Удалить Google Analytics, заменить на Яндекс.Метрику"
- domain: googletagmanager.com
law: "152-ФЗ ст. 18 ч. 5"
fine: "до 6 млн ₽"
action: "Удалить GTM или перевести на server-side"
- domain: googlesyndication.com
law: "152-ФЗ ст. 18 ч. 5"
fine: "до 6 млн ₽"
action: "Удалить Google AdSense"
- domain: facebook.com/tr
law: "152-ФЗ ст. 12"
fine: "до 6 млн ₽"
action: "Удалить Meta Pixel, заменить на VK Pixel"
- domain: connect.facebook.net
law: "152-ФЗ ст. 12"
fine: "до 6 млн ₽"
action: "Удалить Facebook SDK"
- domain: clarity.microsoft.com
law: "152-ФЗ ст. 12"
fine: "до 6 млн ₽"
action: "Удалить Microsoft Clarity"
- domain: segment.com
law: "152-ФЗ ст. 12"
fine: "до 6 млн ₽"
action: "Удалить Segment (передача данных в США)"
- domain: amplitude.com
law: "152-ФЗ ст. 12"
fine: "до 6 млн ₽"
action: "Удалить Amplitude"
- domain: hotjar.com
law: "152-ФЗ ст. 12"
fine: "до 6 млн ₽"
action: "Удалить Hotjar"
- domain: mixpanel.com
law: "152-ФЗ ст. 12"
fine: "до 6 млн ₽"
action: "Удалить Mixpanel"
warning:
- domain: fonts.googleapis.com
law: "152-ФЗ (IP-утечка)"
fine: "до 300 тыс ₽"
action: "Скачать шрифты и раздавать self-hosted"
- domain: google.com/recaptcha
law: "152-ФЗ ст. 18 ч. 5"
fine: "до 6 млн ₽"
action: "Заменить на Yandex SmartCaptcha"
- domain: googleapis.com
law: "152-ФЗ (IP-утечка)"
fine: "до 300 тыс ₽"
action: "Проверить что именно грузится: шрифты/карты = self-hosted"
- domain: googleadservices.com
law: "152-ФЗ ст. 18 ч. 5"
fine: "до 6 млн ₽"
action: "Удалить Google Ads tracking"
- domain: tilda.cdn
law: "152-ФЗ ст. 18 ч. 5"
fine: "до 300 тыс ₽"
action: "Проверить локализацию данных (Tilda — РФ-хостинг)"
- domain: zendesk.com
law: "152-ФЗ ст. 12"
fine: "до 6 млн ₽"
action: "Заменить на российский аналог (Usedesk, Okdesk)"
- domain: intercom.io
law: "152-ФЗ ст. 12"
fine: "до 6 млн ₽"
action: "Заменить на Jivo, Carrot Quest, Verbox"
russian:
- domain: mc.yandex.ru
note: "Яндекс.Метрика — допустима при согласии"
- domain: yandex.ru
note: "Яндекс.Метрика — допустима при согласии"
- domain: vk.com/rtrg
note: "VK Pixel — допустим при согласии"
- domain: top.mail.ru
note: "Mail.ru рейтинг — допустим при согласии"
-46
View File
@@ -1,46 +0,0 @@
INFO: Will watch for changes in these directories: ['/home/neo/workspace/152-fz-scanner']
INFO: Uvicorn running on http://127.0.0.1:8765 (Press CTRL+C to quit)
INFO: Started reloader process [83013] using WatchFiles
INFO: Started server process [83060]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: WatchFiles detected changes in 'scanner/checks/privacy_link.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [83060]
INFO: Started server process [122538]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: WatchFiles detected changes in 'scanner/checks/server_location.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [122538]
INFO: Started server process [123220]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: WatchFiles detected changes in 'scanner/checks/form_checkbox.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [123220]
INFO: Started server process [123432]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: WatchFiles detected changes in 'main.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [123432]
INFO: Started server process [123876]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: WatchFiles detected changes in 'main.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [123876]
INFO: Started server process [125174]
INFO: Waiting for application startup.
INFO: Application startup complete.
+71 -31
View File
@@ -10,22 +10,29 @@ from pathlib import Path
from scanner.engine import ScannerEngine from scanner.engine import ScannerEngine
from scanner.models import SiteScan from scanner.models import SiteScan
from scanner.output import out
from reports.generator import PDFReportGenerator from reports.generator import PDFReportGenerator
async def scan_single(url: str, checks: list[str] | None = None) -> SiteScan: async def scan_single(url: str, checks: list[str] | None = None, deep: bool = False) -> SiteScan:
"""Просканировать один URL и вывести результат.""" """Просканировать один URL и вывести результат.
Args:
url: URL для сканирования
checks: список проверок (None = QUICK_CHECKS)
deep: запустить LLM-анализ политики после основных проверок
"""
engine = ScannerEngine() engine = ScannerEngine()
result = await engine.scan_url(url, checks) result = await engine.scan_url(url, checks)
# Выводим результаты в терминал # Выводим результаты в терминал
print(f"\n{'='*60}") out(f"\n{'='*60}")
print(f"🔍 Сканирование: {url}") out(f"🔍 Сканирование: {url}")
print(f"{'='*60}") out(f"{'='*60}")
print(f"📊 Балл: {result.score}/100") out(f"📊 Балл: {result.score}/100")
print(f"{'✅ Соответствует' if result.is_compliant else '❌ НЕ соответствует'} 152-ФЗ") out(f"{'✅ Соответствует' if result.is_compliant else '❌ НЕ соответствует'} 152-ФЗ")
print(f"📝 {result.summary}") out(f"📝 {result.summary}")
print(f"\n{'─'*40}") out(f"\n{'─'*40}")
for check in result.checks: for check in result.checks:
emoji = { emoji = {
@@ -34,9 +41,40 @@ async def scan_single(url: str, checks: list[str] | None = None) -> SiteScan:
"skip": "⏭", "skip": "⏭",
"error": "⚠️", "error": "⚠️",
}.get(check.status.value, " ") }.get(check.status.value, " ")
print(f"{emoji} {check.title}: {check.message}") out(f"{emoji} {check.title}: {check.message}")
out(f"{'='*60}\n")
# Опциональный LLM-анализ политики
if deep:
out("🤖 Запуск глубокого LLM-анализа политики...")
try:
from scanner.checks.llm_policy import run_llm_analysis
# Ищем URL политики в результатах проверок
policy_url = None
for check in result.checks:
if check.name == "privacy_link" and check.status.value == "pass":
policy_url = check.details.get("policy_url")
break
if policy_url:
llm_result = await run_llm_analysis(policy_url)
out(f"\n{'─'*40}")
out(f"🧠 {llm_result['title']}")
out(f" Статус: {llm_result['status']}")
out(f" Риск: {llm_result['risk_level']}")
out(f" {llm_result['message']}")
if llm_result.get('details', {}).get('present_count'):
d = llm_result['details']
out(f" Разделов: {d['present_count']}/16")
if d.get('missing'):
out(f" Отсутствуют: {', '.join(d['missing'][:5])}")
else:
out(" ⚠️ URL политики не найден — LLM-анализ пропущен")
except Exception as e:
out(f" ⚠️ Ошибка LLM-анализа: {e}")
print(f"{'='*60}\n")
return result return result
@@ -63,11 +101,11 @@ def is_likely_hotel(url: str) -> bool:
return True return True
def scan_csv(filepath: str): def scan_csv(filepath: str, checks: list[str] | None = None):
"""Парксить CSV-файл с URL и запустить сканирование.""" """Парксить CSV-файл с URL и запустить сканирование."""
csv_path = Path(filepath) csv_path = Path(filepath)
if not csv_path.exists(): if not csv_path.exists():
print(f"❌ Файл не найден: {filepath}") out(f"❌ Файл не найден: {filepath}")
sys.exit(1) sys.exit(1)
urls = [] urls = []
@@ -84,22 +122,22 @@ def scan_csv(filepath: str):
skipped.append(url) skipped.append(url)
if skipped: if skipped:
print(f"⚠️ Пропущено {len(skipped)} URL (не похожи на отели):") out(f"⚠️ Пропущено {len(skipped)} URL (не похожи на отели):")
for u in skipped[:5]: for u in skipped[:5]:
print(f" {u}") out(f" {u}")
if len(skipped) > 5: if len(skipped) > 5:
print(f" ...и ещё {len(skipped) - 5}") out(f" ...и ещё {len(skipped) - 5}")
print() out()
if not urls: if not urls:
print("❌ В CSV не найдено валидных URL") out("❌ В CSV не найдено валидных URL")
sys.exit(1) sys.exit(1)
print(f"📋 Загружено {len(urls)} URL из {csv_path}") out(f"📋 Загружено {len(urls)} URL из {csv_path}")
async def batch_scan(): async def batch_scan():
engine = ScannerEngine() engine = ScannerEngine()
results = await engine.scan_urls_batch(urls) results = await engine.scan_urls_batch(urls, checks)
output = [] output = []
for r in results: for r in results:
@@ -119,7 +157,7 @@ def scan_csv(filepath: str):
with open(json_path, "w", encoding="utf-8") as f: with open(json_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2) json.dump(output, f, ensure_ascii=False, indent=2)
print(f"\n📄 Результаты сохранены в: {json_path}") out(f"\n📄 Результаты сохранены в: {json_path}")
# Генерируем PDF для каждого сайта # Генерируем PDF для каждого сайта
generator = PDFReportGenerator() generator = PDFReportGenerator()
@@ -128,20 +166,20 @@ def scan_csv(filepath: str):
for result in output: for result in output:
try: try:
pdf_path = generator.generate(result, str(reports_dir)) pdf_path = generator.generate(result, str(reports_dir))
print(f" 📄 PDF: {pdf_path}") out(f" 📄 PDF: {pdf_path}")
except Exception as e: except Exception as e:
print(f" ⚠️ Ошибка генерации PDF для {result['url']}: {e}") out(f" ⚠️ Ошибка генерации PDF для {result['url']}: {e}")
# Сводка # Сводка
compliant = sum(1 for r in output if r["is_compliant"]) compliant = sum(1 for r in output if r["is_compliant"])
non_compliant = len(output) - compliant non_compliant = len(output) - compliant
print(f"\n📊 Сводка:") out(f"\n📊 Сводка:")
print(f" Всего сайтов: {len(output)}") out(f" Всего сайтов: {len(output)}")
print(f" ✅ Соответствуют: {compliant}") out(f" ✅ Соответствуют: {compliant}")
print(f" ❌ НЕ соответствуют: {non_compliant}") out(f" ❌ НЕ соответствуют: {non_compliant}")
# Показываем топ нарушений # Показываем топ нарушений
print(f"\n🔥 Топ нарушений:") out(f"\n🔥 Топ нарушений:")
check_failures = {} check_failures = {}
for r in output: for r in output:
for check in r["checks"]: for check in r["checks"]:
@@ -150,7 +188,7 @@ def scan_csv(filepath: str):
check_failures[name] = check_failures.get(name, 0) + 1 check_failures[name] = check_failures.get(name, 0) + 1
for name, count in sorted(check_failures.items(), key=lambda x: -x[1]): for name, count in sorted(check_failures.items(), key=lambda x: -x[1]):
print(f" ❌ {name}: {count} сайтов") out(f" ❌ {name}: {count} сайтов")
asyncio.run(batch_scan()) asyncio.run(batch_scan())
@@ -165,10 +203,12 @@ if __name__ == "__main__":
single_parser = subparsers.add_parser("scan", help="Сканировать один URL") single_parser = subparsers.add_parser("scan", help="Сканировать один URL")
single_parser.add_argument("url", help="URL сайта для сканирования") single_parser.add_argument("url", help="URL сайта для сканирования")
single_parser.add_argument("--checks", nargs="+", help="Какие проверки запустить") single_parser.add_argument("--checks", nargs="+", help="Какие проверки запустить")
single_parser.add_argument("--deep", action="store_true", help="Запустить LLM-анализ политики после сканирования")
# Подкоманда: csv # Подкоманда: csv
csv_parser = subparsers.add_parser("csv", help="Сканировать CSV с URL") csv_parser = subparsers.add_parser("csv", help="Сканировать CSV с URL")
csv_parser.add_argument("filepath", help="Путь к CSV-файлу") csv_parser.add_argument("filepath", help="Путь к CSV-файлу")
csv_parser.add_argument("--checks", nargs="+", help="Какие проверки запустить")
# Подкоманда: server # Подкоманда: server
server_parser = subparsers.add_parser("server", help="Запустить API-сервер") server_parser = subparsers.add_parser("server", help="Запустить API-сервер")
@@ -178,9 +218,9 @@ if __name__ == "__main__":
args = parser.parse_args() args = parser.parse_args()
if args.command == "scan": if args.command == "scan":
asyncio.run(scan_single(args.url, args.checks)) asyncio.run(scan_single(args.url, args.checks, args.deep))
elif args.command == "csv": elif args.command == "csv":
scan_csv(args.filepath) scan_csv(args.filepath, args.checks)
elif args.command == "server": elif args.command == "server":
import uvicorn import uvicorn
uvicorn.run("api.server:app", host=args.host, port=args.port, reload=True) uvicorn.run("api.server:app", host=args.host, port=args.port, reload=True)
+54
View File
@@ -0,0 +1,54 @@
[project]
name = "fz152-scanner"
version = "1.0.0"
description = "Автоматизированный сканер сайтов на соответствие 152-ФЗ о персональных данных"
readme = "STRATEGY.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [
{name = "Nikita Tarasov", email = "nik@tarasov.top"},
]
keywords = ["152-fz", "compliance", "privacy", "scanner", "personal-data"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Intended Audience :: Legal Industry",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Security",
"Topic :: Internet :: WWW/HTTP",
]
dependencies = [
"fastapi>=0.104.0",
"uvicorn[standard]>=0.24.0",
"playwright>=1.40.0",
"pydantic>=2.5.0",
"jinja2>=3.1.2",
"weasyprint>=60.0",
"aiohttp>=3.9.0",
"pyyaml>=6.0",
"beautifulsoup4>=4.12.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"pytest-xdist>=3.5",
]
[project.scripts]
fz152-scan = "main:main"
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["scanner*", "api*", "reports*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+15 -15
View File
@@ -150,7 +150,7 @@
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<h1>📋 Аудит соответствия 152-ФЗ</h1> <h1>Аудит соответствия 152-ФЗ</h1>
<p>Автоматизированный сканер | {{ site_scan.url }}</p> <p>Автоматизированный сканер | {{ site_scan.url }}</p>
<p>Дата проверки: {{ scanned_at }}</p> <p>Дата проверки: {{ scanned_at }}</p>
<p>Версия сканера: {{ scanner_version }}</p> <p>Версия сканера: {{ scanner_version }}</p>
@@ -159,16 +159,16 @@
<div class="score-card"> <div class="score-card">
<div class="score-number">{{ score }}</div> <div class="score-number">{{ score }}</div>
<div class="score-label"> <div class="score-label">
{{ '✅ Сайт соответствует 152-ФЗ' if is_compliant else '❌ Сайт НЕ соответствует 152-ФЗ' }} {{ 'Сайт соответствует 152-ФЗ' if is_compliant else 'Сайт НЕ соответствует 152-ФЗ' }}
</div> </div>
</div> </div>
<div class="summary-box"> <div class="summary-box">
<h3>📊 Резюме проверки</h3> <h3>Резюме проверки</h3>
<p>{{ summary }}</p> <p>{{ summary }}</p>
</div> </div>
<h2>📝 Результаты проверок</h2> <h2>Результаты проверок</h2>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@@ -184,24 +184,24 @@
<td><strong>{{ check.title }}</strong></td> <td><strong>{{ check.title }}</strong></td>
<td> <td>
{% if check.status == 'pass' %} {% if check.status == 'pass' %}
<span class="status-pass">✅ Пройдено</span> <span class="status-pass">ПРОЙДЕНО</span>
{% elif check.status == 'fail' %} {% elif check.status == 'fail' %}
<span class="status-fail">❌ Не пройдено</span> <span class="status-fail">НАРУШЕНИЕ</span>
{% elif check.status == 'skip' %} {% elif check.status == 'skip' %}
<span class="status-skip">⏭ Пропущено</span> <span class="status-skip">ПРОПУЩЕНО</span>
{% elif check.status == 'error' %} {% elif check.status == 'error' %}
<span class="status-error">⚠️ Ошибка</span> <span class="status-error">ОШИБКА</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if check.risk_level == 'critical' %} {% if check.risk_level == 'critical' %}
<span class="risk-critical">🔴 Критический</span> <span class="risk-critical">КРИТИЧЕСКИЙ</span>
{% elif check.risk_level == 'high' %} {% elif check.risk_level == 'high' %}
<span class="risk-high">🟠 Высокий</span> <span class="risk-high">ВЫСОКИЙ</span>
{% elif check.risk_level == 'medium' %} {% elif check.risk_level == 'medium' %}
<span class="risk-medium">🟡 Средний</span> <span class="risk-medium">СРЕДНИЙ</span>
{% else %} {% else %}
<span class="risk-low">🟢 Низкий</span> <span class="risk-low">НИЗКИЙ</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ check.message }}</td> <td>{{ check.message }}</td>
@@ -213,10 +213,10 @@
<div class="cta-section"> <div class="cta-section">
<h2>Нужна помощь с приведением сайта в соответствие 152-ФЗ?</h2> <h2>Нужна помощь с приведением сайта в соответствие 152-ФЗ?</h2>
<p>Мы исправим все найденные нарушения, подготовим документы и настроим cookie-баннеры.</p> <p>Мы исправим все найденные нарушения, подготовим документы и настроим cookie-баннеры.</p>
<p style="margin-top: 20px; font-size: 18px;">💰 Стоимость: от 7 000 ₽ (зависит от объема нарушений)</p> <p style="margin-top: 20px; font-size: 18px;">Стоимость: от 7 000 руб. (зависит от объёма нарушений)</p>
<div style="margin-top: 20px;"> <div style="margin-top: 20px;">
<span class="cta-button">📧 Написать на email</span> <span class="cta-button">Написать на email</span>
<span class="cta-button">💬 Telegram</span> <span class="cta-button">Telegram</span>
</div> </div>
</div> </div>
+3
View File
@@ -4,3 +4,6 @@ playwright>=1.40.0
pydantic>=2.5.0 pydantic>=2.5.0
jinja2>=3.1.2 jinja2>=3.1.2
weasyprint>=60.0 weasyprint>=60.0
aiohttp>=3.9.0
pyyaml>=6.0
beautifulsoup4>=4.12.0
+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.form_checkbox import FormCheckboxCheck
from scanner.checks.server_location import ServerLocationCheck from scanner.checks.server_location import ServerLocationCheck
from scanner.checks.external_scripts import ExternalScriptsCheck 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]] = { ALL_CHECKS: dict[str, type[BaseCheck]] = {
"cookie_banner": CookieBannerCheck, "cookie_banner": CookieBannerCheck,
"cookie_audit": CookieAuditCheck,
"privacy_link": PrivacyLinkCheck, "privacy_link": PrivacyLinkCheck,
"form_checkbox": FormCheckboxCheck, "form_checkbox": FormCheckboxCheck,
"server_location": ServerLocationCheck, "server_location": ServerLocationCheck,
"external_scripts": ExternalScriptsCheck, "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__ = [ __all__ = [
"BaseCheck", "BaseCheck",
"CookieBannerCheck", "CookieBannerCheck", "PrivacyLinkCheck", "FormCheckboxCheck",
"PrivacyLinkCheck", "ServerLocationCheck", "ExternalScriptsCheck",
"FormCheckboxCheck", "ConsentDocCheck", "OperatorInfoCheck", "LLMPolicyCheck",
"ServerLocationCheck", "ALL_CHECKS", "QUICK_CHECKS",
"ExternalScriptsCheck",
"ALL_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 from __future__ import annotations
import re import re
from pathlib import Path
import yaml
from playwright.async_api import Page from playwright.async_api import Page
from scanner.checks.base import BaseCheck from scanner.checks.base import BaseCheck
from scanner.models import CheckResult, RiskLevel from scanner.models import CheckResult, RiskLevel
# Путь к словарю трекеров
TRACKERS_YAML = Path(__file__).parent.parent.parent / "dictionaries" / "trackers.yaml"
class ExternalScriptsCheck(BaseCheck): class ExternalScriptsCheck(BaseCheck):
"""Проверяет наличие внешних скриптов, передающих данные за пределы РФ.""" """Проверяет наличие внешних скриптов, передающих данные за пределы РФ."""
name = "external_scripts" name = "external_scripts"
title = "Внешние скрипты аналитики" title = "Внешние скрипты аналитики"
description = "Ищет скрипты Google Analytics, Facebook Pixel и другие иностранные сервисы" description = "Ищет скрипты Google Analytics, Meta Pixel и другие иностранные сервисы"
risk_on_fail = RiskLevel.HIGH risk_on_fail = RiskLevel.HIGH
default_weight = 15 default_weight = 15
EXTERNAL_PATTERNS = [ def _load_trackers(self) -> dict:
r"google-analytics\.com", """Загрузить словарь трекеров из YAML."""
r"googletagmanager\.com", if not TRACKERS_YAML.exists():
r"googlesyndication\.com", # Fallback: базовый набор
r"googleadservices\.com", return {
r"googleapis\.com", "critical": [{"domain": "google-analytics.com"}, {"domain": "facebook.com/tr"}],
r"google\.com/recaptcha", "warning": [{"domain": "fonts.googleapis.com"}, {"domain": "google.com/recaptcha"}],
r"facebook\.com/tr", "russian": [{"domain": "mc.yandex.ru"}, {"domain": "vk.com/rtrg"}],
r"fbcdn\.net", }
r"connect\.facebook\.net", with open(TRACKERS_YAML, "r", encoding="utf-8") as f:
r"mc\.yandex\.ru", return yaml.safe_load(f)
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",
]
async def check(self, page: Page, url: str) -> CheckResult: async def check(self, page: Page, url: str) -> CheckResult:
try: try:
html = await page.evaluate("() => document.documentElement.outerHTML") html = await page.evaluate("() => document.documentElement.outerHTML")
trackers = self._load_trackers()
found_scripts = [] found = {"critical": [], "warning": [], "russian": []}
for pattern in self.EXTERNAL_PATTERNS:
matches = re.findall(pattern, html, re.IGNORECASE)
if matches:
found_scripts.append({
"pattern": pattern,
"matches": len(matches),
})
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( return self._pass_result(
message="Внешние скрипты аналитики не найдены.", message="Внешние скрипты аналитики не найдены.",
) )
critical_services = [ # Формируем сообщение
"google-analytics", parts = []
"googletagmanager", if found["critical"]:
"facebook", domains = [e["domain"] for e in found["critical"]]
"clarity", parts.append(f"ЗАПРЕЩЁННЫЕ ({len(found['critical'])}): {', '.join(domains)}")
"segment", if found["warning"]:
"amplitude", 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 message = "Найдены внешние скрипты: " + " | ".join(parts)
for script in found_scripts:
if any(re.search(c, script["pattern"]) for c in critical_services):
has_critical = True
break
if has_critical: # Только российские → PASS с уведомлением
return self._fail_result( if not found["critical"] and not found["warning"]:
message=( return self._pass_result(
f"Найдены внешние скрипты передачи данных: " message=f"Найдены только российские сервисы: {message}",
f"{', '.join(s['pattern'] for s in found_scripts)}. " details={"found": found},
f"Они передают персональные данные за пределы РФ."
),
details={
"scripts_found": found_scripts,
"has_critical": True,
},
) )
# Есть критические → 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( return self._fail_result(
message=( message=message,
f"Найдены внешние скрипты: " risk_level=RiskLevel.CRITICAL,
f"{', '.join(s['pattern'] for s in found_scripts)}. " details={"found": found},
f"Требуется согласие пользователя." )
),
details={ # Только warning → FAIL с HIGH
"scripts_found": found_scripts, return self._fail_result(
"has_critical": False, message=message,
}, risk_level=RiskLevel.HIGH,
details={"found": found},
) )
except Exception as e: 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" name = "form_checkbox"
title = "Чекбокс согласия в формах" title = "Чекбокс согласия в формах"
description = "Проверяет, есть ли чекбокс согласия на обработку ПД в формах" description = "Проверяет, есть ли чекбокс согласия на обработку ПД в формах, не предзаполнен ли он"
risk_on_fail = RiskLevel.CRITICAL risk_on_fail = RiskLevel.CRITICAL
default_weight = 20 default_weight = 20
@@ -31,85 +31,113 @@ class FormCheckboxCheck(BaseCheck):
r"я\s*принимаю\s*условия", r"я\s*принимаю\s*условия",
r"processing\s*of\s*personal\s*data", r"processing\s*of\s*personal\s*data",
r"обработк[аею]\s*персональных?\s*данных?", r"обработк[аею]\s*персональных?\s*данных?",
r"политик[ууе]?\s*конфиденциальност[иы]", r"политик[уе]?\s*конфиденциальност[иы]",
] ]
async def check(self, page: Page, url: str) -> CheckResult: async def check(self, page: Page, url: str) -> CheckResult:
try: try:
# Получаем все формы на странице
forms = await page.query_selector_all("form") forms = await page.query_selector_all("form")
if not forms: if not forms:
return self._skip_result( return self._skip_result(message="Формы на странице не найдены.")
message="Формы на странице не найдены.",
)
forms_needing_consent = 0 total_forms = 0
forms_with_consent = 0 forms_with_consent = 0
dark_patterns = [] # Предзаполненные чекбоксы
forms_without_consent = [] 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"]') checkboxes = await form.query_selector_all('input[type="checkbox"]')
if not checkboxes:
forms_without_consent.append({"reason": "нет чекбоксов"})
continue
has_consent = False form_has_consent = False
checkbox_texts = [] form_has_dark_pattern = False
for cb in checkboxes: 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( # 1. Ищем связанный <label for="checkbox_id">
f'(el) => {{\n' # 2. Если нет — родительский <label>
f' const cb = el.closest("label") || el.parentElement;\n' # 3. Если нет — соседние текстовые узлы
f' return cb ? cb.innerText : "";\n' checkbox_id = await cb.get_attribute("id")
f'}}', form 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()
combined = f"{cb_text} {cb_label}".lower() 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 ""
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): 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 form_has_consent:
if has_consent:
forms_with_consent += 1 forms_with_consent += 1
if form_has_dark_pattern:
dark_patterns.append({"form_index": total_forms, "issue": "предзаполненный чекбокс согласия"})
else: else:
forms_without_consent.append({ forms_without_consent.append({"reason": "нет текста согласия"})
"form_index": form_idx,
"checkboxes": checkbox_texts[:3],
})
if forms_with_consent == forms_needing_consent: # Формируем результат
if total_forms == forms_with_consent and not dark_patterns:
return self._pass_result( return self._pass_result(
message=f"Все формы ({forms_with_consent}) содержат чекбокс согласия.", message=f"Все формы ({forms_with_consent}) содержат чекбокс согласия, dark patterns не найдены.",
details={ details={"forms_count": forms_with_consent, "dark_patterns": 0},
"forms_count": forms_with_consent,
"checked_forms": forms_with_consent,
},
) )
# Собираем сообщение
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: if forms_with_consent == 0:
return self._fail_result( msg += "Это прямое нарушение 152-ФЗ."
message=(
f"Формы ({forms_needing_consent}) не содержат чекбокс согласия на обработку ПД. " # При dark patterns — повышаем риск
f"Это прямое нарушение 152-ФЗ." risk = RiskLevel.CRITICAL if dark_patterns else self.risk_on_fail
),
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]],
},
)
# Частичное покрытие
return self._fail_result( return self._fail_result(
message=( message=msg,
f"Только {forms_with_consent} из {forms_needing_consent} форм содержат чекбокс согласия." risk_level=risk,
),
details={ details={
"total_forms": forms_needing_consent, "total_forms": total_forms,
"with_consent": forms_with_consent, "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)}")
+19 -7
View File
@@ -60,15 +60,15 @@ class PrivacyLinkCheck(BaseCheck):
details={"total_links": len(link_texts), "page_text_sample": link_texts[:3]}, details={"total_links": len(link_texts), "page_text_sample": link_texts[:3]},
) )
# Проверяем, что ссылка ведет на рабочую страницу # Проверяем доступность страницы политики через aiohttp (не page.goto — гонка данных!)
policy_url = found_links[0]["url"] policy_url = found_links[0]["url"]
try: try:
# Проверяем, не является ли URL документом (PDF, DOCX, XLSX) import aiohttp
doc_extensions = ['.pdf', '.docx', '.doc', '.xlsx', '.xls', '.rtf', '.txt'] doc_extensions = ['.pdf', '.docx', '.doc', '.xlsx', '.xls', '.rtf', '.txt']
is_document = any(policy_url.lower().endswith(ext) for ext in doc_extensions) is_document = any(policy_url.lower().endswith(ext) for ext in doc_extensions)
if is_document: if is_document:
# Документ для скачивания — это допустимо для политики
return self._pass_result( return self._pass_result(
message=f"Найдена ссылка на Политику конфиденциальности ({policy_url})", message=f"Найдена ссылка на Политику конфиденциальности ({policy_url})",
details={ details={
@@ -78,10 +78,22 @@ class PrivacyLinkCheck(BaseCheck):
}, },
) )
# Для HTML-страниц — проверяем доступность # Для HTML-страниц — проверяем через aiohttp (не меняет URL страницы!)
await page.goto(policy_url, timeout=5000) async with aiohttp.ClientSession() as session:
policy_text = await page.evaluate("() => document.body.innerText") async with session.get(policy_url, timeout=aiohttp.ClientTimeout(total=8)) as resp:
await page.goto(url, timeout=5000) # Возвращаемся на главную 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: if len(policy_text) < 50:
return self._fail_result( return self._fail_result(
+10 -15
View File
@@ -79,37 +79,32 @@ class ServerLocationCheck(BaseCheck):
return self._error_result(message=f"Ошибка при проверке геолокации: {str(e)}") return self._error_result(message=f"Ошибка при проверке геолокации: {str(e)}")
async def _get_country(self, ip: str) -> str | None: async def _get_country(self, ip: str) -> str | None:
"""Получает страну по IP через публичный API.""" """Получает страну по IP через публичный API (асинхронно)."""
try: try:
import urllib.request import aiohttp
import json
# Пробуем несколько бесплатных API
apis = [ apis = [
f"https://ipapi.co/{ip}/json/", f"https://ipapi.co/{ip}/json/",
f"https://ip-api.com/json/{ip}?fields=country", f"https://ip-api.com/json/{ip}?fields=country",
f"https://api.ipgeolocation.io/ipgeo?ip={ip}",
] ]
async with aiohttp.ClientSession() as session:
for api_url in apis: for api_url in apis:
try: try:
req = urllib.request.Request( async with session.get(
api_url, api_url,
headers={"User-Agent": "Mozilla/5.0"} headers={"User-Agent": "Mozilla/5.0"},
) timeout=aiohttp.ClientTimeout(total=5),
with urllib.request.urlopen(req, timeout=5) as response: ) as response:
data = json.loads(response.read().decode()) if response.status != 200:
continue
data = await response.json()
# ipapi.co формат # ipapi.co формат
if "country_name" in data: if "country_name" in data:
return data["country_name"] return data["country_name"]
# ip-api.com формат # ip-api.com формат
if "country" in data: if "country" in data:
return data["country"] return data["country"]
# ipgeolocation формат
if "country_name" in data:
return data["country_name"]
except Exception: except Exception:
continue continue
+74 -15
View File
@@ -11,9 +11,22 @@ from typing import Any
from playwright.async_api import async_playwright, Page from playwright.async_api import async_playwright, Page
from scanner.checks import ALL_CHECKS from scanner.checks import ALL_CHECKS, QUICK_CHECKS
from scanner.models import CheckResult, CheckStatus, RiskLevel, SiteScan from scanner.models import CheckResult, CheckStatus, RiskLevel, SiteScan
# Retry-настройки
MAX_RETRIES = 3
RETRY_DELAY_BASE = 2 # секунды
RETRYABLE_ERRORS = (
"ERR_NAME_NOT_RESOLVED",
"ERR_CONNECTION_REFUSED",
"ERR_CONNECTION_RESET",
"ERR_TIMED_OUT",
"ERR_NETWORK_CHANGED",
"net::ERR_",
"Timeout",
)
class ScannerEngine: class ScannerEngine:
"""Управляет процессом сканирования сайтов.""" """Управляет процессом сканирования сайтов."""
@@ -22,6 +35,23 @@ class ScannerEngine:
self.timeout = timeout self.timeout = timeout
self.headless = headless self.headless = headless
async def _retry_goto(self, page: Page, url: str) -> None:
"""Загрузить страницу с повторными попытками при transient-ошибках."""
last_error = None
for attempt in range(1, MAX_RETRIES + 1):
try:
await page.goto(url, timeout=self.timeout, wait_until="networkidle")
return # Успех
except Exception as e:
last_error = e
err_str = str(e)
is_retryable = any(pattern in err_str for pattern in RETRYABLE_ERRORS)
if not is_retryable or attempt == MAX_RETRIES:
raise
delay = RETRY_DELAY_BASE * (2 ** (attempt - 1))
await asyncio.sleep(delay)
raise last_error # type: ignore[misc]
async def scan_url(self, url: str, checks: list[str] | None = None) -> SiteScan: async def scan_url(self, url: str, checks: list[str] | None = None) -> SiteScan:
""" """
Просканировать один URL. Просканировать один URL.
@@ -66,33 +96,56 @@ class ScannerEngine:
}); });
""") """)
# Загружаем страницу # Загружаем страницу с retry
try: try:
await page.goto(url, timeout=self.timeout, wait_until="networkidle") await self._retry_goto(page, url)
except Exception as e: except Exception as e:
result.checks.append( result.checks.append(
CheckResult( CheckResult(
name="page_load", name="page_load",
title="Загрузка страницы", title="Загрузка страницы",
status=CheckStatus.ERROR, status=CheckStatus.FAIL,
risk_level=RiskLevel.CRITICAL, risk_level=RiskLevel.CRITICAL,
message=f"Не удалось загрузить страницу: {str(e)}", message=f"Не удалось загрузить страницу: {str(e)}",
) )
) )
result._calculate_score()
return result return result
# Подождем, пока JavaScript отрендерит динамический контент # Подождем, пока JavaScript отрендерит динамический контент
await page.wait_for_timeout(3000) await page.wait_for_timeout(3000)
# Запускаем все проверки # Запускаем проверки (по умолчанию — быстрые, без LLM)
check_names = checks if checks else list(ALL_CHECKS.keys()) check_names = checks if checks else QUICK_CHECKS
tasks = [] # Фаза 1: cookie_audit — ОДИН (кликает по кнопке, меняет DOM)
for check_name in check_names: # Не может работать параллельно с остальными проверками
mutating_checks = {"cookie_audit"}
phase1_names = [n for n in check_names if n in mutating_checks]
phase2_names = [n for n in check_names if n not in mutating_checks]
for check_name in phase1_names:
check_cls = ALL_CHECKS.get(check_name) check_cls = ALL_CHECKS.get(check_name)
if check_cls: if check_cls:
check_instance = check_cls() try:
tasks.append(check_instance.check(page, url)) cr = await check_cls().check(page, url)
if isinstance(cr, CheckResult):
result.checks.append(cr)
except Exception as e:
result.checks.append(
CheckResult(
name=check_name, title=check_name,
status=CheckStatus.FAIL, risk_level=RiskLevel.HIGH,
message=f"Ошибка: {str(e)}",
)
)
# Фаза 2: остальные проверки — параллельно (только читают DOM)
tasks = []
for check_name in phase2_names:
check_cls = ALL_CHECKS.get(check_name)
if check_cls:
tasks.append(check_cls().check(page, url))
if tasks: if tasks:
check_results = await asyncio.gather(*tasks, return_exceptions=True) check_results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -104,8 +157,8 @@ class ScannerEngine:
CheckResult( CheckResult(
name="unknown", name="unknown",
title="Неизвестная ошибка", title="Неизвестная ошибка",
status=CheckStatus.ERROR, status=CheckStatus.FAIL,
risk_level=RiskLevel.CRITICAL, risk_level=RiskLevel.HIGH,
message=f"Необработанная ошибка: {str(cr)}", message=f"Необработанная ошибка: {str(cr)}",
) )
) )
@@ -117,9 +170,15 @@ class ScannerEngine:
result._calculate_score() result._calculate_score()
return result return result
async def scan_urls_batch(self, urls: list[str], checks: list[str] | None = None) -> list[SiteScan]: async def scan_urls_batch(self, urls: list[str], checks: list[str] | None = None, concurrency: int = 5) -> list[SiteScan]:
"""Параллельно сканировать несколько URL.""" """Параллельно сканировать несколько URL с ограничением конкурентности."""
tasks = [self.scan_url(url, checks) for url in urls] semaphore = asyncio.Semaphore(concurrency)
async def _scan_with_limit(url):
async with semaphore:
return await self.scan_url(url, checks)
tasks = [_scan_with_limit(url) for url in urls]
return await asyncio.gather(*tasks) return await asyncio.gather(*tasks)
+41
View File
@@ -0,0 +1,41 @@
"""Логирование для 152-ФЗ Scanner.
Заменяет print() на структурированный вывод с уровнями и файлом."""
import logging
import sys
from pathlib import Path
from datetime import datetime
LOG_DIR = Path(__file__).parent / "logs"
LOG_DIR.mkdir(exist_ok=True)
def setup_logger(name: str = "scanner", level: int = logging.INFO) -> logging.Logger:
"""Настроить логгер с выводом в консоль + файл."""
logger = logging.getLogger(name)
logger.setLevel(level)
if logger.handlers:
return logger
# Формат: время [уровень] сообщение
fmt = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
# Консоль
console = logging.StreamHandler(sys.stdout)
console.setFormatter(fmt)
logger.addHandler(console)
# Файл
today = datetime.now().strftime("%Y%m%d")
file_handler = logging.FileHandler(LOG_DIR / f"app_{today}.log", encoding="utf-8")
file_handler.setFormatter(fmt)
logger.addHandler(file_handler)
return logger
# Глобальный экземпляр
log = setup_logger()
+46
View File
@@ -0,0 +1,46 @@
"""
Вывод результатов: консоль + файл.
Прозрачная замена print() — всё что идёт в out(), дублируется в лог-файл.
"""
import sys
from pathlib import Path
from datetime import datetime
from typing import Any
class Output:
"""Дуплексный вывод: консоль + лог-файл."""
def __init__(self, log_dir: str | None = None):
if log_dir is None:
log_dir = Path(__file__).parent.parent / "logs"
Path(log_dir).mkdir(parents=True, exist_ok=True)
self._log_path = Path(log_dir) / f"scan_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
self._file = open(self._log_path, "w", encoding="utf-8")
def __call__(self, *args, **kwargs):
"""out('текст') — вывести на консоль и в лог."""
sep = kwargs.get("sep", " ")
end = kwargs.get("end", "\n")
text = sep.join(str(a) for a in args) + end
# Консоль
sys.stdout.write(text)
sys.stdout.flush()
# Файл (без emoji для читаемости)
clean = text.encode("ascii", errors="replace").decode("ascii")
self._file.write(clean)
self._file.flush()
def close(self):
self._file.close()
@property
def log_path(self) -> Path:
return self._log_path
# Глобальный экземпляр
out = Output()
+138
View File
@@ -0,0 +1,138 @@
"""
Smoke-тест: проверяет, что все 7 быстрых проверок импортируются,
создаются и запускаются на минимальной HTML-странице без ошибок.
"""
import pytest
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from scanner.checks import ALL_CHECKS, QUICK_CHECKS
from scanner.models import CheckStatus
# Минимальная HTML-страница для тестирования
MOCK_HTML = """<!DOCTYPE html>
<html>
<head><title>Test Site</title></head>
<body>
<footer>
<a href="/privacy">Политика конфиденциальности</a>
<a href="/consent">Согласие на обработку персональных данных</a>
</footer>
<form action="/submit" method="POST">
<input type="text" name="name" placeholder="Имя">
<input type="email" name="email" placeholder="Email">
<label for="agree">
<input type="checkbox" id="agree" name="agree">
Я даю согласие на обработку персональных данных
</label>
<button type="submit">Отправить</button>
</form>
<div id="cookie-banner" class="cookie-consent">
<p>Мы используем cookies</p>
<button id="accept-cookies">Принять</button>
<button id="reject-cookies">Отказаться</button>
</div>
<script src="https://mc.yandex.ru/metrika/tag.js"></script>
</body>
</html>"""
class TestAllChecksImport:
"""Проверка что все проверки импортируются."""
def test_quick_checks_count(self):
assert len(QUICK_CHECKS) == 7, f"Expected 7 quick checks, got {len(QUICK_CHECKS)}"
def test_all_checks_registered(self):
for name in QUICK_CHECKS:
assert name in ALL_CHECKS, f"Check '{name}' not in ALL_CHECKS"
@pytest.mark.parametrize("check_name", QUICK_CHECKS)
def test_check_instantiable(self, check_name):
"""Каждая проверка создаётся без ошибок."""
check_cls = ALL_CHECKS[check_name]
instance = check_cls()
assert instance.name == check_name
assert instance.title
class TestCheckLogic:
"""Проверка логики проверок (без Playwright — unit tests)."""
def test_form_checkbox_patterns(self):
from scanner.checks.form_checkbox import FormCheckboxCheck
import re
check = FormCheckboxCheck()
# Паттерны должны матчить русский текст согласия
assert any(re.search(p, "согласие на обработку данных", re.IGNORECASE) for p in check.CONSENT_PATTERNS)
assert any(re.search(p, "я согласен на обработку персональных данных", re.IGNORECASE) for p in check.CONSENT_PATTERNS)
def test_operator_info_inn_pattern(self):
from scanner.checks.operator_info import OperatorInfoCheck
import re
check = OperatorInfoCheck()
m = re.search(check.INN_PATTERN, "ИНН: 0278901234", re.IGNORECASE)
assert m, "INN pattern should match 10-digit INN"
assert m.group(1) == "0278901234"
def test_external_scripts_yaml_loads(self):
from scanner.checks.external_scripts import ExternalScriptsCheck
check = ExternalScriptsCheck()
trackers = check._load_trackers()
assert "critical" in trackers
assert "russian" in trackers
assert any(e["domain"] == "google-analytics.com" for e in trackers["critical"])
class TestScoring:
"""Проверка логики скоринга."""
def test_score_calculation_critical(self):
from scanner.models import SiteScan, CheckResult, CheckStatus, RiskLevel
from datetime import datetime
scan = SiteScan(url="https://test.ru", scanned_at=datetime.now())
scan.checks = [
CheckResult(name="t1", title="Test 1", status=CheckStatus.FAIL, risk_level=RiskLevel.CRITICAL),
CheckResult(name="t2", title="Test 2", status=CheckStatus.PASS, risk_level=RiskLevel.LOW),
]
scan._calculate_score()
assert scan.score == 70 # 100 - 30
assert scan.is_compliant # score=70 — на границе, но compliant
def test_score_all_pass(self):
from scanner.models import SiteScan, CheckResult, CheckStatus, RiskLevel
from datetime import datetime
scan = SiteScan(url="https://test.ru", scanned_at=datetime.now())
scan.checks = [
CheckResult(name="t1", title="Test 1", status=CheckStatus.PASS, risk_level=RiskLevel.HIGH),
]
scan._calculate_score()
assert scan.score == 100
assert scan.is_compliant
def test_score_multiple_critical(self):
from scanner.models import SiteScan, CheckResult, CheckStatus, RiskLevel
from datetime import datetime
scan = SiteScan(url="https://test.ru", scanned_at=datetime.now())
scan.checks = [
CheckResult(name="t1", title="C1", status=CheckStatus.FAIL, risk_level=RiskLevel.CRITICAL),
CheckResult(name="t2", title="C2", status=CheckStatus.FAIL, risk_level=RiskLevel.CRITICAL),
CheckResult(name="t3", title="H1", status=CheckStatus.FAIL, risk_level=RiskLevel.HIGH),
]
scan._calculate_score()
assert scan.score == 20 # 100 - 30 - 30 - 20
assert not scan.is_compliant
class TestEnginePhases:
"""Проверка фазовой модели в engine."""
def test_mutating_checks_identified(self):
"""cookie_audit должен быть в mutating_checks."""
from scanner.checks import QUICK_CHECKS
mutating = {"cookie_audit"}
assert "cookie_audit" in QUICK_CHECKS, "cookie_audit должен быть в QUICK_CHECKS"
non_mutating = [n for n in QUICK_CHECKS if n not in mutating]
assert len(non_mutating) == 6 # 7 quick - 1 mutating