@anonympins/fingerprint 0.4.3 → 0.4.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.
@@ -1,54 +1,54 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- namespace Anonympins\Fingerprint\Store;
6
-
7
- /**
8
- * Adaptateur de stockage Redis pour le moteur Fingerprint.
9
- * Compatible avec phpredis et predis.
10
- */
11
- class RedisStore implements IStore
12
- {
13
- /**
14
- * @var mixed Une instance de \Redis ou de \Predis\Client
15
- */
16
- private $redis;
17
-
18
- /**
19
- * @param mixed $redis Client Redis déjà configuré et connecté
20
- */
21
- public function __construct($redis)
22
- {
23
- $this->redis = $redis;
24
- }
25
-
26
- public function get(string $key)
27
- {
28
- $value = $this->redis->get($key);
29
- if ($value === false || $value === null) {
30
- return null;
31
- }
32
- return json_decode($value, true);
33
- }
34
-
35
- public function set(string $key, $value, ?int $ttl = null): void
36
- {
37
- $stringValue = json_encode($value);
38
- if ($ttl !== null && $ttl > 0) {
39
- $this->redis->setex($key, $ttl, $stringValue);
40
- } else {
41
- $this->redis->set($key, $stringValue);
42
- }
43
- }
44
-
45
- public function has(string $key): bool
46
- {
47
- return (bool)$this->redis->exists($key);
48
- }
49
-
50
- public function delete(string $key): void
51
- {
52
- $this->redis->del($key);
53
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Store;
6
+
7
+ /**
8
+ * Adaptateur de stockage Redis pour le moteur Fingerprint.
9
+ * Compatible avec phpredis et predis.
10
+ */
11
+ class RedisStore implements IStore
12
+ {
13
+ /**
14
+ * @var mixed Une instance de \Redis ou de \Predis\Client
15
+ */
16
+ private $redis;
17
+
18
+ /**
19
+ * @param mixed $redis Client Redis déjà configuré et connecté
20
+ */
21
+ public function __construct($redis)
22
+ {
23
+ $this->redis = $redis;
24
+ }
25
+
26
+ public function get(string $key)
27
+ {
28
+ $value = $this->redis->get($key);
29
+ if ($value === false || $value === null) {
30
+ return null;
31
+ }
32
+ return json_decode($value, true);
33
+ }
34
+
35
+ public function set(string $key, $value, ?int $ttl = null): void
36
+ {
37
+ $stringValue = json_encode($value);
38
+ if ($ttl !== null && $ttl > 0) {
39
+ $this->redis->setex($key, $ttl, $stringValue);
40
+ } else {
41
+ $this->redis->set($key, $stringValue);
42
+ }
43
+ }
44
+
45
+ public function has(string $key): bool
46
+ {
47
+ return (bool)$this->redis->exists($key);
48
+ }
49
+
50
+ public function delete(string $key): void
51
+ {
52
+ $this->redis->del($key);
53
+ }
54
54
  }
@@ -11,6 +11,12 @@ use PHPUnit\Framework\TestCase;
11
11
 
12
12
  class RequestUtilsTest extends TestCase
13
13
  {
14
+ protected function setUp(): void
15
+ {
16
+ parent::setUp();
17
+ \Anonympins\Fingerprint\Store\StoreManager::configureStore(new \Anonympins\Fingerprint\Store\InMemoryStore());
18
+ }
19
+
14
20
  private function createRequestContext(array $overrides = []): RequestContext
15
21
  {
16
22
  $defaults = [
@@ -166,6 +172,42 @@ class RequestUtilsTest extends TestCase
166
172
  $this->assertEquals(40.0, $scoreNoActivity['behaviorScore']);
167
173
  }
168
174
 
175
+ public function testGetBehaviorScoreWithBotLikeTouchMovements(): void
176
+ {
177
+ $metrics = [
178
+ 'honeypotInteraction' => false,
179
+ 'touchMovementsHistory' => [
180
+ ['x' => 50, 'y' => 50, 't' => 1, 'p' => 0.5, 'r' => 10, 'num' => 1],
181
+ ['x' => 55, 'y' => 55, 't' => 100, 'p' => 0.5, 'r' => 10, 'num' => 1],
182
+ ['x' => 60, 'y' => 60, 't' => 200, 'p' => 0.5, 'r' => 10, 'num' => 1],
183
+ ['x' => 65, 'y' => 65, 't' => 300, 'p' => 0.5, 'r' => 10, 'num' => 1]
184
+ ]
185
+ ];
186
+ $context = $this->createRequestContext([
187
+ 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
188
+ ]);
189
+ $score = RequestUtils::getBehaviorScore($context);
190
+ $this->assertGreaterThan(60.0, $score['behaviorScore']);
191
+ }
192
+
193
+ public function testGetBehaviorScoreWithHumanLikeTouchMovements(): void
194
+ {
195
+ $metrics = [
196
+ 'honeypotInteraction' => false,
197
+ 'touchMovementsHistory' => [
198
+ ['x' => 50, 'y' => 50, 't' => 1, 'p' => 0.45, 'r' => 8.5, 'num' => 1],
199
+ ['x' => 60, 'y' => 52, 't' => 100, 'p' => 0.52, 'r' => 9.1, 'num' => 1],
200
+ ['x' => 72, 'y' => 60, 't' => 200, 'p' => 0.49, 'r' => 8.8, 'num' => 1],
201
+ ['x' => 80, 'y' => 80, 't' => 300, 'p' => 0.41, 'r' => 8.2, 'num' => 1]
202
+ ]
203
+ ];
204
+ $context = $this->createRequestContext([
205
+ 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
206
+ ]);
207
+ $score = RequestUtils::getBehaviorScore($context);
208
+ $this->assertLessThan(30.0, $score['behaviorScore']);
209
+ }
210
+
169
211
  public function testGetTimeInconsistencyScore(): void
170
212
  {
171
213
  $requestTimestamp = time() * 1000;
@@ -254,4 +296,92 @@ class RequestUtilsTest extends TestCase
254
296
  $this->assertEquals(2, $deviceData['rapidChangeCount']);
255
297
  $this->assertGreaterThan(0, $indicators['rotationScore']);
256
298
  }
299
+
300
+ public function testGetRequestPatternScoreWeightedSubscores(): void
301
+ {
302
+ $context = $this->createRequestContext(['path' => '/search']);
303
+ $deviceData = [
304
+ 'requestHistory' => [],
305
+ 'timingHistory' => [100, 100, 100, 100, 100, 100], // stdDev = 0
306
+ 'lastPatternScore' => 0.0
307
+ ];
308
+ $patternConfig = [
309
+ 'minSamples' => 5,
310
+ 'regularityThreshold' => 50,
311
+ 'benfordThreshold' => 0.15,
312
+ 'patternWeight' => 80,
313
+ 'decayFactor' => 0.9,
314
+ 'inactivityReset' => 5000,
315
+ 'regularityRatio' => 0.4,
316
+ 'benfordRatio' => 0.3,
317
+ 'enumerationRatio' => 0.3
318
+ ];
319
+
320
+ $result = RequestUtils::getRequestPatternScore($context, $deviceData, $patternConfig);
321
+ // regularityScore = 1.0. regularityRatio = 0.4.
322
+ // instantScore = 1.0 * 0.4 * 80 = 32.
323
+ $this->assertEquals(32.0, $result['requestPatternScore']);
324
+ }
325
+
326
+ public function testGetBotnetClusterScoreCalculations(): void
327
+ {
328
+ $stableFpHash = 'test-stable-hash';
329
+
330
+ $context1 = $this->createRequestContext(['clientIp' => '192.168.1.1']);
331
+ $score = RequestUtils::getBotnetClusterScore($context1, $stableFpHash);
332
+ $this->assertEquals(0.0, $score['botnetClusterScore']);
333
+
334
+ RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.2']), $stableFpHash);
335
+ $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.3']), $stableFpHash);
336
+ $this->assertEquals(50.3, $score['botnetClusterScore']);
337
+
338
+ RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.4']), $stableFpHash);
339
+ $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.5']), $stableFpHash);
340
+ $this->assertEquals(75.3, $score['botnetClusterScore']);
341
+
342
+ for ($i = 6; $i <= 10; $i++) {
343
+ $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => "192.168.1.{$i}"]), $stableFpHash);
344
+ }
345
+ $this->assertEquals(95.7, $score['botnetClusterScore']);
346
+ }
347
+
348
+ public function testRealWorldConsoleBotnetClustering(): void
349
+ {
350
+ $ps4Headers = [
351
+ 'user-agent' => 'Mozilla/5.0 (PlayStation 4 11.50) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.50 Safari/605.1.15',
352
+ 'x-ja3-hash' => '76993ef93bf89104037599723ab9f201',
353
+ 'x-ja4-hash' => 't13d1516h2_8daaf6152771_390237aa04be',
354
+ 'x-http2-fingerprint' => '1:65536;3:1000;4:6291456;6:65536',
355
+ 'x-tcp-fingerprint' => '64240:128:1:mss,nop,ws,nop,nop,sok:df:0'
356
+ ];
357
+
358
+ for ($i = 1; $i <= 10; $i++) {
359
+ $context = $this->createRequestContext([
360
+ 'clientIp' => "185.15.20.{$i}",
361
+ 'headers' => array_merge($ps4Headers, [
362
+ 'cookie_keys' => "session_id=fake_sess_{$i}"
363
+ ])
364
+ ]);
365
+
366
+ $currentHash = RequestUtils::getCompositeDeviceHash($context);
367
+ $stableFp = RequestUtils::extractStablePart($currentHash);
368
+ $stableFpHash = FingerprintBuilder::cyrb53($stableFp);
369
+
370
+ $score = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
371
+
372
+ if ($i === 1) {
373
+ $this->assertEquals(0.0, $score['botnetClusterScore']);
374
+ } elseif ($i === 2) {
375
+ $this->assertEquals(29.5, $score['botnetClusterScore']);
376
+ } elseif ($i === 3) {
377
+ $this->assertEquals(50.3, $score['botnetClusterScore']);
378
+ } elseif ($i === 4) {
379
+ $this->assertEquals(65.0, $score['botnetClusterScore']);
380
+ } elseif ($i === 5) {
381
+ $this->assertEquals(75.3, $score['botnetClusterScore']);
382
+ } elseif ($i === 10) {
383
+ $this->assertEquals(95.7, $score['botnetClusterScore']);
384
+ }
385
+ }
386
+ }
257
387
  }
