@anonympins/fingerprint 0.7.4 → 0.8.0

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 (64) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/public/anonympins-bot-mitigation-pow.zip +0 -0
  5. package/src/js/dynamic-wasm.js +218 -218
  6. package/src/js/fingerprint.client.js +1784 -1738
  7. package/src/js/fingerprint.client.obfuscated.js +1 -1
  8. package/src/js/fingerprint.js +489 -99
  9. package/src/js/fingerprint.utils.js +252 -2
  10. package/src/js/pow.worker.js +2 -2
  11. package/src/js/tests/cross_parity.test.js +3 -3
  12. package/src/js/tests/dns-circuit-breaker.test.js +6 -6
  13. package/src/js/tests/dynamic-wasm.test.js +77 -77
  14. package/src/js/tests/fingerprint.builder.test.js +76 -78
  15. package/src/js/tests/fingerprint.client.init.test.js +78 -0
  16. package/src/js/tests/fingerprint.client.test.js +16 -17
  17. package/src/js/tests/fingerprint.test.js +37 -12
  18. package/src/js/tests/ja3AnomalyDetector.test.js +135 -136
  19. package/src/js/tests/library.test.js +94 -95
  20. package/src/js/tests/obfuscation.test.js +8 -8
  21. package/src/js/tests/quicFingerprint.test.js +47 -0
  22. package/src/js/tests/tcpFingerprint.test.js +4 -4
  23. package/src/js/tests/threatIntel.test.js +40 -1
  24. package/src/js/upow-model-task.js +155 -112
  25. package/src/php/Challenge/ChallengeUtils.php +392 -86
  26. package/src/php/Config/SecurityProfiles.php +79 -61
  27. package/src/php/DirectFingerprint.php +55 -8
  28. package/src/php/FingerprintBuilder.php +13 -13
  29. package/src/php/FingerprintClient.php +6 -6
  30. package/src/php/FingerprintEngine.php +2122 -1690
  31. package/src/php/Ja3AnomalyDetector.php +22 -22
  32. package/src/php/Optimization/FunctionRegistry.php +4 -4
  33. package/src/php/Optimization/Optimization.php +6 -6
  34. package/src/php/RequestContext.php +11 -11
  35. package/src/php/Store/InMemoryStore.php +96 -66
  36. package/src/php/Store/MongoDbStore.php +2 -2
  37. package/src/php/Store/RedisStore.php +1 -1
  38. package/src/php/Store/StoreManager.php +48 -35
  39. package/src/php/Tests/ChallengeUtilsTest.php +31 -0
  40. package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
  41. package/src/php/Tests/FingerprintClientTest.php +70 -70
  42. package/src/php/Tests/PowTest.php +37 -39
  43. package/src/php/Tests/QuicFingerprintTest.php +115 -53
  44. package/src/php/Tests/ThreatIntelTest.php +34 -0
  45. package/src/php/Utils/BigInt.php +202 -202
  46. package/src/php/Utils/BlockList.php +13 -13
  47. package/src/php/Utils/Logger.php +19 -1
  48. package/src/php/Utils/MaliciousPatterns.php +6 -6
  49. package/src/php/Utils/MetricsManager.php +209 -209
  50. package/src/php/Utils/RequestUtils.php +362 -1
  51. package/src/php/Utils/TLSClientHelloParser.php +385 -369
  52. package/src/php/WordPress/WpDbStore.php +7 -7
  53. package/src/php/WordPress/anonympins-bot-mitigation-pow.php +1121 -0
  54. package/src/php/WordPress/fingerprint-anti-bot.php +0 -559
  55. package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.mo +0 -0
  56. package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.po +232 -0
  57. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.mo +0 -0
  58. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +297 -261
  59. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
  60. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +348 -267
  61. package/src/php/WordPress/package.php +80 -101
  62. package/src/php/WordPress/readme.txt +21 -5
  63. package/src/php/bin/auto-tune.php +1 -1
  64. package/public/fingerprint-anti-bot.zip +0 -0
@@ -116,6 +116,187 @@ class RequestUtils
116
116
  return (string)$srv;
117
117
  }
118
118
 
