@anonympins/fingerprint 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
1
1
  import crypto from "node:crypto";
2
- import { BlockList, isIPv4, isIPv6 } from "node:net";
2
+ import {BlockList, isIPv4, isIPv6} from "node:net";
3
3
  import * as dns from "node:dns/promises";
4
- import { getProblemManager, problemManager } from "./problem-manager.js";
5
- import { Optimization } from "./library.js";
6
- import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
7
- import { readFileSync } from "node:fs";
8
- import { fileURLToPath } from "node:url";
9
- import { dirname, join } from "node:path";
4
+ import {getProblemManager, problemManager} from "./problem-manager.js";
5
+ import {Optimization} from "./library.js";
6
+ import {cyrb53, FingerprintBuilder} from "./fingerprint.builder.js";
7
+ import {readFileSync} from "node:fs";
8
+ import {fileURLToPath} from "node:url";
9
+ import {dirname, join} from "node:path";
10
+
10
11
  export { createRedisStore } from "./redis-store.js";
11
12
  export { createMongoDbStore } from "./mongodb-store.js";
12
13
 
@@ -769,6 +770,8 @@ export const verifyPoWAndGenerateTicket = (
769
770
  nonce,
770
771
  solution,
771
772
  difficulty = 4,
773
+ deviceId = '',
774
+ deviceHash = ''
772
775
  ) => {
773
776
  // 1. Verify the solution: hash(ip + nonce + solution) must start with N zeros
774
777
  const hash = crypto
@@ -784,10 +787,10 @@ export const verifyPoWAndGenerateTicket = (
784
787
  const expiry = Date.now() + 3600000; // 1 heure
785
788
  const signature = crypto
786
789
  .createHmac("sha256", getPowSecret())
787
- .update(`${ip}:${expiry}`)
790
+ .update(`${expiry}:${ip}:${deviceId}:${deviceHash}`)
788
791
  .digest("hex");
789
792
 
790
- return `${expiry}:${signature}`;
793
+ return `${expiry}|${ip}|${signature}`;
791
794
  };
792
795
 
793
796
 
@@ -822,23 +825,61 @@ export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret =
822
825
  }
823
826
  return finalHash === parseInt(solution, 10);
824
827
  };
825
- export const isTicketValid = (ip, ticket) => {
828
+ export const isTicketValid = (ip, ticket, deviceId = '', deviceHash = '') => {
826
829
  // Input validation: ensure the ticket is a non-empty string with the correct format.
827
- if (typeof ticket !== 'string' || !ticket.includes(':')) return false;
828
- const [expiry, sig] = ticket.split(':');
830
+ if (typeof ticket !== 'string') return false;
831
+
832
+ let expiry, originalIp, sig;
833
+ if (ticket.includes('|')) {
834
+ const parts = ticket.split('|');
835
+ if (parts.length < 3) return false;
836
+ [expiry, originalIp, sig] = parts;
837
+ } else if (ticket.includes(':')) {
838
+ // Legacy fallback format
839
+ const parts = ticket.split(':');
840
+ if (parts.length < 2) return false;
841
+ [expiry, sig] = parts;
842
+ originalIp = ip;
843
+ } else {
844
+ return false;
845
+ }
846
+
829
847
  if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
830
- const expectedSig = crypto
831
- .createHmac("sha256", getPowSecret())
832
- .update(`${ip}:${expiry}`)
833
- .digest("hex");
848
+
849
+ let expectedSig;
850
+ if (ticket.includes('|')) {
851
+ expectedSig = crypto
852
+ .createHmac("sha256", getPowSecret())
853
+ .update(`${expiry}:${originalIp}:${deviceId}:${deviceHash}`)
854
+ .digest("hex");
855
+ } else {
856
+ // Legacy expected signature
857
+ expectedSig = crypto
858
+ .createHmac("sha256", getPowSecret())
859
+ .update(`${ip}:${expiry}`)
860
+ .digest("hex");
861
+ }
834
862
 
835
863
  // Use timingSafeEqual to prevent timing attacks
836
864
  try {
837
- return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
865
+ const isSigValid = crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
866
+ if (!isSigValid) return false;
838
867
  } catch (e) {
839
- // This can happen if the buffers have different lengths, which is a failure case.
840
868
  return false;
841
869
  }
870
+
871
+ if (!ticket.includes('|')) {
872
+ return ip === originalIp;
873
+ }
874
+
875
+ // Roaming & Terminal Identity checks:
876
+ if (ip === originalIp) return true;
877
+ const currentSubnet = getIpSubnet(ip);
878
+ const originalSubnet = getIpSubnet(originalIp);
879
+ if (currentSubnet && originalSubnet && currentSubnet === originalSubnet) return true;
880
+
881
+ // Perfect terminal identity matched via HMAC signature
882
+ return !!(deviceId && deviceHash);
842
883
  };
843
884
 
844
885
 
@@ -1778,6 +1819,22 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
1778
1819
  }
