@anonympins/fingerprint 0.4.2 → 0.4.4

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.
@@ -1,254 +1,387 @@
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
- private function createRequestContext(array $overrides = []): RequestContext
15
- {
16
- $defaults = [
17
- 'clientIp' => '127.0.0.1',
18
- 'path' => '/',
19
- 'headers' => ['user-agent' => 'Test UA'],
20
- 'query' => [],
21
- 'body' => null,
22
- 'cookies' => [],
23
- 'httpVersion' => '1.1',
24
- ];
25
- $params = array_merge($defaults, $overrides);
26
- return new RequestContext(
27
- $params['clientIp'], $params['path'], $params['headers'],
28
- $params['query'], $params['body'], $params['cookies'], $params['httpVersion']
29
- );
30
- }
31
-
32
- public function testGetClickVarianceScoreReturnsZeroForNoHistory(): void
33
- {
34
- $context = $this->createRequestContext([
35
- 'headers' => ['x-behavior-metrics' => json_encode([])]
36
- ]);
37
- $result = RequestUtils::getClickVarianceScore($context);
38
- $this->assertEquals(0.0, $result['clickVarianceScore']);
39
- }
40
-
41
- public function testGetClickVarianceScoreReturnsZeroForInsufficientClicks(): void
42
- {
43
- $metrics = [
44
- 'clicksHistory' => [
45
- ['x' => 10, 'y' => 10, 'targetId' => 'hash1'],
46
- ['x' => 11, 'y' => 11, 'targetId' => 'hash1'],
47
- ['x' => 100, 'y' => 100, 'targetId' => 'hash2']
48
- ]
49
- ];
50
- $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
51
- $result = RequestUtils::getClickVarianceScore($context);
52
- $this->assertEquals(0.0, $result['clickVarianceScore']);
53
- }
54
-
55
- public function testGetClickVarianceScoreReturnsHighScoreForLowVariance(): void
56
- {
57
- $metrics = [
58
- 'clicksHistory' => [
59
- ['x' => 100, 'y' => 100, 'targetId' => 'hash1'],
60
- ['x' => 100.1, 'y' => 100.2, 'targetId' => 'hash1'],
61
- ['x' => 99.9, 'y' => 99.8, 'targetId' => 'hash1']
62
- ]
63
- ];
64
- $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
65
- $result = RequestUtils::getClickVarianceScore($context);
66
- $this->assertGreaterThan(90, $result['clickVarianceScore']);
67
- }
68
-
69
- public function testGetClickVarianceScoreReturnsLowScoreForHighVariance(): void
70
- {
71
- $metrics = [
72
- 'clicksHistory' => [
73
- ['x' => 105, 'y' => 110, 'targetId' => 'hash1'],
74
- ['x' => 98, 'y' => 102, 'targetId' => 'hash1'],
75
- ['x' => 112, 'y' => 95, 'targetId' => 'hash1']
76
- ]
77
- ];
78
- $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
79
- $result = RequestUtils::getClickVarianceScore($context);
80
- $this->assertEquals(0.0, $result['clickVarianceScore']);
81
- }
82
-
83
- public function testSanitizeTrafficDataFiltersSybilAttacks(): void
84
- {
85
- $trafficData = [];
86
- // Ajout de 100 logs provenant d'un attaquant (Sybil)
87
- for ($i = 0; $i < 100; $i++) {
88
- $trafficData[] = ['deviceId' => 'attacker_device', 'type' => 'trap_triggered'];
89
- }
90
- // Ajout de 10 logs d'utilisateurs légitimes distincts
91
- for ($i = 0; $i < 10; $i++) {
92
- $trafficData[] = ['deviceId' => "legit_device_{$i}", 'type' => 'challenge_solved'];
93
- }
94
-
95
- $sanitized = RequestUtils::sanitizeTrafficData($trafficData);
96
-
97
- $attackerLogs = array_filter($sanitized, fn($log) => $log['deviceId'] === 'attacker_device');
98
-
99
- // Total de 110 logs. 2% de 110 est 2.2 -> max(3, 2) = 3 logs maximum autorisés pour l'attaquant.
100
- $this->assertLessThanOrEqual(3, count($attackerLogs));
101
- }
102
-
103
- public function testChallengePayloadSigningAndVerification(): void
104
- {
105
- $secret = 'test-fallback-dev-secret-32-chars-minimum';
106
- $clientIp = '203.0.113.42';
107
- $payload = [
108
- 'clientSecret' => 'some_client_secret',
109
- 'cpuTarget' => '00000000ffffffff',
110
- 'fingerprint' => 'os:hash|gpu:hash2',
111
- 'memDifficulty' => '16',
112
- 'originalPath' => '/submit',
113
- ];
114
-
115
- // 1. Cas nominal : Signature et vérification réussies
116
- $signature = RequestUtils::signChallengePayload($secret, $payload, $clientIp);
117
- $this->assertNotEmpty($signature);
118
-
119
- $payloadWithSig = $payload;
120
- $payloadWithSig['signature'] = $signature;
121
-
122
- $isValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, $clientIp);
123
- $this->assertTrue($isValid, "La signature valide doit être acceptée.");
124
-
125
- // 2. Détection de modification (tampering) sur cpuTarget
126
- $tamperedPayload = $payloadWithSig;
127
- $tamperedPayload['cpuTarget'] = 'ffffffffffffffff'; // Tentative de baisse de la difficulté
128
-
129
- $isTamperedValid = RequestUtils::verifyChallengePayload($secret, $tamperedPayload, $clientIp);
130
- $this->assertFalse($isTamperedValid, "Un payload modifié doit être rejeté.");
131
-
132
- // 3. Détection de modification sur le fingerprint
133
- $tamperedFpPayload = $payloadWithSig;
134
- $tamperedFpPayload['fingerprint'] = 'os:another_hash|gpu:hash2';
135
-
136
- $isTamperedFpValid = RequestUtils::verifyChallengePayload($secret, $tamperedFpPayload, $clientIp);
137
- $this->assertFalse($isTamperedFpValid, "Un fingerprint modifié doit être rejeté.");
138
-
139
- // 4. Détection d'usurpation d'adresse IP (IP mismatch)
140
- $isIpMismatchValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, '198.51.100.1');
141
- $this->assertFalse($isIpMismatchValid, "Le payload ne doit pas être valide pour une autre adresse IP.");
142
-
143
- // 5. Absence de signature
144
- $isMissingSigValid = RequestUtils::verifyChallengePayload($secret, $payload, $clientIp);
145
- $this->assertFalse($isMissingSigValid, "Un payload sans signature doit être rejeté.");
146
- }
147
-
148
- public function testGetBehaviorScoreReturnsProperScores(): void
149
- {
150
- $contextEmpty = $this->createRequestContext();
151
- $scoreEmpty = RequestUtils::getBehaviorScore($contextEmpty);
152
- $this->assertEquals(0.0, $scoreEmpty['behaviorScore']);
153
-
154
- $metricsHoneypot = ['honeypotInteraction' => true];
155
- $contextHoneypot = $this->createRequestContext([
156
- 'headers' => ['x-behavior-metrics' => json_encode($metricsHoneypot)]
157
- ]);
158
- $scoreHoneypot = RequestUtils::getBehaviorScore($contextHoneypot);
159
- $this->assertEquals(100.0, $scoreHoneypot['behaviorScore']);
160
-
161
- $metricsNoActivity = ['honeypotInteraction' => false, 'mouseMovementsHistory' => [], 'keystrokeLatency' => 0];
162
- $contextNoActivity = $this->createRequestContext([
163
- 'headers' => ['x-behavior-metrics' => json_encode($metricsNoActivity)]
164
- ]);
165
- $scoreNoActivity = RequestUtils::getBehaviorScore($contextNoActivity);
166
- $this->assertEquals(40.0, $scoreNoActivity['behaviorScore']);
167
- }
168
-
169
- public function testGetTimeInconsistencyScore(): void
170
- {
171
- $requestTimestamp = time() * 1000;
172
-
173
- $metricsNormal = ['clientTimestamp' => $requestTimestamp - 100];
174
- $contextNormal = $this->createRequestContext([
175
- 'headers' => ['x-behavior-metrics' => json_encode($metricsNormal)]
176
- ]);
177
- $contextNormal->requestTimestamp = $requestTimestamp;
178
- $scoreNormal = RequestUtils::getTimeInconsistencyScore($contextNormal);
179
- $this->assertEquals(0.0, $scoreNormal['timeInconsistencyScore']);
180
-
181
- $metricsReplay = ['clientTimestamp' => $requestTimestamp - 10000];
182
- $contextReplay = $this->createRequestContext([
183
- 'headers' => ['x-behavior-metrics' => json_encode($metricsReplay)]
184
- ]);
185
- $contextReplay->requestTimestamp = $requestTimestamp;
186
- $scoreReplay = RequestUtils::getTimeInconsistencyScore($contextReplay);
187
- $this->assertGreaterThan(0.0, $scoreReplay['timeInconsistencyScore']);
188
- }
189
-
190
- public function testGetCrossLayerInconsistencyOSMismatch(): void
191
- {
192
- $windowsHash = FingerprintBuilder::cyrb53("Windows");
193
- $context = $this->createRequestContext([
194
- 'headers' => [
195
- 'x-device-fingerprint' => "os:{$windowsHash}",
196
- 'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15'
197
- ]
198
- ]);
199
-
200
- $score = RequestUtils::getCrossLayerInconsistency($context);
201
- $this->assertEquals(50.0, $score['crossLayerInconsistencyScore']);
202
- }
203
-
204
- public function testGetBotScoreDetections(): void
205
- {
206
- $contextBot = $this->createRequestContext([
207
- 'headers' => ['x-device-fingerprint' => 'ua:123|bot:true']
208
- ]);
209
- $scoreBot = RequestUtils::getBotScore($contextBot);
210
- $this->assertEquals(100.0, $scoreBot['botScore']);
211
-
212
- $contextCdp = $this->createRequestContext([
213
- 'headers' => ['x-device-fingerprint' => 'ua:123|cdp:true']
214
- ]);
215
- $scoreCdp = RequestUtils::getBotScore($contextCdp);
216
- $this->assertEquals(100.0, $scoreCdp['botScore']);
217
-
218
- $contextClean = $this->createRequestContext([
219
- 'headers' => ['x-device-fingerprint' => 'ua:123|os:456']
220
- ]);
221
- $scoreClean = RequestUtils::getBotScore($contextClean);
222
- $this->assertEquals(0.0, $scoreClean['botScore']);
223
- }
224
-
225
- public function testGetHeaderAnomaliesFirefoxTE(): void
226
- {
227
- $contextFirefoxNoTe = $this->createRequestContext([
228
- 'headers' => [
229
- 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
230
- 'accept-language' => 'en-US,en;q=0.9',
231
- ]
232
- ]);
233
- $scoreFirefoxNoTe = RequestUtils::getHeaderAnomalies($contextFirefoxNoTe);
234
- $this->assertEquals(30.0, $scoreFirefoxNoTe['headerAnomalyScore']);
235
- }
236
-
237
- public function testGetBehavioralIndicatorsRotationAndHistory(): void
238
- {
239
- $deviceData = [
240
- 'ips' => ['127.0.0.1'],
241
- 'lastFpHash' => 'cvs:original-canvas|gpu:original-gpu',
242
- 'lastChangeTimestamp' => (time() * 1000) - 500,
243
- 'rapidChangeCount' => 1
244
- ];
245
-
246
- $context = $this->createRequestContext([
247
- 'headers' => ['x-device-fingerprint' => 'cvs:new-canvas|gpu:new-gpu']
248
- ]);
249
-
250
- $indicators = RequestUtils::getBehavioralIndicators($context, $deviceData);
251
- $this->assertEquals(2, $deviceData['rapidChangeCount']);
252
- $this->assertGreaterThan(0, $indicators['rotationScore']);
253
- }
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 testGetClickVarianceScoreReturnsZeroForNoHistory(): void
39
+ {
40
+ $context = $this->createRequestContext([
41
+ 'headers' => ['x-behavior-metrics' => json_encode([])]
42
+ ]);
43
+ $result = RequestUtils::getClickVarianceScore($context);
44
+ $this->assertEquals(0.0, $result['clickVarianceScore']);
45
+ }
46
+
47
+ public function testGetClickVarianceScoreReturnsZeroForInsufficientClicks(): void
48
+ {
49
+ $metrics = [
50
+ 'clicksHistory' => [
51
+ ['x' => 10, 'y' => 10, 'targetId' => 'hash1'],
52
+ ['x' => 11, 'y' => 11, 'targetId' => 'hash1'],
53
+ ['x' => 100, 'y' => 100, 'targetId' => 'hash2']
54
+ ]
55
+ ];
56
+ $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
57
+ $result = RequestUtils::getClickVarianceScore($context);
58
+ $this->assertEquals(0.0, $result['clickVarianceScore']);
59
+ }
60
+
61
+ public function testGetClickVarianceScoreReturnsHighScoreForLowVariance(): void
62
+ {
63
+ $metrics = [
64
+ 'clicksHistory' => [
65
+ ['x' => 100, 'y' => 100, 'targetId' => 'hash1'],
66
+ ['x' => 100.1, 'y' => 100.2, 'targetId' => 'hash1'],
67
+ ['x' => 99.9, 'y' => 99.8, 'targetId' => 'hash1']
68
+ ]
69
+ ];
70
+ $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
71
+ $result = RequestUtils::getClickVarianceScore($context);
72
+ $this->assertGreaterThan(90, $result['clickVarianceScore']);
73
+ }
74
+
75
+ public function testGetClickVarianceScoreReturnsLowScoreForHighVariance(): void
76
+ {
77
+ $metrics = [
78
+ 'clicksHistory' => [
79
+ ['x' => 105, 'y' => 110, 'targetId' => 'hash1'],
80
+ ['x' => 98, 'y' => 102, 'targetId' => 'hash1'],
81
+ ['x' => 112, 'y' => 95, 'targetId' => 'hash1']
82
+ ]
83
+ ];
84
+ $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
85
+ $result = RequestUtils::getClickVarianceScore($context);
86
+ $this->assertEquals(0.0, $result['clickVarianceScore']);
87
+ }
88
+
89
+ public function testSanitizeTrafficDataFiltersSybilAttacks(): void
90
+ {
91
+ $trafficData = [];
92
+ // Ajout de 100 logs provenant d'un attaquant (Sybil)
93
+ for ($i = 0; $i < 100; $i++) {
94
+ $trafficData[] = ['deviceId' => 'attacker_device', 'type' => 'trap_triggered'];
95
+ }
96
+ // Ajout de 10 logs d'utilisateurs légitimes distincts
97
+ for ($i = 0; $i < 10; $i++) {
98
+ $trafficData[] = ['deviceId' => "legit_device_{$i}", 'type' => 'challenge_solved'];
99
+ }
100
+
101
+ $sanitized = RequestUtils::sanitizeTrafficData($trafficData);
102
+
103
+ $attackerLogs = array_filter($sanitized, fn($log) => $log['deviceId'] === 'attacker_device');
104
+
105
+ // Total de 110 logs. 2% de 110 est 2.2 -> max(3, 2) = 3 logs maximum autorisés pour l'attaquant.
106
+ $this->assertLessThanOrEqual(3, count($attackerLogs));
107
+ }
108
+
109
+ public function testChallengePayloadSigningAndVerification(): void
110
+ {
111
+ $secret = 'test-fallback-dev-secret-32-chars-minimum';
112
+ $clientIp = '203.0.113.42';
113
+ $payload = [
114
+ 'clientSecret' => 'some_client_secret',
115
+ 'cpuTarget' => '00000000ffffffff',
116
+ 'fingerprint' => 'os:hash|gpu:hash2',
117
+ 'memDifficulty' => '16',
118
+ 'originalPath' => '/submit',
119
+ ];
120
+
121
+ // 1. Cas nominal : Signature et vérification réussies
122
+ $signature = RequestUtils::signChallengePayload($secret, $payload, $clientIp);
123
+ $this->assertNotEmpty($signature);
124
+
125
+ $payloadWithSig = $payload;
126
+ $payloadWithSig['signature'] = $signature;
127
+
128
+ $isValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, $clientIp);
129
+ $this->assertTrue($isValid, "La signature valide doit être acceptée.");
130
+
131
+ // 2. Détection de modification (tampering) sur cpuTarget
132
+ $tamperedPayload = $payloadWithSig;
133
+ $tamperedPayload['cpuTarget'] = 'ffffffffffffffff'; // Tentative de baisse de la difficulté
134
+
135
+ $isTamperedValid = RequestUtils::verifyChallengePayload($secret, $tamperedPayload, $clientIp);
136
+ $this->assertFalse($isTamperedValid, "Un payload modifié doit être rejeté.");
137
+
138
+ // 3. Détection de modification sur le fingerprint
139
+ $tamperedFpPayload = $payloadWithSig;
140
+ $tamperedFpPayload['fingerprint'] = 'os:another_hash|gpu:hash2';
141
+
142
+ $isTamperedFpValid = RequestUtils::verifyChallengePayload($secret, $tamperedFpPayload, $clientIp);
143
+ $this->assertFalse($isTamperedFpValid, "Un fingerprint modifié doit être rejeté.");
144
+
145
+ // 4. Détection d'usurpation d'adresse IP (IP mismatch)
146
+ $isIpMismatchValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, '198.51.100.1');
147
+ $this->assertFalse($isIpMismatchValid, "Le payload ne doit pas être valide pour une autre adresse IP.");
148
+
149
+ // 5. Absence de signature
150
+ $isMissingSigValid = RequestUtils::verifyChallengePayload($secret, $payload, $clientIp);
151
+ $this->assertFalse($isMissingSigValid, "Un payload sans signature doit être rejeté.");
152
+ }
153
+
154
+ public function testGetBehaviorScoreReturnsProperScores(): void
155
+ {
156
+ $contextEmpty = $this->createRequestContext();
157
+ $scoreEmpty = RequestUtils::getBehaviorScore($contextEmpty);
158
+ $this->assertEquals(0.0, $scoreEmpty['behaviorScore']);
159
+
160
+ $metricsHoneypot = ['honeypotInteraction' => true];
161
+ $contextHoneypot = $this->createRequestContext([
162
+ 'headers' => ['x-behavior-metrics' => json_encode($metricsHoneypot)]
163
+ ]);
164
+ $scoreHoneypot = RequestUtils::getBehaviorScore($contextHoneypot);
165
+ $this->assertEquals(100.0, $scoreHoneypot['behaviorScore']);
166
+
167
+ $metricsNoActivity = ['honeypotInteraction' => false, 'mouseMovementsHistory' => [], 'keystrokeLatency' => 0];
168
+ $contextNoActivity = $this->createRequestContext([
169
+ 'headers' => ['x-behavior-metrics' => json_encode($metricsNoActivity)]
170
+ ]);
171
+ $scoreNoActivity = RequestUtils::getBehaviorScore($contextNoActivity);
172
+ $this->assertEquals(40.0, $scoreNoActivity['behaviorScore']);
173
+ }
174
+
175
+ public function testGetBehaviorScoreWithBotLikeTouchMovements(): void
176
+ {
177
+ $metrics = [
178
+ 'honeypotInteraction' => false,
179
+ 'touchMovementsHistory' => [
180
+ ['x' => 50, 'y' => 50, 't' => 1, 'p' => 0.5, 'r' => 10, 'num' => 1],
181
+ ['x' => 55, 'y' => 55, 't' => 100, 'p' => 0.5, 'r' => 10, 'num' => 1],
182
+ ['x' => 60, 'y' => 60, 't' => 200, 'p' => 0.5, 'r' => 10, 'num' => 1],
183
+ ['x' => 65, 'y' => 65, 't' => 300, 'p' => 0.5, 'r' => 10, 'num' => 1]
184
+ ]
185
+ ];
186
+ $context = $this->createRequestContext([
187
+ 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
188
+ ]);
189
+ $score = RequestUtils::getBehaviorScore($context);
190
+ $this->assertGreaterThan(60.0, $score['behaviorScore']);
191
+ }
192
+
193
+ public function testGetBehaviorScoreWithHumanLikeTouchMovements(): void
194
+ {
195
+ $metrics = [
196
+ 'honeypotInteraction' => false,
197
+ 'touchMovementsHistory' => [
198
+ ['x' => 50, 'y' => 50, 't' => 1, 'p' => 0.45, 'r' => 8.5, 'num' => 1],
199
+ ['x' => 60, 'y' => 52, 't' => 100, 'p' => 0.52, 'r' => 9.1, 'num' => 1],
200
+ ['x' => 72, 'y' => 60, 't' => 200, 'p' => 0.49, 'r' => 8.8, 'num' => 1],
201
+ ['x' => 80, 'y' => 80, 't' => 300, 'p' => 0.41, 'r' => 8.2, 'num' => 1]
202
+ ]
203
+ ];
204
+ $context = $this->createRequestContext([
205
+ 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
206
+ ]);
207
+ $score = RequestUtils::getBehaviorScore($context);
208
+ $this->assertLessThan(30.0, $score['behaviorScore']);
209
+ }
210
+
211
+ public function testGetTimeInconsistencyScore(): void
212
+ {
213
+ $requestTimestamp = time() * 1000;
214
+
215
+ $metricsNormal = ['clientTimestamp' => $requestTimestamp - 100];
216
+ $contextNormal = $this->createRequestContext([
217
+ 'headers' => ['x-behavior-metrics' => json_encode($metricsNormal)]
218
+ ]);
219
+ $contextNormal->requestTimestamp = $requestTimestamp;
220
+ $scoreNormal = RequestUtils::getTimeInconsistencyScore($contextNormal);
221
+ $this->assertEquals(0.0, $scoreNormal['timeInconsistencyScore']);
222
+
223
+ $metricsReplay = ['clientTimestamp' => $requestTimestamp - 10000];
224
+ $contextReplay = $this->createRequestContext([
225
+ 'headers' => ['x-behavior-metrics' => json_encode($metricsReplay)]
226
+ ]);
227
+ $contextReplay->requestTimestamp = $requestTimestamp;
228
+ $scoreReplay = RequestUtils::getTimeInconsistencyScore($contextReplay);
229
+ $this->assertGreaterThan(0.0, $scoreReplay['timeInconsistencyScore']);
230
+ }
231
+
232
+ public function testGetCrossLayerInconsistencyOSMismatch(): void
233
+ {
234
+ $windowsHash = FingerprintBuilder::cyrb53("Windows");
235
+ $context = $this->createRequestContext([
236
+ 'headers' => [
237
+ 'x-device-fingerprint' => "os:{$windowsHash}",
238
+ 'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15'
239
+ ]
240
+ ]);
241
+
242
+ $score = RequestUtils::getCrossLayerInconsistency($context);
243
+ $this->assertEquals(50.0, $score['crossLayerInconsistencyScore']);
244
+ }
245
+
246
+ public function testGetBotScoreDetections(): void
247
+ {
248
+ $contextBot = $this->createRequestContext([
249
+ 'headers' => ['x-device-fingerprint' => 'ua:123|bot:true']
250
+ ]);
251
+ $scoreBot = RequestUtils::getBotScore($contextBot);
252
+ $this->assertEquals(100.0, $scoreBot['botScore']);
253
+
254
+ $contextCdp = $this->createRequestContext([
255
+ 'headers' => ['x-device-fingerprint' => 'ua:123|cdp:true']
256
+ ]);
257
+ $scoreCdp = RequestUtils::getBotScore($contextCdp);
258
+ $this->assertEquals(100.0, $scoreCdp['botScore']);
259
+
260
+ $contextClean = $this->createRequestContext([
261
+ 'headers' => ['x-device-fingerprint' => 'ua:123|os:456']
262
+ ]);
263
+ $scoreClean = RequestUtils::getBotScore($contextClean);
264
+ $this->assertEquals(0.0, $scoreClean['botScore']);
265
+ }
266
+
267
+ public function testGetHeaderAnomaliesFirefoxTE(): void
268
+ {
269
+ $contextFirefoxNoTe = $this->createRequestContext([
270
+ 'headers' => [
271
+ 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
272
+ 'accept-language' => 'en-US,en;q=0.9',
273
+ ]
274
+ ]);
275
+ $scoreFirefoxNoTe = RequestUtils::getHeaderAnomalies($contextFirefoxNoTe);
276
+ $this->assertEquals(30.0, $scoreFirefoxNoTe['headerAnomalyScore']);
277
+ }
278
+
279
+ public function testGetBehavioralIndicatorsRotationAndHistory(): void
280
+ {
281
+ $deviceData = [
282
+ 'ips' => ['127.0.0.1'],
283
+ 'lastFpHash' => 'cvs:original-canvas|gpu:original-gpu',
284
+ 'lastChangeTimestamp' => (time() * 1000) - 500,
285
+ 'rapidChangeCount' => 1
286
+ ];
287
+
288
+ $context = $this->createRequestContext([
289
+ 'headers' => [
290
+ 'x-device-fingerprint' => 'cvs:new-canvas|gpu:new-gpu',
291
+ 'user-agent' => 'Test UA'
292
+ ]
293
+ ]);
294
+
295
+ $indicators = RequestUtils::getBehavioralIndicators($context, $deviceData);
296
+ $this->assertEquals(2, $deviceData['rapidChangeCount']);
297
+ $this->assertGreaterThan(0, $indicators['rotationScore']);
298
+ }
299
+
300
+ public function testGetRequestPatternScoreWeightedSubscores(): void
301
+ {
302
+ $context = $this->createRequestContext(['path' => '/search']);
303
+ $deviceData = [
304
+ 'requestHistory' => [],
305
+ 'timingHistory' => [100, 100, 100, 100, 100, 100], // stdDev = 0
306
+ 'lastPatternScore' => 0.0
307
+ ];
308
+ $patternConfig = [
309
+ 'minSamples' => 5,
310
+ 'regularityThreshold' => 50,
311
+ 'benfordThreshold' => 0.15,
312
+ 'patternWeight' => 80,
313
+ 'decayFactor' => 0.9,
314
+ 'inactivityReset' => 5000,
315
+ 'regularityRatio' => 0.4,
316
+ 'benfordRatio' => 0.3,
317
+ 'enumerationRatio' => 0.3
318
+ ];
319
+
320
+ $result = RequestUtils::getRequestPatternScore($context, $deviceData, $patternConfig);
321
+ // regularityScore = 1.0. regularityRatio = 0.4.
322
+ // instantScore = 1.0 * 0.4 * 80 = 32.
323
+ $this->assertEquals(32.0, $result['requestPatternScore']);
324
+ }
325
+
326
+ public function testGetBotnetClusterScoreCalculations(): void
327
+ {
328
+ $stableFpHash = 'test-stable-hash';
329
+
330
+ $context1 = $this->createRequestContext(['clientIp' => '192.168.1.1']);
331
+ $score = RequestUtils::getBotnetClusterScore($context1, $stableFpHash);
332
+ $this->assertEquals(0.0, $score['botnetClusterScore']);
333
+
334
+ RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.2']), $stableFpHash);
335
+ $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.3']), $stableFpHash);
336
+ $this->assertEquals(50.3, $score['botnetClusterScore']);
337
+
338
+ RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.4']), $stableFpHash);
339
+ $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.5']), $stableFpHash);
340
+ $this->assertEquals(75.3, $score['botnetClusterScore']);
341
+
342
+ for ($i = 6; $i <= 10; $i++) {
343
+ $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => "192.168.1.{$i}"]), $stableFpHash);
344
+ }
345
+ $this->assertEquals(95.7, $score['botnetClusterScore']);
346
+ }
347
+
348
+ public function testRealWorldConsoleBotnetClustering(): void
349
+ {
350
+ $ps4Headers = [
351
+ 'user-agent' => 'Mozilla/5.0 (PlayStation 4 11.50) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.50 Safari/605.1.15',
352
+ 'x-ja3-hash' => '76993ef93bf89104037599723ab9f201',
353
+ 'x-ja4-hash' => 't13d1516h2_8daaf6152771_390237aa04be',
354
+ 'x-http2-fingerprint' => '1:65536;3:1000;4:6291456;6:65536',
355
+ 'x-tcp-fingerprint' => '64240:128:1:mss,nop,ws,nop,nop,sok:df:0'
356
+ ];
357
+
358
+ for ($i = 1; $i <= 10; $i++) {
359
+ $context = $this->createRequestContext([
360
+ 'clientIp' => "185.15.20.{$i}",
361
+ 'headers' => array_merge($ps4Headers, [
362
+ 'cookie_keys' => "session_id=fake_sess_{$i}"
363
+ ])
364
+ ]);
365
+
366
+ $currentHash = RequestUtils::getCompositeDeviceHash($context);
367
+ $stableFp = RequestUtils::extractStablePart($currentHash);
368
+ $stableFpHash = FingerprintBuilder::cyrb53($stableFp);
369
+
370
+ $score = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
371
+
372
+ if ($i === 1) {
373
+ $this->assertEquals(0.0, $score['botnetClusterScore']);
374
+ } elseif ($i === 2) {
375
+ $this->assertEquals(29.5, $score['botnetClusterScore']);
376
+ } elseif ($i === 3) {
377
+ $this->assertEquals(50.3, $score['botnetClusterScore']);
378
+ } elseif ($i === 4) {
379
+ $this->assertEquals(65.0, $score['botnetClusterScore']);
380
+ } elseif ($i === 5) {
381
+ $this->assertEquals(75.3, $score['botnetClusterScore']);
382
+ } elseif ($i === 10) {
383
+ $this->assertEquals(95.7, $score['botnetClusterScore']);
384
+ }
385
+ }
386
+ }
254
387
  }