Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

ATdot — поведенческий антифрод

Rust Next.js PostgreSQL License Status

Система поведенческой биометрии и обнаружения фрода в реальном времени. Анализирует то, как пользователь двигает мышью, кликает и скроллит — и на основе этого отличает человека от бота, живую сессию от угнанной, и органический трафик от скриптовых атак. Без капчи, без SMS, без трения для настоящих пользователей.


Центральная идея

Большинство антифрод-систем смотрят на что делает пользователь: откуда IP, какое устройство, не попал ли он в чёрный список. ATdot смотрит на как: на физические и когнитивные следы, которые человек оставляет в браузере и которые невозможно точно воспроизвести программно.

Человек, нажимая кнопку, проходит через несколько фаз: увидел → решил → потянулся к цели → скорректировал → нажал. Каждая фаза оставляет след в координатах мыши, временных метках и паузах. Скрипт этих следов не оставляет — он либо вызывает element.click() напрямую, либо воспроизводит математически идеальное движение, которого у живого человека не бывает.

Система строит персональный профиль каждого пользователя и оценивает отклонение каждого нового события от этого профиля. Аномалия может означать: другой человек за тем же аккаунтом, автоматизированный скрипт, или бот, который получил управление сессией после аутентификации.


Архитектура: три слоя оценки

Браузер пользователя
        │
        │  POST /api/ingest/batch (каждые 2 сек)
        ▼
┌───────────────────────────────────────────────────────────────────┐
│                         Fraud Engine                              │
│                                                                   │
│  ┌─────────────────────────────────────────────────────────────┐  │
│  │  L1 — персональный поведенческий граф + онлайн-эмбеддинг   │  │
│  │                                                             │  │
│  │  Хранит граф переходов между событиями для конкретного      │  │
│  │  пользователя. Оценивает: типичен ли текущий переход,       │  │
│  │  насколько оптимален маршрут (бот идёт кратчайшим путём),   │  │
│  │  не сломалась ли непрерывность сессии (handoff).            │  │
│  └─────────────────────────────────────────────────────────────┘  │
│                              │                                    │
│  ┌─────────────────────────────────────────────────────────────┐  │
│  │  L2 — сетевая и устройственная аномалия                     │  │
│  │                                                             │  │
│  │  IP-геолокация (MaxMind), VPN/Tor детект, WebRTC-утечка     │  │
│  │  реального IP за прокси, коллизии fingerprint,              │  │
│  │  несоответствие timezone заявленной геолокации.             │  │
│  └─────────────────────────────────────────────────────────────┘  │
│                              │                                    │
│  ┌─────────────────────────────────────────────────────────────┐  │
│  │  L3 — глобальные паттерны                                   │  │
│  │                                                             │  │
│  │  LCS-сравнение последовательности событий сессии с базой    │  │
│  │  известных человеческих паттернов. Паттерны проходят        │  │
│  │  карантин 3 дня и промоутируются только при достаточной     │  │
│  │  энтропии и humanity score.                                 │  │
│  └─────────────────────────────────────────────────────────────┘  │
│                              │                                    │
│            Взвешенное слияние + условная аномалия                 │
│                              │                                    │
│              allow / challenge / block                            │
└───────────────────────────────────────────────────────────────────┘

L1 — персональный профиль

Для каждого пользователя в памяти держится горячая запись (HotEntry), которая содержит два объекта:

BehaviorGraph — ориентированный взвешенный граф переходов между типами событий. При каждом событии фиксируется переход prev_event → current_event с временной меткой. По мере накопления данных граф показывает, какие последовательности типичны для этого пользователя и с какими паузами они происходят. Три сигнала из графа:

  • transition_score — насколько редок данный переход в истории пользователя
  • path_optimality_score — насколько текущий маршрут (последние 5 событий) похож на кратчайший из всех наблюдавшихся. Боты идут оптимально: page_view → login → checkout → pay. Люди блуждают.
  • familiarity — доля знакомых переходов. Используется для ослабления порога: если пользователь предсказуем, система реагирует на отклонения острее.

