@anonympins/fingerprint 0.5.2 → 0.5.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +1 -1
  3. package/src/js/build-client.js +67 -67
  4. package/src/js/fingerprint.client.js +280 -38
  5. package/src/js/fingerprint.client.obfuscated.js +1 -1
  6. package/src/js/fingerprint.js +474 -163
  7. package/src/js/fingerprint.utils.js +213 -213
  8. package/src/js/library.js +24 -8
  9. package/src/js/mongodb-store.js +165 -79
  10. package/src/js/obfuscation.worker.js +23 -0
  11. package/src/js/pow.solver.inline.js +509 -14
  12. package/src/js/pow.solver.js +350 -32
  13. package/src/js/problem-manager.js +1 -1
  14. package/src/js/redis-store.js +114 -42
  15. package/src/js/tests/cross_parity.test.js +175 -0
  16. package/src/js/tests/dns-circuit-breaker.test.js +55 -0
  17. package/src/js/tests/fingerprint.client.init.test.js +143 -143
  18. package/src/js/tests/fingerprint.client.test.js +128 -128
  19. package/src/js/tests/fingerprint.test.js +57 -2
  20. package/src/js/tests/ip-reputation.test.js +1 -1
  21. package/src/js/tests/obfuscation.test.js +48 -0
  22. package/src/js/tests/pow.solver.test.js +5 -2
  23. package/src/js/tests/usefulWorkSolver.test.js +162 -0
  24. package/src/php/AutoTuner.php +42 -0
  25. package/src/php/Challenge/ChallengeUtils.php +21 -8
  26. package/src/php/FingerprintClient.php +22 -5
  27. package/src/php/FingerprintEngine.php +233 -57
  28. package/src/php/Optimization/Optimization.php +23 -16
  29. package/src/php/Optimization/OptimizationOperators.php +27 -0
  30. package/src/php/ProblemManager.php +1 -1
  31. package/src/php/Store/MongoDbStore.php +184 -104
  32. package/src/php/Store/RedisStore.php +91 -11
  33. package/src/php/Tests/ChallengeUtilsTest.php +285 -285
  34. package/src/php/Tests/DnsCircuitBreakerTest.php +106 -0
  35. package/src/php/Tests/IpReputationTest.php +1 -1
  36. package/src/php/Tests/PowTest.php +1 -1
  37. package/src/php/Tests/RequestUtilsTest.php +29 -0
  38. package/src/php/Tests/TLSClientHelloParserTest.php +177 -117
  39. package/src/php/Utils/BigInt.php +202 -202
  40. package/src/php/Utils/RequestUtils.php +74 -21
  41. package/src/php/Utils/TLSClientHelloParser.php +212 -116
@@ -839,6 +839,7 @@ class ChallengeUtils
839
839
 
840
840
  $solverCode = self::getPowSolverCode();
841
841
  $queriesJson = json_encode($queries);
842
+ $coopTimeout = $securityConfig['pospace']['coopTimeout'] ?? 15;
842
843
 
843
844
  $safePath = json_encode($path, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES);
844
845
  $safeNonce = json_encode($nonce, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES);
@@ -854,6 +855,7 @@ class ChallengeUtils
854
855
  const nodeId = "{$nodeId}";
855
856
  const peerId = "{$peerId}";
856
857
  const peerBlockIdx = {$peerBlockIdx};
858
+ const coopTimeout = {$coopTimeout};
857
859
 
858
860
  document.getElementById('loader').innerText = '⚙️ Checking persistent local storage...';
859
861
  await new Promise(r => setTimeout(r, 10));
@@ -889,7 +891,7 @@ class ChallengeUtils
889
891
  await fetch(window.location.pathname + "?coop_op=request_peer_block&node_id=" + nodeId + "&peer_id=" + peerId + "&block_idx=" + peerBlockIdx + "&req_id=" + reqId);
890
892
 
891
893
  let attempts = 0;
