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