@anonympins/fingerprint 0.7.0 → 0.7.2

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 (66) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/composer.json +8 -1
  4. package/package.json +1 -1
  5. package/public/fingerprint-wordpress.zip +0 -0
  6. package/src/js/build-client.js +8 -10
  7. package/src/js/fingerprint.builder.js +37 -38
  8. package/src/js/fingerprint.client.js +1531 -1463
  9. package/src/js/fingerprint.client.obfuscated.js +1 -0
  10. package/src/js/fingerprint.js +190 -16
  11. package/src/js/fingerprint.utils.js +213 -213
  12. package/src/js/library.js +340 -376
  13. package/src/js/mongodb-store.js +1 -1
  14. package/src/js/optimization.worker.js +27 -27
  15. package/src/js/pow.solver.inline.js +154 -56
  16. package/src/js/pow.solver.js +180 -92
  17. package/src/js/pow.worker.js +48 -48
  18. package/src/js/redis-store.js +1 -1
  19. package/src/js/tests/fingerprint.client.init.test.js +146 -143
  20. package/src/js/tests/fingerprint.test.js +124 -10
  21. package/src/js/tests/ip-reputation.test.js +1 -0
  22. package/src/js/upow-model-task.js +14 -15
  23. package/src/php/AutoTuner.php +34 -34
  24. package/src/php/Challenge/ChallengeUtils.php +57 -11
  25. package/src/php/Config/SecurityProfiles.php +20 -0
  26. package/src/php/DirectFingerprint.php +190 -145
  27. package/src/php/FingerprintBuilder.php +29 -30
  28. package/src/php/FingerprintClient.php +147 -148
  29. package/src/php/FingerprintEngine.php +44 -17
  30. package/src/php/Ja3AnomalyDetector.php +33 -32
  31. package/src/php/Optimization/FunctionRegistry.php +7 -7
  32. package/src/php/Optimization/Optimization.php +24 -24
  33. package/src/php/Optimization/OptimizationOperators.php +26 -25
  34. package/src/php/Optimization/ProblemInitializers.php +52 -52
  35. package/src/php/ProblemManager.php +31 -34
  36. package/src/php/RequestContext.php +8 -1
  37. package/src/php/Store/IStore.php +7 -8
  38. package/src/php/Store/InMemoryStore.php +66 -66
  39. package/src/php/Store/MongoDbStore.php +5 -5
  40. package/src/php/Store/RedisStore.php +5 -5
  41. package/src/php/Store/StoreManager.php +35 -35
  42. package/src/php/Tests/ChallengeUtilsTest.php +453 -453
  43. package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
  44. package/src/php/Tests/FingerprintEngineTest.php +38 -0
  45. package/src/php/Tests/IpReputationTest.php +10 -0
  46. package/src/php/Tests/MaliciousPatternsTest.php +103 -103
  47. package/src/php/Tests/MetricsTest.php +97 -97
  48. package/src/php/Tests/ProblemManagerTest.php +376 -376
  49. package/src/php/Tests/QuicFingerprintTest.php +53 -53
  50. package/src/php/Tests/RequestUtilsTest.php +471 -471
  51. package/src/php/Tests/TLSClientHelloParserTest.php +177 -177
  52. package/src/php/Tests/config/ed25519_key.json +3 -3
  53. package/src/php/Utils/BlockList.php +99 -99
  54. package/src/php/Utils/Env.php +49 -49
  55. package/src/php/Utils/MaliciousPatterns.php +74 -74
  56. package/src/php/Utils/MetricsManager.php +209 -209
  57. package/src/php/Utils/RequestUtils.php +129 -7
  58. package/src/php/Utils/TLSClientHelloParser.php +369 -369
  59. package/src/php/WordPress/WpDbStore.php +155 -0
  60. package/src/php/WordPress/fingerprint-wordpress.php +554 -0
  61. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.mo +0 -0
  62. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +262 -0
  63. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
  64. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +268 -0
  65. package/src/php/WordPress/package.php +218 -0
  66. 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
  }