@anonympins/fingerprint 0.3.4 → 0.3.5

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.
@@ -346,6 +346,30 @@ function getTlsFingerprint(context) {
346
346
  }
347
347
  return { ja3, ja4 };
348
348
  }
349
+
350
+ /**
351
+ * Analyses a raw JA3 string.
352
+ * Format: "TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurveFormats"
353
+ * @param {string} ja3String
354
+ * @returns {object|null}
355
+ */
356
+ export function parseJa3(ja3String) {
357
+ if (!ja3String || typeof ja3String !== 'string') {
358
+ return null;
359
+ }
360
+ const parts = ja3String.split(',');
361
+ if (parts.length !== 5) {
362
+ return null;
363
+ }
364
+ return {
365
+ tlsVersion: parseInt(parts[0], 10),
366
+ ciphers: parts[1] !== '' ? parts[1].split('-').map(Number) : [],
367
+ extensions: parts[2] !== '' ? parts[2].split('-').map(Number) : [],
368
+ curves: parts[3] !== '' ? parts[3].split('-').map(Number) : [],
369
+ points: parts[4] !== '' ? parts[4].split('-').map(Number) : []
370
+ };
371
+ }
372
+
349
373
  /**
350
374
  * Creates a stable hash based on device characteristics, independent of the IP.
351
375
  * This is our "level 2 fingerprint".
@@ -484,6 +508,17 @@ const tlsFingerprintDb = {
484
508
  'c72366b9551263d990b7fa574225332c': 'curl', // curl 7.81.0
485
509
  };
486
510
 
511
+ const GREASE_VALUES = [
512
+ 2570, 6682, 10794, 14906, 19018, 23130, 27242, 31354,
513
+ 35466, 39578, 43690, 47802, 51914, 55926, 60038, 64150
514
+ ];
515
+
516
+ /** @private */
517
+ function hasGrease(values) {
518
+ if (!Array.isArray(values)) return false;
519
+ return values.some(val => GREASE_VALUES.includes(val));
520
+ }
521
+
487
522
  // Fonctions utilitaires
