@anonympins/fingerprint 0.7.4 → 0.8.0

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 (64) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/public/anonympins-bot-mitigation-pow.zip +0 -0
  5. package/src/js/dynamic-wasm.js +218 -218
  6. package/src/js/fingerprint.client.js +1784 -1738
  7. package/src/js/fingerprint.client.obfuscated.js +1 -1
  8. package/src/js/fingerprint.js +489 -99
  9. package/src/js/fingerprint.utils.js +252 -2
  10. package/src/js/pow.worker.js +2 -2
  11. package/src/js/tests/cross_parity.test.js +3 -3
  12. package/src/js/tests/dns-circuit-breaker.test.js +6 -6
  13. package/src/js/tests/dynamic-wasm.test.js +77 -77
  14. package/src/js/tests/fingerprint.builder.test.js +76 -78
  15. package/src/js/tests/fingerprint.client.init.test.js +78 -0
  16. package/src/js/tests/fingerprint.client.test.js +16 -17
  17. package/src/js/tests/fingerprint.test.js +37 -12
  18. package/src/js/tests/ja3AnomalyDetector.test.js +135 -136
  19. package/src/js/tests/library.test.js +94 -95
  20. package/src/js/tests/obfuscation.test.js +8 -8
  21. package/src/js/tests/quicFingerprint.test.js +47 -0
  22. package/src/js/tests/tcpFingerprint.test.js +4 -4
  23. package/src/js/tests/threatIntel.test.js +40 -1
  24. package/src/js/upow-model-task.js +155 -112
  25. package/src/php/Challenge/ChallengeUtils.php +392 -86
  26. package/src/php/Config/SecurityProfiles.php +79 -61
  27. package/src/php/DirectFingerprint.php +55 -8
  28. package/src/php/FingerprintBuilder.php +13 -13
  29. package/src/php/FingerprintClient.php +6 -6
  30. package/src/php/FingerprintEngine.php +2122 -1690
  31. package/src/php/Ja3AnomalyDetector.php +22 -22
  32. package/src/php/Optimization/FunctionRegistry.php +4 -4
  33. package/src/php/Optimization/Optimization.php +6 -6
  34. package/src/php/RequestContext.php +11 -11
  35. package/src/php/Store/InMemoryStore.php +96 -66
  36. package/src/php/Store/MongoDbStore.php +2 -2
  37. package/src/php/Store/RedisStore.php +1 -1
  38. package/src/php/Store/StoreManager.php +48 -35
  39. package/src/php/Tests/ChallengeUtilsTest.php +31 -0
  40. package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
  41. package/src/php/Tests/FingerprintClientTest.php +70 -70
  42. package/src/php/Tests/PowTest.php +37 -39
  43. package/src/php/Tests/QuicFingerprintTest.php +115 -53
  44. package/src/php/Tests/ThreatIntelTest.php +34 -0
  45. package/src/php/Utils/BigInt.php +202 -202
  46. package/src/php/Utils/BlockList.php +13 -13
  47. package/src/php/Utils/Logger.php +19 -1
  48. package/src/php/Utils/MaliciousPatterns.php +6 -6
  49. package/src/php/Utils/MetricsManager.php +209 -209
  50. package/src/php/Utils/RequestUtils.php +362 -1
  51. package/src/php/Utils/TLSClientHelloParser.php +385 -369
  52. package/src/php/WordPress/WpDbStore.php +7 -7
  53. package/src/php/WordPress/anonympins-bot-mitigation-pow.php +1121 -0
  54. package/src/php/WordPress/fingerprint-anti-bot.php +0 -559
  55. package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.mo +0 -0
  56. package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.po +232 -0
  57. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.mo +0 -0
  58. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +297 -261
  59. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
  60. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +348 -267
  61. package/src/php/WordPress/package.php +80 -101
  62. package/src/php/WordPress/readme.txt +21 -5
  63. package/src/php/bin/auto-tune.php +1 -1
  64. package/public/fingerprint-anti-bot.zip +0 -0
@@ -1,972 +1,1211 @@
1
1
  <?php
