@surbee/cipher 0.1.0 → 0.2.0
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 +95 -0
- package/dist/checks/index.d.mts +215 -0
- package/dist/checks/index.d.ts +215 -0
- package/dist/checks/index.js +1157 -0
- package/dist/checks/index.mjs +60 -0
- package/dist/chunk-P2MIOVFQ.mjs +1104 -0
- package/dist/index.d.mts +38 -244
- package/dist/index.d.ts +38 -244
- package/dist/index.js +1716 -35
- package/dist/index.mjs +649 -35
- package/dist/types-C8t_T3bP.d.mts +251 -0
- package/dist/types-C8t_T3bP.d.ts +251 -0
- package/package.json +16 -4
- package/src/checks/behavioral.ts +0 -527
- package/src/checks/content.ts +0 -372
- package/src/checks/device.ts +0 -384
- package/src/checks/index.ts +0 -59
- package/src/checks/timing.ts +0 -256
- package/src/cipher.ts +0 -225
- package/src/index.ts +0 -75
- package/src/tiers.ts +0 -507
- package/src/types.ts +0 -366
- package/test/cipher.test.ts +0 -245
- package/test/fixtures.ts +0 -627
- package/tsconfig.json +0 -20
package/dist/index.js
CHANGED
|
@@ -25,67 +25,1695 @@ __export(index_exports, {
|
|
|
25
25
|
});
|
|
26
26
|
module.exports = __toCommonJS(index_exports);
|
|
27
27
|
|
|
28
|
-
// src/
|
|
29
|
-
var
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
// src/tiers.ts
|
|
29
|
+
var CHECKS = {
|
|
30
|
+
// ============================================
|
|
31
|
+
// TIER 1 - Basic Behavioral (Offline)
|
|
32
|
+
// ============================================
|
|
33
|
+
rapid_completion: {
|
|
34
|
+
id: "rapid_completion",
|
|
35
|
+
name: "Rapid Completion",
|
|
36
|
+
description: "Detects impossibly fast survey completion",
|
|
37
|
+
tier: 1,
|
|
38
|
+
category: "behavioral",
|
|
39
|
+
offline: true
|
|
34
40
|
},
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
uniform_timing: {
|
|
42
|
+
id: "uniform_timing",
|
|
43
|
+
name: "Uniform Timing",
|
|
44
|
+
description: "Detects robotic consistent response times",
|
|
45
|
+
tier: 1,
|
|
46
|
+
category: "behavioral",
|
|
47
|
+
offline: true
|
|
48
|
+
},
|
|
49
|
+
low_interaction: {
|
|
50
|
+
id: "low_interaction",
|
|
51
|
+
name: "Low Interaction",
|
|
52
|
+
description: "Detects minimal mouse/keyboard activity",
|
|
53
|
+
tier: 1,
|
|
54
|
+
category: "behavioral",
|
|
55
|
+
offline: true
|
|
56
|
+
},
|
|
57
|
+
straight_line_answers: {
|
|
58
|
+
id: "straight_line_answers",
|
|
59
|
+
name: "Straight-Lining",
|
|
60
|
+
description: "Detects selecting same option repeatedly",
|
|
61
|
+
tier: 1,
|
|
62
|
+
category: "content",
|
|
63
|
+
offline: true
|
|
64
|
+
},
|
|
65
|
+
impossibly_fast: {
|
|
66
|
+
id: "impossibly_fast",
|
|
67
|
+
name: "Speed Reading",
|
|
68
|
+
description: "Detects reading faster than humanly possible",
|
|
69
|
+
tier: 1,
|
|
70
|
+
category: "timing",
|
|
71
|
+
offline: true
|
|
72
|
+
},
|
|
73
|
+
minimal_effort: {
|
|
74
|
+
id: "minimal_effort",
|
|
75
|
+
name: "Minimal Effort",
|
|
76
|
+
description: "Detects very short or low-quality text responses",
|
|
77
|
+
tier: 1,
|
|
78
|
+
category: "content",
|
|
79
|
+
offline: true
|
|
80
|
+
},
|
|
81
|
+
// ============================================
|
|
82
|
+
// TIER 2 - Device/Automation (Offline)
|
|
83
|
+
// ============================================
|
|
84
|
+
excessive_paste: {
|
|
85
|
+
id: "excessive_paste",
|
|
86
|
+
name: "Excessive Paste",
|
|
87
|
+
description: "Detects heavy copy-paste behavior",
|
|
88
|
+
tier: 2,
|
|
89
|
+
category: "behavioral",
|
|
90
|
+
offline: true
|
|
91
|
+
},
|
|
92
|
+
pointer_spikes: {
|
|
93
|
+
id: "pointer_spikes",
|
|
94
|
+
name: "Pointer Velocity Spikes",
|
|
95
|
+
description: "Detects unnatural mouse movement patterns",
|
|
96
|
+
tier: 2,
|
|
97
|
+
category: "behavioral",
|
|
98
|
+
offline: true
|
|
99
|
+
},
|
|
100
|
+
webdriver_detected: {
|
|
101
|
+
id: "webdriver_detected",
|
|
102
|
+
name: "WebDriver Detection",
|
|
103
|
+
description: "Detects Selenium/automation tools",
|
|
104
|
+
tier: 2,
|
|
105
|
+
category: "device",
|
|
106
|
+
offline: true
|
|
107
|
+
},
|
|
108
|
+
automation_detected: {
|
|
109
|
+
id: "automation_detected",
|
|
110
|
+
name: "Automation Detection",
|
|
111
|
+
description: "Detects headless browsers and bots",
|
|
112
|
+
tier: 2,
|
|
113
|
+
category: "device",
|
|
114
|
+
offline: true
|
|
115
|
+
},
|
|
116
|
+
no_plugins: {
|
|
117
|
+
id: "no_plugins",
|
|
118
|
+
name: "Missing Plugins",
|
|
119
|
+
description: "Detects suspicious browser configurations",
|
|
120
|
+
tier: 2,
|
|
121
|
+
category: "device",
|
|
122
|
+
offline: true
|
|
123
|
+
},
|
|
124
|
+
suspicious_user_agent: {
|
|
125
|
+
id: "suspicious_user_agent",
|
|
126
|
+
name: "Suspicious User Agent",
|
|
127
|
+
description: "Detects bot-like user agent strings",
|
|
128
|
+
tier: 2,
|
|
129
|
+
category: "device",
|
|
130
|
+
offline: true
|
|
131
|
+
},
|
|
132
|
+
device_fingerprint_mismatch: {
|
|
133
|
+
id: "device_fingerprint_mismatch",
|
|
134
|
+
name: "Device Mismatch",
|
|
135
|
+
description: "Detects inconsistent device characteristics",
|
|
136
|
+
tier: 2,
|
|
137
|
+
category: "device",
|
|
138
|
+
offline: true
|
|
139
|
+
},
|
|
140
|
+
screen_anomaly: {
|
|
141
|
+
id: "screen_anomaly",
|
|
142
|
+
name: "Screen Anomaly",
|
|
143
|
+
description: "Detects impossible screen dimensions",
|
|
144
|
+
tier: 2,
|
|
145
|
+
category: "device",
|
|
146
|
+
offline: true
|
|
147
|
+
},
|
|
148
|
+
suspicious_pauses: {
|
|
149
|
+
id: "suspicious_pauses",
|
|
150
|
+
name: "Suspicious Pauses",
|
|
151
|
+
description: "Detects unusual gaps in activity",
|
|
152
|
+
tier: 2,
|
|
153
|
+
category: "timing",
|
|
154
|
+
offline: true
|
|
155
|
+
},
|
|
156
|
+
// ============================================
|
|
157
|
+
// TIER 3 - Enhanced Behavioral + Light AI
|
|
158
|
+
// ============================================
|
|
159
|
+
robotic_typing: {
|
|
160
|
+
id: "robotic_typing",
|
|
161
|
+
name: "Robotic Typing",
|
|
162
|
+
description: "Detects uniform keystroke timing",
|
|
163
|
+
tier: 3,
|
|
164
|
+
category: "behavioral",
|
|
165
|
+
offline: true
|
|
166
|
+
},
|
|
167
|
+
mouse_teleporting: {
|
|
168
|
+
id: "mouse_teleporting",
|
|
169
|
+
name: "Mouse Teleporting",
|
|
170
|
+
description: "Detects large instant mouse jumps",
|
|
171
|
+
tier: 3,
|
|
172
|
+
category: "behavioral",
|
|
173
|
+
offline: true
|
|
174
|
+
},
|
|
175
|
+
no_corrections: {
|
|
176
|
+
id: "no_corrections",
|
|
177
|
+
name: "No Corrections",
|
|
178
|
+
description: "Detects perfect typing with no backspaces",
|
|
179
|
+
tier: 3,
|
|
180
|
+
category: "behavioral",
|
|
181
|
+
offline: true
|
|
182
|
+
},
|
|
183
|
+
excessive_tab_switching: {
|
|
184
|
+
id: "excessive_tab_switching",
|
|
185
|
+
name: "Tab Switching",
|
|
186
|
+
description: "Detects frequent tab/window changes",
|
|
187
|
+
tier: 3,
|
|
188
|
+
category: "content",
|
|
189
|
+
offline: true
|
|
190
|
+
},
|
|
191
|
+
window_focus_loss: {
|
|
192
|
+
id: "window_focus_loss",
|
|
193
|
+
name: "Focus Loss",
|
|
194
|
+
description: "Detects extended periods away from survey",
|
|
195
|
+
tier: 3,
|
|
196
|
+
category: "content",
|
|
197
|
+
offline: true
|
|
198
|
+
},
|
|
199
|
+
ai_content_basic: {
|
|
200
|
+
id: "ai_content_basic",
|
|
201
|
+
name: "AI Content (Basic)",
|
|
202
|
+
description: "Light AI-generated text detection",
|
|
203
|
+
tier: 3,
|
|
204
|
+
category: "ai",
|
|
205
|
+
offline: false
|
|
206
|
+
},
|
|
207
|
+
contradiction_basic: {
|
|
208
|
+
id: "contradiction_basic",
|
|
209
|
+
name: "Contradiction (Basic)",
|
|
210
|
+
description: "Basic response consistency check",
|
|
211
|
+
tier: 3,
|
|
212
|
+
category: "ai",
|
|
213
|
+
offline: false
|
|
214
|
+
},
|
|
215
|
+
// ============================================
|
|
216
|
+
// TIER 4 - Advanced Analysis
|
|
217
|
+
// ============================================
|
|
218
|
+
hover_behavior: {
|
|
219
|
+
id: "hover_behavior",
|
|
220
|
+
name: "Hover Patterns",
|
|
221
|
+
description: "Analyzes mouse hover behavior before clicks",
|
|
222
|
+
tier: 4,
|
|
223
|
+
category: "behavioral",
|
|
224
|
+
offline: true
|
|
225
|
+
},
|
|
226
|
+
scroll_patterns: {
|
|
227
|
+
id: "scroll_patterns",
|
|
228
|
+
name: "Scroll Patterns",
|
|
229
|
+
description: "Analyzes reading/scrolling behavior",
|
|
230
|
+
tier: 4,
|
|
231
|
+
category: "behavioral",
|
|
232
|
+
offline: true
|
|
233
|
+
},
|
|
234
|
+
mouse_acceleration: {
|
|
235
|
+
id: "mouse_acceleration",
|
|
236
|
+
name: "Mouse Acceleration",
|
|
237
|
+
description: "Analyzes natural mouse acceleration",
|
|
238
|
+
tier: 4,
|
|
239
|
+
category: "behavioral",
|
|
240
|
+
offline: true
|
|
241
|
+
},
|
|
242
|
+
vpn_detection: {
|
|
243
|
+
id: "vpn_detection",
|
|
244
|
+
name: "VPN Detection",
|
|
245
|
+
description: "Detects VPN/proxy usage",
|
|
246
|
+
tier: 4,
|
|
247
|
+
category: "network",
|
|
248
|
+
offline: false
|
|
249
|
+
},
|
|
250
|
+
datacenter_ip: {
|
|
251
|
+
id: "datacenter_ip",
|
|
252
|
+
name: "Datacenter IP",
|
|
253
|
+
description: "Detects cloud/datacenter IPs",
|
|
254
|
+
tier: 4,
|
|
255
|
+
category: "network",
|
|
256
|
+
offline: false
|
|
257
|
+
},
|
|
258
|
+
plagiarism_basic: {
|
|
259
|
+
id: "plagiarism_basic",
|
|
260
|
+
name: "Plagiarism (Basic)",
|
|
261
|
+
description: "Quick check for copied content",
|
|
262
|
+
tier: 4,
|
|
263
|
+
category: "ai",
|
|
264
|
+
offline: false
|
|
265
|
+
},
|
|
266
|
+
quality_assessment: {
|
|
267
|
+
id: "quality_assessment",
|
|
268
|
+
name: "Quality Assessment",
|
|
269
|
+
description: "AI assessment of response quality",
|
|
270
|
+
tier: 4,
|
|
271
|
+
category: "ai",
|
|
272
|
+
offline: false
|
|
273
|
+
},
|
|
274
|
+
semantic_analysis: {
|
|
275
|
+
id: "semantic_analysis",
|
|
276
|
+
name: "Semantic Analysis",
|
|
277
|
+
description: "AI analysis of response meaning",
|
|
278
|
+
tier: 4,
|
|
279
|
+
category: "ai",
|
|
280
|
+
offline: false
|
|
281
|
+
},
|
|
282
|
+
// ============================================
|
|
283
|
+
// TIER 5 - Maximum (Opus 4.5)
|
|
284
|
+
// ============================================
|
|
285
|
+
ai_content_full: {
|
|
286
|
+
id: "ai_content_full",
|
|
287
|
+
name: "AI Content (Full)",
|
|
288
|
+
description: "Comprehensive AI-generated text detection",
|
|
289
|
+
tier: 5,
|
|
290
|
+
category: "ai",
|
|
291
|
+
offline: false
|
|
292
|
+
},
|
|
293
|
+
contradiction_full: {
|
|
294
|
+
id: "contradiction_full",
|
|
295
|
+
name: "Contradiction (Full)",
|
|
296
|
+
description: "Deep semantic contradiction analysis",
|
|
297
|
+
tier: 5,
|
|
298
|
+
category: "ai",
|
|
299
|
+
offline: false
|
|
300
|
+
},
|
|
301
|
+
plagiarism_full: {
|
|
302
|
+
id: "plagiarism_full",
|
|
303
|
+
name: "Plagiarism (Full)",
|
|
304
|
+
description: "Comprehensive plagiarism detection",
|
|
305
|
+
tier: 5,
|
|
306
|
+
category: "ai",
|
|
307
|
+
offline: false
|
|
308
|
+
},
|
|
309
|
+
fraud_ring_detection: {
|
|
310
|
+
id: "fraud_ring_detection",
|
|
311
|
+
name: "Fraud Ring",
|
|
312
|
+
description: "Detects coordinated fraud attempts",
|
|
313
|
+
tier: 5,
|
|
314
|
+
category: "ai",
|
|
315
|
+
offline: false
|
|
316
|
+
},
|
|
317
|
+
answer_sharing: {
|
|
318
|
+
id: "answer_sharing",
|
|
319
|
+
name: "Answer Sharing",
|
|
320
|
+
description: "Detects identical answers across respondents",
|
|
321
|
+
tier: 5,
|
|
322
|
+
category: "ai",
|
|
323
|
+
offline: false
|
|
324
|
+
},
|
|
325
|
+
coordinated_timing: {
|
|
326
|
+
id: "coordinated_timing",
|
|
327
|
+
name: "Coordinated Timing",
|
|
328
|
+
description: "Detects synchronized submissions",
|
|
329
|
+
tier: 5,
|
|
330
|
+
category: "ai",
|
|
331
|
+
offline: false
|
|
332
|
+
},
|
|
333
|
+
device_sharing: {
|
|
334
|
+
id: "device_sharing",
|
|
335
|
+
name: "Device Sharing",
|
|
336
|
+
description: "Detects same device across respondents",
|
|
337
|
+
tier: 5,
|
|
338
|
+
category: "ai",
|
|
339
|
+
offline: false
|
|
340
|
+
},
|
|
341
|
+
tor_detection: {
|
|
342
|
+
id: "tor_detection",
|
|
343
|
+
name: "Tor Detection",
|
|
344
|
+
description: "Detects Tor exit node IPs",
|
|
345
|
+
tier: 5,
|
|
346
|
+
category: "network",
|
|
347
|
+
offline: false
|
|
348
|
+
},
|
|
349
|
+
proxy_detection: {
|
|
350
|
+
id: "proxy_detection",
|
|
351
|
+
name: "Proxy Detection",
|
|
352
|
+
description: "Detects proxy server usage",
|
|
353
|
+
tier: 5,
|
|
354
|
+
category: "network",
|
|
355
|
+
offline: false
|
|
356
|
+
},
|
|
357
|
+
timezone_validation: {
|
|
358
|
+
id: "timezone_validation",
|
|
359
|
+
name: "Timezone Validation",
|
|
360
|
+
description: "Validates timezone consistency",
|
|
361
|
+
tier: 5,
|
|
362
|
+
category: "network",
|
|
363
|
+
offline: true
|
|
364
|
+
},
|
|
365
|
+
baseline_deviation: {
|
|
366
|
+
id: "baseline_deviation",
|
|
367
|
+
name: "Baseline Deviation",
|
|
368
|
+
description: "Compares against established behavioral baseline",
|
|
369
|
+
tier: 5,
|
|
370
|
+
category: "ai",
|
|
371
|
+
offline: false
|
|
372
|
+
},
|
|
373
|
+
perplexity_analysis: {
|
|
374
|
+
id: "perplexity_analysis",
|
|
375
|
+
name: "Perplexity Analysis",
|
|
376
|
+
description: "Statistical text predictability analysis",
|
|
377
|
+
tier: 5,
|
|
378
|
+
category: "ai",
|
|
379
|
+
offline: false
|
|
380
|
+
},
|
|
381
|
+
burstiness_analysis: {
|
|
382
|
+
id: "burstiness_analysis",
|
|
383
|
+
name: "Burstiness Analysis",
|
|
384
|
+
description: "Sentence length variation analysis",
|
|
385
|
+
tier: 5,
|
|
386
|
+
category: "ai",
|
|
387
|
+
offline: false
|
|
388
|
+
}
|
|
37
389
|
};
|
|
38
|
-
var
|
|
390
|
+
var TIERS = {
|
|
39
391
|
1: {
|
|
40
392
|
name: "Basic",
|
|
41
|
-
description: "Essential fraud detection with behavioral heuristics",
|
|
42
|
-
|
|
393
|
+
description: "Essential fraud detection with behavioral heuristics. Free, fully offline.",
|
|
394
|
+
checks: {
|
|
395
|
+
behavioral: ["rapid_completion", "uniform_timing", "low_interaction"],
|
|
396
|
+
timing: ["impossibly_fast"],
|
|
397
|
+
device: [],
|
|
398
|
+
content: ["straight_line_answers", "minimal_effort"],
|
|
399
|
+
network: [],
|
|
400
|
+
ai: []
|
|
401
|
+
},
|
|
402
|
+
aiModel: null,
|
|
403
|
+
offline: true,
|
|
404
|
+
estimatedCostPerResponse: 0
|
|
43
405
|
},
|
|
44
406
|
2: {
|
|
45
407
|
name: "Standard",
|
|
46
|
-
description: "Adds device fingerprinting and automation detection",
|
|
47
|
-
|
|
408
|
+
description: "Adds device fingerprinting and automation detection. Free, fully offline.",
|
|
409
|
+
checks: {
|
|
410
|
+
behavioral: ["rapid_completion", "uniform_timing", "low_interaction", "excessive_paste", "pointer_spikes"],
|
|
411
|
+
timing: ["impossibly_fast", "suspicious_pauses"],
|
|
412
|
+
device: ["webdriver_detected", "automation_detected", "no_plugins", "suspicious_user_agent", "device_fingerprint_mismatch", "screen_anomaly"],
|
|
413
|
+
content: ["straight_line_answers", "minimal_effort"],
|
|
414
|
+
network: [],
|
|
415
|
+
ai: []
|
|
416
|
+
},
|
|
417
|
+
aiModel: null,
|
|
418
|
+
offline: true,
|
|
419
|
+
estimatedCostPerResponse: 0
|
|
48
420
|
},
|
|
49
421
|
3: {
|
|
50
422
|
name: "Enhanced",
|
|
51
|
-
description: "Adds
|
|
52
|
-
|
|
423
|
+
description: "Adds Claude Sonnet 4.5 for content quality analysis. ~$0.003 per response.",
|
|
424
|
+
checks: {
|
|
425
|
+
behavioral: ["rapid_completion", "uniform_timing", "low_interaction", "excessive_paste", "pointer_spikes", "robotic_typing", "mouse_teleporting", "no_corrections"],
|
|
426
|
+
timing: ["impossibly_fast", "suspicious_pauses"],
|
|
427
|
+
device: ["webdriver_detected", "automation_detected", "no_plugins", "suspicious_user_agent", "device_fingerprint_mismatch", "screen_anomaly"],
|
|
428
|
+
content: ["straight_line_answers", "minimal_effort", "excessive_tab_switching", "window_focus_loss"],
|
|
429
|
+
network: [],
|
|
430
|
+
ai: ["ai_content_basic", "contradiction_basic"]
|
|
431
|
+
},
|
|
432
|
+
aiModel: "claude-sonnet-4-5-20250514",
|
|
433
|
+
offline: false,
|
|
434
|
+
estimatedCostPerResponse: 3e-3
|
|
53
435
|
},
|
|
54
436
|
4: {
|
|
55
437
|
name: "Advanced",
|
|
56
|
-
description: "Full behavioral analysis
|
|
57
|
-
|
|
438
|
+
description: "Full behavioral analysis + semantic AI. ~$0.01 per response.",
|
|
439
|
+
checks: {
|
|
440
|
+
behavioral: ["rapid_completion", "uniform_timing", "low_interaction", "excessive_paste", "pointer_spikes", "robotic_typing", "mouse_teleporting", "no_corrections", "hover_behavior", "scroll_patterns", "mouse_acceleration"],
|
|
441
|
+
timing: ["impossibly_fast", "suspicious_pauses"],
|
|
442
|
+
device: ["webdriver_detected", "automation_detected", "no_plugins", "suspicious_user_agent", "device_fingerprint_mismatch", "screen_anomaly"],
|
|
443
|
+
content: ["straight_line_answers", "minimal_effort", "excessive_tab_switching", "window_focus_loss"],
|
|
444
|
+
network: ["vpn_detection", "datacenter_ip"],
|
|
445
|
+
ai: ["ai_content_basic", "contradiction_basic", "plagiarism_basic", "quality_assessment", "semantic_analysis"]
|
|
446
|
+
},
|
|
447
|
+
aiModel: "claude-sonnet-4-5-20250514",
|
|
448
|
+
offline: false,
|
|
449
|
+
estimatedCostPerResponse: 0.01
|
|
58
450
|
},
|
|
59
451
|
5: {
|
|
60
452
|
name: "Maximum",
|
|
61
|
-
description: "All
|
|
62
|
-
|
|
453
|
+
description: "All 40+ checks with Claude Opus 4.5. Maximum accuracy. ~$0.05 per response.",
|
|
454
|
+
checks: {
|
|
455
|
+
behavioral: ["rapid_completion", "uniform_timing", "low_interaction", "excessive_paste", "pointer_spikes", "robotic_typing", "mouse_teleporting", "no_corrections", "hover_behavior", "scroll_patterns", "mouse_acceleration"],
|
|
456
|
+
timing: ["impossibly_fast", "suspicious_pauses"],
|
|
457
|
+
device: ["webdriver_detected", "automation_detected", "no_plugins", "suspicious_user_agent", "device_fingerprint_mismatch", "screen_anomaly"],
|
|
458
|
+
content: ["straight_line_answers", "minimal_effort", "excessive_tab_switching", "window_focus_loss"],
|
|
459
|
+
network: ["vpn_detection", "datacenter_ip", "tor_detection", "proxy_detection", "timezone_validation"],
|
|
460
|
+
ai: ["ai_content_full", "contradiction_full", "plagiarism_full", "quality_assessment", "semantic_analysis", "fraud_ring_detection", "answer_sharing", "coordinated_timing", "device_sharing", "baseline_deviation", "perplexity_analysis", "burstiness_analysis"]
|
|
461
|
+
},
|
|
462
|
+
aiModel: "claude-opus-4-5-20250514",
|
|
463
|
+
offline: false,
|
|
464
|
+
estimatedCostPerResponse: 0.05
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
function getChecksForTier(tier) {
|
|
468
|
+
const config = TIERS[tier];
|
|
469
|
+
return [
|
|
470
|
+
...config.checks.behavioral,
|
|
471
|
+
...config.checks.timing,
|
|
472
|
+
...config.checks.device,
|
|
473
|
+
...config.checks.content,
|
|
474
|
+
...config.checks.network,
|
|
475
|
+
...config.checks.ai
|
|
476
|
+
];
|
|
477
|
+
}
|
|
478
|
+
function getOfflineChecksForTier(tier) {
|
|
479
|
+
return getChecksForTier(tier).filter((id) => CHECKS[id].offline);
|
|
480
|
+
}
|
|
481
|
+
function estimateCost(tier, responses) {
|
|
482
|
+
return TIERS[tier].estimatedCostPerResponse * responses;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/checks/timing.ts
|
|
486
|
+
var MIN_MS_PER_CHAR = 20;
|
|
487
|
+
var MIN_RESPONSE_TIME_MS = 1e3;
|
|
488
|
+
function checkImpossiblyFast(responses, context) {
|
|
489
|
+
if (!context?.actualDurationSeconds || !context?.expectedDurationSeconds) {
|
|
490
|
+
return {
|
|
491
|
+
checkId: "impossibly_fast",
|
|
492
|
+
passed: true,
|
|
493
|
+
score: 0,
|
|
494
|
+
details: "No timing data available"
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
const actualMs = context.actualDurationSeconds * 1e3;
|
|
498
|
+
const expectedMs = context.expectedDurationSeconds * 1e3;
|
|
499
|
+
let minPossibleMs = 0;
|
|
500
|
+
for (const response of responses) {
|
|
501
|
+
const questionChars = response.question.length;
|
|
502
|
+
const readingTime = questionChars * MIN_MS_PER_CHAR;
|
|
503
|
+
minPossibleMs += Math.max(readingTime, MIN_RESPONSE_TIME_MS);
|
|
504
|
+
}
|
|
505
|
+
const suspicionThreshold = minPossibleMs * 0.3;
|
|
506
|
+
if (actualMs < suspicionThreshold) {
|
|
507
|
+
return {
|
|
508
|
+
checkId: "impossibly_fast",
|
|
509
|
+
passed: false,
|
|
510
|
+
score: 1,
|
|
511
|
+
details: `Completed in ${context.actualDurationSeconds}s, minimum expected ${Math.round(minPossibleMs / 1e3)}s`,
|
|
512
|
+
data: { actualMs, minPossibleMs, threshold: suspicionThreshold }
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const speedRatio = actualMs / expectedMs;
|
|
516
|
+
const score = speedRatio < 1 ? Math.max(0, 1 - speedRatio) * 0.7 : 0;
|
|
517
|
+
return {
|
|
518
|
+
checkId: "impossibly_fast",
|
|
519
|
+
passed: true,
|
|
520
|
+
score,
|
|
521
|
+
details: speedRatio < 0.5 ? "Faster than average" : void 0,
|
|
522
|
+
data: { actualMs, expectedMs, speedRatio }
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
function checkRapidCompletion(responses, context) {
|
|
526
|
+
if (!context?.actualDurationSeconds || !context?.expectedDurationSeconds) {
|
|
527
|
+
return {
|
|
528
|
+
checkId: "rapid_completion",
|
|
529
|
+
passed: true,
|
|
530
|
+
score: 0,
|
|
531
|
+
details: "No timing data available"
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
const ratio = context.actualDurationSeconds / context.expectedDurationSeconds;
|
|
535
|
+
if (ratio < 0.2) {
|
|
536
|
+
return {
|
|
537
|
+
checkId: "rapid_completion",
|
|
538
|
+
passed: false,
|
|
539
|
+
score: 1,
|
|
540
|
+
details: `Completed in ${Math.round(ratio * 100)}% of expected time`,
|
|
541
|
+
data: { ratio }
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
if (ratio < 0.4) {
|
|
545
|
+
return {
|
|
546
|
+
checkId: "rapid_completion",
|
|
547
|
+
passed: true,
|
|
548
|
+
score: 0.5,
|
|
549
|
+
details: "Faster than typical",
|
|
550
|
+
data: { ratio }
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
checkId: "rapid_completion",
|
|
555
|
+
passed: true,
|
|
556
|
+
score: 0,
|
|
557
|
+
data: { ratio }
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function checkUniformTiming(responses, metrics) {
|
|
561
|
+
const times = metrics?.responseTime || responses.map((r) => r.responseTimeMs).filter(Boolean);
|
|
562
|
+
if (times.length < 3) {
|
|
563
|
+
return {
|
|
564
|
+
checkId: "uniform_timing",
|
|
565
|
+
passed: true,
|
|
566
|
+
score: 0,
|
|
567
|
+
details: "Not enough timing data"
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
const mean = times.reduce((a, b) => a + b, 0) / times.length;
|
|
571
|
+
const variance = times.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / times.length;
|
|
572
|
+
const stddev = Math.sqrt(variance);
|
|
573
|
+
const cv = stddev / mean;
|
|
574
|
+
if (cv < 0.1) {
|
|
575
|
+
return {
|
|
576
|
+
checkId: "uniform_timing",
|
|
577
|
+
passed: false,
|
|
578
|
+
score: 1,
|
|
579
|
+
details: "Response times are suspiciously uniform",
|
|
580
|
+
data: { cv, mean, stddev }
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
if (cv < 0.2) {
|
|
584
|
+
return {
|
|
585
|
+
checkId: "uniform_timing",
|
|
586
|
+
passed: true,
|
|
587
|
+
score: 0.5,
|
|
588
|
+
details: "Lower than typical timing variation",
|
|
589
|
+
data: { cv, mean, stddev }
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
checkId: "uniform_timing",
|
|
594
|
+
passed: true,
|
|
595
|
+
score: 0,
|
|
596
|
+
data: { cv, mean, stddev }
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
function checkSuspiciousPauses(metrics) {
|
|
600
|
+
if (!metrics?.focusEvents || metrics.focusEvents.length < 2) {
|
|
601
|
+
return {
|
|
602
|
+
checkId: "suspicious_pauses",
|
|
603
|
+
passed: true,
|
|
604
|
+
score: 0,
|
|
605
|
+
details: "No focus event data"
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
const blurEvents = metrics.focusEvents.filter((e) => e.type === "blur" || e.type === "hidden");
|
|
609
|
+
const totalBlurTime = metrics.totalBlurDuration || 0;
|
|
610
|
+
const surveyDuration = metrics.duration || 1;
|
|
611
|
+
const blurRatio = totalBlurTime / surveyDuration;
|
|
612
|
+
if (blurRatio > 0.5) {
|
|
613
|
+
return {
|
|
614
|
+
checkId: "suspicious_pauses",
|
|
615
|
+
passed: false,
|
|
616
|
+
score: 0.9,
|
|
617
|
+
details: `${Math.round(blurRatio * 100)}% of time spent away from survey`,
|
|
618
|
+
data: { blurRatio, totalBlurTime, blurEvents: blurEvents.length }
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
if (blurRatio > 0.3) {
|
|
622
|
+
return {
|
|
623
|
+
checkId: "suspicious_pauses",
|
|
624
|
+
passed: true,
|
|
625
|
+
score: 0.5,
|
|
626
|
+
details: "Significant time away from survey",
|
|
627
|
+
data: { blurRatio, totalBlurTime }
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
checkId: "suspicious_pauses",
|
|
632
|
+
passed: true,
|
|
633
|
+
score: 0,
|
|
634
|
+
data: { blurRatio }
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
function runTimingChecks(responses, metrics, context) {
|
|
638
|
+
return [
|
|
639
|
+
checkImpossiblyFast(responses, context),
|
|
640
|
+
checkRapidCompletion(responses, context),
|
|
641
|
+
checkUniformTiming(responses, metrics),
|
|
642
|
+
checkSuspiciousPauses(metrics)
|
|
643
|
+
];
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// src/checks/behavioral.ts
|
|
647
|
+
function checkLowInteraction(metrics) {
|
|
648
|
+
if (!metrics) {
|
|
649
|
+
return {
|
|
650
|
+
checkId: "low_interaction",
|
|
651
|
+
passed: true,
|
|
652
|
+
score: 0,
|
|
653
|
+
details: "No behavioral data available"
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
const durationSeconds = (metrics.duration || 1) / 1e3;
|
|
657
|
+
const totalInteractions = metrics.mouseMovementCount + metrics.keypressCount + metrics.scrollEventCount;
|
|
658
|
+
const interactionsPerSecond = totalInteractions / durationSeconds;
|
|
659
|
+
if (interactionsPerSecond < 0.1) {
|
|
660
|
+
return {
|
|
661
|
+
checkId: "low_interaction",
|
|
662
|
+
passed: false,
|
|
663
|
+
score: 1,
|
|
664
|
+
details: "Almost no user interaction detected",
|
|
665
|
+
data: { interactionsPerSecond, totalInteractions, durationSeconds }
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
if (interactionsPerSecond < 0.5) {
|
|
669
|
+
return {
|
|
670
|
+
checkId: "low_interaction",
|
|
671
|
+
passed: true,
|
|
672
|
+
score: 0.6,
|
|
673
|
+
details: "Below average interaction rate",
|
|
674
|
+
data: { interactionsPerSecond }
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
checkId: "low_interaction",
|
|
679
|
+
passed: true,
|
|
680
|
+
score: 0,
|
|
681
|
+
data: { interactionsPerSecond }
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
function checkExcessivePaste(metrics) {
|
|
685
|
+
if (!metrics) {
|
|
686
|
+
return {
|
|
687
|
+
checkId: "excessive_paste",
|
|
688
|
+
passed: true,
|
|
689
|
+
score: 0,
|
|
690
|
+
details: "No behavioral data available"
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const { pasteEvents, keypressCount } = metrics;
|
|
694
|
+
if (pasteEvents > 0 && keypressCount === 0) {
|
|
695
|
+
return {
|
|
696
|
+
checkId: "excessive_paste",
|
|
697
|
+
passed: false,
|
|
698
|
+
score: 1,
|
|
699
|
+
details: "All content was pasted, no typing detected",
|
|
700
|
+
data: { pasteEvents, keypressCount }
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
const pasteRatio = keypressCount > 0 ? pasteEvents / keypressCount : 0;
|
|
704
|
+
if (pasteRatio > 0.5) {
|
|
705
|
+
return {
|
|
706
|
+
checkId: "excessive_paste",
|
|
707
|
+
passed: false,
|
|
708
|
+
score: 0.8,
|
|
709
|
+
details: "High paste-to-typing ratio",
|
|
710
|
+
data: { pasteRatio, pasteEvents }
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
if (pasteRatio > 0.2) {
|
|
714
|
+
return {
|
|
715
|
+
checkId: "excessive_paste",
|
|
716
|
+
passed: true,
|
|
717
|
+
score: 0.4,
|
|
718
|
+
details: "Some paste events detected",
|
|
719
|
+
data: { pasteRatio }
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
checkId: "excessive_paste",
|
|
724
|
+
passed: true,
|
|
725
|
+
score: 0,
|
|
726
|
+
data: { pasteEvents }
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
function checkPointerSpikes(metrics) {
|
|
730
|
+
if (!metrics?.mouseMovements || metrics.mouseMovements.length < 10) {
|
|
731
|
+
return {
|
|
732
|
+
checkId: "pointer_spikes",
|
|
733
|
+
passed: true,
|
|
734
|
+
score: 0,
|
|
735
|
+
details: "Insufficient mouse data"
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
const velocities = metrics.mouseMovements.map((m) => m.velocity).filter((v) => v > 0);
|
|
739
|
+
if (velocities.length === 0) {
|
|
740
|
+
return {
|
|
741
|
+
checkId: "pointer_spikes",
|
|
742
|
+
passed: true,
|
|
743
|
+
score: 0,
|
|
744
|
+
details: "No velocity data"
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
const spikeThreshold = 50;
|
|
748
|
+
const spikes = velocities.filter((v) => v > spikeThreshold);
|
|
749
|
+
const spikeRatio = spikes.length / velocities.length;
|
|
750
|
+
if (spikeRatio > 0.3) {
|
|
751
|
+
return {
|
|
752
|
+
checkId: "pointer_spikes",
|
|
753
|
+
passed: false,
|
|
754
|
+
score: 0.9,
|
|
755
|
+
details: "Many unnatural mouse speed spikes detected",
|
|
756
|
+
data: { spikeRatio, spikeCount: spikes.length }
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
if (spikeRatio > 0.1) {
|
|
760
|
+
return {
|
|
761
|
+
checkId: "pointer_spikes",
|
|
762
|
+
passed: true,
|
|
763
|
+
score: 0.4,
|
|
764
|
+
details: "Some unusual mouse movements",
|
|
765
|
+
data: { spikeRatio }
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
return {
|
|
769
|
+
checkId: "pointer_spikes",
|
|
770
|
+
passed: true,
|
|
771
|
+
score: 0,
|
|
772
|
+
data: { spikeRatio }
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
function checkRoboticTyping(metrics) {
|
|
776
|
+
if (!metrics?.keystrokeDynamics || metrics.keystrokeDynamics.length < 10) {
|
|
777
|
+
return {
|
|
778
|
+
checkId: "robotic_typing",
|
|
779
|
+
passed: true,
|
|
780
|
+
score: 0,
|
|
781
|
+
details: "Insufficient keystroke data"
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
const dwellTimes = metrics.keystrokeDynamics.map((k) => k.dwell).filter((d) => d > 0 && d < 1e3);
|
|
785
|
+
if (dwellTimes.length < 5) {
|
|
786
|
+
return {
|
|
787
|
+
checkId: "robotic_typing",
|
|
788
|
+
passed: true,
|
|
789
|
+
score: 0,
|
|
790
|
+
details: "Not enough valid keystroke data"
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
const mean = dwellTimes.reduce((a, b) => a + b, 0) / dwellTimes.length;
|
|
794
|
+
const variance = dwellTimes.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / dwellTimes.length;
|
|
795
|
+
const stddev = Math.sqrt(variance);
|
|
796
|
+
const cv = stddev / mean;
|
|
797
|
+
if (cv < 0.08) {
|
|
798
|
+
return {
|
|
799
|
+
checkId: "robotic_typing",
|
|
800
|
+
passed: false,
|
|
801
|
+
score: 1,
|
|
802
|
+
details: "Keystroke timing is machine-like uniform",
|
|
803
|
+
data: { cv, mean, stddev }
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
if (cv < 0.15) {
|
|
807
|
+
return {
|
|
808
|
+
checkId: "robotic_typing",
|
|
809
|
+
passed: true,
|
|
810
|
+
score: 0.5,
|
|
811
|
+
details: "Lower than typical keystroke variation",
|
|
812
|
+
data: { cv }
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
checkId: "robotic_typing",
|
|
817
|
+
passed: true,
|
|
818
|
+
score: 0,
|
|
819
|
+
data: { cv }
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
function checkMouseTeleporting(metrics) {
|
|
823
|
+
if (!metrics?.mouseMovements || metrics.mouseMovements.length < 5) {
|
|
824
|
+
return {
|
|
825
|
+
checkId: "mouse_teleporting",
|
|
826
|
+
passed: true,
|
|
827
|
+
score: 0,
|
|
828
|
+
details: "Insufficient mouse data"
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
let teleportCount = 0;
|
|
832
|
+
const movements = metrics.mouseMovements;
|
|
833
|
+
for (let i = 1; i < movements.length; i++) {
|
|
834
|
+
const prev = movements[i - 1];
|
|
835
|
+
const curr = movements[i];
|
|
836
|
+
const dx = curr.x - prev.x;
|
|
837
|
+
const dy = curr.y - prev.y;
|
|
838
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
839
|
+
const dt = curr.t - prev.t;
|
|
840
|
+
if (distance > 500 && dt < 10) {
|
|
841
|
+
teleportCount++;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const teleportRatio = teleportCount / movements.length;
|
|
845
|
+
if (teleportRatio > 0.2) {
|
|
846
|
+
return {
|
|
847
|
+
checkId: "mouse_teleporting",
|
|
848
|
+
passed: false,
|
|
849
|
+
score: 0.9,
|
|
850
|
+
details: "Frequent mouse teleportation detected",
|
|
851
|
+
data: { teleportCount, teleportRatio }
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
if (teleportRatio > 0.05) {
|
|
855
|
+
return {
|
|
856
|
+
checkId: "mouse_teleporting",
|
|
857
|
+
passed: true,
|
|
858
|
+
score: 0.4,
|
|
859
|
+
details: "Some mouse teleportation detected",
|
|
860
|
+
data: { teleportCount }
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
checkId: "mouse_teleporting",
|
|
865
|
+
passed: true,
|
|
866
|
+
score: 0,
|
|
867
|
+
data: { teleportCount }
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
function checkNoCorrections(metrics) {
|
|
871
|
+
if (!metrics || metrics.keypressCount < 20) {
|
|
872
|
+
return {
|
|
873
|
+
checkId: "no_corrections",
|
|
874
|
+
passed: true,
|
|
875
|
+
score: 0,
|
|
876
|
+
details: "Insufficient typing data"
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
const { backspaceCount, keypressCount } = metrics;
|
|
880
|
+
const correctionRatio = backspaceCount / keypressCount;
|
|
881
|
+
if (backspaceCount === 0 && keypressCount > 50) {
|
|
882
|
+
return {
|
|
883
|
+
checkId: "no_corrections",
|
|
884
|
+
passed: false,
|
|
885
|
+
score: 0.8,
|
|
886
|
+
details: "No typing corrections despite significant text entry",
|
|
887
|
+
data: { backspaceCount, keypressCount }
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
if (correctionRatio < 0.01 && keypressCount > 30) {
|
|
891
|
+
return {
|
|
892
|
+
checkId: "no_corrections",
|
|
893
|
+
passed: true,
|
|
894
|
+
score: 0.5,
|
|
895
|
+
details: "Very few typing corrections",
|
|
896
|
+
data: { correctionRatio }
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
checkId: "no_corrections",
|
|
901
|
+
passed: true,
|
|
902
|
+
score: 0,
|
|
903
|
+
data: { correctionRatio }
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
function checkHoverBehavior(metrics) {
|
|
907
|
+
if (!metrics?.mouseClicks || metrics.mouseClicks.length < 3) {
|
|
908
|
+
return {
|
|
909
|
+
checkId: "hover_behavior",
|
|
910
|
+
passed: true,
|
|
911
|
+
score: 0,
|
|
912
|
+
details: "Insufficient click data"
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
const clicksWithHover = metrics.mouseClicks.filter((c) => c.hadHover);
|
|
916
|
+
const hoverRatio = clicksWithHover.length / metrics.mouseClicks.length;
|
|
917
|
+
if (hoverRatio < 0.2) {
|
|
918
|
+
return {
|
|
919
|
+
checkId: "hover_behavior",
|
|
920
|
+
passed: false,
|
|
921
|
+
score: 0.8,
|
|
922
|
+
details: "Clicks without natural hover behavior",
|
|
923
|
+
data: { hoverRatio, totalClicks: metrics.mouseClicks.length }
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
if (hoverRatio < 0.4) {
|
|
927
|
+
return {
|
|
928
|
+
checkId: "hover_behavior",
|
|
929
|
+
passed: true,
|
|
930
|
+
score: 0.4,
|
|
931
|
+
details: "Lower than typical hover-before-click rate",
|
|
932
|
+
data: { hoverRatio }
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
checkId: "hover_behavior",
|
|
937
|
+
passed: true,
|
|
938
|
+
score: 0,
|
|
939
|
+
data: { hoverRatio }
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
function checkScrollPatterns(metrics) {
|
|
943
|
+
if (!metrics?.scrollEvents || metrics.scrollEvents.length < 5) {
|
|
944
|
+
return {
|
|
945
|
+
checkId: "scroll_patterns",
|
|
946
|
+
passed: true,
|
|
947
|
+
score: 0,
|
|
948
|
+
details: "Insufficient scroll data"
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
const velocities = metrics.scrollEvents.map((s) => Math.abs(s.velocity)).filter((v) => v > 0);
|
|
952
|
+
if (velocities.length < 3) {
|
|
953
|
+
return {
|
|
954
|
+
checkId: "scroll_patterns",
|
|
955
|
+
passed: true,
|
|
956
|
+
score: 0
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
const mean = velocities.reduce((a, b) => a + b, 0) / velocities.length;
|
|
960
|
+
const variance = velocities.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / velocities.length;
|
|
961
|
+
const cv = Math.sqrt(variance) / mean;
|
|
962
|
+
if (cv < 0.1) {
|
|
963
|
+
return {
|
|
964
|
+
checkId: "scroll_patterns",
|
|
965
|
+
passed: false,
|
|
966
|
+
score: 0.7,
|
|
967
|
+
details: "Unnaturally uniform scroll pattern",
|
|
968
|
+
data: { cv }
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
return {
|
|
972
|
+
checkId: "scroll_patterns",
|
|
973
|
+
passed: true,
|
|
974
|
+
score: 0,
|
|
975
|
+
data: { cv }
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
function checkMouseAcceleration(metrics) {
|
|
979
|
+
if (!metrics?.mouseMovements || metrics.mouseMovements.length < 20) {
|
|
980
|
+
return {
|
|
981
|
+
checkId: "mouse_acceleration",
|
|
982
|
+
passed: true,
|
|
983
|
+
score: 0,
|
|
984
|
+
details: "Insufficient mouse data"
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
const velocities = metrics.mouseMovements.map((m) => m.velocity).filter((v) => v > 0);
|
|
988
|
+
if (velocities.length < 10) {
|
|
989
|
+
return {
|
|
990
|
+
checkId: "mouse_acceleration",
|
|
991
|
+
passed: true,
|
|
992
|
+
score: 0
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
const accelerations = [];
|
|
996
|
+
for (let i = 1; i < velocities.length; i++) {
|
|
997
|
+
accelerations.push(Math.abs(velocities[i] - velocities[i - 1]));
|
|
998
|
+
}
|
|
999
|
+
const meanAcc = accelerations.reduce((a, b) => a + b, 0) / accelerations.length;
|
|
1000
|
+
const varianceAcc = accelerations.reduce((sum, a) => sum + Math.pow(a - meanAcc, 2), 0) / accelerations.length;
|
|
1001
|
+
const cvAcc = Math.sqrt(varianceAcc) / meanAcc;
|
|
1002
|
+
if (cvAcc < 0.2) {
|
|
1003
|
+
return {
|
|
1004
|
+
checkId: "mouse_acceleration",
|
|
1005
|
+
passed: true,
|
|
1006
|
+
score: 0.5,
|
|
1007
|
+
details: "Lower than typical acceleration variation",
|
|
1008
|
+
data: { cvAcc }
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
return {
|
|
1012
|
+
checkId: "mouse_acceleration",
|
|
1013
|
+
passed: true,
|
|
1014
|
+
score: 0,
|
|
1015
|
+
data: { cvAcc }
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
function runBehavioralChecks(metrics) {
|
|
1019
|
+
return [
|
|
1020
|
+
checkLowInteraction(metrics),
|
|
1021
|
+
checkExcessivePaste(metrics),
|
|
1022
|
+
checkPointerSpikes(metrics),
|
|
1023
|
+
checkRoboticTyping(metrics),
|
|
1024
|
+
checkMouseTeleporting(metrics),
|
|
1025
|
+
checkNoCorrections(metrics),
|
|
1026
|
+
checkHoverBehavior(metrics),
|
|
1027
|
+
checkScrollPatterns(metrics),
|
|
1028
|
+
checkMouseAcceleration(metrics)
|
|
1029
|
+
];
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// src/checks/content.ts
|
|
1033
|
+
var GIBBERISH_PATTERNS = [
|
|
1034
|
+
/^[a-z]{1,3}$/i,
|
|
1035
|
+
// Single letters or very short
|
|
1036
|
+
/(.)\1{4,}/,
|
|
1037
|
+
// Repeated characters (aaaaa)
|
|
1038
|
+
/^[^aeiou]{5,}$/i,
|
|
1039
|
+
// No vowels in 5+ chars
|
|
1040
|
+
/^(asdf|qwerty|zxcv|wasd)/i,
|
|
1041
|
+
// Keyboard mashing
|
|
1042
|
+
/^[0-9]+$/,
|
|
1043
|
+
// Only numbers
|
|
1044
|
+
/^(.+?)\1{2,}$/
|
|
1045
|
+
// Repeated patterns (abcabcabc)
|
|
1046
|
+
];
|
|
1047
|
+
var LOW_EFFORT_RESPONSES = [
|
|
1048
|
+
"n/a",
|
|
1049
|
+
"na",
|
|
1050
|
+
"none",
|
|
1051
|
+
"nothing",
|
|
1052
|
+
"idk",
|
|
1053
|
+
"i dont know",
|
|
1054
|
+
"i don't know",
|
|
1055
|
+
"no comment",
|
|
1056
|
+
"no",
|
|
1057
|
+
"yes",
|
|
1058
|
+
"ok",
|
|
1059
|
+
"okay",
|
|
1060
|
+
"good",
|
|
1061
|
+
"fine",
|
|
1062
|
+
"whatever",
|
|
1063
|
+
"asdf",
|
|
1064
|
+
"test",
|
|
1065
|
+
".",
|
|
1066
|
+
"-",
|
|
1067
|
+
"..."
|
|
1068
|
+
];
|
|
1069
|
+
function checkMinimalEffort(responses) {
|
|
1070
|
+
const textResponses = responses.filter(
|
|
1071
|
+
(r) => r.questionType === "text" || !r.questionType && r.answer.length > 0
|
|
1072
|
+
);
|
|
1073
|
+
if (textResponses.length === 0) {
|
|
1074
|
+
return {
|
|
1075
|
+
checkId: "minimal_effort",
|
|
1076
|
+
passed: true,
|
|
1077
|
+
score: 0,
|
|
1078
|
+
details: "No text responses to analyze"
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
let lowEffortCount = 0;
|
|
1082
|
+
let totalLength = 0;
|
|
1083
|
+
for (const response of textResponses) {
|
|
1084
|
+
const answer = response.answer.toLowerCase().trim();
|
|
1085
|
+
totalLength += answer.length;
|
|
1086
|
+
if (LOW_EFFORT_RESPONSES.includes(answer)) {
|
|
1087
|
+
lowEffortCount++;
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
if (answer.length < 10) {
|
|
1091
|
+
lowEffortCount++;
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
for (const pattern of GIBBERISH_PATTERNS) {
|
|
1095
|
+
if (pattern.test(answer)) {
|
|
1096
|
+
lowEffortCount++;
|
|
1097
|
+
break;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
const lowEffortRatio = lowEffortCount / textResponses.length;
|
|
1102
|
+
const avgLength = totalLength / textResponses.length;
|
|
1103
|
+
if (lowEffortRatio > 0.7) {
|
|
1104
|
+
return {
|
|
1105
|
+
checkId: "minimal_effort",
|
|
1106
|
+
passed: false,
|
|
1107
|
+
score: 1,
|
|
1108
|
+
details: "Most responses are low effort or gibberish",
|
|
1109
|
+
data: { lowEffortRatio, avgLength, lowEffortCount }
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
if (lowEffortRatio > 0.4) {
|
|
1113
|
+
return {
|
|
1114
|
+
checkId: "minimal_effort",
|
|
1115
|
+
passed: true,
|
|
1116
|
+
score: 0.6,
|
|
1117
|
+
details: "Many responses appear low effort",
|
|
1118
|
+
data: { lowEffortRatio, avgLength }
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
if (avgLength < 20) {
|
|
1122
|
+
return {
|
|
1123
|
+
checkId: "minimal_effort",
|
|
1124
|
+
passed: true,
|
|
1125
|
+
score: 0.4,
|
|
1126
|
+
details: "Responses are quite short on average",
|
|
1127
|
+
data: { avgLength }
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
return {
|
|
1131
|
+
checkId: "minimal_effort",
|
|
1132
|
+
passed: true,
|
|
1133
|
+
score: 0,
|
|
1134
|
+
data: { lowEffortRatio, avgLength }
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
function checkStraightLining(responses) {
|
|
1138
|
+
const scaleResponses = responses.filter(
|
|
1139
|
+
(r) => r.questionType === "rating" || r.questionType === "scale" || r.questionType === "multiple_choice"
|
|
1140
|
+
);
|
|
1141
|
+
if (scaleResponses.length < 4) {
|
|
1142
|
+
return {
|
|
1143
|
+
checkId: "straight_line_answers",
|
|
1144
|
+
passed: true,
|
|
1145
|
+
score: 0,
|
|
1146
|
+
details: "Not enough scale/choice questions"
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
let maxConsecutive = 1;
|
|
1150
|
+
let currentConsecutive = 1;
|
|
1151
|
+
for (let i = 1; i < scaleResponses.length; i++) {
|
|
1152
|
+
if (scaleResponses[i].answer === scaleResponses[i - 1].answer) {
|
|
1153
|
+
currentConsecutive++;
|
|
1154
|
+
maxConsecutive = Math.max(maxConsecutive, currentConsecutive);
|
|
1155
|
+
} else {
|
|
1156
|
+
currentConsecutive = 1;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const answers = scaleResponses.map((r) => r.answer);
|
|
1160
|
+
const uniqueAnswers = new Set(answers);
|
|
1161
|
+
const uniqueRatio = uniqueAnswers.size / scaleResponses.length;
|
|
1162
|
+
if (uniqueRatio < 0.15) {
|
|
1163
|
+
return {
|
|
1164
|
+
checkId: "straight_line_answers",
|
|
1165
|
+
passed: false,
|
|
1166
|
+
score: 1,
|
|
1167
|
+
details: "Nearly all answers are identical",
|
|
1168
|
+
data: { uniqueRatio, uniqueAnswers: uniqueAnswers.size, total: scaleResponses.length }
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
if (maxConsecutive >= 5 || uniqueRatio < 0.25) {
|
|
1172
|
+
return {
|
|
1173
|
+
checkId: "straight_line_answers",
|
|
1174
|
+
passed: false,
|
|
1175
|
+
score: 0.8,
|
|
1176
|
+
details: "Strong straight-lining pattern detected",
|
|
1177
|
+
data: { maxConsecutive, uniqueRatio }
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
if (maxConsecutive >= 4) {
|
|
1181
|
+
return {
|
|
1182
|
+
checkId: "straight_line_answers",
|
|
1183
|
+
passed: true,
|
|
1184
|
+
score: 0.4,
|
|
1185
|
+
details: "Some consecutive identical answers",
|
|
1186
|
+
data: { maxConsecutive }
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
return {
|
|
1190
|
+
checkId: "straight_line_answers",
|
|
1191
|
+
passed: true,
|
|
1192
|
+
score: 0,
|
|
1193
|
+
data: { maxConsecutive, uniqueRatio }
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
function checkExcessiveTabSwitching(metrics) {
|
|
1197
|
+
if (!metrics) {
|
|
1198
|
+
return {
|
|
1199
|
+
checkId: "excessive_tab_switching",
|
|
1200
|
+
passed: true,
|
|
1201
|
+
score: 0,
|
|
1202
|
+
details: "No behavioral data"
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
const { tabSwitchCount, duration } = metrics;
|
|
1206
|
+
const durationMinutes = (duration || 1) / 6e4;
|
|
1207
|
+
const switchesPerMinute = tabSwitchCount / durationMinutes;
|
|
1208
|
+
if (switchesPerMinute > 5) {
|
|
1209
|
+
return {
|
|
1210
|
+
checkId: "excessive_tab_switching",
|
|
1211
|
+
passed: false,
|
|
1212
|
+
score: 0.8,
|
|
1213
|
+
details: "Excessive tab switching detected",
|
|
1214
|
+
data: { switchesPerMinute, tabSwitchCount }
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
if (switchesPerMinute > 2) {
|
|
1218
|
+
return {
|
|
1219
|
+
checkId: "excessive_tab_switching",
|
|
1220
|
+
passed: true,
|
|
1221
|
+
score: 0.4,
|
|
1222
|
+
details: "Above average tab switching",
|
|
1223
|
+
data: { switchesPerMinute }
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
return {
|
|
1227
|
+
checkId: "excessive_tab_switching",
|
|
1228
|
+
passed: true,
|
|
1229
|
+
score: 0,
|
|
1230
|
+
data: { switchesPerMinute }
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
function checkWindowFocusLoss(metrics) {
|
|
1234
|
+
if (!metrics) {
|
|
1235
|
+
return {
|
|
1236
|
+
checkId: "window_focus_loss",
|
|
1237
|
+
passed: true,
|
|
1238
|
+
score: 0,
|
|
1239
|
+
details: "No behavioral data"
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
const { totalBlurDuration, duration } = metrics;
|
|
1243
|
+
const blurRatio = totalBlurDuration / (duration || 1);
|
|
1244
|
+
if (blurRatio > 0.6) {
|
|
1245
|
+
return {
|
|
1246
|
+
checkId: "window_focus_loss",
|
|
1247
|
+
passed: false,
|
|
1248
|
+
score: 0.8,
|
|
1249
|
+
details: "Majority of time spent away from survey",
|
|
1250
|
+
data: { blurRatio, totalBlurDuration }
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
if (blurRatio > 0.3) {
|
|
1254
|
+
return {
|
|
1255
|
+
checkId: "window_focus_loss",
|
|
1256
|
+
passed: true,
|
|
1257
|
+
score: 0.4,
|
|
1258
|
+
details: "Significant time away from survey",
|
|
1259
|
+
data: { blurRatio }
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
return {
|
|
1263
|
+
checkId: "window_focus_loss",
|
|
1264
|
+
passed: true,
|
|
1265
|
+
score: 0,
|
|
1266
|
+
data: { blurRatio }
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
function runContentChecks(responses, metrics) {
|
|
1270
|
+
return [
|
|
1271
|
+
checkMinimalEffort(responses),
|
|
1272
|
+
checkStraightLining(responses),
|
|
1273
|
+
checkExcessiveTabSwitching(metrics),
|
|
1274
|
+
checkWindowFocusLoss(metrics)
|
|
1275
|
+
];
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// src/checks/device.ts
|
|
1279
|
+
var BOT_USER_AGENTS = [
|
|
1280
|
+
/headless/i,
|
|
1281
|
+
/phantom/i,
|
|
1282
|
+
/selenium/i,
|
|
1283
|
+
/webdriver/i,
|
|
1284
|
+
/puppeteer/i,
|
|
1285
|
+
/playwright/i,
|
|
1286
|
+
/crawl/i,
|
|
1287
|
+
/spider/i,
|
|
1288
|
+
/bot/i,
|
|
1289
|
+
/scrape/i,
|
|
1290
|
+
/curl/i,
|
|
1291
|
+
/wget/i,
|
|
1292
|
+
/python-requests/i,
|
|
1293
|
+
/axios/i,
|
|
1294
|
+
/node-fetch/i,
|
|
1295
|
+
/go-http-client/i
|
|
1296
|
+
];
|
|
1297
|
+
var SUSPICIOUS_SCREENS = [
|
|
1298
|
+
{ width: 800, height: 600 },
|
|
1299
|
+
{ width: 1024, height: 768 },
|
|
1300
|
+
{ width: 1, height: 1 },
|
|
1301
|
+
{ width: 0, height: 0 }
|
|
1302
|
+
];
|
|
1303
|
+
function checkWebDriverDetected(device) {
|
|
1304
|
+
if (!device) {
|
|
1305
|
+
return {
|
|
1306
|
+
checkId: "webdriver_detected",
|
|
1307
|
+
passed: true,
|
|
1308
|
+
score: 0,
|
|
1309
|
+
details: "No device info available"
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
if (device.webDriver === true) {
|
|
1313
|
+
return {
|
|
1314
|
+
checkId: "webdriver_detected",
|
|
1315
|
+
passed: false,
|
|
1316
|
+
score: 1,
|
|
1317
|
+
details: "WebDriver automation detected",
|
|
1318
|
+
data: { webDriver: true }
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
return {
|
|
1322
|
+
checkId: "webdriver_detected",
|
|
1323
|
+
passed: true,
|
|
1324
|
+
score: 0,
|
|
1325
|
+
data: { webDriver: false }
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
function checkAutomationDetected(device) {
|
|
1329
|
+
if (!device) {
|
|
1330
|
+
return {
|
|
1331
|
+
checkId: "automation_detected",
|
|
1332
|
+
passed: true,
|
|
1333
|
+
score: 0,
|
|
1334
|
+
details: "No device info available"
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
if (device.automationDetected === true) {
|
|
1338
|
+
return {
|
|
1339
|
+
checkId: "automation_detected",
|
|
1340
|
+
passed: false,
|
|
1341
|
+
score: 1,
|
|
1342
|
+
details: "Browser automation framework detected",
|
|
1343
|
+
data: { automationDetected: true }
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
return {
|
|
1347
|
+
checkId: "automation_detected",
|
|
1348
|
+
passed: true,
|
|
1349
|
+
score: 0,
|
|
1350
|
+
data: { automationDetected: false }
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
function checkNoPlugins(device) {
|
|
1354
|
+
if (!device) {
|
|
1355
|
+
return {
|
|
1356
|
+
checkId: "no_plugins",
|
|
1357
|
+
passed: true,
|
|
1358
|
+
score: 0,
|
|
1359
|
+
details: "No device info available"
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
const { pluginCount } = device;
|
|
1363
|
+
if (pluginCount === 0) {
|
|
1364
|
+
const isMobile = device.touchSupport || device.maxTouchPoints > 0;
|
|
1365
|
+
if (!isMobile) {
|
|
1366
|
+
return {
|
|
1367
|
+
checkId: "no_plugins",
|
|
1368
|
+
passed: true,
|
|
1369
|
+
score: 0.5,
|
|
1370
|
+
details: "No browser plugins (common in automation)",
|
|
1371
|
+
data: { pluginCount }
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return {
|
|
1376
|
+
checkId: "no_plugins",
|
|
1377
|
+
passed: true,
|
|
1378
|
+
score: 0,
|
|
1379
|
+
data: { pluginCount }
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
function checkSuspiciousUserAgent(device) {
|
|
1383
|
+
if (!device?.userAgent) {
|
|
1384
|
+
return {
|
|
1385
|
+
checkId: "suspicious_user_agent",
|
|
1386
|
+
passed: true,
|
|
1387
|
+
score: 0,
|
|
1388
|
+
details: "No user agent available"
|
|
1389
|
+
};
|
|
63
1390
|
}
|
|
1391
|
+
const { userAgent } = device;
|
|
1392
|
+
for (const pattern of BOT_USER_AGENTS) {
|
|
1393
|
+
if (pattern.test(userAgent)) {
|
|
1394
|
+
return {
|
|
1395
|
+
checkId: "suspicious_user_agent",
|
|
1396
|
+
passed: false,
|
|
1397
|
+
score: 1,
|
|
1398
|
+
details: "Bot-like user agent detected",
|
|
1399
|
+
data: { pattern: pattern.source }
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
if (userAgent.length < 20) {
|
|
1404
|
+
return {
|
|
1405
|
+
checkId: "suspicious_user_agent",
|
|
1406
|
+
passed: true,
|
|
1407
|
+
score: 0.5,
|
|
1408
|
+
details: "Unusually short user agent",
|
|
1409
|
+
data: { length: userAgent.length }
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
return {
|
|
1413
|
+
checkId: "suspicious_user_agent",
|
|
1414
|
+
passed: true,
|
|
1415
|
+
score: 0
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
function checkDeviceFingerprintMismatch(device) {
|
|
1419
|
+
if (!device) {
|
|
1420
|
+
return {
|
|
1421
|
+
checkId: "device_fingerprint_mismatch",
|
|
1422
|
+
passed: true,
|
|
1423
|
+
score: 0,
|
|
1424
|
+
details: "No device info available"
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
const issues = [];
|
|
1428
|
+
if (device.touchSupport && device.maxTouchPoints === 0) {
|
|
1429
|
+
issues.push("Touch support claimed but no touch points");
|
|
1430
|
+
}
|
|
1431
|
+
if (device.screenWidth < device.screenAvailWidth || device.screenHeight < device.screenAvailHeight) {
|
|
1432
|
+
issues.push("Available screen larger than total screen");
|
|
1433
|
+
}
|
|
1434
|
+
if (device.hardwareConcurrency > 128) {
|
|
1435
|
+
issues.push("Impossible CPU core count");
|
|
1436
|
+
}
|
|
1437
|
+
if (device.deviceMemory > 256) {
|
|
1438
|
+
issues.push("Impossible memory amount");
|
|
1439
|
+
}
|
|
1440
|
+
if (device.pixelRatio <= 0 || device.pixelRatio > 10) {
|
|
1441
|
+
issues.push("Invalid pixel ratio");
|
|
1442
|
+
}
|
|
1443
|
+
if (issues.length >= 2) {
|
|
1444
|
+
return {
|
|
1445
|
+
checkId: "device_fingerprint_mismatch",
|
|
1446
|
+
passed: false,
|
|
1447
|
+
score: 0.8,
|
|
1448
|
+
details: "Multiple device characteristic mismatches",
|
|
1449
|
+
data: { issues }
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
if (issues.length === 1) {
|
|
1453
|
+
return {
|
|
1454
|
+
checkId: "device_fingerprint_mismatch",
|
|
1455
|
+
passed: true,
|
|
1456
|
+
score: 0.4,
|
|
1457
|
+
details: issues[0],
|
|
1458
|
+
data: { issues }
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
return {
|
|
1462
|
+
checkId: "device_fingerprint_mismatch",
|
|
1463
|
+
passed: true,
|
|
1464
|
+
score: 0
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
function checkScreenAnomaly(device) {
|
|
1468
|
+
if (!device) {
|
|
1469
|
+
return {
|
|
1470
|
+
checkId: "screen_anomaly",
|
|
1471
|
+
passed: true,
|
|
1472
|
+
score: 0,
|
|
1473
|
+
details: "No device info available"
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
const { screenWidth, screenHeight } = device;
|
|
1477
|
+
if (screenWidth <= 0 || screenHeight <= 0) {
|
|
1478
|
+
return {
|
|
1479
|
+
checkId: "screen_anomaly",
|
|
1480
|
+
passed: false,
|
|
1481
|
+
score: 1,
|
|
1482
|
+
details: "Invalid screen dimensions",
|
|
1483
|
+
data: { screenWidth, screenHeight }
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
for (const suspicious of SUSPICIOUS_SCREENS) {
|
|
1487
|
+
if (screenWidth === suspicious.width && screenHeight === suspicious.height) {
|
|
1488
|
+
return {
|
|
1489
|
+
checkId: "screen_anomaly",
|
|
1490
|
+
passed: true,
|
|
1491
|
+
score: 0.4,
|
|
1492
|
+
details: "Common automation screen size",
|
|
1493
|
+
data: { screenWidth, screenHeight }
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
const aspectRatio = screenWidth / screenHeight;
|
|
1498
|
+
if (aspectRatio < 0.3 || aspectRatio > 5) {
|
|
1499
|
+
return {
|
|
1500
|
+
checkId: "screen_anomaly",
|
|
1501
|
+
passed: true,
|
|
1502
|
+
score: 0.5,
|
|
1503
|
+
details: "Unusual screen aspect ratio",
|
|
1504
|
+
data: { aspectRatio }
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
return {
|
|
1508
|
+
checkId: "screen_anomaly",
|
|
1509
|
+
passed: true,
|
|
1510
|
+
score: 0
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
function checkTimezoneValidation(device) {
|
|
1514
|
+
if (!device) {
|
|
1515
|
+
return {
|
|
1516
|
+
checkId: "timezone_validation",
|
|
1517
|
+
passed: true,
|
|
1518
|
+
score: 0,
|
|
1519
|
+
details: "No device info available"
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
const { timezone, timezoneOffset } = device;
|
|
1523
|
+
if (!timezone) {
|
|
1524
|
+
return {
|
|
1525
|
+
checkId: "timezone_validation",
|
|
1526
|
+
passed: true,
|
|
1527
|
+
score: 0.3,
|
|
1528
|
+
details: "No timezone information"
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
if (timezoneOffset < -840 || timezoneOffset > 720) {
|
|
1532
|
+
return {
|
|
1533
|
+
checkId: "timezone_validation",
|
|
1534
|
+
passed: false,
|
|
1535
|
+
score: 0.7,
|
|
1536
|
+
details: "Invalid timezone offset",
|
|
1537
|
+
data: { timezoneOffset }
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
return {
|
|
1541
|
+
checkId: "timezone_validation",
|
|
1542
|
+
passed: true,
|
|
1543
|
+
score: 0,
|
|
1544
|
+
data: { timezone, timezoneOffset }
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
function runDeviceChecks(device) {
|
|
1548
|
+
return [
|
|
1549
|
+
checkWebDriverDetected(device),
|
|
1550
|
+
checkAutomationDetected(device),
|
|
1551
|
+
checkNoPlugins(device),
|
|
1552
|
+
checkSuspiciousUserAgent(device),
|
|
1553
|
+
checkDeviceFingerprintMismatch(device),
|
|
1554
|
+
checkScreenAnomaly(device),
|
|
1555
|
+
checkTimezoneValidation(device)
|
|
1556
|
+
];
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// src/scoring.ts
|
|
1560
|
+
function checkName(checkId) {
|
|
1561
|
+
return CHECKS[checkId]?.name ?? checkId;
|
|
1562
|
+
}
|
|
1563
|
+
function clamp01(n) {
|
|
1564
|
+
return Math.max(0, Math.min(1, n));
|
|
1565
|
+
}
|
|
1566
|
+
function noisyOr(scores) {
|
|
1567
|
+
return 1 - scores.reduce((product, s) => product * (1 - clamp01(s)), 1);
|
|
1568
|
+
}
|
|
1569
|
+
function offlineRequestId() {
|
|
1570
|
+
return `req_local_${Math.random().toString(36).slice(2, 12)}`;
|
|
1571
|
+
}
|
|
1572
|
+
function buildSummary(checks, score, recommendation) {
|
|
1573
|
+
const failed = checks.filter((c) => !c.passed);
|
|
1574
|
+
let verdict;
|
|
1575
|
+
if (score >= 0.85) verdict = "High-quality legitimate response";
|
|
1576
|
+
else if (score >= 0.7) verdict = "Likely legitimate with minor concerns";
|
|
1577
|
+
else if (score >= 0.5) verdict = "Borderline quality \u2014 manual review recommended";
|
|
1578
|
+
else if (score >= 0.3) verdict = "Multiple issues detected \u2014 likely low quality";
|
|
1579
|
+
else verdict = "Suspected fraudulent or bot response";
|
|
1580
|
+
const issues = failed.map((c) => c.details ?? checkName(c.checkId));
|
|
1581
|
+
const positives = [];
|
|
1582
|
+
const failedIds = new Set(failed.map((c) => c.checkId));
|
|
1583
|
+
if (![...failedIds].some((id) => id.includes("timing") || id === "rapid_completion" || id === "impossibly_fast")) {
|
|
1584
|
+
positives.push("Response timing appears natural");
|
|
1585
|
+
}
|
|
1586
|
+
if (![...failedIds].some((id) => id.includes("automation") || id === "webdriver_detected")) {
|
|
1587
|
+
positives.push("No automation tools detected");
|
|
1588
|
+
}
|
|
1589
|
+
if (![...failedIds].some((id) => id.includes("ai_content"))) {
|
|
1590
|
+
positives.push("Content appears human-written");
|
|
1591
|
+
}
|
|
1592
|
+
if (!failedIds.has("minimal_effort")) {
|
|
1593
|
+
positives.push("Response shows reasonable effort");
|
|
1594
|
+
}
|
|
1595
|
+
let suggestion;
|
|
1596
|
+
if (recommendation === "keep") suggestion = "Response can be accepted as-is";
|
|
1597
|
+
else if (recommendation === "review") suggestion = "Consider manual review before accepting";
|
|
1598
|
+
else suggestion = "Response should be rejected or flagged for investigation";
|
|
1599
|
+
return { verdict, issues, positives, suggestion };
|
|
1600
|
+
}
|
|
1601
|
+
function buildResult(checks, tier, thresholds, processingTimeMs) {
|
|
1602
|
+
const failedChecks = checks.filter((c) => !c.passed);
|
|
1603
|
+
const passedChecks = checks.filter((c) => c.passed);
|
|
1604
|
+
const hardSuspicion = noisyOr(failedChecks.map((c) => c.score));
|
|
1605
|
+
const softMean = checks.length > 0 ? passedChecks.reduce((sum, c) => sum + c.score, 0) / checks.length : 0;
|
|
1606
|
+
const softSuspicion = Math.min(0.35, softMean);
|
|
1607
|
+
const suspicion = 1 - (1 - hardSuspicion) * (1 - softSuspicion);
|
|
1608
|
+
const score = clamp01(1 - suspicion);
|
|
1609
|
+
const passed = score >= thresholds.fail;
|
|
1610
|
+
let recommendation;
|
|
1611
|
+
if (score >= thresholds.review) recommendation = "keep";
|
|
1612
|
+
else if (score >= thresholds.fail) recommendation = "review";
|
|
1613
|
+
else recommendation = "discard";
|
|
1614
|
+
const flags = failedChecks.map((c) => checkName(c.checkId));
|
|
1615
|
+
const summary = buildSummary(checks, score, recommendation);
|
|
1616
|
+
const strongestSignal = checks.reduce((max, c) => Math.max(max, c.score), 0);
|
|
1617
|
+
const confidence = clamp01(0.5 + checks.length * 0.015 + strongestSignal * 0.2);
|
|
1618
|
+
return {
|
|
1619
|
+
score,
|
|
1620
|
+
passed,
|
|
1621
|
+
recommendation,
|
|
1622
|
+
confidence,
|
|
1623
|
+
flags,
|
|
1624
|
+
summary,
|
|
1625
|
+
checks,
|
|
1626
|
+
meta: {
|
|
1627
|
+
tier,
|
|
1628
|
+
processingTimeMs,
|
|
1629
|
+
checksRun: checks.length,
|
|
1630
|
+
checksPassed: passedChecks.length,
|
|
1631
|
+
requestId: offlineRequestId(),
|
|
1632
|
+
timestamp: Date.now()
|
|
1633
|
+
}
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
function buildBatchResult(results, submissions, crossAnalysis) {
|
|
1637
|
+
const total = results.length;
|
|
1638
|
+
const passed = results.filter((r) => r.recommendation === "keep").length;
|
|
1639
|
+
const review = results.filter((r) => r.recommendation === "review").length;
|
|
1640
|
+
const failed = results.filter((r) => r.recommendation === "discard").length;
|
|
1641
|
+
const avgScore = total > 0 ? results.reduce((sum, r) => sum + r.score, 0) / total : 0;
|
|
1642
|
+
const batch = {
|
|
1643
|
+
results,
|
|
1644
|
+
summary: { total, passed, review, failed, avgScore }
|
|
1645
|
+
};
|
|
1646
|
+
if (crossAnalysis) {
|
|
1647
|
+
batch.fraudIndicators = detectFraudIndicators(submissions);
|
|
1648
|
+
}
|
|
1649
|
+
return batch;
|
|
1650
|
+
}
|
|
1651
|
+
function detectFraudIndicators(submissions) {
|
|
1652
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1653
|
+
const duplicateAnswers = [];
|
|
1654
|
+
submissions.forEach((sub, index) => {
|
|
1655
|
+
const key = sub.responses.map((r) => `${r.question}=${r.answer}`).join("|");
|
|
1656
|
+
if (seen.has(key)) {
|
|
1657
|
+
const id = sub.context?.surveyId ?? `submission_${index}`;
|
|
1658
|
+
duplicateAnswers.push(id);
|
|
1659
|
+
} else {
|
|
1660
|
+
seen.set(key, index);
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
return {
|
|
1664
|
+
duplicateAnswers,
|
|
1665
|
+
coordinatedTiming: false,
|
|
1666
|
+
deviceSharing: false,
|
|
1667
|
+
fraudRingScore: clamp01(duplicateAnswers.length / Math.max(1, submissions.length))
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// src/cipher.ts
|
|
1672
|
+
var DEFAULT_CONFIG = {
|
|
1673
|
+
tier: 3,
|
|
1674
|
+
thresholds: {
|
|
1675
|
+
fail: 0.4,
|
|
1676
|
+
review: 0.7
|
|
1677
|
+
},
|
|
1678
|
+
debug: false,
|
|
1679
|
+
offline: false,
|
|
1680
|
+
endpoint: "https://api.surbee.com/v1/cipher"
|
|
64
1681
|
};
|
|
65
1682
|
var Cipher = class {
|
|
66
1683
|
config;
|
|
67
|
-
constructor(config) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
1684
|
+
constructor(config = {}) {
|
|
1685
|
+
const offline = config.offline ?? DEFAULT_CONFIG.offline;
|
|
1686
|
+
if (config.apiKey) {
|
|
1687
|
+
if (!config.apiKey.startsWith("cipher_sk_") && !config.apiKey.startsWith("cipher_pk_")) {
|
|
1688
|
+
throw new Error("Invalid API key format. Keys should start with cipher_sk_ or cipher_pk_");
|
|
1689
|
+
}
|
|
1690
|
+
} else if (!offline) {
|
|
1691
|
+
throw new Error(
|
|
1692
|
+
"API key is required for online validation. Pass { offline: true } to run tier 1\u20132 checks locally, or get a key from Settings > API Keys in your Surbee dashboard."
|
|
1693
|
+
);
|
|
73
1694
|
}
|
|
74
1695
|
this.config = {
|
|
75
|
-
apiKey: config.apiKey,
|
|
1696
|
+
apiKey: config.apiKey ?? "",
|
|
76
1697
|
tier: config.tier ?? DEFAULT_CONFIG.tier,
|
|
77
1698
|
thresholds: {
|
|
78
1699
|
fail: config.thresholds?.fail ?? DEFAULT_CONFIG.thresholds.fail,
|
|
79
1700
|
review: config.thresholds?.review ?? DEFAULT_CONFIG.thresholds.review
|
|
80
1701
|
},
|
|
81
1702
|
debug: config.debug ?? DEFAULT_CONFIG.debug,
|
|
1703
|
+
offline,
|
|
82
1704
|
endpoint: config.endpoint ?? DEFAULT_CONFIG.endpoint
|
|
83
1705
|
};
|
|
84
1706
|
}
|
|
85
1707
|
/**
|
|
86
|
-
* Validate a single response
|
|
1708
|
+
* Validate a single response.
|
|
1709
|
+
*
|
|
1710
|
+
* Runs locally when `offline: true`, otherwise calls Surbee's validation
|
|
1711
|
+
* engine (which can run the AI-powered checks for tiers 3–5).
|
|
87
1712
|
*/
|
|
88
1713
|
async validate(input) {
|
|
1714
|
+
if (this.config.offline) {
|
|
1715
|
+
return this.validateSync(input);
|
|
1716
|
+
}
|
|
89
1717
|
const response = await this.request("/validate", {
|
|
90
1718
|
tier: this.config.tier,
|
|
91
1719
|
thresholds: this.config.thresholds,
|
|
@@ -94,32 +1722,80 @@ var Cipher = class {
|
|
|
94
1722
|
return response;
|
|
95
1723
|
}
|
|
96
1724
|
/**
|
|
97
|
-
* Validate
|
|
1725
|
+
* Validate a single response synchronously, fully on-device.
|
|
1726
|
+
*
|
|
1727
|
+
* Only the offline checks for the configured tier are evaluated; AI-powered
|
|
1728
|
+
* checks (tiers 3+) are skipped. No API key or network access required.
|
|
1729
|
+
*/
|
|
1730
|
+
validateSync(input) {
|
|
1731
|
+
const start = Date.now();
|
|
1732
|
+
const checks = this.runOfflineChecks(input);
|
|
1733
|
+
return buildResult(checks, this.config.tier, this.config.thresholds, Date.now() - start);
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Validate multiple responses in batch.
|
|
1737
|
+
*
|
|
1738
|
+
* Runs locally when `offline: true`, otherwise calls Surbee's batch endpoint.
|
|
98
1739
|
*/
|
|
99
1740
|
async validateBatch(input) {
|
|
1741
|
+
const crossAnalysis = input.crossAnalysis ?? this.config.tier === 5;
|
|
1742
|
+
if (this.config.offline) {
|
|
1743
|
+
const results = input.submissions.map((submission) => this.validateSync(submission));
|
|
1744
|
+
return buildBatchResult(results, input.submissions, crossAnalysis);
|
|
1745
|
+
}
|
|
100
1746
|
const response = await this.request("/validate/batch", {
|
|
101
1747
|
tier: this.config.tier,
|
|
102
1748
|
thresholds: this.config.thresholds,
|
|
103
1749
|
submissions: input.submissions,
|
|
104
|
-
crossAnalysis
|
|
1750
|
+
crossAnalysis
|
|
105
1751
|
});
|
|
106
1752
|
return response;
|
|
107
1753
|
}
|
|
108
1754
|
/**
|
|
109
|
-
*
|
|
1755
|
+
* Run the offline checks that apply to the configured tier.
|
|
1756
|
+
*/
|
|
1757
|
+
runOfflineChecks(input) {
|
|
1758
|
+
const { responses, behavioralMetrics, deviceInfo, context } = input;
|
|
1759
|
+
const all = [
|
|
1760
|
+
...runTimingChecks(responses, behavioralMetrics, context),
|
|
1761
|
+
...runBehavioralChecks(behavioralMetrics),
|
|
1762
|
+
...runContentChecks(responses, behavioralMetrics),
|
|
1763
|
+
...runDeviceChecks(deviceInfo)
|
|
1764
|
+
];
|
|
1765
|
+
const byId = new Map(all.map((c) => [c.checkId, c]));
|
|
1766
|
+
const offlineIds = getOfflineChecksForTier(this.config.tier);
|
|
1767
|
+
return offlineIds.map((id) => byId.get(id)).filter((c) => Boolean(c));
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* Estimate the per-response API cost for a number of responses at the
|
|
1771
|
+
* configured tier (tiers 1–2 are free).
|
|
1772
|
+
*/
|
|
1773
|
+
estimateCost(responses = 1) {
|
|
1774
|
+
return estimateCost(this.config.tier, responses);
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Get tier information, including the list of checks it runs.
|
|
110
1778
|
*/
|
|
111
1779
|
getTierInfo(tier) {
|
|
112
1780
|
const t = tier ?? this.config.tier;
|
|
113
|
-
|
|
1781
|
+
const config = TIERS[t];
|
|
1782
|
+
const checks = getChecksForTier(t);
|
|
1783
|
+
return {
|
|
1784
|
+
tier: t,
|
|
1785
|
+
name: config.name,
|
|
1786
|
+
description: config.description,
|
|
1787
|
+
checks,
|
|
1788
|
+
checksCount: checks.length,
|
|
1789
|
+
aiModel: config.aiModel,
|
|
1790
|
+
offline: config.offline,
|
|
1791
|
+
estimatedCostPerResponse: config.estimatedCostPerResponse
|
|
1792
|
+
};
|
|
114
1793
|
}
|
|
115
1794
|
/**
|
|
116
1795
|
* Get all available tiers
|
|
117
1796
|
*/
|
|
118
1797
|
getAllTiers() {
|
|
119
|
-
return
|
|
120
|
-
tier: Number(tier),
|
|
121
|
-
...info
|
|
122
|
-
}));
|
|
1798
|
+
return [1, 2, 3, 4, 5].map((tier) => this.getTierInfo(tier));
|
|
123
1799
|
}
|
|
124
1800
|
/**
|
|
125
1801
|
* Check API key validity and credits
|
|
@@ -132,6 +1808,11 @@ var Cipher = class {
|
|
|
132
1808
|
* Make API request to Surbee
|
|
133
1809
|
*/
|
|
134
1810
|
async request(path, body) {
|
|
1811
|
+
if (!this.config.apiKey) {
|
|
1812
|
+
throw this.createError(401, {
|
|
1813
|
+
message: "This Cipher instance has no API key. Provide one to use online validation, or call validateSync() for offline checks."
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
135
1816
|
const url = `${this.config.endpoint}${path}`;
|
|
136
1817
|
if (this.config.debug) {
|
|
137
1818
|
console.log(`[Cipher] POST ${url}`);
|