@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/src/checks/device.ts
DELETED
|
@@ -1,384 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Device-based checks
|
|
3
|
-
*
|
|
4
|
-
* Analyzes device fingerprint and browser characteristics to detect bots.
|
|
5
|
-
* All checks in this file are offline (no API required).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { CheckResult, DeviceInfo } from '../types';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Known bot user agent patterns
|
|
12
|
-
*/
|
|
13
|
-
const BOT_USER_AGENTS = [
|
|
14
|
-
/headless/i,
|
|
15
|
-
/phantom/i,
|
|
16
|
-
/selenium/i,
|
|
17
|
-
/webdriver/i,
|
|
18
|
-
/puppeteer/i,
|
|
19
|
-
/playwright/i,
|
|
20
|
-
/crawl/i,
|
|
21
|
-
/spider/i,
|
|
22
|
-
/bot/i,
|
|
23
|
-
/scrape/i,
|
|
24
|
-
/curl/i,
|
|
25
|
-
/wget/i,
|
|
26
|
-
/python-requests/i,
|
|
27
|
-
/axios/i,
|
|
28
|
-
/node-fetch/i,
|
|
29
|
-
/go-http-client/i,
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Suspicious screen dimensions (commonly used by bots)
|
|
34
|
-
*/
|
|
35
|
-
const SUSPICIOUS_SCREENS = [
|
|
36
|
-
{ width: 800, height: 600 },
|
|
37
|
-
{ width: 1024, height: 768 },
|
|
38
|
-
{ width: 1, height: 1 },
|
|
39
|
-
{ width: 0, height: 0 },
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Check: WebDriver Detection
|
|
44
|
-
*
|
|
45
|
-
* Detects Selenium/automation tools via navigator.webdriver flag.
|
|
46
|
-
*/
|
|
47
|
-
export function checkWebDriverDetected(device?: DeviceInfo): CheckResult {
|
|
48
|
-
if (!device) {
|
|
49
|
-
return {
|
|
50
|
-
checkId: 'webdriver_detected',
|
|
51
|
-
passed: true,
|
|
52
|
-
score: 0,
|
|
53
|
-
details: 'No device info available',
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (device.webDriver === true) {
|
|
58
|
-
return {
|
|
59
|
-
checkId: 'webdriver_detected',
|
|
60
|
-
passed: false,
|
|
61
|
-
score: 1.0,
|
|
62
|
-
details: 'WebDriver automation detected',
|
|
63
|
-
data: { webDriver: true },
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
checkId: 'webdriver_detected',
|
|
69
|
-
passed: true,
|
|
70
|
-
score: 0,
|
|
71
|
-
data: { webDriver: false },
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Check: Automation Detection
|
|
77
|
-
*
|
|
78
|
-
* Detects headless browsers and automation frameworks.
|
|
79
|
-
*/
|
|
80
|
-
export function checkAutomationDetected(device?: DeviceInfo): CheckResult {
|
|
81
|
-
if (!device) {
|
|
82
|
-
return {
|
|
83
|
-
checkId: 'automation_detected',
|
|
84
|
-
passed: true,
|
|
85
|
-
score: 0,
|
|
86
|
-
details: 'No device info available',
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (device.automationDetected === true) {
|
|
91
|
-
return {
|
|
92
|
-
checkId: 'automation_detected',
|
|
93
|
-
passed: false,
|
|
94
|
-
score: 1.0,
|
|
95
|
-
details: 'Browser automation framework detected',
|
|
96
|
-
data: { automationDetected: true },
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return {
|
|
101
|
-
checkId: 'automation_detected',
|
|
102
|
-
passed: true,
|
|
103
|
-
score: 0,
|
|
104
|
-
data: { automationDetected: false },
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Check: Missing Plugins
|
|
110
|
-
*
|
|
111
|
-
* Bots often have zero browser plugins, which is unusual for real browsers.
|
|
112
|
-
*/
|
|
113
|
-
export function checkNoPlugins(device?: DeviceInfo): CheckResult {
|
|
114
|
-
if (!device) {
|
|
115
|
-
return {
|
|
116
|
-
checkId: 'no_plugins',
|
|
117
|
-
passed: true,
|
|
118
|
-
score: 0,
|
|
119
|
-
details: 'No device info available',
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const { pluginCount } = device;
|
|
124
|
-
|
|
125
|
-
if (pluginCount === 0) {
|
|
126
|
-
// Could be a legitimate mobile browser or privacy-focused setup
|
|
127
|
-
// Check if it's mobile
|
|
128
|
-
const isMobile = device.touchSupport || device.maxTouchPoints > 0;
|
|
129
|
-
|
|
130
|
-
if (!isMobile) {
|
|
131
|
-
return {
|
|
132
|
-
checkId: 'no_plugins',
|
|
133
|
-
passed: true,
|
|
134
|
-
score: 0.5,
|
|
135
|
-
details: 'No browser plugins (common in automation)',
|
|
136
|
-
data: { pluginCount },
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return {
|
|
142
|
-
checkId: 'no_plugins',
|
|
143
|
-
passed: true,
|
|
144
|
-
score: 0,
|
|
145
|
-
data: { pluginCount },
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Check: Suspicious User Agent
|
|
151
|
-
*
|
|
152
|
-
* Detects bot-like user agent strings.
|
|
153
|
-
*/
|
|
154
|
-
export function checkSuspiciousUserAgent(device?: DeviceInfo): CheckResult {
|
|
155
|
-
if (!device?.userAgent) {
|
|
156
|
-
return {
|
|
157
|
-
checkId: 'suspicious_user_agent',
|
|
158
|
-
passed: true,
|
|
159
|
-
score: 0,
|
|
160
|
-
details: 'No user agent available',
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const { userAgent } = device;
|
|
165
|
-
|
|
166
|
-
// Check against known bot patterns
|
|
167
|
-
for (const pattern of BOT_USER_AGENTS) {
|
|
168
|
-
if (pattern.test(userAgent)) {
|
|
169
|
-
return {
|
|
170
|
-
checkId: 'suspicious_user_agent',
|
|
171
|
-
passed: false,
|
|
172
|
-
score: 1.0,
|
|
173
|
-
details: 'Bot-like user agent detected',
|
|
174
|
-
data: { pattern: pattern.source },
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Check for empty or very short user agent
|
|
180
|
-
if (userAgent.length < 20) {
|
|
181
|
-
return {
|
|
182
|
-
checkId: 'suspicious_user_agent',
|
|
183
|
-
passed: true,
|
|
184
|
-
score: 0.5,
|
|
185
|
-
details: 'Unusually short user agent',
|
|
186
|
-
data: { length: userAgent.length },
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return {
|
|
191
|
-
checkId: 'suspicious_user_agent',
|
|
192
|
-
passed: true,
|
|
193
|
-
score: 0,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Check: Device Fingerprint Mismatch
|
|
199
|
-
*
|
|
200
|
-
* Detects inconsistent device characteristics that suggest spoofing.
|
|
201
|
-
*/
|
|
202
|
-
export function checkDeviceFingerprintMismatch(device?: DeviceInfo): CheckResult {
|
|
203
|
-
if (!device) {
|
|
204
|
-
return {
|
|
205
|
-
checkId: 'device_fingerprint_mismatch',
|
|
206
|
-
passed: true,
|
|
207
|
-
score: 0,
|
|
208
|
-
details: 'No device info available',
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const issues: string[] = [];
|
|
213
|
-
|
|
214
|
-
// Check for mismatched touch capabilities
|
|
215
|
-
if (device.touchSupport && device.maxTouchPoints === 0) {
|
|
216
|
-
issues.push('Touch support claimed but no touch points');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Check for mismatched screen dimensions
|
|
220
|
-
if (device.screenWidth < device.screenAvailWidth || device.screenHeight < device.screenAvailHeight) {
|
|
221
|
-
issues.push('Available screen larger than total screen');
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Check for impossible hardware specs
|
|
225
|
-
if (device.hardwareConcurrency > 128) {
|
|
226
|
-
issues.push('Impossible CPU core count');
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (device.deviceMemory > 256) {
|
|
230
|
-
issues.push('Impossible memory amount');
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Check for mismatched pixel ratio
|
|
234
|
-
if (device.pixelRatio <= 0 || device.pixelRatio > 10) {
|
|
235
|
-
issues.push('Invalid pixel ratio');
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (issues.length >= 2) {
|
|
239
|
-
return {
|
|
240
|
-
checkId: 'device_fingerprint_mismatch',
|
|
241
|
-
passed: false,
|
|
242
|
-
score: 0.8,
|
|
243
|
-
details: 'Multiple device characteristic mismatches',
|
|
244
|
-
data: { issues },
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (issues.length === 1) {
|
|
249
|
-
return {
|
|
250
|
-
checkId: 'device_fingerprint_mismatch',
|
|
251
|
-
passed: true,
|
|
252
|
-
score: 0.4,
|
|
253
|
-
details: issues[0],
|
|
254
|
-
data: { issues },
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
return {
|
|
259
|
-
checkId: 'device_fingerprint_mismatch',
|
|
260
|
-
passed: true,
|
|
261
|
-
score: 0,
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Check: Screen Anomaly
|
|
267
|
-
*
|
|
268
|
-
* Detects impossible or suspicious screen dimensions.
|
|
269
|
-
*/
|
|
270
|
-
export function checkScreenAnomaly(device?: DeviceInfo): CheckResult {
|
|
271
|
-
if (!device) {
|
|
272
|
-
return {
|
|
273
|
-
checkId: 'screen_anomaly',
|
|
274
|
-
passed: true,
|
|
275
|
-
score: 0,
|
|
276
|
-
details: 'No device info available',
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const { screenWidth, screenHeight } = device;
|
|
281
|
-
|
|
282
|
-
// Check for impossible dimensions
|
|
283
|
-
if (screenWidth <= 0 || screenHeight <= 0) {
|
|
284
|
-
return {
|
|
285
|
-
checkId: 'screen_anomaly',
|
|
286
|
-
passed: false,
|
|
287
|
-
score: 1.0,
|
|
288
|
-
details: 'Invalid screen dimensions',
|
|
289
|
-
data: { screenWidth, screenHeight },
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Check for suspicious exact dimensions (common in bots)
|
|
294
|
-
for (const suspicious of SUSPICIOUS_SCREENS) {
|
|
295
|
-
if (screenWidth === suspicious.width && screenHeight === suspicious.height) {
|
|
296
|
-
return {
|
|
297
|
-
checkId: 'screen_anomaly',
|
|
298
|
-
passed: true,
|
|
299
|
-
score: 0.4,
|
|
300
|
-
details: 'Common automation screen size',
|
|
301
|
-
data: { screenWidth, screenHeight },
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Check for extremely unusual aspect ratios
|
|
307
|
-
const aspectRatio = screenWidth / screenHeight;
|
|
308
|
-
if (aspectRatio < 0.3 || aspectRatio > 5) {
|
|
309
|
-
return {
|
|
310
|
-
checkId: 'screen_anomaly',
|
|
311
|
-
passed: true,
|
|
312
|
-
score: 0.5,
|
|
313
|
-
details: 'Unusual screen aspect ratio',
|
|
314
|
-
data: { aspectRatio },
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return {
|
|
319
|
-
checkId: 'screen_anomaly',
|
|
320
|
-
passed: true,
|
|
321
|
-
score: 0,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Check: Timezone Validation
|
|
327
|
-
*
|
|
328
|
-
* Validates timezone consistency between browser and behavior.
|
|
329
|
-
*/
|
|
330
|
-
export function checkTimezoneValidation(device?: DeviceInfo): CheckResult {
|
|
331
|
-
if (!device) {
|
|
332
|
-
return {
|
|
333
|
-
checkId: 'timezone_validation',
|
|
334
|
-
passed: true,
|
|
335
|
-
score: 0,
|
|
336
|
-
details: 'No device info available',
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const { timezone, timezoneOffset } = device;
|
|
341
|
-
|
|
342
|
-
// Check for missing timezone data
|
|
343
|
-
if (!timezone) {
|
|
344
|
-
return {
|
|
345
|
-
checkId: 'timezone_validation',
|
|
346
|
-
passed: true,
|
|
347
|
-
score: 0.3,
|
|
348
|
-
details: 'No timezone information',
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Validate offset is reasonable (-12 to +14 hours)
|
|
353
|
-
if (timezoneOffset < -840 || timezoneOffset > 720) {
|
|
354
|
-
return {
|
|
355
|
-
checkId: 'timezone_validation',
|
|
356
|
-
passed: false,
|
|
357
|
-
score: 0.7,
|
|
358
|
-
details: 'Invalid timezone offset',
|
|
359
|
-
data: { timezoneOffset },
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
return {
|
|
364
|
-
checkId: 'timezone_validation',
|
|
365
|
-
passed: true,
|
|
366
|
-
score: 0,
|
|
367
|
-
data: { timezone, timezoneOffset },
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Run all device checks
|
|
373
|
-
*/
|
|
374
|
-
export function runDeviceChecks(device?: DeviceInfo): CheckResult[] {
|
|
375
|
-
return [
|
|
376
|
-
checkWebDriverDetected(device),
|
|
377
|
-
checkAutomationDetected(device),
|
|
378
|
-
checkNoPlugins(device),
|
|
379
|
-
checkSuspiciousUserAgent(device),
|
|
380
|
-
checkDeviceFingerprintMismatch(device),
|
|
381
|
-
checkScreenAnomaly(device),
|
|
382
|
-
checkTimezoneValidation(device),
|
|
383
|
-
];
|
|
384
|
-
}
|
package/src/checks/index.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cipher Checks - Individual importable check functions
|
|
3
|
-
*
|
|
4
|
-
* These can be imported individually for custom validation pipelines:
|
|
5
|
-
*
|
|
6
|
-
* ```typescript
|
|
7
|
-
* import { checkMinimalEffort, checkStraightLining } from '@surbee/cipher/checks';
|
|
8
|
-
*
|
|
9
|
-
* const result1 = checkMinimalEffort(responses);
|
|
10
|
-
* const result2 = checkStraightLining(responses);
|
|
11
|
-
* ```
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
// Timing checks
|
|
15
|
-
export {
|
|
16
|
-
checkImpossiblyFast,
|
|
17
|
-
checkRapidCompletion,
|
|
18
|
-
checkUniformTiming,
|
|
19
|
-
checkSuspiciousPauses,
|
|
20
|
-
runTimingChecks,
|
|
21
|
-
} from './timing';
|
|
22
|
-
|
|
23
|
-
// Behavioral checks
|
|
24
|
-
export {
|
|
25
|
-
checkLowInteraction,
|
|
26
|
-
checkExcessivePaste,
|
|
27
|
-
checkPointerSpikes,
|
|
28
|
-
checkRoboticTyping,
|
|
29
|
-
checkMouseTeleporting,
|
|
30
|
-
checkNoCorrections,
|
|
31
|
-
checkHoverBehavior,
|
|
32
|
-
checkScrollPatterns,
|
|
33
|
-
checkMouseAcceleration,
|
|
34
|
-
runBehavioralChecks,
|
|
35
|
-
} from './behavioral';
|
|
36
|
-
|
|
37
|
-
// Content checks
|
|
38
|
-
export {
|
|
39
|
-
checkMinimalEffort,
|
|
40
|
-
checkStraightLining,
|
|
41
|
-
checkExcessiveTabSwitching,
|
|
42
|
-
checkWindowFocusLoss,
|
|
43
|
-
runContentChecks,
|
|
44
|
-
} from './content';
|
|
45
|
-
|
|
46
|
-
// Device checks
|
|
47
|
-
export {
|
|
48
|
-
checkWebDriverDetected,
|
|
49
|
-
checkAutomationDetected,
|
|
50
|
-
checkNoPlugins,
|
|
51
|
-
checkSuspiciousUserAgent,
|
|
52
|
-
checkDeviceFingerprintMismatch,
|
|
53
|
-
checkScreenAnomaly,
|
|
54
|
-
checkTimezoneValidation,
|
|
55
|
-
runDeviceChecks,
|
|
56
|
-
} from './device';
|
|
57
|
-
|
|
58
|
-
// Re-export types
|
|
59
|
-
export type { CheckResult } from '../types';
|
package/src/checks/timing.ts
DELETED
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Timing-based checks
|
|
3
|
-
*
|
|
4
|
-
* Analyzes response timing patterns to detect bots and low-effort responses.
|
|
5
|
-
* All checks in this file are offline (no API required).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { CheckResult, ResponseInput, BehavioralMetrics, SurveyContext } from '../types';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Minimum reading time per character (ms)
|
|
12
|
-
* Average reading speed is ~250 words/min = ~1250 chars/min = ~48ms per char
|
|
13
|
-
* We use a generous 20ms to account for fast readers
|
|
14
|
-
*/
|
|
15
|
-
const MIN_MS_PER_CHAR = 20;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Minimum response time for any question (ms)
|
|
19
|
-
*/
|
|
20
|
-
const MIN_RESPONSE_TIME_MS = 1000;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Check: Impossibly Fast
|
|
24
|
-
*
|
|
25
|
-
* Detects responses that were submitted faster than humanly possible.
|
|
26
|
-
* Based on reading speed + minimum typing time.
|
|
27
|
-
*/
|
|
28
|
-
export function checkImpossiblyFast(
|
|
29
|
-
responses: ResponseInput[],
|
|
30
|
-
context?: SurveyContext
|
|
31
|
-
): CheckResult {
|
|
32
|
-
if (!context?.actualDurationSeconds || !context?.expectedDurationSeconds) {
|
|
33
|
-
// Can't check without timing data
|
|
34
|
-
return {
|
|
35
|
-
checkId: 'impossibly_fast',
|
|
36
|
-
passed: true,
|
|
37
|
-
score: 0,
|
|
38
|
-
details: 'No timing data available',
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const actualMs = context.actualDurationSeconds * 1000;
|
|
43
|
-
const expectedMs = context.expectedDurationSeconds * 1000;
|
|
44
|
-
|
|
45
|
-
// Calculate minimum possible time based on content
|
|
46
|
-
let minPossibleMs = 0;
|
|
47
|
-
for (const response of responses) {
|
|
48
|
-
const questionChars = response.question.length;
|
|
49
|
-
const readingTime = questionChars * MIN_MS_PER_CHAR;
|
|
50
|
-
minPossibleMs += Math.max(readingTime, MIN_RESPONSE_TIME_MS);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// If completed faster than 30% of minimum possible time, it's suspicious
|
|
54
|
-
const suspicionThreshold = minPossibleMs * 0.3;
|
|
55
|
-
|
|
56
|
-
if (actualMs < suspicionThreshold) {
|
|
57
|
-
return {
|
|
58
|
-
checkId: 'impossibly_fast',
|
|
59
|
-
passed: false,
|
|
60
|
-
score: 1.0,
|
|
61
|
-
details: `Completed in ${context.actualDurationSeconds}s, minimum expected ${Math.round(minPossibleMs / 1000)}s`,
|
|
62
|
-
data: { actualMs, minPossibleMs, threshold: suspicionThreshold },
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Calculate a score based on how fast they were
|
|
67
|
-
// 100% of expected time = score 0, 30% = score 0.7
|
|
68
|
-
const speedRatio = actualMs / expectedMs;
|
|
69
|
-
const score = speedRatio < 1 ? Math.max(0, 1 - speedRatio) * 0.7 : 0;
|
|
70
|
-
|
|
71
|
-
return {
|
|
72
|
-
checkId: 'impossibly_fast',
|
|
73
|
-
passed: true,
|
|
74
|
-
score,
|
|
75
|
-
details: speedRatio < 0.5 ? 'Faster than average' : undefined,
|
|
76
|
-
data: { actualMs, expectedMs, speedRatio },
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Check: Rapid Completion
|
|
82
|
-
*
|
|
83
|
-
* Simpler check for overall survey completion speed.
|
|
84
|
-
* Flags if completed in less than 20% of expected time.
|
|
85
|
-
*/
|
|
86
|
-
export function checkRapidCompletion(
|
|
87
|
-
responses: ResponseInput[],
|
|
88
|
-
context?: SurveyContext
|
|
89
|
-
): CheckResult {
|
|
90
|
-
if (!context?.actualDurationSeconds || !context?.expectedDurationSeconds) {
|
|
91
|
-
return {
|
|
92
|
-
checkId: 'rapid_completion',
|
|
93
|
-
passed: true,
|
|
94
|
-
score: 0,
|
|
95
|
-
details: 'No timing data available',
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const ratio = context.actualDurationSeconds / context.expectedDurationSeconds;
|
|
100
|
-
|
|
101
|
-
if (ratio < 0.2) {
|
|
102
|
-
return {
|
|
103
|
-
checkId: 'rapid_completion',
|
|
104
|
-
passed: false,
|
|
105
|
-
score: 1.0,
|
|
106
|
-
details: `Completed in ${Math.round(ratio * 100)}% of expected time`,
|
|
107
|
-
data: { ratio },
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (ratio < 0.4) {
|
|
112
|
-
return {
|
|
113
|
-
checkId: 'rapid_completion',
|
|
114
|
-
passed: true,
|
|
115
|
-
score: 0.5,
|
|
116
|
-
details: 'Faster than typical',
|
|
117
|
-
data: { ratio },
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return {
|
|
122
|
-
checkId: 'rapid_completion',
|
|
123
|
-
passed: true,
|
|
124
|
-
score: 0,
|
|
125
|
-
data: { ratio },
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Check: Uniform Timing
|
|
131
|
-
*
|
|
132
|
-
* Detects robotic behavior where response times are suspiciously consistent.
|
|
133
|
-
* Humans have natural variation in response times.
|
|
134
|
-
*/
|
|
135
|
-
export function checkUniformTiming(
|
|
136
|
-
responses: ResponseInput[],
|
|
137
|
-
metrics?: BehavioralMetrics
|
|
138
|
-
): CheckResult {
|
|
139
|
-
const times = metrics?.responseTime || responses.map(r => r.responseTimeMs).filter(Boolean) as number[];
|
|
140
|
-
|
|
141
|
-
if (times.length < 3) {
|
|
142
|
-
return {
|
|
143
|
-
checkId: 'uniform_timing',
|
|
144
|
-
passed: true,
|
|
145
|
-
score: 0,
|
|
146
|
-
details: 'Not enough timing data',
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Calculate coefficient of variation (CV = stddev / mean)
|
|
151
|
-
const mean = times.reduce((a, b) => a + b, 0) / times.length;
|
|
152
|
-
const variance = times.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / times.length;
|
|
153
|
-
const stddev = Math.sqrt(variance);
|
|
154
|
-
const cv = stddev / mean;
|
|
155
|
-
|
|
156
|
-
// Humans typically have CV > 0.3 for response times
|
|
157
|
-
// Bots often have CV < 0.1
|
|
158
|
-
if (cv < 0.1) {
|
|
159
|
-
return {
|
|
160
|
-
checkId: 'uniform_timing',
|
|
161
|
-
passed: false,
|
|
162
|
-
score: 1.0,
|
|
163
|
-
details: 'Response times are suspiciously uniform',
|
|
164
|
-
data: { cv, mean, stddev },
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (cv < 0.2) {
|
|
169
|
-
return {
|
|
170
|
-
checkId: 'uniform_timing',
|
|
171
|
-
passed: true,
|
|
172
|
-
score: 0.5,
|
|
173
|
-
details: 'Lower than typical timing variation',
|
|
174
|
-
data: { cv, mean, stddev },
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
checkId: 'uniform_timing',
|
|
180
|
-
passed: true,
|
|
181
|
-
score: 0,
|
|
182
|
-
data: { cv, mean, stddev },
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Check: Suspicious Pauses
|
|
188
|
-
*
|
|
189
|
-
* Detects unusual gaps in activity that might indicate:
|
|
190
|
-
* - Looking up answers
|
|
191
|
-
* - Bot waiting for external input
|
|
192
|
-
* - Copy-pasting from other sources
|
|
193
|
-
*/
|
|
194
|
-
export function checkSuspiciousPauses(
|
|
195
|
-
metrics?: BehavioralMetrics
|
|
196
|
-
): CheckResult {
|
|
197
|
-
if (!metrics?.focusEvents || metrics.focusEvents.length < 2) {
|
|
198
|
-
return {
|
|
199
|
-
checkId: 'suspicious_pauses',
|
|
200
|
-
passed: true,
|
|
201
|
-
score: 0,
|
|
202
|
-
details: 'No focus event data',
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Analyze blur durations
|
|
207
|
-
const blurEvents = metrics.focusEvents.filter(e => e.type === 'blur' || e.type === 'hidden');
|
|
208
|
-
const totalBlurTime = metrics.totalBlurDuration || 0;
|
|
209
|
-
const surveyDuration = metrics.duration || 1;
|
|
210
|
-
|
|
211
|
-
// If more than 30% of time was spent away from survey, suspicious
|
|
212
|
-
const blurRatio = totalBlurTime / surveyDuration;
|
|
213
|
-
|
|
214
|
-
if (blurRatio > 0.5) {
|
|
215
|
-
return {
|
|
216
|
-
checkId: 'suspicious_pauses',
|
|
217
|
-
passed: false,
|
|
218
|
-
score: 0.9,
|
|
219
|
-
details: `${Math.round(blurRatio * 100)}% of time spent away from survey`,
|
|
220
|
-
data: { blurRatio, totalBlurTime, blurEvents: blurEvents.length },
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (blurRatio > 0.3) {
|
|
225
|
-
return {
|
|
226
|
-
checkId: 'suspicious_pauses',
|
|
227
|
-
passed: true,
|
|
228
|
-
score: 0.5,
|
|
229
|
-
details: 'Significant time away from survey',
|
|
230
|
-
data: { blurRatio, totalBlurTime },
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
return {
|
|
235
|
-
checkId: 'suspicious_pauses',
|
|
236
|
-
passed: true,
|
|
237
|
-
score: 0,
|
|
238
|
-
data: { blurRatio },
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Run all timing checks
|
|
244
|
-
*/
|
|
245
|
-
export function runTimingChecks(
|
|
246
|
-
responses: ResponseInput[],
|
|
247
|
-
metrics?: BehavioralMetrics,
|
|
248
|
-
context?: SurveyContext
|
|
249
|
-
): CheckResult[] {
|
|
250
|
-
return [
|
|
251
|
-
checkImpossiblyFast(responses, context),
|
|
252
|
-
checkRapidCompletion(responses, context),
|
|
253
|
-
checkUniformTiming(responses, metrics),
|
|
254
|
-
checkSuspiciousPauses(metrics),
|
|
255
|
-
];
|
|
256
|
-
}
|