2
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
- use Anonympins\Fingerprint\Utils\Env;
15
-
16
- // Correction de l'import
17
-
18
- /**
19
- * Le moteur principal de la bibliothèque de fingerprinting.
20
- * Orchestre l'identification, le calcul de suspicion et la gestion des challenges.
21
- */
22
- class FingerprintEngine
23
- {
24
- private array $securityConfig;
25
- private bool $isProduction;
26
- private BlockList $allowlist;
27
- private bool $verbose;
28
- private ?Logger $logger = null;
29
- private bool $dryRun;
30
-
31
- private static ?array $googlebotEntries = null;
32
- private static ?array $yandexEntries = null;
33
- private static ?array $bingbotEntries = null;
34
-
35
- private static array $dnsCircuitBreaker = [
36
- 'state' => 'CLOSED',
37
- 'failureCount' => 0,
38
- 'lastStateChange' => 0,
39
- 'threshold' => 5,
40
- 'cooldown' => 30, // seconds
41
- ];
42
-
43
- private static function recordDnsSuccess(): void
44
- {
45
- self::$dnsCircuitBreaker['failureCount'] = 0;
46
- self::$dnsCircuitBreaker['state'] = 'CLOSED';
47
- }
48
-
49
- private static function recordDnsFailure(): void
50
- {
51
- self::$dnsCircuitBreaker['failureCount']++;
52
- if (self::$dnsCircuitBreaker['failureCount'] >= self::$dnsCircuitBreaker['threshold']) {
53
- self::$dnsCircuitBreaker['state'] = 'OPEN';
54
- self::$dnsCircuitBreaker['lastStateChange'] = microtime(true);
55
- }
56
- }
57
-
58
- private static function canAttemptDns(): bool
59
- {
60
- if (self::$dnsCircuitBreaker['state'] === 'CLOSED') {
61
- return true;
62
- }
63
- if (self::$dnsCircuitBreaker['state'] === 'OPEN') {
64
- if (microtime(true) - self::$dnsCircuitBreaker['lastStateChange'] >= self::$dnsCircuitBreaker['cooldown']) {
65
- self::$dnsCircuitBreaker['state'] = 'HALF-OPEN';
66
- return true;
67
- }
68
- return false;
69
- }
70
- return true; // HALF-OPEN
71
- }
72
-
73
- private static function getReverseDnsName(string $ip): ?string
74
- {
75
- if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
76
- return implode('.', array_reverse(explode('.', $ip))) . '.in-addr.arpa';
77
- } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
78
- $hex = bin2hex((string)inet_pton($ip));
79
- return implode('.', array_reverse(str_split($hex))) . '.ip6.arpa';
80
- }
81
- return null;
82
- }
83
-
84
- private static function resolveDnsNative(string $query, string $type): ?array
85
- {
86
- $typeMap = [
87
- 'PTR' => DNS_PTR,
88
- 'A' => DNS_A,
89
- 'AAAA' => DNS_AAAA
90
- ];
91
- $dnsType = $typeMap[$type] ?? null;
92
- if ($dnsType === null) {
93
- return null;
94
- }
95
-
96
- $records = @dns_get_record($query, $dnsType);
97
- if ($records === false) {
98
- return null;
99
- }
100
-
101
- $results = [];
102
- foreach ($records as $record) {
103
- if ($type === 'PTR' && isset($record['target'])) {
104
- $results[] = $record['target'];
105
- } elseif ($type === 'A' && isset($record['ip'])) {
106
- $results[] = $record['ip'];
107
- } elseif ($type === 'AAAA' && isset($record['ipv6'])) {
108
- $results[] = $record['ipv6'];
109
- }
110
- }
111
- return $results;
112
- }
113
-
114
- public function __construct(array $securityConfig)
115
- {
116
- $this->isProduction = Env::get('APP_ENV') === 'production';
117
-
118
- // Dynamically bind Ed25519 keys if passed via config
119
- if (isset($securityConfig['ed25519_private_key'])) {
120
- Env::set('ED25519_PRIVATE_KEY', $securityConfig['ed25519_private_key']);
121
- }
122
- if (isset($securityConfig['ed25519_public_key'])) {
123
- Env::set('ED25519_PUBLIC_KEY', $securityConfig['ed25519_public_key']);
124
- }
125
-
126
- // Génération et persistance automatique de la paire de clés Ed25519
127
- $useAsymmetric = ($securityConfig['useAsymmetricTickets'] ?? false) === true || ($securityConfig['ed25519'] ?? '') === 'auto';
128
- $envPrivate = Env::get('ED25519_PRIVATE_KEY');
129
- if ($useAsymmetric && ($envPrivate === null || $envPrivate === '')) {
130
- if (function_exists('get_option') && function_exists('update_option')) {
131
- // In WordPress, persist cryptographic keys in the database options table
132
- $storedKeys = get_option('fingerprint_ed25519_keys');
133
- if (is_array($storedKeys) && isset($storedKeys['privateKey'], $storedKeys['publicKey'])) {
134
- Env::set('ED25519_PRIVATE_KEY', $storedKeys['privateKey']);
135
- Env::set('ED25519_PUBLIC_KEY', $storedKeys['publicKey']);
136
- } elseif (defined('OPENSSL_KEYTYPE_ED25519')) {
137
- $pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
138
- if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
139
- $details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
140
- $publicKeyPem = $details['key'] ?? ''; // @phpstan-ignore-line
141
- Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
142
- Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
143
- update_option('fingerprint_ed25519_keys', [
144
- 'privateKey' => $privateKeyPem,
145
- 'publicKey' => $publicKeyPem,
146
- ], false);
147
- }
148
- }
149
- } else {
150
- self::initStandaloneEd25519Keys();
151
- }
152
- }
153
-
154
- // Auto-load optimized config if autotuning savePath is specified
155
- if (isset($securityConfig['autotuning']['savePath'])) {
156
- $savePath = $securityConfig['autotuning']['savePath'];
157
- if (file_exists($savePath)) {
158
- try {
159
- $savedConfig = json_decode(file_get_contents($savePath), true);
160
- if (json_last_error() === JSON_ERROR_NONE && is_array($savedConfig)) {
161
- $securityConfig = SecurityProfiles::deepMerge($securityConfig, $savedConfig);
162
- }
163
- } catch (\Throwable $e) {
164
- self::logError("[FingerprintEngine] Failed to auto-load optimized config from {$savePath}: " . $e->getMessage());
165
- }
166
- }
167
- }
168
-
169
- // Si aucune liste blanche n'est explicitement fournie, appliquer la liste par défaut.
170
- if (!isset($securityConfig['whitelist'])) {
171
- $securityConfig['whitelist'] = self::default_whitelist();
172
- }
173
-
174
- $this->securityConfig = $securityConfig;
175
- $this->verbose = $securityConfig['verbose'] ?? false;
176
- $this->allowlist = $this->buildAllowlist();
177
- $this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
178
- $this->dryRun = $securityConfig['dryRun'] ?? false;
179
- $this->validateConfig($securityConfig);
180
- if ($this->securityConfig['reset'] ?? false) {
181
- $this->resetStore();
182
- }
183
- }
184
-
185
- /**
186
- * Initialise et persiste les cles Ed25519 pour les environnements PHP hors WordPress.
187
- */
188
- private static function initStandaloneEd25519Keys(): void
189
- {
190
- $configDir = dirname(__DIR__, 1) . '/config';
191
- $persistentKeyPath = $configDir . '/ed25519_key.json';
192
-
193
- if (file_exists($persistentKeyPath)) {
194
- try {
195
- $keys = json_decode((string)file_get_contents($persistentKeyPath), true);
196
- if (isset($keys['privateKey'], $keys['publicKey'])) {
197
- Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
198
- Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
199
- }
200
- } catch (\Throwable $e) {
201
- self::logError('[Fingerprint] Failed to load persistent Ed25519 keys: ' . $e->getMessage());
202
- }
203
- } else {
204
- try {
205
- if (defined('OPENSSL_KEYTYPE_ED25519')) {
206
- $pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
207
- if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
208
- $details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
209
- $publicKeyPem = $details['key'] ?? ''; // @phpstan-ignore-line
210
- Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
211
- Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
212
- if (!is_dir($configDir)) {
213
- mkdir($configDir, 0777, true);
214
- }
215
- // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.PluginDirectoryWrite -- Non-WordPress standalone environment fallback
216
- file_put_contents($persistentKeyPath, json_encode([
217
- 'privateKey' => $privateKeyPem,
218
- 'publicKey' => $publicKeyPem
219
- ], JSON_PRETTY_PRINT));
220
- }
221
- }
222
- } catch (\Throwable $e) {
223
- self::logError('[Fingerprint] Native Ed25519 key generation failed: ' . $e->getMessage());
224
- }
225
- }
226
- }
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
+ use Anonympins\Fingerprint\Utils\Env;
15
+
16
+ /**
17
+ * The main engine of the fingerprinting library.
18
+ * Orchestrates identification, suspicion calculation, and challenge management.
19
+ */
20
+ class FingerprintEngine
21
+ {
22
+ private array $securityConfig;
23
+ private bool $isProduction;
24
+ private BlockList $allowlist;
25
+ private bool $verbose;
26
+ private ?Logger $logger = null;
27
+ private bool $dryRun;
28
+
29
+ private static ?array $googlebotEntries = null;
30
+ private static ?array $yandexEntries = null;
31
+ private static ?array $bingbotEntries = null;
32
+ private static ?array $facebookEntries = null;
33
+
34
+ private static array $dnsCircuitBreaker = [
35
+ 'state' => 'CLOSED',
36
+ 'failureCount' => 0,
37
+ 'lastStateChange' => 0,
38
+ 'threshold' => 5,
39
+ 'cooldown' => 30, // seconds
40
+ ];
227
41
 
228
42
  /**
229
- * Applique à chaud une nouvelle configuration de sécurité (poids, seuils, etc.)
230
- * sans nécessiter de redémarrage.
43
+ * Records a successful DNS lookup, resetting the circuit breaker to CLOSED.
231
44
  *
232
- * @param array $newConfig La nouvelle configuration (partielle ou complète).
45
+ * @return void
46
+ */
47
+ private static function recordDnsSuccess(): void
48
+ {
49
+ self::$dnsCircuitBreaker['failureCount'] = 0;
50
+ self::$dnsCircuitBreaker['state'] = 'CLOSED';
51
+ }
52
+
53
+ /**
54
+ * Records a DNS lookup failure. If the failure count reaches the threshold,
55
+ * the circuit breaker transitions to the OPEN state.
56
+ *
57
+ * @return void
58
+ */
59
+ private static function recordDnsFailure(): void
60
+ {
61
+ self::$dnsCircuitBreaker['failureCount']++;
62
+ if (self::$dnsCircuitBreaker['failureCount'] >= self::$dnsCircuitBreaker['threshold']) {
63
+ self::$dnsCircuitBreaker['state'] = 'OPEN';
64
+ self::$dnsCircuitBreaker['lastStateChange'] = microtime(true);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Determines whether a DNS attempt is allowed based on the circuit breaker state.
70
+ * In OPEN state, allows a retry only after the cooldown has elapsed (HALF-OPEN).
71
+ *
72
+ * @return bool True if a DNS attempt may be made, false otherwise.
73
+ */
74
+ private static function canAttemptDns(): bool
75
+ {
76
+ if (self::$dnsCircuitBreaker['state'] === 'CLOSED') {
77
+ return true;
78
+ }
79
+ if (self::$dnsCircuitBreaker['state'] === 'OPEN') {
80
+ if (microtime(true) - self::$dnsCircuitBreaker['lastStateChange'] >= self::$dnsCircuitBreaker['cooldown']) {
81
+ self::$dnsCircuitBreaker['state'] = 'HALF-OPEN';
82
+ return true;
83
+ }
84
+ return false;
85
+ }
86
+ return true; // HALF-OPEN
87
+ }
88
+
89
+ /**
90
+ * Builds the reverse DNS query name for a given IP address
91
+ * (in-addr.arpa for IPv4, ip6.arpa for IPv6).
92
+ *
93
+ * @param string $ip The IP address.
94
+ * @return string|null The reverse DNS name, or null if the IP is invalid.
95
+ */
96
+ private static function getReverseDnsName(string $ip): ?string
97
+ {
98
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
99
+ return implode('.', array_reverse(explode('.', $ip))) . '.in-addr.arpa';
100
+ } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
101
+ $hex = bin2hex((string)inet_pton($ip));
102
+ return implode('.', array_reverse(str_split($hex))) . '.ip6.arpa';
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Resolves a DNS query using PHP's native `dns_get_record` function.
109
+ * Supports PTR, A, and AAAA record types.
110
+ *
111
+ * @param string $query The DNS query name.
112
+ * @param string $type The record type ('PTR', 'A', or 'AAAA').
113
+ * @return array|null The list of resolved records, or null on failure.
114
+ */
115
+ private static function resolveDnsNative(string $query, string $type): ?array
116
+ {
117
+ $typeMap = [
118
+ 'PTR' => DNS_PTR,
119
+ 'A' => DNS_A,
120
+ 'AAAA' => DNS_AAAA
121
+ ];
122
+ $dnsType = $typeMap[$type] ?? null;
123
+ if ($dnsType === null) {
124
+ return null;
125
+ }
126
+
127
+ $records = @dns_get_record($query, $dnsType);
128
+ if ($records === false) {
129
+ return null;
130
+ }
131
+
132
+ $results = [];
133
+ foreach ($records as $record) {
134
+ if ($type === 'PTR' && isset($record['target'])) {
135
+ $results[] = $record['target'];
136
+ } elseif ($type === 'A' && isset($record['ip'])) {
137
+ $results[] = $record['ip'];
138
+ } elseif ($type === 'AAAA' && isset($record['ipv6'])) {
139
+ $results[] = $record['ipv6'];
140
+ }
141
+ }
142
+ return $results;
143
+ }
144
+
145
+ /**
146
+ * Creates a new FingerprintEngine with the given security configuration.
147
+ * Handles Ed25519 key binding/generation, autotuning config loading,
148
+ * default whitelist application, config validation, and optional store reset.
149
+ *
150
+ * @param array $securityConfig The security configuration.
151
+ */
152
+ public function __construct(array $securityConfig)
153
+ {
154
+ $this->isProduction = Env::get('APP_ENV') === 'production';
155
+
156
+ // Dynamically bind Ed25519 keys if passed via config
157
+ if (isset($securityConfig['ed25519_private_key'])) {
158
+ Env::set('ED25519_PRIVATE_KEY', $securityConfig['ed25519_private_key']);
159
+ }
160
+ if (isset($securityConfig['ed25519_public_key'])) {
161
+ Env::set('ED25519_PUBLIC_KEY', $securityConfig['ed25519_public_key']);
162
+ }
163
+
164
+ // Automatic generation and persistence of the Ed25519 key pair
165
+ $useAsymmetric = ($securityConfig['useAsymmetricTickets'] ?? false) === true || ($securityConfig['ed25519'] ?? '') === 'auto';
166
+ $envPrivate = Env::get('ED25519_PRIVATE_KEY');
167
+ if ($useAsymmetric && ($envPrivate === null || $envPrivate === '')) {
168
+ if (function_exists('get_option') && function_exists('update_option')) {
169
+ // In WordPress, persist cryptographic keys in the database options table
170
+ $storedKeys = get_option('fingerprint_ed25519_keys');
171
+ if (is_array($storedKeys) && isset($storedKeys['privateKey'], $storedKeys['publicKey'])) {
172
+ Env::set('ED25519_PRIVATE_KEY', $storedKeys['privateKey']);
173
+ Env::set('ED25519_PUBLIC_KEY', $storedKeys['publicKey']);
174
+ } elseif (defined('OPENSSL_KEYTYPE_ED25519')) {
175
+ $pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
176
+ $pemResult = ChallengeUtils::generateIssuerPemKeys();
177
+ $privateKeyPem = $pemResult['privateKey'];
178
+ $publicKeyPem = $pemResult['publicKey'];
179
+ Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
180
+ Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
181
+ update_option('fingerprint_ed25519_keys', [
182
+ 'privateKey' => $privateKeyPem,
183
+ 'publicKey' => $publicKeyPem,
184
+ ], false);
185
+ }
186
+ } else {
187
+ self::initStandaloneEd25519Keys();
188
+ }
189
+ }
190
+
191
+ // Auto-load optimized config if autotuning savePath is specified
192
+ if (isset($securityConfig['autotuning']['savePath'])) {
193
+ $savePath = $securityConfig['autotuning']['savePath'];
194
+ if (file_exists($savePath)) {
195
+ try {
196
+ $savedConfig = json_decode(file_get_contents($savePath), true);
197
+ if (json_last_error() === JSON_ERROR_NONE && is_array($savedConfig)) {
198
+ $securityConfig = SecurityProfiles::deepMerge($securityConfig, $savedConfig);
199
+ }
200
+ } catch (\Throwable $e) {
201
+ self::logError("[FingerprintEngine] Failed to auto-load optimized config from {$savePath}: " . $e->getMessage());
202
+ }
203
+ }
204
+ }
205
+
206
+ // If no allowlist is explicitly provided, apply the default list.
207
+ if (!isset($securityConfig['whitelist'])) {
208
+ $securityConfig['whitelist'] = self::default_whitelist();
209
+ }
210
+
211
+ $this->securityConfig = $securityConfig;
212
+ $this->verbose = $securityConfig['verbose'] ?? false;
213
+ $this->allowlist = $this->buildAllowlist();
214
+ $this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
215
+ $this->dryRun = $securityConfig['dryRun'] ?? false;
216
+ $this->validateConfig($securityConfig);
217
+ if ($this->securityConfig['reset'] ?? false) {
218
+ $this->resetStore();
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Initializes and persists Ed25519 keys for standalone PHP environments outside WordPress.
224
+ * Loads from a persistent JSON file if it exists, otherwise generates a new key pair.
225
+ *
226
+ * @return void
227
+ */
228
+ private static function initStandaloneEd25519Keys(): void
229
+ {
230
+ $configDir = dirname(__DIR__, 1) . '/config';
231
+ $persistentKeyPath = $configDir . '/ed25519_key.json';
232
+
233
+ if (file_exists($persistentKeyPath)) {
234
+ try {
235
+ $keys = json_decode((string)file_get_contents($persistentKeyPath), true);
236
+ if (isset($keys['privateKey'], $keys['publicKey'])) {
237
+ Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
238
+ Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
239
+ }
240
+ } catch (\Throwable $e) {
241
+ self::logError('[Fingerprint] Failed to load persistent Ed25519 keys: ' . $e->getMessage());
242
+ }
243
+ } else {
244
+ try {
245
+ if (defined('OPENSSL_KEYTYPE_ED25519')) {
246
+ $pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
247
+ if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
248
+ $details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
249
+ $publicKeyPem = $details['key'] ?? ''; // @phpstan-ignore-line
250
+ Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
251
+ Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
252
+ if (!is_dir($configDir)) {
253
+ if (function_exists('wp_mkdir_p')) {
254
+ wp_mkdir_p($configDir);
255
+ } else {
256
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Non-WordPress standalone environment fallback
257
+ mkdir($configDir, 0777, true);
258
+ }
259
+ }
260
+ // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.PluginDirectoryWrite -- Non-WordPress standalone environment fallback
261
+ file_put_contents($persistentKeyPath, json_encode([
262
+ 'privateKey' => $privateKeyPem,
263
+ 'publicKey' => $publicKeyPem
264
+ ], JSON_PRETTY_PRINT));
265
+ }
266
+ }
267
+ } catch (\Throwable $e) {
268
+ self::logError('[Fingerprint] Native Ed25519 key generation failed: ' . $e->getMessage());
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Hot-reloads a new security configuration (weights, thresholds, etc.)
275
+ * without requiring a restart.
276
+ *
277
+ * @param array $newConfig The new configuration (partial or complete).
233
278
  */
234
279
  public function updateConfig(array $newConfig): void
235
280
  {
236
- $this->validateConfig($newConfig);
237
- $this->securityConfig = SecurityProfiles::deepMerge($this->securityConfig, $newConfig);
238
- $this->log('Configuration mise à jour à chaud (Hot-Reloaded)', $this->securityConfig);
281
+ $this->validateConfig($newConfig);
282
+ $this->securityConfig = SecurityProfiles::deepMerge($this->securityConfig, $newConfig);
283
+ $this->log('Configuration mise à jour à chaud (Hot-Reloaded)', $this->securityConfig);
284
+ }
285
+
286
+ /**
287
+ * Resets the active persistence store.
288
+ */
289
+ public function resetStore(): void
290
+ {
291
+ $store = StoreManager::getStore();
292
+ if (method_exists($store, 'clear')) {
293
+ $store->clear();
294
+ }
295
+ $this->log('Store has been reset/cleared.');
296
+ }
297
+
298
+ /**
299
+ * Validates a security configuration. Warns about missing or unknown keys
300
+ * (e.g. weights, thresholds, typos).
301
+ *
302
+ * @param array $config The configuration to validate.
303
+ * @return void
304
+ */
305
+ private function validateConfig(array $config): void
306
+ {
307
+ if (empty($config)) {
308
+ $this->log('Warning: No securityConfig provided. Using default behaviors.', [], 'warn');
309
+ return;
310
+ }
311
+
312
+ $knownKeys = [
313
+ 'weights', 'thresholds', 'cpu', 'ticketMaxAge', 'challengeTtl',
314
+ 'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
315
+ 'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
316
+ 'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
317
+ 'similarityThreshold', 'summary', 'description',
318
+ 'ed25519_private_key', 'ed25519_public_key',
319
+ 'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret', 'reset',
320
+ 'filterWhitelist',
321
+ 'differentialPrivacy', 'dpEpsilon'
322
+ ,'pat'
323
+ ];
324
+
325
+ if (empty($config['weights'])) {
326
+ $this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
327
+ }
328
+ if (empty($config['thresholds'])) {
329
+ $this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
330
+ }
331
+
332
+ foreach (array_keys($config) as $key) {
333
+ if (!in_array($key, $knownKeys, true)) {
334
+ $this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
335
+ }
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Loads a bot whitelist from a JSON file in the config directory.
341
+ * Falls back to the provided default entries if the file is missing or invalid.
342
+ *
343
+ * @param string $filename The whitelist JSON filename.
344
+ * @param array $fallbackEntries The fallback entries if loading fails.
345
+ * @return array The loaded or fallback whitelist entries.
346
+ */
347
+ private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
348
+ {
349
+ $configDir = dirname(__DIR__, 2) . '/config';
350
+ $filePath = $configDir . '/' . $filename;
351
+ if (file_exists($filePath)) {
352
+ try {
353
+ $content = file_get_contents($filePath);
354
+ if ($content !== false) {
355
+ $decoded = json_decode($content, true);
356
+ if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
357
+ return $decoded;
358
+ }
359
+ }
360
+ } catch (\Throwable $e) {
361
+ self::logError("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
362
+ }
363
+ }
364
+ return $fallbackEntries;
365
+ }
366
+
367
+ /**
368
+ * Returns the Googlebot allowlist definition (type + entries).
369
+ * The entries are lazily loaded and cached in a static property.
370
+ *
371
+ * @return array The Googlebot allowlist rule.
372
+ */
373
+ public static function googlebot_whitelist(): array
374
+ {
375
+ if (self::$googlebotEntries === null) {
376
+ self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
377
+ "2001:4860:4801:10::/64",
378
+ "2001:4860:4801:11::/64",
379
+ "2001:4860:4801:12::/64",
380
+ "66.249.79.64"
381
+ ]);
382
+ }
383
+ return [
384
+ 'type' => 'allowlist',
385
+ 'entries' => self::$googlebotEntries
386
+ ];
387
+ }
388
+
389
+ /**
390
+ * Returns the Yandex allowlist definition (type + entries).
391
+ * The entries are lazily loaded and cached in a static property.
392
+ *
393
+ * @return array The Yandex allowlist rule.
394
+ */
395
+ public static function yandex_whitelist(): array
396
+ {
397
+ if (self::$yandexEntries === null) {
398
+ self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
399
+ "2a02:6b8::/29",
400
+ "5.45.192.0/18",
401
+ "213.180.192.0/19"
402
+ ]);
403
+ }
404
+ return [
405
+ 'type' => 'allowlist',
406
+ 'entries' => self::$yandexEntries
407
+ ];
408
+ }
409
+
410
+ /**
411
+ * Returns the Bingbot allowlist definition (type + entries).
412
+ * The entries are lazily loaded and cached in a static property.
413
+ *
414
+ * @return array The Bingbot allowlist rule.
415
+ */
416
+ public static function bingbot_whitelist(): array
417
+ {
418
+ if (self::$bingbotEntries === null) {
419
+ self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
420
+ "157.55.39.0/24",
421
+ "207.46.13.0/24",
422
+ "40.77.178.0/23"
423
+ ]);
424
+ }
425
+ return [
426
+ 'type' => 'allowlist',
427
+ 'entries' => self::$bingbotEntries
428
+ ];
429
+ }
430
+
431
+ /**
432
+ * Returns the Facebook allowlist definition (type + entries).
433
+ * The entries are lazily loaded and cached in a static property.
434
+ *
435
+ * @return array The Facebook allowlist rule.
436
+ */
437
+ public static function facebook_whitelist(): array
438
+ {
439
+ if (self::$facebookEntries === null) {
440
+ self::$facebookEntries = self::loadBotWhitelist('facebook.json', [
441
+ "31.13.64.0/18", "66.220.144.0/20", "69.63.176.0/20", "157.240.0.0/16"
442
+ ]);
443
+ }
444
+ return [
445
+ 'type' => 'allowlist',
446
+ 'entries' => self::$facebookEntries
447
+ ];
448
+ }
449
+
450
+ /**
451
+ * Returns the complete default whitelist, combining IP allowlists for major
452
+ * bots (Googlebot, Bingbot, Yandex, Facebook) with hostname-suffix rules
453
+ * for many known crawlers.
454
+ *
455
+ * @return array The default whitelist rules.
456
+ */
457
+ public static function default_whitelist(): array
458
+ {
459
+ return [
460
+ self::googlebot_whitelist(),
461
+ self::bingbot_whitelist(),
462
+ self::yandex_whitelist(),
463
+ self::facebook_whitelist(),
464
+ ['userAgent' => 'Googlebot', 'hostnameSuffix' => '.googlebot.com'],
465
+ ['userAgent' => 'Google-Extended', 'hostnameSuffix' => '.google.com'],
466
+ ['userAgent' => 'AdsBot-Google', 'hostnameSuffix' => '.googlebot.com'],
467
+ ['userAgent' => 'Mediapartners-Google', 'hostnameSuffix' => '.google.com'],
468
+ ['userAgent' => 'Google-InspectionTool', 'hostnameSuffix' => '.google.com'],
469
+ ['userAgent' => '(bingbot|adidxbot)', 'hostnameSuffix' => '.search.msn.com'],
470
+ ['userAgent' => 'DuckDuckBot', 'hostnameSuffix' => '.duckduckgo.com'],
471
+ ['userAgent' => 'YandexBot', 'hostnameSuffix' => '.yandex.com'],
472
+ ['userAgent' => 'YandexImages', 'hostnameSuffix' => '.yandex.com'],
473
+ ['userAgent' => 'Baiduspider', 'hostnameSuffix' => '.crawl.baidu.com'],
474
+ ['userAgent' => 'Slurp', 'hostnameSuffix' => '.crawl.yahoo.net'],
475
+ ['userAgent' => 'Sogou web spider', 'hostnameSuffix' => '.sogou.com'],
476
+ ['userAgent' => 'Exabot', 'hostnameSuffix' => '.exabot.com'],
477
+ ['userAgent' => 'ia_archiver', 'hostnameSuffix' => '.alexa.com'],
478
+ ['userAgent' => 'SeznamBot', 'hostnameSuffix' => '.seznam.cz'],
479
+ ['userAgent' => 'Mail.RU_Bot', 'hostnameSuffix' => '.mail.ru'],
480
+ ['userAgent' => 'Yeti', 'hostnameSuffix' => '.naver.com'],
481
+ ['userAgent' => 'AhrefsBot', 'hostnameSuffix' => '.ahrefs.com'],
482
+ ['userAgent' => 'SemrushBot', 'hostnameSuffix' => '.semrush.com'],
483
+ ['userAgent' => 'MJ12bot', 'hostnameSuffix' => '.mj12bot.com'],
484
+ ['userAgent' => 'rogerbot', 'hostnameSuffix' => '.moz.com'],
485
+ ['userAgent' => 'DotBot', 'hostnameSuffix' => '.moz.com'],
486
+ ['userAgent' => 'Screaming Frog SEO Spider', 'hostnameSuffix' => '.screamingfrog.co.uk'],
487
+ ];
488
+ }
489
+
490
+ /**
491
+ * Emits a log message if verbose mode is enabled. Uses the configured
492
+ * logger callback if available, otherwise falls back to error_log.
493
+ *
494
+ * @param string $message The message to log.
495
+ * @param array $data Additional data to include.
496
+ * @param string $level The log level (default 'info').
497
+ * @return void
498
+ */
499
+ private function log(string $message, array $data = [], string $level = 'info'): void
500
+ {
501
+ if ($this->verbose) {
502
+ // Uses the logger if configured, otherwise error_log
503
+ if ($this->logger) {
504
+ $this->logger->log($level, "[FingerprintEngine] " . $message, $data);
505
+ } else {
506
+ $logMessage = "[FingerprintEngine] {$message}";
507
+ if (!empty($data)) $logMessage .= ' ' . json_encode($data);
508
+ self::logError($logMessage);
509
+ }
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Computes the final suspicion score from a suspicion vector by applying
515
+ * the configured weights. When an MTU anomaly (tunnel) is detected, several
516
+ * weights are dynamically amplified to increase the score.
517
+ *
518
+ * @param array $suspicionVector The suspicion vector (metric name => score).
519
+ * @return float The final score, capped at 100.0.
520
+ */
521
+ public function calculateFinalScore(array $suspicionVector): float
522
+ {
523
+ $baseWeights = $this->securityConfig['weights'] ?? [];
524
+
525
+ $score = 0.0;
526
+ // NEW: Weight amplification logic
527
+ // If a tunnel is detected, increase the weight of other suspicious signals.
528
+ $dynamicWeights = $baseWeights;
529
+ if (($suspicionVector['mtuAnomalyScore'] ?? 0.0) > 50.0) {
530
+ $this->log('Tunnel detected, amplifying suspicion weights.', ['mtuScore' => $suspicionVector['mtuAnomalyScore']]);
531
+
532
+ // Increases the weight of low-level inconsistencies (hard to falsify)
533
+ $dynamicWeights['tlsSpoofingScore'] = ($baseWeights['tlsSpoofingScore'] ?? 0.8) * 1.25;
534
+ $dynamicWeights['crossLayerInconsistencyScore'] = ($baseWeights['crossLayerInconsistencyScore'] ?? 0.4) * 1.4;
535
+ $dynamicWeights['clientHintsInconsistencyScore'] = ($baseWeights['clientHintsInconsistencyScore'] ?? 0.7) * 1.2;
536
+
537
+ // Increases the weight of behavioral anomalies (a bot behind a VPN is more suspect)
538
+ $dynamicWeights['behaviorScore'] = ($baseWeights['behaviorScore'] ?? 0.7) * 1.15;
539
+ $dynamicWeights['requestPatternScore'] = ($baseWeights['requestPatternScore'] ?? 0.6) * 1.2;
540
+ }
541
+
542
+ foreach ($dynamicWeights as $key => $weight) {
543
+ $metricScore = $suspicionVector[$key] ?? 0.0;
544
+ if ($metricScore > 0) {
545
+ $score += $metricScore * $weight;
546
+ }
547
+ }
548
+
549
+ return min(100.0, $score);
550
+ }
551
+
552
+ /**
553
+ * Builds the internal BlockList used for IP allowlist checks, from the
554
+ * 'allowlist' rule found in the whitelist configuration.
555
+ *
556
+ * @return BlockList The populated allowlist.
557
+ */
558
+ private function buildAllowlist(): BlockList
559
+ {
560
+ $blockList = new BlockList();
561
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
562
+
563
+ $allowlistRule = null;
564
+ foreach ($whitelistRules as $rule) {
565
+ if (($rule['type'] ?? '') === 'allowlist') {
566
+ $allowlistRule = $rule;
567
+ break;
568
+ }
569
+ }
570
+
571
+ if (empty($allowlistRule['entries'])) {
572
+ return $blockList;
573
+ }
574
+
575
+ foreach ($allowlistRule['entries'] as $entry) {
576
+ $blockList->add($entry);
577
+ }
578
+ return $blockList;
579
+ }
580
+
581
+ /**
582
+ * Checks whether the given client IP is in the IP allowlist.
583
+ *
584
+ * @param string $clientIp The client IP address.
585
+ * @return bool True if the IP is allowlisted, false otherwise.
586
+ */
587
+ private function isIpInAllowlist(string $clientIp): bool
588
+ {
589
+ return $this->allowlist->check($clientIp);
590
+ }
591
+
592
+ /**
593
+ * Checks whether the given request path is in the path allowlist.
594
+ * Supports trailing-wildcard entries (e.g. "/admin/*").
595
+ *
596
+ * @param string $requestPath The request path.
597
+ * @return bool True if the path is allowlisted, false otherwise.
598
+ */
599
+ private function isPathInAllowlist(string $requestPath): bool
600
+ {
601
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
602
+ $pathAllowlistRule = null;
603
+ foreach ($whitelistRules as $rule) {
604
+ if (($rule['type'] ?? '') === 'path_allowlist') {
605
+ $pathAllowlistRule = $rule;
606
+ break;
607
+ }
608
+ }
609
+
610
+ if (empty($pathAllowlistRule['entries'])) {
611
+ return false;
612
+ }
613
+
614
+ foreach ($pathAllowlistRule['entries'] as $entry) {
615
+ if (str_ends_with($entry, '*')) {
616
+ $base = substr($entry, 0, -1);
617
+ if (str_starts_with($requestPath, $base)) {
618
+ return true;
619
+ }
620
+ } elseif ($requestPath === $entry) {
621
+ return true;
622
+ }
623
+ }
624
+
625
+ return false;
626
+ }
627
+
628
+ /**
629
+ * Checks whether the (host, path) pair matches a host_path_allowlist entry.
630
+ *
631
+ * @param string|null $requestHost The request host.
632
+ * @param string $requestPath The request path.
633
+ * @return bool True if the host+path is allowlisted, false otherwise.
634
+ */
635
+ private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
636
+ {
637
+ if (empty($requestHost)) {
638
+ return false;
639
+ }
640
+
641
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
642
+ $hostPathRule = null;
643
+ foreach ($whitelistRules as $rule) {
644
+ if (($rule['type'] ?? '') === 'host_path_allowlist') {
645
+ $hostPathRule = $rule;
646
+ break;
647
+ }
648
+ }
649
+
650
+ if (empty($hostPathRule['entries'])) {
651
+ return false;
652
+ }
653
+
654
+ foreach ($hostPathRule['entries'] as $entry) {
655
+ if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
656
+ return true;
657
+ }
658
+ }
659
+ return false;
660
+ }
661
+
662
+ /**
663
+ * Checks if a request should be exempted due to an allowlist rule.
664
+ * Evaluates IP, path, host+path, GraphQL operation, and verified bot rules.
665
+ * Applies the `filterWhitelist` security option to optionally override
666
+ * allowlisting for clearly malicious requests.
667
+ *
668
+ * @param RequestContext $context The request context.
669
+ * @return bool True if the request should be allowed, false otherwise.
670
+ */
671
+ private function checkAllowlists(RequestContext $context): bool
672
+ {
673
+ $whitelisted = false;
674
+ $type = '';
675
+
676
+ if ($this->isIpInAllowlist($context->clientIp)) {
677
+ $whitelisted = true;
678
+ $type = 'allowlist';
679
+ } elseif ($this->isPathInAllowlist($context->path)) {
680
+ $whitelisted = true;
681
+ $type = 'path_allowlist';
682
+ } elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
683
+ $whitelisted = true;
684
+ $type = 'host_path_allowlist';
685
+ } elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
686
+ $whitelisted = true;
687
+ $type = 'graphql_operation_allowlist';
688
+ } elseif ($this->verifyWhitelistedBot($context)) {
689
+ $whitelisted = true;
690
+ $type = 'bot';
691
+ }
692
+
693
+ if ($whitelisted) {
694
+ $filterWhitelist = $this->securityConfig['filterWhitelist'] ?? false;
695
+ $bypassWhitelist = false;
696
+ if ($filterWhitelist === true || (is_numeric($filterWhitelist) && $type === 'bot')) {
697
+ $bypassWhitelist = $this->hasCertainAttack($context);
698
+ } elseif (is_numeric($filterWhitelist)) {
699
+ if ($context->preCalculatedScore === null) {
700
+ $suspicionVector = [];
701
+ $context->preCalculatedVector = $this->getSuspicionVector($context, $suspicionVector);
702
+ $context->preCalculatedScore = $this->calculateFinalScore($context->preCalculatedVector);
703
+ }
704
+ if ($context->preCalculatedScore > $filterWhitelist) {
705
+ $bypassWhitelist = true;
706
+ }
707
+ }
708
+
709
+ if ($bypassWhitelist) {
710
+ $this->log('Whitelisted request exceeds filter threshold - bypassing whitelist bypass', ['clientIp' => $context->clientIp, 'path' => $context->path]);
711
+ return false;
712
+ }
713
+ $this->log("IP/Path in allowlist ({$type}) - allowing request", ['clientIp' => $context->clientIp, 'path' => $context->path]);
714
+ return true;
715
+ }
716
+
717
+ return false;
718
+ }
719
+
720
+ /**
721
+ * Resolves the request identity (device ID, device data, and possibly a
722
+ * new cookie to set). Attempts cookieless identity recovery via TLS session
723
+ * ID, detects cookie dropping, and validates WebAuthn hardware anchors.
724
+ *
725
+ * @param RequestContext $context The request context.
726
+ * @param array &$suspicionVector The suspicion vector (updated in place).
727
+ * @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
728
+ */
729
+ private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
730
+ {
731
+ if ($context->resolvedIdentity !== null) {
732
+ return $context->resolvedIdentity;
733
+ }
734
+ $this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
735
+ $store = StoreManager::getStore();
736
+ $existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
737
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
738
+ $pendingCookieTtl = 120; // 2 minutes to detect removal
739
+
740
+ $deviceId = $existingDeviceId;
741
+ $deviceData = null;
742
+ $newCookie = null;
743
+
744
+ // Cookieless Identity Tracking: Attempt to restore device ID using TLS session resume ID
745
+ $tlsSessionId = $context->tlsSessionId;
746
+ if (!$deviceId && $tlsSessionId) {
747
+ $resumedDeviceId = $store->get("tls-session:{$tlsSessionId}");
748
+ if ($resumedDeviceId) {
749
+ $deviceId = $resumedDeviceId;
750
+ $this->log('Identity resumed via TLS Session ID', ['deviceId' => $deviceId, 'tlsSessionId' => $tlsSessionId]);
751
+ }
752
+ }
753
+
754
+ if ($deviceId) {
755
+ $deviceData = $store->get("device:{$deviceId}");
756
+ }
757
+
758
+ $ua = $context->getHeader('user-agent') ?? '';
759
+ $pendingKey = "pending_cookie:" . md5($context->clientIp . '|' . $ua);
760
+
761
+ if ($deviceData === null) {
762
+ // New user or lost/invalid cookie
763
+ $pendingDeviceId = $store->get($pendingKey);
764
+ if ($pendingDeviceId && !$existingDeviceId) {
765
+ // The penalty is now added directly to the suspicion vector.
766
+ $this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
767
+ $suspicionVector['cookieDroppingScore'] = 100.0;
768
+ }
769
+
770
+ $deviceId = bin2hex(random_bytes(16)); // UUID-like
771
+
772
+ $isHttps = !empty($context->isHttps);
773
+ $secureOption = $isHttps;
774
+
775
+ // Prepare the cookie to be sent
776
+ $newCookie = [
777
+ 'name' => 'device_id',
778
+ 'value' => $deviceId,
779
+ 'options' => [
780
+ 'httponly' => true,
781
+ 'secure' => $secureOption,
782
+ 'samesite' => 'Lax',
783
+ 'path' => '/',
784
+ ]
785
+ ];
786
+ if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
787
+ $newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
788
+ }
789
+
790
+ $store->set($pendingKey, $deviceId, $pendingCookieTtl);
791
+
792
+ $deviceData = [
793
+ 'initialDeviceHash' => $currentDeviceHash,
794
+ 'ips' => [$context->clientIp],
795
+ 'requestHistory' => [],
796
+ 'lastUpdate' => time() * 1000,
797
+ 'lastFpHash' => $currentDeviceHash,
798
+ 'lastChangeTimestamp' => 0,
799
+ 'rapidChangeCount' => 0,
800
+ 'highScoreCount' => 0,
801
+ 'lastHighScoreTimestamp' => 0,
802
+ ];
803
+ } else {
804
+ // Ensure 'ips' is an array for the following operations.
805
+ if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
806
+ $deviceData['ips'] = [];
807
+ }
808
+ // The client returned a valid cookie: purge the pending entry immediately
809
+ $store->delete($pendingKey);
810
+ }
811
+
812
+ // WebAuthn hardware anchor (Secure Enclave / TPM) validation
813
+ $behaviorHeader = $context->getHeader('x-behavior-metrics');
814
+ if (!empty($behaviorHeader) && is_string($behaviorHeader) && str_starts_with($behaviorHeader, '{')) {
815
+ try {
816
+ $parsedMetrics = json_decode($behaviorHeader, true);
817
+ if (is_array($parsedMetrics) && !empty($parsedMetrics['webauthnAnchor']) && is_array($deviceData)) {
818
+ if ($this->verifyWebAuthnHardwareAnchor($parsedMetrics['webauthnAnchor'], $deviceData)) {
819
+ $deviceData['webauthnVerified'] = true;
820
+ $store->set("device:{$deviceId}", $deviceData);
821
+ }
822
+ }
823
+ } catch (\Throwable $e) {
824
+ }
825
+ }
826
+
827
+ // Bind the current TLS session ID to the device ID
828
+ if ($deviceId && $tlsSessionId) {
829
+ $store->set("tls-session:{$tlsSessionId}", $deviceId, 3600); // 1h cache duration
830
+ }
831
+
832
+ $context->resolvedIdentity = [
833
+ 'deviceId' => $deviceId,
834
+ 'deviceData' => $deviceData,
835
+ 'newCookie' => $newCookie,
836
+ ];
837
+ return $context->resolvedIdentity;
838
+ }
839
+
840
+ /**
841
+ * Computes the full suspicion vector for a request by aggregating dozens
842
+ * of indicators (fingerprint consistency, TLS spoofing/JA4, behavioral
843
+ * metrics, threat intel, subnet reputation, protocol/TCP anomalies, etc.).
844
+ * Also updates and persists the device's state in the store.
845
+ *
846
+ * @param RequestContext $context The request context.
847
+ * @param array &$suspicionVector The suspicion vector (updated in place).
848
+ * @return array<string, float> The completed suspicion vector.
849
+ */
850
+ public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
851
+ {
852
+ $store = StoreManager::getStore();
853
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
854
+ $deviceData = $identity['deviceData'];
855
+ $deviceId = $identity['deviceId'];
856
+ if ($deviceData && ($deviceData['condemned'] ?? false)) {
857
+ $suspicionVector['honeypotScore'] = 100;
858
+ return $suspicionVector;
859
+ }
860
+
861
+ // If a new cookie must be set, temporarily store it in the context
862
+ // so that the calling code can handle it.
863
+ if ($identity['newCookie']) {
864
+ // This property is non-standard, prefix it to avoid conflicts.
865
+ $context->newCookieForResponse = $identity['newCookie'];
866
+ }
867
+
868
+ // Periodic device data cleanup
869
+ if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
870
+ $deviceData['ips'] = [];
871
+ $deviceData['rapidChangeCount'] = 0;
872
+ $deviceData['ipTimes'] = [];
873
+ }
874
+ $deviceData['lastUpdate'] = time() * 1000;
875
+
876
+ // --- Computation of the various suspicion scores ---
877
+
878
+ // Fingerprint inconsistency score (moved here to be with the others)
879
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
880
+ $consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
881
+ $similarityThreshold = (float)($this->securityConfig['similarityThreshold'] ?? 0.72);
882
+ $inconsistencyScore = RequestUtils::calculateAnalogInconsistencyScore($consistencyScore, $similarityThreshold);
883
+
884
+ $behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
885
+
886
+ // Header anomaly score
887
+ $headerAnomalies = RequestUtils::getHeaderAnomalies($context);
888
+
889
+ // TLS spoofing score
890
+ $tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
891
+ $tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
892
+ $virtualizationScore = RequestUtils::getVirtualizationAnomalyScore($context);
893
+
894
+ // Advanced JA4 TLS Inconsistency checks
895
+ $ja4 = $context->getHeader('x-ja4-hash');
896
+ if ($ja4) {
897
+ $spoofedJa4s = [
898
+ 't13d1516h2_8daaf6152771_390237aa04be', // Classic Chrome (curl-impersonate / tls-client)
899
+ 't13d1413h2_bc66258908f0_bc2531da1615', // Static Firefox (curl-impersonate-ff / curl_cffi)
900
+ 't13d1515h2_8daaf6152771_a729e2f67de4', // Static Safari (curl-impersonate-safari / tls-client)
901
+ 't13d1516h2_8daaf6152771_4be0df930c2c', // Chrome alternative (tls-client Go)
902
+ 't12d1516h2_8daaf6152771_390237aa04be', // Spoofed Chrome downgraded to TLS 1.2
903
+ 't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Custom OpenSSL scraping bot
904
+ ];
905
+ if (in_array($ja4, $spoofedJa4s, true)) {
906
+ $tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
907
+ }
908
+
909
+ $parsedJa4 = $this->parseJa4($ja4);
910
+ if ($parsedJa4) {
911
+ $ua = $context->getHeader('user-agent') ?? '';
912
+ $uaParts = $this->parseUserAgent($ua);
913
+
914
+ // Check 1: ALPN / HTTP Version inconsistency
915
+ $httpVersion = $context->httpVersion ?? '1.1';
916
+ if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
917
+ $hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
918
+ if (!$hasProxy) {
919
+ $tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
920
+ }
921
+ }
922
+
923
+ // Check 2: OS/Platform vs TLS Capabilities inconsistency
924
+ if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
925
+ $tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
926
+ }
927
+
928
+ // Check 3: User-Agent vs JA4 Signature inconsistency
929
+ if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
930
+ $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
931
+ }
932
+ if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
933
+ $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
934
+ }
935
+
936
+ // Check 4: Stagnation (Lack of Entropy / Genericity)
937
+ if (is_string($uaParts['browser'])) {
938
+ $ja4Key = "ja4-browsers:{$ja4}";
939
+ $seenBrowsers = $store->get($ja4Key) ?: [];
940
+ if (!is_array($seenBrowsers)) {
941
+ $seenBrowsers = [];
942
+ }
943
+ $browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
944
+ if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
945
+ $seenBrowsers[] = $browserFamily;
946
+ $store->set($ja4Key, $seenBrowsers, 86400);
947
+ }
948
+ if (count($seenBrowsers) > 1) {
949
+ $tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
950
+ }
951
+ }
952
+ }
953
+ }
954
+
955
+ // Time inconsistency score (replay attack)
956
+ $timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
957
+
958
+ // Cross-layer inconsistency score (client vs server)
959
+ $crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
960
+
961
+ // Request pattern score (scraping, velocity)
962
+ $requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
963
+
964
+ // Honeypot score
965
+ $honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
966
+
967
+ // Client behavioral metrics score (mouse, keyboard)
968
+ $behavior = RequestUtils::getBehaviorScore($context);
969
+
970
+ // Explicit bot detection score (automation markers)
971
+ $bot = RequestUtils::getBotScore($context);
972
+
973
+ // Click variance score
974
+ $clickVariance = RequestUtils::getClickVarianceScore($context);
975
+
976
+ // Threat Intelligence score
977
+ $threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
978
+
979
+ // Client-Hints inconsistency score
980
+ $clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
981
+
982
+ // Global fingerprint similarity score (Botnet Clustering)
983
+ $stableFp = RequestUtils::extractStablePart($currentDeviceHash);
984
+ $stableFpHash = FingerprintBuilder::cyrb53($stableFp);
985
+ $botnetCluster = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
986
+
987
+ // NEW: IP subnet reputation score
988
+ $subnetScore = RequestUtils::getSubnetScore($context, $deviceId, $this->securityConfig);
989
+
990
+ // TCP/IP stack anomaly score
991
+ $tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
992
+
993
+ // Protocol anomaly score (HTTP/2 and QUIC)
994
+ $protocolAnomaly = RequestUtils::getProtocolAnomalyScore($context);
995
+
996
+ // Display rendering anomaly score (V-Sync)
997
+ $renderingAnomaly = RequestUtils::getRenderingAnomalyScore($context);
998
+
999
+ // Assembly of the final suspicion vector
1000
+ $suspicionVector = array_merge($suspicionVector, [
1001
+ 'inconsistencyScore' => $inconsistencyScore,
1002
+ 'historyScore' => $behavioral['historyScore'],
1003
+ 'rotationScore' => $behavioral['rotationScore'],
1004
+ 'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
1005
+ 'tlsSpoofingScore' => $tlsSpoofingScore,
1006
+ 'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
1007
+ 'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
1008
+ 'requestPatternScore' => $requestPattern['requestPatternScore'], // This score is now computed
1009
+ 'honeypotScore' => $honeypot['honeypotScore'],
1010
+ 'behaviorScore' => $behavior['behaviorScore'],
1011
+ 'botScore' => $bot['botScore'],
1012
+ 'clickVarianceScore' => $clickVariance['clickVarianceScore'],
1013
+ 'threatIntelScore' => $threatIntel['threatIntelScore'],
1014
+ 'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
1015
+ 'subnetScore' => $subnetScore['subnetScore'],
1016
+ 'botnetClusterScore' => $botnetCluster['botnetClusterScore'],
1017
+ 'tcpAnomalyScore' => $tcpAnomaly['tcpAnomalyScore'],
1018
+ 'protocolAnomalyScore' => $protocolAnomaly['protocolAnomalyScore'],
1019
+ 'renderingAnomalyScore' => $renderingAnomaly['renderingAnomalyScore'],
1020
+ 'virtualizationScore' => $virtualizationScore
1021
+ ]);
1022
+
1023
+ // Save the updated device state in the store
1024
+ $store->set("device:{$deviceId}", $deviceData);
1025
+
1026
+ return $suspicionVector;
1027
+ }
1028
+
1029
+ /**
1030
+ * Parse a basic User-Agent string.
1031
+ *
1032
+ * @param string $ua The User-Agent string.
1033
+ * @return array An array with 'browser' and 'os' keys.
1034
+ */
1035
+ private function parseUserAgent(string $ua): array
1036
+ {
1037
+ $result = ['browser' => null, 'os' => null];
1038
+
1039
+ if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
1040
+ $result['browser'] = 'Chrome';
1041
+ if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
1042
+ $result['browser'] .= '/' . $matches[1];
1043
+ }
1044
+ } elseif (str_contains($ua, 'Firefox')) {
1045
+ $result['browser'] = 'Firefox';
1046
+ if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
1047
+ $result['browser'] .= '/' . $matches[1];
1048
+ }
1049
+ } elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
1050
+ $result['browser'] = 'Safari';
1051
+ if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
1052
+ $result['browser'] .= '/' . $matches[1];
1053
+ }
1054
+ } elseif (str_contains($ua, 'Edg')) {
1055
+ $result['browser'] = 'Edge';
1056
+ if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
1057
+ $result['browser'] .= '/' . $matches[1];
1058
+ }
1059
+ }
1060
+
1061
+ if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
1062
+ elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
1063
+ elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
1064
+ elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
1065
+ elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
1066
+ elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
1067
+
1068
+ return $result;
239
1069
  }
