Система поведенческой биометрии и обнаружения фрода в реальном времени. Анализирует то, как пользователь двигает мышью, кликает и скроллит — и на основе этого отличает человека от бота, живую сессию от угнанной, и органический трафик от скриптовых атак. Без капчи, без 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 │
└───────────────────────────────────────────────────────────────────┘
Для каждого пользователя в памяти держится горячая запись (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)
Работает параллельно с L1 (до захвата блокировок графа). Проверяет:
- IP → геолокация через MaxMind GeoLite2. Несоответствие стране по timezone — сигнал VPN.
- VPN/Tor детект через базу известных диапазонов (хранится в отдельном дереве sled).
- WebRTC-утечка — браузер раскрывает реальный IP через ICE-кандидаты даже за SOCKS-прокси. SDK собирает это при инициализации.
- Fingerprint коллизии — если один fingerprint появляется в нескольких несвязанных сессиях одновременно, это признак клонированного браузера.
- Смена timezone внутри сессии или несоответствие JS-timezone и IP-геолокации.
Canvas fingerprint — самый стабильный компонент: разные GPU-драйверы и шрифты дают разный растр одного и того же текста.
Когда сессия завершается (page_hide), её последовательность событий сохраняется в pattern_candidates. При накоплении 5+ независимых сессий с одинаковой последовательностью — запускается промоушен.
Алгоритм промоушена:
- Карантин 3 дня — убеждаемся, что паттерн не из одной атаки
- Entropy check — равномерно ли паттерн встречается или это артефакт одного источника
- Humanity score — средний балл «человечности» сессий-доноров (Fitts compliance, hover latency, микрокоррекции)
- Diversity check — разные visitor_id и IP у сессий-доноров
Промоутированный паттерн попадает в known_patterns. При новой сессии — LCS (longest common subsequence) сравнение с каждым паттерном. Низкое сходство с любым известным человеческим паттерном — повышает скор.
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/msscroll_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.
При инициализации 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.
SDK при инициализации вставляет в DOM невидимый <input type="text" name="email_confirm"> с position:absolute; left:-9999px; pointer-events:none. Человек никогда его не тронет. Если на него приходит focus или change — это бот, заполняющий форму программно. Событие honeypot_trigger → score=1.0, action=Block, без анализа.
При инициализации _perturb() сканирует DOM и для каждой кнопки отправки формы, элемента с предсказуемым id (#submit, #login-btn, #pay, data-testid, data-cy и т.д.):
- Убирает предсказуемый
idи automation-атрибуты с реального элемента - Присваивает реальному элементу случайный нонс
data-fp="a7k2q9x" - Вставляет в 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 (не достаточно для блока, но слишком высок для пропуска), сервер создаёт запись в таблице challenges со случайными координатами target_x/y (20–80% viewport, из байт UUID). Клиент получает параметры в ответе на ingest и SDK рендерит полноэкранный оверлей с кнопкой на смещённой позиции. Нажатие → POST /api/challenge/verify → если TTL не истёк и не решался раньше → action: allow.
POST /api/feedback { session_id, confirmed_fraud: true } делает две вещи:
- Проставляет
confirmed_fraud = trueвsession_scores - Если пользователь горячий — обновляет
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 |
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 |
Подтвердить фрод / снять ложную тревогу |
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