@@ -289,7 +289,105 @@ class RequestUtils
289
289
 
290
290
  return ['avgSpeed' => $avgSpeed, 'avgAcceleration' => $avgAcceleration, 'straightness' => $straightness, 'pauses' => $pauses, 'segments' => array_column($segments, 'distance')];
291
291
  }
292
+ /**
293
+ * Analyse une série d'événements tactiles mobiles pour en extraire des indicateurs comportementaux.
294
+ * @param array|null $history
295
+ * @return array
296
+ */
297
+ private static function analyzeTouchMovements(?array $history): array
298
+ {
299
+ if (empty($history) || count($history) < 3) {
300
+ return [
301
+ 'avgSpeed' => 0.0, 'avgAcceleration' => 0.0, 'straightness' => 1.0, 'pauses' => 0, 'segments' => [],
302
+ 'avgPressure' => 0.0, 'avgRadius' => 0.0, 'pressureVariance' => 0.0, 'radiusVariance' => 0.0, 'maxTouches' => 1
303
+ ];
304
+ }
305
+
306
+ $segments = [];
307
+ $totalDistance = 0.0;
308
+ $pauses = 0;
309
+ $totalPressure = 0.0;
310
+ $totalRadius = 0.0;
311
+ $maxTouches = 1;
312
+
313
+ for ($i = 1; $i < count($history); $i++) {
314
+ $p1 = $history[$i - 1];
315
+ $p2 = $history[$i];
316
+ $dx = $p2['x'] - $p1['x'];
317
+ $dy = $p2['y'] - $p1['y'];
318
+ $dt = $p2['t'] - $p1['t'];
319
+ $distance = sqrt($dx * $dx + $dy * $dy);
320
+
321
+ $totalPressure += (float)($p2['p'] ?? 0.0);
322
+ $totalRadius += (float)($p2['r'] ?? 0.0);
323
+ if (($p2['num'] ?? 1) > $maxTouches) {
324
+ $maxTouches = (int)$p2['num'];
325
+ }
326
+
327
+ if ($dt > 0) {
328
+ $speed = $distance / $dt;
329
+ $segments[] = ['distance' => $distance, 'dt' => $dt, 'speed' => $speed];
330
+ $totalDistance += $distance;
331
+ }
332
+ if ($dt > 100 && $distance < 5) {
333
+ $pauses++;
334
+ }
335
+ }
336
+
337
+ $totalPressure += (float)($history[0]['p'] ?? 0.0);
338
+ $totalRadius += (float)($history[0]['r'] ?? 0.0);
339
+
340
+ $avgPressure = $totalPressure / count($history);
341
+ $avgRadius = $totalRadius / count($history);
342
+
343
+ $sqDiffPressureSum = 0.0;
344
+ $sqDiffRadiusSum = 0.0;
345
+ foreach ($history as $pt) {
346
+ $sqDiffPressureSum += pow((float)($pt['p'] ?? 0.0) - $avgPressure, 2);
347
+ $sqDiffRadiusSum += pow((float)($pt['r'] ?? 0.0) - $avgRadius, 2);
348
+ }
349
+ $pressureVariance = $sqDiffPressureSum / count($history);
350
+ $radiusVariance = $sqDiffRadiusSum / count($history);
351
+
352
+ if (count($segments) < 2) {
353
+ return [
354
+ 'avgSpeed' => 0.0, 'avgAcceleration' => 0.0, 'straightness' => 1.0, 'pauses' => $pauses, 'segments' => [],
355
+ 'avgPressure' => $avgPressure, 'avgRadius' => $avgRadius, 'pressureVariance' => $pressureVariance, 'radiusVariance' => $radiusVariance, 'maxTouches' => $maxTouches
356
+ ];
357
+ }
358
+
359
+ $totalTime = $history[count($history) - 1]['t'] - $history[0]['t'];
360
+ $avgSpeed = $totalTime > 0 ? array_sum(array_column($segments, 'speed')) / count($segments) : 0.0;
361
+
362
+ $totalAbsAcceleration = 0.0;
363
+ for ($i = 1; $i < count($segments); $i++) {
364
+ $s1 = $segments[$i - 1];
365
+ $s2 = $segments[$i];
366
+ if ($s2['dt'] > 0) {
367
+ $acceleration = ($s2['speed'] - $s1['speed']) / $s2['dt'];
368
+ $totalAbsAcceleration += abs($acceleration);
369
+ }
370
+ }
371
+ $avgAcceleration = $totalAbsAcceleration / (count($segments) - 1);
292
372
 
373
+ $startPoint = $history[0];
374
+ $endPoint = $history[count($history) - 1];
375
+ $straightDistance = sqrt(pow($endPoint['x'] - $startPoint['x'], 2) + pow($endPoint['y'] - $startPoint['y'], 2));
376
+ $straightness = $totalDistance > 0 ? $straightDistance / $totalDistance : 1.0;
377
+
378
+ return [
379
+ 'avgSpeed' => $avgSpeed,
380
+ 'avgAcceleration' => $avgAcceleration,
381
+ 'straightness' => $straightness,
382
+ 'pauses' => $pauses,
383
+ 'segments' => array_column($segments, 'distance'),
384
+ 'avgPressure' => $avgPressure,
385
+ 'avgRadius' => $avgRadius,
386
+ 'pressureVariance' => $pressureVariance,
387
+ 'radiusVariance' => $radiusVariance,
388
+ 'maxTouches' => $maxTouches
389
+ ];
390
+ }
293
391
 
