@webdecoy/fcaptcha 1.30.0 → 1.32.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/server.js CHANGED
@@ -10,13 +10,12 @@ const crypto = require('crypto');
10
10
  const path = require('path');
11
11
  const detection = require('./detection');
12
12
  const webbotauth = require('./webbotauth');
13
- const { ProxyTrust } = require('./clientip');
13
+ const { ProxyTrust, networkIdentity } = require('./clientip');
14
14
  const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
15
15
  const { signingSecretFromEnv } = require('./config');
16
16
  const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
17
17
  const { detectInputForensics } = require('./inputforensics');
18
18
  const {
19
- ERROR_CODES,
20
19
  HostnameAllowlist,
21
20
  IdempotencyStore,
22
21
  requestHostname,
@@ -182,7 +181,7 @@ const powChallengeStore = {
182
181
  },
183
182
 
184
183
  // Verify a PoW solution (signalsHash is optional for backward compat)
185
- verify(challengeId, nonce, hash, siteKey, signalsHash = null) {
184
+ verify(challengeId, nonce, hash, siteKey, ip, signalsHash = null) {
186
185
  const challenge = this.challenges.get(challengeId);
187
186
 
188
187
  if (!challenge) {
@@ -198,6 +197,10 @@ const powChallengeStore = {
198
197
  return { valid: false, reason: 'site_key_mismatch' };
199
198
  }
200
199
 
200
+ if (networkIdentity(challenge.ip) !== networkIdentity(ip)) {
201
+ return { valid: false, reason: 'challenge_network_mismatch' };
202
+ }
203
+
201
204
  // Check if solution was already used (prevent replay)
202
205
  const solutionKey = `${challengeId}:${nonce}`;
203
206
  if (this.usedSolutions.has(solutionKey)) {
@@ -326,714 +329,28 @@ const tokenStore = {
326
329
  }
327
330
  };
328
331
 
329
- // =============================================================================
330
- // Detection Patterns
331
- // =============================================================================
332
-
333
- const AUTOMATION_UA_PATTERNS = [
334
- /headless/i, /phantomjs/i, /selenium/i, /webdriver/i,
335
- /puppeteer/i, /playwright/i, /cypress/i, /nightwatch/i,
336
- /zombie/i, /electron/i, /chromium.*headless/i
337
- ];
338
-
339
- const WEIGHTS = {
340
- vision_ai: 0.15,
341
- headless: 0.15,
342
- automation: 0.08,
343
- cdp: 0.12,
344
- behavioral: 0.18,
345
- fingerprint: 0.08,
346
- rate_limit: 0.01,
347
- datacenter: 0.07,
348
- tor_vpn: 0.01,
349
- bot: 0.13,
350
- declared_ai: 0.02
351
- };
352
-
353
- // =============================================================================
354
- // Detection Functions
355
- // =============================================================================
356
-
357
- /**
358
- * Whether this visitor is using a touch or pen device, and so should be exempt
359
- * from the mouse-trajectory detections.
360
- *
361
- * The old rule was `touchEvents >= 3`, which a mobile user who simply taps the
362
- * checkbox does not meet: the client records touchstart and touchmove, and a
363
- * clean tap on a page short enough not to need scrolling produces exactly one
364
- * event. The bench human panel captured precisely that — `touchEvents: 1` — and
365
- * the visitor collected three agent detections for it, including
366
- * "Zero mouse, touch, or keyboard events recorded" at confidence 0.9.
367
- *
368
- * One touch event is enough to establish modality. Corroborating it with the
369
- * pointer type keeps a bare forged count from claiming the exemption on its
370
- * own — though note this is a soft check either way, since every input here is
371
- * client-supplied and an agent willing to claim `touchEvents: 1` was equally
372
- * willing to claim 3.
373
- */
374
- /**
375
- * Movement that carries independent evidence of a human hand.
376
- *
377
- * A slow pointer is the thing two of these checks look for, and it is also
378
- * exactly what an elderly or motor-impaired visitor produces. The bench human
379
- * panel caught both firing on them: "Mouse event rate abnormally low" on the
380
- * elderly and motor-slow personas, "Mouse velocity too consistent" on
381
- * motor-slow.
382
- *
383
- * Slowness alone cannot separate those users from an agent, but it does not
384
- * have to. The same captures carry markers no low-effort automation produces:
385
- * saturated micro-tremor, dozens of direction changes, corrective overshoots.
386
- * On the bench corpus the split is total - every human persona clears this bar
387
- * (tremor 1.00, 22-49 direction changes, 1-4 corrections) and every agent
388
- * misses it (tremor 0.04-0.16, 0-1 direction changes, 0 corrections).
389
- *
390
- * So: do not read slowness as automation when the movement independently looks
391
- * like a hand. An agent can of course fake all three, but faking three
392
- * correlated properties of human motion is a materially harder job than
393
- * running slowly, which is the point.
394
- */
395
- function hasHumanMovementMarkers(b) {
396
- const tremor = b.microTremorScore ?? 0.5;
397
- const corrections = b.overshootCorrections ?? 0;
398
- const changes = b.directionChanges ?? 0;
399
- return tremor >= 0.5 && (corrections >= 1 || changes >= 10);
400
- }
401
-
402
- function isTouchModality(b) {
403
- const touchEvents = b.touchEvents ?? 0;
404
- const touchPoints = b.touchTotalPoints ?? 0;
405
- return touchEvents >= 3 || ((touchEvents >= 1 || touchPoints >= 1) && b.pointerHasNonMouseType === true);
406
- }
407
-
408
- function getNestedValue(obj, ...keys) {
409
- return keys.reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, obj);
410
- }
411
-
412
- function detectVisionAI(signals) {
413
- const detections = [];
414
- const b = signals.behavioral || {};
415
- const t = signals.temporal || {};
416
-
417
- // Zero/minimal mouse movement - strong indicator of AI agent or programmatic click
418
- // Exempt: touch users (mobile) and keyboard-only users (accessibility)
419
- const totalPoints = b.totalPoints ?? 0;
420
- const trajectory = b.trajectoryLength ?? 0;
421
- const approachPts = b.approachPoints ?? 0;
422
- const touchEvents = b.touchEvents ?? 0;
423
- const keyEvents = b.keyEvents ?? 0;
424
- const isTouchUser = isTouchModality(b);
425
- const isKeyboardUser = keyEvents >= 2 && totalPoints === 0;
426
-
427
- if (totalPoints < 5 && trajectory < 10 && !isTouchUser && !isKeyboardUser) {
428
- detections.push({
429
- category: 'vision_ai', score: 0.9, confidence: 0.85,
430
- reason: 'No mouse movement detected before click (AI agent pattern)'
431
- });
432
- }
433
-
434
- if (approachPts === 0 && !isTouchUser && !isKeyboardUser) {
435
- detections.push({
436
- category: 'vision_ai', score: 0.7, confidence: 0.8,
437
- reason: 'No approach trajectory to target'
438
- });
439
- }
440
-
441
- // PoW timing
442
- const pow = t.pow || {};
443
- if (pow.duration && pow.iterations) {
444
- const expectedMin = (pow.iterations / 500000) * 1000;
445
- const expectedMax = (pow.iterations / 50000) * 1000;
446
-
447
- if (pow.duration < expectedMin * 0.5) {
448
- detections.push({
449
- category: 'vision_ai', score: 0.8, confidence: 0.7,
450
- reason: 'PoW completed impossibly fast'
451
- });
452
- } else if (pow.duration > expectedMax * 3) {
453
- detections.push({
454
- category: 'vision_ai', score: 0.6, confidence: 0.5,
455
- reason: 'PoW timing suggests external processing'
456
- });
457
- }
458
- }
459
-
460
- // Micro-tremor
461
- // Micro-tremor.
462
- //
463
- // Two things were wrong here. Go defaulted a missing microTremorScore to 0
464
- // (maximally suspicious) while Node and Python defaulted to 0.5, so a client
465
- // that omitted the field was flagged by one server and not the others. And
466
- // like the approach-directness check above, this fires on a measurement that
467
- // does not exist for someone who never moved a mouse — the client itself
468
- // reports 0.5 as its "no mouse data" sentinel.
469
- //
470
- // Require real mouse movement before judging its texture, and apply the same
471
- // exemptions as the surrounding checks.
472
- const microTremor = b.microTremorScore ?? 0.5;
473
- const hasMouseMovement = totalPoints >= 5;
474
- if (hasMouseMovement && !isTouchUser && !isKeyboardUser && microTremor < 0.15) {
475
- detections.push({
476
- category: 'vision_ai', score: 0.7, confidence: 0.6,
477
- reason: 'Mouse movement lacks natural micro-tremor'
478
- });
479
- }
480
-
481
- // Approach directness.
482
- //
483
- // The client reports directness 1 (perfectly straight) when there is no
484
- // approach path to measure at all, so this check used to fire on every
485
- // keyboard-only, screen-reader and touch user — the populations the
486
- // surrounding checks go out of their way to exempt. Found by the bench
487
- // human panel: keyboard-only, screen-reader and touch all reported
488
- // approachPoints 0 with approachDirectness 1.
489
- //
490
- // Require an actual path before judging its shape, and apply the same
491
- // exemptions as its neighbours.
492
- const hasApproachPath = approachPts >= 5;
493
- if (hasApproachPath && !isTouchUser && !isKeyboardUser && (b.approachDirectness ?? 0) > 0.95) {
494
- detections.push({
495
- category: 'vision_ai', score: 0.5, confidence: 0.5,
496
- reason: 'Mouse path to target is unnaturally direct'
497
- });
498
- }
499
-
500
- // Click precision
501
- const precision = b.clickPrecision ?? 10;
502
- if (precision > 0 && precision < 2) {
503
- detections.push({
504
- category: 'vision_ai', score: 0.4, confidence: 0.5,
505
- reason: 'Click precision is unnaturally accurate'
506
- });
507
- }
508
-
509
- // Exploration
510
- const exploration = b.explorationRatio ?? 0.3;
511
- if (exploration < 0.05 && trajectory > 50) {
512
- detections.push({
513
- category: 'vision_ai', score: 0.4, confidence: 0.4,
514
- reason: 'No exploratory mouse movement before click'
515
- });
516
- }
517
-
518
- // Input-event forensics: teleport clicks and agent think-time cadence.
519
- const fcs = b.inputForensics;
520
- if (fcs) {
521
- const teleports = fcs.teleportClicks ?? 0;
522
- if (teleports >= 1 && !isTouchUser) {
523
- detections.push({
524
- category: 'vision_ai', score: 0.7, confidence: 0.7,
525
- reason: `Click injected with no pointer trajectory (${teleports} teleport clicks)`
526
- });
527
- }
528
- // Bursts of activity separated by multi-second perfect silence — the agent
529
- // act -> screenshot -> inference loop. Low confidence (slow humans idle too);
530
- // requires silence to dominate. Keyboard-only users are exempt.
531
- if (!isKeyboardUser &&
532
- (fcs.cadenceEvents ?? 0) >= 12 &&
533
- (fcs.cadenceSilentGaps ?? 0) >= 3 &&
534
- (fcs.cadenceGapCV ?? 0) > 2.5 &&
535
- (fcs.cadenceSilentRatio ?? 0) > 0.6) {
536
- detections.push({
537
- category: 'vision_ai', score: 0.6, confidence: 0.5,
538
- reason: 'Interaction cadence matches agent act/think loop (bursts + dead air)'
539
- });
540
- }
541
- }
542
-
543
- return detections;
544
- }
545
-
546
- function detectHeadless(signals, userAgent) {
547
- const detections = [];
548
- const env = signals.environmental || {};
549
- const headless = env.headlessIndicators || {};
550
- const automation = env.automationFlags || {};
551
-
552
- // WebDriver
553
- if (env.webdriver) {
554
- detections.push({
555
- category: 'headless', score: 0.95, confidence: 0.95,
556
- dispositive: true, // navigator.webdriver === true — see DISPOSITIVE_FLOOR
557
- reason: 'WebDriver detected'
558
- });
559
- }
560
-
561
- // Automation flags
562
- if (automation.plugins === 0) {
563
- detections.push({
564
- category: 'headless', score: 0.6, confidence: 0.6,
565
- reason: 'No browser plugins detected'
566
- });
567
- }
568
-
569
- if (automation.languages === false) {
570
- detections.push({
571
- category: 'headless', score: 0.5, confidence: 0.5,
572
- reason: 'No navigator.languages'
573
- });
574
- }
575
-
576
- // Headless indicators
577
- if (headless.hasOuterDimensions === false) {
578
- detections.push({
579
- category: 'headless', score: 0.7, confidence: 0.7,
580
- reason: 'Window lacks outer dimensions'
581
- });
582
- }
583
-
584
- if (headless.innerEqualsOuter === true) {
585
- detections.push({
586
- category: 'headless', score: 0.4, confidence: 0.5,
587
- reason: 'Viewport equals window size'
588
- });
589
- }
590
-
591
- if (headless.notificationPermission === 'denied') {
592
- detections.push({
593
- category: 'headless', score: 0.3, confidence: 0.4,
594
- reason: 'Notifications pre-denied'
595
- });
596
- }
597
-
598
- // User-Agent patterns
599
- for (const pattern of AUTOMATION_UA_PATTERNS) {
600
- if (pattern.test(userAgent)) {
601
- detections.push({
602
- category: 'headless', score: 0.9, confidence: 0.9,
603
- reason: 'Automation pattern in User-Agent'
604
- });
605
- break;
606
- }
607
- }
608
-
609
- // WebGL renderer
610
- const renderer = (getNestedValue(env, 'webglInfo', 'renderer') || '').toLowerCase();
611
- if (renderer.includes('swiftshader') || renderer.includes('llvmpipe')) {
612
- detections.push({
613
- category: 'headless', score: 0.8, confidence: 0.8,
614
- reason: 'Software WebGL renderer detected'
615
- });
616
- }
617
-
618
- // Playwright-specific detection
619
- const playwright = env.playwright || {};
620
- if (playwright.detected) {
621
- const playwrightSignals = playwright.signals || [];
622
- const scoreMap = {
623
- playwright_globals: 0.95,
624
- webdriver_deleted: 0.8,
625
- webdriver_configurable: 0.7,
626
- chrome_runtime_missing: 0.6,
627
- };
628
- // Signals a genuine browser also produces, and therefore not evidence of
629
- // anything. Ignored here as well as in the client, because clients already
630
- // deployed will keep sending them.
631
- //
632
- // Measured in Chrome 150 with navigator.webdriver === false:
633
- // webdriver_configurable descriptor present and configurable — WebIDL
634
- // defines the attribute that way, so every browser
635
- // reports what a patched one reports
636
- // chrome_runtime_missing window.chrome present, chrome.runtime absent, on
637
- // any page without a matching externally_connectable
638
- // extension, i.e. almost every page
639
- //
640
- // They fired together on ordinary Chrome, so they cannot corroborate each
641
- // other either.
642
- const INERT_SIGNALS = new Set(['webdriver_configurable', 'chrome_runtime_missing']);
643
-
644
- for (const sig of playwrightSignals) {
645
- if (INERT_SIGNALS.has(sig)) continue;
646
- const sigScore = scoreMap[sig] || 0.7;
647
- detections.push({
648
- category: 'headless', score: sigScore, confidence: 0.8,
649
- reason: `Playwright artifact detected: ${sig}`
650
- });
651
- }
652
-
653
- }
654
-
655
- return detections;
656
- }
657
-
658
- // Flags anti-detection patch traces collected by the client. FALSE-POSITIVE-
659
- // SAFE: a genuine browser never produces these (internal contradictions /
660
- // native-function tampering, not environment-shape heuristics that would
661
- // misfire on real Linux/VPN users). Targets stealth agents (e.g. Manus AI)
662
- // driving real-but-patched Chromium. Only the two FP-safe signals are scored;
663
- // the client also collects privacy-extension-ambiguous artifacts (patched_*)
664
- // for observability that are intentionally NOT scored. Keep in sync with Go/Py.
665
- function detectStealthArtifacts(signals) {
666
- const detections = [];
667
- const env = signals.environmental || {};
668
-
669
- // Function.prototype.toString proxied — the signature move of stealth
670
- // frameworks (used to make their other native overrides look untouched).
671
- const artifactSignals = (env.stealthArtifacts && env.stealthArtifacts.signals) || [];
672
- if (artifactSignals.includes('tostring_proxied')) {
673
- detections.push({
674
- category: 'headless', score: 0.9, confidence: 0.85,
675
- reason: 'Function.prototype.toString is proxied (stealth automation patch)'
676
- });
677
- }
678
-
679
- // Notification.permission === 'denied' while the Permissions API reports
680
- // 'prompt': a state a real browser cannot reach (classic headless tell).
681
- if (env.permissionProbe && env.permissionProbe.contradiction === true) {
682
- detections.push({
683
- category: 'headless', score: 0.85, confidence: 0.85,
684
- reason: 'Notification permission contradicts Permissions API (headless/stealth tell)'
685
- });
686
- }
687
-
688
- return detections;
689
- }
690
-
691
- function detectAutomation(signals) {
692
- const detections = [];
693
- const env = signals.environmental || {};
694
- const b = signals.behavioral || {};
695
-
696
- // JS execution timing
697
- const jsTime = getNestedValue(env, 'jsExecutionTime', 'mathOps') || 0;
698
- if (jsTime > 0) {
699
- if (jsTime < 0.1) {
700
- detections.push({
701
- category: 'automation', score: 0.4, confidence: 0.3,
702
- reason: 'JS execution unusually fast'
703
- });
704
- } else if (jsTime > 50) {
705
- detections.push({
706
- category: 'automation', score: 0.3, confidence: 0.3,
707
- reason: 'JS execution unusually slow'
708
- });
709
- }
710
- }
711
-
712
- // RAF consistency
713
- const raf = env.rafConsistency || {};
714
- if (raf.frameTimeVariance !== undefined && raf.frameTimeVariance < 0.1) {
715
- detections.push({
716
- category: 'automation', score: 0.5, confidence: 0.4,
717
- reason: 'RequestAnimationFrame timing too consistent'
718
- });
719
- }
720
-
721
- // Event timing
722
- const eventVar = b.eventDeltaVariance ?? 10;
723
- const totalPoints = b.totalPoints ?? 0;
724
- if (eventVar < 2 && totalPoints > 10) {
725
- detections.push({
726
- category: 'automation', score: 0.6, confidence: 0.6,
727
- reason: 'Mouse event timing unnaturally consistent'
728
- });
729
- }
730
-
731
- return detections;
732
- }
733
-
734
- function detectCDP(signals) {
735
- const detections = [];
736
- const env = signals.environmental || {};
737
- const cdp = env.cdp || {};
738
-
739
- // Input-event forensics: catch CDP-injected input that reports isTrusted:true
740
- // and so evades the global-based checks below. Touch users are exempt.
741
- const b = signals.behavioral || {};
742
- const isTouchUser = (b.touchEvents ?? 0) >= 3;
743
- const fcs = b.inputForensics;
744
- if (fcs && !isTouchUser) {
745
- // Real mice coalesce several hardware samples per frame; a stream of
746
- // pointermoves that NEVER coalesced is synthetic injection.
747
- if ((fcs.coalescedSamples ?? 0) >= 20 && (fcs.coalescedMax ?? 0) <= 1) {
748
- detections.push({
749
- category: 'cdp', score: 0.8, confidence: 0.6,
750
- reason: 'Pointer moves never coalesced across many samples (synthetic/CDP input)'
751
- });
752
- }
753
- // movementX/Y incoherent with actual position deltas across most moves.
754
- if ((fcs.pointerMoveSamples ?? 0) >= 20 && (fcs.pointerMoveZeroRatio ?? 0) > 0.9) {
755
- detections.push({
756
- category: 'cdp', score: 0.6, confidence: 0.5,
757
- reason: 'Pointer movement deltas incoherent with position (synthetic input)'
758
- });
759
- }
760
- }
761
-
762
- // CDP Runtime/DevTools console consumer attached. Low confidence: a developer
763
- // with DevTools open also trips this, so it contributes rather than blocks.
764
- if (env.cdpRuntime && env.cdpRuntime.consoleAttached) {
765
- detections.push({
766
- category: 'cdp', score: 0.6, confidence: 0.5,
767
- reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)'
768
- });
769
- }
770
-
771
- if (cdp.detected) {
772
- const signalList = cdp.signals || [];
773
- const signalCount = signalList.length;
774
-
775
- // High-confidence signals
776
- const highConfSignals = ['chromedriver_cdc', 'puppeteer_eval', 'cdp_script_injection'];
777
- const hasHighConf = signalList.some(s => highConfSignals.includes(s));
778
-
779
- if (hasHighConf) {
780
- detections.push({
781
- category: 'cdp',
782
- score: 0.9,
783
- confidence: 0.95,
784
- dispositive: true, // driver-injected globals — see DISPOSITIVE_FLOOR
785
- reason: `CDP automation detected: ${signalList.join(', ')}`
786
- });
787
- } else if (signalCount >= 2) {
788
- detections.push({
789
- category: 'cdp',
790
- score: 0.8,
791
- confidence: 0.85,
792
- reason: `Multiple CDP indicators: ${signalList.join(', ')}`
793
- });
794
- } else if (signalCount === 1) {
795
- detections.push({
796
- category: 'cdp',
797
- score: 0.6,
798
- confidence: 0.7,
799
- reason: `CDP indicator: ${signalList.join(', ')}`
800
- });
801
- }
802
- }
803
-
804
- return detections;
805
- }
806
-
807
- function detectBehavioral(signals) {
808
- const detections = [];
809
- const b = signals.behavioral || {};
810
- const t = signals.temporal || {};
811
-
812
- // Insufficient mouse data - critical check for zero-click bots
813
- // Exempt: touch users (mobile) and keyboard-only users (accessibility)
814
- const totalPoints = b.totalPoints ?? 0;
815
- const trajectory = b.trajectoryLength ?? 0;
816
- const touchEvts = b.touchEvents ?? 0;
817
- const keyEvts = b.keyEvents ?? 0;
818
- const isTouchUsr = isTouchModality(b);
819
- const isKbdUser = keyEvts >= 2 && totalPoints === 0;
820
-
821
- if (totalPoints === 0 && !isTouchUsr && !isKbdUser) {
822
- detections.push({
823
- category: 'behavioral', score: 0.8, confidence: 0.9,
824
- reason: 'Zero mouse, touch, or keyboard events recorded'
825
- });
826
- } else if (totalPoints < 10 && !isTouchUsr && !isKbdUser && trajectory < 30) {
827
- detections.push({
828
- category: 'behavioral', score: 0.6, confidence: 0.7,
829
- reason: 'Insufficient mouse movement before interaction'
830
- });
831
- }
832
-
833
- // Velocity variance
834
- const velVar = b.velocityVariance ?? 1;
835
- if (velVar < 0.02 && trajectory > 50 && !hasHumanMovementMarkers(b)) {
836
- detections.push({
837
- category: 'behavioral', score: 0.6, confidence: 0.6,
838
- reason: 'Mouse velocity too consistent'
839
- });
840
- }
841
-
842
- // Overshoot
843
- const overshoots = b.overshootCorrections ?? 0;
844
- if (overshoots === 0 && trajectory > 200) {
845
- detections.push({
846
- category: 'behavioral', score: 0.4, confidence: 0.4,
847
- reason: 'No overshoot corrections on long trajectory'
848
- });
849
- }
850
-
851
- // Interaction speed
852
- const interactionTime = b.interactionDuration ?? 1000;
853
- if (interactionTime > 0 && interactionTime < 200) {
854
- detections.push({
855
- category: 'behavioral', score: 0.7, confidence: 0.7,
856
- reason: 'Interaction completed too quickly'
857
- });
858
- } else if (interactionTime > 60000) {
859
- detections.push({
860
- category: 'captcha_farm', score: 0.3, confidence: 0.3,
861
- reason: 'Unusually long interaction time'
862
- });
863
- }
864
-
865
- // First interaction
866
- const firstInt = t.pageLoadToFirstInteraction;
867
- if (firstInt !== null && firstInt > 0 && firstInt < 100) {
868
- detections.push({
869
- category: 'behavioral', score: 0.5, confidence: 0.5,
870
- reason: 'First interaction too soon after page load'
871
- });
872
- }
873
-
874
- // Mouse event rate
875
- const eventRate = b.mouseEventRate ?? 60;
876
- if (eventRate > 200) {
877
- detections.push({
878
- category: 'behavioral', score: 0.6, confidence: 0.5,
879
- reason: 'Mouse event rate abnormally high'
880
- });
881
- } else if (eventRate > 0 && eventRate < 10 && !hasHumanMovementMarkers(b)) {
882
- detections.push({
883
- category: 'behavioral', score: 0.4, confidence: 0.4,
884
- reason: 'Mouse event rate abnormally low'
885
- });
886
- }
887
-
888
- // Straight line ratio
889
- const straight = b.straightLineRatio ?? 0;
890
- if (straight > 0.8 && trajectory > 100) {
891
- detections.push({
892
- category: 'behavioral', score: 0.5, confidence: 0.5,
893
- reason: 'Mouse movements too straight'
894
- });
895
- }
896
-
897
- // Direction changes
898
- const dirChanges = b.directionChanges ?? 10;
899
- if (totalPoints > 50 && dirChanges < 3) {
900
- detections.push({
901
- category: 'behavioral', score: 0.4, confidence: 0.4,
902
- reason: 'Too few direction changes'
903
- });
904
- }
905
-
906
- return detections;
907
- }
908
-
909
- // =============================================================================
910
- // Mobile-native detectors (touch authenticity, sensor entropy, touch kinematics)
911
- // UA-gated on mobile. Non-mobile UAs: no-op. Designed to never penalize iOS
912
- // Safari without permission (absence of motion events treated as neutral).
913
- // =============================================================================
914
-
915
- function _isMobileUA(userAgent) {
916
- const ua = (userAgent || '').toLowerCase();
917
- return /mobile|android|iphone|ipad|ipod/.test(ua);
918
- }
919
-
920
- function detectTouchAuthenticity(signals, userAgent) {
921
- const detections = [];
922
- if (!_isMobileUA(userAgent)) return detections;
923
-
924
- const b = signals.behavioral || {};
925
- const touchPoints = b.touchTotalPoints ?? b.touchEvents ?? 0;
926
- if (touchPoints < 3) return detections;
927
-
928
- const forceVariance = b.touchForceVariance ?? 0;
929
- const radiusVariance = b.touchRadiusVariance ?? 0;
930
- const forceAllOne = b.touchForceAllOne === true;
931
- const uniqueIds = b.touchUniqueIdentifiers ?? 0;
932
- const forceMax = b.touchForceMax ?? 0;
933
- const radiusMax = b.touchRadiusMax ?? 0;
934
-
935
- // Uniform non-zero force across all events → synthetic injection.
936
- // Older Android returning all-zero is legitimate — only penalize uniformity
937
- // when max > 0.
938
- if (forceVariance === 0 && forceMax > 0 && touchPoints >= 5) {
939
- detections.push({
940
- category: 'behavioral', score: 0.75, confidence: 0.85,
941
- reason: 'Touch force is identical across all events (synthetic touch)'
942
- });
943
- }
944
-
945
- // All force=1 exactly is a common synthetic default in automation frameworks.
946
- if (forceAllOne && touchPoints >= 5) {
947
- detections.push({
948
- category: 'behavioral', score: 0.8, confidence: 0.9,
949
- reason: 'All touches report force=1.0 exactly (synthetic pattern)'
950
- });
951
- }
952
-
953
- // Uniform contact radius across many events is unusual on real phones.
954
- if (radiusVariance === 0 && radiusMax > 0 && touchPoints >= 5) {
955
- detections.push({
956
- category: 'behavioral', score: 0.7, confidence: 0.8,
957
- reason: 'Touch contact radius identical across all events'
958
- });
959
- }
960
-
961
- // Mobile UA with real touches but zero unique identifiers — framework default.
962
- if (touchPoints >= 5 && uniqueIds === 0) {
963
- detections.push({
964
- category: 'behavioral', score: 0.6, confidence: 0.7,
965
- reason: 'Mobile touches lack identifier tracking (synthetic injection)'
966
- });
967
- }
968
-
969
- return detections;
970
- }
971
-
972
- function detectSensorEntropy(signals, userAgent) {
973
- const detections = [];
974
- if (!_isMobileUA(userAgent)) return detections;
975
-
976
- const env = signals.environmental || {};
977
- const sensor = env.sensor || {};
978
- const motionCount = sensor.motionEventCount ?? 0;
979
- const motionVariance = sensor.motionAccelVariance ?? 0;
980
- const orientationCount = sensor.orientationEventCount ?? 0;
981
- const orientationVariance = sensor.orientationVariance ?? 0;
982
-
983
- // Sensor events fired but completely flat → emulator / headless mobile.
984
- if (motionCount >= 10 && motionVariance < 0.01) {
985
- detections.push({
986
- category: 'headless', score: 0.7, confidence: 0.8,
987
- reason: `Motion sensor active but flat (variance=${motionVariance.toFixed(4)}) — likely emulator`
988
- });
989
- }
990
-
991
- if (orientationCount >= 10 && orientationVariance < 0.01) {
992
- detections.push({
993
- category: 'headless', score: 0.6, confidence: 0.7,
994
- reason: 'Orientation sensor active but completely flat — likely emulator'
995
- });
996
- }
997
-
998
- // motionCount == 0 is NEUTRAL (iOS w/o permission is the common case).
999
-
1000
- return detections;
1001
- }
1002
-
1003
- function detectTouchKinematics(signals) {
1004
- const detections = [];
1005
- const b = signals.behavioral || {};
1006
- const touchPoints = b.touchTotalPoints ?? 0;
1007
- if (touchPoints < 10) return detections;
1008
-
1009
- const straightLine = b.touchStraightLineRatio ?? 0;
1010
- const tremor = b.touchMicroTremorScore ?? 0;
1011
- const dirChanges = b.touchDirectionChanges ?? 0;
1012
-
1013
- if (straightLine > 0.85 && touchPoints >= 20) {
1014
- detections.push({
1015
- category: 'behavioral', score: 0.65, confidence: 0.75,
1016
- reason: `Touch path too straight (ratio=${straightLine.toFixed(2)}) — automation pattern`
1017
- });
1018
- }
1019
-
1020
- if (tremor < 0.05 && touchPoints >= 30) {
1021
- detections.push({
1022
- category: 'behavioral', score: 0.55, confidence: 0.65,
1023
- reason: 'Touch path has no micro-tremor (unnaturally smooth)'
1024
- });
1025
- }
1026
-
1027
- if (dirChanges === 0 && touchPoints >= 30) {
1028
- detections.push({
1029
- category: 'behavioral', score: 0.5, confidence: 0.6,
1030
- reason: 'Touch path has zero direction changes over long trajectory'
1031
- });
1032
- }
1033
-
1034
- return detections;
1035
- }
1036
-
332
+ // Detection patterns, the pure detectors and the scoring aggregation now
333
+ // live in engine.js, shared with the npm library entry point (index.js).
334
+ // The two used to be separate implementations and drifted; see engine.js.
335
+ const {
336
+ getNestedValue,
337
+ setInteractionMode,
338
+ detectVisionAI,
339
+ detectHeadless,
340
+ detectStealthArtifacts,
341
+ detectAutomation,
342
+ detectCDP,
343
+ detectBehavioral,
344
+ detectTouchAuthenticity,
345
+ detectSensorEntropy,
346
+ detectTouchKinematics,
347
+ calculateCategoryScores,
348
+ calculateFinalScore,
349
+ applyDispositiveFloor,
350
+ applyCorroborationFloor,
351
+ } = require('./engine');
352
+
353
+ // Stateful detectors stay here: they read the module-level stores below.
1037
354
  function detectFingerprint(signals, ip, siteKey) {
1038
355
  const detections = [];
1039
356
  const env = signals.environmental || {};
@@ -1100,176 +417,6 @@ function detectRateAbuse(ip, siteKey) {
1100
417
  return detections;
1101
418
  }
1102
419
 
1103
- // =============================================================================
1104
- // Scoring
1105
- // =============================================================================
1106
-
1107
- /**
1108
- * Combines the detections within each category into a category score.
1109
- *
1110
- * ## Why this is not a mean
1111
- *
1112
- * It used to be a confidence-weighted mean, which had a property nobody
1113
- * intended: corroborating evidence *lowered* the verdict. A visitor whose
1114
- * browser reported `navigator.webdriver === true` and nothing else scored 0.95
1115
- * in the headless category. The same visitor, additionally caught with no
1116
- * plugins, a software renderer, a viewport equal to its window and three more
1117
- * automation tells, scored 0.686 — because each additional signal, being
1118
- * individually weaker than the first, pulled the average down. Seven pieces of
1119
- * corroboration made the case weaker than one.
1120
- *
1121
- * Noisy-OR fixes that. Each detection is treated as independent evidence of
1122
- * strength `score × confidence`, and the category is the probability that at
1123
- * least one of them is right:
1124
- *
1125
- * category = 1 - ∏(1 - scoreᵢ × confidenceᵢ)
1126
- *
1127
- * Evidence now accumulates: adding a signal can only raise a category, never
1128
- * lower it. And it is *more* forgiving of isolated weak evidence than the mean
1129
- * was — one detection at score 0.4, confidence 0.5 contributes 0.20 rather than
1130
- * setting the whole category to 0.40 — which is the right treatment for a
1131
- * lone low-confidence hit on a real user.
1132
- *
1133
- * Measured on the bench corpus (bench/tools/compare-aggregation.js): human
1134
- * median 0.182 → 0.097 and human max 0.260 → 0.171, while agent median rose
1135
- * 0.517 → 0.570. Both populations moved in the direction they should.
1136
- */
1137
- function calculateCategoryScores(detections) {
1138
- const categoryData = {};
1139
-
1140
- for (const d of detections) {
1141
- if (!categoryData[d.category]) {
1142
- categoryData[d.category] = [];
1143
- }
1144
- categoryData[d.category].push([d.score, d.confidence]);
1145
- }
1146
-
1147
- const result = {};
1148
- for (const [cat, scores] of Object.entries(categoryData)) {
1149
- if (scores.length > 0) {
1150
- let survives = 1;
1151
- for (const [score, conf] of scores) {
1152
- const strength = Math.max(0, Math.min(1, score * conf));
1153
- survives *= 1 - strength;
1154
- }
1155
- result[cat] = Math.min(1.0, 1 - survives);
1156
- }
1157
- }
1158
-
1159
- // Fill missing
1160
- for (const cat of Object.keys(WEIGHTS)) {
1161
- if (!(cat in result)) {
1162
- result[cat] = 0.0;
1163
- }
1164
- }
1165
-
1166
- return result;
1167
- }
1168
-
1169
- function calculateFinalScore(categoryScores) {
1170
- let total = 0;
1171
- for (const [cat, weight] of Object.entries(WEIGHTS)) {
1172
- total += (categoryScores[cat] || 0) * weight;
1173
- }
1174
- return Math.min(1.0, total);
1175
- }
1176
-
1177
- /**
1178
- * Score below which a self-declared automated browser cannot fall.
1179
- *
1180
- * ## Why a floor exists at all
1181
- *
1182
- * The final score is a weighted sum across all eleven categories, so a category
1183
- * can contribute at most its own weight no matter how certain it is. A local
1184
- * automated browser trips at most the six categories reachable without a
1185
- * datacenter IP, a reused fingerprint or a rate-limit hit — about 0.81 of the
1186
- * weight — and in practice lands near 0.5. The bench measured exactly that: a
1187
- * Playwright browser reporting `navigator.webdriver === true`, no plugins, a
1188
- * software renderer and four more automation tells scored 0.549, i.e.
1189
- * "challenge", not "block".
1190
- *
1191
- * That is the weighted sum working as designed. It expresses "what fraction of
1192
- * the total suspicion budget did this visitor consume", and no single fact can
1193
- * consume most of that budget. The trouble is that some facts are not
1194
- * probabilistic evidence at all — they are the browser saying so.
1195
- *
1196
- * ## What qualifies
1197
- *
1198
- * Only detections marked `dispositive`, and the bar for that mark is that a
1199
- * browser cannot produce the signal without being automated:
1200
- *
1201
- * - `navigator.webdriver === true` — a W3C-specified flag whose sole purpose
1202
- * is to tell the page it is under automation.
1203
- * - ChromeDriver / Puppeteer injected globals (`chromedriver_cdc`,
1204
- * `puppeteer_eval`, `cdp_script_injection`), which exist in no ordinary
1205
- * browsing session.
1206
- *
1207
- * Deliberately excluded, though both look tempting: the "console consumer
1208
- * attached" CDP check, because the bench human panel proves it fires on a
1209
- * developer with DevTools open; and the Playwright `webdriver_configurable`
1210
- * artifact, which relies on a property-descriptor detail no specification
1211
- * guarantees.
1212
- *
1213
- * ## What this does not do
1214
- *
1215
- * It does not catch a stealth agent, which patches `navigator.webdriver` before
1216
- * the page ever sees it. That is not a regression — such an agent scores the
1217
- * same as it did before — and it is the reason the behavioural workstreams
1218
- * still matter. The floor closes the case where an agent is not even trying to
1219
- * hide, which was previously being waved through with a "challenge".
1220
- */
1221
- const DISPOSITIVE_FLOOR = 0.9;
1222
-
1223
- function applyDispositiveFloor(score, detections) {
1224
- const selfDeclared = detections.some((d) => d.dispositive === true);
1225
- return selfDeclared ? Math.max(score, DISPOSITIVE_FLOOR) : score;
1226
- }
1227
-
1228
- /**
1229
- * Behavioural corroboration floor.
1230
- *
1231
- * A source-patched browser scrubs every JS-observable automation flag at the binary
1232
- * level, so it trips no headless, cdp, fingerprint, datacenter or bot category.
1233
- * Those weights total 0.59, and the weighted sum silently keeps all of it — leaving
1234
- * such a browser a ceiling of 0.41 against a 0.5 threshold. It cannot be blocked
1235
- * however obviously robotic its movement is.
1236
- *
1237
- * Measured, not argued: the corpus sample of that adversary trips seven correct
1238
- * behavioural detections and scores 0.234. Every one fires; the aggregation
1239
- * discards the verdict. Same structural flaw that let a bare curl through before
1240
- * v1.23.0, landing this time on the behavioural detection that is the point of the
1241
- * product. It treats absence of environmental evidence as evidence of absence — but
1242
- * on this adversary a clean environment is the attack working, not innocence.
1243
- *
1244
- * The rule: when two or more behavioural categories independently reach 0.5, floor
1245
- * the score at 0.6.
1246
- *
1247
- * Constants swept over a 40-point grid against the labelled corpus
1248
- * (bench/tools/sweep-corroboration.js), not reasoned about. No human in the
1249
- * 126-sample panel reaches two agreeing behavioural categories at any threshold
1250
- * tested, while 66 of 75 agents do at 0.5. Requiring three fails outright — all
1251
- * sixteen such combinations leave the adversary allowed. The floor value does not
1252
- * affect separation, so 0.6 is a policy choice: the block boundary, kept distinct
1253
- * from the 0.9 reserved for a browser that declares its own automation.
1254
- *
1255
- * Caveat: the adversary is one synthetic, hand-authored sample. This shows the
1256
- * arithmetic works on the shape the corpus describes, not that it works on a real
1257
- * source-patched browser.
1258
- */
1259
- const CORROBORATION_AGREE_AT = 0.5;
1260
- const CORROBORATION_MIN_AGREE = 2;
1261
- const CORROBORATION_FLOOR = 0.6;
1262
-
1263
- // Categories a browser trips by how it moves rather than by what it is. A
1264
- // patched binary can hide what it is; it cannot hide that nothing is moving the
1265
- // pointer like a hand.
1266
- const BEHAVIOURAL_CATEGORIES = ['vision_ai', 'behavioral', 'automation', 'cdp'];
1267
-
1268
- function applyCorroborationFloor(score, categoryScores) {
1269
- const agreeing = BEHAVIOURAL_CATEGORIES
1270
- .filter((c) => (categoryScores[c] || 0) >= CORROBORATION_AGREE_AT).length;
1271
- return agreeing >= CORROBORATION_MIN_AGREE ? Math.max(score, CORROBORATION_FLOOR) : score;
1272
- }
1273
420
 
1274
421
  // Bind the token to the page that minted it and to the action it was minted
1275
422
  // for. All three ride inside the signed payload, so a token issued for
@@ -1398,6 +545,11 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1398
545
  signals.temporal.pow = powTiming;
1399
546
  }
1400
547
 
548
+ // Stamp the interaction mode after signalsJson has been resolved above, so
549
+ // it lands on the object the detectors actually read. Defaults to widget
550
+ // mode for callers that do not set it (see setInteractionMode).
551
+ setInteractionMode(signals, opts.widgetInteraction !== false);
552
+
1401
553
  // Verify PoW if provided
1402
554
  let powValid = false;
1403
555
  // powSatisfied records whether the caller actually completed the challenge
@@ -1420,6 +572,7 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1420
572
  powSolution.nonce,
1421
573
  powSolution.hash,
1422
574
  siteKey,
575
+ ip,
1423
576
  clientSignalsHash
1424
577
  );
1425
578
  powValid = powVerification.valid;
@@ -1665,7 +818,7 @@ app.post('/api/verify', async (req, res) => {
1665
818
  // Web Bot Auth verification needs the raw request; its verdict is scored.
1666
819
  const webBotAuth = await verifyWebBotAuth(req);
1667
820
 
1668
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata });
821
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: true });
1669
822
  logVerdict('verify', siteKey, result);
1670
823
  res.json(result);
1671
824
  });
@@ -1686,7 +839,8 @@ app.post('/api/score', async (req, res) => {
1686
839
  // Web Bot Auth verification needs the raw request; its verdict is scored.
1687
840
  const webBotAuth = await verifyWebBotAuth(req);
1688
841
 
1689
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata });
842
+ // Invisible scoring: no widget, so no click analysis in the signals.
843
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: false });
1690
844
  logVerdict('score', siteKey, result);
1691
845
  res.json({
1692
846
  success: result.success,