@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.
@@ -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
- $hashAsInt = BigInt::fromHex($hash);
156
- $targetAsInt = BigInt::fromHex($cpuTargetHex);
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
- ], (int)ceil($ticketTtl / 1000));
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
- 80.0,
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 testGetSubnetScoreCalculations(): void
130
- {
131
- $context = $this->createMock(RequestContext::class);
132
- $context->clientIp = '192.168.1.50';
133
-
134
- $score = RequestUtils::getSubnetScore($context, 'device-1');
135
- $this->assertEquals(0.0, $score['subnetScore']);
136
-
137
- for ($i = 1; $i <= 12; $i++) {
138
- RequestUtils::updateSubnetMetrics($context, "device-{$i}", 30.0);
139
- }
140
-
141
- $scoreWithHistory = RequestUtils::getSubnetScore($context, 'device-1');
142
- $this->assertGreaterThan(0.0, $scoreWithHistory['subnetScore']);
143
- }
144
-
145
- public function testTlsSpoofingScoreWithJa4(): void
146
- {
147
- $context = $this->createMock(RequestContext::class);
148
- $context->ja4 = 't13d1517h2_8daaf61527d5';
149
- $context->ja3 = null;
150
- $context->method('getHeader')->willReturnCallback(function($name) {
151
- return $name === 'user-agent' ? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/117.0' : null;
152
- });
153
-
154
- $score = RequestUtils::getTlsSpoofingScore($context);
155
- $this->assertEquals(90.0, $score['tlsSpoofingScore']);
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
  }