240
1070
 
241
1071
  /**
242
- * Réinitialise le store de persistance actif.
1072
+ * Parse JA4 string into protocol, version, ALPN etc.
1073
+ *
1074
+ * @param string|null $ja4 The JA4 string.
1075
+ * @return array|null The parsed components, or null if invalid.
243
1076
  */
244
- public function resetStore(): void
1077
+ private function parseJa4(?string $ja4): ?array
1078
+ {
1079
+ if (empty($ja4)) return null;
1080
+ $parts = explode('_', $ja4);
1081
+ $ja4a = $parts[0];
1082
+ if (strlen($ja4a) < 10) return null;
1083
+ return [
1084
+ 'protocol' => $ja4a[0],
1085
+ 'version' => substr($ja4a, 1, 2),
1086
+ 'sni' => $ja4a[3],
1087
+ 'ciphersCount' => (int)substr($ja4a, 4, 2),
1088
+ 'extensionsCount' => (int)substr($ja4a, 6, 2),
1089
+ 'alpn' => substr($ja4a, 8, 2),
1090
+ 'ja4b' => $parts[1] ?? null,
1091
+ 'ja4c' => $parts[2] ?? null
1092
+ ];
1093
+ }
1094
+
1095
+
1096
+ /**
1097
+ * Decodes a polymorphic fingerprint by restoring the original keys from
1098
+ * a mapping of randomized keys.
1099
+ *
1100
+ * @param string $fpString The encoded fingerprint string.
1101
+ * @param array $mapping The mapping containing a `keys` (orig => rand) entry.
1102
+ * @return string The decoded fingerprint with original keys.
1103
+ */
1104
+ private function decodePolymorphicFingerprint(string $fpString, array $mapping): string
1105
+ {
1106
+ if (empty($mapping['keys'])) {
1107
+ return $fpString;
1108
+ }
1109
+ $reverseKeys = array_flip($mapping['keys']);
1110
+ $parts = explode('|', $fpString);
1111
+ $mappedParts = [];
1112
+ foreach ($parts as $part) {
1113
+ $pair = explode(':', $part, 2);
1114
+ if (count($pair) === 2) {
1115
+ $origKey = $reverseKeys[$pair[0]] ?? $pair[0];
1116
+ $mappedParts[] = "{$origKey}:{$pair[1]}";
1117
+ } else {
1118
+ $mappedParts[] = $part;
1119
+ }
1120
+ }
1121
+ return implode('|', $mappedParts);
1122
+ }
1123
+
1124
+ /**
1125
+ * Translates polymorphic (randomized) header names back to their canonical
1126
+ * names (x-device-fingerprint, x-behavior-metrics) and decodes the
1127
+ * fingerprint values using the active mapping.
1128
+ *
1129
+ * @param RequestContext $context The request context (mutated in place).
1130
+ * @return void
1131
+ */
1132
+ private function translatePolymorphicHeaders(RequestContext $context): void
245
1133
  {
246
1134
  $store = StoreManager::getStore();
247
- if (method_exists($store, 'clear')) {
248
- $store->clear();
1135
+ $activeMappings = $store->get('active-polymorphic-mappings') ?: [];
1136
+ if (!is_array($activeMappings)) {
1137
+ return;
249
1138
  }
250
- $this->log('Store has been reset/cleared.');
1139
+
1140
+ foreach ($activeMappings as $mapping) {
1141
+ $devFpHeader = strtolower($mapping['headers']['x-device-fingerprint'] ?? '');
1142
+ $behaviorHeader = strtolower($mapping['headers']['x-behavior-metrics'] ?? '');
1143
+
1144
+ if (!empty($devFpHeader) && isset($context->headers[$devFpHeader])) {
1145
+ $context->headers['x-device-fingerprint'] = $context->headers[$devFpHeader];
1146
+ if (!empty($behaviorHeader) && isset($context->headers[$behaviorHeader])) {
1147
+ $context->headers['x-behavior-metrics'] = $context->headers[$behaviorHeader];
1148
+ }
1149
+
1150
+ $clientFp = $context->headers['x-device-fingerprint'];
1151
+ if ($clientFp && is_string($clientFp)) {
1152
+ $context->headers['x-device-fingerprint'] = $this->decodePolymorphicFingerprint($clientFp, $mapping);
1153
+ }
1154
+ break;
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ /**
1160
+ * Evaluates whether the request exhibits clear attack characteristics or
1161
+ * has triggered a honeypot (i.e. a certain attack).
1162
+ *
1163
+ * @param RequestContext $context The request context.
1164
+ * @return bool True if a certain attack is detected, false otherwise.
1165
+ */
1166
+ private function hasCertainAttack(RequestContext $context): bool
1167
+ {
1168
+ $honeypotConfig = $this->securityConfig['honeypot'] ?? [];
1169
+ $honeypot = RequestUtils::getHoneypotScore($context, $honeypotConfig);
1170
+ if (($honeypot['honeypotScore'] ?? 0.0) >= 100.0) {
1171
+ return true;
1172
+ }
1173
+ $bot = RequestUtils::getBotScore($context);
1174
+ if (($bot['botScore'] ?? 0.0) >= 100.0) {
1175
+ return true;
1176
+ }
1177
+ return false;
251
1178
  }
252
1179
 
253
- private function validateConfig(array $config): void
254
- {
255
- if (empty($config)) {
256
- $this->log('Warning: No securityConfig provided. Using default behaviors.', [], 'warn');
257
- return;
258
- }
259
-
260
- $knownKeys = [
261
- 'weights', 'thresholds', 'cpu', 'ticketMaxAge', 'challengeTtl',
262
- 'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
263
- 'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
264
- 'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
265
- 'similarityThreshold', 'summary', 'description',
266
- 'ed25519_private_key', 'ed25519_public_key',
267
- 'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret', 'reset',
268
- 'filterWhitelist'
269
- ];
270
-
271
- if (empty($config['weights'])) {
272
- $this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
273
- }
274
- if (empty($config['thresholds'])) {
275
- $this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
276
- }
277
-
278
- foreach (array_keys($config) as $key) {
279
- if (!in_array($key, $knownKeys, true)) {
280
- $this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
281
- }
282
- }
283
- }
284
- private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
285
- {
286
- $configDir = dirname(__DIR__, 2) . '/config';
287
- $filePath = $configDir . '/' . $filename;
288
- if (file_exists($filePath)) {
289
- try {
290
- $content = file_get_contents($filePath);
291
- if ($content !== false) {
292
- $decoded = json_decode($content, true);
293
- if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
294
- return $decoded;
295
- }
296
- }
297
- } catch (\Throwable $e) {
298
- self::logError("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
299
- }
300
- }
301
- return $fallbackEntries;
302
- }
303
-
304
- public static function googlebot_whitelist(): array
305
- {
306
- if (self::$googlebotEntries === null) {
307
- self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
308
- "2001:4860:4801:10::/64",
309
- "2001:4860:4801:11::/64",
310
- "2001:4860:4801:12::/64",
311
- "66.249.79.64"
312
- ]);
313
- }
314
- return [
315
- 'type' => 'allowlist',
316
- 'entries' => self::$googlebotEntries
317
- ];
318
- }
319
-
320
- public static function yandex_whitelist(): array
321
- {
322
- if (self::$yandexEntries === null) {
323
- self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
324
- "2a02:6b8::/29",
325
- "5.45.192.0/18",
326
- "213.180.192.0/19"
327
- ]);
328
- }
329
- return [
330
- 'type' => 'allowlist',
331
- 'entries' => self::$yandexEntries
332
- ];
333
- }
334
-
335
- public static function bingbot_whitelist(): array
336
- {
337
- if (self::$bingbotEntries === null) {
338
- self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
339
- "157.55.39.0/24",
340
- "207.46.13.0/24",
341
- "40.77.178.0/23"
342
- ]);
343
- }
344
- return [
345
- 'type' => 'allowlist',
346
- 'entries' => self::$bingbotEntries
347
- ];
348
- }
349
-
350
- public static function default_whitelist(): array
351
- {
352
- return [
353
- self::googlebot_whitelist(),
354
- self::bingbot_whitelist(),
355
- self::yandex_whitelist(),
356
- ['userAgent' => 'Googlebot', 'hostnameSuffix' => '.googlebot.com'],
357
- ['userAgent' => 'Google-Extended', 'hostnameSuffix' => '.google.com'],
358
- ['userAgent' => 'AdsBot-Google', 'hostnameSuffix' => '.googlebot.com'],
359
- ['userAgent' => 'Mediapartners-Google', 'hostnameSuffix' => '.google.com'],
360
- ['userAgent' => 'Google-InspectionTool', 'hostnameSuffix' => '.google.com'],
361
- ['userAgent' => '(bingbot|adidxbot)', 'hostnameSuffix' => '.search.msn.com'],
362
- ['userAgent' => 'DuckDuckBot', 'hostnameSuffix' => '.duckduckgo.com'],
363
- ['userAgent' => 'YandexBot', 'hostnameSuffix' => '.yandex.com'],
364
- ['userAgent' => 'YandexImages', 'hostnameSuffix' => '.yandex.com'],
365
- ['userAgent' => 'Baiduspider', 'hostnameSuffix' => '.crawl.baidu.com'],
366
- ['userAgent' => 'Slurp', 'hostnameSuffix' => '.crawl.yahoo.net'],
367
- ['userAgent' => 'Sogou web spider', 'hostnameSuffix' => '.sogou.com'],
368
- ['userAgent' => 'Exabot', 'hostnameSuffix' => '.exabot.com'],
369
- ['userAgent' => 'ia_archiver', 'hostnameSuffix' => '.alexa.com'],
370
- ['userAgent' => 'SeznamBot', 'hostnameSuffix' => '.seznam.cz'],
371
- ['userAgent' => 'Mail.RU_Bot', 'hostnameSuffix' => '.mail.ru'],
372
- ['userAgent' => 'Yeti', 'hostnameSuffix' => '.naver.com'],
373
- ['userAgent' => 'AhrefsBot', 'hostnameSuffix' => '.ahrefs.com'],
374
- ['userAgent' => 'SemrushBot', 'hostnameSuffix' => '.semrush.com'],
375
- ['userAgent' => 'MJ12bot', 'hostnameSuffix' => '.mj12bot.com'],
376
- ['userAgent' => 'rogerbot', 'hostnameSuffix' => '.moz.com'],
377
- ['userAgent' => 'DotBot', 'hostnameSuffix' => '.moz.com'],
378
- ['userAgent' => 'Screaming Frog SEO Spider', 'hostnameSuffix' => '.screamingfrog.co.uk'],
379
- ];
380
- }
381
- private function log(string $message, array $data = [], string $level = 'info'): void
382
- {
383
- if ($this->verbose) {
384
- // Utilise le logger s'il est configuré, sinon error_log
385
- if ($this->logger) {
386
- $this->logger->log($level, "[FingerprintEngine] " . $message, $data);
387
- } else {
388
- $logMessage = "[FingerprintEngine] {$message}";
389
- if (!empty($data)) $logMessage .= ' ' . json_encode($data);
390
- self::logError($logMessage);
391
- }
392
- }
393
- }
394
-
395
- public function calculateFinalScore(array $suspicionVector): float
396
- {
397
- $weights = $this->securityConfig['weights'] ?? [];
398
- if (empty($weights)) {
399
- return 0.0;
400
- }
401
-
402
- $score = 0.0;
403
- foreach ($weights as $key => $weight) {
404
- $score += ($suspicionVector[$key] ?? 0) * $weight;
405
- }
406
-
407
- return min(100.0, $score);
408
- }
409
-
410
- private function buildAllowlist(): BlockList
411
- {
412
- $blockList = new BlockList();
413
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
414
-
415
- $allowlistRule = null;
416
- foreach ($whitelistRules as $rule) {
417
- if (($rule['type'] ?? '') === 'allowlist') {
418
- $allowlistRule = $rule;
419
- break;
420
- }
421
- }
422
-
423
- if (empty($allowlistRule['entries'])) {
424
- return $blockList;
425
- }
426
-
427
- foreach ($allowlistRule['entries'] as $entry) {
428
- $blockList->add($entry);
429
- }
430
- return $blockList;
431
- }
432
-
433
- private function isIpInAllowlist(string $clientIp): bool
434
- {
435
- return $this->allowlist->check($clientIp);
436
- }
437
-
438
- private function isPathInAllowlist(string $requestPath): bool
439
- {
440
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
441
- $pathAllowlistRule = null;
442
- foreach ($whitelistRules as $rule) {
443
- if (($rule['type'] ?? '') === 'path_allowlist') {
444
- $pathAllowlistRule = $rule;
445
- break;
446
- }
447
- }
448
-
449
- if (empty($pathAllowlistRule['entries'])) {
450
- return false;
451
- }
452
-
453
- foreach ($pathAllowlistRule['entries'] as $entry) {
454
- if (str_ends_with($entry, '*')) {
455
- $base = substr($entry, 0, -1);
456
- if (str_starts_with($requestPath, $base)) {
457
- return true;
458
- }
459
- } elseif ($requestPath === $entry) {
460
- return true;
461
- }
462
- }
463
-
464
- return false;
465
- }
466
-
467
- private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
468
- {
469
- if (empty($requestHost)) {
470
- return false;
471
- }
472
-
473
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
474
- $hostPathRule = null;
475
- foreach ($whitelistRules as $rule) {
476
- if (($rule['type'] ?? '') === 'host_path_allowlist') {
477
- $hostPathRule = $rule;
478
- break;
479
- }
480
- }
481
-
482
- if (empty($hostPathRule['entries'])) {
483
- return false;
484
- }
485
-
486
- foreach ($hostPathRule['entries'] as $entry) {
487
- if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
488
- return true;
489
- }
490
- }
491
- return false;
492
- }
493
-
494
- /**
495
- * Vérifie si une requête doit être exemptée en raison d'une règle de liste blanche.
496
- */
497
- private function checkAllowlists(RequestContext $context): bool
498
- {
499
- $whitelisted = false;
500
- $type = '';
501
-
502
- if ($this->isIpInAllowlist($context->clientIp)) {
503
- $whitelisted = true;
504
- $type = 'allowlist';
505
- } elseif ($this->isPathInAllowlist($context->path)) {
506
- $whitelisted = true;
507
- $type = 'path_allowlist';
508
- } elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
509
- $whitelisted = true;
510
- $type = 'host_path_allowlist';
511
- } elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
512
- $whitelisted = true;
513
- $type = 'graphql_operation_allowlist';
514
- } elseif ($this->verifyWhitelistedBot($context)) {
515
- $whitelisted = true;
516
- $type = 'bot';
517
- }
518
-
519
- if ($whitelisted) {
520
- $filterWhitelist = $this->securityConfig['filterWhitelist'] ?? false;
521
- $bypassWhitelist = false;
522
- if ($filterWhitelist === true) {
523
- $bypassWhitelist = $this->hasCertainAttack($context);
524
- } elseif (is_numeric($filterWhitelist)) {
525
- if ($context->preCalculatedScore === null) {
526
- $suspicionVector = [];
527
- $context->preCalculatedVector = $this->getSuspicionVector($context, $suspicionVector);
528
- $context->preCalculatedScore = $this->calculateFinalScore($context->preCalculatedVector);
529
- }
530
- if ($context->preCalculatedScore > $filterWhitelist) {
531
- $bypassWhitelist = true;
532
- }
533
- }
534
-
535
- if ($bypassWhitelist) {
536
- $this->log('Whitelisted request exceeds filter threshold - bypassing whitelist bypass', ['clientIp' => $context->clientIp, 'path' => $context->path]);
537
- return false;
538
- }
539
- $this->log("IP/Path in allowlist ({$type}) - allowing request", ['clientIp' => $context->clientIp, 'path' => $context->path]);
540
- return true;
541
- }
542
-
543
- return false;
544
- }
545
-
546
- /**
547
- * @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
548
- */
549
- private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
550
- {
551
- if ($context->resolvedIdentity !== null) {
552
- return $context->resolvedIdentity;
553
- }
554
- $this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
555
- $store = StoreManager::getStore();
556
- $existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
557
- $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
558
- $pendingCookieTtl = 120; // 2 minutes pour détecter la suppression
559
-
560
- $deviceId = $existingDeviceId;
561
- $deviceData = null;
562
- $newCookie = null;
563
-
564
- // Cookieless Identity Tracking: Attempt to restore device ID using TLS session resume ID
565
- $tlsSessionId = $context->tlsSessionId;
566
- if (!$deviceId && $tlsSessionId) {
567
- $resumedDeviceId = $store->get("tls-session:{$tlsSessionId}");
568
- if ($resumedDeviceId) {
569
- $deviceId = $resumedDeviceId;
570
- $this->log('Identity resumed via TLS Session ID', ['deviceId' => $deviceId, 'tlsSessionId' => $tlsSessionId]);
571
- }
572
- }
573
-
574
- if ($deviceId) {
575
- $deviceData = $store->get("device:{$deviceId}");
576
- }
577
-
578
- if ($deviceData === null) {
579
- // Nouvel utilisateur ou cookie perdu/invalide
580
- $pendingDeviceId = $store->get("pending_cookie:{$context->clientIp}");
581
- if ($pendingDeviceId && !$existingDeviceId) {
582
- // La pénalité est maintenant ajoutée directement au vecteur de suspicion.
583
- $this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
584
- $suspicionVector['cookieDroppingScore'] = 100.0;
585
- }
586
-
587
- $deviceId = bin2hex(random_bytes(16)); // UUID-like
588
-
589
- $isHttps = !empty($context->isHttps);
590
- $secureOption = $isHttps;
591
-
592
- // Préparer le cookie à envoyer
593
- $newCookie = [
594
- 'name' => 'device_id',
595
- 'value' => $deviceId,
596
- 'options' => [
597
- 'httponly' => true,
598
- 'secure' => $secureOption,
599
- 'samesite' => 'Strict',
600
- 'path' => '/',
601
- ]
602
- ];
603
- if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
604
- $newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
605
- }
606
-
607
- $store->set("pending_cookie:{$context->clientIp}", $deviceId, $pendingCookieTtl);
608
-
609
- $deviceData = [
610
- 'initialDeviceHash' => $currentDeviceHash,
611
- 'ips' => [$context->clientIp],
612
- 'requestHistory' => [],
613
- 'lastUpdate' => time() * 1000,
614
- 'lastFpHash' => $currentDeviceHash,
615
- 'lastChangeTimestamp' => 0,
616
- 'rapidChangeCount' => 0,
617
- 'highScoreCount' => 0,
618
- 'lastHighScoreTimestamp' => 0,
619
- ];
620
- } else {
621
- // S'assurer que 'ips' est un tableau pour les opérations suivantes.
622
- if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
623
- $deviceData['ips'] = [];
624
- }
625
- }
626
-
627
- // Bind the current TLS session ID to the device ID
628
- if ($deviceId && $tlsSessionId) {
629
- $store->set("tls-session:{$tlsSessionId}", $deviceId, 3600); // 1h cache duration
630
- }
631
-
632
- $context->resolvedIdentity = [
633
- 'deviceId' => $deviceId,
634
- 'deviceData' => $deviceData,
635
- 'newCookie' => $newCookie,
636
- ];
637
- return $context->resolvedIdentity;
638
- }
639
-
640
- /**
641
- * @return array<string, float>
642
- */
643
- public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
644
- {
645
- $store = StoreManager::getStore();
646
- $identity = $this->resolveRequestIdentity($context, $suspicionVector);
647
- $deviceData = $identity['deviceData'];
648
- $deviceId = $identity['deviceId'];
649
- if ($deviceData && ($deviceData['condemned'] ?? false)) {
650
- $suspicionVector['honeypotScore'] = 100;
651
- return $suspicionVector;
652
- }
653
-
654
- // Si un nouveau cookie doit être défini, on le stocke temporairement dans le contexte
655
- // pour que le code appelant puisse le gérer.
656
- if ($identity['newCookie']) {
657
- // Cette propriété n'est pas standard, on la préfixe pour éviter les conflits.
658
- $context->newCookieForResponse = $identity['newCookie'];
659
- }
660
-
661
- // Nettoyage périodique des données de l'appareil
662
- if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
663
- $deviceData['ips'] = [];
664
- $deviceData['rapidChangeCount'] = 0;
665
- $deviceData['ipTimes'] = [];
666
- }
667
- $deviceData['lastUpdate'] = time() * 1000;
668
-
669
- // --- Calcul des différents scores de suspicion ---
670
-
671
- // Score d'incohérence du fingerprint (déplacé ici pour être avec les autres)
672
- $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
673
- $consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
674
- $similarityThreshold = (float)($this->securityConfig['similarityThreshold'] ?? 0.72);
675
- $inconsistencyScore = RequestUtils::calculateAnalogInconsistencyScore($consistencyScore, $similarityThreshold);
676
-
677
- $behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
678
-
679
- // Score des anomalies d'en-têtes
680
- $headerAnomalies = RequestUtils::getHeaderAnomalies($context);
681
-
682
- // Score de spoofing TLS
683
- $tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
684
- $tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
685
- $virtualizationScore = RequestUtils::getVirtualizationAnomalyScore($context);
686
-
687
- // Advanced JA4 TLS Inconsistency checks
688
- $ja4 = $context->getHeader('x-ja4-hash');
689
- if ($ja4) {
690
- $spoofedJa4s = [
691
- 't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
692
- 't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
693
- 't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
694
- 't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
695
- 't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
696
- 't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
697
- ];
698
- if (in_array($ja4, $spoofedJa4s, true)) {
699
- $tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
700
- }
701
-
702
- $parsedJa4 = $this->parseJa4($ja4);
703
- if ($parsedJa4) {
704
- $ua = $context->getHeader('user-agent') ?? '';
705
- $uaParts = $this->parseUserAgent($ua);
706
-
707
- // Check 1: Incohérence ALPN / HTTP Version
708
- $httpVersion = $context->httpVersion ?? '1.1';
709
- if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
710
- $hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
711
- if (!$hasProxy) {
712
- $tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
713
- }
714
- }
715
-
716
- // Check 2: Incohérence OS/Plateforme vs Capabilities TLS
717
- if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
718
- $tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
719
- }
720
-
721
- // Check 3: Incohérence User-Agent vs Signature JA4
722
- if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
723
- $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
724
- }
725
- if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
726
- $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
727
- }
728
-
729
- // Check 4: La stagnation (Lack of Entropy / Genericity)
730
- if (is_string($uaParts['browser'])) {
731
- $ja4Key = "ja4-browsers:{$ja4}";
732
- $seenBrowsers = $store->get($ja4Key) ?: [];
733
- if (!is_array($seenBrowsers)) {
734
- $seenBrowsers = [];
735
- }
736
- $browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
737
- if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
738
- $seenBrowsers[] = $browserFamily;
739
- $store->set($ja4Key, $seenBrowsers, 86400);
740
- }
741
- if (count($seenBrowsers) > 1) {
742
- $tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
743
- }
744
- }
745
- }
746
- }
747
-
748
- // Score d'incohérence temporelle (attaque par rejeu)
749
- $timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
750
-
751
- // Score des incohérences entre couches (client vs serveur)
752
- $crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
753
-
754
- // Score des patterns de requêtes (scraping, vélocité)
755
- $requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
756
-
757
- // Score des honeypots
758
- $honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
759
-
760
- // Score des métriques comportementales client (souris, clavier)
761
- $behavior = RequestUtils::getBehaviorScore($context);
762
-
763
- // Score de détection de bot explicite (marqueurs d'automatisation)
764
- $bot = RequestUtils::getBotScore($context);
765
-
766
- // Score de variance des clics
767
- $clickVariance = RequestUtils::getClickVarianceScore($context);
768
-
769
- // Score basé sur les listes de menaces (Threat Intelligence)
770
- $threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
771
-
772
- // Score d'incohérence des Client-Hints
773
- $clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
774
-
775
- // Score de similarité globale de l'empreinte (Clustering Botnet)
776
- $stableFp = RequestUtils::extractStablePart($currentDeviceHash);
777
- $stableFpHash = FingerprintBuilder::cyrb53($stableFp);
778
- $botnetCluster = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
779
-
780
- // NOUVEAU: Score de réputation du sous-réseau IP
781
- $subnetScore = RequestUtils::getSubnetScore($context, $deviceId, $this->securityConfig);
782
-
783
- // Score d'anomalie de pile TCP/IP
784
- $tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
785
-
786
- // Score d'anomalie de protocole (HTTP/2 et QUIC)
787
- $protocolAnomaly = RequestUtils::getProtocolAnomalyScore($context);
788
-
789
- // Score d'anomalie de rendu d'affichage (V-Sync)
790
- $renderingAnomaly = RequestUtils::getRenderingAnomalyScore($context);
791
-
792
- // Assemblage du vecteur de suspicion final
793
- $suspicionVector = array_merge($suspicionVector, [
794
- 'inconsistencyScore' => $inconsistencyScore,
795
- 'historyScore' => $behavioral['historyScore'],
796
- 'rotationScore' => $behavioral['rotationScore'],
797
- 'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
798
- 'tlsSpoofingScore' => $tlsSpoofingScore,
799
- 'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
800
- 'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
801
- 'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
802
- 'honeypotScore' => $honeypot['honeypotScore'],
803
- 'behaviorScore' => $behavior['behaviorScore'],
804
- 'botScore' => $bot['botScore'],
805
- 'clickVarianceScore' => $clickVariance['clickVarianceScore'],
806
- 'threatIntelScore' => $threatIntel['threatIntelScore'],
807
- 'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
808
- 'subnetScore' => $subnetScore['subnetScore'],
809
- 'botnetClusterScore' => $botnetCluster['botnetClusterScore'],
810
- 'tcpAnomalyScore' => $tcpAnomaly['tcpAnomalyScore'],
811
- 'protocolAnomalyScore' => $protocolAnomaly['protocolAnomalyScore'],
812
- 'renderingAnomalyScore' => $renderingAnomaly['renderingAnomalyScore'],
813
- 'virtualizationScore' => $virtualizationScore
814
- ]);
815
-
816
- // Sauvegarder l'état mis à jour de l'appareil dans le store
817
- $store->set("device:{$deviceId}", $deviceData);
818
-
819
- return $suspicionVector;
820
- }
821
-
822
- /**
823
- * Parse a basic User-Agent string.
824
- */
825
- private function parseUserAgent(string $ua): array
826
- {
827
- $result = ['browser' => null, 'os' => null];
828
-
829
- if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
830
- $result['browser'] = 'Chrome';
831
- if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
832
- $result['browser'] .= '/' . $matches[1];
833
- }
834
- } elseif (str_contains($ua, 'Firefox')) {
835
- $result['browser'] = 'Firefox';
836
- if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
837
- $result['browser'] .= '/' . $matches[1];
838
- }
839
- } elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
840
- $result['browser'] = 'Safari';
841
- if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
842
- $result['browser'] .= '/' . $matches[1];
843
- }
844
- } elseif (str_contains($ua, 'Edg')) {
845
- $result['browser'] = 'Edge';
846
- if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
847
- $result['browser'] .= '/' . $matches[1];
848
- }
849
- }
850
-
851
- if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
852
- elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
853
- elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
854
- elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
855
- elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
856
- elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
857
-
858
- return $result;
859
- }
860
-
861
- /**
862
- * Parse JA4 string into protocol, version, ALPN etc.
863
- */
864
- private function parseJa4(?string $ja4): ?array
865
- {
866
- if (empty($ja4)) return null;
867
- $parts = explode('_', $ja4);
868
- $ja4a = $parts[0];
869
- if (strlen($ja4a) < 10) return null;
870
- return [
871
- 'protocol' => $ja4a[0],
872
- 'version' => substr($ja4a, 1, 2),
873
- 'sni' => $ja4a[3],
874
- 'ciphersCount' => (int)substr($ja4a, 4, 2),
875
- 'extensionsCount' => (int)substr($ja4a, 6, 2),
876
- 'alpn' => substr($ja4a, 8, 2),
877
- 'ja4b' => $parts[1] ?? null,
878
- 'ja4c' => $parts[2] ?? null
879
- ];
880
- }
881
-
882
-
883
- private function decodePolymorphicFingerprint(string $fpString, array $mapping): string
884
- {
885
- if (empty($mapping['keys'])) {
886
- return $fpString;
887
- }
888
- $reverseKeys = array_flip($mapping['keys']);
889
- $parts = explode('|', $fpString);
890
- $mappedParts = [];
891
- foreach ($parts as $part) {
892
- $pair = explode(':', $part, 2);
893
- if (count($pair) === 2) {
894
- $origKey = $reverseKeys[$pair[0]] ?? $pair[0];
895
- $mappedParts[] = "{$origKey}:{$pair[1]}";
896
- } else {
897
- $mappedParts[] = $part;
898
- }
899
- }
900
- return implode('|', $mappedParts);
901
- }
902
- private function translatePolymorphicHeaders(RequestContext $context): void
903
- {
904
- $store = StoreManager::getStore();
905
- $activeMappings = $store->get('active-polymorphic-mappings') ?: [];
906
- if (!is_array($activeMappings)) {
907
- return;
908
- }
909
-
910
- foreach ($activeMappings as $mapping) {
911
- $devFpHeader = strtolower($mapping['headers']['x-device-fingerprint'] ?? '');
912
- $behaviorHeader = strtolower($mapping['headers']['x-behavior-metrics'] ?? '');
913
-
914
- if (!empty($devFpHeader) && isset($context->headers[$devFpHeader])) {
915
- $context->headers['x-device-fingerprint'] = $context->headers[$devFpHeader];
916
- if (!empty($behaviorHeader) && isset($context->headers[$behaviorHeader])) {
917
- $context->headers['x-behavior-metrics'] = $context->headers[$behaviorHeader];
918
- }
919
-
920
- $clientFp = $context->headers['x-device-fingerprint'];
921
- if ($clientFp && is_string($clientFp)) {
922
- $context->headers['x-device-fingerprint'] = $this->decodePolymorphicFingerprint($clientFp, $mapping);
923
- }
924
- break;
925
- }
926
- }
927
- }
928
-
929
- /**
930
- * Évalue si la requête présente des caractéristiques d'attaque flagrantes ou a déclenché un honeypot.
931
- */
932
- private function hasCertainAttack(RequestContext $context): bool
933
- {
934
- $honeypotConfig = $this->securityConfig['honeypot'] ?? [];
935
- $honeypot = RequestUtils::getHoneypotScore($context, $honeypotConfig);
936
- if (($honeypot['honeypotScore'] ?? 0.0) >= 100.0) {
937
- return true;
938
- }
939
- $bot = RequestUtils::getBotScore($context);
940
- if (($bot['botScore'] ?? 0.0) >= 100.0) {
941
- return true;
942
- }
943
- return false;
944
- }
945
-
946
- /**
947
- * Traite une requête entrante et retourne une décision.
948
- * @param RequestContext $context Le contexte de la requête.
949
- * @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
950
- */
951
- public function processRequest(RequestContext $context): array
952
- {
1180
+ /**
1181
+ * Processes an incoming request and returns a decision (action to take).
1182
+ * This is the main entry point of the engine: it resolves identity,
1183
+ * checks allowlists/tickets, computes the suspicion score, verifies PoW
1184
+ * submissions, selects and issues challenges, and applies block/allow logic.
1185
+ *
1186
+ * @param RequestContext $context The request context.
1187
+ * @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
1188
+ */
1189
+ public function processRequest(RequestContext $context): array
1190
+ {
1191
+ $store = StoreManager::getStore();
953
1192
  $this->translatePolymorphicHeaders($context);
954
1193
 
955
- // Initialiser le vecteur de suspicion pour éviter les erreurs de type.
956
- $suspicionVector = [];
957
-
958
- $coopOp = $context->query['coop_op'] ?? null;
959
- if ($coopOp) {
960
- $result = ChallengeUtils::handleCooperativeRequest($context->query, $context->clientIp, $this->securityConfig);
961
- return [
962
- 'action' => 'challenge',
963
- 'status' => 200,
964
- 'body' => $result
965
- ];
966
- }
1194
+ // Initialize the suspicion vector to avoid type errors.
1195
+ $suspicionVector = [];
1196
+
1197
+ $coopOp = $context->query['coop_op'] ?? null;
1198
+ if ($coopOp) {
1199
+ $result = ChallengeUtils::handleCooperativeRequest($context->query, $context->clientIp, $this->securityConfig);
1200
+ return [
1201
+ 'action' => 'challenge',
1202
+ 'status' => 200,
1203
+ 'body' => $result
1204
+ ];
1205
+ }
967
1206
 
968
1207
  $this->log('Processing request', ['clientIp' => $context->clientIp, 'path' => $context->path]);
969
-
1208
+
970
1209
  // Parse GraphQL query if applicable
971
1210
  if ($context->path === '/graphql' && !empty($context->body)) {
972
1211
  $gqlInfo = RequestUtils::parseGraphQLQuery(is_array($context->body) ? $context->body : []);
@@ -974,777 +1213,949 @@
974
1213
  $context->graphqlOperation = $gqlInfo;
975
1214
  }
976
1215
  }
977
-
978
- // 1. Vérifier les listes blanches
979
- if ($this->checkAllowlists($context)) {
980
- MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
981
- return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
982
- }
983
-
984
- // Initialiser le vecteur de suspicion
985
- $thresholds = $this->securityConfig['thresholds'];
986
-
987
- // 2. Gérer la soumission d'une solution de challenge (PRIORITÉ HAUTE)
988
- $powNonce = $context->query['pow_nonce'] ?? null;
989
- $isChallengeSubmission = $powNonce && (
990
- isset($context->query['pow_solution']) ||
991
- isset($context->query['pow_solution_cpu']) ||
992
- isset($context->query['pow_solution_space']) ||
993
- (isset($context->query['pow_type']) && $context->query['pow_type'] === 'useful_work_task')
994
- );
995
- if ($isChallengeSubmission) {
996
- $this->log('Challenge solution submitted', ['pow_type' => $context->query['pow_type'] ?? 'unknown', 'nonce' => $powNonce]);
997
- $store = StoreManager::getStore();
998
- $challengeContext = $store->get("secret:{$powNonce}");
999
- $powType = $context->query['pow_type'] ?? null;
1000
-
1001
- if ($challengeContext) {
1002
- $isValid = false;
1003
- $ticket = null;
1004
-
1005
- // Vérification de la cohérence du fingerprint
1006
- $solverFingerprint = $context->query['pow_fp'] ?? RequestUtils::getCompositeDeviceHash($context);
1007
- $originalFingerprint = $challengeContext['fingerprint'] ?? '';
1008
- $similarity = FingerprintBuilder::compare($originalFingerprint, $solverFingerprint);
1009
- $similarityThreshold = $this->securityConfig['similarityThreshold'] ?? 0.95;
1010
-
1011
- if ($similarity < $similarityThreshold) {
1012
- $this->log('Fingerprint mismatch - challenge solved on a different machine!', [
1013
- 'similarity' => round($similarity, 4),
1014
- 'threshold' => $similarityThreshold
1015
- ], 'warn');
1016
- $isValid = false;
1017
- } else {
1018
- // Le fingerprint est cohérent, on peut valider la solution
1019
- if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
1020
- $cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
1021
- if ($cpuSolution !== null && $cpuSolution !== '') {
1022
- $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1023
- $deviceId = (string)($identity['deviceId'] ?? '');
1024
- $currentDeviceHash = (string)($identity['currentDeviceHash'] ?? RequestUtils::getCompositeDeviceHash($context));
1025
-
1026
- $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket(
1027
- $context->clientIp,
1028
- 3600000,
1029
- $powNonce,
1030
- (string)$cpuSolution,
1031
- $challengeContext,
1032
- $deviceId,
1033
- $currentDeviceHash
1034
- );
1035
- $isValid = $ticket !== null;
1036
-
1037
- if ($powType === 'cpu_mem') {
1038
- if (!empty($challengeContext['isHttp'])) {
1039
- $isMemValid = true;
1040
- } else {
1041
- $memSolution = $context->query['pow_solution_mem'] ?? null;
1042
- $isMemValid = ($memSolution !== null && $memSolution !== '')
1043
- ? ChallengeUtils::verifyMemoryPoW(
1044
- $powNonce,
1045
- (string)$memSolution,
1046
- $challengeContext['memDifficulty'] ?? 0,
1047
- $challengeContext['clientSecret'] ?? ''
1048
- )
1049
- : false;
1050
- }
1051
- $isValid = $isValid && $isMemValid;
1052
- }
1053
- }
1054
- } elseif ($powType === 'pospace') {
1055
- $powSolutionSpace = $context->query['pow_solution_space'] ?? null;
1056
- if ($powSolutionSpace && isset($challengeContext['queries'])) {
1057
- $isSpaceValid = ChallengeUtils::verifySpacePoW(
1058
- $powNonce,
1059
- $powSolutionSpace,
1060
- $challengeContext['queries'],
1061
- $powNonce . ":" . $challengeContext['clientSecret'],
1062
- $challengeContext['clientSecret']
1063
- );
1064
- $isValid = $isSpaceValid;
1065
- if ($isValid) {
1066
- $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
1067
- $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
1068
- $ticket = ChallengeUtils::generateStatelessTicket([
1069
- 'expiry' => $expiry,
1070
- 'originalIp' => $context->clientIp,
1071
- 'deviceId' => '',
1072
- 'deviceHash' => ''
1073
- ]);
1074
- }
1075
- }
1076
- } elseif ($powType === 'useful_work_task') {
1077
- $problemId = $context->query['pow_problem_id'] ?? null;
1078
- $workResultJson = $context->query['pow_solution_work_result'] ?? null;
1079
- if ($problemId && $workResultJson) {
1080
- $workResult = json_decode($workResultJson, true);
1081
- $this->log('Verifying useful work solution.', [
1082
- 'problemId' => $problemId,
1083
- 'receivedData' => $workResult,
1084
- 'jsonLastError' => json_last_error_msg()
1085
- ]);
1086
- if (json_last_error() === JSON_ERROR_NONE) {
1087
- // @phpstan-ignore-next-line - L'instance est gérée par le singleton
1088
- $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
1216
+
1217
+ // 0. Resolve device identity and validate WebAuthn hardware attestation
1218
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1219
+ $deviceId = $identity['deviceId'];
1220
+ $deviceData = $identity['deviceData'];
1221
+
1222
+ // Anti-Farm Rule: A condemned device or one conducting a certain attack gets no bypass
1223
+ if (($deviceData['condemned'] ?? false) || $this->hasCertainAttack($context)) {
1224
+ if (is_array($deviceData)) {
1225
+ $deviceData['condemned'] = true;
1226
+ $deviceData['webauthnVerified'] = false;
1227
+ $store->set("device:{$deviceId}", $deviceData);
1228
+ }
1229
+ $this->log('Condemned device or attack detected - revoking hardware trust and blocking', ['deviceId' => $deviceId]);
1230
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100.0, 'vector' => ['honeypotScore' => 100.0]];
1231
+ if ($this->dryRun) {
1232
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1233
+ $decision['intendedAction'] = $decision['action'];
1234
+ $decision['action'] = 'next';
1235
+ unset($decision['status'], $decision['body']);
1236
+ }
1237
+ return $decision;
1238
+ }
1239
+
1240
+ // Instant bypass if the device has cryptographically proven its hardware identity (Secure Enclave / TPM)
1241
+ if ($deviceData && !empty($deviceData['webauthnVerified'])) {
1242
+ $this->log('Hardware-anchored device verified (WebAuthn) - full bypass granted', ['deviceId' => $deviceId]);
1243
+ $greenlistTtl = 365 * 86400 * 1000; // 1 year (permanent)
1244
+ $greenlistTicket = ChallengeUtils::generateStatelessTicket([
1245
+ 'expiry' => (int)floor(microtime(true) * 1000) + $greenlistTtl,
1246
+ 'originalIp' => $context->clientIp,
1247
+ 'deviceId' => $deviceId,
1248
+ 'deviceHash' => 'webauthn:greenlist:' . ($deviceData['webauthnCredentialId'] ?? $deviceId),
1249
+ 'greenlist' => true
1250
+ ]);
1251
+
1252
+ $isHttps = !empty($context->isHttps);
1253
+ $decision = [
1254
+ 'action' => 'next',
1255
+ 'score' => 0.0,
1256
+ 'vector' => ['webauthn_verified' => 100.0],
1257
+ 'cookie' => [
1258
+ 'name' => 'pow_clearance',
1259
+ 'value' => $greenlistTicket,
1260
+ 'options' => [
1261
+ 'httponly' => true,
1262
+ 'secure' => $isHttps,
1263
+ 'samesite' => 'Strict',
1264
+ 'expires' => time() + ($greenlistTtl / 1000),
1265
+ 'path' => '/',
1266
+ ]
1267
+ ]
1268
+ ];
1269
+ if (isset($context->newCookieForResponse)) {
1270
+ $decision['newCookieForResponse'] = $context->newCookieForResponse;
1271
+ }
1272
+ return $decision;
1273
+ }
1274
+
1275
+ // --- PRIVATE ACCESS TOKENS (PAT / RFC 9505 & Privacy Pass) ZERO-FRICTION BYPASS ---
1276
+ $rawPatTokens = RequestUtils::extractPrivateAccessTokens($context);
1277
+ if (!empty($rawPatTokens)) {
1278
+ $patConfig = $this->securityConfig['pat'] ?? [];
1279
+ foreach ($rawPatTokens as $rawToken) {
1280
+ $parsedToken = RequestUtils::parsePrivateAccessToken($rawToken);
1281
+ if ($parsedToken !== null) {
1282
+ $nonceKey = "pat-nonce:{$parsedToken['nonce']}";
1283
+ if (!$store->has($nonceKey)) {
1284
+ $isValid = RequestUtils::verifyPatSignature($parsedToken, $patConfig);
1285
+ if ($isValid) {
1286
+ $nonceTtl = $patConfig['nonceTtl'] ?? 86400;
1287
+ $store->set($nonceKey, true, $nonceTtl);
1288
+ $this->log('Private Access Token (PAT) cryptographically verified - granting zero-friction bypass', [
1289
+ 'tokenType' => $parsedToken['tokenType'],
1290
+ 'tokenKeyId' => $parsedToken['tokenKeyId']
1291
+ ]);
1292
+ $decision = [
1293
+ 'action' => 'next',
1294
+ 'score' => 0.0,
1295
+ 'vector' => ['pat_verified' => 100.0, 'privacy_pass' => 100.0]
1296
+ ];
1297
+ return $decision;
1298
+ }
1299
+ }
1300
+ }
1301
+ }
1302
+ }
1303
+
1304
+ // 1. Check allowlists
1305
+ if ($this->checkAllowlists($context)) {
1306
+ MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
1307
+ return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
1308
+ }
1309
+
1310
+ // Initialize the suspicion vector
1311
+ $thresholds = $this->securityConfig['thresholds'];
1312
+
1313
+ // 2. Handle challenge solution submission (HIGH PRIORITY)
1314
+ $powNonce = $context->query['pow_nonce'] ?? null;
1315
+ $isChallengeSubmission = $powNonce && (
1316
+ isset($context->query['pow_solution']) ||
1317
+ isset($context->query['pow_solution_cpu']) ||
1318
+ isset($context->query['pow_solution_space']) ||
1319
+ (isset($context->query['pow_type']) && $context->query['pow_type'] === 'useful_work_task')
1320
+ );
1321
+ if ($isChallengeSubmission) {
1322
+ $this->log('Challenge solution submitted', ['pow_type' => $context->query['pow_type'] ?? 'unknown', 'nonce' => $powNonce]);
1323
+ $store = StoreManager::getStore();
1324
+ $challengeContext = $store->get("secret:{$powNonce}");
1325
+ $powType = $context->query['pow_type'] ?? null;
1326
+
1327
+ if ($challengeContext) {
1328
+ $isValid = false;
1329
+ $ticket = null;
1330
+
1331
+ // Fingerprint consistency check
1332
+ $solverFingerprint = $context->query['pow_fp'] ?? RequestUtils::getCompositeDeviceHash($context);
1333
+ $originalFingerprint = $challengeContext['fingerprint'] ?? '';
1334
+ $similarity = FingerprintBuilder::compare($originalFingerprint, $solverFingerprint);
1335
+ $similarityThreshold = $this->securityConfig['similarityThreshold'] ?? 0.95;
1336
+
1337
+ if ($similarity < $similarityThreshold) {
1338
+ $this->log('Fingerprint mismatch - challenge solved on a different machine!', [
1339
+ 'similarity' => round($similarity, 4),
1340
+ 'threshold' => $similarityThreshold
1341
+ ], 'warn');
1342
+ $isValid = false;
1343
+ } else {
1344
+ // The fingerprint is consistent, the solution can be verified
1345
+ if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
1346
+ $cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
1347
+ if ($cpuSolution !== null && $cpuSolution !== '') {
1348
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1349
+ $deviceId = (string)($identity['deviceId'] ?? '');
1350
+ $currentDeviceHash = (string)($identity['currentDeviceHash'] ?? RequestUtils::getCompositeDeviceHash($context));
1351
+
1352
+ $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket(
1353
+ $context->clientIp,
1354
+ 3600000,
1355
+ $powNonce,
1356
+ (string)$cpuSolution,
1357
+ $challengeContext,
1358
+ $deviceId,
1359
+ $currentDeviceHash
1360
+ );
1361
+ $isValid = $ticket !== null;
1362
+
1363
+ if ($powType === 'cpu_mem') {
1364
+ if (!empty($challengeContext['isHttp'])) {
1365
+ $isMemValid = true;
1366
+ } else {
1367
+ $memSolution = $context->query['pow_solution_mem'] ?? null;
1368
+ $isMemValid = ($memSolution !== null && $memSolution !== '')
1369
+ ? ChallengeUtils::verifyMemoryPoW(
1370
+ $powNonce,
1371
+ (string)$memSolution,
1372
+ $challengeContext['memDifficulty'] ?? 0,
1373
+ $challengeContext['clientSecret'] ?? ''
1374
+ )
1375
+ : false;
1376
+ }
1377
+ $isValid = $isValid && $isMemValid;
1378
+ }
1379
+ }
1380
+ } elseif ($powType === 'pospace') {
1381
+ $powSolutionSpace = $context->query['pow_solution_space'] ?? null;
1382
+ if ($powSolutionSpace && isset($challengeContext['queries'])) {
1383
+ $isSpaceValid = ChallengeUtils::verifySpacePoW(
1384
+ $powNonce,
1385
+ $powSolutionSpace,
1386
+ $challengeContext['queries'],
1387
+ $powNonce . ":" . $challengeContext['clientSecret'],
1388
+ $challengeContext['clientSecret']
1389
+ );
1390
+ $isValid = $isSpaceValid;
1391
+ if ($isValid) {
1392
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
1393
+ $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
1394
+ $ticket = ChallengeUtils::generateStatelessTicket([
1395
+ 'expiry' => $expiry,
1396
+ 'originalIp' => $context->clientIp,
1397
+ 'deviceId' => '',
1398
+ 'deviceHash' => ''
1399
+ ]);
1400
+ }
1401
+ }
1402
+ } elseif ($powType === 'useful_work_task') {
1403
+ $problemId = $context->query['pow_problem_id'] ?? null;
1404
+ $workResultJson = $context->query['pow_solution_work_result'] ?? null;
1405
+ if ($problemId && $workResultJson) {
1406
+ $workResult = json_decode($workResultJson, true);
1407
+ $this->log('Verifying useful work solution.', [
1408
+ 'problemId' => $problemId,
1409
+ 'receivedData' => $workResult,
1410
+ 'jsonLastError' => json_last_error_msg()
1411
+ ]);
1412
+ if (json_last_error() === JSON_ERROR_NONE) {
1413
+ // @phpstan-ignore-next-line - The instance is managed by the singleton
1414
+ $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
1089
1415
  $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
1090
1416
  $problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
1091
- // FIX: La solution est directement le $workResult, pas une sous-propriété.
1092
- $problemManager->integrateSolution($problemId, $workResult);
1093
-
1094
- // Si le problème résolu est l'auto-tuning de sécurité et que l'auto-tuning est activé,
1095
- // on applique directement la meilleure solution calculée au moteur en direct.
1096
- if ($problemId === 'security_auto_tuning' && ($this->securityConfig['autotuning']['enabled'] ?? false)) {
1097
- $paretoFront = $workResult['paretoFront'] ?? null;
1098
- if (is_array($paretoFront) && !empty($paretoFront)) {
1099
- $bestSolution = $paretoFront[0];
1100
- $minDistance = sqrt(pow((float)$bestSolution['objectives'][0], 2) + pow((float)$bestSolution['objectives'][1], 2));
1101
- for ($i = 1; $i < count($paretoFront); $i++) {
1102
- $distance = sqrt(pow((float)$paretoFront[$i]['objectives'][0], 2) + pow((float)$paretoFront[$i]['objectives'][1], 2));
1103
- if ($distance < $minDistance) {
1104
- $minDistance = $distance;
1105
- $bestSolution = $paretoFront[$i];
1106
- }
1107
- }
1108
- if (isset($bestSolution['solution'])) {
1109
- $this->updateConfig($bestSolution['solution']);
1110
- $this->log('Useful Work auto-tuning applied successfully to live config.');
1111
- }
1112
- }
1113
- }
1114
-
1115
- $isValid = true;
1116
- // FIX: Générer un vrai ticket pour uPoW, comme pour un PoW normal.
1117
- $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000; // 1 heure par défaut
1118
- $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
1119
- $signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", ChallengeUtils::getPowSecret());
1120
- $ticket = "{$expiry}:{$signature}";
1121
- }
1122
- }
1123
- }
1124
- }
1125
-
1126
- if ($isValid) {
1127
- $store->delete("secret:{$powNonce}");
1128
- $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
1129
- MetricsManager::incrementCounter('challenges_solved_total');
1130
- $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
1131
-
1132
- $isHttps = !empty($context->isHttps);
1133
- $secureOption = $isHttps;
1134
-
1135
- return [
1136
- 'action' => 'redirect',
1137
- 'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
1138
- 'score' => 0.0,
1139
- 'vector' => ['challenge_solved' => 100],
1140
- 'cookie' => [
1141
- 'name' => 'pow_clearance',
1142
- 'value' => $ticket,
1143
- 'options' => [
1144
- 'httponly' => true,
1145
- 'secure' => $secureOption,
1146
- 'samesite' => 'Strict',
1147
- 'expires' => time() + ($ticketTtl / 1000),
1148
- 'path' => '/',
1149
- ]
1150
- ]
1151
- ];
1152
- }
1153
- }
1154
- // Si la solution est invalide ou le nonce est expiré, on pénalise fortement pour la suite.
1417
+ // FIX: The solution is directly $workResult, not a sub-property.
1418
+ $problemManager->integrateSolution($problemId, $workResult);
1419
+
1420
+ // If the solved problem is security auto-tuning and auto-tuning is enabled,
1421
+ // we directly apply the best computed solution to the live engine.
1422
+ if ($problemId === 'security_auto_tuning' && ($this->securityConfig['autotuning']['enabled'] ?? false)) {
1423
+ $paretoFront = $workResult['paretoFront'] ?? null;
1424
+ if (is_array($paretoFront) && !empty($paretoFront)) {
1425
+ $bestSolution = $paretoFront[0];
1426
+ $minDistance = sqrt(pow((float)$bestSolution['objectives'][0], 2) + pow((float)$bestSolution['objectives'][1], 2));
1427
+ for ($i = 1; $i < count($paretoFront); $i++) {
1428
+ $distance = sqrt(pow((float)$paretoFront[$i]['objectives'][0], 2) + pow((float)$paretoFront[$i]['objectives'][1], 2));
1429
+ if ($distance < $minDistance) {
1430
+ $minDistance = $distance;
1431
+ $bestSolution = $paretoFront[$i];
1432
+ }
1433
+ }
1434
+ if (isset($bestSolution['solution'])) {
1435
+ $this->updateConfig($bestSolution['solution']);
1436
+ $this->log('Useful Work auto-tuning applied successfully to live config.');
1437
+ }
1438
+ }
1439
+ }
1440
+
1441
+ $isValid = true;
1442
+ // FIX: Generate a real ticket for uPoW, like for a normal PoW.
1443
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000; // 1 hour by default
1444
+ $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
1445
+ $signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", ChallengeUtils::getPowSecret());
1446
+ $ticket = "{$expiry}:{$signature}";
1447
+ }
1448
+ }
1449
+ }
1450
+ }
1451
+
1452
+ if ($isValid) {
1453
+ $store->delete("secret:{$powNonce}");
1454
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
1455
+ MetricsManager::incrementCounter('challenges_solved_total');
1456
+ $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
1457
+
1458
+ $isHttps = !empty($context->isHttps);
1459
+ $secureOption = $isHttps;
1460
+
1461
+ return [
1462
+ 'action' => 'redirect',
1463
+ 'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
1464
+ 'score' => 0.0,
1465
+ 'vector' => ['challenge_solved' => 100],
1466
+ 'cookie' => [
1467
+ 'name' => 'pow_clearance',
1468
+ 'value' => $ticket,
1469
+ 'options' => [
1470
+ 'httponly' => true,
1471
+ 'secure' => $secureOption,
1472
+ 'samesite' => 'Strict',
1473
+ 'expires' => time() + ($ticketTtl / 1000),
1474
+ 'path' => '/',
1475
+ ]
1476
+ ]
1477
+ ];
1478
+ }
1479
+ }
1480
+ // If the solution is invalid or the nonce is expired, we strongly penalize it for the following steps.
1155
1481
  MetricsManager::incrementCounter('challenges_failed_total');
