@anonympins/fingerprint 0.4.3 → 0.4.5
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 +43 -0
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/js/dynamic-wasm.js +158 -0
- package/src/js/fingerprint.client.js +852 -635
- package/src/js/fingerprint.js +582 -48
- package/src/js/tests/dynamic-wasm.test.js +78 -0
- package/src/js/tests/fingerprint.client.init.test.js +140 -119
- package/src/js/tests/fingerprint.client.test.js +128 -104
- package/src/js/tests/fingerprint.test.js +130 -11
- package/src/js/tests/ip-reputation.test.js +141 -131
- package/src/js/tests/tcpFingerprint.test.js +78 -0
- package/src/php/Challenge/ChallengeUtils.php +480 -361
- package/src/php/Config/SecurityProfiles.php +286 -271
- package/src/php/FingerprintClient.php +131 -131
- package/src/php/FingerprintEngine.php +30 -0
- package/src/php/RequestContext.php +90 -90
- package/src/php/Store/IStore.php +41 -41
- package/src/php/Store/RedisStore.php +53 -53
- package/src/php/Tests/FingerprintEngineTest.php +27 -1
- package/src/php/Tests/IpReputationTest.php +175 -156
- package/src/php/Tests/RequestUtilsTest.php +130 -0
- package/src/php/Utils/RequestUtils.php +474 -21
package/src/js/fingerprint.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as dns from "node:dns/promises";
|
|
|
4
4
|
import {getProblemManager, problemManager} from "./problem-manager.js";
|
|
5
5
|
import {Optimization} from "./library.js";
|
|
6
6
|
import {cyrb53, FingerprintBuilder} from "./fingerprint.builder.js";
|
|
7
|
+
import {DynamicWasmGenerator} from "./dynamic-wasm.js";
|
|
7
8
|
import {readFileSync, existsSync} from "node:fs";
|
|
8
9
|
import {fileURLToPath} from "node:url";
|
|
9
10
|
import {dirname, join, resolve} from "node:path";
|
|
@@ -15,6 +16,87 @@ export { createRedisStore } from "./redis-store.js";
|
|
|
15
16
|
export { createMongoDbStore } from "./mongodb-store.js";
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
const base64UrlEncode = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
20
|
+
const base64UrlDecode = (str) => {
|
|
21
|
+
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
22
|
+
while (base64.length % 4) {
|
|
23
|
+
base64 += '=';
|
|
24
|
+
}
|
|
25
|
+
return Buffer.from(base64, 'base64');
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function generateStatelessTicket(payload) {
|
|
29
|
+
const secret = getPowSecret();
|
|
30
|
+
const key = crypto.createHash('sha256').update(secret).digest();
|
|
31
|
+
const iv = crypto.randomBytes(16);
|
|
32
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
|
33
|
+
let encrypted = cipher.update(JSON.stringify(payload));
|
|
34
|
+
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
35
|
+
|
|
36
|
+
const signature = crypto.createHmac('sha256', key).update(Buffer.concat([iv, encrypted])).digest();
|
|
37
|
+
return `${base64UrlEncode(iv)}.${base64UrlEncode(encrypted)}.${base64UrlEncode(signature)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseStatelessTicket(ticket) {
|
|
41
|
+
try {
|
|
42
|
+
const parts = ticket.split('.');
|
|
43
|
+
if (parts.length !== 3) return null;
|
|
44
|
+
|
|
45
|
+
const iv = base64UrlDecode(parts[0]);
|
|
46
|
+
const encrypted = base64UrlDecode(parts[1]);
|
|
47
|
+
const signature = base64UrlDecode(parts[2]);
|
|
48
|
+
|
|
49
|
+
if (iv.length !== 16) return null;
|
|
50
|
+
|
|
51
|
+
const secret = getPowSecret();
|
|
52
|
+
const key = crypto.createHash('sha256').update(secret).digest();
|
|
53
|
+
|
|
54
|
+
const expectedSignature = crypto.createHmac('sha256', key).update(Buffer.concat([iv, encrypted])).digest();
|
|
55
|
+
if (signature.length !== expectedSignature.length || !crypto.timingSafeEqual(signature, expectedSignature)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
|
60
|
+
let decrypted = decipher.update(encrypted);
|
|
61
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
62
|
+
|
|
63
|
+
return JSON.parse(decrypted.toString('utf8'));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Vérifie le limiteur de débit Token Bucket pour les demandes de challenge d'un sous-réseau.
|
|
71
|
+
* @param {string} clientIp - L'adresse IP du client.
|
|
72
|
+
* @returns {Promise<boolean>} True si la requête est autorisée, false si elle est limitée.
|
|
73
|
+
*/
|
|
74
|
+
async function checkChallengeRateLimit(clientIp) {
|
|
75
|
+
const subnet = getIpSubnet(clientIp);
|
|
76
|
+
if (!subnet) return false;
|
|
77
|
+
|
|
78
|
+
const key = `rate-limit:${subnet}`;
|
|
79
|
+
const rateLimitData = (await store.get(key)) || {
|
|
80
|
+
tokens: 5.0,
|
|
81
|
+
lastRefill: Date.now() / 1000
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const capacity = 5.0;
|
|
85
|
+
const refillRate = 0.1; // 1 token toutes les 10 secondes
|
|
86
|
+
const now = Date.now() / 1000;
|
|
87
|
+
|
|
88
|
+
const elapsed = now - rateLimitData.lastRefill;
|
|
89
|
+
const tokens = Math.min(capacity, rateLimitData.tokens + elapsed * refillRate);
|
|
90
|
+
|
|
91
|
+
if (tokens < 1.0) {
|
|
92
|
+
await store.set(key, { tokens, lastRefill: now }, 60);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await store.set(key, { tokens: tokens - 1.0, lastRefill: now }, 60);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
18
100
|
/**
|
|
19
101
|
* @private
|
|
20
102
|
* Deep merges two objects. The `source` object's properties overwrite the `target`'s.
|
|
@@ -54,7 +136,8 @@ const securityProfiles = {
|
|
|
54
136
|
timeInconsistencyScore: 0.9,
|
|
55
137
|
tlsSpoofingScore: 0.8, // NOUVEAU: Poids pour la détection de spoofing TLS
|
|
56
138
|
subnetScore: 0.4, // NOUVEAU: Poids pour la réputation du sous-réseau
|
|
57
|
-
ipReputationScore: 0.5 // NOUVEAU: Poids pour la réputation IP
|
|
139
|
+
ipReputationScore: 0.5, // NOUVEAU: Poids pour la réputation IP
|
|
140
|
+
botnetClusterScore: 0.6 // NOUVEAU: Poids pour le clustering botnet
|
|
58
141
|
},
|
|
59
142
|
thresholds: { low: 20, medium: 45, high: 75, block: 95 },
|
|
60
143
|
patterns: {
|
|
@@ -89,7 +172,8 @@ const securityProfiles = {
|
|
|
89
172
|
timeInconsistencyScore: 1.0,
|
|
90
173
|
tlsSpoofingScore: 1.0, // Plus agressif pour le spoofing TLS
|
|
91
174
|
subnetScore: 0.5,
|
|
92
|
-
ipReputationScore: 0.6 // NOUVEAU: Poids pour la réputation IP
|
|
175
|
+
ipReputationScore: 0.6, // NOUVEAU: Poids pour la réputation IP
|
|
176
|
+
botnetClusterScore: 0.8 // NOUVEAU: Poids pour le clustering botnet
|
|
93
177
|
},
|
|
94
178
|
thresholds: { low: 10, medium: 35, high: 65, block: 90 },
|
|
95
179
|
patterns: {
|
|
@@ -125,7 +209,8 @@ const securityProfiles = {
|
|
|
125
209
|
timeInconsistencyScore: 0.8,
|
|
126
210
|
tlsSpoofingScore: 0.7, // Important pour les API
|
|
127
211
|
subnetScore: 0.4,
|
|
128
|
-
ipReputationScore: 0.5 // NOUVEAU: Poids pour la réputation IP
|
|
212
|
+
ipReputationScore: 0.5, // NOUVEAU: Poids pour la réputation IP
|
|
213
|
+
botnetClusterScore: 0.7 // NOUVEAU: Poids pour le clustering botnet
|
|
129
214
|
},
|
|
130
215
|
thresholds: { low: 25, medium: 50, high: 80, block: 95 },
|
|
131
216
|
patterns: {
|
|
@@ -162,7 +247,8 @@ const securityProfiles = {
|
|
|
162
247
|
timeInconsistencyScore: 0.8,
|
|
163
248
|
tlsSpoofingScore: 0.6, // Moins critique pour les blogs
|
|
164
249
|
subnetScore: 0.2,
|
|
165
|
-
ipReputationScore: 0.3 // NOUVEAU: Poids pour la réputation IP
|
|
250
|
+
ipReputationScore: 0.3, // NOUVEAU: Poids pour la réputation IP
|
|
251
|
+
botnetClusterScore: 0.5 // NOUVEAU: Poids pour le clustering botnet
|
|
166
252
|
},
|
|
167
253
|
thresholds: { low: 25, medium: 55, high: 80, block: 95 },
|
|
168
254
|
patterns: {
|
|
@@ -198,7 +284,8 @@ const securityProfiles = {
|
|
|
198
284
|
timeInconsistencyScore: 0.9,
|
|
199
285
|
tlsSpoofingScore: 0.9, // Très important pour l'e-commerce
|
|
200
286
|
subnetScore: 0.5,
|
|
201
|
-
ipReputationScore: 0.6 // NOUVEAU: Poids pour la réputation IP
|
|
287
|
+
ipReputationScore: 0.6, // NOUVEAU: Poids pour la réputation IP
|
|
288
|
+
botnetClusterScore: 0.9 // NOUVEAU: Poids pour le clustering botnet
|
|
202
289
|
},
|
|
203
290
|
thresholds: { low: 15, medium: 40, high: 70, block: 90 },
|
|
204
291
|
patterns: {
|
|
@@ -319,12 +406,12 @@ function getTlsFingerprint(context) {
|
|
|
319
406
|
let ja4 = null;
|
|
320
407
|
|
|
321
408
|
// 1. Prefer JA4 hash from a trusted reverse proxy header.
|
|
322
|
-
const ja4FromHeader = context.headers['x-ja4-hash'];
|
|
409
|
+
const ja4FromHeader = context.headers ? context.headers['x-ja4-hash'] : null;
|
|
323
410
|
if (ja4FromHeader) {
|
|
324
411
|
ja4 = ja4FromHeader;
|
|
325
412
|
}
|
|
326
413
|
// 2. Prefer JA3 hash from a trusted reverse proxy header.
|
|
327
|
-
const ja3FromHeader = context.headers['x-ja3-hash']; // Assuming a proxy might provide JA3 too
|
|
414
|
+
const ja3FromHeader = context.headers ? context.headers['x-ja3-hash'] : null; // Assuming a proxy might provide JA3 too
|
|
328
415
|
if (ja3FromHeader) {
|
|
329
416
|
ja3 = ja3FromHeader;
|
|
330
417
|
}
|
|
@@ -425,7 +512,7 @@ function getCompositeDeviceHash(context) {
|
|
|
425
512
|
// notre propre fingerprint serveur pour le comparer.
|
|
426
513
|
// Un attaquant qui forge un `clientFp` mais oublie de forger les en-têtes
|
|
427
514
|
// correspondants sera détecté par l'incohérence.
|
|
428
|
-
const clientFp = context.headers['x-device-fingerprint'];
|
|
515
|
+
const clientFp = context.headers ? context.headers['x-device-fingerprint'] : null;
|
|
429
516
|
if (clientFp && typeof clientFp === 'string' && clientFp.includes('cvs:')) {
|
|
430
517
|
// On ajoute le hash du fingerprint client comme un composant du fingerprint serveur.
|
|
431
518
|
// Si le clientFp change, le hash serveur changera aussi.
|
|
@@ -433,7 +520,7 @@ function getCompositeDeviceHash(context) {
|
|
|
433
520
|
}
|
|
434
521
|
|
|
435
522
|
// 1. SIGNAL FORT: User Agent (poids élevé)
|
|
436
|
-
const ua = context.headers["user-agent"];
|
|
523
|
+
const ua = context.headers ? context.headers["user-agent"] : null;
|
|
437
524
|
if (ua) {
|
|
438
525
|
srv.add("ua", ua); // User-Agent
|
|
439
526
|
}
|
|
@@ -443,10 +530,10 @@ function getCompositeDeviceHash(context) {
|
|
|
443
530
|
if (ja3) srv.add("ja3", ja3);
|
|
444
531
|
if (ja4) srv.add("ja4", ja4);
|
|
445
532
|
|
|
446
|
-
const h2Fingerprint = context.headers['x-http2-fingerprint'];
|
|
533
|
+
const h2Fingerprint = context.headers ? context.headers['x-http2-fingerprint'] : null;
|
|
447
534
|
if (h2Fingerprint) srv.add("h2", h2Fingerprint);
|
|
448
535
|
|
|
449
|
-
const tcpFingerprint = context.headers['x-tcp-fingerprint'];
|
|
536
|
+
const tcpFingerprint = context.headers ? context.headers['x-tcp-fingerprint'] : null;
|
|
450
537
|
if (tcpFingerprint) srv.add("tcp", tcpFingerprint);
|
|
451
538
|
|
|
452
539
|
// 3. SIGNAUX DE HAUT NIVEAU (Applicatif) Moins fiables, mais utiles pour la corroboration
|
|
@@ -457,6 +544,7 @@ function getCompositeDeviceHash(context) {
|
|
|
457
544
|
"ch_model": "sec-ch-ua-model",
|
|
458
545
|
"ch_arch": "sec-ch-ua-arch",
|
|
459
546
|
"ch_bitness": "sec-ch-ua-bitness",
|
|
547
|
+
"ch_full_version_list": "sec-ch-ua-full-version-list",
|
|
460
548
|
"upgrade_req": "upgrade-insecure-requests",
|
|
461
549
|
"accept_lang": "accept-language",
|
|
462
550
|
"accept_enc": "accept-encoding",
|
|
@@ -464,7 +552,7 @@ function getCompositeDeviceHash(context) {
|
|
|
464
552
|
};
|
|
465
553
|
|
|
466
554
|
for (const [key, headerName] of Object.entries(headersToCapture)) {
|
|
467
|
-
const headerValue = context.headers[headerName];
|
|
555
|
+
const headerValue = context.headers ? context.headers[headerName] : null;
|
|
468
556
|
if (headerValue) {
|
|
469
557
|
srv.add(key, headerValue);
|
|
470
558
|
}
|
|
@@ -799,18 +887,16 @@ export const verifyPoWAndGenerateTicket = async (
|
|
|
799
887
|
return null;
|
|
800
888
|
}
|
|
801
889
|
|
|
802
|
-
// 2. Generate
|
|
803
|
-
const ticketId = crypto.randomUUID();
|
|
890
|
+
// 2. Generate a signed and encrypted stateless ticket
|
|
804
891
|
const expiry = Date.now() + 3600000; // 1 hour
|
|
805
|
-
|
|
806
|
-
await store.set(`ticket:${ticketId}`, {
|
|
892
|
+
const payload = {
|
|
807
893
|
expiry,
|
|
808
894
|
originalIp: ip,
|
|
809
895
|
deviceId,
|
|
810
896
|
deviceHash
|
|
811
|
-
}
|
|
897
|
+
};
|
|
812
898
|
|
|
813
|
-
return
|
|
899
|
+
return generateStatelessTicket(payload);
|
|
814
900
|
};
|
|
815
901
|
|
|
816
902
|
|
|
@@ -866,7 +952,26 @@ export const isTicketValid = async (ip, ticket, deviceId = '', deviceHash = '',
|
|
|
866
952
|
// Input validation: ensure the ticket is a non-empty string with the correct format.
|
|
867
953
|
if (typeof ticket !== 'string' || ticket.length === 0) return false;
|
|
868
954
|
|
|
869
|
-
// 1. Resolve
|
|
955
|
+
// 1. Resolve stateless ticket first (zero database I/O cost)
|
|
956
|
+
const statelessData = parseStatelessTicket(ticket);
|
|
957
|
+
if (statelessData) {
|
|
958
|
+
const { expiry, originalIp, deviceId: storedDeviceId, deviceHash: storedDeviceHash } = statelessData;
|
|
959
|
+
|
|
960
|
+
if (!expiry || Date.now() > expiry) {
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (ip === originalIp) return true;
|
|
965
|
+
const currentSubnet = getIpSubnet(ip);
|
|
966
|
+
const originalSubnet = getIpSubnet(originalIp);
|
|
967
|
+
if (currentSubnet && originalSubnet && currentSubnet === originalSubnet) return true;
|
|
968
|
+
|
|
969
|
+
if (!allowCrossNetworkRoaming) return false;
|
|
970
|
+
|
|
971
|
+
return !!(deviceId && deviceId === storedDeviceId && deviceHash && deviceHash === storedDeviceHash);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// 2. Resolve opaque ticket session from server-side store
|
|
870
975
|
const ticketData = await store.get(`ticket:${ticket}`);
|
|
871
976
|
if (ticketData) {
|
|
872
977
|
const { expiry, originalIp, deviceId: storedDeviceId, deviceHash: storedDeviceHash } = ticketData;
|
|
@@ -886,7 +991,7 @@ export const isTicketValid = async (ip, ticket, deviceId = '', deviceHash = '',
|
|
|
886
991
|
return !!(deviceId && deviceId === storedDeviceId && deviceHash && deviceHash === storedDeviceHash);
|
|
887
992
|
}
|
|
888
993
|
|
|
889
|
-
//
|
|
994
|
+
// 3. Legacy fallback verification (backward compatibility for old client tokens)
|
|
890
995
|
let expiry, originalIp, sig;
|
|
891
996
|
if (ticket.includes('|')) {
|
|
892
997
|
const parts = ticket.split('|');
|
|
@@ -1149,6 +1254,79 @@ function analyzeMouseMovements(history) {
|
|
|
1149
1254
|
return { avgSpeed, avgAcceleration, straightness, pauses, segments: segments.map(s => s.distance) };
|
|
1150
1255
|
}
|
|
1151
1256
|
|
|
1257
|
+
/**
|
|
1258
|
+
* @private
|
|
1259
|
+
* Analyse une série d'événements tactiles mobiles pour en extraire des indicateurs comportementaux robustes.
|
|
1260
|
+
* @param {Array<{x: number, y: number, t: number, p: number, r: number, num: number}>} history
|
|
1261
|
+
* @returns {{avgSpeed: number, avgAcceleration: number, straightness: number, pauses: number, segments: Array<number>, avgPressure: number, avgRadius: number, pressureVariance: number, radiusVariance: number, maxTouches: number}}
|
|
1262
|
+
*/
|
|
1263
|
+
function analyzeTouchMovements(history) {
|
|
1264
|
+
if (!history || history.length < 3) {
|
|
1265
|
+
return { avgSpeed: 0, avgAcceleration: 0, straightness: 1, pauses: 0, segments: [], avgPressure: 0, avgRadius: 0, pressureVariance: 0, radiusVariance: 0, maxTouches: 1 };
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const segments = [];
|
|
1269
|
+
let totalDistance = 0;
|
|
1270
|
+
let pauses = 0;
|
|
1271
|
+
let totalPressure = 0;
|
|
1272
|
+
let totalRadius = 0;
|
|
1273
|
+
let maxTouches = 1;
|
|
1274
|
+
|
|
1275
|
+
for (let i = 1; i < history.length; i++) {
|
|
1276
|
+
const p1 = history[i - 1];
|
|
1277
|
+
const p2 = history[i];
|
|
1278
|
+
const dx = p2.x - p1.x;
|
|
1279
|
+
const dy = p2.y - p1.y;
|
|
1280
|
+
const dt = p2.t - p1.t;
|
|
1281
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
1282
|
+
|
|
1283
|
+
totalPressure += p2.p || 0;
|
|
1284
|
+
totalRadius += p2.r || 0;
|
|
1285
|
+
if (p2.num > maxTouches) {
|
|
1286
|
+
maxTouches = p2.num;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
if (dt > 0) {
|
|
1290
|
+
const speed = distance / dt;
|
|
1291
|
+
segments.push({ distance, dt, speed });
|
|
1292
|
+
totalDistance += distance;
|
|
1293
|
+
}
|
|
1294
|
+
if (dt > 100 && distance < 5) {
|
|
1295
|
+
pauses++;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
totalPressure += history[0].p || 0;
|
|
1300
|
+
totalRadius += history[0].r || 0;
|
|
1301
|
+
|
|
1302
|
+
const avgPressure = totalPressure / history.length;
|
|
1303
|
+
const avgRadius = totalRadius / history.length;
|
|
1304
|
+
|
|
1305
|
+
let sqDiffPressureSum = 0;
|
|
1306
|
+
let sqDiffRadiusSum = 0;
|
|
1307
|
+
for (const pt of history) {
|
|
1308
|
+
sqDiffPressureSum += Math.pow((pt.p || 0) - avgPressure, 2);
|
|
1309
|
+
sqDiffRadiusSum += Math.pow((pt.r || 0) - avgRadius, 2);
|
|
1310
|
+
}
|
|
1311
|
+
const pressureVariance = sqDiffPressureSum / history.length;
|
|
1312
|
+
const radiusVariance = sqDiffRadiusSum / history.length;
|
|
1313
|
+
|
|
1314
|
+
if (segments.length < 2) {
|
|
1315
|
+
return { avgSpeed: 0, avgAcceleration: 0, straightness: 1, pauses, segments: [], avgPressure, avgRadius, pressureVariance, radiusVariance, maxTouches };
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const totalTime = history[history.length - 1].t - history[0].t;
|
|
1319
|
+
const avgSpeed = totalTime > 0 ? segments.reduce((sum, s) => sum + s.speed, 0) / segments.length : 0;
|
|
1320
|
+
const avgAcceleration = segments.reduce((sum, s) => sum + (s.speed / s.dt), 0) / segments.length;
|
|
1321
|
+
|
|
1322
|
+
const startPoint = history[0];
|
|
1323
|
+
const endPoint = history[history.length - 1];
|
|
1324
|
+
const straightDistance = Math.sqrt(Math.pow(endPoint.x - startPoint.x, 2) + Math.pow(endPoint.y - startPoint.y, 2));
|
|
1325
|
+
const straightness = totalDistance > 0 ? straightDistance / totalDistance : 1;
|
|
1326
|
+
|
|
1327
|
+
return { avgSpeed, avgAcceleration, straightness, pauses, segments: segments.map(s => s.distance), avgPressure, avgRadius, pressureVariance, radiusVariance, maxTouches };
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1152
1330
|
/**
|
|
1153
1331
|
* Calcule un score basé sur les métriques comportementales envoyées par le client.
|
|
1154
1332
|
* @param {object} context - Le contexte de la requête, contenant les en-têtes.
|
|
@@ -1171,9 +1349,10 @@ function getBehaviorScore(context) {
|
|
|
1171
1349
|
|
|
1172
1350
|
// 2. Analyse des mouvements de la souris
|
|
1173
1351
|
const { avgSpeed, avgAcceleration, straightness, pauses, segments } = analyzeMouseMovements(metrics.mouseMovementsHistory);
|
|
1352
|
+
const touchAnalysis = analyzeTouchMovements(metrics.touchMovementsHistory);
|
|
1174
1353
|
|
|
1175
1354
|
// Pénalité pour absence totale d'interaction (pas de mouvements, pas de frappes).
|
|
1176
|
-
if (avgSpeed === 0 && metrics.keystrokeLatency === 0) {
|
|
1355
|
+
if (avgSpeed === 0 && touchAnalysis.avgSpeed === 0 && metrics.keystrokeLatency === 0) {
|
|
1177
1356
|
score += 40;
|
|
1178
1357
|
}
|
|
1179
1358
|
|
|
@@ -1197,6 +1376,30 @@ function getBehaviorScore(context) {
|
|
|
1197
1376
|
if (pauses === 0 && segments.length > 20) score += 15; // Mouvement continu sans micro-pauses
|
|
1198
1377
|
}
|
|
1199
1378
|
|
|
1379
|
+
// 4. Analyse comportementale des événements tactiles (Touch Move)
|
|
1380
|
+
const touchHistory = metrics.touchMovementsHistory;
|
|
1381
|
+
if (touchHistory && touchHistory.length > 0) {
|
|
1382
|
+
const touch = analyzeTouchMovements(touchHistory);
|
|
1383
|
+
if (touch.avgSpeed > 0) {
|
|
1384
|
+
if (touch.avgSpeed > 5) score += 30; // Touch d'une vitesse anormale/robotique
|
|
1385
|
+
if (touch.avgAcceleration > 0.8) score += 20;
|
|
1386
|
+
if (touch.straightness > 0.98) score += 35; // Un tracé de doigt humain n'est jamais parfaitement rectiligne
|
|
1387
|
+
if (touch.pauses === 0 && touch.segments.length > 25) score += 15;
|
|
1388
|
+
|
|
1389
|
+
// Détection de l'émulation (pression et rayon de contact constants)
|
|
1390
|
+
if (touch.avgPressure > 0 && touch.pressureVariance === 0) {
|
|
1391
|
+
score += 30; // Spoofed force/pressure
|
|
1392
|
+
}
|
|
1393
|
+
if (touch.avgRadius > 0 && touch.radiusVariance === 0) {
|
|
1394
|
+
score += 30; // Spoofed pointer area size
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
if (touch.segments.length > 10) {
|
|
1398
|
+
const benfordDev = Optimization.Operators.benfordTest(touch.segments);
|
|
1399
|
+
if (benfordDev > 0.18) score += 35;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1200
1403
|
// Plausibilité de la latence de frappe
|
|
1201
1404
|
if (metrics.keystrokeLatency > 0 && metrics.keystrokeLatency < 40) score += 25; // Frappe trop rapide pour un humain.
|
|
1202
1405
|
if (metrics.keystrokeLatency > 1000) score += 15; // Latence très élevée, peut être un script lent.
|
|
@@ -1513,6 +1716,48 @@ function getClientHintsInconsistencyScore(context) {
|
|
|
1513
1716
|
return { clientHintsInconsistencyScore: 0 };
|
|
1514
1717
|
}
|
|
1515
1718
|
|
|
1719
|
+
const fullVersionList = context.headers['sec-ch-ua-full-version-list'];
|
|
1720
|
+
if (fullVersionList) {
|
|
1721
|
+
let chFullVersion = null;
|
|
1722
|
+
let chFullBrowser = null;
|
|
1723
|
+
const matches = [...fullVersionList.matchAll(/"([^"]+)";v="([^"]+)"/g)];
|
|
1724
|
+
for (const match of matches) {
|
|
1725
|
+
const brand = match[1];
|
|
1726
|
+
const version = match[2];
|
|
1727
|
+
if (brand === 'Google Chrome' || brand === 'Chromium' || brand === 'Microsoft Edge') {
|
|
1728
|
+
chFullVersion = version;
|
|
1729
|
+
chFullBrowser = brand === 'Microsoft Edge' ? 'Edge' : 'Chrome';
|
|
1730
|
+
if (brand === 'Google Chrome' || brand === 'Microsoft Edge') {
|
|
1731
|
+
break;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
if (chFullVersion && chFullBrowser) {
|
|
1736
|
+
let uaFullVersion = null;
|
|
1737
|
+
const uaFullMatch = ua.match(/(Chrome|Edg)\/([\d\.]+)/);
|
|
1738
|
+
if (uaFullMatch) {
|
|
1739
|
+
const uaBrowserMapped = uaFullMatch[1] === 'Edg' ? 'Edge' : 'Chrome';
|
|
1740
|
+
uaFullVersion = uaFullMatch[2];
|
|
1741
|
+
if (uaBrowserMapped === chFullBrowser && uaFullVersion !== chFullVersion) {
|
|
1742
|
+
const parts1 = uaFullVersion.split('.').map(Number);
|
|
1743
|
+
const parts2 = chFullVersion.split('.').map(Number);
|
|
1744
|
+
let diffIndex = -1;
|
|
1745
|
+
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
|
1746
|
+
if ((parts1[i] || 0) !== (parts2[i] || 0)) {
|
|
1747
|
+
diffIndex = i;
|
|
1748
|
+
break;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
const baseScores = [95, 90, 85, 80];
|
|
1752
|
+
const baseScore = baseScores[diffIndex] || 80;
|
|
1753
|
+
const delta = Math.abs((parts1[diffIndex] || 0) - (parts2[diffIndex] || 0));
|
|
1754
|
+
const finalFullScore = Math.min(100, baseScore + Math.min(5, delta * 5));
|
|
1755
|
+
return { clientHintsInconsistencyScore: finalFullScore };
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1516
1761
|
// 1. Extract browser and version from User-Agent
|
|
1517
1762
|
let uaVersion = null;
|
|
1518
1763
|
let uaBrowser = null;
|
|
@@ -1547,10 +1792,19 @@ function getClientHintsInconsistencyScore(context) {
|
|
|
1547
1792
|
|
|
1548
1793
|
const versionDifference = Math.abs(parseInt(uaVersion, 10) - parseInt(chVersion, 10));
|
|
1549
1794
|
|
|
1550
|
-
|
|
1551
|
-
|
|
1795
|
+
let clientHintsInconsistencyScore = 0;
|
|
1796
|
+
if (versionDifference > 0) {
|
|
1797
|
+
if (versionDifference <= 2) {
|
|
1798
|
+
clientHintsInconsistencyScore = versionDifference * 20;
|
|
1799
|
+
} else if (versionDifference <= 7) {
|
|
1800
|
+
clientHintsInconsistencyScore = 40 + (versionDifference - 2) * 8;
|
|
1801
|
+
} else {
|
|
1802
|
+
clientHintsInconsistencyScore = Math.min(100, 80 + (versionDifference - 7) * 3.33);
|
|
1803
|
+
}
|
|
1804
|
+
clientHintsInconsistencyScore = Math.round(clientHintsInconsistencyScore);
|
|
1805
|
+
}
|
|
1552
1806
|
|
|
1553
|
-
|
|
1807
|
+
return { clientHintsInconsistencyScore };
|
|
1554
1808
|
}
|
|
1555
1809
|
|
|
1556
1810
|
/**
|
|
@@ -1738,14 +1992,18 @@ async function updateSubnetMetrics(context, deviceId, finalScore) {
|
|
|
1738
1992
|
subnetData.highScoreDevices = {};
|
|
1739
1993
|
}
|
|
1740
1994
|
|
|
1741
|
-
|
|
1995
|
+
// Utilisation d'un identifiant d'appareil stable (fingerprint matériel) plutôt que l'ID de cookie volatil
|
|
1996
|
+
const currentDeviceHash = getCompositeDeviceHash(context);
|
|
1997
|
+
const stableFpId = cyrb53(extractStablePart(currentDeviceHash)).toString();
|
|
1998
|
+
|
|
1999
|
+
const currentDeviceContributions = subnetData.highScoreDevices[stableFpId] || 0;
|
|
1742
2000
|
if (currentDeviceContributions < 5) {
|
|
1743
|
-
subnetData.highScoreDevices[
|
|
2001
|
+
subnetData.highScoreDevices[stableFpId] = currentDeviceContributions + 1;
|
|
1744
2002
|
subnetData.highScoreCount++;
|
|
1745
2003
|
}
|
|
1746
2004
|
|
|
1747
|
-
if (!subnetData.deviceIds.includes(
|
|
1748
|
-
subnetData.deviceIds.push(
|
|
2005
|
+
if (!subnetData.deviceIds.includes(stableFpId)) {
|
|
2006
|
+
subnetData.deviceIds.push(stableFpId);
|
|
1749
2007
|
}
|
|
1750
2008
|
subnetData.lastActivity = Date.now();
|
|
1751
2009
|
|
|
@@ -1792,6 +2050,40 @@ async function getSubnetScore(context) {
|
|
|
1792
2050
|
return { subnetScore: Math.min(100, deviceCountPenalty + highScorePenalty) };
|
|
1793
2051
|
}
|
|
1794
2052
|
|
|
2053
|
+
/**
|
|
2054
|
+
* Calcule le score d'anomalie de similarité réseau (Botnet Clustering).
|
|
2055
|
+
* @param {object} context - Le contexte de la requête.
|
|
2056
|
+
* @param {string} stableFpHash - Le hash de la partie stable de l'empreinte.
|
|
2057
|
+
* @returns {Promise<{botnetClusterScore: number}>}
|
|
2058
|
+
*/
|
|
2059
|
+
async function getBotnetClusterScore(context, stableFpHash) {
|
|
2060
|
+
if (!stableFpHash) return { botnetClusterScore: 0 };
|
|
2061
|
+
const key = `botnet-cluster:${stableFpHash}`;
|
|
2062
|
+
const now = Date.now();
|
|
2063
|
+
const tenMinutesAgo = now - 600 * 1000;
|
|
2064
|
+
|
|
2065
|
+
let clusterData = (await store.get(key)) || [];
|
|
2066
|
+
if (!Array.isArray(clusterData)) {
|
|
2067
|
+
clusterData = [];
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
clusterData = clusterData.filter(entry => entry.timestamp > tenMinutesAgo);
|
|
2071
|
+
const existingIndex = clusterData.findIndex(entry => entry.ip === context.clientIp);
|
|
2072
|
+
if (existingIndex !== -1) {
|
|
2073
|
+
clusterData[existingIndex].timestamp = now;
|
|
2074
|
+
} else {
|
|
2075
|
+
clusterData.push({ ip: context.clientIp, timestamp: now });
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
await store.set(key, clusterData, 600);
|
|
2079
|
+
const uniqueIpsCount = clusterData.length;
|
|
2080
|
+
let botnetClusterScore = 0;
|
|
2081
|
+
if (uniqueIpsCount >= 2) {
|
|
2082
|
+
botnetClusterScore = Math.min(100, Math.round(1000 * (1 - Math.exp(-0.35 * (uniqueIpsCount - 1)))) / 10);
|
|
2083
|
+
}
|
|
2084
|
+
return { botnetClusterScore };
|
|
2085
|
+
}
|
|
2086
|
+
|
|
1795
2087
|
/**
|
|
1796
2088
|
* Retrieves the current local IP reputation score, applying time-based decay.
|
|
1797
2089
|
* @param {string} ip - The client's IP address.
|
|
@@ -1858,7 +2150,10 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
1858
2150
|
benfordThreshold = 0.15, // Seuil de déviation de Benford au-dessus duquel la distribution est "non naturelle".
|
|
1859
2151
|
patternWeight = 80, // Pénalité FORTE et unique si un pattern est détecté.
|
|
1860
2152
|
decayFactor = 0.95, // Décroissance du score dans le temps.
|
|
1861
|
-
inactivityReset = 180000
|
|
2153
|
+
inactivityReset = 180000, // Réinitialisation du score après 3 minutes d'inactivité.
|
|
2154
|
+
regularityRatio = 0.4, // (NOUVEAU) Poids relatif de l'écart-type
|
|
2155
|
+
benfordRatio = 0.3, // (NOUVEAU) Poids relatif de Benford
|
|
2156
|
+
enumerationRatio = 0.3 // (NOUVEAU) Poids relatif de l'énumération de chemins
|
|
1862
2157
|
} = patternConfig;
|
|
1863
2158
|
|
|
1864
2159
|
const now = Date.now();
|
|
@@ -1888,24 +2183,24 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
1888
2183
|
deviceData.timingHistory.push(timeSinceLast);
|
|
1889
2184
|
}
|
|
1890
2185
|
|
|
1891
|
-
let
|
|
2186
|
+
let regularityScore = 0;
|
|
2187
|
+
let benfordScore = 0;
|
|
1892
2188
|
const timings = deviceData.timingHistory;
|
|
1893
2189
|
|
|
1894
2190
|
// Analyse statistique unifiée si nous avons assez de données
|
|
1895
2191
|
if (timings.length >= minSamples) {
|
|
1896
|
-
const timings = deviceData.timingHistory;
|
|
1897
2192
|
const mean = timings.reduce((a, b) => a + b, 0) / timings.length;
|
|
1898
2193
|
const variance = timings.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / timings.length;
|
|
1899
2194
|
const stdDev = Math.sqrt(variance);
|
|
1900
2195
|
const benfordDeviation = Optimization.Operators.benfordTest(timings);
|
|
1901
2196
|
|
|
1902
|
-
//
|
|
2197
|
+
// Calcul progressif de la régularité (stdDev proche de 0 = score max)
|
|
1903
2198
|
if (stdDev < regularityThreshold) {
|
|
1904
|
-
|
|
2199
|
+
regularityScore = 1 - (stdDev / regularityThreshold);
|
|
1905
2200
|
}
|
|
1906
|
-
//
|
|
1907
|
-
|
|
1908
|
-
|
|
2201
|
+
// Calcul progressif de Benford (excès par rapport au seuil)
|
|
2202
|
+
if (benfordDeviation > benfordThreshold) {
|
|
2203
|
+
benfordScore = Math.min(1, (benfordDeviation - benfordThreshold) / (0.5 - benfordThreshold));
|
|
1909
2204
|
}
|
|
1910
2205
|
}
|
|
1911
2206
|
|
|
@@ -1919,12 +2214,18 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
1919
2214
|
templates.forEach(t => templateCounts[t] = (templateCounts[t] || 0) + 1);
|
|
1920
2215
|
|
|
1921
2216
|
const maxTemplateRepetition = Math.max(...Object.values(templateCounts), 0);
|
|
1922
|
-
// Si une même structure de route est répétée mais sur des URLs réelles différentes
|
|
1923
2217
|
if (maxTemplateRepetition >= 3 && uniquePaths.size === history.length) {
|
|
1924
|
-
enumerationScore =
|
|
2218
|
+
enumerationScore = Math.min(1, (maxTemplateRepetition - 2) / 5);
|
|
1925
2219
|
}
|
|
1926
2220
|
}
|
|
1927
2221
|
|
|
2222
|
+
// Score instantané combiné linéaire pondéré
|
|
2223
|
+
const weightedScore = (regularityScore * regularityRatio) +
|
|
2224
|
+
(benfordScore * benfordRatio) +
|
|
2225
|
+
(enumerationScore * enumerationRatio);
|
|
2226
|
+
|
|
2227
|
+
const instantScore = weightedScore * patternWeight;
|
|
2228
|
+
|
|
1928
2229
|
// Garder l'historique à une taille raisonnable
|
|
1929
2230
|
if (history.length > historySize) {
|
|
1930
2231
|
history.shift();
|
|
@@ -1943,7 +2244,7 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
1943
2244
|
}
|
|
1944
2245
|
newPatternScore = Math.max(0, newPatternScore);
|
|
1945
2246
|
|
|
1946
|
-
deviceData.lastPatternScore =
|
|
2247
|
+
deviceData.lastPatternScore = Math.max(instantScore, newPatternScore);
|
|
1947
2248
|
|
|
1948
2249
|
return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
|
|
1949
2250
|
}
|
|
@@ -2175,6 +2476,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
2175
2476
|
const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context, securityConfig);
|
|
2176
2477
|
|
|
2177
2478
|
const clientIp = context.clientIp;
|
|
2479
|
+
const currentDeviceHash = getCompositeDeviceHash(context);
|
|
2178
2480
|
|
|
2179
2481
|
// If a new cookie needs to be set, attach it to the request object
|
|
2180
2482
|
// so the middleware can handle it. This is a temporary state holder.
|
|
@@ -2230,6 +2532,12 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
2230
2532
|
|
|
2231
2533
|
const ipReputationScore = await getIpReputationScore(clientIp);
|
|
2232
2534
|
|
|
2535
|
+
const stableFp = extractStablePart(currentDeviceHash);
|
|
2536
|
+
const stableFpHash = cyrb53(stableFp).toString();
|
|
2537
|
+
const { botnetClusterScore } = await getBotnetClusterScore(context, stableFpHash);
|
|
2538
|
+
|
|
2539
|
+
const { tcpAnomalyScore } = getTcpAnomalyScore(context);
|
|
2540
|
+
|
|
2233
2541
|
// Save the updated device state to the store
|
|
2234
2542
|
// Note: deviceData.ips is a Set, which may not serialize correctly in all stores (e.g., JSON). A Redis store should handle this via custom serialization or by converting to an array.
|
|
2235
2543
|
await store.set(`device:${deviceId}`, deviceData);
|
|
@@ -2240,7 +2548,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
2240
2548
|
deviceData.ips = new Set(deviceData.ips);
|
|
2241
2549
|
}
|
|
2242
2550
|
// Le vecteur de suspicion est maintenant complet.
|
|
2243
|
-
return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, botScore, requestPatternScore, crossLayerInconsistencyScore, timeInconsistencyScore, tlsSpoofingScore, clickVarianceScore, clientHintsInconsistencyScore, subnetScore, ipReputationScore };
|
|
2551
|
+
return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, botScore, requestPatternScore, crossLayerInconsistencyScore, timeInconsistencyScore, tlsSpoofingScore, clickVarianceScore, clientHintsInconsistencyScore, subnetScore, ipReputationScore, botnetClusterScore, tcpAnomalyScore };
|
|
2244
2552
|
};
|
|
2245
2553
|
|
|
2246
2554
|
// A residential user can change networks (home, 4G, public wifi).
|
|
@@ -2536,19 +2844,15 @@ export async function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
2536
2844
|
if (isValid) {
|
|
2537
2845
|
console.log('[FP Server Verify] CPU PoW verification PASSED. Details:', {
|
|
2538
2846
|
});
|
|
2539
|
-
// Generate an opaque ticket ID and store session metadata securely on the server
|
|
2540
|
-
const ticketId = crypto.randomUUID();
|
|
2541
2847
|
const ttl = ticketTtl || 3600000; // Calculates expiration from TTL
|
|
2542
2848
|
const expiry = Date.now() + ttl;
|
|
2543
2849
|
|
|
2544
|
-
|
|
2850
|
+
return generateStatelessTicket({
|
|
2545
2851
|
expiry,
|
|
2546
2852
|
originalIp: clientIp,
|
|
2547
2853
|
deviceId,
|
|
2548
2854
|
deviceHash
|
|
2549
2855
|
}, Math.ceil(ttl / 1000));
|
|
2550
|
-
|
|
2551
|
-
return ticketId;
|
|
2552
2856
|
}
|
|
2553
2857
|
|
|
2554
2858
|
return null;
|
|
@@ -2644,12 +2948,14 @@ export class FingerprintEngine {
|
|
|
2644
2948
|
(suspicionVector.behaviorScore || 0) * (weights.behaviorScore || 0) +
|
|
2645
2949
|
(suspicionVector.botScore || 0) * (weights.botScore || 0) + // Ajout du nouveau score
|
|
2646
2950
|
(suspicionVector.crossLayerInconsistencyScore || 0) * (weights.crossLayerInconsistencyScore || 0) +
|
|
2951
|
+
(suspicionVector.botnetClusterScore || 0) * (weights.botnetClusterScore || 0) +
|
|
2647
2952
|
(suspicionVector.tlsSpoofingScore || 0) * (weights.tlsSpoofingScore || 0) + // NOUVEAU: TLS Spoofing
|
|
2648
2953
|
(suspicionVector.timeInconsistencyScore || 0) * (weights.timeInconsistencyScore || 0) +
|
|
2649
2954
|
(suspicionVector.clickVarianceScore || 0) * (weights.clickVarianceScore || 0) +
|
|
2650
2955
|
(suspicionVector.clientHintsInconsistencyScore || 0) * (weights.clientHintsInconsistencyScore || 0) +
|
|
2651
2956
|
(suspicionVector.subnetScore || 0) * (weights.subnetScore || 0) +
|
|
2652
|
-
(suspicionVector.ipReputationScore || 0) * (weights.ipReputationScore || 0)
|
|
2957
|
+
(suspicionVector.ipReputationScore || 0) * (weights.ipReputationScore || 0) +
|
|
2958
|
+
(suspicionVector.tcpAnomalyScore || 0) * (weights.tcpAnomalyScore || 0);
|
|
2653
2959
|
|
|
2654
2960
|
return Math.min(100, score);
|
|
2655
2961
|
}
|
|
@@ -3374,6 +3680,28 @@ export class FingerprintEngine {
|
|
|
3374
3680
|
if (mustReChallenge) {
|
|
3375
3681
|
this._log('High suspicion score detected - overriding valid ticket to re-issue challenge', { finalScore, deviceId });
|
|
3376
3682
|
}
|
|
3683
|
+
|
|
3684
|
+
// --- AJOUT : Limiteur de débit (Token Bucket) ---
|
|
3685
|
+
const rateLimitPassed = await checkChallengeRateLimit(clientIp);
|
|
3686
|
+
if (!rateLimitPassed) {
|
|
3687
|
+
this._log('Challenge rate limit exceeded - blocking with 429', { clientIp });
|
|
3688
|
+
const decision = {
|
|
3689
|
+
action: 'block',
|
|
3690
|
+
status: 429,
|
|
3691
|
+
body: 'Too Many Requests',
|
|
3692
|
+
score: finalScore,
|
|
3693
|
+
vector: suspicionVector
|
|
3694
|
+
};
|
|
3695
|
+
if (this.dryRun) {
|
|
3696
|
+
this._log(`[Dry Run] Intended action: ${decision.action}`, { score: decision.score });
|
|
3697
|
+
decision.intendedAction = decision.action;
|
|
3698
|
+
decision.action = 'next';
|
|
3699
|
+
delete decision.status;
|
|
3700
|
+
delete decision.body;
|
|
3701
|
+
}
|
|
3702
|
+
return decision;
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3377
3705
|
this._log('Suspicious request without valid ticket - issuing challenge', { finalScore, hasPowCookie: !!powCookie });
|
|
3378
3706
|
|
|
3379
3707
|
// --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
|
|
@@ -3773,6 +4101,185 @@ function determineOptimalTicketTtl(suspicionScore) {
|
|
|
3773
4101
|
return ttl;
|
|
3774
4102
|
}
|
|
3775
4103
|
|
|
4104
|
+
|
|
4105
|
+
/**
|
|
4106
|
+
* Parse une trame TCP SYN brute (IPv4 ou IPv6).
|
|
4107
|
+
* @private
|
|
4108
|
+
* @param {Buffer|Uint8Array} binary - Le paquet binaire.
|
|
4109
|
+
* @returns {object|null}
|
|
4110
|
+
*/
|
|
4111
|
+
function parseTcpSyn(binary) {
|
|
4112
|
+
if (!binary || binary.length < 40) return null;
|
|
4113
|
+
let ttl = 64;
|
|
4114
|
+
let tcpOffset = 20;
|
|
4115
|
+
const version = binary[0] >> 4;
|
|
4116
|
+
|
|
4117
|
+
if (version === 4) {
|
|
4118
|
+
ttl = binary[8];
|
|
4119
|
+
const ihl = binary[0] & 0x0f;
|
|
4120
|
+
tcpOffset = ihl * 4;
|
|
4121
|
+
} else if (version === 6) {
|
|
4122
|
+
ttl = binary[7]; // Hop Limit
|
|
4123
|
+
tcpOffset = 40;
|
|
4124
|
+
} else {
|
|
4125
|
+
tcpOffset = 0;
|
|
4126
|
+
ttl = 64;
|
|
4127
|
+
}
|
|
4128
|
+
|
|
4129
|
+
if (binary.length < tcpOffset + 20) return null;
|
|
4130
|
+
|
|
4131
|
+
const windowSize = (binary[tcpOffset + 14] << 8) | binary[tcpOffset + 15];
|
|
4132
|
+
const dataOffset = (binary[tcpOffset + 12] >> 4) * 4;
|
|
4133
|
+
const optionsEnd = tcpOffset + dataOffset;
|
|
4134
|
+
|
|
4135
|
+
let mss = null;
|
|
4136
|
+
let ws = null;
|
|
4137
|
+
let sack = false;
|
|
4138
|
+
|
|
4139
|
+
let i = tcpOffset + 20;
|
|
4140
|
+
while (i < optionsEnd && i < binary.length) {
|
|
4141
|
+
const optType = binary[i];
|
|
4142
|
+
if (optType === 0) break;
|
|
4143
|
+
if (optType === 1) {
|
|
4144
|
+
i++;
|
|
4145
|
+
continue;
|
|
4146
|
+
}
|
|
4147
|
+
if (i + 1 >= binary.length) break;
|
|
4148
|
+
const optLen = binary[i + 1];
|
|
4149
|
+
if (optLen < 2 || i + optLen > binary.length) break;
|
|
4150
|
+
|
|
4151
|
+
if (optType === 2 && optLen === 4) {
|
|
4152
|
+
mss = (binary[i + 2] << 8) | binary[i + 3];
|
|
4153
|
+
} else if (optType === 3 && optLen === 3) {
|
|
4154
|
+
ws = binary[i + 2];
|
|
4155
|
+
} else if (optType === 4 && optLen === 2) {
|
|
4156
|
+
sack = true;
|
|
4157
|
+
}
|
|
4158
|
+
i += optLen;
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4161
|
+
return { ttl, windowSize, mss, ws, sack };
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
/**
|
|
4165
|
+
* Classifie l'OS à partir du fingerprint de la pile TCP/IP.
|
|
4166
|
+
* @private
|
|
4167
|
+
* @param {object|null} fingerprint
|
|
4168
|
+
* @returns {string}
|
|
4169
|
+
*/
|
|
4170
|
+
function classifyTcpOs(fingerprint) {
|
|
4171
|
+
if (!fingerprint) return 'unknown';
|
|
4172
|
+
const ttl = fingerprint.ttl ?? 64;
|
|
4173
|
+
const windowSize = fingerprint.windowSize ?? 0;
|
|
4174
|
+
const ws = fingerprint.ws ?? null;
|
|
4175
|
+
|
|
4176
|
+
if (ttl > 64 && ttl <= 128) {
|
|
4177
|
+
return 'Windows';
|
|
4178
|
+
}
|
|
4179
|
+
if (ttl > 32 && ttl <= 64) {
|
|
4180
|
+
if (windowSize === 29200 || windowSize === 14600 || windowSize === 5840) {
|
|
4181
|
+
return 'Linux';
|
|
4182
|
+
}
|
|
4183
|
+
return 'Linux';
|
|
4184
|
+
}
|
|
4185
|
+
if (ttl <= 64) {
|
|
4186
|
+
if (windowSize === 65535 && (ws === 6 || ws === 8 || ws === 5)) {
|
|
4187
|
+
return 'macOS/iOS';
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
if (ttl > 64) return 'Windows';
|
|
4191
|
+
if (ttl > 0) return 'Linux';
|
|
4192
|
+
return 'unknown';
|
|
4193
|
+
}
|
|
4194
|
+
|
|
4195
|
+
/**
|
|
4196
|
+
* Détecte les anomalies de pile réseau par rapport au User-Agent.
|
|
4197
|
+
* @private
|
|
4198
|
+
* @param {object} context - Le contexte de la requête.
|
|
4199
|
+
* @returns {{tcpAnomalyScore: number}}
|
|
4200
|
+
*/
|
|
4201
|
+
function getTcpAnomalyScore(context) {
|
|
4202
|
+
let fp = null;
|
|
4203
|
+
const rawTcpBinary = context.headers?.['x-raw-tcp-binary'] || context.rawTcpBinary || null;
|
|
4204
|
+
if (rawTcpBinary) {
|
|
4205
|
+
const binary = Buffer.isBuffer(rawTcpBinary) ? rawTcpBinary : (typeof rawTcpBinary === 'string' ? Buffer.from(rawTcpBinary, 'hex') : rawTcpBinary);
|
|
4206
|
+
fp = parseTcpSyn(binary);
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
if (!fp) {
|
|
4210
|
+
const tcpHeader = context.headers?.['x-tcp-fingerprint'] || context.tcpFingerprint || null;
|
|
4211
|
+
if (tcpHeader && typeof tcpHeader === 'string') {
|
|
4212
|
+
const parts = tcpHeader.split(':');
|
|
4213
|
+
if (parts.length >= 2) {
|
|
4214
|
+
fp = {
|
|
4215
|
+
ttl: parseInt(parts[0], 10),
|
|
4216
|
+
windowSize: parseInt(parts[1], 10),
|
|
4217
|
+
mss: parts[2] ? parseInt(parts[2], 10) : null,
|
|
4218
|
+
ws: parts[3] ? parseInt(parts[3], 10) : null,
|
|
4219
|
+
sack: parts[4] === '1' || parts[4] === 'true'
|
|
4220
|
+
};
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4225
|
+
if (!fp) {
|
|
4226
|
+
return { tcpAnomalyScore: 0.0 };
|
|
4227
|
+
}
|
|
4228
|
+
|
|
4229
|
+
const tcpOs = classifyTcpOs(fp);
|
|
4230
|
+
const ua = context.headers?.['user-agent'] || '';
|
|
4231
|
+
const uaParts = parseUserAgent(ua);
|
|
4232
|
+
const uaOs = uaParts.os;
|
|
4233
|
+
|
|
4234
|
+
if (!uaOs || tcpOs === 'unknown') {
|
|
4235
|
+
return { tcpAnomalyScore: 0.0 };
|
|
4236
|
+
}
|
|
4237
|
+
|
|
4238
|
+
let mappedOs = null;
|
|
4239
|
+
if (uaOs.startsWith('Windows')) mappedOs = 'Windows';
|
|
4240
|
+
else if (uaOs.startsWith('Mac') || uaOs.startsWith('macOS')) mappedOs = 'macOS';
|
|
4241
|
+
else if (uaOs.startsWith('iOS')) mappedOs = 'iOS';
|
|
4242
|
+
else if (uaOs.startsWith('Linux')) mappedOs = 'Linux';
|
|
4243
|
+
|
|
4244
|
+
if (!mappedOs) {
|
|
4245
|
+
return { tcpAnomalyScore: 0.0 };
|
|
4246
|
+
}
|
|
4247
|
+
|
|
4248
|
+
const OS_EXPECTED_TCP = {
|
|
4249
|
+
'Windows': { ttl: 128, windowSize: 64240, ws: 8, mss: 1460, sack: true },
|
|
4250
|
+
'Linux': { ttl: 64, windowSize: 29200, ws: 7, mss: 1460, sack: true },
|
|
4251
|
+
'macOS': { ttl: 64, windowSize: 65535, ws: 6, mss: 1460, sack: true },
|
|
4252
|
+
'iOS': { ttl: 64, windowSize: 65535, ws: 6, mss: 1460, sack: true }
|
|
4253
|
+
};
|
|
4254
|
+
|
|
4255
|
+
const expected = OS_EXPECTED_TCP[mappedOs];
|
|
4256
|
+
const ttlDiff = Math.abs(fp.ttl - expected.ttl) / expected.ttl;
|
|
4257
|
+
const winDiff = Math.abs(fp.windowSize - expected.windowSize) / expected.windowSize;
|
|
4258
|
+
const wsDiff = expected.ws !== null && fp.ws !== null ? Math.abs(fp.ws - expected.ws) / expected.ws : 0.0;
|
|
4259
|
+
const mssDiff = expected.mss !== null && fp.mss !== null ? Math.abs(fp.mss - expected.mss) / expected.mss : 0.0;
|
|
4260
|
+
const sackDiff = (fp.sack ?? true) === (expected.sack ?? true) ? 0.0 : 1.0;
|
|
4261
|
+
|
|
4262
|
+
const deviation = (
|
|
4263
|
+
Math.min(1.0, ttlDiff) * 0.50 +
|
|
4264
|
+
Math.min(1.0, winDiff) * 0.25 +
|
|
4265
|
+
Math.min(1.0, wsDiff) * 0.15 +
|
|
4266
|
+
Math.min(1.0, mssDiff) * 0.05 +
|
|
4267
|
+
sackDiff * 0.05
|
|
4268
|
+
);
|
|
4269
|
+
|
|
4270
|
+
let tcpAnomalyScore = 0.0;
|
|
4271
|
+
if (tcpOs !== mappedOs && tcpOs !== 'unknown') {
|
|
4272
|
+
const baseAnomaly = mappedOs === 'Windows' ? 80.0 :
|
|
4273
|
+
(mappedOs === 'macOS' || mappedOs === 'iOS' ? 85.0 : 75.0);
|
|
4274
|
+
tcpAnomalyScore = baseAnomaly + (deviation - 0.4) * 10.0;
|
|
4275
|
+
} else {
|
|
4276
|
+
tcpAnomalyScore = deviation * 40.0;
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
tcpAnomalyScore = Math.max(0.0, Math.min(100.0, Math.round(tcpAnomalyScore * 10) / 10));
|
|
4280
|
+
return { tcpAnomalyScore };
|
|
4281
|
+
}
|
|
4282
|
+
|
|
3776
4283
|
/**
|
|
3777
4284
|
* Vérifie si une chaîne de caractères contient des patterns d'injection connus.
|
|
3778
4285
|
* @private
|
|
@@ -4037,8 +4544,19 @@ export const powMiddleware = (securityConfig) => {
|
|
|
4037
4544
|
|
|
4038
4545
|
if (jsFile && req.path === jsPath) {
|
|
4039
4546
|
try {
|
|
4040
|
-
|
|
4041
|
-
|
|
4547
|
+
if (wasmConfig === 'dynamic' || wasmConfig.dynamic || wasmConfig.polymorphic) {
|
|
4548
|
+
console.log('[Fingerprint] Generating dynamic polymorphic WASM module...');
|
|
4549
|
+
// Génère des constantes aléatoires uniques pour cette session / requête
|
|
4550
|
+
const seed = crypto.randomBytes(4).readInt32LE(0);
|
|
4551
|
+
const multiplier = crypto.randomBytes(4).readInt32LE(0) | 1; // Doit être impair pour un LCG optimal
|
|
4552
|
+
const adder = crypto.randomBytes(4).readInt32LE(0);
|
|
4553
|
+
|
|
4554
|
+
const wasmBuffer = DynamicWasmGenerator.generate({ seed, multiplier, adder });
|
|
4555
|
+
res.setHeader('Content-Type', 'application/wasm');
|
|
4556
|
+
return res.send(wasmBuffer);
|
|
4557
|
+
}
|
|
4558
|
+
const fileContent = readFileSync(wasmFile);
|
|
4559
|
+
res.setHeader('Content-Type', 'application/wasm');
|
|
4042
4560
|
return res.send(fileContent);
|
|
4043
4561
|
} catch (e) {
|
|
4044
4562
|
// Fallback
|
|
@@ -4046,6 +4564,16 @@ export const powMiddleware = (securityConfig) => {
|
|
|
4046
4564
|
}
|
|
4047
4565
|
if (wasmFile && req.path === wasmPath) {
|
|
4048
4566
|
try {
|
|
4567
|
+
if (wasmConfig === 'dynamic' || wasmConfig.dynamic || wasmConfig.polymorphic) {
|
|
4568
|
+
// Génère des constantes aléatoires uniques pour cette session / requête
|
|
4569
|
+
const seed = crypto.randomBytes(4).readInt32LE(0);
|
|
4570
|
+
const multiplier = crypto.randomBytes(4).readInt32LE(0) | 1; // Doit être impair pour un LCG optimal
|
|
4571
|
+
const adder = crypto.randomBytes(4).readInt32LE(0);
|
|
4572
|
+
|
|
4573
|
+
const wasmBuffer = DynamicWasmGenerator.generate({ seed, multiplier, adder });
|
|
4574
|
+
res.setHeader('Content-Type', 'application/wasm');
|
|
4575
|
+
return res.send(wasmBuffer);
|
|
4576
|
+
}
|
|
4049
4577
|
const fileContent = readFileSync(wasmFile);
|
|
4050
4578
|
res.setHeader('Content-Type', 'application/wasm');
|
|
4051
4579
|
return res.send(fileContent);
|
|
@@ -4143,7 +4671,10 @@ export const __internal = {
|
|
|
4143
4671
|
getTlsFingerprint, // NOUVEAU: Expose pour les tests
|
|
4144
4672
|
sanitizeTrafficData, // NOUVEAU: Expose pour l'auto-tuner/tests
|
|
4145
4673
|
getTlsSpoofingScore, // NOUVEAU: Expose pour les tests
|
|
4674
|
+
generateStatelessTicket,
|
|
4675
|
+
parseStatelessTicket,
|
|
4146
4676
|
parseJa3,
|
|
4677
|
+
getBotnetClusterScore, // NOUVEAU: Expose pour les tests
|
|
4147
4678
|
generateCpuTargetChallengePage,
|
|
4148
4679
|
getClientHintsInconsistencyScore, // Expose for testing
|
|
4149
4680
|
generateCombinedPoWChallengePage,
|
|
@@ -4154,6 +4685,9 @@ export const __internal = {
|
|
|
4154
4685
|
getIpReputationScore, // Expose for testing
|
|
4155
4686
|
updateIpReputationScore, // Expose for testing
|
|
4156
4687
|
setLastBestSolution: (val) => { lastBestSolution = val; }, // Expose to test auto-tuning metrics
|
|
4688
|
+
parseTcpSyn, // Expose for testing
|
|
4689
|
+
classifyTcpOs, // Expose for testing
|
|
4690
|
+
getTcpAnomalyScore // Expose for testing
|
|
4157
4691
|
};
|
|
4158
4692
|
|
|
4159
4693
|
// --- THRESHOLD AUTO-TUNING SECTION ---
|