@anonympins/fingerprint 0.6.7 → 0.7.1

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 (63) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +3 -7
  3. package/composer.json +8 -1
  4. package/package.json +1 -1
  5. package/public/fingerprint-wordpress.zip +0 -0
  6. package/public/fp.wasm +0 -0
  7. package/src/js/build-client.js +9 -11
  8. package/src/js/dynamic-wasm.js +7 -7
  9. package/src/js/fingerprint.builder.js +37 -38
  10. package/src/js/fingerprint.client.js +236 -152
  11. package/src/js/fingerprint.client.obfuscated.js +1 -1
  12. package/src/js/fingerprint.js +517 -101
  13. package/src/js/fingerprint.utils.js +213 -213
  14. package/src/js/library.js +355 -384
  15. package/src/js/mongodb-store.js +1 -1
  16. package/src/js/optimization.worker.js +27 -27
  17. package/src/js/pow.solver.inline.js +206 -62
  18. package/src/js/pow.solver.js +235 -101
  19. package/src/js/pow.worker.js +48 -26
  20. package/src/js/redis-store.js +1 -1
  21. package/src/js/tests/fingerprint.client.init.test.js +146 -143
  22. package/src/js/tests/fingerprint.test.js +101 -40
  23. package/src/js/tests/ip-reputation.test.js +1 -0
  24. package/src/js/upow-model-task.js +14 -15
  25. package/src/php/AutoTuner.php +34 -34
  26. package/src/php/Challenge/ChallengeUtils.php +204 -32
  27. package/src/php/Config/SecurityProfiles.php +21 -0
  28. package/src/php/DirectFingerprint.php +190 -145
  29. package/src/php/FingerprintBuilder.php +29 -30
  30. package/src/php/FingerprintClient.php +24 -25
  31. package/src/php/FingerprintEngine.php +1836 -1880
  32. package/src/php/Ja3AnomalyDetector.php +258 -227
  33. package/src/php/Optimization/FunctionRegistry.php +7 -7
  34. package/src/php/Optimization/Optimization.php +24 -24
  35. package/src/php/Optimization/OptimizationOperators.php +28 -27
  36. package/src/php/Optimization/ProblemInitializers.php +52 -52
  37. package/src/php/ProblemManager.php +31 -34
  38. package/src/php/RequestContext.php +8 -1
  39. package/src/php/Store/IStore.php +7 -8
  40. package/src/php/Store/InMemoryStore.php +66 -66
  41. package/src/php/Store/MongoDbStore.php +5 -5
  42. package/src/php/Store/RedisStore.php +5 -5
  43. package/src/php/Store/StoreManager.php +35 -35
  44. package/src/php/Tests/ChallengeUtilsTest.php +453 -453
  45. package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
  46. package/src/php/Tests/FingerprintEngineTest.php +662 -693
  47. package/src/php/Tests/IpReputationTest.php +10 -0
  48. package/src/php/Tests/MaliciousPatternsTest.php +103 -103
  49. package/src/php/Tests/MetricsTest.php +97 -97
  50. package/src/php/Tests/ProblemManagerTest.php +376 -376
  51. package/src/php/Tests/QuicFingerprintTest.php +53 -53
  52. package/src/php/Tests/RequestUtilsTest.php +471 -471
  53. package/src/php/Tests/TLSClientHelloParserTest.php +177 -177
  54. package/src/php/Tests/config/ed25519_key.json +3 -3
  55. package/src/php/Utils/Env.php +49 -49
  56. package/src/php/Utils/MaliciousPatterns.php +74 -74
  57. package/src/php/Utils/MetricsManager.php +209 -209
  58. package/src/php/Utils/RequestUtils.php +236 -19
  59. package/src/php/Utils/TLSClientHelloParser.php +369 -212
  60. package/src/php/WordPress/WpDbStore.php +155 -0
  61. package/src/php/WordPress/fingerprint-wordpress.php +217 -0
  62. package/src/php/WordPress/package.php +123 -0
  63. package/src/php/bin/auto-tune.php +118 -118
