@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/dist/index.js CHANGED
@@ -25,67 +25,1695 @@ __export(index_exports, {
25
25
  });
26
26
  module.exports = __toCommonJS(index_exports);
27
27
 
28
- // src/cipher.ts
29
- var DEFAULT_CONFIG = {
30
- tier: 3,
31
- thresholds: {
32
- fail: 0.4,
33
- review: 0.7
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
- debug: false,
36
- endpoint: "https://api.surbee.com/v1/cipher"
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 TIER_INFO = {
390
+ var TIERS = {
39
391
  1: {
40
392
  name: "Basic",
41
- description: "Essential fraud detection with behavioral heuristics",
42
- checksCount: 6
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
- checksCount: 15
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 AI-powered content quality analysis",
52
- checksCount: 22
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 with network validation",
57
- checksCount: 30
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 43 checks with cross-respondent fraud detection",
62
- checksCount: 43
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
- if (!config.apiKey) {
69
- throw new Error("API key is required. Get one from Settings > API Keys in your Surbee dashboard.");
70
- }
71
- if (!config.apiKey.startsWith("cipher_sk_") && !config.apiKey.startsWith("cipher_pk_")) {
72
- throw new Error("Invalid API key format. Keys should start with cipher_sk_ or cipher_pk_");
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 multiple responses in batch
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: input.crossAnalysis ?? this.config.tier === 5
1750
+ crossAnalysis
105
1751
  });
106
1752
  return response;
107
1753
  }
108
1754
  /**
109
- * Get tier information
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
- return TIER_INFO[t];
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 Object.entries(TIER_INFO).map(([tier, info]) => ({
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}`);