@anonympins/fingerprint 0.3.3 → 0.3.4

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.
@@ -2,9 +2,11 @@
2
2
 
3
3
  declare(strict_types=1);
4
4
 
5
+
5
6
  namespace Anonympins\Fingerprint\Utils;
6
7
 
7
8
  use Anonympins\Fingerprint\FingerprintBuilder;
9
+ use Anonympins\Fingerprint\Store\StoreManager;
8
10
  use Anonympins\Fingerprint\Optimization\Optimization;
9
11
  use Anonympins\Fingerprint\RequestContext;
10
12
 
@@ -43,6 +45,20 @@ class RequestUtils
43
45
  'c72366b9551263d990b7fa574225332c' => 'curl',
44
46
  ];
45
47
 
48
+ /**
49
+ * Base de données de signatures JA4 connues.
50
+ * @var array<string, string|string[]>
51
+ */
52
+ private const JA4_FINGERPRINT_DB = [
53
+ // Format: {JA4 Hash} => {Client Name}
54
+ // --- Chrome ---
55
+ 't13d1517h2_8daaf61527d5' => 'Chrome', // Chrome 117 on Win11
56
+ 't13d1516h2_8daaf61527d5' => 'Chrome', // Chrome 116 on Win10
57
+ // --- Firefox ---
58
+ 't13d1517h2_2491a244c393' => 'Firefox', // Firefox 117 on Win11
59
+ // --- Common Libraries & Bots ---
60
+ 't13d1500h1_4b56136b4d35' => 'Python', // Python requests
61
+ ];
46
62
  /**
47
63
  * Crée un hash composite stable basé sur les caractéristiques de la requête.
48
64
  */
@@ -62,6 +78,8 @@ class RequestUtils
62
78
 
63
79
  if ($context->ja3) $srv->add("ja3", $context->ja3);
64
80
  if ($context->ja4) $srv->add("ja4", $context->ja4);
81
+ if ($context->ja4s) $srv->add("ja4s", $context->ja4s);
82
+ if ($context->ja4h) $srv->add("ja4h", $context->ja4h);
65
83
  if ($context->http2Fingerprint) $srv->add("h2", $context->http2Fingerprint);
66
84
  if ($context->tcpFingerprint) $srv->add("tcp", $context->tcpFingerprint);
67
85
 
@@ -103,34 +121,59 @@ class RequestUtils
103
121
  */
104
122
  public static function getTlsSpoofingScore(RequestContext $context): array
