@anonympins/fingerprint 0.6.6 → 0.6.7
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.
- package/CHANGELOG.md +16 -0
- package/README.md +15 -2
- package/package.json +1 -1
- package/public/fp.js +1 -1
- package/public/fp.wasm +0 -0
- package/src/js/fingerprint.client.obfuscated.js +1 -1
- package/src/js/fingerprint.js +27 -9
- package/src/js/problem-manager.js +10 -3
- package/src/js/tests/fingerprint.engine.test.js +25 -0
- package/src/js/tests/fingerprint.test.js +1 -1
- package/src/php/Challenge/ChallengeUtils.php +1 -1
- package/src/php/FingerprintEngine.php +1880 -1879
- package/src/php/ProblemManager.php +83 -0
- package/src/php/Utils/RequestUtils.php +9 -1
|
@@ -1,1879 +1,1880 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
declare(strict_types=1);
|
|
4
|
-
|
|
5
|
-
namespace Anonympins\Fingerprint;
|
|
6
|
-
|
|
7
|
-
use Anonympins\Fingerprint\Challenge\ChallengeUtils;
|
|
8
|
-
use Anonympins\Fingerprint\Config\SecurityProfiles;
|
|
9
|
-
use Anonympins\Fingerprint\Store\StoreManager;
|
|
10
|
-
use Anonympins\Fingerprint\Utils\BlockList;
|
|
11
|
-
use Anonympins\Fingerprint\Utils\Logger;
|
|
12
|
-
use Anonympins\Fingerprint\Utils\MetricsManager;
|
|
13
|
-
use Anonympins\Fingerprint\Utils\RequestUtils;
|
|
14
|
-
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 dnsResolveUdp(string $query, string $type, float $timeout = 0.5): ?array
|
|
85
|
-
{
|
|
86
|
-
$dnsServer = '8.8.8.8';
|
|
87
|
-
$port = 53;
|
|
88
|
-
$fp = @stream_socket_client("udp://$dnsServer:$port", $errno, $errstr, $timeout);
|
|
89
|
-
if (!$fp) return null;
|
|
90
|
-
stream_set_timeout($fp, 0, (int)($timeout * 1000000));
|
|
91
|
-
|
|
92
|
-
$id = rand(10000, 65000);
|
|
93
|
-
$header = pack('n6', $id, 0x0100, 1, 0, 0, 0);
|
|
94
|
-
|
|
95
|
-
$qType = ($type === 'PTR') ? 12 : (($type === 'AAAA') ? 28 : 1);
|
|
96
|
-
$parts = explode('.', $query);
|
|
97
|
-
$qName = '';
|
|
98
|
-
foreach ($parts as $part) {
|
|
99
|
-
$qName .= chr(strlen($part)) . $part;
|
|
100
|
-
}
|
|
101
|
-
$qName .= chr(0);
|
|
102
|
-
$question = $qName . pack('n2', $qType, 1);
|
|
103
|
-
|
|
104
|
-
if (!@fwrite($fp, $header . $question)) {
|
|
105
|
-
fclose($fp);
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
$response = @fread($fp, 512);
|
|
110
|
-
fclose($fp);
|
|
111
|
-
if (!$response || strlen($response) < 12) return null;
|
|
112
|
-
|
|
113
|
-
$resHeader = unpack('n6', substr($response, 0, 12));
|
|
114
|
-
if ($resHeader[1] !== $id) return null;
|
|
115
|
-
|
|
116
|
-
$answersCount = $resHeader[4];
|
|
117
|
-
if ($answersCount <= 0) return [];
|
|
118
|
-
|
|
119
|
-
$offset = 12 + strlen($question);
|
|
120
|
-
$results = [];
|
|
121
|
-
for ($i = 0; $i < $answersCount; $i++) {
|
|
122
|
-
if ($offset + 12 > strlen($response)) break;
|
|
123
|
-
if ((ord($response[$offset]) & 0xc0) === 0xc0) {
|
|
124
|
-
$offset += 2;
|
|
125
|
-
} else {
|
|
126
|
-
while ($offset < strlen($response) && ord($response[$offset]) !== 0) {
|
|
127
|
-
$offset += 1 + ord($response[$offset]);
|
|
128
|
-
}
|
|
129
|
-
$offset++;
|
|
130
|
-
}
|
|
131
|
-
if ($offset + 10 > strlen($response)) break;
|
|
132
|
-
$typeAndClass = unpack('n2type_class/Nttl/nlen', substr($response, $offset, 10));
|
|
133
|
-
$rType = $typeAndClass['type_class1'];
|
|
134
|
-
$rLen = $typeAndClass['len'];
|
|
135
|
-
$offset += 10;
|
|
136
|
-
if ($offset + $rLen > strlen($response)) break;
|
|
137
|
-
$rData = substr($response, $offset, $rLen);
|
|
138
|
-
$offset += $rLen;
|
|
139
|
-
|
|
140
|
-
if ($rType === 12) {
|
|
141
|
-
$ptrName = '';
|
|
142
|
-
$p = 0;
|
|
143
|
-
while ($p < strlen($rData)) {
|
|
144
|
-
$l = ord($rData[$p]);
|
|
145
|
-
if ($l === 0) break;
|
|
146
|
-
if (($l & 0xc0) === 0xc0) {
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
$ptrName .= substr($rData, $p + 1, $l) . '.';
|
|
150
|
-
$p += 1 + $l;
|
|
151
|
-
}
|
|
152
|
-
$results[] = rtrim($ptrName, '.');
|
|
153
|
-
} elseif ($rType === 1) {
|
|
154
|
-
if ($rLen === 4) {
|
|
155
|
-
$results[] = long2ip(unpack('N', $rData)[1]);
|
|
156
|
-
}
|
|
157
|
-
} elseif ($rType === 28) {
|
|
158
|
-
if ($rLen === 16) {
|
|
159
|
-
$results[] = inet_ntop($rData);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return $results;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
public function __construct(array $securityConfig)
|
|
167
|
-
{
|
|
168
|
-
$this->isProduction = Env::get('APP_ENV') === 'production';
|
|
169
|
-
|
|
170
|
-
// Dynamically bind Ed25519 keys if passed via config
|
|
171
|
-
if (isset($securityConfig['ed25519_private_key'])) {
|
|
172
|
-
Env::set('ED25519_PRIVATE_KEY', $securityConfig['ed25519_private_key']);
|
|
173
|
-
}
|
|
174
|
-
if (isset($securityConfig['ed25519_public_key'])) {
|
|
175
|
-
Env::set('ED25519_PUBLIC_KEY', $securityConfig['ed25519_public_key']);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Génération et persistance automatique de la paire de clés Ed25519
|
|
179
|
-
$useAsymmetric = ($securityConfig['useAsymmetricTickets'] ?? false) === true || ($securityConfig['ed25519'] ?? '') === 'auto';
|
|
180
|
-
$envPrivate = Env::get('ED25519_PRIVATE_KEY');
|
|
181
|
-
if ($useAsymmetric && ($envPrivate === null || $envPrivate === '')) {
|
|
182
|
-
$configDir = dirname(__DIR__, 1) . '/config';
|
|
183
|
-
$persistentKeyPath = $configDir . '/ed25519_key.json';
|
|
184
|
-
|
|
185
|
-
if (file_exists($persistentKeyPath)) {
|
|
186
|
-
try {
|
|
187
|
-
$keys = json_decode(file_get_contents($persistentKeyPath), true);
|
|
188
|
-
if (isset($keys['privateKey'], $keys['publicKey'])) {
|
|
189
|
-
Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
|
|
190
|
-
Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
|
|
191
|
-
}
|
|
192
|
-
} catch (\Throwable $e) {
|
|
193
|
-
error_log('[Fingerprint] Failed to load persistent Ed25519 keys: ' . $e->getMessage());
|
|
194
|
-
}
|
|
195
|
-
} else {
|
|
196
|
-
try {
|
|
197
|
-
if (defined('OPENSSL_KEYTYPE_ED25519')) {
|
|
198
|
-
$pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
|
|
199
|
-
if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
|
|
200
|
-
$details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
|
|
201
|
-
$publicKeyPem = $details['key']; // @phpstan-ignore-line
|
|
202
|
-
Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
|
|
203
|
-
Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
|
|
204
|
-
// Also update $_ENV and $_SERVER for consistency
|
|
205
|
-
if (!is_dir($configDir)) {
|
|
206
|
-
mkdir($configDir, 0777, true);
|
|
207
|
-
}
|
|
208
|
-
file_put_contents($persistentKeyPath, json_encode([
|
|
209
|
-
'privateKey' => $privateKeyPem,
|
|
210
|
-
'publicKey' => $publicKeyPem
|
|
211
|
-
], JSON_PRETTY_PRINT));
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
} catch (\Throwable $e) {
|
|
215
|
-
error_log('[Fingerprint] Native Ed25519 key generation failed: ' . $e->getMessage());
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Auto-load optimized config if autotuning savePath is specified
|
|
221
|
-
if (isset($securityConfig['autotuning']['savePath'])) {
|
|
222
|
-
$savePath = $securityConfig['autotuning']['savePath'];
|
|
223
|
-
if (file_exists($savePath)) {
|
|
224
|
-
try {
|
|
225
|
-
$savedConfig = json_decode(file_get_contents($savePath), true);
|
|
226
|
-
if (json_last_error() === JSON_ERROR_NONE && is_array($savedConfig)) {
|
|
227
|
-
$securityConfig = SecurityProfiles::deepMerge($securityConfig, $savedConfig);
|
|
228
|
-
}
|
|
229
|
-
} catch (\Throwable $e) {
|
|
230
|
-
error_log("[FingerprintEngine] Failed to auto-load optimized config from {$savePath}: " . $e->getMessage());
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
$this->securityConfig = $securityConfig;
|
|
235
|
-
$this->verbose = $securityConfig['verbose'] ?? false;
|
|
236
|
-
$this->allowlist = $this->buildAllowlist();
|
|
237
|
-
$this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
|
|
238
|
-
$this->dryRun = $securityConfig['dryRun'] ?? false;
|
|
239
|
-
$this->validateConfig($securityConfig);
|
|
240
|
-
if ($this->securityConfig['reset'] ?? false) {
|
|
241
|
-
$this->resetStore();
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Applique à chaud une nouvelle configuration de sécurité (poids, seuils, etc.)
|
|
247
|
-
* sans nécessiter de redémarrage.
|
|
248
|
-
*
|
|
249
|
-
* @param array $newConfig La nouvelle configuration (partielle ou complète).
|
|
250
|
-
*/
|
|
251
|
-
public function updateConfig(array $newConfig): void
|
|
252
|
-
{
|
|
253
|
-
$this->validateConfig($newConfig);
|
|
254
|
-
$this->securityConfig = SecurityProfiles::deepMerge($this->securityConfig, $newConfig);
|
|
255
|
-
$this->log('Configuration mise à jour à chaud (Hot-Reloaded)', $this->securityConfig);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Réinitialise le store de persistance actif.
|
|
260
|
-
*/
|
|
261
|
-
public function resetStore(): void
|
|
262
|
-
{
|
|
263
|
-
$store = StoreManager::getStore();
|
|
264
|
-
if (method_exists($store, 'clear')) {
|
|
265
|
-
$store->clear();
|
|
266
|
-
}
|
|
267
|
-
$this->log('Store has been reset/cleared.');
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
private function validateConfig(array $config): void
|
|
271
|
-
{
|
|
272
|
-
if (empty($config)) {
|
|
273
|
-
$this->log('Warning: No securityConfig provided. Using default behaviors.', [], 'warn');
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
$knownKeys = [
|
|
278
|
-
'weights', 'thresholds', 'cpu', 'ticketMaxAge', 'challengeTtl',
|
|
279
|
-
'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
|
|
280
|
-
'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
|
|
281
|
-
'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
|
|
282
|
-
'similarityThreshold', 'summary', 'description',
|
|
283
|
-
'ed25519_private_key', 'ed25519_public_key',
|
|
284
|
-
'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret', 'reset'
|
|
285
|
-
];
|
|
286
|
-
|
|
287
|
-
if (empty($config['weights'])) {
|
|
288
|
-
$this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
|
|
289
|
-
}
|
|
290
|
-
if (empty($config['thresholds'])) {
|
|
291
|
-
$this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
foreach (array_keys($config) as $key) {
|
|
295
|
-
if (!in_array($key, $knownKeys, true)) {
|
|
296
|
-
$this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
|
|
301
|
-
{
|
|
302
|
-
$configDir = dirname(__DIR__, 2) . '/config';
|
|
303
|
-
$filePath = $configDir . '/' . $filename;
|
|
304
|
-
if (file_exists($filePath)) {
|
|
305
|
-
try {
|
|
306
|
-
$content = file_get_contents($filePath);
|
|
307
|
-
if ($content !== false) {
|
|
308
|
-
$decoded = json_decode($content, true);
|
|
309
|
-
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
|
310
|
-
return $decoded;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
} catch (\Throwable $e) {
|
|
314
|
-
error_log("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
return $fallbackEntries;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
public static function googlebot_whitelist(): array
|
|
321
|
-
{
|
|
322
|
-
if (self::$googlebotEntries === null) {
|
|
323
|
-
self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
|
|
324
|
-
"2001:4860:4801:10::/64",
|
|
325
|
-
"2001:4860:4801:11::/64",
|
|
326
|
-
"2001:4860:4801:12::/64",
|
|
327
|
-
"66.249.79.64"
|
|
328
|
-
]);
|
|
329
|
-
}
|
|
330
|
-
return [
|
|
331
|
-
'type' => 'allowlist',
|
|
332
|
-
'entries' => self::$googlebotEntries
|
|
333
|
-
];
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
public static function yandex_whitelist(): array
|
|
337
|
-
{
|
|
338
|
-
if (self::$yandexEntries === null) {
|
|
339
|
-
self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
|
|
340
|
-
"2a02:6b8::/29",
|
|
341
|
-
"5.45.192.0/18",
|
|
342
|
-
"213.180.192.0/19"
|
|
343
|
-
]);
|
|
344
|
-
}
|
|
345
|
-
return [
|
|
346
|
-
'type' => 'allowlist',
|
|
347
|
-
'entries' => self::$yandexEntries
|
|
348
|
-
];
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
public static function bingbot_whitelist(): array
|
|
352
|
-
{
|
|
353
|
-
if (self::$bingbotEntries === null) {
|
|
354
|
-
self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
|
|
355
|
-
"157.55.39.0/24",
|
|
356
|
-
"207.46.13.0/24",
|
|
357
|
-
"40.77.178.0/23"
|
|
358
|
-
]);
|
|
359
|
-
}
|
|
360
|
-
return [
|
|
361
|
-
'type' => 'allowlist',
|
|
362
|
-
'entries' => self::$bingbotEntries
|
|
363
|
-
];
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
public static function default_whitelist(): array
|
|
367
|
-
{
|
|
368
|
-
return [
|
|
369
|
-
self::googlebot_whitelist(),
|
|
370
|
-
self::bingbot_whitelist(),
|
|
371
|
-
self::yandex_whitelist(),
|
|
372
|
-
['userAgent' => 'Googlebot', 'hostnameSuffix' => '.googlebot.com'],
|
|
373
|
-
['userAgent' => 'Google-Extended', 'hostnameSuffix' => '.google.com'],
|
|
374
|
-
['userAgent' => 'AdsBot-Google', 'hostnameSuffix' => '.googlebot.com'],
|
|
375
|
-
['userAgent' => 'Mediapartners-Google', 'hostnameSuffix' => '.google.com'],
|
|
376
|
-
['userAgent' => 'Google-InspectionTool', 'hostnameSuffix' => '.google.com'],
|
|
377
|
-
['userAgent' => '(bingbot|adidxbot)', 'hostnameSuffix' => '.search.msn.com'],
|
|
378
|
-
['userAgent' => 'DuckDuckBot', 'hostnameSuffix' => '.duckduckgo.com'],
|
|
379
|
-
['userAgent' => 'YandexBot', 'hostnameSuffix' => '.yandex.com'],
|
|
380
|
-
['userAgent' => 'YandexImages', 'hostnameSuffix' => '.yandex.com'],
|
|
381
|
-
['userAgent' => 'Baiduspider', 'hostnameSuffix' => '.crawl.baidu.com'],
|
|
382
|
-
['userAgent' => 'Slurp', 'hostnameSuffix' => '.crawl.yahoo.net'],
|
|
383
|
-
['userAgent' => 'Sogou web spider', 'hostnameSuffix' => '.sogou.com'],
|
|
384
|
-
['userAgent' => 'Exabot', 'hostnameSuffix' => '.exabot.com'],
|
|
385
|
-
['userAgent' => 'ia_archiver', 'hostnameSuffix' => '.alexa.com'],
|
|
386
|
-
['userAgent' => 'SeznamBot', 'hostnameSuffix' => '.seznam.cz'],
|
|
387
|
-
['userAgent' => 'Mail.RU_Bot', 'hostnameSuffix' => '.mail.ru'],
|
|
388
|
-
['userAgent' => 'Yeti', 'hostnameSuffix' => '.naver.com'],
|
|
389
|
-
['userAgent' => 'AhrefsBot', 'hostnameSuffix' => '.ahrefs.com'],
|
|
390
|
-
['userAgent' => 'SemrushBot', 'hostnameSuffix' => '.semrush.com'],
|
|
391
|
-
['userAgent' => 'MJ12bot', 'hostnameSuffix' => '.mj12bot.com'],
|
|
392
|
-
['userAgent' => 'rogerbot', 'hostnameSuffix' => '.moz.com'],
|
|
393
|
-
['userAgent' => 'DotBot', 'hostnameSuffix' => '.moz.com'],
|
|
394
|
-
['userAgent' => 'Screaming Frog SEO Spider', 'hostnameSuffix' => '.screamingfrog.co.uk'],
|
|
395
|
-
];
|
|
396
|
-
}
|
|
397
|
-
private function log(string $message, array $data = [], string $level = 'info'): void
|
|
398
|
-
{
|
|
399
|
-
if ($this->verbose) {
|
|
400
|
-
// Utilise le logger s'il est configuré, sinon error_log
|
|
401
|
-
if ($this->logger) {
|
|
402
|
-
$this->logger->log($level, "[FingerprintEngine] " . $message, $data);
|
|
403
|
-
} else {
|
|
404
|
-
$logMessage = "[FingerprintEngine] {$message}";
|
|
405
|
-
if (!empty($data)) $logMessage .= ' ' . json_encode($data);
|
|
406
|
-
error_log($logMessage);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
public function calculateFinalScore(array $suspicionVector): float
|
|
412
|
-
{
|
|
413
|
-
$weights = $this->securityConfig['weights'] ?? [];
|
|
414
|
-
if (empty($weights)) {
|
|
415
|
-
return 0.0;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
$score = 0.0;
|
|
419
|
-
foreach ($weights as $key => $weight) {
|
|
420
|
-
$score += ($suspicionVector[$key] ?? 0) * $weight;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
return min(100.0, $score);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
private function buildAllowlist(): BlockList
|
|
427
|
-
{
|
|
428
|
-
$blockList = new BlockList();
|
|
429
|
-
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
430
|
-
|
|
431
|
-
$allowlistRule = null;
|
|
432
|
-
foreach ($whitelistRules as $rule) {
|
|
433
|
-
if (($rule['type'] ?? '') === 'allowlist') {
|
|
434
|
-
$allowlistRule = $rule;
|
|
435
|
-
break;
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
if (empty($allowlistRule['entries'])) {
|
|
440
|
-
return $blockList;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
foreach ($allowlistRule['entries'] as $entry) {
|
|
444
|
-
$blockList->add($entry);
|
|
445
|
-
}
|
|
446
|
-
return $blockList;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
private function isIpInAllowlist(string $clientIp): bool
|
|
450
|
-
{
|
|
451
|
-
return $this->allowlist->check($clientIp);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
private function isPathInAllowlist(string $requestPath): bool
|
|
455
|
-
{
|
|
456
|
-
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
457
|
-
$pathAllowlistRule = null;
|
|
458
|
-
foreach ($whitelistRules as $rule) {
|
|
459
|
-
if (($rule['type'] ?? '') === 'path_allowlist') {
|
|
460
|
-
$pathAllowlistRule = $rule;
|
|
461
|
-
break;
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
if (empty($pathAllowlistRule['entries'])) {
|
|
466
|
-
return false;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
foreach ($pathAllowlistRule['entries'] as $entry) {
|
|
470
|
-
if (str_ends_with($entry, '*')) {
|
|
471
|
-
$base = substr($entry, 0, -1);
|
|
472
|
-
if (str_starts_with($requestPath, $base)) {
|
|
473
|
-
return true;
|
|
474
|
-
}
|
|
475
|
-
} elseif ($requestPath === $entry) {
|
|
476
|
-
return true;
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
return false;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
|
|
484
|
-
{
|
|
485
|
-
if (empty($requestHost)) {
|
|
486
|
-
return false;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
490
|
-
$hostPathRule = null;
|
|
491
|
-
foreach ($whitelistRules as $rule) {
|
|
492
|
-
if (($rule['type'] ?? '') === 'host_path_allowlist') {
|
|
493
|
-
$hostPathRule = $rule;
|
|
494
|
-
break;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
if (empty($hostPathRule['entries'])) {
|
|
499
|
-
return false;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
foreach ($hostPathRule['entries'] as $entry) {
|
|
503
|
-
if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
|
|
504
|
-
return true;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
return false;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Vérifie si une requête doit être exemptée en raison d'une règle de liste blanche.
|
|
512
|
-
*/
|
|
513
|
-
private function checkAllowlists(RequestContext $context): bool
|
|
514
|
-
{
|
|
515
|
-
$whitelisted = false;
|
|
516
|
-
$type = '';
|
|
517
|
-
|
|
518
|
-
if ($this->isIpInAllowlist($context->clientIp)) {
|
|
519
|
-
$whitelisted = true;
|
|
520
|
-
$type = 'allowlist';
|
|
521
|
-
} elseif ($this->isPathInAllowlist($context->path)) {
|
|
522
|
-
$whitelisted = true;
|
|
523
|
-
$type = 'path_allowlist';
|
|
524
|
-
} elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
|
|
525
|
-
$whitelisted = true;
|
|
526
|
-
$type = 'host_path_allowlist';
|
|
527
|
-
} elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
|
|
528
|
-
$whitelisted = true;
|
|
529
|
-
$type = 'graphql_operation_allowlist';
|
|
530
|
-
} elseif ($this->verifyWhitelistedBot($context)) {
|
|
531
|
-
$whitelisted = true;
|
|
532
|
-
$type = 'bot';
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
if ($whitelisted) {
|
|
536
|
-
$filterWhitelist = $this->securityConfig['filterWhitelist'] ?? false;
|
|
537
|
-
$bypassWhitelist = false;
|
|
538
|
-
if ($filterWhitelist === true) {
|
|
539
|
-
$bypassWhitelist = $this->hasCertainAttack($context);
|
|
540
|
-
} elseif (is_numeric($filterWhitelist)) {
|
|
541
|
-
if ($context->preCalculatedScore === null) {
|
|
542
|
-
$suspicionVector = [];
|
|
543
|
-
$context->preCalculatedVector = $this->getSuspicionVector($context, $suspicionVector);
|
|
544
|
-
$context->preCalculatedScore = $this->calculateFinalScore($context->preCalculatedVector);
|
|
545
|
-
}
|
|
546
|
-
if ($context->preCalculatedScore > $filterWhitelist) {
|
|
547
|
-
$bypassWhitelist = true;
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
if ($bypassWhitelist) {
|
|
552
|
-
$this->log('Whitelisted request exceeds filter threshold - bypassing whitelist bypass', ['clientIp' => $context->clientIp, 'path' => $context->path]);
|
|
553
|
-
return false;
|
|
554
|
-
}
|
|
555
|
-
$this->log("IP/Path in allowlist ({$type}) - allowing request", ['clientIp' => $context->clientIp, 'path' => $context->path]);
|
|
556
|
-
return true;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
return false;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
|
|
564
|
-
*/
|
|
565
|
-
private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
|
|
566
|
-
{
|
|
567
|
-
if ($context->resolvedIdentity !== null) {
|
|
568
|
-
return $context->resolvedIdentity;
|
|
569
|
-
}
|
|
570
|
-
$this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
|
|
571
|
-
$store = StoreManager::getStore();
|
|
572
|
-
$existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
|
|
573
|
-
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
574
|
-
$pendingCookieTtl = 120; // 2 minutes pour détecter la suppression
|
|
575
|
-
|
|
576
|
-
$deviceId = $existingDeviceId;
|
|
577
|
-
$deviceData = null;
|
|
578
|
-
$newCookie = null;
|
|
579
|
-
|
|
580
|
-
// Cookieless Identity Tracking: Attempt to restore device ID using TLS session resume ID
|
|
581
|
-
$tlsSessionId = $context->tlsSessionId;
|
|
582
|
-
if (!$deviceId && $tlsSessionId) {
|
|
583
|
-
$resumedDeviceId = $store->get("tls-session:{$tlsSessionId}");
|
|
584
|
-
if ($resumedDeviceId) {
|
|
585
|
-
$deviceId = $resumedDeviceId;
|
|
586
|
-
$this->log('Identity resumed via TLS Session ID', ['deviceId' => $deviceId, 'tlsSessionId' => $tlsSessionId]);
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
if ($deviceId) {
|
|
591
|
-
$deviceData = $store->get("device:{$deviceId}");
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
if ($deviceData === null) {
|
|
595
|
-
// Nouvel utilisateur ou cookie perdu/invalide
|
|
596
|
-
$pendingDeviceId = $store->get("pending_cookie:{$context->clientIp}");
|
|
597
|
-
if ($pendingDeviceId && !$existingDeviceId) {
|
|
598
|
-
// La pénalité est maintenant ajoutée directement au vecteur de suspicion.
|
|
599
|
-
$this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
|
|
600
|
-
$suspicionVector['cookieDroppingScore'] = 100.0;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
$deviceId = bin2hex(random_bytes(16)); // UUID-like
|
|
604
|
-
|
|
605
|
-
$isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
|
|
606
|
-
(isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
|
|
607
|
-
($context->getHeader('x-forwarded-proto') === 'https');
|
|
608
|
-
$secureOption = $isHttps || $this->isProduction;
|
|
609
|
-
|
|
610
|
-
// Préparer le cookie à envoyer
|
|
611
|
-
$newCookie = [
|
|
612
|
-
'name' => 'device_id',
|
|
613
|
-
'value' => $deviceId,
|
|
614
|
-
'options' => [
|
|
615
|
-
'httponly' => true,
|
|
616
|
-
'secure' => $secureOption,
|
|
617
|
-
'partitioned' => $secureOption,
|
|
618
|
-
'samesite' => 'Strict',
|
|
619
|
-
'path' => '/',
|
|
620
|
-
]
|
|
621
|
-
];
|
|
622
|
-
if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
|
|
623
|
-
$newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
$store->set("pending_cookie:{$context->clientIp}", $deviceId, $pendingCookieTtl);
|
|
627
|
-
|
|
628
|
-
$deviceData = [
|
|
629
|
-
'initialDeviceHash' => $currentDeviceHash,
|
|
630
|
-
'ips' => [$context->clientIp],
|
|
631
|
-
'requestHistory' => [],
|
|
632
|
-
'lastUpdate' => time() * 1000,
|
|
633
|
-
'lastFpHash' => $currentDeviceHash,
|
|
634
|
-
'lastChangeTimestamp' => 0,
|
|
635
|
-
'rapidChangeCount' => 0,
|
|
636
|
-
'highScoreCount' => 0,
|
|
637
|
-
'lastHighScoreTimestamp' => 0,
|
|
638
|
-
];
|
|
639
|
-
} else {
|
|
640
|
-
// S'assurer que 'ips' est un tableau pour les opérations suivantes.
|
|
641
|
-
if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
|
|
642
|
-
$deviceData['ips'] = [];
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// Bind the current TLS session ID to the device ID
|
|
647
|
-
if ($deviceId && $tlsSessionId) {
|
|
648
|
-
$store->set("tls-session:{$tlsSessionId}", $deviceId, 3600); // 1h cache duration
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
$context->resolvedIdentity = [
|
|
652
|
-
'deviceId' => $deviceId,
|
|
653
|
-
'deviceData' => $deviceData,
|
|
654
|
-
'newCookie' => $newCookie,
|
|
655
|
-
];
|
|
656
|
-
return $context->resolvedIdentity;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
/**
|
|
660
|
-
* @return array<string, float>
|
|
661
|
-
*/
|
|
662
|
-
public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
|
|
663
|
-
{
|
|
664
|
-
$store = StoreManager::getStore();
|
|
665
|
-
$identity = $this->resolveRequestIdentity($context, $suspicionVector);
|
|
666
|
-
$deviceData = $identity['deviceData'];
|
|
667
|
-
$deviceId = $identity['deviceId'];
|
|
668
|
-
if ($deviceData && ($deviceData['condemned'] ?? false)) {
|
|
669
|
-
$suspicionVector['honeypotScore'] = 100;
|
|
670
|
-
return $suspicionVector;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// Si un nouveau cookie doit être défini, on le stocke temporairement dans le contexte
|
|
674
|
-
// pour que le code appelant puisse le gérer.
|
|
675
|
-
if ($identity['newCookie']) {
|
|
676
|
-
// Cette propriété n'est pas standard, on la préfixe pour éviter les conflits.
|
|
677
|
-
$context->newCookieForResponse = $identity['newCookie'];
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// Nettoyage périodique des données de l'appareil
|
|
681
|
-
if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
|
|
682
|
-
$deviceData['ips'] = [];
|
|
683
|
-
$deviceData['rapidChangeCount'] = 0;
|
|
684
|
-
$deviceData['ipTimes'] = [];
|
|
685
|
-
}
|
|
686
|
-
$deviceData['lastUpdate'] = time() * 1000;
|
|
687
|
-
|
|
688
|
-
// --- Calcul des différents scores de suspicion ---
|
|
689
|
-
|
|
690
|
-
// Score d'incohérence du fingerprint (déplacé ici pour être avec les autres)
|
|
691
|
-
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
692
|
-
$consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
|
|
693
|
-
$inconsistencyScore = min(100.0, max(0.0, (1 - $consistencyScore) * 200));
|
|
694
|
-
if ($consistencyScore < ($this->securityConfig['similarityThreshold'] ?? 0.7)) {
|
|
695
|
-
$inconsistencyScore = 100.0;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
$behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
|
|
699
|
-
|
|
700
|
-
// Score des anomalies d'en-têtes
|
|
701
|
-
$headerAnomalies = RequestUtils::getHeaderAnomalies($context);
|
|
702
|
-
|
|
703
|
-
// Score de spoofing TLS
|
|
704
|
-
$tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
|
|
705
|
-
$tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
|
|
706
|
-
|
|
707
|
-
// Advanced JA4 TLS Inconsistency checks
|
|
708
|
-
$ja4 = $context->getHeader('x-ja4-hash');
|
|
709
|
-
if ($ja4) {
|
|
710
|
-
$spoofedJa4s = [
|
|
711
|
-
't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
|
|
712
|
-
't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
|
|
713
|
-
't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
|
|
714
|
-
't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
|
|
715
|
-
't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
|
|
716
|
-
't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
|
|
717
|
-
];
|
|
718
|
-
if (in_array($ja4, $spoofedJa4s, true)) {
|
|
719
|
-
$tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
$parsedJa4 = $this->parseJa4($ja4);
|
|
723
|
-
if ($parsedJa4) {
|
|
724
|
-
$ua = $context->getHeader('user-agent') ?? '';
|
|
725
|
-
$uaParts = $this->parseUserAgent($ua);
|
|
726
|
-
|
|
727
|
-
// Check 1: Incohérence ALPN / HTTP Version
|
|
728
|
-
$httpVersion = $context->httpVersion ?? '1.1';
|
|
729
|
-
if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
|
|
730
|
-
$hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
|
|
731
|
-
if (!$hasProxy) {
|
|
732
|
-
$tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// Check 2: Incohérence OS/Plateforme vs Capabilities TLS
|
|
737
|
-
if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
|
|
738
|
-
$tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
// Check 3: Incohérence User-Agent vs Signature JA4
|
|
742
|
-
if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
|
|
743
|
-
$tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
|
|
744
|
-
}
|
|
745
|
-
if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
|
|
746
|
-
$tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
// Check 4: La stagnation (Lack of Entropy / Genericity)
|
|
750
|
-
if (is_string($uaParts['browser'])) {
|
|
751
|
-
$ja4Key = "ja4-browsers:{$ja4}";
|
|
752
|
-
$seenBrowsers = $store->get($ja4Key) ?: [];
|
|
753
|
-
if (!is_array($seenBrowsers)) {
|
|
754
|
-
$seenBrowsers = [];
|
|
755
|
-
}
|
|
756
|
-
$browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
|
|
757
|
-
if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
|
|
758
|
-
$seenBrowsers[] = $browserFamily;
|
|
759
|
-
$store->set($ja4Key, $seenBrowsers, 86400);
|
|
760
|
-
}
|
|
761
|
-
if (count($seenBrowsers) > 1) {
|
|
762
|
-
$tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
// Score d'incohérence temporelle (attaque par rejeu)
|
|
769
|
-
$timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
|
|
770
|
-
|
|
771
|
-
// Score des incohérences entre couches (client vs serveur)
|
|
772
|
-
$crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
|
|
773
|
-
|
|
774
|
-
// Score des patterns de requêtes (scraping, vélocité)
|
|
775
|
-
$requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
|
|
776
|
-
|
|
777
|
-
// Score des honeypots
|
|
778
|
-
$honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
|
|
779
|
-
|
|
780
|
-
// Score des métriques comportementales client (souris, clavier)
|
|
781
|
-
$behavior = RequestUtils::getBehaviorScore($context);
|
|
782
|
-
|
|
783
|
-
// Score de détection de bot explicite (marqueurs d'automatisation)
|
|
784
|
-
$bot = RequestUtils::getBotScore($context);
|
|
785
|
-
|
|
786
|
-
// Score de variance des clics
|
|
787
|
-
$clickVariance = RequestUtils::getClickVarianceScore($context);
|
|
788
|
-
|
|
789
|
-
// Score basé sur les listes de menaces (Threat Intelligence)
|
|
790
|
-
$threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
|
|
791
|
-
|
|
792
|
-
// Score d'incohérence des Client-Hints
|
|
793
|
-
$clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
|
|
794
|
-
|
|
795
|
-
// Score de similarité globale de l'empreinte (Clustering Botnet)
|
|
796
|
-
$stableFp = RequestUtils::extractStablePart($currentDeviceHash);
|
|
797
|
-
$stableFpHash = FingerprintBuilder::cyrb53($stableFp);
|
|
798
|
-
$botnetCluster = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
|
|
799
|
-
|
|
800
|
-
// NOUVEAU: Score de réputation du sous-réseau IP
|
|
801
|
-
$subnetScore = RequestUtils::getSubnetScore($context, $deviceId);
|
|
802
|
-
|
|
803
|
-
// Score d'anomalie de pile TCP/IP
|
|
804
|
-
$tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
|
|
805
|
-
|
|
806
|
-
// Score d'anomalie de protocole (HTTP/2 et QUIC)
|
|
807
|
-
$protocolAnomaly = RequestUtils::getProtocolAnomalyScore($context);
|
|
808
|
-
|
|
809
|
-
// Score d'anomalie de rendu d'affichage (V-Sync)
|
|
810
|
-
$renderingAnomaly = RequestUtils::getRenderingAnomalyScore($context);
|
|
811
|
-
|
|
812
|
-
// Assemblage du vecteur de suspicion final
|
|
813
|
-
$suspicionVector = array_merge($suspicionVector, [
|
|
814
|
-
'inconsistencyScore' => $inconsistencyScore,
|
|
815
|
-
'historyScore' => $behavioral['historyScore'],
|
|
816
|
-
'rotationScore' => $behavioral['rotationScore'],
|
|
817
|
-
'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
|
|
818
|
-
'tlsSpoofingScore' => $tlsSpoofingScore,
|
|
819
|
-
'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
|
|
820
|
-
'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
|
|
821
|
-
'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
|
|
822
|
-
'honeypotScore' => $honeypot['honeypotScore'],
|
|
823
|
-
'behaviorScore' => $behavior['behaviorScore'],
|
|
824
|
-
'botScore' => $bot['botScore'],
|
|
825
|
-
'clickVarianceScore' => $clickVariance['clickVarianceScore'],
|
|
826
|
-
'threatIntelScore' => $threatIntel['threatIntelScore'],
|
|
827
|
-
'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
|
|
828
|
-
'subnetScore' => $subnetScore['subnetScore'],
|
|
829
|
-
'botnetClusterScore' => $botnetCluster['botnetClusterScore'],
|
|
830
|
-
'tcpAnomalyScore' => $tcpAnomaly['tcpAnomalyScore'],
|
|
831
|
-
'protocolAnomalyScore' => $protocolAnomaly['protocolAnomalyScore'],
|
|
832
|
-
'renderingAnomalyScore' => $renderingAnomaly['renderingAnomalyScore'],
|
|
833
|
-
]);
|
|
834
|
-
|
|
835
|
-
// Sauvegarder l'état mis à jour de l'appareil dans le store
|
|
836
|
-
$store->set("device:{$deviceId}", $deviceData);
|
|
837
|
-
|
|
838
|
-
return $suspicionVector;
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
/**
|
|
842
|
-
* Parse a basic User-Agent string.
|
|
843
|
-
*/
|
|
844
|
-
private function parseUserAgent(string $ua): array
|
|
845
|
-
{
|
|
846
|
-
$result = ['browser' => null, 'os' => null];
|
|
847
|
-
|
|
848
|
-
if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
|
|
849
|
-
$result['browser'] = 'Chrome';
|
|
850
|
-
if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
|
|
851
|
-
$result['browser'] .= '/' . $matches[1];
|
|
852
|
-
}
|
|
853
|
-
} elseif (str_contains($ua, 'Firefox')) {
|
|
854
|
-
$result['browser'] = 'Firefox';
|
|
855
|
-
if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
|
|
856
|
-
$result['browser'] .= '/' . $matches[1];
|
|
857
|
-
}
|
|
858
|
-
} elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
|
|
859
|
-
$result['browser'] = 'Safari';
|
|
860
|
-
if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
|
|
861
|
-
$result['browser'] .= '/' . $matches[1];
|
|
862
|
-
}
|
|
863
|
-
} elseif (str_contains($ua, 'Edg')) {
|
|
864
|
-
$result['browser'] = 'Edge';
|
|
865
|
-
if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
|
|
866
|
-
$result['browser'] .= '/' . $matches[1];
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
|
|
871
|
-
elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
|
|
872
|
-
elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
|
|
873
|
-
elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
|
|
874
|
-
elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
|
|
875
|
-
elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
|
|
876
|
-
|
|
877
|
-
return $result;
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
/**
|
|
881
|
-
* Parse JA4 string into protocol, version, ALPN etc.
|
|
882
|
-
*/
|
|
883
|
-
private function parseJa4(?string $ja4): ?array
|
|
884
|
-
{
|
|
885
|
-
if (empty($ja4)) return null;
|
|
886
|
-
$parts = explode('_', $ja4);
|
|
887
|
-
$ja4a = $parts[0];
|
|
888
|
-
if (strlen($ja4a) < 10) return null;
|
|
889
|
-
return [
|
|
890
|
-
'protocol' => $ja4a[0],
|
|
891
|
-
'version' => substr($ja4a, 1, 2),
|
|
892
|
-
'sni' => $ja4a[3],
|
|
893
|
-
'ciphersCount' => (int)substr($ja4a, 4, 2),
|
|
894
|
-
'extensionsCount' => (int)substr($ja4a, 6, 2),
|
|
895
|
-
'alpn' => substr($ja4a, 8, 2),
|
|
896
|
-
'ja4b' => $parts[1] ?? null,
|
|
897
|
-
'ja4c' => $parts[2] ?? null
|
|
898
|
-
];
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
private function decodePolymorphicFingerprint(string $fpString, array $mapping): string
|
|
903
|
-
{
|
|
904
|
-
if (empty($mapping['keys'])) {
|
|
905
|
-
return $fpString;
|
|
906
|
-
}
|
|
907
|
-
$reverseKeys = array_flip($mapping['keys']);
|
|
908
|
-
$parts = explode('|', $fpString);
|
|
909
|
-
$mappedParts = [];
|
|
910
|
-
foreach ($parts as $part) {
|
|
911
|
-
$pair = explode(':', $part, 2);
|
|
912
|
-
if (count($pair) === 2) {
|
|
913
|
-
$origKey = $reverseKeys[$pair[0]] ?? $pair[0];
|
|
914
|
-
$mappedParts[] = "{$origKey}:{$pair[1]}";
|
|
915
|
-
} else {
|
|
916
|
-
$mappedParts[] = $part;
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
return implode('|', $mappedParts);
|
|
920
|
-
}
|
|
921
|
-
private function translatePolymorphicHeaders(RequestContext $context): void
|
|
922
|
-
{
|
|
923
|
-
$store = StoreManager::getStore();
|
|
924
|
-
$activeMappings = $store->get('active-polymorphic-mappings') ?: [];
|
|
925
|
-
if (!is_array($activeMappings)) {
|
|
926
|
-
return;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
foreach ($activeMappings as $mapping) {
|
|
930
|
-
$devFpHeader = strtolower($mapping['headers']['x-device-fingerprint'] ?? '');
|
|
931
|
-
$behaviorHeader = strtolower($mapping['headers']['x-behavior-metrics'] ?? '');
|
|
932
|
-
|
|
933
|
-
if (!empty($devFpHeader) && isset($context->headers[$devFpHeader])) {
|
|
934
|
-
$context->headers['x-device-fingerprint'] = $context->headers[$devFpHeader];
|
|
935
|
-
if (!empty($behaviorHeader) && isset($context->headers[$behaviorHeader])) {
|
|
936
|
-
$context->headers['x-behavior-metrics'] = $context->headers[$behaviorHeader];
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
$clientFp = $context->headers['x-device-fingerprint'];
|
|
940
|
-
if ($clientFp && is_string($clientFp)) {
|
|
941
|
-
$context->headers['x-device-fingerprint'] = $this->decodePolymorphicFingerprint($clientFp, $mapping);
|
|
942
|
-
}
|
|
943
|
-
break;
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
/**
|
|
949
|
-
* Évalue si la requête présente des caractéristiques d'attaque flagrantes ou a déclenché un honeypot.
|
|
950
|
-
*/
|
|
951
|
-
private function hasCertainAttack(RequestContext $context): bool
|
|
952
|
-
{
|
|
953
|
-
$honeypotConfig = $this->securityConfig['honeypot'] ?? [];
|
|
954
|
-
$honeypot = RequestUtils::getHoneypotScore($context, $honeypotConfig);
|
|
955
|
-
if (($honeypot['honeypotScore'] ?? 0.0) >= 100.0) {
|
|
956
|
-
return true;
|
|
957
|
-
}
|
|
958
|
-
$bot = RequestUtils::getBotScore($context);
|
|
959
|
-
if (($bot['botScore'] ?? 0.0) >= 100.0) {
|
|
960
|
-
return true;
|
|
961
|
-
}
|
|
962
|
-
return false;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
/**
|
|
966
|
-
* Traite une requête entrante et retourne une décision.
|
|
967
|
-
* @param RequestContext $context Le contexte de la requête.
|
|
968
|
-
* @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
|
|
969
|
-
*/
|
|
970
|
-
public function processRequest(RequestContext $context): array
|
|
971
|
-
{
|
|
972
|
-
$this->translatePolymorphicHeaders($context);
|
|
973
|
-
|
|
974
|
-
// Initialiser le vecteur de suspicion pour éviter les erreurs de type.
|
|
975
|
-
$suspicionVector = [];
|
|
976
|
-
|
|
977
|
-
$coopOp = $context->query['coop_op'] ?? null;
|
|
978
|
-
if ($coopOp) {
|
|
979
|
-
$result = ChallengeUtils::handleCooperativeRequest($context->query, $context->clientIp, $this->securityConfig);
|
|
980
|
-
return [
|
|
981
|
-
'action' => 'challenge',
|
|
982
|
-
'status' => 200,
|
|
983
|
-
'body' => $result
|
|
984
|
-
];
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
$this->log('Processing request', ['clientIp' => $context->clientIp, 'path' => $context->path]);
|
|
988
|
-
|
|
989
|
-
// Parse GraphQL query if applicable
|
|
990
|
-
if ($context->path === '/graphql' && !empty($context->body)) {
|
|
991
|
-
$gqlInfo = RequestUtils::parseGraphQLQuery(is_array($context->body) ? $context->body : []);
|
|
992
|
-
if ($gqlInfo) {
|
|
993
|
-
$context->graphqlOperation = $gqlInfo;
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
// 1. Vérifier les listes blanches
|
|
998
|
-
if ($this->checkAllowlists($context)) {
|
|
999
|
-
MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
|
|
1000
|
-
return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
// Initialiser le vecteur de suspicion
|
|
1004
|
-
$thresholds = $this->securityConfig['thresholds'];
|
|
1005
|
-
|
|
1006
|
-
// 2. Gérer la soumission d'une solution de challenge (PRIORITÉ HAUTE)
|
|
1007
|
-
$powNonce = $context->query['pow_nonce'] ?? null;
|
|
1008
|
-
$isChallengeSubmission = $powNonce && (
|
|
1009
|
-
isset($context->query['pow_solution']) ||
|
|
1010
|
-
isset($context->query['pow_solution_cpu']) ||
|
|
1011
|
-
isset($context->query['pow_solution_space']) ||
|
|
1012
|
-
(isset($context->query['pow_type']) && $context->query['pow_type'] === 'useful_work_task')
|
|
1013
|
-
);
|
|
1014
|
-
if ($isChallengeSubmission) {
|
|
1015
|
-
$this->log('Challenge solution submitted', ['pow_type' => $context->query['pow_type'] ?? 'unknown', 'nonce' => $powNonce]);
|
|
1016
|
-
$store = StoreManager::getStore();
|
|
1017
|
-
$challengeContext = $store->get("secret:{$powNonce}");
|
|
1018
|
-
$powType = $context->query['pow_type'] ?? null;
|
|
1019
|
-
|
|
1020
|
-
if ($challengeContext) {
|
|
1021
|
-
$isValid = false;
|
|
1022
|
-
$ticket = null;
|
|
1023
|
-
|
|
1024
|
-
// Vérification de la cohérence du fingerprint
|
|
1025
|
-
$solverFingerprint = $context->query['pow_fp'] ?? RequestUtils::getCompositeDeviceHash($context);
|
|
1026
|
-
$originalFingerprint = $challengeContext['fingerprint'] ?? '';
|
|
1027
|
-
$similarity = FingerprintBuilder::compare($originalFingerprint, $solverFingerprint);
|
|
1028
|
-
$similarityThreshold = $this->securityConfig['similarityThreshold'] ?? 0.95;
|
|
1029
|
-
|
|
1030
|
-
if ($similarity < $similarityThreshold) {
|
|
1031
|
-
$this->log('Fingerprint mismatch - challenge solved on a different machine!', [
|
|
1032
|
-
'similarity' => round($similarity, 4),
|
|
1033
|
-
'threshold' => $similarityThreshold
|
|
1034
|
-
], 'warn');
|
|
1035
|
-
$isValid = false;
|
|
1036
|
-
} else {
|
|
1037
|
-
// Le fingerprint est cohérent, on peut valider la solution
|
|
1038
|
-
if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
|
|
1039
|
-
$cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
|
|
1040
|
-
if ($cpuSolution) {
|
|
1041
|
-
$ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket($context->clientIp, 3600000, $powNonce, $cpuSolution, $challengeContext);
|
|
1042
|
-
$isValid = $ticket !== null;
|
|
1043
|
-
|
|
1044
|
-
if ($powType === 'cpu_mem') {
|
|
1045
|
-
$memSolution = $context->query['pow_solution_mem'] ?? null;
|
|
1046
|
-
$isMemValid = $memSolution ? ChallengeUtils::verifyMemoryPoW($powNonce, $memSolution, $challengeContext['memDifficulty'] ?? 0, $challengeContext['clientSecret'] ?? '') : false;
|
|
1047
|
-
$isValid = $isValid && $isMemValid;
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
} elseif ($powType === 'pospace') {
|
|
1051
|
-
$powSolutionSpace = $context->query['pow_solution_space'] ?? null;
|
|
1052
|
-
if ($powSolutionSpace && isset($challengeContext['queries'])) {
|
|
1053
|
-
$isSpaceValid = ChallengeUtils::verifySpacePoW(
|
|
1054
|
-
$powNonce,
|
|
1055
|
-
$powSolutionSpace,
|
|
1056
|
-
$challengeContext['queries'],
|
|
1057
|
-
$powNonce . ":" . $challengeContext['clientSecret'],
|
|
1058
|
-
$challengeContext['clientSecret']
|
|
1059
|
-
);
|
|
1060
|
-
$isValid = $isSpaceValid;
|
|
1061
|
-
if ($isValid) {
|
|
1062
|
-
$ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
|
|
1063
|
-
$expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
|
|
1064
|
-
$ticket = ChallengeUtils::generateStatelessTicket([
|
|
1065
|
-
'expiry' => $expiry,
|
|
1066
|
-
'originalIp' => $context->clientIp,
|
|
1067
|
-
'deviceId' => '',
|
|
1068
|
-
'deviceHash' => ''
|
|
1069
|
-
]);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
} elseif ($powType === 'useful_work_task') {
|
|
1073
|
-
$problemId = $context->query['pow_problem_id'] ?? null;
|
|
1074
|
-
$workResultJson = $context->query['pow_solution_work_result'] ?? null;
|
|
1075
|
-
if ($problemId && $workResultJson) {
|
|
1076
|
-
$workResult = json_decode($workResultJson, true);
|
|
1077
|
-
$this->log('Verifying useful work solution.', [
|
|
1078
|
-
'problemId' => $problemId,
|
|
1079
|
-
'receivedData' => $workResult,
|
|
1080
|
-
'jsonLastError' => json_last_error_msg()
|
|
1081
|
-
]);
|
|
1082
|
-
if (json_last_error() === JSON_ERROR_NONE) {
|
|
1083
|
-
// @phpstan-ignore-next-line - L'instance est gérée par le singleton
|
|
1084
|
-
$defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
|
|
1085
|
-
$configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
|
|
1086
|
-
$problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
|
|
1087
|
-
// FIX: La solution est directement le $workResult, pas une sous-propriété.
|
|
1088
|
-
$problemManager->integrateSolution($problemId, $workResult);
|
|
1089
|
-
|
|
1090
|
-
// Si le problème résolu est l'auto-tuning de sécurité et que l'auto-tuning est activé,
|
|
1091
|
-
// on applique directement la meilleure solution calculée au moteur en direct.
|
|
1092
|
-
if ($problemId === 'security_auto_tuning' && ($this->securityConfig['autotuning']['enabled'] ?? false)) {
|
|
1093
|
-
$paretoFront = $workResult['paretoFront'] ?? null;
|
|
1094
|
-
if (is_array($paretoFront) && !empty($paretoFront)) {
|
|
1095
|
-
$bestSolution = $paretoFront[0];
|
|
1096
|
-
$minDistance = sqrt(pow((float)$bestSolution['objectives'][0], 2) + pow((float)$bestSolution['objectives'][1], 2));
|
|
1097
|
-
for ($i = 1; $i < count($paretoFront); $i++) {
|
|
1098
|
-
$distance = sqrt(pow((float)$paretoFront[$i]['objectives'][0], 2) + pow((float)$paretoFront[$i]['objectives'][1], 2));
|
|
1099
|
-
if ($distance < $minDistance) {
|
|
1100
|
-
$minDistance = $distance;
|
|
1101
|
-
$bestSolution = $paretoFront[$i];
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
if (isset($bestSolution['solution'])) {
|
|
1105
|
-
$this->updateConfig($bestSolution['solution']);
|
|
1106
|
-
$this->log('Useful Work auto-tuning applied successfully to live config.');
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
$isValid = true;
|
|
1112
|
-
// FIX: Générer un vrai ticket pour uPoW, comme pour un PoW normal.
|
|
1113
|
-
$ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000; // 1 heure par défaut
|
|
1114
|
-
$expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
|
|
1115
|
-
$signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", ChallengeUtils::getPowSecret());
|
|
1116
|
-
$ticket = "{$expiry}:{$signature}";
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
if ($isValid) {
|
|
1123
|
-
$store->delete("secret:{$powNonce}");
|
|
1124
|
-
$ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
|
|
1125
|
-
MetricsManager::incrementCounter('challenges_solved_total');
|
|
1126
|
-
$this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
|
|
1127
|
-
|
|
1128
|
-
$isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
|
|
1129
|
-
(isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
|
|
1130
|
-
($context->getHeader('x-forwarded-proto') === 'https');
|
|
1131
|
-
$secureOption = $isHttps || $this->isProduction;
|
|
1132
|
-
|
|
1133
|
-
return [
|
|
1134
|
-
'action' => 'redirect',
|
|
1135
|
-
'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
|
|
1136
|
-
'score' => 0.0,
|
|
1137
|
-
'vector' => ['challenge_solved' => 100],
|
|
1138
|
-
'cookie' => [
|
|
1139
|
-
'name' => 'pow_clearance',
|
|
1140
|
-
'value' => $ticket,
|
|
1141
|
-
'options' => [
|
|
1142
|
-
'httponly' => true,
|
|
1143
|
-
'secure' => $secureOption,
|
|
1144
|
-
'samesite' => 'Strict',
|
|
1145
|
-
'expires' => time() + ($ticketTtl / 1000),
|
|
1146
|
-
'path' => '/',
|
|
1147
|
-
'partitioned' => $secureOption,
|
|
1148
|
-
]
|
|
1149
|
-
]
|
|
1150
|
-
];
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
// Si la solution est invalide ou le nonce est expiré, on pénalise fortement pour la suite.
|
|
1154
|
-
MetricsManager::incrementCounter('challenges_failed_total');
|
|
1155
|
-
$this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
|
|
1156
|
-
$suspicionVector['honeypotScore'] = 100.0;
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
// 3. Vérifier un ticket existant
|
|
1160
|
-
$hasValidTicket = false;
|
|
1161
|
-
$powCookie = $context->cookies['pow_clearance'] ?? null;
|
|
1162
|
-
$zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
|
|
1163
|
-
$deviceId = $context->cookies['device_id'] ?? '';
|
|
1164
|
-
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
1165
|
-
$allowRoaming = $this->securityConfig['allowCrossNetworkRoaming'] ?? false;
|
|
1166
|
-
if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof)) {
|
|
1167
|
-
$hasValidTicket = true;
|
|
1168
|
-
MetricsManager::incrementCounter('tickets_valid_total');
|
|
1169
|
-
// On ne retourne pas tout de suite pour permettre le re-challenge
|
|
1170
|
-
// $this->log('Valid clearance ticket found');
|
|
1171
|
-
// return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
// 4. Calculer le vecteur et le score de suspicion
|
|
1175
|
-
// Résoudre l'identité et vérifier le statut "condamné"
|
|
1176
|
-
$store = StoreManager::getStore();
|
|
1177
|
-
$identity = $this->resolveRequestIdentity($context, $suspicionVector);
|
|
1178
|
-
$deviceId = $identity['deviceId'];
|
|
1179
|
-
$deviceData = $identity['deviceData'];
|
|
1180
|
-
if ($deviceData && ($deviceData['condemned'] ?? false)) {
|
|
1181
|
-
$this->log('Device condemned - blocking request', ['deviceId' => $deviceId], 'warn');
|
|
1182
|
-
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
|
|
1183
|
-
if ($this->dryRun) {
|
|
1184
|
-
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1185
|
-
$decision['intendedAction'] = $decision['action'];
|
|
1186
|
-
$decision['action'] = 'next';
|
|
1187
|
-
unset($decision['status'], $decision['body']);
|
|
1188
|
-
}
|
|
1189
|
-
return $decision;
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
$suspicionVector = $context->preCalculatedVector ?? $this->getSuspicionVector($context, $suspicionVector);
|
|
1193
|
-
$finalScore = $context->preCalculatedScore ?? $this->calculateFinalScore($suspicionVector);
|
|
1194
|
-
$this->log('Suspicion vector and final score calculated', [
|
|
1195
|
-
'finalScore' => round($finalScore, 2),
|
|
1196
|
-
'vector' => $suspicionVector
|
|
1197
|
-
]);
|
|
1198
|
-
|
|
1199
|
-
// Mettre à jour les métriques du sous-réseau après le calcul du score final
|
|
1200
|
-
if ($finalScore >= ($thresholds['medium'] ?? 45)) {
|
|
1201
|
-
RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
|
|
1202
|
-
MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
// Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
|
|
1206
|
-
$isNewDevice = $identity['newCookie'] !== null;
|
|
1207
|
-
if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
|
|
1208
|
-
$this->log('New device - enforcing minimum challenge score', [
|
|
1209
|
-
'originalScore' => round($finalScore, 2),
|
|
1210
|
-
'enforcedScore' => (float)$thresholds['low']
|
|
1211
|
-
]);
|
|
1212
|
-
$finalScore = (float)$thresholds['low'];
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
// Vérifier les URL pièges (après calcul du score)
|
|
1216
|
-
$lastNonce = $deviceData['lastChallengeNonce'] ?? null;
|
|
1217
|
-
if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
|
|
1218
|
-
if ($this->logger) {
|
|
1219
|
-
$this->logger->log('info', 'trap_triggered', ['deviceId' => $deviceId, 'score' => 100, 'path' => $context->path, 'vector' => ['honeypotScore' => 100]]);
|
|
1220
|
-
}
|
|
1221
|
-
$this->log('Honeypot trap URL triggered - condemning device', ['path' => $context->path, 'deviceId' => $deviceId]);
|
|
1222
|
-
$deviceData['condemned'] = true; // @phpstan-ignore-line
|
|
1223
|
-
$store->set("device:{$deviceId}", $deviceData);
|
|
1224
|
-
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
|
|
1225
|
-
if ($this->dryRun) {
|
|
1226
|
-
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1227
|
-
$decision['intendedAction'] = $decision['action'];
|
|
1228
|
-
$decision['action'] = 'next';
|
|
1229
|
-
unset($decision['status'], $decision['body']);
|
|
1230
|
-
}
|
|
1231
|
-
return $decision;
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
// 5. Prendre une décision basée sur le score - Vérifier le blocage d'abord.
|
|
1235
|
-
$blockThreshold = $thresholds['block'] ?? 95;
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
$
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
$
|
|
1248
|
-
$
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
$
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
$
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
$decision['
|
|
1274
|
-
$decision['
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
//
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
$
|
|
1285
|
-
$
|
|
1286
|
-
$
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
$decision['
|
|
1292
|
-
$decision['
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
$
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
$
|
|
1312
|
-
|
|
1313
|
-
'
|
|
1314
|
-
'
|
|
1315
|
-
'
|
|
1316
|
-
'
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
$decision['
|
|
1321
|
-
$decision['
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
$decision['
|
|
1333
|
-
$decision['
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
$
|
|
1343
|
-
$
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
$suspicionFactor =
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
$
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
$
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
'
|
|
1372
|
-
'
|
|
1373
|
-
'
|
|
1374
|
-
|
|
1375
|
-
'
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
$html
|
|
1385
|
-
$html .= '
|
|
1386
|
-
$
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
//
|
|
1392
|
-
|
|
1393
|
-
$
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
$
|
|
1402
|
-
|
|
1403
|
-
'
|
|
1404
|
-
'
|
|
1405
|
-
'
|
|
1406
|
-
'
|
|
1407
|
-
'
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
$
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
'
|
|
1420
|
-
'
|
|
1421
|
-
'
|
|
1422
|
-
'
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
$
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
'
|
|
1435
|
-
'
|
|
1436
|
-
'
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
$
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
$
|
|
1445
|
-
$
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
'
|
|
1450
|
-
'
|
|
1451
|
-
'
|
|
1452
|
-
'
|
|
1453
|
-
'
|
|
1454
|
-
'
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
$
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
$
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
'
|
|
1478
|
-
'
|
|
1479
|
-
'
|
|
1480
|
-
'
|
|
1481
|
-
'
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
$
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
$
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
$
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
$
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
$
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
$
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
$
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
$
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
'
|
|
1588
|
-
'
|
|
1589
|
-
'
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
$
|
|
1601
|
-
$
|
|
1602
|
-
$
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
$
|
|
1609
|
-
$
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
$
|
|
1617
|
-
$out
|
|
1618
|
-
$out .= "
|
|
1619
|
-
$out .= "Content-
|
|
1620
|
-
$out .= "
|
|
1621
|
-
$out .= "X-Federation-Signature
|
|
1622
|
-
$out .= "X-Federation-
|
|
1623
|
-
$out .= "
|
|
1624
|
-
$out .=
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
@
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
*
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
$
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
$
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
$
|
|
1657
|
-
$
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
if ($cachedStatus === '
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
$
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
$
|
|
1738
|
-
$
|
|
1739
|
-
$
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
if ($val
|
|
1744
|
-
if ($val ===
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
$
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
$
|
|
1763
|
-
$
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
$
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
$
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
$
|
|
1778
|
-
$
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
"
|
|
1790
|
-
"
|
|
1791
|
-
"
|
|
1792
|
-
"
|
|
1793
|
-
"
|
|
1794
|
-
"
|
|
1795
|
-
"-----
|
|
1796
|
-
"
|
|
1797
|
-
"
|
|
1798
|
-
"
|
|
1799
|
-
"
|
|
1800
|
-
"
|
|
1801
|
-
"
|
|
1802
|
-
"
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
$
|
|
1820
|
-
$
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
$
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
$
|
|
1847
|
-
$
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
$deviceData['
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
$
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Anonympins\Fingerprint;
|
|
6
|
+
|
|
7
|
+
use Anonympins\Fingerprint\Challenge\ChallengeUtils;
|
|
8
|
+
use Anonympins\Fingerprint\Config\SecurityProfiles;
|
|
9
|
+
use Anonympins\Fingerprint\Store\StoreManager;
|
|
10
|
+
use Anonympins\Fingerprint\Utils\BlockList;
|
|
11
|
+
use Anonympins\Fingerprint\Utils\Logger;
|
|
12
|
+
use Anonympins\Fingerprint\Utils\MetricsManager;
|
|
13
|
+
use Anonympins\Fingerprint\Utils\RequestUtils;
|
|
14
|
+
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 dnsResolveUdp(string $query, string $type, float $timeout = 0.5): ?array
|
|
85
|
+
{
|
|
86
|
+
$dnsServer = '8.8.8.8';
|
|
87
|
+
$port = 53;
|
|
88
|
+
$fp = @stream_socket_client("udp://$dnsServer:$port", $errno, $errstr, $timeout);
|
|
89
|
+
if (!$fp) return null;
|
|
90
|
+
stream_set_timeout($fp, 0, (int)($timeout * 1000000));
|
|
91
|
+
|
|
92
|
+
$id = rand(10000, 65000);
|
|
93
|
+
$header = pack('n6', $id, 0x0100, 1, 0, 0, 0);
|
|
94
|
+
|
|
95
|
+
$qType = ($type === 'PTR') ? 12 : (($type === 'AAAA') ? 28 : 1);
|
|
96
|
+
$parts = explode('.', $query);
|
|
97
|
+
$qName = '';
|
|
98
|
+
foreach ($parts as $part) {
|
|
99
|
+
$qName .= chr(strlen($part)) . $part;
|
|
100
|
+
}
|
|
101
|
+
$qName .= chr(0);
|
|
102
|
+
$question = $qName . pack('n2', $qType, 1);
|
|
103
|
+
|
|
104
|
+
if (!@fwrite($fp, $header . $question)) {
|
|
105
|
+
fclose($fp);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
$response = @fread($fp, 512);
|
|
110
|
+
fclose($fp);
|
|
111
|
+
if (!$response || strlen($response) < 12) return null;
|
|
112
|
+
|
|
113
|
+
$resHeader = unpack('n6', substr($response, 0, 12));
|
|
114
|
+
if ($resHeader[1] !== $id) return null;
|
|
115
|
+
|
|
116
|
+
$answersCount = $resHeader[4];
|
|
117
|
+
if ($answersCount <= 0) return [];
|
|
118
|
+
|
|
119
|
+
$offset = 12 + strlen($question);
|
|
120
|
+
$results = [];
|
|
121
|
+
for ($i = 0; $i < $answersCount; $i++) {
|
|
122
|
+
if ($offset + 12 > strlen($response)) break;
|
|
123
|
+
if ((ord($response[$offset]) & 0xc0) === 0xc0) {
|
|
124
|
+
$offset += 2;
|
|
125
|
+
} else {
|
|
126
|
+
while ($offset < strlen($response) && ord($response[$offset]) !== 0) {
|
|
127
|
+
$offset += 1 + ord($response[$offset]);
|
|
128
|
+
}
|
|
129
|
+
$offset++;
|
|
130
|
+
}
|
|
131
|
+
if ($offset + 10 > strlen($response)) break;
|
|
132
|
+
$typeAndClass = unpack('n2type_class/Nttl/nlen', substr($response, $offset, 10));
|
|
133
|
+
$rType = $typeAndClass['type_class1'];
|
|
134
|
+
$rLen = $typeAndClass['len'];
|
|
135
|
+
$offset += 10;
|
|
136
|
+
if ($offset + $rLen > strlen($response)) break;
|
|
137
|
+
$rData = substr($response, $offset, $rLen);
|
|
138
|
+
$offset += $rLen;
|
|
139
|
+
|
|
140
|
+
if ($rType === 12) {
|
|
141
|
+
$ptrName = '';
|
|
142
|
+
$p = 0;
|
|
143
|
+
while ($p < strlen($rData)) {
|
|
144
|
+
$l = ord($rData[$p]);
|
|
145
|
+
if ($l === 0) break;
|
|
146
|
+
if (($l & 0xc0) === 0xc0) {
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
$ptrName .= substr($rData, $p + 1, $l) . '.';
|
|
150
|
+
$p += 1 + $l;
|
|
151
|
+
}
|
|
152
|
+
$results[] = rtrim($ptrName, '.');
|
|
153
|
+
} elseif ($rType === 1) {
|
|
154
|
+
if ($rLen === 4) {
|
|
155
|
+
$results[] = long2ip(unpack('N', $rData)[1]);
|
|
156
|
+
}
|
|
157
|
+
} elseif ($rType === 28) {
|
|
158
|
+
if ($rLen === 16) {
|
|
159
|
+
$results[] = inet_ntop($rData);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return $results;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
public function __construct(array $securityConfig)
|
|
167
|
+
{
|
|
168
|
+
$this->isProduction = Env::get('APP_ENV') === 'production';
|
|
169
|
+
|
|
170
|
+
// Dynamically bind Ed25519 keys if passed via config
|
|
171
|
+
if (isset($securityConfig['ed25519_private_key'])) {
|
|
172
|
+
Env::set('ED25519_PRIVATE_KEY', $securityConfig['ed25519_private_key']);
|
|
173
|
+
}
|
|
174
|
+
if (isset($securityConfig['ed25519_public_key'])) {
|
|
175
|
+
Env::set('ED25519_PUBLIC_KEY', $securityConfig['ed25519_public_key']);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Génération et persistance automatique de la paire de clés Ed25519
|
|
179
|
+
$useAsymmetric = ($securityConfig['useAsymmetricTickets'] ?? false) === true || ($securityConfig['ed25519'] ?? '') === 'auto';
|
|
180
|
+
$envPrivate = Env::get('ED25519_PRIVATE_KEY');
|
|
181
|
+
if ($useAsymmetric && ($envPrivate === null || $envPrivate === '')) {
|
|
182
|
+
$configDir = dirname(__DIR__, 1) . '/config';
|
|
183
|
+
$persistentKeyPath = $configDir . '/ed25519_key.json';
|
|
184
|
+
|
|
185
|
+
if (file_exists($persistentKeyPath)) {
|
|
186
|
+
try {
|
|
187
|
+
$keys = json_decode(file_get_contents($persistentKeyPath), true);
|
|
188
|
+
if (isset($keys['privateKey'], $keys['publicKey'])) {
|
|
189
|
+
Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
|
|
190
|
+
Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
|
|
191
|
+
}
|
|
192
|
+
} catch (\Throwable $e) {
|
|
193
|
+
error_log('[Fingerprint] Failed to load persistent Ed25519 keys: ' . $e->getMessage());
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
try {
|
|
197
|
+
if (defined('OPENSSL_KEYTYPE_ED25519')) {
|
|
198
|
+
$pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
|
|
199
|
+
if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
|
|
200
|
+
$details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
|
|
201
|
+
$publicKeyPem = $details['key']; // @phpstan-ignore-line
|
|
202
|
+
Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
|
|
203
|
+
Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
|
|
204
|
+
// Also update $_ENV and $_SERVER for consistency
|
|
205
|
+
if (!is_dir($configDir)) {
|
|
206
|
+
mkdir($configDir, 0777, true);
|
|
207
|
+
}
|
|
208
|
+
file_put_contents($persistentKeyPath, json_encode([
|
|
209
|
+
'privateKey' => $privateKeyPem,
|
|
210
|
+
'publicKey' => $publicKeyPem
|
|
211
|
+
], JSON_PRETTY_PRINT));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (\Throwable $e) {
|
|
215
|
+
error_log('[Fingerprint] Native Ed25519 key generation failed: ' . $e->getMessage());
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Auto-load optimized config if autotuning savePath is specified
|
|
221
|
+
if (isset($securityConfig['autotuning']['savePath'])) {
|
|
222
|
+
$savePath = $securityConfig['autotuning']['savePath'];
|
|
223
|
+
if (file_exists($savePath)) {
|
|
224
|
+
try {
|
|
225
|
+
$savedConfig = json_decode(file_get_contents($savePath), true);
|
|
226
|
+
if (json_last_error() === JSON_ERROR_NONE && is_array($savedConfig)) {
|
|
227
|
+
$securityConfig = SecurityProfiles::deepMerge($securityConfig, $savedConfig);
|
|
228
|
+
}
|
|
229
|
+
} catch (\Throwable $e) {
|
|
230
|
+
error_log("[FingerprintEngine] Failed to auto-load optimized config from {$savePath}: " . $e->getMessage());
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
$this->securityConfig = $securityConfig;
|
|
235
|
+
$this->verbose = $securityConfig['verbose'] ?? false;
|
|
236
|
+
$this->allowlist = $this->buildAllowlist();
|
|
237
|
+
$this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
|
|
238
|
+
$this->dryRun = $securityConfig['dryRun'] ?? false;
|
|
239
|
+
$this->validateConfig($securityConfig);
|
|
240
|
+
if ($this->securityConfig['reset'] ?? false) {
|
|
241
|
+
$this->resetStore();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Applique à chaud une nouvelle configuration de sécurité (poids, seuils, etc.)
|
|
247
|
+
* sans nécessiter de redémarrage.
|
|
248
|
+
*
|
|
249
|
+
* @param array $newConfig La nouvelle configuration (partielle ou complète).
|
|
250
|
+
*/
|
|
251
|
+
public function updateConfig(array $newConfig): void
|
|
252
|
+
{
|
|
253
|
+
$this->validateConfig($newConfig);
|
|
254
|
+
$this->securityConfig = SecurityProfiles::deepMerge($this->securityConfig, $newConfig);
|
|
255
|
+
$this->log('Configuration mise à jour à chaud (Hot-Reloaded)', $this->securityConfig);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Réinitialise le store de persistance actif.
|
|
260
|
+
*/
|
|
261
|
+
public function resetStore(): void
|
|
262
|
+
{
|
|
263
|
+
$store = StoreManager::getStore();
|
|
264
|
+
if (method_exists($store, 'clear')) {
|
|
265
|
+
$store->clear();
|
|
266
|
+
}
|
|
267
|
+
$this->log('Store has been reset/cleared.');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private function validateConfig(array $config): void
|
|
271
|
+
{
|
|
272
|
+
if (empty($config)) {
|
|
273
|
+
$this->log('Warning: No securityConfig provided. Using default behaviors.', [], 'warn');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
$knownKeys = [
|
|
278
|
+
'weights', 'thresholds', 'cpu', 'ticketMaxAge', 'challengeTtl',
|
|
279
|
+
'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
|
|
280
|
+
'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
|
|
281
|
+
'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
|
|
282
|
+
'similarityThreshold', 'summary', 'description',
|
|
283
|
+
'ed25519_private_key', 'ed25519_public_key',
|
|
284
|
+
'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret', 'reset'
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
if (empty($config['weights'])) {
|
|
288
|
+
$this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
|
|
289
|
+
}
|
|
290
|
+
if (empty($config['thresholds'])) {
|
|
291
|
+
$this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
foreach (array_keys($config) as $key) {
|
|
295
|
+
if (!in_array($key, $knownKeys, true)) {
|
|
296
|
+
$this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
|
|
301
|
+
{
|
|
302
|
+
$configDir = dirname(__DIR__, 2) . '/config';
|
|
303
|
+
$filePath = $configDir . '/' . $filename;
|
|
304
|
+
if (file_exists($filePath)) {
|
|
305
|
+
try {
|
|
306
|
+
$content = file_get_contents($filePath);
|
|
307
|
+
if ($content !== false) {
|
|
308
|
+
$decoded = json_decode($content, true);
|
|
309
|
+
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
|
310
|
+
return $decoded;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch (\Throwable $e) {
|
|
314
|
+
error_log("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return $fallbackEntries;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
public static function googlebot_whitelist(): array
|
|
321
|
+
{
|
|
322
|
+
if (self::$googlebotEntries === null) {
|
|
323
|
+
self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
|
|
324
|
+
"2001:4860:4801:10::/64",
|
|
325
|
+
"2001:4860:4801:11::/64",
|
|
326
|
+
"2001:4860:4801:12::/64",
|
|
327
|
+
"66.249.79.64"
|
|
328
|
+
]);
|
|
329
|
+
}
|
|
330
|
+
return [
|
|
331
|
+
'type' => 'allowlist',
|
|
332
|
+
'entries' => self::$googlebotEntries
|
|
333
|
+
];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
public static function yandex_whitelist(): array
|
|
337
|
+
{
|
|
338
|
+
if (self::$yandexEntries === null) {
|
|
339
|
+
self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
|
|
340
|
+
"2a02:6b8::/29",
|
|
341
|
+
"5.45.192.0/18",
|
|
342
|
+
"213.180.192.0/19"
|
|
343
|
+
]);
|
|
344
|
+
}
|
|
345
|
+
return [
|
|
346
|
+
'type' => 'allowlist',
|
|
347
|
+
'entries' => self::$yandexEntries
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
public static function bingbot_whitelist(): array
|
|
352
|
+
{
|
|
353
|
+
if (self::$bingbotEntries === null) {
|
|
354
|
+
self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
|
|
355
|
+
"157.55.39.0/24",
|
|
356
|
+
"207.46.13.0/24",
|
|
357
|
+
"40.77.178.0/23"
|
|
358
|
+
]);
|
|
359
|
+
}
|
|
360
|
+
return [
|
|
361
|
+
'type' => 'allowlist',
|
|
362
|
+
'entries' => self::$bingbotEntries
|
|
363
|
+
];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
public static function default_whitelist(): array
|
|
367
|
+
{
|
|
368
|
+
return [
|
|
369
|
+
self::googlebot_whitelist(),
|
|
370
|
+
self::bingbot_whitelist(),
|
|
371
|
+
self::yandex_whitelist(),
|
|
372
|
+
['userAgent' => 'Googlebot', 'hostnameSuffix' => '.googlebot.com'],
|
|
373
|
+
['userAgent' => 'Google-Extended', 'hostnameSuffix' => '.google.com'],
|
|
374
|
+
['userAgent' => 'AdsBot-Google', 'hostnameSuffix' => '.googlebot.com'],
|
|
375
|
+
['userAgent' => 'Mediapartners-Google', 'hostnameSuffix' => '.google.com'],
|
|
376
|
+
['userAgent' => 'Google-InspectionTool', 'hostnameSuffix' => '.google.com'],
|
|
377
|
+
['userAgent' => '(bingbot|adidxbot)', 'hostnameSuffix' => '.search.msn.com'],
|
|
378
|
+
['userAgent' => 'DuckDuckBot', 'hostnameSuffix' => '.duckduckgo.com'],
|
|
379
|
+
['userAgent' => 'YandexBot', 'hostnameSuffix' => '.yandex.com'],
|
|
380
|
+
['userAgent' => 'YandexImages', 'hostnameSuffix' => '.yandex.com'],
|
|
381
|
+
['userAgent' => 'Baiduspider', 'hostnameSuffix' => '.crawl.baidu.com'],
|
|
382
|
+
['userAgent' => 'Slurp', 'hostnameSuffix' => '.crawl.yahoo.net'],
|
|
383
|
+
['userAgent' => 'Sogou web spider', 'hostnameSuffix' => '.sogou.com'],
|
|
384
|
+
['userAgent' => 'Exabot', 'hostnameSuffix' => '.exabot.com'],
|
|
385
|
+
['userAgent' => 'ia_archiver', 'hostnameSuffix' => '.alexa.com'],
|
|
386
|
+
['userAgent' => 'SeznamBot', 'hostnameSuffix' => '.seznam.cz'],
|
|
387
|
+
['userAgent' => 'Mail.RU_Bot', 'hostnameSuffix' => '.mail.ru'],
|
|
388
|
+
['userAgent' => 'Yeti', 'hostnameSuffix' => '.naver.com'],
|
|
389
|
+
['userAgent' => 'AhrefsBot', 'hostnameSuffix' => '.ahrefs.com'],
|
|
390
|
+
['userAgent' => 'SemrushBot', 'hostnameSuffix' => '.semrush.com'],
|
|
391
|
+
['userAgent' => 'MJ12bot', 'hostnameSuffix' => '.mj12bot.com'],
|
|
392
|
+
['userAgent' => 'rogerbot', 'hostnameSuffix' => '.moz.com'],
|
|
393
|
+
['userAgent' => 'DotBot', 'hostnameSuffix' => '.moz.com'],
|
|
394
|
+
['userAgent' => 'Screaming Frog SEO Spider', 'hostnameSuffix' => '.screamingfrog.co.uk'],
|
|
395
|
+
];
|
|
396
|
+
}
|
|
397
|
+
private function log(string $message, array $data = [], string $level = 'info'): void
|
|
398
|
+
{
|
|
399
|
+
if ($this->verbose) {
|
|
400
|
+
// Utilise le logger s'il est configuré, sinon error_log
|
|
401
|
+
if ($this->logger) {
|
|
402
|
+
$this->logger->log($level, "[FingerprintEngine] " . $message, $data);
|
|
403
|
+
} else {
|
|
404
|
+
$logMessage = "[FingerprintEngine] {$message}";
|
|
405
|
+
if (!empty($data)) $logMessage .= ' ' . json_encode($data);
|
|
406
|
+
error_log($logMessage);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
public function calculateFinalScore(array $suspicionVector): float
|
|
412
|
+
{
|
|
413
|
+
$weights = $this->securityConfig['weights'] ?? [];
|
|
414
|
+
if (empty($weights)) {
|
|
415
|
+
return 0.0;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
$score = 0.0;
|
|
419
|
+
foreach ($weights as $key => $weight) {
|
|
420
|
+
$score += ($suspicionVector[$key] ?? 0) * $weight;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return min(100.0, $score);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private function buildAllowlist(): BlockList
|
|
427
|
+
{
|
|
428
|
+
$blockList = new BlockList();
|
|
429
|
+
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
430
|
+
|
|
431
|
+
$allowlistRule = null;
|
|
432
|
+
foreach ($whitelistRules as $rule) {
|
|
433
|
+
if (($rule['type'] ?? '') === 'allowlist') {
|
|
434
|
+
$allowlistRule = $rule;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (empty($allowlistRule['entries'])) {
|
|
440
|
+
return $blockList;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
foreach ($allowlistRule['entries'] as $entry) {
|
|
444
|
+
$blockList->add($entry);
|
|
445
|
+
}
|
|
446
|
+
return $blockList;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private function isIpInAllowlist(string $clientIp): bool
|
|
450
|
+
{
|
|
451
|
+
return $this->allowlist->check($clientIp);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private function isPathInAllowlist(string $requestPath): bool
|
|
455
|
+
{
|
|
456
|
+
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
457
|
+
$pathAllowlistRule = null;
|
|
458
|
+
foreach ($whitelistRules as $rule) {
|
|
459
|
+
if (($rule['type'] ?? '') === 'path_allowlist') {
|
|
460
|
+
$pathAllowlistRule = $rule;
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (empty($pathAllowlistRule['entries'])) {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
foreach ($pathAllowlistRule['entries'] as $entry) {
|
|
470
|
+
if (str_ends_with($entry, '*')) {
|
|
471
|
+
$base = substr($entry, 0, -1);
|
|
472
|
+
if (str_starts_with($requestPath, $base)) {
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
} elseif ($requestPath === $entry) {
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
|
|
484
|
+
{
|
|
485
|
+
if (empty($requestHost)) {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
490
|
+
$hostPathRule = null;
|
|
491
|
+
foreach ($whitelistRules as $rule) {
|
|
492
|
+
if (($rule['type'] ?? '') === 'host_path_allowlist') {
|
|
493
|
+
$hostPathRule = $rule;
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (empty($hostPathRule['entries'])) {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
foreach ($hostPathRule['entries'] as $entry) {
|
|
503
|
+
if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Vérifie si une requête doit être exemptée en raison d'une règle de liste blanche.
|
|
512
|
+
*/
|
|
513
|
+
private function checkAllowlists(RequestContext $context): bool
|
|
514
|
+
{
|
|
515
|
+
$whitelisted = false;
|
|
516
|
+
$type = '';
|
|
517
|
+
|
|
518
|
+
if ($this->isIpInAllowlist($context->clientIp)) {
|
|
519
|
+
$whitelisted = true;
|
|
520
|
+
$type = 'allowlist';
|
|
521
|
+
} elseif ($this->isPathInAllowlist($context->path)) {
|
|
522
|
+
$whitelisted = true;
|
|
523
|
+
$type = 'path_allowlist';
|
|
524
|
+
} elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
|
|
525
|
+
$whitelisted = true;
|
|
526
|
+
$type = 'host_path_allowlist';
|
|
527
|
+
} elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
|
|
528
|
+
$whitelisted = true;
|
|
529
|
+
$type = 'graphql_operation_allowlist';
|
|
530
|
+
} elseif ($this->verifyWhitelistedBot($context)) {
|
|
531
|
+
$whitelisted = true;
|
|
532
|
+
$type = 'bot';
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if ($whitelisted) {
|
|
536
|
+
$filterWhitelist = $this->securityConfig['filterWhitelist'] ?? false;
|
|
537
|
+
$bypassWhitelist = false;
|
|
538
|
+
if ($filterWhitelist === true) {
|
|
539
|
+
$bypassWhitelist = $this->hasCertainAttack($context);
|
|
540
|
+
} elseif (is_numeric($filterWhitelist)) {
|
|
541
|
+
if ($context->preCalculatedScore === null) {
|
|
542
|
+
$suspicionVector = [];
|
|
543
|
+
$context->preCalculatedVector = $this->getSuspicionVector($context, $suspicionVector);
|
|
544
|
+
$context->preCalculatedScore = $this->calculateFinalScore($context->preCalculatedVector);
|
|
545
|
+
}
|
|
546
|
+
if ($context->preCalculatedScore > $filterWhitelist) {
|
|
547
|
+
$bypassWhitelist = true;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if ($bypassWhitelist) {
|
|
552
|
+
$this->log('Whitelisted request exceeds filter threshold - bypassing whitelist bypass', ['clientIp' => $context->clientIp, 'path' => $context->path]);
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
$this->log("IP/Path in allowlist ({$type}) - allowing request", ['clientIp' => $context->clientIp, 'path' => $context->path]);
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
|
|
564
|
+
*/
|
|
565
|
+
private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
|
|
566
|
+
{
|
|
567
|
+
if ($context->resolvedIdentity !== null) {
|
|
568
|
+
return $context->resolvedIdentity;
|
|
569
|
+
}
|
|
570
|
+
$this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
|
|
571
|
+
$store = StoreManager::getStore();
|
|
572
|
+
$existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
|
|
573
|
+
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
574
|
+
$pendingCookieTtl = 120; // 2 minutes pour détecter la suppression
|
|
575
|
+
|
|
576
|
+
$deviceId = $existingDeviceId;
|
|
577
|
+
$deviceData = null;
|
|
578
|
+
$newCookie = null;
|
|
579
|
+
|
|
580
|
+
// Cookieless Identity Tracking: Attempt to restore device ID using TLS session resume ID
|
|
581
|
+
$tlsSessionId = $context->tlsSessionId;
|
|
582
|
+
if (!$deviceId && $tlsSessionId) {
|
|
583
|
+
$resumedDeviceId = $store->get("tls-session:{$tlsSessionId}");
|
|
584
|
+
if ($resumedDeviceId) {
|
|
585
|
+
$deviceId = $resumedDeviceId;
|
|
586
|
+
$this->log('Identity resumed via TLS Session ID', ['deviceId' => $deviceId, 'tlsSessionId' => $tlsSessionId]);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if ($deviceId) {
|
|
591
|
+
$deviceData = $store->get("device:{$deviceId}");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if ($deviceData === null) {
|
|
595
|
+
// Nouvel utilisateur ou cookie perdu/invalide
|
|
596
|
+
$pendingDeviceId = $store->get("pending_cookie:{$context->clientIp}");
|
|
597
|
+
if ($pendingDeviceId && !$existingDeviceId) {
|
|
598
|
+
// La pénalité est maintenant ajoutée directement au vecteur de suspicion.
|
|
599
|
+
$this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
|
|
600
|
+
$suspicionVector['cookieDroppingScore'] = 100.0;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
$deviceId = bin2hex(random_bytes(16)); // UUID-like
|
|
604
|
+
|
|
605
|
+
$isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
|
|
606
|
+
(isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
|
|
607
|
+
($context->getHeader('x-forwarded-proto') === 'https');
|
|
608
|
+
$secureOption = $isHttps || $this->isProduction;
|
|
609
|
+
|
|
610
|
+
// Préparer le cookie à envoyer
|
|
611
|
+
$newCookie = [
|
|
612
|
+
'name' => 'device_id',
|
|
613
|
+
'value' => $deviceId,
|
|
614
|
+
'options' => [
|
|
615
|
+
'httponly' => true,
|
|
616
|
+
'secure' => $secureOption,
|
|
617
|
+
'partitioned' => $secureOption,
|
|
618
|
+
'samesite' => 'Strict',
|
|
619
|
+
'path' => '/',
|
|
620
|
+
]
|
|
621
|
+
];
|
|
622
|
+
if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
|
|
623
|
+
$newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
$store->set("pending_cookie:{$context->clientIp}", $deviceId, $pendingCookieTtl);
|
|
627
|
+
|
|
628
|
+
$deviceData = [
|
|
629
|
+
'initialDeviceHash' => $currentDeviceHash,
|
|
630
|
+
'ips' => [$context->clientIp],
|
|
631
|
+
'requestHistory' => [],
|
|
632
|
+
'lastUpdate' => time() * 1000,
|
|
633
|
+
'lastFpHash' => $currentDeviceHash,
|
|
634
|
+
'lastChangeTimestamp' => 0,
|
|
635
|
+
'rapidChangeCount' => 0,
|
|
636
|
+
'highScoreCount' => 0,
|
|
637
|
+
'lastHighScoreTimestamp' => 0,
|
|
638
|
+
];
|
|
639
|
+
} else {
|
|
640
|
+
// S'assurer que 'ips' est un tableau pour les opérations suivantes.
|
|
641
|
+
if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
|
|
642
|
+
$deviceData['ips'] = [];
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Bind the current TLS session ID to the device ID
|
|
647
|
+
if ($deviceId && $tlsSessionId) {
|
|
648
|
+
$store->set("tls-session:{$tlsSessionId}", $deviceId, 3600); // 1h cache duration
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
$context->resolvedIdentity = [
|
|
652
|
+
'deviceId' => $deviceId,
|
|
653
|
+
'deviceData' => $deviceData,
|
|
654
|
+
'newCookie' => $newCookie,
|
|
655
|
+
];
|
|
656
|
+
return $context->resolvedIdentity;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* @return array<string, float>
|
|
661
|
+
*/
|
|
662
|
+
public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
|
|
663
|
+
{
|
|
664
|
+
$store = StoreManager::getStore();
|
|
665
|
+
$identity = $this->resolveRequestIdentity($context, $suspicionVector);
|
|
666
|
+
$deviceData = $identity['deviceData'];
|
|
667
|
+
$deviceId = $identity['deviceId'];
|
|
668
|
+
if ($deviceData && ($deviceData['condemned'] ?? false)) {
|
|
669
|
+
$suspicionVector['honeypotScore'] = 100;
|
|
670
|
+
return $suspicionVector;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Si un nouveau cookie doit être défini, on le stocke temporairement dans le contexte
|
|
674
|
+
// pour que le code appelant puisse le gérer.
|
|
675
|
+
if ($identity['newCookie']) {
|
|
676
|
+
// Cette propriété n'est pas standard, on la préfixe pour éviter les conflits.
|
|
677
|
+
$context->newCookieForResponse = $identity['newCookie'];
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Nettoyage périodique des données de l'appareil
|
|
681
|
+
if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
|
|
682
|
+
$deviceData['ips'] = [];
|
|
683
|
+
$deviceData['rapidChangeCount'] = 0;
|
|
684
|
+
$deviceData['ipTimes'] = [];
|
|
685
|
+
}
|
|
686
|
+
$deviceData['lastUpdate'] = time() * 1000;
|
|
687
|
+
|
|
688
|
+
// --- Calcul des différents scores de suspicion ---
|
|
689
|
+
|
|
690
|
+
// Score d'incohérence du fingerprint (déplacé ici pour être avec les autres)
|
|
691
|
+
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
692
|
+
$consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
|
|
693
|
+
$inconsistencyScore = min(100.0, max(0.0, (1 - $consistencyScore) * 200));
|
|
694
|
+
if ($consistencyScore < ($this->securityConfig['similarityThreshold'] ?? 0.7)) {
|
|
695
|
+
$inconsistencyScore = 100.0;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
$behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
|
|
699
|
+
|
|
700
|
+
// Score des anomalies d'en-têtes
|
|
701
|
+
$headerAnomalies = RequestUtils::getHeaderAnomalies($context);
|
|
702
|
+
|
|
703
|
+
// Score de spoofing TLS
|
|
704
|
+
$tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
|
|
705
|
+
$tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
|
|
706
|
+
|
|
707
|
+
// Advanced JA4 TLS Inconsistency checks
|
|
708
|
+
$ja4 = $context->getHeader('x-ja4-hash');
|
|
709
|
+
if ($ja4) {
|
|
710
|
+
$spoofedJa4s = [
|
|
711
|
+
't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
|
|
712
|
+
't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
|
|
713
|
+
't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
|
|
714
|
+
't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
|
|
715
|
+
't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
|
|
716
|
+
't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
|
|
717
|
+
];
|
|
718
|
+
if (in_array($ja4, $spoofedJa4s, true)) {
|
|
719
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
$parsedJa4 = $this->parseJa4($ja4);
|
|
723
|
+
if ($parsedJa4) {
|
|
724
|
+
$ua = $context->getHeader('user-agent') ?? '';
|
|
725
|
+
$uaParts = $this->parseUserAgent($ua);
|
|
726
|
+
|
|
727
|
+
// Check 1: Incohérence ALPN / HTTP Version
|
|
728
|
+
$httpVersion = $context->httpVersion ?? '1.1';
|
|
729
|
+
if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
|
|
730
|
+
$hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
|
|
731
|
+
if (!$hasProxy) {
|
|
732
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Check 2: Incohérence OS/Plateforme vs Capabilities TLS
|
|
737
|
+
if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
|
|
738
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Check 3: Incohérence User-Agent vs Signature JA4
|
|
742
|
+
if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
|
|
743
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
|
|
744
|
+
}
|
|
745
|
+
if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
|
|
746
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Check 4: La stagnation (Lack of Entropy / Genericity)
|
|
750
|
+
if (is_string($uaParts['browser'])) {
|
|
751
|
+
$ja4Key = "ja4-browsers:{$ja4}";
|
|
752
|
+
$seenBrowsers = $store->get($ja4Key) ?: [];
|
|
753
|
+
if (!is_array($seenBrowsers)) {
|
|
754
|
+
$seenBrowsers = [];
|
|
755
|
+
}
|
|
756
|
+
$browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
|
|
757
|
+
if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
|
|
758
|
+
$seenBrowsers[] = $browserFamily;
|
|
759
|
+
$store->set($ja4Key, $seenBrowsers, 86400);
|
|
760
|
+
}
|
|
761
|
+
if (count($seenBrowsers) > 1) {
|
|
762
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Score d'incohérence temporelle (attaque par rejeu)
|
|
769
|
+
$timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
|
|
770
|
+
|
|
771
|
+
// Score des incohérences entre couches (client vs serveur)
|
|
772
|
+
$crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
|
|
773
|
+
|
|
774
|
+
// Score des patterns de requêtes (scraping, vélocité)
|
|
775
|
+
$requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
|
|
776
|
+
|
|
777
|
+
// Score des honeypots
|
|
778
|
+
$honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
|
|
779
|
+
|
|
780
|
+
// Score des métriques comportementales client (souris, clavier)
|
|
781
|
+
$behavior = RequestUtils::getBehaviorScore($context);
|
|
782
|
+
|
|
783
|
+
// Score de détection de bot explicite (marqueurs d'automatisation)
|
|
784
|
+
$bot = RequestUtils::getBotScore($context);
|
|
785
|
+
|
|
786
|
+
// Score de variance des clics
|
|
787
|
+
$clickVariance = RequestUtils::getClickVarianceScore($context);
|
|
788
|
+
|
|
789
|
+
// Score basé sur les listes de menaces (Threat Intelligence)
|
|
790
|
+
$threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
|
|
791
|
+
|
|
792
|
+
// Score d'incohérence des Client-Hints
|
|
793
|
+
$clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
|
|
794
|
+
|
|
795
|
+
// Score de similarité globale de l'empreinte (Clustering Botnet)
|
|
796
|
+
$stableFp = RequestUtils::extractStablePart($currentDeviceHash);
|
|
797
|
+
$stableFpHash = FingerprintBuilder::cyrb53($stableFp);
|
|
798
|
+
$botnetCluster = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
|
|
799
|
+
|
|
800
|
+
// NOUVEAU: Score de réputation du sous-réseau IP
|
|
801
|
+
$subnetScore = RequestUtils::getSubnetScore($context, $deviceId);
|
|
802
|
+
|
|
803
|
+
// Score d'anomalie de pile TCP/IP
|
|
804
|
+
$tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
|
|
805
|
+
|
|
806
|
+
// Score d'anomalie de protocole (HTTP/2 et QUIC)
|
|
807
|
+
$protocolAnomaly = RequestUtils::getProtocolAnomalyScore($context);
|
|
808
|
+
|
|
809
|
+
// Score d'anomalie de rendu d'affichage (V-Sync)
|
|
810
|
+
$renderingAnomaly = RequestUtils::getRenderingAnomalyScore($context);
|
|
811
|
+
|
|
812
|
+
// Assemblage du vecteur de suspicion final
|
|
813
|
+
$suspicionVector = array_merge($suspicionVector, [
|
|
814
|
+
'inconsistencyScore' => $inconsistencyScore,
|
|
815
|
+
'historyScore' => $behavioral['historyScore'],
|
|
816
|
+
'rotationScore' => $behavioral['rotationScore'],
|
|
817
|
+
'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
|
|
818
|
+
'tlsSpoofingScore' => $tlsSpoofingScore,
|
|
819
|
+
'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
|
|
820
|
+
'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
|
|
821
|
+
'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
|
|
822
|
+
'honeypotScore' => $honeypot['honeypotScore'],
|
|
823
|
+
'behaviorScore' => $behavior['behaviorScore'],
|
|
824
|
+
'botScore' => $bot['botScore'],
|
|
825
|
+
'clickVarianceScore' => $clickVariance['clickVarianceScore'],
|
|
826
|
+
'threatIntelScore' => $threatIntel['threatIntelScore'],
|
|
827
|
+
'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
|
|
828
|
+
'subnetScore' => $subnetScore['subnetScore'],
|
|
829
|
+
'botnetClusterScore' => $botnetCluster['botnetClusterScore'],
|
|
830
|
+
'tcpAnomalyScore' => $tcpAnomaly['tcpAnomalyScore'],
|
|
831
|
+
'protocolAnomalyScore' => $protocolAnomaly['protocolAnomalyScore'],
|
|
832
|
+
'renderingAnomalyScore' => $renderingAnomaly['renderingAnomalyScore'],
|
|
833
|
+
]);
|
|
834
|
+
|
|
835
|
+
// Sauvegarder l'état mis à jour de l'appareil dans le store
|
|
836
|
+
$store->set("device:{$deviceId}", $deviceData);
|
|
837
|
+
|
|
838
|
+
return $suspicionVector;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Parse a basic User-Agent string.
|
|
843
|
+
*/
|
|
844
|
+
private function parseUserAgent(string $ua): array
|
|
845
|
+
{
|
|
846
|
+
$result = ['browser' => null, 'os' => null];
|
|
847
|
+
|
|
848
|
+
if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
|
|
849
|
+
$result['browser'] = 'Chrome';
|
|
850
|
+
if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
|
|
851
|
+
$result['browser'] .= '/' . $matches[1];
|
|
852
|
+
}
|
|
853
|
+
} elseif (str_contains($ua, 'Firefox')) {
|
|
854
|
+
$result['browser'] = 'Firefox';
|
|
855
|
+
if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
|
|
856
|
+
$result['browser'] .= '/' . $matches[1];
|
|
857
|
+
}
|
|
858
|
+
} elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
|
|
859
|
+
$result['browser'] = 'Safari';
|
|
860
|
+
if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
|
|
861
|
+
$result['browser'] .= '/' . $matches[1];
|
|
862
|
+
}
|
|
863
|
+
} elseif (str_contains($ua, 'Edg')) {
|
|
864
|
+
$result['browser'] = 'Edge';
|
|
865
|
+
if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
|
|
866
|
+
$result['browser'] .= '/' . $matches[1];
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
|
|
871
|
+
elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
|
|
872
|
+
elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
|
|
873
|
+
elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
|
|
874
|
+
elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
|
|
875
|
+
elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
|
|
876
|
+
|
|
877
|
+
return $result;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Parse JA4 string into protocol, version, ALPN etc.
|
|
882
|
+
*/
|
|
883
|
+
private function parseJa4(?string $ja4): ?array
|
|
884
|
+
{
|
|
885
|
+
if (empty($ja4)) return null;
|
|
886
|
+
$parts = explode('_', $ja4);
|
|
887
|
+
$ja4a = $parts[0];
|
|
888
|
+
if (strlen($ja4a) < 10) return null;
|
|
889
|
+
return [
|
|
890
|
+
'protocol' => $ja4a[0],
|
|
891
|
+
'version' => substr($ja4a, 1, 2),
|
|
892
|
+
'sni' => $ja4a[3],
|
|
893
|
+
'ciphersCount' => (int)substr($ja4a, 4, 2),
|
|
894
|
+
'extensionsCount' => (int)substr($ja4a, 6, 2),
|
|
895
|
+
'alpn' => substr($ja4a, 8, 2),
|
|
896
|
+
'ja4b' => $parts[1] ?? null,
|
|
897
|
+
'ja4c' => $parts[2] ?? null
|
|
898
|
+
];
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
private function decodePolymorphicFingerprint(string $fpString, array $mapping): string
|
|
903
|
+
{
|
|
904
|
+
if (empty($mapping['keys'])) {
|
|
905
|
+
return $fpString;
|
|
906
|
+
}
|
|
907
|
+
$reverseKeys = array_flip($mapping['keys']);
|
|
908
|
+
$parts = explode('|', $fpString);
|
|
909
|
+
$mappedParts = [];
|
|
910
|
+
foreach ($parts as $part) {
|
|
911
|
+
$pair = explode(':', $part, 2);
|
|
912
|
+
if (count($pair) === 2) {
|
|
913
|
+
$origKey = $reverseKeys[$pair[0]] ?? $pair[0];
|
|
914
|
+
$mappedParts[] = "{$origKey}:{$pair[1]}";
|
|
915
|
+
} else {
|
|
916
|
+
$mappedParts[] = $part;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return implode('|', $mappedParts);
|
|
920
|
+
}
|
|
921
|
+
private function translatePolymorphicHeaders(RequestContext $context): void
|
|
922
|
+
{
|
|
923
|
+
$store = StoreManager::getStore();
|
|
924
|
+
$activeMappings = $store->get('active-polymorphic-mappings') ?: [];
|
|
925
|
+
if (!is_array($activeMappings)) {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
foreach ($activeMappings as $mapping) {
|
|
930
|
+
$devFpHeader = strtolower($mapping['headers']['x-device-fingerprint'] ?? '');
|
|
931
|
+
$behaviorHeader = strtolower($mapping['headers']['x-behavior-metrics'] ?? '');
|
|
932
|
+
|
|
933
|
+
if (!empty($devFpHeader) && isset($context->headers[$devFpHeader])) {
|
|
934
|
+
$context->headers['x-device-fingerprint'] = $context->headers[$devFpHeader];
|
|
935
|
+
if (!empty($behaviorHeader) && isset($context->headers[$behaviorHeader])) {
|
|
936
|
+
$context->headers['x-behavior-metrics'] = $context->headers[$behaviorHeader];
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
$clientFp = $context->headers['x-device-fingerprint'];
|
|
940
|
+
if ($clientFp && is_string($clientFp)) {
|
|
941
|
+
$context->headers['x-device-fingerprint'] = $this->decodePolymorphicFingerprint($clientFp, $mapping);
|
|
942
|
+
}
|
|
943
|
+
break;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Évalue si la requête présente des caractéristiques d'attaque flagrantes ou a déclenché un honeypot.
|
|
950
|
+
*/
|
|
951
|
+
private function hasCertainAttack(RequestContext $context): bool
|
|
952
|
+
{
|
|
953
|
+
$honeypotConfig = $this->securityConfig['honeypot'] ?? [];
|
|
954
|
+
$honeypot = RequestUtils::getHoneypotScore($context, $honeypotConfig);
|
|
955
|
+
if (($honeypot['honeypotScore'] ?? 0.0) >= 100.0) {
|
|
956
|
+
return true;
|
|
957
|
+
}
|
|
958
|
+
$bot = RequestUtils::getBotScore($context);
|
|
959
|
+
if (($bot['botScore'] ?? 0.0) >= 100.0) {
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Traite une requête entrante et retourne une décision.
|
|
967
|
+
* @param RequestContext $context Le contexte de la requête.
|
|
968
|
+
* @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
|
|
969
|
+
*/
|
|
970
|
+
public function processRequest(RequestContext $context): array
|
|
971
|
+
{
|
|
972
|
+
$this->translatePolymorphicHeaders($context);
|
|
973
|
+
|
|
974
|
+
// Initialiser le vecteur de suspicion pour éviter les erreurs de type.
|
|
975
|
+
$suspicionVector = [];
|
|
976
|
+
|
|
977
|
+
$coopOp = $context->query['coop_op'] ?? null;
|
|
978
|
+
if ($coopOp) {
|
|
979
|
+
$result = ChallengeUtils::handleCooperativeRequest($context->query, $context->clientIp, $this->securityConfig);
|
|
980
|
+
return [
|
|
981
|
+
'action' => 'challenge',
|
|
982
|
+
'status' => 200,
|
|
983
|
+
'body' => $result
|
|
984
|
+
];
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
$this->log('Processing request', ['clientIp' => $context->clientIp, 'path' => $context->path]);
|
|
988
|
+
|
|
989
|
+
// Parse GraphQL query if applicable
|
|
990
|
+
if ($context->path === '/graphql' && !empty($context->body)) {
|
|
991
|
+
$gqlInfo = RequestUtils::parseGraphQLQuery(is_array($context->body) ? $context->body : []);
|
|
992
|
+
if ($gqlInfo) {
|
|
993
|
+
$context->graphqlOperation = $gqlInfo;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// 1. Vérifier les listes blanches
|
|
998
|
+
if ($this->checkAllowlists($context)) {
|
|
999
|
+
MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
|
|
1000
|
+
return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Initialiser le vecteur de suspicion
|
|
1004
|
+
$thresholds = $this->securityConfig['thresholds'];
|
|
1005
|
+
|
|
1006
|
+
// 2. Gérer la soumission d'une solution de challenge (PRIORITÉ HAUTE)
|
|
1007
|
+
$powNonce = $context->query['pow_nonce'] ?? null;
|
|
1008
|
+
$isChallengeSubmission = $powNonce && (
|
|
1009
|
+
isset($context->query['pow_solution']) ||
|
|
1010
|
+
isset($context->query['pow_solution_cpu']) ||
|
|
1011
|
+
isset($context->query['pow_solution_space']) ||
|
|
1012
|
+
(isset($context->query['pow_type']) && $context->query['pow_type'] === 'useful_work_task')
|
|
1013
|
+
);
|
|
1014
|
+
if ($isChallengeSubmission) {
|
|
1015
|
+
$this->log('Challenge solution submitted', ['pow_type' => $context->query['pow_type'] ?? 'unknown', 'nonce' => $powNonce]);
|
|
1016
|
+
$store = StoreManager::getStore();
|
|
1017
|
+
$challengeContext = $store->get("secret:{$powNonce}");
|
|
1018
|
+
$powType = $context->query['pow_type'] ?? null;
|
|
1019
|
+
|
|
1020
|
+
if ($challengeContext) {
|
|
1021
|
+
$isValid = false;
|
|
1022
|
+
$ticket = null;
|
|
1023
|
+
|
|
1024
|
+
// Vérification de la cohérence du fingerprint
|
|
1025
|
+
$solverFingerprint = $context->query['pow_fp'] ?? RequestUtils::getCompositeDeviceHash($context);
|
|
1026
|
+
$originalFingerprint = $challengeContext['fingerprint'] ?? '';
|
|
1027
|
+
$similarity = FingerprintBuilder::compare($originalFingerprint, $solverFingerprint);
|
|
1028
|
+
$similarityThreshold = $this->securityConfig['similarityThreshold'] ?? 0.95;
|
|
1029
|
+
|
|
1030
|
+
if ($similarity < $similarityThreshold) {
|
|
1031
|
+
$this->log('Fingerprint mismatch - challenge solved on a different machine!', [
|
|
1032
|
+
'similarity' => round($similarity, 4),
|
|
1033
|
+
'threshold' => $similarityThreshold
|
|
1034
|
+
], 'warn');
|
|
1035
|
+
$isValid = false;
|
|
1036
|
+
} else {
|
|
1037
|
+
// Le fingerprint est cohérent, on peut valider la solution
|
|
1038
|
+
if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
|
|
1039
|
+
$cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
|
|
1040
|
+
if ($cpuSolution) {
|
|
1041
|
+
$ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket($context->clientIp, 3600000, $powNonce, $cpuSolution, $challengeContext);
|
|
1042
|
+
$isValid = $ticket !== null;
|
|
1043
|
+
|
|
1044
|
+
if ($powType === 'cpu_mem') {
|
|
1045
|
+
$memSolution = $context->query['pow_solution_mem'] ?? null;
|
|
1046
|
+
$isMemValid = $memSolution ? ChallengeUtils::verifyMemoryPoW($powNonce, $memSolution, $challengeContext['memDifficulty'] ?? 0, $challengeContext['clientSecret'] ?? '') : false;
|
|
1047
|
+
$isValid = $isValid && $isMemValid;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
} elseif ($powType === 'pospace') {
|
|
1051
|
+
$powSolutionSpace = $context->query['pow_solution_space'] ?? null;
|
|
1052
|
+
if ($powSolutionSpace && isset($challengeContext['queries'])) {
|
|
1053
|
+
$isSpaceValid = ChallengeUtils::verifySpacePoW(
|
|
1054
|
+
$powNonce,
|
|
1055
|
+
$powSolutionSpace,
|
|
1056
|
+
$challengeContext['queries'],
|
|
1057
|
+
$powNonce . ":" . $challengeContext['clientSecret'],
|
|
1058
|
+
$challengeContext['clientSecret']
|
|
1059
|
+
);
|
|
1060
|
+
$isValid = $isSpaceValid;
|
|
1061
|
+
if ($isValid) {
|
|
1062
|
+
$ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
|
|
1063
|
+
$expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
|
|
1064
|
+
$ticket = ChallengeUtils::generateStatelessTicket([
|
|
1065
|
+
'expiry' => $expiry,
|
|
1066
|
+
'originalIp' => $context->clientIp,
|
|
1067
|
+
'deviceId' => '',
|
|
1068
|
+
'deviceHash' => ''
|
|
1069
|
+
]);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
} elseif ($powType === 'useful_work_task') {
|
|
1073
|
+
$problemId = $context->query['pow_problem_id'] ?? null;
|
|
1074
|
+
$workResultJson = $context->query['pow_solution_work_result'] ?? null;
|
|
1075
|
+
if ($problemId && $workResultJson) {
|
|
1076
|
+
$workResult = json_decode($workResultJson, true);
|
|
1077
|
+
$this->log('Verifying useful work solution.', [
|
|
1078
|
+
'problemId' => $problemId,
|
|
1079
|
+
'receivedData' => $workResult,
|
|
1080
|
+
'jsonLastError' => json_last_error_msg()
|
|
1081
|
+
]);
|
|
1082
|
+
if (json_last_error() === JSON_ERROR_NONE) {
|
|
1083
|
+
// @phpstan-ignore-next-line - L'instance est gérée par le singleton
|
|
1084
|
+
$defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
|
|
1085
|
+
$configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
|
|
1086
|
+
$problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
|
|
1087
|
+
// FIX: La solution est directement le $workResult, pas une sous-propriété.
|
|
1088
|
+
$problemManager->integrateSolution($problemId, $workResult);
|
|
1089
|
+
|
|
1090
|
+
// Si le problème résolu est l'auto-tuning de sécurité et que l'auto-tuning est activé,
|
|
1091
|
+
// on applique directement la meilleure solution calculée au moteur en direct.
|
|
1092
|
+
if ($problemId === 'security_auto_tuning' && ($this->securityConfig['autotuning']['enabled'] ?? false)) {
|
|
1093
|
+
$paretoFront = $workResult['paretoFront'] ?? null;
|
|
1094
|
+
if (is_array($paretoFront) && !empty($paretoFront)) {
|
|
1095
|
+
$bestSolution = $paretoFront[0];
|
|
1096
|
+
$minDistance = sqrt(pow((float)$bestSolution['objectives'][0], 2) + pow((float)$bestSolution['objectives'][1], 2));
|
|
1097
|
+
for ($i = 1; $i < count($paretoFront); $i++) {
|
|
1098
|
+
$distance = sqrt(pow((float)$paretoFront[$i]['objectives'][0], 2) + pow((float)$paretoFront[$i]['objectives'][1], 2));
|
|
1099
|
+
if ($distance < $minDistance) {
|
|
1100
|
+
$minDistance = $distance;
|
|
1101
|
+
$bestSolution = $paretoFront[$i];
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
if (isset($bestSolution['solution'])) {
|
|
1105
|
+
$this->updateConfig($bestSolution['solution']);
|
|
1106
|
+
$this->log('Useful Work auto-tuning applied successfully to live config.');
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
$isValid = true;
|
|
1112
|
+
// FIX: Générer un vrai ticket pour uPoW, comme pour un PoW normal.
|
|
1113
|
+
$ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000; // 1 heure par défaut
|
|
1114
|
+
$expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
|
|
1115
|
+
$signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", ChallengeUtils::getPowSecret());
|
|
1116
|
+
$ticket = "{$expiry}:{$signature}";
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if ($isValid) {
|
|
1123
|
+
$store->delete("secret:{$powNonce}");
|
|
1124
|
+
$ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
|
|
1125
|
+
MetricsManager::incrementCounter('challenges_solved_total');
|
|
1126
|
+
$this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
|
|
1127
|
+
|
|
1128
|
+
$isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
|
|
1129
|
+
(isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
|
|
1130
|
+
($context->getHeader('x-forwarded-proto') === 'https');
|
|
1131
|
+
$secureOption = $isHttps || $this->isProduction;
|
|
1132
|
+
|
|
1133
|
+
return [
|
|
1134
|
+
'action' => 'redirect',
|
|
1135
|
+
'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
|
|
1136
|
+
'score' => 0.0,
|
|
1137
|
+
'vector' => ['challenge_solved' => 100],
|
|
1138
|
+
'cookie' => [
|
|
1139
|
+
'name' => 'pow_clearance',
|
|
1140
|
+
'value' => $ticket,
|
|
1141
|
+
'options' => [
|
|
1142
|
+
'httponly' => true,
|
|
1143
|
+
'secure' => $secureOption,
|
|
1144
|
+
'samesite' => 'Strict',
|
|
1145
|
+
'expires' => time() + ($ticketTtl / 1000),
|
|
1146
|
+
'path' => '/',
|
|
1147
|
+
'partitioned' => $secureOption,
|
|
1148
|
+
]
|
|
1149
|
+
]
|
|
1150
|
+
];
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
// Si la solution est invalide ou le nonce est expiré, on pénalise fortement pour la suite.
|
|
1154
|
+
MetricsManager::incrementCounter('challenges_failed_total');
|
|
1155
|
+
$this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
|
|
1156
|
+
$suspicionVector['honeypotScore'] = 100.0;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// 3. Vérifier un ticket existant
|
|
1160
|
+
$hasValidTicket = false;
|
|
1161
|
+
$powCookie = $context->cookies['pow_clearance'] ?? null;
|
|
1162
|
+
$zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
|
|
1163
|
+
$deviceId = $context->cookies['device_id'] ?? '';
|
|
1164
|
+
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
1165
|
+
$allowRoaming = $this->securityConfig['allowCrossNetworkRoaming'] ?? false;
|
|
1166
|
+
if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof)) {
|
|
1167
|
+
$hasValidTicket = true;
|
|
1168
|
+
MetricsManager::incrementCounter('tickets_valid_total');
|
|
1169
|
+
// On ne retourne pas tout de suite pour permettre le re-challenge
|
|
1170
|
+
// $this->log('Valid clearance ticket found');
|
|
1171
|
+
// return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// 4. Calculer le vecteur et le score de suspicion
|
|
1175
|
+
// Résoudre l'identité et vérifier le statut "condamné"
|
|
1176
|
+
$store = StoreManager::getStore();
|
|
1177
|
+
$identity = $this->resolveRequestIdentity($context, $suspicionVector);
|
|
1178
|
+
$deviceId = $identity['deviceId'];
|
|
1179
|
+
$deviceData = $identity['deviceData'];
|
|
1180
|
+
if ($deviceData && ($deviceData['condemned'] ?? false)) {
|
|
1181
|
+
$this->log('Device condemned - blocking request', ['deviceId' => $deviceId], 'warn');
|
|
1182
|
+
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
|
|
1183
|
+
if ($this->dryRun) {
|
|
1184
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1185
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1186
|
+
$decision['action'] = 'next';
|
|
1187
|
+
unset($decision['status'], $decision['body']);
|
|
1188
|
+
}
|
|
1189
|
+
return $decision;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
$suspicionVector = $context->preCalculatedVector ?? $this->getSuspicionVector($context, $suspicionVector);
|
|
1193
|
+
$finalScore = $context->preCalculatedScore ?? $this->calculateFinalScore($suspicionVector);
|
|
1194
|
+
$this->log('Suspicion vector and final score calculated', [
|
|
1195
|
+
'finalScore' => round($finalScore, 2),
|
|
1196
|
+
'vector' => $suspicionVector
|
|
1197
|
+
]);
|
|
1198
|
+
|
|
1199
|
+
// Mettre à jour les métriques du sous-réseau après le calcul du score final
|
|
1200
|
+
if ($finalScore >= ($thresholds['medium'] ?? 45)) {
|
|
1201
|
+
RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
|
|
1202
|
+
MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
|
|
1206
|
+
$isNewDevice = $identity['newCookie'] !== null;
|
|
1207
|
+
if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
|
|
1208
|
+
$this->log('New device - enforcing minimum challenge score', [
|
|
1209
|
+
'originalScore' => round($finalScore, 2),
|
|
1210
|
+
'enforcedScore' => (float)$thresholds['low']
|
|
1211
|
+
]);
|
|
1212
|
+
$finalScore = (float)$thresholds['low'];
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Vérifier les URL pièges (après calcul du score)
|
|
1216
|
+
$lastNonce = $deviceData['lastChallengeNonce'] ?? null;
|
|
1217
|
+
if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
|
|
1218
|
+
if ($this->logger) {
|
|
1219
|
+
$this->logger->log('info', 'trap_triggered', ['deviceId' => $deviceId, 'score' => 100, 'path' => $context->path, 'vector' => ['honeypotScore' => 100]]);
|
|
1220
|
+
}
|
|
1221
|
+
$this->log('Honeypot trap URL triggered - condemning device', ['path' => $context->path, 'deviceId' => $deviceId]);
|
|
1222
|
+
$deviceData['condemned'] = true; // @phpstan-ignore-line
|
|
1223
|
+
$store->set("device:{$deviceId}", $deviceData);
|
|
1224
|
+
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
|
|
1225
|
+
if ($this->dryRun) {
|
|
1226
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1227
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1228
|
+
$decision['action'] = 'next';
|
|
1229
|
+
unset($decision['status'], $decision['body']);
|
|
1230
|
+
}
|
|
1231
|
+
return $decision;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// 5. Prendre une décision basée sur le score - Vérifier le blocage d'abord.
|
|
1235
|
+
$blockThreshold = $thresholds['block'] ?? 95;
|
|
1236
|
+
$isBlocked = $finalScore >= $blockThreshold;
|
|
1237
|
+
if ($isBlocked) {
|
|
1238
|
+
if ($this->logger) {
|
|
1239
|
+
MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
|
|
1240
|
+
$this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Federated Threat Intelligence & ZKP Synchronization
|
|
1244
|
+
$zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
|
|
1245
|
+
$parts = explode(':', $zkpProof);
|
|
1246
|
+
if (count($parts) === 3) {
|
|
1247
|
+
$zkpY = $parts[0];
|
|
1248
|
+
$zkpT = $parts[1];
|
|
1249
|
+
$zkpS = $parts[2];
|
|
1250
|
+
// 1. Valider cryptographiquement la preuve avant de bannir/diffuser
|
|
1251
|
+
if (ChallengeUtils::verifyZkpProof($zkpY, $zkpT, $zkpS)) {
|
|
1252
|
+
// 2. Dédoublonner : Ne diffuser que si la clé n'est pas déjà bannie
|
|
1253
|
+
$peersKey = "fed-peers:{$zkpY}";
|
|
1254
|
+
$reportedPeers = $store->get($peersKey) ?: [];
|
|
1255
|
+
if (!is_array($reportedPeers)) {
|
|
1256
|
+
$reportedPeers = [];
|
|
1257
|
+
}
|
|
1258
|
+
if (!in_array('local', $reportedPeers, true)) {
|
|
1259
|
+
$reportedPeers[] = 'local';
|
|
1260
|
+
$store->set($peersKey, $reportedPeers, 86400 * 30);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
$threshold = $this->securityConfig['federationConsensusThreshold'] ?? 3;
|
|
1264
|
+
if (count($reportedPeers) >= $threshold) {
|
|
1265
|
+
$store->set("banned-zkp-y:{$zkpY}", true, 86400 * 30);
|
|
1266
|
+
}
|
|
1267
|
+
$this->broadcastBannedZkp($zkpY);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
|
|
1272
|
+
if ($this->dryRun) {
|
|
1273
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1274
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1275
|
+
$decision['action'] = 'next';
|
|
1276
|
+
unset($decision['status'], $decision['body']);
|
|
1277
|
+
MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
|
|
1278
|
+
}
|
|
1279
|
+
$response = $decision;
|
|
1280
|
+
} else {
|
|
1281
|
+
// Logique de re-challenge
|
|
1282
|
+
// Si un nonce est présent mais que ce n'est pas une soumission de solution valide, c'est une sonde.
|
|
1283
|
+
if ($powNonce && !$isChallengeSubmission) {
|
|
1284
|
+
$this->log('Honeypot probe detected - blocking request', ['path' => $context->path, 'pow_nonce' => $powNonce]);
|
|
1285
|
+
$suspicionVector['honeypotScore'] = 100.0;
|
|
1286
|
+
$finalScore = $this->calculateFinalScore($suspicionVector); // Recalculate score
|
|
1287
|
+
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
|
|
1288
|
+
// Apply dry run logic here as well
|
|
1289
|
+
if ($this->dryRun) {
|
|
1290
|
+
MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
|
|
1291
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1292
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1293
|
+
$decision['action'] = 'next';
|
|
1294
|
+
unset($decision['status'], $decision['body']);
|
|
1295
|
+
}
|
|
1296
|
+
return $decision;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
$highThreshold = $thresholds['high'] ?? 75;
|
|
1300
|
+
$mustReChallenge = $finalScore >= $highThreshold && $hasValidTicket && $finalScore > 0 && !$isBlocked;
|
|
1301
|
+
|
|
1302
|
+
$lowThreshold = $thresholds['low'] ?? 20;
|
|
1303
|
+
if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
|
|
1304
|
+
if ($mustReChallenge) {
|
|
1305
|
+
$this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// --- AJOUT: Limiteur de débit (Token Bucket) ---
|
|
1309
|
+
$rateLimitPassed = ChallengeUtils::checkChallengeRateLimit($context->clientIp);
|
|
1310
|
+
if (!$rateLimitPassed) {
|
|
1311
|
+
$this->log('Challenge rate limit exceeded - blocking with 429', ['clientIp' => $context->clientIp]);
|
|
1312
|
+
$decision = [
|
|
1313
|
+
'action' => 'block',
|
|
1314
|
+
'status' => 429,
|
|
1315
|
+
'body' => 'Too Many Requests',
|
|
1316
|
+
'score' => $finalScore,
|
|
1317
|
+
'vector' => $suspicionVector
|
|
1318
|
+
];
|
|
1319
|
+
if ($this->dryRun) {
|
|
1320
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1321
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1322
|
+
$decision['action'] = 'next';
|
|
1323
|
+
unset($decision['status'], $decision['body']);
|
|
1324
|
+
}
|
|
1325
|
+
return $decision;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
$decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
|
|
1329
|
+
|
|
1330
|
+
if ($this->dryRun) {
|
|
1331
|
+
MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_challenge']);
|
|
1332
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1333
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1334
|
+
$decision['action'] = 'next';
|
|
1335
|
+
unset($decision['status']);
|
|
1336
|
+
return $decision;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
MetricsManager::incrementCounter('requests_total', ['status' => 'challenged']);
|
|
1340
|
+
$this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
|
|
1341
|
+
|
|
1342
|
+
$nonce = bin2hex(random_bytes(16));
|
|
1343
|
+
$clientSecret = bin2hex(random_bytes(16));
|
|
1344
|
+
$highThreshold = $thresholds['high'] ?? 75;
|
|
1345
|
+
|
|
1346
|
+
$suspicionFactor = ($finalScore - $lowThreshold) / (($thresholds['high'] ?? 75) - $lowThreshold);
|
|
1347
|
+
$suspicionFactor = max(0, min(1.5, $suspicionFactor));
|
|
1348
|
+
|
|
1349
|
+
// --- NOUVELLE LOGIQUE uPoW ---
|
|
1350
|
+
$shouldUseUsefulWork = ($this->securityConfig['enableUsefulWork'] ?? false) && (
|
|
1351
|
+
($this->securityConfig['forceUsefulWork'] ?? false) || (random_int(0, 255) / 255) > 0.5
|
|
1352
|
+
);
|
|
1353
|
+
|
|
1354
|
+
// Déterminer si c'est une requête API avant de choisir le type de challenge
|
|
1355
|
+
$isApiRequest = false;
|
|
1356
|
+
if (isset($this->securityConfig['isApiRequest']) && is_callable($this->securityConfig['isApiRequest'])) {
|
|
1357
|
+
$isApiRequest = ($this->securityConfig['isApiRequest'])($context);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
if ($shouldUseUsefulWork) {
|
|
1361
|
+
$this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
|
|
1362
|
+
$defaultPath = dirname(__DIR__, 1) . '/config/problems.config.json';
|
|
1363
|
+
$configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
|
|
1364
|
+
$problemManager = ProblemManager::getInstance($configPath, $store);
|
|
1365
|
+
$work = $problemManager->dispatchWork($finalScore);
|
|
1366
|
+
|
|
1367
|
+
if ($work !== null) {
|
|
1368
|
+
$store->set("secret:{$nonce}", ['clientSecret' => $clientSecret, 'originalPath' => $context->path], 300);
|
|
1369
|
+
$challengePayload = [
|
|
1370
|
+
'challenge' => [
|
|
1371
|
+
'type' => 'useful_work_task',
|
|
1372
|
+
'nonce' => $nonce,
|
|
1373
|
+
'clientSecret' => $clientSecret,
|
|
1374
|
+
'usefulWorkTask' => [
|
|
1375
|
+
'problemId' => $work['problemId'],
|
|
1376
|
+
'task' => $work['task']
|
|
1377
|
+
]
|
|
1378
|
+
]
|
|
1379
|
+
];
|
|
1380
|
+
if ($isApiRequest) {
|
|
1381
|
+
$decision['body'] = $challengePayload;
|
|
1382
|
+
return $decision;
|
|
1383
|
+
} else {
|
|
1384
|
+
$html = '<html><body><script>';
|
|
1385
|
+
$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}));';
|
|
1386
|
+
$html .= '</script></body></html>';
|
|
1387
|
+
$decision['body'] = $html;
|
|
1388
|
+
return $decision;
|
|
1389
|
+
}
|
|
1390
|
+
} else {
|
|
1391
|
+
// This case handles when uPoW is enabled but dispatching a task fails (e.g., config not found).
|
|
1392
|
+
// We log it and fall through to the standard PoW challenge.
|
|
1393
|
+
$this->log('Useful work dispatch failed, falling back to standard PoW.', [], 'warn');
|
|
1394
|
+
$shouldUseUsefulWork = false; // Explicitly disable for this request
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// --- FIN DE LA LOGIQUE uPoW (le reste est le fallback) ---
|
|
1399
|
+
|
|
1400
|
+
if ($this->securityConfig['enableProofOfSpace'] ?? false) {
|
|
1401
|
+
$spaceChallenge = ChallengeUtils::generateSpaceChallenge($context->clientIp, $nonce, $suspicionFactor, $context->path, $this->securityConfig);
|
|
1402
|
+
$store->set("secret:{$nonce}", [
|
|
1403
|
+
'clientSecret' => $clientSecret,
|
|
1404
|
+
'suspicionScore' => $finalScore,
|
|
1405
|
+
'queries' => $spaceChallenge['queries'],
|
|
1406
|
+
'sizeMb' => $spaceChallenge['sizeMb'],
|
|
1407
|
+
'fingerprint' => RequestUtils::getCompositeDeviceHash($context),
|
|
1408
|
+
'originalPath' => $context->path,
|
|
1409
|
+
], $this->securityConfig['challengeTtl'] ?? 300);
|
|
1410
|
+
|
|
1411
|
+
if ($deviceData) {
|
|
1412
|
+
$deviceData['lastChallengeNonce'] = $nonce;
|
|
1413
|
+
$store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if ($isApiRequest) {
|
|
1417
|
+
$decision['body'] = [
|
|
1418
|
+
'challenge' => [
|
|
1419
|
+
'type' => 'pospace',
|
|
1420
|
+
'nonce' => $nonce,
|
|
1421
|
+
'clientSecret' => $clientSecret,
|
|
1422
|
+
'queries' => $spaceChallenge['queries'],
|
|
1423
|
+
'sizeMb' => $spaceChallenge['sizeMb'],
|
|
1424
|
+
]
|
|
1425
|
+
];
|
|
1426
|
+
} else {
|
|
1427
|
+
$page = ChallengeUtils::generateSpaceChallengePage($spaceChallenge, $clientSecret, $this->securityConfig);
|
|
1428
|
+
$decision['body'] = $page;
|
|
1429
|
+
}
|
|
1430
|
+
return $decision;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
$cpuChallengeDetails = [
|
|
1434
|
+
'type' => 'cpu_target',
|
|
1435
|
+
'nonce' => $nonce,
|
|
1436
|
+
'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
|
|
1437
|
+
'path' => $context->path,
|
|
1438
|
+
];
|
|
1439
|
+
|
|
1440
|
+
// Alignement linéaire parfait du ratio d'effort CPU/Mémoire
|
|
1441
|
+
$memActivationFactor = $suspicionFactor;
|
|
1442
|
+
$memDifficulty = (int)round($memActivationFactor * 48); // 0 à 48MB
|
|
1443
|
+
|
|
1444
|
+
$originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
|
|
1445
|
+
$tlsSessionId = $context->tlsSessionId ?? '';
|
|
1446
|
+
$baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint, $context->clientIp, $tlsSessionId);
|
|
1447
|
+
|
|
1448
|
+
$challengeContext = [
|
|
1449
|
+
'clientSecret' => $clientSecret,
|
|
1450
|
+
'cpuTarget' => $cpuChallengeDetails['target'],
|
|
1451
|
+
'suspicionScore' => $finalScore,
|
|
1452
|
+
'fingerprint' => $originalFingerprint,
|
|
1453
|
+
'memDifficulty' => $memDifficulty,
|
|
1454
|
+
'baseBlock' => $baseBlock,
|
|
1455
|
+
'originalPath' => $context->path,
|
|
1456
|
+
];
|
|
1457
|
+
|
|
1458
|
+
$store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
|
|
1459
|
+
|
|
1460
|
+
// Associer le nonce au device pour la vérification des URL pièges
|
|
1461
|
+
if ($deviceData) {
|
|
1462
|
+
$deviceData['lastChallengeNonce'] = $nonce;
|
|
1463
|
+
$store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
$trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
|
|
1467
|
+
$this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
|
|
1468
|
+
|
|
1469
|
+
if ($this->logger) {
|
|
1470
|
+
$this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Pour les API, retourner un challenge JSON
|
|
1474
|
+
if ($isApiRequest) {
|
|
1475
|
+
$challengePayload = [
|
|
1476
|
+
'challenge' => [
|
|
1477
|
+
'type' => 'cpu_mem',
|
|
1478
|
+
'nonce' => $nonce,
|
|
1479
|
+
'clientSecret' => $clientSecret,
|
|
1480
|
+
'cpuTarget' => $cpuChallengeDetails['target'],
|
|
1481
|
+
'memDifficulty' => $memDifficulty,
|
|
1482
|
+
'baseBlock' => array_values(unpack('C*', $baseBlock)), // Envoyer comme un tableau d'octets
|
|
1483
|
+
]
|
|
1484
|
+
];
|
|
1485
|
+
$decision['body'] = $challengePayload;
|
|
1486
|
+
} else {
|
|
1487
|
+
// Pour les navigateurs, retourner une page HTML
|
|
1488
|
+
$pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
|
|
1489
|
+
$cpuChallengeDetails, $memDifficulty, $clientSecret,
|
|
1490
|
+
$this->securityConfig, $trapUrls, $originalFingerprint
|
|
1491
|
+
);
|
|
1492
|
+
$decision['body'] = $pageBody;
|
|
1493
|
+
}
|
|
1494
|
+
$response = $decision;
|
|
1495
|
+
} elseif ($hasValidTicket) {
|
|
1496
|
+
// Si on arrive ici avec un ticket valide et un score bas, on autorise
|
|
1497
|
+
MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
|
|
1498
|
+
$this->log('Valid clearance ticket found and score is low - allowing request');
|
|
1499
|
+
$response = ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100], 'intendedAction' => 'next'];
|
|
1500
|
+
} else {
|
|
1501
|
+
// 6. Si le score est bas et qu'il n'y a pas de ticket, autoriser la requête
|
|
1502
|
+
MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
|
|
1503
|
+
$this->log('Request passed - no challenge required', ['finalScore' => $finalScore]);
|
|
1504
|
+
if ($this->logger) {
|
|
1505
|
+
MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'passed']);
|
|
1506
|
+
$this->logger->log('info', 'request_passed', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
|
|
1507
|
+
}
|
|
1508
|
+
$response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Si un nouveau cookie d'identification a été généré, on l'ajoute à la réponse.
|
|
1513
|
+
if (isset($context->newCookieForResponse)) {
|
|
1514
|
+
$response['newCookieForResponse'] = $context->newCookieForResponse;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
return $response;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Vérifie si l'opération GraphQL correspond à une entrée dans la liste blanche.
|
|
1522
|
+
*/
|
|
1523
|
+
private function isGraphqlOperationInAllowlist(?string $operationType, ?string $operationName): bool
|
|
1524
|
+
{
|
|
1525
|
+
if (empty($operationType) || empty($operationName)) {
|
|
1526
|
+
return false;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
1530
|
+
$graphqlRule = null;
|
|
1531
|
+
foreach ($whitelistRules as $rule) {
|
|
1532
|
+
if (($rule['type'] ?? '') === 'graphql_operation_allowlist') {
|
|
1533
|
+
$graphqlRule = $rule;
|
|
1534
|
+
break;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
if (empty($graphqlRule['entries'])) {
|
|
1539
|
+
return false;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
foreach ($graphqlRule['entries'] as $entry) {
|
|
1543
|
+
[$entryType, $entryName] = explode(':', $entry, 2);
|
|
1544
|
+
if ($entryType !== $operationType) continue;
|
|
1545
|
+
|
|
1546
|
+
if ($entryName === $operationName || $entryName === '*') return true;
|
|
1547
|
+
|
|
1548
|
+
if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
return false;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
private function broadcastBannedZkp(string $zkpY): void
|
|
1556
|
+
{
|
|
1557
|
+
$peers = $this->securityConfig['federatedPeers'] ?? [];
|
|
1558
|
+
if (empty($peers)) return;
|
|
1559
|
+
|
|
1560
|
+
$timestamp = (int)(microtime(true) * 1000);
|
|
1561
|
+
$msg = "{$timestamp}:{$zkpY}";
|
|
1562
|
+
|
|
1563
|
+
$signature = '';
|
|
1564
|
+
$isAsymmetric = false;
|
|
1565
|
+
|
|
1566
|
+
$privateKey = Env::get('ED25519_PRIVATE_KEY');
|
|
1567
|
+
if ($privateKey) {
|
|
1568
|
+
try {
|
|
1569
|
+
$cleanKey = str_replace('\n', "\n", $privateKey);
|
|
1570
|
+
$pkeyObj = openssl_pkey_get_private($cleanKey);
|
|
1571
|
+
if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
|
|
1572
|
+
$signature = bin2hex($sigBytes);
|
|
1573
|
+
$isAsymmetric = true;
|
|
1574
|
+
}
|
|
1575
|
+
} catch (\Throwable $e) {
|
|
1576
|
+
error_log('[Fingerprint] Asymmetric broadcast signing failed: ' . $e->getMessage());
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
if (!$isAsymmetric) {
|
|
1581
|
+
$secret = $this->securityConfig['federationSecret'] ?? ChallengeUtils::getPowSecret();
|
|
1582
|
+
$signature = hash_hmac('sha256', $msg, $secret);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
foreach ($peers as $peerUrl) {
|
|
1586
|
+
$this->asyncPost($peerUrl . '?coop_op=share_threat_intel', [
|
|
1587
|
+
'zkpY' => $zkpY,
|
|
1588
|
+
'signature' => $isAsymmetric ? '' : $signature,
|
|
1589
|
+
'signature_ed25519' => $isAsymmetric ? $signature : '',
|
|
1590
|
+
'timestamp' => $timestamp
|
|
1591
|
+
]);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
private function asyncPost(string $url, array $params): void
|
|
1596
|
+
{
|
|
1597
|
+
$parts = parse_url($url);
|
|
1598
|
+
if ($parts === false) return;
|
|
1599
|
+
|
|
1600
|
+
$host = $parts['host'];
|
|
1601
|
+
$port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
|
|
1602
|
+
$path = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : '');
|
|
1603
|
+
$scheme = $parts['scheme'] === 'https' ? 'ssl://' : '';
|
|
1604
|
+
|
|
1605
|
+
$postData = json_encode($params);
|
|
1606
|
+
|
|
1607
|
+
$fp = @stream_socket_client(
|
|
1608
|
+
"{$scheme}{$host}:{$port}",
|
|
1609
|
+
$errno,
|
|
1610
|
+
$errstr,
|
|
1611
|
+
0.5,
|
|
1612
|
+
STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
|
|
1613
|
+
);
|
|
1614
|
+
|
|
1615
|
+
if ($fp) {
|
|
1616
|
+
stream_set_blocking($fp, false);
|
|
1617
|
+
$out = "POST {$path} HTTP/1.1\r\n";
|
|
1618
|
+
$out .= "Host: {$host}\r\n";
|
|
1619
|
+
$out .= "Content-Type: application/json\r\n";
|
|
1620
|
+
$out .= "Content-Length: " . strlen($postData) . "\r\n";
|
|
1621
|
+
$out .= "X-Federation-Signature: " . ($params['signature'] ?? '') . "\r\n";
|
|
1622
|
+
$out .= "X-Federation-Signature-Ed25519: " . ($params['signature_ed25519'] ?? '') . "\r\n";
|
|
1623
|
+
$out .= "X-Federation-Timestamp: " . ($params['timestamp'] ?? '') . "\r\n";
|
|
1624
|
+
$out .= "Connection: Close\r\n\r\n";
|
|
1625
|
+
$out .= $postData;
|
|
1626
|
+
|
|
1627
|
+
@fwrite($fp, $out);
|
|
1628
|
+
@fclose($fp);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
|
|
1634
|
+
* en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
|
|
1635
|
+
*/
|
|
1636
|
+
private function verifyWhitelistedBot(RequestContext $context): bool
|
|
1637
|
+
{
|
|
1638
|
+
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
1639
|
+
$botRules = array_filter($whitelistRules, fn($rule) => isset($rule['hostnameSuffix']));
|
|
1640
|
+
if (empty($botRules)) {
|
|
1641
|
+
return false;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
$userAgent = $context->getHeader('user-agent') ?? '';
|
|
1645
|
+
$matchedRule = null;
|
|
1646
|
+
foreach ($botRules as $rule) {
|
|
1647
|
+
if (isset($rule['userAgent']) && preg_match('/' . $rule['userAgent'] . '/', $userAgent)) {
|
|
1648
|
+
$matchedRule = $rule;
|
|
1649
|
+
break;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
if ($matchedRule === null) {
|
|
1653
|
+
return false;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
$store = StoreManager::getStore();
|
|
1657
|
+
$cacheKey = "ip-whitelist:{$context->clientIp}";
|
|
1658
|
+
$cachedStatus = $store->get($cacheKey);
|
|
1659
|
+
|
|
1660
|
+
if ($cachedStatus === 'verified') return true;
|
|
1661
|
+
if ($cachedStatus === 'failed') return false;
|
|
1662
|
+
|
|
1663
|
+
if (!self::canAttemptDns()) {
|
|
1664
|
+
return false;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
try {
|
|
1668
|
+
// 1. Reverse DNS lookup with strict 500ms timeout using our custom UDP DNS Client
|
|
1669
|
+
$revName = self::getReverseDnsName($context->client_ip);
|
|
1670
|
+
if (!$revName) {
|
|
1671
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
$hostnames = self::dnsResolveUdp($revName, 'PTR', 0.5);
|
|
1675
|
+
if ($hostnames === null || empty($hostnames)) {
|
|
1676
|
+
self::recordDnsFailure();
|
|
1677
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1678
|
+
return false;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
$validHostname = null;
|
|
1682
|
+
foreach ($hostnames as $hostname) {
|
|
1683
|
+
if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
|
|
1684
|
+
$validHostname = $hostname;
|
|
1685
|
+
break;
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
if ($validHostname === null) {
|
|
1690
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// 2. Forward DNS lookup with strict 500ms timeout
|
|
1695
|
+
$ips = [];
|
|
1696
|
+
$resolvedA = self::dnsResolveUdp($validHostname, 'A', 0.5);
|
|
1697
|
+
if ($resolvedA === null) {
|
|
1698
|
+
self::recordDnsFailure();
|
|
1699
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1700
|
+
return false;
|
|
1701
|
+
}
|
|
1702
|
+
$ips = array_merge($ips, $resolvedA);
|
|
1703
|
+
|
|
1704
|
+
$resolvedAaaa = self::dnsResolveUdp($validHostname, 'AAAA', 0.5);
|
|
1705
|
+
if ($resolvedAaaa === null) {
|
|
1706
|
+
self::recordDnsFailure();
|
|
1707
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
$ips = array_merge($ips, $resolvedAaaa);
|
|
1711
|
+
|
|
1712
|
+
if (in_array($context->client_ip, $ips, true)) {
|
|
1713
|
+
self::recordDnsSuccess();
|
|
1714
|
+
$store->set($cacheKey, 'verified', 86400);
|
|
1715
|
+
return true;
|
|
1716
|
+
}
|
|
1717
|
+
} catch (\Exception $e) {
|
|
1718
|
+
self::recordDnsFailure();
|
|
1719
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1720
|
+
return false;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1724
|
+
return false;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* @internal For testing purposes only.
|
|
1729
|
+
*/
|
|
1730
|
+
public function getProblems(): array
|
|
1731
|
+
{
|
|
1732
|
+
$problemManager = ProblemManager::getInstance();
|
|
1733
|
+
return $problemManager->getProblems();
|
|
1734
|
+
}
|
|
1735
|
+
private static function decodeCBOR(string $str, int &$offset = 0)
|
|
1736
|
+
{
|
|
1737
|
+
if ($offset >= strlen($str)) throw new \Exception("End of CBOR");
|
|
1738
|
+
$initial = ord($str[$offset++]);
|
|
1739
|
+
$major = $initial >> 5;
|
|
1740
|
+
$val = $initial & 0x1f;
|
|
1741
|
+
|
|
1742
|
+
$readInt = function (int $val, string $str, int &$offset) {
|
|
1743
|
+
if ($val < 24) return $val;
|
|
1744
|
+
if ($val === 24) return ord($str[$offset++]);
|
|
1745
|
+
if ($val === 25) {
|
|
1746
|
+
$b1 = ord($str[$offset++]); $b2 = ord($str[$offset++]);
|
|
1747
|
+
return ($b1 << 8) | $b2;
|
|
1748
|
+
}
|
|
1749
|
+
if ($val === 26) {
|
|
1750
|
+
$b1 = ord($str[$offset++]); $b2 = ord($str[$offset++]);
|
|
1751
|
+
$b3 = ord($str[$offset++]); $b4 = ord($str[$offset++]);
|
|
1752
|
+
return ($b1 << 24) | ($b2 << 16) | ($b3 << 8) | $b4;
|
|
1753
|
+
}
|
|
1754
|
+
throw new \Exception("Unsupported integer size: " . $val);
|
|
1755
|
+
};
|
|
1756
|
+
|
|
1757
|
+
if ($major === 0) {
|
|
1758
|
+
return $readInt($val, $str, $offset);
|
|
1759
|
+
} elseif ($major === 1) {
|
|
1760
|
+
return -1 - $readInt($val, $str, $offset);
|
|
1761
|
+
} elseif ($major === 2 || $major === 3) {
|
|
1762
|
+
$len = $readInt($val, $str, $offset);
|
|
1763
|
+
$bytes = substr($str, $offset, $len);
|
|
1764
|
+
$offset += $len;
|
|
1765
|
+
return $bytes;
|
|
1766
|
+
} elseif ($major === 4) {
|
|
1767
|
+
$len = $readInt($val, $str, $offset);
|
|
1768
|
+
$arr = [];
|
|
1769
|
+
for ($i = 0; $i < $len; $i++) {
|
|
1770
|
+
$arr[] = self::decodeCBOR($str, $offset);
|
|
1771
|
+
}
|
|
1772
|
+
return $arr;
|
|
1773
|
+
} elseif ($major === 5) {
|
|
1774
|
+
$len = $readInt($val, $str, $offset);
|
|
1775
|
+
$map = [];
|
|
1776
|
+
for ($i = 0; $i < $len; $i++) {
|
|
1777
|
+
$k = self::decodeCBOR($str, $offset);
|
|
1778
|
+
$v = self::decodeCBOR($str, $offset);
|
|
1779
|
+
$map[$k] = $v;
|
|
1780
|
+
}
|
|
1781
|
+
return $map;
|
|
1782
|
+
}
|
|
1783
|
+
return null;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
private static function getTrustedHardwareRoots(): array
|
|
1787
|
+
{
|
|
1788
|
+
return [
|
|
1789
|
+
"-----BEGIN CERTIFICATE-----\n" .
|
|
1790
|
+
"MIIDHzCCAfegAwIBAgIJANCvWjvF+2O6MA0GCSqGSIb3DQEBCwUAMC0xKzApBgNV\n" .
|
|
1791
|
+
"BAMTIll1YmljbyBBdHRlc3RhdGlvbiBSb290IENBMB4XDTE0MDgwNDAwMDAwMFox\n" .
|
|
1792
|
+
"TSUxSDBGBgNVBAMMT1l1YmljbyBBdHRlc3RhdGlvbiBSb290IENBMSowKAYDVQQK\n" .
|
|
1793
|
+
"EyFZdWJpY28gQUIxDzANBgNVBAcTBVN0b2NraG9sbTELMAkGA1UEBhMCU0UwggEi\n" .
|
|
1794
|
+
"MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6XW0d87g+N6kGgSgC/H9UfA2p\n" .
|
|
1795
|
+
"-----END CERTIFICATE-----",
|
|
1796
|
+
"-----BEGIN CERTIFICATE-----\n" .
|
|
1797
|
+
"MIIB1DCCAXWgAwIBAgIEUI70WjAKBggqhkjOPQQDAjArMSkwJwYDVQQDEyBGSURP\n" .
|
|
1798
|
+
"IEFsbGlhbmNlIFJvb3QgQ0EgKFRlc3QpMB4XDTE0MDgxODA4MzA0NVoXDTM5MDgx\n" .
|
|
1799
|
+
"ODA4MzA0NVowKzEpMCcGA1UEAxMgRklETyBBbGxpYW5jZSBSb290IENBIChUZXN0\n" .
|
|
1800
|
+
"KTB2MBAGByqGSM49AgEGBSuBBAAiA2IABFv81Jm9M7AehfOIdpCH567gP0yqS40m\n" +
|
|
1801
|
+
"aN0j1a8n152G7n/nUf7J0j9F4pL9J2w1X8hN1N8f9Y3G9w8L29/m7/zX3O3n2e7/\n" .
|
|
1802
|
+
"g==\n" .
|
|
1803
|
+
"-----END CERTIFICATE-----"
|
|
1804
|
+
];
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
private function verifyWebAuthnHardwareAnchor(array $anchor, array &$deviceData): bool
|
|
1808
|
+
{
|
|
1809
|
+
if (empty($anchor['type'])) return false;
|
|
1810
|
+
|
|
1811
|
+
try {
|
|
1812
|
+
$clientDataHash = hash('sha256', base64_decode($anchor['clientDataJSON']), true);
|
|
1813
|
+
|
|
1814
|
+
if ($anchor['type'] === 'registration') {
|
|
1815
|
+
if (empty($anchor['publicKey']) || empty($anchor['credentialId']) || empty($anchor['attestationObject'])) {
|
|
1816
|
+
return false;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
$attestationBytes = base64_decode($anchor['attestationObject']);
|
|
1820
|
+
$offset = 0;
|
|
1821
|
+
$decoded = self::decodeCBOR($attestationBytes, $offset);
|
|
1822
|
+
|
|
1823
|
+
if (!$decoded || empty($decoded['fmt']) || empty($decoded['attStmt'])) {
|
|
1824
|
+
return false;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
$fmt = $decoded['fmt'];
|
|
1828
|
+
$attStmt = $decoded['attStmt'];
|
|
1829
|
+
|
|
1830
|
+
if ($fmt !== 'none') {
|
|
1831
|
+
if (empty($attStmt['x5c']) || !is_array($attStmt['x5c'])) {
|
|
1832
|
+
return false;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
$pems = [];
|
|
1836
|
+
foreach ($attStmt['x5c'] as $der) {
|
|
1837
|
+
$pems[] = "-----BEGIN CERTIFICATE-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END CERTIFICATE-----";
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
for ($i = 0; $i < count($pems) - 1; $i++) {
|
|
1841
|
+
if (openssl_x509_verify($pems[$i], $pems[$i + 1]) !== 1) {
|
|
1842
|
+
return false;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
$rootPem = $pems[count($pems) - 1];
|
|
1847
|
+
$trusted = false;
|
|
1848
|
+
$trustedRoots = self::getTrustedHardwareRoots();
|
|
1849
|
+
foreach ($trustedRoots as $trustedRootPem) {
|
|
1850
|
+
if (openssl_x509_verify($rootPem, $trustedRootPem) === 1 || md5($rootPem) === md5($trustedRootPem)) {
|
|
1851
|
+
$trusted = true;
|
|
1852
|
+
break;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
if (!$trusted) {
|
|
1856
|
+
return false;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
$deviceData['webauthnPublicKey'] = $anchor['publicKey'];
|
|
1861
|
+
$deviceData['webauthnCredentialId'] = $anchor['credentialId'];
|
|
1862
|
+
return true;
|
|
1863
|
+
} elseif ($anchor['type'] === 'assertion') {
|
|
1864
|
+
$storedPublicKeyPem = $deviceData['webauthnPublicKey'] ?? null;
|
|
1865
|
+
if (!$storedPublicKeyPem || ($deviceData['webauthnCredentialId'] ?? '') !== $anchor['credentialId']) {
|
|
1866
|
+
return false;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
$verifyBuffer = base64_decode($anchor['authenticatorData']) . $clientDataHash;
|
|
1870
|
+
$publicKey = openssl_pkey_get_public("-----BEGIN PUBLIC KEY-----\n" . chunk_split($storedPublicKeyPem, 64, "\n") . "-----END PUBLIC KEY-----");
|
|
1871
|
+
if (!$publicKey) return false;
|
|
1872
|
+
|
|
1873
|
+
return openssl_verify($verifyBuffer, base64_decode($anchor['signature']), $publicKey, OPENSSL_ALGO_SHA256) === 1;
|
|
1874
|
+
}
|
|
1875
|
+
} catch (\Throwable $e) {
|
|
1876
|
+
error_log('[WebAuthn-Server] PHP verification failed: ' . $e->getMessage());
|
|
1877
|
+
}
|
|
1878
|
+
return false;
|
|
1879
|
+
}
|
|
1880
|
+
}
|