@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.
- package/clientip.js +14 -1
- package/engine.js +980 -0
- package/index.js +62 -309
- package/package.json +2 -2
- 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
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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,
|