@anonympins/fingerprint 0.4.0 → 0.4.2

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.
@@ -1,1006 +1,1010 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- namespace Anonympins\Fingerprint;
6
-
7
- use Anonympins\Fingerprint\Challenge\ChallengeUtils;
8
- use Anonympins\Fingerprint\Config\SecurityProfiles;
9
- use Anonympins\Fingerprint\Store\StoreManager;
10
- use Anonympins\Fingerprint\Utils\BlockList;
11
- use Anonympins\Fingerprint\Utils\Logger;
12
- use Anonympins\Fingerprint\Utils\MetricsManager;
13
- use Anonympins\Fingerprint\Utils\RequestUtils;
14
-
15
- // Correction de l'import
16
-
17
- /**
18
- * Le moteur principal de la bibliothèque de fingerprinting.
19
- * Orchestre l'identification, le calcul de suspicion et la gestion des challenges.
20
- */
21
- class FingerprintEngine
22
- {
23
- private array $securityConfig;
24
- private bool $isProduction;
25
- private BlockList $allowlist;
26
- private bool $verbose;
27
- private ?Logger $logger = null;
28
- private bool $dryRun;
29
-
30
- public function __construct(array $securityConfig)
31
- {
32
- $this->isProduction = ($_ENV['APP_ENV'] ?? getenv('APP_ENV')) === 'production';
33
- $this->securityConfig = $securityConfig;
34
- $this->verbose = $securityConfig['verbose'] ?? false;
35
- $this->allowlist = $this->buildAllowlist();
36
- $this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
37
- $this->dryRun = $securityConfig['dryRun'] ?? false;
38
- $this->validateConfig($securityConfig);
39
- }
40
-
41
- private function validateConfig(array $config): void
42
- {
43
- if (empty($config)) {
44
- $this->log('Warning: No securityConfig provided. Using default behaviors.', [], 'warn');
45
- return;
46
- }
47
-
48
- $knownKeys = [
49
- 'weights', 'thresholds', 'cpu', 'ticketMaxAge', 'challengeTtl',
50
- 'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
51
- 'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
52
- 'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
53
- 'similarityThreshold', 'summary', 'description'
54
- ];
55
-
56
- if (empty($config['weights'])) {
57
- $this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
58
- }
59
- if (empty($config['thresholds'])) {
60
- $this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
61
- }
62
-
63
- foreach (array_keys($config) as $key) {
64
- if (!in_array($key, $knownKeys, true)) {
65
- $this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
66
- }
67
- }
68
- }
69
-
70
- private function log(string $message, array $data = [], string $level = 'info'): void
71
- {
72
- if ($this->verbose) {
73
- // Utilise le logger s'il est configuré, sinon error_log
74
- if ($this->logger) {
75
- $this->logger->log($level, "[FingerprintEngine] " . $message, $data);
76
- } else {
77
- $logMessage = "[FingerprintEngine] {$message}";
78
- if (!empty($data)) $logMessage .= ' ' . json_encode($data);
79
- error_log($logMessage);
80
- }
81
- }
82
- }
83
-
84
- public function calculateFinalScore(array $suspicionVector): float
85
- {
86
- $weights = $this->securityConfig['weights'] ?? [];
87
- if (empty($weights)) {
88
- return 0.0;
89
- }
90
-
91
- $score = 0.0;
92
- foreach ($weights as $key => $weight) {
93
- $score += ($suspicionVector[$key] ?? 0) * $weight;
94
- }
95
-
96
- return min(100.0, $score);
97
- }
98
-
99
- private function buildAllowlist(): BlockList
100
- {
101
- $blockList = new BlockList();
102
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
103
-
104
- $allowlistRule = null;
105
- foreach ($whitelistRules as $rule) {
106
- if (($rule['type'] ?? '') === 'allowlist') {
107
- $allowlistRule = $rule;
108
- break;
109
- }
110
- }
111
-
112
- if (empty($allowlistRule['entries'])) {
113
- return $blockList;
114
- }
115
-
116
- foreach ($allowlistRule['entries'] as $entry) {
117
- $blockList->add($entry);
118
- }
119
- return $blockList;
120
- }
121
-
122
- private function isIpInAllowlist(string $clientIp): bool
123
- {
124
- return $this->allowlist->check($clientIp);
125
- }
126
-
127
- private function isPathInAllowlist(string $requestPath): bool
128
- {
129
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
130
- $pathAllowlistRule = null;
131
- foreach ($whitelistRules as $rule) {
132
- if (($rule['type'] ?? '') === 'path_allowlist') {
133
- $pathAllowlistRule = $rule;
134
- break;
135
- }
136
- }
137
-
138
- if (empty($pathAllowlistRule['entries'])) {
139
- return false;
140
- }
141
-
142
- foreach ($pathAllowlistRule['entries'] as $entry) {
143
- if (str_ends_with($entry, '*')) {
144
- $base = substr($entry, 0, -1);
145
- if (str_starts_with($requestPath, $base)) {
146
- return true;
147
- }
148
- } elseif ($requestPath === $entry) {
149
- return true;
150
- }
151
- }
152
-
153
- return false;
154
- }
155
-
156
- private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
157
- {
158
- if (empty($requestHost)) {
159
- return false;
160
- }
161
-
162
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
163
- $hostPathRule = null;
164
- foreach ($whitelistRules as $rule) {
165
- if (($rule['type'] ?? '') === 'host_path_allowlist') {
166
- $hostPathRule = $rule;
167
- break;
168
- }
169
- }
170
-
171
- if (empty($hostPathRule['entries'])) {
172
- return false;
173
- }
174
-
175
- foreach ($hostPathRule['entries'] as $entry) {
176
- if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
177
- return true;
178
- }
179
- }
180
- return false;
181
- }
182
-
183
- /**
184
- * Vérifie si une requête doit être exemptée en raison d'une règle de liste blanche.
185
- */
186
- private function checkAllowlists(RequestContext $context): bool
187
- {
188
- if ($this->isIpInAllowlist($context->clientIp)) {
189
- $this->log('IP in allowlist - allowing request', ['clientIp' => $context->clientIp]);
190
- return true;
191
- }
192
- if ($this->isPathInAllowlist($context->path)) {
193
- $this->log('Path in allowlist - allowing request', ['path' => $context->path]);
194
- return true;
195
- }
196
- if ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
197
- $this->log('Host and path in allowlist - allowing request', ['host' => $context->getHeader('host'), 'path' => $context->path]);
198
- return true;
199
- }
200
- // NOUVEAU: Vérifier la liste blanche GraphQL
201
- if ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
202
- $this->log('GraphQL operation in allowlist - allowing request', ['operation' => "{$context->graphqlOperation['type']}:{$context->graphqlOperation['name']}"]);
203
- return true;
204
- }
205
- if ($this->verifyWhitelistedBot($context)) {
206
- $this->log('Whitelisted bot verified - allowing request', ['clientIp' => $context->clientIp]);
207
- return true;
208
- }
209
-
210
- return false;
211
- }
212
-
213
- /**
214
- * @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
215
- */
216
- private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
217
- {
218
- $this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
219
- $store = StoreManager::getStore();
220
- $existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
221
- $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
222
- $pendingCookieTtl = 120; // 2 minutes pour détecter la suppression
223
-
224
- $deviceId = $existingDeviceId;
225
- $deviceData = null;
226
- $newCookie = null;
227
-
228
- if ($deviceId) {
229
- $deviceData = $store->get("device:{$deviceId}");
230
- }
231
-
232
- if ($deviceData === null) {
233
- // Nouvel utilisateur ou cookie perdu/invalide
234
- $pendingDeviceId = $store->get("pending_cookie:{$context->clientIp}");
235
- if ($pendingDeviceId && !$existingDeviceId) {
236
- // La pénalité est maintenant ajoutée directement au vecteur de suspicion.
237
- $this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
238
- $suspicionVector['cookieDroppingScore'] = 100.0;
239
- }
240
-
241
- $deviceId = bin2hex(random_bytes(16)); // UUID-like
242
-
243
- // Préparer le cookie à envoyer
244
- $newCookie = [
245
- 'name' => 'device_id',
246
- 'value' => $deviceId,
247
- 'options' => [
248
- 'httponly' => true,
249
- 'secure' => $this->isProduction,
250
- 'samesite' => 'Strict',
251
- 'path' => '/',
252
- ]
253
- ];
254
- if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
255
- $newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
256
- }
257
-
258
- $store->set("pending_cookie:{$context->clientIp}", $deviceId, $pendingCookieTtl);
259
-
260
- $deviceData = [
261
- 'initialDeviceHash' => $currentDeviceHash,
262
- 'ips' => [$context->clientIp],
263
- 'requestHistory' => [],
264
- 'lastUpdate' => time() * 1000,
265
- 'lastFpHash' => $currentDeviceHash,
266
- 'lastChangeTimestamp' => 0,
267
- 'rapidChangeCount' => 0,
268
- 'highScoreCount' => 0,
269
- 'lastHighScoreTimestamp' => 0,
270
- ];
271
- } else {
272
- // S'assurer que 'ips' est un tableau pour les opérations suivantes.
273
- if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
274
- $deviceData['ips'] = [];
275
- }
276
- }
277
-
278
- return [
279
- 'deviceId' => $deviceId,
280
- 'deviceData' => $deviceData,
281
- 'newCookie' => $newCookie,
282
- ];
283
- }
284
-
285
- /**
286
- * @return array<string, float>
287
- */
288
- public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
289
- {
290
- $store = StoreManager::getStore();
291
- $identity = $this->resolveRequestIdentity($context, $suspicionVector);
292
- $deviceData = $identity['deviceData'];
293
- $deviceId = $identity['deviceId'];
294
- if ($deviceData && ($deviceData['condemned'] ?? false)) {
295
- $suspicionVector['honeypotScore'] = 100;
296
- return $suspicionVector;
297
- }
298
-
299
- // Si un nouveau cookie doit être défini, on le stocke temporairement dans le contexte
300
- // pour que le code appelant puisse le gérer.
301
- if ($identity['newCookie']) {
302
- // Cette propriété n'est pas standard, on la préfixe pour éviter les conflits.
303
- $context->newCookieForResponse = $identity['newCookie'];
304
- }
305
-
306
- // Nettoyage périodique des données de l'appareil
307
- if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
308
- $deviceData['ips'] = [];
309
- $deviceData['rapidChangeCount'] = 0;
310
- }
311
- $deviceData['lastUpdate'] = time() * 1000;
312
-
313
- // --- Calcul des différents scores de suspicion ---
314
-
315
- // Score d'incohérence du fingerprint (déplacé ici pour être avec les autres)
316
- $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
317
- $consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
318
- $inconsistencyScore = min(100.0, max(0.0, (1 - $consistencyScore) * 200));
319
- if ($consistencyScore < ($this->securityConfig['similarityThreshold'] ?? 0.7)) {
320
- $inconsistencyScore = 100.0;
321
- }
322
-
323
- $behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
324
-
325
- // Score des anomalies d'en-têtes
326
- $headerAnomalies = RequestUtils::getHeaderAnomalies($context);
327
-
328
- // Score de spoofing TLS
329
- $tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
330
- $tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
331
-
332
- // Advanced JA4 TLS Inconsistency checks
333
- $ja4 = $context->getHeader('x-ja4-hash');
334
- if ($ja4) {
335
- $spoofedJa4s = [
336
- 't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
337
- 't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
338
- 't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
339
- 't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
340
- 't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
341
- 't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
342
- ];
343
- if (in_array($ja4, $spoofedJa4s, true)) {
344
- $tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
345
- }
346
-
347
- $parsedJa4 = $this->parseJa4($ja4);
348
- if ($parsedJa4) {
349
- $ua = $context->getHeader('user-agent') ?? '';
350
- $uaParts = $this->parseUserAgent($ua);
351
-
352
- // Check 1: Incohérence ALPN / HTTP Version
353
- $httpVersion = $context->httpVersion ?? '1.1';
354
- if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
355
- $hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
356
- if (!$hasProxy) {
357
- $tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
358
- }
359
- }
360
-
361
- // Check 2: Incohérence OS/Plateforme vs Capabilities TLS
362
- if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
363
- $tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
364
- }
365
-
366
- // Check 3: Incohérence User-Agent vs Signature JA4
367
- if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
368
- $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
369
- }
370
- if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
371
- $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
372
- }
373
-
374
- // Check 4: La stagnation (Lack of Entropy / Genericity)
375
- if ($uaParts['browser']) {
376
- $ja4Key = "ja4-browsers:{$ja4}";
377
- $seenBrowsers = $store->get($ja4Key) ?: [];
378
- if (!is_array($seenBrowsers)) {
379
- $seenBrowsers = [];
380
- }
381
- $browserFamily = explode('/', $uaParts['browser'])[0] ?? null;
382
- if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
383
- $seenBrowsers[] = $browserFamily;
384
- $store->set($ja4Key, $seenBrowsers, 86400);
385
- }
386
- if (count($seenBrowsers) > 1) {
387
- $tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
388
- }
389
- }
390
- }
391
- }
392
-
393
- // Score d'incohérence temporelle (attaque par rejeu)
394
- $timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
395
-
396
- // Score des incohérences entre couches (client vs serveur)
397
- $crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
398
-
399
- // Score des patterns de requêtes (scraping, vélocité)
400
- $requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
401
-
402
- // Score des honeypots
403
- $honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
404
-
405
- // Score des métriques comportementales client (souris, clavier)
406
- $behavior = RequestUtils::getBehaviorScore($context);
407
-
408
- // Score de détection de bot explicite (marqueurs d'automatisation)
409
- $bot = RequestUtils::getBotScore($context);
410
-
411
- // Score de variance des clics
412
- $clickVariance = RequestUtils::getClickVarianceScore($context);
413
-
414
- // Score basé sur les listes de menaces (Threat Intelligence)
415
- $threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
416
-
417
- // Score d'incohérence des Client-Hints
418
- $clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
419
-
420
- // NOUVEAU: Score de réputation du sous-réseau IP
421
- $subnetScore = RequestUtils::getSubnetScore($context, $deviceId);
422
-
423
- // Assemblage du vecteur de suspicion final
424
- $suspicionVector = array_merge($suspicionVector, [
425
- 'inconsistencyScore' => $inconsistencyScore,
426
- 'historyScore' => $behavioral['historyScore'],
427
- 'rotationScore' => $behavioral['rotationScore'],
428
- 'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
429
- 'tlsSpoofingScore' => $tlsSpoofingScore,
430
- 'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
431
- 'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
432
- 'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
433
- 'honeypotScore' => $honeypot['honeypotScore'],
434
- 'behaviorScore' => $behavior['behaviorScore'],
435
- 'botScore' => $bot['botScore'],
436
- 'clickVarianceScore' => $clickVariance['clickVarianceScore'],
437
- 'threatIntelScore' => $threatIntel['threatIntelScore'],
438
- 'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
439
- 'subnetScore' => $subnetScore['subnetScore'],
440
- ]);
441
-
442
- // Sauvegarder l'état mis à jour de l'appareil dans le store
443
- $store->set("device:{$deviceId}", $deviceData);
444
-
445
- return $suspicionVector;
446
- }
447
-
448
- /**
449
- * Parse a basic User-Agent string.
450
- */
451
- private function parseUserAgent(string $ua): array
452
- {
453
- $result = ['browser' => null, 'os' => null];
454
-
455
- if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
456
- $result['browser'] = 'Chrome';
457
- if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
458
- $result['browser'] .= '/' . $matches[1];
459
- }
460
- } elseif (str_contains($ua, 'Firefox')) {
461
- $result['browser'] = 'Firefox';
462
- if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
463
- $result['browser'] .= '/' . $matches[1];
464
- }
465
- } elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
466
- $result['browser'] = 'Safari';
467
- if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
468
- $result['browser'] .= '/' . $matches[1];
469
- }
470
- } elseif (str_contains($ua, 'Edg')) {
471
- $result['browser'] = 'Edge';
472
- if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
473
- $result['browser'] .= '/' . $matches[1];
474
- }
475
- }
476
-
477
- if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
478
- elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
479
- elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
480
- elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
481
- elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
482
- elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
483
-
484
- return $result;
485
- }
486
-
487
- /**
488
- * Parse JA4 string into protocol, version, ALPN etc.
489
- */
490
- private function parseJa4(?string $ja4): ?array
491
- {
492
- if (empty($ja4)) return null;
493
- $parts = explode('_', $ja4);
494
- $ja4a = $parts[0];
495
- if (strlen($ja4a) < 10) return null;
496
- return [
497
- 'protocol' => $ja4a[0],
498
- 'version' => substr($ja4a, 1, 2),
499
- 'sni' => $ja4a[3],
500
- 'ciphersCount' => (int)substr($ja4a, 4, 2),
501
- 'extensionsCount' => (int)substr($ja4a, 6, 2),
502
- 'alpn' => substr($ja4a, 8, 2),
503
- 'ja4b' => $parts[1] ?? null,
504
- 'ja4c' => $parts[2] ?? null
505
- ];
506
- }
507
-
508
- /**
509
- * Traite une requête entrante et retourne une décision.
510
- * @param RequestContext $context Le contexte de la requête.
511
- * @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
512
- */
513
- public function processRequest(RequestContext $context): array
514
- {
515
- // Initialiser le vecteur de suspicion pour éviter les erreurs de type.
516
- $suspicionVector = [];
517
-
518
- $this->log('Processing request', ['clientIp' => $context->clientIp, 'path' => $context->path]);
519
-
520
- // Parse GraphQL query if applicable
521
- if ($context->path === '/graphql' && !empty($context->body)) {
522
- $gqlInfo = RequestUtils::parseGraphQLQuery(is_array($context->body) ? $context->body : []);
523
- if ($gqlInfo) {
524
- $context->graphqlOperation = $gqlInfo;
525
- }
526
- }
527
-
528
- // 1. Vérifier les listes blanches
529
- if ($this->checkAllowlists($context)) {
530
- return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
531
- MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
532
- }
533
-
534
- // Initialiser le vecteur de suspicion
535
- $thresholds = $this->securityConfig['thresholds'];
536
-
537
- // 2. Gérer la soumission d'une solution de challenge (PRIORITÉ HAUTE)
538
- $powNonce = $context->query['pow_nonce'] ?? null;
539
- $isChallengeSubmission = $powNonce && (
540
- isset($context->query['pow_solution']) ||
541
- isset($context->query['pow_solution_cpu']) ||
542
- (isset($context->query['pow_type']) && $context->query['pow_type'] === 'useful_work_task')
543
- );
544
- if ($isChallengeSubmission) {
545
- $this->log('Challenge solution submitted', ['pow_type' => $context->query['pow_type'] ?? 'unknown', 'nonce' => $powNonce]);
546
- $store = StoreManager::getStore();
547
- $challengeContext = $store->get("secret:{$powNonce}");
548
- $powType = $context->query['pow_type'] ?? null;
549
-
550
- if ($challengeContext) {
551
- $isValid = false;
552
- $ticket = null;
553
-
554
- // Vérification de la cohérence du fingerprint
555
- $solverFingerprint = $context->query['pow_fp'] ?? RequestUtils::getCompositeDeviceHash($context);
556
- $originalFingerprint = $challengeContext['fingerprint'] ?? '';
557
- $similarity = FingerprintBuilder::compare($originalFingerprint, $solverFingerprint);
558
- $similarityThreshold = $this->securityConfig['similarityThreshold'] ?? 0.95;
559
-
560
- if ($similarity < $similarityThreshold) {
561
- $this->log('Fingerprint mismatch - challenge solved on a different machine!', [
562
- 'similarity' => round($similarity, 4),
563
- 'threshold' => $similarityThreshold
564
- ], 'warn');
565
- $isValid = false;
566
- } else {
567
- // Le fingerprint est cohérent, on peut valider la solution
568
- if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
569
- $cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
570
- if ($cpuSolution) {
571
- $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket($context->clientIp, 3600000, $powNonce, $cpuSolution, $challengeContext);
572
- $isValid = $ticket !== null;
573
-
574
- if ($powType === 'cpu_mem') {
575
- $memSolution = $context->query['pow_solution_mem'] ?? null;
576
- $isMemValid = $memSolution ? ChallengeUtils::verifyMemoryPoW($powNonce, $memSolution, $challengeContext['memDifficulty'] ?? 0, $challengeContext['clientSecret'] ?? '') : false;
577
- $isValid = $isValid && $isMemValid;
578
- }
579
- }
580
- } elseif ($powType === 'useful_work_task') {
581
- $problemId = $context->query['pow_problem_id'] ?? null;
582
- $workResultJson = $context->query['pow_solution_work_result'] ?? null;
583
- if ($problemId && $workResultJson) {
584
- $workResult = json_decode($workResultJson, true);
585
- $this->log('Verifying useful work solution.', [
586
- 'problemId' => $problemId,
587
- 'receivedData' => $workResult,
588
- 'jsonLastError' => json_last_error_msg()
589
- ]);
590
- if (json_last_error() === JSON_ERROR_NONE) {
591
- // @phpstan-ignore-next-line - L'instance est gérée par le singleton
592
- $problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($this->securityConfig['usefulWorkConfigPath'] ?? null, $store);
593
- // FIX: La solution est directement le $workResult, pas une sous-propriété.
594
- $problemManager->integrateSolution($problemId, $workResult);
595
- $isValid = true;
596
- // FIX: Générer un vrai ticket pour uPoW, comme pour un PoW normal.
597
- $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000; // 1 heure par défaut
598
- $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
599
- $signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", ChallengeUtils::getPowSecret());
600
- $ticket = "{$expiry}:{$signature}";
601
- }
602
- }
603
- }
604
- }
605
-
606
- if ($isValid) {
607
- $store->delete("secret:{$powNonce}");
608
- $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
609
- MetricsManager::incrementCounter('challenges_solved_total');
610
- $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
611
-
612
- return [
613
- // ... (le reste de la logique de redirection)
614
- 'action' => 'redirect',
615
- 'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
616
- 'score' => 0.0,
617
- 'action' => 'redirect',
618
- 'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
619
- 'score' => 0.0,
620
- 'vector' => ['challenge_solved' => 100],
621
- 'cookie' => ['name' => 'pow_clearance', 'value' => $ticket, 'options' => ['httponly' => true, 'secure' => $this->isProduction, 'expires' => time() + ($ticketTtl / 1000), 'path' => '/']]
622
- ];
623
- }
624
- }
625
- // Si la solution est invalide ou le nonce est expiré, on pénalise fortement pour la suite.
626
- MetricsManager::incrementCounter('challenges_failed_total');
627
- $this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
628
- $suspicionVector['honeypotScore'] = 100.0;
629
- }
630
-
631
- // 3. Vérifier un ticket existant
632
- $hasValidTicket = false;
633
- $powCookie = $context->cookies['pow_clearance'] ?? null;
634
- if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie)) {
635
- $hasValidTicket = true;
636
- MetricsManager::incrementCounter('tickets_valid_total');
637
- // On ne retourne pas tout de suite pour permettre le re-challenge
638
- // $this->log('Valid clearance ticket found');
639
- // return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
640
- }
641
-
642
- // 4. Calculer le vecteur et le score de suspicion
643
- // Résoudre l'identité et vérifier le statut "condamné"
644
- $store = StoreManager::getStore();
645
- $identity = $this->resolveRequestIdentity($context, $suspicionVector);
646
- $deviceId = $identity['deviceId'];
647
- $deviceData = $identity['deviceData'];
648
- if ($deviceData && ($deviceData['condemned'] ?? false)) {
649
- $this->log('Device condemned - blocking request', ['deviceId' => $deviceId], 'warn');
650
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
651
- if ($this->dryRun) {
652
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
653
- $decision['intendedAction'] = $decision['action'];
654
- $decision['action'] = 'next';
655
- unset($decision['status'], $decision['body']);
656
- }
657
- return $decision;
658
- }
659
-
660
- $suspicionVector = $this->getSuspicionVector($context, $suspicionVector);
661
- $finalScore = $this->calculateFinalScore($suspicionVector);
662
- $this->log('Suspicion vector and final score calculated', [
663
- 'finalScore' => round($finalScore, 2),
664
- 'vector' => $suspicionVector
665
- ]);
666
-
667
- // Mettre à jour les métriques du sous-réseau après le calcul du score final
668
- if ($finalScore > ($thresholds['low'] ?? 20)) {
669
- RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
670
- MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
671
- }
672
-
673
- // Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
674
- $isNewDevice = $identity['newCookie'] !== null;
675
- if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
676
- $this->log('New device - enforcing minimum challenge score', [
677
- 'originalScore' => round($finalScore, 2),
678
- 'enforcedScore' => (float)$thresholds['low']
679
- ]);
680
- $finalScore = (float)$thresholds['low'];
681
- }
682
-
683
- // Vérifier les URL pièges (après calcul du score)
684
- $lastNonce = $deviceData['lastChallengeNonce'] ?? null;
685
- if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
686
- if ($this->logger) {
687
- $this->logger->log('info', 'trap_triggered', ['deviceId' => $deviceId, 'score' => 100, 'path' => $context->path, 'vector' => ['honeypotScore' => 100]]);
688
- }
689
- $this->log('Honeypot trap URL triggered - condemning device', ['path' => $context->path, 'deviceId' => $deviceId]);
690
- $deviceData['condemned'] = true; // @phpstan-ignore-line
691
- $store->set("device:{$deviceId}", $deviceData);
692
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
693
- if ($this->dryRun) {
694
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
695
- $decision['intendedAction'] = $decision['action'];
696
- $decision['action'] = 'next';
697
- unset($decision['status'], $decision['body']);
698
- }
699
- return $decision;
700
- }
701
-
702
- // 5. Prendre une décision basée sur le score - Vérifier le blocage d'abord.
703
- $blockThreshold = $thresholds['block'] ?? 95;
704
- if ($finalScore >= $blockThreshold) {
705
- if ($this->logger) {
706
- MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
707
- $this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
708
- }
709
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
710
- if ($this->dryRun) {
711
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
712
- $decision['intendedAction'] = $decision['action'];
713
- $decision['action'] = 'next';
714
- unset($decision['status'], $decision['body']);
715
- MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
716
- }
717
- $response = $decision;
718
- } else {
719
- // Logique de re-challenge
720
- // Si un nonce est présent mais que ce n'est pas une soumission de solution valide, c'est une sonde.
721
- if ($powNonce && !$isChallengeSubmission) {
722
- $this->log('Honeypot probe detected - blocking request', ['path' => $context->path, 'pow_nonce' => $powNonce]);
723
- $suspicionVector['honeypotScore'] = 100.0;
724
- $finalScore = $this->calculateFinalScore($suspicionVector); // Recalculate score
725
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
726
- // Apply dry run logic here as well
727
- if ($this->dryRun) {
728
- MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
729
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
730
- $decision['intendedAction'] = $decision['action'];
731
- $decision['action'] = 'next';
732
- unset($decision['status'], $decision['body']);
733
- }
734
- return $decision;
735
- }
736
-
737
- $highThreshold = $thresholds['high'] ?? 75;
738
- $mustReChallenge = $finalScore >= $highThreshold && $hasValidTicket;
739
-
740
- $lowThreshold = $thresholds['low'] ?? 20;
741
- if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
742
- if ($mustReChallenge) {
743
- $this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
744
- }
745
-
746
- $decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
747
-
748
- if ($this->dryRun) {
749
- MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_challenge']);
750
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
751
- $decision['intendedAction'] = $decision['action'];
752
- $decision['action'] = 'next';
753
- unset($decision['status']);
754
- return $decision;
755
- }
756
-
757
- MetricsManager::incrementCounter('requests_total', ['status' => 'challenged']);
758
- $this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
759
-
760
- $nonce = bin2hex(random_bytes(16));
761
- $clientSecret = bin2hex(random_bytes(16));
762
-
763
- // --- NOUVELLE LOGIQUE uPoW ---
764
- $shouldUseUsefulWork = ($this->securityConfig['enableUsefulWork'] ?? false) && (
765
- ($this->securityConfig['forceUsefulWork'] ?? false) || (random_int(0, 255) / 255) > 0.5
766
- );
767
-
768
- // Déterminer si c'est une requête API avant de choisir le type de challenge
769
- $isApiRequest = false;
770
- if (isset($this->securityConfig['isApiRequest']) && is_callable($this->securityConfig['isApiRequest'])) {
771
- $isApiRequest = ($this->securityConfig['isApiRequest'])($context);
772
- }
773
-
774
- if ($shouldUseUsefulWork) {
775
- $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
776
- $problemManager = ProblemManager::getInstance($this->securityConfig['usefulWorkConfigPath'] ?? null, $store);
777
- $work = $problemManager->dispatchWork($finalScore);
778
-
779
- if ($work !== null) {
780
- $store->set("secret:{$nonce}", ['clientSecret' => $clientSecret, 'originalPath' => $context->path], 300);
781
- $challengePayload = [
782
- 'challenge' => [
783
- 'type' => 'useful_work_task',
784
- 'nonce' => $nonce,
785
- 'clientSecret' => $clientSecret,
786
- 'usefulWorkTask' => [
787
- 'problemId' => $work['problemId'],
788
- 'task' => $work['task']
789
- ]
790
- ]
791
- ];
792
- $decision['body'] = $challengePayload;
793
- return $decision;
794
- } else {
795
- // This case handles when uPoW is enabled but dispatching a task fails (e.g., config not found).
796
- // We log it and fall through to the standard PoW challenge.
797
- $this->log('Useful work dispatch failed, falling back to standard PoW.', [], 'warn');
798
- $shouldUseUsefulWork = false; // Explicitly disable for this request
799
- }
800
- }
801
-
802
- // --- FIN DE LA LOGIQUE uPoW (le reste est le fallback) ---
803
-
804
- $suspicionFactor = ($finalScore - $lowThreshold) / (($thresholds['high'] ?? 75) - $lowThreshold);
805
- $suspicionFactor = max(0, min(1.5, $suspicionFactor));
806
-
807
- $cpuChallengeDetails = [
808
- 'type' => 'cpu_target',
809
- 'nonce' => $nonce,
810
- 'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
811
- 'path' => $context->path,
812
- ];
813
-
814
- $memActivationFactor = max(0, ($suspicionFactor - 0.25) / 0.75);
815
- $memDifficulty = (int)round($memActivationFactor * 48); // 0 à 48MB
816
-
817
- $originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
818
- $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint);
819
-
820
- $challengeContext = [
821
- 'clientSecret' => $clientSecret,
822
- 'cpuTarget' => $cpuChallengeDetails['target'],
823
- 'suspicionScore' => $finalScore,
824
- 'fingerprint' => $originalFingerprint,
825
- 'memDifficulty' => $memDifficulty,
826
- 'baseBlock' => $baseBlock,
827
- 'originalPath' => $context->path,
828
- ];
829
-
830
- $store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
831
-
832
- // Associer le nonce au device pour la vérification des URL pièges
833
- if ($deviceData) {
834
- $deviceData['lastChallengeNonce'] = $nonce;
835
- $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
836
- }
837
-
838
- $trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
839
- $this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
840
-
841
- if ($this->logger) {
842
- $this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
843
- }
844
-
845
- // Pour les API, retourner un challenge JSON
846
- if ($isApiRequest) {
847
- $challengePayload = [
848
- 'challenge' => [
849
- 'type' => 'cpu_mem',
850
- 'nonce' => $nonce,
851
- 'clientSecret' => $clientSecret,
852
- 'cpuTarget' => $cpuChallengeDetails['target'],
853
- 'memDifficulty' => $memDifficulty,
854
- 'baseBlock' => array_values(unpack('C*', $baseBlock)), // Envoyer comme un tableau d'octets
855
- ]
856
- ];
857
- $decision['body'] = $challengePayload;
858
- } else {
859
- // Pour les navigateurs, retourner une page HTML
860
- $pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
861
- $cpuChallengeDetails, $memDifficulty, $clientSecret,
862
- $this->securityConfig, $trapUrls, $originalFingerprint
863
- );
864
- $decision['body'] = $pageBody;
865
- }
866
- $response = $decision;
867
- } elseif ($hasValidTicket) {
868
- // Si on arrive ici avec un ticket valide et un score bas, on autorise
869
- MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
870
- $this->log('Valid clearance ticket found and score is low - allowing request');
871
- $response = ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100], 'intendedAction' => 'next'];
872
- } else {
873
- // 6. Si le score est bas et qu'il n'y a pas de ticket, autoriser la requête
874
- MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
875
- $this->log('Request passed - no challenge required', ['finalScore' => $finalScore]);
876
- if ($this->logger) {
877
- MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'passed']);
878
- $this->logger->log('info', 'request_passed', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
879
- }
880
- $response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
881
- }
882
- }
883
-
884
- // Si un nouveau cookie d'identification a été généré, on l'ajoute à la réponse.
885
- if (isset($context->newCookieForResponse)) {
886
- $response['newCookieForResponse'] = $context->newCookieForResponse;
887
- }
888
-
889
- return $response;
890
- }
891
-
892
- /**
893
- * Vérifie si l'opération GraphQL correspond à une entrée dans la liste blanche.
894
- */
895
- private function isGraphqlOperationInAllowlist(?string $operationType, ?string $operationName): bool
896
- {
897
- if (empty($operationType) || empty($operationName)) {
898
- return false;
899
- }
900
-
901
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
902
- $graphqlRule = null;
903
- foreach ($whitelistRules as $rule) {
904
- if (($rule['type'] ?? '') === 'graphql_operation_allowlist') {
905
- $graphqlRule = $rule;
906
- break;
907
- }
908
- }
909
-
910
- if (empty($graphqlRule['entries'])) {
911
- return false;
912
- }
913
-
914
- foreach ($graphqlRule['entries'] as $entry) {
915
- [$entryType, $entryName] = explode(':', $entry, 2);
916
- if ($entryType !== $operationType) continue;
917
-
918
- if ($entryName === $operationName || $entryName === '*') return true;
919
-
920
- if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
921
- }
922
-
923
- return false;
924
- }
925
-
926
- /**
927
- * Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
928
- * en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
929
- */
930
- private function verifyWhitelistedBot(RequestContext $context): bool
931
- {
932
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
933
- $botRules = array_filter($whitelistRules, fn($rule) => isset($rule['hostnameSuffix']));
934
- if (empty($botRules)) {
935
- return false;
936
- }
937
-
938
- $userAgent = $context->getHeader('user-agent') ?? '';
939
- $matchedRule = null;
940
- foreach ($botRules as $rule) {
941
- if (isset($rule['userAgent']) && preg_match('/' . $rule['userAgent'] . '/', $userAgent)) {
942
- $matchedRule = $rule;
943
- break;
944
- }
945
- }
946
- if ($matchedRule === null) {
947
- return false;
948
- }
949
-
950
- $store = StoreManager::getStore();
951
- $cacheKey = "ip-whitelist:{$context->clientIp}";
952
- $cachedStatus = $store->get($cacheKey);
953
-
954
- if ($cachedStatus === 'verified') return true;
955
- if ($cachedStatus === 'failed') return false;
956
-
957
- try {
958
- // 1. Reverse DNS lookup. gethostbyaddr peut être lent, mais c'est la méthode standard.
959
- // @ pour supprimer les warnings si l'IP n'a pas de PTR record.
960
- $hostname = @gethostbyaddr($context->clientIp);
961
- if ($hostname === false || $hostname === $context->clientIp) {
962
- $store->set($cacheKey, 'failed', 86400);
963
- return false;
964
- }
965
-
966
- $validHostname = null;
967
- if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
968
- $validHostname = $hostname;
969
- }
970
-
971
- if ($validHostname === null) {
972
- $store->set($cacheKey, 'failed', 86400);
973
- return false;
974
- }
975
-
976
- // 2. Forward DNS lookup
977
- $addresses = array_merge(dns_get_record($validHostname, DNS_A) ?: [], dns_get_record($validHostname, DNS_AAAA) ?: []);
978
- $ips = [];
979
- foreach ($addresses as $address) {
980
- if (isset($address['ip'])) {
981
- $ips[] = $address['ip'];
982
- }
983
- if (isset($address['ipv6'])) {
984
- $ips[] = $address['ipv6'];
985
- }
986
- }
987
-
988
- if (in_array($context->clientIp, $ips, true)) {
989
- $store->set($cacheKey, 'verified', 86400);
990
- return true;
991
- }
992
- } catch (\Exception $e) { /* DNS errors */ }
993
-
994
- $store->set($cacheKey, 'failed', 86400);
995
- return false;
996
- }
997
-
998
- /**
999
- * @internal For testing purposes only.
1000
- */
1001
- public function getProblems(): array
1002
- {
1003
- $problemManager = ProblemManager::getInstance();
1004
- return $problemManager->getProblems();
1005
- }
1006
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint;
6
+
7
+ use Anonympins\Fingerprint\Challenge\ChallengeUtils;
8
+ use Anonympins\Fingerprint\Config\SecurityProfiles;
9
+ use Anonympins\Fingerprint\Store\StoreManager;
10
+ use Anonympins\Fingerprint\Utils\BlockList;
11
+ use Anonympins\Fingerprint\Utils\Logger;
12
+ use Anonympins\Fingerprint\Utils\MetricsManager;
13
+ use Anonympins\Fingerprint\Utils\RequestUtils;
14
+
15
+ // Correction de l'import
16
+
17
+ /**
18
+ * Le moteur principal de la bibliothèque de fingerprinting.
19
+ * Orchestre l'identification, le calcul de suspicion et la gestion des challenges.
20
+ */
21
+ class FingerprintEngine
22
+ {
23
+ private array $securityConfig;
24
+ private bool $isProduction;
25
+ private BlockList $allowlist;
26
+ private bool $verbose;
27
+ private ?Logger $logger = null;
28
+ private bool $dryRun;
29
+
30
+ public function __construct(array $securityConfig)
31
+ {
32
+ $this->isProduction = ($_ENV['APP_ENV'] ?? getenv('APP_ENV')) === 'production';
33
+ $this->securityConfig = $securityConfig;
34
+ $this->verbose = $securityConfig['verbose'] ?? false;
35
+ $this->allowlist = $this->buildAllowlist();
36
+ $this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
37
+ $this->dryRun = $securityConfig['dryRun'] ?? false;
38
+ $this->validateConfig($securityConfig);
39
+ }
40
+
41
+ private function validateConfig(array $config): void
42
+ {
43
+ if (empty($config)) {
44
+ $this->log('Warning: No securityConfig provided. Using default behaviors.', [], 'warn');
45
+ return;
46
+ }
47
+
48
+ $knownKeys = [
49
+ 'weights', 'thresholds', 'cpu', 'ticketMaxAge', 'challengeTtl',
50
+ 'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
51
+ 'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
52
+ 'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
53
+ 'similarityThreshold', 'summary', 'description'
54
+ ];
55
+
56
+ if (empty($config['weights'])) {
57
+ $this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
58
+ }
59
+ if (empty($config['thresholds'])) {
60
+ $this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
61
+ }
62
+
63
+ foreach (array_keys($config) as $key) {
64
+ if (!in_array($key, $knownKeys, true)) {
65
+ $this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
66
+ }
67
+ }
68
+ }
69
+
70
+ private function log(string $message, array $data = [], string $level = 'info'): void
71
+ {
72
+ if ($this->verbose) {
73
+ // Utilise le logger s'il est configuré, sinon error_log
74
+ if ($this->logger) {
75
+ $this->logger->log($level, "[FingerprintEngine] " . $message, $data);
76
+ } else {
77
+ $logMessage = "[FingerprintEngine] {$message}";
78
+ if (!empty($data)) $logMessage .= ' ' . json_encode($data);
79
+ error_log($logMessage);
80
+ }
81
+ }
82
+ }
83
+
84
+ public function calculateFinalScore(array $suspicionVector): float
85
+ {
86
+ $weights = $this->securityConfig['weights'] ?? [];
87
+ if (empty($weights)) {
88
+ return 0.0;
89
+ }
90
+
91
+ $score = 0.0;
92
+ foreach ($weights as $key => $weight) {
93
+ $score += ($suspicionVector[$key] ?? 0) * $weight;
94
+ }
95
+
96
+ return min(100.0, $score);
97
+ }
98
+
99
+ private function buildAllowlist(): BlockList
100
+ {
101
+ $blockList = new BlockList();
102
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
103
+
104
+ $allowlistRule = null;
105
+ foreach ($whitelistRules as $rule) {
106
+ if (($rule['type'] ?? '') === 'allowlist') {
107
+ $allowlistRule = $rule;
108
+ break;
109
+ }
110
+ }
111
+
112
+ if (empty($allowlistRule['entries'])) {
113
+ return $blockList;
114
+ }
115
+
116
+ foreach ($allowlistRule['entries'] as $entry) {
117
+ $blockList->add($entry);
118
+ }
119
+ return $blockList;
120
+ }
121
+
122
+ private function isIpInAllowlist(string $clientIp): bool
123
+ {
124
+ return $this->allowlist->check($clientIp);
125
+ }
126
+
127
+ private function isPathInAllowlist(string $requestPath): bool
128
+ {
129
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
130
+ $pathAllowlistRule = null;
131
+ foreach ($whitelistRules as $rule) {
132
+ if (($rule['type'] ?? '') === 'path_allowlist') {
133
+ $pathAllowlistRule = $rule;
134
+ break;
135
+ }
136
+ }
137
+
138
+ if (empty($pathAllowlistRule['entries'])) {
139
+ return false;
140
+ }
141
+
142
+ foreach ($pathAllowlistRule['entries'] as $entry) {
143
+ if (str_ends_with($entry, '*')) {
144
+ $base = substr($entry, 0, -1);
145
+ if (str_starts_with($requestPath, $base)) {
146
+ return true;
147
+ }
148
+ } elseif ($requestPath === $entry) {
149
+ return true;
150
+ }
151
+ }
152
+
153
+ return false;
154
+ }
155
+
156
+ private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
157
+ {
158
+ if (empty($requestHost)) {
159
+ return false;
160
+ }
161
+
162
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
163
+ $hostPathRule = null;
164
+ foreach ($whitelistRules as $rule) {
165
+ if (($rule['type'] ?? '') === 'host_path_allowlist') {
166
+ $hostPathRule = $rule;
167
+ break;
168
+ }
169
+ }
170
+
171
+ if (empty($hostPathRule['entries'])) {
172
+ return false;
173
+ }
174
+
175
+ foreach ($hostPathRule['entries'] as $entry) {
176
+ if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
177
+ return true;
178
+ }
179
+ }
180
+ return false;
181
+ }
182
+
183
+ /**
184
+ * Vérifie si une requête doit être exemptée en raison d'une règle de liste blanche.
185
+ */
186
+ private function checkAllowlists(RequestContext $context): bool
187
+ {
188
+ if ($this->isIpInAllowlist($context->clientIp)) {
189
+ $this->log('IP in allowlist - allowing request', ['clientIp' => $context->clientIp]);
190
+ return true;
191
+ }
192
+ if ($this->isPathInAllowlist($context->path)) {
193
+ $this->log('Path in allowlist - allowing request', ['path' => $context->path]);
194
+ return true;
195
+ }
196
+ if ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
197
+ $this->log('Host and path in allowlist - allowing request', ['host' => $context->getHeader('host'), 'path' => $context->path]);
198
+ return true;
199
+ }
200
+ // NOUVEAU: Vérifier la liste blanche GraphQL
201
+ if ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
202
+ $this->log('GraphQL operation in allowlist - allowing request', ['operation' => "{$context->graphqlOperation['type']}:{$context->graphqlOperation['name']}"]);
203
+ return true;
204
+ }
205
+ if ($this->verifyWhitelistedBot($context)) {
206
+ $this->log('Whitelisted bot verified - allowing request', ['clientIp' => $context->clientIp]);
207
+ return true;
208
+ }
209
+
210
+ return false;
211
+ }
212
+
213
+ /**
214
+ * @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
215
+ */
216
+ private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
217
+ {
218
+ $this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
219
+ $store = StoreManager::getStore();
220
+ $existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
221
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
222
+ $pendingCookieTtl = 120; // 2 minutes pour détecter la suppression
223
+
224
+ $deviceId = $existingDeviceId;
225
+ $deviceData = null;
226
+ $newCookie = null;
227
+
228
+ if ($deviceId) {
229
+ $deviceData = $store->get("device:{$deviceId}");
230
+ }
231
+
232
+ if ($deviceData === null) {
233
+ // Nouvel utilisateur ou cookie perdu/invalide
234
+ $pendingDeviceId = $store->get("pending_cookie:{$context->clientIp}");
235
+ if ($pendingDeviceId && !$existingDeviceId) {
236
+ // La pénalité est maintenant ajoutée directement au vecteur de suspicion.
237
+ $this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
238
+ $suspicionVector['cookieDroppingScore'] = 100.0;
239
+ }
240
+
241
+ $deviceId = bin2hex(random_bytes(16)); // UUID-like
242
+
243
+ // Préparer le cookie à envoyer
244
+ $newCookie = [
245
+ 'name' => 'device_id',
246
+ 'value' => $deviceId,
247
+ 'options' => [
248
+ 'httponly' => true,
249
+ 'secure' => $this->isProduction,
250
+ 'samesite' => 'Strict',
251
+ 'path' => '/',
252
+ ]
253
+ ];
254
+ if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
255
+ $newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
256
+ }
257
+
258
+ $store->set("pending_cookie:{$context->clientIp}", $deviceId, $pendingCookieTtl);
259
+
260
+ $deviceData = [
261
+ 'initialDeviceHash' => $currentDeviceHash,
262
+ 'ips' => [$context->clientIp],
263
+ 'requestHistory' => [],
264
+ 'lastUpdate' => time() * 1000,
265
+ 'lastFpHash' => $currentDeviceHash,
266
+ 'lastChangeTimestamp' => 0,
267
+ 'rapidChangeCount' => 0,
268
+ 'highScoreCount' => 0,
269
+ 'lastHighScoreTimestamp' => 0,
270
+ ];
271
+ } else {
272
+ // S'assurer que 'ips' est un tableau pour les opérations suivantes.
273
+ if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
274
+ $deviceData['ips'] = [];
275
+ }
276
+ }
277
+
278
+ return [
279
+ 'deviceId' => $deviceId,
280
+ 'deviceData' => $deviceData,
281
+ 'newCookie' => $newCookie,
282
+ ];
283
+ }
284
+
285
+ /**
286
+ * @return array<string, float>
287
+ */
288
+ public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
289
+ {
290
+ $store = StoreManager::getStore();
291
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
292
+ $deviceData = $identity['deviceData'];
293
+ $deviceId = $identity['deviceId'];
294
+ if ($deviceData && ($deviceData['condemned'] ?? false)) {
295
+ $suspicionVector['honeypotScore'] = 100;
296
+ return $suspicionVector;
297
+ }
298
+
299
+ // Si un nouveau cookie doit être défini, on le stocke temporairement dans le contexte
300
+ // pour que le code appelant puisse le gérer.
301
+ if ($identity['newCookie']) {
302
+ // Cette propriété n'est pas standard, on la préfixe pour éviter les conflits.
303
+ $context->newCookieForResponse = $identity['newCookie'];
304
+ }
305
+
306
+ // Nettoyage périodique des données de l'appareil
307
+ if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
308
+ $deviceData['ips'] = [];
309
+ $deviceData['rapidChangeCount'] = 0;
310
+ }
311
+ $deviceData['lastUpdate'] = time() * 1000;
312
+
313
+ // --- Calcul des différents scores de suspicion ---
314
+
315
+ // Score d'incohérence du fingerprint (déplacé ici pour être avec les autres)
316
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
317
+ $consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
318
+ $inconsistencyScore = min(100.0, max(0.0, (1 - $consistencyScore) * 200));
319
+ if ($consistencyScore < ($this->securityConfig['similarityThreshold'] ?? 0.7)) {
320
+ $inconsistencyScore = 100.0;
321
+ }
322
+
323
+ $behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
324
+
325
+ // Score des anomalies d'en-têtes
326
+ $headerAnomalies = RequestUtils::getHeaderAnomalies($context);
327
+
328
+ // Score de spoofing TLS
329
+ $tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
330
+ $tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
331
+
332
+ // Advanced JA4 TLS Inconsistency checks
333
+ $ja4 = $context->getHeader('x-ja4-hash');
334
+ if ($ja4) {
335
+ $spoofedJa4s = [
336
+ 't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
337
+ 't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
338
+ 't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
339
+ 't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
340
+ 't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
341
+ 't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
342
+ ];
343
+ if (in_array($ja4, $spoofedJa4s, true)) {
344
+ $tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
345
+ }
346
+
347
+ $parsedJa4 = $this->parseJa4($ja4);
348
+ if ($parsedJa4) {
349
+ $ua = $context->getHeader('user-agent') ?? '';
350
+ $uaParts = $this->parseUserAgent($ua);
351
+
352
+ // Check 1: Incohérence ALPN / HTTP Version
353
+ $httpVersion = $context->httpVersion ?? '1.1';
354
+ if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
355
+ $hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
356
+ if (!$hasProxy) {
357
+ $tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
358
+ }
359
+ }
360
+
361
+ // Check 2: Incohérence OS/Plateforme vs Capabilities TLS
362
+ if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
363
+ $tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
364
+ }
365
+
366
+ // Check 3: Incohérence User-Agent vs Signature JA4
367
+ if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
368
+ $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
369
+ }
370
+ if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
371
+ $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
372
+ }
373
+
374
+ // Check 4: La stagnation (Lack of Entropy / Genericity)
375
+ if ($uaParts['browser']) {
376
+ $ja4Key = "ja4-browsers:{$ja4}";
377
+ $seenBrowsers = $store->get($ja4Key) ?: [];
378
+ if (!is_array($seenBrowsers)) {
379
+ $seenBrowsers = [];
380
+ }
381
+ $browserFamily = explode('/', $uaParts['browser'])[0] ?? null;
382
+ if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
383
+ $seenBrowsers[] = $browserFamily;
384
+ $store->set($ja4Key, $seenBrowsers, 86400);
385
+ }
386
+ if (count($seenBrowsers) > 1) {
387
+ $tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ // Score d'incohérence temporelle (attaque par rejeu)
394
+ $timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
395
+
396
+ // Score des incohérences entre couches (client vs serveur)
397
+ $crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
398
+
399
+ // Score des patterns de requêtes (scraping, vélocité)
400
+ $requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
401
+
402
+ // Score des honeypots
403
+ $honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
404
+
405
+ // Score des métriques comportementales client (souris, clavier)
406
+ $behavior = RequestUtils::getBehaviorScore($context);
407
+
408
+ // Score de détection de bot explicite (marqueurs d'automatisation)
409
+ $bot = RequestUtils::getBotScore($context);
410
+
411
+ // Score de variance des clics
412
+ $clickVariance = RequestUtils::getClickVarianceScore($context);
413
+
414
+ // Score basé sur les listes de menaces (Threat Intelligence)
415
+ $threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
416
+
417
+ // Score d'incohérence des Client-Hints
418
+ $clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
419
+
420
+ // NOUVEAU: Score de réputation du sous-réseau IP
421
+ $subnetScore = RequestUtils::getSubnetScore($context, $deviceId);
422
+
423
+ // Assemblage du vecteur de suspicion final
424
+ $suspicionVector = array_merge($suspicionVector, [
425
+ 'inconsistencyScore' => $inconsistencyScore,
426
+ 'historyScore' => $behavioral['historyScore'],
427
+ 'rotationScore' => $behavioral['rotationScore'],
428
+ 'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
429
+ 'tlsSpoofingScore' => $tlsSpoofingScore,
430
+ 'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
431
+ 'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
432
+ 'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
433
+ 'honeypotScore' => $honeypot['honeypotScore'],
434
+ 'behaviorScore' => $behavior['behaviorScore'],
435
+ 'botScore' => $bot['botScore'],
436
+ 'clickVarianceScore' => $clickVariance['clickVarianceScore'],
437
+ 'threatIntelScore' => $threatIntel['threatIntelScore'],
438
+ 'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
439
+ 'subnetScore' => $subnetScore['subnetScore'],
440
+ ]);
441
+
442
+ // Sauvegarder l'état mis à jour de l'appareil dans le store
443
+ $store->set("device:{$deviceId}", $deviceData);
444
+
445
+ return $suspicionVector;
446
+ }
447
+
448
+ /**
449
+ * Parse a basic User-Agent string.
450
+ */
451
+ private function parseUserAgent(string $ua): array
452
+ {
453
+ $result = ['browser' => null, 'os' => null];
454
+
455
+ if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
456
+ $result['browser'] = 'Chrome';
457
+ if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
458
+ $result['browser'] .= '/' . $matches[1];
459
+ }
460
+ } elseif (str_contains($ua, 'Firefox')) {
461
+ $result['browser'] = 'Firefox';
462
+ if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
463
+ $result['browser'] .= '/' . $matches[1];
464
+ }
465
+ } elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
466
+ $result['browser'] = 'Safari';
467
+ if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
468
+ $result['browser'] .= '/' . $matches[1];
469
+ }
470
+ } elseif (str_contains($ua, 'Edg')) {
471
+ $result['browser'] = 'Edge';
472
+ if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
473
+ $result['browser'] .= '/' . $matches[1];
474
+ }
475
+ }
476
+
477
+ if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
478
+ elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
479
+ elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
480
+ elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
481
+ elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
482
+ elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
483
+
484
+ return $result;
485
+ }
486
+
487
+ /**
488
+ * Parse JA4 string into protocol, version, ALPN etc.
489
+ */
490
+ private function parseJa4(?string $ja4): ?array
491
+ {
492
+ if (empty($ja4)) return null;
493
+ $parts = explode('_', $ja4);
494
+ $ja4a = $parts[0];
495
+ if (strlen($ja4a) < 10) return null;
496
+ return [
497
+ 'protocol' => $ja4a[0],
498
+ 'version' => substr($ja4a, 1, 2),
499
+ 'sni' => $ja4a[3],
500
+ 'ciphersCount' => (int)substr($ja4a, 4, 2),
501
+ 'extensionsCount' => (int)substr($ja4a, 6, 2),
502
+ 'alpn' => substr($ja4a, 8, 2),
503
+ 'ja4b' => $parts[1] ?? null,
504
+ 'ja4c' => $parts[2] ?? null
505
+ ];
506
+ }
507
+
508
+ /**
509
+ * Traite une requête entrante et retourne une décision.
510
+ * @param RequestContext $context Le contexte de la requête.
511
+ * @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
512
+ */
513
+ public function processRequest(RequestContext $context): array
514
+ {
515
+ // Initialiser le vecteur de suspicion pour éviter les erreurs de type.
516
+ $suspicionVector = [];
517
+
518
+ $this->log('Processing request', ['clientIp' => $context->clientIp, 'path' => $context->path]);
519
+
520
+ // Parse GraphQL query if applicable
521
+ if ($context->path === '/graphql' && !empty($context->body)) {
522
+ $gqlInfo = RequestUtils::parseGraphQLQuery(is_array($context->body) ? $context->body : []);
523
+ if ($gqlInfo) {
524
+ $context->graphqlOperation = $gqlInfo;
525
+ }
526
+ }
527
+
528
+ // 1. Vérifier les listes blanches
529
+ if ($this->checkAllowlists($context)) {
530
+ return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
531
+ MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
532
+ }
533
+
534
+ // Initialiser le vecteur de suspicion
535
+ $thresholds = $this->securityConfig['thresholds'];
536
+
537
+ // 2. Gérer la soumission d'une solution de challenge (PRIORITÉ HAUTE)
538
+ $powNonce = $context->query['pow_nonce'] ?? null;
539
+ $isChallengeSubmission = $powNonce && (
540
+ isset($context->query['pow_solution']) ||
541
+ isset($context->query['pow_solution_cpu']) ||
542
+ (isset($context->query['pow_type']) && $context->query['pow_type'] === 'useful_work_task')
543
+ );
544
+ if ($isChallengeSubmission) {
545
+ $this->log('Challenge solution submitted', ['pow_type' => $context->query['pow_type'] ?? 'unknown', 'nonce' => $powNonce]);
546
+ $store = StoreManager::getStore();
547
+ $challengeContext = $store->get("secret:{$powNonce}");
548
+ $powType = $context->query['pow_type'] ?? null;
549
+
550
+ if ($challengeContext) {
551
+ $isValid = false;
552
+ $ticket = null;
553
+
554
+ // Vérification de la cohérence du fingerprint
555
+ $solverFingerprint = $context->query['pow_fp'] ?? RequestUtils::getCompositeDeviceHash($context);
556
+ $originalFingerprint = $challengeContext['fingerprint'] ?? '';
557
+ $similarity = FingerprintBuilder::compare($originalFingerprint, $solverFingerprint);
558
+ $similarityThreshold = $this->securityConfig['similarityThreshold'] ?? 0.95;
559
+
560
+ if ($similarity < $similarityThreshold) {
561
+ $this->log('Fingerprint mismatch - challenge solved on a different machine!', [
562
+ 'similarity' => round($similarity, 4),
563
+ 'threshold' => $similarityThreshold
564
+ ], 'warn');
565
+ $isValid = false;
566
+ } else {
567
+ // Le fingerprint est cohérent, on peut valider la solution
568
+ if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
569
+ $cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
570
+ if ($cpuSolution) {
571
+ $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket($context->clientIp, 3600000, $powNonce, $cpuSolution, $challengeContext);
572
+ $isValid = $ticket !== null;
573
+
574
+ if ($powType === 'cpu_mem') {
575
+ $memSolution = $context->query['pow_solution_mem'] ?? null;
576
+ $isMemValid = $memSolution ? ChallengeUtils::verifyMemoryPoW($powNonce, $memSolution, $challengeContext['memDifficulty'] ?? 0, $challengeContext['clientSecret'] ?? '') : false;
577
+ $isValid = $isValid && $isMemValid;
578
+ }
579
+ }
580
+ } elseif ($powType === 'useful_work_task') {
581
+ $problemId = $context->query['pow_problem_id'] ?? null;
582
+ $workResultJson = $context->query['pow_solution_work_result'] ?? null;
583
+ if ($problemId && $workResultJson) {
584
+ $workResult = json_decode($workResultJson, true);
585
+ $this->log('Verifying useful work solution.', [
586
+ 'problemId' => $problemId,
587
+ 'receivedData' => $workResult,
588
+ 'jsonLastError' => json_last_error_msg()
589
+ ]);
590
+ if (json_last_error() === JSON_ERROR_NONE) {
591
+ // @phpstan-ignore-next-line - L'instance est gérée par le singleton
592
+ $defaultPath = dirname(__DIR__, 2) . '/problems.config.json';
593
+ $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
594
+ $problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
595
+ // FIX: La solution est directement le $workResult, pas une sous-propriété.
596
+ $problemManager->integrateSolution($problemId, $workResult);
597
+ $isValid = true;
598
+ // FIX: Générer un vrai ticket pour uPoW, comme pour un PoW normal.
599
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000; // 1 heure par défaut
600
+ $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
601
+ $signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", ChallengeUtils::getPowSecret());
602
+ $ticket = "{$expiry}:{$signature}";
603
+ }
604
+ }
605
+ }
606
+ }
607
+
608
+ if ($isValid) {
609
+ $store->delete("secret:{$powNonce}");
610
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
611
+ MetricsManager::incrementCounter('challenges_solved_total');
612
+ $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
613
+
614
+ return [
615
+ // ... (le reste de la logique de redirection)
616
+ 'action' => 'redirect',
617
+ 'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
618
+ 'score' => 0.0,
619
+ 'action' => 'redirect',
620
+ 'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
621
+ 'score' => 0.0,
622
+ 'vector' => ['challenge_solved' => 100],
623
+ 'cookie' => ['name' => 'pow_clearance', 'value' => $ticket, 'options' => ['httponly' => true, 'secure' => $this->isProduction, 'expires' => time() + ($ticketTtl / 1000), 'path' => '/']]
624
+ ];
625
+ }
626
+ }
627
+ // Si la solution est invalide ou le nonce est expiré, on pénalise fortement pour la suite.
628
+ MetricsManager::incrementCounter('challenges_failed_total');
629
+ $this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
630
+ $suspicionVector['honeypotScore'] = 100.0;
631
+ }
632
+
633
+ // 3. Vérifier un ticket existant
634
+ $hasValidTicket = false;
635
+ $powCookie = $context->cookies['pow_clearance'] ?? null;
636
+ if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie)) {
637
+ $hasValidTicket = true;
638
+ MetricsManager::incrementCounter('tickets_valid_total');
639
+ // On ne retourne pas tout de suite pour permettre le re-challenge
640
+ // $this->log('Valid clearance ticket found');
641
+ // return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
642
+ }
643
+
644
+ // 4. Calculer le vecteur et le score de suspicion
645
+ // Résoudre l'identité et vérifier le statut "condamné"
646
+ $store = StoreManager::getStore();
647
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
648
+ $deviceId = $identity['deviceId'];
649
+ $deviceData = $identity['deviceData'];
650
+ if ($deviceData && ($deviceData['condemned'] ?? false)) {
651
+ $this->log('Device condemned - blocking request', ['deviceId' => $deviceId], 'warn');
652
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
653
+ if ($this->dryRun) {
654
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
655
+ $decision['intendedAction'] = $decision['action'];
656
+ $decision['action'] = 'next';
657
+ unset($decision['status'], $decision['body']);
658
+ }
659
+ return $decision;
660
+ }
661
+
662
+ $suspicionVector = $this->getSuspicionVector($context, $suspicionVector);
663
+ $finalScore = $this->calculateFinalScore($suspicionVector);
664
+ $this->log('Suspicion vector and final score calculated', [
665
+ 'finalScore' => round($finalScore, 2),
666
+ 'vector' => $suspicionVector
667
+ ]);
668
+
669
+ // Mettre à jour les métriques du sous-réseau après le calcul du score final
670
+ if ($finalScore > ($thresholds['low'] ?? 20)) {
671
+ RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
672
+ MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
673
+ }
674
+
675
+ // Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
676
+ $isNewDevice = $identity['newCookie'] !== null;
677
+ if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
678
+ $this->log('New device - enforcing minimum challenge score', [
679
+ 'originalScore' => round($finalScore, 2),
680
+ 'enforcedScore' => (float)$thresholds['low']
681
+ ]);
682
+ $finalScore = (float)$thresholds['low'];
683
+ }
684
+
685
+ // Vérifier les URL pièges (après calcul du score)
686
+ $lastNonce = $deviceData['lastChallengeNonce'] ?? null;
687
+ if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
688
+ if ($this->logger) {
689
+ $this->logger->log('info', 'trap_triggered', ['deviceId' => $deviceId, 'score' => 100, 'path' => $context->path, 'vector' => ['honeypotScore' => 100]]);
690
+ }
691
+ $this->log('Honeypot trap URL triggered - condemning device', ['path' => $context->path, 'deviceId' => $deviceId]);
692
+ $deviceData['condemned'] = true; // @phpstan-ignore-line
693
+ $store->set("device:{$deviceId}", $deviceData);
694
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
695
+ if ($this->dryRun) {
696
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
697
+ $decision['intendedAction'] = $decision['action'];
698
+ $decision['action'] = 'next';
699
+ unset($decision['status'], $decision['body']);
700
+ }
701
+ return $decision;
702
+ }
703
+
704
+ // 5. Prendre une décision basée sur le score - Vérifier le blocage d'abord.
705
+ $blockThreshold = $thresholds['block'] ?? 95;
706
+ if ($finalScore >= $blockThreshold) {
707
+ if ($this->logger) {
708
+ MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
709
+ $this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
710
+ }
711
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
712
+ if ($this->dryRun) {
713
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
714
+ $decision['intendedAction'] = $decision['action'];
715
+ $decision['action'] = 'next';
716
+ unset($decision['status'], $decision['body']);
717
+ MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
718
+ }
719
+ $response = $decision;
720
+ } else {
721
+ // Logique de re-challenge
722
+ // Si un nonce est présent mais que ce n'est pas une soumission de solution valide, c'est une sonde.
723
+ if ($powNonce && !$isChallengeSubmission) {
724
+ $this->log('Honeypot probe detected - blocking request', ['path' => $context->path, 'pow_nonce' => $powNonce]);
725
+ $suspicionVector['honeypotScore'] = 100.0;
726
+ $finalScore = $this->calculateFinalScore($suspicionVector); // Recalculate score
727
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
728
+ // Apply dry run logic here as well
729
+ if ($this->dryRun) {
730
+ MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
731
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
732
+ $decision['intendedAction'] = $decision['action'];
733
+ $decision['action'] = 'next';
734
+ unset($decision['status'], $decision['body']);
735
+ }
736
+ return $decision;
737
+ }
738
+
739
+ $highThreshold = $thresholds['high'] ?? 75;
740
+ $mustReChallenge = $finalScore >= $highThreshold && $hasValidTicket;
741
+
742
+ $lowThreshold = $thresholds['low'] ?? 20;
743
+ if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
744
+ if ($mustReChallenge) {
745
+ $this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
746
+ }
747
+
748
+ $decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
749
+
750
+ if ($this->dryRun) {
751
+ MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_challenge']);
752
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
753
+ $decision['intendedAction'] = $decision['action'];
754
+ $decision['action'] = 'next';
755
+ unset($decision['status']);
756
+ return $decision;
757
+ }
758
+
759
+ MetricsManager::incrementCounter('requests_total', ['status' => 'challenged']);
760
+ $this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
761
+
762
+ $nonce = bin2hex(random_bytes(16));
763
+ $clientSecret = bin2hex(random_bytes(16));
764
+
765
+ // --- NOUVELLE LOGIQUE uPoW ---
766
+ $shouldUseUsefulWork = ($this->securityConfig['enableUsefulWork'] ?? false) && (
767
+ ($this->securityConfig['forceUsefulWork'] ?? false) || (random_int(0, 255) / 255) > 0.5
768
+ );
769
+
770
+ // Déterminer si c'est une requête API avant de choisir le type de challenge
771
+ $isApiRequest = false;
772
+ if (isset($this->securityConfig['isApiRequest']) && is_callable($this->securityConfig['isApiRequest'])) {
773
+ $isApiRequest = ($this->securityConfig['isApiRequest'])($context);
774
+ }
775
+
776
+ if ($shouldUseUsefulWork) {
777
+ $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
778
+ $defaultPath = dirname(__DIR__, 2) . '/problems.config.json';
779
+ $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
780
+ $problemManager = ProblemManager::getInstance($configPath, $store);
781
+ $work = $problemManager->dispatchWork($finalScore);
782
+
783
+ if ($work !== null) {
784
+ $store->set("secret:{$nonce}", ['clientSecret' => $clientSecret, 'originalPath' => $context->path], 300);
785
+ $challengePayload = [
786
+ 'challenge' => [
787
+ 'type' => 'useful_work_task',
788
+ 'nonce' => $nonce,
789
+ 'clientSecret' => $clientSecret,
790
+ 'usefulWorkTask' => [
791
+ 'problemId' => $work['problemId'],
792
+ 'task' => $work['task']
793
+ ]
794
+ ]
795
+ ];
796
+ $decision['body'] = $challengePayload;
797
+ return $decision;
798
+ } else {
799
+ // This case handles when uPoW is enabled but dispatching a task fails (e.g., config not found).
800
+ // We log it and fall through to the standard PoW challenge.
801
+ $this->log('Useful work dispatch failed, falling back to standard PoW.', [], 'warn');
802
+ $shouldUseUsefulWork = false; // Explicitly disable for this request
803
+ }
804
+ }
805
+
806
+ // --- FIN DE LA LOGIQUE uPoW (le reste est le fallback) ---
807
+
808
+ $suspicionFactor = ($finalScore - $lowThreshold) / (($thresholds['high'] ?? 75) - $lowThreshold);
809
+ $suspicionFactor = max(0, min(1.5, $suspicionFactor));
810
+
811
+ $cpuChallengeDetails = [
812
+ 'type' => 'cpu_target',
813
+ 'nonce' => $nonce,
814
+ 'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
815
+ 'path' => $context->path,
816
+ ];
817
+
818
+ $memActivationFactor = max(0, ($suspicionFactor - 0.25) / 0.75);
819
+ $memDifficulty = (int)round($memActivationFactor * 48); // 0 à 48MB
820
+
821
+ $originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
822
+ $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint);
823
+
824
+ $challengeContext = [
825
+ 'clientSecret' => $clientSecret,
826
+ 'cpuTarget' => $cpuChallengeDetails['target'],
827
+ 'suspicionScore' => $finalScore,
828
+ 'fingerprint' => $originalFingerprint,
829
+ 'memDifficulty' => $memDifficulty,
830
+ 'baseBlock' => $baseBlock,
831
+ 'originalPath' => $context->path,
832
+ ];
833
+
834
+ $store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
835
+
836
+ // Associer le nonce au device pour la vérification des URL pièges
837
+ if ($deviceData) {
838
+ $deviceData['lastChallengeNonce'] = $nonce;
839
+ $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
840
+ }
841
+
842
+ $trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
843
+ $this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
844
+
845
+ if ($this->logger) {
846
+ $this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
847
+ }
848
+
849
+ // Pour les API, retourner un challenge JSON
850
+ if ($isApiRequest) {
851
+ $challengePayload = [
852
+ 'challenge' => [
853
+ 'type' => 'cpu_mem',
854
+ 'nonce' => $nonce,
855
+ 'clientSecret' => $clientSecret,
856
+ 'cpuTarget' => $cpuChallengeDetails['target'],
857
+ 'memDifficulty' => $memDifficulty,
858
+ 'baseBlock' => array_values(unpack('C*', $baseBlock)), // Envoyer comme un tableau d'octets
859
+ ]
860
+ ];
861
+ $decision['body'] = $challengePayload;
862
+ } else {
863
+ // Pour les navigateurs, retourner une page HTML
864
+ $pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
865
+ $cpuChallengeDetails, $memDifficulty, $clientSecret,
866
+ $this->securityConfig, $trapUrls, $originalFingerprint
867
+ );
868
+ $decision['body'] = $pageBody;
869
+ }
870
+ $response = $decision;
871
+ } elseif ($hasValidTicket) {
872
+ // Si on arrive ici avec un ticket valide et un score bas, on autorise
873
+ MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
874
+ $this->log('Valid clearance ticket found and score is low - allowing request');
875
+ $response = ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100], 'intendedAction' => 'next'];
876
+ } else {
877
+ // 6. Si le score est bas et qu'il n'y a pas de ticket, autoriser la requête
878
+ MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
879
+ $this->log('Request passed - no challenge required', ['finalScore' => $finalScore]);
880
+ if ($this->logger) {
881
+ MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'passed']);
882
+ $this->logger->log('info', 'request_passed', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
883
+ }
884
+ $response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
885
+ }
886
+ }
887
+
888
+ // Si un nouveau cookie d'identification a été généré, on l'ajoute à la réponse.
889
+ if (isset($context->newCookieForResponse)) {
890
+ $response['newCookieForResponse'] = $context->newCookieForResponse;
891
+ }
892
+
893
+ return $response;
894
+ }
895
+
896
+ /**
897
+ * Vérifie si l'opération GraphQL correspond à une entrée dans la liste blanche.
898
+ */
899
+ private function isGraphqlOperationInAllowlist(?string $operationType, ?string $operationName): bool
900
+ {
901
+ if (empty($operationType) || empty($operationName)) {
902
+ return false;
903
+ }
904
+
905
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
906
+ $graphqlRule = null;
907
+ foreach ($whitelistRules as $rule) {
908
+ if (($rule['type'] ?? '') === 'graphql_operation_allowlist') {
909
+ $graphqlRule = $rule;
910
+ break;
911
+ }
912
+ }
913
+
914
+ if (empty($graphqlRule['entries'])) {
915
+ return false;
916
+ }
917
+
918
+ foreach ($graphqlRule['entries'] as $entry) {
919
+ [$entryType, $entryName] = explode(':', $entry, 2);
920
+ if ($entryType !== $operationType) continue;
921
+
922
+ if ($entryName === $operationName || $entryName === '*') return true;
923
+
924
+ if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
925
+ }
926
+
927
+ return false;
928
+ }
929
+
930
+ /**
931
+ * Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
932
+ * en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
933
+ */
934
+ private function verifyWhitelistedBot(RequestContext $context): bool
935
+ {
936
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
937
+ $botRules = array_filter($whitelistRules, fn($rule) => isset($rule['hostnameSuffix']));
938
+ if (empty($botRules)) {
939
+ return false;
940
+ }
941
+
942
+ $userAgent = $context->getHeader('user-agent') ?? '';
943
+ $matchedRule = null;
944
+ foreach ($botRules as $rule) {
945
+ if (isset($rule['userAgent']) && preg_match('/' . $rule['userAgent'] . '/', $userAgent)) {
946
+ $matchedRule = $rule;
947
+ break;
948
+ }
949
+ }
950
+ if ($matchedRule === null) {
951
+ return false;
952
+ }
953
+
954
+ $store = StoreManager::getStore();
955
+ $cacheKey = "ip-whitelist:{$context->clientIp}";
956
+ $cachedStatus = $store->get($cacheKey);
957
+
958
+ if ($cachedStatus === 'verified') return true;
959
+ if ($cachedStatus === 'failed') return false;
960
+
961
+ try {
962
+ // 1. Reverse DNS lookup. gethostbyaddr peut être lent, mais c'est la méthode standard.
963
+ // @ pour supprimer les warnings si l'IP n'a pas de PTR record.
964
+ $hostname = @gethostbyaddr($context->clientIp);
965
+ if ($hostname === false || $hostname === $context->clientIp) {
966
+ $store->set($cacheKey, 'failed', 86400);
967
+ return false;
968
+ }
969
+
970
+ $validHostname = null;
971
+ if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
972
+ $validHostname = $hostname;
973
+ }
974
+
975
+ if ($validHostname === null) {
976
+ $store->set($cacheKey, 'failed', 86400);
977
+ return false;
978
+ }
979
+
980
+ // 2. Forward DNS lookup
981
+ $addresses = array_merge(dns_get_record($validHostname, DNS_A) ?: [], dns_get_record($validHostname, DNS_AAAA) ?: []);
982
+ $ips = [];
983
+ foreach ($addresses as $address) {
984
+ if (isset($address['ip'])) {
985
+ $ips[] = $address['ip'];
986
+ }
987
+ if (isset($address['ipv6'])) {
988
+ $ips[] = $address['ipv6'];
989
+ }
990
+ }
991
+
992
+ if (in_array($context->clientIp, $ips, true)) {
993
+ $store->set($cacheKey, 'verified', 86400);
994
+ return true;
995
+ }
996
+ } catch (\Exception $e) { /* DNS errors */ }
997
+
998
+ $store->set($cacheKey, 'failed', 86400);
999
+ return false;
1000
+ }
1001
+
1002
+ /**
1003
+ * @internal For testing purposes only.
1004
+ */
1005
+ public function getProblems(): array
1006
+ {
1007
+ $problemManager = ProblemManager::getInstance();
1008
+ return $problemManager->getProblems();
1009
+ }
1010
+ }