@anonympins/fingerprint 0.3.4 → 0.3.6
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.
- package/CHANGELOG.md +225 -192
- package/README.md +1087 -1080
- package/package.json +1 -1
- package/src/js/fingerprint.js +403 -63
- package/src/php/FingerprintEngine.php +133 -6
- package/src/php/Ja3AnomalyDetector.php +228 -0
- package/src/php/Tests/Ja3AnomalyDetectorTest.php +180 -0
- package/src/php/Tests/RequestUtilsTest.php +65 -0
- package/src/php/Utils/RequestUtils.php +187 -6
- package/src/php/bin/auto-tune.php +118 -0
|
@@ -324,6 +324,68 @@
|
|
|
324
324
|
|
|
325
325
|
// Score de spoofing TLS
|
|
326
326
|
$tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
|
|
327
|
+
$tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
|
|
328
|
+
|
|
329
|
+
// Advanced JA4 TLS Inconsistency checks
|
|
330
|
+
$ja4 = $context->getHeader('x-ja4-hash');
|
|
331
|
+
if ($ja4) {
|
|
332
|
+
$spoofedJa4s = [
|
|
333
|
+
't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
|
|
334
|
+
't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
|
|
335
|
+
't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
|
|
336
|
+
't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
|
|
337
|
+
't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
|
|
338
|
+
't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
|
|
339
|
+
];
|
|
340
|
+
if (in_array($ja4, $spoofedJa4s, true)) {
|
|
341
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
$parsedJa4 = $this->parseJa4($ja4);
|
|
345
|
+
if ($parsedJa4) {
|
|
346
|
+
$ua = $context->getHeader('user-agent') ?? '';
|
|
347
|
+
$uaParts = $this->parseUserAgent($ua);
|
|
348
|
+
|
|
349
|
+
// Check 1: Incohérence ALPN / HTTP Version
|
|
350
|
+
$httpVersion = $context->httpVersion ?? '1.1';
|
|
351
|
+
if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
|
|
352
|
+
$hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
|
|
353
|
+
if (!$hasProxy) {
|
|
354
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Check 2: Incohérence OS/Plateforme vs Capabilities TLS
|
|
359
|
+
if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
|
|
360
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Check 3: Incohérence User-Agent vs Signature JA4
|
|
364
|
+
if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
|
|
365
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
|
|
366
|
+
}
|
|
367
|
+
if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
|
|
368
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Check 4: La stagnation (Lack of Entropy / Genericity)
|
|
372
|
+
if ($uaParts['browser']) {
|
|
373
|
+
$ja4Key = "ja4-browsers:{$ja4}";
|
|
374
|
+
$seenBrowsers = $store->get($ja4Key) ?: [];
|
|
375
|
+
if (!is_array($seenBrowsers)) {
|
|
376
|
+
$seenBrowsers = [];
|
|
377
|
+
}
|
|
378
|
+
$browserFamily = explode('/', $uaParts['browser'])[0] ?? null;
|
|
379
|
+
if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
|
|
380
|
+
$seenBrowsers[] = $browserFamily;
|
|
381
|
+
$store->set($ja4Key, $seenBrowsers, 86400);
|
|
382
|
+
}
|
|
383
|
+
if (count($seenBrowsers) > 1) {
|
|
384
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
327
389
|
|
|
328
390
|
// Score d'incohérence temporelle (attaque par rejeu)
|
|
329
391
|
$timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
|
|
@@ -346,9 +408,6 @@
|
|
|
346
408
|
// Score de variance des clics
|
|
347
409
|
$clickVariance = RequestUtils::getClickVarianceScore($context);
|
|
348
410
|
|
|
349
|
-
// Score de variance des clics
|
|
350
|
-
$clickVariance = RequestUtils::getClickVarianceScore($context);
|
|
351
|
-
|
|
352
411
|
// Score basé sur les listes de menaces (Threat Intelligence)
|
|
353
412
|
$threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
|
|
354
413
|
|
|
@@ -364,7 +423,7 @@
|
|
|
364
423
|
'historyScore' => $behavioral['historyScore'],
|
|
365
424
|
'rotationScore' => $behavioral['rotationScore'],
|
|
366
425
|
'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
|
|
367
|
-
'tlsSpoofingScore' => $
|
|
426
|
+
'tlsSpoofingScore' => $tlsSpoofingScore,
|
|
368
427
|
'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
|
|
369
428
|
'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
|
|
370
429
|
'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
|
|
@@ -383,6 +442,66 @@
|
|
|
383
442
|
return $suspicionVector;
|
|
384
443
|
}
|
|
385
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Parse a basic User-Agent string.
|
|
447
|
+
*/
|
|
448
|
+
private function parseUserAgent(string $ua): array
|
|
449
|
+
{
|
|
450
|
+
$result = ['browser' => null, 'os' => null];
|
|
451
|
+
|
|
452
|
+
if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
|
|
453
|
+
$result['browser'] = 'Chrome';
|
|
454
|
+
if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
|
|
455
|
+
$result['browser'] .= '/' . $matches[1];
|
|
456
|
+
}
|
|
457
|
+
} elseif (str_contains($ua, 'Firefox')) {
|
|
458
|
+
$result['browser'] = 'Firefox';
|
|
459
|
+
if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
|
|
460
|
+
$result['browser'] .= '/' . $matches[1];
|
|
461
|
+
}
|
|
462
|
+
} elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
|
|
463
|
+
$result['browser'] = 'Safari';
|
|
464
|
+
if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
|
|
465
|
+
$result['browser'] .= '/' . $matches[1];
|
|
466
|
+
}
|
|
467
|
+
} elseif (str_contains($ua, 'Edg')) {
|
|
468
|
+
$result['browser'] = 'Edge';
|
|
469
|
+
if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
|
|
470
|
+
$result['browser'] .= '/' . $matches[1];
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
|
|
475
|
+
elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
|
|
476
|
+
elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
|
|
477
|
+
elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
|
|
478
|
+
elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
|
|
479
|
+
elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
|
|
480
|
+
|
|
481
|
+
return $result;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Parse JA4 string into protocol, version, ALPN etc.
|
|
486
|
+
*/
|
|
487
|
+
private function parseJa4(?string $ja4): ?array
|
|
488
|
+
{
|
|
489
|
+
if (empty($ja4)) return null;
|
|
490
|
+
$parts = explode('_', $ja4);
|
|
491
|
+
$ja4a = $parts[0];
|
|
492
|
+
if (strlen($ja4a) < 10) return null;
|
|
493
|
+
return [
|
|
494
|
+
'protocol' => $ja4a[0],
|
|
495
|
+
'version' => substr($ja4a, 1, 2),
|
|
496
|
+
'sni' => $ja4a[3],
|
|
497
|
+
'ciphersCount' => (int)substr($ja4a, 4, 2),
|
|
498
|
+
'extensionsCount' => (int)substr($ja4a, 6, 2),
|
|
499
|
+
'alpn' => substr($ja4a, 8, 2),
|
|
500
|
+
'ja4b' => $parts[1] ?? null,
|
|
501
|
+
'ja4c' => $parts[2] ?? null
|
|
502
|
+
];
|
|
503
|
+
}
|
|
504
|
+
|
|
386
505
|
/**
|
|
387
506
|
* Traite une requête entrante et retourne une décision.
|
|
388
507
|
* @param RequestContext $context Le contexte de la requête.
|
|
@@ -840,9 +959,17 @@
|
|
|
840
959
|
|
|
841
960
|
// 2. Forward DNS lookup
|
|
842
961
|
$addresses = array_merge(dns_get_record($validHostname, DNS_A) ?: [], dns_get_record($validHostname, DNS_AAAA) ?: []);
|
|
843
|
-
$ips =
|
|
962
|
+
$ips = [];
|
|
963
|
+
foreach ($addresses as $address) {
|
|
964
|
+
if (isset($address['ip'])) {
|
|
965
|
+
$ips[] = $address['ip'];
|
|
966
|
+
}
|
|
967
|
+
if (isset($address['ipv6'])) {
|
|
968
|
+
$ips[] = $address['ipv6'];
|
|
969
|
+
}
|
|
970
|
+
}
|
|
844
971
|
|
|
845
|
-
if (in_array($context->clientIp, $
|
|
972
|
+
if (in_array($context->clientIp, $ips, true)) {
|
|
846
973
|
$store->set($cacheKey, 'verified', 86400);
|
|
847
974
|
return true;
|
|
848
975
|
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Anonympins\Fingerprint;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Classe de détection d'anomalies et d'usurpation TLS (JA3) en PHP.
|
|
9
|
+
*/
|
|
10
|
+
class Ja3AnomalyDetector
|
|
11
|
+
{
|
|
12
|
+
// Liste décimale des valeurs GREASE (RFC 8701) utilisées par les moteurs Chromium/Safari récents
|
|
13
|
+
private const GREASE_VALUES = [
|
|
14
|
+
2570, 6682, 10794, 14906, 19018, 23130, 27242, 31354,
|
|
15
|
+
35466, 39578, 43690, 47802, 51914, 55926, 60038, 64150
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// Base de données locale de signatures JA3 MD5 connues pour la corroboration de base
|
|
19
|
+
private const TLS_FINGERPRINT_DB = [
|
|
20
|
+
'e188a442b87f422c5a1e80b05399435b' => ['Chrome'],
|
|
21
|
+
'd8e35855049321c6042a4325c697858f' => ['Chrome'],
|
|
22
|
+
'a9f90958d44533748c139a5d1895b925' => ['Chrome'],
|
|
23
|
+
'3b5379916d2b3882253c42885956a350' => ['Chrome'],
|
|
24
|
+
'59822058c95c33d2d06e52f410855c8c' => ['Chrome'],
|
|
25
|
+
'b386946a5a586163c7c533636b45c355' => ['Firefox'],
|
|
26
|
+
'66236495a523c1785f8f3a105b248b11' => ['Firefox'],
|
|
27
|
+
'b73d470006575b5e35167a0b5a8540e2' => ['Firefox'],
|
|
28
|
+
'8443d7562933834333943465d52363cf' => ['Firefox'],
|
|
29
|
+
'b633f21d532d35967c8753c38536b4d3' => ['Safari'],
|
|
30
|
+
'4d7a28d5f55b359b69100a311013f03e' => ['Safari', 'Chrome', 'Firefox'],
|
|
31
|
+
'8dd3d7532873575314df23c447543001' => ['Safari', 'Chrome', 'Firefox'],
|
|
32
|
+
// Bibliothèques et scrapers automatisés connus
|
|
33
|
+
'47344a349b75c4e82333475553b5f358' => ['Python'],
|
|
34
|
+
'b29587b8a143c42546133ad7704b3310' => ['Go'],
|
|
35
|
+
'd435b5223b2884c5a832b842637e245f' => ['Java'],
|
|
36
|
+
'c72366b9551263d990b7fa574225332c' => ['curl'],
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Analyse une chaîne JA3 brute non hachée.
|
|
41
|
+
* Format attendu : "TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurveFormats"
|
|
42
|
+
*/
|
|
43
|
+
public static function parseJa3(string $ja3String): ?array
|
|
44
|
+
{
|
|
45
|
+
if (empty($ja3String)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
$parts = explode(',', $ja3String);
|
|
50
|
+
if (count($parts) !== 5) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return [
|
|
55
|
+
'tlsVersion' => (int)$parts[0],
|
|
56
|
+
'ciphers' => $parts[1] !== '' ? array_map('intval', explode('-', $parts[1])) : [],
|
|
57
|
+
'extensions' => $parts[2] !== '' ? array_map('intval', explode('-', $parts[2])) : [],
|
|
58
|
+
'curves' => $parts[3] !== '' ? array_map('intval', explode('-', $parts[3])) : [],
|
|
59
|
+
'points' => $parts[4] !== '' ? array_map('intval', explode('-', $parts[4])) : []
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Vérifie si un tableau contient au moins une valeur GREASE.
|
|
65
|
+
*/
|
|
66
|
+
public static function hasGrease(array $values): bool
|
|
67
|
+
{
|
|
68
|
+
foreach ($values as $val) {
|
|
69
|
+
if (in_array($val, self::GREASE_VALUES, true)) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse sommairement le User-Agent pour en extraire la famille de navigateur.
|
|
78
|
+
*/
|
|
79
|
+
public static function getBrowserFamily(string $userAgent): ?string
|
|
80
|
+
{
|
|
81
|
+
$ua = strtolower($userAgent);
|
|
82
|
+
if (strpos($ua, 'edg') !== false) {
|
|
83
|
+
return 'Edge';
|
|
84
|
+
}
|
|
85
|
+
if (strpos($ua, 'chrome') !== false) {
|
|
86
|
+
return 'Chrome';
|
|
87
|
+
}
|
|
88
|
+
if (strpos($ua, 'firefox') !== false) {
|
|
89
|
+
return 'Firefox';
|
|
90
|
+
}
|
|
91
|
+
if (strpos($ua, 'safari') !== false) {
|
|
92
|
+
return 'Safari';
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Calcule le score global d'anomalie et d'usurpation JA3.
|
|
99
|
+
*
|
|
100
|
+
* @param string|null $ja3Hash L'empreinte MD5 du JA3 (32 caractères)
|
|
101
|
+
* @param string|null $ja3Raw L'empreinte brute non hachée (si disponible)
|
|
102
|
+
* @param string $userAgent Le User-Agent de la requête
|
|
103
|
+
* @param string $httpVersion La version HTTP de la requête (ex: "HTTP/2", "HTTP/1.1", ou "2.0")
|
|
104
|
+
* @param object|null $cacheInstance Un driver de cache (ex: instance Redis) supportant get() et set() pour la détection de stagnation
|
|
105
|
+
* @return int Un score de suspicion compris entre 0 et 100
|
|
106
|
+
*/
|
|
107
|
+
public static function getJa3AnomalyScore(
|
|
108
|
+
?string $ja3Hash,
|
|
109
|
+
?string $ja3Raw,
|
|
110
|
+
string $userAgent,
|
|
111
|
+
string $httpVersion,
|
|
112
|
+
?object $cacheInstance = null
|
|
113
|
+
): int {
|
|
114
|
+
$score = 0;
|
|
115
|
+
$claimedBrowser = self::getBrowserFamily($userAgent);
|
|
116
|
+
$isHumanBrowser = in_array($claimedBrowser, ['Chrome', 'Firefox', 'Safari', 'Edge'], true);
|
|
117
|
+
|
|
118
|
+
// --- ANALYSE 1 : CONTRÔLE SUR LE MD5 DU JA3 ---
|
|
119
|
+
if ($ja3Hash && strlen($ja3Hash) === 32) {
|
|
120
|
+
if (isset(self::TLS_FINGERPRINT_DB[$ja3Hash])) {
|
|
121
|
+
$expectedBrowsers = self::TLS_FINGERPRINT_DB[$ja3Hash];
|
|
122
|
+
|
|
123
|
+
// Cas A : L'empreinte correspond à un outil de scraping mais le UA prétend être humain
|
|
124
|
+
$isLibrary = array_intersect($expectedBrowsers, ['Python', 'Go', 'Java', 'curl']);
|
|
125
|
+
if (!empty($isLibrary) && $isHumanBrowser) {
|
|
126
|
+
$score = max($score, 90); // Suspicion maximale : usurpation évidente
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Cas B : Incohérence directe entre le UA prétendu et la stack TLS correspondante
|
|
130
|
+
if ($claimedBrowser !== null) {
|
|
131
|
+
$matched = false;
|
|
132
|
+
foreach ($expectedBrowsers as $expected) {
|
|
133
|
+
if (stripos($claimedBrowser, $expected) === 0) {
|
|
134
|
+
$matched = true;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!$matched) {
|
|
139
|
+
$score = max($score, 80); // Le navigateur déclaré ne correspond pas au client TLS utilisé
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Cas C : Tracking de stagnation multi-UA (Stateful)
|
|
145
|
+
if ($cacheInstance && $claimedBrowser !== null && method_exists($cacheInstance, 'get') && method_exists($cacheInstance, 'set')) {
|
|
146
|
+
$cacheKey = "ja3-browsers:" . $ja3Hash;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
$rawCached = $cacheInstance->get($cacheKey);
|
|
150
|
+
$seenBrowsers = $rawCached ? json_decode((string)$rawCached, true) : [];
|
|
151
|
+
if (!is_array($seenBrowsers)) {
|
|
152
|
+
$seenBrowsers = [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!in_array($claimedBrowser, $seenBrowsers, true)) {
|
|
156
|
+
$seenBrowsers[] = $claimedBrowser;
|
|
157
|
+
// Cache pendant 24 heures (86400 secondes)
|
|
158
|
+
if (method_exists($cacheInstance, 'setex')) {
|
|
159
|
+
$cacheInstance->setex($cacheKey, 86400, json_encode($seenBrowsers));
|
|
160
|
+
} else {
|
|
161
|
+
$cacheInstance->set($cacheKey, json_encode($seenBrowsers), 86400);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Si une seule stack TLS génère des requêtes avec différents navigateurs, c'est un bot en rotation de UA
|
|
166
|
+
if (count($seenBrowsers) > 1) {
|
|
167
|
+
$score = max($score, 85);
|
|
168
|
+
}
|
|
169
|
+
} catch (\Throwable $e) {
|
|
170
|
+
// Tolérance aux pannes du cache
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- ANALYSE 2 : CONTRÔLE PROFOND SUR L'EMPREINTE BRUTE (RAW JA3) ---
|
|
176
|
+
if ($ja3Raw) {
|
|
177
|
+
$parsed = self::parseJa3($ja3Raw);
|
|
178
|
+
if ($parsed) {
|
|
179
|
+
// Contrôle A : Mécanisme GREASE pour Chrome / Edge (obligatoire)
|
|
180
|
+
if ($claimedBrowser === 'Chrome' || $claimedBrowser === 'Edge') {
|
|
181
|
+
$hasCiphersGrease = self::hasGrease($parsed['ciphers']);
|
|
182
|
+
$hasExtensionsGrease = self::hasGrease($parsed['extensions']);
|
|
183
|
+
|
|
184
|
+
if (!$hasCiphersGrease && !$hasExtensionsGrease) {
|
|
185
|
+
// Chrome ou Edge moderne sans GREASE = spoofing de bas niveau (ex: python-requests déguisé)
|
|
186
|
+
$score = max($score, 75);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Contrôle B : HTTP/2 ou HTTP/3 sans négociation ALPN (Extension 16)
|
|
191
|
+
$isH2OrHigher = (
|
|
192
|
+
strpos($httpVersion, '2.0') !== false ||
|
|
193
|
+
strpos($httpVersion, 'HTTP/2') !== false ||
|
|
194
|
+
strpos($httpVersion, 'HTTP/3') !== false
|
|
195
|
+
);
|
|
196
|
+
$hasAlpnExtension = in_array(16, $parsed['extensions'], true);
|
|
197
|
+
|
|
198
|
+
if ($isH2OrHigher && !$hasAlpnExtension) {
|
|
199
|
+
// Négociation HTTP/2 active au niveau serveur mais absente au niveau des extensions TLS du client
|
|
200
|
+
$score = max($score, 70);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Contrôle C : Version TLS obsolète négociée par un navigateur moderne (ex: TLS < 1.2, id < 771)
|
|
204
|
+
if ($isHumanBrowser && $parsed['tlsVersion'] < 771) {
|
|
205
|
+
$score = max($score, 80);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return $score;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- EXEMPLE D'UTILISATION PRATIQUE ---
|
|
215
|
+
/*
|
|
216
|
+
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
|
217
|
+
$httpVersion = $_SERVER['SERVER_PROTOCOL'] ?? '';
|
|
218
|
+
|
|
219
|
+
// Récupération des en-têtes injectés par votre Reverse-Proxy (Nginx, HAProxy, etc.)
|
|
220
|
+
$ja3Hash = $_SERVER['HTTP_X_JA3_HASH'] ?? null;
|
|
221
|
+
$ja3Raw = $_SERVER['HTTP_X_JA3_RAW'] ?? null;
|
|
222
|
+
|
|
223
|
+
// Redis facultatif pour la détection stateful de rotation UA
|
|
224
|
+
$redis = new \Redis();
|
|
225
|
+
$redis->connect('127.0.0.1', 6379);
|
|
226
|
+
|
|
227
|
+
$suspicionScore = Ja3AnomalyDetector::getJa3AnomalyScore($ja3Hash, $ja3Raw, $userAgent, $httpVersion, $redis);
|
|
228
|
+
*/
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Anonympins\Fingerprint\Tests;
|
|
6
|
+
|
|
7
|
+
use PHPUnit\Framework\TestCase;
|
|
8
|
+
use Anonympins\Fingerprint\Ja3AnomalyDetector;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tests unitaires pour la classe Ja3AnomalyDetector.
|
|
12
|
+
*/
|
|
13
|
+
class Ja3AnomalyDetectorTest extends TestCase
|
|
14
|
+
{
|
|
15
|
+
/**
|
|
16
|
+
* Teste le parsing d'une chaîne JA3 brute valide.
|
|
17
|
+
*/
|
|
18
|
+
public function testParseJa3ValidString(): void
|
|
19
|
+
{
|
|
20
|
+
$ja3 = '771,4865-4866-4867,0-23-65281-10-11,29-23-24,0';
|
|
21
|
+
$parsed = Ja3AnomalyDetector::parseJa3($ja3);
|
|
22
|
+
|
|
23
|
+
$this->assertNotNull($parsed);
|
|
24
|
+
$this->assertSame(771, $parsed['tlsVersion']);
|
|
25
|
+
$this->assertSame([4865, 4866, 4867], $parsed['ciphers']);
|
|
26
|
+
$this->assertSame([0, 23, 65281, 10, 11], $parsed['extensions']);
|
|
27
|
+
$this->assertSame([29, 23, 24], $parsed['curves']);
|
|
28
|
+
$this->assertSame([0], $parsed['points']);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Teste le parsing avec des valeurs manquantes ou malformées.
|
|
33
|
+
*/
|
|
34
|
+
public function testParseJa3InvalidString(): void
|
|
35
|
+
{
|
|
36
|
+
$this->assertNull(Ja3AnomalyDetector::parseJa3(''));
|
|
37
|
+
$this->assertNull(Ja3AnomalyDetector::parseJa3('771,4865,0-23')); // Moins de 5 parties
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Teste la détection du mécanisme GREASE.
|
|
42
|
+
*/
|
|
43
|
+
public function testHasGrease(): void
|
|
44
|
+
{
|
|
45
|
+
// Contient la valeur GREASE 2570 (0x0A0A)
|
|
46
|
+
$this->assertTrue(Ja3AnomalyDetector::hasGrease([4865, 2570, 4866]));
|
|
47
|
+
// Ne contient pas de valeur GREASE
|
|
48
|
+
$this->assertFalse(Ja3AnomalyDetector::hasGrease([4865, 4866, 4867]));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Teste l'extraction de la famille du User-Agent.
|
|
53
|
+
*/
|
|
54
|
+
public function testGetBrowserFamily(): void
|
|
55
|
+
{
|
|
56
|
+
$this->assertSame('Chrome', Ja3AnomalyDetector::getBrowserFamily('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'));
|
|
57
|
+
$this->assertSame('Edge', Ja3AnomalyDetector::getBrowserFamily('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'));
|
|
58
|
+
$this->assertSame('Firefox', Ja3AnomalyDetector::getBrowserFamily('Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0'));
|
|
59
|
+
$this->assertSame('Safari', Ja3AnomalyDetector::getBrowserFamily('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'));
|
|
60
|
+
$this->assertNull(Ja3AnomalyDetector::getBrowserFamily('curl/7.68.0'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Teste la détection d'usurpation d'une bibliothèque connue (ex: Python/Go/curl).
|
|
65
|
+
*/
|
|
66
|
+
public function testSpoofingLibraryDetected(): void
|
|
67
|
+
{
|
|
68
|
+
$pythonJa3 = '47344a349b75c4e82333475553b5f358'; // Signature Python dans DB
|
|
69
|
+
$userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
70
|
+
|
|
71
|
+
$score = Ja3AnomalyDetector::getJa3AnomalyScore($pythonJa3, null, $userAgent, 'HTTP/2');
|
|
72
|
+
|
|
73
|
+
// Devrait retourner un score élevé (90) car une signature python se fait passer pour Chrome
|
|
74
|
+
$this->assertEquals(90, $score);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Teste la détection d'une incohérence de navigateur (ex: signature Firefox avec UA Chrome).
|
|
79
|
+
*/
|
|
80
|
+
public function testBrowserMismatchDetected(): void
|
|
81
|
+
{
|
|
82
|
+
$firefoxJa3 = 'b386946a5a586163c7c533636b45c355'; // Signature Firefox dans DB
|
|
83
|
+
$userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
84
|
+
|
|
85
|
+
$score = Ja3AnomalyDetector::getJa3AnomalyScore($firefoxJa3, null, $userAgent, 'HTTP/2');
|
|
86
|
+
|
|
87
|
+
$this->assertEquals(80, $score);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Teste la détection de stagnation de hash avec rotation d'User-Agents.
|
|
92
|
+
*/
|
|
93
|
+
public function testStagnationWithRotatingUserAgents(): void
|
|
94
|
+
{
|
|
95
|
+
// Mock d'un stockage de cache minimal en mémoire
|
|
96
|
+
$cache = new class {
|
|
97
|
+
private array $store = [];
|
|
98
|
+
public function get(string $key): ?string { return $this->store[$key] ?? null; }
|
|
99
|
+
public function set(string $key, string $val, int $ttl = 0): void { $this->store[$key] = $val; }
|
|
100
|
+
public function setex(string $key, int $ttl, string $val): void { $this->store[$key] = $val; }
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
$unknownJa3 = '00000000000000000000000000000000';
|
|
104
|
+
$chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
|
|
105
|
+
$firefoxUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/120.0';
|
|
106
|
+
|
|
107
|
+
// Premier appel avec Chrome -> pas d'anomalie de stagnation encore
|
|
108
|
+
$score1 = Ja3AnomalyDetector::getJa3AnomalyScore($unknownJa3, null, $chromeUA, 'HTTP/2', $cache);
|
|
109
|
+
$this->assertLessThan(85, $score1);
|
|
110
|
+
|
|
111
|
+
// Deuxième appel avec la même stack TLS mais un UA Firefox -> Détection de rotation !
|
|
112
|
+
$score2 = Ja3AnomalyDetector::getJa3AnomalyScore($unknownJa3, null, $firefoxUA, 'HTTP/2', $cache);
|
|
113
|
+
$this->assertEquals(85, $score2);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Teste l'absence de valeurs GREASE pour un navigateur Chromium.
|
|
118
|
+
*/
|
|
119
|
+
public function testMissingGreaseForChrome(): void
|
|
120
|
+
{
|
|
121
|
+
$chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
|
|
122
|
+
// JA3 brut valide mais sans AUCUNE valeur GREASE dans les extensions ou les ciphers
|
|
123
|
+
$ja3RawWithoutGrease = '771,4865-4866,0-23-10,29,0';
|
|
124
|
+
|
|
125
|
+
$score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $ja3RawWithoutGrease, $chromeUA, 'HTTP/2');
|
|
126
|
+
$this->assertEquals(75, $score);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Teste le cas légitime d'un navigateur Chromium disposant de GREASE.
|
|
131
|
+
*/
|
|
132
|
+
public function testLegitimateChromeWithGrease(): void
|
|
133
|
+
{
|
|
134
|
+
$chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
|
|
135
|
+
// 2570 est une valeur GREASE valide
|
|
136
|
+
$ja3RawWithGrease = '771,4865-2570,0-23-10,29,0';
|
|
137
|
+
|
|
138
|
+
$score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $ja3RawWithGrease, $chromeUA, 'HTTP/2');
|
|
139
|
+
$this->assertLessThan(75, $score);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Teste la détection d'absence d'ALPN lors de connexions HTTP/2.
|
|
144
|
+
*/
|
|
145
|
+
public function testHttp2WithoutAlpnExtension(): void
|
|
146
|
+
{
|
|
147
|
+
$chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
|
|
148
|
+
// Pas d'extension 16 (ALPN) dans les extensions
|
|
149
|
+
$ja3RawNoAlpn = '771,4865-2570,0-23-10,29,0';
|
|
150
|
+
|
|
151
|
+
$score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $ja3RawNoAlpn, $chromeUA, 'HTTP/2');
|
|
152
|
+
$this->assertEquals(70, $score);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Teste la présence d'ALPN lors de connexions HTTP/2 (cas normal).
|
|
157
|
+
*/
|
|
158
|
+
public function testHttp2WithAlpnExtension(): void
|
|
159
|
+
{
|
|
160
|
+
$chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
|
|
161
|
+
// L'extension 16 est présente
|
|
162
|
+
$ja3RawWithAlpn = '771,4865-2570,0-23-10-16,29,0';
|
|
163
|
+
|
|
164
|
+
$score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $ja3RawWithAlpn, $chromeUA, 'HTTP/2');
|
|
165
|
+
$this->assertLessThan(70, $score);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Teste l'utilisation d'une version obsolète de TLS par un navigateur récent.
|
|
170
|
+
*/
|
|
171
|
+
public function testObsoleteTlsVersionWithModernBrowser(): void
|
|
172
|
+
{
|
|
173
|
+
$chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
|
|
174
|
+
// Version TLS 769 (TLS 1.0) initiée par un Chrome récent
|
|
175
|
+
$obsoleteTlsJa3 = '769,4865-2570,0-23-10-16,29,0';
|
|
176
|
+
|
|
177
|
+
$score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $obsoleteTlsJa3, $chromeUA, 'HTTP/2');
|
|
178
|
+
$this->assertEquals(80, $score);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -78,4 +78,69 @@ class RequestUtilsTest extends TestCase
|
|
|
78
78
|
$result = RequestUtils::getClickVarianceScore($context);
|
|
79
79
|
$this->assertEquals(0.0, $result['clickVarianceScore']);
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
public function testSanitizeTrafficDataFiltersSybilAttacks(): void
|
|
83
|
+
{
|
|
84
|
+
$trafficData = [];
|
|
85
|
+
// Ajout de 100 logs provenant d'un attaquant (Sybil)
|
|
86
|
+
for ($i = 0; $i < 100; $i++) {
|
|
87
|
+
$trafficData[] = ['deviceId' => 'attacker_device', 'type' => 'trap_triggered'];
|
|
88
|
+
}
|
|
89
|
+
// Ajout de 10 logs d'utilisateurs légitimes distincts
|
|
90
|
+
for ($i = 0; $i < 10; $i++) {
|
|
91
|
+
$trafficData[] = ['deviceId' => "legit_device_{$i}", 'type' => 'challenge_solved'];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
$sanitized = RequestUtils::sanitizeTrafficData($trafficData);
|
|
95
|
+
|
|
96
|
+
$attackerLogs = array_filter($sanitized, fn($log) => $log['deviceId'] === 'attacker_device');
|
|
97
|
+
|
|
98
|
+
// Total de 110 logs. 2% de 110 est 2.2 -> max(3, 2) = 3 logs maximum autorisés pour l'attaquant.
|
|
99
|
+
$this->assertLessThanOrEqual(3, count($attackerLogs));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public function testChallengePayloadSigningAndVerification(): void
|
|
103
|
+
{
|
|
104
|
+
$secret = 'test-fallback-dev-secret-32-chars-minimum';
|
|
105
|
+
$clientIp = '203.0.113.42';
|
|
106
|
+
$payload = [
|
|
107
|
+
'clientSecret' => 'some_client_secret',
|
|
108
|
+
'cpuTarget' => '00000000ffffffff',
|
|
109
|
+
'fingerprint' => 'os:hash|gpu:hash2',
|
|
110
|
+
'memDifficulty' => '16',
|
|
111
|
+
'originalPath' => '/submit',
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
// 1. Cas nominal : Signature et vérification réussies
|
|
115
|
+
$signature = RequestUtils::signChallengePayload($secret, $payload, $clientIp);
|
|
116
|
+
$this->assertNotEmpty($signature);
|
|
117
|
+
|
|
118
|
+
$payloadWithSig = $payload;
|
|
119
|
+
$payloadWithSig['signature'] = $signature;
|
|
120
|
+
|
|
121
|
+
$isValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, $clientIp);
|
|
122
|
+
$this->assertTrue($isValid, "La signature valide doit être acceptée.");
|
|
123
|
+
|
|
124
|
+
// 2. Détection de modification (tampering) sur cpuTarget
|
|
125
|
+
$tamperedPayload = $payloadWithSig;
|
|
126
|
+
$tamperedPayload['cpuTarget'] = 'ffffffffffffffff'; // Tentative de baisse de la difficulté
|
|
127
|
+
|
|
128
|
+
$isTamperedValid = RequestUtils::verifyChallengePayload($secret, $tamperedPayload, $clientIp);
|
|
129
|
+
$this->assertFalse($isTamperedValid, "Un payload modifié doit être rejeté.");
|
|
130
|
+
|
|
131
|
+
// 3. Détection de modification sur le fingerprint
|
|
132
|
+
$tamperedFpPayload = $payloadWithSig;
|
|
133
|
+
$tamperedFpPayload['fingerprint'] = 'os:another_hash|gpu:hash2';
|
|
134
|
+
|
|
135
|
+
$isTamperedFpValid = RequestUtils::verifyChallengePayload($secret, $tamperedFpPayload, $clientIp);
|
|
136
|
+
$this->assertFalse($isTamperedFpValid, "Un fingerprint modifié doit être rejeté.");
|
|
137
|
+
|
|
138
|
+
// 4. Détection d'usurpation d'adresse IP (IP mismatch)
|
|
139
|
+
$isIpMismatchValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, '198.51.100.1');
|
|
140
|
+
$this->assertFalse($isIpMismatchValid, "Le payload ne doit pas être valide pour une autre adresse IP.");
|
|
141
|
+
|
|
142
|
+
// 5. Absence de signature
|
|
143
|
+
$isMissingSigValid = RequestUtils::verifyChallengePayload($secret, $payload, $clientIp);
|
|
144
|
+
$this->assertFalse($isMissingSigValid, "Un payload sans signature doit être rejeté.");
|
|
145
|
+
}
|
|
81
146
|
}
|