@anonympins/fingerprint 0.4.4 → 0.4.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 +22 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/js/dynamic-wasm.js +158 -0
- package/src/js/fingerprint.client.js +21 -0
- package/src/js/fingerprint.js +344 -20
- package/src/js/tests/dynamic-wasm.test.js +78 -0
- package/src/js/tests/fingerprint.client.test.js +128 -104
- package/src/js/tests/fingerprint.test.js +20 -1
- package/src/js/tests/ip-reputation.test.js +141 -131
- package/src/js/tests/tcpFingerprint.test.js +78 -0
- package/src/php/Challenge/ChallengeUtils.php +77 -12
- package/src/php/Config/SecurityProfiles.php +10 -0
- package/src/php/FingerprintEngine.php +4 -0
- package/src/php/Tests/FingerprintEngineTest.php +27 -1
- package/src/php/Tests/IpReputationTest.php +175 -156
- package/src/php/Utils/RequestUtils.php +274 -5
|
@@ -35,6 +35,52 @@ class ChallengeUtils
|
|
|
35
35
|
return $secret ?: "fallback-dev-secret-32-chars-minimum";
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Génère un ticket stateless chiffré et signé contenant le contexte d'autorisation.
|
|
40
|
+
* @param array $payload
|
|
41
|
+
* @return string
|
|
42
|
+
*/
|
|
43
|
+
public static function generateStatelessTicket(array $payload): string
|
|
44
|
+
{
|
|
45
|
+
$key = hash('sha256', self::getPowSecret(), true);
|
|
46
|
+
$iv = random_bytes(16);
|
|
47
|
+
$encrypted = openssl_encrypt(json_encode($payload), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
|
48
|
+
$signature = hash_hmac('sha256', $iv . $encrypted, $key, true);
|
|
49
|
+
|
|
50
|
+
return rtrim(strtr(base64_encode($iv), '+/', '-_'), '=') . '.' .
|
|
51
|
+
rtrim(strtr(base64_encode($encrypted), '+/', '-_'), '=') . '.' .
|
|
52
|
+
rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Décode et valide un ticket stateless chiffré et signé.
|
|
57
|
+
* @param string $ticket
|
|
58
|
+
* @return array|null
|
|
59
|
+
*/
|
|
60
|
+
public static function parseStatelessTicket(string $ticket): ?array
|
|
61
|
+
{
|
|
62
|
+
$parts = explode('.', $ticket);
|
|
63
|
+
if (count($parts) !== 3) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
$base64UrlDecode = function ($input) {
|
|
67
|
+
return base64_decode(strtr($input, '-_', '+/'));
|
|
68
|
+
};
|
|
69
|
+
$iv = $base64UrlDecode($parts[0]);
|
|
70
|
+
$encrypted = $base64UrlDecode($parts[1]);
|
|
71
|
+
$signature = $base64UrlDecode($parts[2]);
|
|
72
|
+
if (!$iv || !$encrypted || !$signature || strlen($iv) !== 16) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
$key = hash('sha256', self::getPowSecret(), true);
|
|
76
|
+
$expectedSignature = hash_hmac('sha256', $iv . $encrypted, $key, true);
|
|
77
|
+
if (!hash_equals($expectedSignature, $signature)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
$decrypted = openssl_decrypt($encrypted, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
|
81
|
+
return $decrypted !== false ? json_decode($decrypted, true) : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
38
84
|
/**
|
|
39
85
|
* Vérifie si un ticket de passage est valide (supporte les tickets opaques via store et le fallback legacy).
|
|
40
86
|
*/
|
|
@@ -49,6 +95,31 @@ class ChallengeUtils
|
|
|
49
95
|
return false;
|
|
50
96
|
}
|
|
51
97
|
|
|
98
|
+
// Tentative de validation stateless d'abord
|
|
99
|
+
$ticketData = self::parseStatelessTicket($ticket);
|
|
100
|
+
if ($ticketData !== null) {
|
|
101
|
+
$expiry = $ticketData['expiry'] ?? null;
|
|
102
|
+
$originalIp = $ticketData['originalIp'] ?? null;
|
|
103
|
+
$storedDeviceId = $ticketData['deviceId'] ?? '';
|
|
104
|
+
$storedDeviceHash = $ticketData['deviceHash'] ?? '';
|
|
105
|
+
|
|
106
|
+
if (!$expiry || (int)floor(microtime(true) * 1000) > (int)$expiry) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if ($ip === $originalIp) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
$currentSubnet = RequestUtils::getIpSubnet($ip);
|
|
113
|
+
$originalSubnet = RequestUtils::getIpSubnet($originalIp);
|
|
114
|
+
if ($currentSubnet !== null && $originalSubnet !== null && $currentSubnet === $originalSubnet) {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
if (!$allowCrossNetworkRoaming) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return !empty($deviceId) && $deviceId === $storedDeviceId && !empty($deviceHash) && $deviceHash === $storedDeviceHash;
|
|
121
|
+
}
|
|
122
|
+
|
|
52
123
|
$store = StoreManager::getStore();
|
|
53
124
|
$ticketData = $store->get("ticket:{$ticket}");
|
|
54
125
|
|
|
@@ -152,27 +223,21 @@ class ChallengeUtils
|
|
|
152
223
|
$finalBlock = $baseBlock . $solution;
|
|
153
224
|
$hash = hash('sha256', $finalBlock);
|
|
154
225
|
|
|
155
|
-
|
|
156
|
-
$
|
|
157
|
-
|
|
158
|
-
$isValid = $hashAsInt->compareTo($targetAsInt) < 0;
|
|
226
|
+
// Pad target to 64 hex characters to allow direct O(1) lexicographical comparison
|
|
227
|
+
$paddedTarget = str_pad($cpuTargetHex, 64, '0', STR_PAD_LEFT);
|
|
228
|
+
$isValid = strcmp($hash, $paddedTarget) < 0;
|
|
159
229
|
|
|
160
230
|
if ($isValid) {
|
|
161
231
|
error_log('[FP Server Verify] CPU PoW verification PASSED.');
|
|
162
232
|
|
|
163
|
-
// Génération d'un jeton opaque et unique
|
|
164
|
-
$ticketId = bin2hex(random_bytes(16));
|
|
165
233
|
$expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
|
|
166
|
-
|
|
167
|
-
$store = StoreManager::getStore();
|
|
168
|
-
$store->set("ticket:{$ticketId}", [
|
|
234
|
+
$payload = [
|
|
169
235
|
'expiry' => $expiry,
|
|
170
236
|
'originalIp' => $clientIp,
|
|
171
237
|
'deviceId' => $deviceId,
|
|
172
238
|
'deviceHash' => $deviceHash
|
|
173
|
-
]
|
|
174
|
-
|
|
175
|
-
return $ticketId;
|
|
239
|
+
];
|
|
240
|
+
return self::generateStatelessTicket($payload);
|
|
176
241
|
}
|
|
177
242
|
|
|
178
243
|
// Log details on failure
|
|
@@ -40,6 +40,8 @@ class SecurityProfiles
|
|
|
40
40
|
'clickVarianceScore' => 0.6, // Poids pour la variance des clics
|
|
41
41
|
'subnetScore' => 0.5, // Pénalise les sous-réseaux IP avec une activité suspecte agrégée
|
|
42
42
|
'botnetClusterScore' => 0.6, // NOUVEAU: Poids pour le clustering botnet
|
|
43
|
+
'tcpAnomalyScore' => 0.8, // NEW: Anomalie de pile TCP/IP
|
|
44
|
+
|
|
43
45
|
],
|
|
44
46
|
'thresholds' => ['low' => 20, 'medium' => 45, 'high' => 75, 'block' => 95],
|
|
45
47
|
'patterns' => [
|
|
@@ -84,6 +86,8 @@ class SecurityProfiles
|
|
|
84
86
|
'clickVarianceScore' => 0.7, // High weight for click variance
|
|
85
87
|
'subnetScore' => 0.7, // Poids plus élevé en mode strict
|
|
86
88
|
'botnetClusterScore' => 0.8, // NOUVEAU: Poids pour le clustering botnet
|
|
89
|
+
'tcpAnomalyScore' => 1.0, // NEW: Anomalie de pile TCP/IP
|
|
90
|
+
|
|
87
91
|
],
|
|
88
92
|
'thresholds' => ['low' => 10, 'medium' => 35, 'high' => 65, 'block' => 90],
|
|
89
93
|
'patterns' => [
|
|
@@ -128,6 +132,8 @@ class SecurityProfiles
|
|
|
128
132
|
'clickVarianceScore' => 0.3, // Low weight as not applicable to APIs
|
|
129
133
|
'subnetScore' => 0.8, // Très important pour les API pour détecter les botnets
|
|
130
134
|
'botnetClusterScore' => 0.7, // NOUVEAU: Poids pour le clustering botnet
|
|
135
|
+
'tcpAnomalyScore' => 0.8, // NEW: Anomalie de pile TCP/IP
|
|
136
|
+
|
|
131
137
|
],
|
|
132
138
|
'thresholds' => ['low' => 25, 'medium' => 50, 'high' => 80, 'block' => 95],
|
|
133
139
|
'patterns' => [
|
|
@@ -174,6 +180,8 @@ class SecurityProfiles
|
|
|
174
180
|
'clickVarianceScore' => 0.5, // Moderate weight for click variance
|
|
175
181
|
'subnetScore' => 0.4, // Utile contre le spam de commentaires coordonné
|
|
176
182
|
'botnetClusterScore' => 0.5, // NOUVEAU: Poids pour le clustering botnet
|
|
183
|
+
'tcpAnomalyScore' => 0.5, // NEW: Anomalie de pile TCP/IP
|
|
184
|
+
|
|
177
185
|
],
|
|
178
186
|
'thresholds' => ['low' => 25, 'medium' => 55, 'high' => 80, 'block' => 95],
|
|
179
187
|
'patterns' => [
|
|
@@ -219,6 +227,8 @@ class SecurityProfiles
|
|
|
219
227
|
'clickVarianceScore' => 0.8, // Very high weight for click variance
|
|
220
228
|
'subnetScore' => 0.9, // Crucial contre les attaques de scalping distribuées
|
|
221
229
|
'botnetClusterScore' => 0.9, // NOUVEAU: Poids pour le clustering botnet
|
|
230
|
+
'tcpAnomalyScore' => 0.9, // NEW: Anomalie de pile TCP/IP
|
|
231
|
+
|
|
222
232
|
],
|
|
223
233
|
'thresholds' => ['low' => 15, 'medium' => 40, 'high' => 70, 'block' => 90],
|
|
224
234
|
'patterns' => [
|
|
@@ -425,6 +425,9 @@
|
|
|
425
425
|
// NOUVEAU: Score de réputation du sous-réseau IP
|
|
426
426
|
$subnetScore = RequestUtils::getSubnetScore($context, $deviceId);
|
|
427
427
|
|
|
428
|
+
// Score d'anomalie de pile TCP/IP
|
|
429
|
+
$tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
|
|
430
|
+
|
|
428
431
|
// Assemblage du vecteur de suspicion final
|
|
429
432
|
$suspicionVector = array_merge($suspicionVector, [
|
|
430
433
|
'inconsistencyScore' => $inconsistencyScore,
|
|
@@ -443,6 +446,7 @@
|
|
|
443
446
|
'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
|
|
444
447
|
'subnetScore' => $subnetScore['subnetScore'],
|
|
445
448
|
'botnetClusterScore' => $botnetCluster['botnetClusterScore'],
|
|
449
|
+
'tcpAnomalyScore' => $tcpAnomaly['tcpAnomalyScore'],
|
|
446
450
|
]);
|
|
447
451
|
|
|
448
452
|
// Sauvegarder l'état mis à jour de l'appareil dans le store
|
|
@@ -294,7 +294,7 @@ class FingerprintEngineTest extends TestCase
|
|
|
294
294
|
'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
|
|
295
295
|
'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
|
|
296
296
|
]),
|
|
297
|
-
|
|
297
|
+
96.7,
|
|
298
298
|
'Should return 80 for a large version mismatch (>5).'
|
|
299
299
|
],
|
|
300
300
|
'small version mismatch' => [
|
|
@@ -328,4 +328,30 @@ class FingerprintEngineTest extends TestCase
|
|
|
328
328
|
],
|
|
329
329
|
];
|
|
330
330
|
}
|
|
331
|
+
|
|
332
|
+
public function testDetectsTcpAnomalyScore(): void
|
|
333
|
+
{
|
|
334
|
+
$firstContext = $this->createRequestContext();
|
|
335
|
+
$firstDecision = $this->engine->processRequest($firstContext);
|
|
336
|
+
$this->assertArrayHasKey('newCookieForResponse', $firstDecision);
|
|
337
|
+
$deviceIdCookie = $firstDecision['newCookieForResponse'];
|
|
338
|
+
|
|
339
|
+
// Trame binaire TCP SYN Linux simulée (TTL=64, WS=7)
|
|
340
|
+
$linuxSynHex = '4500003c1a2b400040063c1a7f0000017f0000011f9000500000000100000000a00272103c1a0000020405b4040201030307';
|
|
341
|
+
|
|
342
|
+
$context = $this->createRequestContext([
|
|
343
|
+
'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
|
|
344
|
+
'headers' => [
|
|
345
|
+
'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
|
|
346
|
+
'accept-language' => 'en-US,en;q=0.9',
|
|
347
|
+
'x-raw-tcp-binary' => $linuxSynHex
|
|
348
|
+
]
|
|
349
|
+
]);
|
|
350
|
+
|
|
351
|
+
$decision = $this->engine->processRequest($context);
|
|
352
|
+
|
|
353
|
+
$this->assertEquals(80.1, $decision['vector']['tcpAnomalyScore']);
|
|
354
|
+
// Le poids de tcpAnomalyScore dans le profil balanced est de 0.8 (80 * 0.8 = 64)
|
|
355
|
+
$this->assertGreaterThanOrEqual(64.0, $decision['score']);
|
|
356
|
+
}
|
|
331
357
|
}
|
|
@@ -1,157 +1,176 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
declare(strict_types=1);
|
|
4
|
-
|
|
5
|
-
namespace Anonympins\Fingerprint\Tests;
|
|
6
|
-
|
|
7
|
-
use Anonympins\Fingerprint\RequestContext;
|
|
8
|
-
use Anonympins\Fingerprint\Store\IStore;
|
|
9
|
-
use Anonympins\Fingerprint\Store\StoreManager;
|
|
10
|
-
use Anonympins\Fingerprint\Utils\RequestUtils;
|
|
11
|
-
use PHPUnit\Framework\TestCase;
|
|
12
|
-
|
|
13
|
-
class IpReputationTest extends TestCase
|
|
14
|
-
{
|
|
15
|
-
private $store;
|
|
16
|
-
|
|
17
|
-
protected function setUp(): void
|
|
18
|
-
{
|
|
19
|
-
parent::setUp();
|
|
20
|
-
|
|
21
|
-
// Un mock anonyme simple et en mémoire du Store pour isoler les tests
|
|
22
|
-
$this->store = new class implements IStore {
|
|
23
|
-
private array $data = [];
|
|
24
|
-
public function get(string $key) { return $this->data[$key] ?? null; }
|
|
25
|
-
public function set(string $key, $value, ?int $ttl = null): void { $this->data[$key] = $value; }
|
|
26
|
-
public function has(string $key): bool { return isset($this->data[$key]); }
|
|
27
|
-
public function delete(string $key): void { unset($this->data[$key]); }
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
StoreManager::setStore($this->store);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
public function testGetScoreReturnsZeroForUnknownIp(): void
|
|
34
|
-
{
|
|
35
|
-
$score = RequestUtils::getIpReputationScore('8.8.8.8');
|
|
36
|
-
$this->assertEquals(0.0, $score);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
public function testUpdateScoreChangesValueCorrectly(): void
|
|
40
|
-
{
|
|
41
|
-
$ip = '8.8.4.4';
|
|
42
|
-
RequestUtils::updateIpReputationScore($ip, 45.0);
|
|
43
|
-
$this->assertEquals(45.0, RequestUtils::getIpReputationScore($ip));
|
|
44
|
-
|
|
45
|
-
RequestUtils::updateIpReputationScore($ip, -15.0);
|
|
46
|
-
$this->assertEquals(30.0, RequestUtils::getIpReputationScore($ip));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
public function testBoundsAreClampedBetween0And100(): void
|
|
50
|
-
{
|
|
51
|
-
$ip = '1.1.1.1';
|
|
52
|
-
RequestUtils::updateIpReputationScore($ip, 120.0);
|
|
53
|
-
$this->assertEquals(100.0, RequestUtils::getIpReputationScore($ip));
|
|
54
|
-
|
|
55
|
-
RequestUtils::updateIpReputationScore($ip, -150.0);
|
|
56
|
-
$this->assertEquals(0.0, RequestUtils::getIpReputationScore($ip));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
public function testTimeDecayCalculatesLossOfTwoPointsPerHour(): void
|
|
60
|
-
{
|
|
61
|
-
$ip = '2.2.2.2';
|
|
62
|
-
$now = time();
|
|
63
|
-
|
|
64
|
-
// Simulation d'un score de 80 vieux de 4 heures (4 * 2 = 8 points de perte)
|
|
65
|
-
$this->store->set("ip-reputation:{$ip}", [
|
|
66
|
-
'score' => 80.0,
|
|
67
|
-
'lastUpdate' => $now - 14400
|
|
68
|
-
]);
|
|
69
|
-
|
|
70
|
-
$score = RequestUtils::getIpReputationScore($ip);
|
|
71
|
-
$this->assertEquals(72.0, $score);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
public function testIpReputationScoreIsIntegratedIntoFinalScore(): void
|
|
75
|
-
{
|
|
76
|
-
$ip = '1.2.3.4';
|
|
77
|
-
RequestUtils::updateIpReputationScore($ip, 60.0);
|
|
78
|
-
|
|
79
|
-
$weights = [
|
|
80
|
-
'ipReputationScore' => 0.5,
|
|
81
|
-
'historyScore' => 0.0,
|
|
82
|
-
'rotationScore' => 0.0,
|
|
83
|
-
'headerAnomalyScore' => 0.0,
|
|
84
|
-
'requestPatternScore' => 0.0,
|
|
85
|
-
'inconsistencyScore' => 0.0,
|
|
86
|
-
'honeypotScore' => 0.0,
|
|
87
|
-
'behaviorScore' => 0.0,
|
|
88
|
-
'botScore' => 0.0,
|
|
89
|
-
'crossLayerInconsistencyScore' => 0.0,
|
|
90
|
-
'tlsSpoofingScore' => 0.0,
|
|
91
|
-
'timeInconsistencyScore' => 0.0,
|
|
92
|
-
'clickVarianceScore' => 0.0,
|
|
93
|
-
'clientHintsInconsistencyScore' => 0.0,
|
|
94
|
-
'subnetScore' => 0.0,
|
|
95
|
-
];
|
|
96
|
-
|
|
97
|
-
$ipRepScore = RequestUtils::getIpReputationScore($ip);
|
|
98
|
-
$this->assertEquals(60.0, $ipRepScore);
|
|
99
|
-
$score = $ipRepScore * $weights['ipReputationScore'];
|
|
100
|
-
$this->assertEquals(30.0, $score);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
public function testGetIpSubnetIpv4AndIpv6(): void
|
|
104
|
-
{
|
|
105
|
-
$ipv4Subnet = RequestUtils::getIpSubnet('192.168.1.50', 24, 48);
|
|
106
|
-
$this->assertEquals('192.168.1.0/24', $ipv4Subnet);
|
|
107
|
-
|
|
108
|
-
$ipv6Subnet = RequestUtils::getIpSubnet('2001:db8:abcd:0012::1', 24, 48);
|
|
109
|
-
$this->assertEquals('2001:db8:abcd::/48', $ipv6Subnet);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
public function testGetClientHintsInconsistencyScoreMismatch(): void
|
|
113
|
-
{
|
|
114
|
-
$context = $this->createMock(RequestContext::class);
|
|
115
|
-
$context->method('getHeader')->willReturnCallback(function($name) {
|
|
116
|
-
if ($name === 'user-agent') {
|
|
117
|
-
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/117.0';
|
|
118
|
-
}
|
|
119
|
-
if ($name === 'sec-ch-ua') {
|
|
120
|
-
return '"Google Chrome";v="117"';
|
|
121
|
-
}
|
|
122
|
-
return null;
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
$score = RequestUtils::getClientHintsInconsistencyScore($context);
|
|
126
|
-
$this->assertEquals(90.0, $score['clientHintsInconsistencyScore']);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
public function
|
|
130
|
-
{
|
|
131
|
-
$context = $this->createMock(RequestContext::class);
|
|
132
|
-
$context->
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
$context->
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
$
|
|
155
|
-
|
|
156
|
-
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Anonympins\Fingerprint\Tests;
|
|
6
|
+
|
|
7
|
+
use Anonympins\Fingerprint\RequestContext;
|
|
8
|
+
use Anonympins\Fingerprint\Store\IStore;
|
|
9
|
+
use Anonympins\Fingerprint\Store\StoreManager;
|
|
10
|
+
use Anonympins\Fingerprint\Utils\RequestUtils;
|
|
11
|
+
use PHPUnit\Framework\TestCase;
|
|
12
|
+
|
|
13
|
+
class IpReputationTest extends TestCase
|
|
14
|
+
{
|
|
15
|
+
private $store;
|
|
16
|
+
|
|
17
|
+
protected function setUp(): void
|
|
18
|
+
{
|
|
19
|
+
parent::setUp();
|
|
20
|
+
|
|
21
|
+
// Un mock anonyme simple et en mémoire du Store pour isoler les tests
|
|
22
|
+
$this->store = new class implements IStore {
|
|
23
|
+
private array $data = [];
|
|
24
|
+
public function get(string $key) { return $this->data[$key] ?? null; }
|
|
25
|
+
public function set(string $key, $value, ?int $ttl = null): void { $this->data[$key] = $value; }
|
|
26
|
+
public function has(string $key): bool { return isset($this->data[$key]); }
|
|
27
|
+
public function delete(string $key): void { unset($this->data[$key]); }
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
StoreManager::setStore($this->store);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public function testGetScoreReturnsZeroForUnknownIp(): void
|
|
34
|
+
{
|
|
35
|
+
$score = RequestUtils::getIpReputationScore('8.8.8.8');
|
|
36
|
+
$this->assertEquals(0.0, $score);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public function testUpdateScoreChangesValueCorrectly(): void
|
|
40
|
+
{
|
|
41
|
+
$ip = '8.8.4.4';
|
|
42
|
+
RequestUtils::updateIpReputationScore($ip, 45.0);
|
|
43
|
+
$this->assertEquals(45.0, RequestUtils::getIpReputationScore($ip));
|
|
44
|
+
|
|
45
|
+
RequestUtils::updateIpReputationScore($ip, -15.0);
|
|
46
|
+
$this->assertEquals(30.0, RequestUtils::getIpReputationScore($ip));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public function testBoundsAreClampedBetween0And100(): void
|
|
50
|
+
{
|
|
51
|
+
$ip = '1.1.1.1';
|
|
52
|
+
RequestUtils::updateIpReputationScore($ip, 120.0);
|
|
53
|
+
$this->assertEquals(100.0, RequestUtils::getIpReputationScore($ip));
|
|
54
|
+
|
|
55
|
+
RequestUtils::updateIpReputationScore($ip, -150.0);
|
|
56
|
+
$this->assertEquals(0.0, RequestUtils::getIpReputationScore($ip));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public function testTimeDecayCalculatesLossOfTwoPointsPerHour(): void
|
|
60
|
+
{
|
|
61
|
+
$ip = '2.2.2.2';
|
|
62
|
+
$now = time();
|
|
63
|
+
|
|
64
|
+
// Simulation d'un score de 80 vieux de 4 heures (4 * 2 = 8 points de perte)
|
|
65
|
+
$this->store->set("ip-reputation:{$ip}", [
|
|
66
|
+
'score' => 80.0,
|
|
67
|
+
'lastUpdate' => $now - 14400
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
$score = RequestUtils::getIpReputationScore($ip);
|
|
71
|
+
$this->assertEquals(72.0, $score);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public function testIpReputationScoreIsIntegratedIntoFinalScore(): void
|
|
75
|
+
{
|
|
76
|
+
$ip = '1.2.3.4';
|
|
77
|
+
RequestUtils::updateIpReputationScore($ip, 60.0);
|
|
78
|
+
|
|
79
|
+
$weights = [
|
|
80
|
+
'ipReputationScore' => 0.5,
|
|
81
|
+
'historyScore' => 0.0,
|
|
82
|
+
'rotationScore' => 0.0,
|
|
83
|
+
'headerAnomalyScore' => 0.0,
|
|
84
|
+
'requestPatternScore' => 0.0,
|
|
85
|
+
'inconsistencyScore' => 0.0,
|
|
86
|
+
'honeypotScore' => 0.0,
|
|
87
|
+
'behaviorScore' => 0.0,
|
|
88
|
+
'botScore' => 0.0,
|
|
89
|
+
'crossLayerInconsistencyScore' => 0.0,
|
|
90
|
+
'tlsSpoofingScore' => 0.0,
|
|
91
|
+
'timeInconsistencyScore' => 0.0,
|
|
92
|
+
'clickVarianceScore' => 0.0,
|
|
93
|
+
'clientHintsInconsistencyScore' => 0.0,
|
|
94
|
+
'subnetScore' => 0.0,
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
$ipRepScore = RequestUtils::getIpReputationScore($ip);
|
|
98
|
+
$this->assertEquals(60.0, $ipRepScore);
|
|
99
|
+
$score = $ipRepScore * $weights['ipReputationScore'];
|
|
100
|
+
$this->assertEquals(30.0, $score);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public function testGetIpSubnetIpv4AndIpv6(): void
|
|
104
|
+
{
|
|
105
|
+
$ipv4Subnet = RequestUtils::getIpSubnet('192.168.1.50', 24, 48);
|
|
106
|
+
$this->assertEquals('192.168.1.0/24', $ipv4Subnet);
|
|
107
|
+
|
|
108
|
+
$ipv6Subnet = RequestUtils::getIpSubnet('2001:db8:abcd:0012::1', 24, 48);
|
|
109
|
+
$this->assertEquals('2001:db8:abcd::/48', $ipv6Subnet);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public function testGetClientHintsInconsistencyScoreMismatch(): void
|
|
113
|
+
{
|
|
114
|
+
$context = $this->createMock(RequestContext::class);
|
|
115
|
+
$context->method('getHeader')->willReturnCallback(function($name) {
|
|
116
|
+
if ($name === 'user-agent') {
|
|
117
|
+
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/117.0';
|
|
118
|
+
}
|
|
119
|
+
if ($name === 'sec-ch-ua') {
|
|
120
|
+
return '"Google Chrome";v="117"';
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
$score = RequestUtils::getClientHintsInconsistencyScore($context);
|
|
126
|
+
$this->assertEquals(90.0, $score['clientHintsInconsistencyScore']);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public function testGetClientHintsInconsistencyFullVersionMismatch(): void
|
|
130
|
+
{
|
|
131
|
+
$context = $this->createMock(RequestContext::class);
|
|
132
|
+
$context->method('getHeader')->willReturnCallback(function($name) {
|
|
133
|
+
if ($name === 'user-agent') {
|
|
134
|
+
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.1.2 Safari/537.36';
|
|
135
|
+
}
|
|
136
|
+
if ($name === 'sec-ch-ua') {
|
|
137
|
+
return '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"';
|
|
138
|
+
}
|
|
139
|
+
if ($name === 'sec-ch-ua-full-version-list') {
|
|
140
|
+
return '"Not_A Brand";v="8.0.0.0", "Chromium";v="120.0.1.3", "Google Chrome";v="120.0.1.3"';
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
});
|
|
144
|
+
$score = RequestUtils::getClientHintsInconsistencyScore($context);
|
|
145
|
+
$this->assertEquals(85.0, $score['clientHintsInconsistencyScore']);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public function testGetSubnetScoreCalculations(): void
|
|
149
|
+
{
|
|
150
|
+
$context = $this->createMock(RequestContext::class);
|
|
151
|
+
$context->clientIp = '192.168.1.50';
|
|
152
|
+
|
|
153
|
+
$score = RequestUtils::getSubnetScore($context, 'device-1');
|
|
154
|
+
$this->assertEquals(0.0, $score['subnetScore']);
|
|
155
|
+
|
|
156
|
+
for ($i = 1; $i <= 12; $i++) {
|
|
157
|
+
RequestUtils::updateSubnetMetrics($context, "device-{$i}", 30.0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
$scoreWithHistory = RequestUtils::getSubnetScore($context, 'device-1');
|
|
161
|
+
$this->assertGreaterThan(0.0, $scoreWithHistory['subnetScore']);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public function testTlsSpoofingScoreWithJa4(): void
|
|
165
|
+
{
|
|
166
|
+
$context = $this->createMock(RequestContext::class);
|
|
167
|
+
$context->ja4 = 't13d1517h2_8daaf61527d5';
|
|
168
|
+
$context->ja3 = null;
|
|
169
|
+
$context->method('getHeader')->willReturnCallback(function($name) {
|
|
170
|
+
return $name === 'user-agent' ? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/117.0' : null;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
$score = RequestUtils::getTlsSpoofingScore($context);
|
|
174
|
+
$this->assertEquals(90.0, $score['tlsSpoofingScore']);
|
|
175
|
+
}
|
|
157
176
|
}
|