@anonympins/fingerprint 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +276 -34
  3. package/composer.json +38 -0
  4. package/index.js +5 -0
  5. package/package.json +23 -18
  6. package/phpunit.xml +20 -0
  7. package/public/fp.js +2 -0
  8. package/src/js/build-client.js +69 -0
  9. package/{fingerprint.builder.js → src/js/fingerprint.builder.js} +168 -175
  10. package/{fingerprint.client.js → src/js/fingerprint.client.js} +634 -538
  11. package/src/js/fingerprint.client.obfuscated.js +1 -0
  12. package/{fingerprint.js → src/js/fingerprint.js} +255 -101
  13. package/{library.js → src/js/library.js} +1729 -1729
  14. package/{problem-manager.js → src/js/problem-manager.js} +539 -522
  15. package/src/php/AutoTuner.php +155 -0
  16. package/src/php/Challenge/ChallengeUtils.php +306 -0
  17. package/src/php/Config/SecurityProfiles.php +257 -0
  18. package/src/php/DirectFingerprint.php +81 -0
  19. package/src/php/FingerprintBuilder.php +185 -0
  20. package/src/php/FingerprintClient.php +118 -0
  21. package/src/php/FingerprintEngine.php +850 -0
  22. package/src/php/Optimization/FunctionRegistry.php +63 -0
  23. package/src/php/Optimization/Optimization.php +256 -0
  24. package/src/php/Optimization/OptimizationOperators.php +305 -0
  25. package/src/php/Optimization/ProblemInitializers.php +53 -0
  26. package/src/php/ProblemManager.php +255 -0
  27. package/src/php/RequestContext.php +87 -0
  28. package/src/php/Store/IStore.php +42 -0
  29. package/src/php/Store/InMemoryStore.php +67 -0
  30. package/src/php/Store/StoreManager.php +26 -0
  31. package/src/php/Tests/ChallengeUtilsTest.php +82 -0
  32. package/src/php/Tests/FingerprintBuilderTest.php +58 -0
  33. package/src/php/Tests/FingerprintEngineTest.php +219 -0
  34. package/src/php/Tests/PowTest.php +40 -0
  35. package/src/php/Tests/ProblemManagerTest.php +295 -0
  36. package/src/php/Tests/RequestUtilsTest.php +81 -0
  37. package/src/php/Tests/problems.config.json +9 -0
  38. package/src/php/Utils/BigInt.php +102 -0
  39. package/src/php/Utils/BlockList.php +100 -0
  40. package/src/php/Utils/Logger.php +30 -0
  41. package/src/php/Utils/MaliciousPatterns.php +59 -0
  42. package/src/php/Utils/RequestUtils.php +673 -0
  43. package/fingerprint.client.obfuscated.js +0 -1
  44. /package/{mongodb-store.js → src/js/mongodb-store.js} +0 -0
  45. /package/{optimization.worker.js → src/js/optimization.worker.js} +0 -0
  46. /package/{pow.solver.inline.js → src/js/pow.solver.inline.js} +0 -0
  47. /package/{pow.solver.js → src/js/pow.solver.js} +0 -0
  48. /package/{pow.worker.js → src/js/pow.worker.js} +0 -0
  49. /package/{redis-store.js → src/js/redis-store.js} +0 -0
  50. /package/{sql-store.js → src/js/sql-store.js} +0 -0
