From b9db93538970ab5a68814983434eedec84cd9316 Mon Sep 17 00:00:00 2001 From: neo <397763@gmail.com> Date: Wed, 10 Jun 2026 00:49:07 +0500 Subject: [PATCH] =?UTF-8?q?v2.0:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BA=D1=80=D0=B8=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20=D0=B1=D0=B0=D0=B3=D0=B8,=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=204=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B8,=204-=D1=84=D0=B0=D0=B7=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?cookie-=D0=B0=D1=83=D0=B4=D0=B8=D1=82,=20retry-=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=BA=D0=B0,=20YAML-=D1=81=D0=BB=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D0=B8,=20LLM-=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7,=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исправлено: - 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) --- .gitignore | 19 +- STRATEGY.md | 346 +++++++++++++++++++++++++++ api/server.py | 7 +- data/input/russia_hotels_domains.csv | 1 + data/input/test_batch.csv | 3 + dictionaries/trackers.yaml | 84 +++++++ logs/app.log | 46 ---- main.py | 102 +++++--- pyproject.toml | 54 +++++ reports/templates/report.html.j2 | 30 +-- requirements.txt | 3 + scanner/checks/__init__.py | 27 ++- scanner/checks/consent_doc.py | 76 ++++++ scanner/checks/cookie_audit.py | 187 +++++++++++++++ scanner/checks/external_scripts.py | 133 +++++----- scanner/checks/form_checkbox.py | 130 ++++++---- scanner/checks/llm_policy.py | 233 ++++++++++++++++++ scanner/checks/operator_info.py | 135 +++++++++++ scanner/checks/privacy_link.py | 34 ++- scanner/checks/server_location.py | 47 ++-- scanner/engine.py | 89 +++++-- scanner/logging_config.py | 41 ++++ scanner/output.py | 46 ++++ tests/test_smoke.py | 138 +++++++++++ 24 files changed, 1725 insertions(+), 286 deletions(-) create mode 100644 STRATEGY.md create mode 100644 data/input/russia_hotels_domains.csv create mode 100644 data/input/test_batch.csv create mode 100644 dictionaries/trackers.yaml delete mode 100644 logs/app.log create mode 100644 pyproject.toml create mode 100644 scanner/checks/consent_doc.py create mode 100644 scanner/checks/cookie_audit.py create mode 100644 scanner/checks/llm_policy.py create mode 100644 scanner/checks/operator_info.py create mode 100644 scanner/logging_config.py create mode 100644 scanner/output.py create mode 100644 tests/test_smoke.py diff --git a/.gitignore b/.gitignore index 5c888d5..638abc3 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/STRATEGY.md b/STRATEGY.md new file mode 100644 index 0000000..8c6fcbc --- /dev/null +++ b/STRATEGY.md @@ -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 | Ссылка рядом с каждой формой | Поиск
→ соседний с политикой | 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 | Ссылка на политику в баннере | Поиск внутри cookie-баннера | MEDIUM | + +### 3.3. Категория C: Формы и чекбоксы (5 проверок) + +| # | Проверка | Метод | Риск | +|---|----------|-------|------| +| C1 | Чекбокс в каждой форме | Поиск → 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 diff --git a/api/server.py b/api/server.py index fb84b28..1a06c38 100644 --- a/api/server.py +++ b/api/server.py @@ -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: diff --git a/data/input/russia_hotels_domains.csv b/data/input/russia_hotels_domains.csv new file mode 100644 index 0000000..53cabe6 --- /dev/null +++ b/data/input/russia_hotels_domains.csv @@ -0,0 +1 @@ +name,address,url,phone diff --git a/data/input/test_batch.csv b/data/input/test_batch.csv new file mode 100644 index 0000000..7303e40 --- /dev/null +++ b/data/input/test_batch.csv @@ -0,0 +1,3 @@ +url +https://hotel-astoria.ru +https://posadskaya-hotel.ru diff --git a/dictionaries/trackers.yaml b/dictionaries/trackers.yaml new file mode 100644 index 0000000..c22661e --- /dev/null +++ b/dictionaries/trackers.yaml @@ -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 рейтинг — допустим при согласии" diff --git a/logs/app.log b/logs/app.log deleted file mode 100644 index 9eee141..0000000 --- a/logs/app.log +++ /dev/null @@ -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. diff --git a/main.py b/main.py index 4d87614..48af3d1 100644 --- a/main.py +++ b/main.py @@ -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}") - print(f"{'='*60}\n") + 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}") + 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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cd0839a --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/reports/templates/report.html.j2 b/reports/templates/report.html.j2 index 0458c8c..8fb2cc2 100644 --- a/reports/templates/report.html.j2 +++ b/reports/templates/report.html.j2 @@ -150,7 +150,7 @@
-