892
- while (attempts < 15) {
894
+ while (attempts < coopTimeout) {
893
895
  const res = await fetch(window.location.pathname + "?coop_op=poll_response&node_id=" + nodeId + "&req_id=" + reqId);
894
896
  const data = await res.json();
895
897
  if (data.status === 'ready') {
@@ -944,12 +946,23 @@ JS;
944
946
  $baseBlock = self::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint);
945
947
  $baseBlockBytes = '[' . implode(',', array_values(unpack('C*', $baseBlock))) . ']';
946
948
 
947
- $trapLinksHtml = implode(' ', array_map(
948
- fn($url, $index) => "<a href=\"{$url}\" tabindex=\"-1\"><span>&gt; " . ($index + 1) . "</span></a>",
949
- $trapUrls,
950
- array_keys($trapUrls)
951
- ));
952
- $trapContainerHtml = "<div style=\"position:absolute;left:-9999px;top:-9999px;transform:scale(0);pointer-events:none;\" aria-hidden=\"true\">{$trapLinksHtml}</div>";
949
+ $trapTags = ['div', 'span', 'p', 'section'];
950
+ $selectedTrapTag = $trapTags[array_rand($trapTags)];
951
+ $layoutProps = [
952
+ 'position:absolute;left:-9999px;top:-9999px;transform:scale(0);pointer-events:none;',
953
+ 'position:fixed;left:-8888px;top:-8888px;opacity:0;pointer-events:none;width:0;height:0;overflow:hidden;',
954
+ 'display:none;visibility:hidden;pointer-events:none;'
955
+ ];
956
+ $selectedLayout = $layoutProps[array_rand($layoutProps)];
957
+
958
+ $trapLinks = [];
959
+ foreach ($trapUrls as $index => $url) {
960
+ $nestingType = rand(0, 2);
961
+ $innerHtml = ($nestingType === 1) ? "<b>&gt; " . ($index + 1) . "</b>" : (($nestingType === 2) ? "<i>&gt; " . ($index + 1) . "</i>" : "<span>&gt; " . ($index + 1) . "</span>");
962
+ $trapLinks[] = "<a href=\"{$url}\" tabindex=\"-1\">{$innerHtml}</a>";
963
+ }
964
+ $trapLinksHtml = implode(' ', $trapLinks);
965
+ $trapContainerHtml = "<{$selectedTrapTag} style=\"{$selectedLayout}\" aria-hidden=\"true\">{$trapLinksHtml}</{$selectedTrapTag}>";
953
966
 
954
967
  $safePath = json_encode($path, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES);
955
968
  $safeNonce = json_encode($nonce, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES);
@@ -980,7 +993,7 @@ JS;
980
993
  return;
981
994
  }
982
995
 
983
- const finalUrl = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
996
+ const finalUrl = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + encodeURIComponent(JSON.stringify(memSolution));
984
997
  window.location.href = finalUrl;
985
998
  }
986
999
  solve();
@@ -77,13 +77,30 @@ class FingerprintClient
77
77
  $this->clientConfig['honeypots'][] = $fieldName;
78
78
  }
79
79
 
80
- // Styles CSS pour cacher le champ de manière robuste.
81
- $styles = 'position:absolute; left:-9999px; top:-9999px; transform:scale(0); opacity:0; pointer-events:none;';
80
+ // Styles CSS polymorphiques pour cacher le champ de manière robuste.
81
+ $styleOptions = [
82
+ 'position:absolute; left:-9999px; top:-9999px; transform:scale(0); opacity:0; pointer-events:none;',
83
+ 'position:fixed; left:-8888px; top:-8888px; width:0; height:0; overflow:hidden; opacity:0; pointer-events:none;',
84
+ 'display:none; visibility:hidden; pointer-events:none;'
85
+ ];
86
+ $styles = $styleOptions[array_rand($styleOptions)];
87
+
88
+ $containerTags = ['div', 'span', 'p', 'section'];
89
+ $tag = $containerTags[array_rand($containerTags)];
90
+
91
+ $nestingType = rand(0, 1);
92
+ if ($nestingType === 1) {
93
+ return '<' . $tag . ' style="' . $styles . '" aria-hidden="true">'
94
+ . '<label for="' . htmlspecialchars($fieldName) . '">' . htmlspecialchars($fieldName)
95
+ . '<input type="text" id="' . htmlspecialchars($fieldName) . '" name="' . htmlspecialchars($fieldName) . '" tabindex="-1" autocomplete="off">'
96
+ . '</label>'
97
+ . '</' . $tag . '>';
98
+ }
82
99
 
