@anonympins/fingerprint 0.3.2 → 0.3.3
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 +172 -0
- package/README.md +276 -34
- package/composer.json +38 -0
- package/index.js +5 -0
- package/package.json +23 -18
- package/phpunit.xml +20 -0
- package/public/fp.js +2 -0
- package/src/js/build-client.js +69 -0
- package/{fingerprint.builder.js → src/js/fingerprint.builder.js} +168 -175
- package/{fingerprint.client.js → src/js/fingerprint.client.js} +634 -538
- package/src/js/fingerprint.client.obfuscated.js +1 -0
- package/{fingerprint.js → src/js/fingerprint.js} +255 -101
- package/{library.js → src/js/library.js} +1729 -1729
- package/{problem-manager.js → src/js/problem-manager.js} +539 -522
- package/src/php/AutoTuner.php +155 -0
- package/src/php/Challenge/ChallengeUtils.php +306 -0
- package/src/php/Config/SecurityProfiles.php +257 -0
- package/src/php/DirectFingerprint.php +81 -0
- package/src/php/FingerprintBuilder.php +185 -0
- package/src/php/FingerprintClient.php +118 -0
- package/src/php/FingerprintEngine.php +850 -0
- package/src/php/Optimization/FunctionRegistry.php +63 -0
- package/src/php/Optimization/Optimization.php +256 -0
- package/src/php/Optimization/OptimizationOperators.php +305 -0
- package/src/php/Optimization/ProblemInitializers.php +53 -0
- package/src/php/ProblemManager.php +255 -0
- package/src/php/RequestContext.php +87 -0
- package/src/php/Store/IStore.php +42 -0
- package/src/php/Store/InMemoryStore.php +67 -0
- package/src/php/Store/StoreManager.php +26 -0
- package/src/php/Tests/ChallengeUtilsTest.php +82 -0
- package/src/php/Tests/FingerprintBuilderTest.php +58 -0
- package/src/php/Tests/FingerprintEngineTest.php +219 -0
- package/src/php/Tests/PowTest.php +40 -0
- package/src/php/Tests/ProblemManagerTest.php +295 -0
- package/src/php/Tests/RequestUtilsTest.php +81 -0
- package/src/php/Tests/problems.config.json +9 -0
- package/src/php/Utils/BigInt.php +102 -0
- package/src/php/Utils/BlockList.php +100 -0
- package/src/php/Utils/Logger.php +30 -0
- package/src/php/Utils/MaliciousPatterns.php +59 -0
- package/src/php/Utils/RequestUtils.php +673 -0
- package/fingerprint.client.obfuscated.js +0 -1
- /package/{mongodb-store.js → src/js/mongodb-store.js} +0 -0
- /package/{optimization.worker.js → src/js/optimization.worker.js} +0 -0
- /package/{pow.solver.inline.js → src/js/pow.solver.inline.js} +0 -0
- /package/{pow.solver.js → src/js/pow.solver.js} +0 -0
- /package/{pow.worker.js → src/js/pow.worker.js} +0 -0
- /package/{redis-store.js → src/js/redis-store.js} +0 -0
- /package/{sql-store.js → src/js/sql-store.js} +0 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Anonympins\Fingerprint\Utils;
|
|
6
|
+
|
|
7
|
+
use Anonympins\Fingerprint\FingerprintBuilder;
|
|
8
|
+
use Anonympins\Fingerprint\Optimization\Optimization;
|
|
9
|
+
use Anonympins\Fingerprint\RequestContext;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Classe utilitaire pour l'analyse des requêtes et le calcul des scores de suspicion.
|
|
13
|
+
*/
|
|
14
|
+
class RequestUtils
|
|
15
|
+
{
|
|
16
|
+
/**
|
|
17
|
+
* Base de données de signatures JA3 connues.
|
|
18
|
+
* @var array<string, string|string[]>
|
|
19
|
+
*/
|
|
20
|
+
private const TLS_FINGERPRINT_DB = [
|
|
21
|
+
// --- Chrome (Desktop) ---
|
|
22
|
+
'e188a442b87f422c5a1e80b05399435b' => 'Chrome',
|
|
23
|
+
'd8e35855049321c6042a4325c697858f' => 'Chrome',
|
|
24
|
+
'a9f90958d44533748c139a5d1895b925' => 'Chrome',
|
|
25
|
+
'3b5379916d2b3882253c42885956a350' => 'Chrome',
|
|
26
|
+
// --- Chrome (Mobile) ---
|
|
27
|
+
'59822058c95c33d2d06e52f410855c8c' => 'Chrome',
|
|
28
|
+
// --- Firefox (Desktop) ---
|
|
29
|
+
'b386946a5a586163c7c533636b45c355' => 'Firefox',
|
|
30
|
+
'66236495a523c1785f8f3a105b248b11' => 'Firefox',
|
|
31
|
+
'b73d470006575b5e35167a0b5a8540e2' => 'Firefox',
|
|
32
|
+
'8443d7562933834333943465d52363cf' => 'Firefox',
|
|
33
|
+
// --- Firefox (Mobile) ---
|
|
34
|
+
'02720628957d38c6111a18433abe833f' => 'Firefox',
|
|
35
|
+
// --- Safari & iOS (Shared TLS Stack) ---
|
|
36
|
+
'b633f21d532d35967c8753c38536b4d3' => 'Safari',
|
|
37
|
+
'4d7a28d5f55b359b69100a311013f03e' => ['Safari', 'Chrome', 'Firefox'],
|
|
38
|
+
'8dd3d7532873575314df23c447543001' => ['Safari', 'Chrome', 'Firefox'],
|
|
39
|
+
// --- Common Libraries & Bots ---
|
|
40
|
+
'47344a349b75c4e82333475553b5f358' => 'Python',
|
|
41
|
+
'b29587b8a143c42546133ad7704b3310' => 'Go',
|
|
42
|
+
'd435b5223b2884c5a832b842637e245f' => 'Java',
|
|
43
|
+
'c72366b9551263d990b7fa574225332c' => 'curl',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Crée un hash composite stable basé sur les caractéristiques de la requête.
|
|
48
|
+
*/
|
|
49
|
+
public static function getCompositeDeviceHash(RequestContext $context): string
|
|
50
|
+
{
|
|
51
|
+
$srv = new FingerprintBuilder();
|
|
52
|
+
|
|
53
|
+
$clientFp = $context->getHeader('x-device-fingerprint');
|
|
54
|
+
if ($clientFp && str_contains($clientFp, 'cvs:')) {
|
|
55
|
+
$srv->add("client_fp_hash", $clientFp);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
$ua = $context->getHeader("user-agent");
|
|
59
|
+
if ($ua) {
|
|
60
|
+
$srv->add("ua", $ua);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if ($context->ja3) $srv->add("ja3", $context->ja3);
|
|
64
|
+
if ($context->ja4) $srv->add("ja4", $context->ja4);
|
|
65
|
+
if ($context->http2Fingerprint) $srv->add("h2", $context->http2Fingerprint);
|
|
66
|
+
if ($context->tcpFingerprint) $srv->add("tcp", $context->tcpFingerprint);
|
|
67
|
+
|
|
68
|
+
$headersToCapture = [
|
|
69
|
+
"ch_ua" => "sec-ch-ua",
|
|
70
|
+
"ch_platform" => "sec-ch-ua-platform",
|
|
71
|
+
"ch_mobile" => "sec-ch-ua-mobile",
|
|
72
|
+
"ch_model" => "sec-ch-ua-model",
|
|
73
|
+
"ch_arch" => "sec-ch-ua-arch",
|
|
74
|
+
"ch_bitness" => "sec-ch-ua-bitness",
|
|
75
|
+
"upgrade_req" => "upgrade-insecure-requests",
|
|
76
|
+
"accept_lang" => "accept-language",
|
|
77
|
+
"accept_enc" => "accept-encoding",
|
|
78
|
+
"accept" => "accept"
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
foreach ($headersToCapture as $key => $headerName) {
|
|
82
|
+
$headerValue = $context->getHeader($headerName);
|
|
83
|
+
if ($headerValue) {
|
|
84
|
+
$srv->add($key, $headerValue);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if ($context->httpVersion) {
|
|
89
|
+
$srv->add("http_ver", $context->httpVersion);
|
|
90
|
+
}
|
|
91
|
+
if (!empty($context->cookies)) {
|
|
92
|
+
$cookieKeys = array_keys($context->cookies);
|
|
93
|
+
sort($cookieKeys);
|
|
94
|
+
$srv->add("cookie_keys", implode(',', $cookieKeys));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (string)$srv;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Calcule un score d'incohérence entre la signature TLS (JA3) et le User-Agent.
|
|
102
|
+
* @return array{'tlsSpoofingScore': float}
|
|
103
|
+
*/
|
|
104
|
+
public static function getTlsSpoofingScore(RequestContext $context): array
|
|
105
|
+
{
|
|
106
|
+
$ja3 = $context->ja3;
|
|
107
|
+
$ua = $context->getHeader('user-agent') ?? '';
|
|
108
|
+
|
|
109
|
+
if ($ja3 && (empty($ua) || strlen($ua) < 10 || stripos($ua, 'python') !== false || stripos($ua, 'curl') !== false)) {
|
|
110
|
+
return ['tlsSpoofingScore' => 50.0];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if ($ja3 && !empty($ua) && isset(self::TLS_FINGERPRINT_DB[$ja3])) {
|
|
114
|
+
$expectedBrowsers = self::TLS_FINGERPRINT_DB[$ja3];
|
|
115
|
+
if (!is_array($expectedBrowsers)) {
|
|
116
|
+
$expectedBrowsers = [$expectedBrowsers];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
$claimedBrowserInfo = self::parseUserAgent($ua);
|
|
120
|
+
$claimedBrowser = $claimedBrowserInfo['browser'] ?? null;
|
|
121
|
+
|
|
122
|
+
if ($claimedBrowser) {
|
|
123
|
+
$isMatch = false;
|
|
124
|
+
foreach ($expectedBrowsers as $expected) {
|
|
125
|
+
if (str_starts_with($claimedBrowser, $expected)) {
|
|
126
|
+
$isMatch = true;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!$isMatch) {
|
|
131
|
+
return ['tlsSpoofingScore' => 80.0];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return ['tlsSpoofingScore' => 0.0];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Calcule un score basé sur les anomalies des en-têtes HTTP.
|
|
141
|
+
* @return array{'headerAnomalyScore': float}
|
|
142
|
+
*/
|
|
143
|
+
public static function getHeaderAnomalies(RequestContext $context): array
|
|
144
|
+
{
|
|
145
|
+
$anomalyScore = 0;
|
|
146
|
+
$ua = $context->getHeader('user-agent');
|
|
147
|
+
if (empty($ua) || strlen($ua) < 10) {
|
|
148
|
+
$anomalyScore += 60;
|
|
149
|
+
}
|
|
150
|
+
if (!$context->getHeader('accept-language')) {
|
|
151
|
+
$anomalyScore += 25;
|
|
152
|
+
}
|
|
153
|
+
if ($context->httpVersion === '1.0') {
|
|
154
|
+
$anomalyScore += 15;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return ['headerAnomalyScore' => min(100.0, $anomalyScore)];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Calcule un score basé sur la détection de marqueurs d'automatisation.
|
|
162
|
+
* @return array{'botScore': float}
|
|
163
|
+
*/
|
|
164
|
+
public static function getBotScore(RequestContext $context): array
|
|
165
|
+
{
|
|
166
|
+
$clientFpString = $context->getHeader('x-device-fingerprint');
|
|
167
|
+
if (!$clientFpString) {
|
|
168
|
+
return ['botScore' => 0.0];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Une simple vérification par chaîne est suffisante et performante.
|
|
172
|
+
if (str_contains($clientFpString, 'bot:true') || str_contains($clientFpString, 'cdp:true')) {
|
|
173
|
+
return ['botScore' => 100.0];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return ['botScore' => 0.0];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @private
|
|
181
|
+
* Analyse une série de mouvements de souris pour en extraire des métriques comportementales.
|
|
182
|
+
* @param array<int, array{x: int, y: int, t: float}>|null $history
|
|
183
|
+
* @return array{avgSpeed: float, avgAcceleration: float, straightness: float, pauses: int, segments: array<float>}
|
|
184
|
+
*/
|
|
185
|
+
private static function analyzeMouseMovements(?array $history): array
|
|
186
|
+
{
|
|
187
|
+
if (empty($history) || count($history) < 3) {
|
|
188
|
+
return ['avgSpeed' => 0, 'avgAcceleration' => 0, 'straightness' => 1, 'pauses' => 0, 'segments' => []];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
$segments = [];
|
|
192
|
+
$totalDistance = 0.0;
|
|
193
|
+
$pauses = 0;
|
|
194
|
+
|
|
195
|
+
for ($i = 1; $i < count($history); $i++) {
|
|
196
|
+
$p1 = $history[$i - 1];
|
|
197
|
+
$p2 = $history[$i];
|
|
198
|
+
$dx = $p2['x'] - $p1['x'];
|
|
199
|
+
$dy = $p2['y'] - $p1['y'];
|
|
200
|
+
$dt = $p2['t'] - $p1['t'];
|
|
201
|
+
$distance = sqrt($dx * $dx + $dy * $dy);
|
|
202
|
+
|
|
203
|
+
if ($dt > 0) {
|
|
204
|
+
$speed = $distance / $dt;
|
|
205
|
+
$segments[] = ['distance' => $distance, 'dt' => $dt, 'speed' => $speed];
|
|
206
|
+
$totalDistance += $distance;
|
|
207
|
+
}
|
|
208
|
+
if ($dt > 100 && $distance < 5) {
|
|
209
|
+
$pauses++;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (count($segments) < 2) {
|
|
214
|
+
return ['avgSpeed' => 0, 'avgAcceleration' => 0, 'straightness' => 1, 'pauses' => $pauses, 'segments' => []];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
$totalTime = $history[count($history) - 1]['t'] - $history[0]['t'];
|
|
218
|
+
$avgSpeed = $totalTime > 0 ? array_sum(array_column($segments, 'speed')) / count($segments) : 0;
|
|
219
|
+
|
|
220
|
+
$totalAbsAcceleration = 0.0;
|
|
221
|
+
for ($i = 1; $i < count($segments); $i++) {
|
|
222
|
+
$s1 = $segments[$i - 1];
|
|
223
|
+
$s2 = $segments[$i];
|
|
224
|
+
if ($s2['dt'] > 0) {
|
|
225
|
+
$acceleration = ($s2['speed'] - $s1['speed']) / $s2['dt'];
|
|
226
|
+
$totalAbsAcceleration += abs($acceleration);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
$avgAcceleration = $totalAbsAcceleration / (count($segments) - 1);
|
|
230
|
+
|
|
231
|
+
$startPoint = $history[0];
|
|
232
|
+
$endPoint = $history[count($history) - 1];
|
|
233
|
+
$straightDistance = sqrt(pow($endPoint['x'] - $startPoint['x'], 2) + pow($endPoint['y'] - $startPoint['y'], 2));
|
|
234
|
+
$straightness = $totalDistance > 0 ? $straightDistance / $totalDistance : 1;
|
|
235
|
+
|
|
236
|
+
return ['avgSpeed' => $avgSpeed, 'avgAcceleration' => $avgAcceleration, 'straightness' => $straightness, 'pauses' => $pauses, 'segments' => array_column($segments, 'distance')];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Calcule un score basé sur les métriques comportementales envoyées par le client.
|
|
242
|
+
* @return array{'behaviorScore': float}
|
|
243
|
+
*/
|
|
244
|
+
public static function getBehaviorScore(RequestContext $context): array
|
|
245
|
+
{
|
|
246
|
+
$behaviorHeader = $context->getHeader('x-behavior-metrics');
|
|
247
|
+
if (!$behaviorHeader) {
|
|
248
|
+
return ['behaviorScore' => 0.0];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
$metrics = json_decode($behaviorHeader, true);
|
|
252
|
+
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
253
|
+
return ['behaviorScore' => 10.0]; // En-tête malformé
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!empty($metrics['honeypotInteraction'])) {
|
|
257
|
+
return ['behaviorScore' => 100.0];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
$score = 0.0;
|
|
261
|
+
|
|
262
|
+
$mouseAnalysis = self::analyzeMouseMovements($metrics['mouseMovementsHistory'] ?? null);
|
|
263
|
+
|
|
264
|
+
if (isset($metrics['historyLength'])) {
|
|
265
|
+
if ($metrics['historyLength'] === 1) $score += 15;
|
|
266
|
+
elseif ($metrics['historyLength'] >= 5) $score -= 20;
|
|
267
|
+
elseif ($metrics['historyLength'] >= 2) $score -= 10;
|
|
268
|
+
} else {
|
|
269
|
+
// Pénalité pour absence totale d'interaction si l'historique n'est pas dispo
|
|
270
|
+
if ($mouseAnalysis['avgSpeed'] == 0 && ($metrics['keystrokeLatency'] ?? 0) == 0) {
|
|
271
|
+
$score += 40;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if ($mouseAnalysis['avgSpeed'] > 0) {
|
|
276
|
+
if ($mouseAnalysis['avgSpeed'] > 3) $score += 25;
|
|
277
|
+
if ($mouseAnalysis['avgAcceleration'] > 0.5) $score += 20;
|
|
278
|
+
if ($mouseAnalysis['straightness'] > 0.95) $score += 30;
|
|
279
|
+
if ($mouseAnalysis['pauses'] === 0 && count($mouseAnalysis['segments']) > 20) $score += 15;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (($metrics['keystrokeLatency'] ?? 0) > 0 && $metrics['keystrokeLatency'] < 40) $score += 25;
|
|
283
|
+
if (($metrics['keystrokeLatency'] ?? 0) > 1000) $score += 15;
|
|
284
|
+
|
|
285
|
+
// Analyse de Benford sur les segments de mouvement de la souris
|
|
286
|
+
if (count($mouseAnalysis['segments']) > 10) {
|
|
287
|
+
$benfordDeviation = Optimization::benfordTest($mouseAnalysis['segments']);
|
|
288
|
+
if ($benfordDeviation > 0.18) {
|
|
289
|
+
$score += 35;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return ['behaviorScore' => min(100.0, $score)];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Analyse basique d'un User-Agent.
|
|
298
|
+
* @return array{browser?: string, os?: string, device?: string}
|
|
299
|
+
*/
|
|
300
|
+
private static function parseUserAgent(string $ua): array
|
|
301
|
+
{
|
|
302
|
+
$result = [];
|
|
303
|
+
|
|
304
|
+
if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
|
|
305
|
+
$result['browser'] = 'Chrome';
|
|
306
|
+
} elseif (str_contains($ua, 'Firefox')) {
|
|
307
|
+
$result['browser'] = 'Firefox';
|
|
308
|
+
} elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
|
|
309
|
+
$result['browser'] = 'Safari';
|
|
310
|
+
} elseif (str_contains($ua, 'Edg')) {
|
|
311
|
+
$result['browser'] = 'Edge';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows';
|
|
315
|
+
elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
|
|
316
|
+
elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
|
|
317
|
+
elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
|
|
318
|
+
elseif (str_contains($ua, 'Linux')) $result['os'] = 'Linux';
|
|
319
|
+
|
|
320
|
+
if (str_contains($ua, 'Mobile')) $result['device'] = 'mobile';
|
|
321
|
+
else $result['device'] = 'desktop';
|
|
322
|
+
|
|
323
|
+
return $result;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Calcule un score d'incohérence temporelle.
|
|
328
|
+
* @return array{'timeInconsistencyScore': float}
|
|
329
|
+
* @param RequestContext $context
|
|
330
|
+
* @param array|null $metrics
|
|
331
|
+
*/
|
|
332
|
+
public static function getTimeInconsistencyScore(RequestContext $context, ?array $metrics = null): array
|
|
333
|
+
{
|
|
334
|
+
if ($metrics === null) {
|
|
335
|
+
$behaviorHeader = $context->getHeader('x-behavior-metrics');
|
|
336
|
+
if (!$behaviorHeader) return ['timeInconsistencyScore' => 0.0];
|
|
337
|
+
$metrics = json_decode($behaviorHeader, true);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (!is_array($metrics) || empty($metrics['clientTimestamp'])) {
|
|
341
|
+
return ['timeInconsistencyScore' => 0.0];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
$timeDelta = $context->requestTimestamp - $metrics['clientTimestamp'];
|
|
345
|
+
$replayThreshold = 5000; // 5 secondes
|
|
346
|
+
|
|
347
|
+
$score = ($timeDelta > $replayThreshold) ? min(100.0, ($timeDelta / $replayThreshold - 1) * 50) : 0.0;
|
|
348
|
+
return ['timeInconsistencyScore' => $score];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Calcule un score d'incohérence entre les couches client et serveur.
|
|
353
|
+
* @return array{'crossLayerInconsistencyScore': float}
|
|
354
|
+
*/
|
|
355
|
+
public static function getCrossLayerInconsistency(RequestContext $context): array
|
|
356
|
+
{
|
|
357
|
+
$clientFpString = $context->getHeader('x-device-fingerprint');
|
|
358
|
+
if (!$clientFpString) return ['crossLayerInconsistencyScore' => 0.0];
|
|
359
|
+
|
|
360
|
+
$clientFpMap = [];
|
|
361
|
+
foreach (explode('|', $clientFpString) as $part) {
|
|
362
|
+
$pair = explode(':', $part, 2);
|
|
363
|
+
if (count($pair) === 2) $clientFpMap[$pair[0]] = $pair[1];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
$ua = $context->getHeader('user-agent') ?? '';
|
|
367
|
+
$score = 0;
|
|
368
|
+
|
|
369
|
+
$clientOsHash = $clientFpMap['os'] ?? null;
|
|
370
|
+
if ($clientOsHash) {
|
|
371
|
+
$serverOsParts = self::parseUserAgent($ua);
|
|
372
|
+
if (!empty($serverOsParts['os']) && $clientOsHash !== FingerprintBuilder::cyrb53($serverOsParts['os'])) {
|
|
373
|
+
$score += 50;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return ['crossLayerInconsistencyScore' => min(100.0, $score)];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Calcule les indicateurs comportementaux liés à l'historique de l'appareil.
|
|
382
|
+
* @param array<string, mixed> $deviceData
|
|
383
|
+
* @return array{'historyScore': float, 'rotationScore': float}
|
|
384
|
+
*/
|
|
385
|
+
public static function getBehavioralIndicators(RequestContext $context, array &$deviceData): array
|
|
386
|
+
{
|
|
387
|
+
$now = time() * 1000;
|
|
388
|
+
$clientIp = $context->clientIp;
|
|
389
|
+
$currentFpHash = self::getCompositeDeviceHash($context);
|
|
390
|
+
|
|
391
|
+
// Analyse de la fréquence de changement du fingerprint
|
|
392
|
+
$rapidChangeThresholdMs = 2000; // 2 secondes
|
|
393
|
+
$maxRapidChanges = 3;
|
|
394
|
+
|
|
395
|
+
if (isset($deviceData['lastFpHash']) && $currentFpHash !== $deviceData['lastFpHash']) {
|
|
396
|
+
$timeSinceLastChange = $now - ($deviceData['lastChangeTimestamp'] ?? 0);
|
|
397
|
+
if ($timeSinceLastChange < $rapidChangeThresholdMs) {
|
|
398
|
+
$deviceData['rapidChangeCount'] = ($deviceData['rapidChangeCount'] ?? 0) + 1;
|
|
399
|
+
} else {
|
|
400
|
+
$deviceData['rapidChangeCount'] = max(0, ($deviceData['rapidChangeCount'] ?? 0) - 1);
|
|
401
|
+
}
|
|
402
|
+
$deviceData['lastChangeTimestamp'] = $now;
|
|
403
|
+
}
|
|
404
|
+
$deviceData['lastFpHash'] = $currentFpHash;
|
|
405
|
+
|
|
406
|
+
// Enregistrement de l'IP
|
|
407
|
+
if (!in_array($clientIp, $deviceData['ips'])) {
|
|
408
|
+
$deviceData['ips'][] = $clientIp;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Score d'historique basé sur le nombre d'IPs utilisées (rotation de proxy)
|
|
412
|
+
$maxIpsPerDevice = 15;
|
|
413
|
+
$freeIpChanges = 3;
|
|
414
|
+
$historyScore = min(100.0, (max(0, count($deviceData['ips']) - $freeIpChanges) / $maxIpsPerDevice) * 100);
|
|
415
|
+
// Score de rotation basé sur les changements rapides de fingerprint
|
|
416
|
+
$rotationScore = min(100.0, (($deviceData['rapidChangeCount'] ?? 0) / $maxRapidChanges) * 100);
|
|
417
|
+
|
|
418
|
+
return ['historyScore' => $historyScore, 'rotationScore' => $rotationScore];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Analyse les patterns de requêtes pour détecter les comportements de bot.
|
|
423
|
+
* @param array<string, mixed> $deviceData
|
|
424
|
+
* @param array<string, mixed> $patternConfig
|
|
425
|
+
* @return array{'requestPatternScore': float}
|
|
426
|
+
*/
|
|
427
|
+
public static function getRequestPatternScore(RequestContext $context, array &$deviceData, array $patternConfig): array
|
|
428
|
+
{
|
|
429
|
+
// Configuration avec valeurs par défaut robustes
|
|
430
|
+
$historySize = $patternConfig['historySize'] ?? 20;
|
|
431
|
+
$minSamples = $patternConfig['minSamples'] ?? 10;
|
|
432
|
+
$regularityThreshold = $patternConfig['regularityThreshold'] ?? 150; // ms
|
|
433
|
+
$benfordThreshold = $patternConfig['benfordThreshold'] ?? 0.15;
|
|
434
|
+
$patternWeight = $patternConfig['patternWeight'] ?? 80;
|
|
435
|
+
$decayFactor = $patternConfig['decayFactor'] ?? 0.95;
|
|
436
|
+
$inactivityReset = $patternConfig['inactivityReset'] ?? 180000;
|
|
437
|
+
|
|
438
|
+
$now = time() * 1000;
|
|
439
|
+
$history = $deviceData['requestHistory'] ?? [];
|
|
440
|
+
$deviceData['timingHistory'] = $deviceData['timingHistory'] ?? [];
|
|
441
|
+
|
|
442
|
+
$lastRequest = end($history) ?: null;
|
|
443
|
+
$timeSinceLast = $lastRequest ? $now - $lastRequest['timestamp'] : PHP_INT_MAX;
|
|
444
|
+
|
|
445
|
+
// Mise à jour de l'historique
|
|
446
|
+
$history[] = ['timestamp' => $now, 'path' => $context->path];
|
|
447
|
+
if ($lastRequest) {
|
|
448
|
+
$deviceData['timingHistory'][] = $timeSinceLast;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (count($history) > $historySize) {
|
|
452
|
+
array_shift($history);
|
|
453
|
+
}
|
|
454
|
+
if (count($deviceData['timingHistory']) > $historySize) {
|
|
455
|
+
array_shift($deviceData['timingHistory']);
|
|
456
|
+
}
|
|
457
|
+
$deviceData['requestHistory'] = $history;
|
|
458
|
+
|
|
459
|
+
$instantScore = 0;
|
|
460
|
+
$timings = $deviceData['timingHistory'];
|
|
461
|
+
|
|
462
|
+
// Analyse statistique si nous avons assez de données
|
|
463
|
+
if (count($timings) >= $minSamples) {
|
|
464
|
+
// FIX: Éviter la division par zéro si le tableau est vide, bien que count() >= minSamples devrait déjà le prévenir.
|
|
465
|
+
if (count($timings) === 0) {
|
|
466
|
+
return ['requestPatternScore' => min(100.0, $deviceData['lastPatternScore'] ?? 0)];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
$mean = array_sum($timings) / count($timings); // @phpstan-ignore-line
|
|
470
|
+
$variance = array_reduce($timings, fn($carry, $item) => $carry + pow($item - $mean, 2), 0) / count($timings); // @phpstan-ignore-line
|
|
471
|
+
$stdDev = sqrt($variance);
|
|
472
|
+
$benfordDeviation = Optimization::benfordTest($timings);
|
|
473
|
+
|
|
474
|
+
// Détection de régularité (bots de type "cron")
|
|
475
|
+
if ($stdDev < $regularityThreshold) {
|
|
476
|
+
$instantScore = $patternWeight;
|
|
477
|
+
}
|
|
478
|
+
// Détection de distribution non-naturelle (bots "faussement aléatoires")
|
|
479
|
+
elseif ($benfordDeviation > $benfordThreshold) {
|
|
480
|
+
$instantScore = $patternWeight;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Logique de décroissance et de score final
|
|
485
|
+
$newPatternScore = $deviceData['lastPatternScore'] ?? 0;
|
|
486
|
+
|
|
487
|
+
if ($timeSinceLast > $inactivityReset) {
|
|
488
|
+
$newPatternScore = 0; // Réinitialisation après inactivité
|
|
489
|
+
} else {
|
|
490
|
+
$newPatternScore *= $decayFactor;
|
|
491
|
+
}
|
|
492
|
+
$newPatternScore = max(0, $newPatternScore);
|
|
493
|
+
|
|
494
|
+
$deviceData['lastPatternScore'] = $newPatternScore + $instantScore;
|
|
495
|
+
|
|
496
|
+
return ['requestPatternScore' => min(100.0, $deviceData['lastPatternScore'])];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Vérifie la soumission de champs honeypot.
|
|
501
|
+
* @param array<string, mixed> $honeypotConfig
|
|
502
|
+
* @return array{'honeypotScore': float}
|
|
503
|
+
*/
|
|
504
|
+
public static function getHoneypotScore(RequestContext $context, array $honeypotConfig): array // @phpstan-ignore-line
|
|
505
|
+
{
|
|
506
|
+
$fields = $honeypotConfig['fields'] ?? [];
|
|
507
|
+
$trapUrls = $honeypotConfig['trapUrls'] ?? [];
|
|
508
|
+
$data = array_merge($context->query, is_array($context->body) ? $context->body : []);
|
|
509
|
+
|
|
510
|
+
// 1. Vérifier les champs de formulaire pièges
|
|
511
|
+
foreach ($fields as $field) {
|
|
512
|
+
// Ignorer les paramètres de solution de challenge pour éviter les faux positifs.
|
|
513
|
+
if (str_starts_with($field, 'pow_')) {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (!empty($data[$field])) {
|
|
517
|
+
return ['honeypotScore' => 100.0];
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// 2. Vérifier l'accès aux URL pièges
|
|
522
|
+
foreach ($trapUrls as $trap) {
|
|
523
|
+
if (str_starts_with($context->path, $trap)) {
|
|
524
|
+
return ['honeypotScore' => 100.0];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 3. (Optionnel) Détection d'injections
|
|
529
|
+
if ($honeypotConfig['detectInjections'] ?? false) {
|
|
530
|
+
$typesToDetect = is_array($honeypotConfig['detectInjections']) ? $honeypotConfig['detectInjections'] : [];
|
|
531
|
+
foreach ($data as $value) {
|
|
532
|
+
if (is_string($value) && MaliciousPatterns::isMalicious($value, $typesToDetect)) {
|
|
533
|
+
return ['honeypotScore' => 100.0];
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return ['honeypotScore' => 0.0];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
public static function getThreatIntelScore(RequestContext $context, array $threatIntelConfig): array
|
|
542
|
+
{
|
|
543
|
+
return ['threatIntelScore' => 0.0];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* @private
|
|
548
|
+
* Analyzes click positions to detect unnaturally low variance.
|
|
549
|
+
* @param array<int, array{x: int, y: int, targetId: string}>|null $history
|
|
550
|
+
* @return float
|
|
551
|
+
*/
|
|
552
|
+
private static function analyzeClickPositions(?array $history): float
|
|
553
|
+
{
|
|
554
|
+
if (empty($history) || count($history) < 3) {
|
|
555
|
+
return 0.0;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
$clicksByTarget = [];
|
|
559
|
+
foreach ($history as $click) {
|
|
560
|
+
if (empty($click['targetId'])) continue;
|
|
561
|
+
if (!isset($clicksByTarget[$click['targetId']])) {
|
|
562
|
+
$clicksByTarget[$click['targetId']] = [];
|
|
563
|
+
}
|
|
564
|
+
$clicksByTarget[$click['targetId']][] = $click;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
$maxScore = 0.0;
|
|
568
|
+
|
|
569
|
+
foreach ($clicksByTarget as $clicks) {
|
|
570
|
+
if (count($clicks) < 3) continue;
|
|
571
|
+
|
|
572
|
+
$n = count($clicks);
|
|
573
|
+
$meanX = array_sum(array_column($clicks, 'x')) / $n;
|
|
574
|
+
$meanY = array_sum(array_column($clicks, 'y')) / $n;
|
|
575
|
+
|
|
576
|
+
$variance = array_reduce($clicks, function ($sum, $c) use ($meanX, $meanY) {
|
|
577
|
+
return $sum + pow($c['x'] - $meanX, 2) + pow($c['y'] - $meanY, 2);
|
|
578
|
+
}, 0) / $n;
|
|
579
|
+
|
|
580
|
+
if ($variance < 1.0) {
|
|
581
|
+
$score = (1 - sqrt($variance) / 5) * 100;
|
|
582
|
+
if ($score > $maxScore) {
|
|
583
|
+
$maxScore = $score;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return min(100.0, $maxScore);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Calculates a score based on click variance metrics sent by the client.
|
|
593
|
+
* @return array{'clickVarianceScore': float}
|
|
594
|
+
*/
|
|
595
|
+
public static function getClickVarianceScore(RequestContext $context): array
|
|
596
|
+
{
|
|
597
|
+
$behaviorHeader = $context->getHeader('x-behavior-metrics');
|
|
598
|
+
if (!$behaviorHeader) {
|
|
599
|
+
return ['clickVarianceScore' => 0.0];
|
|
600
|
+
}
|
|
601
|
+
$metrics = json_decode($behaviorHeader, true);
|
|
602
|
+
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
603
|
+
return ['clickVarianceScore' => 0.0];
|
|
604
|
+
}
|
|
605
|
+
$score = self::analyzeClickPositions($metrics['clicksHistory'] ?? null);
|
|
606
|
+
return ['clickVarianceScore' => $score];
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Parse une chaîne de requête GraphQL pour extraire le type et le nom de l'opération.
|
|
611
|
+
* @param array<string, mixed> $body Le corps de la requête.
|
|
612
|
+
* @return array{type: string, name: string}|null
|
|
613
|
+
*/
|
|
614
|
+
public static function parseGraphQLQuery(array $body): ?array
|
|
615
|
+
{
|
|
616
|
+
$query = $body['query'] ?? null;
|
|
617
|
+
if (!is_string($query)) {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Regex pour capturer le type d'opération et le nom optionnel.
|
|
622
|
+
if (preg_match('/(?:^|\s)(query|mutation|subscription)\s*([_A-Za-z][_0-9A-Za-z]*)?/', $query, $matches)) {
|
|
623
|
+
return [
|
|
624
|
+
'type' => $matches[1],
|
|
625
|
+
'name' => $matches[2] ?? 'Anonymous',
|
|
626
|
+
];
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Nettoie une URL de tous les paramètres de requête liés au PoW.
|
|
632
|
+
* @param string $originalPath Le chemin original, potentiellement avec des query params.
|
|
633
|
+
* @param array<string, mixed> $incomingQuery Le tableau de la query string de la requête entrante.
|
|
634
|
+
* @return string Le chemin final nettoyé.
|
|
635
|
+
*/
|
|
636
|
+
public static function cleanUrlFromPowParams(string $originalPath, array $incomingQuery): string
|
|
637
|
+
{
|
|
638
|
+
$urlParts = parse_url($originalPath);
|
|
639
|
+
$path = $urlParts['path'] ?? '/';
|
|
640
|
+
$finalQuery = $incomingQuery;
|
|
641
|
+
|
|
642
|
+
$powParams = [
|
|
643
|
+
'pow_type', 'pow_nonce', 'pow_solution', 'pow_solution_cpu',
|
|
644
|
+
'pow_solution_mem', 'pow_fp', 'pow_solution_population',
|
|
645
|
+
'pow_solution_work_result', 'pow_problem_id'
|
|
646
|
+
];
|
|
647
|
+
|
|
648
|
+
foreach ($powParams as $param) {
|
|
649
|
+
unset($finalQuery[$param]);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (!empty($finalQuery)) {
|
|
653
|
+
return $path . '?' . http_build_query($finalQuery);
|
|
654
|
+
}
|
|
655
|
+
return $path;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Vérifie si un host et un path de requête correspondent à une entrée de liste blanche.
|
|
660
|
+
*/
|
|
661
|
+
public static function hostPathMatches(string $requestHost, string $requestPath, string $entry): bool
|
|
662
|
+
{
|
|
663
|
+
$firstSlashIndex = strpos($entry, '/');
|
|
664
|
+
if ($firstSlashIndex === false) return false;
|
|
665
|
+
|
|
666
|
+
$hostPattern = substr($entry, 0, $firstSlashIndex);
|
|
667
|
+
$pathPattern = substr($entry, $firstSlashIndex);
|
|
668
|
+
|
|
669
|
+
if ($requestHost !== $hostPattern) return false;
|
|
670
|
+
|
|
671
|
+
return self::pathMatches($requestPath, $pathPattern);
|
|
672
|
+
}
|
|
673
|
+
}
|