@@ -1,472 +1,472 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- namespace Anonympins\Fingerprint\Tests;
6
-
7
- use Anonympins\Fingerprint\FingerprintBuilder;
8
- use Anonympins\Fingerprint\RequestContext;
9
- use Anonympins\Fingerprint\Utils\RequestUtils;
10
- use PHPUnit\Framework\TestCase;
11
-
12
- class RequestUtilsTest extends TestCase
13
- {
14
- protected function setUp(): void
15
- {
16
- parent::setUp();
17
- \Anonympins\Fingerprint\Store\StoreManager::configureStore(new \Anonympins\Fingerprint\Store\InMemoryStore());
18
- }
19
-
20
- private function createRequestContext(array $overrides = []): RequestContext
21
- {
22
- $defaults = [
23
- 'clientIp' => '127.0.0.1',
24
- 'path' => '/',
25
- 'headers' => ['user-agent' => 'Test UA'],
26
- 'query' => [],
27
- 'body' => null,
28
- 'cookies' => [],
29
- 'httpVersion' => '1.1',
30
- ];
31
- $params = array_merge($defaults, $overrides);
32
- return new RequestContext(
33
- $params['clientIp'], $params['path'], $params['headers'],
34
- $params['query'], $params['body'], $params['cookies'], $params['httpVersion']
35
- );
36
- }
37
-
38
- public function testGetCrossLayerInconsistencyViewportExceedsScreen(): void
39
- {
40
- $scrHash = FingerprintBuilder::cyrb53("1920x1080_24");
41
- $context = $this->createRequestContext([
42
- 'headers' => [
43
- 'x-device-fingerprint' => "scr:{$scrHash}",
44
- 'sec-ch-viewport-width' => '2560'
45
- ]
46
- ]);
47
-
48
- $score = RequestUtils::getCrossLayerInconsistency($context);
49
- $this->assertEquals(20.0, $score['crossLayerInconsistencyScore']);
50
- }
51
- public function testGetRenderingAnomalyScoreEmpty(): void
52
- {
53
- $context = $this->createRequestContext();
54
- $res = RequestUtils::getRenderingAnomalyScore($context);
55
- $this->assertEquals(0.0, $res['renderingAnomalyScore']);
56
- }
57
-
58
- public function testGetRenderingAnomalyScoreSpoofed(): void
59
- {
60
- $metrics = [
61
- 'rendering' => [
62
- 'fps' => 60,
63
- 'jitter' => 12.5,
64
- 'offscreenAnom' => false
65
- ]
66
- ];
67
- $context = $this->createRequestContext([
68
- 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
69
- ]);
70
- $res = RequestUtils::getRenderingAnomalyScore($context);
71
- $this->assertEquals(65.0, $res['renderingAnomalyScore']);
72
- }
73
- public function testGetClickVarianceScoreReturnsZeroForNoHistory(): void
74
- {
75
- $context = $this->createRequestContext([
76
- 'headers' => ['x-behavior-metrics' => json_encode([])]
77
- ]);
78
- $result = RequestUtils::getClickVarianceScore($context);
79
- $this->assertEquals(0.0, $result['clickVarianceScore']);
80
- }
81
-
82
- public function testGetClickVarianceScoreReturnsZeroForInsufficientClicks(): void
83
- {
84
- $metrics = [
85
- 'clicksHistory' => [
86
- ['x' => 10, 'y' => 10, 'targetId' => 'hash1'],
87
- ['x' => 11, 'y' => 11, 'targetId' => 'hash1'],
88
- ['x' => 100, 'y' => 100, 'targetId' => 'hash2']
89
- ]
90
- ];
91
- $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
92
- $result = RequestUtils::getClickVarianceScore($context);
93
- $this->assertEquals(0.0, $result['clickVarianceScore']);
94
- }
95
-
96
- public function testGetClickVarianceScoreReturnsHighScoreForLowVariance(): void
97
- {
98
- $metrics = [
99
- 'clicksHistory' => [
100
- ['x' => 100, 'y' => 100, 'targetId' => 'hash1'],
101
- ['x' => 100.1, 'y' => 100.2, 'targetId' => 'hash1'],
102
- ['x' => 99.9, 'y' => 99.8, 'targetId' => 'hash1']
103
- ]
104
- ];
105
- $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
106
- $result = RequestUtils::getClickVarianceScore($context);
107
- $this->assertGreaterThan(90, $result['clickVarianceScore']);
108
- }
109
-
110
- public function testGetClickVarianceScoreReturnsLowScoreForHighVariance(): void
111
- {
112
- $metrics = [
113
- 'clicksHistory' => [
114
- ['x' => 105, 'y' => 110, 'targetId' => 'hash1'],
115
- ['x' => 98, 'y' => 102, 'targetId' => 'hash1'],
116
- ['x' => 112, 'y' => 95, 'targetId' => 'hash1']
117
- ]
118
- ];
119
- $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
120
- $result = RequestUtils::getClickVarianceScore($context);
121
- $this->assertEquals(0.0, $result['clickVarianceScore']);
122
- }
123
-
124
- public function testSanitizeTrafficDataFiltersSybilAttacks(): void
125
- {
126
- $trafficData = [];
127
- // Ajout de 100 logs provenant d'un attaquant (Sybil)
128
- for ($i = 0; $i < 100; $i++) {
129
- $trafficData[] = ['deviceId' => 'attacker_device', 'type' => 'trap_triggered'];
130
- }
131
- // Ajout de 10 logs d'utilisateurs légitimes distincts
132
- for ($i = 0; $i < 10; $i++) {
133
- $trafficData[] = ['deviceId' => "legit_device_{$i}", 'type' => 'challenge_solved'];
134
- }
135
-
136
- $sanitized = RequestUtils::sanitizeTrafficData($trafficData);
137
-
138
- $attackerLogs = array_filter($sanitized, fn($log) => $log['deviceId'] === 'attacker_device');
139
-
140
- // Total de 110 logs. 2% de 110 est 2.2 -> max(3, 2) = 3 logs maximum autorisés pour l'attaquant.
141
- $this->assertLessThanOrEqual(3, count($attackerLogs));
142
- }
143
-
144
- public function testSanitizeTrafficDataFiltersHardwareClusters(): void
145
- {
146
- $trafficData = [];
147
- // Simulated attacker rotating deviceId and IP, but keeping identical hardware fingerprint
148
- for ($i = 0; $i < 100; $i++) {
149
- $trafficData[] = [
150
- 'deviceId' => "attacker_device_{$i}",
151
- 'clientIp' => "192.168.1.{$i}",
152
- 'type' => 'trap_triggered',
153
- 'fingerprint' => 'gpu:heavy_nvidia_hash|cvs:canvas_hash_1'
154
- ];
155
- }
156
- // Legit users with unique hardware fingerprints
157
- for ($i = 0; $i < 10; $i++) {
158
- $trafficData[] = [
159
- 'deviceId' => "legit_device_{$i}",
160
- 'clientIp' => "10.0.0.{$i}",
161
- 'type' => 'challenge_solved',
162
- 'fingerprint' => "gpu:legit_gpu_{$i}|cvs:legit_cvs_{$i}"
163
- ];
164
- }
165
-
166
- $sanitized = RequestUtils::sanitizeTrafficData($trafficData);
167
- $attackerLogs = array_filter($sanitized, fn($log) => str_starts_with($log['deviceId'], 'attacker_device_'));
168
-
169
- // Max limit of 2% of 110 total = 2.2 -> max(3, 2) = 3 logs allowed for this hardware cluster
170
- $this->assertLessThanOrEqual(3, count($attackerLogs));
171
- }
172
-
173
- public function testChallengePayloadSigningAndVerification(): void
174
- {
175
- $secret = 'test-fallback-dev-secret-32-chars-minimum';
176
- $clientIp = '203.0.113.42';
177
- $payload = [
178
- 'clientSecret' => 'some_client_secret',
179
- 'cpuTarget' => '00000000ffffffff',
180
- 'fingerprint' => 'os:hash|gpu:hash2',
181
- 'memDifficulty' => '16',
182
- 'originalPath' => '/submit',
183
- ];
184
-
185
- // 1. Cas nominal : Signature et vérification réussies
186
- $signature = RequestUtils::signChallengePayload($secret, $payload, $clientIp);
187
- $this->assertNotEmpty($signature);
188
-
189
- $payloadWithSig = $payload;
190
- $payloadWithSig['signature'] = $signature;
191
-
192
- $isValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, $clientIp);
193
- $this->assertTrue($isValid, "La signature valide doit être acceptée.");
194
-
195
- // 2. Détection de modification (tampering) sur cpuTarget
196
- $tamperedPayload = $payloadWithSig;
197
- $tamperedPayload['cpuTarget'] = 'ffffffffffffffff'; // Tentative de baisse de la difficulté
198
-
199
- $isTamperedValid = RequestUtils::verifyChallengePayload($secret, $tamperedPayload, $clientIp);
200
- $this->assertFalse($isTamperedValid, "Un payload modifié doit être rejeté.");
201
-
202
- // 3. Détection de modification sur le fingerprint
203
- $tamperedFpPayload = $payloadWithSig;
204
- $tamperedFpPayload['fingerprint'] = 'os:another_hash|gpu:hash2';
205
-
206
- $isTamperedFpValid = RequestUtils::verifyChallengePayload($secret, $tamperedFpPayload, $clientIp);
207
- $this->assertFalse($isTamperedFpValid, "Un fingerprint modifié doit être rejeté.");
208
-
209
- // 4. Détection d'usurpation d'adresse IP (IP mismatch)
210
- $isIpMismatchValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, '198.51.100.1');
211
- $this->assertFalse($isIpMismatchValid, "Le payload ne doit pas être valide pour une autre adresse IP.");
212
-
213
- // 5. Absence de signature
214
- $isMissingSigValid = RequestUtils::verifyChallengePayload($secret, $payload, $clientIp);
215
- $this->assertFalse($isMissingSigValid, "Un payload sans signature doit être rejeté.");
216
- }
217
-
218
- public function testGetBehaviorScoreReturnsProperScores(): void
219
- {
220
- $contextEmpty = $this->createRequestContext();
221
- $scoreEmpty = RequestUtils::getBehaviorScore($contextEmpty);
222
- $this->assertEquals(0.0, $scoreEmpty['behaviorScore']);
223
-
224
- $metricsHoneypot = ['honeypotInteraction' => true];
225
- $contextHoneypot = $this->createRequestContext([
226
- 'headers' => ['x-behavior-metrics' => json_encode($metricsHoneypot)]
227
- ]);
228
- $scoreHoneypot = RequestUtils::getBehaviorScore($contextHoneypot);
229
- $this->assertEquals(100.0, $scoreHoneypot['behaviorScore']);
230
-
231
- $metricsNoActivity = ['honeypotInteraction' => false, 'mouseMovementsHistory' => [], 'keystrokeLatency' => 0];
232
- $contextNoActivity = $this->createRequestContext([
233
- 'headers' => ['x-behavior-metrics' => json_encode($metricsNoActivity)]
234
- ]);
235
- $scoreNoActivity = RequestUtils::getBehaviorScore($contextNoActivity);
236
- $this->assertEquals(40.0, $scoreNoActivity['behaviorScore']);
237
- }
238
-
239
- public function testGetBehaviorScoreWithBotLikeKeystrokeDynamics(): void
240
- {
241
- $metrics = [
242
- 'honeypotInteraction' => false,
243
- 'keystrokeLatency' => 100.0,
244
- 'keystrokeDwellTimes' => [50, 50, 50, 50, 50],
245
- 'keystrokeFlightTimes' => [
246
- ['digraph' => 'ab', 'time' => 100],
247
- ['digraph' => 'bc', 'time' => 100],
248
- ['digraph' => 'cd', 'time' => 100],
249
- ['digraph' => 'de', 'time' => 100],
250
- ['digraph' => 'ef', 'time' => 100]
251
- ]
252
- ];
253
- $context = $this->createRequestContext([
254
- 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
255
- ]);
256
- $score = RequestUtils::getBehaviorScore($context);
257
- $this->assertGreaterThanOrEqual(70.0, $score['behaviorScore']);
258
- }
259
-
260
- public function testGetBehaviorScoreWithBotLikeTouchMovements(): void
261
- {
262
- $metrics = [
263
- 'honeypotInteraction' => false,
264
- 'touchMovementsHistory' => [
265
- ['x' => 50, 'y' => 50, 't' => 1, 'p' => 0.5, 'r' => 10, 'num' => 1],
266
- ['x' => 55, 'y' => 55, 't' => 100, 'p' => 0.5, 'r' => 10, 'num' => 1],
267
- ['x' => 60, 'y' => 60, 't' => 200, 'p' => 0.5, 'r' => 10, 'num' => 1],
268
- ['x' => 65, 'y' => 65, 't' => 300, 'p' => 0.5, 'r' => 10, 'num' => 1]
269
- ]
270
- ];
271
- $context = $this->createRequestContext([
272
- 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
273
- ]);
274
- $score = RequestUtils::getBehaviorScore($context);
275
- $this->assertGreaterThan(60.0, $score['behaviorScore']);
276
- }
277
-
278
- public function testGetBehaviorScoreWithHumanLikeTouchMovements(): void
279
- {
280
- $metrics = [
281
- 'honeypotInteraction' => false,
282
- 'touchMovementsHistory' => [
283
- ['x' => 50, 'y' => 50, 't' => 1, 'p' => 0.45, 'r' => 8.5, 'num' => 1],
284
- ['x' => 60, 'y' => 52, 't' => 100, 'p' => 0.52, 'r' => 9.1, 'num' => 1],
285
- ['x' => 72, 'y' => 60, 't' => 200, 'p' => 0.49, 'r' => 8.8, 'num' => 1],
286
- ['x' => 80, 'y' => 80, 't' => 300, 'p' => 0.41, 'r' => 8.2, 'num' => 1]
287
- ]
288
- ];
289
- $context = $this->createRequestContext([
290
- 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
291
- ]);
292
- $score = RequestUtils::getBehaviorScore($context);
293
- $this->assertLessThan(30.0, $score['behaviorScore']);
294
- }
295
-
296
- public function testGetTimeInconsistencyScore(): void
297
- {
298
- $requestTimestamp = time() * 1000;
299
-
300
- $metricsNormal = ['clientTimestamp' => $requestTimestamp - 100];
301
- $contextNormal = $this->createRequestContext([
302
- 'headers' => ['x-behavior-metrics' => json_encode($metricsNormal)]
303
- ]);
304
- $contextNormal->requestTimestamp = $requestTimestamp;
305
- $scoreNormal = RequestUtils::getTimeInconsistencyScore($contextNormal);
306
- $this->assertEquals(0.0, $scoreNormal['timeInconsistencyScore']);
307
-
308
- $metricsReplay = ['clientTimestamp' => $requestTimestamp - 10000];
309
- $contextReplay = $this->createRequestContext([
310
- 'headers' => ['x-behavior-metrics' => json_encode($metricsReplay)]
311
- ]);
312
- $contextReplay->requestTimestamp = $requestTimestamp;
313
- $scoreReplay = RequestUtils::getTimeInconsistencyScore($contextReplay);
314
- $this->assertGreaterThan(0.0, $scoreReplay['timeInconsistencyScore']);
315
- }
316
-
317
- public function testGetCrossLayerInconsistencyOSMismatch(): void
318
- {
319
- $windowsHash = FingerprintBuilder::cyrb53("Windows");
320
- $context = $this->createRequestContext([
321
- 'headers' => [
322
- 'x-device-fingerprint' => "os:{$windowsHash}",
323
- 'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15'
324
- ]
325
- ]);
326
-
327
- $score = RequestUtils::getCrossLayerInconsistency($context);
328
- $this->assertEquals(50.0, $score['crossLayerInconsistencyScore']);
329
- }
330
-
331
- public function testGetBotScoreDetections(): void
332
- {
333
- $contextBot = $this->createRequestContext([
334
- 'headers' => ['x-device-fingerprint' => 'ua:123|bot:true']
335
- ]);
336
- $scoreBot = RequestUtils::getBotScore($contextBot);
337
- $this->assertEquals(100.0, $scoreBot['botScore']);
338
-
339
- $contextCdp = $this->createRequestContext([
340
- 'headers' => ['x-device-fingerprint' => 'ua:123|cdp:true']
341
- ]);
342
- $scoreCdp = RequestUtils::getBotScore($contextCdp);
343
- $this->assertEquals(100.0, $scoreCdp['botScore']);
344
-
345
- $contextClean = $this->createRequestContext([
346
- 'headers' => ['x-device-fingerprint' => 'ua:123|os:456']
347
- ]);
348
- $scoreClean = RequestUtils::getBotScore($contextClean);
349
- $this->assertEquals(0.0, $scoreClean['botScore']);
350
- }
351
-
352
- public function testGetHeaderAnomaliesFirefoxTE(): void
353
- {
354
- $contextFirefoxNoTe = $this->createRequestContext([
355
- 'headers' => [
356
- 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
357
- 'accept-language' => 'en-US,en;q=0.9',
358
- ]
359
- ]);
360
- $scoreFirefoxNoTe = RequestUtils::getHeaderAnomalies($contextFirefoxNoTe);
361
- $this->assertEquals(30.0, $scoreFirefoxNoTe['headerAnomalyScore']);
362
- }
363
-
364
- public function testGetBehavioralIndicatorsRotationAndHistory(): void
365
- {
366
- $deviceData = [
367
- 'ips' => ['127.0.0.1'],
368
- 'lastFpHash' => 'cvs:original-canvas|gpu:original-gpu',
369
- 'lastChangeTimestamp' => (time() * 1000) - 500,
370
- 'rapidChangeCount' => 1
371
- ];
372
-
373
- $context = $this->createRequestContext([
374
- 'headers' => [
375
- 'x-device-fingerprint' => 'cvs:new-canvas|gpu:new-gpu',
376
- 'user-agent' => 'Test UA'
377
- ]
378
- ]);
379
-
380
- $indicators = RequestUtils::getBehavioralIndicators($context, $deviceData);
381
- $this->assertEquals(2, $deviceData['rapidChangeCount']);
382
- $this->assertGreaterThan(0, $indicators['rotationScore']);
383
- }
384
-
385
- public function testGetRequestPatternScoreWeightedSubscores(): void
386
- {
387
- $context = $this->createRequestContext(['path' => '/search']);
388
- $deviceData = [
389
- 'requestHistory' => [],
390
- 'timingHistory' => [100, 100, 100, 100, 100, 100], // stdDev = 0
391
- 'lastPatternScore' => 0.0
392
- ];
393
- $patternConfig = [
394
- 'minSamples' => 5,
395
- 'regularityThreshold' => 50,
396
- 'benfordThreshold' => 0.15,
397
- 'patternWeight' => 80,
398
- 'decayFactor' => 0.9,
399
- 'inactivityReset' => 5000,
400
- 'regularityRatio' => 0.4,
401
- 'benfordRatio' => 0.3,
402
- 'enumerationRatio' => 0.3
403
- ];
404
-
405
- $result = RequestUtils::getRequestPatternScore($context, $deviceData, $patternConfig);
406
- // regularityScore = 1.0. regularityRatio = 0.4.
407
- // instantScore = 1.0 * 0.4 * 80 = 32.
408
- $this->assertEquals(32.0, $result['requestPatternScore']);
409
- }
410
-
411
- public function testGetBotnetClusterScoreCalculations(): void
412
- {
413
- $stableFpHash = 'test-stable-hash';
414
-
415
- $context1 = $this->createRequestContext(['clientIp' => '192.168.1.1']);
416
- $score = RequestUtils::getBotnetClusterScore($context1, $stableFpHash);
417
- $this->assertEquals(0.0, $score['botnetClusterScore']);
418
-
419
- RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.2']), $stableFpHash);
420
- $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.3']), $stableFpHash);
421
- $this->assertEquals(30.2, $score['botnetClusterScore']);
422
-
423
- RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.4']), $stableFpHash);
424
- $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.5']), $stableFpHash);
425
- $this->assertEquals(45.2, $score['botnetClusterScore']);
426
-
427
- for ($i = 6; $i <= 10; $i++) {
428
- $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => "192.168.1.{$i}"]), $stableFpHash);
429
- }
430
- $this->assertEquals(57.4, $score['botnetClusterScore']);
431
- }
432
-
433
- public function testRealWorldConsoleBotnetClustering(): void
434
- {
435
- $ps4Headers = [
436
- 'user-agent' => 'Mozilla/5.0 (PlayStation 4 11.50) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.50 Safari/605.1.15',
437
- 'x-ja3-hash' => '76993ef93bf89104037599723ab9f201',
438
- 'x-ja4-hash' => 't13d1516h2_8daaf6152771_390237aa04be',
439
- 'x-http2-fingerprint' => '1:65536;3:1000;4:6291456;6:65536',
440
- 'x-tcp-fingerprint' => '64240:128:1:mss,nop,ws,nop,nop,sok:df:0'
441
- ];
442
-
443
- for ($i = 1; $i <= 10; $i++) {
444
- $context = $this->createRequestContext([
445
- 'clientIp' => "185.15.20.{$i}",
446
- 'headers' => array_merge($ps4Headers, [
447
- 'cookie_keys' => "session_id=fake_sess_{$i}"
448
- ])
449
- ]);
450
-
451
- $currentHash = RequestUtils::getCompositeDeviceHash($context);
452
- $stableFp = RequestUtils::extractStablePart($currentHash);
453
- $stableFpHash = FingerprintBuilder::cyrb53($stableFp);
454
-
455
- $score = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
456
-
457
- if ($i === 1) {
458
- $this->assertEquals(0.0, $score['botnetClusterScore'], "Expected score for 1 IP");
459
- } else if ($i === 2) {
460
- $this->assertEquals(17.7, $score['botnetClusterScore'], "Expected score for 2 IPs");
461
- } else if ($i === 3) {
462
- $this->assertEquals(30.2, $score['botnetClusterScore'], "Expected score for 3 IPs");
463
- } else if ($i === 4) {
464
- $this->assertEquals(39.0, $score['botnetClusterScore'], "Expected score for 4 IPs");
465
- } else if ($i === 5) {
466
- $this->assertEquals(45.2, $score['botnetClusterScore'], "Expected score for 5 IPs");
467
- } else if ($i === 10) {
468
- $this->assertEquals(57.4, $score['botnetClusterScore'], "Expected score for 10 IPs");
469
- }
470
- }
471
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use Anonympins\Fingerprint\FingerprintBuilder;
8
+ use Anonympins\Fingerprint\RequestContext;
9
+ use Anonympins\Fingerprint\Utils\RequestUtils;
10
+ use PHPUnit\Framework\TestCase;
11
+
12
+ class RequestUtilsTest extends TestCase
13
+ {
14
+ protected function setUp(): void
15
+ {
16
+ parent::setUp();
17
+ \Anonympins\Fingerprint\Store\StoreManager::configureStore(new \Anonympins\Fingerprint\Store\InMemoryStore());
18
+ }
19
+
20
+ private function createRequestContext(array $overrides = []): RequestContext
21
+ {
22
+ $defaults = [
23
+ 'clientIp' => '127.0.0.1',
24
+ 'path' => '/',
25
+ 'headers' => ['user-agent' => 'Test UA'],
26
+ 'query' => [],
27
+ 'body' => null,
28
+ 'cookies' => [],
29
+ 'httpVersion' => '1.1',
30
+ ];
31
+ $params = array_merge($defaults, $overrides);
32
+ return new RequestContext(
33
+ $params['clientIp'], $params['path'], $params['headers'],
34
+ $params['query'], $params['body'], $params['cookies'], $params['httpVersion']
35
+ );
36
+ }
37
+
38
+ public function testGetCrossLayerInconsistencyViewportExceedsScreen(): void
39
+ {
40
+ $scrHash = FingerprintBuilder::cyrb53("1920x1080_24");
41
+ $context = $this->createRequestContext([
42
+ 'headers' => [
43
+ 'x-device-fingerprint' => "scr:{$scrHash}",
44
+ 'sec-ch-viewport-width' => '2560'
45
+ ]
46
+ ]);
47
+
48
+ $score = RequestUtils::getCrossLayerInconsistency($context);
49
+ $this->assertEquals(20.0, $score['crossLayerInconsistencyScore']);
50
+ }
51
+ public function testGetRenderingAnomalyScoreEmpty(): void
52
+ {
53
+ $context = $this->createRequestContext();
54
+ $res = RequestUtils::getRenderingAnomalyScore($context);
55
+ $this->assertEquals(0.0, $res['renderingAnomalyScore']);
56
+ }
57
+
58
+ public function testGetRenderingAnomalyScoreSpoofed(): void
59
+ {
60
+ $metrics = [
61
+ 'rendering' => [
62
+ 'fps' => 60,
63
+ 'jitter' => 12.5,
64
+ 'offscreenAnom' => false
65
+ ]
66
+ ];
67
+ $context = $this->createRequestContext([
68
+ 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
69
+ ]);
70
+ $res = RequestUtils::getRenderingAnomalyScore($context);
71
+ $this->assertEquals(65.0, $res['renderingAnomalyScore']);
72
+ }
73
+ public function testGetClickVarianceScoreReturnsZeroForNoHistory(): void
74
+ {
75
+ $context = $this->createRequestContext([
76
+ 'headers' => ['x-behavior-metrics' => json_encode([])]
77
+ ]);
78
+ $result = RequestUtils::getClickVarianceScore($context);
79
+ $this->assertEquals(0.0, $result['clickVarianceScore']);
80
+ }
81
+
82
+ public function testGetClickVarianceScoreReturnsZeroForInsufficientClicks(): void
83
+ {
84
+ $metrics = [
85
+ 'clicksHistory' => [
86
+ ['x' => 10, 'y' => 10, 'targetId' => 'hash1'],
87
+ ['x' => 11, 'y' => 11, 'targetId' => 'hash1'],
88
+ ['x' => 100, 'y' => 100, 'targetId' => 'hash2']
89
+ ]
90
+ ];
91
+ $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
92
+ $result = RequestUtils::getClickVarianceScore($context);
93
+ $this->assertEquals(0.0, $result['clickVarianceScore']);
94
+ }
95
+
96
+ public function testGetClickVarianceScoreReturnsHighScoreForLowVariance(): void
97
+ {
98
+ $metrics = [
99
+ 'clicksHistory' => [
100
+ ['x' => 100, 'y' => 100, 'targetId' => 'hash1'],
101
+ ['x' => 100.1, 'y' => 100.2, 'targetId' => 'hash1'],
102
+ ['x' => 99.9, 'y' => 99.8, 'targetId' => 'hash1']
103
+ ]
104
+ ];
105
+ $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
106
+ $result = RequestUtils::getClickVarianceScore($context);
107
+ $this->assertGreaterThan(90, $result['clickVarianceScore']);
108
+ }
109
+
110
+ public function testGetClickVarianceScoreReturnsLowScoreForHighVariance(): void
111
+ {
112
+ $metrics = [
113
+ 'clicksHistory' => [
114
+ ['x' => 105, 'y' => 110, 'targetId' => 'hash1'],
115
+ ['x' => 98, 'y' => 102, 'targetId' => 'hash1'],
116
+ ['x' => 112, 'y' => 95, 'targetId' => 'hash1']
117
+ ]
118
+ ];
119
+ $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
120
+ $result = RequestUtils::getClickVarianceScore($context);
121
+ $this->assertEquals(0.0, $result['clickVarianceScore']);
122
+ }
123
+
124
+ public function testSanitizeTrafficDataFiltersSybilAttacks(): void
125
+ {
126
+ $trafficData = [];
127
+ // Ajout de 100 logs provenant d'un attaquant (Sybil)
128
+ for ($i = 0; $i < 100; $i++) {
129
+ $trafficData[] = ['deviceId' => 'attacker_device', 'type' => 'trap_triggered'];
130
+ }
131
+ // Ajout de 10 logs d'utilisateurs légitimes distincts
132
+ for ($i = 0; $i < 10; $i++) {
133
+ $trafficData[] = ['deviceId' => "legit_device_{$i}", 'type' => 'challenge_solved'];
134
+ }
135
+
136
+ $sanitized = RequestUtils::sanitizeTrafficData($trafficData);
137
+
138
+ $attackerLogs = array_filter($sanitized, fn($log) => $log['deviceId'] === 'attacker_device');
139
+
140
+ // Total de 110 logs. 2% de 110 est 2.2 -> max(3, 2) = 3 logs maximum autorisés pour l'attaquant.
141
+ $this->assertLessThanOrEqual(3, count($attackerLogs));
142
+ }
143
+
144
+ public function testSanitizeTrafficDataFiltersHardwareClusters(): void
145
+ {
146
+ $trafficData = [];
147
+ // Simulated attacker rotating deviceId and IP, but keeping identical hardware fingerprint
148
+ for ($i = 0; $i < 100; $i++) {
149
+ $trafficData[] = [
150
+ 'deviceId' => "attacker_device_{$i}",
151
+ 'clientIp' => "192.168.1.{$i}",
152
+ 'type' => 'trap_triggered',
153
+ 'fingerprint' => 'gpu:heavy_nvidia_hash|cvs:canvas_hash_1'
154
+ ];
155
+ }
156
+ // Legit users with unique hardware fingerprints
157
+ for ($i = 0; $i < 10; $i++) {
158
+ $trafficData[] = [
159
+ 'deviceId' => "legit_device_{$i}",
160
+ 'clientIp' => "10.0.0.{$i}",
161
+ 'type' => 'challenge_solved',
162
+ 'fingerprint' => "gpu:legit_gpu_{$i}|cvs:legit_cvs_{$i}"
163
+ ];
164
+ }
165
+
166
+ $sanitized = RequestUtils::sanitizeTrafficData($trafficData);
167
+ $attackerLogs = array_filter($sanitized, fn($log) => str_starts_with($log['deviceId'], 'attacker_device_'));
168
+
169
+ // Max limit of 2% of 110 total = 2.2 -> max(3, 2) = 3 logs allowed for this hardware cluster
170
+ $this->assertLessThanOrEqual(3, count($attackerLogs));
171
+ }
172
+
173
+ public function testChallengePayloadSigningAndVerification(): void
174
+ {
175
+ $secret = 'test-fallback-dev-secret-32-chars-minimum';
176
+ $clientIp = '203.0.113.42';
177
+ $payload = [
178
+ 'clientSecret' => 'some_client_secret',
179
+ 'cpuTarget' => '00000000ffffffff',
180
+ 'fingerprint' => 'os:hash|gpu:hash2',
181
+ 'memDifficulty' => '16',
182
+ 'originalPath' => '/submit',
183
+ ];
184
+
185
+ // 1. Cas nominal : Signature et vérification réussies
186
+ $signature = RequestUtils::signChallengePayload($secret, $payload, $clientIp);
187
+ $this->assertNotEmpty($signature);
188
+
189
+ $payloadWithSig = $payload;
190
+ $payloadWithSig['signature'] = $signature;
191
+
192
+ $isValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, $clientIp);
193
+ $this->assertTrue($isValid, "La signature valide doit être acceptée.");
194
+
195
+ // 2. Détection de modification (tampering) sur cpuTarget
196
+ $tamperedPayload = $payloadWithSig;
197
+ $tamperedPayload['cpuTarget'] = 'ffffffffffffffff'; // Tentative de baisse de la difficulté
198
+
199
+ $isTamperedValid = RequestUtils::verifyChallengePayload($secret, $tamperedPayload, $clientIp);
200
+ $this->assertFalse($isTamperedValid, "Un payload modifié doit être rejeté.");
201
+
202
+ // 3. Détection de modification sur le fingerprint
203
+ $tamperedFpPayload = $payloadWithSig;
204
+ $tamperedFpPayload['fingerprint'] = 'os:another_hash|gpu:hash2';
205
+
206
+ $isTamperedFpValid = RequestUtils::verifyChallengePayload($secret, $tamperedFpPayload, $clientIp);
207
+ $this->assertFalse($isTamperedFpValid, "Un fingerprint modifié doit être rejeté.");
208
+
209
+ // 4. Détection d'usurpation d'adresse IP (IP mismatch)
210
+ $isIpMismatchValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, '198.51.100.1');
211
+ $this->assertFalse($isIpMismatchValid, "Le payload ne doit pas être valide pour une autre adresse IP.");
212
+
213
+ // 5. Absence de signature
214
+ $isMissingSigValid = RequestUtils::verifyChallengePayload($secret, $payload, $clientIp);
215
+ $this->assertFalse($isMissingSigValid, "Un payload sans signature doit être rejeté.");
216
+ }
217
+
218
+ public function testGetBehaviorScoreReturnsProperScores(): void
219
+ {
220
+ $contextEmpty = $this->createRequestContext();
221
+ $scoreEmpty = RequestUtils::getBehaviorScore($contextEmpty);
222
+ $this->assertEquals(0.0, $scoreEmpty['behaviorScore']);
223
+
224
+ $metricsHoneypot = ['honeypotInteraction' => true];
225
+ $contextHoneypot = $this->createRequestContext([
226
+ 'headers' => ['x-behavior-metrics' => json_encode($metricsHoneypot)]
227
+ ]);
228
+ $scoreHoneypot = RequestUtils::getBehaviorScore($contextHoneypot);
229
+ $this->assertEquals(100.0, $scoreHoneypot['behaviorScore']);
230
+
231
+ $metricsNoActivity = ['honeypotInteraction' => false, 'mouseMovementsHistory' => [], 'keystrokeLatency' => 0];
232
+ $contextNoActivity = $this->createRequestContext([
233
+ 'headers' => ['x-behavior-metrics' => json_encode($metricsNoActivity)]
234
+ ]);
235
+ $scoreNoActivity = RequestUtils::getBehaviorScore($contextNoActivity);
236
+ $this->assertEquals(40.0, $scoreNoActivity['behaviorScore']);
237
+ }
238
+
239
+ public function testGetBehaviorScoreWithBotLikeKeystrokeDynamics(): void
240
+ {
241
+ $metrics = [
242
+ 'honeypotInteraction' => false,
243
+ 'keystrokeLatency' => 100.0,
244
+ 'keystrokeDwellTimes' => [50, 50, 50, 50, 50],
245
+ 'keystrokeFlightTimes' => [
246
+ ['digraph' => 'ab', 'time' => 100],
247
+ ['digraph' => 'bc', 'time' => 100],
248
+ ['digraph' => 'cd', 'time' => 100],
249
+ ['digraph' => 'de', 'time' => 100],
250
+ ['digraph' => 'ef', 'time' => 100]
251
+ ]
252
+ ];
253
+ $context = $this->createRequestContext([
254
+ 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
255
+ ]);
256
+ $score = RequestUtils::getBehaviorScore($context);
257
+ $this->assertGreaterThanOrEqual(70.0, $score['behaviorScore']);
258
+ }
259
+
260
+ public function testGetBehaviorScoreWithBotLikeTouchMovements(): void
261
+ {
262
+ $metrics = [
263
+ 'honeypotInteraction' => false,
264
+ 'touchMovementsHistory' => [
265
+ ['x' => 50, 'y' => 50, 't' => 1, 'p' => 0.5, 'r' => 10, 'num' => 1],
266
+ ['x' => 55, 'y' => 55, 't' => 100, 'p' => 0.5, 'r' => 10, 'num' => 1],
267
+ ['x' => 60, 'y' => 60, 't' => 200, 'p' => 0.5, 'r' => 10, 'num' => 1],
268
+ ['x' => 65, 'y' => 65, 't' => 300, 'p' => 0.5, 'r' => 10, 'num' => 1]
269
+ ]
270
+ ];
271
+ $context = $this->createRequestContext([
272
+ 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
273
+ ]);
274
+ $score = RequestUtils::getBehaviorScore($context);
275
+ $this->assertGreaterThan(60.0, $score['behaviorScore']);
276
+ }
277
+
278
+ public function testGetBehaviorScoreWithHumanLikeTouchMovements(): void
279
+ {
280
+ $metrics = [
281
+ 'honeypotInteraction' => false,
282
+ 'touchMovementsHistory' => [
283
+ ['x' => 50, 'y' => 50, 't' => 1, 'p' => 0.45, 'r' => 8.5, 'num' => 1],
284
+ ['x' => 60, 'y' => 52, 't' => 100, 'p' => 0.52, 'r' => 9.1, 'num' => 1],
285
+ ['x' => 72, 'y' => 60, 't' => 200, 'p' => 0.49, 'r' => 8.8, 'num' => 1],
286
+ ['x' => 80, 'y' => 80, 't' => 300, 'p' => 0.41, 'r' => 8.2, 'num' => 1]
287
+ ]
288
+ ];
289
+ $context = $this->createRequestContext([
290
+ 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
291
+ ]);
292
+ $score = RequestUtils::getBehaviorScore($context);
293
+ $this->assertLessThan(30.0, $score['behaviorScore']);
294
+ }
295
+
296
+ public function testGetTimeInconsistencyScore(): void
297
+ {
298
+ $requestTimestamp = time() * 1000;
299
+
300
+ $metricsNormal = ['clientTimestamp' => $requestTimestamp - 100];
301
+ $contextNormal = $this->createRequestContext([
302
+ 'headers' => ['x-behavior-metrics' => json_encode($metricsNormal)]
303
+ ]);
304
+ $contextNormal->requestTimestamp = $requestTimestamp;
305
+ $scoreNormal = RequestUtils::getTimeInconsistencyScore($contextNormal);
306
+ $this->assertEquals(0.0, $scoreNormal['timeInconsistencyScore']);
307
+
308
+ $metricsReplay = ['clientTimestamp' => $requestTimestamp - 10000];
309
+ $contextReplay = $this->createRequestContext([
310
+ 'headers' => ['x-behavior-metrics' => json_encode($metricsReplay)]
311
+ ]);
312
+ $contextReplay->requestTimestamp = $requestTimestamp;
313
+ $scoreReplay = RequestUtils::getTimeInconsistencyScore($contextReplay);
314
+ $this->assertGreaterThan(0.0, $scoreReplay['timeInconsistencyScore']);
315
+ }
316
+
317
+ public function testGetCrossLayerInconsistencyOSMismatch(): void
318
+ {
319
+ $windowsHash = FingerprintBuilder::cyrb53("Windows");
320
+ $context = $this->createRequestContext([
321
+ 'headers' => [
322
+ 'x-device-fingerprint' => "os:{$windowsHash}",
323
+ 'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15'
324
+ ]
325
+ ]);
326
+
327
+ $score = RequestUtils::getCrossLayerInconsistency($context);
328
+ $this->assertEquals(50.0, $score['crossLayerInconsistencyScore']);
329
+ }
330
+
331
+ public function testGetBotScoreDetections(): void
332
+ {
333
+ $contextBot = $this->createRequestContext([
334
+ 'headers' => ['x-device-fingerprint' => 'ua:123|bot:true']
335
+ ]);
336
+ $scoreBot = RequestUtils::getBotScore($contextBot);
337
+ $this->assertEquals(100.0, $scoreBot['botScore']);
338
+
339
+ $contextCdp = $this->createRequestContext([
340
+ 'headers' => ['x-device-fingerprint' => 'ua:123|cdp:true']
341
+ ]);
342
+ $scoreCdp = RequestUtils::getBotScore($contextCdp);
343
+ $this->assertEquals(100.0, $scoreCdp['botScore']);
344
+
345
+ $contextClean = $this->createRequestContext([
346
+ 'headers' => ['x-device-fingerprint' => 'ua:123|os:456']
347
+ ]);
348
+ $scoreClean = RequestUtils::getBotScore($contextClean);
349
+ $this->assertEquals(0.0, $scoreClean['botScore']);
350
+ }
351
+
352
+ public function testGetHeaderAnomaliesFirefoxTE(): void
353
+ {
354
+ $contextFirefoxNoTe = $this->createRequestContext([
355
+ 'headers' => [
356
+ 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
357
+ 'accept-language' => 'en-US,en;q=0.9',
358
+ ]
359
+ ]);
360
+ $scoreFirefoxNoTe = RequestUtils::getHeaderAnomalies($contextFirefoxNoTe);
361
+ $this->assertEquals(30.0, $scoreFirefoxNoTe['headerAnomalyScore']);
362
+ }
363
+
364
+ public function testGetBehavioralIndicatorsRotationAndHistory(): void
365
+ {
366
+ $deviceData = [
367
+ 'ips' => ['127.0.0.1'],
368
+ 'lastFpHash' => 'cvs:original-canvas|gpu:original-gpu',
369
+ 'lastChangeTimestamp' => (time() * 1000) - 500,
370
+ 'rapidChangeCount' => 1
371
+ ];
372
+
373
+ $context = $this->createRequestContext([
374
+ 'headers' => [
375
+ 'x-device-fingerprint' => 'cvs:new-canvas|gpu:new-gpu',
376
+ 'user-agent' => 'Test UA'
377
+ ]
378
+ ]);
379
+
380
+ $indicators = RequestUtils::getBehavioralIndicators($context, $deviceData);
381
+ $this->assertEquals(2, $deviceData['rapidChangeCount']);
382
+ $this->assertGreaterThan(0, $indicators['rotationScore']);
383
+ }
384
+
385
+ public function testGetRequestPatternScoreWeightedSubscores(): void
386
+ {
387
+ $context = $this->createRequestContext(['path' => '/search']);
388
+ $deviceData = [
389
+ 'requestHistory' => [],
390
+ 'timingHistory' => [100, 100, 100, 100, 100, 100], // stdDev = 0
391
+ 'lastPatternScore' => 0.0
392
+ ];
393
+ $patternConfig = [
394
+ 'minSamples' => 5,
395
+ 'regularityThreshold' => 50,
396
+ 'benfordThreshold' => 0.15,
397
+ 'patternWeight' => 80,
398
+ 'decayFactor' => 0.9,
399
+ 'inactivityReset' => 5000,
400
+ 'regularityRatio' => 0.4,
401
+ 'benfordRatio' => 0.3,
402
+ 'enumerationRatio' => 0.3
403
+ ];
404
+
405
+ $result = RequestUtils::getRequestPatternScore($context, $deviceData, $patternConfig);
406
+ // regularityScore = 1.0. regularityRatio = 0.4.
407
+ // instantScore = 1.0 * 0.4 * 80 = 32.
408
+ $this->assertEquals(32.0, $result['requestPatternScore']);
409
+ }
410
+
411
+ public function testGetBotnetClusterScoreCalculations(): void
412
+ {
413
+ $stableFpHash = 'test-stable-hash';
414
+
415
+ $context1 = $this->createRequestContext(['clientIp' => '192.168.1.1']);
416
+ $score = RequestUtils::getBotnetClusterScore($context1, $stableFpHash);
417
+ $this->assertEquals(0.0, $score['botnetClusterScore']);
418
+
419
+ RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.2']), $stableFpHash);
420
+ $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.3']), $stableFpHash);
421
+ $this->assertEquals(30.2, $score['botnetClusterScore']);
422
+
423
+ RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.4']), $stableFpHash);
424
+ $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.5']), $stableFpHash);
425
+ $this->assertEquals(45.2, $score['botnetClusterScore']);
426
+
427
+ for ($i = 6; $i <= 10; $i++) {
428
+ $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => "192.168.1.{$i}"]), $stableFpHash);
429
+ }
430
+ $this->assertEquals(57.4, $score['botnetClusterScore']);
431
+ }
432
+
433
+ public function testRealWorldConsoleBotnetClustering(): void
434
+ {
435
+ $ps4Headers = [
436
+ 'user-agent' => 'Mozilla/5.0 (PlayStation 4 11.50) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.50 Safari/605.1.15',
437
+ 'x-ja3-hash' => '76993ef93bf89104037599723ab9f201',
438
+ 'x-ja4-hash' => 't13d1516h2_8daaf6152771_390237aa04be',
439
+ 'x-http2-fingerprint' => '1:65536;3:1000;4:6291456;6:65536',
440
+ 'x-tcp-fingerprint' => '64240:128:1:mss,nop,ws,nop,nop,sok:df:0'
441
+ ];
442
+
443
+ for ($i = 1; $i <= 10; $i++) {
444
+ $context = $this->createRequestContext([
445
+ 'clientIp' => "185.15.20.{$i}",
446
+ 'headers' => array_merge($ps4Headers, [
447
+ 'cookie_keys' => "session_id=fake_sess_{$i}"
448
+ ])
449
+ ]);
450
+
451
+ $currentHash = RequestUtils::getCompositeDeviceHash($context);
452
+ $stableFp = RequestUtils::extractStablePart($currentHash);
453
+ $stableFpHash = FingerprintBuilder::cyrb53($stableFp);
454
+
455
+ $score = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
456
+
457
+ if ($i === 1) {
458
+ $this->assertEquals(0.0, $score['botnetClusterScore'], "Expected score for 1 IP");
459
+ } else if ($i === 2) {
460
+ $this->assertEquals(17.7, $score['botnetClusterScore'], "Expected score for 2 IPs");
461
+ } else if ($i === 3) {
462
+ $this->assertEquals(30.2, $score['botnetClusterScore'], "Expected score for 3 IPs");
463
+ } else if ($i === 4) {
464
+ $this->assertEquals(39.0, $score['botnetClusterScore'], "Expected score for 4 IPs");
465
+ } else if ($i === 5) {
466
+ $this->assertEquals(45.2, $score['botnetClusterScore'], "Expected score for 5 IPs");
467
+ } else if ($i === 10) {
468
+ $this->assertEquals(57.4, $score['botnetClusterScore'], "Expected score for 10 IPs");
469
+ }
470
+ }
471
+ }
472
472
  }