83
- return '<div style="' . $styles . '" aria-hidden="true">'
84
- . '<label for="' . htmlspecialchars($fieldName) . '">' . $fieldName . '</label>'
100
+ return '<' . $tag . ' style="' . $styles . '" aria-hidden="true">'
101
+ . '<label for="' . htmlspecialchars($fieldName) . '">' . htmlspecialchars($fieldName) . '</label>'
85
102
  . '<input type="text" id="' . htmlspecialchars($fieldName) . '" name="' . htmlspecialchars($fieldName) . '" tabindex="-1" autocomplete="off">'
86
- . '</div>';
103
+ . '</' . $tag . '>';
87
104
  }
88
105
 
89
106
  /**
@@ -31,6 +31,137 @@
31
31
  private static ?array $yandexEntries = null;
32
32
  private static ?array $bingbotEntries = null;
33
33
 
34
+ private static array $dnsCircuitBreaker = [
35
+ 'state' => 'CLOSED',
36
+ 'failureCount' => 0,
37
+ 'lastStateChange' => 0,
38
+ 'threshold' => 5,
39
+ 'cooldown' => 30, // seconds
40
+ ];
41
+
42
+ private static function recordDnsSuccess(): void
43
+ {
44
+ self::$dnsCircuitBreaker['failureCount'] = 0;
45
+ self::$dnsCircuitBreaker['state'] = 'CLOSED';
46
+ }
47
+
48
+ private static function recordDnsFailure(): void
49
+ {
50
+ self::$dnsCircuitBreaker['failureCount']++;
51
+ if (self::$dnsCircuitBreaker['failureCount'] >= self::$dnsCircuitBreaker['threshold']) {
52
+ self::$dnsCircuitBreaker['state'] = 'OPEN';
53
+ self::$dnsCircuitBreaker['lastStateChange'] = microtime(true);
54
+ }
55
+ }
56
+
57
+ private static function canAttemptDns(): bool
58
+ {
59
+ if (self::$dnsCircuitBreaker['state'] === 'CLOSED') {
60
+ return true;
61
+ }
62
+ if (self::$dnsCircuitBreaker['state'] === 'OPEN') {
63
+ if (microtime(true) - self::$dnsCircuitBreaker['lastStateChange'] >= self::$dnsCircuitBreaker['cooldown']) {
64
+ self::$dnsCircuitBreaker['state'] = 'HALF-OPEN';
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ return true; // HALF-OPEN
70
+ }
71
+
72
+ private static function getReverseDnsName(string $ip): ?string
73
+ {
74
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
75
+ return implode('.', array_reverse(explode('.', $ip))) . '.in-addr.arpa';
76
+ } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
77
+ $hex = bin2hex((string)inet_pton($ip));
78
+ return implode('.', array_reverse(str_split($hex))) . '.ip6.arpa';
79
+ }
80
+ return null;
81
+ }
82
+
83
+ private static function dnsResolveUdp(string $query, string $type, float $timeout = 0.5): ?array
84
+ {
85
+ $dnsServer = '8.8.8.8';
86
+ $port = 53;
87
+ $fp = @stream_socket_client("udp://$dnsServer:$port", $errno, $errstr, $timeout);
88
+ if (!$fp) return null;
89
+ stream_set_timeout($fp, 0, (int)($timeout * 1000000));
90
+
91
+ $id = rand(10000, 65000);
92
+ $header = pack('n6', $id, 0x0100, 1, 0, 0, 0);
93
+
94
+ $qType = ($type === 'PTR') ? 12 : (($type === 'AAAA') ? 28 : 1);
95
+ $parts = explode('.', $query);
96
+ $qName = '';
97
+ foreach ($parts as $part) {
98
+ $qName .= chr(strlen($part)) . $part;
99
+ }
100
+ $qName .= chr(0);
101
+ $question = $qName . pack('n2', $qType, 1);
102
+
103
+ if (!@fwrite($fp, $header . $question)) {
104
+ fclose($fp);
105
+ return null;
106
+ }
107
+
108
+ $response = @fread($fp, 512);
109
+ fclose($fp);
110
+ if (!$response || strlen($response) < 12) return null;
111
+
112
+ $resHeader = unpack('n6', substr($response, 0, 12));
113
+ if ($resHeader[1] !== $id) return null;
114
+
115
+ $answersCount = $resHeader[4];
116
+ if ($answersCount <= 0) return [];
117
+
118
+ $offset = 12 + strlen($question);
119
+ $results = [];
120
+ for ($i = 0; $i < $answersCount; $i++) {
121
+ if ($offset + 12 > strlen($response)) break;
122
+ if ((ord($response[$offset]) & 0xc0) === 0xc0) {
123
+ $offset += 2;
124
+ } else {
125
+ while ($offset < strlen($response) && ord($response[$offset]) !== 0) {
126
+ $offset += 1 + ord($response[$offset]);
127
+ }
128
+ $offset++;
129
+ }
130
+ if ($offset + 10 > strlen($response)) break;
131
+ $typeAndClass = unpack('n2type_class/Nttl/nlen', substr($response, $offset, 10));
132
+ $rType = $typeAndClass['type_class1'];
133
+ $rLen = $typeAndClass['len'];
134
+ $offset += 10;
135
+ if ($offset + $rLen > strlen($response)) break;
136
+ $rData = substr($response, $offset, $rLen);
137
+ $offset += $rLen;
138
+
139
+ if ($rType === 12) {
140
+ $ptrName = '';
141
+ $p = 0;
142
+ while ($p < strlen($rData)) {
143
+ $l = ord($rData[$p]);
144
+ if ($l === 0) break;
145
+ if (($l & 0xc0) === 0xc0) {
146
+ break;
147
+ }
148
+ $ptrName .= substr($rData, $p + 1, $l) . '.';
149
+ $p += 1 + $l;
150
+ }
151
+ $results[] = rtrim($ptrName, '.');
152
+ } elseif ($rType === 1) {
153
+ if ($rLen === 4) {
154
+ $results[] = long2ip(unpack('N', $rData)[1]);
155
+ }
156
+ } elseif ($rType === 28) {
157
+ if ($rLen === 16) {
158
+ $results[] = inet_ntop($rData);
159
+ }
160
+ }
161
+ }
162
+ return $results;
163
+ }
164
+
34
165
  public function __construct(array $securityConfig)
35
166
  {
36
167
  $this->isProduction = ($_ENV['APP_ENV'] ?? getenv('APP_ENV')) === 'production';
@@ -387,13 +518,19 @@
387
518
 
388
519
  $deviceId = bin2hex(random_bytes(16)); // UUID-like
389
520
 
521
+ $isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
522
+ (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
523
+ ($context->getHeader('x-forwarded-proto') === 'https');
524
+ $secureOption = $isHttps || $this->isProduction;
525
+
390
526
  // Préparer le cookie à envoyer
391
527
  $newCookie = [
392
528
  'name' => 'device_id',
393
529
  'value' => $deviceId,
394
530
  'options' => [
395
531
  'httponly' => true,
396
- 'secure' => $this->isProduction,
532
+ 'secure' => $secureOption,
533
+ 'partitioned' => $secureOption,
397
534
  'samesite' => 'Strict',
398
535
  'path' => '/',
399
536
  ]
@@ -852,7 +989,7 @@
852
989
  ]);
853
990
  if (json_last_error() === JSON_ERROR_NONE) {
854
991
  // @phpstan-ignore-next-line - L'instance est gérée par le singleton
855
- $defaultPath = dirname(__DIR__, 2) . '/problems.config.json';
992
+ $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
856
993
  $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
857
994
  $problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
858
995
  // FIX: La solution est directement le $workResult, pas une sous-propriété.
@@ -871,15 +1008,31 @@
871
1008
  if ($isValid) {
872
1009
  $store->delete("secret:{$powNonce}");
873
1010
  $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
874
- MetricsManager::incrementCounter('challenges_solved_total');
1011
+ MetricsManager::incrementCounter('challenges_solved_total');
875
1012
  $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
876
-
1013
+
1014
+ $isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
1015
+ (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
1016
+ ($context->getHeader('x-forwarded-proto') === 'https');
1017
+ $secureOption = $isHttps || $this->isProduction;
1018
+
877
1019
  return [
878
1020
  'action' => 'redirect',
879
1021
  'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
880
1022
  'score' => 0.0,
881
1023
  'vector' => ['challenge_solved' => 100],
882
- 'cookie' => ['name' => 'pow_clearance', 'value' => $ticket, 'options' => ['httponly' => true, 'secure' => $this->isProduction, 'expires' => time() + ($ticketTtl / 1000), 'path' => '/']]
1024
+ 'cookie' => [
1025
+ 'name' => 'pow_clearance',
1026
+ 'value' => $ticket,
1027
+ 'options' => [
1028
+ 'httponly' => true,
1029
+ 'secure' => $secureOption,
1030
+ 'samesite' => 'Strict',
1031
+ 'expires' => time() + ($ticketTtl / 1000),
1032
+ 'path' => '/',
1033
+ 'partitioned' => $secureOption,
1034
+ ]
1035
+ ]
883
1036
  ];
884
1037
  }
885
1038
  }
@@ -888,7 +1041,7 @@
888
1041
  $this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
889
1042
  $suspicionVector['honeypotScore'] = 100.0;
890
1043
  }
891
-
1044
+
892
1045
  // 3. Vérifier un ticket existant
893
1046
  $hasValidTicket = false;
894
1047
  $powCookie = $context->cookies['pow_clearance'] ?? null;
@@ -903,7 +1056,7 @@
903
1056
  // $this->log('Valid clearance ticket found');
904
1057
  // return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
905
1058
  }
906
-
1059
+
907
1060
  // 4. Calculer le vecteur et le score de suspicion
908
1061
  // Résoudre l'identité et vérifier le statut "condamné"
909
1062
  $store = StoreManager::getStore();
@@ -934,7 +1087,7 @@
934
1087
  RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
935
1088
  MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
936
1089
  }
937
-
1090
+
938
1091
  // Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
939
1092
  $isNewDevice = $identity['newCookie'] !== null;
940
1093
  if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
@@ -944,7 +1097,7 @@
944
1097
  ]);
945
1098
  $finalScore = (float)$thresholds['low'];
946
1099
  }
947
-
1100
+
948
1101
  // Vérifier les URL pièges (après calcul du score)
949
1102
  $lastNonce = $deviceData['lastChallengeNonce'] ?? null;
950
1103
  if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
@@ -963,7 +1116,7 @@
963
1116
  }
964
1117
  return $decision;
965
1118
  }
966
-
1119
+
967
1120
  // 5. Prendre une décision basée sur le score - Vérifier le blocage d'abord.
968
1121
  $blockThreshold = $thresholds['block'] ?? 95;
969
1122
  if ($finalScore >= $blockThreshold) {
@@ -1000,14 +1153,14 @@
1000
1153
  }
1001
1154
 
1002
1155
  $highThreshold = $thresholds['high'] ?? 75;
1003
- $mustReChallenge = $finalScore >= $highThreshold && $hasValidTicket;
1004
-
1156
+ $mustReChallenge = $finalScore >= $highThreshold && $hasValidTicket && $finalScore > 0;
1157
+
1005
1158
  $lowThreshold = $thresholds['low'] ?? 20;
1006
1159
  if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
1007
1160
  if ($mustReChallenge) {
1008
1161
  $this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
1009
1162
  }
1010
-
1163
+
1011
1164
  // --- AJOUT: Limiteur de débit (Token Bucket) ---
1012
1165
  $rateLimitPassed = ChallengeUtils::checkChallengeRateLimit($context->clientIp);
1013
1166
  if (!$rateLimitPassed) {
@@ -1027,9 +1180,9 @@
1027
1180
  }
1028
1181
  return $decision;
1029
1182
  }
1030
-
1183
+
1031
1184
  $decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
1032
-
1185
+
1033
1186
  if ($this->dryRun) {
1034
1187
  MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_challenge']);
1035
1188
  $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
@@ -1038,10 +1191,10 @@
1038
1191
  unset($decision['status']);
1039
1192
  return $decision;
1040
1193
  }
1041
-
1194
+
1042
1195
  MetricsManager::incrementCounter('requests_total', ['status' => 'challenged']);
1043
1196
  $this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
1044
-
1197
+
1045
1198
  $nonce = bin2hex(random_bytes(16));
1046
1199
  $clientSecret = bin2hex(random_bytes(16));
1047
1200
  $highThreshold = $thresholds['high'] ?? 75;
@@ -1062,7 +1215,7 @@
1062
1215
 
1063
1216
  if ($shouldUseUsefulWork) {
1064
1217
  $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
1065
- $defaultPath = dirname(__DIR__, 2) . '/problems.config.json';
1218
+ $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
1066
1219
  $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
1067
1220
  $problemManager = ProblemManager::getInstance($configPath, $store);
1068
1221
  $work = $problemManager->dispatchWork($finalScore);
@@ -1143,10 +1296,10 @@
1143
1296
  // Alignement linéaire parfait du ratio d'effort CPU/Mémoire
1144
1297
  $memActivationFactor = $suspicionFactor;
1145
1298
  $memDifficulty = (int)round($memActivationFactor * 48); // 0 à 48MB
1146
-
1299
+
1147
1300
  $originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
1148
1301
  $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint);
1149
-
1302
+
1150
1303
  $challengeContext = [
1151
1304
  'clientSecret' => $clientSecret,
1152
1305
  'cpuTarget' => $cpuChallengeDetails['target'],
@@ -1156,22 +1309,22 @@
1156
1309
  'baseBlock' => $baseBlock,
1157
1310
  'originalPath' => $context->path,
1158
1311
  ];
1159
-
1312
+
1160
1313
  $store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
1161
-
1314
+
1162
1315
  // Associer le nonce au device pour la vérification des URL pièges
1163
1316
  if ($deviceData) {
1164
1317
  $deviceData['lastChallengeNonce'] = $nonce;
1165
1318
  $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1166
1319
  }
1167
-
1320
+
1168
1321
  $trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
1169
1322
  $this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
1170
-
1323
+
1171
1324
  if ($this->logger) {
1172
1325
  $this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1173
1326
  }
1174
-
1327
+
1175
1328
  // Pour les API, retourner un challenge JSON
1176
1329
  if ($isApiRequest) {
1177
1330
  $challengePayload = [
@@ -1188,7 +1341,7 @@
1188
1341
  } else {
1189
1342
  // Pour les navigateurs, retourner une page HTML
1190
1343
  $pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
1191
- $cpuChallengeDetails, $memDifficulty, $clientSecret,
1344
+ $cpuChallengeDetails, $memDifficulty, $clientSecret,
1192
1345
  $this->securityConfig, $trapUrls, $originalFingerprint
1193
1346
  );
1194
1347
  $decision['body'] = $pageBody;
@@ -1210,12 +1363,12 @@
1210
1363
  $response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
1211
1364
  }
1212
1365
  }
1213
-
1366
+
1214
1367
  // Si un nouveau cookie d'identification a été généré, on l'ajoute à la réponse.
1215
1368
  if (isset($context->newCookieForResponse)) {
1216
1369
  $response['newCookieForResponse'] = $context->newCookieForResponse;
1217
1370
  }
1218
-
1371
+
1219
1372
  return $response;
1220
1373
  }
1221
1374
 
@@ -1227,7 +1380,7 @@
1227
1380
  if (empty($operationType) || empty($operationName)) {
1228
1381
  return false;
1229
1382
  }
1230
-
1383
+
1231
1384
  $whitelistRules = $this->securityConfig['whitelist'] ?? [];
1232
1385
  $graphqlRule = null;
1233
1386
  foreach ($whitelistRules as $rule) {
@@ -1236,23 +1389,23 @@
1236
1389
  break;
1237
1390
  }
1238
1391
  }
1239
-
1392
+
1240
1393
  if (empty($graphqlRule['entries'])) {
1241
1394
  return false;
1242
1395
  }
1243
-
1396
+
1244
1397
  foreach ($graphqlRule['entries'] as $entry) {
1245
1398
  [$entryType, $entryName] = explode(':', $entry, 2);
1246
1399
  if ($entryType !== $operationType) continue;
1247
-
1400
+
1248
1401
  if ($entryName === $operationName || $entryName === '*') return true;
1249
-
1402
+
1250
1403
  if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
1251
1404
  }
1252
-
1405
+
1253
1406
  return false;
1254
1407
  }
1255
-
1408
+
1256
1409
  /**
1257
1410
  * Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
1258
1411
  * en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
@@ -1284,44 +1437,67 @@
1284
1437
  if ($cachedStatus === 'verified') return true;
1285
1438
  if ($cachedStatus === 'failed') return false;
1286
1439
 
1440
+ if (!self::canAttemptDns()) {
1441
+ return false;
1442
+ }
1443
+
1287
1444
  try {
1288
- // 1. Reverse DNS lookup. gethostbyaddr peut être lent, mais c'est la méthode standard.
1289
- // @ pour supprimer les warnings si l'IP n'a pas de PTR record.
1290
- $hostname = @gethostbyaddr($context->clientIp);
1291
- if ($hostname === false || $hostname === $context->clientIp) {
1292
- $store->set($cacheKey, 'failed', 86400);
1445
+ // 1. Reverse DNS lookup with strict 500ms timeout using our custom UDP DNS Client
1446
+ $revName = self::getReverseDnsName($context->client_ip);
1447
+ if (!$revName) {
1448
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1449
+ return false;
1450
+ }
1451
+ $hostnames = self::dnsResolveUdp($revName, 'PTR', 0.5);
1452
+ if ($hostnames === null || empty($hostnames)) {
1453
+ self::recordDnsFailure();
1454
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1293
1455
  return false;
1294
1456
  }
1295
1457
 
1296
1458
  $validHostname = null;
1297
- if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
1298
- $validHostname = $hostname;
1459
+ foreach ($hostnames as $hostname) {
1460
+ if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
1461
+ $validHostname = $hostname;
1462
+ break;
1463
+ }
1299
1464
  }
1300
1465
 
1301
1466
  if ($validHostname === null) {
1302
- $store->set($cacheKey, 'failed', 86400);
1467
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1303
1468
  return false;
1304
1469
  }
1305
-
1306
- // 2. Forward DNS lookup
1307
- $addresses = array_merge(dns_get_record($validHostname, DNS_A) ?: [], dns_get_record($validHostname, DNS_AAAA) ?: []);
1470
+
1471
+ // 2. Forward DNS lookup with strict 500ms timeout
1308
1472
  $ips = [];
1309
- foreach ($addresses as $address) {
1310
- if (isset($address['ip'])) {
1311
- $ips[] = $address['ip'];
1312
- }
1313
- if (isset($address['ipv6'])) {
1314
- $ips[] = $address['ipv6'];
1315
- }
1473
+ $resolvedA = self::dnsResolveUdp($validHostname, 'A', 0.5);
1474
+ if ($resolvedA === null) {
1475
+ self::recordDnsFailure();
1476
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1477
+ return false;
1316
1478
  }
1317
-
1318
- if (in_array($context->clientIp, $ips, true)) {
1479
+ $ips = array_merge($ips, $resolvedA);
1480
+
1481
+ $resolvedAaaa = self::dnsResolveUdp($validHostname, 'AAAA', 0.5);
1482
+ if ($resolvedAaaa === null) {
1483
+ self::recordDnsFailure();
1484
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1485
+ return false;
1486
+ }
1487
+ $ips = array_merge($ips, $resolvedAaaa);
1488
+
1489
+ if (in_array($context->client_ip, $ips, true)) {
1490
+ self::recordDnsSuccess();
1319
1491
  $store->set($cacheKey, 'verified', 86400);
1320
1492
  return true;
1321
1493
  }
1322
- } catch (\Exception $e) { /* DNS errors */ }
1494
+ } catch (\Exception $e) {
1495
+ self::recordDnsFailure();
1496
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1497
+ return false;
1498
+ }
1323
1499
 