294
392
  /**
295
393
  * Calcule un score basé sur les métriques comportementales envoyées par le client.
@@ -314,6 +412,7 @@ class RequestUtils
314
412
  $score = 0.0;
315
413
 
316
414
  $mouseAnalysis = self::analyzeMouseMovements($metrics['mouseMovementsHistory'] ?? null);
415
+ $touch = self::analyzeTouchMovements($metrics['touchMovementsHistory'] ?? null);
317
416
 
318
417
  if (isset($metrics['historyLength'])) {
319
418
  if ($metrics['historyLength'] === 1) $score += 15;
@@ -321,7 +420,7 @@ class RequestUtils
321
420
  elseif ($metrics['historyLength'] >= 2) $score -= 10;
322
421
  } else {
323
422
  // Pénalité pour absence totale d'interaction si l'historique n'est pas dispo
324
- if ($mouseAnalysis['avgSpeed'] == 0 && ($metrics['keystrokeLatency'] ?? 0) == 0) {
423
+ if ($mouseAnalysis['avgSpeed'] == 0 && $touch['avgSpeed'] == 0 && ($metrics['keystrokeLatency'] ?? 0) == 0) {
325
424
  $score += 40;
326
425
  }
327
426
  }
@@ -343,6 +442,30 @@ class RequestUtils
343
442
  $score += 35;
344
443
  }
345
444
  }
445
+ // Analyse comportementale des événements tactiles (Touch Move)
446
+ $touchHistory = $metrics['touchMovementsHistory'] ?? null;
447
+ if (!empty($touchHistory)) {
448
+ if ($touch['avgSpeed'] > 0) {
449
+ if ($touch['avgSpeed'] > 5) $score += 30;
450
+ if ($touch['avgAcceleration'] > 0.8) $score += 20;
451
+ if ($touch['straightness'] > 0.98) $score += 35;
452
+ if ($touch['pauses'] === 0 && count($touch['segments']) > 25) $score += 15;
453
+
454
+ // Détection de l'émulation (pression et rayon de contact constants)
455
+ if ($touch['avgPressure'] > 0 && $touch['pressureVariance'] == 0) {
456
+ $score += 30;
457
+ }
458
+ if ($touch['avgRadius'] > 0 && $touch['radiusVariance'] == 0) {
459
+ $score += 30;
460
+ }
461
+ }
462
+ if (count($touch['segments']) > 10) {
463
+ $benfordDev = Optimization::benfordTest($touch['segments']);
464
+ if ($benfordDev > 0.18) {
465
+ $score += 35;
466
+ }
467
+ }
468
+ }
346
469
 
347
470
  return ['behaviorScore' => min(100.0, $score)];
348
471
  }
@@ -553,7 +676,7 @@ class RequestUtils
553
676
  * @param string $fpString La chaîne d'empreinte complète.
554
677
  * @return string La sous-chaîne de l'empreinte contenant uniquement les parties stables.
555
678
  */
