@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.
@@ -0,0 +1,1104 @@
1
+ // src/checks/timing.ts
2
+ var MIN_MS_PER_CHAR = 20;
3
+ var MIN_RESPONSE_TIME_MS = 1e3;
4
+ function checkImpossiblyFast(responses, context) {
5
+ if (!context?.actualDurationSeconds || !context?.expectedDurationSeconds) {
6
+ return {
7
+ checkId: "impossibly_fast",
8
+ passed: true,
9
+ score: 0,
10
+ details: "No timing data available"
11
+ };
12
+ }
13
+ const actualMs = context.actualDurationSeconds * 1e3;
14
+ const expectedMs = context.expectedDurationSeconds * 1e3;
15
+ let minPossibleMs = 0;
16
+ for (const response of responses) {
17
+ const questionChars = response.question.length;
18
+ const readingTime = questionChars * MIN_MS_PER_CHAR;
19
+ minPossibleMs += Math.max(readingTime, MIN_RESPONSE_TIME_MS);
20
+ }
21
+ const suspicionThreshold = minPossibleMs * 0.3;
22
+ if (actualMs < suspicionThreshold) {
23
+ return {
24
+ checkId: "impossibly_fast",
25
+ passed: false,
26
+ score: 1,
27
+ details: `Completed in ${context.actualDurationSeconds}s, minimum expected ${Math.round(minPossibleMs / 1e3)}s`,
28
+ data: { actualMs, minPossibleMs, threshold: suspicionThreshold }
29
+ };
30
+ }
31
+ const speedRatio = actualMs / expectedMs;
32
+ const score = speedRatio < 1 ? Math.max(0, 1 - speedRatio) * 0.7 : 0;
33
+ return {
34
+ checkId: "impossibly_fast",
35
+ passed: true,
36
+ score,
37
+ details: speedRatio < 0.5 ? "Faster than average" : void 0,
38
+ data: { actualMs, expectedMs, speedRatio }
39
+ };
40
+ }
41
+ function checkRapidCompletion(responses, context) {
42
+ if (!context?.actualDurationSeconds || !context?.expectedDurationSeconds) {
43
+ return {
44
+ checkId: "rapid_completion",
45
+ passed: true,
46
+ score: 0,
47
+ details: "No timing data available"
48
+ };
49
+ }
50
+ const ratio = context.actualDurationSeconds / context.expectedDurationSeconds;
51
+ if (ratio < 0.2) {
52
+ return {
53
+ checkId: "rapid_completion",
54
+ passed: false,
55
+ score: 1,
56
+ details: `Completed in ${Math.round(ratio * 100)}% of expected time`,
57
+ data: { ratio }
58
+ };
59
+ }
60
+ if (ratio < 0.4) {
61
+ return {
62
+ checkId: "rapid_completion",
63
+ passed: true,
64
+ score: 0.5,
65
+ details: "Faster than typical",
66
+ data: { ratio }
67
+ };
68
+ }
69
+ return {
70
+ checkId: "rapid_completion",
71
+ passed: true,
72
+ score: 0,
73
+ data: { ratio }
74
+ };
75
+ }
76
+ function checkUniformTiming(responses, metrics) {
77
+ const times = metrics?.responseTime || responses.map((r) => r.responseTimeMs).filter(Boolean);
78
+ if (times.length < 3) {
79
+ return {
80
+ checkId: "uniform_timing",
81
+ passed: true,
82
+ score: 0,
83
+ details: "Not enough timing data"
84
+ };
85
+ }
86
+ const mean = times.reduce((a, b) => a + b, 0) / times.length;
87
+ const variance = times.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / times.length;
88
+ const stddev = Math.sqrt(variance);
89
+ const cv = stddev / mean;
90
+ if (cv < 0.1) {
91
+ return {
92
+ checkId: "uniform_timing",
93
+ passed: false,
94
+ score: 1,
95
+ details: "Response times are suspiciously uniform",
96
+ data: { cv, mean, stddev }
97
+ };
98
+ }
99
+ if (cv < 0.2) {
100
+ return {
101
+ checkId: "uniform_timing",
102
+ passed: true,
103
+ score: 0.5,
104
+ details: "Lower than typical timing variation",
105
+ data: { cv, mean, stddev }
106
+ };
107
+ }
108
+ return {
109
+ checkId: "uniform_timing",
110
+ passed: true,
111
+ score: 0,
112
+ data: { cv, mean, stddev }
113
+ };
114
+ }
115
+ function checkSuspiciousPauses(metrics) {
116
+ if (!metrics?.focusEvents || metrics.focusEvents.length < 2) {
117
+ return {
118
+ checkId: "suspicious_pauses",
119
+ passed: true,
120
+ score: 0,
121
+ details: "No focus event data"
122
+ };
123
+ }
124
+ const blurEvents = metrics.focusEvents.filter((e) => e.type === "blur" || e.type === "hidden");
125
+ const totalBlurTime = metrics.totalBlurDuration || 0;
126
+ const surveyDuration = metrics.duration || 1;
127
+ const blurRatio = totalBlurTime / surveyDuration;
128
+ if (blurRatio > 0.5) {
129
+ return {
130
+ checkId: "suspicious_pauses",
131
+ passed: false,
132
+ score: 0.9,
133
+ details: `${Math.round(blurRatio * 100)}% of time spent away from survey`,
134
+ data: { blurRatio, totalBlurTime, blurEvents: blurEvents.length }
135
+ };
136
+ }
137
+ if (blurRatio > 0.3) {
138
+ return {
139
+ checkId: "suspicious_pauses",
140
+ passed: true,
141
+ score: 0.5,
142
+ details: "Significant time away from survey",
143
+ data: { blurRatio, totalBlurTime }
144
+ };
145
+ }
146
+ return {
147
+ checkId: "suspicious_pauses",
148
+ passed: true,
149
+ score: 0,
150
+ data: { blurRatio }
151
+ };
152
+ }
153
+ function runTimingChecks(responses, metrics, context) {
154
+ return [
155
+ checkImpossiblyFast(responses, context),
156
+ checkRapidCompletion(responses, context),
157
+ checkUniformTiming(responses, metrics),
158
+ checkSuspiciousPauses(metrics)
159
+ ];
160
+ }
161
+
162
+ // src/checks/behavioral.ts
163
+ function checkLowInteraction(metrics) {
164
+ if (!metrics) {
165
+ return {
166
+ checkId: "low_interaction",
167
+ passed: true,
168
+ score: 0,
169
+ details: "No behavioral data available"
170
+ };
171
+ }
172
+ const durationSeconds = (metrics.duration || 1) / 1e3;
173
+ const totalInteractions = metrics.mouseMovementCount + metrics.keypressCount + metrics.scrollEventCount;
174
+ const interactionsPerSecond = totalInteractions / durationSeconds;
175
+ if (interactionsPerSecond < 0.1) {
176
+ return {
177
+ checkId: "low_interaction",
178
+ passed: false,
179
+ score: 1,
180
+ details: "Almost no user interaction detected",
181
+ data: { interactionsPerSecond, totalInteractions, durationSeconds }
182
+ };
183
+ }
184
+ if (interactionsPerSecond < 0.5) {
185
+ return {
186
+ checkId: "low_interaction",
187
+ passed: true,
188
+ score: 0.6,
189
+ details: "Below average interaction rate",
190
+ data: { interactionsPerSecond }
191
+ };
192
+ }
193
+ return {
194
+ checkId: "low_interaction",
195
+ passed: true,
196
+ score: 0,
197
+ data: { interactionsPerSecond }
198
+ };
199
+ }
200
+ function checkExcessivePaste(metrics) {
201
+ if (!metrics) {
202
+ return {
203
+ checkId: "excessive_paste",
204
+ passed: true,
205
+ score: 0,
206
+ details: "No behavioral data available"
207
+ };
208
+ }
209
+ const { pasteEvents, keypressCount } = metrics;
210
+ if (pasteEvents > 0 && keypressCount === 0) {
211
+ return {
212
+ checkId: "excessive_paste",
213
+ passed: false,
214
+ score: 1,
215
+ details: "All content was pasted, no typing detected",
216
+ data: { pasteEvents, keypressCount }
217
+ };
218
+ }
219
+ const pasteRatio = keypressCount > 0 ? pasteEvents / keypressCount : 0;
220
+ if (pasteRatio > 0.5) {
221
+ return {
222
+ checkId: "excessive_paste",
223
+ passed: false,
224
+ score: 0.8,
225
+ details: "High paste-to-typing ratio",
226
+ data: { pasteRatio, pasteEvents }
227
+ };
228
+ }
229
+ if (pasteRatio > 0.2) {
230
+ return {
231
+ checkId: "excessive_paste",
232
+ passed: true,
233
+ score: 0.4,
234
+ details: "Some paste events detected",
235
+ data: { pasteRatio }
236
+ };
237
+ }
238
+ return {
239
+ checkId: "excessive_paste",
240
+ passed: true,
241
+ score: 0,
242
+ data: { pasteEvents }
243
+ };
244
+ }
245
+ function checkPointerSpikes(metrics) {
246
+ if (!metrics?.mouseMovements || metrics.mouseMovements.length < 10) {
247
+ return {
248
+ checkId: "pointer_spikes",
249
+ passed: true,
250
+ score: 0,
251
+ details: "Insufficient mouse data"
252
+ };
253
+ }
254
+ const velocities = metrics.mouseMovements.map((m) => m.velocity).filter((v) => v > 0);
255
+ if (velocities.length === 0) {
256
+ return {
257
+ checkId: "pointer_spikes",
258
+ passed: true,
259
+ score: 0,
260
+ details: "No velocity data"
261
+ };
262
+ }
263
+ const spikeThreshold = 50;
264
+ const spikes = velocities.filter((v) => v > spikeThreshold);
265
+ const spikeRatio = spikes.length / velocities.length;
266
+ if (spikeRatio > 0.3) {
267
+ return {
268
+ checkId: "pointer_spikes",
269
+ passed: false,
270
+ score: 0.9,
271
+ details: "Many unnatural mouse speed spikes detected",
272
+ data: { spikeRatio, spikeCount: spikes.length }
273
+ };
274
+ }
275
+ if (spikeRatio > 0.1) {
276
+ return {
277
+ checkId: "pointer_spikes",
278
+ passed: true,
279
+ score: 0.4,
280
+ details: "Some unusual mouse movements",
281
+ data: { spikeRatio }
282
+ };
283
+ }
284
+ return {
285
+ checkId: "pointer_spikes",
286
+ passed: true,
287
+ score: 0,
288
+ data: { spikeRatio }
289
+ };
290
+ }
291
+ function checkRoboticTyping(metrics) {
292
+ if (!metrics?.keystrokeDynamics || metrics.keystrokeDynamics.length < 10) {
293
+ return {
294
+ checkId: "robotic_typing",
295
+ passed: true,
296
+ score: 0,
297
+ details: "Insufficient keystroke data"
298
+ };
299
+ }
300
+ const dwellTimes = metrics.keystrokeDynamics.map((k) => k.dwell).filter((d) => d > 0 && d < 1e3);
301
+ if (dwellTimes.length < 5) {
302
+ return {
303
+ checkId: "robotic_typing",
304
+ passed: true,
305
+ score: 0,
306
+ details: "Not enough valid keystroke data"
307
+ };
308
+ }
309
+ const mean = dwellTimes.reduce((a, b) => a + b, 0) / dwellTimes.length;
310
+ const variance = dwellTimes.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / dwellTimes.length;
311
+ const stddev = Math.sqrt(variance);
312
+ const cv = stddev / mean;
313
+ if (cv < 0.08) {
314
+ return {
315
+ checkId: "robotic_typing",
316
+ passed: false,
317
+ score: 1,
318
+ details: "Keystroke timing is machine-like uniform",
319
+ data: { cv, mean, stddev }
320
+ };
321
+ }
322
+ if (cv < 0.15) {
323
+ return {
324
+ checkId: "robotic_typing",
325
+ passed: true,
326
+ score: 0.5,
327
+ details: "Lower than typical keystroke variation",
328
+ data: { cv }
329
+ };
330
+ }
331
+ return {
332
+ checkId: "robotic_typing",
333
+ passed: true,
334
+ score: 0,
335
+ data: { cv }
336
+ };
337
+ }
338
+ function checkMouseTeleporting(metrics) {
339
+ if (!metrics?.mouseMovements || metrics.mouseMovements.length < 5) {
340
+ return {
341
+ checkId: "mouse_teleporting",
342
+ passed: true,
343
+ score: 0,
344
+ details: "Insufficient mouse data"
345
+ };
346
+ }
347
+ let teleportCount = 0;
348
+ const movements = metrics.mouseMovements;
349
+ for (let i = 1; i < movements.length; i++) {
350
+ const prev = movements[i - 1];
351
+ const curr = movements[i];
352
+ const dx = curr.x - prev.x;
353
+ const dy = curr.y - prev.y;
354
+ const distance = Math.sqrt(dx * dx + dy * dy);
355
+ const dt = curr.t - prev.t;
356
+ if (distance > 500 && dt < 10) {
357
+ teleportCount++;
358
+ }
359
+ }
360
+ const teleportRatio = teleportCount / movements.length;
361
+ if (teleportRatio > 0.2) {
362
+ return {
363
+ checkId: "mouse_teleporting",
364
+ passed: false,
365
+ score: 0.9,
366
+ details: "Frequent mouse teleportation detected",
367
+ data: { teleportCount, teleportRatio }
368
+ };
369
+ }
370
+ if (teleportRatio > 0.05) {
371
+ return {
372
+ checkId: "mouse_teleporting",
373
+ passed: true,
374
+ score: 0.4,
375
+ details: "Some mouse teleportation detected",
376
+ data: { teleportCount }
377
+ };
378
+ }
379
+ return {
380
+ checkId: "mouse_teleporting",
381
+ passed: true,
382
+ score: 0,
383
+ data: { teleportCount }
384
+ };
385
+ }
386
+ function checkNoCorrections(metrics) {
387
+ if (!metrics || metrics.keypressCount < 20) {
388
+ return {
389
+ checkId: "no_corrections",
390
+ passed: true,
391
+ score: 0,
392
+ details: "Insufficient typing data"
393
+ };
394
+ }
395
+ const { backspaceCount, keypressCount } = metrics;
396
+ const correctionRatio = backspaceCount / keypressCount;
397
+ if (backspaceCount === 0 && keypressCount > 50) {
398
+ return {
399
+ checkId: "no_corrections",
400
+ passed: false,
401
+ score: 0.8,
402
+ details: "No typing corrections despite significant text entry",
403
+ data: { backspaceCount, keypressCount }
404
+ };
405
+ }
406
+ if (correctionRatio < 0.01 && keypressCount > 30) {
407
+ return {
408
+ checkId: "no_corrections",
409
+ passed: true,
410
+ score: 0.5,
411
+ details: "Very few typing corrections",
412
+ data: { correctionRatio }
413
+ };
414
+ }
415
+ return {
416
+ checkId: "no_corrections",
417
+ passed: true,
418
+ score: 0,
419
+ data: { correctionRatio }
420
+ };
421
+ }
422
+ function checkHoverBehavior(metrics) {
423
+ if (!metrics?.mouseClicks || metrics.mouseClicks.length < 3) {
424
+ return {
425
+ checkId: "hover_behavior",
426
+ passed: true,
427
+ score: 0,
428
+ details: "Insufficient click data"
429
+ };
430
+ }
431
+ const clicksWithHover = metrics.mouseClicks.filter((c) => c.hadHover);
432
+ const hoverRatio = clicksWithHover.length / metrics.mouseClicks.length;
433
+ if (hoverRatio < 0.2) {
434
+ return {
435
+ checkId: "hover_behavior",
436
+ passed: false,
437
+ score: 0.8,
438
+ details: "Clicks without natural hover behavior",
439
+ data: { hoverRatio, totalClicks: metrics.mouseClicks.length }
440
+ };
441
+ }
442
+ if (hoverRatio < 0.4) {
443
+ return {
444
+ checkId: "hover_behavior",
445
+ passed: true,
446
+ score: 0.4,
447
+ details: "Lower than typical hover-before-click rate",
448
+ data: { hoverRatio }
449
+ };
450
+ }
451
+ return {
452
+ checkId: "hover_behavior",
453
+ passed: true,
454
+ score: 0,
455
+ data: { hoverRatio }
456
+ };
457
+ }
458
+ function checkScrollPatterns(metrics) {
459
+ if (!metrics?.scrollEvents || metrics.scrollEvents.length < 5) {
460
+ return {
461
+ checkId: "scroll_patterns",
462
+ passed: true,
463
+ score: 0,
464
+ details: "Insufficient scroll data"
465
+ };
466
+ }
467
+ const velocities = metrics.scrollEvents.map((s) => Math.abs(s.velocity)).filter((v) => v > 0);
468
+ if (velocities.length < 3) {
469
+ return {
470
+ checkId: "scroll_patterns",
471
+ passed: true,
472
+ score: 0
473
+ };
474
+ }
475
+ const mean = velocities.reduce((a, b) => a + b, 0) / velocities.length;
476
+ const variance = velocities.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / velocities.length;
477
+ const cv = Math.sqrt(variance) / mean;
478
+ if (cv < 0.1) {
479
+ return {
480
+ checkId: "scroll_patterns",
481
+ passed: false,
482
+ score: 0.7,
483
+ details: "Unnaturally uniform scroll pattern",
484
+ data: { cv }
485
+ };
486
+ }
487
+ return {
488
+ checkId: "scroll_patterns",
489
+ passed: true,
490
+ score: 0,
491
+ data: { cv }
492
+ };
493
+ }
494
+ function checkMouseAcceleration(metrics) {
495
+ if (!metrics?.mouseMovements || metrics.mouseMovements.length < 20) {
496
+ return {
497
+ checkId: "mouse_acceleration",
498
+ passed: true,
499
+ score: 0,
500
+ details: "Insufficient mouse data"
501
+ };
502
+ }
503
+ const velocities = metrics.mouseMovements.map((m) => m.velocity).filter((v) => v > 0);
504
+ if (velocities.length < 10) {
505
+ return {
506
+ checkId: "mouse_acceleration",
507
+ passed: true,
508
+ score: 0
509
+ };
510
+ }
511
+ const accelerations = [];
512
+ for (let i = 1; i < velocities.length; i++) {
513
+ accelerations.push(Math.abs(velocities[i] - velocities[i - 1]));
514
+ }
515
+ const meanAcc = accelerations.reduce((a, b) => a + b, 0) / accelerations.length;
516
+ const varianceAcc = accelerations.reduce((sum, a) => sum + Math.pow(a - meanAcc, 2), 0) / accelerations.length;
517
+ const cvAcc = Math.sqrt(varianceAcc) / meanAcc;
518
+ if (cvAcc < 0.2) {
519
+ return {
520
+ checkId: "mouse_acceleration",
521
+ passed: true,
522
+ score: 0.5,
523
+ details: "Lower than typical acceleration variation",
524
+ data: { cvAcc }
525
+ };
526
+ }
527
+ return {
528
+ checkId: "mouse_acceleration",
529
+ passed: true,
530
+ score: 0,
531
+ data: { cvAcc }
532
+ };
533
+ }
534
+ function runBehavioralChecks(metrics) {
535
+ return [
536
+ checkLowInteraction(metrics),
537
+ checkExcessivePaste(metrics),
538
+ checkPointerSpikes(metrics),
539
+ checkRoboticTyping(metrics),
540
+ checkMouseTeleporting(metrics),
541
+ checkNoCorrections(metrics),
542
+ checkHoverBehavior(metrics),
543
+ checkScrollPatterns(metrics),
544
+ checkMouseAcceleration(metrics)
545
+ ];
546
+ }
547
+
548
+ // src/checks/content.ts
549
+ var GIBBERISH_PATTERNS = [
550
+ /^[a-z]{1,3}$/i,
551
+ // Single letters or very short
552
+ /(.)\1{4,}/,
553
+ // Repeated characters (aaaaa)
554
+ /^[^aeiou]{5,}$/i,
555
+ // No vowels in 5+ chars
556
+ /^(asdf|qwerty|zxcv|wasd)/i,
557
+ // Keyboard mashing
558
+ /^[0-9]+$/,
559
+ // Only numbers
560
+ /^(.+?)\1{2,}$/
561
+ // Repeated patterns (abcabcabc)
562
+ ];
563
+ var LOW_EFFORT_RESPONSES = [
564
+ "n/a",
565
+ "na",
566
+ "none",
567
+ "nothing",
568
+ "idk",
569
+ "i dont know",
570
+ "i don't know",
571
+ "no comment",
572
+ "no",
573
+ "yes",
574
+ "ok",
575
+ "okay",
576
+ "good",
577
+ "fine",
578
+ "whatever",
579
+ "asdf",
580
+ "test",
581
+ ".",
582
+ "-",
583
+ "..."
584
+ ];
585
+ function checkMinimalEffort(responses) {
586
+ const textResponses = responses.filter(
587
+ (r) => r.questionType === "text" || !r.questionType && r.answer.length > 0
588
+ );
589
+ if (textResponses.length === 0) {
590
+ return {
591
+ checkId: "minimal_effort",
592
+ passed: true,
593
+ score: 0,
594
+ details: "No text responses to analyze"
595
+ };
596
+ }
597
+ let lowEffortCount = 0;
598
+ let totalLength = 0;
599
+ for (const response of textResponses) {
600
+ const answer = response.answer.toLowerCase().trim();
601
+ totalLength += answer.length;
602
+ if (LOW_EFFORT_RESPONSES.includes(answer)) {
603
+ lowEffortCount++;
604
+ continue;
605
+ }
606
+ if (answer.length < 10) {
607
+ lowEffortCount++;
608
+ continue;
609
+ }
610
+ for (const pattern of GIBBERISH_PATTERNS) {
611
+ if (pattern.test(answer)) {
612
+ lowEffortCount++;
613
+ break;
614
+ }
615
+ }
616
+ }
617
+ const lowEffortRatio = lowEffortCount / textResponses.length;
618
+ const avgLength = totalLength / textResponses.length;
619
+ if (lowEffortRatio > 0.7) {
620
+ return {
621
+ checkId: "minimal_effort",
622
+ passed: false,
623
+ score: 1,
624
+ details: "Most responses are low effort or gibberish",
625
+ data: { lowEffortRatio, avgLength, lowEffortCount }
626
+ };
627
+ }
628
+ if (lowEffortRatio > 0.4) {
629
+ return {
630
+ checkId: "minimal_effort",
631
+ passed: true,
632
+ score: 0.6,
633
+ details: "Many responses appear low effort",
634
+ data: { lowEffortRatio, avgLength }
635
+ };
636
+ }
637
+ if (avgLength < 20) {
638
+ return {
639
+ checkId: "minimal_effort",
640
+ passed: true,
641
+ score: 0.4,
642
+ details: "Responses are quite short on average",
643
+ data: { avgLength }
644
+ };
645
+ }
646
+ return {
647
+ checkId: "minimal_effort",
648
+ passed: true,
649
+ score: 0,
650
+ data: { lowEffortRatio, avgLength }
651
+ };
652
+ }
653
+ function checkStraightLining(responses) {
654
+ const scaleResponses = responses.filter(
655
+ (r) => r.questionType === "rating" || r.questionType === "scale" || r.questionType === "multiple_choice"
656
+ );
657
+ if (scaleResponses.length < 4) {
658
+ return {
659
+ checkId: "straight_line_answers",
660
+ passed: true,
661
+ score: 0,
662
+ details: "Not enough scale/choice questions"
663
+ };
664
+ }
665
+ let maxConsecutive = 1;
666
+ let currentConsecutive = 1;
667
+ for (let i = 1; i < scaleResponses.length; i++) {
668
+ if (scaleResponses[i].answer === scaleResponses[i - 1].answer) {
669
+ currentConsecutive++;
670
+ maxConsecutive = Math.max(maxConsecutive, currentConsecutive);
671
+ } else {
672
+ currentConsecutive = 1;
673
+ }
674
+ }
675
+ const answers = scaleResponses.map((r) => r.answer);
676
+ const uniqueAnswers = new Set(answers);
677
+ const uniqueRatio = uniqueAnswers.size / scaleResponses.length;
678
+ if (uniqueRatio < 0.15) {
679
+ return {
680
+ checkId: "straight_line_answers",
681
+ passed: false,
682
+ score: 1,
683
+ details: "Nearly all answers are identical",
684
+ data: { uniqueRatio, uniqueAnswers: uniqueAnswers.size, total: scaleResponses.length }
685
+ };
686
+ }
687
+ if (maxConsecutive >= 5 || uniqueRatio < 0.25) {
688
+ return {
689
+ checkId: "straight_line_answers",
690
+ passed: false,
691
+ score: 0.8,
692
+ details: "Strong straight-lining pattern detected",
693
+ data: { maxConsecutive, uniqueRatio }
694
+ };
695
+ }
696
+ if (maxConsecutive >= 4) {
697
+ return {
698
+ checkId: "straight_line_answers",
699
+ passed: true,
700
+ score: 0.4,
701
+ details: "Some consecutive identical answers",
702
+ data: { maxConsecutive }
703
+ };
704
+ }
705
+ return {
706
+ checkId: "straight_line_answers",
707
+ passed: true,
708
+ score: 0,
709
+ data: { maxConsecutive, uniqueRatio }
710
+ };
711
+ }
712
+ function checkExcessiveTabSwitching(metrics) {
713
+ if (!metrics) {
714
+ return {
715
+ checkId: "excessive_tab_switching",
716
+ passed: true,
717
+ score: 0,
718
+ details: "No behavioral data"
719
+ };
720
+ }
721
+ const { tabSwitchCount, duration } = metrics;
722
+ const durationMinutes = (duration || 1) / 6e4;
723
+ const switchesPerMinute = tabSwitchCount / durationMinutes;
724
+ if (switchesPerMinute > 5) {
725
+ return {
726
+ checkId: "excessive_tab_switching",
727
+ passed: false,
728
+ score: 0.8,
729
+ details: "Excessive tab switching detected",
730
+ data: { switchesPerMinute, tabSwitchCount }
731
+ };
732
+ }
733
+ if (switchesPerMinute > 2) {
734
+ return {
735
+ checkId: "excessive_tab_switching",
736
+ passed: true,
737
+ score: 0.4,
738
+ details: "Above average tab switching",
739
+ data: { switchesPerMinute }
740
+ };
741
+ }
742
+ return {
743
+ checkId: "excessive_tab_switching",
744
+ passed: true,
745
+ score: 0,
746
+ data: { switchesPerMinute }
747
+ };
748
+ }
749
+ function checkWindowFocusLoss(metrics) {
750
+ if (!metrics) {
751
+ return {
752
+ checkId: "window_focus_loss",
753
+ passed: true,
754
+ score: 0,
755
+ details: "No behavioral data"
756
+ };
757
+ }
758
+ const { totalBlurDuration, duration } = metrics;
759
+ const blurRatio = totalBlurDuration / (duration || 1);
760
+ if (blurRatio > 0.6) {
761
+ return {
762
+ checkId: "window_focus_loss",
763
+ passed: false,
764
+ score: 0.8,
765
+ details: "Majority of time spent away from survey",
766
+ data: { blurRatio, totalBlurDuration }
767
+ };
768
+ }
769
+ if (blurRatio > 0.3) {
770
+ return {
771
+ checkId: "window_focus_loss",
772
+ passed: true,
773
+ score: 0.4,
774
+ details: "Significant time away from survey",
775
+ data: { blurRatio }
776
+ };
777
+ }
778
+ return {
779
+ checkId: "window_focus_loss",
780
+ passed: true,
781
+ score: 0,
782
+ data: { blurRatio }
783
+ };
784
+ }
785
+ function runContentChecks(responses, metrics) {
786
+ return [
787
+ checkMinimalEffort(responses),
788
+ checkStraightLining(responses),
789
+ checkExcessiveTabSwitching(metrics),
790
+ checkWindowFocusLoss(metrics)
791
+ ];
792
+ }
793
+
794
+ // src/checks/device.ts
795
+ var BOT_USER_AGENTS = [
796
+ /headless/i,
797
+ /phantom/i,
798
+ /selenium/i,
799
+ /webdriver/i,
800
+ /puppeteer/i,
801
+ /playwright/i,
802
+ /crawl/i,
803
+ /spider/i,
804
+ /bot/i,
805
+ /scrape/i,
806
+ /curl/i,
807
+ /wget/i,
808
+ /python-requests/i,
809
+ /axios/i,
810
+ /node-fetch/i,
811
+ /go-http-client/i
812
+ ];
813
+ var SUSPICIOUS_SCREENS = [
814
+ { width: 800, height: 600 },
815
+ { width: 1024, height: 768 },
816
+ { width: 1, height: 1 },
817
+ { width: 0, height: 0 }
818
+ ];
819
+ function checkWebDriverDetected(device) {
820
+ if (!device) {
821
+ return {
822
+ checkId: "webdriver_detected",
823
+ passed: true,
824
+ score: 0,
825
+ details: "No device info available"
826
+ };
827
+ }
828
+ if (device.webDriver === true) {
829
+ return {
830
+ checkId: "webdriver_detected",
831
+ passed: false,
832
+ score: 1,
833
+ details: "WebDriver automation detected",
834
+ data: { webDriver: true }
835
+ };
836
+ }
837
+ return {
838
+ checkId: "webdriver_detected",
839
+ passed: true,
840
+ score: 0,
841
+ data: { webDriver: false }
842
+ };
843
+ }
844
+ function checkAutomationDetected(device) {
845
+ if (!device) {
846
+ return {
847
+ checkId: "automation_detected",
848
+ passed: true,
849
+ score: 0,
850
+ details: "No device info available"
851
+ };
852
+ }
853
+ if (device.automationDetected === true) {
854
+ return {
855
+ checkId: "automation_detected",
856
+ passed: false,
857
+ score: 1,
858
+ details: "Browser automation framework detected",
859
+ data: { automationDetected: true }
860
+ };
861
+ }
862
+ return {
863
+ checkId: "automation_detected",
864
+ passed: true,
865
+ score: 0,
866
+ data: { automationDetected: false }
867
+ };
868
+ }
869
+ function checkNoPlugins(device) {
870
+ if (!device) {
871
+ return {
872
+ checkId: "no_plugins",
873
+ passed: true,
874
+ score: 0,
875
+ details: "No device info available"
876
+ };
877
+ }
878
+ const { pluginCount } = device;
879
+ if (pluginCount === 0) {
880
+ const isMobile = device.touchSupport || device.maxTouchPoints > 0;
881
+ if (!isMobile) {
882
+ return {
883
+ checkId: "no_plugins",
884
+ passed: true,
885
+ score: 0.5,
886
+ details: "No browser plugins (common in automation)",
887
+ data: { pluginCount }
888
+ };
889
+ }
890
+ }
891
+ return {
892
+ checkId: "no_plugins",
893
+ passed: true,
894
+ score: 0,
895
+ data: { pluginCount }
896
+ };
897
+ }
898
+ function checkSuspiciousUserAgent(device) {
899
+ if (!device?.userAgent) {
900
+ return {
901
+ checkId: "suspicious_user_agent",
902
+ passed: true,
903
+ score: 0,
904
+ details: "No user agent available"
905
+ };
906
+ }
907
+ const { userAgent } = device;
908
+ for (const pattern of BOT_USER_AGENTS) {
909
+ if (pattern.test(userAgent)) {
910
+ return {
911
+ checkId: "suspicious_user_agent",
912
+ passed: false,
913
+ score: 1,
914
+ details: "Bot-like user agent detected",
915
+ data: { pattern: pattern.source }
916
+ };
917
+ }
918
+ }
919
+ if (userAgent.length < 20) {
920
+ return {
921
+ checkId: "suspicious_user_agent",
922
+ passed: true,
923
+ score: 0.5,
924
+ details: "Unusually short user agent",
925
+ data: { length: userAgent.length }
926
+ };
927
+ }
928
+ return {
929
+ checkId: "suspicious_user_agent",
930
+ passed: true,
931
+ score: 0
932
+ };
933
+ }
934
+ function checkDeviceFingerprintMismatch(device) {
935
+ if (!device) {
936
+ return {
937
+ checkId: "device_fingerprint_mismatch",
938
+ passed: true,
939
+ score: 0,
940
+ details: "No device info available"
941
+ };
942
+ }
943
+ const issues = [];
944
+ if (device.touchSupport && device.maxTouchPoints === 0) {
945
+ issues.push("Touch support claimed but no touch points");
946
+ }
947
+ if (device.screenWidth < device.screenAvailWidth || device.screenHeight < device.screenAvailHeight) {
948
+ issues.push("Available screen larger than total screen");
949
+ }
950
+ if (device.hardwareConcurrency > 128) {
951
+ issues.push("Impossible CPU core count");
952
+ }
953
+ if (device.deviceMemory > 256) {
954
+ issues.push("Impossible memory amount");
955
+ }
956
+ if (device.pixelRatio <= 0 || device.pixelRatio > 10) {
957
+ issues.push("Invalid pixel ratio");
958
+ }
959
+ if (issues.length >= 2) {
960
+ return {
961
+ checkId: "device_fingerprint_mismatch",
962
+ passed: false,
963
+ score: 0.8,
964
+ details: "Multiple device characteristic mismatches",
965
+ data: { issues }
966
+ };
967
+ }
968
+ if (issues.length === 1) {
969
+ return {
970
+ checkId: "device_fingerprint_mismatch",
971
+ passed: true,
972
+ score: 0.4,
973
+ details: issues[0],
974
+ data: { issues }
975
+ };
976
+ }
977
+ return {
978
+ checkId: "device_fingerprint_mismatch",
979
+ passed: true,
980
+ score: 0
981
+ };
982
+ }
983
+ function checkScreenAnomaly(device) {
984
+ if (!device) {
985
+ return {
986
+ checkId: "screen_anomaly",
987
+ passed: true,
988
+ score: 0,
989
+ details: "No device info available"
990
+ };
991
+ }
992
+ const { screenWidth, screenHeight } = device;
993
+ if (screenWidth <= 0 || screenHeight <= 0) {
994
+ return {
995
+ checkId: "screen_anomaly",
996
+ passed: false,
997
+ score: 1,
998
+ details: "Invalid screen dimensions",
999
+ data: { screenWidth, screenHeight }
1000
+ };
1001
+ }
1002
+ for (const suspicious of SUSPICIOUS_SCREENS) {
1003
+ if (screenWidth === suspicious.width && screenHeight === suspicious.height) {
1004
+ return {
1005
+ checkId: "screen_anomaly",
1006
+ passed: true,
1007
+ score: 0.4,
1008
+ details: "Common automation screen size",
1009
+ data: { screenWidth, screenHeight }
1010
+ };
1011
+ }
1012
+ }
1013
+ const aspectRatio = screenWidth / screenHeight;
1014
+ if (aspectRatio < 0.3 || aspectRatio > 5) {
1015
+ return {
1016
+ checkId: "screen_anomaly",
1017
+ passed: true,
1018
+ score: 0.5,
1019
+ details: "Unusual screen aspect ratio",
1020
+ data: { aspectRatio }
1021
+ };
1022
+ }
1023
+ return {
1024
+ checkId: "screen_anomaly",
1025
+ passed: true,
1026
+ score: 0
1027
+ };
1028
+ }
1029
+ function checkTimezoneValidation(device) {
1030
+ if (!device) {
1031
+ return {
1032
+ checkId: "timezone_validation",
1033
+ passed: true,
1034
+ score: 0,
1035
+ details: "No device info available"
1036
+ };
1037
+ }
1038
+ const { timezone, timezoneOffset } = device;
1039
+ if (!timezone) {
1040
+ return {
1041
+ checkId: "timezone_validation",
1042
+ passed: true,
1043
+ score: 0.3,
1044
+ details: "No timezone information"
1045
+ };
1046
+ }
1047
+ if (timezoneOffset < -840 || timezoneOffset > 720) {
1048
+ return {
1049
+ checkId: "timezone_validation",
1050
+ passed: false,
1051
+ score: 0.7,
1052
+ details: "Invalid timezone offset",
1053
+ data: { timezoneOffset }
1054
+ };
1055
+ }
1056
+ return {
1057
+ checkId: "timezone_validation",
1058
+ passed: true,
1059
+ score: 0,
1060
+ data: { timezone, timezoneOffset }
1061
+ };
1062
+ }
1063
+ function runDeviceChecks(device) {
1064
+ return [
1065
+ checkWebDriverDetected(device),
1066
+ checkAutomationDetected(device),
1067
+ checkNoPlugins(device),
1068
+ checkSuspiciousUserAgent(device),
1069
+ checkDeviceFingerprintMismatch(device),
1070
+ checkScreenAnomaly(device),
1071
+ checkTimezoneValidation(device)
1072
+ ];
1073
+ }
1074
+
1075
+ export {
1076
+ checkImpossiblyFast,
1077
+ checkRapidCompletion,
1078
+ checkUniformTiming,
1079
+ checkSuspiciousPauses,
1080
+ runTimingChecks,
1081
+ checkLowInteraction,
1082
+ checkExcessivePaste,
1083
+ checkPointerSpikes,
1084
+ checkRoboticTyping,
1085
+ checkMouseTeleporting,
1086
+ checkNoCorrections,
1087
+ checkHoverBehavior,
1088
+ checkScrollPatterns,
1089
+ checkMouseAcceleration,
1090
+ runBehavioralChecks,
1091
+ checkMinimalEffort,
1092
+ checkStraightLining,
1093
+ checkExcessiveTabSwitching,
1094
+ checkWindowFocusLoss,
1095
+ runContentChecks,
1096
+ checkWebDriverDetected,
1097
+ checkAutomationDetected,
1098
+ checkNoPlugins,
1099
+ checkSuspiciousUserAgent,
1100
+ checkDeviceFingerprintMismatch,
1101
+ checkScreenAnomaly,
1102
+ checkTimezoneValidation,
1103
+ runDeviceChecks
1104
+ };