1779
1820
  }
1780
1821
 
1822
+ // Détection d'énumération de chemins (crawling/scraping de ressources séquentielles)
1823
+ let enumerationScore = 0;
1824
+ if (history.length >= 3) {
1825
+ const templates = history.map(h => h.path.replace(/\d+/g, '{num}'));
1826
+ const uniquePaths = new Set(history.map(h => h.path));
1827
+
1828
+ const templateCounts = {};
1829
+ templates.forEach(t => templateCounts[t] = (templateCounts[t] || 0) + 1);
1830
+
1831
+ const maxTemplateRepetition = Math.max(...Object.values(templateCounts), 0);
1832
+ // Si une même structure de route est répétée mais sur des URLs réelles différentes
1833
+ if (maxTemplateRepetition >= 3 && uniquePaths.size === history.length) {
1834
+ enumerationScore = patternWeight * 0.8; // Appliquer une forte pénalité
1835
+ }
1836
+ }
1837
+
1781
1838
  // Garder l'historique à une taille raisonnable
1782
1839
  if (history.length > historySize) {
1783
1840
  history.shift();
@@ -1796,7 +1853,7 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
1796
1853
  }
1797
1854
  newPatternScore = Math.max(0, newPatternScore);
1798
1855
 
1799
- deviceData.lastPatternScore = newPatternScore + instantScore;
1856
+ deviceData.lastPatternScore = newPatternScore + instantScore + enumerationScore;
1800
1857
 
1801
1858
  return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
1802
1859
  }
@@ -2341,6 +2398,8 @@ export function verifyCpuTargetPoWAndGenerateTicket(
2341
2398
  nonce,
2342
2399
  solution,
2343
2400
  challengeContext = {}, // Le contexte complet du challenge est maintenant passé
2401
+ deviceId = '',
2402
+ deviceHash = ''
2344
2403
  ) {
2345
2404
  const { cpuTarget, baseBlock } = challengeContext;
2346
2405
  if (!cpuTarget || !baseBlock) {
@@ -2392,9 +2451,9 @@ export function verifyCpuTargetPoWAndGenerateTicket(
2392
2451
  const expiry = Date.now() + (ticketTtl || 3600000); // Calcule l'expiration à partir du TTL
2393
2452
  const signature = crypto
2394
2453
  .createHmac("sha256", getPowSecret())
2395
- .update(`${clientIp}:${expiry}`)
2454
+ .update(`${expiry}:${clientIp}:${deviceId}:${deviceHash}`)
2396
2455
  .digest("hex");
2397
- return `${expiry}:${signature}`;
2456
+ return `${expiry}|${clientIp}|${signature}`;
2398
2457
  }
2399
2458
 
2400
2459
  return null;
@@ -2474,7 +2533,7 @@ export class FingerprintEngine {
2474
2533
  console.log(`[FingerprintEngine] ${message}`, data);
2475
2534
  }
2476
2535
  }
2477
- calculateFinalScore = function(suspicionVector) {
2536
+ calculateFinalScore(suspicionVector) {
2478
2537
  const { weights } = this.securityConfig;
2479
2538
  if (!weights) return 0;
2480
2539
 
@@ -2760,6 +2819,11 @@ export class FingerprintEngine {
2760
2819
  return { action: 'next', score: 0, vector: {} };
2761
2820
  }
2762
2821
 
2822
+ // Resolve identity and check for persisted "condemned" status early.
2823
+ const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
2824
+ const currentDeviceHash = getCompositeDeviceHash(requestContext);
2825
+ const isNewDevice = !!newCookie;
2826
+
2763
2827
  // 1. Check static IP allowlist first for maximum performance.
2764
2828
  if (this._isIpInAllowlist(clientIp)) {
2765
2829
  this._log('IP in allowlist - allowing request', { clientIp });
@@ -2798,7 +2862,7 @@ export class FingerprintEngine {
2798
2862
  // If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
2799
2863
  if (pow_nonce) {
2800
2864
  const powCookie = cookies?.pow_clearance;
2801
- if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
2865
+ if (!isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash)) { // Only check if there's no valid ticket
2802
2866
  // This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
2803
2867
  // The final decision is made later, after calculating the score.
2804
2868
  }
@@ -2810,10 +2874,6 @@ export class FingerprintEngine {
2810
2874
  return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
2811
2875
  }
2812
2876
 
2813
- // Resolve identity and check for persisted "condemned" status early.
2814
- const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
2815
- const isNewDevice = !!newCookie;
2816
-
2817
2877
  this._log('Identity resolved', { deviceId, isNewDevice, hasDeviceData: !!deviceData });
2818
2878
 
2819
2879
  if (deviceData?.condemned) {
@@ -2908,7 +2968,26 @@ export class FingerprintEngine {
2908
2968
  });
2909
2969
 
2910
2970
  let isValid = false;
2911
- const challengeContext = await store.get(`secret:${pow_nonce}`);
2971
+ let challengeContext = await store.get(`secret:${pow_nonce}`);
2972
+
2973
+ // SECURITY: Verify that the retrieved context has not been tampered with
2974
+ if (challengeContext && challengeContext.signature) {
2975
+ const payloadToSign = `${challengeContext.clientSecret}:${challengeContext.cpuTarget}:${challengeContext.fingerprint}:${challengeContext.memDifficulty}:${challengeContext.originalPath}:${clientIp}`;
2976
+ const expectedSignature = crypto.createHmac("sha256", getPowSecret()).update(payloadToSign).digest("hex");
2977
+ try {
2978
+ const isSignatureValid = crypto.timingSafeEqual(
2979
+ Buffer.from(challengeContext.signature, 'hex'),
2980
+ Buffer.from(expectedSignature, 'hex')
2981
+ );
2982
+ if (!isSignatureValid) {
2983
+ this._log('Challenge context signature invalid - storage tampering detected!', { nonce: pow_nonce });
2984
+ challengeContext = null; // Invalidate context immediately
2985
+ }
2986
+ } catch (e) {
2987
+ this._log('Error validating challenge context signature:', e);
2988
+ challengeContext = null;
2989
+ }
2990
+ }
2912
2991
  let ticket = null;
2913
2992
  // Déclarer optimalTtl ici avec une valeur par défaut
2914
2993
  let optimalTtl = this.securityConfig.ticketMaxAge || 3600000;
@@ -2957,11 +3036,12 @@ export class FingerprintEngine {
2957
3036
 
2958
3037
  if ((pow_type === "cpu_target" || !pow_type) && (pow_solution_cpu || pow_solution)) { // !pow_type pour compatibilité
2959
3038
  const cpuSolution = pow_solution_cpu || pow_solution;
2960
- ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext);
3039
+ ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext, deviceId, currentDeviceHash);
2961
3040
  isValid = ticket !== null;
2962
3041
  this._log('CPU target challenge verification', { isValid });
2963
- } else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) { const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext);
2964
- const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret); // Memory PoW is independent of fingerprint
3042
+ } else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) {
3043
+ const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext, deviceId, currentDeviceHash);
3044
+ const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret); // Memory PoW is independent of fingerprint
2965
3045
  isValid = cpuTicket !== null && isMemValid;