488
523
  function parseUserAgent(ua) {
489
524
  // Parser basique du User-Agent
@@ -814,8 +849,9 @@ export const isTicketValid = (ip, ticket) => {
814
849
  */
815
850
  function getHeaderAnomalies(context) {
816
851
  let anomalyScore = 0;
852
+ const ua = context.headers["user-agent"] || '';
817
853
  // Strong penalty if User-Agent is missing or very short (sign of a simple script)
818
- if (!context.headers["user-agent"] || context.headers["user-agent"].length < 10) {
854
+ if (!ua || ua.length < 10) {
819
855
  anomalyScore += 60;
820
856
  }
821
857
  // Penalty if Accept-Language header is missing
@@ -827,6 +863,17 @@ function getHeaderAnomalies(context) {
827
863
  anomalyScore += 15;
828
864
  }
829
865
 
866
+ // TE: trailers check for Firefox on Desktop
867
+ const uaParts = parseUserAgent(ua);
868
+ const isFirefoxDesktop = uaParts.browser?.startsWith('Firefox') && uaParts.device === 'desktop';
869
+ const teHeader = context.headers['te'];
870
+
871
+ if (isFirefoxDesktop && teHeader !== 'trailers') {
872
+ anomalyScore += 30; // Suspicious: Firefox desktop missing TE: trailers
873
+ } else if (!isFirefoxDesktop && uaParts.device === 'desktop' && teHeader === 'trailers') {
874
+ anomalyScore += 30; // Suspicious: Non-Firefox desktop sending TE: trailers
875
+ }
876
+
830
877
  return {
831
878
  headerAnomalyScore: Math.min(100, anomalyScore),
832
879
  };
@@ -1142,21 +1189,130 @@ function getCrossLayerInconsistency(context) {
1142
1189
  return { crossLayerInconsistencyScore: 10 }; // Erreur de parsing = suspect.
1143
1190
  }
1144
1191
  }
1192
+ function parseJa4(ja4) {
1193
+ if (!ja4 || typeof ja4 !== 'string') return null;
1194
+ const parts = ja4.split('_');
1195
+ const ja4a = parts[0];
1196
+ if (ja4a.length < 10) return null;
1197
+ return {
1198
+ protocol: ja4a[0],
1199
+ version: ja4a.substring(1, 3),
1200
+ sni: ja4a[3],
1201
+ ciphersCount: parseInt(ja4a.substring(4, 6), 10) || 0,
1202
+ extensionsCount: parseInt(ja4a.substring(6, 8), 10) || 0,
1203
+ alpn: ja4a.substring(8, 10),
1204
+ ja4b: parts[1] || null,
1205
+ ja4c: parts[2] || null
1206
+ };
1207
+ }
1145
1208
 
1146
1209
  /**
1147
1210
  * Calcule un score d'incohérence entre les données du fingerprint TLS (JA3/JA4) et les en-têtes serveur (User-Agent).
1148
1211
  * Cela permet de détecter le spoofing de fingerprint TLS.
1149
1212
  * @param {object} context - Le contexte de la requête.
1150
- * @returns {{tlsSpoofingScore: number}}
1213
+ * @returns {Promise<{tlsSpoofingScore: number}>}
1151
1214
  */
1152
- function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint) {
1153
- const { ja3, ja4 } = getTlsFingerprintFn(context) || { ja3: null, ja4: null }; // Defensive check
1215
+ export function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint, customStore = null) {
1216
+ let actualGetTlsFingerprintFn = getTlsFingerprintFn;
1217
+ let actualStore = customStore || store;
1218
+
1219
+ // Detect if the second argument is actually a store (compatibility with tests)
1220
+ if (getTlsFingerprintFn && typeof getTlsFingerprintFn.get === 'function' && typeof getTlsFingerprintFn.set === 'function') {
1221
+ actualStore = getTlsFingerprintFn;
1222
+ actualGetTlsFingerprintFn = getTlsFingerprint;
1223
+ }
1224
+
1225
+ const { ja3, ja4 } = actualGetTlsFingerprintFn(context) || { ja3: null, ja4: null }; // Defensive check
1154
1226
  const ua = context.headers["user-agent"] || '';
1227
+ const ja3Raw = context.headers['x-ja3-raw'] || null;
1228
+ const httpVersion = context.httpVersion || '';
1229
+
1230
+ let score = 0;
1155
1231
 
1156
1232
  // 1. Penalize if a TLS fingerprint is present but the User-Agent is generic or missing.
1157
1233
  // This is a strong indicator of a non-browser client trying to look legitimate.
1158
1234
  if ((ja3 || ja4) && (!ua || ua.length < 10 || ua.toLowerCase().includes('python') || ua.toLowerCase().includes('curl'))) {
1159
- return { tlsSpoofingScore: 50 };
1235
+ score = Math.max(score, 50);
1236
+ }
1237
+
1238
+ // Check for known spoofed/suspicious JA4 fingerprints
1239
+ const spoofedJa4s = [
1240
+ 't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
1241
+ 't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
1242
+ 't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
1243
+ 't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
1244
+ 't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
1245
+ 't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
1246
+ ];
1247
+ if (ja4 && spoofedJa4s.includes(ja4)) {
1248
+ score = Math.max(score, 100);
1249
+ }
1250
+
1251
+ const claimedBrowser = parseUserAgent(ua).browser?.split('/')[0] || null;
1252
+ const isHumanBrowser = ['Chrome', 'Firefox', 'Safari', 'Edge'].includes(claimedBrowser);
1253
+
1254
+ // --- ANALYSE 2 : CONTRÔLE PROFOND SUR L'EMPREINTE BRUTE (RAW JA3) ---
1255
+ if (ja3Raw) {
1256
+ const parsed = parseJa3(ja3Raw);
1257
+ if (parsed) {
1258
+ // Contrôle A : Mécanisme GREASE pour Chrome / Edge (obligatoire)
1259
+ if (claimedBrowser === 'Chrome' || claimedBrowser === 'Edge') {
1260
+ const hasCiphersGrease = hasGrease(parsed.ciphers);
1261
+ const hasExtensionsGrease = hasGrease(parsed.extensions);
1262
+
1263
+ if (!hasCiphersGrease && !hasExtensionsGrease) {
1264
+ // Chrome ou Edge moderne sans GREASE = spoofing de bas niveau (ex: python-requests déguisé)
1265
+ score = Math.max(score, 75);
1266
+ }
1267
+ }
1268
+
1269
+ // Contrôle B : HTTP/2 ou HTTP/3 sans négociation ALPN (Extension 16)
1270
+ const isH2OrHigher = (
1271
+ httpVersion.includes('2.0') ||
1272
+ httpVersion.includes('HTTP/2') ||
1273
+ httpVersion.includes('HTTP/3')
1274
+ );
1275
+ const hasAlpnExtension = parsed.extensions.includes(16);
1276
+
1277
+ if (isH2OrHigher && !hasAlpnExtension) {
1278
+ // Négociation HTTP/2 active au niveau serveur mais absente au niveau des extensions TLS du client
1279
+ score = Math.max(score, 70);
1280
+ }
1281
+
1282
+ // Contrôle C : Version TLS obsolète négociée par un navigateur moderne (ex: TLS < 1.2, id < 771)
1283
+ if (isHumanBrowser && parsed.tlsVersion < 771) {
1284
+ score = Math.max(score, 80);
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ // Parse JA4 if available for advanced checks
1290
+ if (ja4) {
1291
+ const parsedJa4 = parseJa4(ja4);
1292
+ if (parsedJa4) {
1293
+ const uaParts = parseUserAgent(ua);
1294
+
1295
+ // Check 1: Incohérence ALPN / HTTP Version
1296
+ if (parsedJa4.alpn === 'h2' && (context.httpVersion === '1.1' || context.httpVersion === '1.0')) {
1297
+ const hasProxy = context.headers['via'] || context.headers['forwarded'] || context.headers['x-forwarded-proto'] || context.headers['x-forwarded-for'];
1298
+ if (!hasProxy) {
1299
+ score = Math.max(score, 40);
1300
+ }
1301
+ }
1302
+
1303
+ // Check 2: Incohérence OS/Plateforme vs Capabilities TLS
1304
+ if (parsedJa4.version === '12' && (uaParts.os === 'iOS' || uaParts.os === 'macOS') && uaParts.browser?.startsWith('Safari')) {
1305
+ score = Math.max(score, 60);
1306
+ }
1307
+
1308
+ // Check 3: Incohérence User-Agent vs Signature JA4
1309
+ if (uaParts.browser?.startsWith('Chrome') && parsedJa4.alpn === '00') {
1310
+ score = Math.max(score, 50);
1311
+ }
1312
+ if (uaParts.browser?.startsWith('Firefox') && parsedJa4.extensionsCount > 15) {
1313
+ score = Math.max(score, 50);
1314
+ }
1315
+ }
1160
1316
  }
1161
1317
 
1162
1318
  // 2. If no JA3 hash is available, we cannot perform the consistency check.
@@ -1173,23 +1329,72 @@ function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint) {
1173
1329
  // Parse the User-Agent to get the claimed browser.
1174
1330
  const { browser: claimedBrowser } = parseUserAgent(ua);
1175
1331
 
1176
- // Check if the claimed browser is one of the legitimate possibilities for this JA3 hash.
1177
- // We use `some` to see if the claimed browser starts with any of the expected browser names.
1178
- // (e.g., "Chrome/116" starts with "Chrome").
1179
- const isMatch = expectedBrowsers.some(expected => claimedBrowser?.startsWith(expected));
1180
-
1181
- if (claimedBrowser && !isMatch) {
1182
- return { tlsSpoofingScore: 80 }; // High score for a clear mismatch.
1332
+ // Check 1: Known library JA3 with a human-claimed browser
1333
+ const isLibrary = expectedBrowsers.some(expected => ['Python', 'Go', 'Java', 'curl'].includes(expected));
1334
+ const claimsToBeHumanBrowser = claimedBrowser && (
1335
+ claimedBrowser.startsWith('Chrome') ||
1336
+ claimedBrowser.startsWith('Firefox') ||
1337
+ claimedBrowser.startsWith('Safari') ||
1338
+ claimedBrowser.startsWith('Edge')
1339
+ );
1340
+
1341
+ if (isLibrary && claimsToBeHumanBrowser) {
1342
+ score = Math.max(score, 90); // High confidence spoofing of library as browser
1343
+ } else {
1344
+ // Check if the claimed browser is one of the legitimate possibilities for this JA3 hash.
1345
+ const isMatch = expectedBrowsers.some(expected => claimedBrowser?.startsWith(expected));
1346
+ if (claimedBrowser && !isMatch) {
1347
+ score = Math.max(score, 80); // High score for a clear mismatch.
1348
+ }
1183
1349
  }
1184
1350
  }
1185
1351
  }
1186
1352
 
1187
- // If we reach here, either:
1188
- // - No JA3 was available.
1189
- // - The JA3 was not in our database (we can't make a decision).
1190
- // - The JA3 and User-Agent were consistent.
1191
- // In all these cases, the score is 0.
1192
- return { tlsSpoofingScore: 0 };
1353
+ // Create the promise for the async part (Check 4)
1354
+ const promise = (async () => {
1355
+ let asyncScore = score;
1356
+ const uaParts = parseUserAgent(ua);
1357
+
1358
+ if (uaParts.browser) {
1359
+ const browserFamily = uaParts.browser.split('/')[0];
1360
+
1361
+ // Check 4a: Stagnation JA4
1362
+ if (ja4) {
1363
+ const parsedJa4 = parseJa4(ja4);
1364
+ if (parsedJa4) {
1365
+ const ja4Key = `ja4-browsers:${ja4}`;
1366
+ let seenBrowsers = await actualStore.get(ja4Key) || [];
1367
+ if (!Array.isArray(seenBrowsers)) seenBrowsers = [];
1368
+ if (browserFamily && !seenBrowsers.includes(browserFamily)) {
1369
+ seenBrowsers.push(browserFamily);
1370
+ await actualStore.set(ja4Key, seenBrowsers, 86400); // 24h cache
1371
+ }
1372
+ if (seenBrowsers.length > 1) {
1373
+ asyncScore = Math.max(asyncScore, 80);
1374
+ }
1375
+ }
1376
+ }
1377
+
1378
+ // Check 4b: JA3 MD5 stagnation with rotating browser UAs
1379
+ if (ja3 && browserFamily) {
1380
+ const ja3Key = `ja3-browsers:${ja3}`;
1381
+ let seenBrowsers = await actualStore.get(ja3Key) || [];
1382
+ if (!Array.isArray(seenBrowsers)) seenBrowsers = [];
1383
+ if (!seenBrowsers.includes(browserFamily)) {
1384
+ seenBrowsers.push(browserFamily);
1385
+ await actualStore.set(ja3Key, seenBrowsers, 86400); // 24h cache
1386
+ }
1387
+ if (seenBrowsers.length > 1) {
1388
+ asyncScore = Math.max(asyncScore, 85); // Staging different UAs on same JA3 MD5 signature
1389
+ }
1390
+ }
1391
+ }
1392
+ return { tlsSpoofingScore: asyncScore };
1393
+ })();
1394
+
1395
+ // Decorate the promise so synchronous calls can destructure it!
1396
+ promise.tlsSpoofingScore = score;
1397
+ return promise;
1193
1398
  }
1194
1399
 
1195
1400
  /**
@@ -1501,7 +1706,7 @@ function getBotScore(context) {
1501
1706
  }
1502
1707
  } catch (e) { /* Ignorer les erreurs de parsing */ }
1503
1708
 
1504
- return { botScore: 0 };
1709
+ return { botScore: 0 };
1505
1710
  }
1506
1711
 
1507
1712
  /**
@@ -1854,7 +2059,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
1854
2059
  // On appelle getHoneypotScore ici pour que son résultat soit inclus dans le vecteur.
1855
2060
  const { honeypotScore } = getHoneypotScore(context, honeypotConfig);
1856
2061
 
1857
- const { tlsSpoofingScore } = getTlsSpoofingScore(context);
2062
+ const { tlsSpoofingScore } = await getTlsSpoofingScore(context);
1858
2063
 
1859
2064
  const { botScore } = getBotScore(context);
1860
2065
 
@@ -2523,8 +2728,15 @@ export class FingerprintEngine {
2523
2728
  }
2524
2729
 
2525
2730
  // 2. Forward DNS lookup
2526
- const addresses = await dns.resolve(validHostname);
2527
- if (addresses.includes(clientIp)) {
2731
+ let addresses = [];
2732
+ try {
2733
+ addresses = await dns.resolve(validHostname);
2734
+ } catch (e) {}
2735
+ try {
2736
+ const ipv6 = await dns.resolve(validHostname, 'AAAA');
2737
+ addresses = addresses.concat(ipv6);
2738
+ } catch (e) {}
2739
+ if (addresses.includes(clientIp)) {
2528
2740
  await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
2529
2741
  return true;
2530
2742
  }
@@ -3540,6 +3752,7 @@ export const __internal = {
3540
3752
  getClickVarianceScore, // NOUVEAU: Expose pour les tests
3541
3753
  getTlsFingerprint, // NOUVEAU: Expose pour les tests
3542
3754
  getTlsSpoofingScore, // NOUVEAU: Expose pour les tests
3755
+ parseJa3,
3543
3756
  generateCpuTargetChallengePage,
3544
3757
  getClientHintsInconsistencyScore, // Expose for testing
3545
3758
  generateCombinedPoWChallengePage,
@@ -324,6 +324,68 @@
324
324
 
325
325
  // Score de spoofing TLS
326
326
  $tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
327
+ $tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
328
+
329
+ // Advanced JA4 TLS Inconsistency checks
330
+ $ja4 = $context->getHeader('x-ja4-hash');
331
+ if ($ja4) {
332
+ $spoofedJa4s = [
333
+ 't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
334
+ 't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
335
+ 't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
336
+ 't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
337
+ 't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
338
+ 't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
339
+ ];
340
+ if (in_array($ja4, $spoofedJa4s, true)) {
341
+ $tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
342
+ }
343
+
344
+ $parsedJa4 = $this->parseJa4($ja4);
345
+ if ($parsedJa4) {
346
+ $ua = $context->getHeader('user-agent') ?? '';
347
+ $uaParts = $this->parseUserAgent($ua);
348
+
349
+ // Check 1: Incohérence ALPN / HTTP Version
350
+ $httpVersion = $context->httpVersion ?? '1.1';
351
+ if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
352
+ $hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
353
+ if (!$hasProxy) {
354
+ $tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
355
+ }
356
+ }
357
+
358
+ // Check 2: Incohérence OS/Plateforme vs Capabilities TLS
359
+ if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
360
+ $tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
361
+ }
362
+
363
+ // Check 3: Incohérence User-Agent vs Signature JA4
364
+ if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
365
+ $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
366
+ }
367
+ if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
368
+ $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
369
+ }
370
+
371
+ // Check 4: La stagnation (Lack of Entropy / Genericity)
372
+ if ($uaParts['browser']) {
373
+ $ja4Key = "ja4-browsers:{$ja4}";
374
+ $seenBrowsers = $store->get($ja4Key) ?: [];
375
+ if (!is_array($seenBrowsers)) {
376
+ $seenBrowsers = [];
377
+ }
378
+ $browserFamily = explode('/', $uaParts['browser'])[0] ?? null;
379
+ if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
380
+ $seenBrowsers[] = $browserFamily;
381
+ $store->set($ja4Key, $seenBrowsers, 86400);
382
+ }
383
+ if (count($seenBrowsers) > 1) {
384
+ $tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
385
+ }
386
+ }
387
+ }
388
+ }
327
389
 
328
390
  // Score d'incohérence temporelle (attaque par rejeu)
329
391
  $timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
@@ -346,9 +408,6 @@
346
408
  // Score de variance des clics
347
409
  $clickVariance = RequestUtils::getClickVarianceScore($context);
348
410
 
349
- // Score de variance des clics
350
- $clickVariance = RequestUtils::getClickVarianceScore($context);
351
-
352
411
  // Score basé sur les listes de menaces (Threat Intelligence)
353
412
  $threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
354
413
 
@@ -364,7 +423,7 @@
364
423
  'historyScore' => $behavioral['historyScore'],
365
424
  'rotationScore' => $behavioral['rotationScore'],
366
425
  'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
367
- 'tlsSpoofingScore' => $tlsSpoofing['tlsSpoofingScore'],
426
+ 'tlsSpoofingScore' => $tlsSpoofingScore,
368
427
  'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
369
428
  'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
370
429
  'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
@@ -383,6 +442,66 @@
383
442
  return $suspicionVector;
384
443
  }
385
444
 
445
+ /**
446
+ * Parse a basic User-Agent string.
447
+ */
448
+ private function parseUserAgent(string $ua): array
449
+ {
450
+ $result = ['browser' => null, 'os' => null];
451
+
452
+ if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
453
+ $result['browser'] = 'Chrome';
454
+ if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
455
+ $result['browser'] .= '/' . $matches[1];
456
+ }
457
+ } elseif (str_contains($ua, 'Firefox')) {
458
+ $result['browser'] = 'Firefox';
459
+ if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
460
+ $result['browser'] .= '/' . $matches[1];
461
+ }
462
+ } elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
463
+ $result['browser'] = 'Safari';
464
+ if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
465
+ $result['browser'] .= '/' . $matches[1];
466
+ }
467
+ } elseif (str_contains($ua, 'Edg')) {
468
+ $result['browser'] = 'Edge';
469
+ if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
470
+ $result['browser'] .= '/' . $matches[1];
471
+ }
472
+ }
473
+
474
+ if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
475
+ elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
476
+ elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
477
+ elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
478
+ elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
479
+ elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
480
+
481
+ return $result;
482
+ }
483
+
484
+ /**
485
+ * Parse JA4 string into protocol, version, ALPN etc.
486
+ */
487
+ private function parseJa4(?string $ja4): ?array
488
+ {
489
+ if (empty($ja4)) return null;
490
+ $parts = explode('_', $ja4);
491
+ $ja4a = $parts[0];
492
+ if (strlen($ja4a) < 10) return null;
493
+ return [
494
+ 'protocol' => $ja4a[0],
495
+ 'version' => substr($ja4a, 1, 2),
496
+ 'sni' => $ja4a[3],
497
+ 'ciphersCount' => (int)substr($ja4a, 4, 2),
498
+ 'extensionsCount' => (int)substr($ja4a, 6, 2),
499
+ 'alpn' => substr($ja4a, 8, 2),
500
+ 'ja4b' => $parts[1] ?? null,
501
+ 'ja4c' => $parts[2] ?? null
502
+ ];
503
+ }
504
+
386
505
  /**
387
506
  * Traite une requête entrante et retourne une décision.
388
507
  * @param RequestContext $context Le contexte de la requête.
@@ -840,9 +959,17 @@
840
959
 
841
960
  // 2. Forward DNS lookup
842
961
  $addresses = array_merge(dns_get_record($validHostname, DNS_A) ?: [], dns_get_record($validHostname, DNS_AAAA) ?: []);
843
- $ips = array_column($addresses, 'ip');
962
+ $ips = [];
963
+ foreach ($addresses as $address) {
964
+ if (isset($address['ip'])) {
965
+ $ips[] = $address['ip'];
966
+ }
967
+ if (isset($address['ipv6'])) {
968
+ $ips[] = $address['ipv6'];
969
+ }
970
+ }
844
971
 
845
- if (in_array($context->clientIp, $addresses)) {
972
+ if (in_array($context->clientIp, $ips, true)) {
846
973
  $store->set($cacheKey, 'verified', 86400);
847
974
  return true;
848
975
  }