556
- private static function extractStablePart(string $fpString): string
679
+ public static function extractStablePart(string $fpString): string
557
680
  {
558
681
  $stableKeys = ['ua', 'ja3', 'ja4', 'h2', 'tcp'];
559
682
  $parts = explode('|', $fpString);
@@ -584,6 +707,9 @@ class RequestUtils
584
707
  $patternWeight = $patternConfig['patternWeight'] ?? 80;
585
708
  $decayFactor = $patternConfig['decayFactor'] ?? 0.95;
586
709
  $inactivityReset = $patternConfig['inactivityReset'] ?? 180000;
710
+ $regularityRatio = $patternConfig['regularityRatio'] ?? 0.4;
711
+ $benfordRatio = $patternConfig['benfordRatio'] ?? 0.3;
712
+ $enumerationRatio = $patternConfig['enumerationRatio'] ?? 0.3;
587
713
 
588
714
  $now = time() * 1000;
589
715
  $history = $deviceData['requestHistory'] ?? [];
@@ -606,7 +732,8 @@ class RequestUtils
606
732
  }
607
733
  $deviceData['requestHistory'] = $history;
608
734
 
609
- $instantScore = 0;
735
+ $regularityScore = 0.0;
736
+ $benfordScore = 0.0;
610
737
  $timings = $deviceData['timingHistory'];
