@webdecoy/fcaptcha 1.45.0 → 1.46.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/detection.js CHANGED
@@ -1031,18 +1031,6 @@ function analyzeFonts(fontsInfo, userAgent) {
1031
1031
  });
1032
1032
  }
1033
1033
 
1034
- // Mac UA but no SF Pro (modern macOS)
1035
- if ((ua.includes('mac os x') || ua.includes('macintosh')) && fontsInfo.hasSFPro === false &&
1036
- ua.includes('10_15') === false && ua.includes('10_14') === false && fontsInfo.count > 5) {
1037
- // SF Pro is on macOS 10.15+ (Catalina and later)
1038
- detections.push({
1039
- category: 'bot',
1040
- score: 0.3,
1041
- confidence: 0.4,
1042
- reason: 'Modern macOS UA but SF Pro font not detected'
1043
- });
1044
- }
1045
-
1046
1034
  // Linux UA but no DejaVu Sans (very common on Linux)
1047
1035
  if (ua.includes('linux') && !ua.includes('android') &&
1048
1036
  fontsInfo.hasDejaVuSans === false && fontsInfo.count > 5) {
package/engine.js CHANGED
@@ -585,17 +585,9 @@ function detectCDP(signals) {
585
585
  }
586
586
  }
587
587
 
588
- // CDP Runtime/DevTools console consumer attached. Low confidence: a developer
589
- // with DevTools open also trips this, so it contributes rather than blocks.
590
- if (env.cdpRuntime && env.cdpRuntime.consoleAttached) {
591
- detections.push({
592
- category: 'cdp', score: 0.6, confidence: 0.5,
593
- reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)',
594
- // A developer with DevTools open produces exactly this signal, so it
595
- // cannot be one of the independent views the corroboration floor counts.
596
- nonCorroborating: true
597
- });
598
- }
588
+ // cdpRuntime.consoleAttached is collected but not scored: measured on real
589
+ // visitors it fires in every Chrome and Safari session (no DevTools, no
590
+ // extensions), so it cannot tell a person from an agent.
599
591
 