1156
- $this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
1157
- $suspicionVector['honeypotScore'] = 100.0;
1158
- }
1159
-
1160
- // 3. Vérifier un ticket existant
1161
- $hasValidTicket = false;
1162
- $powCookie = $context->cookies['pow_clearance'] ?? null;
1163
- $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1164
- $deviceId = $context->cookies['device_id'] ?? '';
1165
- $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
1166
- $allowRoaming = $this->securityConfig['allowCrossNetworkRoaming'] ?? false;
1167
- if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof)) {
1168
- $hasValidTicket = true;
1482
+ $this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
1483
+ $suspicionVector['honeypotScore'] = 100.0;
1484
+ }
1485
+
1486
+ // 3. Check an existing ticket
1487
+ $hasValidTicket = false;
1488
+ $powCookie = $context->cookies['pow_clearance'] ?? null;
1489
+ $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1490
+ $deviceId = $context->cookies['device_id'] ?? '';
1491
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
1492
+ $allowRoaming = $this->securityConfig['allowCrossNetworkRoaming'] ?? false;
1493
+ if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof)) {
1494
+ $hasValidTicket = true;
1169
1495
  MetricsManager::incrementCounter('tickets_valid_total');
1170
- // On ne retourne pas tout de suite pour permettre le re-challenge
1171
- // $this->log('Valid clearance ticket found');
1172
- // return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
1173
- }
1174
-
1175
- // 4. Calculer le vecteur et le score de suspicion
1176
- // Résoudre l'identité et vérifier le statut "condamné"
1177
- $store = StoreManager::getStore();
1178
- $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1179
- $deviceId = $identity['deviceId'];
1180
- $deviceData = $identity['deviceData'];
1181
- if ($deviceData && ($deviceData['condemned'] ?? false)) {
1182
- $this->log('Device condemned - blocking request', ['deviceId' => $deviceId], 'warn');
1183
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
1184
- if ($this->dryRun) {
1185
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1186
- $decision['intendedAction'] = $decision['action'];
1187
- $decision['action'] = 'next';
1188
- unset($decision['status'], $decision['body']);
1189
- }
1190
- return $decision;
1191
- }
1192
-
1193
- $suspicionVector = $context->preCalculatedVector ?? $this->getSuspicionVector($context, $suspicionVector);
1194
- $finalScore = $context->preCalculatedScore ?? $this->calculateFinalScore($suspicionVector);
1195
- $this->log('Suspicion vector and final score calculated', [
1196
- 'finalScore' => round($finalScore, 2),
1197
- 'vector' => $suspicionVector
1198
- ]);
1199
-
1200
- // Mettre à jour les métriques du sous-réseau après le calcul du score final
1201
- if ($finalScore >= ($thresholds['medium'] ?? 45)) {
1496
+ // We do not return immediately to allow re-challenging.
1497
+ // $this->log('Valid clearance ticket found');
1498
+ // return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
1499
+ }
1500
+
1501
+ // 4. Compute the suspicion vector and score
1502
+ // Resolve identity and check "condemned" status
1503
+ $store = StoreManager::getStore();
1504
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1505
+ $deviceId = $identity['deviceId'];
1506
+ $deviceData = $identity['deviceData'];
1507
+ if ($deviceData && ($deviceData['condemned'] ?? false)) {
1508
+ $this->log('Device condemned - blocking request', ['deviceId' => $deviceId], 'warn');
1509
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
1510
+ if ($this->dryRun) {
1511
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1512
+ $decision['intendedAction'] = $decision['action'];
1513
+ $decision['action'] = 'next';
1514
+ unset($decision['status'], $decision['body']);
1515
+ }
1516
+ return $decision;
1517
+ }
1518
+
1519
+ $suspicionVector = $context->preCalculatedVector ?? $this->getSuspicionVector($context, $suspicionVector);
1520
+ $finalScore = $context->preCalculatedScore ?? $this->calculateFinalScore($suspicionVector);
1521
+ $this->log('Suspicion vector and final score calculated', [
1522
+ 'finalScore' => round($finalScore, 2),
1523
+ 'vector' => $suspicionVector
1524
+ ]);
1525
+
1526
+ // Update subnet metrics after computing the final score
1527
+ if ($finalScore >= ($thresholds['medium'] ?? 45)) {
1202
1528
  RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
1203
1529
  MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
1204
- }
1205
-
1206
- // Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
1207
- $isNewDevice = $identity['newCookie'] !== null;
1208
- if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
1209
- $this->log('New device - enforcing minimum challenge score', [
1210
- 'originalScore' => round($finalScore, 2),
1211
- 'enforcedScore' => (float)$thresholds['low']
1212
- ]);
1213
- $finalScore = (float)$thresholds['low'];
1214
- }
1215
-
1216
- // Vérifier les URL pièges (après calcul du score)
1217
- $lastNonce = $deviceData['lastChallengeNonce'] ?? null;
1218
- if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
1219
- if ($this->logger) {
1220
- $this->logger->log('info', 'trap_triggered', ['deviceId' => $deviceId, 'score' => 100, 'path' => $context->path, 'vector' => ['honeypotScore' => 100]]);
1221
- }
1222
- $this->log('Honeypot trap URL triggered - condemning device', ['path' => $context->path, 'deviceId' => $deviceId]);
1223
- $deviceData['condemned'] = true; // @phpstan-ignore-line
1224
- $store->set("device:{$deviceId}", $deviceData);
1225
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
1226
- if ($this->dryRun) {
1227
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1228
- $decision['intendedAction'] = $decision['action'];
1229
- $decision['action'] = 'next';
1230
- unset($decision['status'], $decision['body']);
1231
- }
1232
- return $decision;
1233
- }
1234
-
1235
- // 5. Prendre une décision basée sur le score - Vérifier le blocage d'abord.
1236
- $blockThreshold = $thresholds['block'] ?? 95;
1237
- $isBlocked = $finalScore >= $blockThreshold;
1238
- if ($isBlocked) {
1239
- if ($this->logger) {
1530
+ }
1531
+
1532
+ // Logic to challenge new devices (moved here to have the final score)
1533
+ $isNewDevice = $identity['newCookie'] !== null;
1534
+ if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
1535
+ $this->log('New device - enforcing minimum challenge score', [
1536
+ 'originalScore' => round($finalScore, 2),
1537
+ 'enforcedScore' => (float)$thresholds['low']
1538
+ ]);
1539
+ $finalScore = (float)$thresholds['low'];
1540
+ }
1541
+
1542
+ // Check trap URLs (after score computation)
1543
+ $lastNonce = $deviceData['lastChallengeNonce'] ?? null;
1544
+ if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
1545
+ if ($this->logger) {
1546
+ $this->logger->log('info', 'trap_triggered', ['deviceId' => $deviceId, 'score' => 100, 'path' => $context->path, 'vector' => ['honeypotScore' => 100]]);
1547
+ }
1548
+ $this->log('Honeypot trap URL triggered - condemning device', ['path' => $context->path, 'deviceId' => $deviceId]);
1549
+ $deviceData['condemned'] = true; // @phpstan-ignore-line
1550
+ $store->set("device:{$deviceId}", $deviceData);
1551
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
1552
+ if ($this->dryRun) {
1553
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1554
+ $decision['intendedAction'] = $decision['action'];
1555
+ $decision['action'] = 'next';
1556
+ unset($decision['status'], $decision['body']);
1557
+ }
1558
+ return $decision;
1559
+ }
1560
+
1561
+ // 5. Decide based on the score - Check blocking first.
1562
+ $blockThreshold = $thresholds['block'] ?? 95;
1563
+ $isBlocked = $finalScore >= $blockThreshold;
1564
+ if ($isBlocked) {
1565
+ if ($this->logger) {
1240
1566
  MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
1241
- $this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1242
- }
1243
-
1244
- // Federated Threat Intelligence & ZKP Synchronization
1245
- $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1246
- $parts = explode(':', $zkpProof);
1247
- if (count($parts) === 3) {
1248
- $zkpY = $parts[0];
1249
- $zkpT = $parts[1];
1250
- $zkpS = $parts[2];
1251
- // 1. Valider cryptographiquement la preuve avant de bannir/diffuser
1252
- if (ChallengeUtils::verifyZkpProof($zkpY, $zkpT, $zkpS)) {
1253
- // 2. Dédoublonner : Ne diffuser que si la clé n'est pas déjà bannie
1254
- $peersKey = "fed-peers:{$zkpY}";
1255
- $reportedPeers = $store->get($peersKey) ?: [];
1256
- if (!is_array($reportedPeers)) {
1257
- $reportedPeers = [];
1258
- }
1259
- if (!in_array('local', $reportedPeers, true)) {
1260
- $reportedPeers[] = 'local';
1261
- $store->set($peersKey, $reportedPeers, 86400 * 30);
1262
- }
1263
-
1264
- $threshold = $this->securityConfig['federationConsensusThreshold'] ?? 3;
1265
- if (count($reportedPeers) >= $threshold) {
1266
- $store->set("banned-zkp-y:{$zkpY}", true, 86400 * 30);
1267
- }
1268
- $this->broadcastBannedZkp($zkpY);
1269
- }
1270
- }
1271
-
1272
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1273
- if ($this->dryRun) {
1274
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1275
- $decision['intendedAction'] = $decision['action'];
1276
- $decision['action'] = 'next';
1277
- unset($decision['status'], $decision['body']);
1567
+ $this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1568
+ }
1569
+
1570
+ // Federated Threat Intelligence & ZKP Synchronization
1571
+ $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1572
+ $parts = explode(':', $zkpProof);
1573
+ if (count($parts) === 3) {
1574
+ $zkpY = $parts[0];
1575
+ $zkpT = $parts[1];
1576
+ $zkpS = $parts[2];
1577
+ // 1. Cryptographically validate the proof before banning/broadcasting
1578
+ if (ChallengeUtils::verifyZkpProof($zkpY, $zkpT, $zkpS)) {
1579
+ // 2. De-duplicate: only broadcast if the key is not already banned
1580
+ $peersKey = "fed-peers:{$zkpY}";
1581
+ $reportedPeers = $store->get($peersKey) ?: [];
1582
+ if (!is_array($reportedPeers)) {
1583
+ $reportedPeers = [];
1584
+ }
1585
+ if (!in_array('local', $reportedPeers, true)) {
1586
+ $reportedPeers[] = 'local';
1587
+ $store->set($peersKey, $reportedPeers, 86400 * 30);
1588
+ }
1589
+
1590
+ $threshold = $this->securityConfig['federationConsensusThreshold'] ?? 3;
1591
+ if (count($reportedPeers) >= $threshold) {
1592
+ $store->set("banned-zkp-y:{$zkpY}", true, 86400 * 30);
1593
+ }
1594
+ $this->broadcastBannedZkp($zkpY);
1595
+ }
1596
+ }
1597
+
1598
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1599
+ if ($this->dryRun) {
1600
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1601
+ $decision['intendedAction'] = $decision['action'];
1602
+ $decision['action'] = 'next';
1603
+ unset($decision['status'], $decision['body']);
1278
1604
  MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
1279
- }
1280
- $response = $decision;
1281
- } else {
1282
- // Logique de re-challenge
1283
- // Si un nonce est présent mais que ce n'est pas une soumission de solution valide, c'est une sonde.
1284
- if ($powNonce && !$isChallengeSubmission) {
1285
- $this->log('Honeypot probe detected - blocking request', ['path' => $context->path, 'pow_nonce' => $powNonce]);
1286
- $suspicionVector['honeypotScore'] = 100.0;
1287
- $finalScore = $this->calculateFinalScore($suspicionVector); // Recalculate score
1288
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1289
- // Apply dry run logic here as well
1290
- if ($this->dryRun) {
1605
+ }
1606
+ $response = $decision;
1607
+ } else {
1608
+ // Re-challenge logic
1609
+ // If a nonce is present but it is not a valid solution submission, it is a probe.
1610
+ if ($powNonce && !$isChallengeSubmission) {
1611
+ $this->log('Honeypot probe detected - blocking request', ['path' => $context->path, 'pow_nonce' => $powNonce]);
1612
+ $suspicionVector['honeypotScore'] = 100.0;
1613
+ $finalScore = $this->calculateFinalScore($suspicionVector); // Recalculate score
1614
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1615
+ // Apply dry run logic here as well
1616
+ if ($this->dryRun) {
1291
1617
  MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
1292
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1293
- $decision['intendedAction'] = $decision['action'];
1294
- $decision['action'] = 'next';
1295
- unset($decision['status'], $decision['body']);
1296
- }
1297
- return $decision;
1298
- }
1299
-
1300
- $mediumThreshold = $thresholds['medium'] ?? 45;
1301
- $maxIndicatorsCount = 0;
1302
- foreach ($suspicionVector as $val) {
1303
- if (is_numeric($val) && (float)$val >= 100.0) {
1304
- $maxIndicatorsCount++;
1305
- }
1306
- }
1307
- $mustReChallenge = (($suspicionVector['honeypotScore'] ?? 0.0) >= $mediumThreshold) || ($maxIndicatorsCount >= 1);
1308
-
1309
- $lowThreshold = $thresholds['low'] ?? 20;
1310
- if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
1311
- if ($mustReChallenge) {
1312
- $this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
1313
- }
1314
-
1315
- // --- AJOUT: Limiteur de débit (Token Bucket) ---
1316
- $rateLimitPassed = ChallengeUtils::checkChallengeRateLimit($context->clientIp);
1317
- if (!$rateLimitPassed) {
1318
- $this->log('Challenge rate limit exceeded - blocking with 429', ['clientIp' => $context->clientIp]);
1319
- $decision = [
1320
- 'action' => 'block',
1321
- 'status' => 429,
1322
- 'body' => 'Too Many Requests',
1323
- 'score' => $finalScore,
1324
- 'vector' => $suspicionVector
1325
- ];
1326
- if ($this->dryRun) {
1327
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1328
- $decision['intendedAction'] = $decision['action'];
1329
- $decision['action'] = 'next';
1330
- unset($decision['status'], $decision['body']);
1331
- }
1332
- return $decision;
1333
- }
1334
-
1335
- $decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
1336
-
1337
- if ($this->dryRun) {
1618
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1619
+ $decision['intendedAction'] = $decision['action'];
1620
+ $decision['action'] = 'next';
1621
+ unset($decision['status'], $decision['body']);
1622
+ }
1623
+ return $decision;
1624
+ }
1625
+
1626
+ $mediumThreshold = $thresholds['medium'] ?? 45;
1627
+ $maxIndicatorsCount = 0;
1628
+ foreach ($suspicionVector as $val) {
1629
+ if (is_numeric($val) && (float)$val >= 100.0) {
1630
+ $maxIndicatorsCount++;
1631
+ }
1632
+ }
1633
+ $mustReChallenge = (($suspicionVector['honeypotScore'] ?? 0.0) >= $mediumThreshold) || ($maxIndicatorsCount >= 1);
1634
+
1635
+ $lowThreshold = $thresholds['low'] ?? 20;
1636
+ if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
1637
+ if ($mustReChallenge) {
1638
+ $this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
1639
+ }
1640
+
1641
+ // --- ADDED: Rate limiter (Token Bucket) ---
1642
+ $rateLimitPassed = ChallengeUtils::checkChallengeRateLimit($context->clientIp);
1643
+ if (!$rateLimitPassed) {
1644
+ $this->log('Challenge rate limit exceeded - blocking with 429', ['clientIp' => $context->clientIp]);
1645
+ $decision = [
1646
+ 'action' => 'block',
1647
+ 'status' => 429,
1648
+ 'body' => 'Too Many Requests',
1649
+ 'score' => $finalScore,
1650
+ 'vector' => $suspicionVector
1651
+ ];
1652
+ if ($this->dryRun) {
1653
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1654
+ $decision['intendedAction'] = $decision['action'];
1655
+ $decision['action'] = 'next';
1656
+ unset($decision['status'], $decision['body']);
1657
+ }
1658
+ return $decision;
1659
+ }
1660
+
1661
+ $decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
1662
+
1663
+ if ($this->dryRun) {
1338
1664
  MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_challenge']);
1339
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1340
- $decision['intendedAction'] = $decision['action'];
1341
- $decision['action'] = 'next';
1342
- unset($decision['status']);
1343
- return $decision;
1344
- }
1665
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1666
+ $decision['intendedAction'] = $decision['action'];
1667
+ $decision['action'] = 'next';
1668
+ unset($decision['status']);
1669
+ return $decision;
1670
+ }
1345
1671
 
