engagelab-captcha-sdk 1.0.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.
Files changed (73) hide show
  1. package/dist/captcha-sdk.esm.js +1 -0
  2. package/dist/captcha-sdk.umd.js +1 -0
  3. package/dist/types/api.d.ts +12 -0
  4. package/dist/types/challenges/dragsort.d.ts +24 -0
  5. package/dist/types/challenges/grid.d.ts +20 -0
  6. package/dist/types/challenges/icons.d.ts +18 -0
  7. package/dist/types/challenges/invisible.d.ts +8 -0
  8. package/dist/types/challenges/physics.d.ts +40 -0
  9. package/dist/types/challenges/rotate.d.ts +14 -0
  10. package/dist/types/challenges/slider.d.ts +17 -0
  11. package/dist/types/challenges/spatial.d.ts +12 -0
  12. package/dist/types/compat/hcaptcha.d.ts +1 -0
  13. package/dist/types/compat/recaptcha.d.ts +1 -0
  14. package/dist/types/crypto.d.ts +11 -0
  15. package/dist/types/deep_probes.d.ts +13 -0
  16. package/dist/types/degradation.d.ts +27 -0
  17. package/dist/types/device_attestation.d.ts +19 -0
  18. package/dist/types/fingerprint.d.ts +2 -0
  19. package/dist/types/headless.d.ts +2 -0
  20. package/dist/types/i18n.d.ts +7 -0
  21. package/dist/types/index.d.ts +36 -0
  22. package/dist/types/integrity.d.ts +3 -0
  23. package/dist/types/logger.d.ts +49 -0
  24. package/dist/types/native_monitor.d.ts +21 -0
  25. package/dist/types/passive_signals.d.ts +119 -0
  26. package/dist/types/pow.d.ts +2 -0
  27. package/dist/types/reporter.d.ts +8 -0
  28. package/dist/types/session_persist.d.ts +18 -0
  29. package/dist/types/theme.d.ts +11 -0
  30. package/dist/types/timing.d.ts +2 -0
  31. package/dist/types/trajectory.d.ts +18 -0
  32. package/dist/types/types.d.ts +182 -0
  33. package/dist/types/ui/container.d.ts +20 -0
  34. package/dist/types/ui/styles.d.ts +1 -0
  35. package/dist/types/wasm_core.d.ts +44 -0
  36. package/dist/types/web-component.d.ts +11 -0
  37. package/package.json +22 -0
  38. package/rollup.config.mjs +62 -0
  39. package/src/api.ts +180 -0
  40. package/src/challenges/dragsort.ts +168 -0
  41. package/src/challenges/grid.ts +147 -0
  42. package/src/challenges/icons.ts +106 -0
  43. package/src/challenges/invisible.ts +57 -0
  44. package/src/challenges/physics.ts +437 -0
  45. package/src/challenges/rotate.ts +81 -0
  46. package/src/challenges/slider.ts +168 -0
  47. package/src/challenges/spatial.ts +91 -0
  48. package/src/compat/hcaptcha.ts +112 -0
  49. package/src/compat/recaptcha.ts +108 -0
  50. package/src/crypto.ts +69 -0
  51. package/src/deep_probes.ts +690 -0
  52. package/src/degradation.ts +113 -0
  53. package/src/device_attestation.ts +109 -0
  54. package/src/fingerprint.ts +247 -0
  55. package/src/headless.ts +233 -0
  56. package/src/i18n.ts +455 -0
  57. package/src/index.ts +527 -0
  58. package/src/integrity.ts +100 -0
  59. package/src/logger.ts +170 -0
  60. package/src/native_monitor.ts +100 -0
  61. package/src/passive_signals.ts +544 -0
  62. package/src/pow.ts +120 -0
  63. package/src/reporter.ts +75 -0
  64. package/src/session_persist.ts +90 -0
  65. package/src/theme.ts +41 -0
  66. package/src/timing.ts +79 -0
  67. package/src/trajectory.ts +110 -0
  68. package/src/types.ts +199 -0
  69. package/src/ui/container.ts +161 -0
  70. package/src/ui/styles.ts +153 -0
  71. package/src/wasm_core.ts +189 -0
  72. package/src/web-component.ts +103 -0
  73. package/tsconfig.json +18 -0