119
+ /**
120
+ * Extracts Private Access Tokens (PAT / RFC 9578) from request headers.
121
+ *
122
+ * @param RequestContext $context
123
+ * @return array<int, string> Raw binary tokens.
124
+ */
125
+ public static function extractPrivateAccessTokens(RequestContext $context): array
126
+ {
127
+ $tokens = [];
128
+ $authHeader = $context->getHeader('authorization');
129
+ if ($authHeader && stripos($authHeader, 'privatetoken ') === 0) {
130
+ if (preg_match('/token=(?:"([^"]+)"|([a-zA-Z0-9_\-+\/=]+))/i', $authHeader, $matches)) {
131
+ $rawB64 = !empty($matches[1]) ? $matches[1] : $matches[2];
132
+ $decoded = base64_decode(strtr($rawB64, '-_', '+/'), true);
133
+ if ($decoded !== false) {
134
+ $tokens[] = $decoded;
135
+ }
136
+ }
137
+ }
138
+
139
+ $pstHeader = $context->getHeader('sec-private-state-token');
140
+ if ($pstHeader) {
141
+ foreach (explode(',', $pstHeader) as $part) {
142
+ $trimmed = trim($part);
143
+ if (!empty($trimmed)) {
144
+ $decoded = base64_decode(strtr($trimmed, '-_', '+/'), true);
145
+ if ($decoded !== false) {
146
+ $tokens[] = $decoded;
147
+ }
148
+ }
149
+ }
150
+ }
151
+ return $tokens;
152
+ }
153
+
154
+ /**
155
+ * Decodes an RFC 9578 Token structure:
156
+ * uint16_t token_type (2 bytes)
157
+ * uint8_t nonce[32] (32 bytes)
158
+ * uint8_t challenge_digest[32] (32 bytes)
159
+ * uint8_t token_key_id[32] (32 bytes)
160
+ * uint8_t authenticator[Nk] (>= 32 bytes)
161
+ *
162
+ * @param string $binary
163
+ * @return array<string, mixed>|null
164
+ */
165
+ public static function parsePrivateAccessToken(string $binary): ?array
166
+ {
167
+ $len = strlen($binary);
168
+ if ($len < 98) {
169
+ return null;
170
+ }
171
+
172
+ $tokenType = unpack('n', substr($binary, 0, 2))[1];
173
+ $nonce = substr($binary, 2, 32);
174
+ $challengeDigest = substr($binary, 34, 32);
175
+ $tokenKeyId = substr($binary, 66, 32);
176
+ $authenticator = substr($binary, 98);
177
+
178
+ if ($authenticator === '') {
179
+ return null;
180
+ }
181
+
182
+ return [
183
+ 'tokenType' => $tokenType,
184
+ 'nonce' => bin2hex($nonce),
185
+ 'challengeDigest' => bin2hex($challengeDigest),
186
+ 'tokenKeyId' => bin2hex($tokenKeyId),
187
+ 'authenticator' => $authenticator,
188
+ 'signedData' => substr($binary, 0, 98),
189
+ ];
190
+ }
191
+
192
+ /**
193
+ * Cryptographically validates a Private Access Token signature.
194
+ *
195
+ * @param array<string, mixed> $parsedToken
196
+ * @param array<string, mixed> $patConfig
197
+ * @return bool
198
+ */
199
+ public static function verifyPatSignature(array $parsedToken, array $patConfig = []): bool
200
+ {
201
+ $trustedKeys = $patConfig['trustedKeys'] ?? [];
202
+ $keyId = $parsedToken['tokenKeyId'];
203
+ $publicKey = $trustedKeys[$keyId] ?? $trustedKeys[strtolower($keyId)] ?? ($patConfig['defaultPublicKey'] ?? null);
204
+
205
+ if (!$publicKey) {
206
+ return false;
207
+ }
208
+
209
+ $tokenType = (int)$parsedToken['tokenType'];
210
+ $signedData = $parsedToken['signedData'];
211
+ $authenticator = $parsedToken['authenticator'];
212
+
213
+ $pubKeyObj = openssl_pkey_get_public($publicKey);
214
+ if (!$pubKeyObj) {
215
+ return false;
216
+ }
217
+
218
+ // Type 0x0001 (Blind RSA 2048) / 0x0002 (Rate-Limited Blind RSA)
219
+ if ($tokenType === 1 || $tokenType === 2) {
220
+ // Try RSA-PSS with SHA-384, then SHA-256, then PKCS#1 v1.5
221
+ if (defined('OPENSSL_PKCS1_PSS_PADDING') && @openssl_verify($signedData, $authenticator, $pubKeyObj, OPENSSL_ALGO_SHA384)) {
222
+ return true;
223
+ }
224
+ if (defined('OPENSSL_PKCS1_PSS_PADDING') && @openssl_verify($signedData, $authenticator, $pubKeyObj, OPENSSL_ALGO_SHA256)) {
225
+ return true;
226
+ }
227
+ if (@openssl_verify($signedData, $authenticator, $pubKeyObj, OPENSSL_ALGO_SHA256) === 1) {
228
+ return true;
229
+ }
230
+ } elseif ($tokenType === 3) {
231
+ return @openssl_verify($signedData, $authenticator, $pubKeyObj, OPENSSL_ALGO_SHA256) === 1;
232
+ }
233
+
234
+ return false;
235
+ }
236
+
237
+ /**
238
+ * Analyzes TCP MTU and fragmentation flags to detect network tunnels (VPN/Proxy).
239
+ * @param RequestContext $context The request context.
240
+ * @return array{'mtuAnomalyScore': float}
241
+ */
242
+ public static function getMtuAnomalyScore(RequestContext $context): array
243
+ {
244
+ $mtuHeader = $context->getHeader('x-tcp-mtu-info');
245
+ $mssHeader = $context->getHeader('x-tcp-mss') ?? $context->getHeader('x-mss') ?? $context->getHeader('x-forwarded-mss');
246
+
247
+ $mtu = null;
248
+ $df = null;
249
+
250
+ if (!empty($mtuHeader) && is_string($mtuHeader)) {
251
+ $parts = explode(':', $mtuHeader);
252
+ if (count($parts) >= 2) {
253
+ $parsedMtu = filter_var($parts[0], FILTER_VALIDATE_INT);
254
+ $parsedDf = filter_var($parts[1], FILTER_VALIDATE_INT);
255
+ $mtu = $parsedMtu !== false ? $parsedMtu : null;
256
+ $df = $parsedDf !== false ? $parsedDf : null;
257
+ } elseif (count($parts) === 1 && is_numeric($parts[0])) {
258
+ $mtu = (int)$parts[0];
259
+ }
260
+ }
261
+
262
+ // Reconstitution depuis MSS (ex: FastCGI Nginx $tcpinfo_rcv_mss)
263
+ if ($mtu === null && !empty($mssHeader) && is_numeric($mssHeader)) {
264
+ $mss = (int)$mssHeader;
265
+ if ($mss > 0) {
266
+ $isV6 = filter_var($context->clientIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
267
+ $mtu = $mss + ($isV6 ? 60 : 40);
268
+ $df = 1;
269
+ }
270
+ }
271
+
272
+ if ($mtu === null) {
273
+ return ['mtuAnomalyScore' => 0.0];
274
+ }
275
+
276
+ $score = 0.0;
277
+
278
+ // 1. Détection des tunnels (VPN WireGuard/OpenVPN, GRE, proxies résidentiels encapsulés)
279
+ if ($mtu > 500 && $mtu <= 1420) {
280
+ $score += 55.0;
281
+ } elseif ($mtu > 1420 && $mtu < 1492) {
282
+ $score += 30.0;
283
+ }
284
+
285
+ // 2. OS vs. Network Stack Inconsistency
286
+ $ua = $context->getHeader('user-agent') ?? '';
287
+ $uaParts = self::parseUserAgent($ua);
288
+ $os = $uaParts['os'] ?? null;
289
+
290
+ // Pénalités additionnelles en cas d'incohérence OS vs signature réseau.
291
+ if ($os) {
292
+ if (str_starts_with($os, 'Windows') && $mtu < 1492) $score += 20.0;
293
+ if ((str_starts_with($os, 'Android') || str_starts_with($os, 'iOS')) && $mtu < 1480) $score += 15.0;
294
+ if ($df === 0 && (str_starts_with($os, 'Windows') || str_starts_with($os, 'Mac') || str_starts_with($os, 'Linux'))) $score += 40.0;
295
+ }
296
+
297
+ return ['mtuAnomalyScore' => min(100.0, $score)];
298
+ }
299
+
119
300
  /**
120
301
  * Calcule un score d'incohérence entre la signature TLS (JA3) et le User-Agent.
121
302
  * @return array{'tlsSpoofingScore': float}
@@ -177,6 +358,25 @@ class RequestUtils
177
358
  }
178
359
  }
179
360
 
361
+ // ECH & SNI Discrepancy Checks
362
+ $outerSni = $context->getHeader('x-ech-outer-sni');
363
+ $host = explode(':', $context->getHeader('host') ?? '')[0];
364
+ $hasEch = $context->getHeader('x-ech-present') === 'true' || $context->getHeader('x-has-ech') === 'true';
365
+ $echExpected = $context->getHeader('x-ech-expected') === 'true' || !empty($outerSni);
366
+
367
+ if (!empty($outerSni) && !empty($host) && strcasecmp($outerSni, $host) !== 0 && !$hasEch) {
368
+ return ['tlsSpoofingScore' => max($tlsSpoofingScore ?? 0.0, 60.0)];
369
+ }
370
+
371
+ $browserVer = (int)($claimedBrowserInfo['version'] ?? 0);
372
+ $isModernEchBrowser = (
373
+ (in_array($claimedBrowser, ['Chrome', 'Edge'], true) && $browserVer >= 119) ||
374
+ ($claimedBrowser === 'Firefox' && $browserVer >= 118)
375
+ );
376
+ if ($context->isHttps && $isModernEchBrowser && $echExpected && !$hasEch) {
377
+ return ['tlsSpoofingScore' => max($tlsSpoofingScore ?? 0.0, 55.0)];
378
+ }
379
+
180
380
  return ['tlsSpoofingScore' => 0.0];
181
381
  }
182
382
 
@@ -585,8 +785,14 @@ class RequestUtils
585
785
 
586
786
  if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
587
787
  $result['browser'] = 'Chrome';
788
+ if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
789
+ $result['version'] = (int)$matches[1];
790
+ }
588
791
  } elseif (str_contains($ua, 'Firefox')) {
589
792
  $result['browser'] = 'Firefox';
793
+ if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
794
+ $result['version'] = (int)$matches[1];
795
+ }
590
796
  } elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
591
797
  $result['browser'] = 'Safari';
592
798
  } elseif (str_contains($ua, 'Edg')) {
@@ -2058,6 +2264,52 @@ class RequestUtils
2058
2264
  $uaParts = self::parseUserAgent($ua);
2059
2265
  $browser = $uaParts['browser'] ?? null;
2060
2266
 
2267
+ $serverProtocol = strtoupper($context->httpVersion ?? '');
2268
+ $isH2 = str_starts_with($serverProtocol, 'HTTP/2') || str_starts_with($serverProtocol, '2');
2269
+ $isH3 = str_starts_with($serverProtocol, 'HTTP/3') || str_starts_with($serverProtocol, '3');
2270
+ $isH1 = str_starts_with($serverProtocol, 'HTTP/1') || str_starts_with($serverProtocol, '1');
2271
+
2272
+ // 1. Violation RFC 7540 (HTTP/2) & RFC 9114 (HTTP/3) : en-têtes hop-by-hop interdits
2273
+ $hasIllegalHopByHop = $context->getHeader('connection') !== null
2274
+ || $context->getHeader('keep-alive') !== null
2275
+ || $context->getHeader('proxy-connection') !== null;
2276
+
2277
+ if ($isH2 && $hasIllegalHopByHop) {
2278
+ $http2Anomaly += 70.0;
2279
+ }
2280
+ if ($isH3 && $hasIllegalHopByHop) {
2281
+ $quicAnomaly += 75.0;
2282
+ }
2283
+
2284
+ // 2. Corrélation avec la télémétrie client W3C Navigation Timing
2285
+ $clientProtocol = null;
2286
+ $behaviorHeader = $context->getHeader('x-behavior-metrics');
2287
+ if ($behaviorHeader && is_string($behaviorHeader) && str_starts_with($behaviorHeader, '{')) {
2288
+ $metrics = json_decode($behaviorHeader, true);
2289
+ if (is_array($metrics)) {
2290
+ $clientProtocol = $metrics['protocol'] ?? $metrics['nextHopProtocol'] ?? null;
2291
+ }
2292
+ }
2293
+ if ($clientProtocol && is_string($clientProtocol)) {
2294
+ $clientProtocol = strtolower(trim($clientProtocol));
2295
+ $hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-for');
2296
+ if ($clientProtocol === 'h3' && $isH1 && !$hasProxy) {
2297
+ $quicAnomaly += 80.0;
2298
+ } elseif ($clientProtocol === 'h2' && $isH1 && !$hasProxy) {
2299
+ $http2Anomaly += 75.0;
2300
+ } elseif ($clientProtocol === 'http/1.1' && ($isH2 || $isH3)) {
2301
+ $http2Anomaly += 60.0;
2302
+ }
2303
+ }
2304
+
2305
+ // 3. Rétrogradation protocolaire (Navigateur moderne naviguant en HTTP/1.1 sur HTTPS)
2306
+ if ($context->isHttps && $browser && $isH1) {
2307
+ $hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
2308
+ if (!$hasProxy) {
2309
+ $http2Anomaly += 50.0;
2310
+ }
2311
+ }
2312
+
2061
2313
  if ($browser) {
2062
2314
  // HTTP/2 Anomaly logic
2063
2315
  $h2Fp = $context->getHeader('x-http2-fingerprint') ?? $context->http2Fingerprint ?? null;
@@ -2075,11 +2327,40 @@ class RequestUtils
2075
2327
  if ($headerOrder && $headerOrder !== 'm,a,s,p') $http2Anomaly += 60.0;
2076
2328
  if ($connWindow === 65535 || $connWindow === 65536) $http2Anomaly += 40.0;
2077
2329
  if ($streamPriority === '0' || $streamPriority === '') $http2Anomaly += 50.0;
2330
+
2331
+ $priorityDeps = substr_count($streamPriority, ',');
2332
+ if (str_contains($streamPriority, ',') && $priorityDeps < 2 && !empty($streamPriority) && $streamPriority !== '0') {
2333
+ $http2Anomaly += 35.0;
2334
+ }
2078
2335
  } elseif ($isFirefox) {
2079
2336
  if ($headerOrder && $headerOrder !== 'm,s,p,a') $http2Anomaly += 60.0;
2080
2337
  } elseif ($isSafari) {
2081
2338
  if ($headerOrder && $headerOrder !== 'm,s,p,a') $http2Anomaly += 60.0;
2082
2339
  }
2340
+
2341
+ // Analyse de la structure des dépendances de flux HTTP/2 (RFC 7540)
2342
+ if (!empty($streamPriority)) {
2343
+ $priorityEntries = array_filter(array_map('trim', explode(',', $streamPriority)));
2344
+ foreach ($priorityEntries as $entry) {
2345
+ $sub = explode(':', $entry);
2346
+ if (count($sub) >= 4) {
2347
+ $streamId = (int)$sub[0];
2348
+ $exclusive = (int)$sub[1];
2349
+ $depStreamId = (int)$sub[2];
2350
+ $weight = (int)$sub[3];
2351
+
2352
+ if ($streamId === $depStreamId && $streamId > 0) {
2353
+ $http2Anomaly += 60.0;
2354
+ }
2355
+ if ($weight < 1 || $weight > 256) {
2356
+ $http2Anomaly += 45.0;
2357
+ }
2358
+ if ($isChromium && $depStreamId === 0 && $weight === 16 && $exclusive === 0) {
2359
+ $http2Anomaly += 55.0;
2360
+ }
2361
+ }
2362
+ }
2363
+ }
2083
2364
  }
2084
2365
  }
2085
2366
 
@@ -2111,7 +2392,32 @@ class RequestUtils
2111
2392
  if ($isChromium) {
2112
2393
  if ($maxData > 0 && $maxData < 1048576) $quicAnomaly += 40.0;
2113
2394
  if ($maxStreams > 0 && $maxStreams !== 100) $quicAnomaly += 30.0;
2114
- if (!empty($priorityOrder) && !str_contains($priorityOrder, 'u=')) $quicAnomaly += 30.0;
2395
+
2396
+ if (!empty($priorityOrder)) {
2397
+ if (preg_match('/\bu=(-?\d+)\b/', $priorityOrder, $uMatch)) {
2398
+ $urgency = (int)$uMatch[1];
2399
+ if ($urgency < 0 || $urgency > 7) {
2400
+ $quicAnomaly += 50.0;
2401
+ }
2402
+ }
2403
+ if (preg_match('/\bi=\d+\b/', $priorityOrder)) {
2404
+ $quicAnomaly += 40.0;
2405
+ }
2406
+ if (preg_match('/\bw=\d+\b/', $priorityOrder)) {
2407
+ $quicAnomaly += 45.0;
2408
+ }
2409
+ }
2410
+
2411
+ if (!empty($priorityOrder)) {
2412
+ if (str_contains($priorityOrder, 'u=') && !str_contains($priorityOrder, 'i')) {
2413
+ $quicAnomaly += 40.0;
2414
+ } elseif (!str_contains($priorityOrder, 'u=')) {
2415
+ $quicAnomaly += 30.0;
2416
+ }
2417
+ if ($priorityOrder === 'p' || $priorityOrder === 'i') {
2418
+ $quicAnomaly += 50.0;
2419
+ }
2420
+ }
2115
2421
  if ($bidiLocal > 0 && ($bidiLocal < 524288 || $bidiLocal === 262144)) $quicAnomaly += 40.0;
2116
2422
  if ($bidiRemote > 0 && ($bidiRemote < 524288 || $bidiRemote === 262144)) $quicAnomaly += 30.0;
2117
2423
 
@@ -2123,6 +2429,11 @@ class RequestUtils
2123
2429
  if ($sIdx !== null && $sIdx !== 0) $quicAnomaly += 50.0;
2124
2430
  if ($mIdx !== null && $sIdx !== null && $mIdx < $sIdx) $quicAnomaly += 60.0;
2125
2431
  if ($pIdx !== null && $sIdx !== null && $pIdx < $sIdx) $quicAnomaly += 60.0;
2432
+
2433
+ $sCount = count(array_filter($frameOrder, fn($f) => in_array($f, ['s', 'settings', '4'], true)));
2434
+ if ($sCount > 1) {
2435
+ $quicAnomaly += 50.0;
2436
+ }
2126
2437
  }
2127
2438
  } elseif ($isFirefox) {
2128
2439
  if ($maxData > 0 && $maxData > 5000000) $quicAnomaly += 40.0;
@@ -2151,6 +2462,56 @@ class RequestUtils
2151
2462
  }
2152
2463
  }
2153
2464
 
2465
+ // HPACK / QPACK Compression Ratio Analysis
2466
+ $compressionInfo = $context->getHeader('x-compression-info');
2467
+ if (!empty($compressionInfo)) {
2468
+ $info = [];
2469
+ foreach (explode(',', $compressionInfo) as $item) {
2470
+ $kv = explode(':', $item, 2);
2471
+ if (count($kv) === 2) {
2472
+ $info[trim($kv[0])] = (float)trim($kv[1]);
2473
+ }
2474
+ }
2475
+ $reqCount = $info['req_count'] ?? 0;
2476
+ $hpackRatio = $info['hpack_ratio'] ?? null;
2477
+ $qpackRatio = $info['qpack_ratio'] ?? null;
2478
+ $dynamicEntries = isset($info['dynamic_table_entries']) ? (int)$info['dynamic_table_entries'] : (isset($info['table_entries']) ? (int)$info['table_entries'] : null);
2479
+ $dynamicHits = isset($info['dynamic_hits']) ? (int)$info['dynamic_hits'] : null;
2480
+ $dynamicTableSize = isset($info['dynamic_table_size']) ? (int)$info['dynamic_table_size'] : null;
2481
+
2482
+ $claimedFamily = explode('/', $browser ?? '')[0];
2483
+ $isHuman = in_array($claimedFamily, ['Chrome', 'Firefox', 'Safari', 'Edge'], true);
2484
+
2485
+ if ($reqCount > 3 && $isHuman) {
2486
+ if ($hpackRatio !== null) {
2487
+ if ($hpackRatio < 0.4) {
2488
+ $http2Anomaly += (1.0 - $hpackRatio) * 50.0;
2489
+ } elseif ($hpackRatio > 0.75) {
2490
+ $http2Anomaly += min(50.0, ($hpackRatio - 0.5) * 100.0);
2491
+ }
2492
+ }
2493
+ if ($qpackRatio !== null) {
2494
+ if ($qpackRatio < 0.4) {
2495
+ $quicAnomaly += (1.0 - $qpackRatio) * 50.0;
2496
+ } elseif ($qpackRatio > 0.75) {
2497
+ $quicAnomaly += min(50.0, ($qpackRatio - 0.5) * 100.0);
2498
+ }
2499
+ }
2500
+ if ($dynamicEntries !== null && $dynamicEntries === 0) {
2501
+ $http2Anomaly += 45.0;
2502
+ $quicAnomaly += 45.0;
2503
+ }
2504
+ if ($dynamicHits !== null && $dynamicHits === 0) {
2505
+ $http2Anomaly += 35.0;
2506
+ $quicAnomaly += 35.0;
2507
+ }
2508
+ if ($dynamicTableSize !== null && $dynamicTableSize === 0) {
2509
+ $http2Anomaly += 40.0;
2510
+ $quicAnomaly += 40.0;
2511
+ }
2512
+ }
2513
+ }
2514
+
2154
2515
  return [
2155
2516
  'protocolAnomalyScore' => max(
2156
2517
  0.0,