1324
- $store->set($cacheKey, 'failed', 86400);
1500
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1325
1501
  return false;
1326
1502
  }
1327
1503
 
@@ -224,31 +224,38 @@ class Optimization
224
224
  */
225
225
  public static function benfordTest(array $numbers): float // Rendre la méthode publique et statique
226
226
  {
227
- if (count($numbers) < 10) {
228
- return 0.0; // Pas assez de données
229
- }
230
-
231
- $leadingDigits = array_map(function ($n) {
232
- $str = ltrim((string)$n, '0.');
233
- return $str[0] ?? '';
234
- }, $numbers);
235
-
236
- $leadingDigits = array_filter($leadingDigits, fn ($d) => $d >= '1' && $d <= '9');
227
+ $counts = array_fill(1, 9, 0);
228
+ $validCount = 0;
237
229
 
238
- if (count($leadingDigits) < 10) {
239
- return 0.0;
230
+ foreach ($numbers as $n) {
231
+ if (is_string($n)) {
232
+ $n = (float)$n;
233
+ }
234
+ if (!is_numeric($n)) {
235
+ continue;
236
+ }
237
+ $val = abs((float)$n);
238
+ if ($val == 0.0) {
239
+ continue;
240
+ }
241
+ $log = log10($val);
242
+ $factor = pow(10, (int)floor($log));
243
+ $digit = (int)floor($val / $factor);
244
+ if ($digit >= 1 && $digit <= 9) {
245
+ $counts[$digit]++;
246
+ $validCount++;
247
+ }
240
248
  }
241
249
 
242
- $counts = array_fill(1, 9, 0);
243
- foreach ($leadingDigits as $digit) {
244
- $counts[(int)$digit]++;
250
+ if ($validCount < 10) {
251
+ return 0.0;
245
252
  }
246
253
 
247
254
  $benfordDistribution = [1 => 30.1, 2 => 17.6, 3 => 12.5, 4 => 9.7, 5 => 7.9, 6 => 6.7, 7 => 5.8, 8 => 5.1, 9 => 4.6];
248
255
 
249
256
  $totalDeviation = 0.0;
250
257
  for ($i = 1; $i <= 9; $i++) {
251
- $observedFrequency = ($counts[$i] / count($leadingDigits)) * 100;
258
+ $observedFrequency = ($counts[$i] / $validCount) * 100.0;
252
259
  $expectedFrequency = $benfordDistribution[$i];
253
260
  $totalDeviation += pow($observedFrequency - $expectedFrequency, 2);
254
261
  }