@anonympins/fingerprint 0.4.3 → 0.4.4

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.
@@ -15,6 +15,37 @@ export { createRedisStore } from "./redis-store.js";
15
15
  export { createMongoDbStore } from "./mongodb-store.js";
16
16
 
17
17
 
18
+ /**
19
+ * Vérifie le limiteur de débit Token Bucket pour les demandes de challenge d'un sous-réseau.
20
+ * @param {string} clientIp - L'adresse IP du client.
21
+ * @returns {Promise<boolean>} True si la requête est autorisée, false si elle est limitée.
22
+ */
23
+ async function checkChallengeRateLimit(clientIp) {
24
+ const subnet = getIpSubnet(clientIp);
25
+ if (!subnet) return false;
26
+
27
+ const key = `rate-limit:${subnet}`;
28
+ const rateLimitData = (await store.get(key)) || {
29
+ tokens: 5.0,
30
+ lastRefill: Date.now() / 1000
31
+ };
32
+
33
+ const capacity = 5.0;
34
+ const refillRate = 0.1; // 1 token toutes les 10 secondes
35
+ const now = Date.now() / 1000;
36
+
37
+ const elapsed = now - rateLimitData.lastRefill;
38
+ const tokens = Math.min(capacity, rateLimitData.tokens + elapsed * refillRate);
39
+
40
+ if (tokens < 1.0) {
41
+ await store.set(key, { tokens, lastRefill: now }, 60);
42
+ return false;
43
+ }
44
+
45
+ await store.set(key, { tokens: tokens - 1.0, lastRefill: now }, 60);
46
+ return true;
47
+ }
48
+
18
49
  /**
19
50
  * @private
20
51
  * Deep merges two objects. The `source` object's properties overwrite the `target`'s.
@@ -54,7 +85,8 @@ const securityProfiles = {
54
85
  timeInconsistencyScore: 0.9,
55
86
  tlsSpoofingScore: 0.8, // NOUVEAU: Poids pour la détection de spoofing TLS
56
87
  subnetScore: 0.4, // NOUVEAU: Poids pour la réputation du sous-réseau
57
- ipReputationScore: 0.5 // NOUVEAU: Poids pour la réputation IP
88
+ ipReputationScore: 0.5, // NOUVEAU: Poids pour la réputation IP
89
+ botnetClusterScore: 0.6 // NOUVEAU: Poids pour le clustering botnet
58
90
  },
59
91
  thresholds: { low: 20, medium: 45, high: 75, block: 95 },
60
92
  patterns: {
@@ -89,7 +121,8 @@ const securityProfiles = {
89
121
  timeInconsistencyScore: 1.0,
90
122
  tlsSpoofingScore: 1.0, // Plus agressif pour le spoofing TLS
91
123
  subnetScore: 0.5,
92
- ipReputationScore: 0.6 // NOUVEAU: Poids pour la réputation IP
124
+ ipReputationScore: 0.6, // NOUVEAU: Poids pour la réputation IP
125
+ botnetClusterScore: 0.8 // NOUVEAU: Poids pour le clustering botnet
93
126
  },
94
127
  thresholds: { low: 10, medium: 35, high: 65, block: 90 },
95
128
  patterns: {
@@ -125,7 +158,8 @@ const securityProfiles = {
125
158
  timeInconsistencyScore: 0.8,
126
159
  tlsSpoofingScore: 0.7, // Important pour les API
127
160
  subnetScore: 0.4,
128
- ipReputationScore: 0.5 // NOUVEAU: Poids pour la réputation IP
161
+ ipReputationScore: 0.5, // NOUVEAU: Poids pour la réputation IP
162
+ botnetClusterScore: 0.7 // NOUVEAU: Poids pour le clustering botnet
129
163
  },
130
164
  thresholds: { low: 25, medium: 50, high: 80, block: 95 },
131
165
  patterns: {
@@ -162,7 +196,8 @@ const securityProfiles = {
162
196
  timeInconsistencyScore: 0.8,
163
197
  tlsSpoofingScore: 0.6, // Moins critique pour les blogs
164
198
  subnetScore: 0.2,
165
- ipReputationScore: 0.3 // NOUVEAU: Poids pour la réputation IP
199
+ ipReputationScore: 0.3, // NOUVEAU: Poids pour la réputation IP
200
+ botnetClusterScore: 0.5 // NOUVEAU: Poids pour le clustering botnet
166
201
  },
167
202
  thresholds: { low: 25, medium: 55, high: 80, block: 95 },
168
203
  patterns: {
@@ -198,7 +233,8 @@ const securityProfiles = {
198
233
  timeInconsistencyScore: 0.9,
199
234
  tlsSpoofingScore: 0.9, // Très important pour l'e-commerce
200
235
  subnetScore: 0.5,
201
- ipReputationScore: 0.6 // NOUVEAU: Poids pour la réputation IP
236
+ ipReputationScore: 0.6, // NOUVEAU: Poids pour la réputation IP
237
+ botnetClusterScore: 0.9 // NOUVEAU: Poids pour le clustering botnet
202
238
  },
203
239
  thresholds: { low: 15, medium: 40, high: 70, block: 90 },
204
240
  patterns: {
@@ -319,12 +355,12 @@ function getTlsFingerprint(context) {
319
355
  let ja4 = null;
320
356
 
321
357
  // 1. Prefer JA4 hash from a trusted reverse proxy header.
322
- const ja4FromHeader = context.headers['x-ja4-hash'];
358
+ const ja4FromHeader = context.headers ? context.headers['x-ja4-hash'] : null;
323
359
  if (ja4FromHeader) {
324
360
  ja4 = ja4FromHeader;
325
361
  }
326
362
  // 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
363
+ const ja3FromHeader = context.headers ? context.headers['x-ja3-hash'] : null; // Assuming a proxy might provide JA3 too
328
364
  if (ja3FromHeader) {
329
365
  ja3 = ja3FromHeader;
330
366
  }
@@ -425,7 +461,7 @@ function getCompositeDeviceHash(context) {
425
461
  // notre propre fingerprint serveur pour le comparer.
426
462
  // Un attaquant qui forge un `clientFp` mais oublie de forger les en-têtes
427
463
  // correspondants sera détecté par l'incohérence.
428
- const clientFp = context.headers['x-device-fingerprint'];
464
+ const clientFp = context.headers ? context.headers['x-device-fingerprint'] : null;
429
465
  if (clientFp && typeof clientFp === 'string' && clientFp.includes('cvs:')) {
430
466
  // On ajoute le hash du fingerprint client comme un composant du fingerprint serveur.
431
467
  // Si le clientFp change, le hash serveur changera aussi.
@@ -433,7 +469,7 @@ function getCompositeDeviceHash(context) {
433
469
  }
434
470
 
435
471
  // 1. SIGNAL FORT: User Agent (poids élevé)
436
- const ua = context.headers["user-agent"];
472
+ const ua = context.headers ? context.headers["user-agent"] : null;
437
473
  if (ua) {
438
474
  srv.add("ua", ua); // User-Agent
439
475
  }
@@ -443,10 +479,10 @@ function getCompositeDeviceHash(context) {
443
479
  if (ja3) srv.add("ja3", ja3);
444
480
  if (ja4) srv.add("ja4", ja4);
445
481
 
446
- const h2Fingerprint = context.headers['x-http2-fingerprint'];
482
+ const h2Fingerprint = context.headers ? context.headers['x-http2-fingerprint'] : null;
447
483
  if (h2Fingerprint) srv.add("h2", h2Fingerprint);
448
484
 
449
- const tcpFingerprint = context.headers['x-tcp-fingerprint'];
485
+ const tcpFingerprint = context.headers ? context.headers['x-tcp-fingerprint'] : null;
450
486
  if (tcpFingerprint) srv.add("tcp", tcpFingerprint);
451
487
 
452
488
  // 3. SIGNAUX DE HAUT NIVEAU (Applicatif) Moins fiables, mais utiles pour la corroboration
@@ -464,7 +500,7 @@ function getCompositeDeviceHash(context) {
464
500
  };
465
501
 
466
502
  for (const [key, headerName] of Object.entries(headersToCapture)) {
467
- const headerValue = context.headers[headerName];
503
+ const headerValue = context.headers ? context.headers[headerName] : null;
468
504
  if (headerValue) {
469
505
  srv.add(key, headerValue);
470
506
  }
@@ -1149,6 +1185,79 @@ function analyzeMouseMovements(history) {
1149
1185
  return { avgSpeed, avgAcceleration, straightness, pauses, segments: segments.map(s => s.distance) };
1150
1186
  }
1151
1187
 
1188
+ /**
1189
+ * @private
1190
+ * Analyse une série d'événements tactiles mobiles pour en extraire des indicateurs comportementaux robustes.
1191
+ * @param {Array<{x: number, y: number, t: number, p: number, r: number, num: number}>} history
1192
+ * @returns {{avgSpeed: number, avgAcceleration: number, straightness: number, pauses: number, segments: Array<number>, avgPressure: number, avgRadius: number, pressureVariance: number, radiusVariance: number, maxTouches: number}}
1193
+ */
1194
+ function analyzeTouchMovements(history) {
1195
+ if (!history || history.length < 3) {
1196
+ return { avgSpeed: 0, avgAcceleration: 0, straightness: 1, pauses: 0, segments: [], avgPressure: 0, avgRadius: 0, pressureVariance: 0, radiusVariance: 0, maxTouches: 1 };
1197
+ }
1198
+
1199
+ const segments = [];
1200
+ let totalDistance = 0;
1201
+ let pauses = 0;
1202
+ let totalPressure = 0;
1203
+ let totalRadius = 0;
1204
+ let maxTouches = 1;
1205
+
1206
+ for (let i = 1; i < history.length; i++) {
1207
+ const p1 = history[i - 1];
1208
+ const p2 = history[i];
1209
+ const dx = p2.x - p1.x;
1210
+ const dy = p2.y - p1.y;
1211
+ const dt = p2.t - p1.t;
1212
+ const distance = Math.sqrt(dx * dx + dy * dy);
1213
+
1214
+ totalPressure += p2.p || 0;
1215
+ totalRadius += p2.r || 0;
1216
+ if (p2.num > maxTouches) {
1217
+ maxTouches = p2.num;
1218
+ }
1219
+
1220
+ if (dt > 0) {
1221
+ const speed = distance / dt;
1222
+ segments.push({ distance, dt, speed });
1223
+ totalDistance += distance;
1224
+ }
1225
+ if (dt > 100 && distance < 5) {
1226
+ pauses++;
1227
+ }
1228
+ }
1229
+
1230
+ totalPressure += history[0].p || 0;
1231
+ totalRadius += history[0].r || 0;
1232
+
1233
+ const avgPressure = totalPressure / history.length;
1234
+ const avgRadius = totalRadius / history.length;
1235
+
1236
+ let sqDiffPressureSum = 0;
1237
+ let sqDiffRadiusSum = 0;
1238
+ for (const pt of history) {
1239
+ sqDiffPressureSum += Math.pow((pt.p || 0) - avgPressure, 2);
1240
+ sqDiffRadiusSum += Math.pow((pt.r || 0) - avgRadius, 2);
1241
+ }
1242
+ const pressureVariance = sqDiffPressureSum / history.length;
1243
+ const radiusVariance = sqDiffRadiusSum / history.length;
1244
+
1245
+ if (segments.length < 2) {
1246
+ return { avgSpeed: 0, avgAcceleration: 0, straightness: 1, pauses, segments: [], avgPressure, avgRadius, pressureVariance, radiusVariance, maxTouches };
1247
+ }
1248
+
1249
+ const totalTime = history[history.length - 1].t - history[0].t;
1250
+ const avgSpeed = totalTime > 0 ? segments.reduce((sum, s) => sum + s.speed, 0) / segments.length : 0;
1251
+ const avgAcceleration = segments.reduce((sum, s) => sum + (s.speed / s.dt), 0) / segments.length;
1252
+
1253
+ const startPoint = history[0];
1254
+ const endPoint = history[history.length - 1];
1255
+ const straightDistance = Math.sqrt(Math.pow(endPoint.x - startPoint.x, 2) + Math.pow(endPoint.y - startPoint.y, 2));
1256
+ const straightness = totalDistance > 0 ? straightDistance / totalDistance : 1;
1257
+
1258
+ return { avgSpeed, avgAcceleration, straightness, pauses, segments: segments.map(s => s.distance), avgPressure, avgRadius, pressureVariance, radiusVariance, maxTouches };
1259
+ }
1260
+
1152
1261
  /**
1153
1262
  * Calcule un score basé sur les métriques comportementales envoyées par le client.
1154
1263
  * @param {object} context - Le contexte de la requête, contenant les en-têtes.
@@ -1171,9 +1280,10 @@ function getBehaviorScore(context) {
1171
1280
 
1172
1281
  // 2. Analyse des mouvements de la souris
1173
1282
  const { avgSpeed, avgAcceleration, straightness, pauses, segments } = analyzeMouseMovements(metrics.mouseMovementsHistory);
1283
+ const touchAnalysis = analyzeTouchMovements(metrics.touchMovementsHistory);
1174
1284
 
1175
1285
  // Pénalité pour absence totale d'interaction (pas de mouvements, pas de frappes).
1176
- if (avgSpeed === 0 && metrics.keystrokeLatency === 0) {
1286
+ if (avgSpeed === 0 && touchAnalysis.avgSpeed === 0 && metrics.keystrokeLatency === 0) {
1177
1287
  score += 40;
1178
1288
  }
1179
1289
 
@@ -1197,6 +1307,30 @@ function getBehaviorScore(context) {
1197
1307
  if (pauses === 0 && segments.length > 20) score += 15; // Mouvement continu sans micro-pauses
1198
1308
  }
1199
1309
 
1310
+ // 4. Analyse comportementale des événements tactiles (Touch Move)
1311
+ const touchHistory = metrics.touchMovementsHistory;
1312
+ if (touchHistory && touchHistory.length > 0) {
1313
+ const touch = analyzeTouchMovements(touchHistory);
1314
+ if (touch.avgSpeed > 0) {
1315
+ if (touch.avgSpeed > 5) score += 30; // Touch d'une vitesse anormale/robotique
1316
+ if (touch.avgAcceleration > 0.8) score += 20;
1317
+ if (touch.straightness > 0.98) score += 35; // Un tracé de doigt humain n'est jamais parfaitement rectiligne
1318
+ if (touch.pauses === 0 && touch.segments.length > 25) score += 15;
1319
+
1320
+ // Détection de l'émulation (pression et rayon de contact constants)
1321
+ if (touch.avgPressure > 0 && touch.pressureVariance === 0) {
1322
+ score += 30; // Spoofed force/pressure
1323
+ }
1324
+ if (touch.avgRadius > 0 && touch.radiusVariance === 0) {
1325
+ score += 30; // Spoofed pointer area size
1326
+ }
1327
+ }
1328
+ if (touch.segments.length > 10) {
1329
+ const benfordDev = Optimization.Operators.benfordTest(touch.segments);
1330
+ if (benfordDev > 0.18) score += 35;
1331
+ }
1332
+ }
1333
+
1200
1334
  // Plausibilité de la latence de frappe
1201
1335
  if (metrics.keystrokeLatency > 0 && metrics.keystrokeLatency < 40) score += 25; // Frappe trop rapide pour un humain.
1202
1336
  if (metrics.keystrokeLatency > 1000) score += 15; // Latence très élevée, peut être un script lent.
@@ -1738,14 +1872,18 @@ async function updateSubnetMetrics(context, deviceId, finalScore) {
1738
1872
  subnetData.highScoreDevices = {};
1739
1873
  }
1740
1874
 
1741
- const currentDeviceContributions = subnetData.highScoreDevices[deviceId] || 0;
1875
+ // Utilisation d'un identifiant d'appareil stable (fingerprint matériel) plutôt que l'ID de cookie volatil
1876
+ const currentDeviceHash = getCompositeDeviceHash(context);
1877
+ const stableFpId = cyrb53(extractStablePart(currentDeviceHash)).toString();
1878
+
1879
+ const currentDeviceContributions = subnetData.highScoreDevices[stableFpId] || 0;
1742
1880
  if (currentDeviceContributions < 5) {
1743
- subnetData.highScoreDevices[deviceId] = currentDeviceContributions + 1;
1881
+ subnetData.highScoreDevices[stableFpId] = currentDeviceContributions + 1;
1744
1882
  subnetData.highScoreCount++;
1745
1883
  }
1746
1884
 
1747
- if (!subnetData.deviceIds.includes(deviceId)) {
1748
- subnetData.deviceIds.push(deviceId);
1885
+ if (!subnetData.deviceIds.includes(stableFpId)) {
1886
+ subnetData.deviceIds.push(stableFpId);
1749
1887
  }
1750
1888
  subnetData.lastActivity = Date.now();
1751
1889
 
@@ -1792,6 +1930,40 @@ async function getSubnetScore(context) {
1792
1930
  return { subnetScore: Math.min(100, deviceCountPenalty + highScorePenalty) };
1793
1931
  }
1794
1932
 
1933
+ /**
1934
+ * Calcule le score d'anomalie de similarité réseau (Botnet Clustering).
1935
+ * @param {object} context - Le contexte de la requête.
1936
+ * @param {string} stableFpHash - Le hash de la partie stable de l'empreinte.
1937
+ * @returns {Promise<{botnetClusterScore: number}>}
1938
+ */
1939
+ async function getBotnetClusterScore(context, stableFpHash) {
1940
+ if (!stableFpHash) return { botnetClusterScore: 0 };
1941
+ const key = `botnet-cluster:${stableFpHash}`;
1942
+ const now = Date.now();
1943
+ const tenMinutesAgo = now - 600 * 1000;
1944
+
1945
+ let clusterData = (await store.get(key)) || [];
1946
+ if (!Array.isArray(clusterData)) {
1947
+ clusterData = [];
1948
+ }
1949
+
1950
+ clusterData = clusterData.filter(entry => entry.timestamp > tenMinutesAgo);
1951
+ const existingIndex = clusterData.findIndex(entry => entry.ip === context.clientIp);
1952
+ if (existingIndex !== -1) {
1953
+ clusterData[existingIndex].timestamp = now;
1954
+ } else {
1955
+ clusterData.push({ ip: context.clientIp, timestamp: now });
1956
+ }
1957
+
1958
+ await store.set(key, clusterData, 600);
1959
+ const uniqueIpsCount = clusterData.length;
1960
+ let botnetClusterScore = 0;
1961
+ if (uniqueIpsCount >= 2) {
1962
+ botnetClusterScore = Math.min(100, Math.round(1000 * (1 - Math.exp(-0.35 * (uniqueIpsCount - 1)))) / 10);
1963
+ }
1964
+ return { botnetClusterScore };
1965
+ }
1966
+
1795
1967
  /**
1796
1968
  * Retrieves the current local IP reputation score, applying time-based decay.
1797
1969
  * @param {string} ip - The client's IP address.
@@ -1858,7 +2030,10 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
1858
2030
  benfordThreshold = 0.15, // Seuil de déviation de Benford au-dessus duquel la distribution est "non naturelle".
1859
2031
  patternWeight = 80, // Pénalité FORTE et unique si un pattern est détecté.
1860
2032
  decayFactor = 0.95, // Décroissance du score dans le temps.
1861
- inactivityReset = 180000 // Réinitialisation du score après 3 minutes d'inactivité.
2033
+ inactivityReset = 180000, // Réinitialisation du score après 3 minutes d'inactivité.
2034
+ regularityRatio = 0.4, // (NOUVEAU) Poids relatif de l'écart-type
2035
+ benfordRatio = 0.3, // (NOUVEAU) Poids relatif de Benford
2036
+ enumerationRatio = 0.3 // (NOUVEAU) Poids relatif de l'énumération de chemins
1862
2037
  } = patternConfig;
1863
2038
 
1864
2039
  const now = Date.now();
@@ -1888,24 +2063,24 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
1888
2063
  deviceData.timingHistory.push(timeSinceLast);
1889
2064
  }
1890
2065
 
1891
- let instantScore = 0;
2066
+ let regularityScore = 0;
2067
+ let benfordScore = 0;
1892
2068
  const timings = deviceData.timingHistory;
1893
2069
 
1894
2070
  // Analyse statistique unifiée si nous avons assez de données
1895
2071
  if (timings.length >= minSamples) {
1896
- const timings = deviceData.timingHistory;
1897
2072
  const mean = timings.reduce((a, b) => a + b, 0) / timings.length;
1898
2073
  const variance = timings.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / timings.length;
1899
2074
  const stdDev = Math.sqrt(variance);
1900
2075
  const benfordDeviation = Optimization.Operators.benfordTest(timings);
1901
2076
 
1902
- // Détection de régularité (bots de type "cron")
2077
+ // Calcul progressif de la régularité (stdDev proche de 0 = score max)
1903
2078
  if (stdDev < regularityThreshold) {
1904
- instantScore = patternWeight;
2079
+ regularityScore = 1 - (stdDev / regularityThreshold);
1905
2080
  }
1906
- // Détection de distribution non-naturelle (bots "faussement aléatoires")
1907
- else if (benfordDeviation > benfordThreshold) {
1908
- instantScore = patternWeight;
2081
+ // Calcul progressif de Benford (excès par rapport au seuil)
2082
+ if (benfordDeviation > benfordThreshold) {
2083
+ benfordScore = Math.min(1, (benfordDeviation - benfordThreshold) / (0.5 - benfordThreshold));
1909
2084
  }
1910
2085
  }
1911
2086
 
@@ -1919,12 +2094,18 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
1919
2094
  templates.forEach(t => templateCounts[t] = (templateCounts[t] || 0) + 1);
1920
2095
 
1921
2096
  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
2097
  if (maxTemplateRepetition >= 3 && uniquePaths.size === history.length) {
1924
- enumerationScore = patternWeight * 0.8; // Appliquer une forte pénalité
2098
+ enumerationScore = Math.min(1, (maxTemplateRepetition - 2) / 5);
1925
2099
  }
1926
2100
  }
1927
2101
 
2102
+ // Score instantané combiné linéaire pondéré
2103
+ const weightedScore = (regularityScore * regularityRatio) +
2104
+ (benfordScore * benfordRatio) +
2105
+ (enumerationScore * enumerationRatio);
2106
+
2107
+ const instantScore = weightedScore * patternWeight;
2108
+
1928
2109
  // Garder l'historique à une taille raisonnable
1929
2110
  if (history.length > historySize) {
1930
2111
  history.shift();
@@ -1943,7 +2124,7 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
1943
2124
  }
1944
2125
  newPatternScore = Math.max(0, newPatternScore);
1945
2126
 
1946
- deviceData.lastPatternScore = newPatternScore + instantScore + enumerationScore;
2127
+ deviceData.lastPatternScore = Math.max(instantScore, newPatternScore);
1947
2128
 
1948
2129
  return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
1949
2130
  }
@@ -2175,6 +2356,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
2175
2356
  const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context, securityConfig);
2176
2357
 
2177
2358
  const clientIp = context.clientIp;
2359
+ const currentDeviceHash = getCompositeDeviceHash(context);
2178
2360
 
2179
2361
  // If a new cookie needs to be set, attach it to the request object
2180
2362
  // so the middleware can handle it. This is a temporary state holder.
@@ -2230,6 +2412,10 @@ export const getSuspicionVector = async (context, securityConfig) => {
2230
2412
 
2231
2413
  const ipReputationScore = await getIpReputationScore(clientIp);
2232
2414
 
2415
+ const stableFp = extractStablePart(currentDeviceHash);
2416
+ const stableFpHash = cyrb53(stableFp).toString();
2417
+ const { botnetClusterScore } = await getBotnetClusterScore(context, stableFpHash);
2418
+
2233
2419
  // Save the updated device state to the store
2234
2420
  // 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
2421
  await store.set(`device:${deviceId}`, deviceData);
@@ -2240,7 +2426,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
2240
2426
  deviceData.ips = new Set(deviceData.ips);
2241
2427
  }
2242
2428
  // Le vecteur de suspicion est maintenant complet.
2243
- return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, botScore, requestPatternScore, crossLayerInconsistencyScore, timeInconsistencyScore, tlsSpoofingScore, clickVarianceScore, clientHintsInconsistencyScore, subnetScore, ipReputationScore };
2429
+ return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, botScore, requestPatternScore, crossLayerInconsistencyScore, timeInconsistencyScore, tlsSpoofingScore, clickVarianceScore, clientHintsInconsistencyScore, subnetScore, ipReputationScore, botnetClusterScore };
2244
2430
  };
2245
2431
 
2246
2432
  // A residential user can change networks (home, 4G, public wifi).
@@ -2644,6 +2830,7 @@ export class FingerprintEngine {
2644
2830
  (suspicionVector.behaviorScore || 0) * (weights.behaviorScore || 0) +
2645
2831
  (suspicionVector.botScore || 0) * (weights.botScore || 0) + // Ajout du nouveau score
2646
2832
  (suspicionVector.crossLayerInconsistencyScore || 0) * (weights.crossLayerInconsistencyScore || 0) +
2833
+ (suspicionVector.botnetClusterScore || 0) * (weights.botnetClusterScore || 0) +
2647
2834
  (suspicionVector.tlsSpoofingScore || 0) * (weights.tlsSpoofingScore || 0) + // NOUVEAU: TLS Spoofing
2648
2835
  (suspicionVector.timeInconsistencyScore || 0) * (weights.timeInconsistencyScore || 0) +
2649
2836
  (suspicionVector.clickVarianceScore || 0) * (weights.clickVarianceScore || 0) +
@@ -3374,6 +3561,28 @@ export class FingerprintEngine {
3374
3561
  if (mustReChallenge) {
3375
3562
  this._log('High suspicion score detected - overriding valid ticket to re-issue challenge', { finalScore, deviceId });
3376
3563
  }
3564
+
3565
+ // --- AJOUT : Limiteur de débit (Token Bucket) ---
3566
+ const rateLimitPassed = await checkChallengeRateLimit(clientIp);
3567
+ if (!rateLimitPassed) {
3568
+ this._log('Challenge rate limit exceeded - blocking with 429', { clientIp });
3569
+ const decision = {
3570
+ action: 'block',
3571
+ status: 429,
3572
+ body: 'Too Many Requests',
3573
+ score: finalScore,
3574
+ vector: suspicionVector
3575
+ };
3576
+ if (this.dryRun) {
3577
+ this._log(`[Dry Run] Intended action: ${decision.action}`, { score: decision.score });
3578
+ decision.intendedAction = decision.action;
3579
+ decision.action = 'next';
3580
+ delete decision.status;
3581
+ delete decision.body;
3582
+ }
3583
+ return decision;
3584
+ }
3585
+
3377
3586
  this._log('Suspicious request without valid ticket - issuing challenge', { finalScore, hasPowCookie: !!powCookie });
3378
3587
 
3379
3588
  // --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
@@ -4144,6 +4353,7 @@ export const __internal = {
4144
4353
  sanitizeTrafficData, // NOUVEAU: Expose pour l'auto-tuner/tests
4145
4354
  getTlsSpoofingScore, // NOUVEAU: Expose pour les tests
4146
4355
  parseJa3,
4356
+ getBotnetClusterScore, // NOUVEAU: Expose pour les tests
4147
4357
  generateCpuTargetChallengePage,
4148
4358
  getClientHintsInconsistencyScore, // Expose for testing
4149
4359
  generateCombinedPoWChallengePage,