1346
1672
  MetricsManager::incrementCounter('requests_total', ['status' => 'challenged']);
1347
- $this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
1673
+ $this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
1348
1674
 
1349
- $nonce = bin2hex(random_bytes(16));
1350
- $clientSecret = bin2hex(random_bytes(16));
1351
- $highThreshold = $thresholds['high'] ?? 75;
1675
+ $nonce = bin2hex(random_bytes(16));
1676
+ $clientSecret = bin2hex(random_bytes(16));
1677
+ $highThreshold = $thresholds['high'] ?? 75;
1352
1678
 
1353
- $suspicionFactor = ($finalScore - $lowThreshold) / (($thresholds['high'] ?? 75) - $lowThreshold);
1354
- $suspicionFactor = max(0, min(1.5, $suspicionFactor));
1679
+ $suspicionFactor = ($finalScore - $lowThreshold) / (($thresholds['high'] ?? 75) - $lowThreshold);
1680
+ $suspicionFactor = max(0, min(1.5, $suspicionFactor));
1355
1681
 
1356
- // --- NOUVELLE LOGIQUE uPoW ---
1357
- $shouldUseUsefulWork = ($this->securityConfig['enableUsefulWork'] ?? false) && (
1358
- ($this->securityConfig['forceUsefulWork'] ?? false) || (random_int(0, 255) / 255) > 0.5
1359
- );
1682
+ // --- NEW uPoW LOGIC ---
1683
+ $shouldUseUsefulWork = ($this->securityConfig['enableUsefulWork'] ?? false) && (
1684
+ ($this->securityConfig['forceUsefulWork'] ?? false) || (random_int(0, 255) / 255) > 0.5
1685
+ );
1360
1686
 