BehaviorEmbedding — онлайн-статистика 16-мерного вектора признаков сессии. Алгоритм Велфорда в расширенной форме: хранит среднее и M2-аккумулятор для вычисления дисперсии без хранения всей истории. При новой завершённой сессии вектор обновляет распределение. Аномальность текущей сессии вычисляется как нормированное отклонение по каждому измерению (Mahalanobis-подобная метрика):

anomaly = mean( |v[i] - μ[i]| / σ[i] ) по всем активным измерениям

Граф и эмбеддинг хранятся в RAM (DashMap + RwLock) пока пользователь активен, и вытесняются на диск (sled) после 5 минут неактивности фоновым воркером каждые 30 секунд.

Концептуальный дрейф. Поведение меняется со временем: новое устройство, смена привычек, смена работы. Чтобы эмбеддинг не застревал на старых данных, каждая новая сессия затухает предыдущие с коэффициентом decay = 0.97. Эффективное окно памяти — примерно 33 последние сессии:

eff_n  = eff_n × 0.97 + 1.0
M2[i]  = M2[i] × 0.97 + delta × delta2

Непрерывность сессии (ContinuitySignature). Детектирует handoff — момент, когда бот перехватывает управление у человека. У человека микрокоррекции мыши и hover-латентность имеют естественную дисперсию. Когда бот берёт управление, дисперсия резко коллапсирует к нулю. Система отслеживает скользящее окно последних 12 кликов: если дисперсия второй половины окна значительно меньше первой — сигнал тревоги.

drift_direction = -1.0  →  дисперсия упала  →  машинное поведение
anomaly_score = (drift_velocity × 5.0).clamp(0, 1)

L2 — сеть и устройство

Работает параллельно с L1 (до захвата блокировок графа). Проверяет:

  • IP → геолокация через MaxMind GeoLite2. Несоответствие стране по timezone — сигнал VPN.
  • VPN/Tor детект через базу известных диапазонов (хранится в отдельном дереве sled).
  • WebRTC-утечка — браузер раскрывает реальный IP через ICE-кандидаты даже за SOCKS-прокси. SDK собирает это при инициализации.
  • Fingerprint коллизии — если один fingerprint появляется в нескольких несвязанных сессиях одновременно, это признак клонированного браузера.
  • Смена timezone внутри сессии или несоответствие JS-timezone и IP-геолокации.

Canvas fingerprint — самый стабильный компонент: разные GPU-драйверы и шрифты дают разный растр одного и того же текста.

L3 — глобальные паттерны

Когда сессия завершается (page_hide), её последовательность событий сохраняется в pattern_candidates. При накоплении 5+ независимых сессий с одинаковой последовательностью — запускается промоушен.

Алгоритм промоушена:

  1. Карантин 3 дня — убеждаемся, что паттерн не из одной атаки
  2. Entropy check — равномерно ли паттерн встречается или это артефакт одного источника
  3. Humanity score — средний балл «человечности» сессий-доноров (Fitts compliance, hover latency, микрокоррекции)
  4. Diversity check — разные visitor_id и IP у сессий-доноров

Промоутированный паттерн попадает в known_patterns. При новой сессии — LCS (longest common subsequence) сравнение с каждым паттерном. Низкое сходство с любым известным человеческим паттерном — повышает скор.


Сигналы SDK: что и зачем собирается

SDK — это ~8kb vanilla JS без зависимостей. Слушает события браузера и каждые 2 секунды отправляет пакет в POST /api/ingest/batch.

Движение мыши и клики

Закон Фиттса. Время, которое нужно человеку чтобы достичь цели, предсказывается формулой:

T = a + b × log₂(1 + D / W)

где D — расстояние до цели, W — размер цели. SDK вычисляет fitts_id = log₂(1 + D/W) и паузу перед кликом. У человека пауза коррелирует с ID. У бота — нет.

