@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.
@@ -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
- }
@@ -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';
@@ -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
- }