@anonympins/fingerprint 0.5.1 → 0.5.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +118 -115
  3. package/package.json +1 -1
  4. package/src/js/dynamic-wasm.js +52 -13
  5. package/src/js/fingerprint.builder.js +169 -168
  6. package/src/js/fingerprint.client.js +1271 -1189
  7. package/src/js/fingerprint.client.obfuscated.js +1 -1
  8. package/src/js/fingerprint.js +436 -162
  9. package/src/js/fingerprint.utils.js +30 -0
  10. package/src/js/gpu_pow.solver.js +96 -1
  11. package/src/js/library.js +40 -21
  12. package/src/js/mongodb-store.js +165 -79
  13. package/src/js/pow.solver.inline.js +627 -30
  14. package/src/js/pow.solver.js +453 -31
  15. package/src/js/problem-manager.js +1 -1
  16. package/src/js/redis-store.js +114 -42
  17. package/src/js/tests/dns-circuit-breaker.test.js +55 -0
  18. package/src/js/tests/fingerprint.client.init.test.js +143 -143
  19. package/src/js/tests/fingerprint.client.test.js +128 -128
  20. package/src/js/tests/fingerprint.engine.test.js +370 -370
  21. package/src/js/tests/fingerprint.test.js +119 -5
  22. package/src/js/tests/gpu_pow.test.js +81 -0
  23. package/src/js/tests/ip-reputation.test.js +1 -1
  24. package/src/js/tests/pow.solver.test.js +9 -6
  25. package/src/js/tests/usefulWorkSolver.test.js +162 -0
  26. package/src/php/Challenge/ChallengeUtils.php +137 -35
  27. package/src/php/FingerprintClient.php +22 -5
  28. package/src/php/FingerprintEngine.php +335 -58
  29. package/src/php/Optimization/Optimization.php +23 -16
  30. package/src/php/Optimization/OptimizationOperators.php +27 -0
  31. package/src/php/ProblemManager.php +1 -1
  32. package/src/php/Store/MongoDbStore.php +184 -104
  33. package/src/php/Store/RedisStore.php +91 -11
  34. package/src/php/Tests/ChallengeUtilsTest.php +285 -285
  35. package/src/php/Tests/DnsCircuitBreakerTest.php +106 -0
  36. package/src/php/Tests/IpReputationTest.php +1 -1
  37. package/src/php/Tests/PowTest.php +1 -1
  38. package/src/php/Tests/RequestUtilsTest.php +29 -0
  39. package/src/php/Tests/TLSClientHelloParserTest.php +177 -117
  40. package/src/php/Utils/BigInt.php +202 -202
  41. package/src/php/Utils/RequestUtils.php +74 -21
  42. package/src/php/Utils/TLSClientHelloParser.php +212 -116
@@ -27,6 +27,141 @@
27
27
  private ?Logger $logger = null;
28
28
  private bool $dryRun;
29
29
 
30
+ private static ?array $googlebotEntries = null;
31
+ private static ?array $yandexEntries = null;
32
+ private static ?array $bingbotEntries = null;
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
+
30
165
  public function __construct(array $securityConfig)
31
166
  {
32
167
  $this->isProduction = ($_ENV['APP_ENV'] ?? getenv('APP_ENV')) === 'production';
@@ -103,7 +238,103 @@
103
238
  }
104
239
  }
105
240
  }
241
+ private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
242
+ {
243
+ $configDir = dirname(__DIR__, 2) . '/config';
244
+ $filePath = $configDir . '/' . $filename;
245
+ if (file_exists($filePath)) {
246
+ try {
247
+ $content = file_get_contents($filePath);
248
+ if ($content !== false) {
249
+ $decoded = json_decode($content, true);
250
+ if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
251
+ return $decoded;
252
+ }
253
+ }
254
+ } catch (\Throwable $e) {
255
+ error_log("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
256
+ }
257
+ }
258
+ return $fallbackEntries;
259
+ }
260
+
261
+ public static function googlebot_whitelist(): array
262
+ {
263
+ if (self::$googlebotEntries === null) {
264
+ self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
265
+ "2001:4860:4801:10::/64",
266
+ "2001:4860:4801:11::/64",
267
+ "2001:4860:4801:12::/64",
268
+ "66.249.79.64"
269
+ ]);
270
+ }
271
+ return [
272
+ 'type' => 'allowlist',
273
+ 'entries' => self::$googlebotEntries
274
+ ];
275
+ }
276
+
277
+ public static function yandex_whitelist(): array
278
+ {
279
+ if (self::$yandexEntries === null) {
280
+ self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
281
+ "2a02:6b8::/29",
282
+ "5.45.192.0/18",
283
+ "213.180.192.0/19"
284
+ ]);
285
+ }
286
+ return [
287
+ 'type' => 'allowlist',
288
+ 'entries' => self::$yandexEntries
289
+ ];
290
+ }
106
291
 
292
+ public static function bingbot_whitelist(): array
293
+ {
294
+ if (self::$bingbotEntries === null) {
295
+ self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
296
+ "157.55.39.0/24",
297
+ "207.46.13.0/24",
298
+ "40.77.178.0/23"
299
+ ]);
300
+ }
301
+ return [
302
+ 'type' => 'allowlist',
303
+ 'entries' => self::$bingbotEntries
304
+ ];
305
+ }
306
+
307
+ public static function default_whitelist(): array
308
+ {
309
+ return [
310
+ self::googlebot_whitelist(),
311
+ self::bingbot_whitelist(),
312
+ self::yandex_whitelist(),
313
+ ['userAgent' => 'Googlebot', 'hostnameSuffix' => '.googlebot.com'],
314
+ ['userAgent' => 'Google-Extended', 'hostnameSuffix' => '.google.com'],
315
+ ['userAgent' => 'AdsBot-Google', 'hostnameSuffix' => '.googlebot.com'],
316
+ ['userAgent' => 'Mediapartners-Google', 'hostnameSuffix' => '.google.com'],
317
+ ['userAgent' => 'Google-InspectionTool', 'hostnameSuffix' => '.google.com'],
318
+ ['userAgent' => '(bingbot|adidxbot)', 'hostnameSuffix' => '.search.msn.com'],
319
+ ['userAgent' => 'DuckDuckBot', 'hostnameSuffix' => '.duckduckgo.com'],
320
+ ['userAgent' => 'YandexBot', 'hostnameSuffix' => '.yandex.com'],
321
+ ['userAgent' => 'YandexImages', 'hostnameSuffix' => '.yandex.com'],
322
+ ['userAgent' => 'Baiduspider', 'hostnameSuffix' => '.crawl.baidu.com'],
323
+ ['userAgent' => 'Slurp', 'hostnameSuffix' => '.crawl.yahoo.net'],
324
+ ['userAgent' => 'Sogou web spider', 'hostnameSuffix' => '.sogou.com'],
325
+ ['userAgent' => 'Exabot', 'hostnameSuffix' => '.exabot.com'],
326
+ ['userAgent' => 'ia_archiver', 'hostnameSuffix' => '.alexa.com'],
327
+ ['userAgent' => 'SeznamBot', 'hostnameSuffix' => '.seznam.cz'],
328
+ ['userAgent' => 'Mail.RU_Bot', 'hostnameSuffix' => '.mail.ru'],
329
+ ['userAgent' => 'Yeti', 'hostnameSuffix' => '.naver.com'],
330
+ ['userAgent' => 'AhrefsBot', 'hostnameSuffix' => '.ahrefs.com'],
331
+ ['userAgent' => 'SemrushBot', 'hostnameSuffix' => '.semrush.com'],
332
+ ['userAgent' => 'MJ12bot', 'hostnameSuffix' => '.mj12bot.com'],
333
+ ['userAgent' => 'rogerbot', 'hostnameSuffix' => '.moz.com'],
334
+ ['userAgent' => 'DotBot', 'hostnameSuffix' => '.moz.com'],
335
+ ['userAgent' => 'Screaming Frog SEO Spider', 'hostnameSuffix' => '.screamingfrog.co.uk'],
336
+ ];
337
+ }
107
338
  private function log(string $message, array $data = [], string $level = 'info'): void
108
339
  {
109
340
  if ($this->verbose) {
@@ -287,13 +518,19 @@
287
518
 
288
519
  $deviceId = bin2hex(random_bytes(16)); // UUID-like
289
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
+
290
526
  // Préparer le cookie à envoyer
291
527
  $newCookie = [
292
528
  'name' => 'device_id',
293
529
  'value' => $deviceId,
294
530
  'options' => [
295
531
  'httponly' => true,
296
- 'secure' => $this->isProduction,
532
+ 'secure' => $secureOption,
533
+ 'partitioned' => $secureOption,
297
534
  'samesite' => 'Strict',
298
535
  'path' => '/',
299
536
  ]
@@ -752,7 +989,7 @@
752
989
  ]);
753
990
  if (json_last_error() === JSON_ERROR_NONE) {
754
991
  // @phpstan-ignore-next-line - L'instance est gérée par le singleton
755
- $defaultPath = dirname(__DIR__, 2) . '/problems.config.json';
992
+ $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
756
993
  $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
757
994
  $problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
758
995
  // FIX: La solution est directement le $workResult, pas une sous-propriété.
@@ -771,15 +1008,31 @@
771
1008
  if ($isValid) {
772
1009
  $store->delete("secret:{$powNonce}");
773
1010
  $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
774
- MetricsManager::incrementCounter('challenges_solved_total');
1011
+ MetricsManager::incrementCounter('challenges_solved_total');
775
1012
  $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
776
-
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
+
777
1019
  return [
778
1020
  'action' => 'redirect',
779
1021
  'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
780
1022
  'score' => 0.0,
781
1023
  'vector' => ['challenge_solved' => 100],
782
- '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
+ ]
783
1036
  ];
784
1037
  }
785
1038
  }
@@ -788,7 +1041,7 @@
788
1041
  $this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
789
1042
  $suspicionVector['honeypotScore'] = 100.0;
790
1043
  }
791
-
1044
+
792
1045
  // 3. Vérifier un ticket existant
793
1046
  $hasValidTicket = false;
794
1047
  $powCookie = $context->cookies['pow_clearance'] ?? null;
@@ -803,7 +1056,7 @@
803
1056
  // $this->log('Valid clearance ticket found');
804
1057
  // return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
805
1058
  }
806
-
1059
+
807
1060
  // 4. Calculer le vecteur et le score de suspicion
808
1061
  // Résoudre l'identité et vérifier le statut "condamné"
809
1062
  $store = StoreManager::getStore();
@@ -834,7 +1087,7 @@
834
1087
  RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
835
1088
  MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
836
1089
  }
837
-
1090
+
838
1091
  // Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
839
1092
  $isNewDevice = $identity['newCookie'] !== null;
840
1093
  if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
@@ -844,7 +1097,7 @@
844
1097
  ]);
845
1098
  $finalScore = (float)$thresholds['low'];
846
1099
  }
847
-
1100
+
848
1101
  // Vérifier les URL pièges (après calcul du score)
849
1102
  $lastNonce = $deviceData['lastChallengeNonce'] ?? null;
850
1103
  if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
@@ -863,7 +1116,7 @@
863
1116
  }
864
1117
  return $decision;
865
1118
  }
866
-
1119
+
867
1120
  // 5. Prendre une décision basée sur le score - Vérifier le blocage d'abord.
868
1121
  $blockThreshold = $thresholds['block'] ?? 95;
869
1122
  if ($finalScore >= $blockThreshold) {
@@ -901,13 +1154,13 @@
901
1154
 
902
1155
  $highThreshold = $thresholds['high'] ?? 75;
903
1156
  $mustReChallenge = $finalScore >= $highThreshold && $hasValidTicket;
904
-
1157
+
905
1158
  $lowThreshold = $thresholds['low'] ?? 20;
906
1159
  if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
907
1160
  if ($mustReChallenge) {
908
1161
  $this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
909
1162
  }
910
-
1163
+
911
1164
  // --- AJOUT: Limiteur de débit (Token Bucket) ---
912
1165
  $rateLimitPassed = ChallengeUtils::checkChallengeRateLimit($context->clientIp);
913
1166
  if (!$rateLimitPassed) {
@@ -927,9 +1180,9 @@
927
1180
  }
928
1181
  return $decision;
929
1182
  }
930
-
1183
+
931
1184
  $decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
932
-
1185
+
933
1186
  if ($this->dryRun) {
934
1187
  MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_challenge']);
935
1188
  $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
@@ -938,10 +1191,10 @@
938
1191
  unset($decision['status']);
939
1192
  return $decision;
940
1193
  }
941
-
1194
+
942
1195
  MetricsManager::incrementCounter('requests_total', ['status' => 'challenged']);
943
1196
  $this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
944
-
1197
+
945
1198
  $nonce = bin2hex(random_bytes(16));
946
1199
  $clientSecret = bin2hex(random_bytes(16));
947
1200
  $highThreshold = $thresholds['high'] ?? 75;
@@ -962,7 +1215,7 @@
962
1215
 
963
1216
  if ($shouldUseUsefulWork) {
964
1217
  $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
965
- $defaultPath = dirname(__DIR__, 2) . '/problems.config.json';
1218
+ $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
966
1219
  $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
967
1220
  $problemManager = ProblemManager::getInstance($configPath, $store);
968
1221
  $work = $problemManager->dispatchWork($finalScore);
@@ -1039,13 +1292,14 @@
1039
1292
  'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
1040
1293
  'path' => $context->path,
1041
1294
  ];
1042
-
1043
- $memActivationFactor = max(0, ($suspicionFactor - 0.25) / 0.75);
1295
+
1296
+ // Alignement linéaire parfait du ratio d'effort CPU/Mémoire
1297
+ $memActivationFactor = $suspicionFactor;
1044
1298
  $memDifficulty = (int)round($memActivationFactor * 48); // 0 à 48MB
1045
-
1299
+
1046
1300
  $originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
1047
1301
  $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint);
1048
-
1302
+
1049
1303
  $challengeContext = [
1050
1304
  'clientSecret' => $clientSecret,
1051
1305
  'cpuTarget' => $cpuChallengeDetails['target'],
@@ -1055,22 +1309,22 @@
1055
1309
  'baseBlock' => $baseBlock,
1056
1310
  'originalPath' => $context->path,
1057
1311
  ];
1058
-
1312
+
1059
1313
  $store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
1060
-
1314
+
1061
1315
  // Associer le nonce au device pour la vérification des URL pièges
1062
1316
  if ($deviceData) {
1063
1317
  $deviceData['lastChallengeNonce'] = $nonce;
1064
1318
  $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1065
1319
  }
1066
-
1320
+
1067
1321
  $trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
1068
1322
  $this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
1069
-
1323
+
1070
1324
  if ($this->logger) {
1071
1325
  $this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1072
1326
  }
1073
-
1327
+
1074
1328
  // Pour les API, retourner un challenge JSON
1075
1329
  if ($isApiRequest) {
1076
1330
  $challengePayload = [
@@ -1087,7 +1341,7 @@
1087
1341
  } else {
1088
1342
  // Pour les navigateurs, retourner une page HTML
1089
1343
  $pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
1090
- $cpuChallengeDetails, $memDifficulty, $clientSecret,
1344
+ $cpuChallengeDetails, $memDifficulty, $clientSecret,
1091
1345
  $this->securityConfig, $trapUrls, $originalFingerprint
1092
1346
  );
1093
1347
  $decision['body'] = $pageBody;
@@ -1109,12 +1363,12 @@
1109
1363
  $response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
1110
1364
  }
1111
1365
  }
1112
-
1366
+
1113
1367
  // Si un nouveau cookie d'identification a été généré, on l'ajoute à la réponse.
1114
1368
  if (isset($context->newCookieForResponse)) {
1115
1369
  $response['newCookieForResponse'] = $context->newCookieForResponse;
1116
1370
  }
1117
-
1371
+
1118
1372
  return $response;
1119
1373
  }
1120
1374
 
@@ -1126,7 +1380,7 @@
1126
1380
  if (empty($operationType) || empty($operationName)) {
1127
1381
  return false;
1128
1382
  }
1129
-
1383
+
1130
1384
  $whitelistRules = $this->securityConfig['whitelist'] ?? [];
1131
1385
  $graphqlRule = null;
1132
1386
  foreach ($whitelistRules as $rule) {
@@ -1135,23 +1389,23 @@
1135
1389
  break;
1136
1390
  }
1137
1391
  }
1138
-
1392
+
1139
1393
  if (empty($graphqlRule['entries'])) {
1140
1394
  return false;
1141
1395
  }
1142
-
1396
+
1143
1397
  foreach ($graphqlRule['entries'] as $entry) {
1144
1398
  [$entryType, $entryName] = explode(':', $entry, 2);
1145
1399
  if ($entryType !== $operationType) continue;
1146
-
1400
+
1147
1401
  if ($entryName === $operationName || $entryName === '*') return true;
1148
-
1402
+
1149
1403
  if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
1150
1404
  }
1151
-
1405
+
1152
1406
  return false;
1153
1407
  }
1154
-
1408
+
1155
1409
  /**
1156
1410
  * Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
1157
1411
  * en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
@@ -1183,44 +1437,67 @@
1183
1437
  if ($cachedStatus === 'verified') return true;
1184
1438
  if ($cachedStatus === 'failed') return false;
1185
1439
 
1440
+ if (!self::canAttemptDns()) {
1441
+ return false;
1442
+ }
1443
+
1186
1444
  try {
1187
- // 1. Reverse DNS lookup. gethostbyaddr peut être lent, mais c'est la méthode standard.
1188
- // @ pour supprimer les warnings si l'IP n'a pas de PTR record.
1189
- $hostname = @gethostbyaddr($context->clientIp);
1190
- if ($hostname === false || $hostname === $context->clientIp) {
1191
- $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
1192
1455
  return false;
1193
1456
  }
1194
1457
 
1195
1458
  $validHostname = null;
1196
- if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
1197
- $validHostname = $hostname;
1459
+ foreach ($hostnames as $hostname) {
1460
+ if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
1461
+ $validHostname = $hostname;
1462
+ break;
1463
+ }
1198
1464
  }
1199
1465
 
1200
1466
  if ($validHostname === null) {
1201
- $store->set($cacheKey, 'failed', 86400);
1467
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1202
1468
  return false;
1203
1469
  }
1204
-
1205
- // 2. Forward DNS lookup
1206
- $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
1207
1472
  $ips = [];
1208
- foreach ($addresses as $address) {
1209
- if (isset($address['ip'])) {
1210
- $ips[] = $address['ip'];
1211
- }
1212
- if (isset($address['ipv6'])) {
1213
- $ips[] = $address['ipv6'];
1214
- }
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;
1215
1478
  }
1216
-
1217
- 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();
1218
1491
  $store->set($cacheKey, 'verified', 86400);
1219
1492
  return true;
1220
1493
  }
1221
- } 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
+ }
1222
1499
 
1223
- $store->set($cacheKey, 'failed', 86400);
1500
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1224
1501
  return false;
1225
1502
  }
1226
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
  }