611
738
 
612
739
  // Analyse statistique si nous avons assez de données
@@ -621,18 +748,16 @@ class RequestUtils
621
748
  $stdDev = sqrt($variance);
622
749
  $benfordDeviation = Optimization::benfordTest($timings);
623
750
 
624
- // Détection de régularité (bots de type "cron")
625
751
  if ($stdDev < $regularityThreshold) {
626
- $instantScore = $patternWeight;
752
+ $regularityScore = 1.0 - ($stdDev / $regularityThreshold);
627
753
  }
628
- // Détection de distribution non-naturelle (bots "faussement aléatoires")
629
- elseif ($benfordDeviation > $benfordThreshold) {
630
- $instantScore = $patternWeight;
754
+ if ($benfordDeviation > $benfordThreshold) {
755
+ $benfordScore = min(1.0, ($benfordDeviation - $benfordThreshold) / (0.5 - $benfordThreshold));
631
756
  }
632
757
  }
633
758
 
634
- // Détection d'énumération de chemins (crawling/scraping de ressources séquentielles)
635
- $enumerationScore = 0;
759
+ // Path enumeration progressif
760
+ $enumerationScore = 0.0;
636
761
  if (count($history) >= 3) {
637
762
  $templates = array_map(function($h) {
638
763
  return preg_replace('/\d+/', '{num}', $h['path']);
@@ -646,10 +771,15 @@ class RequestUtils
646
771
  $maxTemplateRepetition = !empty($templateCounts) ? max($templateCounts) : 0;
647
772
 
648
773
  if ($maxTemplateRepetition >= 3 && count($uniquePaths) === count($history)) {
649
- $enumerationScore = $patternWeight * 0.8;
774
+ $enumerationScore = min(1.0, ($maxTemplateRepetition - 2) / 5.0);
650
775
  }
651
776
  }
652
777
 
778
+ $weightedScore = ($regularityScore * $regularityRatio) +
779
+ ($benfordScore * $benfordRatio) +
780
+ ($enumerationScore * $enumerationRatio);
781
+ $instantScore = $weightedScore * $patternWeight;
782
+
653
783
  // Logique de décroissance et de score final
654
784
  $newPatternScore = $deviceData['lastPatternScore'] ?? 0;
655
785
 
@@ -660,7 +790,7 @@ class RequestUtils
660
790
  }
661
791
  $newPatternScore = max(0, $newPatternScore);
662
792
 
663
- $deviceData['lastPatternScore'] = $newPatternScore + $instantScore + $enumerationScore;
793
+ $deviceData['lastPatternScore'] = max((float)$instantScore, (float)$newPatternScore);
664
794
 
665
795
  return ['requestPatternScore' => min(100.0, $deviceData['lastPatternScore'])];
666
796
  }