1361
- // Déterminer si c'est une requête API avant de choisir le type de challenge
1362
- $isApiRequest = false;
1363
- if (isset($this->securityConfig['isApiRequest']) && is_callable($this->securityConfig['isApiRequest'])) {
1364
- $isApiRequest = ($this->securityConfig['isApiRequest'])($context);
1365
- }
1687
+ // Determine if it is an API request before choosing the challenge type
1688
+ $isApiRequest = false;
1689
+ if (isset($this->securityConfig['isApiRequest']) && is_callable($this->securityConfig['isApiRequest'])) {
1690
+ $isApiRequest = ($this->securityConfig['isApiRequest'])($context);
1691
+ }
1366
1692
 
1367
- if ($shouldUseUsefulWork) {
1368
- $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
1369
- $defaultPath = dirname(__DIR__, 1) . '/config/problems.config.json';
1693
+ if ($shouldUseUsefulWork) {
1694
+ $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
1695
+ $defaultPath = dirname(__DIR__, 1) . '/config/problems.config.json';
1370
1696
  $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
1371
1697
  $problemManager = ProblemManager::getInstance($configPath, $store);
1372
- $work = $problemManager->dispatchWork($finalScore);
1373
-
1374
- if ($work !== null) {
1375
- $store->set("secret:{$nonce}", ['clientSecret' => $clientSecret, 'originalPath' => $context->path], 300);
1376
- $challengePayload = [
1377
- 'challenge' => [
1378
- 'type' => 'useful_work_task',
1379
- 'nonce' => $nonce,
1380
- 'clientSecret' => $clientSecret,
1381
- 'usefulWorkTask' => [
1382
- 'problemId' => $work['problemId'],
1383
- 'task' => $work['task']
1384
- ]
1385
- ]
1386
- ];
1387
- if ($isApiRequest) {
1388
- $decision['body'] = $challengePayload;
1389
- return $decision;
1390
- } else {
1391
- $html = '<html><body><script>';
1392
- $html .= 'window.location.href = "' . $context->path . '?pow_type=useful_work_task&pow_nonce=' . $nonce . '&pow_problem_id=' . $work['problemId'] . '&pow_solution_work_result=" + encodeURIComponent(JSON.stringify({"solution": [], "energy": 0}));';
1393
- $html .= '</script></body></html>';
1394
- $decision['body'] = $html;
1395
- return $decision;
1396
- }
1397
- } else {
1398
- // This case handles when uPoW is enabled but dispatching a task fails (e.g., config not found).
1399
- // We log it and fall through to the standard PoW challenge.
1400
- $this->log('Useful work dispatch failed, falling back to standard PoW.', [], 'warn');
1401
- $shouldUseUsefulWork = false; // Explicitly disable for this request
1402
- }
1403
- }
1404
-
1405
- // --- FIN DE LA LOGIQUE uPoW (le reste est le fallback) ---
1406
-
1407
- if ($this->securityConfig['enableProofOfSpace'] ?? false) {
1408
- $spaceChallenge = ChallengeUtils::generateSpaceChallenge($context->clientIp, $nonce, $suspicionFactor, $context->path, $this->securityConfig);
1409
- $store->set("secret:{$nonce}", [
1410
- 'clientSecret' => $clientSecret,
1411
- 'suspicionScore' => $finalScore,
1412
- 'queries' => $spaceChallenge['queries'],
1413
- 'sizeMb' => $spaceChallenge['sizeMb'],
1414
- 'fingerprint' => RequestUtils::getCompositeDeviceHash($context),
1415
- 'originalPath' => $context->path,
1416
- ], $this->securityConfig['challengeTtl'] ?? 300);
1417
-
1418
- if ($deviceData) {
1419
- $deviceData['lastChallengeNonce'] = $nonce;
1420
- $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1421
- }
1422
-
1423
- if ($isApiRequest) {
1424
- $decision['body'] = [
1425
- 'challenge' => [
1426
- 'type' => 'pospace',
1427
- 'nonce' => $nonce,
1428
- 'clientSecret' => $clientSecret,
1429
- 'queries' => $spaceChallenge['queries'],
1430
- 'sizeMb' => $spaceChallenge['sizeMb'],
1431
- ]
1432
- ];
1433
- } else {
1434
- $page = ChallengeUtils::generateSpaceChallengePage($spaceChallenge, $clientSecret, $this->securityConfig);
1435
- $decision['body'] = $page;
1436
- }
1437
- return $decision;
1438
- }
1439
-
1440
- $cpuChallengeDetails = [
1441
- 'type' => 'cpu_target',
1442
- 'nonce' => $nonce,
1443
- 'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
1444
- 'path' => $context->path,
1445
- ];
1446
-
1447
- // Alignement linéaire parfait du ratio d'effort CPU/Mémoire
1448
- $memActivationFactor = $suspicionFactor;
1449
- $memDifficulty = (int)round($memActivationFactor * 48); // 0 à 48MB
1450
-
1451
- $originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
1452
- $tlsSessionId = $context->tlsSessionId ?? '';
1453
- $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint, $context->clientIp, $tlsSessionId);
1454
-
1455
- $isHttps = !empty($context->isHttps);
1456
- $challengeContext = [
1457
- 'clientSecret' => $clientSecret,
1458
- 'cpuTarget' => $cpuChallengeDetails['target'],
1459
- 'suspicionScore' => $finalScore,
1460
- 'fingerprint' => $originalFingerprint,
1461
- 'memDifficulty' => $memDifficulty,
1462
- 'baseBlock' => $baseBlock,
1463
- 'originalPath' => $context->path,
1464
- 'isHttp' => !$isHttps,
1465
- ];
1466
-
1467
- $store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
1468
-
1469
- // Associer le nonce au device pour la vérification des URL pièges
1470
- if ($deviceData) {
1471
- $deviceData['lastChallengeNonce'] = $nonce;
1472
- $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1473
- }
1474
-
1475
- $trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
1476
- $this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
1477
-
1478
- if ($this->logger) {
1479
- $this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1480
- }
1481
-
1482
- // Pour les API, retourner un challenge JSON
1483
- if ($isApiRequest) {
1484
- $challengePayload = [
1485
- 'challenge' => [
1486
- 'type' => 'cpu_mem',
1487
- 'nonce' => $nonce,
1488
- 'clientSecret' => $clientSecret,
1489
- 'cpuTarget' => $cpuChallengeDetails['target'],
1490
- 'memDifficulty' => $memDifficulty,
1491
- 'baseBlock' => array_values(unpack('C*', $baseBlock)), // Envoyer comme un tableau d'octets
1492
- ]
1493
- ];
1494
- $decision['body'] = $challengePayload;
1495
- } else {
1496
- // Pour les navigateurs, retourner une page HTML
1497
- $pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
1498
- $cpuChallengeDetails, $memDifficulty, $clientSecret,
1499
- $this->securityConfig, $trapUrls, $originalFingerprint,
1500
- $context->clientIp, $tlsSessionId, $baseBlock, $isHttps
1501
- );
1502
- $decision['body'] = $pageBody;
1503
- }
1504
- $response = $decision;
1505
- } elseif ($hasValidTicket) {
1506
- // Si on arrive ici avec un ticket valide et un score bas, on autorise
1698
+ $work = $problemManager->dispatchWork($finalScore);
1699
+
1700
+ if ($work !== null) {
1701
+ $store->set("secret:{$nonce}", ['clientSecret' => $clientSecret, 'originalPath' => $context->path], 300);
1702
+ $challengePayload = [
1703
+ 'challenge' => [
1704
+ 'type' => 'useful_work_task',
1705
+ 'nonce' => $nonce,
1706
+ 'clientSecret' => $clientSecret,
1707
+ 'usefulWorkTask' => [
1708
+ 'problemId' => $work['problemId'],
1709
+ 'task' => $work['task']
1710
+ ]
1711
+ ]
1712
+ ];
1713
+ if ($isApiRequest) {
1714
+ $decision['body'] = $challengePayload;
1715
+ return $decision;
1716
+ } else {
1717
+ $html = '<html><body><script>';
1718
+ $html .= 'window.location.href = "' . $context->path . '?pow_type=useful_work_task&pow_nonce=' . $nonce . '&pow_problem_id=' . $work['problemId'] . '&pow_solution_work_result=" + encodeURIComponent(JSON.stringify({"solution": [], "energy": 0}));';
1719
+ $html .= '</script></body></html>';
1720
+ $decision['body'] = $html;
1721
+ return $decision;
1722
+ }
1723
+ } else {
1724
+ // This case handles when uPoW is enabled but dispatching a task fails (e.g., config not found).
1725
+ // We log it and fall through to the standard PoW challenge.
1726
+ $this->log('Useful work dispatch failed, falling back to standard PoW.', [], 'warn');
1727
+ $shouldUseUsefulWork = false; // Explicitly disable for this request
1728
+ }
1729
+ }
1730
+
1731
+ // --- END OF uPoW LOGIC (the rest is the fallback) ---
1732
+
1733
+ if ($this->securityConfig['enableProofOfSpace'] ?? false) {
1734
+ $spaceChallenge = ChallengeUtils::generateSpaceChallenge($context->clientIp, $nonce, $suspicionFactor, $context->path, $this->securityConfig);
1735
+ $store->set("secret:{$nonce}", [
1736
+ 'clientSecret' => $clientSecret,
1737
+ 'suspicionScore' => $finalScore,
1738
+ 'queries' => $spaceChallenge['queries'],
1739
+ 'sizeMb' => $spaceChallenge['sizeMb'],
1740
+ 'fingerprint' => RequestUtils::getCompositeDeviceHash($context),
1741
+ 'originalPath' => $context->path,
1742
+ ], $this->securityConfig['challengeTtl'] ?? 300);
1743
+
1744
+ if ($deviceData) {
1745
+ $deviceData['lastChallengeNonce'] = $nonce;
1746
+ $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1747
+ }
1748
+
1749
+ if ($isApiRequest) {
1750
+ $decision['body'] = [
1751
+ 'challenge' => [
1752
+ 'type' => 'pospace',
1753
+ 'nonce' => $nonce,
1754
+ 'clientSecret' => $clientSecret,
1755
+ 'queries' => $spaceChallenge['queries'],
1756
+ 'sizeMb' => $spaceChallenge['sizeMb'],
1757
+ ]
1758
+ ];
1759
+ } else {
1760
+ $page = ChallengeUtils::generateSpaceChallengePage($spaceChallenge, $clientSecret, $this->securityConfig);
1761
+ $decision['body'] = $page;
1762
+ }
1763
+ return $decision;
1764
+ }
1765
+
1766
+ $cpuChallengeDetails = [
1767
+ 'type' => 'cpu_target',
1768
+ 'nonce' => $nonce,
1769
+ 'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
1770
+ 'path' => $context->path,
1771
+ ];
1772
+
1773
+ // Perfect linear alignment of the CPU/Memory effort ratio
1774
+ $memActivationFactor = $suspicionFactor;
1775
+ $memDifficulty = (int)round($memActivationFactor * 48); // 0 to 48MB
1776
+
1777
+ $originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
1778
+ $tlsSessionId = $context->tlsSessionId ?? '';
1779
+ $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint, $context->clientIp, $tlsSessionId);
1780
+
1781
+ $isHttps = !empty($context->isHttps);
1782
+ $challengeContext = [
1783
+ 'clientSecret' => $clientSecret,
1784
+ 'cpuTarget' => $cpuChallengeDetails['target'],
1785
+ 'suspicionScore' => $finalScore,
1786
+ 'fingerprint' => $originalFingerprint,
1787
+ 'memDifficulty' => $memDifficulty,
1788
+ 'baseBlock' => $baseBlock,
1789
+ 'originalPath' => $context->path,
1790
+ 'isHttp' => !$isHttps,
1791
+ ];
1792
+
1793
+ $store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
1794
+
1795
+ // Associate the nonce with the device for trap URL verification
1796
+ if ($deviceData) {
1797
+ $deviceData['lastChallengeNonce'] = $nonce;
1798
+ $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1799
+ }
1800
+
1801
+ $trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
1802
+ $this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
1803
+
1804
+ if ($this->logger) {
1805
+ $this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1806
+ }
1807
+
1808
+ // For API requests, return a JSON challenge
1809
+ if ($isApiRequest) {
1810
+ $challengePayload = [
1811
+ 'challenge' => [
1812
+ 'type' => 'cpu_mem',
1813
+ 'nonce' => $nonce,
1814
+ 'clientSecret' => $clientSecret,
1815
+ 'cpuTarget' => $cpuChallengeDetails['target'],
1816
+ 'memDifficulty' => $memDifficulty,
1817
+ 'baseBlock' => array_values(unpack('C*', $baseBlock)), // Send as a byte array
1818
+ ]
1819
+ ];
1820
+ $decision['body'] = $challengePayload;
1821
+ } else {
1822
+ // For browsers, return an HTML page
1823
+ $pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
1824
+ $cpuChallengeDetails, $memDifficulty, $clientSecret,
1825
+ $this->securityConfig, $trapUrls, $originalFingerprint,
1826
+ $context->clientIp, $tlsSessionId, $baseBlock, $isHttps
1827
+ );
1828
+ $decision['body'] = $pageBody;
1829
+ }
1830
+ $response = $decision;
1831
+ } elseif ($hasValidTicket) {
1832
+ // If we get here with a valid ticket and a low score, allow the request
1507
1833
  MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
1508
- $this->log('Valid clearance ticket found and score is low - allowing request');
1509
- $response = ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100], 'intendedAction' => 'next'];
1510
- } else {
1511
- // 6. Si le score est bas et qu'il n'y a pas de ticket, autoriser la requête
1834
+ $this->log('Valid clearance ticket found and score is low - allowing request');
1835
+ $response = ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100], 'intendedAction' => 'next'];
1836
+ } else {
1837
+ // 6. If the score is low and there is no ticket, allow the request
1512
1838
  MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
1513
- $this->log('Request passed - no challenge required', ['finalScore' => $finalScore]);
1514
- if ($this->logger) {
1839
+ $this->log('Request passed - no challenge required', ['finalScore' => $finalScore]);
1840
+ if ($this->logger) {
1515
1841
  MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'passed']);
1516
- $this->logger->log('info', 'request_passed', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1517
- }
1518
- $response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
1519
- }
1520
- }
1521
-
1522
- // Si un nouveau cookie d'identification a été généré, on l'ajoute à la réponse.
1523
- if (isset($context->newCookieForResponse)) {
1524
- $response['newCookieForResponse'] = $context->newCookieForResponse;
1525
- }
1526
-
1527
- return $response;
1528
- }
1529
-
1530
- /**
1531
- * Vérifie si l'opération GraphQL correspond à une entrée dans la liste blanche.
1532
- */
1533
- private function isGraphqlOperationInAllowlist(?string $operationType, ?string $operationName): bool
1534
- {
1535
- if (empty($operationType) || empty($operationName)) {
1536
- return false;
1537
- }
1538
-
1539
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
1540
- $graphqlRule = null;
1541
- foreach ($whitelistRules as $rule) {
1542
- if (($rule['type'] ?? '') === 'graphql_operation_allowlist') {
1543
- $graphqlRule = $rule;
1544
- break;
1545
- }
1546
- }
1547
-
1548
- if (empty($graphqlRule['entries'])) {
1549
- return false;
1550
- }
1551
-
1552
- foreach ($graphqlRule['entries'] as $entry) {
1553
- [$entryType, $entryName] = explode(':', $entry, 2);
1554
- if ($entryType !== $operationType) continue;
1555
-
1556
- if ($entryName === $operationName || $entryName === '*') return true;
1557
-
1558
- if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
1559
- }
1560
-
1561
- return false;
1562
- }
1563
-
1564
-
1565
- private function broadcastBannedZkp(string $zkpY): void
1566
- {
1567
- $peers = $this->securityConfig['federatedPeers'] ?? [];
1568
- if (empty($peers)) return;
1569
-
1570
- $timestamp = (int)(microtime(true) * 1000);
1571
- $msg = "{$timestamp}:{$zkpY}";
1572
-
1573
- $signature = '';
1574
- $isAsymmetric = false;
1575
-
1576
- $privateKey = Env::get('ED25519_PRIVATE_KEY');
1577
- if ($privateKey) {
1578
- try {
1579
- $cleanKey = str_replace('\n', "\n", $privateKey);
1580
- $pkeyObj = openssl_pkey_get_private($cleanKey);
1581
- if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
1582
- $signature = bin2hex($sigBytes);
1583
- $isAsymmetric = true;
1584
- }
1585
- } catch (\Throwable $e) {
1586
- self::logError('[Fingerprint] Asymmetric broadcast signing failed: ' . $e->getMessage());
1587
- }
1588
- }
1589
-
1590
- if (!$isAsymmetric) {
1591
- $secret = $this->securityConfig['federationSecret'] ?? ChallengeUtils::getPowSecret();
1592
- $signature = hash_hmac('sha256', $msg, $secret);
1593
- }
1594
-
1595
- foreach ($peers as $peerUrl) {
1596
- $this->asyncPost($peerUrl . '?coop_op=share_threat_intel', [
1597
- 'zkpY' => $zkpY,
1598
- 'signature' => $isAsymmetric ? '' : $signature,
1599
- 'signature_ed25519' => $isAsymmetric ? $signature : '',
1600
- 'timestamp' => $timestamp
1601
- ]);
1602
- }
1603
- }
1604
-
1605
- private function asyncPost(string $url, array $params): void
1606
- {
1607
- // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- wp_parse_url used if available
1608
- $parts = function_exists('wp_parse_url') ? wp_parse_url($url) : parse_url($url);
1609
- if ($parts === false) return;
1610
-
1611
- $host = $parts['host'];
1612
- $port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
1613
- $path = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : '');
1614
- $scheme = $parts['scheme'] === 'https' ? 'ssl://' : '';
1615
-
1616
- $postData = json_encode($params);
1617
-
1618
- $fp = @stream_socket_client(
1619
- "{$scheme}{$host}:{$port}",
1620
- $errno,
1621
- $errstr,
1622
- 0.5,
1623
- STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
1624
- );
1625
-
1626
- if ($fp) {
1627
- stream_set_blocking($fp, false);
1628
- $out = "POST {$path} HTTP/1.1\r\n";
1629
- $out .= "Host: {$host}\r\n";
1630
- $out .= "Content-Type: application/json\r\n";
1631
- $out .= "Content-Length: " . strlen($postData) . "\r\n";
1632
- $out .= "X-Federation-Signature: " . ($params['signature'] ?? '') . "\r\n";
1633
- $out .= "X-Federation-Signature-Ed25519: " . ($params['signature_ed25519'] ?? '') . "\r\n";
1634
- $out .= "X-Federation-Timestamp: " . ($params['timestamp'] ?? '') . "\r\n";
1635
- $out .= "Connection: Close\r\n\r\n";
1636
- $out .= $postData;
1637
-
1638
- // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Socket stream write
1639
- @fwrite($fp, $out);
1640
- // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Socket stream close
1641
- @fclose($fp);
1642
- }
1643
- }
1644
-
1645
- /**
1646
- * Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
1647
- * en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
1648
- */
1649
- private function verifyWhitelistedBot(RequestContext $context): bool
1650
- {
1651
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
1652
- $botRules = array_filter($whitelistRules, fn($rule) => isset($rule['hostnameSuffix']));
1653
- if (empty($botRules)) {
1654
- return false;
1655
- }
1656
-
1657
- $userAgent = $context->getHeader('user-agent') ?? '';
1658
- $matchedRule = null;
1659
- foreach ($botRules as $rule) {
1660
- if (isset($rule['userAgent']) && preg_match('/' . $rule['userAgent'] . '/', $userAgent)) {
1661
- $matchedRule = $rule;
1662
- break;
1663
- }
1664
- }
1665
- if ($matchedRule === null) {
1666
- return false;
1667
- }
1668
-
1669
- $store = StoreManager::getStore();
1670
- $cacheKey = "ip-whitelist:{$context->clientIp}";
1671
- $cachedStatus = $store->get($cacheKey);
1672
-
1673
- if ($cachedStatus === 'verified') return true;
1674
- if ($cachedStatus === 'failed') return false;
1675
-
1676
- if (!self::canAttemptDns()) {
1677
- return false;
1678
- }
1679
-
1680
- try {
1681
- // 1. Reverse DNS lookup with strict 500ms timeout using our custom UDP DNS Client
1682
- $revName = self::getReverseDnsName($context->clientIp);
1683
- if (!$revName) {
1684
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1685
- return false;
1686
- }
1687
- $hostnames = self::resolveDnsNative($revName, 'PTR');
1688
- if ($hostnames === null || empty($hostnames)) {
1689
- self::recordDnsFailure();
1690
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1691
- return false;
1692
- }
1693
-
1694
- $validHostname = null;
1695
- foreach ($hostnames as $hostname) {
1696
- if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
1697
- $validHostname = $hostname;
1698
- break;
1699
- }
1700
- }
1701
-
1702
- if ($validHostname === null) {
1703
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1704
- return false;
1705
- }
1706
-
1707
- // 2. Forward DNS lookup with strict 500ms timeout
1708
- $ips = [];
1709
- $resolvedA = self::resolveDnsNative($validHostname, 'A');
1710
- if ($resolvedA === null) {
1711
- self::recordDnsFailure();
1712
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1713
- return false;
1714
- }
1715
- $ips = array_merge($ips, $resolvedA);
1716
-
1717
- $resolvedAaaa = self::resolveDnsNative($validHostname, 'AAAA');
1718
- if ($resolvedAaaa === null) {
1719
- self::recordDnsFailure();
1720
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1721
- return false;
1722
- }
1723
- $ips = array_merge($ips, $resolvedAaaa);
1724
-
1725
- if (in_array($context->clientIp, $ips, true)) {
1726
- self::recordDnsSuccess();
1727
- $store->set($cacheKey, 'verified', 86400);
1728
- return true;
1729
- }
1730
- } catch (\Exception $e) {
1731
- self::recordDnsFailure();
1732
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1733
- return false;
1734
- }
1735
-
1736
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1737
- return false;
1738
- }
1842
+ $this->logger->log('info', 'request_passed', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1843
+ }
1844
+ $response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
1845
+ }
1846
+ }
1847
+
1848
+ // If a new identification cookie has been generated, add it to the response.
1849
+ if (isset($context->newCookieForResponse)) {
1850
+ $response['newCookieForResponse'] = $context->newCookieForResponse;
1851
+ }
1852
+
1853
+ return $response;
1854
+ }
1855
+
1856
+ /**
1857
+ * Checks whether the GraphQL operation matches a whitelist entry.
1858
+ * Supports exact matches, wildcard '*' for operation name, and
1859
+ * trailing-wildcard prefixes on operation names.
1860
+ *
1861
+ * @param string|null $operationType The GraphQL operation type (query/mutation).
1862
+ * @param string|null $operationName The GraphQL operation name.
1863
+ * @return bool True if the operation is allowlisted, false otherwise.
1864
+ */
1865
+ private function isGraphqlOperationInAllowlist(?string $operationType, ?string $operationName): bool
1866
+ {
1867
+ if (empty($operationType) || empty($operationName)) {
1868
+ return false;
1869
+ }
1870
+
1871
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
1872
+ $graphqlRule = null;
1873
+ foreach ($whitelistRules as $rule) {
1874
+ if (($rule['type'] ?? '') === 'graphql_operation_allowlist') {
1875
+ $graphqlRule = $rule;
1876
+ break;
1877
+ }
1878
+ }
1879
+
1880
+ if (empty($graphqlRule['entries'])) {
1881
+ return false;
1882
+ }
1883
+
1884
+ foreach ($graphqlRule['entries'] as $entry) {
1885
+ [$entryType, $entryName] = explode(':', $entry, 2);
1886
+ if ($entryType !== $operationType) continue;
1887
+
1888
+ if ($entryName === $operationName || $entryName === '*') return true;
1889
+
1890
+ if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
1891
+ }
1892
+
1893
+ return false;
1894
+ }
1895
+
1896
+
1897
+ /**
1898
+ * Broadcasts a banned ZKP public key to all federated peers, with optional
1899
+ * differential privacy (Laplacian timestamp noise and decoy keys).
1900
+ *
1901
+ * @param string $zkpY The ZKP public key to broadcast.
1902
+ * @return void
1903
+ */
1904
+ private function broadcastBannedZkp(string $zkpY): void
1905
+ {
1906
+ $peers = $this->securityConfig['federatedPeers'] ?? [];
1907
+ if (empty($peers)) return;
1908
+
1909
+ $dpConfig = $this->securityConfig['differentialPrivacy'] ?? [];
1910
+ $dpEnabled = ($dpConfig['enabled'] ?? true) !== false;
1911
+ $epsilon = isset($dpConfig['epsilon']) && is_numeric($dpConfig['epsilon'])
1912
+ ? (float)$dpConfig['epsilon']
1913
+ : (float)($this->securityConfig['dpEpsilon'] ?? 1.0);
1914
+
1915
+ $sendThreatReport = function (string $targetZkpY, int $ts) use ($peers) {
1916
+ $msg = "{$ts}:{$targetZkpY}";
1917
+ $signature = '';
1918
+ $isAsymmetric = false;
1919
+
1920
+ $privateKey = Env::get('ED25519_PRIVATE_KEY');
1921
+ if ($privateKey) {
1922
+ try {
1923
+ $cleanKey = str_replace('\n', "\n", $privateKey);
1924
+ $pkeyObj = openssl_pkey_get_private($cleanKey);
1925
+ if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
1926
+ $signature = bin2hex($sigBytes);
1927
+ $isAsymmetric = true;
1928
+ }
1929
+ } catch (\Throwable $e) {
1930
+ self::logError('[Fingerprint] Asymmetric broadcast signing failed: ' . $e->getMessage());
1931
+ }
1932
+ }
1933
+
1934
+ if (!$isAsymmetric) {
1935
+ $secret = $this->securityConfig['federationSecret'] ?? ChallengeUtils::getPowSecret();
1936
+ $signature = hash_hmac('sha256', $msg, $secret);
1937
+ }
1938
+
1939
+ foreach ($peers as $peerUrl) {
1940
+ $this->asyncPost($peerUrl . '?coop_op=share_threat_intel', [
1941
+ 'zkpY' => $targetZkpY,
1942
+ 'signature' => $isAsymmetric ? '' : $signature,
1943
+ 'signature_ed25519' => $isAsymmetric ? $signature : '',
1944
+ 'timestamp' => $ts
1945
+ ]);
1946
+ }
1947
+ };
1948
+
1949
+ $now = (int)(microtime(true) * 1000);
1950
+ $reportTimestamp = $now;
1951
+
1952
+ if ($dpEnabled) {
1953
+ // 1. Differential Privacy: Laplacian noise on the timestamp
1954
+ $deltaT = 5000;
1955
+ $b = $deltaT / max(0.1, $epsilon);
1956
+ $u = (random_int(1, 999999) / 1000000.0) - 0.5;
1957
+ $safeU = abs($u) < 1e-7 ? ($u >= 0 ? 1e-7 : -1e-7) : $u;
1958
+ $laplaceNoise = -$b * ($safeU <=> 0) * log(1.0 - 2.0 * abs($safeU));
1959
+ $clampedNoise = (int)max(-60000, min(60000, round($laplaceNoise)));
1960
+ $reportTimestamp = $now + $clampedNoise;
1961
+ }
1962
+
1963
+ $sendThreatReport($zkpY, $reportTimestamp);
1964
+
1965
+ if ($dpEnabled) {
1966
+ // 2. Differential Privacy: Decoy ZKP key injection
1967
+ $decoyProb = isset($dpConfig['dummyRate']) && is_numeric($dpConfig['dummyRate'])
1968
+ ? (float)$dpConfig['dummyRate']
1969
+ : (1.0 / (1.0 + exp($epsilon)));
1970
+
1971
+ $randFloat = random_int(0, 999999) / 1000000.0;
1972
+ if ($randFloat < $decoyProb) {
1973
+ $decoyBytes = random_bytes(32);
1974
+ $decoyZkpY = ltrim(bin2hex($decoyBytes), '0') ?: '1';
1975
+
1976
+ $uDecoy = (random_int(1, 999999) / 1000000.0) - 0.5;
1977
+ $safeUDecoy = abs($uDecoy) < 1e-7 ? ($uDecoy >= 0 ? 1e-7 : -1e-7) : $uDecoy;
1978
+ $b = 5000 / max(0.1, $epsilon);
1979
+ $decoyNoise = (int)max(-60000, min(60000, round(-$b * ($safeUDecoy <=> 0) * log(1.0 - 2.0 * abs($safeUDecoy)))));
1980
+ $decoyTimestamp = $now + $decoyNoise;
1981
+
1982
+ $sendThreatReport($decoyZkpY, $decoyTimestamp);
1983
+ }
1984
+ }
1985
+ }
1986
+
1987
+ /**
1988
+ * Sends an asynchronous HTTP POST request to a given URL with the given
1989
+ * parameters (JSON-encoded). Uses non-blocking stream sockets with a short
1990
+ * connection timeout, so the caller is not delayed.
1991
+ *
1992
+ * @param string $url The target URL.
1993
+ * @param array $params The parameters to JSON-encode and POST.
1994
+ * @return void
1995
+ */
1996
+ protected function asyncPost(string $url, array $params): void
1997
+ {
1998
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- wp_parse_url used if available
1999
+ $parts = function_exists('wp_parse_url') ? wp_parse_url($url) : parse_url($url);
2000
+ if ($parts === false) return;
2001
+
2002
+ $host = $parts['host'];
2003
+ $port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
2004
+ $path = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : '');
2005
+ $scheme = $parts['scheme'] === 'https' ? 'ssl://' : '';
2006
+
2007
+ $postData = json_encode($params);
2008
+
2009
+ $fp = @stream_socket_client(
2010
+ "{$scheme}{$host}:{$port}",
2011
+ $errno,
2012
+ $errstr,
2013
+ 0.5,
2014
+ STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
2015
+ );
2016
+
2017
+ if ($fp) {
2018
+ stream_set_blocking($fp, false);
2019
+ $out = "POST {$path} HTTP/1.1\r\n";
2020
+ $out .= "Host: {$host}\r\n";
2021
+ $out .= "Content-Type: application/json\r\n";
2022
+ $out .= "Content-Length: " . strlen($postData) . "\r\n";
2023
+ $out .= "X-Federation-Signature: " . ($params['signature'] ?? '') . "\r\n";
2024
+ $out .= "X-Federation-Signature-Ed25519: " . ($params['signature_ed25519'] ?? '') . "\r\n";
2025
+ $out .= "X-Federation-Timestamp: " . ($params['timestamp'] ?? '') . "\r\n";
2026
+ $out .= "Connection: Close\r\n\r\n";
2027
+ $out .= $postData;
2028
+
2029
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Socket stream write
2030
+ @fwrite($fp, $out);
2031
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Socket stream close
2032
+ @fclose($fp);
2033
+ }
2034
+ }
2035
+
2036
+ /**
2037
+ * Checks whether a request comes from a legitimate, whitelisted bot (e.g. Googlebot)
2038
+ * using reverse DNS lookup followed by forward DNS lookup (with a circuit breaker
2039
+ * on DNS failures). Results are cached for 24 hours on success and 5 minutes on
2040
+ * failure.
2041
+ *
2042
+ * @param RequestContext $context The request context.
2043
+ * @return bool True if the request is from a verified whitelisted bot, false otherwise.
2044
+ */
2045
+ private function verifyWhitelistedBot(RequestContext $context): bool
2046
+ {
2047
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
2048
+ $botRules = array_filter($whitelistRules, fn($rule) => isset($rule['hostnameSuffix']));
2049
+ if (empty($botRules)) {
2050
+ return false;
2051
+ }
2052
+
2053
+ $userAgent = $context->getHeader('user-agent') ?? '';
2054
+ $matchedRule = null;
2055
+ foreach ($botRules as $rule) {
2056
+ if (isset($rule['userAgent']) && preg_match('/' . $rule['userAgent'] . '/', $userAgent)) {
2057
+ $matchedRule = $rule;
2058
+ break;
2059
+ }
2060
+ }
2061
+ if ($matchedRule === null) {
2062
+ return false;
2063
+ }
2064
+
2065
+ $store = StoreManager::getStore();
2066
+ $cacheKey = "ip-whitelist:{$context->clientIp}";
2067
+ $cachedStatus = $store->get($cacheKey);
2068
+
2069
+ if ($cachedStatus === 'verified') return true;
2070
+ if ($cachedStatus === 'failed') return false;
2071
+
2072
+ if (!self::canAttemptDns()) {
2073
+ return false;
2074
+ }
2075
+
2076
+ try {
2077
+ // 1. Reverse DNS lookup with strict 500ms timeout using our custom UDP DNS Client
2078
+ $revName = self::getReverseDnsName($context->clientIp);
2079
+ if (!$revName) {
2080
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2081
+ return false;
2082
+ }
2083
+ $hostnames = self::resolveDnsNative($revName, 'PTR');
2084
+ if ($hostnames === null || empty($hostnames)) {
2085
+ self::recordDnsFailure();
2086
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2087
+ return false;
2088
+ }
2089
+
2090
+ $validHostname = null;
2091
+ foreach ($hostnames as $hostname) {
2092
+ if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
2093
+ $validHostname = $hostname;
2094
+ break;
2095
+ }
2096
+ }
2097
+
2098
+ if ($validHostname === null) {
2099
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2100
+ return false;
2101
+ }
2102
+
2103
+ // 2. Forward DNS lookup with strict 500ms timeout
2104
+ $ips = [];
2105
+ $resolvedA = self::resolveDnsNative($validHostname, 'A');
2106
+ if ($resolvedA === null) {
2107
+ self::recordDnsFailure();
2108
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2109
+ return false;
2110
+ }
2111
+ $ips = array_merge($ips, $resolvedA);
2112
+
2113
+ $resolvedAaaa = self::resolveDnsNative($validHostname, 'AAAA');
2114
+ if ($resolvedAaaa === null) {
2115
+ self::recordDnsFailure();
2116
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2117
+ return false;
2118
+ }
2119
+ $ips = array_merge($ips, $resolvedAaaa);
2120
+
2121
+ if (in_array($context->clientIp, $ips, true)) {
2122
+ self::recordDnsSuccess();
2123
+ $store->set($cacheKey, 'verified', 86400);
2124
+ return true;
2125
+ }
2126
+ } catch (\Exception $e) {
2127
+ self::recordDnsFailure();
2128
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2129
+ return false;
2130
+ }
2131
+
2132
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2133
+ return false;
2134
+ }
1739
2135
 
