@anonympins/fingerprint 0.3.6 → 0.3.7
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 +20 -1
- package/README.md +119 -6
- package/package.json +1 -1
- package/src/js/build-client.js +4 -5
- package/src/js/fingerprint.client.js +2 -2
- package/src/js/fingerprint.js +202 -54
- package/src/js/library.js +1 -1
- package/src/js/mongodb-store.js +79 -52
- package/src/js/optimization.worker.js +2 -2
- package/src/js/problem-manager.js +2 -2
- package/src/php/DirectFingerprint.php +145 -80
- package/src/php/FingerprintEngine.php +19 -3
- package/src/php/ProblemManager.php +1 -1
- package/src/php/RequestContext.php +90 -90
- package/src/php/Store/MongoDbStore.php +105 -0
- package/src/php/Store/RedisStore.php +54 -0
- package/src/php/Tests/ChallengeUtilsTest.php +1 -1
- package/src/php/Tests/FingerprintBuilderTest.php +1 -1
- package/src/php/Tests/FingerprintEngineTest.php +4 -4
- package/src/php/Tests/IpReputationTest.php +4 -4
- package/src/php/Tests/Ja3AnomalyDetectorTest.php +1 -1
- package/src/php/Tests/MetricsTest.php +46 -0
- package/src/php/Tests/PowTest.php +2 -2
- package/src/php/Tests/ProblemManagerTest.php +5 -3
- package/src/php/Tests/RequestUtilsTest.php +1 -1
- package/src/php/Utils/MetricsManager.php +167 -0
- package/src/php/Utils/RequestUtils.php +1142 -1142
- package/src/php/bin/auto-tune.php +2 -2
|
@@ -1,1143 +1,1143 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
declare(strict_types=1);
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
namespace Anonympins\Fingerprint\Utils;
|
|
7
|
-
|
|
8
|
-
use Anonympins\Fingerprint\FingerprintBuilder;
|
|
9
|
-
use Anonympins\Fingerprint\
|
|
10
|
-
use Anonympins\Fingerprint\
|
|
11
|
-
use Anonympins\Fingerprint\
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Classe utilitaire pour l'analyse des requêtes et le calcul des scores de suspicion.
|
|
15
|
-
*/
|
|
16
|
-
class RequestUtils
|
|
17
|
-
{
|
|
18
|
-
/**
|
|
19
|
-
* Base de données de signatures JA3 connues.
|
|
20
|
-
* @var array<string, string|string[]>
|
|
21
|
-
*/
|
|
22
|
-
private const TLS_FINGERPRINT_DB = [
|
|
23
|
-
// --- Chrome (Desktop) ---
|
|
24
|
-
'e188a442b87f422c5a1e80b05399435b' => 'Chrome',
|
|
25
|
-
'd8e35855049321c6042a4325c697858f' => 'Chrome',
|
|
26
|
-
'a9f90958d44533748c139a5d1895b925' => 'Chrome',
|
|
27
|
-
'3b5379916d2b3882253c42885956a350' => 'Chrome',
|
|
28
|
-
// --- Chrome (Mobile) ---
|
|
29
|
-
'59822058c95c33d2d06e52f410855c8c' => 'Chrome',
|
|
30
|
-
// --- Firefox (Desktop) ---
|
|
31
|
-
'b386946a5a586163c7c533636b45c355' => 'Firefox',
|
|
32
|
-
'66236495a523c1785f8f3a105b248b11' => 'Firefox',
|
|
33
|
-
'b73d470006575b5e35167a0b5a8540e2' => 'Firefox',
|
|
34
|
-
'8443d7562933834333943465d52363cf' => 'Firefox',
|
|
35
|
-
// --- Firefox (Mobile) ---
|
|
36
|
-
'02720628957d38c6111a18433abe833f' => 'Firefox',
|
|
37
|
-
// --- Safari & iOS (Shared TLS Stack) ---
|
|
38
|
-
'b633f21d532d35967c8753c38536b4d3' => 'Safari',
|
|
39
|
-
'4d7a28d5f55b359b69100a311013f03e' => ['Safari', 'Chrome', 'Firefox'],
|
|
40
|
-
'8dd3d7532873575314df23c447543001' => ['Safari', 'Chrome', 'Firefox'],
|
|
41
|
-
// --- Common Libraries & Bots ---
|
|
42
|
-
'47344a349b75c4e82333475553b5f358' => 'Python',
|
|
43
|
-
'b29587b8a143c42546133ad7704b3310' => 'Go',
|
|
44
|
-
'd435b5223b2884c5a832b842637e245f' => 'Java',
|
|
45
|
-
'c72366b9551263d990b7fa574225332c' => 'curl',
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Base de données de signatures JA4 connues.
|
|
50
|
-
* @var array<string, string|string[]>
|
|
51
|
-
*/
|
|
52
|
-
private const JA4_FINGERPRINT_DB = [
|
|
53
|
-
// Format: {JA4 Hash} => {Client Name}
|
|
54
|
-
// --- Chrome ---
|
|
55
|
-
't13d1517h2_8daaf61527d5' => 'Chrome', // Chrome 117 on Win11
|
|
56
|
-
't13d1516h2_8daaf61527d5' => 'Chrome', // Chrome 116 on Win10
|
|
57
|
-
// --- Firefox ---
|
|
58
|
-
't13d1517h2_2491a244c393' => 'Firefox', // Firefox 117 on Win11
|
|
59
|
-
// --- Common Libraries & Bots ---
|
|
60
|
-
't13d1500h1_4b56136b4d35' => 'Python', // Python requests
|
|
61
|
-
];
|
|
62
|
-
/**
|
|
63
|
-
* Crée un hash composite stable basé sur les caractéristiques de la requête.
|
|
64
|
-
*/
|
|
65
|
-
public static function getCompositeDeviceHash(RequestContext $context): string
|
|
66
|
-
{
|
|
67
|
-
$srv = new FingerprintBuilder();
|
|
68
|
-
|
|
69
|
-
$clientFp = $context->getHeader('x-device-fingerprint');
|
|
70
|
-
if ($clientFp && str_contains($clientFp, 'cvs:')) {
|
|
71
|
-
$srv->add("client_fp_hash", $clientFp);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
$ua = $context->getHeader("user-agent");
|
|
75
|
-
if ($ua) {
|
|
76
|
-
$srv->add("ua", $ua);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if ($context->ja3) $srv->add("ja3", $context->ja3);
|
|
80
|
-
if ($context->ja4) $srv->add("ja4", $context->ja4);
|
|
81
|
-
if ($context->ja4s) $srv->add("ja4s", $context->ja4s);
|
|
82
|
-
if ($context->ja4h) $srv->add("ja4h", $context->ja4h);
|
|
83
|
-
if ($context->http2Fingerprint) $srv->add("h2", $context->http2Fingerprint);
|
|
84
|
-
if ($context->tcpFingerprint) $srv->add("tcp", $context->tcpFingerprint);
|
|
85
|
-
|
|
86
|
-
$headersToCapture = [
|
|
87
|
-
"ch_ua" => "sec-ch-ua",
|
|
88
|
-
"ch_platform" => "sec-ch-ua-platform",
|
|
89
|
-
"ch_mobile" => "sec-ch-ua-mobile",
|
|
90
|
-
"ch_model" => "sec-ch-ua-model",
|
|
91
|
-
"ch_arch" => "sec-ch-ua-arch",
|
|
92
|
-
"ch_bitness" => "sec-ch-ua-bitness",
|
|
93
|
-
"upgrade_req" => "upgrade-insecure-requests",
|
|
94
|
-
"accept_lang" => "accept-language",
|
|
95
|
-
"accept_enc" => "accept-encoding",
|
|
96
|
-
"accept" => "accept"
|
|
97
|
-
];
|
|
98
|
-
|
|
99
|
-
foreach ($headersToCapture as $key => $headerName) {
|
|
100
|
-
$headerValue = $context->getHeader($headerName);
|
|
101
|
-
if ($headerValue) {
|
|
102
|
-
$srv->add($key, $headerValue);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if ($context->httpVersion) {
|
|
107
|
-
$srv->add("http_ver", $context->httpVersion);
|
|
108
|
-
}
|
|
109
|
-
if (!empty($context->cookies)) {
|
|
110
|
-
$cookieKeys = array_keys($context->cookies);
|
|
111
|
-
sort($cookieKeys);
|
|
112
|
-
$srv->add("cookie_keys", implode(',', $cookieKeys));
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return (string)$srv;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Calcule un score d'incohérence entre la signature TLS (JA3) et le User-Agent.
|
|
120
|
-
* @return array{'tlsSpoofingScore': float}
|
|
121
|
-
*/
|
|
122
|
-
public static function getTlsSpoofingScore(RequestContext $context): array
|
|
123
|
-
{
|
|
124
|
-
$ua = $context->getHeader('user-agent') ?? '';
|
|
125
|
-
$ja3 = $context->ja3;
|
|
126
|
-
$ja4 = $context->ja4;
|
|
127
|
-
|
|
128
|
-
// Si un fingerprint TLS est présent mais que le User-Agent est absent ou générique, c'est suspect.
|
|
129
|
-
if (($ja3 || $ja4) && (empty($ua) || strlen($ua) < 10 || stripos($ua, 'python') !== false || stripos($ua, 'curl') !== false)) {
|
|
130
|
-
return ['tlsSpoofingScore' => 50.0];
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
$claimedBrowserInfo = self::parseUserAgent($ua);
|
|
134
|
-
$claimedBrowser = $claimedBrowserInfo['browser'] ?? null;
|
|
135
|
-
|
|
136
|
-
if (empty($claimedBrowser) || empty($ua)) {
|
|
137
|
-
return ['tlsSpoofingScore' => 0.0];
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Priorité à JA4 pour la détection de spoofing
|
|
141
|
-
if ($ja4 && isset(self::JA4_FINGERPRINT_DB[$ja4])) {
|
|
142
|
-
$expectedClients = self::JA4_FINGERPRINT_DB[$ja4];
|
|
143
|
-
if (!is_array($expectedClients)) {
|
|
144
|
-
$expectedClients = [$expectedClients];
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
$isMatch = false;
|
|
148
|
-
foreach ($expectedClients as $expected) {
|
|
149
|
-
if (stripos($claimedBrowser, $expected) !== false) {
|
|
150
|
-
$isMatch = true;
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (!$isMatch) {
|
|
155
|
-
// Incohérence forte détectée avec JA4
|
|
156
|
-
return ['tlsSpoofingScore' => 90.0];
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
// Fallback sur JA3 si JA4 n'a pas matché
|
|
160
|
-
elseif ($ja3 && isset(self::TLS_FINGERPRINT_DB[$ja3])) {
|
|
161
|
-
$expectedClients = self::TLS_FINGERPRINT_DB[$ja3];
|
|
162
|
-
if (!is_array($expectedClients)) {
|
|
163
|
-
$expectedClients = [$expectedClients];
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
$isMatch = false;
|
|
167
|
-
foreach ($expectedClients as $expected) {
|
|
168
|
-
if (stripos($claimedBrowser, $expected) !== false) {
|
|
169
|
-
$isMatch = true;
|
|
170
|
-
break;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
if (!$isMatch) {
|
|
174
|
-
// Incohérence détectée avec JA3
|
|
175
|
-
return ['tlsSpoofingScore' => 80.0];
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return ['tlsSpoofingScore' => 0.0];
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Calcule un score basé sur les anomalies des en-têtes HTTP.
|
|
184
|
-
* @return array{'headerAnomalyScore': float}
|
|
185
|
-
*/
|
|
186
|
-
public static function getHeaderAnomalies(RequestContext $context): array
|
|
187
|
-
{
|
|
188
|
-
$anomalyScore = 0;
|
|
189
|
-
$ua = $context->getHeader('user-agent') ?? '';
|
|
190
|
-
if (empty($ua) || strlen($ua) < 10) {
|
|
191
|
-
$anomalyScore += 60;
|
|
192
|
-
}
|
|
193
|
-
if (!$context->getHeader('accept-language')) {
|
|
194
|
-
$anomalyScore += 25;
|
|
195
|
-
}
|
|
196
|
-
if ($context->httpVersion === '1.0') {
|
|
197
|
-
$anomalyScore += 15;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// TE: trailers check for Firefox on Desktop
|
|
201
|
-
$uaParts = self::parseUserAgent($ua);
|
|
202
|
-
$isFirefoxDesktop = isset($uaParts['browser']) && str_starts_with($uaParts['browser'], 'Firefox') && ($uaParts['device'] ?? 'desktop') === 'desktop';
|
|
203
|
-
$te = strtolower($context->getHeader('te') ?? '');
|
|
204
|
-
|
|
205
|
-
if ($isFirefoxDesktop && $te !== 'trailers') {
|
|
206
|
-
$anomalyScore += 30;
|
|
207
|
-
} elseif (!$isFirefoxDesktop && ($uaParts['device'] ?? 'desktop') === 'desktop' && $te === 'trailers') {
|
|
208
|
-
$anomalyScore += 30;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return ['headerAnomalyScore' => min(100.0, $anomalyScore)];
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Calcule un score basé sur la détection de marqueurs d'automatisation.
|
|
216
|
-
* @return array{'botScore': float}
|
|
217
|
-
*/
|
|
218
|
-
public static function getBotScore(RequestContext $context): array
|
|
219
|
-
{
|
|
220
|
-
$clientFpString = $context->getHeader('x-device-fingerprint');
|
|
221
|
-
if (!$clientFpString) {
|
|
222
|
-
return ['botScore' => 0.0];
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Une simple vérification par chaîne est suffisante et performante.
|
|
226
|
-
if (str_contains($clientFpString, 'bot:true') || str_contains($clientFpString, 'cdp:true')) {
|
|
227
|
-
return ['botScore' => 100.0];
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
return ['botScore' => 0.0];
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* @private
|
|
235
|
-
* Analyse une série de mouvements de souris pour en extraire des métriques comportementales.
|
|
236
|
-
* @param array<int, array{x: int, y: int, t: float}>|null $history
|
|
237
|
-
* @return array{avgSpeed: float, avgAcceleration: float, straightness: float, pauses: int, segments: array<float>}
|
|
238
|
-
*/
|
|
239
|
-
private static function analyzeMouseMovements(?array $history): array
|
|
240
|
-
{
|
|
241
|
-
if (empty($history) || count($history) < 3) {
|
|
242
|
-
return ['avgSpeed' => 0, 'avgAcceleration' => 0, 'straightness' => 1, 'pauses' => 0, 'segments' => []];
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
$segments = [];
|
|
246
|
-
$totalDistance = 0.0;
|
|
247
|
-
$pauses = 0;
|
|
248
|
-
|
|
249
|
-
for ($i = 1; $i < count($history); $i++) {
|
|
250
|
-
$p1 = $history[$i - 1];
|
|
251
|
-
$p2 = $history[$i];
|
|
252
|
-
$dx = $p2['x'] - $p1['x'];
|
|
253
|
-
$dy = $p2['y'] - $p1['y'];
|
|
254
|
-
$dt = $p2['t'] - $p1['t'];
|
|
255
|
-
$distance = sqrt($dx * $dx + $dy * $dy);
|
|
256
|
-
|
|
257
|
-
if ($dt > 0) {
|
|
258
|
-
$speed = $distance / $dt;
|
|
259
|
-
$segments[] = ['distance' => $distance, 'dt' => $dt, 'speed' => $speed];
|
|
260
|
-
$totalDistance += $distance;
|
|
261
|
-
}
|
|
262
|
-
if ($dt > 100 && $distance < 5) {
|
|
263
|
-
$pauses++;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (count($segments) < 2) {
|
|
268
|
-
return ['avgSpeed' => 0, 'avgAcceleration' => 0, 'straightness' => 1, 'pauses' => $pauses, 'segments' => []];
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
$totalTime = $history[count($history) - 1]['t'] - $history[0]['t'];
|
|
272
|
-
$avgSpeed = $totalTime > 0 ? array_sum(array_column($segments, 'speed')) / count($segments) : 0;
|
|
273
|
-
|
|
274
|
-
$totalAbsAcceleration = 0.0;
|
|
275
|
-
for ($i = 1; $i < count($segments); $i++) {
|
|
276
|
-
$s1 = $segments[$i - 1];
|
|
277
|
-
$s2 = $segments[$i];
|
|
278
|
-
if ($s2['dt'] > 0) {
|
|
279
|
-
$acceleration = ($s2['speed'] - $s1['speed']) / $s2['dt'];
|
|
280
|
-
$totalAbsAcceleration += abs($acceleration);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
$avgAcceleration = $totalAbsAcceleration / (count($segments) - 1);
|
|
284
|
-
|
|
285
|
-
$startPoint = $history[0];
|
|
286
|
-
$endPoint = $history[count($history) - 1];
|
|
287
|
-
$straightDistance = sqrt(pow($endPoint['x'] - $startPoint['x'], 2) + pow($endPoint['y'] - $startPoint['y'], 2));
|
|
288
|
-
$straightness = $totalDistance > 0 ? $straightDistance / $totalDistance : 1;
|
|
289
|
-
|
|
290
|
-
return ['avgSpeed' => $avgSpeed, 'avgAcceleration' => $avgAcceleration, 'straightness' => $straightness, 'pauses' => $pauses, 'segments' => array_column($segments, 'distance')];
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Calcule un score basé sur les métriques comportementales envoyées par le client.
|
|
296
|
-
* @return array{'behaviorScore': float}
|
|
297
|
-
*/
|
|
298
|
-
public static function getBehaviorScore(RequestContext $context): array
|
|
299
|
-
{
|
|
300
|
-
$behaviorHeader = $context->getHeader('x-behavior-metrics');
|
|
301
|
-
if (!$behaviorHeader) {
|
|
302
|
-
return ['behaviorScore' => 0.0];
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
$metrics = json_decode($behaviorHeader, true);
|
|
306
|
-
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
307
|
-
return ['behaviorScore' => 10.0]; // En-tête malformé
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (!empty($metrics['honeypotInteraction'])) {
|
|
311
|
-
return ['behaviorScore' => 100.0];
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
$score = 0.0;
|
|
315
|
-
|
|
316
|
-
$mouseAnalysis = self::analyzeMouseMovements($metrics['mouseMovementsHistory'] ?? null);
|
|
317
|
-
|
|
318
|
-
if (isset($metrics['historyLength'])) {
|
|
319
|
-
if ($metrics['historyLength'] === 1) $score += 15;
|
|
320
|
-
elseif ($metrics['historyLength'] >= 5) $score -= 20;
|
|
321
|
-
elseif ($metrics['historyLength'] >= 2) $score -= 10;
|
|
322
|
-
} else {
|
|
323
|
-
// Pénalité pour absence totale d'interaction si l'historique n'est pas dispo
|
|
324
|
-
if ($mouseAnalysis['avgSpeed'] == 0 && ($metrics['keystrokeLatency'] ?? 0) == 0) {
|
|
325
|
-
$score += 40;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
if ($mouseAnalysis['avgSpeed'] > 0) {
|
|
330
|
-
if ($mouseAnalysis['avgSpeed'] > 3) $score += 25;
|
|
331
|
-
if ($mouseAnalysis['avgAcceleration'] > 0.5) $score += 20;
|
|
332
|
-
if ($mouseAnalysis['straightness'] > 0.95) $score += 30;
|
|
333
|
-
if ($mouseAnalysis['pauses'] === 0 && count($mouseAnalysis['segments']) > 20) $score += 15;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
if (($metrics['keystrokeLatency'] ?? 0) > 0 && $metrics['keystrokeLatency'] < 40) $score += 25;
|
|
337
|
-
if (($metrics['keystrokeLatency'] ?? 0) > 1000) $score += 15;
|
|
338
|
-
|
|
339
|
-
// Analyse de Benford sur les segments de mouvement de la souris
|
|
340
|
-
if (count($mouseAnalysis['segments']) > 10) {
|
|
341
|
-
$benfordDeviation = Optimization::benfordTest($mouseAnalysis['segments']);
|
|
342
|
-
if ($benfordDeviation > 0.18) {
|
|
343
|
-
$score += 35;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
return ['behaviorScore' => min(100.0, $score)];
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Analyse basique d'un User-Agent.
|
|
352
|
-
* @return array{browser?: string, os?: string, device?: string}
|
|
353
|
-
*/
|
|
354
|
-
private static function parseUserAgent(string $ua): array
|
|
355
|
-
{
|
|
356
|
-
$result = [];
|
|
357
|
-
|
|
358
|
-
if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
|
|
359
|
-
$result['browser'] = 'Chrome';
|
|
360
|
-
} elseif (str_contains($ua, 'Firefox')) {
|
|
361
|
-
$result['browser'] = 'Firefox';
|
|
362
|
-
} elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
|
|
363
|
-
$result['browser'] = 'Safari';
|
|
364
|
-
} elseif (str_contains($ua, 'Edg')) {
|
|
365
|
-
$result['browser'] = 'Edge';
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows';
|
|
369
|
-
elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
|
|
370
|
-
elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
|
|
371
|
-
elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
|
|
372
|
-
elseif (str_contains($ua, 'Linux')) $result['os'] = 'Linux';
|
|
373
|
-
|
|
374
|
-
if (str_contains($ua, 'Mobile')) $result['device'] = 'mobile';
|
|
375
|
-
else $result['device'] = 'desktop';
|
|
376
|
-
|
|
377
|
-
return $result;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Calcule un score d'incohérence temporelle.
|
|
382
|
-
* @return array{'timeInconsistencyScore': float}
|
|
383
|
-
* @param RequestContext $context
|
|
384
|
-
* @param array|null $metrics
|
|
385
|
-
*/
|
|
386
|
-
public static function getTimeInconsistencyScore(RequestContext $context, ?array $metrics = null): array
|
|
387
|
-
{
|
|
388
|
-
if ($metrics === null) {
|
|
389
|
-
$behaviorHeader = $context->getHeader('x-behavior-metrics');
|
|
390
|
-
if (!$behaviorHeader) return ['timeInconsistencyScore' => 0.0];
|
|
391
|
-
$metrics = json_decode($behaviorHeader, true);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (!is_array($metrics) || empty($metrics['clientTimestamp'])) {
|
|
395
|
-
return ['timeInconsistencyScore' => 0.0];
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
$timeDelta = $context->requestTimestamp - $metrics['clientTimestamp'];
|
|
399
|
-
$replayThreshold = 5000; // 5 secondes
|
|
400
|
-
|
|
401
|
-
$score = ($timeDelta > $replayThreshold) ? min(100.0, ($timeDelta / $replayThreshold - 1) * 50) : 0.0;
|
|
402
|
-
return ['timeInconsistencyScore' => $score];
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Calcule un score d'incohérence entre les couches client et serveur.
|
|
407
|
-
* @return array{'crossLayerInconsistencyScore': float}
|
|
408
|
-
*/
|
|
409
|
-
public static function getCrossLayerInconsistency(RequestContext $context): array
|
|
410
|
-
{
|
|
411
|
-
$clientFpString = $context->getHeader('x-device-fingerprint');
|
|
412
|
-
if (!$clientFpString) return ['crossLayerInconsistencyScore' => 0.0];
|
|
413
|
-
|
|
414
|
-
$clientFpMap = [];
|
|
415
|
-
foreach (explode('|', $clientFpString) as $part) {
|
|
416
|
-
$pair = explode(':', $part, 2);
|
|
417
|
-
if (count($pair) === 2) $clientFpMap[$pair[0]] = $pair[1];
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
$ua = $context->getHeader('user-agent') ?? '';
|
|
421
|
-
$score = 0;
|
|
422
|
-
|
|
423
|
-
$clientOsHash = $clientFpMap['os'] ?? null;
|
|
424
|
-
if ($clientOsHash) {
|
|
425
|
-
$serverOsParts = self::parseUserAgent($ua);
|
|
426
|
-
if (!empty($serverOsParts['os']) && $clientOsHash !== FingerprintBuilder::cyrb53($serverOsParts['os'])) {
|
|
427
|
-
$score += 50;
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
return ['crossLayerInconsistencyScore' => min(100.0, $score)];
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* Calcule un score d'incohérence entre le User-Agent et les en-têtes Sec-CH-UA (Client Hints).
|
|
436
|
-
* @return array{'clientHintsInconsistencyScore': float}
|
|
437
|
-
*/
|
|
438
|
-
public static function getClientHintsInconsistencyScore(RequestContext $context): array
|
|
439
|
-
{
|
|
440
|
-
$ua = $context->getHeader('user-agent');
|
|
441
|
-
$clientHints = $context->getHeader('sec-ch-ua');
|
|
442
|
-
|
|
443
|
-
if (empty($ua) || empty($clientHints)) {
|
|
444
|
-
return ['clientHintsInconsistencyScore' => 0.0];
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// 1. Extraire la version du navigateur depuis le User-Agent
|
|
448
|
-
$uaVersion = null;
|
|
449
|
-
if (preg_match('/(Chrome|Firefox|Edg|Safari)\/([\d\.]+)/', $ua, $uaMatches)) {
|
|
450
|
-
$uaBrowser = $uaMatches[1] === 'Edg' ? 'Edge' : $uaMatches[1];
|
|
451
|
-
// Prendre uniquement la version majeure
|
|
452
|
-
$uaVersion = explode('.', $uaMatches[2])[0] ?? null;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// 2. Extraire la version du navigateur depuis Sec-CH-UA
|
|
456
|
-
$chVersion = null;
|
|
457
|
-
$chBrowser = null;
|
|
458
|
-
// Regex pour trouver une marque de navigateur connue et sa version
|
|
459
|
-
if (preg_match('/"(?:Google Chrome|Chromium|Microsoft Edge)";v="(\d+)"/', $clientHints, $chMatches)) {
|
|
460
|
-
$chVersion = $chMatches[1];
|
|
461
|
-
// Déterminer le navigateur à partir de la marque trouvée
|
|
462
|
-
if (str_contains($chMatches[0], 'Edge')) {
|
|
463
|
-
$chBrowser = 'Edge';
|
|
464
|
-
} else {
|
|
465
|
-
$chBrowser = 'Chrome'; // Chrome ou Chromium
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
if ($uaVersion === null || $chVersion === null || $uaBrowser === null || $chBrowser === null) {
|
|
470
|
-
return ['clientHintsInconsistencyScore' => 0.0];
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// 3. Comparer les versions
|
|
474
|
-
// Tolérer une petite différence car les Client-Hints peuvent être plus précis ou mis à jour différemment
|
|
475
|
-
$versionDifference = abs((int)$uaVersion - (int)$chVersion);
|
|
476
|
-
|
|
477
|
-
// Si les navigateurs déclarés sont différents (ex: UA dit Firefox, CH dit Chrome)
|
|
478
|
-
if ($uaBrowser !== $chBrowser && ($uaBrowser !== 'Chrome' || $chBrowser !== 'Edge')) { // Tolérer Chrome/Edge
|
|
479
|
-
return ['clientHintsInconsistencyScore' => 90.0];
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
if ($versionDifference > 5) { // Un écart de plus de 5 versions majeures est très suspect
|
|
483
|
-
return ['clientHintsInconsistencyScore' => 80.0];
|
|
484
|
-
} elseif ($versionDifference > 1) { // Un petit écart est légèrement suspect
|
|
485
|
-
return ['clientHintsInconsistencyScore' => 40.0];
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
return ['clientHintsInconsistencyScore' => 0.0];
|
|
489
|
-
}
|
|
490
|
-
/**
|
|
491
|
-
* Calcule les indicateurs comportementaux liés à l'historique de l'appareil.
|
|
492
|
-
* @param array<string, mixed> $deviceData
|
|
493
|
-
* @return array{'historyScore': float, 'rotationScore': float}
|
|
494
|
-
*/
|
|
495
|
-
public static function getBehavioralIndicators(RequestContext $context, array &$deviceData): array
|
|
496
|
-
{
|
|
497
|
-
$now = time() * 1000;
|
|
498
|
-
$clientIp = $context->clientIp;
|
|
499
|
-
$currentFpHash = self::getCompositeDeviceHash($context);
|
|
500
|
-
|
|
501
|
-
// Analyse de la fréquence de changement du fingerprint
|
|
502
|
-
$rapidChangeThresholdMs = 2000; // 2 secondes
|
|
503
|
-
$maxRapidChanges = 3;
|
|
504
|
-
|
|
505
|
-
$lastFpHash = $deviceData['lastFpHash'] ?? null;
|
|
506
|
-
|
|
507
|
-
if ($lastFpHash && $currentFpHash !== $lastFpHash) {
|
|
508
|
-
// Comparaison plus intelligente : ne pénaliser que si les parties STABLES de l'empreinte changent.
|
|
509
|
-
// Les parties stables sont celles qui ne devraient pas changer lors d'un simple changement de réseau.
|
|
510
|
-
$stablePart1 = self::extractStablePart($lastFpHash);
|
|
511
|
-
$stablePart2 = self::extractStablePart($currentFpHash);
|
|
512
|
-
|
|
513
|
-
$timeSinceLastChange = $now - ($deviceData['lastChangeTimestamp'] ?? 0);
|
|
514
|
-
|
|
515
|
-
// On incrémente le compteur de rotation rapide SEULEMENT si la partie stable a changé.
|
|
516
|
-
if ($stablePart1 !== $stablePart2) {
|
|
517
|
-
if ($timeSinceLastChange < $rapidChangeThresholdMs) {
|
|
518
|
-
$deviceData['rapidChangeCount'] = ($deviceData['rapidChangeCount'] ?? 0) + 1;
|
|
519
|
-
} else {
|
|
520
|
-
// Si le changement est lent, on réduit le compteur pour pardonner les anciens changements rapides.
|
|
521
|
-
$deviceData['rapidChangeCount'] = max(0, ($deviceData['rapidChangeCount'] ?? 0) - 1);
|
|
522
|
-
}
|
|
523
|
-
$deviceData['lastChangeTimestamp'] = $now;
|
|
524
|
-
}
|
|
525
|
-
// Si seule la partie volatile a changé (ex: User-Agent, IP via en-têtes), on ne met pas à jour le `lastChangeTimestamp`.
|
|
526
|
-
// Cela évite qu'un changement de réseau légitime soit suivi d'un autre changement (ex: mise en veille)
|
|
527
|
-
// et soit compté comme une rotation rapide.
|
|
528
|
-
|
|
529
|
-
} else if ($lastFpHash === null) {
|
|
530
|
-
// Première visite, on initialise le timestamp.
|
|
531
|
-
$deviceData['lastChangeTimestamp'] = $now;
|
|
532
|
-
}
|
|
533
|
-
$deviceData['lastFpHash'] = $currentFpHash;
|
|
534
|
-
|
|
535
|
-
// Enregistrement de l'IP
|
|
536
|
-
if (!in_array($clientIp, $deviceData['ips'])) {
|
|
537
|
-
$deviceData['ips'][] = $clientIp;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// Score d'historique basé sur le nombre d'IPs utilisées (rotation de proxy)
|
|
541
|
-
$maxIpsPerDevice = 15;
|
|
542
|
-
$freeIpChanges = 3;
|
|
543
|
-
$historyScore = min(100.0, (max(0, count($deviceData['ips']) - $freeIpChanges) / $maxIpsPerDevice) * 100);
|
|
544
|
-
// Score de rotation basé sur les changements rapides de fingerprint
|
|
545
|
-
$rotationScore = min(100.0, (($deviceData['rapidChangeCount'] ?? 0) / $maxRapidChanges) * 100);
|
|
546
|
-
|
|
547
|
-
return ['historyScore' => $historyScore, 'rotationScore' => $rotationScore];
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Extrait la partie "stable" d'une chaîne d'empreinte.
|
|
552
|
-
* La partie stable inclut les composants matériels (canvas, gpu) qui ne devraient pas changer.
|
|
553
|
-
* @param string $fpString La chaîne d'empreinte complète.
|
|
554
|
-
* @return string La sous-chaîne de l'empreinte contenant uniquement les parties stables.
|
|
555
|
-
*/
|
|
556
|
-
private static function extractStablePart(string $fpString): string
|
|
557
|
-
{
|
|
558
|
-
$stableKeys = ['cvs', 'gpu', 'hw', 'client_fp_hash', 'os', 'scr'];
|
|
559
|
-
$parts = explode('|', $fpString);
|
|
560
|
-
$stableParts = [];
|
|
561
|
-
foreach ($parts as $part) {
|
|
562
|
-
$pair = explode(':', $part, 2);
|
|
563
|
-
if (count($pair) === 2 && in_array($pair[0], $stableKeys, true)) {
|
|
564
|
-
$stableParts[] = $part;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
sort($stableParts);
|
|
568
|
-
return implode('|', $stableParts);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/**
|
|
572
|
-
* Analyse les patterns de requêtes pour détecter les comportements de bot.
|
|
573
|
-
* @param array<string, mixed> $deviceData
|
|
574
|
-
* @param array<string, mixed> $patternConfig
|
|
575
|
-
* @return array{'requestPatternScore': float}
|
|
576
|
-
*/
|
|
577
|
-
public static function getRequestPatternScore(RequestContext $context, array &$deviceData, array $patternConfig): array
|
|
578
|
-
{
|
|
579
|
-
// Configuration avec valeurs par défaut robustes
|
|
580
|
-
$historySize = $patternConfig['historySize'] ?? 20;
|
|
581
|
-
$minSamples = $patternConfig['minSamples'] ?? 10;
|
|
582
|
-
$regularityThreshold = $patternConfig['regularityThreshold'] ?? 150; // ms
|
|
583
|
-
$benfordThreshold = $patternConfig['benfordThreshold'] ?? 0.15;
|
|
584
|
-
$patternWeight = $patternConfig['patternWeight'] ?? 80;
|
|
585
|
-
$decayFactor = $patternConfig['decayFactor'] ?? 0.95;
|
|
586
|
-
$inactivityReset = $patternConfig['inactivityReset'] ?? 180000;
|
|
587
|
-
|
|
588
|
-
$now = time() * 1000;
|
|
589
|
-
$history = $deviceData['requestHistory'] ?? [];
|
|
590
|
-
$deviceData['timingHistory'] = $deviceData['timingHistory'] ?? [];
|
|
591
|
-
|
|
592
|
-
$lastRequest = end($history) ?: null;
|
|
593
|
-
$timeSinceLast = $lastRequest ? $now - $lastRequest['timestamp'] : PHP_INT_MAX;
|
|
594
|
-
|
|
595
|
-
// Mise à jour de l'historique
|
|
596
|
-
$history[] = ['timestamp' => $now, 'path' => $context->path];
|
|
597
|
-
if ($lastRequest) {
|
|
598
|
-
$deviceData['timingHistory'][] = $timeSinceLast;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
if (count($history) > $historySize) {
|
|
602
|
-
array_shift($history);
|
|
603
|
-
}
|
|
604
|
-
if (count($deviceData['timingHistory']) > $historySize) {
|
|
605
|
-
array_shift($deviceData['timingHistory']);
|
|
606
|
-
}
|
|
607
|
-
$deviceData['requestHistory'] = $history;
|
|
608
|
-
|
|
609
|
-
$instantScore = 0;
|
|
610
|
-
$timings = $deviceData['timingHistory'];
|
|
611
|
-
|
|
612
|
-
// Analyse statistique si nous avons assez de données
|
|
613
|
-
if (count($timings) >= $minSamples) {
|
|
614
|
-
// FIX: Éviter la division par zéro si le tableau est vide, bien que count() >= minSamples devrait déjà le prévenir.
|
|
615
|
-
if (count($timings) === 0) {
|
|
616
|
-
return ['requestPatternScore' => min(100.0, $deviceData['lastPatternScore'] ?? 0)];
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
$mean = array_sum($timings) / count($timings); // @phpstan-ignore-line
|
|
620
|
-
$variance = array_reduce($timings, fn($carry, $item) => $carry + pow($item - $mean, 2), 0) / count($timings); // @phpstan-ignore-line
|
|
621
|
-
$stdDev = sqrt($variance);
|
|
622
|
-
$benfordDeviation = Optimization::benfordTest($timings);
|
|
623
|
-
|
|
624
|
-
// Détection de régularité (bots de type "cron")
|
|
625
|
-
if ($stdDev < $regularityThreshold) {
|
|
626
|
-
$instantScore = $patternWeight;
|
|
627
|
-
}
|
|
628
|
-
// Détection de distribution non-naturelle (bots "faussement aléatoires")
|
|
629
|
-
elseif ($benfordDeviation > $benfordThreshold) {
|
|
630
|
-
$instantScore = $patternWeight;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// Détection d'énumération de chemins (crawling/scraping de ressources séquentielles)
|
|
635
|
-
$enumerationScore = 0;
|
|
636
|
-
if (count($history) >= 3) {
|
|
637
|
-
$templates = array_map(function($h) {
|
|
638
|
-
return preg_replace('/\d+/', '{num}', $h['path']);
|
|
639
|
-
}, $history);
|
|
640
|
-
|
|
641
|
-
$uniquePaths = array_unique(array_map(function($h) {
|
|
642
|
-
return $h['path'];
|
|
643
|
-
}, $history));
|
|
644
|
-
|
|
645
|
-
$templateCounts = array_count_values($templates);
|
|
646
|
-
$maxTemplateRepetition = !empty($templateCounts) ? max($templateCounts) : 0;
|
|
647
|
-
|
|
648
|
-
if ($maxTemplateRepetition >= 3 && count($uniquePaths) === count($history)) {
|
|
649
|
-
$enumerationScore = $patternWeight * 0.8;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// Logique de décroissance et de score final
|
|
654
|
-
$newPatternScore = $deviceData['lastPatternScore'] ?? 0;
|
|
655
|
-
|
|
656
|
-
if ($timeSinceLast > $inactivityReset) {
|
|
657
|
-
$newPatternScore = 0; // Réinitialisation après inactivité
|
|
658
|
-
} else {
|
|
659
|
-
$newPatternScore *= $decayFactor;
|
|
660
|
-
}
|
|
661
|
-
$newPatternScore = max(0, $newPatternScore);
|
|
662
|
-
|
|
663
|
-
$deviceData['lastPatternScore'] = $newPatternScore + $instantScore + $enumerationScore;
|
|
664
|
-
|
|
665
|
-
return ['requestPatternScore' => min(100.0, $deviceData['lastPatternScore'])];
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* Vérifie la soumission de champs honeypot.
|
|
670
|
-
* @param array<string, mixed> $honeypotConfig
|
|
671
|
-
* @return array{'honeypotScore': float}
|
|
672
|
-
*/
|
|
673
|
-
public static function getHoneypotScore(RequestContext $context, array $honeypotConfig): array // @phpstan-ignore-line
|
|
674
|
-
{
|
|
675
|
-
$fields = $honeypotConfig['fields'] ?? [];
|
|
676
|
-
$trapUrls = $honeypotConfig['trapUrls'] ?? [];
|
|
677
|
-
$data = array_merge($context->query, is_array($context->body) ? $context->body : []);
|
|
678
|
-
|
|
679
|
-
// 1. Vérifier les champs de formulaire pièges
|
|
680
|
-
foreach ($fields as $field) {
|
|
681
|
-
// Ignorer les paramètres de solution de challenge pour éviter les faux positifs.
|
|
682
|
-
if (str_starts_with($field, 'pow_')) {
|
|
683
|
-
continue;
|
|
684
|
-
}
|
|
685
|
-
if (!empty($data[$field])) {
|
|
686
|
-
return ['honeypotScore' => 100.0];
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// 2. Vérifier l'accès aux URL pièges
|
|
691
|
-
foreach ($trapUrls as $trap) {
|
|
692
|
-
if (str_starts_with($context->path, $trap)) {
|
|
693
|
-
return ['honeypotScore' => 100.0];
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
// 3. (Optionnel) Détection d'injections
|
|
698
|
-
if ($honeypotConfig['detectInjections'] ?? false) {
|
|
699
|
-
$typesToDetect = is_array($honeypotConfig['detectInjections']) ? $honeypotConfig['detectInjections'] : [];
|
|
700
|
-
foreach ($data as $value) {
|
|
701
|
-
if (is_string($value) && MaliciousPatterns::isMalicious($value, $typesToDetect)) {
|
|
702
|
-
return ['honeypotScore' => 100.0];
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
return ['honeypotScore' => 0.0];
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
public static function getThreatIntelScore(RequestContext $context, array $threatIntelConfig): array
|
|
711
|
-
{
|
|
712
|
-
return ['threatIntelScore' => 0.0];
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/**
|
|
716
|
-
* @private
|
|
717
|
-
* Analyzes click positions to detect unnaturally low variance.
|
|
718
|
-
* @param array<int, array{x: int, y: int, targetId: string}>|null $history
|
|
719
|
-
* @return float
|
|
720
|
-
*/
|
|
721
|
-
private static function analyzeClickPositions(?array $history): float
|
|
722
|
-
{
|
|
723
|
-
if (empty($history) || count($history) < 3) {
|
|
724
|
-
return 0.0;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
$clicksByTarget = [];
|
|
728
|
-
foreach ($history as $click) {
|
|
729
|
-
if (empty($click['targetId'])) continue;
|
|
730
|
-
if (!isset($clicksByTarget[$click['targetId']])) {
|
|
731
|
-
$clicksByTarget[$click['targetId']] = [];
|
|
732
|
-
}
|
|
733
|
-
$clicksByTarget[$click['targetId']][] = $click;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
$maxScore = 0.0;
|
|
737
|
-
|
|
738
|
-
foreach ($clicksByTarget as $clicks) {
|
|
739
|
-
if (count($clicks) < 3) continue;
|
|
740
|
-
|
|
741
|
-
$n = count($clicks);
|
|
742
|
-
$meanX = array_sum(array_column($clicks, 'x')) / $n;
|
|
743
|
-
$meanY = array_sum(array_column($clicks, 'y')) / $n;
|
|
744
|
-
|
|
745
|
-
$variance = array_reduce($clicks, function ($sum, $c) use ($meanX, $meanY) {
|
|
746
|
-
return $sum + pow($c['x'] - $meanX, 2) + pow($c['y'] - $meanY, 2);
|
|
747
|
-
}, 0) / $n;
|
|
748
|
-
|
|
749
|
-
if ($variance < 1.0) {
|
|
750
|
-
$score = (1 - sqrt($variance) / 5) * 100;
|
|
751
|
-
if ($score > $maxScore) {
|
|
752
|
-
$maxScore = $score;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
return min(100.0, $maxScore);
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
* Calculates a score based on click variance metrics sent by the client.
|
|
762
|
-
* @return array{'clickVarianceScore': float}
|
|
763
|
-
*/
|
|
764
|
-
public static function getClickVarianceScore(RequestContext $context): array
|
|
765
|
-
{
|
|
766
|
-
$behaviorHeader = $context->getHeader('x-behavior-metrics');
|
|
767
|
-
if (!$behaviorHeader) {
|
|
768
|
-
return ['clickVarianceScore' => 0.0];
|
|
769
|
-
}
|
|
770
|
-
$metrics = json_decode($behaviorHeader, true);
|
|
771
|
-
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
772
|
-
return ['clickVarianceScore' => 0.0];
|
|
773
|
-
}
|
|
774
|
-
$score = self::analyzeClickPositions($metrics['clicksHistory'] ?? null);
|
|
775
|
-
return ['clickVarianceScore' => $score];
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
/**
|
|
779
|
-
* Parse une chaîne de requête GraphQL pour extraire le type et le nom de l'opération.
|
|
780
|
-
* @param array<string, mixed> $body Le corps de la requête.
|
|
781
|
-
* @return array{type: string, name: string}|null
|
|
782
|
-
*/
|
|
783
|
-
public static function parseGraphQLQuery(array $body): ?array
|
|
784
|
-
{
|
|
785
|
-
$query = $body['query'] ?? null;
|
|
786
|
-
if (!is_string($query)) {
|
|
787
|
-
return null;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
// Regex pour capturer le type d'opération et le nom optionnel.
|
|
791
|
-
if (preg_match('/(?:^|\s)(query|mutation|subscription)\s*([_A-Za-z][_0-9A-Za-z]*)?/', $query, $matches)) {
|
|
792
|
-
return [
|
|
793
|
-
'type' => $matches[1],
|
|
794
|
-
'name' => $matches[2] ?? 'Anonymous',
|
|
795
|
-
];
|
|
796
|
-
}
|
|
797
|
-
return null;
|
|
798
|
-
}
|
|
799
|
-
/**
|
|
800
|
-
* Nettoie une URL de tous les paramètres de requête liés au PoW.
|
|
801
|
-
* @param string $originalPath Le chemin original, potentiellement avec des query params.
|
|
802
|
-
* @param array<string, mixed> $incomingQuery Le tableau de la query string de la requête entrante.
|
|
803
|
-
* @return string Le chemin final nettoyé.
|
|
804
|
-
*/
|
|
805
|
-
public static function cleanUrlFromPowParams(string $originalPath, array $incomingQuery): string
|
|
806
|
-
{
|
|
807
|
-
$urlParts = parse_url($originalPath);
|
|
808
|
-
$path = $urlParts['path'] ?? '/';
|
|
809
|
-
$finalQuery = $incomingQuery;
|
|
810
|
-
|
|
811
|
-
$powParams = [
|
|
812
|
-
'pow_type', 'pow_nonce', 'pow_solution', 'pow_solution_cpu',
|
|
813
|
-
'pow_solution_mem', 'pow_fp', 'pow_solution_population',
|
|
814
|
-
'pow_solution_work_result', 'pow_problem_id'
|
|
815
|
-
];
|
|
816
|
-
|
|
817
|
-
foreach ($powParams as $param) {
|
|
818
|
-
unset($finalQuery[$param]);
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
if (!empty($finalQuery)) {
|
|
822
|
-
return $path . '?' . http_build_query($finalQuery);
|
|
823
|
-
}
|
|
824
|
-
return $path;
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
/**
|
|
828
|
-
* Vérifie si un host et un path de requête correspondent à une entrée de liste blanche.
|
|
829
|
-
*/
|
|
830
|
-
public static function hostPathMatches(string $requestHost, string $requestPath, string $entry): bool
|
|
831
|
-
{
|
|
832
|
-
$firstSlashIndex = strpos($entry, '/');
|
|
833
|
-
if ($firstSlashIndex === false) return false;
|
|
834
|
-
|
|
835
|
-
$hostPattern = substr($entry, 0, $firstSlashIndex);
|
|
836
|
-
$pathPattern = substr($entry, $firstSlashIndex);
|
|
837
|
-
|
|
838
|
-
if ($requestHost !== $hostPattern) return false;
|
|
839
|
-
|
|
840
|
-
return self::pathMatches($requestPath, $pathPattern);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
/**
|
|
844
|
-
* Génère un masque de sous-réseau binaire pour une longueur de préfixe donnée.
|
|
845
|
-
*
|
|
846
|
-
* @param int $prefix La longueur du préfixe (ex: 24 pour IPv4, 48 pour IPv6).
|
|
847
|
-
* @param int $totalBytes Le nombre total d'octets pour le masque (4 pour IPv4, 16 pour IPv6).
|
|
848
|
-
* @return string|null Le masque binaire ou null si le préfixe est invalide.
|
|
849
|
-
*/
|
|
850
|
-
private static function generateMask(int $prefix, int $totalBytes): ?string
|
|
851
|
-
{
|
|
852
|
-
if ($prefix < 0 || $prefix > $totalBytes * 8) {
|
|
853
|
-
return null; // Préfixe invalide
|
|
854
|
-
}
|
|
855
|
-
$mask = str_repeat(chr(255), (int)floor($prefix / 8));
|
|
856
|
-
if ($prefix % 8 !== 0) {
|
|
857
|
-
$mask .= chr((255 << (8 - $prefix % 8)) & 255);
|
|
858
|
-
}
|
|
859
|
-
return str_pad($mask, $totalBytes, chr(0));
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/**
|
|
863
|
-
* Calcule le sous-réseau d'une adresse IP.
|
|
864
|
-
* @param string $ip L'adresse IP.
|
|
865
|
-
* @param int $ipv4Prefix Le préfixe pour les adresses IPv4 (défaut /24).
|
|
866
|
-
* @param int $ipv6Prefix Le préfixe pour les adresses IPv6 (défaut /48).
|
|
867
|
-
* @return string|null Le sous-réseau CIDR ou null si l'IP est invalide.
|
|
868
|
-
*/
|
|
869
|
-
public static function getIpSubnet(string $ip, int $ipv4Prefix = 24, int $ipv6Prefix = 48): ?string
|
|
870
|
-
{
|
|
871
|
-
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
872
|
-
$ipBinary = inet_pton($ip);
|
|
873
|
-
if ($ipBinary === false) return null;
|
|
874
|
-
|
|
875
|
-
$mask = self::generateMask($ipv4Prefix, 4);
|
|
876
|
-
if ($mask === null) return null;
|
|
877
|
-
|
|
878
|
-
$networkBinary = $ipBinary & $mask;
|
|
879
|
-
return inet_ntop($networkBinary) . '/' . $ipv4Prefix;
|
|
880
|
-
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
|
881
|
-
$ipBinary = inet_pton($ip);
|
|
882
|
-
if ($ipBinary === false) return null;
|
|
883
|
-
|
|
884
|
-
$mask = self::generateMask($ipv6Prefix, 16);
|
|
885
|
-
if ($mask === null) return null;
|
|
886
|
-
|
|
887
|
-
$networkBinary = $ipBinary & $mask;
|
|
888
|
-
return inet_ntop($networkBinary) . '/' . $ipv6Prefix; // FIX: Use the provided ipv6Prefix
|
|
889
|
-
}
|
|
890
|
-
return null;
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
/**
|
|
894
|
-
* Met à jour les métriques agrégées pour un sous-réseau IP.
|
|
895
|
-
* @param RequestContext $context
|
|
896
|
-
* @param string $deviceId
|
|
897
|
-
* @param float $finalScore
|
|
898
|
-
*/
|
|
899
|
-
public static function updateSubnetMetrics(RequestContext $context, string $deviceId, float $finalScore): void
|
|
900
|
-
{
|
|
901
|
-
$subnet = self::getIpSubnet($context->clientIp);
|
|
902
|
-
if ($subnet === null) return;
|
|
903
|
-
|
|
904
|
-
$store = StoreManager::getStore();
|
|
905
|
-
$key = "subnet:{$subnet}";
|
|
906
|
-
$subnetData = $store->get($key) ?? [
|
|
907
|
-
'highScoreCount' => 0,
|
|
908
|
-
'deviceIds' => [],
|
|
909
|
-
'lastActivity' => 0
|
|
910
|
-
];
|
|
911
|
-
|
|
912
|
-
$subnetData['highScoreCount']++;
|
|
913
|
-
if (!in_array($deviceId, $subnetData['deviceIds'])) {
|
|
914
|
-
$subnetData['deviceIds'][] = $deviceId;
|
|
915
|
-
}
|
|
916
|
-
$subnetData['lastActivity'] = time();
|
|
917
|
-
|
|
918
|
-
// Limiter la taille du tableau des deviceIds pour éviter une consommation mémoire excessive.
|
|
919
|
-
if (count($subnetData['deviceIds']) > 100) {
|
|
920
|
-
array_shift($subnetData['deviceIds']);
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
// TTL de 24 heures pour les données de sous-réseau.
|
|
924
|
-
$store->set($key, $subnetData, 86400);
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
/**
|
|
928
|
-
* Calcule un score de suspicion basé sur l'activité historique du sous-réseau IP.
|
|
929
|
-
* @param RequestContext $context
|
|
930
|
-
* @param string $currentDeviceId
|
|
931
|
-
* @return array{'subnetScore': float}
|
|
932
|
-
*/
|
|
933
|
-
public static function getSubnetScore(RequestContext $context, string $currentDeviceId): array
|
|
934
|
-
{
|
|
935
|
-
$subnet = self::getIpSubnet($context->clientIp);
|
|
936
|
-
if ($subnet === null) {
|
|
937
|
-
return ['subnetScore' => 0.0];
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
$store = StoreManager::getStore();
|
|
941
|
-
$key = "subnet:{$subnet}";
|
|
942
|
-
$subnetData = $store->get($key);
|
|
943
|
-
|
|
944
|
-
if ($subnetData === null) {
|
|
945
|
-
return ['subnetScore' => 0.0];
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
$score = 0.0;
|
|
949
|
-
|
|
950
|
-
// Pénalité basée sur le nombre de devices uniques vus depuis ce sous-réseau.
|
|
951
|
-
$deviceCount = count($subnetData['deviceIds']);
|
|
952
|
-
if ($deviceCount > 10) {
|
|
953
|
-
$score += min(80.0, ($deviceCount - 10) * 5);
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// Pénalité basée sur le nombre de scores élevés enregistrés.
|
|
957
|
-
$score += min(40.0, $subnetData['highScoreCount'] * 2);
|
|
958
|
-
|
|
959
|
-
return ['subnetScore' => min(100.0, $score)];
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
/**
|
|
963
|
-
* Calcule le score de réputation d'une IP en appliquant la décroissance temporelle.
|
|
964
|
-
*/
|
|
965
|
-
public static function getIpReputationScore(string $ip): float
|
|
966
|
-
{
|
|
967
|
-
$store = StoreManager::getStore();
|
|
968
|
-
$key = "ip-reputation:{$ip}";
|
|
969
|
-
$data = $store->get($key);
|
|
970
|
-
if ($data === null) {
|
|
971
|
-
return 0.0;
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
$now = time();
|
|
975
|
-
$hoursPassed = ($now - $data['lastUpdate']) / 3600;
|
|
976
|
-
$decay = (int)floor($hoursPassed * 2); // Décroissance de 2 points par heure
|
|
977
|
-
|
|
978
|
-
return (float)max(0.0, $data['score'] - $decay);
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
/**
|
|
982
|
-
* Met à jour le score de réputation locale d'une IP.
|
|
983
|
-
*/
|
|
984
|
-
public static function updateIpReputationScore(string $ip, float $change): void
|
|
985
|
-
{
|
|
986
|
-
$store = StoreManager::getStore();
|
|
987
|
-
$key = "ip-reputation:{$ip}";
|
|
988
|
-
$current = self::getIpReputationScore($ip);
|
|
989
|
-
$newScore = min(100.0, max(0.0, $current + $change));
|
|
990
|
-
$store->set($key, ['score' => $newScore, 'lastUpdate' => time()], 86400 * 7); // TTL de 7 jours
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
/**
|
|
995
|
-
* Assainit les données de trafic pour l'auto-tuner afin de prévenir les attaques par empoisonnement.
|
|
996
|
-
* Limite la contribution de chaque deviceId à un pourcentage maximum (ex: 2%) du jeu de données total.
|
|
997
|
-
*
|
|
998
|
-
* @param array<int, array<string, mixed>> $trafficData
|
|
999
|
-
* @return array<int, array<string, mixed>>
|
|
1000
|
-
*/
|
|
1001
|
-
public static function sanitizeTrafficData(array $trafficData): array
|
|
1002
|
-
{
|
|
1003
|
-
if (empty($trafficData)) {
|
|
1004
|
-
return [];
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
$tempSanitized = [];
|
|
1008
|
-
$deviceCounts = [];
|
|
1009
|
-
$maxLogsPerDevice = max(3, (int)floor(count($trafficData) * 0.02));
|
|
1010
|
-
|
|
1011
|
-
foreach ($trafficData as $log) {
|
|
1012
|
-
$deviceId = $log['deviceId'] ?? 'anonymous';
|
|
1013
|
-
if (!isset($deviceCounts[$deviceId])) {
|
|
1014
|
-
$deviceCounts[$deviceId] = 0;
|
|
1015
|
-
}
|
|
1016
|
-
if ($deviceCounts[$deviceId] < $maxLogsPerDevice) {
|
|
1017
|
-
$deviceCounts[$deviceId]++;
|
|
1018
|
-
$tempSanitized[] = $log;
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
$passedLogs = [];
|
|
1023
|
-
$suspiciousLogs = [];
|
|
1024
|
-
foreach ($tempSanitized as $log) {
|
|
1025
|
-
if (($log['type'] ?? '') === 'request_passed') {
|
|
1026
|
-
$passedLogs[] = $log;
|
|
1027
|
-
} else {
|
|
1028
|
-
$suspiciousLogs[] = $log;
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
$minDataPoints = 200; // Seuil par défaut
|
|
1033
|
-
$maxPassedAllowed = max($minDataPoints, count($suspiciousLogs) * 9);
|
|
1034
|
-
|
|
1035
|
-
if (count($passedLogs) > $maxPassedAllowed) {
|
|
1036
|
-
shuffle($passedLogs);
|
|
1037
|
-
$passedLogs = array_slice($passedLogs, 0, $maxPassedAllowed);
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
return array_merge($suspiciousLogs, $passedLogs);
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
/**
|
|
1044
|
-
* Génère une signature HMAC-SHA256 pour sécuriser les données du challenge stockées.
|
|
1045
|
-
* @param string $secret Le secret global (POW_SECRET).
|
|
1046
|
-
* @param array<string, mixed> $payload Les données du challenge.
|
|
1047
|
-
* @param string $clientIp L'IP du client pour lier la signature.
|
|
1048
|
-
* @return string
|
|
1049
|
-
*/
|
|
1050
|
-
public static function signChallengePayload(string $secret, array $payload, string $clientIp): string
|
|
1051
|
-
{
|
|
1052
|
-
$dataToSign = implode(':', [
|
|
1053
|
-
$payload['clientSecret'] ?? '',
|
|
1054
|
-
$payload['cpuTarget'] ?? '',
|
|
1055
|
-
$payload['fingerprint'] ?? '',
|
|
1056
|
-
$payload['memDifficulty'] ?? '',
|
|
1057
|
-
$payload['originalPath'] ?? '',
|
|
1058
|
-
$clientIp
|
|
1059
|
-
]);
|
|
1060
|
-
|
|
1061
|
-
return hash_hmac('sha256', $dataToSign, $secret);
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
/**
|
|
1065
|
-
* Vérifie la signature HMAC-SHA256 des données de challenge récupérées du store.
|
|
1066
|
-
* @param string $secret Le secret global (POW_SECRET).
|
|
1067
|
-
* @param array<string, mixed> $payload Les données du challenge contenant la signature.
|
|
1068
|
-
* @param string $clientIp L'IP du client.
|
|
1069
|
-
* @return bool True si la signature est valide, false sinon.
|
|
1070
|
-
*/
|
|
1071
|
-
public static function verifyChallengePayload(string $secret, array $payload, string $clientIp): bool
|
|
1072
|
-
{
|
|
1073
|
-
if (empty($payload['signature'])) {
|
|
1074
|
-
return false;
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
$storedSignature = $payload['signature'];
|
|
1078
|
-
$payloadWithoutSig = $payload;
|
|
1079
|
-
unset($payloadWithoutSig['signature']);
|
|
1080
|
-
|
|
1081
|
-
$expectedSignature = self::signChallengePayload($secret, $payloadWithoutSig, $clientIp);
|
|
1082
|
-
|
|
1083
|
-
return hash_equals($expectedSignature, $storedSignature);
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
/**
|
|
1087
|
-
* Vérifie si un ticket de clearance (PoW) est valide, en supportant la tolérance au roaming.
|
|
1088
|
-
*
|
|
1089
|
-
* @param string $ip L'adresse IP de la requête courante.
|
|
1090
|
-
* @param string|null $ticket Le ticket de clearance extrait du cookie.
|
|
1091
|
-
* @param string $deviceId L'identifiant du cookie de l'appareil.
|
|
1092
|
-
* @param string $deviceHash L'empreinte matérielle calculée côté serveur.
|
|
1093
|
-
* @param string $secret La clé secrète (POW_SECRET).
|
|
1094
|
-
* @return bool True si le ticket est valide et correspond aux contraintes de sécurité.
|
|
1095
|
-
*/
|
|
1096
|
-
public static function isTicketValid(string $ip, ?string $ticket, string $deviceId = '', string $deviceHash = '', string $secret = ''): bool
|
|
1097
|
-
{
|
|
1098
|
-
if (empty($ticket)) {
|
|
1099
|
-
return false;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
if (str_contains($ticket, '|')) {
|
|
1103
|
-
$parts = explode('|', $ticket);
|
|
1104
|
-
if (count($parts) < 3) return false;
|
|
1105
|
-
[$expiry, $originalIp, $sig] = $parts;
|
|
1106
|
-
} elseif (str_contains($ticket, ':')) {
|
|
1107
|
-
// Fallback rétrocompatible pour les anciens tickets
|
|
1108
|
-
$parts = explode(':', $ticket);
|
|
1109
|
-
if (count($parts) < 2) return false;
|
|
1110
|
-
[$expiry, $sig] = $parts;
|
|
1111
|
-
$originalIp = $ip;
|
|
1112
|
-
} else {
|
|
1113
|
-
return false;
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
if (empty($expiry) || empty($sig) || (time() * 1000) > (int)$expiry) {
|
|
1117
|
-
return false;
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
if (str_contains($ticket, '|')) {
|
|
1121
|
-
$expectedSig = hash_hmac('sha256', "{$expiry}:{$originalIp}:{$deviceId}:{$deviceHash}", $secret);
|
|
1122
|
-
} else {
|
|
1123
|
-
$expectedSig = hash_hmac('sha256', "{$ip}:{$expiry}", $secret);
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
if (!hash_equals($expectedSig, $sig)) {
|
|
1127
|
-
return false;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
if (!str_contains($ticket, '|')) {
|
|
1131
|
-
return $ip === $originalIp;
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
if ($ip === $originalIp) return true;
|
|
1135
|
-
$currentSubnet = self::getIpSubnet($ip);
|
|
1136
|
-
$originalSubnet = self::getIpSubnet($originalIp);
|
|
1137
|
-
if ($currentSubnet !== null && $originalSubnet !== null && $currentSubnet === $originalSubnet) {
|
|
1138
|
-
return true;
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
return !empty($deviceId) && !empty($deviceHash); // Match d'identité matérielle stricte (deviceId + deviceHash validés par HMAC)
|
|
1142
|
-
}
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
namespace Anonympins\Fingerprint\Utils;
|
|
7
|
+
|
|
8
|
+
use Anonympins\Fingerprint\FingerprintBuilder;
|
|
9
|
+
use Anonympins\Fingerprint\Optimization\Optimization;
|
|
10
|
+
use Anonympins\Fingerprint\RequestContext;
|
|
11
|
+
use Anonympins\Fingerprint\Store\StoreManager;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Classe utilitaire pour l'analyse des requêtes et le calcul des scores de suspicion.
|
|
15
|
+
*/
|
|
16
|
+
class RequestUtils
|
|
17
|
+
{
|
|
18
|
+
/**
|
|
19
|
+
* Base de données de signatures JA3 connues.
|
|
20
|
+
* @var array<string, string|string[]>
|
|
21
|
+
*/
|
|
22
|
+
private const TLS_FINGERPRINT_DB = [
|
|
23
|
+
// --- Chrome (Desktop) ---
|
|
24
|
+
'e188a442b87f422c5a1e80b05399435b' => 'Chrome',
|
|
25
|
+
'd8e35855049321c6042a4325c697858f' => 'Chrome',
|
|
26
|
+
'a9f90958d44533748c139a5d1895b925' => 'Chrome',
|
|
27
|
+
'3b5379916d2b3882253c42885956a350' => 'Chrome',
|
|
28
|
+
// --- Chrome (Mobile) ---
|
|
29
|
+
'59822058c95c33d2d06e52f410855c8c' => 'Chrome',
|
|
30
|
+
// --- Firefox (Desktop) ---
|
|
31
|
+
'b386946a5a586163c7c533636b45c355' => 'Firefox',
|
|
32
|
+
'66236495a523c1785f8f3a105b248b11' => 'Firefox',
|
|
33
|
+
'b73d470006575b5e35167a0b5a8540e2' => 'Firefox',
|
|
34
|
+
'8443d7562933834333943465d52363cf' => 'Firefox',
|
|
35
|
+
// --- Firefox (Mobile) ---
|
|
36
|
+
'02720628957d38c6111a18433abe833f' => 'Firefox',
|
|
37
|
+
// --- Safari & iOS (Shared TLS Stack) ---
|
|
38
|
+
'b633f21d532d35967c8753c38536b4d3' => 'Safari',
|
|
39
|
+
'4d7a28d5f55b359b69100a311013f03e' => ['Safari', 'Chrome', 'Firefox'],
|
|
40
|
+
'8dd3d7532873575314df23c447543001' => ['Safari', 'Chrome', 'Firefox'],
|
|
41
|
+
// --- Common Libraries & Bots ---
|
|
42
|
+
'47344a349b75c4e82333475553b5f358' => 'Python',
|
|
43
|
+
'b29587b8a143c42546133ad7704b3310' => 'Go',
|
|
44
|
+
'd435b5223b2884c5a832b842637e245f' => 'Java',
|
|
45
|
+
'c72366b9551263d990b7fa574225332c' => 'curl',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Base de données de signatures JA4 connues.
|
|
50
|
+
* @var array<string, string|string[]>
|
|
51
|
+
*/
|
|
52
|
+
private const JA4_FINGERPRINT_DB = [
|
|
53
|
+
// Format: {JA4 Hash} => {Client Name}
|
|
54
|
+
// --- Chrome ---
|
|
55
|
+
't13d1517h2_8daaf61527d5' => 'Chrome', // Chrome 117 on Win11
|
|
56
|
+
't13d1516h2_8daaf61527d5' => 'Chrome', // Chrome 116 on Win10
|
|
57
|
+
// --- Firefox ---
|
|
58
|
+
't13d1517h2_2491a244c393' => 'Firefox', // Firefox 117 on Win11
|
|
59
|
+
// --- Common Libraries & Bots ---
|
|
60
|
+
't13d1500h1_4b56136b4d35' => 'Python', // Python requests
|
|
61
|
+
];
|
|
62
|
+
/**
|
|
63
|
+
* Crée un hash composite stable basé sur les caractéristiques de la requête.
|
|
64
|
+
*/
|
|
65
|
+
public static function getCompositeDeviceHash(RequestContext $context): string
|
|
66
|
+
{
|
|
67
|
+
$srv = new FingerprintBuilder();
|
|
68
|
+
|
|
69
|
+
$clientFp = $context->getHeader('x-device-fingerprint');
|
|
70
|
+
if ($clientFp && str_contains($clientFp, 'cvs:')) {
|
|
71
|
+
$srv->add("client_fp_hash", $clientFp);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
$ua = $context->getHeader("user-agent");
|
|
75
|
+
if ($ua) {
|
|
76
|
+
$srv->add("ua", $ua);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if ($context->ja3) $srv->add("ja3", $context->ja3);
|
|
80
|
+
if ($context->ja4) $srv->add("ja4", $context->ja4);
|
|
81
|
+
if ($context->ja4s) $srv->add("ja4s", $context->ja4s);
|
|
82
|
+
if ($context->ja4h) $srv->add("ja4h", $context->ja4h);
|
|
83
|
+
if ($context->http2Fingerprint) $srv->add("h2", $context->http2Fingerprint);
|
|
84
|
+
if ($context->tcpFingerprint) $srv->add("tcp", $context->tcpFingerprint);
|
|
85
|
+
|
|
86
|
+
$headersToCapture = [
|
|
87
|
+
"ch_ua" => "sec-ch-ua",
|
|
88
|
+
"ch_platform" => "sec-ch-ua-platform",
|
|
89
|
+
"ch_mobile" => "sec-ch-ua-mobile",
|
|
90
|
+
"ch_model" => "sec-ch-ua-model",
|
|
91
|
+
"ch_arch" => "sec-ch-ua-arch",
|
|
92
|
+
"ch_bitness" => "sec-ch-ua-bitness",
|
|
93
|
+
"upgrade_req" => "upgrade-insecure-requests",
|
|
94
|
+
"accept_lang" => "accept-language",
|
|
95
|
+
"accept_enc" => "accept-encoding",
|
|
96
|
+
"accept" => "accept"
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
foreach ($headersToCapture as $key => $headerName) {
|
|
100
|
+
$headerValue = $context->getHeader($headerName);
|
|
101
|
+
if ($headerValue) {
|
|
102
|
+
$srv->add($key, $headerValue);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if ($context->httpVersion) {
|
|
107
|
+
$srv->add("http_ver", $context->httpVersion);
|
|
108
|
+
}
|
|
109
|
+
if (!empty($context->cookies)) {
|
|
110
|
+
$cookieKeys = array_keys($context->cookies);
|
|
111
|
+
sort($cookieKeys);
|
|
112
|
+
$srv->add("cookie_keys", implode(',', $cookieKeys));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (string)$srv;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Calcule un score d'incohérence entre la signature TLS (JA3) et le User-Agent.
|
|
120
|
+
* @return array{'tlsSpoofingScore': float}
|
|
121
|
+
*/
|
|
122
|
+
public static function getTlsSpoofingScore(RequestContext $context): array
|
|
123
|
+
{
|
|
124
|
+
$ua = $context->getHeader('user-agent') ?? '';
|
|
125
|
+
$ja3 = $context->ja3;
|
|
126
|
+
$ja4 = $context->ja4;
|
|
127
|
+
|
|
128
|
+
// Si un fingerprint TLS est présent mais que le User-Agent est absent ou générique, c'est suspect.
|
|
129
|
+
if (($ja3 || $ja4) && (empty($ua) || strlen($ua) < 10 || stripos($ua, 'python') !== false || stripos($ua, 'curl') !== false)) {
|
|
130
|
+
return ['tlsSpoofingScore' => 50.0];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
$claimedBrowserInfo = self::parseUserAgent($ua);
|
|
134
|
+
$claimedBrowser = $claimedBrowserInfo['browser'] ?? null;
|
|
135
|
+
|
|
136
|
+
if (empty($claimedBrowser) || empty($ua)) {
|
|
137
|
+
return ['tlsSpoofingScore' => 0.0];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Priorité à JA4 pour la détection de spoofing
|
|
141
|
+
if ($ja4 && isset(self::JA4_FINGERPRINT_DB[$ja4])) {
|
|
142
|
+
$expectedClients = self::JA4_FINGERPRINT_DB[$ja4];
|
|
143
|
+
if (!is_array($expectedClients)) {
|
|
144
|
+
$expectedClients = [$expectedClients];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
$isMatch = false;
|
|
148
|
+
foreach ($expectedClients as $expected) {
|
|
149
|
+
if (stripos($claimedBrowser, $expected) !== false) {
|
|
150
|
+
$isMatch = true;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!$isMatch) {
|
|
155
|
+
// Incohérence forte détectée avec JA4
|
|
156
|
+
return ['tlsSpoofingScore' => 90.0];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Fallback sur JA3 si JA4 n'a pas matché
|
|
160
|
+
elseif ($ja3 && isset(self::TLS_FINGERPRINT_DB[$ja3])) {
|
|
161
|
+
$expectedClients = self::TLS_FINGERPRINT_DB[$ja3];
|
|
162
|
+
if (!is_array($expectedClients)) {
|
|
163
|
+
$expectedClients = [$expectedClients];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
$isMatch = false;
|
|
167
|
+
foreach ($expectedClients as $expected) {
|
|
168
|
+
if (stripos($claimedBrowser, $expected) !== false) {
|
|
169
|
+
$isMatch = true;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!$isMatch) {
|
|
174
|
+
// Incohérence détectée avec JA3
|
|
175
|
+
return ['tlsSpoofingScore' => 80.0];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return ['tlsSpoofingScore' => 0.0];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Calcule un score basé sur les anomalies des en-têtes HTTP.
|
|
184
|
+
* @return array{'headerAnomalyScore': float}
|
|
185
|
+
*/
|
|
186
|
+
public static function getHeaderAnomalies(RequestContext $context): array
|
|
187
|
+
{
|
|
188
|
+
$anomalyScore = 0;
|
|
189
|
+
$ua = $context->getHeader('user-agent') ?? '';
|
|
190
|
+
if (empty($ua) || strlen($ua) < 10) {
|
|
191
|
+
$anomalyScore += 60;
|
|
192
|
+
}
|
|
193
|
+
if (!$context->getHeader('accept-language')) {
|
|
194
|
+
$anomalyScore += 25;
|
|
195
|
+
}
|
|
196
|
+
if ($context->httpVersion === '1.0') {
|
|
197
|
+
$anomalyScore += 15;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// TE: trailers check for Firefox on Desktop
|
|
201
|
+
$uaParts = self::parseUserAgent($ua);
|
|
202
|
+
$isFirefoxDesktop = isset($uaParts['browser']) && str_starts_with($uaParts['browser'], 'Firefox') && ($uaParts['device'] ?? 'desktop') === 'desktop';
|
|
203
|
+
$te = strtolower($context->getHeader('te') ?? '');
|
|
204
|
+
|
|
205
|
+
if ($isFirefoxDesktop && $te !== 'trailers') {
|
|
206
|
+
$anomalyScore += 30;
|
|
207
|
+
} elseif (!$isFirefoxDesktop && ($uaParts['device'] ?? 'desktop') === 'desktop' && $te === 'trailers') {
|
|
208
|
+
$anomalyScore += 30;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return ['headerAnomalyScore' => min(100.0, $anomalyScore)];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Calcule un score basé sur la détection de marqueurs d'automatisation.
|
|
216
|
+
* @return array{'botScore': float}
|
|
217
|
+
*/
|
|
218
|
+
public static function getBotScore(RequestContext $context): array
|
|
219
|
+
{
|
|
220
|
+
$clientFpString = $context->getHeader('x-device-fingerprint');
|
|
221
|
+
if (!$clientFpString) {
|
|
222
|
+
return ['botScore' => 0.0];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Une simple vérification par chaîne est suffisante et performante.
|
|
226
|
+
if (str_contains($clientFpString, 'bot:true') || str_contains($clientFpString, 'cdp:true')) {
|
|
227
|
+
return ['botScore' => 100.0];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return ['botScore' => 0.0];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @private
|
|
235
|
+
* Analyse une série de mouvements de souris pour en extraire des métriques comportementales.
|
|
236
|
+
* @param array<int, array{x: int, y: int, t: float}>|null $history
|
|
237
|
+
* @return array{avgSpeed: float, avgAcceleration: float, straightness: float, pauses: int, segments: array<float>}
|
|
238
|
+
*/
|
|
239
|
+
private static function analyzeMouseMovements(?array $history): array
|
|
240
|
+
{
|
|
241
|
+
if (empty($history) || count($history) < 3) {
|
|
242
|
+
return ['avgSpeed' => 0, 'avgAcceleration' => 0, 'straightness' => 1, 'pauses' => 0, 'segments' => []];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
$segments = [];
|
|
246
|
+
$totalDistance = 0.0;
|
|
247
|
+
$pauses = 0;
|
|
248
|
+
|
|
249
|
+
for ($i = 1; $i < count($history); $i++) {
|
|
250
|
+
$p1 = $history[$i - 1];
|
|
251
|
+
$p2 = $history[$i];
|
|
252
|
+
$dx = $p2['x'] - $p1['x'];
|
|
253
|
+
$dy = $p2['y'] - $p1['y'];
|
|
254
|
+
$dt = $p2['t'] - $p1['t'];
|
|
255
|
+
$distance = sqrt($dx * $dx + $dy * $dy);
|
|
256
|
+
|
|
257
|
+
if ($dt > 0) {
|
|
258
|
+
$speed = $distance / $dt;
|
|
259
|
+
$segments[] = ['distance' => $distance, 'dt' => $dt, 'speed' => $speed];
|
|
260
|
+
$totalDistance += $distance;
|
|
261
|
+
}
|
|
262
|
+
if ($dt > 100 && $distance < 5) {
|
|
263
|
+
$pauses++;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (count($segments) < 2) {
|
|
268
|
+
return ['avgSpeed' => 0, 'avgAcceleration' => 0, 'straightness' => 1, 'pauses' => $pauses, 'segments' => []];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
$totalTime = $history[count($history) - 1]['t'] - $history[0]['t'];
|
|
272
|
+
$avgSpeed = $totalTime > 0 ? array_sum(array_column($segments, 'speed')) / count($segments) : 0;
|
|
273
|
+
|
|
274
|
+
$totalAbsAcceleration = 0.0;
|
|
275
|
+
for ($i = 1; $i < count($segments); $i++) {
|
|
276
|
+
$s1 = $segments[$i - 1];
|
|
277
|
+
$s2 = $segments[$i];
|
|
278
|
+
if ($s2['dt'] > 0) {
|
|
279
|
+
$acceleration = ($s2['speed'] - $s1['speed']) / $s2['dt'];
|
|
280
|
+
$totalAbsAcceleration += abs($acceleration);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
$avgAcceleration = $totalAbsAcceleration / (count($segments) - 1);
|
|
284
|
+
|
|
285
|
+
$startPoint = $history[0];
|
|
286
|
+
$endPoint = $history[count($history) - 1];
|
|
287
|
+
$straightDistance = sqrt(pow($endPoint['x'] - $startPoint['x'], 2) + pow($endPoint['y'] - $startPoint['y'], 2));
|
|
288
|
+
$straightness = $totalDistance > 0 ? $straightDistance / $totalDistance : 1;
|
|
289
|
+
|
|
290
|
+
return ['avgSpeed' => $avgSpeed, 'avgAcceleration' => $avgAcceleration, 'straightness' => $straightness, 'pauses' => $pauses, 'segments' => array_column($segments, 'distance')];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Calcule un score basé sur les métriques comportementales envoyées par le client.
|
|
296
|
+
* @return array{'behaviorScore': float}
|
|
297
|
+
*/
|
|
298
|
+
public static function getBehaviorScore(RequestContext $context): array
|
|
299
|
+
{
|
|
300
|
+
$behaviorHeader = $context->getHeader('x-behavior-metrics');
|
|
301
|
+
if (!$behaviorHeader) {
|
|
302
|
+
return ['behaviorScore' => 0.0];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
$metrics = json_decode($behaviorHeader, true);
|
|
306
|
+
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
307
|
+
return ['behaviorScore' => 10.0]; // En-tête malformé
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!empty($metrics['honeypotInteraction'])) {
|
|
311
|
+
return ['behaviorScore' => 100.0];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
$score = 0.0;
|
|
315
|
+
|
|
316
|
+
$mouseAnalysis = self::analyzeMouseMovements($metrics['mouseMovementsHistory'] ?? null);
|
|
317
|
+
|
|
318
|
+
if (isset($metrics['historyLength'])) {
|
|
319
|
+
if ($metrics['historyLength'] === 1) $score += 15;
|
|
320
|
+
elseif ($metrics['historyLength'] >= 5) $score -= 20;
|
|
321
|
+
elseif ($metrics['historyLength'] >= 2) $score -= 10;
|
|
322
|
+
} else {
|
|
323
|
+
// Pénalité pour absence totale d'interaction si l'historique n'est pas dispo
|
|
324
|
+
if ($mouseAnalysis['avgSpeed'] == 0 && ($metrics['keystrokeLatency'] ?? 0) == 0) {
|
|
325
|
+
$score += 40;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if ($mouseAnalysis['avgSpeed'] > 0) {
|
|
330
|
+
if ($mouseAnalysis['avgSpeed'] > 3) $score += 25;
|
|
331
|
+
if ($mouseAnalysis['avgAcceleration'] > 0.5) $score += 20;
|
|
332
|
+
if ($mouseAnalysis['straightness'] > 0.95) $score += 30;
|
|
333
|
+
if ($mouseAnalysis['pauses'] === 0 && count($mouseAnalysis['segments']) > 20) $score += 15;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (($metrics['keystrokeLatency'] ?? 0) > 0 && $metrics['keystrokeLatency'] < 40) $score += 25;
|
|
337
|
+
if (($metrics['keystrokeLatency'] ?? 0) > 1000) $score += 15;
|
|
338
|
+
|
|
339
|
+
// Analyse de Benford sur les segments de mouvement de la souris
|
|
340
|
+
if (count($mouseAnalysis['segments']) > 10) {
|
|
341
|
+
$benfordDeviation = Optimization::benfordTest($mouseAnalysis['segments']);
|
|
342
|
+
if ($benfordDeviation > 0.18) {
|
|
343
|
+
$score += 35;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return ['behaviorScore' => min(100.0, $score)];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Analyse basique d'un User-Agent.
|
|
352
|
+
* @return array{browser?: string, os?: string, device?: string}
|
|
353
|
+
*/
|
|
354
|
+
private static function parseUserAgent(string $ua): array
|
|
355
|
+
{
|
|
356
|
+
$result = [];
|
|
357
|
+
|
|
358
|
+
if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
|
|
359
|
+
$result['browser'] = 'Chrome';
|
|
360
|
+
} elseif (str_contains($ua, 'Firefox')) {
|
|
361
|
+
$result['browser'] = 'Firefox';
|
|
362
|
+
} elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
|
|
363
|
+
$result['browser'] = 'Safari';
|
|
364
|
+
} elseif (str_contains($ua, 'Edg')) {
|
|
365
|
+
$result['browser'] = 'Edge';
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows';
|
|
369
|
+
elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
|
|
370
|
+
elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
|
|
371
|
+
elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
|
|
372
|
+
elseif (str_contains($ua, 'Linux')) $result['os'] = 'Linux';
|
|
373
|
+
|
|
374
|
+
if (str_contains($ua, 'Mobile')) $result['device'] = 'mobile';
|
|
375
|
+
else $result['device'] = 'desktop';
|
|
376
|
+
|
|
377
|
+
return $result;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Calcule un score d'incohérence temporelle.
|
|
382
|
+
* @return array{'timeInconsistencyScore': float}
|
|
383
|
+
* @param RequestContext $context
|
|
384
|
+
* @param array|null $metrics
|
|
385
|
+
*/
|
|
386
|
+
public static function getTimeInconsistencyScore(RequestContext $context, ?array $metrics = null): array
|
|
387
|
+
{
|
|
388
|
+
if ($metrics === null) {
|
|
389
|
+
$behaviorHeader = $context->getHeader('x-behavior-metrics');
|
|
390
|
+
if (!$behaviorHeader) return ['timeInconsistencyScore' => 0.0];
|
|
391
|
+
$metrics = json_decode($behaviorHeader, true);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!is_array($metrics) || empty($metrics['clientTimestamp'])) {
|
|
395
|
+
return ['timeInconsistencyScore' => 0.0];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
$timeDelta = $context->requestTimestamp - $metrics['clientTimestamp'];
|
|
399
|
+
$replayThreshold = 5000; // 5 secondes
|
|
400
|
+
|
|
401
|
+
$score = ($timeDelta > $replayThreshold) ? min(100.0, ($timeDelta / $replayThreshold - 1) * 50) : 0.0;
|
|
402
|
+
return ['timeInconsistencyScore' => $score];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Calcule un score d'incohérence entre les couches client et serveur.
|
|
407
|
+
* @return array{'crossLayerInconsistencyScore': float}
|
|
408
|
+
*/
|
|
409
|
+
public static function getCrossLayerInconsistency(RequestContext $context): array
|
|
410
|
+
{
|
|
411
|
+
$clientFpString = $context->getHeader('x-device-fingerprint');
|
|
412
|
+
if (!$clientFpString) return ['crossLayerInconsistencyScore' => 0.0];
|
|
413
|
+
|
|
414
|
+
$clientFpMap = [];
|
|
415
|
+
foreach (explode('|', $clientFpString) as $part) {
|
|
416
|
+
$pair = explode(':', $part, 2);
|
|
417
|
+
if (count($pair) === 2) $clientFpMap[$pair[0]] = $pair[1];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
$ua = $context->getHeader('user-agent') ?? '';
|
|
421
|
+
$score = 0;
|
|
422
|
+
|
|
423
|
+
$clientOsHash = $clientFpMap['os'] ?? null;
|
|
424
|
+
if ($clientOsHash) {
|
|
425
|
+
$serverOsParts = self::parseUserAgent($ua);
|
|
426
|
+
if (!empty($serverOsParts['os']) && $clientOsHash !== FingerprintBuilder::cyrb53($serverOsParts['os'])) {
|
|
427
|
+
$score += 50;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return ['crossLayerInconsistencyScore' => min(100.0, $score)];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Calcule un score d'incohérence entre le User-Agent et les en-têtes Sec-CH-UA (Client Hints).
|
|
436
|
+
* @return array{'clientHintsInconsistencyScore': float}
|
|
437
|
+
*/
|
|
438
|
+
public static function getClientHintsInconsistencyScore(RequestContext $context): array
|
|
439
|
+
{
|
|
440
|
+
$ua = $context->getHeader('user-agent');
|
|
441
|
+
$clientHints = $context->getHeader('sec-ch-ua');
|
|
442
|
+
|
|
443
|
+
if (empty($ua) || empty($clientHints)) {
|
|
444
|
+
return ['clientHintsInconsistencyScore' => 0.0];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 1. Extraire la version du navigateur depuis le User-Agent
|
|
448
|
+
$uaVersion = null;
|
|
449
|
+
if (preg_match('/(Chrome|Firefox|Edg|Safari)\/([\d\.]+)/', $ua, $uaMatches)) {
|
|
450
|
+
$uaBrowser = $uaMatches[1] === 'Edg' ? 'Edge' : $uaMatches[1];
|
|
451
|
+
// Prendre uniquement la version majeure
|
|
452
|
+
$uaVersion = explode('.', $uaMatches[2])[0] ?? null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 2. Extraire la version du navigateur depuis Sec-CH-UA
|
|
456
|
+
$chVersion = null;
|
|
457
|
+
$chBrowser = null;
|
|
458
|
+
// Regex pour trouver une marque de navigateur connue et sa version
|
|
459
|
+
if (preg_match('/"(?:Google Chrome|Chromium|Microsoft Edge)";v="(\d+)"/', $clientHints, $chMatches)) {
|
|
460
|
+
$chVersion = $chMatches[1];
|
|
461
|
+
// Déterminer le navigateur à partir de la marque trouvée
|
|
462
|
+
if (str_contains($chMatches[0], 'Edge')) {
|
|
463
|
+
$chBrowser = 'Edge';
|
|
464
|
+
} else {
|
|
465
|
+
$chBrowser = 'Chrome'; // Chrome ou Chromium
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if ($uaVersion === null || $chVersion === null || $uaBrowser === null || $chBrowser === null) {
|
|
470
|
+
return ['clientHintsInconsistencyScore' => 0.0];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// 3. Comparer les versions
|
|
474
|
+
// Tolérer une petite différence car les Client-Hints peuvent être plus précis ou mis à jour différemment
|
|
475
|
+
$versionDifference = abs((int)$uaVersion - (int)$chVersion);
|
|
476
|
+
|
|
477
|
+
// Si les navigateurs déclarés sont différents (ex: UA dit Firefox, CH dit Chrome)
|
|
478
|
+
if ($uaBrowser !== $chBrowser && ($uaBrowser !== 'Chrome' || $chBrowser !== 'Edge')) { // Tolérer Chrome/Edge
|
|
479
|
+
return ['clientHintsInconsistencyScore' => 90.0];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if ($versionDifference > 5) { // Un écart de plus de 5 versions majeures est très suspect
|
|
483
|
+
return ['clientHintsInconsistencyScore' => 80.0];
|
|
484
|
+
} elseif ($versionDifference > 1) { // Un petit écart est légèrement suspect
|
|
485
|
+
return ['clientHintsInconsistencyScore' => 40.0];
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return ['clientHintsInconsistencyScore' => 0.0];
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Calcule les indicateurs comportementaux liés à l'historique de l'appareil.
|
|
492
|
+
* @param array<string, mixed> $deviceData
|
|
493
|
+
* @return array{'historyScore': float, 'rotationScore': float}
|
|
494
|
+
*/
|
|
495
|
+
public static function getBehavioralIndicators(RequestContext $context, array &$deviceData): array
|
|
496
|
+
{
|
|
497
|
+
$now = time() * 1000;
|
|
498
|
+
$clientIp = $context->clientIp;
|
|
499
|
+
$currentFpHash = self::getCompositeDeviceHash($context);
|
|
500
|
+
|
|
501
|
+
// Analyse de la fréquence de changement du fingerprint
|
|
502
|
+
$rapidChangeThresholdMs = 2000; // 2 secondes
|
|
503
|
+
$maxRapidChanges = 3;
|
|
504
|
+
|
|
505
|
+
$lastFpHash = $deviceData['lastFpHash'] ?? null;
|
|
506
|
+
|
|
507
|
+
if ($lastFpHash && $currentFpHash !== $lastFpHash) {
|
|
508
|
+
// Comparaison plus intelligente : ne pénaliser que si les parties STABLES de l'empreinte changent.
|
|
509
|
+
// Les parties stables sont celles qui ne devraient pas changer lors d'un simple changement de réseau.
|
|
510
|
+
$stablePart1 = self::extractStablePart($lastFpHash);
|
|
511
|
+
$stablePart2 = self::extractStablePart($currentFpHash);
|
|
512
|
+
|
|
513
|
+
$timeSinceLastChange = $now - ($deviceData['lastChangeTimestamp'] ?? 0);
|
|
514
|
+
|
|
515
|
+
// On incrémente le compteur de rotation rapide SEULEMENT si la partie stable a changé.
|
|
516
|
+
if ($stablePart1 !== $stablePart2) {
|
|
517
|
+
if ($timeSinceLastChange < $rapidChangeThresholdMs) {
|
|
518
|
+
$deviceData['rapidChangeCount'] = ($deviceData['rapidChangeCount'] ?? 0) + 1;
|
|
519
|
+
} else {
|
|
520
|
+
// Si le changement est lent, on réduit le compteur pour pardonner les anciens changements rapides.
|
|
521
|
+
$deviceData['rapidChangeCount'] = max(0, ($deviceData['rapidChangeCount'] ?? 0) - 1);
|
|
522
|
+
}
|
|
523
|
+
$deviceData['lastChangeTimestamp'] = $now;
|
|
524
|
+
}
|
|
525
|
+
// Si seule la partie volatile a changé (ex: User-Agent, IP via en-têtes), on ne met pas à jour le `lastChangeTimestamp`.
|
|
526
|
+
// Cela évite qu'un changement de réseau légitime soit suivi d'un autre changement (ex: mise en veille)
|
|
527
|
+
// et soit compté comme une rotation rapide.
|
|
528
|
+
|
|
529
|
+
} else if ($lastFpHash === null) {
|
|
530
|
+
// Première visite, on initialise le timestamp.
|
|
531
|
+
$deviceData['lastChangeTimestamp'] = $now;
|
|
532
|
+
}
|
|
533
|
+
$deviceData['lastFpHash'] = $currentFpHash;
|
|
534
|
+
|
|
535
|
+
// Enregistrement de l'IP
|
|
536
|
+
if (!in_array($clientIp, $deviceData['ips'])) {
|
|
537
|
+
$deviceData['ips'][] = $clientIp;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Score d'historique basé sur le nombre d'IPs utilisées (rotation de proxy)
|
|
541
|
+
$maxIpsPerDevice = 15;
|
|
542
|
+
$freeIpChanges = 3;
|
|
543
|
+
$historyScore = min(100.0, (max(0, count($deviceData['ips']) - $freeIpChanges) / $maxIpsPerDevice) * 100);
|
|
544
|
+
// Score de rotation basé sur les changements rapides de fingerprint
|
|
545
|
+
$rotationScore = min(100.0, (($deviceData['rapidChangeCount'] ?? 0) / $maxRapidChanges) * 100);
|
|
546
|
+
|
|
547
|
+
return ['historyScore' => $historyScore, 'rotationScore' => $rotationScore];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Extrait la partie "stable" d'une chaîne d'empreinte.
|
|
552
|
+
* La partie stable inclut les composants matériels (canvas, gpu) qui ne devraient pas changer.
|
|
553
|
+
* @param string $fpString La chaîne d'empreinte complète.
|
|
554
|
+
* @return string La sous-chaîne de l'empreinte contenant uniquement les parties stables.
|
|
555
|
+
*/
|
|
556
|
+
private static function extractStablePart(string $fpString): string
|
|
557
|
+
{
|
|
558
|
+
$stableKeys = ['cvs', 'gpu', 'hw', 'client_fp_hash', 'os', 'scr'];
|
|
559
|
+
$parts = explode('|', $fpString);
|
|
560
|
+
$stableParts = [];
|
|
561
|
+
foreach ($parts as $part) {
|
|
562
|
+
$pair = explode(':', $part, 2);
|
|
563
|
+
if (count($pair) === 2 && in_array($pair[0], $stableKeys, true)) {
|
|
564
|
+
$stableParts[] = $part;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
sort($stableParts);
|
|
568
|
+
return implode('|', $stableParts);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Analyse les patterns de requêtes pour détecter les comportements de bot.
|
|
573
|
+
* @param array<string, mixed> $deviceData
|
|
574
|
+
* @param array<string, mixed> $patternConfig
|
|
575
|
+
* @return array{'requestPatternScore': float}
|
|
576
|
+
*/
|
|
577
|
+
public static function getRequestPatternScore(RequestContext $context, array &$deviceData, array $patternConfig): array
|
|
578
|
+
{
|
|
579
|
+
// Configuration avec valeurs par défaut robustes
|
|
580
|
+
$historySize = $patternConfig['historySize'] ?? 20;
|
|
581
|
+
$minSamples = $patternConfig['minSamples'] ?? 10;
|
|
582
|
+
$regularityThreshold = $patternConfig['regularityThreshold'] ?? 150; // ms
|
|
583
|
+
$benfordThreshold = $patternConfig['benfordThreshold'] ?? 0.15;
|
|
584
|
+
$patternWeight = $patternConfig['patternWeight'] ?? 80;
|
|
585
|
+
$decayFactor = $patternConfig['decayFactor'] ?? 0.95;
|
|
586
|
+
$inactivityReset = $patternConfig['inactivityReset'] ?? 180000;
|
|
587
|
+
|
|
588
|
+
$now = time() * 1000;
|
|
589
|
+
$history = $deviceData['requestHistory'] ?? [];
|
|
590
|
+
$deviceData['timingHistory'] = $deviceData['timingHistory'] ?? [];
|
|
591
|
+
|
|
592
|
+
$lastRequest = end($history) ?: null;
|
|
593
|
+
$timeSinceLast = $lastRequest ? $now - $lastRequest['timestamp'] : PHP_INT_MAX;
|
|
594
|
+
|
|
595
|
+
// Mise à jour de l'historique
|
|
596
|
+
$history[] = ['timestamp' => $now, 'path' => $context->path];
|
|
597
|
+
if ($lastRequest) {
|
|
598
|
+
$deviceData['timingHistory'][] = $timeSinceLast;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (count($history) > $historySize) {
|
|
602
|
+
array_shift($history);
|
|
603
|
+
}
|
|
604
|
+
if (count($deviceData['timingHistory']) > $historySize) {
|
|
605
|
+
array_shift($deviceData['timingHistory']);
|
|
606
|
+
}
|
|
607
|
+
$deviceData['requestHistory'] = $history;
|
|
608
|
+
|
|
609
|
+
$instantScore = 0;
|
|
610
|
+
$timings = $deviceData['timingHistory'];
|
|
611
|
+
|
|
612
|
+
// Analyse statistique si nous avons assez de données
|
|
613
|
+
if (count($timings) >= $minSamples) {
|
|
614
|
+
// FIX: Éviter la division par zéro si le tableau est vide, bien que count() >= minSamples devrait déjà le prévenir.
|
|
615
|
+
if (count($timings) === 0) {
|
|
616
|
+
return ['requestPatternScore' => min(100.0, $deviceData['lastPatternScore'] ?? 0)];
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
$mean = array_sum($timings) / count($timings); // @phpstan-ignore-line
|
|
620
|
+
$variance = array_reduce($timings, fn($carry, $item) => $carry + pow($item - $mean, 2), 0) / count($timings); // @phpstan-ignore-line
|
|
621
|
+
$stdDev = sqrt($variance);
|
|
622
|
+
$benfordDeviation = Optimization::benfordTest($timings);
|
|
623
|
+
|
|
624
|
+
// Détection de régularité (bots de type "cron")
|
|
625
|
+
if ($stdDev < $regularityThreshold) {
|
|
626
|
+
$instantScore = $patternWeight;
|
|
627
|
+
}
|
|
628
|
+
// Détection de distribution non-naturelle (bots "faussement aléatoires")
|
|
629
|
+
elseif ($benfordDeviation > $benfordThreshold) {
|
|
630
|
+
$instantScore = $patternWeight;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Détection d'énumération de chemins (crawling/scraping de ressources séquentielles)
|
|
635
|
+
$enumerationScore = 0;
|
|
636
|
+
if (count($history) >= 3) {
|
|
637
|
+
$templates = array_map(function($h) {
|
|
638
|
+
return preg_replace('/\d+/', '{num}', $h['path']);
|
|
639
|
+
}, $history);
|
|
640
|
+
|
|
641
|
+
$uniquePaths = array_unique(array_map(function($h) {
|
|
642
|
+
return $h['path'];
|
|
643
|
+
}, $history));
|
|
644
|
+
|
|
645
|
+
$templateCounts = array_count_values($templates);
|
|
646
|
+
$maxTemplateRepetition = !empty($templateCounts) ? max($templateCounts) : 0;
|
|
647
|
+
|
|
648
|
+
if ($maxTemplateRepetition >= 3 && count($uniquePaths) === count($history)) {
|
|
649
|
+
$enumerationScore = $patternWeight * 0.8;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Logique de décroissance et de score final
|
|
654
|
+
$newPatternScore = $deviceData['lastPatternScore'] ?? 0;
|
|
655
|
+
|
|
656
|
+
if ($timeSinceLast > $inactivityReset) {
|
|
657
|
+
$newPatternScore = 0; // Réinitialisation après inactivité
|
|
658
|
+
} else {
|
|
659
|
+
$newPatternScore *= $decayFactor;
|
|
660
|
+
}
|
|
661
|
+
$newPatternScore = max(0, $newPatternScore);
|
|
662
|
+
|
|
663
|
+
$deviceData['lastPatternScore'] = $newPatternScore + $instantScore + $enumerationScore;
|
|
664
|
+
|
|
665
|
+
return ['requestPatternScore' => min(100.0, $deviceData['lastPatternScore'])];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Vérifie la soumission de champs honeypot.
|
|
670
|
+
* @param array<string, mixed> $honeypotConfig
|
|
671
|
+
* @return array{'honeypotScore': float}
|
|
672
|
+
*/
|
|
673
|
+
public static function getHoneypotScore(RequestContext $context, array $honeypotConfig): array // @phpstan-ignore-line
|
|
674
|
+
{
|
|
675
|
+
$fields = $honeypotConfig['fields'] ?? [];
|
|
676
|
+
$trapUrls = $honeypotConfig['trapUrls'] ?? [];
|
|
677
|
+
$data = array_merge($context->query, is_array($context->body) ? $context->body : []);
|
|
678
|
+
|
|
679
|
+
// 1. Vérifier les champs de formulaire pièges
|
|
680
|
+
foreach ($fields as $field) {
|
|
681
|
+
// Ignorer les paramètres de solution de challenge pour éviter les faux positifs.
|
|
682
|
+
if (str_starts_with($field, 'pow_')) {
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (!empty($data[$field])) {
|
|
686
|
+
return ['honeypotScore' => 100.0];
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// 2. Vérifier l'accès aux URL pièges
|
|
691
|
+
foreach ($trapUrls as $trap) {
|
|
692
|
+
if (str_starts_with($context->path, $trap)) {
|
|
693
|
+
return ['honeypotScore' => 100.0];
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// 3. (Optionnel) Détection d'injections
|
|
698
|
+
if ($honeypotConfig['detectInjections'] ?? false) {
|
|
699
|
+
$typesToDetect = is_array($honeypotConfig['detectInjections']) ? $honeypotConfig['detectInjections'] : [];
|
|
700
|
+
foreach ($data as $value) {
|
|
701
|
+
if (is_string($value) && MaliciousPatterns::isMalicious($value, $typesToDetect)) {
|
|
702
|
+
return ['honeypotScore' => 100.0];
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return ['honeypotScore' => 0.0];
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
public static function getThreatIntelScore(RequestContext $context, array $threatIntelConfig): array
|
|
711
|
+
{
|
|
712
|
+
return ['threatIntelScore' => 0.0];
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* @private
|
|
717
|
+
* Analyzes click positions to detect unnaturally low variance.
|
|
718
|
+
* @param array<int, array{x: int, y: int, targetId: string}>|null $history
|
|
719
|
+
* @return float
|
|
720
|
+
*/
|
|
721
|
+
private static function analyzeClickPositions(?array $history): float
|
|
722
|
+
{
|
|
723
|
+
if (empty($history) || count($history) < 3) {
|
|
724
|
+
return 0.0;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
$clicksByTarget = [];
|
|
728
|
+
foreach ($history as $click) {
|
|
729
|
+
if (empty($click['targetId'])) continue;
|
|
730
|
+
if (!isset($clicksByTarget[$click['targetId']])) {
|
|
731
|
+
$clicksByTarget[$click['targetId']] = [];
|
|
732
|
+
}
|
|
733
|
+
$clicksByTarget[$click['targetId']][] = $click;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
$maxScore = 0.0;
|
|
737
|
+
|
|
738
|
+
foreach ($clicksByTarget as $clicks) {
|
|
739
|
+
if (count($clicks) < 3) continue;
|
|
740
|
+
|
|
741
|
+
$n = count($clicks);
|
|
742
|
+
$meanX = array_sum(array_column($clicks, 'x')) / $n;
|
|
743
|
+
$meanY = array_sum(array_column($clicks, 'y')) / $n;
|
|
744
|
+
|
|
745
|
+
$variance = array_reduce($clicks, function ($sum, $c) use ($meanX, $meanY) {
|
|
746
|
+
return $sum + pow($c['x'] - $meanX, 2) + pow($c['y'] - $meanY, 2);
|
|
747
|
+
}, 0) / $n;
|
|
748
|
+
|
|
749
|
+
if ($variance < 1.0) {
|
|
750
|
+
$score = (1 - sqrt($variance) / 5) * 100;
|
|
751
|
+
if ($score > $maxScore) {
|
|
752
|
+
$maxScore = $score;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return min(100.0, $maxScore);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Calculates a score based on click variance metrics sent by the client.
|
|
762
|
+
* @return array{'clickVarianceScore': float}
|
|
763
|
+
*/
|
|
764
|
+
public static function getClickVarianceScore(RequestContext $context): array
|
|
765
|
+
{
|
|
766
|
+
$behaviorHeader = $context->getHeader('x-behavior-metrics');
|
|
767
|
+
if (!$behaviorHeader) {
|
|
768
|
+
return ['clickVarianceScore' => 0.0];
|
|
769
|
+
}
|
|
770
|
+
$metrics = json_decode($behaviorHeader, true);
|
|
771
|
+
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
772
|
+
return ['clickVarianceScore' => 0.0];
|
|
773
|
+
}
|
|
774
|
+
$score = self::analyzeClickPositions($metrics['clicksHistory'] ?? null);
|
|
775
|
+
return ['clickVarianceScore' => $score];
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Parse une chaîne de requête GraphQL pour extraire le type et le nom de l'opération.
|
|
780
|
+
* @param array<string, mixed> $body Le corps de la requête.
|
|
781
|
+
* @return array{type: string, name: string}|null
|
|
782
|
+
*/
|
|
783
|
+
public static function parseGraphQLQuery(array $body): ?array
|
|
784
|
+
{
|
|
785
|
+
$query = $body['query'] ?? null;
|
|
786
|
+
if (!is_string($query)) {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Regex pour capturer le type d'opération et le nom optionnel.
|
|
791
|
+
if (preg_match('/(?:^|\s)(query|mutation|subscription)\s*([_A-Za-z][_0-9A-Za-z]*)?/', $query, $matches)) {
|
|
792
|
+
return [
|
|
793
|
+
'type' => $matches[1],
|
|
794
|
+
'name' => $matches[2] ?? 'Anonymous',
|
|
795
|
+
];
|
|
796
|
+
}
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Nettoie une URL de tous les paramètres de requête liés au PoW.
|
|
801
|
+
* @param string $originalPath Le chemin original, potentiellement avec des query params.
|
|
802
|
+
* @param array<string, mixed> $incomingQuery Le tableau de la query string de la requête entrante.
|
|
803
|
+
* @return string Le chemin final nettoyé.
|
|
804
|
+
*/
|
|
805
|
+
public static function cleanUrlFromPowParams(string $originalPath, array $incomingQuery): string
|
|
806
|
+
{
|
|
807
|
+
$urlParts = parse_url($originalPath);
|
|
808
|
+
$path = $urlParts['path'] ?? '/';
|
|
809
|
+
$finalQuery = $incomingQuery;
|
|
810
|
+
|
|
811
|
+
$powParams = [
|
|
812
|
+
'pow_type', 'pow_nonce', 'pow_solution', 'pow_solution_cpu',
|
|
813
|
+
'pow_solution_mem', 'pow_fp', 'pow_solution_population',
|
|
814
|
+
'pow_solution_work_result', 'pow_problem_id'
|
|
815
|
+
];
|
|
816
|
+
|
|
817
|
+
foreach ($powParams as $param) {
|
|
818
|
+
unset($finalQuery[$param]);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (!empty($finalQuery)) {
|
|
822
|
+
return $path . '?' . http_build_query($finalQuery);
|
|
823
|
+
}
|
|
824
|
+
return $path;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Vérifie si un host et un path de requête correspondent à une entrée de liste blanche.
|
|
829
|
+
*/
|
|
830
|
+
public static function hostPathMatches(string $requestHost, string $requestPath, string $entry): bool
|
|
831
|
+
{
|
|
832
|
+
$firstSlashIndex = strpos($entry, '/');
|
|
833
|
+
if ($firstSlashIndex === false) return false;
|
|
834
|
+
|
|
835
|
+
$hostPattern = substr($entry, 0, $firstSlashIndex);
|
|
836
|
+
$pathPattern = substr($entry, $firstSlashIndex);
|
|
837
|
+
|
|
838
|
+
if ($requestHost !== $hostPattern) return false;
|
|
839
|
+
|
|
840
|
+
return self::pathMatches($requestPath, $pathPattern);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Génère un masque de sous-réseau binaire pour une longueur de préfixe donnée.
|
|
845
|
+
*
|
|
846
|
+
* @param int $prefix La longueur du préfixe (ex: 24 pour IPv4, 48 pour IPv6).
|
|
847
|
+
* @param int $totalBytes Le nombre total d'octets pour le masque (4 pour IPv4, 16 pour IPv6).
|
|
848
|
+
* @return string|null Le masque binaire ou null si le préfixe est invalide.
|
|
849
|
+
*/
|
|
850
|
+
private static function generateMask(int $prefix, int $totalBytes): ?string
|
|
851
|
+
{
|
|
852
|
+
if ($prefix < 0 || $prefix > $totalBytes * 8) {
|
|
853
|
+
return null; // Préfixe invalide
|
|
854
|
+
}
|
|
855
|
+
$mask = str_repeat(chr(255), (int)floor($prefix / 8));
|
|
856
|
+
if ($prefix % 8 !== 0) {
|
|
857
|
+
$mask .= chr((255 << (8 - $prefix % 8)) & 255);
|
|
858
|
+
}
|
|
859
|
+
return str_pad($mask, $totalBytes, chr(0));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Calcule le sous-réseau d'une adresse IP.
|
|
864
|
+
* @param string $ip L'adresse IP.
|
|
865
|
+
* @param int $ipv4Prefix Le préfixe pour les adresses IPv4 (défaut /24).
|
|
866
|
+
* @param int $ipv6Prefix Le préfixe pour les adresses IPv6 (défaut /48).
|
|
867
|
+
* @return string|null Le sous-réseau CIDR ou null si l'IP est invalide.
|
|
868
|
+
*/
|
|
869
|
+
public static function getIpSubnet(string $ip, int $ipv4Prefix = 24, int $ipv6Prefix = 48): ?string
|
|
870
|
+
{
|
|
871
|
+
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
872
|
+
$ipBinary = inet_pton($ip);
|
|
873
|
+
if ($ipBinary === false) return null;
|
|
874
|
+
|
|
875
|
+
$mask = self::generateMask($ipv4Prefix, 4);
|
|
876
|
+
if ($mask === null) return null;
|
|
877
|
+
|
|
878
|
+
$networkBinary = $ipBinary & $mask;
|
|
879
|
+
return inet_ntop($networkBinary) . '/' . $ipv4Prefix;
|
|
880
|
+
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
|
881
|
+
$ipBinary = inet_pton($ip);
|
|
882
|
+
if ($ipBinary === false) return null;
|
|
883
|
+
|
|
884
|
+
$mask = self::generateMask($ipv6Prefix, 16);
|
|
885
|
+
if ($mask === null) return null;
|
|
886
|
+
|
|
887
|
+
$networkBinary = $ipBinary & $mask;
|
|
888
|
+
return inet_ntop($networkBinary) . '/' . $ipv6Prefix; // FIX: Use the provided ipv6Prefix
|
|
889
|
+
}
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Met à jour les métriques agrégées pour un sous-réseau IP.
|
|
895
|
+
* @param RequestContext $context
|
|
896
|
+
* @param string $deviceId
|
|
897
|
+
* @param float $finalScore
|
|
898
|
+
*/
|
|
899
|
+
public static function updateSubnetMetrics(RequestContext $context, string $deviceId, float $finalScore): void
|
|
900
|
+
{
|
|
901
|
+
$subnet = self::getIpSubnet($context->clientIp);
|
|
902
|
+
if ($subnet === null) return;
|
|
903
|
+
|
|
904
|
+
$store = StoreManager::getStore();
|
|
905
|
+
$key = "subnet:{$subnet}";
|
|
906
|
+
$subnetData = $store->get($key) ?? [
|
|
907
|
+
'highScoreCount' => 0,
|
|
908
|
+
'deviceIds' => [],
|
|
909
|
+
'lastActivity' => 0
|
|
910
|
+
];
|
|
911
|
+
|
|
912
|
+
$subnetData['highScoreCount']++;
|
|
913
|
+
if (!in_array($deviceId, $subnetData['deviceIds'])) {
|
|
914
|
+
$subnetData['deviceIds'][] = $deviceId;
|
|
915
|
+
}
|
|
916
|
+
$subnetData['lastActivity'] = time();
|
|
917
|
+
|
|
918
|
+
// Limiter la taille du tableau des deviceIds pour éviter une consommation mémoire excessive.
|
|
919
|
+
if (count($subnetData['deviceIds']) > 100) {
|
|
920
|
+
array_shift($subnetData['deviceIds']);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// TTL de 24 heures pour les données de sous-réseau.
|
|
924
|
+
$store->set($key, $subnetData, 86400);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Calcule un score de suspicion basé sur l'activité historique du sous-réseau IP.
|
|
929
|
+
* @param RequestContext $context
|
|
930
|
+
* @param string $currentDeviceId
|
|
931
|
+
* @return array{'subnetScore': float}
|
|
932
|
+
*/
|
|
933
|
+
public static function getSubnetScore(RequestContext $context, string $currentDeviceId): array
|
|
934
|
+
{
|
|
935
|
+
$subnet = self::getIpSubnet($context->clientIp);
|
|
936
|
+
if ($subnet === null) {
|
|
937
|
+
return ['subnetScore' => 0.0];
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
$store = StoreManager::getStore();
|
|
941
|
+
$key = "subnet:{$subnet}";
|
|
942
|
+
$subnetData = $store->get($key);
|
|
943
|
+
|
|
944
|
+
if ($subnetData === null) {
|
|
945
|
+
return ['subnetScore' => 0.0];
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
$score = 0.0;
|
|
949
|
+
|
|
950
|
+
// Pénalité basée sur le nombre de devices uniques vus depuis ce sous-réseau.
|
|
951
|
+
$deviceCount = count($subnetData['deviceIds']);
|
|
952
|
+
if ($deviceCount > 10) {
|
|
953
|
+
$score += min(80.0, ($deviceCount - 10) * 5);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Pénalité basée sur le nombre de scores élevés enregistrés.
|
|
957
|
+
$score += min(40.0, $subnetData['highScoreCount'] * 2);
|
|
958
|
+
|
|
959
|
+
return ['subnetScore' => min(100.0, $score)];
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Calcule le score de réputation d'une IP en appliquant la décroissance temporelle.
|
|
964
|
+
*/
|
|
965
|
+
public static function getIpReputationScore(string $ip): float
|
|
966
|
+
{
|
|
967
|
+
$store = StoreManager::getStore();
|
|
968
|
+
$key = "ip-reputation:{$ip}";
|
|
969
|
+
$data = $store->get($key);
|
|
970
|
+
if ($data === null) {
|
|
971
|
+
return 0.0;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
$now = time();
|
|
975
|
+
$hoursPassed = ($now - $data['lastUpdate']) / 3600;
|
|
976
|
+
$decay = (int)floor($hoursPassed * 2); // Décroissance de 2 points par heure
|
|
977
|
+
|
|
978
|
+
return (float)max(0.0, $data['score'] - $decay);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Met à jour le score de réputation locale d'une IP.
|
|
983
|
+
*/
|
|
984
|
+
public static function updateIpReputationScore(string $ip, float $change): void
|
|
985
|
+
{
|
|
986
|
+
$store = StoreManager::getStore();
|
|
987
|
+
$key = "ip-reputation:{$ip}";
|
|
988
|
+
$current = self::getIpReputationScore($ip);
|
|
989
|
+
$newScore = min(100.0, max(0.0, $current + $change));
|
|
990
|
+
$store->set($key, ['score' => $newScore, 'lastUpdate' => time()], 86400 * 7); // TTL de 7 jours
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Assainit les données de trafic pour l'auto-tuner afin de prévenir les attaques par empoisonnement.
|
|
996
|
+
* Limite la contribution de chaque deviceId à un pourcentage maximum (ex: 2%) du jeu de données total.
|
|
997
|
+
*
|
|
998
|
+
* @param array<int, array<string, mixed>> $trafficData
|
|
999
|
+
* @return array<int, array<string, mixed>>
|
|
1000
|
+
*/
|
|
1001
|
+
public static function sanitizeTrafficData(array $trafficData): array
|
|
1002
|
+
{
|
|
1003
|
+
if (empty($trafficData)) {
|
|
1004
|
+
return [];
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
$tempSanitized = [];
|
|
1008
|
+
$deviceCounts = [];
|
|
1009
|
+
$maxLogsPerDevice = max(3, (int)floor(count($trafficData) * 0.02));
|
|
1010
|
+
|
|
1011
|
+
foreach ($trafficData as $log) {
|
|
1012
|
+
$deviceId = $log['deviceId'] ?? 'anonymous';
|
|
1013
|
+
if (!isset($deviceCounts[$deviceId])) {
|
|
1014
|
+
$deviceCounts[$deviceId] = 0;
|
|
1015
|
+
}
|
|
1016
|
+
if ($deviceCounts[$deviceId] < $maxLogsPerDevice) {
|
|
1017
|
+
$deviceCounts[$deviceId]++;
|
|
1018
|
+
$tempSanitized[] = $log;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
$passedLogs = [];
|
|
1023
|
+
$suspiciousLogs = [];
|
|
1024
|
+
foreach ($tempSanitized as $log) {
|
|
1025
|
+
if (($log['type'] ?? '') === 'request_passed') {
|
|
1026
|
+
$passedLogs[] = $log;
|
|
1027
|
+
} else {
|
|
1028
|
+
$suspiciousLogs[] = $log;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
$minDataPoints = 200; // Seuil par défaut
|
|
1033
|
+
$maxPassedAllowed = max($minDataPoints, count($suspiciousLogs) * 9);
|
|
1034
|
+
|
|
1035
|
+
if (count($passedLogs) > $maxPassedAllowed) {
|
|
1036
|
+
shuffle($passedLogs);
|
|
1037
|
+
$passedLogs = array_slice($passedLogs, 0, $maxPassedAllowed);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return array_merge($suspiciousLogs, $passedLogs);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Génère une signature HMAC-SHA256 pour sécuriser les données du challenge stockées.
|
|
1045
|
+
* @param string $secret Le secret global (POW_SECRET).
|
|
1046
|
+
* @param array<string, mixed> $payload Les données du challenge.
|
|
1047
|
+
* @param string $clientIp L'IP du client pour lier la signature.
|
|
1048
|
+
* @return string
|
|
1049
|
+
*/
|
|
1050
|
+
public static function signChallengePayload(string $secret, array $payload, string $clientIp): string
|
|
1051
|
+
{
|
|
1052
|
+
$dataToSign = implode(':', [
|
|
1053
|
+
$payload['clientSecret'] ?? '',
|
|
1054
|
+
$payload['cpuTarget'] ?? '',
|
|
1055
|
+
$payload['fingerprint'] ?? '',
|
|
1056
|
+
$payload['memDifficulty'] ?? '',
|
|
1057
|
+
$payload['originalPath'] ?? '',
|
|
1058
|
+
$clientIp
|
|
1059
|
+
]);
|
|
1060
|
+
|
|
1061
|
+
return hash_hmac('sha256', $dataToSign, $secret);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Vérifie la signature HMAC-SHA256 des données de challenge récupérées du store.
|
|
1066
|
+
* @param string $secret Le secret global (POW_SECRET).
|
|
1067
|
+
* @param array<string, mixed> $payload Les données du challenge contenant la signature.
|
|
1068
|
+
* @param string $clientIp L'IP du client.
|
|
1069
|
+
* @return bool True si la signature est valide, false sinon.
|
|
1070
|
+
*/
|
|
1071
|
+
public static function verifyChallengePayload(string $secret, array $payload, string $clientIp): bool
|
|
1072
|
+
{
|
|
1073
|
+
if (empty($payload['signature'])) {
|
|
1074
|
+
return false;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
$storedSignature = $payload['signature'];
|
|
1078
|
+
$payloadWithoutSig = $payload;
|
|
1079
|
+
unset($payloadWithoutSig['signature']);
|
|
1080
|
+
|
|
1081
|
+
$expectedSignature = self::signChallengePayload($secret, $payloadWithoutSig, $clientIp);
|
|
1082
|
+
|
|
1083
|
+
return hash_equals($expectedSignature, $storedSignature);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Vérifie si un ticket de clearance (PoW) est valide, en supportant la tolérance au roaming.
|
|
1088
|
+
*
|
|
1089
|
+
* @param string $ip L'adresse IP de la requête courante.
|
|
1090
|
+
* @param string|null $ticket Le ticket de clearance extrait du cookie.
|
|
1091
|
+
* @param string $deviceId L'identifiant du cookie de l'appareil.
|
|
1092
|
+
* @param string $deviceHash L'empreinte matérielle calculée côté serveur.
|
|
1093
|
+
* @param string $secret La clé secrète (POW_SECRET).
|
|
1094
|
+
* @return bool True si le ticket est valide et correspond aux contraintes de sécurité.
|
|
1095
|
+
*/
|
|
1096
|
+
public static function isTicketValid(string $ip, ?string $ticket, string $deviceId = '', string $deviceHash = '', string $secret = ''): bool
|
|
1097
|
+
{
|
|
1098
|
+
if (empty($ticket)) {
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (str_contains($ticket, '|')) {
|
|
1103
|
+
$parts = explode('|', $ticket);
|
|
1104
|
+
if (count($parts) < 3) return false;
|
|
1105
|
+
[$expiry, $originalIp, $sig] = $parts;
|
|
1106
|
+
} elseif (str_contains($ticket, ':')) {
|
|
1107
|
+
// Fallback rétrocompatible pour les anciens tickets
|
|
1108
|
+
$parts = explode(':', $ticket);
|
|
1109
|
+
if (count($parts) < 2) return false;
|
|
1110
|
+
[$expiry, $sig] = $parts;
|
|
1111
|
+
$originalIp = $ip;
|
|
1112
|
+
} else {
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (empty($expiry) || empty($sig) || (time() * 1000) > (int)$expiry) {
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (str_contains($ticket, '|')) {
|
|
1121
|
+
$expectedSig = hash_hmac('sha256', "{$expiry}:{$originalIp}:{$deviceId}:{$deviceHash}", $secret);
|
|
1122
|
+
} else {
|
|
1123
|
+
$expectedSig = hash_hmac('sha256', "{$ip}:{$expiry}", $secret);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (!hash_equals($expectedSig, $sig)) {
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (!str_contains($ticket, '|')) {
|
|
1131
|
+
return $ip === $originalIp;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if ($ip === $originalIp) return true;
|
|
1135
|
+
$currentSubnet = self::getIpSubnet($ip);
|
|
1136
|
+
$originalSubnet = self::getIpSubnet($originalIp);
|
|
1137
|
+
if ($currentSubnet !== null && $originalSubnet !== null && $currentSubnet === $originalSubnet) {
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
return !empty($deviceId) && !empty($deviceHash); // Match d'identité matérielle stricte (deviceId + deviceHash validés par HMAC)
|
|
1142
|
+
}
|
|
1143
1143
|
}
|