600
592
  if (cdp.detected) {
601
593
  const signalList = cdp.signals || [];
package/experimental.js CHANGED
@@ -8,6 +8,27 @@ const { calculateCategoryScores, BEHAVIOURAL_CATEGORIES, CORROBORATION_AGREE_AT,
8
8
  // change. An old selector must never silently opt an operator into a new policy.
9
9
  const EXPERIMENTAL_POLICY = 'stealth-corroboration-v1';
10
10
  const ANIMATION_POLICY = 'animation-consistency-v1';
11
+ const POINTER_POLICY = 'pointer-movement-v1';
12
+ const POINTER_MIN_SAMPLES = 30;
13
+ const POINTER_ZERO_RATIO_BELOW = 0.05;
14
+
15
+ // Moves where the pointer position changed but movementX/Y read 0. Measured on
16
+ // real mice and trackpads in Chrome, Safari and Firefox, at 1x and 2x pixel
17
+ // ratio: 14-38% of moves. Stealth CDP-driven Chrome: 0-3%. Observe only until
18
+ // production traffic shows the rate on hardware the bench never saw.
19
+ function pointerObservation(signals) {
20
+ const b = signals.behavioral || {};
21
+ const f = b.inputForensics || {};
22
+ const samples = f.pointerMoveSamples;
23
+ const ratio = f.pointerMoveZeroRatio;
24
+ const known = (b.touchEvents ?? 0) < 3 && Number.isFinite(samples) && samples >= POINTER_MIN_SAMPLES &&
25
+ Number.isFinite(ratio) && ratio >= 0 && ratio <= 1;
26
+ const status = !known ? 'unknown' : ratio < POINTER_ZERO_RATIO_BELOW ? 'detected' : 'clear';
27
+ return { mode: 'observe', status, detections: status === 'detected' ? [{
28
+ id: 'pointer-movement-never-zero',
29
+ reason: 'Pointer moves always report movement; experimental observation, not proof of automation',
30
+ }] : [] };
31
+ }
11
32
 
12
33
  function animationObservation(signals) {
13
34
  const probe = signals.environmental?.animationConsistency;
@@ -55,8 +76,9 @@ function evaluateExperimental(signals, productionScore, detections, blocking = f
55
76
  observations: {
56
77
  [ANIMATION_POLICY]: animationObservation(signals),
57
78
  [IDENTITY_POLICY]: identityObservation(signals),
79
+ [POINTER_POLICY]: pointerObservation(signals),
58
80
  },
59
81
  };
60
82
  }
61
83
 
62
- module.exports = { evaluateExperimental, EXPERIMENTAL_POLICY };
84
+ module.exports = { evaluateExperimental, pointerObservation, EXPERIMENTAL_POLICY, POINTER_POLICY };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.45.0",
3
+ "version": "1.46.0",
4
4
  "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
5
5
  "main": "index.js",
6
6
  "exports": {
package/server.js CHANGED
@@ -28,7 +28,8 @@ const {
28
28
  sanitizeAction,
29
29
  sanitizeCdata,
30
30
  secretMatches,
31
- siteverifyAsync
31
+ siteverifyAsync,
32
+ asRecaptchaScore
32
33
  } = require('./siteverify');
33
34
 
34
35
  const app = express();
@@ -960,17 +961,16 @@ app.post('/api/token/verify', asyncRoute(async (req, res) => {
960
961
  // integration at this server should be a base-URL change and nothing else.
961
962
  // See siteverify.js for the adapter itself.
962
963
  async function siteverifyHandler(req, res) {
963
- res.json(
964
- await siteverifyAsync({
965
- body: req.body,
966
- // Bind to this server's token store, so replay state is shared with the
967
- // native endpoint rather than kept in a parallel universe.
968
- verifyToken,
969
- expectedSecret: VERIFY_SECRET,
970
- idempotencyStore: IDEMPOTENCY,
971
- requireSecret: !LEGACY_UNAUTH_VERIFY
972
- })
973
- );
964
+ const out = await siteverifyAsync({
965
+ body: req.body,
966
+ // Bind to this server's token store, so replay state is shared with the
967
+ // native endpoint rather than kept in a parallel universe.
968
+ verifyToken,
969
+ expectedSecret: VERIFY_SECRET,
970
+ idempotencyStore: IDEMPOTENCY,
971
+ requireSecret: !LEGACY_UNAUTH_VERIFY
972
+ });
973
+ res.json(req.path === '/recaptcha/api/siteverify' ? asRecaptchaScore(out) : out);
974
974
  }
975
975
 
976
976
  app.post('/turnstile/v0/siteverify', asyncRoute(siteverifyHandler));
package/siteverify.js CHANGED
@@ -237,6 +237,19 @@ class IdempotencyStore {
237
237
  }
238
238
  }
239
239
 
240
+ /**
241
+ * Restate a response in reCAPTCHA v3's score direction.
242
+ *
243
+ * reCAPTCHA scores run 1.0 = human, 0.0 = bot, and every integration compares
244
+ * `score >= threshold`. FCaptcha's run the other way, so served unchanged on the
245
+ * reCAPTCHA path they would reject people and admit bots. The native score
246
+ * stays available as `fcaptcha_score`.
247
+ */
248
+ function asRecaptchaScore(out) {
249
+ if (!out || typeof out.score !== 'number') return out;
250
+ return { ...out, score: Math.round((1 - out.score) * 10000) / 10000, fcaptcha_score: out.score };
251
+ }
252
+
240
253
  /** A failing siteverify response carrying the given codes. */
241
254
  function failure(...codes) {
242
255
  return { success: false, 'error-codes': codes };
@@ -355,6 +368,7 @@ async function siteverifyAsync({
355
368
 
356
369
  module.exports = {
357
370
  ERROR_CODES,
371
+ asRecaptchaScore,
358
372
  HostnameAllowlist,
359
373
  IdempotencyStore,
360
374
  hostFromUrl,