1740
2136
  /**
1741
2137
  * @internal For testing purposes only.
2138
+ *
2139
+ * Returns the list of registered problems from the ProblemManager singleton.
2140
+ *
2141
+ * @return array The list of problems.
1742
2142
  */
1743
2143
  public function getProblems(): array
1744
2144
  {
1745
2145
  $problemManager = ProblemManager::getInstance();
1746
2146
  return $problemManager->getProblems();
1747
2147
  }
2148
+
2149
+ /**
2150
+ * Decodes a CBOR-encoded string (used for WebAuthn attestation objects).
2151
+ * Supports major types 0-5 (unsigned int, negative int, byte string,
2152
+ * text string, array, map).
2153
+ *
2154
+ * @param string $str The CBOR-encoded input.
2155
+ * @param int &$offset The current byte offset (updated in place).
2156
+ * @return mixed The decoded value.
2157
+ * @throws \Exception On malformed CBOR or unsupported integer sizes.
2158
+ */
1748
2159
  private static function decodeCBOR(string $str, int &$offset = 0)
1749
2160
  {
1750
2161
  if ($offset >= strlen($str)) throw new \Exception("End of CBOR");
@@ -1796,6 +2207,12 @@
1796
2207
  return null;
1797
2208
  }
1798
2209
 