@@ -914,14 +1044,18 @@ class RequestUtils
914
1044
  $subnetData['highScoreDevices'] = [];
915
1045
  }
916
1046
 
917
- $currentDeviceContributions = $subnetData['highScoreDevices'][$deviceId] ?? 0;
1047
+ // Utilisation de la partie stable du fingerprint matériel plutôt que l'ID de cookie volatil
1048
+ $currentDeviceHash = self::getCompositeDeviceHash($context);
1049
+ $stableFpId = FingerprintBuilder::cyrb53(self::extractStablePart($currentDeviceHash));
1050
+
1051
+ $currentDeviceContributions = $subnetData['highScoreDevices'][$stableFpId] ?? 0;
918
1052
  if ($currentDeviceContributions < 5 && $finalScore < 95) {
919
- $subnetData['highScoreDevices'][$deviceId] = $currentDeviceContributions + 1;
1053
+ $subnetData['highScoreDevices'][$stableFpId] = $currentDeviceContributions + 1;
920
1054
  $subnetData['highScoreCount']++;
921
1055
  }
922
1056
 
923
- if (!in_array($deviceId, $subnetData['deviceIds'])) {
924
- $subnetData['deviceIds'][] = $deviceId;
1057
+ if (!in_array($stableFpId, $subnetData['deviceIds'], true)) {
1058
+ $subnetData['deviceIds'][] = $stableFpId;
925
1059
  }
926
1060
  $subnetData['lastActivity'] = time();
927
1061
 
@@ -986,6 +1120,56 @@ class RequestUtils
986
1120
  return ['subnetScore' => min(100.0, $score)];
987
1121
  }