105
123
  {
106
- $ja3 = $context->ja3;
107
124
  $ua = $context->getHeader('user-agent') ?? '';
125
+ $ja3 = $context->ja3;
126
+ $ja4 = $context->ja4;
108
127
 
109
- if ($ja3 && (empty($ua) || strlen($ua) < 10 || stripos($ua, 'python') !== false || stripos($ua, 'curl') !== false)) {
128
+ // Si un fingerprint TLS est présent mais que le User-Agent est absent ou générique, c'est suspect.
129
+ if (($ja3 || $ja4) && (empty($ua) || strlen($ua) < 10 || stripos($ua, 'python') !== false || stripos($ua, 'curl') !== false)) {
110
130
  return ['tlsSpoofingScore' => 50.0];
111
131
  }
112
132
 
113
- if ($ja3 && !empty($ua) && isset(self::TLS_FINGERPRINT_DB[$ja3])) {
114
- $expectedBrowsers = self::TLS_FINGERPRINT_DB[$ja3];
115
- if (!is_array($expectedBrowsers)) {
116
- $expectedBrowsers = [$expectedBrowsers];
117
- }
133
+ $claimedBrowserInfo = self::parseUserAgent($ua);
134
+ $claimedBrowser = $claimedBrowserInfo['browser'] ?? null;
118
135
 
119
- $claimedBrowserInfo = self::parseUserAgent($ua);
120
- $claimedBrowser = $claimedBrowserInfo['browser'] ?? null;
136
+ if (empty($claimedBrowser) || empty($ua)) {
137
+ return ['tlsSpoofingScore' => 0.0];
138
+ }
121
139
 
122
- if ($claimedBrowser) {
123
- $isMatch = false;
124
- foreach ($expectedBrowsers as $expected) {
125
- if (str_starts_with($claimedBrowser, $expected)) {
126
- $isMatch = true;
127
- break;
128
- }
140
+ // Priorité à JA4 pour la détection de spoofing
141
+ if ($ja4 && isset(self::JA4_FINGERPRINT_DB[$ja4])) {
142
+ $expectedClients = self::JA4_FINGERPRINT_DB[$ja4];
143
+ if (!is_array($expectedClients)) {
144
+ $expectedClients = [$expectedClients];
145
+ }
146
+
147
+ $isMatch = false;
148
+ foreach ($expectedClients as $expected) {
149
+ if (stripos($claimedBrowser, $expected) !== false) {
150
+ $isMatch = true;
151
+ break;
129
152
  }
130
- if (!$isMatch) {
131
- return ['tlsSpoofingScore' => 80.0];
153
+ }
154
+ if (!$isMatch) {
155
+ // Incohérence forte détectée avec JA4
156
+ return ['tlsSpoofingScore' => 90.0];
157
+ }
158
+ }
159
+ // Fallback sur JA3 si JA4 n'a pas matché
160
+ elseif ($ja3 && isset(self::TLS_FINGERPRINT_DB[$ja3])) {
161
+ $expectedClients = self::TLS_FINGERPRINT_DB[$ja3];
162
+ if (!is_array($expectedClients)) {
163
+ $expectedClients = [$expectedClients];
164
+ }
165
+
166
+ $isMatch = false;
167
+ foreach ($expectedClients as $expected) {
168
+ if (stripos($claimedBrowser, $expected) !== false) {
169
+ $isMatch = true;
170
+ break;
132
171
  }
133
172
  }
173
+ if (!$isMatch) {
174
+ // Incohérence détectée avec JA3
175
+ return ['tlsSpoofingScore' => 80.0];
176
+ }
134
177
  }
135
178
 
136
179
  return ['tlsSpoofingScore' => 0.0];
@@ -377,6 +420,62 @@ class RequestUtils
377
420
  return ['crossLayerInconsistencyScore' => min(100.0, $score)];
378
421
  }
379
422
 
423
+ /**
424
+ * Calcule un score d'incohérence entre le User-Agent et les en-têtes Sec-CH-UA (Client Hints).
425
+ * @return array{'clientHintsInconsistencyScore': float}
426
+ */
427
+ public static function getClientHintsInconsistencyScore(RequestContext $context): array
428
+ {
429
+ $ua = $context->getHeader('user-agent');
430
+ $clientHints = $context->getHeader('sec-ch-ua');
431
+
432
+ if (empty($ua) || empty($clientHints)) {
433
+ return ['clientHintsInconsistencyScore' => 0.0];
434
+ }
435
+
436
+ // 1. Extraire la version du navigateur depuis le User-Agent
437
+ $uaVersion = null;
438
+ if (preg_match('/(Chrome|Firefox|Edg|Safari)\/([\d\.]+)/', $ua, $uaMatches)) {
439
+ $uaBrowser = $uaMatches[1] === 'Edg' ? 'Edge' : $uaMatches[1];
440
+ // Prendre uniquement la version majeure
441
+ $uaVersion = explode('.', $uaMatches[2])[0] ?? null;
442
+ }
443
+
444
+ // 2. Extraire la version du navigateur depuis Sec-CH-UA
445
+ $chVersion = null;
446
+ $chBrowser = null;
447
+ // Regex pour trouver une marque de navigateur connue et sa version
448
+ if (preg_match('/"(?:Google Chrome|Chromium|Microsoft Edge)";v="(\d+)"/', $clientHints, $chMatches)) {
449
+ $chVersion = $chMatches[1];
450
+ // Déterminer le navigateur à partir de la marque trouvée
451
+ if (str_contains($chMatches[0], 'Edge')) {
452
+ $chBrowser = 'Edge';
453
+ } else {
454
+ $chBrowser = 'Chrome'; // Chrome ou Chromium
455
+ }
456
+ }
457
+
458
+ if ($uaVersion === null || $chVersion === null || $uaBrowser === null || $chBrowser === null) {
459
+ return ['clientHintsInconsistencyScore' => 0.0];
460
+ }
461
+
462
+ // 3. Comparer les versions
463
+ // Tolérer une petite différence car les Client-Hints peuvent être plus précis ou mis à jour différemment
464
+ $versionDifference = abs((int)$uaVersion - (int)$chVersion);
465
+
466
+ // Si les navigateurs déclarés sont différents (ex: UA dit Firefox, CH dit Chrome)
467
+ if ($uaBrowser !== $chBrowser && ($uaBrowser !== 'Chrome' || $chBrowser !== 'Edge')) { // Tolérer Chrome/Edge
468
+ return ['clientHintsInconsistencyScore' => 90.0];
469
+ }
470
+
471
+ if ($versionDifference > 5) { // Un écart de plus de 5 versions majeures est très suspect
472
+ return ['clientHintsInconsistencyScore' => 80.0];
473
+ } elseif ($versionDifference > 1) { // Un petit écart est légèrement suspect
474
+ return ['clientHintsInconsistencyScore' => 40.0];
475
+ }
476
+
477
+ return ['clientHintsInconsistencyScore' => 0.0];
478
+ }
380
479
  /**
381
480
  * Calcule les indicateurs comportementaux liés à l'historique de l'appareil.
382
481
  * @param array<string, mixed> $deviceData
@@ -392,17 +491,36 @@ class RequestUtils
392
491
  $rapidChangeThresholdMs = 2000; // 2 secondes
393
492
  $maxRapidChanges = 3;
394
493
 
395
- if (isset($deviceData['lastFpHash']) && $currentFpHash !== $deviceData['lastFpHash']) {
494
+ $lastFpHash = $deviceData['lastFpHash'] ?? null;
495
+
496
+ if ($lastFpHash && $currentFpHash !== $lastFpHash) {
497
+ // Comparaison plus intelligente : ne pénaliser que si les parties STABLES de l'empreinte changent.
498
+ // Les parties stables sont celles qui ne devraient pas changer lors d'un simple changement de réseau.
499
+ $stablePart1 = self::extractStablePart($lastFpHash);
500
+ $stablePart2 = self::extractStablePart($currentFpHash);
501
+
396
502
  $timeSinceLastChange = $now - ($deviceData['lastChangeTimestamp'] ?? 0);
397
- if ($timeSinceLastChange < $rapidChangeThresholdMs) {
398
- $deviceData['rapidChangeCount'] = ($deviceData['rapidChangeCount'] ?? 0) + 1;
399
- } else {
400
- $deviceData['rapidChangeCount'] = max(0, ($deviceData['rapidChangeCount'] ?? 0) - 1);
503
+
504
+ // On incrémente le compteur de rotation rapide SEULEMENT si la partie stable a changé.
505
+ if ($stablePart1 !== $stablePart2) {
506
+ if ($timeSinceLastChange < $rapidChangeThresholdMs) {
507
+ $deviceData['rapidChangeCount'] = ($deviceData['rapidChangeCount'] ?? 0) + 1;
508
+ } else {
509
+ // Si le changement est lent, on réduit le compteur pour pardonner les anciens changements rapides.
510
+ $deviceData['rapidChangeCount'] = max(0, ($deviceData['rapidChangeCount'] ?? 0) - 1);
511
+ }
512
+ $deviceData['lastChangeTimestamp'] = $now;
401
513
  }
514
+ // Si seule la partie volatile a changé (ex: User-Agent, IP via en-têtes), on ne met pas à jour le `lastChangeTimestamp`.
515
+ // Cela évite qu'un changement de réseau légitime soit suivi d'un autre changement (ex: mise en veille)
516
+ // et soit compté comme une rotation rapide.
517
+
518
+ } else if ($lastFpHash === null) {
519
+ // Première visite, on initialise le timestamp.
402
520
  $deviceData['lastChangeTimestamp'] = $now;
403
521
  }
404
522
  $deviceData['lastFpHash'] = $currentFpHash;
405
-
523
+
406
524
  // Enregistrement de l'IP
407
525
  if (!in_array($clientIp, $deviceData['ips'])) {
408
526
  $deviceData['ips'][] = $clientIp;
@@ -418,6 +536,27 @@ class RequestUtils
418
536
  return ['historyScore' => $historyScore, 'rotationScore' => $rotationScore];
419
537
  }
420
538
 
539
+ /**
540
+ * Extrait la partie "stable" d'une chaîne d'empreinte.
541
+ * La partie stable inclut les composants matériels (canvas, gpu) qui ne devraient pas changer.
542
+ * @param string $fpString La chaîne d'empreinte complète.
543
+ * @return string La sous-chaîne de l'empreinte contenant uniquement les parties stables.
544
+ */
545
+ private static function extractStablePart(string $fpString): string
546
+ {
547
+ $stableKeys = ['cvs', 'gpu', 'hw', 'client_fp_hash', 'os', 'scr'];
548
+ $parts = explode('|', $fpString);
549
+ $stableParts = [];
550
+ foreach ($parts as $part) {
551
+ $pair = explode(':', $part, 2);
552
+ if (count($pair) === 2 && in_array($pair[0], $stableKeys, true)) {
553
+ $stableParts[] = $part;
554
+ }
555
+ }
556
+ sort($stableParts);
557
+ return implode('|', $stableParts);
558
+ }
559
+
421
560
  /**
422
561
  * Analyse les patterns de requêtes pour détecter les comportements de bot.
423
562
  * @param array<string, mixed> $deviceData
@@ -670,4 +809,154 @@ class RequestUtils
670
809
 
671
810
  return self::pathMatches($requestPath, $pathPattern);
672
811
  }
812
+
813
+ /**
814
+ * Génère un masque de sous-réseau binaire pour une longueur de préfixe donnée.
815
+ *
816
+ * @param int $prefix La longueur du préfixe (ex: 24 pour IPv4, 48 pour IPv6).
817
+ * @param int $totalBytes Le nombre total d'octets pour le masque (4 pour IPv4, 16 pour IPv6).
818
+ * @return string|null Le masque binaire ou null si le préfixe est invalide.
819
+ */
820
+ private static function generateMask(int $prefix, int $totalBytes): ?string
821
+ {
822
+ if ($prefix < 0 || $prefix > $totalBytes * 8) {
823
+ return null; // Préfixe invalide
824
+ }
825
+ $mask = str_repeat(chr(255), (int)floor($prefix / 8));
826
+ if ($prefix % 8 !== 0) {
827
+ $mask .= chr((255 << (8 - $prefix % 8)) & 255);
828
+ }
829
+ return str_pad($mask, $totalBytes, chr(0));
830
+ }
831
+
832
+ /**
833
+ * Calcule le sous-réseau d'une adresse IP.
834
+ * @param string $ip L'adresse IP.
835
+ * @param int $ipv4Prefix Le préfixe pour les adresses IPv4 (défaut /24).
836
+ * @param int $ipv6Prefix Le préfixe pour les adresses IPv6 (défaut /48).
837
+ * @return string|null Le sous-réseau CIDR ou null si l'IP est invalide.
838
+ */
839
+ public static function getIpSubnet(string $ip, int $ipv4Prefix = 24, int $ipv6Prefix = 48): ?string
840
+ {
841
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
842
+ $ipBinary = inet_pton($ip);
843
+ if ($ipBinary === false) return null;
844
+
845
+ $mask = self::generateMask($ipv4Prefix, 4);
846
+ if ($mask === null) return null;
847
+
848
+ $networkBinary = $ipBinary & $mask;
849
+ return inet_ntop($networkBinary) . '/' . $ipv4Prefix;
850
+ } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
851
+ $ipBinary = inet_pton($ip);
852
+ if ($ipBinary === false) return null;
853
+
854
+ $mask = self::generateMask($ipv6Prefix, 16);
855
+ if ($mask === null) return null;
856
+
857
+ $networkBinary = $ipBinary & $mask;
858
+ return inet_ntop($networkBinary) . '/' . $ipv6Prefix; // FIX: Use the provided ipv6Prefix
859
+ }
860
+ return null;
861
+ }
862
+
863
+ /**
864
+ * Met à jour les métriques agrégées pour un sous-réseau IP.
865
+ * @param RequestContext $context
866
+ * @param string $deviceId
867
+ * @param float $finalScore
868
+ */
869
+ public static function updateSubnetMetrics(RequestContext $context, string $deviceId, float $finalScore): void
870
+ {
871
+ $subnet = self::getIpSubnet($context->clientIp);
872
+ if ($subnet === null) return;
873
+
874
+ $store = StoreManager::getStore();
875
+ $key = "subnet:{$subnet}";
876
+ $subnetData = $store->get($key) ?? [
877
+ 'highScoreCount' => 0,
878
+ 'deviceIds' => [],
879
+ 'lastActivity' => 0
880
+ ];
881
+
882
+ $subnetData['highScoreCount']++;
883
+ if (!in_array($deviceId, $subnetData['deviceIds'])) {
884
+ $subnetData['deviceIds'][] = $deviceId;
885
+ }
886
+ $subnetData['lastActivity'] = time();
887
+
888
+ // Limiter la taille du tableau des deviceIds pour éviter une consommation mémoire excessive.
889
+ if (count($subnetData['deviceIds']) > 100) {
890
+ array_shift($subnetData['deviceIds']);
891
+ }
892
+
893
+ // TTL de 24 heures pour les données de sous-réseau.
894
+ $store->set($key, $subnetData, 86400);
895
+ }
896
+
897
+ /**
898
+ * Calcule un score de suspicion basé sur l'activité historique du sous-réseau IP.
899
+ * @param RequestContext $context
900
+ * @param string $currentDeviceId
901
+ * @return array{'subnetScore': float}
902
+ */
903
+ public static function getSubnetScore(RequestContext $context, string $currentDeviceId): array
904
+ {
905
+ $subnet = self::getIpSubnet($context->clientIp);
906
+ if ($subnet === null) {
907
+ return ['subnetScore' => 0.0];
908
+ }
909
+
910
+ $store = StoreManager::getStore();
911
+ $key = "subnet:{$subnet}";
912
+ $subnetData = $store->get($key);
913
+
914
+ if ($subnetData === null) {
915
+ return ['subnetScore' => 0.0];
916
+ }
917
+
918
+ $score = 0.0;
919
+
920
+ // Pénalité basée sur le nombre de devices uniques vus depuis ce sous-réseau.
921
+ $deviceCount = count($subnetData['deviceIds']);
922
+ if ($deviceCount > 10) {
923
+ $score += min(80.0, ($deviceCount - 10) * 5);
924
+ }
925
+
926
+ // Pénalité basée sur le nombre de scores élevés enregistrés.
927
+ $score += min(40.0, $subnetData['highScoreCount'] * 2);
928
+
929
+ return ['subnetScore' => min(100.0, $score)];
930
+ }
931
+
932
+ /**
933
+ * Calcule le score de réputation d'une IP en appliquant la décroissance temporelle.
934
+ */
935
+ public static function getIpReputationScore(string $ip): float
936
+ {
937
+ $store = StoreManager::getStore();
938
+ $key = "ip-reputation:{$ip}";
939
+ $data = $store->get($key);
940
+ if ($data === null) {
941
+ return 0.0;
942
+ }
943
+
944
+ $now = time();
945
+ $hoursPassed = ($now - $data['lastUpdate']) / 3600;
946
+ $decay = (int)floor($hoursPassed * 2); // Décroissance de 2 points par heure
947
+
948
+ return (float)max(0.0, $data['score'] - $decay);
949
+ }
950
+
951
+ /**
952
+ * Met à jour le score de réputation locale d'une IP.
953
+ */
954
+ public static function updateIpReputationScore(string $ip, float $change): void
955
+ {
956
+ $store = StoreManager::getStore();
957
+ $key = "ip-reputation:{$ip}";
958
+ $current = self::getIpReputationScore($ip);
959
+ $newScore = min(100.0, max(0.0, $current + $change));
960
+ $store->set($key, ['score' => $newScore, 'lastUpdate' => time()], 86400 * 7); // TTL de 7 jours
961
+ }
673
962
  }