2966
3046
  if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
2967
3047
  this._log('Combined CPU+Memory challenge verification', {
@@ -3087,7 +3167,10 @@ export class FingerprintEngine {
3087
3167
  if (challengeContext) {
3088
3168
  try {
3089
3169
  const workResult = JSON.parse(pow_solution_work_result);
3090
- getProblemManager(this.securityConfig.usefulWorkConfigPath).integrateSolution(pow_problem_id, workResult);
3170
+ getProblemManager({
3171
+ configPath: this.securityConfig.usefulWorkConfigPath,
3172
+ config: this.securityConfig.usefulWorkConfig
3173
+ }, store).integrateSolution(pow_problem_id, workResult);
3091
3174
 
3092
3175
  await store.delete(`secret:${pow_nonce}`);
3093
3176
  // Accorder un ticket de passage comme pour un PoW normal
@@ -3153,7 +3236,7 @@ export class FingerprintEngine {
3153
3236
  // 1. La requête est suspecte ET il n'y a pas de ticket valide.
3154
3237
  // OU
3155
3238
  // 2. La requête est *très* suspecte (dépasse le seuil 'high'), ce qui annule la validité du ticket actuel.
3156
- const hasValidTicket = isTicketValid(clientIp, powCookie);
3239
+ const hasValidTicket = isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash);
3157
3240
  const mustReChallenge = isSuspiciousHigh && hasValidTicket;
3158
3241
 
3159
3242
  if (isSuspicious && (!hasValidTicket || mustReChallenge)) {
@@ -3197,7 +3280,10 @@ export class FingerprintEngine {
3197
3280
  if (isSuspicious && shouldUseUsefulWork) {
3198
3281
  this._log('Issuing a useful work challenge', { finalScore });
3199
3282
 
3200
- const { problemId, task } = getProblemManager(this.securityConfig.usefulWorkConfigPath).dispatchWork(suspicionFactor);
3283
+ const { problemId, task } = getProblemManager({
3284
+ configPath: this.securityConfig.usefulWorkConfigPath,
3285
+ config: this.securityConfig.usefulWorkConfig
3286
+ }, store).dispatchWork(suspicionFactor);
3201
3287
 
3202
3288
  await store.set(`secret:${nonce}`, { clientSecret, originalPath: path }, 300);
3203
3289
 
@@ -3245,6 +3331,11 @@ export class FingerprintEngine {
3245
3331
 
3246
3332
  // Store the entire challenge context with a short TTL (e.g., 5 minutes)
3247
3333
  const baseBlock = createCpuChallengeBaseBlock(nonce, clientSecret, originalFingerprint);
3334
+
3335
+ // SECURITY: Cryptographically sign the payload before storing it to prevent database tampering
3336
+ const payloadToSign = `${clientSecret}:${cpuChallengeDetails.target}:${originalFingerprint}:${memDifficulty}:${path}:${clientIp}`;
3337
+ const signature = crypto.createHmac("sha256", getPowSecret()).update(payloadToSign).digest("hex");
3338
+
3248
3339
  await store.set(`secret:${nonce}`, {
3249
3340
  clientSecret,
3250
3341
  cpuTarget: cpuChallengeDetails.target,
@@ -3253,6 +3344,7 @@ export class FingerprintEngine {
3253
3344
  memDifficulty: memDifficulty,
3254
3345
  baseBlock: baseBlock, // *** NOUVEAU: Le bloc de base est stocké pour la vérification ***
3255
3346
  originalPath: path, // *** FIX: Store the original path ***
3347
+ signature, // *** NOUVEAU: Cryptographic signature to prevent storage tampering ***
3256
3348
  }, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
3257
3349
 
3258
3350
  // Associate the current challenge nonce with the device for trap URL verification later.
@@ -3302,7 +3394,7 @@ export class FingerprintEngine {
3302
3394
  }
3303
3395
 
3304
3396
  // Basic log for each non-static request that passed without a challenge
3305
- this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie) });
3397
+ this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash) });
3306
3398
 
3307
3399
  if (logger) {
3308
3400
  logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now(), vector: suspicionVector });
@@ -3365,68 +3457,123 @@ const staticExtensions = new RegExp(
3365
3457
  const isStaticResource = (path) => staticExtensions.test(path);
3366
3458
 
3367
3459
 
3460
+ /** @type {Map<number, number>} Cache des TTL optimisés par score de suspicion (clés de 0 à 100 par pas de 10) */
3461
+ let optimizedTtlCache = new Map();
3462
+
3368
3463
  /**
3369
- * Détermine le TTL optimal pour un ticket en utilisant un algorithme génétique multi-objectifs.
3370
- * @param {number} suspicionScore - Le score de suspicion de la requête.
3371
- * @returns {number} Le TTL optimal calculé en millisecondes.
3372
- */
3373
- function determineOptimalTicketTtl(suspicionScore) {
3374
- // Définir les bornes pour la durée de vie du ticket (5 minutes à 24 heures)
3464
+ * Exécute l'optimisation des TTL en tâche de fond de manière asynchrone et non-bloquante.
3465
+ * Utilise l'algorithme génétique multi-objectifs de Pareto pour trouver des solutions stables.
3466
+ */
3467
+ export async function runBackgroundTtlOptimization() {
3375
3468
  const MIN_TTL = 300000;
3376
3469
  const MAX_TTL = 86400000;
3470
+ const tempCache = new Map();
3471
+ const keyScores = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
3472
+
3473
+ for (const suspicionScore of keyScores) {
3474
+ // Rend la main à la boucle d'événements Node.js à chaque itération pour ne pas bloquer les requêtes web actives
3475
+ await new Promise(resolve => {
3476
+ if (typeof setImmediate === 'function') {
3477
+ setImmediate(resolve);
3478
+ } else {
3479
+ setTimeout(resolve, 0);
3480
+ }
3481
+ });
3377
3482
 
3378
- const solverFunction = () => {
3379
- const fitnessFunction = Optimization.Operators.createOptimalTtlEvaluator({ suspicionScore });
3483
+ const solverFunction = () => {
3484
+ const fitnessFunction = Optimization.Operators.createOptimalTtlEvaluator({ suspicionScore });
3485
+ const createIndividual = () => MIN_TTL + Math.random() * (MAX_TTL - MIN_TTL);
3486
+ const crossover = (ttl1, ttl2) => (ttl1 + ttl2) / 2;
3487
+ const mutate = (ttl) => {
3488
+ const newTtl = ttl + (Math.random() - 0.5) * (MAX_TTL - MIN_TTL) * 0.1;
3489
+ return Math.max(MIN_TTL, Math.min(MAX_TTL, newTtl));
3490
+ };
3380
3491
 
3381
- // Un "individu" est simplement une valeur de TTL en millisecondes.
3382
- const createIndividual = () => MIN_TTL + Math.random() * (MAX_TTL - MIN_TTL);
3383
- const crossover = (ttl1, ttl2) => (ttl1 + ttl2) / 2;
3384
- const mutate = (ttl) => {
3385
- const newTtl = ttl + (Math.random() - 0.5) * (MAX_TTL - MIN_TTL) * 0.1; // Mutation de +/- 10% max
3386
- return Math.max(MIN_TTL, Math.min(MAX_TTL, newTtl));
3387
- };
3492
+ const paretoFront = Optimization.geneticAlgorithmMultiObjective(
3493
+ createIndividual,
3494
+ fitnessFunction,
3495
+ crossover,
3496
+ mutate,
3497
+ {
3498
+ generations: 40,
3499
+ populationSize: 30,
3500
+ }
3501
+ );
3388
3502
 
3389
- const paretoFront = Optimization.geneticAlgorithmMultiObjective(
3390
- createIndividual,
3391
- fitnessFunction,
3392
- crossover,
3393
- mutate,
3394
- {
3395
- generations: 40,
3396
- populationSize: 30,
3503
+ if (!paretoFront || paretoFront.length === 0) {
3504
+ return { solution: null, fitness: Infinity };
3397
3505
  }
3398
- );
3399
3506
 
3400
- // Pour runMultiple, on doit retourner un objet avec une propriété "fitness" ou "energy".
3401
- // Pour un front de Pareto, il n'y a pas de score unique. On choisit la meilleure solution
3402
- // en fonction du score de suspicion et on lui assigne un score de 0 pour que runMultiple la sélectionne.
3403
- if (!paretoFront || paretoFront.length === 0) {
3404
- return { solution: null, fitness: Infinity };
3405
- }
3507
+ let bestSolutionInFront;
3508
+ if (suspicionScore < 50) {
3509
+ bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
3510
+ } else {
3511
+ bestSolutionInFront = paretoFront.reduce((min, p) => Math.min(min, p.solution), Infinity);
3512
+ }
3513
+ return { solution: bestSolutionInFront, fitness: 0 };
3514
+ };
3406
3515
 
3407
- // Stratégie de sélection :
3408
- // Pour un score faible (< 50), on privilégie la solution avec le plus grand TTL (minimise la friction).
3409
- // Pour un score élevé (>= 50), on privilégie la solution avec le plus petit TTL (minimise le risque).
3410
- let bestSolutionInFront;
3411
- if (suspicionScore < 50) {
3412
- bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
3516
+ const { bestResult } = Optimization.runMultiple(solverFunction, 20);
3517
+ if (bestResult && bestResult.solution && bestResult.solution !== Infinity) {
3518
+ tempCache.set(suspicionScore, Math.round(bestResult.solution));
3413
3519
  } else {
3414
- bestSolutionInFront = paretoFront.reduce((min, p) => Math.min(min, p.solution), Infinity);
3520
+ tempCache.set(suspicionScore, Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL));
3415
3521
  }
3416
- return { solution: bestSolutionInFront, fitness: 0 }; // fitness=0 car on a déjà la meilleure solution du cycle.
3417
- };
3522
+ }
3523
+
3524
+ optimizedTtlCache = tempCache;
3525
+ }
3526
+
3527
+ // Lancement de l'optimisation initiale immédiate en arrière-plan
3528
+ runBackgroundTtlOptimization().catch(err => {
3529
+ console.error('[Fingerprint] Error in background TTL optimization:', err);
3530
+ });
3418
3531
 
3419
- // On exécute le solveur 20 fois pour trouver une solution plus stable et robuste.
3420
- const { bestResult } = Optimization.runMultiple(solverFunction, 20);
3532
+ // Planification périodique toutes les 30 minutes sans bloquer la fermeture du processus Node.js (via unref)
3533
+ const ttlInterval = setInterval(() => {
3534
+ runBackgroundTtlOptimization().catch(err => {
3535
+ console.error('[Fingerprint] Error in background TTL optimization:', err);
3536
+ });
3537
+ }, 1800000);
3538
+ if (ttlInterval && typeof ttlInterval.unref === 'function') {
3539
+ ttlInterval.unref();
3540
+ }
3541
+
3542
+ /**
3543
+ * Détermine le TTL optimal pour un ticket.
3544
+ * Utilise les valeurs pré-calculées de la tâche d'optimisation en arrière-plan et effectue
3545
+ * une interpolation linéaire instantanée pour le score requis.
3546
+ *
3547
+ * @param {number} suspicionScore - Le score de suspicion de la requête.
3548
+ * @returns {number} Le TTL optimal calculé en millisecondes.
3549
+ */
3550
+ function determineOptimalTicketTtl(suspicionScore) {
3551
+ const MIN_TTL = 300000;
3552
+ const MAX_TTL = 86400000;
3553
+ const score = Math.max(0, Math.min(100, suspicionScore));
3554
+
3555
+ if (!optimizedTtlCache || optimizedTtlCache.size === 0) {
3556
+ // Formule mathématique instantanée de secours si le cache de fond n'est pas encore prêt
3557
+ return Math.round(MAX_TTL - (score / 100) * (MAX_TTL - MIN_TTL));
3558
+ }
3559
+
3560
+ const lowerKey = Math.floor(score / 10) * 10;
3561
+ const upperKey = Math.ceil(score / 10) * 10;
3562
+
3563
+ const lowerTtl = optimizedTtlCache.get(lowerKey);
3564
+ const upperTtl = optimizedTtlCache.get(upperKey);
3421
3565
 
3422
- if (!bestResult || !bestResult.solution || bestResult.solution === Infinity) {
3423
- // Fallback : si l'algo ne retourne rien, on applique une règle simple et sûre.
3424
- return Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL);
3566
+ if (lowerTtl === undefined || upperTtl === undefined) {
3567
+ return Math.round(MAX_TTL - (score / 100) * (MAX_TTL - MIN_TTL));
3425
3568
  }
3426
3569
 
3427
- // runMultiple choisit le meilleur résultat sur la base du score (ici, 0).
3428
- // La "meilleure" solution dépendra du cycle qui a trouvé le meilleur compromis.
3429
- return Math.round(bestResult.solution);
3570
+ if (lowerKey === upperKey) {
3571
+ return lowerTtl;
3572
+ }
3573
+
3574
+ // Interpolation linéaire entre les deux points clés optimisés du front de Pareto
3575
+ const fraction = (score - lowerKey) / (upperKey - lowerKey);
3576
+ return Math.round(lowerTtl + fraction * (upperTtl - lowerTtl));
3430
3577
  }
3431
3578
 
3432
3579
  /**
@@ -3744,6 +3891,7 @@ export const __internal = {
3744
3891
  FingerprintBuilder, // Export for testing
3745
3892
  calculateTarget,
3746
3893
  determineOptimalTicketTtl,
3894
+ runBackgroundTtlOptimization,
3747
3895
  getRequestPatternScore, // Expose for testing
3748
3896
  getBehaviorScore, // Expose for testing
3749
3897
  getCrossLayerInconsistency, // Expose for testing
@@ -3751,6 +3899,7 @@ export const __internal = {
3751
3899
  getTimeInconsistencyScore,
3752
3900
  getClickVarianceScore, // NOUVEAU: Expose pour les tests
3753
3901
  getTlsFingerprint, // NOUVEAU: Expose pour les tests
3902
+ sanitizeTrafficData, // NOUVEAU: Expose pour l'auto-tuner/tests
3754
3903
  getTlsSpoofingScore, // NOUVEAU: Expose pour les tests
3755
3904
  parseJa3,
3756
3905
  generateCpuTargetChallengePage,
@@ -3762,6 +3911,7 @@ export const __internal = {
3762
3911
  getSubnetScore, // Expose for testing
3763
3912
  getIpReputationScore, // Expose for testing
3764
3913
  updateIpReputationScore, // Expose for testing
3914
+ setLastBestSolution: (val) => { lastBestSolution = val; }, // Expose to test auto-tuning metrics
3765
3915
  };
3766
3916
 
3767
3917
  // --- THRESHOLD AUTO-TUNING SECTION ---
@@ -3769,25 +3919,60 @@ export const __internal = {
3769
3919
  let autoTuningJobId = null;
3770
3920
  let lastBestSolution = null; // NOUVEAU: Stocke la meilleure solution trouvée
3771
3921
 
3922
+ /**
3923
+ * Assainit les données de trafic pour l'auto-tuner afin de prévenir les attaques par empoisonnement.
3924
+ * Limite la contribution de chaque deviceId à un pourcentage maximum (ex: 2%) du jeu de données total.
3925
+ * @export
3926
+ * @param {Array<object>} trafficData
3927
+ * @returns {Array<object>}
3928
+ */
3929
+ export function sanitizeTrafficData(trafficData) {
3930
+ if (!trafficData || trafficData.length === 0) {
3931
+ return [];
3932
+ }
3933
+ const tempSanitized = [];
3934
+ const deviceCounts = new Map();
3935
+ const maxLogsPerDevice = Math.max(3, Math.floor(trafficData.length * 0.02)); // Max 2% contribution per device
3936
+
3937
+ for (const log of trafficData) {
3938
+ const devId = log.deviceId || 'anonymous';
3939
+ const currentCount = deviceCounts.get(devId) || 0;
3940
+ if (currentCount < maxLogsPerDevice) {
3941
+ deviceCounts.set(devId, currentCount + 1);
3942
+ tempSanitized.push(log);
3943
+ }
3944
+ }
3945
+
3946
+ const passedLogs = tempSanitized.filter(log => log.type === 'request_passed');
3947
+ const suspiciousLogs = tempSanitized.filter(log => log.type !== 'request_passed');
3948
+
3949
+ const minDataPoints = 200; // Seuil par défaut
3950
+ const maxPassedAllowed = Math.max(minDataPoints, suspiciousLogs.length * 9);
3951
+ const shuffledPassed = passedLogs.sort(() => 0.5 - Math.random());
3952
+ const selectedPassed = shuffledPassed.slice(0, maxPassedAllowed);
3953
+
3954
+ return [...suspiciousLogs, ...selectedPassed];
3955
+ }
3956
+
3772
3957
  /**
3773
3958
  * Executes a threshold optimization pass using collected traffic data.
3774
3959
  * @private
3775
- * @param {object} securityConfig - The security configuration object to update.
3776
- * @param {Array<object>} trafficData - The array containing traffic logs.
3777
- * @param {number} minDataPoints - The minimum number of data points required to start optimization.
3778
- * @param {number} maxDataPoints - The maximum number of data points to keep after an optimization cycle.
3779
- * @param {string} [savePath] - Optional path to save the best configuration to a file.
3780
3960
  */
3781
3961
  function runThresholdOptimization(securityConfig, trafficData, minDataPoints, maxDataPoints, savePath) {
3782
- const highConfidenceLogs = trafficData.filter(log => log.type === 'challenge_solved' || log.type === 'trap_triggered').length;
3783
- const highConfidenceRatio = trafficData.length > 0 ? highConfidenceLogs / trafficData.length : 0;
3962
+ const sanitizedData = sanitizeTrafficData(trafficData);
3963
+
3964
+ const highConfidenceLogs = sanitizedData.filter(log => log.type === 'challenge_solved' || log.type === 'trap_triggered').length;
3965
+ const highConfidenceRatio = sanitizedData.length > 0 ? highConfidenceLogs / sanitizedData.length : 0;
3784
3966
  const MIN_CONFIDENCE_RATIO = 0.05; // Exiger au moins 5% de signaux forts.
3967
+ const MIN_HIGH_CONFIDENCE_COUNT = 10; // Absolu de secours pour éviter le gel lors de floods
3968
+
3969
+ const hasEnoughSignal = highConfidenceRatio >= MIN_CONFIDENCE_RATIO || highConfidenceLogs >= MIN_HIGH_CONFIDENCE_COUNT;
3785
3970
 
3786
- if (trafficData.length < minDataPoints || highConfidenceRatio < MIN_CONFIDENCE_RATIO) {
3787
- if (trafficData.length < minDataPoints) {
3788
- console.log(`[AutoTuning] Reporté : ${trafficData.length}/${minDataPoints} points de données.`);
3971
+ if (sanitizedData.length < minDataPoints || !hasEnoughSignal) {
3972
+ if (sanitizedData.length < minDataPoints) {
3973
+ console.log(`[AutoTuning] Reporté : ${sanitizedData.length}/${minDataPoints} points de données.`);
3789
3974
  } else {
3790
- console.log(`[AutoTuning] Reporté : Ratio de confiance insuffisant (${(highConfidenceRatio * 100).toFixed(2)}% < ${(MIN_CONFIDENCE_RATIO * 100).toFixed(2)}%).`);
3975
+ console.log(`[AutoTuning] Reporté : Signaux de confiance insuffisants (Ratio: ${(highConfidenceRatio * 100).toFixed(2)}% < ${(MIN_CONFIDENCE_RATIO * 100).toFixed(2)}% et absolu: ${highConfidenceLogs} < ${MIN_HIGH_CONFIDENCE_COUNT}).`);
3791
3976
  }
3792
3977
  return;
3793
3978
  }
@@ -3797,9 +3982,9 @@ function runThresholdOptimization(securityConfig, trafficData, minDataPoints, ma
3797
3982
  trafficData.splice(0, trafficData.length - maxDataPoints);
3798
3983
  }
3799
3984
 
3800
- console.log(`[AutoTuning] Démarrage du cycle d'optimisation complet avec ${trafficData.length} points de données.`);
3985
+ console.log(`[AutoTuning] Démarrage du cycle d'optimisation complet avec ${sanitizedData.length} points de données assainis.`);
3801
3986
 
3802
- const paretoFront = Optimization.Operators.solveFullSecurityTuning({ trafficData });
3987
+ const paretoFront = Optimization.Operators.solveFullSecurityTuning({ trafficData: sanitizedData });
3803
3988
 
3804
3989
  if (!paretoFront || paretoFront.length === 0) {
3805
3990
  console.warn("[AutoTuning] L'optimisation n'a retourné aucune solution.");
@@ -3944,3 +4129,93 @@ export function stopThresholdAutoTuning() {
3944
4129
  export function getBestTuningSolution() {
3945
4130
  return lastBestSolution;
3946
4131
  }
4132
+
4133
+ class RequestContext {
4134
+ constructor(ip, path, headers, query, body, cookies, httpVersion) {
4135
+ this.clientIp = ip || '127.0.0.1';
4136
+ this.path = path || '/';
4137
+ this.headers = headers || {};
4138
+ this.query = query || {};
4139
+ this.body = body || null;
4140
+ this.cookies = cookies || {};
4141
+ this.httpVersion = httpVersion || '1.1';
4142
+ }
4143
+ }
4144
+
4145
+ const MetricsManager = {
4146
+ getPrometheusMetrics(securityConfig = {}) {
4147
+ let metrics = `# HELP fingerprint_requests_total Total requests processed.\n# TYPE fingerprint_requests_total counter\nfingerprint_requests_total{status="passed"} 1\n`;
4148
+
4149
+ if (securityConfig.weights) {
4150
+ metrics += `\n# HELP fingerprint_security_weight Active weight for each suspicion indicator.\n# TYPE fingerprint_security_weight gauge\n`;
4151
+ for (const [indicator, weight] of Object.entries(securityConfig.weights)) {
4152
+ if (typeof weight === 'number') {
4153
+ metrics += `fingerprint_security_weight{indicator="${indicator}"} ${weight}\n`;
4154
+ }
4155
+ }
4156
+ }
4157
+
4158
+ if (securityConfig.thresholds) {
4159
+ metrics += `\n# HELP fingerprint_security_threshold Active score threshold for each enforcement action level.\n# TYPE fingerprint_security_threshold gauge\n`;
4160
+ for (const [level, threshold] of Object.entries(securityConfig.thresholds)) {
4161
+ if (typeof threshold === 'number') {
4162
+ metrics += `fingerprint_security_threshold{level="${level}"} ${threshold}\n`;
4163
+ }
4164
+ }
4165
+ }
4166
+
4167
+ // Include auto-tuning objectives metrics if the auto-tuner has run
4168
+ if (lastBestSolution && lastBestSolution.objectives) {
4169
+ metrics += `\n# HELP fingerprint_autotuning_false_positive_rate Current false positive rate calculated by the auto-tuner.\n# TYPE fingerprint_autotuning_false_positive_rate gauge\nfingerprint_autotuning_false_positive_rate ${lastBestSolution.objectives[0]}\n`;
4170
+ metrics += `\n# HELP fingerprint_autotuning_false_negative_rate Current false negative rate calculated by the auto-tuner.\n# TYPE fingerprint_autotuning_false_negative_rate gauge\nfingerprint_autotuning_false_negative_rate ${lastBestSolution.objectives[1]}\n`;
4171
+ }
4172
+
4173
+ return metrics;
4174
+ }
4175
+ };
4176
+
4177
+ /**
4178
+ * Gère une requête vers le point de terminaison /metrics, en appliquant les règles d'autorisation.
4179
+ * Si les métriques sont activées et autorisées, elle renvoie les métriques au format Prometheus.
4180
+ * Sinon, elle gère l'accès non autorisé ou renvoie un 404 si les métriques ne sont pas activées.
4181
+ *
4182
+ * @param {object} req L'objet requête Express.
4183
+ * @param {object} res L'objet réponse Express.
4184
+ * @param {object} securityConfig La configuration de sécurité.
4185
+ */
4186
+ export async function handleMetricsRequest(req, res, securityConfig) {
4187
+ // 2. Appliquer le callback d'autorisation personnalisé si défini.
4188
+ const authorizationCallback = securityConfig.metricsAuthorizationCallback;
4189
+ if (typeof authorizationCallback === 'function') {
4190
+ const context = new RequestContext(
4191
+ req.ip,
4192
+ req.path,
4193
+ req.headers,
4194
+ req.query,
4195
+ req.body,
4196
+ req.cookies,
4197
+ req.httpVersion
4198
+ );
4199
+
4200
+ const decision = await authorizationCallback(context); // Supposons que le callback peut être asynchrone
4201
+
4202
+ if (typeof decision === 'boolean') {
4203
+ if (!decision) {
4204
+ res.status(403).send('Access to metrics denied.');
4205
+ return;
4206
+ }
4207
+ } else if (typeof decision === 'object' && decision !== null && decision.action) {
4208
+ if (decision.action === 'block') {
4209
+ res.status(decision.status || 403).send(decision.body || 'Access denied.');
4210
+ return;
4211
+ } else if (decision.action === 'redirect') {
4212
+ res.redirect(decision.status || 302, decision.path);
4213
+ return;
4214
+ }
4215
+ }
4216
+ }
4217
+
4218
+ // 3. Si autorisé, servir les métriques.
4219
+ res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
4220
+ res.send(MetricsManager.getPrometheusMetrics(securityConfig));
4221
+ }