@axeptio/behavior-detection 1.0.3 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -12
- package/dist/behavior-detection.esm.min.js +1 -1
- package/dist/behavior-detection.esm.min.js.map +4 -4
- package/dist/behavior-detection.min.js +1 -1
- package/dist/behavior-detection.min.js.map +3 -3
- package/dist/cjs/index.cjs +937 -29
- package/dist/esm/browser.js +0 -2
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/strategies/click.d.ts +8 -0
- package/dist/esm/strategies/click.js +108 -9
- package/dist/esm/strategies/environment.d.ts +39 -0
- package/dist/esm/strategies/environment.js +218 -16
- package/dist/esm/strategies/index.d.ts +4 -0
- package/dist/esm/strategies/index.js +4 -0
- package/dist/esm/strategies/mouse.d.ts +53 -1
- package/dist/esm/strategies/mouse.js +198 -2
- package/dist/esm/strategies/timing.d.ts +64 -0
- package/dist/esm/strategies/timing.js +308 -0
- package/dist/esm/strategies/visibility.d.ts +64 -0
- package/dist/esm/strategies/visibility.js +295 -0
- package/dist/index.d.ts +1 -1
- package/dist/strategies/click.d.ts +8 -0
- package/dist/strategies/environment.d.ts +39 -0
- package/dist/strategies/index.d.ts +4 -0
- package/dist/strategies/mouse.d.ts +53 -1
- package/dist/strategies/timing.d.ts +64 -0
- package/dist/strategies/visibility.d.ts +64 -0
- package/package.json +1 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timing Analysis Strategy
|
|
3
|
+
* Detects bot-like timing patterns in user interactions
|
|
4
|
+
*
|
|
5
|
+
* Key detection signals:
|
|
6
|
+
* - Pre-action stillness (humans have micro-movements before clicking)
|
|
7
|
+
* - Action timing patterns (machine-precise intervals)
|
|
8
|
+
* - Inactivity patterns (bursts after long silence, no idle variance)
|
|
9
|
+
* - Actions while tab is hidden (bots ignore visibility)
|
|
10
|
+
*/
|
|
11
|
+
import { BaseStrategy } from '../strategy.js';
|
|
12
|
+
import { calculateStatistics } from '../math-utils.js';
|
|
13
|
+
export class TimingStrategy extends BaseStrategy {
|
|
14
|
+
constructor() {
|
|
15
|
+
super(...arguments);
|
|
16
|
+
this.name = 'timing';
|
|
17
|
+
this.defaultWeight = 0.15;
|
|
18
|
+
this.actions = [];
|
|
19
|
+
this.mouseStillness = {
|
|
20
|
+
position: null,
|
|
21
|
+
lastMoveTime: 0,
|
|
22
|
+
microMovementCount: 0,
|
|
23
|
+
};
|
|
24
|
+
this.microMovements = [];
|
|
25
|
+
this.isActive = false;
|
|
26
|
+
this.mouseListener = null;
|
|
27
|
+
this.clickListener = null;
|
|
28
|
+
this.keydownListener = null;
|
|
29
|
+
this.scrollListener = null;
|
|
30
|
+
/** Time window to consider for pre-action stillness (ms) */
|
|
31
|
+
this.STILLNESS_WINDOW = 500;
|
|
32
|
+
/** Minimum distance to count as meaningful movement (px) */
|
|
33
|
+
this.MICRO_MOVEMENT_THRESHOLD = 1;
|
|
34
|
+
/** Maximum micro-movement to count (jitter vs intentional) (px) */
|
|
35
|
+
this.MAX_MICRO_MOVEMENT = 5;
|
|
36
|
+
/** Machine-precision threshold (intervals within this are suspicious) */
|
|
37
|
+
this.MACHINE_PRECISION_THRESHOLD = 5; // 5ms
|
|
38
|
+
}
|
|
39
|
+
start() {
|
|
40
|
+
if (this.isActive)
|
|
41
|
+
return;
|
|
42
|
+
this.isActive = true;
|
|
43
|
+
// Track mouse movement for stillness detection
|
|
44
|
+
this.mouseListener = (e) => {
|
|
45
|
+
const mouseEvent = e;
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const currentPos = { x: mouseEvent.clientX, y: mouseEvent.clientY };
|
|
48
|
+
if (this.mouseStillness.position) {
|
|
49
|
+
const dx = currentPos.x - this.mouseStillness.position.x;
|
|
50
|
+
const dy = currentPos.y - this.mouseStillness.position.y;
|
|
51
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
52
|
+
// Track micro-movements (1-5px range)
|
|
53
|
+
if (distance >= this.MICRO_MOVEMENT_THRESHOLD && distance <= this.MAX_MICRO_MOVEMENT) {
|
|
54
|
+
this.microMovements.push({ timestamp: now, distance });
|
|
55
|
+
}
|
|
56
|
+
// Any meaningful movement updates the last move time
|
|
57
|
+
if (distance >= this.MICRO_MOVEMENT_THRESHOLD) {
|
|
58
|
+
this.mouseStillness.lastMoveTime = now;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
this.mouseStillness.position = currentPos;
|
|
62
|
+
// Clean up old micro-movements (keep only last STILLNESS_WINDOW)
|
|
63
|
+
const cutoff = now - this.STILLNESS_WINDOW;
|
|
64
|
+
this.microMovements = this.microMovements.filter(m => m.timestamp >= cutoff);
|
|
65
|
+
};
|
|
66
|
+
// Track clicks with timing context
|
|
67
|
+
this.clickListener = (e) => {
|
|
68
|
+
this.recordAction('click', e);
|
|
69
|
+
};
|
|
70
|
+
// Track keydown with timing context
|
|
71
|
+
this.keydownListener = () => {
|
|
72
|
+
this.recordAction('keydown');
|
|
73
|
+
};
|
|
74
|
+
// Track scroll with timing context
|
|
75
|
+
this.scrollListener = () => {
|
|
76
|
+
this.recordAction('scroll');
|
|
77
|
+
};
|
|
78
|
+
document.addEventListener('mousemove', this.mouseListener, { passive: true });
|
|
79
|
+
document.addEventListener('click', this.clickListener, { passive: true });
|
|
80
|
+
document.addEventListener('keydown', this.keydownListener, { passive: true });
|
|
81
|
+
document.addEventListener('scroll', this.scrollListener, { passive: true });
|
|
82
|
+
}
|
|
83
|
+
recordAction(type, _event) {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
// Calculate time since last mouse movement
|
|
86
|
+
const timeSinceMouseMove = this.mouseStillness.lastMoveTime > 0
|
|
87
|
+
? now - this.mouseStillness.lastMoveTime
|
|
88
|
+
: -1;
|
|
89
|
+
// Count micro-movements in stillness window (already filtered in mouse listener)
|
|
90
|
+
this.mouseStillness.microMovementCount = this.microMovements.length;
|
|
91
|
+
this.actions.push({
|
|
92
|
+
type,
|
|
93
|
+
timestamp: now,
|
|
94
|
+
timeSinceMouseMove,
|
|
95
|
+
wasHidden: document.hidden,
|
|
96
|
+
});
|
|
97
|
+
// Notify detector
|
|
98
|
+
this.notifyEvent(0.7);
|
|
99
|
+
// Keep only last 100 actions for memory efficiency (shift is O(n) but rare)
|
|
100
|
+
if (this.actions.length > 100) {
|
|
101
|
+
this.actions.shift();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
stop() {
|
|
105
|
+
if (!this.isActive)
|
|
106
|
+
return;
|
|
107
|
+
this.isActive = false;
|
|
108
|
+
if (this.mouseListener) {
|
|
109
|
+
document.removeEventListener('mousemove', this.mouseListener);
|
|
110
|
+
this.mouseListener = null;
|
|
111
|
+
}
|
|
112
|
+
if (this.clickListener) {
|
|
113
|
+
document.removeEventListener('click', this.clickListener);
|
|
114
|
+
this.clickListener = null;
|
|
115
|
+
}
|
|
116
|
+
if (this.keydownListener) {
|
|
117
|
+
document.removeEventListener('keydown', this.keydownListener);
|
|
118
|
+
this.keydownListener = null;
|
|
119
|
+
}
|
|
120
|
+
if (this.scrollListener) {
|
|
121
|
+
document.removeEventListener('scroll', this.scrollListener);
|
|
122
|
+
this.scrollListener = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
reset() {
|
|
126
|
+
this.actions = [];
|
|
127
|
+
this.microMovements = [];
|
|
128
|
+
this.mouseStillness = {
|
|
129
|
+
position: null,
|
|
130
|
+
lastMoveTime: 0,
|
|
131
|
+
microMovementCount: 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
score() {
|
|
135
|
+
const actionCount = this.actions.length;
|
|
136
|
+
if (actionCount < 3)
|
|
137
|
+
return undefined;
|
|
138
|
+
let score = 0;
|
|
139
|
+
let factors = 0;
|
|
140
|
+
// Cache filtered arrays to avoid repeated iteration
|
|
141
|
+
const clicks = [];
|
|
142
|
+
const clicksWithMouse = [];
|
|
143
|
+
let hiddenCount = 0;
|
|
144
|
+
for (const action of this.actions) {
|
|
145
|
+
if (action.type === 'click') {
|
|
146
|
+
clicks.push(action);
|
|
147
|
+
if (action.timeSinceMouseMove >= 0) {
|
|
148
|
+
clicksWithMouse.push(action);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (action.wasHidden)
|
|
152
|
+
hiddenCount++;
|
|
153
|
+
}
|
|
154
|
+
// 1. Pre-action stillness detection
|
|
155
|
+
if (clicks.length >= 2) {
|
|
156
|
+
const stillnessScore = this.scorePreActionStillness(clicks);
|
|
157
|
+
if (stillnessScore !== undefined) {
|
|
158
|
+
score += stillnessScore;
|
|
159
|
+
factors++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// 2. Mouse-to-action delay analysis
|
|
163
|
+
if (clicksWithMouse.length >= 3) {
|
|
164
|
+
const mouseToClickScore = this.scoreMouseToClickDelayFromCache(clicksWithMouse);
|
|
165
|
+
if (mouseToClickScore !== undefined) {
|
|
166
|
+
score += mouseToClickScore;
|
|
167
|
+
factors++;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// 3. Action interval patterns
|
|
171
|
+
if (actionCount >= 5) {
|
|
172
|
+
const intervalScore = this.scoreActionIntervals();
|
|
173
|
+
if (intervalScore !== undefined) {
|
|
174
|
+
score += intervalScore;
|
|
175
|
+
factors++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// 4. Hidden document actions
|
|
179
|
+
const hiddenScore = this.scoreHiddenActionsFromCount(hiddenCount, actionCount);
|
|
180
|
+
score += hiddenScore;
|
|
181
|
+
factors++;
|
|
182
|
+
return factors > 0 ? score / factors : undefined;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Score based on pre-action stillness
|
|
186
|
+
* Humans have micro-movements before clicking; bots are perfectly still
|
|
187
|
+
*/
|
|
188
|
+
scorePreActionStillness(clicks) {
|
|
189
|
+
// Look at clicks with mouse data
|
|
190
|
+
const clicksWithMouseData = clicks.filter(c => c.timeSinceMouseMove >= 0);
|
|
191
|
+
if (clicksWithMouseData.length < 2)
|
|
192
|
+
return undefined;
|
|
193
|
+
// Count clicks with zero pre-action movement (100-500ms window)
|
|
194
|
+
const perfectlyStillClicks = clicksWithMouseData.filter(c => {
|
|
195
|
+
// Time since mouse move in the suspicious range (100-500ms of perfect stillness)
|
|
196
|
+
return c.timeSinceMouseMove >= 100 && c.timeSinceMouseMove <= 500 &&
|
|
197
|
+
this.mouseStillness.microMovementCount === 0;
|
|
198
|
+
}).length;
|
|
199
|
+
const stillRatio = perfectlyStillClicks / clicksWithMouseData.length;
|
|
200
|
+
// High ratio of perfectly still clicks = likely bot
|
|
201
|
+
// 0% still = very human (1.0)
|
|
202
|
+
// 50%+ still = suspicious (0.3)
|
|
203
|
+
// 100% still = very bot-like (0.1)
|
|
204
|
+
if (stillRatio >= 0.9)
|
|
205
|
+
return 0.1;
|
|
206
|
+
if (stillRatio >= 0.7)
|
|
207
|
+
return 0.3;
|
|
208
|
+
if (stillRatio >= 0.5)
|
|
209
|
+
return 0.5;
|
|
210
|
+
if (stillRatio >= 0.3)
|
|
211
|
+
return 0.7;
|
|
212
|
+
return 1.0;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Score mouse-to-click delay patterns (using pre-filtered cache)
|
|
216
|
+
*/
|
|
217
|
+
scoreMouseToClickDelayFromCache(clicksWithMouse) {
|
|
218
|
+
const delays = clicksWithMouse.map(c => c.timeSinceMouseMove);
|
|
219
|
+
const stats = calculateStatistics(delays);
|
|
220
|
+
// Suspiciously fast clicks (< 10ms average)
|
|
221
|
+
if (stats.mean < 10)
|
|
222
|
+
return 0.1;
|
|
223
|
+
// Suspiciously consistent delays (CV < 0.2)
|
|
224
|
+
if (stats.cv < 0.2 && clicksWithMouse.length >= 5)
|
|
225
|
+
return 0.2;
|
|
226
|
+
// Normal human range (50-300ms with variation)
|
|
227
|
+
if (stats.mean >= 50 && stats.mean <= 300 && stats.cv >= 0.3)
|
|
228
|
+
return 1.0;
|
|
229
|
+
// Somewhat suspicious
|
|
230
|
+
if (stats.cv < 0.4)
|
|
231
|
+
return 0.6;
|
|
232
|
+
return 0.8;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Score action intervals for machine-like precision
|
|
236
|
+
* Detects exact intervals: 100ms, 500ms, 1000ms
|
|
237
|
+
*/
|
|
238
|
+
scoreActionIntervals() {
|
|
239
|
+
if (this.actions.length < 5)
|
|
240
|
+
return undefined;
|
|
241
|
+
const intervals = [];
|
|
242
|
+
for (let i = 1; i < this.actions.length; i++) {
|
|
243
|
+
intervals.push(this.actions[i].timestamp - this.actions[i - 1].timestamp);
|
|
244
|
+
}
|
|
245
|
+
// Check for machine-precise intervals (multiples of common values)
|
|
246
|
+
const machineIntervals = [100, 200, 250, 500, 1000];
|
|
247
|
+
let preciseCount = 0;
|
|
248
|
+
for (const interval of intervals) {
|
|
249
|
+
for (const machineInterval of machineIntervals) {
|
|
250
|
+
// Check if interval is within threshold of machine interval or its multiples
|
|
251
|
+
const remainder = interval % machineInterval;
|
|
252
|
+
if (remainder < this.MACHINE_PRECISION_THRESHOLD ||
|
|
253
|
+
(machineInterval - remainder) < this.MACHINE_PRECISION_THRESHOLD) {
|
|
254
|
+
preciseCount++;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const preciseRatio = preciseCount / intervals.length;
|
|
260
|
+
// High ratio of machine-precise intervals = bot
|
|
261
|
+
if (preciseRatio >= 0.8)
|
|
262
|
+
return 0.1;
|
|
263
|
+
if (preciseRatio >= 0.6)
|
|
264
|
+
return 0.3;
|
|
265
|
+
if (preciseRatio >= 0.4)
|
|
266
|
+
return 0.5;
|
|
267
|
+
// Also check coefficient of variation (should be high for humans)
|
|
268
|
+
const stats = calculateStatistics(intervals);
|
|
269
|
+
if (stats.cv < 0.15)
|
|
270
|
+
return 0.2; // Too consistent
|
|
271
|
+
return 1.0;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Score based on actions while document was hidden (using pre-computed count)
|
|
275
|
+
*/
|
|
276
|
+
scoreHiddenActionsFromCount(hiddenCount, totalCount) {
|
|
277
|
+
if (hiddenCount === 0)
|
|
278
|
+
return 1.0; // Perfect - no hidden actions
|
|
279
|
+
const hiddenRatio = hiddenCount / totalCount;
|
|
280
|
+
// Any hidden actions are suspicious
|
|
281
|
+
if (hiddenRatio > 0.5)
|
|
282
|
+
return 0.1; // Major bot indicator
|
|
283
|
+
if (hiddenRatio > 0.2)
|
|
284
|
+
return 0.3;
|
|
285
|
+
if (hiddenRatio > 0)
|
|
286
|
+
return 0.5;
|
|
287
|
+
return 1.0;
|
|
288
|
+
}
|
|
289
|
+
getDebugInfo() {
|
|
290
|
+
const clicks = this.actions.filter(a => a.type === 'click');
|
|
291
|
+
const intervals = [];
|
|
292
|
+
for (let i = 1; i < this.actions.length; i++) {
|
|
293
|
+
intervals.push(this.actions[i].timestamp - this.actions[i - 1].timestamp);
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
actionCount: this.actions.length,
|
|
297
|
+
clickCount: clicks.length,
|
|
298
|
+
keydownCount: this.actions.filter(a => a.type === 'keydown').length,
|
|
299
|
+
scrollCount: this.actions.filter(a => a.type === 'scroll').length,
|
|
300
|
+
hiddenActionCount: this.actions.filter(a => a.wasHidden).length,
|
|
301
|
+
microMovementCount: this.microMovements.length,
|
|
302
|
+
intervals: intervals.slice(-20), // Last 20 intervals
|
|
303
|
+
lastStillnessDuration: this.mouseStillness.lastMoveTime > 0
|
|
304
|
+
? Date.now() - this.mouseStillness.lastMoveTime
|
|
305
|
+
: null,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visibility Strategy
|
|
3
|
+
* Detects bot-like behavior based on tab visibility and focus patterns
|
|
4
|
+
*
|
|
5
|
+
* Key detection signals:
|
|
6
|
+
* - Actions while document.hidden === true (bots ignore visibility)
|
|
7
|
+
* - No pause when tab loses focus
|
|
8
|
+
* - Activity resumes too quickly after tab regains focus
|
|
9
|
+
* - Focus patterns: humans focus inputs before typing
|
|
10
|
+
*/
|
|
11
|
+
import { BaseStrategy } from '../strategy.js';
|
|
12
|
+
export declare class VisibilityStrategy extends BaseStrategy {
|
|
13
|
+
readonly name = "visibility";
|
|
14
|
+
readonly defaultWeight = 0.1;
|
|
15
|
+
private events;
|
|
16
|
+
private focusTypingPairs;
|
|
17
|
+
private actionsWhileHidden;
|
|
18
|
+
private lastVisibilityChange;
|
|
19
|
+
private resumeDelays;
|
|
20
|
+
private isActive;
|
|
21
|
+
private visibilityListener;
|
|
22
|
+
private focusListener;
|
|
23
|
+
private blurListener;
|
|
24
|
+
private clickListener;
|
|
25
|
+
private keydownListener;
|
|
26
|
+
private inputFocusListener;
|
|
27
|
+
private lastFocusedInput;
|
|
28
|
+
private hasTypedInFocusedInput;
|
|
29
|
+
private lastActionTime;
|
|
30
|
+
private preHideActionTime;
|
|
31
|
+
start(): void;
|
|
32
|
+
private recordAction;
|
|
33
|
+
stop(): void;
|
|
34
|
+
reset(): void;
|
|
35
|
+
score(): number | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Score based on actions while document was hidden
|
|
38
|
+
* Humans can't interact with hidden tabs; bots can
|
|
39
|
+
*/
|
|
40
|
+
private scoreHiddenActions;
|
|
41
|
+
/**
|
|
42
|
+
* Score based on how quickly activity resumes after tab becomes visible
|
|
43
|
+
* Humans need time to refocus (100-500ms minimum)
|
|
44
|
+
* Bots often resume instantly (< 50ms)
|
|
45
|
+
*/
|
|
46
|
+
private scoreResumeDelays;
|
|
47
|
+
/**
|
|
48
|
+
* Score based on focus-to-keypress timing
|
|
49
|
+
* Humans focus inputs before typing (natural delay 100-500ms)
|
|
50
|
+
* Bots often type without focusing or with instant delay
|
|
51
|
+
*/
|
|
52
|
+
private scoreFocusTyping;
|
|
53
|
+
getDebugInfo(): {
|
|
54
|
+
eventCount: number;
|
|
55
|
+
actionsWhileHidden: number;
|
|
56
|
+
visibilityChanges: number;
|
|
57
|
+
focusChanges: number;
|
|
58
|
+
resumeDelays: number[];
|
|
59
|
+
focusTypingPairs: {
|
|
60
|
+
delay: number;
|
|
61
|
+
element: string;
|
|
62
|
+
}[];
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visibility Strategy
|
|
3
|
+
* Detects bot-like behavior based on tab visibility and focus patterns
|
|
4
|
+
*
|
|
5
|
+
* Key detection signals:
|
|
6
|
+
* - Actions while document.hidden === true (bots ignore visibility)
|
|
7
|
+
* - No pause when tab loses focus
|
|
8
|
+
* - Activity resumes too quickly after tab regains focus
|
|
9
|
+
* - Focus patterns: humans focus inputs before typing
|
|
10
|
+
*/
|
|
11
|
+
import { BaseStrategy } from '../strategy.js';
|
|
12
|
+
export class VisibilityStrategy extends BaseStrategy {
|
|
13
|
+
constructor() {
|
|
14
|
+
super(...arguments);
|
|
15
|
+
this.name = 'visibility';
|
|
16
|
+
this.defaultWeight = 0.10;
|
|
17
|
+
this.events = [];
|
|
18
|
+
this.focusTypingPairs = [];
|
|
19
|
+
this.actionsWhileHidden = 0;
|
|
20
|
+
this.lastVisibilityChange = null;
|
|
21
|
+
this.resumeDelays = [];
|
|
22
|
+
this.isActive = false;
|
|
23
|
+
// Listeners
|
|
24
|
+
this.visibilityListener = null;
|
|
25
|
+
this.focusListener = null;
|
|
26
|
+
this.blurListener = null;
|
|
27
|
+
this.clickListener = null;
|
|
28
|
+
this.keydownListener = null;
|
|
29
|
+
this.inputFocusListener = null;
|
|
30
|
+
// State
|
|
31
|
+
this.lastFocusedInput = null;
|
|
32
|
+
this.hasTypedInFocusedInput = false;
|
|
33
|
+
this.lastActionTime = 0;
|
|
34
|
+
this.preHideActionTime = 0;
|
|
35
|
+
}
|
|
36
|
+
start() {
|
|
37
|
+
if (this.isActive)
|
|
38
|
+
return;
|
|
39
|
+
this.isActive = true;
|
|
40
|
+
// Track visibility changes
|
|
41
|
+
this.visibilityListener = () => {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const wasHidden = document.hidden;
|
|
44
|
+
// Track time between visibility changes
|
|
45
|
+
if (this.lastVisibilityChange && !wasHidden) {
|
|
46
|
+
// Tab just became visible again
|
|
47
|
+
// Check if there was activity right before becoming visible
|
|
48
|
+
// Humans need time to refocus after tab switch
|
|
49
|
+
if (this.lastActionTime > 0 && this.preHideActionTime > 0) {
|
|
50
|
+
const timeSinceLastAction = now - this.lastActionTime;
|
|
51
|
+
// If action was very recent (< 50ms), suspicious
|
|
52
|
+
if (timeSinceLastAction < 50) {
|
|
53
|
+
this.actionsWhileHidden++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Track resume delay (time until first action after visibility)
|
|
57
|
+
// Will be calculated when next action occurs
|
|
58
|
+
this.lastVisibilityChange = { hidden: false, timestamp: now };
|
|
59
|
+
}
|
|
60
|
+
else if (wasHidden) {
|
|
61
|
+
// Tab just became hidden - record pre-hide action time
|
|
62
|
+
this.preHideActionTime = this.lastActionTime;
|
|
63
|
+
this.lastVisibilityChange = { hidden: true, timestamp: now };
|
|
64
|
+
}
|
|
65
|
+
this.events.push({
|
|
66
|
+
type: 'visibility_change',
|
|
67
|
+
timestamp: now,
|
|
68
|
+
wasHidden,
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
// Track window focus
|
|
72
|
+
this.focusListener = () => {
|
|
73
|
+
this.events.push({
|
|
74
|
+
type: 'focus_change',
|
|
75
|
+
timestamp: Date.now(),
|
|
76
|
+
wasHidden: document.hidden,
|
|
77
|
+
wasFocused: true,
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
this.blurListener = () => {
|
|
81
|
+
this.events.push({
|
|
82
|
+
type: 'focus_change',
|
|
83
|
+
timestamp: Date.now(),
|
|
84
|
+
wasHidden: document.hidden,
|
|
85
|
+
wasFocused: false,
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
// Track actions while hidden
|
|
89
|
+
this.clickListener = (e) => {
|
|
90
|
+
this.recordAction('click', e);
|
|
91
|
+
};
|
|
92
|
+
this.keydownListener = (e) => {
|
|
93
|
+
this.recordAction('keydown', e);
|
|
94
|
+
// Track focus-to-keypress timing
|
|
95
|
+
if (this.lastFocusedInput && !this.hasTypedInFocusedInput) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const delay = now - this.lastFocusedInput.timestamp;
|
|
98
|
+
this.focusTypingPairs.push({
|
|
99
|
+
focusTime: this.lastFocusedInput.timestamp,
|
|
100
|
+
firstKeypressTime: now,
|
|
101
|
+
delay,
|
|
102
|
+
element: this.lastFocusedInput.element,
|
|
103
|
+
});
|
|
104
|
+
this.hasTypedInFocusedInput = true;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
// Track input focus for focus-typing analysis
|
|
108
|
+
this.inputFocusListener = (e) => {
|
|
109
|
+
const target = e.target;
|
|
110
|
+
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
|
111
|
+
this.lastFocusedInput = {
|
|
112
|
+
element: target.tagName.toLowerCase(),
|
|
113
|
+
timestamp: Date.now(),
|
|
114
|
+
};
|
|
115
|
+
this.hasTypedInFocusedInput = false;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
document.addEventListener('visibilitychange', this.visibilityListener);
|
|
119
|
+
window.addEventListener('focus', this.focusListener);
|
|
120
|
+
window.addEventListener('blur', this.blurListener);
|
|
121
|
+
document.addEventListener('click', this.clickListener, { passive: true });
|
|
122
|
+
document.addEventListener('keydown', this.keydownListener, { passive: true });
|
|
123
|
+
document.addEventListener('focusin', this.inputFocusListener, { passive: true });
|
|
124
|
+
}
|
|
125
|
+
recordAction(_type, _event) {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
this.lastActionTime = now;
|
|
128
|
+
// Check if action occurred while document was hidden
|
|
129
|
+
if (document.hidden) {
|
|
130
|
+
this.actionsWhileHidden++;
|
|
131
|
+
this.events.push({
|
|
132
|
+
type: 'action_while_hidden',
|
|
133
|
+
timestamp: now,
|
|
134
|
+
wasHidden: true,
|
|
135
|
+
});
|
|
136
|
+
// Notify detector - this is a strong bot signal
|
|
137
|
+
this.notifyEvent(1.0);
|
|
138
|
+
}
|
|
139
|
+
// Track resume delay after visibility change
|
|
140
|
+
if (this.lastVisibilityChange && !this.lastVisibilityChange.hidden) {
|
|
141
|
+
const resumeDelay = now - this.lastVisibilityChange.timestamp;
|
|
142
|
+
// Only count if this is the first action after becoming visible
|
|
143
|
+
// and the delay is reasonable (< 10 seconds)
|
|
144
|
+
if (resumeDelay > 0 && resumeDelay < 10000 && this.resumeDelays.length < 20) {
|
|
145
|
+
// Check if we haven't already recorded a delay for this visibility change
|
|
146
|
+
const lastRecordedDelay = this.resumeDelays[this.resumeDelays.length - 1];
|
|
147
|
+
if (lastRecordedDelay === undefined || Math.abs(resumeDelay - lastRecordedDelay) > 100) {
|
|
148
|
+
this.resumeDelays.push(resumeDelay);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
stop() {
|
|
154
|
+
if (!this.isActive)
|
|
155
|
+
return;
|
|
156
|
+
this.isActive = false;
|
|
157
|
+
if (this.visibilityListener) {
|
|
158
|
+
document.removeEventListener('visibilitychange', this.visibilityListener);
|
|
159
|
+
this.visibilityListener = null;
|
|
160
|
+
}
|
|
161
|
+
if (this.focusListener) {
|
|
162
|
+
window.removeEventListener('focus', this.focusListener);
|
|
163
|
+
this.focusListener = null;
|
|
164
|
+
}
|
|
165
|
+
if (this.blurListener) {
|
|
166
|
+
window.removeEventListener('blur', this.blurListener);
|
|
167
|
+
this.blurListener = null;
|
|
168
|
+
}
|
|
169
|
+
if (this.clickListener) {
|
|
170
|
+
document.removeEventListener('click', this.clickListener);
|
|
171
|
+
this.clickListener = null;
|
|
172
|
+
}
|
|
173
|
+
if (this.keydownListener) {
|
|
174
|
+
document.removeEventListener('keydown', this.keydownListener);
|
|
175
|
+
this.keydownListener = null;
|
|
176
|
+
}
|
|
177
|
+
if (this.inputFocusListener) {
|
|
178
|
+
document.removeEventListener('focusin', this.inputFocusListener);
|
|
179
|
+
this.inputFocusListener = null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
reset() {
|
|
183
|
+
this.events = [];
|
|
184
|
+
this.focusTypingPairs = [];
|
|
185
|
+
this.actionsWhileHidden = 0;
|
|
186
|
+
this.lastVisibilityChange = null;
|
|
187
|
+
this.resumeDelays = [];
|
|
188
|
+
this.lastFocusedInput = null;
|
|
189
|
+
this.hasTypedInFocusedInput = false;
|
|
190
|
+
this.lastActionTime = 0;
|
|
191
|
+
this.preHideActionTime = 0;
|
|
192
|
+
}
|
|
193
|
+
score() {
|
|
194
|
+
// Need some visibility events to score
|
|
195
|
+
if (this.events.length < 2)
|
|
196
|
+
return undefined;
|
|
197
|
+
let score = 0;
|
|
198
|
+
let factors = 0;
|
|
199
|
+
// 1. Actions while hidden (strongest signal)
|
|
200
|
+
const hiddenActionScore = this.scoreHiddenActions();
|
|
201
|
+
if (hiddenActionScore !== undefined) {
|
|
202
|
+
score += hiddenActionScore;
|
|
203
|
+
factors++;
|
|
204
|
+
}
|
|
205
|
+
// 2. Resume delay analysis
|
|
206
|
+
const resumeScore = this.scoreResumeDelays();
|
|
207
|
+
if (resumeScore !== undefined) {
|
|
208
|
+
score += resumeScore;
|
|
209
|
+
factors++;
|
|
210
|
+
}
|
|
211
|
+
// 3. Focus-typing pattern
|
|
212
|
+
const focusTypingScore = this.scoreFocusTyping();
|
|
213
|
+
if (focusTypingScore !== undefined) {
|
|
214
|
+
score += focusTypingScore;
|
|
215
|
+
factors++;
|
|
216
|
+
}
|
|
217
|
+
return factors > 0 ? score / factors : undefined;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Score based on actions while document was hidden
|
|
221
|
+
* Humans can't interact with hidden tabs; bots can
|
|
222
|
+
*/
|
|
223
|
+
scoreHiddenActions() {
|
|
224
|
+
if (this.actionsWhileHidden === 0)
|
|
225
|
+
return 1.0; // Perfect
|
|
226
|
+
// Any action while hidden is highly suspicious
|
|
227
|
+
if (this.actionsWhileHidden >= 5)
|
|
228
|
+
return 0.1;
|
|
229
|
+
if (this.actionsWhileHidden >= 3)
|
|
230
|
+
return 0.2;
|
|
231
|
+
if (this.actionsWhileHidden >= 1)
|
|
232
|
+
return 0.3;
|
|
233
|
+
return 1.0;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Score based on how quickly activity resumes after tab becomes visible
|
|
237
|
+
* Humans need time to refocus (100-500ms minimum)
|
|
238
|
+
* Bots often resume instantly (< 50ms)
|
|
239
|
+
*/
|
|
240
|
+
scoreResumeDelays() {
|
|
241
|
+
if (this.resumeDelays.length < 2)
|
|
242
|
+
return undefined;
|
|
243
|
+
// Count suspiciously fast resumes (< 50ms)
|
|
244
|
+
const fastResumeCount = this.resumeDelays.filter(d => d < 50).length;
|
|
245
|
+
const fastResumeRatio = fastResumeCount / this.resumeDelays.length;
|
|
246
|
+
if (fastResumeRatio >= 0.8)
|
|
247
|
+
return 0.1;
|
|
248
|
+
if (fastResumeRatio >= 0.5)
|
|
249
|
+
return 0.3;
|
|
250
|
+
if (fastResumeRatio >= 0.3)
|
|
251
|
+
return 0.5;
|
|
252
|
+
if (fastResumeRatio > 0)
|
|
253
|
+
return 0.7;
|
|
254
|
+
return 1.0;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Score based on focus-to-keypress timing
|
|
258
|
+
* Humans focus inputs before typing (natural delay 100-500ms)
|
|
259
|
+
* Bots often type without focusing or with instant delay
|
|
260
|
+
*/
|
|
261
|
+
scoreFocusTyping() {
|
|
262
|
+
if (this.focusTypingPairs.length < 2)
|
|
263
|
+
return undefined;
|
|
264
|
+
let suspiciousCount = 0;
|
|
265
|
+
for (const pair of this.focusTypingPairs) {
|
|
266
|
+
// Instant typing after focus (< 20ms) is suspicious
|
|
267
|
+
if (pair.delay < 20) {
|
|
268
|
+
suspiciousCount++;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const suspiciousRatio = suspiciousCount / this.focusTypingPairs.length;
|
|
272
|
+
if (suspiciousRatio >= 0.8)
|
|
273
|
+
return 0.2;
|
|
274
|
+
if (suspiciousRatio >= 0.5)
|
|
275
|
+
return 0.4;
|
|
276
|
+
if (suspiciousRatio >= 0.3)
|
|
277
|
+
return 0.6;
|
|
278
|
+
if (suspiciousRatio > 0)
|
|
279
|
+
return 0.8;
|
|
280
|
+
return 1.0;
|
|
281
|
+
}
|
|
282
|
+
getDebugInfo() {
|
|
283
|
+
return {
|
|
284
|
+
eventCount: this.events.length,
|
|
285
|
+
actionsWhileHidden: this.actionsWhileHidden,
|
|
286
|
+
visibilityChanges: this.events.filter(e => e.type === 'visibility_change').length,
|
|
287
|
+
focusChanges: this.events.filter(e => e.type === 'focus_change').length,
|
|
288
|
+
resumeDelays: this.resumeDelays,
|
|
289
|
+
focusTypingPairs: this.focusTypingPairs.map(p => ({
|
|
290
|
+
delay: p.delay,
|
|
291
|
+
element: p.element,
|
|
292
|
+
})),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -33,6 +33,6 @@
|
|
|
33
33
|
*/
|
|
34
34
|
export { BehaviorDetector } from './behavior-detector.js';
|
|
35
35
|
export type { DetectionStrategy, StrategyConfig } from './strategy.js';
|
|
36
|
-
export { Mouse, Scroll, Click, Tap, Keyboard, Environment, Resize, MouseStrategy, ScrollStrategy, ClickStrategy, TapStrategy, KeyboardStrategy, EnvironmentStrategy, ResizeStrategy, } from './strategies/index.js';
|
|
36
|
+
export { Mouse, Scroll, Click, Tap, Keyboard, Environment, Resize, Timing, Visibility, MouseStrategy, ScrollStrategy, ClickStrategy, TapStrategy, KeyboardStrategy, EnvironmentStrategy, ResizeStrategy, TimingStrategy, VisibilityStrategy, } from './strategies/index.js';
|
|
37
37
|
export type { BehaviorSettings, ScoreOptions, ScoreResult, ScoreBreakdown, TrackedEvent, EventType, ScoringFunction, } from './types.js';
|
|
38
38
|
export { DEFAULT_SETTINGS } from './types.js';
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Click Behavior Detection Strategy
|
|
3
3
|
* Monitors specific elements for click positioning patterns
|
|
4
|
+
*
|
|
5
|
+
* Enhanced detection:
|
|
6
|
+
* - Click-mouse position delta (bots using .click() have mismatched positions)
|
|
7
|
+
* - Pre-click mouse trajectory analysis
|
|
8
|
+
* - Time between last mousemove and click
|
|
4
9
|
*/
|
|
5
10
|
import { BaseStrategy } from '../strategy.js';
|
|
6
11
|
export declare class ClickStrategy extends BaseStrategy {
|
|
@@ -35,5 +40,8 @@ export declare class ClickStrategy extends BaseStrategy {
|
|
|
35
40
|
};
|
|
36
41
|
inViewport: number;
|
|
37
42
|
trackedElements: number;
|
|
43
|
+
mouseClickDeltas: number[];
|
|
44
|
+
timeSinceMouseMoves: number[];
|
|
45
|
+
untrustedClicks: number;
|
|
38
46
|
};
|
|
39
47
|
}
|