@anonympins/fingerprint 0.6.6 → 0.7.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.
- package/CHANGELOG.md +32 -0
- package/README.md +11 -2
- package/package.json +1 -1
- package/public/fp.js +1 -1
- package/public/fp.wasm +0 -0
- package/src/js/build-client.js +1 -1
- package/src/js/dynamic-wasm.js +7 -7
- package/src/js/fingerprint.client.js +1463 -1447
- package/src/js/fingerprint.js +386 -94
- package/src/js/library.js +15 -8
- package/src/js/pow.solver.inline.js +56 -10
- package/src/js/pow.solver.js +56 -10
- package/src/js/pow.worker.js +35 -13
- package/src/js/problem-manager.js +10 -3
- package/src/js/tests/fingerprint.engine.test.js +25 -0
- package/src/js/tests/fingerprint.test.js +69 -35
- package/src/php/Challenge/ChallengeUtils.php +149 -23
- package/src/php/Config/SecurityProfiles.php +1 -0
- package/src/php/FingerprintClient.php +148 -148
- package/src/php/FingerprintEngine.php +39 -84
- package/src/php/Ja3AnomalyDetector.php +257 -227
- package/src/php/Optimization/OptimizationOperators.php +2 -2
- package/src/php/ProblemManager.php +83 -0
- package/src/php/Tests/FingerprintEngineTest.php +2 -33
- package/src/php/Utils/RequestUtils.php +114 -11
- package/src/php/Utils/TLSClientHelloParser.php +158 -1
- package/src/js/fingerprint.client.obfuscated.js +0 -1
|
@@ -1,228 +1,258 @@
|
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
$
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
$
|
|
225
|
-
$
|
|
226
|
-
|
|
227
|
-
$
|
|
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
|
+
* Feature 7 : Corrélation RTT & Latence de Proxy Résidentiel
|
|
215
|
+
* Calcule un score d'anomalie en corrélant le RTT TCP de bas niveau avec la latence applicative.
|
|
216
|
+
*/
|
|
217
|
+
public static function getRttProxyScore(RequestContext $context): int
|
|
218
|
+
{
|
|
219
|
+
$tcpRttHeader = $context->headers['x-tcp-rtt'] ?? $context->headers['x-real-rtt'] ?? null;
|
|
220
|
+
$tcpRtt = $tcpRttHeader !== null ? (int)$tcpRttHeader : null;
|
|
221
|
+
|
|
222
|
+
$behaviorHeader = $context->headers['x-behavior-metrics'] ?? null;
|
|
223
|
+
if ($behaviorHeader) {
|
|
224
|
+
$metrics = json_decode($behaviorHeader, true);
|
|
225
|
+
if (json_last_error() === JSON_ERROR_NONE && isset($metrics['clientTimestamp'])) {
|
|
226
|
+
$clientTimestamp = (int)$metrics['clientTimestamp'];
|
|
227
|
+
$appLatency = $context->requestTimestamp - $clientTimestamp;
|
|
228
|
+
if ($tcpRtt !== null && $tcpRtt > 0) {
|
|
229
|
+
$clientToProxyDelta = $appLatency - $tcpRtt;
|
|
230
|
+
if ($tcpRtt < 35 && $clientToProxyDelta > 150) {
|
|
231
|
+
return 85;
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
if ($appLatency > 350) {
|
|
235
|
+
return 40;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- EXEMPLE D'UTILISATION PRATIQUE ---
|
|
245
|
+
/*
|
|
246
|
+
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
|
247
|
+
$httpVersion = $_SERVER['SERVER_PROTOCOL'] ?? '';
|
|
248
|
+
|
|
249
|
+
// Récupération des en-têtes injectés par votre Reverse-Proxy (Nginx, HAProxy, etc.)
|
|
250
|
+
$ja3Hash = $_SERVER['HTTP_X_JA3_HASH'] ?? null;
|
|
251
|
+
$ja3Raw = $_SERVER['HTTP_X_JA3_RAW'] ?? null;
|
|
252
|
+
|
|
253
|
+
// Redis facultatif pour la détection stateful de rotation UA
|
|
254
|
+
$redis = new \Redis();
|
|
255
|
+
$redis->connect('127.0.0.1', 6379);
|
|
256
|
+
|
|
257
|
+
$suspicionScore = Ja3AnomalyDetector::getJa3AnomalyScore($ja3Hash, $ja3Raw, $userAgent, $httpVersion, $redis);
|
|
228
258
|
*/
|
|
@@ -104,7 +104,7 @@ class OptimizationOperators
|
|
|
104
104
|
'importance' => 3.0,
|
|
105
105
|
'ux_vs_security_ratio' => 0.8,
|
|
106
106
|
'target_threshold' => 'low',
|
|
107
|
-
|
|
107
|
+
'indicators' => ['requestPatternScore', 'renderingAnomalyScore', 'clientHintsInconsistencyScore', 'virtualizationScore']
|
|
108
108
|
],
|
|
109
109
|
'distributed_botnets' => [
|
|
110
110
|
'importance' => 6.0,
|
|
@@ -116,7 +116,7 @@ class OptimizationOperators
|
|
|
116
116
|
'importance' => 5.0,
|
|
117
117
|
'ux_vs_security_ratio' => 0.4,
|
|
118
118
|
'target_threshold' => 'medium',
|
|
119
|
-
|
|
119
|
+
'indicators' => ['botScore', 'tlsSpoofingScore', 'tcpAnomalyScore', 'virtualizationScore']
|
|
120
120
|
]
|
|
121
121
|
];
|
|
122
122
|
|
|
@@ -303,6 +303,89 @@ class ProblemManager
|
|
|
303
303
|
return false;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
/**
|
|
307
|
+
* S'assure qu'un problème a une solution initiale. Si non, en génère une.
|
|
308
|
+
*
|
|
309
|
+
* @param array &$problem Le problème à vérifier (passé par référence).
|
|
310
|
+
*/
|
|
311
|
+
private function ensureInitialSolution(array &$problem): void
|
|
312
|
+
{
|
|
313
|
+
if (!empty($problem['state']['bestSolution'])) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
$scoreFunction = $problem['workUnit']['scoreFunction'] ?? null;
|
|
318
|
+
$initialSolutionSourceKey = $problem['workUnit']['initialSolutionSource'] ?? '';
|
|
319
|
+
$initialSolution = $problem['payload'][$initialSolutionSourceKey] ?? null;
|
|
320
|
+
|
|
321
|
+
if ($scoreFunction && is_array($initialSolution)) {
|
|
322
|
+
$score = $scoreFunction($initialSolution, $problem['payload'] ?? []);
|
|
323
|
+
$problem['state']['bestSolution'] = $initialSolution;
|
|
324
|
+
$problem['state']['bestEnergy'] = $score;
|
|
325
|
+
$problem['state']['lastUpdate'] = (new \DateTime())->format(\DateTime::ATOM);
|
|
326
|
+
$this->store->set("problem-state:{$problem['id']}", $problem['state']);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Formate l'état d'un problème pour l'export externe.
|
|
332
|
+
*/
|
|
333
|
+
private function formatSolution(array $problem): ?array
|
|
334
|
+
{
|
|
335
|
+
if (!isset($problem['state'])) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (($problem['workUnit']['type'] ?? '') === 'multi_objective_genetic_algorithm') {
|
|
340
|
+
return [
|
|
341
|
+
'id' => $problem['id'],
|
|
342
|
+
'solution' => $problem['state']['paretoFront'] ?? [],
|
|
343
|
+
'score' => count($problem['state']['paretoFront'] ?? []),
|
|
344
|
+
'lastUpdate' => $problem['state']['lastUpdate'] ?? null,
|
|
345
|
+
];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return [
|
|
349
|
+
'id' => $problem['id'],
|
|
350
|
+
'solution' => $problem['state']['bestSolution'] ?? null,
|
|
351
|
+
'score' => $problem['state']['bestEnergy'] ?? null,
|
|
352
|
+
'lastUpdate' => $problem['state']['lastUpdate'] ?? null,
|
|
353
|
+
];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Récupère la meilleure solution actuellement connue pour un ou plusieurs problèmes.
|
|
358
|
+
*
|
|
359
|
+
* @param string|null $problemId L'ID optionnel du problème à consulter.
|
|
360
|
+
* @return array|null Un tableau associatif ou une liste de tableaux associatifs.
|
|
361
|
+
*/
|
|
362
|
+
public function getBestSolutions(?string $problemId = null): ?array
|
|
363
|
+
{
|
|
364
|
+
$problemsToProcess = [];
|
|
365
|
+
foreach ($this->problems as &$p) {
|
|
366
|
+
if ($problemId === null || $p['id'] === $problemId) {
|
|
367
|
+
if (($p['workUnit']['type'] ?? '') !== 'multi_objective_genetic_algorithm') {
|
|
368
|
+
$this->ensureInitialSolution($p);
|
|
369
|
+
}
|
|
370
|
+
$problemsToProcess[] = $p;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
unset($p);
|
|
374
|
+
|
|
375
|
+
if ($problemId !== null) {
|
|
376
|
+
return !empty($problemsToProcess) ? $this->formatSolution($problemsToProcess[0]) : null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
$results = [];
|
|
380
|
+
foreach ($this->problems as $p) {
|
|
381
|
+
$formatted = $this->formatSolution($p);
|
|
382
|
+
if ($formatted && $formatted['solution'] !== null) {
|
|
383
|
+
$results[] = $formatted;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return $results;
|
|
387
|
+
}
|
|
388
|
+
|
|
306
389
|
/**
|
|
307
390
|
* Réinitialise l'instance singleton.
|
|
308
391
|
* @internal Uniquement pour les tests.
|
|
@@ -222,8 +222,8 @@ class FingerprintEngineTest extends TestCase
|
|
|
222
222
|
|
|
223
223
|
$decision = $this->engine->processRequest($nextContext);
|
|
224
224
|
|
|
225
|
-
// Le score d'incohérence
|
|
226
|
-
$this->
|
|
225
|
+
// Le score d'incohérence plafonne à l'asymptote de 99.9
|
|
226
|
+
$this->assertEqualsWithDelta(99.9, $decision['vector']['inconsistencyScore'], 0.1);
|
|
227
227
|
// L'action devrait être un challenge ou un blocage
|
|
228
228
|
$this->assertContains($decision['action'], ['challenge', 'block']);
|
|
229
229
|
}
|
|
@@ -288,37 +288,6 @@ class FingerprintEngineTest extends TestCase
|
|
|
288
288
|
$this->assertEquals(0, $decision['score']);
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
public function testReChallengeWhenScoreIsHighEvenWithValidTicket(): void
|
|
292
|
-
{
|
|
293
|
-
// 1. Établir l'identité d'origine du terminal
|
|
294
|
-
$context = $this->createRequestContext();
|
|
295
|
-
$decision = $this->engine->processRequest($context);
|
|
296
|
-
$deviceIdCookie = $decision['newCookieForResponse'];
|
|
297
|
-
|
|
298
|
-
// Générer un ticket de clearance cryptographiquement valide
|
|
299
|
-
$expiry = (time() + 3600) * 1000;
|
|
300
|
-
$secret = getenv('POW_SECRET') ?: "fallback-dev-secret-32-chars-minimum";
|
|
301
|
-
$signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", $secret);
|
|
302
|
-
$validTicket = "{$expiry}:{$signature}";
|
|
303
|
-
|
|
304
|
-
// Créer une requête avec un ticket valide, mais avec un UA et des attributs incohérents provoquant un score >= 75
|
|
305
|
-
$suspiciousContext = $this->createRequestContext([
|
|
306
|
-
'cookies' => [
|
|
307
|
-
$deviceIdCookie['name'] => $deviceIdCookie['value'],
|
|
308
|
-
'pow_clearance' => $validTicket
|
|
309
|
-
],
|
|
310
|
-
'headers' => [
|
|
311
|
-
'user-agent' => 'DefinitelyNotTheSameBrowser/1.0', // Incohérence matérielle forte
|
|
312
|
-
'accept-language' => 'fr-FR',
|
|
313
|
-
]
|
|
314
|
-
]);
|
|
315
|
-
|
|
316
|
-
$redecision = $this->engine->processRequest($suspiciousContext);
|
|
317
|
-
|
|
318
|
-
// Malgré le ticket valide, le score est élevé, une nouvelle vérification PoW doit être imposée
|
|
319
|
-
$this->assertEquals('challenge', $redecision['action']);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
291
|
public function testEd25519KeyAutoGenerationAndPersistence(): void
|
|
323
292
|
{
|
|
324
293
|
if (!extension_loaded('openssl')) {
|