@webdecoy/fcaptcha 1.30.0 → 1.31.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 (5) hide show
  1. package/clientip.js +14 -1
  2. package/engine.js +980 -0
  3. package/index.js +62 -309
  4. package/package.json +2 -2
  5. package/server.js +37 -883
package/engine.js ADDED
@@ -0,0 +1,980 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared detection and scoring core.
5
+ *
6
+ * server-node used to carry two independent engines: server.js (the Express
7
+ * server) and index.js (the npm `main`, published as `@webdecoy/fcaptcha`).
8
+ * They drifted badly. The library was missing seventeen detectors, had no
9
+ * `cdp` or `declared_ai` weight category, and still aggregated with the
10
+ * pre-v1.18.0 confidence-weighted mean — the model where corroborating
11
+ * evidence *lowers* the verdict — with no dispositive floor. Anyone using the
12
+ * package as a library got materially weaker detection than anyone running the
13
+ * server, and nothing in the API said so.
14
+ *
15
+ * Everything here is pure: signals in, detections out. The stateful detectors
16
+ * (fingerprint reuse, rate abuse) stay with whichever engine owns the store.
17
+ */
18
+
19
+ // =============================================================================
20
+ // Detection Patterns
21
+ // =============================================================================
22
+
23
+ const AUTOMATION_UA_PATTERNS = [
24
+ /headless/i, /phantomjs/i, /selenium/i, /webdriver/i,
25
+ /puppeteer/i, /playwright/i, /cypress/i, /nightwatch/i,
26
+ /zombie/i, /electron/i, /chromium.*headless/i
27
+ ];
28
+
29
+ const WEIGHTS = {
30
+ vision_ai: 0.15,
31
+ headless: 0.15,
32
+ automation: 0.08,
33
+ cdp: 0.12,
34
+ behavioral: 0.18,
35
+ fingerprint: 0.08,
36
+ rate_limit: 0.01,
37
+ datacenter: 0.07,
38
+ tor_vpn: 0.01,
39
+ bot: 0.13,
40
+ declared_ai: 0.02
41
+ };
42
+
43
+ // =============================================================================
44
+ // Detection Functions
45
+ // =============================================================================
46
+
47
+ /**
48
+ * Movement that carries independent evidence of a human hand.
49
+ *
50
+ * A slow pointer is the thing two of these checks look for, and it is also
51
+ * exactly what an elderly or motor-impaired visitor produces. The bench human
52
+ * panel caught both firing on them: "Mouse event rate abnormally low" on the
53
+ * elderly and motor-slow personas, "Mouse velocity too consistent" on
54
+ * motor-slow.
55
+ *
56
+ * Slowness alone cannot separate those users from an agent, but it does not
57
+ * have to. The same captures carry markers no low-effort automation produces:
58
+ * saturated micro-tremor, dozens of direction changes, corrective overshoots.
59
+ * On the bench corpus the split is total - every human persona clears this bar
60
+ * (tremor 1.00, 22-49 direction changes, 1-4 corrections) and every agent
61
+ * misses it (tremor 0.04-0.16, 0-1 direction changes, 0 corrections).
62
+ *
63
+ * So: do not read slowness as automation when the movement independently looks
64
+ * like a hand. An agent can of course fake all three, but faking three
65
+ * correlated properties of human motion is a materially harder job than
66
+ * running slowly, which is the point.
67
+ */
68
+ function hasHumanMovementMarkers(b) {
69
+ const tremor = b.microTremorScore ?? 0.5;
70
+ const corrections = b.overshootCorrections ?? 0;
71
+ const changes = b.directionChanges ?? 0;
72
+ return tremor >= 0.5 && (corrections >= 1 || changes >= 10);
73
+ }
74
+
75
+ /**
76
+ * Whether this visitor is using a touch or pen device, and so should be exempt
77
+ * from the mouse-trajectory detections.
78
+ *
79
+ * The old rule was `touchEvents >= 3`, which a mobile user who simply taps the
80
+ * checkbox does not meet: the client records touchstart and touchmove, and a
81
+ * clean tap on a page short enough not to need scrolling produces exactly one
82
+ * event. The bench human panel captured precisely that — `touchEvents: 1` — and
83
+ * the visitor collected three agent detections for it, including
84
+ * "Zero mouse, touch, or keyboard events recorded" at confidence 0.9.
85
+ *
86
+ * One touch event is enough to establish modality. Corroborating it with the
87
+ * pointer type keeps a bare forged count from claiming the exemption on its
88
+ * own — though note this is a soft check either way, since every input here is
89
+ * client-supplied and an agent willing to claim `touchEvents: 1` was equally
90
+ * willing to claim 3.
91
+ */
92
+ function isTouchModality(b) {
93
+ const touchEvents = b.touchEvents ?? 0;
94
+ const touchPoints = b.touchTotalPoints ?? 0;
95
+ return touchEvents >= 3 || ((touchEvents >= 1 || touchPoints >= 1) && b.pointerHasNonMouseType === true);
96
+ }
97
+
98
+ function getNestedValue(obj, ...keys) {
99
+ return keys.reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, obj);
100
+ }
101
+
102
+ // serverContext holds request facts the server establishes itself, kept apart
103
+ // from the client-supplied signal tree it sits beside.
104
+ const SERVER_CONTEXT_KEY = 'serverContext';
105
+
106
+ /**
107
+ * Record whether this request came from a rendered widget (a real click
108
+ * target) or from invisible scoring.
109
+ *
110
+ * approachPoints, approachDirectness, clickPrecision, explorationRatio and
111
+ * overshootCorrections are all produced by the client's analyzeClick(), which
112
+ * only runs when there is a widget to click. Invisible scoring never calls it,
113
+ * so those fields are absent and read back as 0 — indistinguishable from "the
114
+ * pointer teleported onto the target". Production logs showed the approach
115
+ * check firing on 100% of invisible calls for exactly that reason.
116
+ *
117
+ * Set from the endpoint, never from the signals: a client that could claim
118
+ * "invisible" could switch these checks off on the widget path.
119
+ */
120
+ function setInteractionMode(signals, widget) {
121
+ if (!signals || typeof signals !== 'object') return signals;
122
+ signals[SERVER_CONTEXT_KEY] = {
123
+ ...(signals[SERVER_CONTEXT_KEY] || {}),
124
+ widgetInteraction: !!widget
125
+ };
126
+ return signals;
127
+ }
128
+
129
+ /**
130
+ * Whether click-derived behavioural fields carry meaning for this request.
131
+ * Absent context means widget mode — the long-standing behaviour.
132
+ */
133
+ function hasWidgetInteraction(signals) {
134
+ const ctx = signals && signals[SERVER_CONTEXT_KEY];
135
+ if (!ctx || typeof ctx.widgetInteraction !== 'boolean') return true;
136
+ return ctx.widgetInteraction;
137
+ }
138
+
139
+ function detectVisionAI(signals) {
140
+ const detections = [];
141
+ const b = signals.behavioral || {};
142
+ const t = signals.temporal || {};
143
+
144
+ // Zero/minimal mouse movement - strong indicator of AI agent or programmatic click
145
+ // Exempt: touch users (mobile) and keyboard-only users (accessibility)
146
+ const totalPoints = b.totalPoints ?? 0;
147
+ const trajectory = b.trajectoryLength ?? 0;
148
+ const approachPts = b.approachPoints ?? 0;
149
+ const touchEvents = b.touchEvents ?? 0;
150
+ const keyEvents = b.keyEvents ?? 0;
151
+ const isTouchUser = isTouchModality(b);
152
+ const isKeyboardUser = keyEvents >= 2 && totalPoints === 0;
153
+ // Click-derived fields only exist when a widget was there to click.
154
+ const isWidget = hasWidgetInteraction(signals);
155
+
156
+ if (totalPoints < 5 && trajectory < 10 && !isTouchUser && !isKeyboardUser) {
157
+ detections.push({
158
+ category: 'vision_ai', score: 0.9, confidence: 0.85,
159
+ reason: 'No mouse movement detected before click (AI agent pattern)'
160
+ });
161
+ }
162
+
163
+ // Invisible scoring has no target to approach, so a missing approach path
164
+ // says nothing there (see setInteractionMode).
165
+ if (isWidget && approachPts === 0 && !isTouchUser && !isKeyboardUser) {
166
+ detections.push({
167
+ category: 'vision_ai', score: 0.7, confidence: 0.8,
168
+ reason: 'No approach trajectory to target'
169
+ });
170
+ }
171
+
172
+ // PoW timing
173
+ const pow = t.pow || {};
174
+ if (pow.duration && pow.iterations) {
175
+ const expectedMin = (pow.iterations / 500000) * 1000;
176
+ const expectedMax = (pow.iterations / 50000) * 1000;
177
+
178
+ if (pow.duration < expectedMin * 0.5) {
179
+ detections.push({
180
+ category: 'vision_ai', score: 0.8, confidence: 0.7,
181
+ reason: 'PoW completed impossibly fast'
182
+ });
183
+ } else if (pow.duration > expectedMax * 3) {
184
+ detections.push({
185
+ category: 'vision_ai', score: 0.6, confidence: 0.5,
186
+ reason: 'PoW timing suggests external processing'
187
+ });
188
+ }
189
+ }
190
+
191
+ // Micro-tremor
192
+ // Micro-tremor.
193
+ //
194
+ // Two things were wrong here. Go defaulted a missing microTremorScore to 0
195
+ // (maximally suspicious) while Node and Python defaulted to 0.5, so a client
196
+ // that omitted the field was flagged by one server and not the others. And
197
+ // like the approach-directness check above, this fires on a measurement that
198
+ // does not exist for someone who never moved a mouse — the client itself
199
+ // reports 0.5 as its "no mouse data" sentinel.
200
+ //
201
+ // Require real mouse movement before judging its texture, and apply the same
202
+ // exemptions as the surrounding checks.
203
+ const microTremor = b.microTremorScore ?? 0.5;
204
+ const hasMouseMovement = totalPoints >= 5;
205
+ if (hasMouseMovement && !isTouchUser && !isKeyboardUser && microTremor < 0.15) {
206
+ detections.push({
207
+ category: 'vision_ai', score: 0.7, confidence: 0.6,
208
+ reason: 'Mouse movement lacks natural micro-tremor'
209
+ });
210
+ }
211
+
212
+ // Approach directness.
213
+ //
214
+ // The client reports directness 1 (perfectly straight) when there is no
215
+ // approach path to measure at all, so this check used to fire on every
216
+ // keyboard-only, screen-reader and touch user — the populations the
217
+ // surrounding checks go out of their way to exempt. Found by the bench
218
+ // human panel: keyboard-only, screen-reader and touch all reported
219
+ // approachPoints 0 with approachDirectness 1.
220
+ //
221
+ // Require an actual path before judging its shape, and apply the same
222
+ // exemptions as its neighbours.
223
+ const hasApproachPath = approachPts >= 5;
224
+ if (hasApproachPath && !isTouchUser && !isKeyboardUser && (b.approachDirectness ?? 0) > 0.95) {
225
+ detections.push({
226
+ category: 'vision_ai', score: 0.5, confidence: 0.5,
227
+ reason: 'Mouse path to target is unnaturally direct'
228
+ });
229
+ }
230
+
231
+ // Click precision
232
+ const precision = b.clickPrecision ?? 10;
233
+ if (precision > 0 && precision < 2) {
234
+ detections.push({
235
+ category: 'vision_ai', score: 0.4, confidence: 0.5,
236
+ reason: 'Click precision is unnaturally accurate'
237
+ });
238
+ }
239
+
240
+ // Exploration. Click-derived, so widget-only.
241
+ const exploration = b.explorationRatio ?? 0.3;
242
+ if (isWidget && exploration < 0.05 && trajectory > 50) {
243
+ detections.push({
244
+ category: 'vision_ai', score: 0.4, confidence: 0.4,
245
+ reason: 'No exploratory mouse movement before click'
246
+ });
247
+ }
248
+
249
+ // Input-event forensics: teleport clicks and agent think-time cadence.
250
+ const fcs = b.inputForensics;
251
+ if (fcs) {
252
+ const teleports = fcs.teleportClicks ?? 0;
253
+ if (teleports >= 1 && !isTouchUser) {
254
+ detections.push({
255
+ category: 'vision_ai', score: 0.7, confidence: 0.7,
256
+ reason: `Click injected with no pointer trajectory (${teleports} teleport clicks)`
257
+ });
258
+ }
259
+ // Bursts of activity separated by multi-second perfect silence — the agent
260
+ // act -> screenshot -> inference loop. Low confidence (slow humans idle too);
261
+ // requires silence to dominate. Keyboard-only users are exempt.
262
+ if (!isKeyboardUser &&
263
+ (fcs.cadenceEvents ?? 0) >= 12 &&
264
+ (fcs.cadenceSilentGaps ?? 0) >= 3 &&
265
+ (fcs.cadenceGapCV ?? 0) > 2.5 &&
266
+ (fcs.cadenceSilentRatio ?? 0) > 0.6) {
267
+ detections.push({
268
+ category: 'vision_ai', score: 0.6, confidence: 0.5,
269
+ reason: 'Interaction cadence matches agent act/think loop (bursts + dead air)'
270
+ });
271
+ }
272
+ }
273
+
274
+ return detections;
275
+ }
276
+
277
+ function detectHeadless(signals, userAgent) {
278
+ const detections = [];
279
+ const env = signals.environmental || {};
280
+ const headless = env.headlessIndicators || {};
281
+ const automation = env.automationFlags || {};
282
+
283
+ // WebDriver
284
+ if (env.webdriver) {
285
+ detections.push({
286
+ category: 'headless', score: 0.95, confidence: 0.95,
287
+ dispositive: true, // navigator.webdriver === true — see DISPOSITIVE_FLOOR
288
+ reason: 'WebDriver detected'
289
+ });
290
+ }
291
+
292
+ // Automation flags
293
+ if (automation.plugins === 0) {
294
+ detections.push({
295
+ category: 'headless', score: 0.6, confidence: 0.6,
296
+ reason: 'No browser plugins detected'
297
+ });
298
+ }
299
+
300
+ if (automation.languages === false) {
301
+ detections.push({
302
+ category: 'headless', score: 0.5, confidence: 0.5,
303
+ reason: 'No navigator.languages'
304
+ });
305
+ }
306
+
307
+ // Headless indicators
308
+ if (headless.hasOuterDimensions === false) {
309
+ detections.push({
310
+ category: 'headless', score: 0.7, confidence: 0.7,
311
+ reason: 'Window lacks outer dimensions'
312
+ });
313
+ }
314
+
315
+ if (headless.innerEqualsOuter === true) {
316
+ detections.push({
317
+ category: 'headless', score: 0.4, confidence: 0.5,
318
+ reason: 'Viewport equals window size'
319
+ });
320
+ }
321
+
322
+ if (headless.notificationPermission === 'denied') {
323
+ detections.push({
324
+ category: 'headless', score: 0.3, confidence: 0.4,
325
+ reason: 'Notifications pre-denied'
326
+ });
327
+ }
328
+
329
+ // User-Agent patterns
330
+ for (const pattern of AUTOMATION_UA_PATTERNS) {
331
+ if (pattern.test(userAgent)) {
332
+ detections.push({
333
+ category: 'headless', score: 0.9, confidence: 0.9,
334
+ reason: 'Automation pattern in User-Agent'
335
+ });
336
+ break;
337
+ }
338
+ }
339
+
340
+ // WebGL renderer
341
+ const renderer = (getNestedValue(env, 'webglInfo', 'renderer') || '').toLowerCase();
342
+ if (renderer.includes('swiftshader') || renderer.includes('llvmpipe')) {
343
+ detections.push({
344
+ category: 'headless', score: 0.8, confidence: 0.8,
345
+ reason: 'Software WebGL renderer detected'
346
+ });
347
+ }
348
+
349
+ // Playwright-specific detection
350
+ const playwright = env.playwright || {};
351
+ if (playwright.detected) {
352
+ const playwrightSignals = playwright.signals || [];
353
+ const scoreMap = {
354
+ playwright_globals: 0.95,
355
+ webdriver_deleted: 0.8,
356
+ webdriver_configurable: 0.7,
357
+ chrome_runtime_missing: 0.6,
358
+ };
359
+ // Signals a genuine browser also produces, and therefore not evidence of
360
+ // anything. Ignored here as well as in the client, because clients already
361
+ // deployed will keep sending them.
362
+ //
363
+ // Measured in Chrome 150 with navigator.webdriver === false:
364
+ // webdriver_configurable descriptor present and configurable — WebIDL
365
+ // defines the attribute that way, so every browser
366
+ // reports what a patched one reports
367
+ // chrome_runtime_missing window.chrome present, chrome.runtime absent, on
368
+ // any page without a matching externally_connectable
369
+ // extension, i.e. almost every page
370
+ //
371
+ // They fired together on ordinary Chrome, so they cannot corroborate each
372
+ // other either.
373
+ const INERT_SIGNALS = new Set(['webdriver_configurable', 'chrome_runtime_missing']);
374
+
375
+ for (const sig of playwrightSignals) {
376
+ if (INERT_SIGNALS.has(sig)) continue;
377
+ const sigScore = scoreMap[sig] || 0.7;
378
+ detections.push({
379
+ category: 'headless', score: sigScore, confidence: 0.8,
380
+ reason: `Playwright artifact detected: ${sig}`
381
+ });
382
+ }
383
+
384
+ }
385
+
386
+ return detections;
387
+ }
388
+
389
+ // Flags anti-detection patch traces collected by the client. FALSE-POSITIVE-
390
+ // SAFE: a genuine browser never produces these (internal contradictions /
391
+ // native-function tampering, not environment-shape heuristics that would
392
+ // misfire on real Linux/VPN users). Targets stealth agents (e.g. Manus AI)
393
+ // driving real-but-patched Chromium. Only the two FP-safe signals are scored;
394
+ // the client also collects privacy-extension-ambiguous artifacts (patched_*)
395
+ // for observability that are intentionally NOT scored. Keep in sync with Go/Py.
396
+ function detectStealthArtifacts(signals) {
397
+ const detections = [];
398
+ const env = signals.environmental || {};
399
+
400
+ // Function.prototype.toString proxied — the signature move of stealth
401
+ // frameworks (used to make their other native overrides look untouched).
402
+ const artifactSignals = (env.stealthArtifacts && env.stealthArtifacts.signals) || [];
403
+ if (artifactSignals.includes('tostring_proxied')) {
404
+ detections.push({
405
+ category: 'headless', score: 0.9, confidence: 0.85,
406
+ reason: 'Function.prototype.toString is proxied (stealth automation patch)'
407
+ });
408
+ }
409
+
410
+ // Notification.permission === 'denied' while the Permissions API reports
411
+ // 'prompt': a state a real browser cannot reach (classic headless tell).
412
+ if (env.permissionProbe && env.permissionProbe.contradiction === true) {
413
+ detections.push({
414
+ category: 'headless', score: 0.85, confidence: 0.85,
415
+ reason: 'Notification permission contradicts Permissions API (headless/stealth tell)'
416
+ });
417
+ }
418
+
419
+ return detections;
420
+ }
421
+
422
+ function detectAutomation(signals) {
423
+ const detections = [];
424
+ const env = signals.environmental || {};
425
+ const b = signals.behavioral || {};
426
+
427
+ // JS execution timing
428
+ const jsTime = getNestedValue(env, 'jsExecutionTime', 'mathOps') || 0;
429
+ if (jsTime > 0) {
430
+ if (jsTime < 0.1) {
431
+ detections.push({
432
+ category: 'automation', score: 0.4, confidence: 0.3,
433
+ reason: 'JS execution unusually fast'
434
+ });
435
+ } else if (jsTime > 50) {
436
+ detections.push({
437
+ category: 'automation', score: 0.3, confidence: 0.3,
438
+ reason: 'JS execution unusually slow'
439
+ });
440
+ }
441
+ }
442
+
443
+ // RAF consistency
444
+ const raf = env.rafConsistency || {};
445
+ if (raf.frameTimeVariance !== undefined && raf.frameTimeVariance < 0.1) {
446
+ detections.push({
447
+ category: 'automation', score: 0.5, confidence: 0.4,
448
+ reason: 'RequestAnimationFrame timing too consistent'
449
+ });
450
+ }
451
+
452
+ // Event timing
453
+ const eventVar = b.eventDeltaVariance ?? 10;
454
+ const totalPoints = b.totalPoints ?? 0;
455
+ if (eventVar < 2 && totalPoints > 10) {
456
+ detections.push({
457
+ category: 'automation', score: 0.6, confidence: 0.6,
458
+ reason: 'Mouse event timing unnaturally consistent'
459
+ });
460
+ }
461
+
462
+ return detections;
463
+ }
464
+
465
+ function detectCDP(signals) {
466
+ const detections = [];
467
+ const env = signals.environmental || {};
468
+ const cdp = env.cdp || {};
469
+
470
+ // Input-event forensics: catch CDP-injected input that reports isTrusted:true
471
+ // and so evades the global-based checks below. Touch users are exempt.
472
+ const b = signals.behavioral || {};
473
+ const isTouchUser = (b.touchEvents ?? 0) >= 3;
474
+ const fcs = b.inputForensics;
475
+ if (fcs && !isTouchUser) {
476
+ // Real mice coalesce several hardware samples per frame; a stream of
477
+ // pointermoves that NEVER coalesced is synthetic injection.
478
+ if ((fcs.coalescedSamples ?? 0) >= 20 && (fcs.coalescedMax ?? 0) <= 1) {
479
+ detections.push({
480
+ category: 'cdp', score: 0.8, confidence: 0.6,
481
+ reason: 'Pointer moves never coalesced across many samples (synthetic/CDP input)'
482
+ });
483
+ }
484
+ // movementX/Y incoherent with actual position deltas across most moves.
485
+ if ((fcs.pointerMoveSamples ?? 0) >= 20 && (fcs.pointerMoveZeroRatio ?? 0) > 0.9) {
486
+ detections.push({
487
+ category: 'cdp', score: 0.6, confidence: 0.5,
488
+ reason: 'Pointer movement deltas incoherent with position (synthetic input)'
489
+ });
490
+ }
491
+ }
492
+
493
+ // CDP Runtime/DevTools console consumer attached. Low confidence: a developer
494
+ // with DevTools open also trips this, so it contributes rather than blocks.
495
+ if (env.cdpRuntime && env.cdpRuntime.consoleAttached) {
496
+ detections.push({
497
+ category: 'cdp', score: 0.6, confidence: 0.5,
498
+ reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)'
499
+ });
500
+ }
501
+
502
+ if (cdp.detected) {
503
+ const signalList = cdp.signals || [];
504
+ const signalCount = signalList.length;
505
+
506
+ // High-confidence signals
507
+ const highConfSignals = ['chromedriver_cdc', 'puppeteer_eval', 'cdp_script_injection'];
508
+ const hasHighConf = signalList.some(s => highConfSignals.includes(s));
509
+
510
+ if (hasHighConf) {
511
+ detections.push({
512
+ category: 'cdp',
513
+ score: 0.9,
514
+ confidence: 0.95,
515
+ dispositive: true, // driver-injected globals — see DISPOSITIVE_FLOOR
516
+ reason: `CDP automation detected: ${signalList.join(', ')}`
517
+ });
518
+ } else if (signalCount >= 2) {
519
+ detections.push({
520
+ category: 'cdp',
521
+ score: 0.8,
522
+ confidence: 0.85,
523
+ reason: `Multiple CDP indicators: ${signalList.join(', ')}`
524
+ });
525
+ } else if (signalCount === 1) {
526
+ detections.push({
527
+ category: 'cdp',
528
+ score: 0.6,
529
+ confidence: 0.7,
530
+ reason: `CDP indicator: ${signalList.join(', ')}`
531
+ });
532
+ }
533
+ }
534
+
535
+ return detections;
536
+ }
537
+
538
+ function detectBehavioral(signals) {
539
+ const detections = [];
540
+ const b = signals.behavioral || {};
541
+ const t = signals.temporal || {};
542
+
543
+ // Insufficient mouse data - critical check for zero-click bots
544
+ // Exempt: touch users (mobile) and keyboard-only users (accessibility)
545
+ const totalPoints = b.totalPoints ?? 0;
546
+ const trajectory = b.trajectoryLength ?? 0;
547
+ const touchEvts = b.touchEvents ?? 0;
548
+ const keyEvts = b.keyEvents ?? 0;
549
+ const isTouchUsr = isTouchModality(b);
550
+ const isKbdUser = keyEvts >= 2 && totalPoints === 0;
551
+
552
+ if (totalPoints === 0 && !isTouchUsr && !isKbdUser) {
553
+ detections.push({
554
+ category: 'behavioral', score: 0.8, confidence: 0.9,
555
+ reason: 'Zero mouse, touch, or keyboard events recorded'
556
+ });
557
+ } else if (totalPoints < 10 && !isTouchUsr && !isKbdUser && trajectory < 30) {
558
+ detections.push({
559
+ category: 'behavioral', score: 0.6, confidence: 0.7,
560
+ reason: 'Insufficient mouse movement before interaction'
561
+ });
562
+ }
563
+
564
+ // Velocity variance
565
+ const velVar = b.velocityVariance ?? 1;
566
+ if (velVar < 0.02 && trajectory > 50 && !hasHumanMovementMarkers(b)) {
567
+ detections.push({
568
+ category: 'behavioral', score: 0.6, confidence: 0.6,
569
+ reason: 'Mouse velocity too consistent'
570
+ });
571
+ }
572
+
573
+ const isWidgetInt = hasWidgetInteraction(signals);
574
+
575
+ // Overshoot. Click-derived, so widget-only.
576
+ const overshoots = b.overshootCorrections ?? 0;
577
+ if (isWidgetInt && overshoots === 0 && trajectory > 200) {
578
+ detections.push({
579
+ category: 'behavioral', score: 0.4, confidence: 0.4,
580
+ reason: 'No overshoot corrections on long trajectory'
581
+ });
582
+ }
583
+
584
+ // Interaction speed. Widget mode measures time spent solving; invisible mode
585
+ // reuses the same field for time on page, where a minute is just a reader.
586
+ const interactionTime = b.interactionDuration ?? 1000;
587
+ if (interactionTime > 0 && interactionTime < 200) {
588
+ detections.push({
589
+ category: 'behavioral', score: 0.7, confidence: 0.7,
590
+ reason: 'Interaction completed too quickly'
591
+ });
592
+ } else if (isWidgetInt && interactionTime > 60000) {
593
+ detections.push({
594
+ category: 'captcha_farm', score: 0.3, confidence: 0.3,
595
+ reason: 'Unusually long interaction time'
596
+ });
597
+ }
598
+
599
+ // First interaction
600
+ const firstInt = t.pageLoadToFirstInteraction;
601
+ if (firstInt !== null && firstInt > 0 && firstInt < 100) {
602
+ detections.push({
603
+ category: 'behavioral', score: 0.5, confidence: 0.5,
604
+ reason: 'First interaction too soon after page load'
605
+ });
606
+ }
607
+
608
+ // Mouse event rate
609
+ const eventRate = b.mouseEventRate ?? 60;
610
+ if (eventRate > 200) {
611
+ detections.push({
612
+ category: 'behavioral', score: 0.6, confidence: 0.5,
613
+ reason: 'Mouse event rate abnormally high'
614
+ });
615
+ } else if (eventRate > 0 && eventRate < 10 && !hasHumanMovementMarkers(b)) {
616
+ detections.push({
617
+ category: 'behavioral', score: 0.4, confidence: 0.4,
618
+ reason: 'Mouse event rate abnormally low'
619
+ });
620
+ }
621
+
622
+ // Straight line ratio
623
+ const straight = b.straightLineRatio ?? 0;
624
+ if (straight > 0.8 && trajectory > 100) {
625
+ detections.push({
626
+ category: 'behavioral', score: 0.5, confidence: 0.5,
627
+ reason: 'Mouse movements too straight'
628
+ });
629
+ }
630
+
631
+ // Direction changes
632
+ const dirChanges = b.directionChanges ?? 10;
633
+ if (totalPoints > 50 && dirChanges < 3) {
634
+ detections.push({
635
+ category: 'behavioral', score: 0.4, confidence: 0.4,
636
+ reason: 'Too few direction changes'
637
+ });
638
+ }
639
+
640
+ return detections;
641
+ }
642
+
643
+ // =============================================================================
644
+ // Mobile-native detectors (touch authenticity, sensor entropy, touch kinematics)
645
+ // UA-gated on mobile. Non-mobile UAs: no-op. Designed to never penalize iOS
646
+ // Safari without permission (absence of motion events treated as neutral).
647
+ // =============================================================================
648
+
649
+ function _isMobileUA(userAgent) {
650
+ const ua = (userAgent || '').toLowerCase();
651
+ return /mobile|android|iphone|ipad|ipod/.test(ua);
652
+ }
653
+
654
+ function detectTouchAuthenticity(signals, userAgent) {
655
+ const detections = [];
656
+ if (!_isMobileUA(userAgent)) return detections;
657
+
658
+ const b = signals.behavioral || {};
659
+ const touchPoints = b.touchTotalPoints ?? b.touchEvents ?? 0;
660
+ if (touchPoints < 3) return detections;
661
+
662
+ const forceVariance = b.touchForceVariance ?? 0;
663
+ const radiusVariance = b.touchRadiusVariance ?? 0;
664
+ const forceAllOne = b.touchForceAllOne === true;
665
+ const uniqueIds = b.touchUniqueIdentifiers ?? 0;
666
+ const forceMax = b.touchForceMax ?? 0;
667
+ const radiusMax = b.touchRadiusMax ?? 0;
668
+
669
+ // Uniform non-zero force across all events → synthetic injection.
670
+ // Older Android returning all-zero is legitimate — only penalize uniformity
671
+ // when max > 0.
672
+ if (forceVariance === 0 && forceMax > 0 && touchPoints >= 5) {
673
+ detections.push({
674
+ category: 'behavioral', score: 0.75, confidence: 0.85,
675
+ reason: 'Touch force is identical across all events (synthetic touch)'
676
+ });
677
+ }
678
+
679
+ // All force=1 exactly is a common synthetic default in automation frameworks.
680
+ if (forceAllOne && touchPoints >= 5) {
681
+ detections.push({
682
+ category: 'behavioral', score: 0.8, confidence: 0.9,
683
+ reason: 'All touches report force=1.0 exactly (synthetic pattern)'
684
+ });
685
+ }
686
+
687
+ // Uniform contact radius across many events is unusual on real phones.
688
+ if (radiusVariance === 0 && radiusMax > 0 && touchPoints >= 5) {
689
+ detections.push({
690
+ category: 'behavioral', score: 0.7, confidence: 0.8,
691
+ reason: 'Touch contact radius identical across all events'
692
+ });
693
+ }
694
+
695
+ // Mobile UA with real touches but zero unique identifiers — framework default.
696
+ if (touchPoints >= 5 && uniqueIds === 0) {
697
+ detections.push({
698
+ category: 'behavioral', score: 0.6, confidence: 0.7,
699
+ reason: 'Mobile touches lack identifier tracking (synthetic injection)'
700
+ });
701
+ }
702
+
703
+ return detections;
704
+ }
705
+
706
+ function detectSensorEntropy(signals, userAgent) {
707
+ const detections = [];
708
+ if (!_isMobileUA(userAgent)) return detections;
709
+
710
+ const env = signals.environmental || {};
711
+ const sensor = env.sensor || {};
712
+ const motionCount = sensor.motionEventCount ?? 0;
713
+ const motionVariance = sensor.motionAccelVariance ?? 0;
714
+ const orientationCount = sensor.orientationEventCount ?? 0;
715
+ const orientationVariance = sensor.orientationVariance ?? 0;
716
+
717
+ // Sensor events fired but completely flat → emulator / headless mobile.
718
+ if (motionCount >= 10 && motionVariance < 0.01) {
719
+ detections.push({
720
+ category: 'headless', score: 0.7, confidence: 0.8,
721
+ reason: `Motion sensor active but flat (variance=${motionVariance.toFixed(4)}) — likely emulator`
722
+ });
723
+ }
724
+
725
+ if (orientationCount >= 10 && orientationVariance < 0.01) {
726
+ detections.push({
727
+ category: 'headless', score: 0.6, confidence: 0.7,
728
+ reason: 'Orientation sensor active but completely flat — likely emulator'
729
+ });
730
+ }
731
+
732
+ // motionCount == 0 is NEUTRAL (iOS w/o permission is the common case).
733
+
734
+ return detections;
735
+ }
736
+
737
+ function detectTouchKinematics(signals) {
738
+ const detections = [];
739
+ const b = signals.behavioral || {};
740
+ const touchPoints = b.touchTotalPoints ?? 0;
741
+ if (touchPoints < 10) return detections;
742
+
743
+ const straightLine = b.touchStraightLineRatio ?? 0;
744
+ const tremor = b.touchMicroTremorScore ?? 0;
745
+ const dirChanges = b.touchDirectionChanges ?? 0;
746
+
747
+ if (straightLine > 0.85 && touchPoints >= 20) {
748
+ detections.push({
749
+ category: 'behavioral', score: 0.65, confidence: 0.75,
750
+ reason: `Touch path too straight (ratio=${straightLine.toFixed(2)}) — automation pattern`
751
+ });
752
+ }
753
+
754
+ if (tremor < 0.05 && touchPoints >= 30) {
755
+ detections.push({
756
+ category: 'behavioral', score: 0.55, confidence: 0.65,
757
+ reason: 'Touch path has no micro-tremor (unnaturally smooth)'
758
+ });
759
+ }
760
+
761
+ if (dirChanges === 0 && touchPoints >= 30) {
762
+ detections.push({
763
+ category: 'behavioral', score: 0.5, confidence: 0.6,
764
+ reason: 'Touch path has zero direction changes over long trajectory'
765
+ });
766
+ }
767
+
768
+ return detections;
769
+ }
770
+
771
+ // =============================================================================
772
+ // Scoring
773
+ // =============================================================================
774
+
775
+ /**
776
+ * Combines the detections within each category into a category score.
777
+ *
778
+ * ## Why this is not a mean
779
+ *
780
+ * It used to be a confidence-weighted mean, which had a property nobody
781
+ * intended: corroborating evidence *lowered* the verdict. A visitor whose
782
+ * browser reported `navigator.webdriver === true` and nothing else scored 0.95
783
+ * in the headless category. The same visitor, additionally caught with no
784
+ * plugins, a software renderer, a viewport equal to its window and three more
785
+ * automation tells, scored 0.686 — because each additional signal, being
786
+ * individually weaker than the first, pulled the average down. Seven pieces of
787
+ * corroboration made the case weaker than one.
788
+ *
789
+ * Noisy-OR fixes that. Each detection is treated as independent evidence of
790
+ * strength `score × confidence`, and the category is the probability that at
791
+ * least one of them is right:
792
+ *
793
+ * category = 1 - ∏(1 - scoreᵢ × confidenceᵢ)
794
+ *
795
+ * Evidence now accumulates: adding a signal can only raise a category, never
796
+ * lower it. And it is *more* forgiving of isolated weak evidence than the mean
797
+ * was — one detection at score 0.4, confidence 0.5 contributes 0.20 rather than
798
+ * setting the whole category to 0.40 — which is the right treatment for a
799
+ * lone low-confidence hit on a real user.
800
+ *
801
+ * Measured on the bench corpus (bench/tools/compare-aggregation.js): human
802
+ * median 0.182 → 0.097 and human max 0.260 → 0.171, while agent median rose
803
+ * 0.517 → 0.570. Both populations moved in the direction they should.
804
+ */
805
+ // weights is a parameter because ScoringEngine accepts `options.weights`. It
806
+ // only decides which categories get filled in at zero; the per-category
807
+ // aggregation below is weight-independent.
808
+ function calculateCategoryScores(detections, weights = WEIGHTS) {
809
+ const categoryData = {};
810
+
811
+ for (const d of detections) {
812
+ if (!categoryData[d.category]) {
813
+ categoryData[d.category] = [];
814
+ }
815
+ categoryData[d.category].push([d.score, d.confidence]);
816
+ }
817
+
818
+ const result = {};
819
+ for (const [cat, scores] of Object.entries(categoryData)) {
820
+ if (scores.length > 0) {
821
+ let survives = 1;
822
+ for (const [score, conf] of scores) {
823
+ const strength = Math.max(0, Math.min(1, score * conf));
824
+ survives *= 1 - strength;
825
+ }
826
+ result[cat] = Math.min(1.0, 1 - survives);
827
+ }
828
+ }
829
+
830
+ // Fill missing
831
+ for (const cat of Object.keys(weights)) {
832
+ if (!(cat in result)) {
833
+ result[cat] = 0.0;
834
+ }
835
+ }
836
+
837
+ return result;
838
+ }
839
+
840
+ function calculateFinalScore(categoryScores, weights = WEIGHTS) {
841
+ let total = 0;
842
+ for (const [cat, weight] of Object.entries(weights)) {
843
+ total += (categoryScores[cat] || 0) * weight;
844
+ }
845
+ return Math.min(1.0, total);
846
+ }
847
+
848
+ /**
849
+ * Score below which a self-declared automated browser cannot fall.
850
+ *
851
+ * ## Why a floor exists at all
852
+ *
853
+ * The final score is a weighted sum across all eleven categories, so a category
854
+ * can contribute at most its own weight no matter how certain it is. A local
855
+ * automated browser trips at most the six categories reachable without a
856
+ * datacenter IP, a reused fingerprint or a rate-limit hit — about 0.81 of the
857
+ * weight — and in practice lands near 0.5. The bench measured exactly that: a
858
+ * Playwright browser reporting `navigator.webdriver === true`, no plugins, a
859
+ * software renderer and four more automation tells scored 0.549, i.e.
860
+ * "challenge", not "block".
861
+ *
862
+ * That is the weighted sum working as designed. It expresses "what fraction of
863
+ * the total suspicion budget did this visitor consume", and no single fact can
864
+ * consume most of that budget. The trouble is that some facts are not
865
+ * probabilistic evidence at all — they are the browser saying so.
866
+ *
867
+ * ## What qualifies
868
+ *
869
+ * Only detections marked `dispositive`, and the bar for that mark is that a
870
+ * browser cannot produce the signal without being automated:
871
+ *
872
+ * - `navigator.webdriver === true` — a W3C-specified flag whose sole purpose
873
+ * is to tell the page it is under automation.
874
+ * - ChromeDriver / Puppeteer injected globals (`chromedriver_cdc`,
875
+ * `puppeteer_eval`, `cdp_script_injection`), which exist in no ordinary
876
+ * browsing session.
877
+ *
878
+ * Deliberately excluded, though both look tempting: the "console consumer
879
+ * attached" CDP check, because the bench human panel proves it fires on a
880
+ * developer with DevTools open; and the Playwright `webdriver_configurable`
881
+ * artifact, which relies on a property-descriptor detail no specification
882
+ * guarantees.
883
+ *
884
+ * ## What this does not do
885
+ *
886
+ * It does not catch a stealth agent, which patches `navigator.webdriver` before
887
+ * the page ever sees it. That is not a regression — such an agent scores the
888
+ * same as it did before — and it is the reason the behavioural workstreams
889
+ * still matter. The floor closes the case where an agent is not even trying to
890
+ * hide, which was previously being waved through with a "challenge".
891
+ */
892
+ const DISPOSITIVE_FLOOR = 0.9;
893
+
894
+ function applyDispositiveFloor(score, detections) {
895
+ const selfDeclared = detections.some((d) => d.dispositive === true);
896
+ return selfDeclared ? Math.max(score, DISPOSITIVE_FLOOR) : score;
897
+ }
898
+
899
+ /**
900
+ * Behavioural corroboration floor.
901
+ *
902
+ * A source-patched browser scrubs every JS-observable automation flag at the binary
903
+ * level, so it trips no headless, cdp, fingerprint, datacenter or bot category.
904
+ * Those weights total 0.59, and the weighted sum silently keeps all of it — leaving
905
+ * such a browser a ceiling of 0.41 against a 0.5 threshold. It cannot be blocked
906
+ * however obviously robotic its movement is.
907
+ *
908
+ * Measured, not argued: the corpus sample of that adversary trips seven correct
909
+ * behavioural detections and scores 0.234. Every one fires; the aggregation
910
+ * discards the verdict. Same structural flaw that let a bare curl through before
911
+ * v1.23.0, landing this time on the behavioural detection that is the point of the
912
+ * product. It treats absence of environmental evidence as evidence of absence — but
913
+ * on this adversary a clean environment is the attack working, not innocence.
914
+ *
915
+ * The rule: when two or more behavioural categories independently reach 0.5, floor
916
+ * the score at 0.6.
917
+ *
918
+ * Constants swept over a 40-point grid against the labelled corpus
919
+ * (bench/tools/sweep-corroboration.js), not reasoned about. No human in the
920
+ * 126-sample panel reaches two agreeing behavioural categories at any threshold
921
+ * tested, while 66 of 75 agents do at 0.5. Requiring three fails outright — all
922
+ * sixteen such combinations leave the adversary allowed. The floor value does not
923
+ * affect separation, so 0.6 is a policy choice: the block boundary, kept distinct
924
+ * from the 0.9 reserved for a browser that declares its own automation.
925
+ *
926
+ * Caveat: the adversary is one synthetic, hand-authored sample. This shows the
927
+ * arithmetic works on the shape the corpus describes, not that it works on a real
928
+ * source-patched browser.
929
+ */
930
+ const CORROBORATION_AGREE_AT = 0.5;
931
+ const CORROBORATION_MIN_AGREE = 2;
932
+ const CORROBORATION_FLOOR = 0.6;
933
+
934
+ // Categories a browser trips by how it moves rather than by what it is. A
935
+ // patched binary can hide what it is; it cannot hide that nothing is moving the
936
+ // pointer like a hand.
937
+ const BEHAVIOURAL_CATEGORIES = ['vision_ai', 'behavioral', 'automation', 'cdp'];
938
+
939
+ function applyCorroborationFloor(score, categoryScores) {
940
+ const agreeing = BEHAVIOURAL_CATEGORIES
941
+ .filter((c) => (categoryScores[c] || 0) >= CORROBORATION_AGREE_AT).length;
942
+ return agreeing >= CORROBORATION_MIN_AGREE ? Math.max(score, CORROBORATION_FLOOR) : score;
943
+ }
944
+
945
+ module.exports = {
946
+ // Constants
947
+ AUTOMATION_UA_PATTERNS,
948
+ WEIGHTS,
949
+ DISPOSITIVE_FLOOR,
950
+ CORROBORATION_AGREE_AT,
951
+ CORROBORATION_MIN_AGREE,
952
+ CORROBORATION_FLOOR,
953
+ BEHAVIOURAL_CATEGORIES,
954
+
955
+ // Helpers
956
+ hasHumanMovementMarkers,
957
+ isTouchModality,
958
+ getNestedValue,
959
+
960
+ // Interaction mode (set from the endpoint, never from client signals)
961
+ setInteractionMode,
962
+ hasWidgetInteraction,
963
+
964
+ // Pure detectors
965
+ detectVisionAI,
966
+ detectHeadless,
967
+ detectStealthArtifacts,
968
+ detectAutomation,
969
+ detectCDP,
970
+ detectBehavioral,
971
+ detectTouchAuthenticity,
972
+ detectSensorEntropy,
973
+ detectTouchKinematics,
974
+
975
+ // Aggregation
976
+ calculateCategoryScores,
977
+ calculateFinalScore,
978
+ applyDispositiveFloor,
979
+ applyCorroborationFloor,
980
+ };