@@ -0,0 +1,690 @@
1
+ /**
2
+ * Deep environment probes — counter Camoufox/Nodriver/Stealth attacks.
3
+ * These tests exploit behavioral and timing differences that are extremely
4
+ * difficult to fake even with C++-level browser patching.
5
+ */
6
+
7
+ import { sdkLogger } from './logger';
8
+
9
+ export interface DeepProbeResult {
10
+ name: string;
11
+ detected: boolean;
12
+ value: string;
13
+ weight: number;
14
+ confidence: number; // 0-1, how confident we are this is a true signal
15
+ }
16
+
17
+ export async function runDeepProbes(): Promise<DeepProbeResult[]> {
18
+ sdkLogger.debug('DeepProbes', 'runDeepProbes start');
19
+ const results: DeepProbeResult[] = [];
20
+
21
+ const push = (r: DeepProbeResult) => {
22
+ results.push(r);
23
+ sdkLogger.debug('DeepProbes', `probe ${r.name}`, { detected: r.detected, weight: r.weight });
24
+ };
25
+
26
+ push(probePropertyDescriptors());
27
+ push(probeErrorStackDepth());
28
+ push(probeCanvasConsistency());
29
+ push(probeWebGLParamDepth());
30
+ push(probeIntlConsistency());
31
+ push(probeTimerCoherence());
32
+ push(probeFontRendering());
33
+ push(probeObjectStringify());
34
+ push(probeWorkerScope());
35
+ push(probeSharedArrayBuffer());
36
+ push(probeAudioContextFingerprint());
37
+ push(probeEventTrustChain());
38
+ push(probeNavigatorInconsistency());
39
+ push(await probeNetworkTiming());
40
+
41
+ push(probeFingerprintBrowser());
42
+ push(probeMultiLoginBrowserArtifacts());
43
+
44
+ const detectedCount = results.filter(p => p.detected).length;
45
+ sdkLogger.info('DeepProbes', `Completed ${results.length} probes, ${detectedCount} detected`);
46
+
47
+ return results;
48
+ }
49
+
50
+ /**
51
+ * Check if native property descriptors have been tampered.
52
+ * Camoufox/Stealth modify getters but rarely fix the descriptor metadata.
53
+ */
54
+ function probePropertyDescriptors(): DeepProbeResult {
55
+ let tampered = 0;
56
+ const checks = [
57
+ { obj: navigator, prop: 'webdriver' },
58
+ { obj: navigator, prop: 'languages' },
59
+ { obj: navigator, prop: 'plugins' },
60
+ { obj: navigator, prop: 'hardwareConcurrency' },
61
+ { obj: screen, prop: 'width' },
62
+ { obj: screen, prop: 'height' },
63
+ ];
64
+
65
+ for (const { obj, prop } of checks) {
66
+ try {
67
+ const desc = Object.getOwnPropertyDescriptor(obj, prop);
68
+ // Native properties should NOT have own descriptors on the instance
69
+ // They should be on the prototype. If found on instance = patched
70
+ if (desc && desc.get && !Function.prototype.toString.call(desc.get).includes('[native code]')) {
71
+ tampered++;
72
+ }
73
+ // Also check: own property on instance instead of prototype
74
+ if (desc && Object.prototype.hasOwnProperty.call(obj, prop)) {
75
+ const protoDesc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(obj), prop);
76
+ if (protoDesc && protoDesc.get) {
77
+ // Property exists on both instance AND prototype = suspicious override
78
+ tampered++;
79
+ }
80
+ }
81
+ } catch { /* skip */ }
82
+ }
83
+
84
+ return {
85
+ name: 'property_descriptor_tampering',
86
+ detected: tampered > 1,
87
+ value: String(tampered),
88
+ weight: tampered > 2 ? 15 : tampered > 0 ? 8 : 0,
89
+ confidence: Math.min(1, tampered / 3),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Analyze Error stack trace depth and format.
95
+ * Automated browsers often have different stack patterns.
96
+ */
97
+ function probeErrorStackDepth(): DeepProbeResult {
98
+ let anomalies = 0;
99
+ try {
100
+ const e = new Error('probe');
101
+ const stack = e.stack || '';
102
+ const lines = stack.split('\n').filter(l => l.trim().length > 0);
103
+
104
+ // Check for automation-framework frames in stack
105
+ const suspiciousFrames = ['puppeteer', 'playwright', 'selenium', 'webdriver', 'cdp', 'devtools', '__puppeteer'];
106
+ for (const frame of lines) {
107
+ const lower = frame.toLowerCase();
108
+ for (const sus of suspiciousFrames) {
109
+ if (lower.includes(sus)) anomalies++;
110
+ }
111
+ }
112
+
113
+ // Very shallow stack in non-minimal context = suspicious
114
+ if (lines.length < 2) anomalies++;
115
+
116
+ // Check Error.captureStackTrace behavior (Chrome-specific)
117
+ if (typeof Error.captureStackTrace === 'function') {
118
+ const obj: Record<string, unknown> = {};
119
+ Error.captureStackTrace(obj);
120
+ const st = typeof obj.stack === 'string' ? obj.stack : '';
121
+ if (!st || st.length < 10) anomalies++;
122
+ }
123
+ } catch {
124
+ anomalies++;
125
+ }
126
+
127
+ return {
128
+ name: 'error_stack_anomaly',
129
+ detected: anomalies > 0,
130
+ value: String(anomalies),
131
+ weight: anomalies > 1 ? 10 : anomalies > 0 ? 5 : 0,
132
+ confidence: Math.min(1, anomalies / 2),
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Canvas rendering consistency check.
138
+ * Draw the same thing twice with slight timing — result should be identical.
139
+ * Randomized fingerprint spoofing will produce different results.
140
+ */
141
+ function probeCanvasConsistency(): DeepProbeResult {
142
+ try {
143
+ const draw = () => {
144
+ const c = document.createElement('canvas');
145
+ c.width = 100; c.height = 30;
146
+ const ctx = c.getContext('2d')!;
147
+ ctx.textBaseline = 'alphabetic';
148
+ ctx.fillStyle = '#f60';
149
+ ctx.fillRect(10, 1, 62, 20);
150
+ ctx.fillStyle = '#069';
151
+ ctx.font = '11px Arial';
152
+ ctx.fillText('Cwm fjordbank', 2, 15);
153
+ ctx.fillStyle = 'rgba(102,204,0,0.7)';
154
+ ctx.font = '18px Arial';
155
+ ctx.fillText('glyphs vext', 4, 25);
156
+ return c.toDataURL();
157
+ };
158
+
159
+ const hash1 = draw();
160
+ const hash2 = draw();
161
+ const consistent = hash1 === hash2;
162
+
163
+ // Inconsistency = fingerprint randomization (Camoufox does this)
164
+ return {
165
+ name: 'canvas_consistency',
166
+ detected: !consistent,
167
+ value: consistent ? 'consistent' : 'randomized',
168
+ weight: !consistent ? 20 : 0,
169
+ confidence: !consistent ? 0.9 : 0,
170
+ };
171
+ } catch {
172
+ return { name: 'canvas_consistency', detected: false, value: 'error', weight: 0, confidence: 0 };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Deep WebGL parameter check.
178
+ * Query many obscure WebGL parameters; spoofed renderers often miss some.
179
+ */
180
+ function probeWebGLParamDepth(): DeepProbeResult {
181
+ try {
182
+ const canvas = document.createElement('canvas');
183
+ const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
184
+ if (!gl) return { name: 'webgl_param_depth', detected: false, value: 'no_webgl', weight: 0, confidence: 0 };
185
+
186
+ const g = gl as WebGLRenderingContext;
187
+ let inconsistencies = 0;
188
+
189
+ const ext = g.getExtension('WEBGL_debug_renderer_info');
190
+ const renderer = ext ? String(g.getParameter(ext.UNMASKED_RENDERER_WEBGL) ?? '') : '';
191
+ const vendor = ext ? String(g.getParameter(ext.UNMASKED_VENDOR_WEBGL) ?? '') : '';
192
+
193
+ // Check max values consistency with renderer
194
+ const maxTextureSize = g.getParameter(g.MAX_TEXTURE_SIZE) as number;
195
+
196
+ // Software renderers have specific limits
197
+ if (renderer.includes('SwiftShader') || renderer.includes('llvmpipe')) {
198
+ // Already caught by headless detection
199
+ } else if (renderer.includes('ANGLE')) {
200
+ // ANGLE on real GPU: maxTextureSize is usually 16384+
201
+ if (maxTextureSize < 4096) inconsistencies++;
202
+ }
203
+
204
+ // Check: if vendor is "Google Inc." but not ANGLE = suspicious
205
+ if (vendor === 'Google Inc.' && !renderer.includes('ANGLE') && !renderer.includes('SwiftShader')) {
206
+ inconsistencies++;
207
+ }
208
+
209
+ // Check aliased line width range (often not spoofed)
210
+ const aliasedLineRange = g.getParameter(g.ALIASED_LINE_WIDTH_RANGE) as Float32Array | null;
211
+ if (aliasedLineRange && aliasedLineRange[0] === aliasedLineRange[1]) {
212
+ inconsistencies++;
213
+ }
214
+
215
+ return {
216
+ name: 'webgl_param_depth',
217
+ detected: inconsistencies > 0,
218
+ value: `renderer=${renderer.substring(0, 30)},issues=${inconsistencies}`,
219
+ weight: inconsistencies > 1 ? 12 : inconsistencies > 0 ? 5 : 0,
220
+ confidence: Math.min(1, inconsistencies / 2),
221
+ };
222
+ } catch {
223
+ return { name: 'webgl_param_depth', detected: false, value: 'error', weight: 0, confidence: 0 };
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Intl API consistency check.
229
+ * Timezone, locale, and Intl formatting should all be consistent.
230
+ * Fingerprint spoofers often miss Intl.
231
+ */
232
+ function probeIntlConsistency(): DeepProbeResult {
233
+ let inconsistencies = 0;
234
+ try {
235
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
236
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale;
237
+ const tzOffset = new Date().getTimezoneOffset();
238
+
239
+ // Check: navigator.language vs Intl locale
240
+ const navLang = navigator.language;
241
+ if (navLang && locale && navLang.split('-')[0] !== locale.split('-')[0]) {
242
+ // Language mismatch between navigator and Intl
243
+ inconsistencies++;
244
+ }
245
+
246
+ // Check: timezone offset should be plausible for the Intl timezone
247
+ // This is hard to fake consistently
248
+ if (tz && tzOffset !== undefined) {
249
+ const d = new Date();
250
+ const fmt = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', hour12: false });
251
+ // If this throws, tz is invalid
252
+ try { fmt.format(d); } catch { inconsistencies++; }
253
+ }
254
+
255
+ // Check NumberFormat consistency
256
+ const nf = new Intl.NumberFormat(navLang);
257
+ nf.format(1234.5);
258
+ // formatted should use the grouping/decimal separators of navLang
259
+ // If navigator.language was spoofed but Intl wasn't, this may reveal inconsistency
260
+ } catch {
261
+ inconsistencies++;
262
+ }
263
+
264
+ return {
265
+ name: 'intl_consistency',
266
+ detected: inconsistencies > 0,
267
+ value: String(inconsistencies),
268
+ weight: inconsistencies > 0 ? 8 : 0,
269
+ confidence: Math.min(1, inconsistencies / 2),
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Timer coherence test.
275
+ * Compare performance.now() vs Date.now() drift.
276
+ * Automated environments sometimes have desynchronized timers.
277
+ */
278
+ function probeTimerCoherence(): DeepProbeResult {
279
+ const perfStart = performance.now();
280
+ const dateStart = Date.now();
281
+
282
+ // Busy wait ~5ms
283
+ while (performance.now() - perfStart < 5) { /* spin */ }
284
+
285
+ const perfElapsed = performance.now() - perfStart;
286
+ const dateElapsed = Date.now() - dateStart;
287
+
288
+ const drift = Math.abs(perfElapsed - dateElapsed);
289
+ // Normally these should agree within ~2ms
290
+ // Virtualized or hooked timers may show larger drift
291
+ const detected = drift > 10;
292
+
293
+ return {
294
+ name: 'timer_coherence',
295
+ detected,
296
+ value: `perf=${perfElapsed.toFixed(2)},date=${dateElapsed},drift=${drift.toFixed(2)}`,
297
+ weight: detected ? 8 : 0,
298
+ confidence: detected ? 0.6 : 0,
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Font rendering measurement.
304
+ * Measure specific text rendering dimensions; spoofed font lists create inconsistencies.
305
+ */
306
+ function probeFontRendering(): DeepProbeResult {
307
+ try {
308
+ const testFonts = ['monospace', 'serif', 'sans-serif'];
309
+ const span = document.createElement('span');
310
+ span.style.position = 'absolute';
311
+ span.style.left = '-9999px';
312
+ span.style.fontSize = '72px';
313
+ span.textContent = 'mmmmmmmmmmlli';
314
+ document.body.appendChild(span);
315
+
316
+ const widths: number[] = [];
317
+ for (const font of testFonts) {
318
+ span.style.fontFamily = font;
319
+ widths.push(span.offsetWidth);
320
+ }
321
+ document.body.removeChild(span);
322
+
323
+ // All three should produce different widths on real systems
324
+ const allSame = widths[0] === widths[1] && widths[1] === widths[2];
325
+ const detected = allSame && widths[0] > 0;
326
+
327
+ return {
328
+ name: 'font_rendering',
329
+ detected,
330
+ value: widths.join(','),
331
+ weight: detected ? 10 : 0,
332
+ confidence: detected ? 0.7 : 0,
333
+ };
334
+ } catch {
335
+ return { name: 'font_rendering', detected: false, value: 'error', weight: 0, confidence: 0 };
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Check if toString() on native objects has been overridden.
341
+ * Stealth plugins often override toString to hide modifications.
342
+ */
343
+ function probeObjectStringify(): DeepProbeResult {
344
+ let tampered = 0;
345
+ try {
346
+ // Check if Function.prototype.toString itself was modified
347
+ const toStringStr = Function.prototype.toString.toString();
348
+ if (!toStringStr.includes('[native code]')) tampered += 2;
349
+
350
+ // Check Object.getOwnPropertyDescriptor hasn't been wrapped
351
+ const gopdStr = Object.getOwnPropertyDescriptor.toString();
352
+ if (!gopdStr.includes('[native code]')) tampered++;
353
+
354
+ // Check Proxy detection: new Proxy wrapping often leaks
355
+ const test = { a: 1 };
356
+ const keys = Object.keys(test);
357
+ if (keys.length !== 1) tampered++;
358
+ } catch {
359
+ tampered++;
360
+ }
361
+
362
+ return {
363
+ name: 'object_stringify_tampering',
364
+ detected: tampered > 0,
365
+ value: String(tampered),
366
+ weight: tampered >= 2 ? 15 : tampered > 0 ? 8 : 0,
367
+ confidence: Math.min(1, tampered / 2),
368
+ };
369
+ }
370
+
371
+ /**
372
+ * Check Worker scope availability.
373
+ * Some automation tools have inconsistent Worker support.
374
+ */
375
+ function probeWorkerScope(): DeepProbeResult {
376
+ try {
377
+ const hasServiceWorker = 'serviceWorker' in navigator;
378
+ const ua = navigator.userAgent.toLowerCase();
379
+
380
+ // Chrome should have all three
381
+ if (ua.includes('chrome') && !ua.includes('edge')) {
382
+ if (!hasServiceWorker) return { name: 'worker_scope', detected: true, value: 'no_sw', weight: 10, confidence: 0.7 };
383
+ }
384
+
385
+ return { name: 'worker_scope', detected: false, value: 'ok', weight: 0, confidence: 0 };
386
+ } catch {
387
+ return { name: 'worker_scope', detected: false, value: 'error', weight: 0, confidence: 0 };
388
+ }
389
+ }
390
+
391
+ /**
392
+ * SharedArrayBuffer availability check.
393
+ * Requires COOP/COEP headers; automated environments often don't have this.
394
+ */
395
+ function probeSharedArrayBuffer(): DeepProbeResult {
396
+ const hasSAB = typeof SharedArrayBuffer !== 'undefined';
397
+ // Not having SAB is common and not suspicious by itself
398
+ // But having it when COOP headers aren't set IS suspicious
399
+ return { name: 'shared_array_buffer', detected: false, value: String(hasSAB), weight: 0, confidence: 0 };
400
+ }
401
+
402
+ /**
403
+ * AudioContext fingerprint timing.
404
+ * Real audio processing takes measurable time; mocked contexts return instantly.
405
+ */
406
+ function probeAudioContextFingerprint(): DeepProbeResult {
407
+ try {
408
+ const w = window as typeof window & { webkitAudioContext?: typeof AudioContext };
409
+ const AudioCtx = w.AudioContext || w.webkitAudioContext;
410
+ if (!AudioCtx) return { name: 'audio_context', detected: false, value: 'unsupported', weight: 0, confidence: 0 };
411
+
412
+ const ctx = new AudioCtx();
413
+ const oscillator = ctx.createOscillator();
414
+ const analyser = ctx.createAnalyser();
415
+ const gain = ctx.createGain();
416
+
417
+ oscillator.type = 'triangle';
418
+ oscillator.connect(analyser);
419
+ analyser.connect(gain);
420
+ gain.connect(ctx.destination);
421
+
422
+ gain.gain.value = 0; // mute
423
+ oscillator.start(0);
424
+ oscillator.stop(ctx.currentTime + 0.01);
425
+
426
+ const data = new Float32Array(analyser.fftSize);
427
+ analyser.getFloatTimeDomainData(data);
428
+
429
+ ctx.close();
430
+
431
+ // All zeros = mocked audio
432
+ const allZero = data.every(v => v === 0);
433
+ return {
434
+ name: 'audio_context',
435
+ detected: allZero,
436
+ value: allZero ? 'mocked' : 'real',
437
+ weight: allZero ? 8 : 0,
438
+ confidence: allZero ? 0.6 : 0,
439
+ };
440
+ } catch {
441
+ return { name: 'audio_context', detected: false, value: 'error', weight: 0, confidence: 0 };
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Event trust chain verification.
447
+ * Dispatch a custom event and verify isTrusted behavior.
448
+ */
449
+ function probeEventTrustChain(): DeepProbeResult {
450
+ let tampered = false;
451
+ try {
452
+ const div = document.createElement('div');
453
+ let receivedTrusted: boolean | null = null;
454
+
455
+ div.addEventListener('click', (e: MouseEvent) => {
456
+ receivedTrusted = e.isTrusted;
457
+ });
458
+
459
+ // Programmatically dispatched events should have isTrusted=false
460
+ div.dispatchEvent(new MouseEvent('click'));
461
+
462
+ // If isTrusted is true for programmatic event = hook detected
463
+ if (receivedTrusted === true) {
464
+ tampered = true;
465
+ }
466
+ } catch {
467
+ // Ignore
468
+ }
469
+
470
+ return {
471
+ name: 'event_trust_chain',
472
+ detected: tampered,
473
+ value: String(tampered),
474
+ weight: tampered ? 20 : 0,
475
+ confidence: tampered ? 0.95 : 0,
476
+ };
477
+ }
478
+
479
+ /**
480
+ * Navigator property enumeration inconsistency.
481
+ * Count enumerable properties; automated browsers often have extra or missing ones.
482
+ */
483
+ function probeNavigatorInconsistency(): DeepProbeResult {
484
+ let score = 0;
485
+ try {
486
+ // Check for extra properties injected by automation
487
+ const navKeys = Object.keys(navigator);
488
+ const suspiciousKeys = ['__driver', '__webdriver', '__selenium', '__nightmare', 'domAutomation', 'domAutomationController'];
489
+ for (const k of navKeys) {
490
+ if (suspiciousKeys.some(s => k.toLowerCase().includes(s))) score += 2;
491
+ }
492
+
493
+ // In normal Chrome, navigator shouldn't have many own enumerable properties
494
+ if (navKeys.length > 5) score++;
495
+
496
+ // Check if navigator.permissions.query exists and works correctly
497
+ if (!navigator.permissions) score++;
498
+ } catch {
499
+ score++;
500
+ }
501
+
502
+ return {
503
+ name: 'navigator_inconsistency',
504
+ detected: score > 1,
505
+ value: String(score),
506
+ weight: score > 2 ? 12 : score > 0 ? 5 : 0,
507
+ confidence: Math.min(1, score / 3),
508
+ };
509
+ }
510
+
511
+ /**
512
+ * Detect fingerprint browsers (AdsPower, HubStudio, Multilogin, GoLogin, etc.)
513
+ * These tools create isolated browser profiles with spoofed fingerprints.
514
+ * They leave detectable artifacts in window/navigator/DOM.
515
+ */
516
+ function probeFingerprintBrowser(): DeepProbeResult {
517
+ let score = 0;
518
+ const artifacts: string[] = [];
519
+
520
+ try {
521
+ const w = window as any;
522
+ const n = navigator as any;
523
+
524
+ // AdsPower specific artifacts
525
+ if (w.__adspower || w.ADS_POWER || w._adspower_state) { score += 3; artifacts.push('adspower_global'); }
526
+ if (n.adspower || n.__adspower_id) { score += 3; artifacts.push('adspower_nav'); }
527
+
528
+ // HubStudio / Hubstudio artifacts
529
+ if (w.__hubstudio || w.HubStudio || w._hubstudio) { score += 3; artifacts.push('hubstudio_global'); }
530
+
531
+ // Multilogin artifacts
532
+ if (w.__multilogin || w.Multilogin || w._mla_data) { score += 3; artifacts.push('multilogin_global'); }
533
+ if (n.multiloginVersion || n.__multilogin_profile_id) { score += 3; artifacts.push('multilogin_nav'); }
534
+
535
+ // GoLogin artifacts
536
+ if (w.__gologin || w.GoLogin || w._gologin) { score += 3; artifacts.push('gologin_global'); }
537
+
538
+ // VMLogin artifacts
539
+ if (w.__vmlogin || w.VmLogin || w._vmlogin) { score += 3; artifacts.push('vmlogin_global'); }
540
+
541
+ // Generic fingerprint browser detection heuristics
542
+
543
+ // Check for profile-id or session-id cookies that fingerprint browsers often inject
544
+ const cookies = document.cookie;
545
+ const fpBrowserCookies = ['_adspower_', '_multilogin_', '_gologin_', '_vmlogin_', '_hubstudio_'];
546
+ for (const prefix of fpBrowserCookies) {
547
+ if (cookies.includes(prefix)) { score += 2; artifacts.push(`cookie_${prefix}`); }
548
+ }
549
+
550
+ // Many fingerprint browsers inject custom extensions with predictable IDs
551
+ if ('chrome' in window && (window as any).chrome?.runtime) {
552
+ const knownExtIds = [
553
+ 'fhbjgbiflinjbdggehcddcbncdddomop', // AdsPower
554
+ 'nkbihfbeogaeaoehlefnkodbefgpgknn', // Some multilogin
555
+ ];
556
+ for (const id of knownExtIds) {
557
+ try {
558
+ const img = new Image();
559
+ img.src = `chrome-extension://${id}/icon.png`;
560
+ // Can't fully check, but attempt is recorded
561
+ } catch { /* expected */ }
562
+ }
563
+ }
564
+
565
+ // Detect Chromium kernel modifications:
566
+ // Fingerprint browsers modify Chrome version string inconsistently
567
+ const ua = navigator.userAgent;
568
+ const chromeMatch = ua.match(/Chrome\/(\d+)/);
569
+ if (chromeMatch) {
570
+ const majorVersion = parseInt(chromeMatch[1]);
571
+ // Check if claimed Chrome version is consistent with feature support
572
+ if (majorVersion > 100 && !('cookieStore' in window)) {
573
+ score += 1; // Mild signal: cookie Store API missing in claimed modern Chrome
574
+ }
575
+ if (majorVersion > 90 && typeof (navigator as any).userAgentData === 'undefined') {
576
+ score += 1; // Modern Chrome should have userAgentData
577
+ artifacts.push('missing_ua_data');
578
+ }
579
+ }
580
+
581
+ // Timezone spoofing detection via Performance API timing
582
+ const perfTiming = performance.timing;
583
+ if (perfTiming && perfTiming.navigationStart > 0) {
584
+ const now = Date.now();
585
+ const drift = Math.abs(now - perfTiming.navigationStart - performance.now());
586
+ if (drift > 100) {
587
+ score += 1;
588
+ artifacts.push('timing_drift');
589
+ }
590
+ }
591
+
592
+ } catch { /* ignore detection errors */ }
593
+
594
+ return {
595
+ name: 'fingerprint_browser',
596
+ detected: score >= 3,
597
+ value: artifacts.length > 0 ? artifacts.join(',') : 'none',
598
+ weight: score >= 6 ? 25 : score >= 3 ? 15 : 0,
599
+ confidence: Math.min(1, score / 6),
600
+ };
601
+ }
602
+
603
+ /**
604
+ * Detect multi-login browser profile artifacts via DOM/API inconsistencies.
605
+ * Fingerprint browsers create profiles that reuse the same underlying Chromium
606
+ * but spoof device parameters — resulting in subtle inconsistencies.
607
+ */
608
+ function probeMultiLoginBrowserArtifacts(): DeepProbeResult {
609
+ let inconsistencies = 0;
610
+
611
+ try {
612
+ // Check: deviceMemory + hardwareConcurrency vs WebGL renderer
613
+ const mem = (navigator as any).deviceMemory;
614
+ const cores = navigator.hardwareConcurrency;
615
+
616
+ const canvas = document.createElement('canvas');
617
+ const gl = canvas.getContext('webgl');
618
+ if (gl) {
619
+ const ext = gl.getExtension('WEBGL_debug_renderer_info');
620
+ const renderer = ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) ?? '') : '';
621
+
622
+ // Low-end spoofed: claims 2GB RAM but has high-end GPU name
623
+ if (mem && mem <= 2 && renderer.match(/RTX|RX 6|RX 7|Arc A/i)) {
624
+ inconsistencies++;
625
+ }
626
+ // Claims many cores but software renderer
627
+ if (cores && cores >= 8 && renderer.match(/SwiftShader|llvmpipe|Software/i)) {
628
+ inconsistencies++;
629
+ }
630
+ }
631
+
632
+ // Check: screen resolution vs device pixel ratio mismatch
633
+ const screenW = screen.width;
634
+ const screenH = screen.height;
635
+ const dpr = window.devicePixelRatio;
636
+ // Common spoofed combo: mobile resolution with desktop DPR
637
+ if (screenW <= 414 && screenH <= 896 && dpr === 1 && !('ontouchstart' in window)) {
638
+ inconsistencies++;
639
+ }
640
+
641
+ // Check: platform vs userAgent consistency
642
+ const platform = navigator.platform;
643
+ const ua = navigator.userAgent;
644
+ if (platform === 'Win32' && ua.includes('Macintosh')) inconsistencies += 2;
645
+ if (platform === 'MacIntel' && ua.includes('Windows')) inconsistencies += 2;
646
+ if (platform === 'Linux x86_64' && ua.includes('Windows NT')) inconsistencies++;
647
+
648
+ // Check: language list consistency with timezone
649
+ const lang = navigator.language;
650
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
651
+ // Chinese language with US timezone or vice versa (mild signal)
652
+ if (lang.startsWith('zh') && tz.startsWith('America/')) inconsistencies++;
653
+ if (lang.startsWith('en-US') && tz.startsWith('Asia/Shanghai')) inconsistencies++;
654
+
655
+ } catch { /* ignore */ }
656
+
657
+ return {
658
+ name: 'multilogin_artifacts',
659
+ detected: inconsistencies >= 2,
660
+ value: String(inconsistencies),
661
+ weight: inconsistencies >= 3 ? 18 : inconsistencies >= 2 ? 10 : 0,
662
+ confidence: Math.min(1, inconsistencies / 4),
663
+ };
664
+ }
665
+
666
+ /**
667
+ * Network request timing test.
668
+ * Measure time to fetch a tiny resource; automated environments in VMs
669
+ * sometimes have different network characteristics.
670
+ */
671
+ async function probeNetworkTiming(): Promise<DeepProbeResult> {
672
+ try {
673
+ const start = performance.now();
674
+ // Fetch the same page/a small known resource
675
+ await fetch(window.location.href, { method: 'HEAD', cache: 'no-store' }).catch(() => {});
676
+ const elapsed = performance.now() - start;
677
+
678
+ // Very fast response (< 1ms) = mocked fetch
679
+ const detected = elapsed < 0.5;
680
+ return {
681
+ name: 'network_timing',
682
+ detected,
683
+ value: `${elapsed.toFixed(2)}ms`,
684
+ weight: detected ? 10 : 0,
685
+ confidence: detected ? 0.7 : 0,
686
+ };
687
+ } catch {
688
+ return { name: 'network_timing', detected: false, value: 'error', weight: 0, confidence: 0 };
689
+ }
690
+ }