@anonympins/fingerprint 0.3.3 → 0.3.5
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 +33 -0
- 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 +3946 -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 +990 -850
- package/src/php/Ja3AnomalyDetector.php +228 -0
- 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/Tests/Ja3AnomalyDetectorTest.php +180 -0
- package/src/php/Utils/BigInt.php +77 -34
- package/src/php/Utils/RequestUtils.php +325 -25
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
declare(strict_types=1);
|
|
4
4
|
|
|
5
|
+
|
|
5
6
|
namespace Anonympins\Fingerprint\Utils;
|
|
6
7
|
|
|
7
8
|
use Anonympins\Fingerprint\FingerprintBuilder;
|
|
9
|
+
use Anonympins\Fingerprint\Store\StoreManager;
|
|
8
10
|
use Anonympins\Fingerprint\Optimization\Optimization;
|
|
9
11
|
use Anonympins\Fingerprint\RequestContext;
|
|
10
12
|
|
|
@@ -43,6 +45,20 @@ class RequestUtils
|
|
|
43
45
|
'c72366b9551263d990b7fa574225332c' => 'curl',
|
|
44
46
|
];
|
|
45
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Base de données de signatures JA4 connues.
|
|
50
|
+
* @var array<string, string|string[]>
|
|
51
|
+
*/
|
|
52
|
+
private const JA4_FINGERPRINT_DB = [
|
|
53
|
+
// Format: {JA4 Hash} => {Client Name}
|
|
54
|
+
// --- Chrome ---
|
|
55
|
+
't13d1517h2_8daaf61527d5' => 'Chrome', // Chrome 117 on Win11
|
|
56
|
+
't13d1516h2_8daaf61527d5' => 'Chrome', // Chrome 116 on Win10
|
|
57
|
+
// --- Firefox ---
|
|
58
|
+
't13d1517h2_2491a244c393' => 'Firefox', // Firefox 117 on Win11
|
|
59
|
+
// --- Common Libraries & Bots ---
|
|
60
|
+
't13d1500h1_4b56136b4d35' => 'Python', // Python requests
|
|
61
|
+
];
|
|
46
62
|
/**
|
|
47
63
|
* Crée un hash composite stable basé sur les caractéristiques de la requête.
|
|
48
64
|
*/
|
|
@@ -62,6 +78,8 @@ class RequestUtils
|
|
|
62
78
|
|
|
63
79
|
if ($context->ja3) $srv->add("ja3", $context->ja3);
|
|
64
80
|
if ($context->ja4) $srv->add("ja4", $context->ja4);
|
|
81
|
+
if ($context->ja4s) $srv->add("ja4s", $context->ja4s);
|
|
82
|
+
if ($context->ja4h) $srv->add("ja4h", $context->ja4h);
|
|
65
83
|
if ($context->http2Fingerprint) $srv->add("h2", $context->http2Fingerprint);
|
|
66
84
|
if ($context->tcpFingerprint) $srv->add("tcp", $context->tcpFingerprint);
|
|
67
85
|
|
|
@@ -103,34 +121,59 @@ class RequestUtils
|
|
|
103
121
|
*/
|
|
104
122
|
public static function getTlsSpoofingScore(RequestContext $context): array
|
|
105
123
|
{
|
|
106
|
-
$ja3 = $context->ja3;
|
|
107
124
|
$ua = $context->getHeader('user-agent') ?? '';
|
|
125
|
+
$ja3 = $context->ja3;
|
|
126
|
+
$ja4 = $context->ja4;
|
|
108
127
|
|
|
109
|
-
|
|
128
|
+
// Si un fingerprint TLS est présent mais que le User-Agent est absent ou générique, c'est suspect.
|
|
129
|
+
if (($ja3 || $ja4) && (empty($ua) || strlen($ua) < 10 || stripos($ua, 'python') !== false || stripos($ua, 'curl') !== false)) {
|
|
110
130
|
return ['tlsSpoofingScore' => 50.0];
|
|
111
131
|
}
|
|
112
132
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
133
|
+
$claimedBrowserInfo = self::parseUserAgent($ua);
|
|
134
|
+
$claimedBrowser = $claimedBrowserInfo['browser'] ?? null;
|
|
135
|
+
|
|
136
|
+
if (empty($claimedBrowser) || empty($ua)) {
|
|
137
|
+
return ['tlsSpoofingScore' => 0.0];
|
|
138
|
+
}
|
|
118
139
|
|
|
119
|
-
|
|
120
|
-
|
|
140
|
+
// Priorité à JA4 pour la détection de spoofing
|
|
141
|
+
if ($ja4 && isset(self::JA4_FINGERPRINT_DB[$ja4])) {
|
|
142
|
+
$expectedClients = self::JA4_FINGERPRINT_DB[$ja4];
|
|
143
|
+
if (!is_array($expectedClients)) {
|
|
144
|
+
$expectedClients = [$expectedClients];
|
|
145
|
+
}
|
|
121
146
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
break;
|
|
128
|
-
}
|
|
147
|
+
$isMatch = false;
|
|
148
|
+
foreach ($expectedClients as $expected) {
|
|
149
|
+
if (stripos($claimedBrowser, $expected) !== false) {
|
|
150
|
+
$isMatch = true;
|
|
151
|
+
break;
|
|
129
152
|
}
|
|
130
|
-
|
|
131
|
-
|
|
153
|
+
}
|
|
154
|
+
if (!$isMatch) {
|
|
155
|
+
// Incohérence forte détectée avec JA4
|
|
156
|
+
return ['tlsSpoofingScore' => 90.0];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Fallback sur JA3 si JA4 n'a pas matché
|
|
160
|
+
elseif ($ja3 && isset(self::TLS_FINGERPRINT_DB[$ja3])) {
|
|
161
|
+
$expectedClients = self::TLS_FINGERPRINT_DB[$ja3];
|
|
162
|
+
if (!is_array($expectedClients)) {
|
|
163
|
+
$expectedClients = [$expectedClients];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
$isMatch = false;
|
|
167
|
+
foreach ($expectedClients as $expected) {
|
|
168
|
+
if (stripos($claimedBrowser, $expected) !== false) {
|
|
169
|
+
$isMatch = true;
|
|
170
|
+
break;
|
|
132
171
|
}
|
|
133
172
|
}
|
|
173
|
+
if (!$isMatch) {
|
|
174
|
+
// Incohérence détectée avec JA3
|
|
175
|
+
return ['tlsSpoofingScore' => 80.0];
|
|
176
|
+
}
|
|
134
177
|
}
|
|
135
178
|
|
|
136
179
|
return ['tlsSpoofingScore' => 0.0];
|
|
@@ -143,7 +186,7 @@ class RequestUtils
|
|
|
143
186
|
public static function getHeaderAnomalies(RequestContext $context): array
|
|
144
187
|
{
|
|
145
188
|
$anomalyScore = 0;
|
|
146
|
-
$ua = $context->getHeader('user-agent');
|
|
189
|
+
$ua = $context->getHeader('user-agent') ?? '';
|
|
147
190
|
if (empty($ua) || strlen($ua) < 10) {
|
|
148
191
|
$anomalyScore += 60;
|
|
149
192
|
}
|
|
@@ -154,6 +197,17 @@ class RequestUtils
|
|
|
154
197
|
$anomalyScore += 15;
|
|
155
198
|
}
|
|
156
199
|
|
|
200
|
+
// TE: trailers check for Firefox on Desktop
|
|
201
|
+
$uaParts = self::parseUserAgent($ua);
|
|
202
|
+
$isFirefoxDesktop = isset($uaParts['browser']) && str_starts_with($uaParts['browser'], 'Firefox') && ($uaParts['device'] ?? 'desktop') === 'desktop';
|
|
203
|
+
$te = strtolower($context->getHeader('te') ?? '');
|
|
204
|
+
|
|
205
|
+
if ($isFirefoxDesktop && $te !== 'trailers') {
|
|
206
|
+
$anomalyScore += 30;
|
|
207
|
+
} elseif (!$isFirefoxDesktop && ($uaParts['device'] ?? 'desktop') === 'desktop' && $te === 'trailers') {
|
|
208
|
+
$anomalyScore += 30;
|
|
209
|
+
}
|
|
210
|
+
|
|
157
211
|
return ['headerAnomalyScore' => min(100.0, $anomalyScore)];
|
|
158
212
|
}
|
|
159
213
|
|
|
@@ -377,6 +431,62 @@ class RequestUtils
|
|
|
377
431
|
return ['crossLayerInconsistencyScore' => min(100.0, $score)];
|
|
378
432
|
}
|
|
379
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Calcule un score d'incohérence entre le User-Agent et les en-têtes Sec-CH-UA (Client Hints).
|
|
436
|
+
* @return array{'clientHintsInconsistencyScore': float}
|
|
437
|
+
*/
|
|
438
|
+
public static function getClientHintsInconsistencyScore(RequestContext $context): array
|
|
439
|
+
{
|
|
440
|
+
$ua = $context->getHeader('user-agent');
|
|
441
|
+
$clientHints = $context->getHeader('sec-ch-ua');
|
|
442
|
+
|
|
443
|
+
if (empty($ua) || empty($clientHints)) {
|
|
444
|
+
return ['clientHintsInconsistencyScore' => 0.0];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 1. Extraire la version du navigateur depuis le User-Agent
|
|
448
|
+
$uaVersion = null;
|
|
449
|
+
if (preg_match('/(Chrome|Firefox|Edg|Safari)\/([\d\.]+)/', $ua, $uaMatches)) {
|
|
450
|
+
$uaBrowser = $uaMatches[1] === 'Edg' ? 'Edge' : $uaMatches[1];
|
|
451
|
+
// Prendre uniquement la version majeure
|
|
452
|
+
$uaVersion = explode('.', $uaMatches[2])[0] ?? null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 2. Extraire la version du navigateur depuis Sec-CH-UA
|
|
456
|
+
$chVersion = null;
|
|
457
|
+
$chBrowser = null;
|
|
458
|
+
// Regex pour trouver une marque de navigateur connue et sa version
|
|
459
|
+
if (preg_match('/"(?:Google Chrome|Chromium|Microsoft Edge)";v="(\d+)"/', $clientHints, $chMatches)) {
|
|
460
|
+
$chVersion = $chMatches[1];
|
|
461
|
+
// Déterminer le navigateur à partir de la marque trouvée
|
|
462
|
+
if (str_contains($chMatches[0], 'Edge')) {
|
|
463
|
+
$chBrowser = 'Edge';
|
|
464
|
+
} else {
|
|
465
|
+
$chBrowser = 'Chrome'; // Chrome ou Chromium
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if ($uaVersion === null || $chVersion === null || $uaBrowser === null || $chBrowser === null) {
|
|
470
|
+
return ['clientHintsInconsistencyScore' => 0.0];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// 3. Comparer les versions
|
|
474
|
+
// Tolérer une petite différence car les Client-Hints peuvent être plus précis ou mis à jour différemment
|
|
475
|
+
$versionDifference = abs((int)$uaVersion - (int)$chVersion);
|
|
476
|
+
|
|
477
|
+
// Si les navigateurs déclarés sont différents (ex: UA dit Firefox, CH dit Chrome)
|
|
478
|
+
if ($uaBrowser !== $chBrowser && ($uaBrowser !== 'Chrome' || $chBrowser !== 'Edge')) { // Tolérer Chrome/Edge
|
|
479
|
+
return ['clientHintsInconsistencyScore' => 90.0];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if ($versionDifference > 5) { // Un écart de plus de 5 versions majeures est très suspect
|
|
483
|
+
return ['clientHintsInconsistencyScore' => 80.0];
|
|
484
|
+
} elseif ($versionDifference > 1) { // Un petit écart est légèrement suspect
|
|
485
|
+
return ['clientHintsInconsistencyScore' => 40.0];
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return ['clientHintsInconsistencyScore' => 0.0];
|
|
489
|
+
}
|
|
380
490
|
/**
|
|
381
491
|
* Calcule les indicateurs comportementaux liés à l'historique de l'appareil.
|
|
382
492
|
* @param array<string, mixed> $deviceData
|
|
@@ -392,13 +502,32 @@ class RequestUtils
|
|
|
392
502
|
$rapidChangeThresholdMs = 2000; // 2 secondes
|
|
393
503
|
$maxRapidChanges = 3;
|
|
394
504
|
|
|
395
|
-
|
|
505
|
+
$lastFpHash = $deviceData['lastFpHash'] ?? null;
|
|
506
|
+
|
|
507
|
+
if ($lastFpHash && $currentFpHash !== $lastFpHash) {
|
|
508
|
+
// Comparaison plus intelligente : ne pénaliser que si les parties STABLES de l'empreinte changent.
|
|
509
|
+
// Les parties stables sont celles qui ne devraient pas changer lors d'un simple changement de réseau.
|
|
510
|
+
$stablePart1 = self::extractStablePart($lastFpHash);
|
|
511
|
+
$stablePart2 = self::extractStablePart($currentFpHash);
|
|
512
|
+
|
|
396
513
|
$timeSinceLastChange = $now - ($deviceData['lastChangeTimestamp'] ?? 0);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
514
|
+
|
|
515
|
+
// On incrémente le compteur de rotation rapide SEULEMENT si la partie stable a changé.
|
|
516
|
+
if ($stablePart1 !== $stablePart2) {
|
|
517
|
+
if ($timeSinceLastChange < $rapidChangeThresholdMs) {
|
|
518
|
+
$deviceData['rapidChangeCount'] = ($deviceData['rapidChangeCount'] ?? 0) + 1;
|
|
519
|
+
} else {
|
|
520
|
+
// Si le changement est lent, on réduit le compteur pour pardonner les anciens changements rapides.
|
|
521
|
+
$deviceData['rapidChangeCount'] = max(0, ($deviceData['rapidChangeCount'] ?? 0) - 1);
|
|
522
|
+
}
|
|
523
|
+
$deviceData['lastChangeTimestamp'] = $now;
|
|
401
524
|
}
|
|
525
|
+
// Si seule la partie volatile a changé (ex: User-Agent, IP via en-têtes), on ne met pas à jour le `lastChangeTimestamp`.
|
|
526
|
+
// Cela évite qu'un changement de réseau légitime soit suivi d'un autre changement (ex: mise en veille)
|
|
527
|
+
// et soit compté comme une rotation rapide.
|
|
528
|
+
|
|
529
|
+
} else if ($lastFpHash === null) {
|
|
530
|
+
// Première visite, on initialise le timestamp.
|
|
402
531
|
$deviceData['lastChangeTimestamp'] = $now;
|
|
403
532
|
}
|
|
404
533
|
$deviceData['lastFpHash'] = $currentFpHash;
|
|
@@ -418,6 +547,27 @@ class RequestUtils
|
|
|
418
547
|
return ['historyScore' => $historyScore, 'rotationScore' => $rotationScore];
|
|
419
548
|
}
|
|
420
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Extrait la partie "stable" d'une chaîne d'empreinte.
|
|
552
|
+
* La partie stable inclut les composants matériels (canvas, gpu) qui ne devraient pas changer.
|
|
553
|
+
* @param string $fpString La chaîne d'empreinte complète.
|
|
554
|
+
* @return string La sous-chaîne de l'empreinte contenant uniquement les parties stables.
|
|
555
|
+
*/
|
|
556
|
+
private static function extractStablePart(string $fpString): string
|
|
557
|
+
{
|
|
558
|
+
$stableKeys = ['cvs', 'gpu', 'hw', 'client_fp_hash', 'os', 'scr'];
|
|
559
|
+
$parts = explode('|', $fpString);
|
|
560
|
+
$stableParts = [];
|
|
561
|
+
foreach ($parts as $part) {
|
|
562
|
+
$pair = explode(':', $part, 2);
|
|
563
|
+
if (count($pair) === 2 && in_array($pair[0], $stableKeys, true)) {
|
|
564
|
+
$stableParts[] = $part;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
sort($stableParts);
|
|
568
|
+
return implode('|', $stableParts);
|
|
569
|
+
}
|
|
570
|
+
|
|
421
571
|
/**
|
|
422
572
|
* Analyse les patterns de requêtes pour détecter les comportements de bot.
|
|
423
573
|
* @param array<string, mixed> $deviceData
|
|
@@ -610,7 +760,7 @@ class RequestUtils
|
|
|
610
760
|
* Parse une chaîne de requête GraphQL pour extraire le type et le nom de l'opération.
|
|
611
761
|
* @param array<string, mixed> $body Le corps de la requête.
|
|
612
762
|
* @return array{type: string, name: string}|null
|
|
613
|
-
*/
|
|
763
|
+
*/
|
|
614
764
|
public static function parseGraphQLQuery(array $body): ?array
|
|
615
765
|
{
|
|
616
766
|
$query = $body['query'] ?? null;
|
|
@@ -670,4 +820,154 @@ class RequestUtils
|
|
|
670
820
|
|
|
671
821
|
return self::pathMatches($requestPath, $pathPattern);
|
|
672
822
|
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Génère un masque de sous-réseau binaire pour une longueur de préfixe donnée.
|
|
826
|
+
*
|
|
827
|
+
* @param int $prefix La longueur du préfixe (ex: 24 pour IPv4, 48 pour IPv6).
|
|
828
|
+
* @param int $totalBytes Le nombre total d'octets pour le masque (4 pour IPv4, 16 pour IPv6).
|
|
829
|
+
* @return string|null Le masque binaire ou null si le préfixe est invalide.
|
|
830
|
+
*/
|
|
831
|
+
private static function generateMask(int $prefix, int $totalBytes): ?string
|
|
832
|
+
{
|
|
833
|
+
if ($prefix < 0 || $prefix > $totalBytes * 8) {
|
|
834
|
+
return null; // Préfixe invalide
|
|
835
|
+
}
|
|
836
|
+
$mask = str_repeat(chr(255), (int)floor($prefix / 8));
|
|
837
|
+
if ($prefix % 8 !== 0) {
|
|
838
|
+
$mask .= chr((255 << (8 - $prefix % 8)) & 255);
|
|
839
|
+
}
|
|
840
|
+
return str_pad($mask, $totalBytes, chr(0));
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Calcule le sous-réseau d'une adresse IP.
|
|
845
|
+
* @param string $ip L'adresse IP.
|
|
846
|
+
* @param int $ipv4Prefix Le préfixe pour les adresses IPv4 (défaut /24).
|
|
847
|
+
* @param int $ipv6Prefix Le préfixe pour les adresses IPv6 (défaut /48).
|
|
848
|
+
* @return string|null Le sous-réseau CIDR ou null si l'IP est invalide.
|
|
849
|
+
*/
|
|
850
|
+
public static function getIpSubnet(string $ip, int $ipv4Prefix = 24, int $ipv6Prefix = 48): ?string
|
|
851
|
+
{
|
|
852
|
+
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
853
|
+
$ipBinary = inet_pton($ip);
|
|
854
|
+
if ($ipBinary === false) return null;
|
|
855
|
+
|
|
856
|
+
$mask = self::generateMask($ipv4Prefix, 4);
|
|
857
|
+
if ($mask === null) return null;
|
|
858
|
+
|
|
859
|
+
$networkBinary = $ipBinary & $mask;
|
|
860
|
+
return inet_ntop($networkBinary) . '/' . $ipv4Prefix;
|
|
861
|
+
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
|
862
|
+
$ipBinary = inet_pton($ip);
|
|
863
|
+
if ($ipBinary === false) return null;
|
|
864
|
+
|
|
865
|
+
$mask = self::generateMask($ipv6Prefix, 16);
|
|
866
|
+
if ($mask === null) return null;
|
|
867
|
+
|
|
868
|
+
$networkBinary = $ipBinary & $mask;
|
|
869
|
+
return inet_ntop($networkBinary) . '/' . $ipv6Prefix; // FIX: Use the provided ipv6Prefix
|
|
870
|
+
}
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Met à jour les métriques agrégées pour un sous-réseau IP.
|
|
876
|
+
* @param RequestContext $context
|
|
877
|
+
* @param string $deviceId
|
|
878
|
+
* @param float $finalScore
|
|
879
|
+
*/
|
|
880
|
+
public static function updateSubnetMetrics(RequestContext $context, string $deviceId, float $finalScore): void
|
|
881
|
+
{
|
|
882
|
+
$subnet = self::getIpSubnet($context->clientIp);
|
|
883
|
+
if ($subnet === null) return;
|
|
884
|
+
|
|
885
|
+
$store = StoreManager::getStore();
|
|
886
|
+
$key = "subnet:{$subnet}";
|
|
887
|
+
$subnetData = $store->get($key) ?? [
|
|
888
|
+
'highScoreCount' => 0,
|
|
889
|
+
'deviceIds' => [],
|
|
890
|
+
'lastActivity' => 0
|
|
891
|
+
];
|
|
892
|
+
|
|
893
|
+
$subnetData['highScoreCount']++;
|
|
894
|
+
if (!in_array($deviceId, $subnetData['deviceIds'])) {
|
|
895
|
+
$subnetData['deviceIds'][] = $deviceId;
|
|
896
|
+
}
|
|
897
|
+
$subnetData['lastActivity'] = time();
|
|
898
|
+
|
|
899
|
+
// Limiter la taille du tableau des deviceIds pour éviter une consommation mémoire excessive.
|
|
900
|
+
if (count($subnetData['deviceIds']) > 100) {
|
|
901
|
+
array_shift($subnetData['deviceIds']);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// TTL de 24 heures pour les données de sous-réseau.
|
|
905
|
+
$store->set($key, $subnetData, 86400);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Calcule un score de suspicion basé sur l'activité historique du sous-réseau IP.
|
|
910
|
+
* @param RequestContext $context
|
|
911
|
+
* @param string $currentDeviceId
|
|
912
|
+
* @return array{'subnetScore': float}
|
|
913
|
+
*/
|
|
914
|
+
public static function getSubnetScore(RequestContext $context, string $currentDeviceId): array
|
|
915
|
+
{
|
|
916
|
+
$subnet = self::getIpSubnet($context->clientIp);
|
|
917
|
+
if ($subnet === null) {
|
|
918
|
+
return ['subnetScore' => 0.0];
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
$store = StoreManager::getStore();
|
|
922
|
+
$key = "subnet:{$subnet}";
|
|
923
|
+
$subnetData = $store->get($key);
|
|
924
|
+
|
|
925
|
+
if ($subnetData === null) {
|
|
926
|
+
return ['subnetScore' => 0.0];
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
$score = 0.0;
|
|
930
|
+
|
|
931
|
+
// Pénalité basée sur le nombre de devices uniques vus depuis ce sous-réseau.
|
|
932
|
+
$deviceCount = count($subnetData['deviceIds']);
|
|
933
|
+
if ($deviceCount > 10) {
|
|
934
|
+
$score += min(80.0, ($deviceCount - 10) * 5);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Pénalité basée sur le nombre de scores élevés enregistrés.
|
|
938
|
+
$score += min(40.0, $subnetData['highScoreCount'] * 2);
|
|
939
|
+
|
|
940
|
+
return ['subnetScore' => min(100.0, $score)];
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Calcule le score de réputation d'une IP en appliquant la décroissance temporelle.
|
|
945
|
+
*/
|
|
946
|
+
public static function getIpReputationScore(string $ip): float
|
|
947
|
+
{
|
|
948
|
+
$store = StoreManager::getStore();
|
|
949
|
+
$key = "ip-reputation:{$ip}";
|
|
950
|
+
$data = $store->get($key);
|
|
951
|
+
if ($data === null) {
|
|
952
|
+
return 0.0;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
$now = time();
|
|
956
|
+
$hoursPassed = ($now - $data['lastUpdate']) / 3600;
|
|
957
|
+
$decay = (int)floor($hoursPassed * 2); // Décroissance de 2 points par heure
|
|
958
|
+
|
|
959
|
+
return (float)max(0.0, $data['score'] - $decay);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Met à jour le score de réputation locale d'une IP.
|
|
964
|
+
*/
|
|
965
|
+
public static function updateIpReputationScore(string $ip, float $change): void
|
|
966
|
+
{
|
|
967
|
+
$store = StoreManager::getStore();
|
|
968
|
+
$key = "ip-reputation:{$ip}";
|
|
969
|
+
$current = self::getIpReputationScore($ip);
|
|
970
|
+
$newScore = min(100.0, max(0.0, $current + $change));
|
|
971
|
+
$store->set($key, ['score' => $newScore, 'lastUpdate' => time()], 86400 * 7); // TTL de 7 jours
|
|
972
|
+
}
|
|
673
973
|
}
|