Важная деталь: _mouseStart берётся не из mousedown, а из первой точки буфера на момент mousedown — то есть с начала баллистического движения к цели, а не с момента нажатия кнопки.

Микрокоррекции. Когда рука подходит к мелкой цели, мозг делает несколько корректирующих движений — dot product между соседними векторами становится отрицательным. SDK считает количество таких смен направления в последних 12 точках трека. Ноль микрокоррекций при длинной траектории — признак машинного движения.

Velocity profile. Движение человека имеет форму S-кривой: разгон, пик, замедление. Метрика 1 - final_velocity / max_velocity показывает, было ли замедление у цели. Равномерное движение или мгновенный клик — аномалия.

Hover latency. Человек читает кнопку перед нажатием — 80–400мс на знакомых элементах, дольше на новых. Время между mouseover и click фиксируется для каждого интерактивного элемента.

Novelty response. Первое взаимодействие с элементом требует больше времени, чем повторное (is_new_element). Бот обычно одинаково быстро кликает на всё.

Скролл

При page_hide в payload добавляются:

  • scroll_depth — максимальная глубина прокрутки (0–1)
  • scroll_avg_velocity — средняя скорость прокрутки в px/ms
  • scroll_pauses — количество пауз >500мс в скролле

Бот скроллит равномерно или не скроллит вообще. Человек читает: скролл → пауза → скролл.

Идентификация устройства

Fingerprint строится из: User-Agent, язык, разрешение экрана, timezone offset, количество ядер, объём RAM, количество плагинов — и canvas fingerprint. Canvas рендерит текст и дугу на offscreen-канвасе; последние 50 байт data URL уникальны для конкретного GPU-драйвера, шрифтового стека и настроек anti-aliasing.

visitor_id хранится в cookie с max-age=1 year как приоритетный источник, и дублируется в localStorage как fallback. Cookie переживает Safari ITP и инкогнито-режим лучше, чем только localStorage.

WebRTC детект

При инициализации SDK создаёт RTCPeerConnection и слушает ICE-кандидаты. Браузер в процессе ICE-обмена раскрывает все сетевые интерфейсы, включая реальный IP — даже за SOCKS5-прокси или VPN без DNS-leak protection. SDK фильтрует приватные диапазоны (10.x, 192.168.x, 172.16-31.x, 127.x) и отправляет публичный IP в поле webrtc_ip. Если он отличается от IP в заголовке запроса — сильный сигнал прокси.


Формула скоринга

s_rate = L1 × 0.32
       + L2 × 0.23
       + embedding × 0.18
       + combined_ho × 0.15
       + continuity × 0.07
       + L3 × 0.05

final_score = s_rate × (1 + 0.4 × global_risk × (1 − familiarity))

combined_ho = higher_order_score × 0.7 + path_optimality × 0.3

higher_order_score начисляется за мышиные аномалии клика: идеальная траектория (linearity > 0.95), мгновенный клик (<80ms), отсутствие novelty response при высокой линейности.

Глобальный риск события — множитель, отражающий ставки при данном типе события:

Событие Риск
password_change, withdrawal, transfer 1.00
purchase, checkout 0.90
login 0.80
registration 0.70
click 0.20
page_view 0.10
page_hide, scroll 0.05

Условная аномалия — если пользователь хорошо знаком системе (familiarity высокий), любое отклонение весит больше. Для нового пользователя с нулевой историей формула даёт скидку на незнакомость.

Адаптивный порог

Вместо фиксированного порога 0.75 для всех пользователей, система вычисляет персональный порог на основе средней дисперсии эмбеддинга:

base_threshold = clamp(0.65 − √avg_variance × 0.15, 0.40, 0.75)

Предсказуемый пользователь (малая дисперсия поведения) получает более жёсткий порог. Вариативный — более мягкий. Каждый подтверждённый случай фрода снижает порог на 0.04 (до 10 страйков), запоминая, что этот аккаунт уже компрометировался.

final_threshold = base_threshold − fraud_strikes × 0.04

Challenge-порог = block_threshold × 0.76.