2210
+ /**
2211
+ * Returns the list of trusted hardware attestation root certificates
2212
+ * (e.g. Yubico, FIDO Alliance) used to validate WebAuthn attestation chains.
2213
+ *
2214
+ * @return array The list of PEM-encoded trusted root certificates.
2215
+ */
1799
2216
  private static function getTrustedHardwareRoots(): array
1800
2217
  {
1801
2218
  return [
@@ -1817,6 +2234,15 @@
1817
2234
  ];
1818
2235
  }
1819
2236
 
2237
+ /**
2238
+ * Verifies a WebAuthn hardware anchor, handling both registration
2239
+ * (attestation chain validation against trusted roots) and assertion
2240
+ * (signature verification against the stored public key).
2241
+ *
2242
+ * @param array $anchor The WebAuthn anchor data (type, clientDataJSON, etc.).
2243
+ * @param array &$deviceData The device data (updated in place on registration).
2244
+ * @return bool True if the anchor is valid, false otherwise.
2245
+ */
1820
2246
  private function verifyWebAuthnHardwareAnchor(array $anchor, array &$deviceData): bool
1821
2247
  {
1822
2248
  if (empty($anchor['type'])) return false;
@@ -1832,7 +2258,7 @@
1832
2258
  $attestationBytes = base64_decode($anchor['attestationObject']);
1833
2259
  $offset = 0;
1834
2260
  $decoded = self::decodeCBOR($attestationBytes, $offset);
1835
-
2261
+
1836
2262
  if (!$decoded || empty($decoded['fmt']) || empty($decoded['attStmt'])) {
1837
2263
  return false;
1838
2264
  }
@@ -1891,9 +2317,15 @@
1891
2317
  return false;
1892
2318
  }
1893
2319
 
2320
+ /**
2321
+ * Logs an error message using the PHP error log.
2322
+ *
2323
+ * @param string $message The message to log.
2324
+ * @return void
2325
+ */
1894
2326
  private static function logError(string $message): void
1895
2327
  {
1896
2328
  // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Engine diagnostic error log
1897
2329
  error_log($message);
1898
2330
  }
1899
- }
2331
+ }