988
1122
 
1123
+ /**
1124
+ * Calcule le score d'anomalie de similarité réseau (Botnet Clustering).
1125
+ * @param RequestContext $context
1126
+ * @param string $stableFpHash
1127
+ * @return array{'botnetClusterScore': float}
1128
+ */
1129
+ public static function getBotnetClusterScore(RequestContext $context, string $stableFpHash): array
1130
+ {
1131
+ if (empty($stableFpHash)) {
1132
+ return ['botnetClusterScore' => 0.0];
1133
+ }
1134
+
1135
+ $store = StoreManager::getStore();
1136
+ $key = "botnet-cluster:{$stableFpHash}";
1137
+ $now = time();
1138
+ $tenMinutesAgo = $now - 600;
1139
+
1140
+ $clusterData = $store->get($key) ?? [];
1141
+ if (!is_array($clusterData)) {
1142
+ $clusterData = [];
1143
+ }
1144
+
1145
+ $clusterData = array_filter($clusterData, function ($entry) use ($tenMinutesAgo) {
1146
+ return isset($entry['timestamp']) && $entry['timestamp'] > $tenMinutesAgo;
1147
+ });
1148
+ $clusterData = array_values($clusterData);
1149
+
1150
+ $found = false;
1151
+ foreach ($clusterData as &$entry) {
1152
+ if (isset($entry['ip']) && $entry['ip'] === $context->clientIp) {
1153
+ $entry['timestamp'] = $now;
1154
+ $found = true;
1155
+ break;
1156
+ }
1157
+ }
1158
+ unset($entry);
1159
+
1160
+ if (!$found) {
1161
+ $clusterData[] = ['ip' => $context->clientIp, 'timestamp' => $now];
1162
+ }
1163
+
1164
+ $store->set($key, $clusterData, 600);
1165
+ $uniqueIpsCount = count($clusterData);
1166
+ $botnetClusterScore = 0.0;
1167
+ if ($uniqueIpsCount >= 2) {
1168
+ $botnetClusterScore = min(100.0, round(100.0 * (1.0 - exp(-0.35 * ($uniqueIpsCount - 1))), 1));
1169
+ }
1170
+ return ['botnetClusterScore' => $botnetClusterScore];
1171
+ }
1172
+
989
1173
  /**
990
1174
  * Calcule le score de réputation d'une IP en appliquant la décroissance temporelle.
991
1175
  */