v2.0: исправлены критические баги, добавлены 4 новые проверки, 4-фазный cookie-аудит, retry-логика, YAML-словари, LLM-анализ, тесты
Исправлено: - engine: _calculate_score() при ошибке загрузки (score не 100 для мёртвых сайтов) - privacy_link: замена page.goto() на aiohttp (гонка данных) - form_checkbox: 3-уровневый поиск label + dark-pattern detection - server_location: синхронный urllib → aiohttp - api/server: перепутанные request/response модели в batch - main.py: csv --checks флаг - engine: фазовая модель (cookie_audit отдельно, остальные параллельно) - engine: retry-логика (3 попытки, exponential backoff) - PDF: emoji заменены на текст (WeasyPrint) Добавлено: - consent_doc: проверка отдельного согласия на обработку ПД - operator_info: проверка ИНН/ОГРН/DPO (читает главную + политику) - cookie_audit: 4-фазный аудит (load→detect→reject→compare) - llm_policy: опциональный LLM-анализ 16 разделов (--deep) - external_scripts: YAML-словари trackers.yaml (critical/warning/russian) - logging (output.py), .gitignore, pyproject.toml - smoke-тесты (16/16)
This commit is contained in:
1 parent
7fb417dad5
commit
b9db935389
24 files changed
+1710
-271
No files matched your search
+11
-8
@@ -1,15 +1,17 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
*.egg
|
||||
|
||||
# Virtual env
|
||||
# Virtual environment
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
@@ -17,14 +19,15 @@ venv/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Data
|
||||
data/output/results.json
|
||||
data/output/reports/audit_*.pdf
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
# Project output
|
||||
data/output/
|
||||
logs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
*.pem
|
||||
*.key
|
||||
+346
@@ -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
@@ -7,6 +7,7 @@ from scanner.engine import ScannerEngine
|
||||
from api.schemas import (
|
||||
HealthResponse,
|
||||
ScanRequest,
|
||||
CSVScanRequest,
|
||||
ScanResultResponse,
|
||||
BatchScanResultResponse,
|
||||
)
|
||||
@@ -55,16 +56,14 @@ async def scan_single(request: ScanRequest):
|
||||
|
||||
|
||||
@app.post("/api/scan/batch", tags=["Scan"])
|
||||
async def scan_batch(request: BatchScanResultResponse):
|
||||
async def scan_batch(request: CSVScanRequest):
|
||||
"""
|
||||
Просканировать несколько URL.
|
||||
|
||||
Запрос: {"urls": ["https://site1.ru", "https://site2.ru"], "checks": [...]}
|
||||
"""
|
||||
from scanner.engine import scanner_engine
|
||||
|
||||
try:
|
||||
results = await scanner_engine.scan_urls_batch(request.urls, request.checks)
|
||||
results = await scanner.scan_urls_batch(request.urls, request.checks)
|
||||
|
||||
scan_results = []
|
||||
for r in results:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
name,address,url,phone
|
||||
|
@@ -0,0 +1,3 @@
|
||||
url
|
||||
https://hotel-astoria.ru
|
||||
https://posadskaya-hotel.ru
|
||||
|
@@ -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 рейтинг — допустим при согласии"
|
||||
@@ -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.
|
||||
@@ -10,22 +10,29 @@ from pathlib import Path
|
||||
|
||||
from scanner.engine import ScannerEngine
|
||||
from scanner.models import SiteScan
|
||||
from scanner.output import out
|
||||
from reports.generator import PDFReportGenerator
|
||||
|
||||
|
||||
async def scan_single(url: str, checks: list[str] | None = None) -> SiteScan:
|
||||
"""Просканировать один URL и вывести результат."""
|
||||
async def scan_single(url: str, checks: list[str] | None = None, deep: bool = False) -> SiteScan:
|
||||
"""Просканировать один URL и вывести результат.
|
||||
|
||||
Args:
|
||||
url: URL для сканирования
|
||||
checks: список проверок (None = QUICK_CHECKS)
|
||||
deep: запустить LLM-анализ политики после основных проверок
|
||||
"""
|
||||
engine = ScannerEngine()
|
||||
result = await engine.scan_url(url, checks)
|
||||
|
||||
# Выводим результаты в терминал
|
||||
print(f"\n{'='*60}")
|
||||
print(f"🔍 Сканирование: {url}")
|
||||
print(f"{'='*60}")
|
||||
print(f"📊 Балл: {result.score}/100")
|
||||
print(f"{'✅ Соответствует' if result.is_compliant else '❌ НЕ соответствует'} 152-ФЗ")
|
||||
print(f"📝 {result.summary}")
|
||||
print(f"\n{'─'*40}")
|
||||
out(f"\n{'='*60}")
|
||||
out(f"🔍 Сканирование: {url}")
|
||||
out(f"{'='*60}")
|
||||
out(f"📊 Балл: {result.score}/100")
|
||||
out(f"{'✅ Соответствует' if result.is_compliant else '❌ НЕ соответствует'} 152-ФЗ")
|
||||
out(f"📝 {result.summary}")
|
||||
out(f"\n{'─'*40}")
|
||||
|
||||
for check in result.checks:
|
||||
emoji = {
|
||||
@@ -34,9 +41,40 @@ async def scan_single(url: str, checks: list[str] | None = None) -> SiteScan:
|
||||
"skip": "⏭",
|
||||
"error": "⚠️",
|
||||
}.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
|
||||
|
||||
|
||||
@@ -63,11 +101,11 @@ def is_likely_hotel(url: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def scan_csv(filepath: str):
|
||||
def scan_csv(filepath: str, checks: list[str] | None = None):
|
||||
"""Парксить CSV-файл с URL и запустить сканирование."""
|
||||
csv_path = Path(filepath)
|
||||
if not csv_path.exists():
|
||||
print(f"❌ Файл не найден: {filepath}")
|
||||
out(f"❌ Файл не найден: {filepath}")
|
||||
sys.exit(1)
|
||||
|
||||
urls = []
|
||||
@@ -84,22 +122,22 @@ def scan_csv(filepath: str):
|
||||
skipped.append(url)
|
||||
|
||||
if skipped:
|
||||
print(f"⚠️ Пропущено {len(skipped)} URL (не похожи на отели):")
|
||||
out(f"⚠️ Пропущено {len(skipped)} URL (не похожи на отели):")
|
||||
for u in skipped[:5]:
|
||||
print(f" {u}")
|
||||
out(f" {u}")
|
||||
if len(skipped) > 5:
|
||||
print(f" ...и ещё {len(skipped) - 5}")
|
||||
print()
|
||||
out(f" ...и ещё {len(skipped) - 5}")
|
||||
out()
|
||||
|
||||
if not urls:
|
||||
print("❌ В CSV не найдено валидных URL")
|
||||
out("❌ В CSV не найдено валидных URL")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"📋 Загружено {len(urls)} URL из {csv_path}")
|
||||
out(f"📋 Загружено {len(urls)} URL из {csv_path}")
|
||||
|
||||
async def batch_scan():
|
||||
engine = ScannerEngine()
|
||||
results = await engine.scan_urls_batch(urls)
|
||||
results = await engine.scan_urls_batch(urls, checks)
|
||||
|
||||
output = []
|
||||
for r in results:
|
||||
@@ -119,7 +157,7 @@ def scan_csv(filepath: str):
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(output, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n📄 Результаты сохранены в: {json_path}")
|
||||
out(f"\n📄 Результаты сохранены в: {json_path}")
|
||||
|
||||
# Генерируем PDF для каждого сайта
|
||||
generator = PDFReportGenerator()
|
||||
@@ -128,20 +166,20 @@ def scan_csv(filepath: str):
|
||||
for result in output:
|
||||
try:
|
||||
pdf_path = generator.generate(result, str(reports_dir))
|
||||
print(f" 📄 PDF: {pdf_path}")
|
||||
out(f" 📄 PDF: {pdf_path}")
|
||||
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"])
|
||||
non_compliant = len(output) - compliant
|
||||
print(f"\n📊 Сводка:")
|
||||
print(f" Всего сайтов: {len(output)}")
|
||||
print(f" ✅ Соответствуют: {compliant}")
|
||||
print(f" ❌ НЕ соответствуют: {non_compliant}")
|
||||
out(f"\n📊 Сводка:")
|
||||
out(f" Всего сайтов: {len(output)}")
|
||||
out(f" ✅ Соответствуют: {compliant}")
|
||||
out(f" ❌ НЕ соответствуют: {non_compliant}")
|
||||
|
||||
# Показываем топ нарушений
|
||||
print(f"\n🔥 Топ нарушений:")
|
||||
out(f"\n🔥 Топ нарушений:")
|
||||
check_failures = {}
|
||||
for r in output:
|
||||
for check in r["checks"]:
|
||||
@@ -150,7 +188,7 @@ def scan_csv(filepath: str):
|
||||
check_failures[name] = check_failures.get(name, 0) + 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())
|
||||
|
||||
@@ -165,10 +203,12 @@ if __name__ == "__main__":
|
||||
single_parser = subparsers.add_parser("scan", help="Сканировать один URL")
|
||||
single_parser.add_argument("url", help="URL сайта для сканирования")
|
||||
single_parser.add_argument("--checks", nargs="+", help="Какие проверки запустить")
|
||||
single_parser.add_argument("--deep", action="store_true", help="Запустить LLM-анализ политики после сканирования")
|
||||
|
||||
# Подкоманда: csv
|
||||
csv_parser = subparsers.add_parser("csv", help="Сканировать CSV с URL")
|
||||
csv_parser.add_argument("filepath", help="Путь к CSV-файлу")
|
||||
csv_parser.add_argument("--checks", nargs="+", help="Какие проверки запустить")
|
||||
|
||||
# Подкоманда: server
|
||||
server_parser = subparsers.add_parser("server", help="Запустить API-сервер")
|
||||
@@ -178,9 +218,9 @@ if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
|
||||
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":
|
||||
scan_csv(args.filepath)
|
||||
scan_csv(args.filepath, args.checks)
|
||||
elif args.command == "server":
|
||||
import uvicorn
|
||||
uvicorn.run("api.server:app", host=args.host, port=args.port, reload=True)
|
||||
|
||||
@@ -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"]
|
||||
@@ -150,7 +150,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📋 Аудит соответствия 152-ФЗ</h1>
|
||||
<h1>Аудит соответствия 152-ФЗ</h1>
|
||||
<p>Автоматизированный сканер | {{ site_scan.url }}</p>
|
||||
<p>Дата проверки: {{ scanned_at }}</p>
|
||||
<p>Версия сканера: {{ scanner_version }}</p>
|
||||
@@ -159,16 +159,16 @@
|
||||
<div class="score-card">
|
||||
<div class="score-number">{{ score }}</div>
|
||||
<div class="score-label">
|
||||
{{ '✅ Сайт соответствует 152-ФЗ' if is_compliant else '❌ Сайт НЕ соответствует 152-ФЗ' }}
|
||||
{{ 'Сайт соответствует 152-ФЗ' if is_compliant else 'Сайт НЕ соответствует 152-ФЗ' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-box">
|
||||
<h3>📊 Резюме проверки</h3>
|
||||
<h3>Резюме проверки</h3>
|
||||
<p>{{ summary }}</p>
|
||||
</div>
|
||||
|
||||
<h2>📝 Результаты проверок</h2>
|
||||
<h2>Результаты проверок</h2>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -184,24 +184,24 @@
|
||||
<td><strong>{{ check.title }}</strong></td>
|
||||
<td>
|
||||
{% if check.status == 'pass' %}
|
||||
<span class="status-pass">✅ Пройдено</span>
|
||||
<span class="status-pass">ПРОЙДЕНО</span>
|
||||
{% elif check.status == 'fail' %}
|
||||
<span class="status-fail">❌ Не пройдено</span>
|
||||
<span class="status-fail">НАРУШЕНИЕ</span>
|
||||
{% elif check.status == 'skip' %}
|
||||
<span class="status-skip">⏭ Пропущено</span>
|
||||
<span class="status-skip">ПРОПУЩЕНО</span>
|
||||
{% elif check.status == 'error' %}
|
||||
<span class="status-error">⚠️ Ошибка</span>
|
||||
<span class="status-error">ОШИБКА</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if check.risk_level == 'critical' %}
|
||||
<span class="risk-critical">🔴 Критический</span>
|
||||
<span class="risk-critical">КРИТИЧЕСКИЙ</span>
|
||||
{% elif check.risk_level == 'high' %}
|
||||
<span class="risk-high">🟠 Высокий</span>
|
||||
<span class="risk-high">ВЫСОКИЙ</span>
|
||||
{% elif check.risk_level == 'medium' %}
|
||||
<span class="risk-medium">🟡 Средний</span>
|
||||
<span class="risk-medium">СРЕДНИЙ</span>
|
||||
{% else %}
|
||||
<span class="risk-low">🟢 Низкий</span>
|
||||
<span class="risk-low">НИЗКИЙ</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ check.message }}</td>
|
||||
@@ -213,10 +213,10 @@
|
||||
<div class="cta-section">
|
||||
<h2>Нужна помощь с приведением сайта в соответствие 152-ФЗ?</h2>
|
||||
<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;">
|
||||
<span class="cta-button">📧 Написать на email</span>
|
||||
<span class="cta-button">💬 Telegram</span>
|
||||
<span class="cta-button">Написать на email</span>
|
||||
<span class="cta-button">Telegram</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,3 +4,6 @@ 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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)}")
|
||||
@@ -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
|
||||
@@ -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=(
|
||||
f"Найдены внешние скрипты: "
|
||||
f"{', '.join(s['pattern'] for s in found_scripts)}. "
|
||||
f"Требуется согласие пользователя."
|
||||
),
|
||||
details={
|
||||
"scripts_found": found_scripts,
|
||||
"has_critical": False,
|
||||
},
|
||||
message=message,
|
||||
risk_level=RiskLevel.CRITICAL,
|
||||
details={"found": found},
|
||||
)
|
||||
|
||||
# Только warning → FAIL с HIGH
|
||||
return self._fail_result(
|
||||
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)}")
|
||||
@@ -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()
|
||||
|
||||
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):
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)}")
|
||||
@@ -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={
|
||||
@@ -78,10 +78,22 @@ class PrivacyLinkCheck(BaseCheck):
|
||||
},
|
||||
)
|
||||
|
||||
# Для 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(
|
||||
|
||||
@@ -79,37 +79,32 @@ 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}",
|
||||
]
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for api_url in apis:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
async with session.get(
|
||||
api_url,
|
||||
headers={"User-Agent": "Mozilla/5.0"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
|
||||
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"]
|
||||
# ipgeolocation формат
|
||||
if "country_name" in data:
|
||||
return data["country_name"]
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
+74
-15
@@ -11,9 +11,22 @@ from typing import Any
|
||||
|
||||
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
|
||||
|
||||
# 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:
|
||||
"""Управляет процессом сканирования сайтов."""
|
||||
@@ -22,6 +35,23 @@ class ScannerEngine:
|
||||
self.timeout = timeout
|
||||
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:
|
||||
"""
|
||||
Просканировать один URL.
|
||||
@@ -66,33 +96,56 @@ class ScannerEngine:
|
||||
});
|
||||
""")
|
||||
|
||||
# Загружаем страницу
|
||||
# Загружаем страницу с retry
|
||||
try:
|
||||
await page.goto(url, timeout=self.timeout, wait_until="networkidle")
|
||||
await self._retry_goto(page, url)
|
||||
except Exception as e:
|
||||
result.checks.append(
|
||||
CheckResult(
|
||||
name="page_load",
|
||||
title="Загрузка страницы",
|
||||
status=CheckStatus.ERROR,
|
||||
status=CheckStatus.FAIL,
|
||||
risk_level=RiskLevel.CRITICAL,
|
||||
message=f"Не удалось загрузить страницу: {str(e)}",
|
||||
)
|
||||
)
|
||||
result._calculate_score()
|
||||
return result
|
||||
|
||||
# Подождем, пока JavaScript отрендерит динамический контент
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# Запускаем все проверки
|
||||
check_names = checks if checks else list(ALL_CHECKS.keys())
|
||||
# Запускаем проверки (по умолчанию — быстрые, без LLM)
|
||||
check_names = checks if checks else QUICK_CHECKS
|
||||
|
||||
tasks = []
|
||||
for check_name in check_names:
|
||||
# Фаза 1: cookie_audit — ОДИН (кликает по кнопке, меняет DOM)
|
||||
# Не может работать параллельно с остальными проверками
|
||||
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)
|
||||
if check_cls:
|
||||
check_instance = check_cls()
|
||||
tasks.append(check_instance.check(page, url))
|
||||
try:
|
||||
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:
|
||||
check_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -104,8 +157,8 @@ class ScannerEngine:
|
||||
CheckResult(
|
||||
name="unknown",
|
||||
title="Неизвестная ошибка",
|
||||
status=CheckStatus.ERROR,
|
||||
risk_level=RiskLevel.CRITICAL,
|
||||
status=CheckStatus.FAIL,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
message=f"Необработанная ошибка: {str(cr)}",
|
||||
)
|
||||
)
|
||||
@@ -117,9 +170,15 @@ class ScannerEngine:
|
||||
result._calculate_score()
|
||||
return result
|
||||
|
||||
async def scan_urls_batch(self, urls: list[str], checks: list[str] | None = None) -> list[SiteScan]:
|
||||
"""Параллельно сканировать несколько URL."""
|
||||
tasks = [self.scan_url(url, checks) for url in urls]
|
||||
async def scan_urls_batch(self, urls: list[str], checks: list[str] | None = None, concurrency: int = 5) -> list[SiteScan]:
|
||||
"""Параллельно сканировать несколько URL с ограничением конкурентности."""
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user