Механизмы активной защиты

Honeypot

SDK при инициализации вставляет в DOM невидимый <input type="text" name="email_confirm"> с position:absolute; left:-9999px; pointer-events:none. Человек никогда его не тронет. Если на него приходит focus или change — это бот, заполняющий форму программно. Событие honeypot_trigger → score=1.0, action=Block, без анализа.

DOM perturbation (ephemeral IDs)

При инициализации _perturb() сканирует DOM и для каждой кнопки отправки формы, элемента с предсказуемым id (#submit, #login-btn, #pay, data-testid, data-cy и т.д.):

  1. Убирает предсказуемый id и automation-атрибуты с реального элемента
  2. Присваивает реальному элементу случайный нонс data-fp="a7k2q9x"
  3. Вставляет в DOM перед ним декой-клон с оригинальными предсказуемыми атрибутами

Декой оформлен через CSS: position:fixed; left:-9999px; display:block; opacity:1; width:120px; height:40px — визуально отсутствует, но getComputedStyle возвращает нормальные значения, что обманывает умных ботов, проверяющих видимость перед кликом. Бот, использующий document.querySelector('#submit').click(), попадает на декой и генерирует decoy_interaction. Человек кликает на видимую кнопку.

MutationObserver с дебаунсом 80мс перезапускает _perturbSubtree() на каждой новой партии DOM-нод — покрывает React/Next.js/Vue роут-переходы и лениво загружаемые компоненты.

Challenge-response

Когда скор попадает в зону Challenge (не достаточно для блока, но слишком высок для пропуска), сервер создаёт запись в таблице challenges со случайными координатами target_x/y (20–80% viewport, из байт UUID). Клиент получает параметры в ответе на ingest и SDK рендерит полноэкранный оверлей с кнопкой на смещённой позиции. Нажатие → POST /api/challenge/verify → если TTL не истёк и не решался раньше → action: allow.

Feedback loop

POST /api/feedback { session_id, confirmed_fraud: true } делает две вещи:

  1. Проставляет confirmed_fraud = true в session_scores
  2. Если пользователь горячий — обновляет fraud_strikes в памяти немедленно; если холодный — загружает эмбеддинг из sled, добавляет страйк, сохраняет обратно

Это закрывает петлю: решения команды риск-менеджмента влияют на будущие пороги.


Хранение данных

PostgreSQL:
  events              — сырые события с payload
  session_scores      — скоры по каждому событию (L1/L2/L3/embedding/action)
  pattern_candidates  — кандидаты в L3-паттерны
  known_patterns      — промоутированные глобальные паттерны
  honeypot_triggers   — срабатывания honeypot и decoy
  challenges          — challenge-response сессии
  api_keys            — ключи интеграций
  users               — аккаунты мерчантов

sled (встроенная key-value на диске):
  user_id → BehaviorGraph    — граф переходов (сериализован bincode)
  emb_<user_id> → BehaviorEmbedding  — онлайн-статистика 16 признаков
  vpn_ips → set<prefix>      — база VPN/Tor диапазонов

RAM (DashMap):

  • Горячие записи пользователей (HotEntry) живут 5 минут после последнего события
  • Фоновый воркер каждые 30 секунд вытесняет холодные в sled с предварительным pruning графа

Стек

Слой Технология Зачем
API Rust, Axum 0.7 Минимальная латентность на hot path скоринга
Fraud engine Rust (sync + async) L1 в памяти — zero-copy, без сериализации на критическом пути
Граф-хранилище sled 0.34 Встроенная LSM, без отдельного процесса
Hot cache DashMap + RwLock Concurrent access без глобальных блокировок
База данных PostgreSQL + SQLx Аналитика, паттерны, audit trail
WebSocket axum ws Real-time обновления дашборда без polling
Dashboard Next.js 15, React 19 Server components, App Router
SDK Vanilla JS IIFE Нет зависимостей, ~8kb, async/non-blocking

API: ключевые эндпоинты

Инgest (SDK → бэкенд)

POST /api/ingest/batch
X-API-Key: mdr_<key>

{ "events": [ { session_id, visitor_id, event_type, payload, user_id?, fingerprint?, ... } ] }

Ответ содержит action и опционально challenge для каждого события.

Дашборд (аутентифицированные)

Метод Путь Описание
GET /api/events/recent Последние 50 событий
GET /api/events/scores/:session_id Скоринг-детали сессии (L1/L2/L3, reasons)
GET /api/stats DAU, события, фрод-алерты
POST /api/feedback Подтвердить фрод / снять ложную тревогу

Debug (только локально, без auth)

GET /debug/stats

Возвращает последние 50 записей session_scores с полным разбором: L1/L2/L3/embedding, action, reasons, временная метка.


Иерархия идентификации

API-ключ → tenant_id (мерчант)
                │
    ┌───────────┴──────────────┐
    │                          │
user_id присутствует      только visitor_id
(atdot.identify() вызван)  (анонимный посетитель)
    │                          │
граф (tenant_id ⊗ user_id)  граф (tenant_id ⊗ visitor_id)
— сильный, не привязан       — слабее, привязан к cookie
  к конкретному браузеру
    │
fingerprint → только L2-сигнал:
  • изменился при том же user_id  → device mismatch (L2 +0.65)
  • виден у другого user_id       → collision (L2 +0.55)

Аккаунт первичен. Устройство — вторично. Fingerprint никогда не является ключом графа — только сигналом.


Когнитивный профиль рёбер графа

Каждое ребро A→B хранит не просто паузу, а когнитивный профиль перехода:

Поле Что измеряет
pause_stats Сколько думает пользователь между событиями
linearity_stats Насколько прямая траектория мыши при этом переходе
hover_stats Сколько нависает над элементом перед кликом
micro_stats Частота микрокоррекций (показатель неуверенности)
velocity_stats S-кривая замедления у цели
fitts_stats Моторная сложность цели

При скоринге: если текущий профиль отклоняется от накопленного (z-score > 1σ по 5+ наблюдениям) — сигнал.

Meta-variance — дисперсия дисперсий: насколько нестабильна сама нестабильность поведения. У человека есть характерная "амплитуда шума", у бота — либо нулевая (скриптовая стабильность) либо чисто случайная.


Известные ограничения / технический долг

Скоринг

  • event_global_risk (scoring.rs) захардкожена одной таблицей на всю систему — нет способа для мерчанта задать свои веса per-tenant (п.1.1.3).
  • hover_duration_ms и is_new_element — полностью клиентские числа из payload, сервер их не пересчитывает из более первичных данных (таймстемпы событий, DOM-состояние).
  • Первое событие новой session_id: session_pause_ms (l1.rs) возвращает pause_ms = 0.0, потому что prev в session_clock ещё None. Формально 0.0 < 80.0 — условие "мгновенный клик" в higher_order_score. На практике риск ложного срабатывания около нуля, так как higher_order_score реагирует только на event_type == "click", а первым событием сессии почти всегда идёт page_view/init. Стоит перепроверить на реальном потоке, что SDK никогда не шлёт click первым событием сессии до page_view.

Инфраструктура (упомянуто мимоходом, не проверялось глубже)

  • /api/ingest/batch: MAX_TRAJECTORY_POINTS обрезает уже распарсенный массив точек — не защищает от дорогого парсинга огромного JSON-тела до обрезки. Явного DefaultBodyLimit на роуте нет.
  • Rate limiting на ingest-эндпоинтах отсутствует.
  • fp_by_subject / subject_by_fp в sled (scoring.rs) растут без TTL/pruning.
  • GeoIp::lookup (geoip.rs) — каждый запрос это curl-сабпроцесс без кэша результатов.

Запуск

# бэкенд
cd backend
cp .env.example .env  # DATABASE_URL, JWT_SECRET, PORT=8080
cargo run

# дашборд
cd atdot
npm install
npm run dev

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages