@anonympins/fingerprint 0.5.2 → 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 (35) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +1 -1
  3. package/src/js/fingerprint.client.js +1271 -1191
  4. package/src/js/fingerprint.js +202 -108
  5. package/src/js/fingerprint.utils.js +213 -213
  6. package/src/js/library.js +24 -8
  7. package/src/js/mongodb-store.js +165 -79
  8. package/src/js/pow.solver.inline.js +500 -14
  9. package/src/js/pow.solver.js +323 -14
  10. package/src/js/problem-manager.js +1 -1
  11. package/src/js/redis-store.js +114 -42
  12. package/src/js/tests/dns-circuit-breaker.test.js +55 -0
  13. package/src/js/tests/fingerprint.client.init.test.js +143 -143
  14. package/src/js/tests/fingerprint.client.test.js +128 -128
  15. package/src/js/tests/fingerprint.test.js +32 -2
  16. package/src/js/tests/ip-reputation.test.js +1 -1
  17. package/src/js/tests/pow.solver.test.js +5 -2
  18. package/src/js/tests/usefulWorkSolver.test.js +162 -0
  19. package/src/php/Challenge/ChallengeUtils.php +21 -8
  20. package/src/php/FingerprintClient.php +22 -5
  21. package/src/php/FingerprintEngine.php +232 -56
  22. package/src/php/Optimization/Optimization.php +23 -16
  23. package/src/php/Optimization/OptimizationOperators.php +27 -0
  24. package/src/php/ProblemManager.php +1 -1
  25. package/src/php/Store/MongoDbStore.php +184 -104
  26. package/src/php/Store/RedisStore.php +91 -11
  27. package/src/php/Tests/ChallengeUtilsTest.php +285 -285
  28. package/src/php/Tests/DnsCircuitBreakerTest.php +106 -0
  29. package/src/php/Tests/IpReputationTest.php +1 -1
  30. package/src/php/Tests/PowTest.php +1 -1
  31. package/src/php/Tests/RequestUtilsTest.php +29 -0
  32. package/src/php/Tests/TLSClientHelloParserTest.php +177 -117
  33. package/src/php/Utils/BigInt.php +202 -202
  34. package/src/php/Utils/RequestUtils.php +74 -21
  35. package/src/php/Utils/TLSClientHelloParser.php +212 -116
@@ -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) {
@@ -1001,13 +1154,13 @@
1001
1154
 
1002
1155
  $highThreshold = $thresholds['high'] ?? 75;
1003
1156
  $mustReChallenge = $finalScore >= $highThreshold && $hasValidTicket;
1004
-
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
  }
@@ -9,6 +9,33 @@ namespace Anonympins\Fingerprint\Optimization;
9
9
  */
10
10
  class OptimizationOperators
11
11
  {
12
+ /**
13
+ * Crée une fonction de sélection par tournoi pour un algorithme génétique.
14
+ * @param array $options Options pour le tournoi.
15
+ * @return callable Une fonction de sélection.
16
+ */
17
+ public static function createTournamentSelection(array $options = []): callable
18
+ {
19
+ $tournamentSize = $options['size'] ?? 5;
20
+
21
+ return function (array $population) use ($tournamentSize) {
22
+ $best = null;
23
+ $count = count($population);
24
+
25
+ for ($i = 0; $i < $tournamentSize; $i++) {
26
+ $individual = $population[random_int(0, $count - 1)];
27
+ $individualFitness = $individual['fitness'] ?? (isset($individual['objectives']) ? array_sum($individual['objectives']) : INF);
28
+ $bestFitness = $best !== null ? ($best['fitness'] ?? (isset($best['objectives']) ? array_sum($best['objectives']) : INF)) : INF;
29
+
30
+ if ($best === null || $individualFitness < $bestFitness) {
31
+ $best = $individual;
32
+ }
33
+ }
34
+
35
+ return $best ?? $population[random_int(0, $count - 1)];
36
+ };
37
+ }
38
+
12
39
  /**
13
40
  * Génère un nombre flottant aléatoire cryptographiquement sûr entre 0 (inclus) et 1 (exclus).
14
41
  */
@@ -36,7 +36,7 @@ class ProblemManager
36
36
  {
37
37
  if (self::$instance === null) {
38
38
  if ($configPath === null) {
39
- $defaultPath = dirname(__DIR__, 2) . '/problems.config.json';
39
+ $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
40
40
  $configPath = file_exists($defaultPath) ? $defaultPath : null;
41
41
  }
42
42
  // Si on essaie d'obtenir l'instance sans l'initialiser d'abord, c'est une erreur.