@@ -0,0 +1,673 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Utils;
6
+
7
+ use Anonympins\Fingerprint\FingerprintBuilder;
8
+ use Anonympins\Fingerprint\Optimization\Optimization;
9
+ use Anonympins\Fingerprint\RequestContext;
10
+
11
+ /**
12
+ * Classe utilitaire pour l'analyse des requêtes et le calcul des scores de suspicion.
13
+ */
14
+ class RequestUtils
15
+ {
16
+ /**
17
+ * Base de données de signatures JA3 connues.
18
+ * @var array<string, string|string[]>
19
+ */
20
+ private const TLS_FINGERPRINT_DB = [
21
+ // --- Chrome (Desktop) ---
22
+ 'e188a442b87f422c5a1e80b05399435b' => 'Chrome',
23
+ 'd8e35855049321c6042a4325c697858f' => 'Chrome',
24
+ 'a9f90958d44533748c139a5d1895b925' => 'Chrome',
25
+ '3b5379916d2b3882253c42885956a350' => 'Chrome',
26
+ // --- Chrome (Mobile) ---
27
+ '59822058c95c33d2d06e52f410855c8c' => 'Chrome',
28
+ // --- Firefox (Desktop) ---
29
+ 'b386946a5a586163c7c533636b45c355' => 'Firefox',
30
+ '66236495a523c1785f8f3a105b248b11' => 'Firefox',
31
+ 'b73d470006575b5e35167a0b5a8540e2' => 'Firefox',
32
+ '8443d7562933834333943465d52363cf' => 'Firefox',
33
+ // --- Firefox (Mobile) ---
34
+ '02720628957d38c6111a18433abe833f' => 'Firefox',
35
+ // --- Safari & iOS (Shared TLS Stack) ---
36
+ 'b633f21d532d35967c8753c38536b4d3' => 'Safari',
37
+ '4d7a28d5f55b359b69100a311013f03e' => ['Safari', 'Chrome', 'Firefox'],
38
+ '8dd3d7532873575314df23c447543001' => ['Safari', 'Chrome', 'Firefox'],
39
+ // --- Common Libraries & Bots ---
40
+ '47344a349b75c4e82333475553b5f358' => 'Python',
41
+ 'b29587b8a143c42546133ad7704b3310' => 'Go',
42
+ 'd435b5223b2884c5a832b842637e245f' => 'Java',
43
+ 'c72366b9551263d990b7fa574225332c' => 'curl',
44
+ ];
45
+
46
+ /**
47
+ * Crée un hash composite stable basé sur les caractéristiques de la requête.
48
+ */
49
+ public static function getCompositeDeviceHash(RequestContext $context): string
50
+ {
51
+ $srv = new FingerprintBuilder();
52
+
53
+ $clientFp = $context->getHeader('x-device-fingerprint');
54
+ if ($clientFp && str_contains($clientFp, 'cvs:')) {
55
+ $srv->add("client_fp_hash", $clientFp);
56
+ }
57
+
58
+ $ua = $context->getHeader("user-agent");
59
+ if ($ua) {
60
+ $srv->add("ua", $ua);
61
+ }
62
+
63
+ if ($context->ja3) $srv->add("ja3", $context->ja3);
64
+ if ($context->ja4) $srv->add("ja4", $context->ja4);
65
+ if ($context->http2Fingerprint) $srv->add("h2", $context->http2Fingerprint);
66
+ if ($context->tcpFingerprint) $srv->add("tcp", $context->tcpFingerprint);
67
+
68
+ $headersToCapture = [
69
+ "ch_ua" => "sec-ch-ua",
70
+ "ch_platform" => "sec-ch-ua-platform",
71
+ "ch_mobile" => "sec-ch-ua-mobile",
72
+ "ch_model" => "sec-ch-ua-model",
73
+ "ch_arch" => "sec-ch-ua-arch",
74
+ "ch_bitness" => "sec-ch-ua-bitness",
75
+ "upgrade_req" => "upgrade-insecure-requests",
76
+ "accept_lang" => "accept-language",
77
+ "accept_enc" => "accept-encoding",
78
+ "accept" => "accept"
79
+ ];
80
+
81
+ foreach ($headersToCapture as $key => $headerName) {
82
+ $headerValue = $context->getHeader($headerName);
83
+ if ($headerValue) {
84
+ $srv->add($key, $headerValue);
85
+ }
86
+ }
87
+
88
+ if ($context->httpVersion) {
89
+ $srv->add("http_ver", $context->httpVersion);
90
+ }
91
+ if (!empty($context->cookies)) {
92
+ $cookieKeys = array_keys($context->cookies);
93
+ sort($cookieKeys);
94
+ $srv->add("cookie_keys", implode(',', $cookieKeys));
95
+ }
96
+
97
+ return (string)$srv;
98
+ }
99
+
100
+ /**
101
+ * Calcule un score d'incohérence entre la signature TLS (JA3) et le User-Agent.
102
+ * @return array{'tlsSpoofingScore': float}
103
+ */
104
+ public static function getTlsSpoofingScore(RequestContext $context): array
105
+ {
106
+ $ja3 = $context->ja3;
107
+ $ua = $context->getHeader('user-agent') ?? '';
108
+
109
+ if ($ja3 && (empty($ua) || strlen($ua) < 10 || stripos($ua, 'python') !== false || stripos($ua, 'curl') !== false)) {
110
+ return ['tlsSpoofingScore' => 50.0];
111
+ }
112
+
113
+ if ($ja3 && !empty($ua) && isset(self::TLS_FINGERPRINT_DB[$ja3])) {
114
+ $expectedBrowsers = self::TLS_FINGERPRINT_DB[$ja3];
115
+ if (!is_array($expectedBrowsers)) {
116
+ $expectedBrowsers = [$expectedBrowsers];
117
+ }
118
+
119
+ $claimedBrowserInfo = self::parseUserAgent($ua);
120
+ $claimedBrowser = $claimedBrowserInfo['browser'] ?? null;
121
+
122
+ if ($claimedBrowser) {
123
+ $isMatch = false;
124
+ foreach ($expectedBrowsers as $expected) {
125
+ if (str_starts_with($claimedBrowser, $expected)) {
126
+ $isMatch = true;
127
+ break;
128
+ }
129
+ }
130
+ if (!$isMatch) {
131
+ return ['tlsSpoofingScore' => 80.0];
132
+ }
133
+ }
134
+ }
135
+
136
+ return ['tlsSpoofingScore' => 0.0];
137
+ }
138
+
139
+ /**
140
+ * Calcule un score basé sur les anomalies des en-têtes HTTP.
141
+ * @return array{'headerAnomalyScore': float}
142
+ */
143
+ public static function getHeaderAnomalies(RequestContext $context): array
144
+ {
145
+ $anomalyScore = 0;
146
+ $ua = $context->getHeader('user-agent');
147
+ if (empty($ua) || strlen($ua) < 10) {
148
+ $anomalyScore += 60;
149
+ }
150
+ if (!$context->getHeader('accept-language')) {
151
+ $anomalyScore += 25;
152
+ }
153
+ if ($context->httpVersion === '1.0') {
154
+ $anomalyScore += 15;
155
+ }
156
+
157
+ return ['headerAnomalyScore' => min(100.0, $anomalyScore)];
158
+ }
159
+
160
+ /**
161
+ * Calcule un score basé sur la détection de marqueurs d'automatisation.
162
+ * @return array{'botScore': float}
163
+ */
164
+ public static function getBotScore(RequestContext $context): array
165
+ {
166
+ $clientFpString = $context->getHeader('x-device-fingerprint');
167
+ if (!$clientFpString) {
168
+ return ['botScore' => 0.0];
169
+ }
170
+
171
+ // Une simple vérification par chaîne est suffisante et performante.
172
+ if (str_contains($clientFpString, 'bot:true') || str_contains($clientFpString, 'cdp:true')) {
173
+ return ['botScore' => 100.0];
174
+ }
175
+
176
+ return ['botScore' => 0.0];
177
+ }
178
+
179
+ /**
180
+ * @private
181
+ * Analyse une série de mouvements de souris pour en extraire des métriques comportementales.
182
+ * @param array<int, array{x: int, y: int, t: float}>|null $history
183
+ * @return array{avgSpeed: float, avgAcceleration: float, straightness: float, pauses: int, segments: array<float>}
184
+ */
185
+ private static function analyzeMouseMovements(?array $history): array
186
+ {
187
+ if (empty($history) || count($history) < 3) {
188
+ return ['avgSpeed' => 0, 'avgAcceleration' => 0, 'straightness' => 1, 'pauses' => 0, 'segments' => []];
189
+ }
190
+
191
+ $segments = [];
192
+ $totalDistance = 0.0;
193
+ $pauses = 0;
194
+
195
+ for ($i = 1; $i < count($history); $i++) {
196
+ $p1 = $history[$i - 1];
197
+ $p2 = $history[$i];
198
+ $dx = $p2['x'] - $p1['x'];
199
+ $dy = $p2['y'] - $p1['y'];
200
+ $dt = $p2['t'] - $p1['t'];
201
+ $distance = sqrt($dx * $dx + $dy * $dy);
202
+
203
+ if ($dt > 0) {
204
+ $speed = $distance / $dt;
205
+ $segments[] = ['distance' => $distance, 'dt' => $dt, 'speed' => $speed];
206
+ $totalDistance += $distance;
207
+ }
208
+ if ($dt > 100 && $distance < 5) {
209
+ $pauses++;
210
+ }
211
+ }
212
+
213
+ if (count($segments) < 2) {
214
+ return ['avgSpeed' => 0, 'avgAcceleration' => 0, 'straightness' => 1, 'pauses' => $pauses, 'segments' => []];
215
+ }
216
+
217
+ $totalTime = $history[count($history) - 1]['t'] - $history[0]['t'];
218
+ $avgSpeed = $totalTime > 0 ? array_sum(array_column($segments, 'speed')) / count($segments) : 0;
219
+
220
+ $totalAbsAcceleration = 0.0;
221
+ for ($i = 1; $i < count($segments); $i++) {
222
+ $s1 = $segments[$i - 1];
223
+ $s2 = $segments[$i];
224
+ if ($s2['dt'] > 0) {
225
+ $acceleration = ($s2['speed'] - $s1['speed']) / $s2['dt'];
226
+ $totalAbsAcceleration += abs($acceleration);
227
+ }
228
+ }
229
+ $avgAcceleration = $totalAbsAcceleration / (count($segments) - 1);
230
+
231
+ $startPoint = $history[0];
232
+ $endPoint = $history[count($history) - 1];
233
+ $straightDistance = sqrt(pow($endPoint['x'] - $startPoint['x'], 2) + pow($endPoint['y'] - $startPoint['y'], 2));
234
+ $straightness = $totalDistance > 0 ? $straightDistance / $totalDistance : 1;
235
+
236
+ return ['avgSpeed' => $avgSpeed, 'avgAcceleration' => $avgAcceleration, 'straightness' => $straightness, 'pauses' => $pauses, 'segments' => array_column($segments, 'distance')];
237
+ }
238
+
239
+
240
+ /**
241
+ * Calcule un score basé sur les métriques comportementales envoyées par le client.
242
+ * @return array{'behaviorScore': float}
243
+ */
244
+ public static function getBehaviorScore(RequestContext $context): array
245
+ {
246
+ $behaviorHeader = $context->getHeader('x-behavior-metrics');
247
+ if (!$behaviorHeader) {
248
+ return ['behaviorScore' => 0.0];
249
+ }
250
+
251
+ $metrics = json_decode($behaviorHeader, true);
252
+ if (json_last_error() !== JSON_ERROR_NONE) {
253
+ return ['behaviorScore' => 10.0]; // En-tête malformé
254
+ }
255
+
256
+ if (!empty($metrics['honeypotInteraction'])) {
257
+ return ['behaviorScore' => 100.0];
258
+ }
259
+
260
+ $score = 0.0;
261
+
262
+ $mouseAnalysis = self::analyzeMouseMovements($metrics['mouseMovementsHistory'] ?? null);
263
+
264
+ if (isset($metrics['historyLength'])) {
265
+ if ($metrics['historyLength'] === 1) $score += 15;
266
+ elseif ($metrics['historyLength'] >= 5) $score -= 20;
267
+ elseif ($metrics['historyLength'] >= 2) $score -= 10;
268
+ } else {
269
+ // Pénalité pour absence totale d'interaction si l'historique n'est pas dispo
270
+ if ($mouseAnalysis['avgSpeed'] == 0 && ($metrics['keystrokeLatency'] ?? 0) == 0) {
271
+ $score += 40;
272
+ }
273
+ }
274
+
275
+ if ($mouseAnalysis['avgSpeed'] > 0) {
276
+ if ($mouseAnalysis['avgSpeed'] > 3) $score += 25;
277
+ if ($mouseAnalysis['avgAcceleration'] > 0.5) $score += 20;
278
+ if ($mouseAnalysis['straightness'] > 0.95) $score += 30;
279
+ if ($mouseAnalysis['pauses'] === 0 && count($mouseAnalysis['segments']) > 20) $score += 15;
280
+ }
281
+
282
+ if (($metrics['keystrokeLatency'] ?? 0) > 0 && $metrics['keystrokeLatency'] < 40) $score += 25;
283
+ if (($metrics['keystrokeLatency'] ?? 0) > 1000) $score += 15;
284
+
285
+ // Analyse de Benford sur les segments de mouvement de la souris
286
+ if (count($mouseAnalysis['segments']) > 10) {
287
+ $benfordDeviation = Optimization::benfordTest($mouseAnalysis['segments']);
288
+ if ($benfordDeviation > 0.18) {
289
+ $score += 35;
290
+ }
291
+ }
292
+
293
+ return ['behaviorScore' => min(100.0, $score)];
294
+ }
295
+
296
+ /**
297
+ * Analyse basique d'un User-Agent.
298
+ * @return array{browser?: string, os?: string, device?: string}
299
+ */
300
+ private static function parseUserAgent(string $ua): array
301
+ {
302
+ $result = [];
303
+
304
+ if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
305
+ $result['browser'] = 'Chrome';
306
+ } elseif (str_contains($ua, 'Firefox')) {
307
+ $result['browser'] = 'Firefox';
308
+ } elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
309
+ $result['browser'] = 'Safari';
310
+ } elseif (str_contains($ua, 'Edg')) {
311
+ $result['browser'] = 'Edge';
312
+ }
313
+
314
+ if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows';
315
+ elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
316
+ elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
317
+ elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
318
+ elseif (str_contains($ua, 'Linux')) $result['os'] = 'Linux';
319
+
320
+ if (str_contains($ua, 'Mobile')) $result['device'] = 'mobile';
321
+ else $result['device'] = 'desktop';
322
+
323
+ return $result;
324
+ }
325
+
326
+ /**
327
+ * Calcule un score d'incohérence temporelle.
328
+ * @return array{'timeInconsistencyScore': float}
329
+ * @param RequestContext $context
330
+ * @param array|null $metrics
331
+ */
332
+ public static function getTimeInconsistencyScore(RequestContext $context, ?array $metrics = null): array
333
+ {
334
+ if ($metrics === null) {
335
+ $behaviorHeader = $context->getHeader('x-behavior-metrics');
336
+ if (!$behaviorHeader) return ['timeInconsistencyScore' => 0.0];
337
+ $metrics = json_decode($behaviorHeader, true);
338
+ }
339
+
340
+ if (!is_array($metrics) || empty($metrics['clientTimestamp'])) {
341
+ return ['timeInconsistencyScore' => 0.0];
342
+ }
343
+
344
+ $timeDelta = $context->requestTimestamp - $metrics['clientTimestamp'];
345
+ $replayThreshold = 5000; // 5 secondes
346
+
347
+ $score = ($timeDelta > $replayThreshold) ? min(100.0, ($timeDelta / $replayThreshold - 1) * 50) : 0.0;
348
+ return ['timeInconsistencyScore' => $score];
349
+ }
350
+
351
+ /**
352
+ * Calcule un score d'incohérence entre les couches client et serveur.
353
+ * @return array{'crossLayerInconsistencyScore': float}
354
+ */
355
+ public static function getCrossLayerInconsistency(RequestContext $context): array
356
+ {
357
+ $clientFpString = $context->getHeader('x-device-fingerprint');
358
+ if (!$clientFpString) return ['crossLayerInconsistencyScore' => 0.0];
359
+
360
+ $clientFpMap = [];
361
+ foreach (explode('|', $clientFpString) as $part) {
362
+ $pair = explode(':', $part, 2);
363
+ if (count($pair) === 2) $clientFpMap[$pair[0]] = $pair[1];
364
+ }
365
+
366
+ $ua = $context->getHeader('user-agent') ?? '';
367
+ $score = 0;
368
+
369
+ $clientOsHash = $clientFpMap['os'] ?? null;
370
+ if ($clientOsHash) {
371
+ $serverOsParts = self::parseUserAgent($ua);
372
+ if (!empty($serverOsParts['os']) && $clientOsHash !== FingerprintBuilder::cyrb53($serverOsParts['os'])) {
373
+ $score += 50;
374
+ }
375
+ }
376
+
377
+ return ['crossLayerInconsistencyScore' => min(100.0, $score)];
378
+ }
379
+
380
+ /**
381
+ * Calcule les indicateurs comportementaux liés à l'historique de l'appareil.
382
+ * @param array<string, mixed> $deviceData
383
+ * @return array{'historyScore': float, 'rotationScore': float}
384
+ */
385
+ public static function getBehavioralIndicators(RequestContext $context, array &$deviceData): array
386
+ {
387
+ $now = time() * 1000;
388
+ $clientIp = $context->clientIp;
389
+ $currentFpHash = self::getCompositeDeviceHash($context);
390
+
391
+ // Analyse de la fréquence de changement du fingerprint
392
+ $rapidChangeThresholdMs = 2000; // 2 secondes
393
+ $maxRapidChanges = 3;
394
+
395
+ if (isset($deviceData['lastFpHash']) && $currentFpHash !== $deviceData['lastFpHash']) {
396
+ $timeSinceLastChange = $now - ($deviceData['lastChangeTimestamp'] ?? 0);
397
+ if ($timeSinceLastChange < $rapidChangeThresholdMs) {
398
+ $deviceData['rapidChangeCount'] = ($deviceData['rapidChangeCount'] ?? 0) + 1;
399
+ } else {
400
+ $deviceData['rapidChangeCount'] = max(0, ($deviceData['rapidChangeCount'] ?? 0) - 1);
401
+ }
402
+ $deviceData['lastChangeTimestamp'] = $now;
403
+ }
404
+ $deviceData['lastFpHash'] = $currentFpHash;
405
+
406
+ // Enregistrement de l'IP
407
+ if (!in_array($clientIp, $deviceData['ips'])) {
408
+ $deviceData['ips'][] = $clientIp;
409
+ }
410
+
411
+ // Score d'historique basé sur le nombre d'IPs utilisées (rotation de proxy)
412
+ $maxIpsPerDevice = 15;
413
+ $freeIpChanges = 3;
414
+ $historyScore = min(100.0, (max(0, count($deviceData['ips']) - $freeIpChanges) / $maxIpsPerDevice) * 100);
415
+ // Score de rotation basé sur les changements rapides de fingerprint
416
+ $rotationScore = min(100.0, (($deviceData['rapidChangeCount'] ?? 0) / $maxRapidChanges) * 100);
417
+
418
+ return ['historyScore' => $historyScore, 'rotationScore' => $rotationScore];
419
+ }
420
+
421
+ /**
422
+ * Analyse les patterns de requêtes pour détecter les comportements de bot.
423
+ * @param array<string, mixed> $deviceData
424
+ * @param array<string, mixed> $patternConfig
425
+ * @return array{'requestPatternScore': float}
426
+ */
427
+ public static function getRequestPatternScore(RequestContext $context, array &$deviceData, array $patternConfig): array
428
+ {
429
+ // Configuration avec valeurs par défaut robustes
430
+ $historySize = $patternConfig['historySize'] ?? 20;
431
+ $minSamples = $patternConfig['minSamples'] ?? 10;
432
+ $regularityThreshold = $patternConfig['regularityThreshold'] ?? 150; // ms
433
+ $benfordThreshold = $patternConfig['benfordThreshold'] ?? 0.15;
434
+ $patternWeight = $patternConfig['patternWeight'] ?? 80;
435
+ $decayFactor = $patternConfig['decayFactor'] ?? 0.95;
436
+ $inactivityReset = $patternConfig['inactivityReset'] ?? 180000;
437
+
438
+ $now = time() * 1000;
439
+ $history = $deviceData['requestHistory'] ?? [];
440
+ $deviceData['timingHistory'] = $deviceData['timingHistory'] ?? [];
441
+
442
+ $lastRequest = end($history) ?: null;
443
+ $timeSinceLast = $lastRequest ? $now - $lastRequest['timestamp'] : PHP_INT_MAX;
444
+
445
+ // Mise à jour de l'historique
446
+ $history[] = ['timestamp' => $now, 'path' => $context->path];
447
+ if ($lastRequest) {
448
+ $deviceData['timingHistory'][] = $timeSinceLast;
449
+ }
450
+
451
+ if (count($history) > $historySize) {
452
+ array_shift($history);
453
+ }
454
+ if (count($deviceData['timingHistory']) > $historySize) {
455
+ array_shift($deviceData['timingHistory']);
456
+ }
457
+ $deviceData['requestHistory'] = $history;
458
+
459
+ $instantScore = 0;
460
+ $timings = $deviceData['timingHistory'];
461
+
462
+ // Analyse statistique si nous avons assez de données
463
+ if (count($timings) >= $minSamples) {
464
+ // FIX: Éviter la division par zéro si le tableau est vide, bien que count() >= minSamples devrait déjà le prévenir.
465
+ if (count($timings) === 0) {
466
+ return ['requestPatternScore' => min(100.0, $deviceData['lastPatternScore'] ?? 0)];
467
+ }
468
+
469
+ $mean = array_sum($timings) / count($timings); // @phpstan-ignore-line
470
+ $variance = array_reduce($timings, fn($carry, $item) => $carry + pow($item - $mean, 2), 0) / count($timings); // @phpstan-ignore-line
471
+ $stdDev = sqrt($variance);
472
+ $benfordDeviation = Optimization::benfordTest($timings);
473
+
474
+ // Détection de régularité (bots de type "cron")
475
+ if ($stdDev < $regularityThreshold) {
476
+ $instantScore = $patternWeight;
477
+ }
478
+ // Détection de distribution non-naturelle (bots "faussement aléatoires")
479
+ elseif ($benfordDeviation > $benfordThreshold) {
480
+ $instantScore = $patternWeight;
481
+ }
482
+ }
483
+
484
+ // Logique de décroissance et de score final
485
+ $newPatternScore = $deviceData['lastPatternScore'] ?? 0;
486
+
487
+ if ($timeSinceLast > $inactivityReset) {
488
+ $newPatternScore = 0; // Réinitialisation après inactivité
489
+ } else {
490
+ $newPatternScore *= $decayFactor;
491
+ }
492
+ $newPatternScore = max(0, $newPatternScore);
493
+
494
+ $deviceData['lastPatternScore'] = $newPatternScore + $instantScore;
495
+
496
+ return ['requestPatternScore' => min(100.0, $deviceData['lastPatternScore'])];
497
+ }
498
+
499
+ /**
500
+ * Vérifie la soumission de champs honeypot.
501
+ * @param array<string, mixed> $honeypotConfig
502
+ * @return array{'honeypotScore': float}
503
+ */
504
+ public static function getHoneypotScore(RequestContext $context, array $honeypotConfig): array // @phpstan-ignore-line
505
+ {
506
+ $fields = $honeypotConfig['fields'] ?? [];
507
+ $trapUrls = $honeypotConfig['trapUrls'] ?? [];
508
+ $data = array_merge($context->query, is_array($context->body) ? $context->body : []);
509
+
510
+ // 1. Vérifier les champs de formulaire pièges
511
+ foreach ($fields as $field) {
512
+ // Ignorer les paramètres de solution de challenge pour éviter les faux positifs.
513
+ if (str_starts_with($field, 'pow_')) {
514
+ continue;
515
+ }
516
+ if (!empty($data[$field])) {
517
+ return ['honeypotScore' => 100.0];
518
+ }
519
+ }
520
+
521
+ // 2. Vérifier l'accès aux URL pièges
522
+ foreach ($trapUrls as $trap) {
523
+ if (str_starts_with($context->path, $trap)) {
524
+ return ['honeypotScore' => 100.0];
525
+ }
526
+ }
527
+
528
+ // 3. (Optionnel) Détection d'injections
529
+ if ($honeypotConfig['detectInjections'] ?? false) {
530
+ $typesToDetect = is_array($honeypotConfig['detectInjections']) ? $honeypotConfig['detectInjections'] : [];
531
+ foreach ($data as $value) {
532
+ if (is_string($value) && MaliciousPatterns::isMalicious($value, $typesToDetect)) {
533
+ return ['honeypotScore' => 100.0];
534
+ }
535
+ }
536
+ }
537
+
538
+ return ['honeypotScore' => 0.0];
539
+ }
540
+
541
+ public static function getThreatIntelScore(RequestContext $context, array $threatIntelConfig): array
542
+ {
543
+ return ['threatIntelScore' => 0.0];
544
+ }
545
+
546
+ /**
547
+ * @private
548
+ * Analyzes click positions to detect unnaturally low variance.
549
+ * @param array<int, array{x: int, y: int, targetId: string}>|null $history
550
+ * @return float
551
+ */
552
+ private static function analyzeClickPositions(?array $history): float
553
+ {
554
+ if (empty($history) || count($history) < 3) {
555
+ return 0.0;
556
+ }
557
+
558
+ $clicksByTarget = [];
559
+ foreach ($history as $click) {
560
+ if (empty($click['targetId'])) continue;
561
+ if (!isset($clicksByTarget[$click['targetId']])) {
562
+ $clicksByTarget[$click['targetId']] = [];
563
+ }
564
+ $clicksByTarget[$click['targetId']][] = $click;
565
+ }
566
+
567
+ $maxScore = 0.0;
568
+
569
+ foreach ($clicksByTarget as $clicks) {
570
+ if (count($clicks) < 3) continue;
571
+
572
+ $n = count($clicks);
573
+ $meanX = array_sum(array_column($clicks, 'x')) / $n;
574
+ $meanY = array_sum(array_column($clicks, 'y')) / $n;
575
+
576
+ $variance = array_reduce($clicks, function ($sum, $c) use ($meanX, $meanY) {
577
+ return $sum + pow($c['x'] - $meanX, 2) + pow($c['y'] - $meanY, 2);
578
+ }, 0) / $n;
579
+
580
+ if ($variance < 1.0) {
581
+ $score = (1 - sqrt($variance) / 5) * 100;
582
+ if ($score > $maxScore) {
583
+ $maxScore = $score;
584
+ }
585
+ }
586
+ }
587
+
588
+ return min(100.0, $maxScore);
589
+ }
590
+
591
+ /**
592
+ * Calculates a score based on click variance metrics sent by the client.
593
+ * @return array{'clickVarianceScore': float}
594
+ */
595
+ public static function getClickVarianceScore(RequestContext $context): array
596
+ {
597
+ $behaviorHeader = $context->getHeader('x-behavior-metrics');
598
+ if (!$behaviorHeader) {
599
+ return ['clickVarianceScore' => 0.0];
600
+ }
601
+ $metrics = json_decode($behaviorHeader, true);
602
+ if (json_last_error() !== JSON_ERROR_NONE) {
603
+ return ['clickVarianceScore' => 0.0];
604
+ }
605
+ $score = self::analyzeClickPositions($metrics['clicksHistory'] ?? null);
606
+ return ['clickVarianceScore' => $score];
607
+ }
608
+
609
+ /**
610
+ * Parse une chaîne de requête GraphQL pour extraire le type et le nom de l'opération.
611
+ * @param array<string, mixed> $body Le corps de la requête.
612
+ * @return array{type: string, name: string}|null
613
+ */
614
+ public static function parseGraphQLQuery(array $body): ?array
615
+ {
616
+ $query = $body['query'] ?? null;
617
+ if (!is_string($query)) {
618
+ return null;
619
+ }
620
+
621
+ // Regex pour capturer le type d'opération et le nom optionnel.
622
+ if (preg_match('/(?:^|\s)(query|mutation|subscription)\s*([_A-Za-z][_0-9A-Za-z]*)?/', $query, $matches)) {
623
+ return [
624
+ 'type' => $matches[1],
625
+ 'name' => $matches[2] ?? 'Anonymous',
626
+ ];
627
+ }
628
+ return null;
629
+ }
630
+ /**
631
+ * Nettoie une URL de tous les paramètres de requête liés au PoW.
632
+ * @param string $originalPath Le chemin original, potentiellement avec des query params.
633
+ * @param array<string, mixed> $incomingQuery Le tableau de la query string de la requête entrante.
634
+ * @return string Le chemin final nettoyé.
635
+ */
636
+ public static function cleanUrlFromPowParams(string $originalPath, array $incomingQuery): string
637
+ {
638
+ $urlParts = parse_url($originalPath);
639
+ $path = $urlParts['path'] ?? '/';
640
+ $finalQuery = $incomingQuery;
641
+
642
+ $powParams = [
643
+ 'pow_type', 'pow_nonce', 'pow_solution', 'pow_solution_cpu',
644
+ 'pow_solution_mem', 'pow_fp', 'pow_solution_population',
645
+ 'pow_solution_work_result', 'pow_problem_id'
646
+ ];
647
+
648
+ foreach ($powParams as $param) {
649
+ unset($finalQuery[$param]);
650
+ }
651
+
652
+ if (!empty($finalQuery)) {
653
+ return $path . '?' . http_build_query($finalQuery);
654
+ }
655
+ return $path;
656
+ }
657
+
658
+ /**
659
+ * Vérifie si un host et un path de requête correspondent à une entrée de liste blanche.
660
+ */
661
+ public static function hostPathMatches(string $requestHost, string $requestPath, string $entry): bool
662
+ {
663
+ $firstSlashIndex = strpos($entry, '/');
664
+ if ($firstSlashIndex === false) return false;
665
+
666
+ $hostPattern = substr($entry, 0, $firstSlashIndex);
667
+ $pathPattern = substr($entry, $firstSlashIndex);
668
+
669
+ if ($requestHost !== $hostPattern) return false;
670
+
671
+ return self::pathMatches($requestPath, $pathPattern);
672
+ }
673
+ }