📋 Аудит соответствия 152-ФЗ

+

Аудит соответствия 152-ФЗ

Автоматизированный сканер | {{ site_scan.url }}

Дата проверки: {{ scanned_at }}

Версия сканера: {{ scanner_version }}

@@ -159,16 +159,16 @@
{{ score }}
- {{ '✅ Сайт соответствует 152-ФЗ' if is_compliant else '❌ Сайт НЕ соответствует 152-ФЗ' }} + {{ 'Сайт соответствует 152-ФЗ' if is_compliant else 'Сайт НЕ соответствует 152-ФЗ' }}
-

📊 Резюме проверки

+

Резюме проверки

{{ summary }}

-

📝 Результаты проверок

+

Результаты проверок

@@ -184,24 +184,24 @@ @@ -213,10 +213,10 @@

Нужна помощь с приведением сайта в соответствие 152-ФЗ?

Мы исправим все найденные нарушения, подготовим документы и настроим cookie-баннеры.

-

💰 Стоимость: от 7 000 ₽ (зависит от объема нарушений)

+

Стоимость: от 7 000 руб. (зависит от объёма нарушений)

- 📧 Написать на email - 💬 Telegram + Написать на email + Telegram
diff --git a/requirements.txt b/requirements.txt index b972f82..1008ef2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scanner/checks/__init__.py b/scanner/checks/__init__.py index e614249..740b7e6 100644 --- a/scanner/checks/__init__.py +++ b/scanner/checks/__init__.py @@ -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", ] diff --git a/scanner/checks/consent_doc.py b/scanner/checks/consent_doc.py new file mode 100644 index 0000000..dcee7ba --- /dev/null +++ b/scanner/checks/consent_doc.py @@ -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)}") diff --git a/scanner/checks/cookie_audit.py b/scanner/checks/cookie_audit.py new file mode 100644 index 0000000..6918f51 --- /dev/null +++ b/scanner/checks/cookie_audit.py @@ -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 diff --git a/scanner/checks/external_scripts.py b/scanner/checks/external_scripts.py index d2fc6ec..942aef4 100644 --- a/scanner/checks/external_scripts.py +++ b/scanner/checks/external_scripts.py @@ -1,110 +1,99 @@ """ Проверка внешних скриптов, собирающих данные пользователей. -Google Analytics, Facebook Pixel, Яндекс.Метрика и другие — -отправляют данные на сервера за пределами РФ. +Загружает словари трекеров из dictionaries/trackers.yaml. +Разделяет на категории: CRITICAL (запрещены), WARNING (рискованны), RUSSIAN (допустимы). """ from __future__ import annotations import re +from pathlib import Path + +import yaml from playwright.async_api import Page from scanner.checks.base import BaseCheck from scanner.models import CheckResult, RiskLevel +# Путь к словарю трекеров +TRACKERS_YAML = Path(__file__).parent.parent.parent / "dictionaries" / "trackers.yaml" + class ExternalScriptsCheck(BaseCheck): """Проверяет наличие внешних скриптов, передающих данные за пределы РФ.""" name = "external_scripts" title = "Внешние скрипты аналитики" - description = "Ищет скрипты Google Analytics, Facebook Pixel и другие иностранные сервисы" + description = "Ищет скрипты Google Analytics, Meta Pixel и другие иностранные сервисы" risk_on_fail = RiskLevel.HIGH default_weight = 15 - EXTERNAL_PATTERNS = [ - r"google-analytics\.com", - r"googletagmanager\.com", - r"googlesyndication\.com", - r"googleadservices\.com", - r"googleapis\.com", - r"google\.com/recaptcha", - r"facebook\.com/tr", - r"fbcdn\.net", - r"connect\.facebook\.net", - r"mc\.yandex\.ru", - r"monitoring\.yandex\.ru", - r"top\.mail\.ru", - r"clarity\.microsoft\.com", - r"hotjar\.com", - r"mixpanel\.com", - r"segment\.com", - r"amplitude\.com", - r"zendesk\.com", - r"intercom\.io", - r"chatra\.com", - r"tilda\.cdn", - r"wix\.com", - r"wordpress\.com", - r"shopify\.com", - ] + def _load_trackers(self) -> dict: + """Загрузить словарь трекеров из YAML.""" + if not TRACKERS_YAML.exists(): + # Fallback: базовый набор + return { + "critical": [{"domain": "google-analytics.com"}, {"domain": "facebook.com/tr"}], + "warning": [{"domain": "fonts.googleapis.com"}, {"domain": "google.com/recaptcha"}], + "russian": [{"domain": "mc.yandex.ru"}, {"domain": "vk.com/rtrg"}], + } + with open(TRACKERS_YAML, "r", encoding="utf-8") as f: + return yaml.safe_load(f) async def check(self, page: Page, url: str) -> CheckResult: try: html = await page.evaluate("() => document.documentElement.outerHTML") + trackers = self._load_trackers() - found_scripts = [] - for pattern in self.EXTERNAL_PATTERNS: - matches = re.findall(pattern, html, re.IGNORECASE) - if matches: - found_scripts.append({ - "pattern": pattern, - "matches": len(matches), - }) + found = {"critical": [], "warning": [], "russian": []} - if not found_scripts: + for category in ("critical", "warning", "russian"): + for entry in trackers.get(category, []): + domain = entry["domain"] + if re.search(domain, html, re.IGNORECASE): + found[category].append(entry) + + if not any(found.values()): return self._pass_result( message="Внешние скрипты аналитики не найдены.", ) - critical_services = [ - "google-analytics", - "googletagmanager", - "facebook", - "clarity", - "segment", - "amplitude", - ] + # Формируем сообщение + parts = [] + if found["critical"]: + domains = [e["domain"] for e in found["critical"]] + parts.append(f"ЗАПРЕЩЁННЫЕ ({len(found['critical'])}): {', '.join(domains)}") + if found["warning"]: + domains = [e["domain"] for e in found["warning"]] + parts.append(f"РИСКОВАННЫЕ ({len(found['warning'])}): {', '.join(domains)}") + if found["russian"]: + domains = [e["domain"] for e in found["russian"]] + parts.append(f"Российские ({len(found['russian'])}): {', '.join(domains)}") - has_critical = False - for script in found_scripts: - if any(re.search(c, script["pattern"]) for c in critical_services): - has_critical = True - break + message = "Найдены внешние скрипты: " + " | ".join(parts) - if has_critical: - return self._fail_result( - message=( - f"Найдены внешние скрипты передачи данных: " - f"{', '.join(s['pattern'] for s in found_scripts)}. " - f"Они передают персональные данные за пределы РФ." - ), - details={ - "scripts_found": found_scripts, - "has_critical": True, - }, + # Только российские → PASS с уведомлением + if not found["critical"] and not found["warning"]: + return self._pass_result( + message=f"Найдены только российские сервисы: {message}", + details={"found": found}, ) + # Есть критические → FAIL + if found["critical"]: + actions = [f"{e['domain']}: {e.get('action', 'Удалить')}" for e in found["critical"]] + message += f"\nРекомендации: {'; '.join(actions)}" + return self._fail_result( + message=message, + risk_level=RiskLevel.CRITICAL, + details={"found": found}, + ) + + # Только warning → FAIL с HIGH return self._fail_result( - message=( - f"Найдены внешние скрипты: " - f"{', '.join(s['pattern'] for s in found_scripts)}. " - f"Требуется согласие пользователя." - ), - details={ - "scripts_found": found_scripts, - "has_critical": False, - }, + message=message, + risk_level=RiskLevel.HIGH, + details={"found": found}, ) except Exception as e: - return self._error_result(message=f"Ошибка: {str(e)}") + return self._error_result(message=f"Ошибка при проверке внешних скриптов: {str(e)}") diff --git a/scanner/checks/form_checkbox.py b/scanner/checks/form_checkbox.py index cce2e6e..e417e09 100644 --- a/scanner/checks/form_checkbox.py +++ b/scanner/checks/form_checkbox.py @@ -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. Ищем связанный
{{ check.title }} {% if check.status == 'pass' %} - ✅ Пройдено + ПРОЙДЕНО {% elif check.status == 'fail' %} - ❌ Не пройдено + НАРУШЕНИЕ {% elif check.status == 'skip' %} - ⏭ Пропущено + ПРОПУЩЕНО {% elif check.status == 'error' %} - ⚠️ Ошибка + ОШИБКА {% endif %} {% if check.risk_level == 'critical' %} - 🔴 Критический + КРИТИЧЕСКИЙ {% elif check.risk_level == 'high' %} - 🟠 Высокий + ВЫСОКИЙ {% elif check.risk_level == 'medium' %} - 🟡 Средний + СРЕДНИЙ {% else %} - 🟢 Низкий + НИЗКИЙ {% endif %} {{ check.message }}