@anonympins/fingerprint 0.1.1 → 0.1.2

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.
Files changed (3) hide show
  1. package/fingerprint.js +2267 -2267
  2. package/package.json +76 -76
  3. package/pow.solver.js +213 -213
package/fingerprint.js CHANGED
@@ -1,2267 +1,2267 @@
1
- // C:/Dev/games.primals.net/src/utils/fingerprint.js
2
- import crypto from "node:crypto";
3
- import { BlockList } from "node:net";
4
- import dns from "node:dns/promises";
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
- export { createRedisStore } from "./redis-store.js";
11
- export { createMongoDbStore } from "./mongodb-store.js";
12
-
13
- /**
14
- * Retrieves the POW_SECRET from environment variables with appropriate checks.
15
- * @returns {string} The secret key.
16
- */
17
- const getPowSecret = () => {
18
- const secret = process.env.POW_SECRET;
19
- if (!secret && process.env.NODE_ENV === 'production') {
20
- throw new Error('POW_SECRET environment variable is not set. This is required for production.');
21
- }
22
- return secret || "fallback-dev-secret-32-chars-minimum";
23
- };
24
-
25
- /**
26
- * Loads the pow.solver.js content for inlining in HTML pages.
27
- * @returns {string} The solver JavaScript code.
28
- */
29
- const getPowSolverCode = () => {
30
- try {
31
- const __filename = fileURLToPath(import.meta.url);
32
- const __dirname = dirname(__filename);
33
- const solverPath = join(__dirname, 'pow.solver.js');
34
- return readFileSync(solverPath, 'utf-8');
35
- } catch (error) {
36
- console.warn('Could not load pow.solver.js for inlining, using fallback inline code');
37
- // Fallback inline code if file cannot be loaded
38
- return `(function(global){
39
- async function solveCpuTargetInline(clientIp, nonce, target, clientSecret, progressCallback){
40
- const cpuTarget = BigInt(target);
41
- let cpuSolution = 0;
42
- while(true){
43
- const msg = clientSecret ? clientIp+':'+nonce+':'+cpuSolution+':'+clientSecret : clientIp+':'+nonce+':'+cpuSolution;
44
- const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
45
- const hashHex = Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('');
46
- if(BigInt('0x'+hashHex) < cpuTarget) break;
47
- cpuSolution++;
48
- if(cpuSolution % 100000 === 0) await new Promise(r=>setTimeout(r,0));
49
- }
50
- return cpuSolution;
51
- }
52
- async function solveMemory(seed, difficulty){
53
- const size = difficulty * 1024 * 1024;
54
- const buffer = new Uint32Array(size / 4);
55
- let h = new TextEncoder().encode(seed).reduce((acc,v)=>acc+v,0);
56
- for(let i=0;i<buffer.length;i++) buffer[i] = h = Math.imul(h^i,1597334677);
57
- let solution = 0;
58
- const iterations = size / 16;
59
- let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
60
- for(let i=0;i<iterations;i++){
61
- addr = buffer[addr] % buffer.length;
62
- solution ^= addr;
63
- }
64
- return solution;
65
- }
66
- async function solveTsp(cities, targetMaxDistance){
67
- function distance(c1,c2){return Math.sqrt(Math.pow(c1.x-c2.x,2)+Math.pow(c1.y-c2.y,2));}
68
- function evaluatePathDistance(cities,path){
69
- let total=0;
70
- for(let i=0;i<path.length-1;i++) total+=distance(cities[path[i]],cities[path[i+1]]);
71
- total+=distance(cities[path[path.length-1]],cities[path[0]]);
72
- return total;
73
- }
74
- function solveTspNearestNeighbor(cities){
75
- const n=cities.length;
76
- if(n===0)return[];
77
- let path=[0];
78
- let visited=new Array(n).fill(false);
79
- visited[0]=true;
80
- for(let i=1;i<n;i++){
81
- let nearest=-1, minDist=Infinity;
82
- for(let j=0;j<n;j++){
83
- if(!visited[j]){
84
- const d=distance(cities[path[i-1]],cities[j]);
85
- if(d<minDist){minDist=d;nearest=j;}
86
- }
87
- }
88
- path.push(nearest);
89
- visited[nearest]=true;
90
- }
91
- return path;
92
- }
93
- await new Promise(r=>setTimeout(r,10));
94
- const solutionPath=solveTspNearestNeighbor(cities);
95
- const solutionDistance=evaluatePathDistance(cities,solutionPath);
96
- return{path:solutionPath,distance:solutionDistance};
97
- }
98
- global.solveCpuChallengeInline=solveCpuTargetInline;
99
- global.solveMemoryChallenge=solveMemory;
100
- global.solveTspChallenge=solveTsp;
101
- })(typeof window!=='undefined'?window:global);`;
102
- }
103
- };
104
-
105
- /**
106
- * Calculates the JA3 fingerprint hash from the TLS Client Hello message.
107
- * JA3 is a more reliable way to identify client applications (e.g., a specific browser or a script)
108
- * based on the specifics of its TLS handshake.
109
- * @param {object} context - The request context, containing the raw request object.
110
- * @returns {string|null} The MD5 hash of the JA3 string, or null if it cannot be computed.
111
- */
112
- function getJa3Hash(context) {
113
- // 1. Prefer the JA3 hash from a trusted reverse proxy (e.g., Nginx, Cloudflare).
114
- const ja3FromHeader = context.headers['x-ja3-hash'];
115
- if (ja3FromHeader) {
116
- return ja3FromHeader;
117
- }
118
-
119
- // 2. Fallback to calculating from the raw socket if available (requires Node.js to handle TLS).
120
- const clientHello = context.rawReq?.socket?.clientHello;
121
- if (!clientHello) {
122
- return null;
123
- }
124
-
125
- try {
126
- const { version, ciphers, extensions, ellipticCurves, ellipticCurvePointFormats } = clientHello;
127
-
128
- // The official JA3 spec includes the TLS version.
129
- // Node.js provides it as a string like 'TLSv1.3', we need the corresponding decimal value.
130
- const tlsVersionMap = {
131
- 'TLSv1': 769, 'TLSv1.1': 770, 'TLSv1.2': 771, 'TLSv1.3': 772
132
- };
133
- const tlsVersionId = tlsVersionMap[version] || 0;
134
-
135
- const ja3String = [
136
- tlsVersionId,
137
- // The ciphers array from clientHello is an array of objects, not just IDs.
138
- Array.isArray(ciphers) ? ciphers.join('-') : '',
139
- extensions?.join('-') || '',
140
- ellipticCurves?.join('-') || '',
141
- ellipticCurvePointFormats?.join('-') || ''
142
- ].join(',');
143
-
144
- return crypto.createHash('md5').update(ja3String).digest('hex');
145
- } catch (e) {
146
- return null; // Could fail if clientHello structure is unexpected.
147
- }
148
- }
149
- /**
150
- * Creates a stable hash based on device characteristics, independent of the IP.
151
- * This is our "level 2 fingerprint".
152
- * @param {object} context - The request context.
153
- * @returns {string} A hash representing the device.
154
- */
155
- function getHeaderSignature(context) {
156
- if (!context.rawHeaders) return '';
157
- const headerKeys = [];
158
- for (let i = 0; i < context.rawHeaders.length; i += 2) {
159
- headerKeys.push(context.rawHeaders[i]);
160
- }
161
- return cyrb53(headerKeys.join(','));
162
- }
163
- export function getDeviceHash(context) {
164
- // Prioritize the rich client-side fingerprint if provided.
165
- const clientFp = context.headers['x-device-fingerprint'];
166
- if (clientFp && typeof clientFp === 'string' && clientFp.includes('cvs:')) {
167
- return clientFp;
168
- }
169
-
170
- const srv = new FingerprintBuilder();
171
-
172
- // 1. SIGNAL FORT: User Agent (poids élevé)
173
- const ua = context.headers["user-agent"];
174
- if (ua) {
175
- srv.add("ua", ua);
176
- // Extraire des infos supplémentaires du UA
177
- const uaParts = parseUserAgent(ua);
178
- if (uaParts.browser) srv.add("browser", uaParts.browser);
179
- if (uaParts.os) srv.add("os_version", uaParts.os);
180
- if (uaParts.device) srv.add("device_type", uaParts.device);
181
- }
182
-
183
- // 2. SIGNAL FORT: JA3 TLS Fingerprint
184
- const ja3 = getJa3Hash(context);
185
- if (ja3) srv.add("ja3", ja3);
186
-
187
- // 3. SIGNAL MOYEN: Client Hints (modern browsers)
188
- if (context.headers["sec-ch-ua"]) {
189
- srv.add("ch_ua", context.headers["sec-ch-ua"]);
190
- }
191
- if (context.headers["sec-ch-ua-platform"]) {
192
- srv.add("ch_platform", context.headers["sec-ch-ua-platform"]);
193
- }
194
- if (context.headers["sec-ch-ua-mobile"]) {
195
- srv.add("ch_mobile", context.headers["sec-ch-ua-mobile"]);
196
- }
197
- if (context.headers["sec-ch-ua-model"]) {
198
- srv.add("ch_model", context.headers["sec-ch-ua-model"]);
199
- }
200
- if (context.headers["sec-ch-ua-arch"]) {
201
- srv.add("ch_arch", context.headers["sec-ch-ua-arch"]);
202
- }
203
- if (context.headers["sec-ch-ua-bitness"]) {
204
- srv.add("ch_bitness", context.headers["sec-ch-ua-bitness"]);
205
- }
206
-
207
- // 4. SIGNAL MOYEN: HTTP Version et protocole
208
- if (context.httpVersion) {
209
- srv.add("http_ver", context.httpVersion);
210
- }
211
- if (context.headers["upgrade-insecure-requests"]) {
212
- srv.add("upgrade", context.headers["upgrade-insecure-requests"]);
213
- }
214
-
215
- // 10. SIGNAL FORT: Ordonnancement des headers
216
- srv.add("h_ord", getHeaderSignature(context));
217
-
218
- // 11. SIGNAL AVANCÉ: Cookies (si disponible)
219
- if (context.cookies) {
220
- const cookieKeys = Object.keys(context.cookies).sort().join(',');
221
- srv.add("cookie_keys", cookieKeys);
222
- }
223
-
224
- // 12. SIGNAL AVANCÉ: Format de la requête
225
- if (context.rawHeaders) {
226
- // Vérifier des headers spécifiques qui indiquent le client
227
- const clientHeaders = ['x-requested-with', 'x-forwarded-for', 'x-real-ip', 'cf-connecting-ip'];
228
- clientHeaders.forEach(h => {
229
- if (context.headers[h]) {
230
- srv.add(h.replace(/-/g, '_'), context.headers[h]);
231
- }
232
- });
233
- }
234
-
235
- // 13. OPTIONNEL: IP (version simplifiée pour les réseaux partagés)
236
- // Ne pas inclure l'IP complète, mais un hash du réseau /24 ou /16
237
- // pour détecter les changements de réseau tout en protégeant la vie privée
238
- const ip = context.clientIp || context.headers['x-forwarded-for']?.split(',')[0]?.trim();
239
- if (ip && isPrivateIp(ip)) {
240
- // Pour les IP privées, on peut prendre le /24
241
- const networkHash = hashNetwork(ip, 24);
242
- srv.add("network", networkHash);
243
- }
244
-
245
- return srv.toString();
246
- }
247
-
248
- // Fonctions utilitaires
249
- function parseUserAgent(ua) {
250
- // Parser basique du User-Agent
251
- const result = {};
252
-
253
- // Détection du navigateur
254
- if (ua.includes('Chrome') && !ua.includes('Edg')) {
255
- result.browser = 'Chrome';
256
- const match = ua.match(/Chrome\/(\d+)/);
257
- if (match) result.browser += `/${match[1]}`;
258
- } else if (ua.includes('Firefox')) {
259
- result.browser = 'Firefox';
260
- const match = ua.match(/Firefox\/(\d+)/);
261
- if (match) result.browser += `/${match[1]}`;
262
- } else if (ua.includes('Safari') && !ua.includes('Chrome')) {
263
- result.browser = 'Safari';
264
- const match = ua.match(/Version\/(\d+)/);
265
- if (match) result.browser += `/${match[1]}`;
266
- } else if (ua.includes('Edg')) {
267
- result.browser = 'Edge';
268
- const match = ua.match(/Edg\/(\d+)/);
269
- if (match) result.browser += `/${match[1]}`;
270
- }
271
-
272
- // Détection de l'OS
273
- if (ua.includes('Windows NT 10.0')) result.os = 'Windows 10';
274
- else if (ua.includes('Windows NT 6.1')) result.os = 'Windows 7';
275
- else if (ua.includes('Mac OS X')) result.os = 'macOS';
276
- else if (ua.includes('Linux') && !ua.includes('Android')) result.os = 'Linux';
277
- else if (ua.includes('Android')) result.os = 'Android';
278
- else if (ua.includes('iPhone') || ua.includes('iPad')) result.os = 'iOS';
279
-
280
- // Détection du type d'appareil
281
- if (ua.includes('Mobile')) result.device = 'mobile';
282
- else if (ua.includes('Tablet')) result.device = 'tablet';
283
- else result.device = 'desktop';
284
-
285
- return result;
286
- }
287
-
288
- function normalizeReferer(referer) {
289
- try {
290
- const url = new URL(referer);
291
- return `${url.protocol}//${url.hostname}`;
292
- } catch {
293
- return referer;
294
- }
295
- }
296
-
297
- function isPrivateIp(ip) {
298
- // Vérifier si l'IP est privée
299
- const parts = ip.split('.');
300
- if (parts.length !== 4) return false;
301
- const first = parseInt(parts[0]);
302
- return (first === 10) || (first === 172 && parseInt(parts[1]) >= 16 && parseInt(parts[1]) <= 31) || (first === 192 && parseInt(parts[1]) === 168);
303
- }
304
-
305
- function hashNetwork(ip, prefix = 24) {
306
- // Hash du réseau (masque /24 ou /16)
307
- const parts = ip.split('.');
308
- if (parts.length !== 4) return null;
309
- const maskBytes = prefix / 8;
310
- const network = parts.slice(0, maskBytes).join('.');
311
- // Hash simple
312
- let hash = 0;
313
- for (let i = 0; i < network.length; i++) {
314
- const char = network.charCodeAt(i);
315
- hash = ((hash << 5) - hash) + char;
316
- hash = hash & hash;
317
- }
318
- return hash.toString(16);
319
- }
320
-
321
- /**
322
- * Generates the HTML content for a TSP (Traveling Salesperson Problem) challenge.
323
- * @param {string} nonce - Unique nonce for the challenge.
324
- * @param {number} numCities - Number of cities to include in the problem.
325
- * @param {number} targetMaxDistance - Maximum acceptable distance for the solution.
326
- * @param {Array<{x: number, y: number}>} cities - Coordinates of the cities.
327
- * @param {string} path - Redirect path after solving.
328
- * @returns {string} HTML of the challenge page.
329
- */
330
- const generateTspChallenge = (
331
- nonce,
332
- numCities,
333
- targetMaxDistance,
334
- cities,
335
- path = "",
336
- ) => {
337
- const citiesJson = JSON.stringify(cities);
338
- const solverCode = getPowSolverCode();
339
- return `
340
- <html>
341
- <head><title>Advanced Security Check (Level 3)</title></head>
342
- <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
343
- <h1>Ultimate Verification (Level 3)</h1>
344
- <p>Please solve this small optimization problem to prove you are human.</p>
345
- <div id="loader" style="margin:20px;">⚙️ Calculating route... (${numCities} cities)</div>
346
- <script>${solverCode}</script>
347
- <script>
348
- const cities = ${citiesJson};
349
- const nonce = "${nonce}";
350
- const targetMaxDistance = ${targetMaxDistance};
351
-
352
- async function solve() {
353
- const result = await window.solveTspChallenge(cities, targetMaxDistance);
354
-
355
- if (result.distance <= targetMaxDistance) {
356
- window.location.href = "${path}" + "?pow_type=tsp&pow_nonce=" + nonce + "&pow_solution=" + JSON.stringify(result.path);
357
- } else {
358
- document.getElementById('loader').innerText = "Error: Could not find a sufficient solution. Please try again.";
359
- }
360
- }
361
- solve();
362
- </script>
363
- </body>
364
- </html>`;
365
- };
366
-
367
- /**
368
- * Verifies a TSP PoW solution.
369
- * @param {string} nonce - The challenge nonce.
370
- * @param {string} solutionPathJson - The path proposed by the client (stringified JSON).
371
- * @param {number} numCities - The number of cities in the challenge.
372
- * @param {number} targetMaxDistance - The maximum acceptable distance.
373
- * @param {Array<{x: number, y: number}>} cities - The coordinates of the cities.
374
- * @returns {boolean} True if the solution is valid.
375
- */
376
- export const verifyTspChallenge = (
377
- nonce,
378
- solutionPathJson,
379
- numCities,
380
- targetMaxDistance,
381
- cities,
382
- ) => {
383
- try {
384
- const solutionPath = JSON.parse(solutionPathJson);
385
- if (!Array.isArray(solutionPath) || solutionPath.length !== numCities)
386
- return false;
387
-
388
- // Verify that the path is a valid permutation of the cities
389
- const uniqueCities = new Set(solutionPath);
390
- if (
391
- uniqueCities.size !== numCities ||
392
- Math.min(...solutionPath) < 0 ||
393
- Math.max(...solutionPath) >= numCities
394
- )
395
- return false;
396
-
397
- // Recalculate the distance on the server side
398
- let totalDistance = 0;
399
- let totalPenalty = 0;
400
-
401
- // Function to calculate the angle between 3 points (p1 -> p2 -> p3)
402
- const calculateAngle = (p1, p2, p3) => {
403
- const v1 = { x: p1.x - p2.x, y: p1.y - p2.y };
404
- const v2 = { x: p3.x - p2.x, y: p3.y - p2.y };
405
- const dotProduct = v1.x * v2.x + v1.y * v2.y;
406
- const mag1 = Math.sqrt(v1.x * v1.x + v1.y * v1.y);
407
- const mag2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y);
408
- if (mag1 === 0 || mag2 === 0) return 180;
409
- const angleRad = Math.acos(dotProduct / (mag1 * mag2));
410
- return angleRad * (180 / Math.PI);
411
- };
412
-
413
- for (let i = 0; i < solutionPath.length; i++) {
414
- const p1_idx = solutionPath[i];
415
- const p2_idx = solutionPath[(i + 1) % numCities];
416
- const p3_idx = solutionPath[(i + 2) % numCities];
417
-
418
- // 1. Calculate segment distance
419
- totalDistance += Math.sqrt(Math.pow(cities[p1_idx].x - cities[p2_idx].x, 2) + Math.pow(cities[p1_idx].y - cities[p2_idx].y, 2));
420
-
421
- // 2. Calculate turn penalty
422
- const angle = calculateAngle(
423
- cities[p1_idx],
424
- cities[p2_idx],
425
- cities[p3_idx],
426
- );
427
- if (angle < 45) {
428
- // Penalty for very sharp turns (< 45 degrees)
429
- totalPenalty += (45 - angle) * 5; // The penalty is proportional to the sharpness of the angle
430
- }
431
- }
432
-
433
- const finalScore = totalDistance + totalPenalty;
434
- return finalScore <= targetMaxDistance;
435
- } catch (e) {
436
- console.error("Error during TSP challenge verification:", e);
437
- return false;
438
- }
439
- };
440
-
441
- /**
442
- * Generates the HTML content for the CPU PoW challenge (SHA-256).
443
- */
444
- const generateCpuPoWChallenge = (
445
- clientIp,
446
- nonce,
447
- difficulty = 4,
448
- path = "",
449
- ) => {
450
- return `
451
- <html>
452
- <head><title>Security Check</title></head>
453
- <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
454
- <h1>One moment... (Level 1)</h1>
455
- <p>We are verifying that you are not a bot. This takes a few seconds.</p>
456
- <div id="loader" style="margin:20px;">⚙️ Performing CPU security calculation...</div>
457
- <script>
458
- async function solve() {
459
- const ip = "${clientIp}";
460
- const nonce = "${nonce}";
461
- const diff = ${difficulty};
462
- const target = "0".repeat(diff);
463
- let solution = 0;
464
-
465
- while (true) {
466
- const msg = "${ip}" + ":" + "${nonce}" + ":" + solution;
467
- const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
468
- const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
469
- if (hash.startsWith(target)) break;
470
- solution++;
471
- if (solution % 100000 === 0) await new Promise(resolve => setTimeout(resolve, 0)); // To avoid freezing the browser
472
- }
473
- window.location.href = "${path}" + "?pow_type=cpu&pow_nonce=" + nonce + "&pow_solution=" + solution;
474
- }
475
- solve();
476
- </script>
477
- </body>
478
- </html>
479
- `;
480
- };
481
-
482
- /**
483
- * Generates the HTML content for a memory-intensive PoW challenge.
484
- */
485
- const generateMemoryPoWChallenge = (
486
- clientIp,
487
- nonce,
488
- difficulty = 16,
489
- path = "",
490
- ) => {
491
- // difficulty here is the buffer size in MB.
492
- return `
493
- <html>
494
- <head><title>Advanced Security Check</title></head>
495
- <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
496
- <h1>Enhanced Verification... (Level 2)</h1>
497
- <p>Your activity requires an additional security check.</p>
498
- <div id="loader" style="margin:20px;">⚙️ Performing memory allocation and calculation... (${difficulty} MB)</div>
499
- <script>
500
- async function solve() {
501
- const nonce = "${nonce}";
502
- const size = ${difficulty} * 1024 * 1024; // en octets
503
- const iterations = size / 16;
504
-
505
- try {
506
- const buffer = new Uint32Array(size / 4);
507
- let h = new TextEncoder().encode(nonce).reduce((acc, v) => acc + v, 0);
508
- for (let i = 0; i < buffer.length; i++) {
509
- buffer[i] = (h = Math.imul(h ^ i, 1597334677));
510
- }
511
-
512
- let finalHash = 0;
513
- for(let i = 0; i < iterations; i++) {
514
- const addr = buffer[i % buffer.length] % buffer.length;
515
- finalHash ^= buffer[addr];
516
- }
517
- window.location.href = "${path}" + "?pow_type=mem&pow_nonce=" + nonce + "&pow_solution=" + finalHash;
518
- } catch(e) {
519
- document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
520
- }
521
- }
522
- solve();
523
- </script>
524
- </body>
525
- </html>`;
526
- };
527
-
528
- /**
529
- * Verifies if a PoW solution is valid and generates a clearance ticket.
530
- */
531
- export const verifyPoWAndGenerateTicket = (
532
- ip,
533
- nonce,
534
- solution,
535
- difficulty = 4,
536
- ) => {
537
- // 1. Verify the solution: hash(ip + nonce + solution) must start with N zeros
538
- const hash = crypto
539
- .createHash("sha256")
540
- .update(`${ip}:${nonce}:${solution}`)
541
- .digest("hex");
542
-
543
- if (!hash.startsWith("0".repeat(difficulty))) {
544
- return null;
545
- }
546
-
547
- // 2. Generate an HMAC ticket so the client doesn't have to do it again for 1 hour
548
- const expiry = Date.now() + 3600000; // 1 heure
549
- const signature = crypto
550
- .createHmac("sha256", getPowSecret())
551
- .update(`${ip}:${expiry}`)
552
- .digest("hex");
553
-
554
- return `${expiry}:${signature}`;
555
- };
556
-
557
-
558
-
559
- /**
560
- * Verifies a memory PoW solution.
561
- * The server performs the same calculation to validate.
562
- */
563
- export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret) => {
564
- const size = difficulty * 1024 * 1024;
565
- const iterations = size / 16;
566
- const buffer = new Uint32Array(size / 4);
567
- const seed = clientSecret ? `${nonce}:${clientSecret}` : nonce;
568
- let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
569
-
570
- for (let i = 0; i < buffer.length; i++) {
571
- buffer[i] = h = Math.imul(h ^ i, 1597334677);
572
- }
573
-
574
- let finalHash = 0;
575
- let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
576
- for (let i = 0; i < iterations; i++) {
577
- addr = buffer[addr] % buffer.length;
578
- finalHash ^= addr;
579
- }
580
- return finalHash === parseInt(solution, 10);
581
- };
582
- export const isTicketValid = (ip, ticket) => {
583
- if (!ticket) return false;
584
- const [expiry, sig] = ticket.split(":");
585
- if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
586
- const expectedSig = crypto
587
- .createHmac("sha256", getPowSecret())
588
- .update(`${ip}:${expiry}`)
589
- .digest("hex");
590
-
591
- // Use timingSafeEqual to prevent timing attacks
592
- try {
593
- return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
594
- } catch (e) {
595
- // This can happen if the buffers have different lengths, which is a failure case.
596
- return false;
597
- }
598
- };
599
-
600
-
601
- /**
602
- * Calculates suspicion indicators related to HTTP header anomalies.
603
- * @param {object} context - The request context.
604
- * @returns {{headerAnomalyScore: number}}
605
- */
606
- function getHeaderAnomalies(context) {
607
- let anomalyScore = 0;
608
- // Strong penalty if User-Agent is missing or very short (sign of a simple script)
609
- if (!context.headers["user-agent"] || context.headers["user-agent"].length < 10) {
610
- anomalyScore += 60;
611
- }
612
- // Penalty if Accept-Language header is missing
613
- if (!context.headers["accept-language"]) {
614
- anomalyScore += 25;
615
- }
616
- // Penalty for HTTP/1.0 requests, often used by old tools or bots
617
- if (context.httpVersion === "1.0") {
618
- anomalyScore += 15;
619
- }
620
-
621
- return {
622
- headerAnomalyScore: Math.min(100, anomalyScore),
623
- };
624
- }
625
-
626
- /**
627
- * Checks for submitted honeypot fields to detect bots.
628
- * @param {object} context - The request context.
629
- * @param {object} honeypotConfig - The honeypot configuration.
630
- * @returns {{honeypotScore: number}}
631
- */
632
- function getHoneypotScore(context, honeypotConfig = {}) {
633
- const { fields = [], trapUrls = [], detectInjections = true } = honeypotConfig;
634
- // (NOUVEAU) Permettre de brancher des analyseurs externes plus robustes.
635
- // L'utilisateur pourrait passer une fonction qui prend les données de la requête
636
- // et retourne `true` si une menace est détectée.
637
- // Exemple: `(data) => myWafLibrary.isMalicious(data)`
638
- const externalAnalyzers = honeypotConfig.analyzers || [];
639
- if (typeof detectInjections === 'object' && detectInjections.analyzers) {
640
- externalAnalyzers.push(...detectInjections.analyzers);
641
- }
642
-
643
- // 1. Check for trap URL access
644
- if (trapUrls.some(trap => context.path.startsWith(trap))) {
645
- return { honeypotScore: 100 };
646
- }
647
-
648
- if (fields.length === 0 && !detectInjections) {
649
- return { honeypotScore: 0 };
650
- }
651
-
652
- // Check both query parameters (for URL probing) and the request body (for hidden form fields).
653
- const queryData =
654
- context.query instanceof URLSearchParams
655
- ? Object.fromEntries(context.query.entries())
656
- : context.query || {};
657
- const bodyData = context.body || {};
658
-
659
- // 2. Check for honeypot field names
660
- for (const field of fields) {
661
- // A bot is trapped if the field exists in either the query OR the body.
662
- if (
663
- Object.prototype.hasOwnProperty.call(queryData, field) ||
664
- Object.prototype.hasOwnProperty.call(bodyData, field)
665
- ) {
666
- return { honeypotScore: 100 }; // A bot fell into the trap, maximum score.
667
- }
668
- }
669
-
670
- // 3. (NOUVEAU) Utiliser les analyseurs externes
671
- const allData = { ...queryData, ...bodyData };
672
- if (externalAnalyzers.length > 0) {
673
- for (const analyzer of externalAnalyzers) {
674
- // On passe à l'analyseur l'ensemble des données de la requête.
675
- if (analyzer(allData)) {
676
- return { honeypotScore: 100 };
677
- }
678
- }
679
- }
680
-
681
- if (detectInjections) {
682
- // 3. Check for injection attempts in values using the centralized isMalicious function.
683
- const inspect = (obj) => {
684
- for (const key in obj) {
685
- if (Object.prototype.hasOwnProperty.call(obj, key)) {
686
- const value = obj[key];
687
- if (typeof value === 'string') {
688
- if (isMalicious(value)) return true;
689
- } else if (typeof value === 'object' && value !== null) {
690
- // For nested objects (like in NoSQL injections), we stringify them once
691
- // to check for malicious patterns within their structure or values.
692
- if (isMalicious(JSON.stringify(value))) return true;
693
- // Then, we recurse to check individual string values inside.
694
- if (inspect(value)) return true;
695
- }
696
- }
697
- }
698
- return false;
699
- };
700
-
701
- if (inspect(queryData)) {
702
- return { honeypotScore: 100 };
703
- }
704
- if (inspect(bodyData)) {
705
- return { honeypotScore: 100 };
706
- }
707
- }
708
-
709
- return { honeypotScore: 0 };
710
- }
711
-
712
- /**
713
- * Calcule un score basé sur les métriques comportementales envoyées par le client.
714
- * @param {object} context - Le contexte de la requête, contenant les en-têtes.
715
- * @returns {{behaviorScore: number}}
716
- */
717
- function getBehaviorScore(context) {
718
- const behaviorHeader = context.headers['x-behavior-metrics'];
719
- if (!behaviorHeader) {
720
- return { behaviorScore: 0 }; // Pas de données, pas de pénalité.
721
- }
722
-
723
- try {
724
- const metrics = JSON.parse(behaviorHeader);
725
- let score = 0;
726
- if (metrics.honeypotInteraction) score = 100; // Interaction avec un honeypot client = bot.
727
- if (metrics.mouseEntropy === 0 && metrics.keystrokeLatency === 0) score += 40; // Aucune interaction = suspect.
728
- return { behaviorScore: Math.min(100, score) };
729
- } catch (e) {
730
- return { behaviorScore: 10 }; // En-tête malformé = légèrement suspect.
731
- }
732
- }
733
-
734
- /**
735
- * Analyzes server-side request patterns for a given device to detect bot-like behavior.
736
- * This is a stateful check that looks for repetitive or unnaturally fast requests.
737
- * @param {object} context - The request context.
738
- * @param {object} deviceData - The device's activity data from the store.
739
- * @returns {{requestPatternScore: number}}
740
- */
741
- function getRequestPatternScore(context, deviceData, patternConfig = {}) {
742
- if (!deviceData) return { requestPatternScore: 0 };
743
-
744
- // Default values for the pattern detection logic, which can be overridden by the auto-tuner.
745
- const {
746
- velocityThreshold = 800, velocityWeight = 30,
747
- burstThreshold = 1500, burstWeight = 50,
748
- scrapeThreshold = 1000, scrapeWeight = 20, scrapeBurstWeight = 40,
749
- historySize = 10,
750
- decayFactor = 0.9,
751
- inactivityReset = 30000,
752
- // Nouveau paramètre pour la détection de séquences
753
- sequenceLength = 3, sequenceWeight = 60
754
- } = patternConfig;
755
-
756
- const now = Date.now();
757
- const currentPath = context.path;
758
- // Make the function robust to handle both URLSearchParams and plain objects for query.
759
- // Ensure query parameters are consistently handled, whether they come from a URLSearchParams object or a plain object.
760
- const params = context.query instanceof URLSearchParams ? context.query : new URLSearchParams(context.query);
761
- params.sort(); // Sort for deterministic order
762
- const currentQueryString = params.toString();
763
-
764
- // Initialize request history if it doesn't exist
765
- if (!deviceData.requestHistory) {
766
- deviceData.requestHistory = [];
767
- }
768
-
769
- const history = deviceData.requestHistory;
770
- let score = 0;
771
-
772
- // --- Analyze patterns based on the last few requests ---
773
- if (history.length > 0) {
774
- const lastRequest = history[history.length - 1];
775
- const timeSinceLast = now - lastRequest.timestamp; // 150
776
-
777
- // 1. Velocity Check: Penalize requests that are too fast to be human.
778
- if (timeSinceLast < velocityThreshold) { // 150 < 200 -> true
779
- score += velocityWeight; // score = 30
780
- }
781
-
782
- // 2. Burst Check: Add additional penalty for identical requests in a very short time frame.
783
- if (currentPath === lastRequest.path && currentQueryString === lastRequest.queryString && timeSinceLast < burstThreshold) { // 150 < 500 -> true
784
- score += burstWeight; // score = 30 + 50 = 80
785
- }
786
-
787
- // 3. Sequential Scraping Check: Add additional penalty for same path with different query params (potential scraping).
788
- // This is a simplified check, now independent of the burst check.
789
- if (currentPath === lastRequest.path && currentQueryString !== lastRequest.queryString && timeSinceLast < scrapeThreshold) {
790
- const previousRequest = history.length > 2 ? history[history.length - 2] : null;
791
- if (previousRequest && previousRequest.path === currentPath) {
792
- score += scrapeBurstWeight; // This is at least the 3rd request in a sequence to the same path.
793
- } else {
794
- score += scrapeWeight; // First sign of a potential scraping pattern
795
- }
796
- }
797
-
798
- // 4. (NOUVEAU) Détection de séquences répétitives (ex: A -> B -> C -> A -> B -> C)
799
- if (history.length >= sequenceLength * 2) {
800
- const lastSequence = history.slice(-sequenceLength);
801
- const previousSequence = history.slice(-sequenceLength * 2, -sequenceLength);
802
-
803
- const isRepeating = lastSequence.every((req, i) =>
804
- req.path === previousSequence[i].path && req.queryString === previousSequence[i].queryString
805
- );
806
- if (isRepeating) score += sequenceWeight;
807
- }
808
- }
809
-
810
- // --- Update history ---
811
- history.push({
812
- timestamp: now,
813
- path: currentPath,
814
- queryString: currentQueryString,
815
- });
816
-
817
- // Keep history to a reasonable size (e.g., last 10 requests)
818
- if (history.length > historySize) {
819
- history.shift();
820
- }
821
-
822
- // Decay the score over time if behavior becomes normal again.
823
- // We can store the score in deviceData and decay it.
824
- deviceData.lastPatternScore = (deviceData.lastPatternScore || 0) * decayFactor + score; // Decay old score and add new
825
-
826
- // If there hasn't been a request in a while, reset the pattern score.
827
- if (history.length > 1 && (now - history[history.length - 2].timestamp > inactivityReset)) { // X ms inactivity
828
- deviceData.lastPatternScore = 0;
829
- }
830
-
831
- return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
832
- }
833
-
834
- const trapUrlTemplates = [
835
- '/includes/config-{RANDOM}.php', // Classic PHP config file
836
- '/.env.{RANDOM}', // Environment file
837
- '/backups/db_backup_{RANDOM}.sql.gz', // Database backup
838
- '/api/v1/internal/status?trace={RANDOM}', // Internal API endpoint
839
- '/_private/deploy_key_{RANDOM}.pem', // Private key file
840
- '/logs/app_error_{RANDOM}.log', // Log file
841
- '/.git/config_{RANDOM}' // Exposed git config variant
842
- ];
843
-
844
- /**
845
- * Generates a signed trap URL.
846
- * @param {string} nonce - The nonce to sign the URL with.
847
- * @returns {string} The trap URL.
848
- */
849
- function generateTrapUrl(nonce) {
850
- // Pick a random template to diversify the traps
851
- const template = trapUrlTemplates[Math.floor(Math.random() * trapUrlTemplates.length)];
852
- const randomPart = crypto.randomBytes(8).toString('hex');
853
- const path = template.replace('{RANDOM}', randomPart);
854
-
855
- const signature = crypto.createHmac('sha256', getPowSecret()).update(nonce + path).digest('hex').substring(0, 16);
856
- return `${path}?sig=${signature}`;
857
- }
858
-
859
- /**
860
- * Verifies if a given path is a valid trap URL for a given nonce.
861
- * @param {string} path - The request path.
862
- * @param {string} signature - The signature from the query.
863
- * @param {string} nonce - The nonce to verify against.
864
- * @returns {boolean}
865
- */
866
- function verifyTrapUrl(path, signature, nonce) {
867
- const expectedSignature = crypto.createHmac('sha256', getPowSecret()).update(nonce + path).digest('hex').substring(0, 16);
868
- try {
869
- // Use timingSafeEqual to prevent timing attacks where an attacker could guess the signature byte by byte.
870
- return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex'));
871
- } catch {
872
- // This will catch errors if buffers have different lengths or contain invalid hex characters, which is a failure case.
873
- return false;
874
- }
875
- }
876
- /**
877
- * @typedef {object} IStore
878
- * @property {(key: string) => Promise<any>} get
879
- * @property {(key: string, value: any, ttl?: number) => Promise<void>} set
880
- * @property {(key: string) => Promise<boolean>} has
881
- * @property {(key: string) => Promise<void>} delete
882
- */
883
-
884
- /**
885
- * Default in-memory store implementation.
886
- * @type {IStore}
887
- */
888
- const inMemoryStore = {
889
- _map: new Map(),
890
- _timeouts: new Map(),
891
- async get(key) { return this._map.get(key); },
892
- async set(key, value, ttl) {
893
- this._map.set(key, value);
894
- // If a timeout already exists for this key, clear it.
895
- if (this._timeouts.has(key)) {
896
- clearTimeout(this._timeouts.get(key));
897
- this._timeouts.delete(key);
898
- }
899
- // If a TTL is provided, set a timeout to delete the key.
900
- if (ttl && ttl > 0) {
901
- const timeoutId = setTimeout(() => this._map.delete(key), ttl * 1000);
902
- this._timeouts.set(key, timeoutId);
903
- }
904
- },
905
- async has(key) { return this._map.has(key); },
906
- async delete(key) { this._map.delete(key); },
907
- };
908
-
909
- /** @type {IStore} */
910
- let store = inMemoryStore;
911
-
912
- /**
913
- * Allows configuring an external datastore (e.g., Redis).
914
- * Must be called before the middleware is used.
915
- * @param {IStore} externalStore - An implementation of the IStore interface.
916
- */
917
- export const configureStore = (externalStore) => {
918
- store = externalStore;
919
- };
920
-
921
- /**
922
- * Orchestrates request identification using a persistent anchor (cookie)
923
- * and fingerprint verification.
924
- * @param {object} context - The request context.
925
- * @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
926
- */
927
- async function resolveRequestIdentity(context, securityConfig = {}) {
928
- const existingDeviceId = context.cookies?.device_id;
929
- const currentDeviceHash = getDeviceHash(context);
930
- let deviceId = existingDeviceId;
931
- let consistencyScore = 1.0; // 1.0 = perfectly consistent
932
- let deviceData = null;
933
- let newCookie = null;
934
- if (deviceId) {
935
- deviceData = await store.get(`device:${deviceId}`);
936
- }
937
-
938
- if (deviceData) {
939
- // Case 1: The user has a "passport" and we know them.
940
- const storedHash = deviceData.initialDeviceHash;
941
-
942
- // Compare the current fingerprint with the reference one.
943
- consistencyScore = FingerprintBuilder.compare(
944
- storedHash,
945
- currentDeviceHash,
946
- );
947
- } else {
948
- // Case 2: New user or lost/invalid cookie.
949
- deviceId = crypto.randomUUID(); // Generate a new "passport".
950
-
951
- // Return the intention to set a cookie.
952
- newCookie = {
953
- name: "device_id",
954
- value: deviceId,
955
- options: {
956
- httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict",
957
- // Le maxAge est maintenant configurable. Par défaut, c'est un cookie de session.
958
- ...(securityConfig.deviceIdCookieMaxAge && { maxAge: securityConfig.deviceIdCookieMaxAge }),
959
- }
960
- };
961
-
962
- // Initialize tracking for this new device.
963
- deviceData = {
964
- initialDeviceHash: currentDeviceHash, // Anchor the initial fingerprint.
965
- ips: new Set(),
966
- requestHistory: [], // Initialize state for the new pattern score
967
- lastUpdate: Date.now(),
968
- lastFpHash: currentDeviceHash,
969
- lastChangeTimestamp: 0,
970
- rapidChangeCount: 0,
971
- highScoreCount: 0,
972
- lastHighScoreTimestamp: 0,
973
- };
974
- // The write will happen in getSuspicionVector after all modifications.
975
- }
976
-
977
- return { deviceId, deviceData, consistencyScore, newCookie };
978
- }
979
-
980
- /*
981
- * Calcule les indicateurs de suspicion liés au comportement de l'appareil (historique, rotation).
982
- * @param {object} context - The request context.
983
- * @param {object} deviceData - The device's activity data.
984
- * @returns {Promise<{historyScore: number, rotationScore: number}>}
985
- */
986
- async function getBehavioralIndicators(context, deviceData) {
987
- const now = Date.now();
988
- const clientIp = context.clientIp;
989
-
990
- // Get the IP type to modulate the score
991
- const ipProfile = (await store.get(`ip:${clientIp}`)) || { type: "residential" };
992
- const isSharedIp = ipProfile.type === "shared";
993
-
994
- const currentFpHash = getDeviceHash(context); // Use the device hash
995
-
996
- // --- Behavior analysis (Change frequency) ---
997
- if (deviceData.lastFpHash && currentFpHash !== deviceData.lastFpHash) {
998
- const timeSinceLastChange = now - deviceData.lastChangeTimestamp;
999
-
1000
- if (timeSinceLastChange < RAPID_CHANGE_THRESHOLD_MS) {
1001
- deviceData.rapidChangeCount = Math.min(
1002
- deviceData.rapidChangeCount + 1,
1003
- MAX_RAPID_CHANGES_PER_DEVICE * 2, // Increases quickly
1004
- );
1005
- } else {
1006
- deviceData.rapidChangeCount = Math.max(0, deviceData.rapidChangeCount - 1); // Decreases slowly
1007
- }
1008
- deviceData.lastChangeTimestamp = now;
1009
- }
1010
-
1011
- deviceData.lastFpHash = currentFpHash;
1012
- deviceData.ips.add(clientIp); // Record the IP used by this device
1013
-
1014
- // NOUVELLE LOGIQUE : Le score d'historique est basé sur le nombre d'IPs utilisées par l'appareil.
1015
- // Très efficace contre la rotation de proxy.
1016
- const maxIpsForDevice = isSharedIp
1017
- ? MAX_DISTINCT_IPS_FOR_SHARED_USER
1018
- : MAX_DISTINCT_IPS_PER_DEVICE;
1019
- const freeIpChanges = isSharedIp ? 1 : 3;
1020
-
1021
- const historyScore = Math.min(
1022
- 100,
1023
- (Math.max(0, deviceData.ips.size - freeIpChanges) /
1024
- (maxIpsForDevice - freeIpChanges)) *
1025
- 100,
1026
- );
1027
-
1028
- // Score based on rapid identity rotation (0-100)
1029
- const rotationScore = Math.min(
1030
- 100,
1031
- (deviceData.rapidChangeCount / MAX_RAPID_CHANGES_PER_DEVICE) * 100,
1032
- );
1033
-
1034
- return { historyScore, rotationScore };
1035
- }
1036
-
1037
- /**
1038
- * Returns a vector of raw (unweighted) suspicion scores.
1039
- * @param {object} context - The request context object.
1040
- * @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number, honeypotScore: number}>}
1041
- */
1042
- export const getSuspicionVector = async (context, securityConfig) => {
1043
- // On récupère la configuration du honeypot pour l'utiliser ici.
1044
- const honeypotConfig = securityConfig.honeypot || {};
1045
-
1046
- const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context, securityConfig);
1047
-
1048
- const clientIp = context.clientIp;
1049
-
1050
- // If a new cookie needs to be set, attach it to the request object
1051
- // so the middleware can handle it. This is a temporary state holder.
1052
- if (newCookie) {
1053
- context._newCookies = context._newCookies || [];
1054
- context._newCookies.push(newCookie);
1055
- }
1056
- await store.set(`ip-device:${clientIp}`, deviceId, 600); // Link the IP to the device for 10 minutes
1057
-
1058
- // Periodically clean up device data
1059
- if (Date.now() - deviceData.lastUpdate > 10 * 60 * 1000) { // 10 minutes
1060
- deviceData.ips.clear();
1061
- deviceData.rapidChangeCount = 0;
1062
- }
1063
- deviceData.lastUpdate = Date.now();
1064
-
1065
- const behavioral = await getBehavioralIndicators(context, deviceData);
1066
- const { headerAnomalyScore } = getHeaderAnomalies(context);
1067
- // Calculate the inconsistency score here, separately.
1068
- let inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200)); // Amplified score
1069
-
1070
- // NOUVEAU: Si l'incohérence est très forte (cookie probablement volé), on applique une pénalité maximale.
1071
- if (consistencyScore < 0.7) { // Seuil de rupture
1072
- inconsistencyScore = 100;
1073
- }
1074
-
1075
- const { behaviorScore } = getBehaviorScore(context); // Appel de la fonction
1076
-
1077
- // On appelle getHoneypotScore ici pour que son résultat soit inclus dans le vecteur.
1078
- const { honeypotScore } = getHoneypotScore(context, honeypotConfig);
1079
-
1080
- const { requestPatternScore } = getRequestPatternScore(context, deviceData, securityConfig.patterns);
1081
-
1082
- // Save the updated device state to the store
1083
- // 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.
1084
- await store.set(`device:${deviceId}`, deviceData);
1085
-
1086
- // Ensure deviceData.ips is a Set for subsequent operations within the same request,
1087
- // even if the store returns an array.
1088
- if (Array.isArray(deviceData.ips)) {
1089
- deviceData.ips = new Set(deviceData.ips);
1090
- }
1091
- // Le vecteur de suspicion est maintenant complet.
1092
- return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, requestPatternScore };
1093
- };
1094
-
1095
- // A residential user can change networks (home, 4G, public wifi).
1096
- const MAX_DISTINCT_IPS_PER_DEVICE = 15;
1097
- // Un utilisateur derrière un NAT/proxy ne devrait pas utiliser BEAUCOUP d'autres IPs.
1098
- const MAX_DISTINCT_IPS_FOR_SHARED_USER = 5;
1099
-
1100
- // Une IP est considérée comme "partagée" si elle est utilisée par plus de 50 appareils différents en 10 minutes.
1101
- const SHARED_IP_DEVICE_THRESHOLD = 50;
1102
-
1103
- const RAPID_CHANGE_THRESHOLD_MS = 2000; // 2 secondes
1104
- const MAX_RAPID_CHANGES_PER_DEVICE = 3; // Number of rapid fingerprint changes allowed per device.
1105
-
1106
- /**
1107
- * Identifies a request on the server side in a granular way.
1108
- * Uses FingerprintBuilder to create a fingerprint based on headers
1109
- * and IP, making spoofing more complex (requires changing the entire stack).
1110
- */
1111
- export const identifyRequest = (securityConfig) => async (req, res) => {
1112
- // This function now acts as a lightweight wrapper around the engine's identifyRequest method.
1113
- // It requires a default configuration to work.
1114
- const config = securityConfig || {
1115
- weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.1, inconsistencyScore: 0.8, honeypotScore: 1.0 },
1116
- thresholds: { low: 20, medium: 40, high: 75 },
1117
- honeypot: { fields: [] } // Ensure honeypot config exists to prevent errors
1118
- };
1119
- const engine = new FingerprintEngine(config);
1120
-
1121
- const requestContext = {
1122
- clientIp: req.ip || req.socket?.remoteAddress || "unknown",
1123
- query: req.query,
1124
- body: req.body,
1125
- cookies: req.cookies,
1126
- headers: req.headers,
1127
- rawHeaders: req.rawHeaders,
1128
- httpVersion: req.httpVersion,
1129
- };
1130
-
1131
- const key = await engine.identifyRequest(requestContext);
1132
-
1133
- if (requestContext._newCookies && res) {
1134
- requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
1135
- }
1136
-
1137
- return key;
1138
- };
1139
- // --- NOUVEAU CHALLENGE CPU "ANALOGIQUE" ---
1140
-
1141
- // Le plus grand nombre possible avec SHA-256 (2^256 - 1)
1142
- // The largest possible number with SHA-256 (2^256 - 1)
1143
- const MAX_DIFFICULTY_TARGET = 2n ** 256n - 1n;
1144
- // Une difficulté de base, ex: nécessite que les 16 premiers bits soient à 0
1145
- // (équivalent à 4 zéros en hexadécimal)
1146
- // A base difficulty, e.g., requires the first 16 bits to be 0
1147
- // (equivalent to 4 zeros in hexadecimal)
1148
- const BASE_TARGET = MAX_DIFFICULTY_TARGET >> 16n;
1149
-
1150
- /**
1151
- * Calculates the difficulty target based on the suspicion factor.
1152
- * @param {number} suspicionFactor - A number from 0 to 1.
1153
- * @returns {BigInt} The target number.
1154
- */
1155
- function calculateTarget(suspicionFactor) {
1156
- // Difficulty range adjusted to be realistic.
1157
- // MIN_DIFFICULTY: Fast enough not to bother a slightly suspicious user.
1158
- // MAX_DIFFICULTY: Slow enough to heavily penalize a bot, but feasible for a patient human (5-30s).
1159
- const MIN_DIFFICULTY_BITS = 18; // Default value, should be configurable
1160
- const MAX_DIFFICULTY_BITS = 26; // Default value, should be configurable
1161
-
1162
- // Use linear interpolation between min and max difficulty.
1163
- const totalDifficultyBits =
1164
- MIN_DIFFICULTY_BITS +
1165
- suspicionFactor * (MAX_DIFFICULTY_BITS - MIN_DIFFICULTY_BITS);
1166
-
1167
- // The target is max / 2^bits
1168
- return MAX_DIFFICULTY_TARGET >> BigInt(Math.floor(totalDifficultyBits));
1169
- }
1170
-
1171
- /**
1172
- * Generates a CPU challenge based on a target.
1173
- */
1174
- export function generateCpuTargetChallenge(
1175
- clientIp,
1176
- nonce,
1177
- suspicionFactor,
1178
- originalUrl,
1179
- ) {
1180
- const target = calculateTarget(suspicionFactor);
1181
- return {
1182
- type: "cpu_target",
1183
- nonce: nonce,
1184
- target: target.toString(16), // Send the target in hexadecimal
1185
- path: originalUrl,
1186
- };
1187
- }
1188
-
1189
- /**
1190
- * Generates the HTML page for the CPU target challenge.
1191
- * @param {object} challengeDetails - The details from generateCpuTargetChallenge.
1192
- * @param {string} clientIp - The client's IP address.
1193
- * @returns {string} HTML content.
1194
- */
1195
- function generateCpuTargetChallengePage(challengeDetails, clientIp) {
1196
- const { nonce, target, path } = challengeDetails;
1197
- const solverCode = getPowSolverCode();
1198
- return `
1199
- <html><head><title>Security Check</title></head>
1200
- <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
1201
- <h1>Please wait... (Level 1)</h1>
1202
- <p>We are verifying that you are not a bot. This may take a few seconds.</p>
1203
- <div id="loader" style="margin:20px;">⚙️ Performing CPU security calculation...</div>
1204
- <script>${solverCode}</script>
1205
- <script>
1206
- async function solve() {
1207
- const clientIp = "${clientIp}";
1208
- const nonce = "${nonce}";
1209
- const cpuTarget = BigInt("0x${target}");
1210
- const solution = await window.solveCpuChallengeInline(clientIp, nonce, cpuTarget, null, (progress) => {
1211
- // Optional progress callback
1212
- });
1213
- window.location.href = "${path}?pow_type=cpu_target&pow_nonce=${nonce}&pow_solution=" + solution;
1214
- }
1215
- solve();
1216
- </script>
1217
- </body></html>`;
1218
- }
1219
-
1220
- /**
1221
- * Generates the HTML content for a combined CPU + Memory PoW challenge.
1222
- * @param {object} cpuChallengeDetails - Details from generateCpuTargetChallenge.
1223
- * @param {number} memoryDifficulty - Memory allocation in MB.
1224
- * @param {string} clientIp - The client's IP address.
1225
- * @returns {string} HTML content.
1226
- */
1227
- function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty, clientIp, clientSecret) {
1228
- const { nonce, target, path } = cpuChallengeDetails;
1229
- const solverCode = getPowSolverCode();
1230
- return `
1231
- <html><head><title>Advanced Security Check</title></head>
1232
- <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
1233
- <h1>Enhanced Verification... (Level 2)</h1>
1234
- <p>Your activity requires an additional security check. This may take a few moments.</p>
1235
- <div id="loader" style="margin:20px;">⚙️ Initializing combined verification...</div>
1236
- <script>${solverCode}</script>
1237
- <script>
1238
- async function solve() {
1239
- const nonce = "${nonce}";
1240
- const path = "${path}";
1241
- const clientSecret = "${clientSecret}";
1242
- const clientIp = "${clientIp}";
1243
- const cpuTarget = BigInt("0x${target}");
1244
- const memDifficulty = ${memoryDifficulty};
1245
-
1246
- // --- CPU Challenge ---
1247
- document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
1248
- const cpuSolution = await window.solveCpuChallengeInline(clientIp, nonce, cpuTarget, clientSecret, (progress) => {
1249
- // Optional progress callback
1250
- });
1251
-
1252
- // --- Memory Challenge ---
1253
- document.getElementById('loader').innerText = '⚙️ Performing memory allocation and calculation... (' + memDifficulty + ' MB)';
1254
- await new Promise(r => setTimeout(r, 10)); // Yield to update UI
1255
-
1256
- let memSolution = 0;
1257
- try {
1258
- const memSeed = nonce + ":" + clientSecret;
1259
- memSolution = await window.solveMemoryChallenge(memSeed, memDifficulty);
1260
- } catch(e) {
1261
- document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
1262
- return;
1263
- }
1264
- window.location.href = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
1265
- }
1266
- solve();
1267
- </script>
1268
- </body></html>`;
1269
- }
1270
-
1271
- /**
1272
- * Verifies a PoW solution based on a target and generates a ticket.
1273
- */
1274
- export function verifyCpuTargetPoWAndGenerateTicket(
1275
- clientIp, // This parameter is crucial and must be the actual client IP
1276
- ticketMaxAge, // NOUVEAU: Durée de validité du ticket configurable
1277
- nonce,
1278
- solution,
1279
- suspicionFactor,
1280
- clientSecret, // Le secret est maintenant requis
1281
- ) {
1282
- const target = calculateTarget(suspicionFactor);
1283
- const message = clientSecret
1284
- ? `${clientIp}:${nonce}:${solution}:${clientSecret}`
1285
- : `${clientIp}:${nonce}:${solution}`;
1286
- const hash = crypto
1287
- .createHash("sha256")
1288
- .update(message)
1289
- .digest("hex");
1290
- const hashAsInt = BigInt("0x" + hash);
1291
-
1292
- if (hashAsInt < target) {
1293
- // The comparison is direct with native BigInts
1294
- // The proof is valid, generate the ticket
1295
- const expiry = Date.now() + (ticketMaxAge || 3600000); // Utilise la durée passée ou un fallback.
1296
- const signature = crypto
1297
- .createHmac("sha256", getPowSecret())
1298
- .update(`${clientIp}:${expiry}`)
1299
- .digest("hex");
1300
- return `${expiry}:${signature}`;
1301
- }
1302
-
1303
- return null;
1304
- }
1305
-
1306
- const staticExtensions = new RegExp(
1307
- "\\.(js|css|png|jpg|jpeg|gif|svg|mp3|webp|ico|woff|woff2|ttf|otf|map|json|manifest)$",
1308
- "i",
1309
- );
1310
- const isStaticResource = (path) => staticExtensions.test(path);
1311
-
1312
-
1313
- /**
1314
- * Détermine le TTL optimal pour un ticket en utilisant un algorithme génétique multi-objectifs.
1315
- * @param {number} suspicionScore - Le score de suspicion de la requête.
1316
- * @returns {number} Le TTL optimal calculé en millisecondes.
1317
- */
1318
- function determineOptimalTicketTtl(suspicionScore) {
1319
- // Définir les bornes pour la durée de vie du ticket (5 minutes à 24 heures)
1320
- const MIN_TTL = 300000;
1321
- const MAX_TTL = 86400000;
1322
-
1323
- const solverFunction = () => {
1324
- const fitnessFunction = Optimization.Operators.createOptimalTtlEvaluator({ suspicionScore });
1325
-
1326
- // Un "individu" est simplement une valeur de TTL en millisecondes.
1327
- const createIndividual = () => MIN_TTL + Math.random() * (MAX_TTL - MIN_TTL);
1328
- const crossover = (ttl1, ttl2) => (ttl1 + ttl2) / 2;
1329
- const mutate = (ttl) => {
1330
- const newTtl = ttl + (Math.random() - 0.5) * (MAX_TTL - MIN_TTL) * 0.1; // Mutation de +/- 10% max
1331
- return Math.max(MIN_TTL, Math.min(MAX_TTL, newTtl));
1332
- };
1333
-
1334
- const paretoFront = Optimization.geneticAlgorithmMultiObjective(
1335
- createIndividual,
1336
- fitnessFunction,
1337
- crossover,
1338
- mutate,
1339
- {
1340
- generations: 40,
1341
- populationSize: 30,
1342
- }
1343
- );
1344
-
1345
- // Pour runMultiple, on doit retourner un objet avec une propriété "fitness" ou "energy".
1346
- // Pour un front de Pareto, il n'y a pas de score unique. On choisit la meilleure solution
1347
- // en fonction du score de suspicion et on lui assigne un score de 0 pour que runMultiple la sélectionne.
1348
- if (!paretoFront || paretoFront.length === 0) {
1349
- return { solution: null, fitness: Infinity };
1350
- }
1351
-
1352
- // Stratégie de sélection :
1353
- // Pour un score faible (< 50), on privilégie la solution avec le plus grand TTL (minimise la friction).
1354
- // Pour un score élevé (>= 50), on privilégie la solution avec le plus petit TTL (minimise le risque).
1355
- let bestSolutionInFront;
1356
- if (suspicionScore < 50) {
1357
- bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
1358
- } else {
1359
- bestSolutionInFront = paretoFront.reduce((min, p) => Math.min(min, p.solution), Infinity);
1360
- }
1361
- return { solution: bestSolutionInFront, fitness: 0 }; // fitness=0 car on a déjà la meilleure solution du cycle.
1362
- };
1363
-
1364
- // On exécute le solveur 20 fois pour trouver une solution plus stable et robuste.
1365
- const { bestResult } = Optimization.runMultiple(solverFunction, 20);
1366
-
1367
- if (!bestResult || !bestResult.solution || bestResult.solution === Infinity) {
1368
- // Fallback : si l'algo ne retourne rien, on applique une règle simple et sûre.
1369
- return Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL);
1370
- }
1371
-
1372
- // runMultiple choisit le meilleur résultat sur la base du score (ici, 0).
1373
- // La "meilleure" solution dépendra du cycle qui a trouvé le meilleur compromis.
1374
- return bestResult.solution;
1375
- }
1376
-
1377
- /**
1378
- * Vérifie si une chaîne de caractères contient des patterns d'injection connus.
1379
- * @param {string} str - La chaîne à vérifier.
1380
- * @returns {boolean} - True si un pattern malveillant est détecté.
1381
- * @private
1382
- */
1383
- function isMalicious(str) {
1384
- // Regex pour les injections SQL et NoSQL de base
1385
- // Ajout de la détection des injections basées sur le temps (SLEEP, BENCHMARK, WAITFOR) et d'autres commandes dangereuses.
1386
- const injectionRegex = /(\$ne|' OR '1'='1|['";]\s*--|; ?(DROP|TRUNCATE|DELETE)|UNION SELECT|SLEEP\(|BENCHMARK\(|WAITFOR DELAY)/i;
1387
- // Regex pour les injections plus avancées
1388
- const log4ShellRegex = /\$\{jndi:(ldap|rmi|dns):/i;
1389
- const sstiRegex = /\{\{.*\}\}|\{%.*%\}/; // Détecte les syntaxes de type Jinja2, Twig, etc.
1390
- const xxeRegex = /<!ENTITY\s+.*SYSTEM/i;
1391
- const pathTraversalRegex = /(\.\.\/|\.\.\\)/;
1392
- // NOUVEAU : Regex pour les injections de commandes basiques.
1393
- // Cible les séparateurs de commandes et les backticks d'exécution.
1394
- const commandInjectionRegex = /(&&|\|\||;|\n|`)/;
1395
-
1396
- return injectionRegex.test(str) || log4ShellRegex.test(str) || sstiRegex.test(str) || xxeRegex.test(str) || pathTraversalRegex.test(str) || commandInjectionRegex.test(str);
1397
- }
1398
-
1399
- // --- Middleware Proof-of-Work (Le péage) ---
1400
- export class FingerprintEngine {
1401
- constructor(securityConfig) {
1402
- const isProduction = process.env.NODE_ENV === 'production';
1403
- this.securityConfig = securityConfig;
1404
- this.isProduction = isProduction;
1405
- this._allowlist = this._buildAllowlist();
1406
- this.verbose = securityConfig.verbose || false;
1407
- }
1408
-
1409
- _log(message, data = {}) {
1410
- if (this.verbose) {
1411
- console.log(`[FingerprintEngine] ${message}`, data);
1412
- }
1413
- }
1414
- calculateFinalScore = function(suspicionVector) {
1415
- const { weights } = this.securityConfig;
1416
- if (!weights) return 0;
1417
-
1418
- const score =
1419
- (suspicionVector.historyScore || 0) * (weights.historyScore || 0) +
1420
- (suspicionVector.rotationScore || 0) * (weights.rotationScore || 0) +
1421
- (suspicionVector.headerAnomalyScore || 0) * (weights.headerAnomalyScore || 0) +
1422
- (suspicionVector.requestPatternScore || 0) * (weights.requestPatternScore || 0) +
1423
- (suspicionVector.inconsistencyScore || 0) * (weights.inconsistencyScore || 0) +
1424
- (suspicionVector.honeypotScore || 0) * (weights.honeypotScore || 0) +
1425
- (suspicionVector.behaviorScore || 0) * (weights.behaviorScore || 0);
1426
-
1427
- return Math.min(100, score);
1428
- }
1429
- /**
1430
- * Checks if an IP address is in the static allowlist (IPs or CIDR ranges).
1431
- * This is the fastest check and should be performed first.
1432
- * @private
1433
- * @param {string} clientIp - The IP address of the client.
1434
- * @returns {boolean} True if the IP is in the allowlist.
1435
- */
1436
- _buildAllowlist() {
1437
- const blockList = new BlockList();
1438
- const { whitelist = [] } = this.securityConfig;
1439
- const allowlistRule = whitelist.find(rule => rule.type === 'allowlist');
1440
-
1441
- if (!allowlistRule || !allowlistRule.entries || allowlistRule.entries.length === 0) {
1442
- return blockList; // Retourne une liste vide
1443
- }
1444
-
1445
- for (const entry of allowlistRule.entries) {
1446
- if (entry.includes('/')) { // CIDR range
1447
- try {
1448
- const [address, prefix] = entry.split('/');
1449
- blockList.addSubnet(address, parseInt(prefix, 10));
1450
- } catch (e) {
1451
- // Ignore les entrées CIDR invalides
1452
- }
1453
- } else { // Direct IP match
1454
- blockList.addAddress(entry);
1455
- }
1456
- }
1457
- return blockList;
1458
- }
1459
- _isIpInAllowlist(clientIp) {
1460
- return this._allowlist.check(clientIp);
1461
- }
1462
- /**
1463
- * Verifies if a request comes from a legitimate, whitelisted bot (e.g., Googlebot)
1464
- * using reverse and forward DNS lookups. The result is cached.
1465
- * @private
1466
- * @param {object} requestContext - The request context.
1467
- * @returns {Promise<boolean>} True if the request is from a verified whitelisted bot.
1468
- */
1469
- async _verifyWhitelistedBot(requestContext) {
1470
- const { whitelist = [] } = this.securityConfig;
1471
- const botRules = whitelist.filter(rule => rule.hostnameSuffix);
1472
- if (botRules.length === 0) {
1473
- return false;
1474
- }
1475
-
1476
- const { clientIp, headers } = requestContext;
1477
- const userAgent = headers['user-agent'] || '';
1478
-
1479
- const matchedRule = botRules.find(rule => {
1480
- if (!rule.userAgent) return false;
1481
- try {
1482
- return new RegExp(rule.userAgent).test(userAgent);
1483
- } catch (e) {
1484
- console.error(`[Fingerprint] Invalid regex in whitelist rule: ${rule.userAgent}`);
1485
- return false;
1486
- }
1487
- });
1488
- if (!matchedRule) {
1489
- return false;
1490
- }
1491
-
1492
- const cacheKey = `ip-whitelist:${clientIp}`;
1493
- const cachedStatus = await store.get(cacheKey);
1494
-
1495
- if (cachedStatus === 'verified') {
1496
- return true;
1497
- }
1498
- if (cachedStatus === 'failed') {
1499
- return false;
1500
- }
1501
-
1502
- try {
1503
- // 1. Reverse DNS lookup
1504
- const hostnames = await dns.reverse(clientIp);
1505
- const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
1506
-
1507
- if (!validHostname) {
1508
- await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
1509
- return false;
1510
- }
1511
-
1512
- // 2. Forward DNS lookup
1513
- const addresses = await dns.resolve(validHostname);
1514
- if (addresses.includes(clientIp)) {
1515
- await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
1516
- return true;
1517
- }
1518
- } catch (error) {
1519
- // DNS errors are common (e.g., for IPs with no rDNS record), treat as failure.
1520
- }
1521
-
1522
- await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
1523
- return false;
1524
- }
1525
-
1526
- async processRequest(requestContext) {
1527
-
1528
- const { clientIp = "unknown", path, cookies, query, isStatic } = requestContext;
1529
- const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
1530
-
1531
- this._log('Processing request', { clientIp, path, isStatic });
1532
-
1533
- if (isStatic) {
1534
- this._log('Static resource - skipping checks');
1535
- return { action: 'next', score: 0, vector: {} };
1536
- }
1537
-
1538
- // 1. Check static IP allowlist first for maximum performance.
1539
- if (this._isIpInAllowlist(clientIp)) {
1540
- this._log('IP in allowlist - allowing request', { clientIp });
1541
- return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'allowlist' } };
1542
- }
1543
-
1544
- const { pow_nonce } = query;
1545
-
1546
- // Honeypot: Direct probing of challenge endpoints is highly suspicious.
1547
- // A legitimate user only hits these endpoints via the challenge page itself.
1548
- // If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
1549
- if (pow_nonce) {
1550
- const powCookie = cookies?.pow_clearance;
1551
- if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
1552
- // This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
1553
- // The final decision is made later, after calculating the score.
1554
- }
1555
- }
1556
-
1557
- // Check if the request is from a verified, whitelisted bot (e.g., Googlebot)
1558
- if (await this._verifyWhitelistedBot(requestContext)) {
1559
- this._log('Whitelisted bot verified - allowing request', { clientIp });
1560
- return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
1561
- }
1562
-
1563
- // --- NOUVELLE LOGIQUE DE PRIORITÉ ---
1564
- // Si une solution de challenge est soumise, on la traite en priorité absolue,
1565
- // avant même de recalculer le score de suspicion.
1566
- const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
1567
- if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
1568
- this._log('Challenge solution submitted', { pow_type, pow_nonce });
1569
-
1570
- // On doit calculer le score de suspicion *avant* de valider le ticket,
1571
- // car le TTL optimal en dépend.
1572
- const preliminaryVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
1573
- const preliminaryScore = this.calculateFinalScore(preliminaryVector);
1574
-
1575
- this._log('Preliminary suspicion vector calculated', {
1576
- vector: preliminaryVector,
1577
- score: preliminaryScore
1578
- });
1579
-
1580
- let isValid = false;
1581
- const challengeContext = await store.get(`secret:${pow_nonce}`);
1582
- let ticket = null;
1583
-
1584
- if (challengeContext) {
1585
- const optimalTtl = determineOptimalTicketTtl(preliminaryScore);
1586
- this._log('Challenge context found, verifying solution', { optimalTtl });
1587
-
1588
- if (pow_type === "cpu_target") {
1589
- // On passe la durée de vie du ticket configurée
1590
- ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution, null, challengeContext.clientSecret, challengeContext.cpuTarget);
1591
- isValid = ticket !== null;
1592
- this._log('CPU target challenge verification', { isValid });
1593
- } else if (pow_type === "cpu_mem") {
1594
- const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution_cpu, null, challengeContext.clientSecret, challengeContext.cpuTarget);
1595
- const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret);
1596
- isValid = cpuTicket !== null && isMemValid;
1597
- if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
1598
- this._log('Combined CPU+Memory challenge verification', {
1599
- cpuValid: cpuTicket !== null,
1600
- memValid: isMemValid,
1601
- isValid
1602
- });
1603
- }
1604
- } else {
1605
- this._log('Challenge context not found or expired', { pow_nonce });
1606
- }
1607
-
1608
- if (isValid) {
1609
- // La solution est valide. On supprime le secret et on redirige.
1610
- await store.delete(`secret:${pow_nonce}`);
1611
- this._log('Challenge solution valid - issuing ticket', { ticketMaxAge: this.securityConfig.ticketMaxAge || 3600000 });
1612
-
1613
- if (logger) {
1614
- logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: preliminaryScore, challengeType: pow_type, timestamp: Date.now() });
1615
- }
1616
-
1617
- return {
1618
- action: 'redirect',
1619
- path: path,
1620
- score: 0, // Le score n'est pas pertinent ici, on a passé le test.
1621
- vector: { challenge_solved: 100 },
1622
- cookie: {
1623
- name: 'pow_clearance',
1624
- value: ticket,
1625
- options: {
1626
- httpOnly: true,
1627
- secure: this.isProduction, // Le maxAge est déjà inclus dans le ticket, mais on le met aussi sur le cookie
1628
- maxAge: this.securityConfig.ticketMaxAge || 3600000,
1629
- }
1630
- }
1631
- };
1632
- }
1633
- // Si la solution est INVALIDE, on ne fait rien ici. La requête continuera son cours normal,
1634
- // sera recalculée comme suspecte, et probablement bloquée ou re-challengée, ce qui est le comportement souhaité.
1635
- // On pourrait même ajouter une pénalité ici si on le voulait.
1636
- this._log('Challenge solution invalid', { reason: challengeContext ? 'Invalid solution' : 'Nonce not found or expired' });
1637
-
1638
- if (logger && challengeContext) {
1639
- logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Invalid PoW solution', timestamp: Date.now() });
1640
- } else if (logger && !challengeContext) {
1641
- logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Nonce not found or expired', timestamp: Date.now() });
1642
- }
1643
- }
1644
- // --- FIN DE LA LOGIQUE DE PRIORITÉ ---
1645
-
1646
- // Resolve identity and check for persisted "condemned" status early.
1647
- const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
1648
- const isNewDevice = !!newCookie;
1649
-
1650
- this._log('Identity resolved', { deviceId, isNewDevice, hasDeviceData: !!deviceData });
1651
-
1652
- if (deviceData?.condemned) {
1653
- this._log('Device condemned - blocking request', { deviceId });
1654
- if (onDeviceCompromised) {
1655
- onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Previously condemned', score: 100, vector: { honeypotScore: 100 } });
1656
- }
1657
- return { action: 'block', status: 404, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1658
- }
1659
-
1660
- // The engine now works with the context directly, no more rawReq dependency here.
1661
- const suspicionVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
1662
- // honeypotScore et behaviorScore sont maintenant inclus directement dans le vecteur de suspicion.
1663
-
1664
- this._log('Suspicion vector calculated', {
1665
- vector: suspicionVector,
1666
- weights: this.securityConfig.weights
1667
- });
1668
-
1669
- let finalScore = this.calculateFinalScore(suspicionVector);
1670
-
1671
- this._log('Final score calculated', { finalScore });
1672
-
1673
- // Si c'est un nouvel appareil, on lui impose un challenge de base, même si son score est bas.
1674
- // Cela augmente le coût pour les bots qui tentent de simplement supprimer leurs cookies.
1675
- // NOUVEAU : Cette logique est maintenant configurable.
1676
- const challengeNewDevices = this.securityConfig.challengeNewDevices === true;
1677
- if (isNewDevice && finalScore < thresholds.low) {
1678
- this._log('New device - enforcing minimum challenge score', {
1679
- originalScore: finalScore,
1680
- enforcedScore: thresholds.low
1681
- });
1682
- finalScore = thresholds.low;
1683
- }
1684
-
1685
- const blockThreshold = thresholds.block ?? 95;
1686
- const isBlocked = finalScore >= blockThreshold;
1687
-
1688
- const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
1689
- const isSuspiciousMedium = finalScore >= thresholds.medium;
1690
- const isSuspicious = finalScore >= thresholds.low;
1691
-
1692
- // Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
1693
- const suspicionFactor = isSuspicious
1694
- ? Math.min(
1695
- 1.5, // On autorise un dépassement pour rendre les challenges très difficiles si le score est très élevé
1696
- (finalScore - thresholds.low) / (thresholds.high - thresholds.low),
1697
- )
1698
- : 0;
1699
-
1700
- this._log('Suspicion levels evaluated', {
1701
- finalScore,
1702
- isBlocked,
1703
- isSuspiciousHigh,
1704
- isSuspiciousMedium,
1705
- isSuspicious,
1706
- suspicionFactor,
1707
- thresholds: { low: thresholds.low, medium: thresholds.medium, high: thresholds.high, block: blockThreshold }
1708
- });
1709
-
1710
- const powCookie = cookies?.pow_clearance;
1711
-
1712
- // If the action is to block, we should still include the score and vector for logging/testing.
1713
- if (isBlocked) {
1714
- this._log('Request blocked - score exceeded block threshold', { finalScore, blockThreshold });
1715
- if (onDeviceCompromised) {
1716
- onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Score exceeded block threshold', score: finalScore, vector: suspicionVector });
1717
- }
1718
- return { action: 'block', status: 404, body: 'Forbidden', score: finalScore, vector: suspicionVector };
1719
- }
1720
-
1721
- // Honeypot: Check if the request is for a trap URL generated in a previous challenge.
1722
- // This requires a nonce from a *previous* challenge, which we can look up via the device ID.
1723
- const lastNonce = deviceData?.lastChallengeNonce;
1724
- if (lastNonce && query.sig && verifyTrapUrl(path, query.sig, lastNonce)) {
1725
- this._log('Honeypot trap URL triggered - condemning device', { path, deviceId });
1726
- deviceData.condemned = true; // This device is a bot. Condemn it.
1727
- if (onDeviceCompromised) {
1728
- onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Triggered signed honeypot trap URL', score: 100, vector: { honeypotScore: 100 } });
1729
- }
1730
- if (logger) {
1731
- logger({ type: 'trap_triggered', deviceId: cookies?.device_id, score: 100, path: path, timestamp: Date.now() });
1732
- }
1733
- await store.set(`device:${cookies.device_id}`, deviceData); // No TTL for condemned status
1734
- return { action: 'block', status: 404, score: 100, vector: { honeypotScore: 100 } };
1735
- }
1736
-
1737
- if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
1738
- this._log('Suspicious request without valid ticket - issuing challenge', { finalScore, hasPowCookie: !!powCookie });
1739
-
1740
- // Honeypot: Direct probing of challenge endpoints is highly suspicious.
1741
- // A legitimate user only hits these endpoints via the challenge page itself.
1742
- // If we see a pow_nonce on a request that IS suspicious but has no valid ticket,
1743
- // AND it's not a legitimate response to a challenge we issued, it's a probe.
1744
- const isChallengeResponse = query.pow_solution || (query.pow_solution_cpu && query.pow_solution_mem);
1745
- if (pow_nonce && !isChallengeResponse) {
1746
- this._log('Honeypot probe detected - blocking request', { path, pow_nonce });
1747
- if (logger) {
1748
- logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now() });
1749
- }
1750
- suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
1751
- // Recalculate the final score with the updated vector.
1752
- const newFinalScore = this.calculateFinalScore(suspicionVector);
1753
- return { action: 'block', status: 404, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
1754
- }
1755
-
1756
- // --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
1757
- const nonce = crypto.randomBytes(16).toString("hex");
1758
- const clientSecret = crypto.randomBytes(16).toString("hex");
1759
-
1760
- // LEVEL 3: CAPTCHA (the highest)
1761
- if (isSuspiciousHigh) {
1762
- // ... logic for TSP/Captcha challenge
1763
- }
1764
-
1765
- // UNIFIED CHALLENGE LOGIC for all suspicion levels (low, medium, high)
1766
- if (isSuspicious) { // Couvre à la fois low et medium
1767
- // Generate some trap URLs to embed in the challenge page.
1768
- // These links are visually hidden but present in the DOM to trap bots.
1769
- const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce));
1770
- const trapLinksHtml = trapUrls.map(url => `<a href="${url}" tabindex="-1">config</a>`).join(' ');
1771
-
1772
-
1773
- const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
1774
-
1775
- // La difficulté mémoire démarre à 0 et augmente seulement après un certain seuil de suspicion.
1776
- // Par exemple, elle ne commence à augmenter qu'à partir de 25% du chemin entre 'low' et 'high'.
1777
- const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
1778
-
1779
- const minMemDifficulty = 0; // Peut être 0 Mo !
1780
- const maxMemDifficulty = 48; // 48Mo pour les plus suspects
1781
- const memDifficulty = Math.round(minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty));
1782
-
1783
- this._log('Challenge parameters calculated', {
1784
- suspicionFactor,
1785
- memActivationFactor,
1786
- memDifficulty,
1787
- cpuTarget: cpuChallengeDetails.target
1788
- });
1789
-
1790
- // Store the entire challenge context with a short TTL (e.g., 5 minutes)
1791
- await store.set(`secret:${nonce}`, {
1792
- clientSecret,
1793
- cpuTarget: cpuChallengeDetails.target,
1794
- memDifficulty: memDifficulty
1795
- }, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
1796
-
1797
- // Associate the current challenge nonce with the device for trap URL verification later.
1798
- if (deviceData) {
1799
- deviceData.lastChallengeNonce = nonce;
1800
- await store.set(`device:${deviceId}`, deviceData); // Utiliser le deviceId résolu, pas celui des cookies
1801
- }
1802
-
1803
- this._log('Challenge issued', {
1804
- nonce,
1805
- challengeTtl: this.securityConfig.challengeTtl || 300,
1806
- trapUrlsCount: trapUrls.length
1807
- });
1808
-
1809
- if (logger) {
1810
- logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
1811
- }
1812
-
1813
- // Check if the request is an API request to return a JSON challenge
1814
- const isApi = this.securityConfig.thresholds?.isApiRequest?.(requestContext);
1815
-
1816
- if (isApi) {
1817
- // For API clients, send a JSON response with challenge details.
1818
- const challengePayload = {
1819
- challenge: {
1820
- type: 'cpu_mem',
1821
- nonce: nonce,
1822
- clientSecret: clientSecret, // The client needs this to solve the challenge
1823
- cpuTarget: cpuChallengeDetails.target,
1824
- memDifficulty: memDifficulty,
1825
- }
1826
- };
1827
- this._log('API challenge response generated', { challengePayload });
1828
- return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 404, body: challengePayload };
1829
- } else {
1830
- // For browsers, send the HTML page.
1831
- const trapContainer = `<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">${trapLinksHtml}</div>`;
1832
- const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp, clientSecret).replace('</body>', `${trapContainer}</body>`);
1833
- this._log('Browser challenge page generated', {
1834
- pageLength: page.length,
1835
- hasTrapContainer: true
1836
- });
1837
- return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 404, body: page };
1838
- }
1839
- }
1840
- }
1841
-
1842
- // Basic log for each non-static request that passed without a challenge
1843
- this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie) });
1844
-
1845
- if (logger) {
1846
- logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
1847
- }
1848
-
1849
- return { action: 'next', score: finalScore, vector: suspicionVector };
1850
- }
1851
-
1852
- /**
1853
- * Identifies a request in a granular way for non-Express environments.
1854
- * @param {object} requestContext - The request context object.
1855
- * @returns {Promise<string>} An identification string (e.g., "device:<id>", "suspicious_high:<ip>").
1856
- */
1857
- async identifyRequest(requestContext) {
1858
- const { clientIp, cookies, rawReq, rawRes } = requestContext;
1859
-
1860
- // --- Update IP reputation ---
1861
- const ipProfile = (await store.get(`ip:${clientIp}`)) || {
1862
- type: "residential",
1863
- deviceIds: new Set(),
1864
- statelessCount: 0,
1865
- lastSeen: 0,
1866
- };
1867
- ipProfile.lastSeen = Date.now();
1868
- if (cookies?.device_id) {
1869
- ipProfile.deviceIds.add(cookies.device_id);
1870
- } else {
1871
- ipProfile.statelessCount++;
1872
- }
1873
-
1874
- if (ipProfile.deviceIds.size > SHARED_IP_DEVICE_THRESHOLD) {
1875
- ipProfile.type = "shared";
1876
- }
1877
-
1878
- const statelessLimit = ipProfile.type === "shared" ? 50 : 10;
1879
- if (ipProfile.statelessCount > statelessLimit) {
1880
- return `suspicious_high:${clientIp}`;
1881
- }
1882
- await store.set(`ip:${clientIp}`, ipProfile, 600); // Keep IP profile for 10 minutes
1883
-
1884
- const vector = await __internal.getSuspicionVector(requestContext, this.securityConfig); // Pass the config
1885
- const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
1886
- const { requestPatternScore } = getRequestPatternScore(requestContext, (await store.get(`device:${requestContext.cookies?.device_id}`)), this.securityConfig.patterns);
1887
- const score =
1888
- vector.historyScore * (this.securityConfig.weights.historyScore || 0.3) +
1889
- vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
1890
- vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
1891
- vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8) +
1892
- honeypotScore * (this.securityConfig.weights.honeypotScore || 0) +
1893
- requestPatternScore * (this.securityConfig.weights.requestPatternScore || 0) +
1894
- vector.behaviorScore * (this.securityConfig.weights.behaviorScore || 0);
1895
-
1896
- if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
1897
- if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
1898
- if (score >= this.securityConfig.thresholds.medium) return `suspicious_medium:${clientIp}`;
1899
-
1900
- // If a new device_id was created, it's in the context.
1901
- const newDeviceId = requestContext._newCookies?.find(c => c.name === 'device_id')?.value;
1902
- const finalDeviceId = cookies?.device_id || newDeviceId || clientIp;
1903
-
1904
- return `device:${finalDeviceId}`;
1905
- }
1906
- }
1907
-
1908
- /**
1909
- * Returns a default list of whitelisting rules for common and legitimate web crawlers.
1910
- * This list can be used as a base and extended with custom rules.
1911
- * @returns {Array<{userAgent: string, hostnameSuffix: string}>}
1912
- */
1913
- export const default_whitelist = () => [
1914
- // === Moteurs de recherche majeurs ===
1915
- { userAgent: 'Googlebot', hostnameSuffix: '.googlebot.com' },
1916
- { userAgent: 'Google-Extended', hostnameSuffix: '.google.com' },
1917
- { userAgent: 'AdsBot-Google', hostnameSuffix: '.googlebot.com' },
1918
- { userAgent: 'Mediapartners-Google', hostnameSuffix: '.google.com' },
1919
- { userAgent: 'Google-InspectionTool', hostnameSuffix: '.google.com' },
1920
- { userAgent: '(bingbot|adidxbot)', hostnameSuffix: '.search.msn.com' },
1921
- { userAgent: 'DuckDuckBot', hostnameSuffix: '.duckduckgo.com' },
1922
- { userAgent: 'YandexBot', hostnameSuffix: '.yandex.com' },
1923
- { userAgent: 'YandexImages', hostnameSuffix: '.yandex.com' },
1924
- { userAgent: 'Baiduspider', hostnameSuffix: '.crawl.baidu.com' },
1925
- { userAgent: 'Slurp', hostnameSuffix: '.crawl.yahoo.net' },
1926
- { userAgent: 'Sogou web spider', hostnameSuffix: '.sogou.com' },
1927
- { userAgent: 'Exabot', hostnameSuffix: '.exabot.com' },
1928
- { userAgent: 'ia_archiver', hostnameSuffix: '.alexa.com' },
1929
- { userAgent: 'SeznamBot', hostnameSuffix: '.seznam.cz' },
1930
- { userAgent: 'Mail.RU_Bot', hostnameSuffix: '.mail.ru' },
1931
- { userAgent: 'Yeti', hostnameSuffix: '.naver.com' }, // Naver
1932
-
1933
- // === Outils SEO et d'analyse ===
1934
- { userAgent: 'AhrefsBot', hostnameSuffix: '.ahrefs.com' },
1935
- { userAgent: 'SemrushBot', hostnameSuffix: '.semrush.com' },
1936
- { userAgent: 'MJ12bot', hostnameSuffix: '.mj12bot.com' }, // Majestic
1937
- { userAgent: 'rogerbot', hostnameSuffix: '.moz.com' }, // Moz
1938
- { userAgent: 'DotBot', hostnameSuffix: '.moz.com' }, // Moz (anciennement opensiteexplorer.org)
1939
- { userAgent: 'Screaming Frog SEO Spider', hostnameSuffix: '.screamingfrog.co.uk' },
1940
- { userAgent: 'cognitiveseo', hostnameSuffix: '.cognitiveseo.com' },
1941
- { userAgent: 'SEOkicks', hostnameSuffix: '.seokicks.com' },
1942
- { userAgent: 'serpstatbot', hostnameSuffix: '.serpstatbot.com' },
1943
- { userAgent: 'MegaIndex', hostnameSuffix: '.megaindex.com' },
1944
- { userAgent: 'LinkpadBot', hostnameSuffix: '.linkpad.ru' },
1945
- { userAgent: 'Sistrix', hostnameSuffix: '.sistrix.com' },
1946
- { userAgent: 'RyteBot', hostnameSuffix: '.ryte.com' },
1947
- { userAgent: 'linkfluence', hostnameSuffix: '.linkfluence.com' },
1948
- { userAgent: 'TurnitinBot', hostnameSuffix: '.turnitin.com' },
1949
- { userAgent: 'GrapeshotCrawler', hostnameSuffix: '.grapeshot.co.uk' },
1950
-
1951
- // === Robots d'IA et de données ===
1952
- { userAgent: 'GPTBot', hostnameSuffix: '.openai.com' },
1953
- { userAgent: 'ChatGPT-User', hostnameSuffix: '.openai.com' },
1954
- { userAgent: 'Applebot', hostnameSuffix: '.applebot.apple.com' },
1955
- { userAgent: 'CCBot', hostnameSuffix: '.commoncrawl.org' },
1956
- { userAgent: 'Bytespider', hostnameSuffix: '.bytespider.com' }, // ByteDance (TikTok)
1957
- { userAgent: 'Diffbot', hostnameSuffix: '.diffbot.com' },
1958
- { userAgent: 'PerplexityBot', hostnameSuffix: '.perplexity.ai' },
1959
- { userAgent: 'ClaudeBot', hostnameSuffix: '.anthropic.com' },
1960
- { userAgent: 'cohere.io', hostnameSuffix: '.cohere.io' },
1961
- { userAgent: 'DataForSeoBot', hostnameSuffix: '.dataforseo.com' },
1962
- { userAgent: 'YouBot', hostnameSuffix: '.you.com' },
1963
- { userAgent: 'omgili', hostnameSuffix: '.omgili.com' },
1964
-
1965
- // === Réseaux sociaux et partage ===
1966
- { userAgent: 'facebookexternalhit', hostnameSuffix: '.facebook.com' },
1967
- { userAgent: 'facebot', hostnameSuffix: '.facebook.com' },
1968
- { userAgent: 'Twitterbot', hostnameSuffix: '.twttr.com' },
1969
- { userAgent: 'Pinterestbot', hostnameSuffix: '.pinterest.com' },
1970
- { userAgent: 'LinkedInBot', hostnameSuffix: '.linkedin.com' },
1971
- { userAgent: 'Slackbot', hostnameSuffix: '.slack.com' },
1972
- { userAgent: 'Discordbot', hostnameSuffix: '.discord.com' },
1973
- { userAgent: 'TelegramBot', hostnameSuffix: '.telegram.org' },
1974
- { userAgent: 'WhatsApp', hostnameSuffix: '.wa.me' },
1975
- { userAgent: 'SkypeUriPreview', hostnameSuffix: '.skype.com' },
1976
- { userAgent: 'redditbot', hostnameSuffix: '.reddit.com' },
1977
-
1978
- // === Services de monitoring et d'uptime ===
1979
- { userAgent: 'UptimeRobot', hostnameSuffix: '.uptimerobot.com' },
1980
- { userAgent: 'Pingdom', hostnameSuffix: '.pingdom.com' },
1981
- { userAgent: 'StatusCake', hostnameSuffix: '.statuscake.com' },
1982
- { userAgent: 'Site24x7', hostnameSuffix: '.site24x7.com' },
1983
- { userAgent: 'Freshping', hostnameSuffix: '.freshping.io' },
1984
- { userAgent: 'Better Uptime', hostnameSuffix: '.betteruptime.com' },
1985
- { userAgent: 'Checkly', hostnameSuffix: '.checkly-infra.com' },
1986
- { userAgent: 'Datadog', hostnameSuffix: '.datadoghq.com' },
1987
- { userAgent: 'NewRelicPinger', hostnameSuffix: '.newrelic.com' },
1988
-
1989
- // === Archives et agrégateurs de contenu ===
1990
- { userAgent: 'archive.org_bot', hostnameSuffix: '.archive.org' },
1991
- { userAgent: 'Feedly', hostnameSuffix: '.feedly.com' },
1992
- { userAgent: 'FeedFetcher-Google', hostnameSuffix: '.google.com' },
1993
- { userAgent: 'TheOldReader', hostnameSuffix: '.theoldreader.com' },
1994
- { userAgent: 'Inoreader', hostnameSuffix: '.inoreader.com' },
1995
- { userAgent: 'FlipboardProxy', hostnameSuffix: '.flipboard.com' },
1996
- { userAgent: 'PaperLiBot', hostnameSuffix: '.paper.li' },
1997
-
1998
- // === Services Cloud et Plateformes ===
1999
- { userAgent: 'Amazon Route 53 Health Check', hostnameSuffix: '.amazonaws.com' },
2000
- { userAgent: 'Google-Cloud-Scheduler', hostnameSuffix: '.google.com' },
2001
- { userAgent: 'APIs-Google', hostnameSuffix: '.google.com' },
2002
-
2003
- // === Divers ===
2004
- { userAgent: 'W3C_Validator', hostnameSuffix: '.w3.org' },
2005
- { userAgent: 'GTmetrix', hostnameSuffix: '.gtmetrix.com' },
2006
- { userAgent: 'WebPageTest', hostnameSuffix: '.webpagetest.org' },
2007
- { userAgent: 'Google-Site-Verification', hostnameSuffix: '.google.com' },
2008
- { userAgent: 'KeyCDN', hostnameSuffix: '.keycdn.com' },
2009
- ];
2010
-
2011
-
2012
-
2013
- // --- Proof-of-Work Middleware (The Tollbooth) ---
2014
- export const powMiddleware = (securityConfig) => {
2015
- const engine = new FingerprintEngine(securityConfig);
2016
-
2017
- if (securityConfig.autotuning) {
2018
- startThresholdAutoTuning({
2019
- securityConfig: securityConfig,
2020
- ...securityConfig.autotuning,
2021
- });
2022
- }
2023
-
2024
- // Provide a default for isApiRequest if not specified by the user.
2025
- // This makes API challenge handling work more seamlessly out-of-the-box.
2026
- if (!securityConfig.thresholds?.isApiRequest) {
2027
- if (!securityConfig.thresholds) securityConfig.thresholds = {};
2028
- securityConfig.thresholds.isApiRequest = (req) =>
2029
- req.headers?.accept?.includes('application/json');
2030
- }
2031
-
2032
- return async (req, res, next) => {
2033
- const requestContext = {
2034
- clientIp: req.ip || req.socket?.remoteAddress || "unknown",
2035
- path: req.path,
2036
- cookies: req.cookies,
2037
- query: req.query,
2038
- body: req.body,
2039
- headers: req.headers,
2040
- isStatic: isStaticResource(req.path),
2041
- // Pass the original request object for the isApiRequest function
2042
- rawReq: req,
2043
- // Add the newly required properties for full decoupling
2044
- rawHeaders: req.rawHeaders,
2045
- // Pass the raw request object for advanced inspection (e.g., JA3)
2046
- httpVersion: req.httpVersion,
2047
- };
2048
-
2049
- const decision = await engine.processRequest(requestContext);
2050
-
2051
- // Attach the fingerprinting result to the request object for downstream middlewares.
2052
- req.fingerprint = {
2053
- score: decision.score,
2054
- vector: decision.vector,
2055
- };
2056
-
2057
- // After getSuspicionVector runs, it might have attached cookies to be set.
2058
- if (requestContext._newCookies) {
2059
- requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
2060
- }
2061
-
2062
- switch (decision.action) {
2063
- case 'block':
2064
- return res.status(decision.status).send(decision.body);
2065
-
2066
- case 'challenge': // Gère à la fois les réponses HTML et JSON
2067
- if (typeof decision.body === 'object' && decision.body !== null) {
2068
- return res.status(decision.status).json(decision.body);
2069
- }
2070
- // Par défaut, envoie du HTML
2071
- return res.status(decision.status).send(decision.body);
2072
-
2073
- case 'redirect':
2074
- if (decision.cookie) {
2075
- res.cookie(decision.cookie.name, decision.cookie.value, decision.cookie.options);
2076
- }
2077
- return res.redirect(decision.path);
2078
-
2079
- case 'next':
2080
- default:
2081
- return next();
2082
- }
2083
- };
2084
- };
2085
-
2086
- /**
2087
- * @internal
2088
- * Exporting an object containing the functions to make them mockable in tests.
2089
- * This is a common pattern to allow mocking of ES module functions.
2090
- */
2091
- export const __internal = {
2092
- getDeviceHash,
2093
- isMalicious,
2094
- getSuspicionVector,
2095
- cyrb53, // Export for testing
2096
- FingerprintBuilder, // Export for testing
2097
- calculateTarget,
2098
- determineOptimalTicketTtl,
2099
- getRequestPatternScore, // Expose for testing
2100
- getBehaviorScore, // Expose for testing
2101
- };
2102
-
2103
- // --- THRESHOLD AUTO-TUNING SECTION ---
2104
-
2105
- let autoTuningJobId = null;
2106
-
2107
- /**
2108
- * Executes a threshold optimization pass using collected traffic data.
2109
- * @private
2110
- * @param {object} securityConfig - The security configuration object to update.
2111
- * @param {Array<object>} trafficData - The array containing traffic logs.
2112
- * @param {number} minDataPoints - The minimum number of data points required to start optimization.
2113
- */
2114
- function runThresholdOptimization(securityConfig, trafficData, minDataPoints) {
2115
- if (trafficData.length < minDataPoints) {
2116
- console.log(`[AutoTuning] Reporté : ${trafficData.length}/${minDataPoints} points de données.`);
2117
- return;
2118
- }
2119
- console.log(`[AutoTuning] Démarrage du cycle d'optimisation avec ${trafficData.length} points de données.`);
2120
-
2121
- // Classify historical requests with a confidence weight.
2122
- const solvedDevices = new Set(trafficData.filter(e => e.type === 'challenge_solved').map(e => e.deviceId));
2123
- const challengedDevices = new Set(trafficData.filter(e => e.type === 'challenge_issued').map(e => e.deviceId));
2124
-
2125
- const historicalRequests = trafficData.map(log => {
2126
- // Assign a label ('bot' or 'human') and a confidence weight to each log entry.
2127
- switch (log.type) {
2128
- case 'honeypot_probe':
2129
- case 'trap_triggered':
2130
- return { score: log.score, label: 'bot', confidence: 10.0 }; // Very high confidence
2131
-
2132
- case 'challenge_issued':
2133
- // A challenge issued to a device that never solved it is a strong bot signal.
2134
- if (!solvedDevices.has(log.deviceId)) {
2135
- return { score: log.score, label: 'bot', confidence: 3.0 }; // High confidence
2136
- }
2137
- // If the challenge was eventually solved, this specific log is neutral.
2138
- return null;
2139
-
2140
- case 'challenge_solved':
2141
- return { score: log.score, label: 'human', confidence: 5.0 }; // High confidence
2142
-
2143
- case 'request_passed':
2144
- // A passed request from a device that was never even challenged is likely a human.
2145
- if (!challengedDevices.has(log.deviceId)) {
2146
- return { score: log.score, label: 'human', confidence: 0.5 }; // Low confidence
2147
- }
2148
- // If the device was challenged at some point, this log is ambiguous.
2149
- return null;
2150
-
2151
- default:
2152
- return null;
2153
- }
2154
- }).filter(Boolean); // Remove null entries
2155
-
2156
- // The "fitness" function evaluates the quality of a set of thresholds.
2157
- // A lower score is better.
2158
- const fitnessFunction = (solution) => {
2159
- const [low, medium, high, velocityThreshold, burstThreshold, scrapeThreshold] = solution;
2160
- if (low >= medium || medium >= high || low < 10 || high > 90) return Infinity;
2161
- if (velocityThreshold < 50 || velocityThreshold > burstThreshold || burstThreshold > scrapeThreshold) return Infinity;
2162
-
2163
- let weightedFalsePositives = 0; // Humans challenged unnecessarily.
2164
- let weightedFalseNegatives = 0; // Undetected bots.
2165
-
2166
- for (const req of historicalRequests) {
2167
- if (req.label === 'bot') {
2168
- if (req.score < low) weightedFalseNegatives += req.confidence;
2169
- } else { // 'human'
2170
- if (req.score >= low) weightedFalsePositives += req.confidence;
2171
- }
2172
- }
2173
- // The penalty for false negatives is implicitly higher due to the higher confidence scores of bot signals.
2174
- return weightedFalsePositives + weightedFalseNegatives;
2175
- };
2176
-
2177
- // Functions for the genetic algorithm.
2178
- const createIndividual = () => [
2179
- 10 + Math.random() * 20, // low
2180
- 30 + Math.random() * 30, // medium
2181
- 60 + Math.random() * 30, // high
2182
- 100 + Math.random() * 150, // velocityThreshold (100-250ms)
2183
- 300 + Math.random() * 400, // burstThreshold (300-700ms)
2184
- 800 + Math.random() * 700, // scrapeThreshold (800-1500ms)
2185
- ];
2186
- const crossover = (p1, p2) => p1.map((val, i) => (val + p2[i]) / 2);
2187
- const mutate = (s) => {
2188
- const n = [...s];
2189
- const i = Math.floor(Math.random() * n.length);
2190
- // Adjust mutation range based on parameter
2191
- const mutationRange = i < 3 ? 5 : 50;
2192
- n[i] += (Math.random() - 0.5) * mutationRange;
2193
- return n;
2194
- };
2195
-
2196
- // Start optimization.
2197
- const result = Optimization.geneticAlgorithm(createIndividual, fitnessFunction, crossover, mutate, {
2198
- generations: 50,
2199
- populationSize: 40
2200
- });
2201
-
2202
- const [newLow, newMedium, newHigh, newVelocity, newBurst, newScrape] = result.solution;
2203
-
2204
- // Update the configuration live.
2205
- // Ensure thresholds object exists
2206
- if (!securityConfig.thresholds) securityConfig.thresholds = {};
2207
- securityConfig.thresholds.low = Math.round(newLow);
2208
- securityConfig.thresholds.medium = Math.round(newMedium);
2209
- securityConfig.thresholds.high = Math.round(newHigh);
2210
-
2211
- // Update pattern detection parameters
2212
- if (!securityConfig.patterns) securityConfig.patterns = {};
2213
- securityConfig.patterns.velocityThreshold = Math.round(newVelocity);
2214
- securityConfig.patterns.burstThreshold = Math.round(newBurst);
2215
- securityConfig.patterns.scrapeThreshold = Math.round(newScrape);
2216
- // Weights could also be optimized, but let's keep it to thresholds for now for simplicity.
2217
-
2218
- console.log("[AutoTuning] Nouveaux seuils optimisés appliqués :", securityConfig.thresholds);
2219
- if (securityConfig.patterns) {
2220
- console.log("[AutoTuning] Nouveaux paramètres de pattern appliqués :", securityConfig.patterns);
2221
- }
2222
- }
2223
-
2224
- /**
2225
- * Starts the background process for auto-tuning security thresholds.
2226
- * @export
2227
- * @param {object} options - Configuration options for auto-tuning.
2228
- * @param {object} options.securityConfig - The live security configuration object that will be mutated.
2229
- * @param {Array<object>} options.trafficData - The array where the logger pushes traffic data.
2230
- * @param {number} [options.interval=1800000] - The interval in milliseconds between each optimization cycle (default: 30 minutes).
2231
- * @param {number} [options.minDataPoints=200] - The minimum number of requests to analyze before starting a cycle (default: 200).
2232
- */
2233
- export function startThresholdAutoTuning(options) {
2234
- if (autoTuningJobId) {
2235
- console.warn("[AutoTuning] Le job est déjà en cours d'exécution.");
2236
- return;
2237
- }
2238
-
2239
- const {
2240
- securityConfig,
2241
- trafficData,
2242
- interval = 1800000,
2243
- minDataPoints = 200
2244
- } = options;
2245
-
2246
- if (!securityConfig || !trafficData) {
2247
- throw new Error("[AutoTuning] `securityConfig` et `trafficData` sont requis.");
2248
- }
2249
-
2250
- console.log(`[AutoTuning] Job d'optimisation des seuils démarré. Prochain cycle dans ${interval / 60000} minutes.`);
2251
-
2252
- autoTuningJobId = setInterval(() => {
2253
- runThresholdOptimization(securityConfig, trafficData, minDataPoints);
2254
- }, interval);
2255
- }
2256
-
2257
- /**
2258
- * Stops the threshold auto-tuning process.
2259
- * @export
2260
- */
2261
- export function stopThresholdAutoTuning() {
2262
- if (autoTuningJobId) {
2263
- clearInterval(autoTuningJobId);
2264
- autoTuningJobId = null;
2265
- console.log("[AutoTuning] Job d'optimisation des seuils arrêté.");
2266
- }
2267
- }
1
+ // C:/Dev/games.primals.net/src/utils/fingerprint.js
2
+ import crypto from "node:crypto";
3
+ import { BlockList } from "node:net";
4
+ import dns from "node:dns/promises";
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
+ export { createRedisStore } from "./redis-store.js";
11
+ export { createMongoDbStore } from "./mongodb-store.js";
12
+
13
+ /**
14
+ * Retrieves the POW_SECRET from environment variables with appropriate checks.
15
+ * @returns {string} The secret key.
16
+ */
17
+ const getPowSecret = () => {
18
+ const secret = process.env.POW_SECRET;
19
+ if (!secret && process.env.NODE_ENV === 'production') {
20
+ throw new Error('POW_SECRET environment variable is not set. This is required for production.');
21
+ }
22
+ return secret || "fallback-dev-secret-32-chars-minimum";
23
+ };
24
+
25
+ /**
26
+ * Loads the pow.solver.js content for inlining in HTML pages.
27
+ * @returns {string} The solver JavaScript code.
28
+ */
29
+ const getPowSolverCode = () => {
30
+ try {
31
+ const __filename = fileURLToPath(import.meta.url);
32
+ const __dirname = dirname(__filename);
33
+ const solverPath = join(__dirname, 'pow.solver.inline.js'); // Use the inline version
34
+ return readFileSync(solverPath, 'utf-8');
35
+ } catch (error) {
36
+ console.warn('Could not load pow.solver.js for inlining, using fallback inline code');
37
+ // Fallback inline code if file cannot be loaded
38
+ return `(function(global){
39
+ async function solveCpuTargetInline(clientIp, nonce, target, clientSecret, progressCallback){
40
+ const cpuTarget = BigInt(target);
41
+ let cpuSolution = 0;
42
+ while(true){
43
+ const msg = clientSecret ? clientIp+':'+nonce+':'+cpuSolution+':'+clientSecret : clientIp+':'+nonce+':'+cpuSolution;
44
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
45
+ const hashHex = Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('');
46
+ if(BigInt('0x'+hashHex) < cpuTarget) break;
47
+ cpuSolution++;
48
+ if(cpuSolution % 100000 === 0) await new Promise(r=>setTimeout(r,0));
49
+ }
50
+ return cpuSolution;
51
+ }
52
+ async function solveMemory(seed, difficulty){
53
+ const size = difficulty * 1024 * 1024;
54
+ const buffer = new Uint32Array(size / 4);
55
+ let h = new TextEncoder().encode(seed).reduce((acc,v)=>acc+v,0);
56
+ for(let i=0;i<buffer.length;i++) buffer[i] = h = Math.imul(h^i,1597334677);
57
+ let solution = 0;
58
+ const iterations = size / 16;
59
+ let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
60
+ for(let i=0;i<iterations;i++){
61
+ addr = buffer[addr] % buffer.length;
62
+ solution ^= addr;
63
+ }
64
+ return solution;
65
+ }
66
+ async function solveTsp(cities, targetMaxDistance){
67
+ function distance(c1,c2){return Math.sqrt(Math.pow(c1.x-c2.x,2)+Math.pow(c1.y-c2.y,2));}
68
+ function evaluatePathDistance(cities,path){
69
+ let total=0;
70
+ for(let i=0;i<path.length-1;i++) total+=distance(cities[path[i]],cities[path[i+1]]);
71
+ total+=distance(cities[path[path.length-1]],cities[path[0]]);
72
+ return total;
73
+ }
74
+ function solveTspNearestNeighbor(cities){
75
+ const n=cities.length;
76
+ if(n===0)return[];
77
+ let path=[0];
78
+ let visited=new Array(n).fill(false);
79
+ visited[0]=true;
80
+ for(let i=1;i<n;i++){
81
+ let nearest=-1, minDist=Infinity;
82
+ for(let j=0;j<n;j++){
83
+ if(!visited[j]){
84
+ const d=distance(cities[path[i-1]],cities[j]);
85
+ if(d<minDist){minDist=d;nearest=j;}
86
+ }
87
+ }
88
+ path.push(nearest);
89
+ visited[nearest]=true;
90
+ }
91
+ return path;
92
+ }
93
+ await new Promise(r=>setTimeout(r,10));
94
+ const solutionPath=solveTspNearestNeighbor(cities);
95
+ const solutionDistance=evaluatePathDistance(cities,solutionPath);
96
+ return{path:solutionPath,distance:solutionDistance};
97
+ }
98
+ global.solveCpuChallengeInline=solveCpuTargetInline;
99
+ global.solveMemoryChallenge=solveMemory;
100
+ global.solveTspChallenge=solveTsp;
101
+ })(typeof window!=='undefined'?window:global);`;
102
+ }
103
+ };
104
+
105
+ /**
106
+ * Calculates the JA3 fingerprint hash from the TLS Client Hello message.
107
+ * JA3 is a more reliable way to identify client applications (e.g., a specific browser or a script)
108
+ * based on the specifics of its TLS handshake.
109
+ * @param {object} context - The request context, containing the raw request object.
110
+ * @returns {string|null} The MD5 hash of the JA3 string, or null if it cannot be computed.
111
+ */
112
+ function getJa3Hash(context) {
113
+ // 1. Prefer the JA3 hash from a trusted reverse proxy (e.g., Nginx, Cloudflare).
114
+ const ja3FromHeader = context.headers['x-ja3-hash'];
115
+ if (ja3FromHeader) {
116
+ return ja3FromHeader;
117
+ }
118
+
119
+ // 2. Fallback to calculating from the raw socket if available (requires Node.js to handle TLS).
120
+ const clientHello = context.rawReq?.socket?.clientHello;
121
+ if (!clientHello) {
122
+ return null;
123
+ }
124
+
125
+ try {
126
+ const { version, ciphers, extensions, ellipticCurves, ellipticCurvePointFormats } = clientHello;
127
+
128
+ // The official JA3 spec includes the TLS version.
129
+ // Node.js provides it as a string like 'TLSv1.3', we need the corresponding decimal value.
130
+ const tlsVersionMap = {
131
+ 'TLSv1': 769, 'TLSv1.1': 770, 'TLSv1.2': 771, 'TLSv1.3': 772
132
+ };
133
+ const tlsVersionId = tlsVersionMap[version] || 0;
134
+
135
+ const ja3String = [
136
+ tlsVersionId,
137
+ // The ciphers array from clientHello is an array of objects, not just IDs.
138
+ Array.isArray(ciphers) ? ciphers.join('-') : '',
139
+ extensions?.join('-') || '',
140
+ ellipticCurves?.join('-') || '',
141
+ ellipticCurvePointFormats?.join('-') || ''
142
+ ].join(',');
143
+
144
+ return crypto.createHash('md5').update(ja3String).digest('hex');
145
+ } catch (e) {
146
+ return null; // Could fail if clientHello structure is unexpected.
147
+ }
148
+ }
149
+ /**
150
+ * Creates a stable hash based on device characteristics, independent of the IP.
151
+ * This is our "level 2 fingerprint".
152
+ * @param {object} context - The request context.
153
+ * @returns {string} A hash representing the device.
154
+ */
155
+ function getHeaderSignature(context) {
156
+ if (!context.rawHeaders) return '';
157
+ const headerKeys = [];
158
+ for (let i = 0; i < context.rawHeaders.length; i += 2) {
159
+ headerKeys.push(context.rawHeaders[i]);
160
+ }
161
+ return cyrb53(headerKeys.join(','));
162
+ }
163
+ export function getDeviceHash(context) {
164
+ // Prioritize the rich client-side fingerprint if provided.
165
+ const clientFp = context.headers['x-device-fingerprint'];
166
+ if (clientFp && typeof clientFp === 'string' && clientFp.includes('cvs:')) {
167
+ return clientFp;
168
+ }
169
+
170
+ const srv = new FingerprintBuilder();
171
+
172
+ // 1. SIGNAL FORT: User Agent (poids élevé)
173
+ const ua = context.headers["user-agent"];
174
+ if (ua) {
175
+ srv.add("ua", ua);
176
+ // Extraire des infos supplémentaires du UA
177
+ const uaParts = parseUserAgent(ua);
178
+ if (uaParts.browser) srv.add("browser", uaParts.browser);
179
+ if (uaParts.os) srv.add("os_version", uaParts.os);
180
+ if (uaParts.device) srv.add("device_type", uaParts.device);
181
+ }
182
+
183
+ // 2. SIGNAL FORT: JA3 TLS Fingerprint
184
+ const ja3 = getJa3Hash(context);
185
+ if (ja3) srv.add("ja3", ja3);
186
+
187
+ // 3. SIGNAL MOYEN: Client Hints (modern browsers)
188
+ if (context.headers["sec-ch-ua"]) {
189
+ srv.add("ch_ua", context.headers["sec-ch-ua"]);
190
+ }
191
+ if (context.headers["sec-ch-ua-platform"]) {
192
+ srv.add("ch_platform", context.headers["sec-ch-ua-platform"]);
193
+ }
194
+ if (context.headers["sec-ch-ua-mobile"]) {
195
+ srv.add("ch_mobile", context.headers["sec-ch-ua-mobile"]);
196
+ }
197
+ if (context.headers["sec-ch-ua-model"]) {
198
+ srv.add("ch_model", context.headers["sec-ch-ua-model"]);
199
+ }
200
+ if (context.headers["sec-ch-ua-arch"]) {
201
+ srv.add("ch_arch", context.headers["sec-ch-ua-arch"]);
202
+ }
203
+ if (context.headers["sec-ch-ua-bitness"]) {
204
+ srv.add("ch_bitness", context.headers["sec-ch-ua-bitness"]);
205
+ }
206
+
207
+ // 4. SIGNAL MOYEN: HTTP Version et protocole
208
+ if (context.httpVersion) {
209
+ srv.add("http_ver", context.httpVersion);
210
+ }
211
+ if (context.headers["upgrade-insecure-requests"]) {
212
+ srv.add("upgrade", context.headers["upgrade-insecure-requests"]);
213
+ }
214
+
215
+ // 10. SIGNAL FORT: Ordonnancement des headers
216
+ srv.add("h_ord", getHeaderSignature(context));
217
+
218
+ // 11. SIGNAL AVANCÉ: Cookies (si disponible)
219
+ if (context.cookies) {
220
+ const cookieKeys = Object.keys(context.cookies).sort().join(',');
221
+ srv.add("cookie_keys", cookieKeys);
222
+ }
223
+
224
+ // 12. SIGNAL AVANCÉ: Format de la requête
225
+ if (context.rawHeaders) {
226
+ // Vérifier des headers spécifiques qui indiquent le client
227
+ const clientHeaders = ['x-requested-with', 'x-forwarded-for', 'x-real-ip', 'cf-connecting-ip'];
228
+ clientHeaders.forEach(h => {
229
+ if (context.headers[h]) {
230
+ srv.add(h.replace(/-/g, '_'), context.headers[h]);
231
+ }
232
+ });
233
+ }
234
+
235
+ // 13. OPTIONNEL: IP (version simplifiée pour les réseaux partagés)
236
+ // Ne pas inclure l'IP complète, mais un hash du réseau /24 ou /16
237
+ // pour détecter les changements de réseau tout en protégeant la vie privée
238
+ const ip = context.clientIp || context.headers['x-forwarded-for']?.split(',')[0]?.trim();
239
+ if (ip && isPrivateIp(ip)) {
240
+ // Pour les IP privées, on peut prendre le /24
241
+ const networkHash = hashNetwork(ip, 24);
242
+ srv.add("network", networkHash);
243
+ }
244
+
245
+ return srv.toString();
246
+ }
247
+
248
+ // Fonctions utilitaires
249
+ function parseUserAgent(ua) {
250
+ // Parser basique du User-Agent
251
+ const result = {};
252
+
253
+ // Détection du navigateur
254
+ if (ua.includes('Chrome') && !ua.includes('Edg')) {
255
+ result.browser = 'Chrome';
256
+ const match = ua.match(/Chrome\/(\d+)/);
257
+ if (match) result.browser += `/${match[1]}`;
258
+ } else if (ua.includes('Firefox')) {
259
+ result.browser = 'Firefox';
260
+ const match = ua.match(/Firefox\/(\d+)/);
261
+ if (match) result.browser += `/${match[1]}`;
262
+ } else if (ua.includes('Safari') && !ua.includes('Chrome')) {
263
+ result.browser = 'Safari';
264
+ const match = ua.match(/Version\/(\d+)/);
265
+ if (match) result.browser += `/${match[1]}`;
266
+ } else if (ua.includes('Edg')) {
267
+ result.browser = 'Edge';
268
+ const match = ua.match(/Edg\/(\d+)/);
269
+ if (match) result.browser += `/${match[1]}`;
270
+ }
271
+
272
+ // Détection de l'OS
273
+ if (ua.includes('Windows NT 10.0')) result.os = 'Windows 10';
274
+ else if (ua.includes('Windows NT 6.1')) result.os = 'Windows 7';
275
+ else if (ua.includes('Mac OS X')) result.os = 'macOS';
276
+ else if (ua.includes('Linux') && !ua.includes('Android')) result.os = 'Linux';
277
+ else if (ua.includes('Android')) result.os = 'Android';
278
+ else if (ua.includes('iPhone') || ua.includes('iPad')) result.os = 'iOS';
279
+
280
+ // Détection du type d'appareil
281
+ if (ua.includes('Mobile')) result.device = 'mobile';
282
+ else if (ua.includes('Tablet')) result.device = 'tablet';
283
+ else result.device = 'desktop';
284
+
285
+ return result;
286
+ }
287
+
288
+ function normalizeReferer(referer) {
289
+ try {
290
+ const url = new URL(referer);
291
+ return `${url.protocol}//${url.hostname}`;
292
+ } catch {
293
+ return referer;
294
+ }
295
+ }
296
+
297
+ function isPrivateIp(ip) {
298
+ // Vérifier si l'IP est privée
299
+ const parts = ip.split('.');
300
+ if (parts.length !== 4) return false;
301
+ const first = parseInt(parts[0]);
302
+ return (first === 10) || (first === 172 && parseInt(parts[1]) >= 16 && parseInt(parts[1]) <= 31) || (first === 192 && parseInt(parts[1]) === 168);
303
+ }
304
+
305
+ function hashNetwork(ip, prefix = 24) {
306
+ // Hash du réseau (masque /24 ou /16)
307
+ const parts = ip.split('.');
308
+ if (parts.length !== 4) return null;
309
+ const maskBytes = prefix / 8;
310
+ const network = parts.slice(0, maskBytes).join('.');
311
+ // Hash simple
312
+ let hash = 0;
313
+ for (let i = 0; i < network.length; i++) {
314
+ const char = network.charCodeAt(i);
315
+ hash = ((hash << 5) - hash) + char;
316
+ hash = hash & hash;
317
+ }
318
+ return hash.toString(16);
319
+ }
320
+
321
+ /**
322
+ * Generates the HTML content for a TSP (Traveling Salesperson Problem) challenge.
323
+ * @param {string} nonce - Unique nonce for the challenge.
324
+ * @param {number} numCities - Number of cities to include in the problem.
325
+ * @param {number} targetMaxDistance - Maximum acceptable distance for the solution.
326
+ * @param {Array<{x: number, y: number}>} cities - Coordinates of the cities.
327
+ * @param {string} path - Redirect path after solving.
328
+ * @returns {string} HTML of the challenge page.
329
+ */
330
+ const generateTspChallenge = (
331
+ nonce,
332
+ numCities,
333
+ targetMaxDistance,
334
+ cities,
335
+ path = "",
336
+ ) => {
337
+ const citiesJson = JSON.stringify(cities);
338
+ const solverCode = getPowSolverCode();
339
+ return `
340
+ <html>
341
+ <head><title>Advanced Security Check (Level 3)</title></head>
342
+ <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
343
+ <h1>Ultimate Verification (Level 3)</h1>
344
+ <p>Please solve this small optimization problem to prove you are human.</p>
345
+ <div id="loader" style="margin:20px;">⚙️ Calculating route... (${numCities} cities)</div>
346
+ <script>${solverCode}</script>
347
+ <script>
348
+ const cities = ${citiesJson};
349
+ const nonce = "${nonce}";
350
+ const targetMaxDistance = ${targetMaxDistance};
351
+
352
+ async function solve() {
353
+ const result = await window.solveTspChallenge(cities, targetMaxDistance);
354
+
355
+ if (result.distance <= targetMaxDistance) {
356
+ window.location.href = "${path}" + "?pow_type=tsp&pow_nonce=" + nonce + "&pow_solution=" + JSON.stringify(result.path);
357
+ } else {
358
+ document.getElementById('loader').innerText = "Error: Could not find a sufficient solution. Please try again.";
359
+ }
360
+ }
361
+ solve();
362
+ </script>
363
+ </body>
364
+ </html>`;
365
+ };
366
+
367
+ /**
368
+ * Verifies a TSP PoW solution.
369
+ * @param {string} nonce - The challenge nonce.
370
+ * @param {string} solutionPathJson - The path proposed by the client (stringified JSON).
371
+ * @param {number} numCities - The number of cities in the challenge.
372
+ * @param {number} targetMaxDistance - The maximum acceptable distance.
373
+ * @param {Array<{x: number, y: number}>} cities - The coordinates of the cities.
374
+ * @returns {boolean} True if the solution is valid.
375
+ */
376
+ export const verifyTspChallenge = (
377
+ nonce,
378
+ solutionPathJson,
379
+ numCities,
380
+ targetMaxDistance,
381
+ cities,
382
+ ) => {
383
+ try {
384
+ const solutionPath = JSON.parse(solutionPathJson);
385
+ if (!Array.isArray(solutionPath) || solutionPath.length !== numCities)
386
+ return false;
387
+
388
+ // Verify that the path is a valid permutation of the cities
389
+ const uniqueCities = new Set(solutionPath);
390
+ if (
391
+ uniqueCities.size !== numCities ||
392
+ Math.min(...solutionPath) < 0 ||
393
+ Math.max(...solutionPath) >= numCities
394
+ )
395
+ return false;
396
+
397
+ // Recalculate the distance on the server side
398
+ let totalDistance = 0;
399
+ let totalPenalty = 0;
400
+
401
+ // Function to calculate the angle between 3 points (p1 -> p2 -> p3)
402
+ const calculateAngle = (p1, p2, p3) => {
403
+ const v1 = { x: p1.x - p2.x, y: p1.y - p2.y };
404
+ const v2 = { x: p3.x - p2.x, y: p3.y - p2.y };
405
+ const dotProduct = v1.x * v2.x + v1.y * v2.y;
406
+ const mag1 = Math.sqrt(v1.x * v1.x + v1.y * v1.y);
407
+ const mag2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y);
408
+ if (mag1 === 0 || mag2 === 0) return 180;
409
+ const angleRad = Math.acos(dotProduct / (mag1 * mag2));
410
+ return angleRad * (180 / Math.PI);
411
+ };
412
+
413
+ for (let i = 0; i < solutionPath.length; i++) {
414
+ const p1_idx = solutionPath[i];
415
+ const p2_idx = solutionPath[(i + 1) % numCities];
416
+ const p3_idx = solutionPath[(i + 2) % numCities];
417
+
418
+ // 1. Calculate segment distance
419
+ totalDistance += Math.sqrt(Math.pow(cities[p1_idx].x - cities[p2_idx].x, 2) + Math.pow(cities[p1_idx].y - cities[p2_idx].y, 2));
420
+
421
+ // 2. Calculate turn penalty
422
+ const angle = calculateAngle(
423
+ cities[p1_idx],
424
+ cities[p2_idx],
425
+ cities[p3_idx],
426
+ );
427
+ if (angle < 45) {
428
+ // Penalty for very sharp turns (< 45 degrees)
429
+ totalPenalty += (45 - angle) * 5; // The penalty is proportional to the sharpness of the angle
430
+ }
431
+ }
432
+
433
+ const finalScore = totalDistance + totalPenalty;
434
+ return finalScore <= targetMaxDistance;
435
+ } catch (e) {
436
+ console.error("Error during TSP challenge verification:", e);
437
+ return false;
438
+ }
439
+ };
440
+
441
+ /**
442
+ * Generates the HTML content for the CPU PoW challenge (SHA-256).
443
+ */
444
+ const generateCpuPoWChallenge = (
445
+ clientIp,
446
+ nonce,
447
+ difficulty = 4,
448
+ path = "",
449
+ ) => {
450
+ return `
451
+ <html>
452
+ <head><title>Security Check</title></head>
453
+ <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
454
+ <h1>One moment... (Level 1)</h1>
455
+ <p>We are verifying that you are not a bot. This takes a few seconds.</p>
456
+ <div id="loader" style="margin:20px;">⚙️ Performing CPU security calculation...</div>
457
+ <script>
458
+ async function solve() {
459
+ const ip = "${clientIp}";
460
+ const nonce = "${nonce}";
461
+ const diff = ${difficulty};
462
+ const target = "0".repeat(diff);
463
+ let solution = 0;
464
+
465
+ while (true) {
466
+ const msg = "${ip}" + ":" + "${nonce}" + ":" + solution;
467
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
468
+ const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
469
+ if (hash.startsWith(target)) break;
470
+ solution++;
471
+ if (solution % 100000 === 0) await new Promise(resolve => setTimeout(resolve, 0)); // To avoid freezing the browser
472
+ }
473
+ window.location.href = "${path}" + "?pow_type=cpu&pow_nonce=" + nonce + "&pow_solution=" + solution;
474
+ }
475
+ solve();
476
+ </script>
477
+ </body>
478
+ </html>
479
+ `;
480
+ };
481
+
482
+ /**
483
+ * Generates the HTML content for a memory-intensive PoW challenge.
484
+ */
485
+ const generateMemoryPoWChallenge = (
486
+ clientIp,
487
+ nonce,
488
+ difficulty = 16,
489
+ path = "",
490
+ ) => {
491
+ // difficulty here is the buffer size in MB.
492
+ return `
493
+ <html>
494
+ <head><title>Advanced Security Check</title></head>
495
+ <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
496
+ <h1>Enhanced Verification... (Level 2)</h1>
497
+ <p>Your activity requires an additional security check.</p>
498
+ <div id="loader" style="margin:20px;">⚙️ Performing memory allocation and calculation... (${difficulty} MB)</div>
499
+ <script>
500
+ async function solve() {
501
+ const nonce = "${nonce}";
502
+ const size = ${difficulty} * 1024 * 1024; // en octets
503
+ const iterations = size / 16;
504
+
505
+ try {
506
+ const buffer = new Uint32Array(size / 4);
507
+ let h = new TextEncoder().encode(nonce).reduce((acc, v) => acc + v, 0);
508
+ for (let i = 0; i < buffer.length; i++) {
509
+ buffer[i] = (h = Math.imul(h ^ i, 1597334677));
510
+ }
511
+
512
+ let finalHash = 0;
513
+ for(let i = 0; i < iterations; i++) {
514
+ const addr = buffer[i % buffer.length] % buffer.length;
515
+ finalHash ^= buffer[addr];
516
+ }
517
+ window.location.href = "${path}" + "?pow_type=mem&pow_nonce=" + nonce + "&pow_solution=" + finalHash;
518
+ } catch(e) {
519
+ document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
520
+ }
521
+ }
522
+ solve();
523
+ </script>
524
+ </body>
525
+ </html>`;
526
+ };
527
+
528
+ /**
529
+ * Verifies if a PoW solution is valid and generates a clearance ticket.
530
+ */
531
+ export const verifyPoWAndGenerateTicket = (
532
+ ip,
533
+ nonce,
534
+ solution,
535
+ difficulty = 4,
536
+ ) => {
537
+ // 1. Verify the solution: hash(ip + nonce + solution) must start with N zeros
538
+ const hash = crypto
539
+ .createHash("sha256")
540
+ .update(`${ip}:${nonce}:${solution}`)
541
+ .digest("hex");
542
+
543
+ if (!hash.startsWith("0".repeat(difficulty))) {
544
+ return null;
545
+ }
546
+
547
+ // 2. Generate an HMAC ticket so the client doesn't have to do it again for 1 hour
548
+ const expiry = Date.now() + 3600000; // 1 heure
549
+ const signature = crypto
550
+ .createHmac("sha256", getPowSecret())
551
+ .update(`${ip}:${expiry}`)
552
+ .digest("hex");
553
+
554
+ return `${expiry}:${signature}`;
555
+ };
556
+
557
+
558
+
559
+ /**
560
+ * Verifies a memory PoW solution.
561
+ * The server performs the same calculation to validate.
562
+ */
563
+ export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret) => {
564
+ const size = difficulty * 1024 * 1024;
565
+ const iterations = size / 16;
566
+ const buffer = new Uint32Array(size / 4);
567
+ const seed = clientSecret ? `${nonce}:${clientSecret}` : nonce;
568
+ let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
569
+
570
+ for (let i = 0; i < buffer.length; i++) {
571
+ buffer[i] = h = Math.imul(h ^ i, 1597334677);
572
+ }
573
+
574
+ let finalHash = 0;
575
+ let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
576
+ for (let i = 0; i < iterations; i++) {
577
+ addr = buffer[addr] % buffer.length;
578
+ finalHash ^= addr;
579
+ }
580
+ return finalHash === parseInt(solution, 10);
581
+ };
582
+ export const isTicketValid = (ip, ticket) => {
583
+ if (!ticket) return false;
584
+ const [expiry, sig] = ticket.split(":");
585
+ if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
586
+ const expectedSig = crypto
587
+ .createHmac("sha256", getPowSecret())
588
+ .update(`${ip}:${expiry}`)
589
+ .digest("hex");
590
+
591
+ // Use timingSafeEqual to prevent timing attacks
592
+ try {
593
+ return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
594
+ } catch (e) {
595
+ // This can happen if the buffers have different lengths, which is a failure case.
596
+ return false;
597
+ }
598
+ };
599
+
600
+
601
+ /**
602
+ * Calculates suspicion indicators related to HTTP header anomalies.
603
+ * @param {object} context - The request context.
604
+ * @returns {{headerAnomalyScore: number}}
605
+ */
606
+ function getHeaderAnomalies(context) {
607
+ let anomalyScore = 0;
608
+ // Strong penalty if User-Agent is missing or very short (sign of a simple script)
609
+ if (!context.headers["user-agent"] || context.headers["user-agent"].length < 10) {
610
+ anomalyScore += 60;
611
+ }
612
+ // Penalty if Accept-Language header is missing
613
+ if (!context.headers["accept-language"]) {
614
+ anomalyScore += 25;
615
+ }
616
+ // Penalty for HTTP/1.0 requests, often used by old tools or bots
617
+ if (context.httpVersion === "1.0") {
618
+ anomalyScore += 15;
619
+ }
620
+
621
+ return {
622
+ headerAnomalyScore: Math.min(100, anomalyScore),
623
+ };
624
+ }
625
+
626
+ /**
627
+ * Checks for submitted honeypot fields to detect bots.
628
+ * @param {object} context - The request context.
629
+ * @param {object} honeypotConfig - The honeypot configuration.
630
+ * @returns {{honeypotScore: number}}
631
+ */
632
+ function getHoneypotScore(context, honeypotConfig = {}) {
633
+ const { fields = [], trapUrls = [], detectInjections = true } = honeypotConfig;
634
+ // (NOUVEAU) Permettre de brancher des analyseurs externes plus robustes.
635
+ // L'utilisateur pourrait passer une fonction qui prend les données de la requête
636
+ // et retourne `true` si une menace est détectée.
637
+ // Exemple: `(data) => myWafLibrary.isMalicious(data)`
638
+ const externalAnalyzers = honeypotConfig.analyzers || [];
639
+ if (typeof detectInjections === 'object' && detectInjections.analyzers) {
640
+ externalAnalyzers.push(...detectInjections.analyzers);
641
+ }
642
+
643
+ // 1. Check for trap URL access
644
+ if (trapUrls.some(trap => context.path.startsWith(trap))) {
645
+ return { honeypotScore: 100 };
646
+ }
647
+
648
+ if (fields.length === 0 && !detectInjections) {
649
+ return { honeypotScore: 0 };
650
+ }
651
+
652
+ // Check both query parameters (for URL probing) and the request body (for hidden form fields).
653
+ const queryData =
654
+ context.query instanceof URLSearchParams
655
+ ? Object.fromEntries(context.query.entries())
656
+ : context.query || {};
657
+ const bodyData = context.body || {};
658
+
659
+ // 2. Check for honeypot field names
660
+ for (const field of fields) {
661
+ // A bot is trapped if the field exists in either the query OR the body.
662
+ if (
663
+ Object.prototype.hasOwnProperty.call(queryData, field) ||
664
+ Object.prototype.hasOwnProperty.call(bodyData, field)
665
+ ) {
666
+ return { honeypotScore: 100 }; // A bot fell into the trap, maximum score.
667
+ }
668
+ }
669
+
670
+ // 3. (NOUVEAU) Utiliser les analyseurs externes
671
+ const allData = { ...queryData, ...bodyData };
672
+ if (externalAnalyzers.length > 0) {
673
+ for (const analyzer of externalAnalyzers) {
674
+ // On passe à l'analyseur l'ensemble des données de la requête.
675
+ if (analyzer(allData)) {
676
+ return { honeypotScore: 100 };
677
+ }
678
+ }
679
+ }
680
+
681
+ if (detectInjections) {
682
+ // 3. Check for injection attempts in values using the centralized isMalicious function.
683
+ const inspect = (obj) => {
684
+ for (const key in obj) {
685
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
686
+ const value = obj[key];
687
+ if (typeof value === 'string') {
688
+ if (isMalicious(value)) return true;
689
+ } else if (typeof value === 'object' && value !== null) {
690
+ // For nested objects (like in NoSQL injections), we stringify them once
691
+ // to check for malicious patterns within their structure or values.
692
+ if (isMalicious(JSON.stringify(value))) return true;
693
+ // Then, we recurse to check individual string values inside.
694
+ if (inspect(value)) return true;
695
+ }
696
+ }
697
+ }
698
+ return false;
699
+ };
700
+
701
+ if (inspect(queryData)) {
702
+ return { honeypotScore: 100 };
703
+ }
704
+ if (inspect(bodyData)) {
705
+ return { honeypotScore: 100 };
706
+ }
707
+ }
708
+
709
+ return { honeypotScore: 0 };
710
+ }
711
+
712
+ /**
713
+ * Calcule un score basé sur les métriques comportementales envoyées par le client.
714
+ * @param {object} context - Le contexte de la requête, contenant les en-têtes.
715
+ * @returns {{behaviorScore: number}}
716
+ */
717
+ function getBehaviorScore(context) {
718
+ const behaviorHeader = context.headers['x-behavior-metrics'];
719
+ if (!behaviorHeader) {
720
+ return { behaviorScore: 0 }; // Pas de données, pas de pénalité.
721
+ }
722
+
723
+ try {
724
+ const metrics = JSON.parse(behaviorHeader);
725
+ let score = 0;
726
+ if (metrics.honeypotInteraction) score = 100; // Interaction avec un honeypot client = bot.
727
+ if (metrics.mouseEntropy === 0 && metrics.keystrokeLatency === 0) score += 40; // Aucune interaction = suspect.
728
+ return { behaviorScore: Math.min(100, score) };
729
+ } catch (e) {
730
+ return { behaviorScore: 10 }; // En-tête malformé = légèrement suspect.
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Analyzes server-side request patterns for a given device to detect bot-like behavior.
736
+ * This is a stateful check that looks for repetitive or unnaturally fast requests.
737
+ * @param {object} context - The request context.
738
+ * @param {object} deviceData - The device's activity data from the store.
739
+ * @returns {{requestPatternScore: number}}
740
+ */
741
+ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
742
+ if (!deviceData) return { requestPatternScore: 0 };
743
+
744
+ // Default values for the pattern detection logic, which can be overridden by the auto-tuner.
745
+ const {
746
+ velocityThreshold = 800, velocityWeight = 30,
747
+ burstThreshold = 1500, burstWeight = 50,
748
+ scrapeThreshold = 1000, scrapeWeight = 20, scrapeBurstWeight = 40,
749
+ historySize = 10,
750
+ decayFactor = 0.9,
751
+ inactivityReset = 30000,
752
+ // Nouveau paramètre pour la détection de séquences
753
+ sequenceLength = 3, sequenceWeight = 60
754
+ } = patternConfig;
755
+
756
+ const now = Date.now();
757
+ const currentPath = context.path;
758
+ // Make the function robust to handle both URLSearchParams and plain objects for query.
759
+ // Ensure query parameters are consistently handled, whether they come from a URLSearchParams object or a plain object.
760
+ const params = context.query instanceof URLSearchParams ? context.query : new URLSearchParams(context.query);
761
+ params.sort(); // Sort for deterministic order
762
+ const currentQueryString = params.toString();
763
+
764
+ // Initialize request history if it doesn't exist
765
+ if (!deviceData.requestHistory) {
766
+ deviceData.requestHistory = [];
767
+ }
768
+
769
+ const history = deviceData.requestHistory;
770
+ let score = 0;
771
+
772
+ // --- Analyze patterns based on the last few requests ---
773
+ if (history.length > 0) {
774
+ const lastRequest = history[history.length - 1];
775
+ const timeSinceLast = now - lastRequest.timestamp; // 150
776
+
777
+ // 1. Velocity Check: Penalize requests that are too fast to be human.
778
+ if (timeSinceLast < velocityThreshold) { // 150 < 200 -> true
779
+ score += velocityWeight; // score = 30
780
+ }
781
+
782
+ // 2. Burst Check: Add additional penalty for identical requests in a very short time frame.
783
+ if (currentPath === lastRequest.path && currentQueryString === lastRequest.queryString && timeSinceLast < burstThreshold) { // 150 < 500 -> true
784
+ score += burstWeight; // score = 30 + 50 = 80
785
+ }
786
+
787
+ // 3. Sequential Scraping Check: Add additional penalty for same path with different query params (potential scraping).
788
+ // This is a simplified check, now independent of the burst check.
789
+ if (currentPath === lastRequest.path && currentQueryString !== lastRequest.queryString && timeSinceLast < scrapeThreshold) {
790
+ const previousRequest = history.length > 2 ? history[history.length - 2] : null;
791
+ if (previousRequest && previousRequest.path === currentPath) {
792
+ score += scrapeBurstWeight; // This is at least the 3rd request in a sequence to the same path.
793
+ } else {
794
+ score += scrapeWeight; // First sign of a potential scraping pattern
795
+ }
796
+ }
797
+
798
+ // 4. (NOUVEAU) Détection de séquences répétitives (ex: A -> B -> C -> A -> B -> C)
799
+ if (history.length >= sequenceLength * 2) {
800
+ const lastSequence = history.slice(-sequenceLength);
801
+ const previousSequence = history.slice(-sequenceLength * 2, -sequenceLength);
802
+
803
+ const isRepeating = lastSequence.every((req, i) =>
804
+ req.path === previousSequence[i].path && req.queryString === previousSequence[i].queryString
805
+ );
806
+ if (isRepeating) score += sequenceWeight;
807
+ }
808
+ }
809
+
810
+ // --- Update history ---
811
+ history.push({
812
+ timestamp: now,
813
+ path: currentPath,
814
+ queryString: currentQueryString,
815
+ });
816
+
817
+ // Keep history to a reasonable size (e.g., last 10 requests)
818
+ if (history.length > historySize) {
819
+ history.shift();
820
+ }
821
+
822
+ // Decay the score over time if behavior becomes normal again.
823
+ // We can store the score in deviceData and decay it.
824
+ deviceData.lastPatternScore = (deviceData.lastPatternScore || 0) * decayFactor + score; // Decay old score and add new
825
+
826
+ // If there hasn't been a request in a while, reset the pattern score.
827
+ if (history.length > 1 && (now - history[history.length - 2].timestamp > inactivityReset)) { // X ms inactivity
828
+ deviceData.lastPatternScore = 0;
829
+ }
830
+
831
+ return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
832
+ }
833
+
834
+ const trapUrlTemplates = [
835
+ '/includes/config-{RANDOM}.php', // Classic PHP config file
836
+ '/.env.{RANDOM}', // Environment file
837
+ '/backups/db_backup_{RANDOM}.sql.gz', // Database backup
838
+ '/api/v1/internal/status?trace={RANDOM}', // Internal API endpoint
839
+ '/_private/deploy_key_{RANDOM}.pem', // Private key file
840
+ '/logs/app_error_{RANDOM}.log', // Log file
841
+ '/.git/config_{RANDOM}' // Exposed git config variant
842
+ ];
843
+
844
+ /**
845
+ * Generates a signed trap URL.
846
+ * @param {string} nonce - The nonce to sign the URL with.
847
+ * @returns {string} The trap URL.
848
+ */
849
+ function generateTrapUrl(nonce) {
850
+ // Pick a random template to diversify the traps
851
+ const template = trapUrlTemplates[Math.floor(Math.random() * trapUrlTemplates.length)];
852
+ const randomPart = crypto.randomBytes(8).toString('hex');
853
+ const path = template.replace('{RANDOM}', randomPart);
854
+
855
+ const signature = crypto.createHmac('sha256', getPowSecret()).update(nonce + path).digest('hex').substring(0, 16);
856
+ return `${path}?sig=${signature}`;
857
+ }
858
+
859
+ /**
860
+ * Verifies if a given path is a valid trap URL for a given nonce.
861
+ * @param {string} path - The request path.
862
+ * @param {string} signature - The signature from the query.
863
+ * @param {string} nonce - The nonce to verify against.
864
+ * @returns {boolean}
865
+ */
866
+ function verifyTrapUrl(path, signature, nonce) {
867
+ const expectedSignature = crypto.createHmac('sha256', getPowSecret()).update(nonce + path).digest('hex').substring(0, 16);
868
+ try {
869
+ // Use timingSafeEqual to prevent timing attacks where an attacker could guess the signature byte by byte.
870
+ return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex'));
871
+ } catch {
872
+ // This will catch errors if buffers have different lengths or contain invalid hex characters, which is a failure case.
873
+ return false;
874
+ }
875
+ }
876
+ /**
877
+ * @typedef {object} IStore
878
+ * @property {(key: string) => Promise<any>} get
879
+ * @property {(key: string, value: any, ttl?: number) => Promise<void>} set
880
+ * @property {(key: string) => Promise<boolean>} has
881
+ * @property {(key: string) => Promise<void>} delete
882
+ */
883
+
884
+ /**
885
+ * Default in-memory store implementation.
886
+ * @type {IStore}
887
+ */
888
+ const inMemoryStore = {
889
+ _map: new Map(),
890
+ _timeouts: new Map(),
891
+ async get(key) { return this._map.get(key); },
892
+ async set(key, value, ttl) {
893
+ this._map.set(key, value);
894
+ // If a timeout already exists for this key, clear it.
895
+ if (this._timeouts.has(key)) {
896
+ clearTimeout(this._timeouts.get(key));
897
+ this._timeouts.delete(key);
898
+ }
899
+ // If a TTL is provided, set a timeout to delete the key.
900
+ if (ttl && ttl > 0) {
901
+ const timeoutId = setTimeout(() => this._map.delete(key), ttl * 1000);
902
+ this._timeouts.set(key, timeoutId);
903
+ }
904
+ },
905
+ async has(key) { return this._map.has(key); },
906
+ async delete(key) { this._map.delete(key); },
907
+ };
908
+
909
+ /** @type {IStore} */
910
+ let store = inMemoryStore;
911
+
912
+ /**
913
+ * Allows configuring an external datastore (e.g., Redis).
914
+ * Must be called before the middleware is used.
915
+ * @param {IStore} externalStore - An implementation of the IStore interface.
916
+ */
917
+ export const configureStore = (externalStore) => {
918
+ store = externalStore;
919
+ };
920
+
921
+ /**
922
+ * Orchestrates request identification using a persistent anchor (cookie)
923
+ * and fingerprint verification.
924
+ * @param {object} context - The request context.
925
+ * @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
926
+ */
927
+ async function resolveRequestIdentity(context, securityConfig = {}) {
928
+ const existingDeviceId = context.cookies?.device_id;
929
+ const currentDeviceHash = getDeviceHash(context);
930
+ let deviceId = existingDeviceId;
931
+ let consistencyScore = 1.0; // 1.0 = perfectly consistent
932
+ let deviceData = null;
933
+ let newCookie = null;
934
+ if (deviceId) {
935
+ deviceData = await store.get(`device:${deviceId}`);
936
+ }
937
+
938
+ if (deviceData) {
939
+ // Case 1: The user has a "passport" and we know them.
940
+ const storedHash = deviceData.initialDeviceHash;
941
+
942
+ // Compare the current fingerprint with the reference one.
943
+ consistencyScore = FingerprintBuilder.compare(
944
+ storedHash,
945
+ currentDeviceHash,
946
+ );
947
+ } else {
948
+ // Case 2: New user or lost/invalid cookie.
949
+ deviceId = crypto.randomUUID(); // Generate a new "passport".
950
+
951
+ // Return the intention to set a cookie.
952
+ newCookie = {
953
+ name: "device_id",
954
+ value: deviceId,
955
+ options: {
956
+ httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict",
957
+ // Le maxAge est maintenant configurable. Par défaut, c'est un cookie de session.
958
+ ...(securityConfig.deviceIdCookieMaxAge && { maxAge: securityConfig.deviceIdCookieMaxAge }),
959
+ }
960
+ };
961
+
962
+ // Initialize tracking for this new device.
963
+ deviceData = {
964
+ initialDeviceHash: currentDeviceHash, // Anchor the initial fingerprint.
965
+ ips: new Set(),
966
+ requestHistory: [], // Initialize state for the new pattern score
967
+ lastUpdate: Date.now(),
968
+ lastFpHash: currentDeviceHash,
969
+ lastChangeTimestamp: 0,
970
+ rapidChangeCount: 0,
971
+ highScoreCount: 0,
972
+ lastHighScoreTimestamp: 0,
973
+ };
974
+ // The write will happen in getSuspicionVector after all modifications.
975
+ }
976
+
977
+ return { deviceId, deviceData, consistencyScore, newCookie };
978
+ }
979
+
980
+ /*
981
+ * Calcule les indicateurs de suspicion liés au comportement de l'appareil (historique, rotation).
982
+ * @param {object} context - The request context.
983
+ * @param {object} deviceData - The device's activity data.
984
+ * @returns {Promise<{historyScore: number, rotationScore: number}>}
985
+ */
986
+ async function getBehavioralIndicators(context, deviceData) {
987
+ const now = Date.now();
988
+ const clientIp = context.clientIp;
989
+
990
+ // Get the IP type to modulate the score
991
+ const ipProfile = (await store.get(`ip:${clientIp}`)) || { type: "residential" };
992
+ const isSharedIp = ipProfile.type === "shared";
993
+
994
+ const currentFpHash = getDeviceHash(context); // Use the device hash
995
+
996
+ // --- Behavior analysis (Change frequency) ---
997
+ if (deviceData.lastFpHash && currentFpHash !== deviceData.lastFpHash) {
998
+ const timeSinceLastChange = now - deviceData.lastChangeTimestamp;
999
+
1000
+ if (timeSinceLastChange < RAPID_CHANGE_THRESHOLD_MS) {
1001
+ deviceData.rapidChangeCount = Math.min(
1002
+ deviceData.rapidChangeCount + 1,
1003
+ MAX_RAPID_CHANGES_PER_DEVICE * 2, // Increases quickly
1004
+ );
1005
+ } else {
1006
+ deviceData.rapidChangeCount = Math.max(0, deviceData.rapidChangeCount - 1); // Decreases slowly
1007
+ }
1008
+ deviceData.lastChangeTimestamp = now;
1009
+ }
1010
+
1011
+ deviceData.lastFpHash = currentFpHash;
1012
+ deviceData.ips.add(clientIp); // Record the IP used by this device
1013
+
1014
+ // NOUVELLE LOGIQUE : Le score d'historique est basé sur le nombre d'IPs utilisées par l'appareil.
1015
+ // Très efficace contre la rotation de proxy.
1016
+ const maxIpsForDevice = isSharedIp
1017
+ ? MAX_DISTINCT_IPS_FOR_SHARED_USER
1018
+ : MAX_DISTINCT_IPS_PER_DEVICE;
1019
+ const freeIpChanges = isSharedIp ? 1 : 3;
1020
+
1021
+ const historyScore = Math.min(
1022
+ 100,
1023
+ (Math.max(0, deviceData.ips.size - freeIpChanges) /
1024
+ (maxIpsForDevice - freeIpChanges)) *
1025
+ 100,
1026
+ );
1027
+
1028
+ // Score based on rapid identity rotation (0-100)
1029
+ const rotationScore = Math.min(
1030
+ 100,
1031
+ (deviceData.rapidChangeCount / MAX_RAPID_CHANGES_PER_DEVICE) * 100,
1032
+ );
1033
+
1034
+ return { historyScore, rotationScore };
1035
+ }
1036
+
1037
+ /**
1038
+ * Returns a vector of raw (unweighted) suspicion scores.
1039
+ * @param {object} context - The request context object.
1040
+ * @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number, honeypotScore: number}>}
1041
+ */
1042
+ export const getSuspicionVector = async (context, securityConfig) => {
1043
+ // On récupère la configuration du honeypot pour l'utiliser ici.
1044
+ const honeypotConfig = securityConfig.honeypot || {};
1045
+
1046
+ const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context, securityConfig);
1047
+
1048
+ const clientIp = context.clientIp;
1049
+
1050
+ // If a new cookie needs to be set, attach it to the request object
1051
+ // so the middleware can handle it. This is a temporary state holder.
1052
+ if (newCookie) {
1053
+ context._newCookies = context._newCookies || [];
1054
+ context._newCookies.push(newCookie);
1055
+ }
1056
+ await store.set(`ip-device:${clientIp}`, deviceId, 600); // Link the IP to the device for 10 minutes
1057
+
1058
+ // Periodically clean up device data
1059
+ if (Date.now() - deviceData.lastUpdate > 10 * 60 * 1000) { // 10 minutes
1060
+ deviceData.ips.clear();
1061
+ deviceData.rapidChangeCount = 0;
1062
+ }
1063
+ deviceData.lastUpdate = Date.now();
1064
+
1065
+ const behavioral = await getBehavioralIndicators(context, deviceData);
1066
+ const { headerAnomalyScore } = getHeaderAnomalies(context);
1067
+ // Calculate the inconsistency score here, separately.
1068
+ let inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200)); // Amplified score
1069
+
1070
+ // NOUVEAU: Si l'incohérence est très forte (cookie probablement volé), on applique une pénalité maximale.
1071
+ if (consistencyScore < 0.7) { // Seuil de rupture
1072
+ inconsistencyScore = 100;
1073
+ }
1074
+
1075
+ const { behaviorScore } = getBehaviorScore(context); // Appel de la fonction
1076
+
1077
+ // On appelle getHoneypotScore ici pour que son résultat soit inclus dans le vecteur.
1078
+ const { honeypotScore } = getHoneypotScore(context, honeypotConfig);
1079
+
1080
+ const { requestPatternScore } = getRequestPatternScore(context, deviceData, securityConfig.patterns);
1081
+
1082
+ // Save the updated device state to the store
1083
+ // 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.
1084
+ await store.set(`device:${deviceId}`, deviceData);
1085
+
1086
+ // Ensure deviceData.ips is a Set for subsequent operations within the same request,
1087
+ // even if the store returns an array.
1088
+ if (Array.isArray(deviceData.ips)) {
1089
+ deviceData.ips = new Set(deviceData.ips);
1090
+ }
1091
+ // Le vecteur de suspicion est maintenant complet.
1092
+ return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, requestPatternScore };
1093
+ };
1094
+
1095
+ // A residential user can change networks (home, 4G, public wifi).
1096
+ const MAX_DISTINCT_IPS_PER_DEVICE = 15;
1097
+ // Un utilisateur derrière un NAT/proxy ne devrait pas utiliser BEAUCOUP d'autres IPs.
1098
+ const MAX_DISTINCT_IPS_FOR_SHARED_USER = 5;
1099
+
1100
+ // Une IP est considérée comme "partagée" si elle est utilisée par plus de 50 appareils différents en 10 minutes.
1101
+ const SHARED_IP_DEVICE_THRESHOLD = 50;
1102
+
1103
+ const RAPID_CHANGE_THRESHOLD_MS = 2000; // 2 secondes
1104
+ const MAX_RAPID_CHANGES_PER_DEVICE = 3; // Number of rapid fingerprint changes allowed per device.
1105
+
1106
+ /**
1107
+ * Identifies a request on the server side in a granular way.
1108
+ * Uses FingerprintBuilder to create a fingerprint based on headers
1109
+ * and IP, making spoofing more complex (requires changing the entire stack).
1110
+ */
1111
+ export const identifyRequest = (securityConfig) => async (req, res) => {
1112
+ // This function now acts as a lightweight wrapper around the engine's identifyRequest method.
1113
+ // It requires a default configuration to work.
1114
+ const config = securityConfig || {
1115
+ weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.1, inconsistencyScore: 0.8, honeypotScore: 1.0 },
1116
+ thresholds: { low: 20, medium: 40, high: 75 },
1117
+ honeypot: { fields: [] } // Ensure honeypot config exists to prevent errors
1118
+ };
1119
+ const engine = new FingerprintEngine(config);
1120
+
1121
+ const requestContext = {
1122
+ clientIp: req.ip || req.socket?.remoteAddress || "unknown",
1123
+ query: req.query,
1124
+ body: req.body,
1125
+ cookies: req.cookies,
1126
+ headers: req.headers,
1127
+ rawHeaders: req.rawHeaders,
1128
+ httpVersion: req.httpVersion,
1129
+ };
1130
+
1131
+ const key = await engine.identifyRequest(requestContext);
1132
+
1133
+ if (requestContext._newCookies && res) {
1134
+ requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
1135
+ }
1136
+
1137
+ return key;
1138
+ };
1139
+ // --- NOUVEAU CHALLENGE CPU "ANALOGIQUE" ---
1140
+
1141
+ // Le plus grand nombre possible avec SHA-256 (2^256 - 1)
1142
+ // The largest possible number with SHA-256 (2^256 - 1)
1143
+ const MAX_DIFFICULTY_TARGET = 2n ** 256n - 1n;
1144
+ // Une difficulté de base, ex: nécessite que les 16 premiers bits soient à 0
1145
+ // (équivalent à 4 zéros en hexadécimal)
1146
+ // A base difficulty, e.g., requires the first 16 bits to be 0
1147
+ // (equivalent to 4 zeros in hexadecimal)
1148
+ const BASE_TARGET = MAX_DIFFICULTY_TARGET >> 16n;
1149
+
1150
+ /**
1151
+ * Calculates the difficulty target based on the suspicion factor.
1152
+ * @param {number} suspicionFactor - A number from 0 to 1.
1153
+ * @returns {BigInt} The target number.
1154
+ */
1155
+ function calculateTarget(suspicionFactor) {
1156
+ // Difficulty range adjusted to be realistic.
1157
+ // MIN_DIFFICULTY: Fast enough not to bother a slightly suspicious user.
1158
+ // MAX_DIFFICULTY: Slow enough to heavily penalize a bot, but feasible for a patient human (5-30s).
1159
+ const MIN_DIFFICULTY_BITS = 18; // Default value, should be configurable
1160
+ const MAX_DIFFICULTY_BITS = 26; // Default value, should be configurable
1161
+
1162
+ // Use linear interpolation between min and max difficulty.
1163
+ const totalDifficultyBits =
1164
+ MIN_DIFFICULTY_BITS +
1165
+ suspicionFactor * (MAX_DIFFICULTY_BITS - MIN_DIFFICULTY_BITS);
1166
+
1167
+ // The target is max / 2^bits
1168
+ return MAX_DIFFICULTY_TARGET >> BigInt(Math.floor(totalDifficultyBits));
1169
+ }
1170
+
1171
+ /**
1172
+ * Generates a CPU challenge based on a target.
1173
+ */
1174
+ export function generateCpuTargetChallenge(
1175
+ clientIp,
1176
+ nonce,
1177
+ suspicionFactor,
1178
+ originalUrl,
1179
+ ) {
1180
+ const target = calculateTarget(suspicionFactor);
1181
+ return {
1182
+ type: "cpu_target",
1183
+ nonce: nonce,
1184
+ target: target.toString(16), // Send the target in hexadecimal
1185
+ path: originalUrl,
1186
+ };
1187
+ }
1188
+
1189
+ /**
1190
+ * Generates the HTML page for the CPU target challenge.
1191
+ * @param {object} challengeDetails - The details from generateCpuTargetChallenge.
1192
+ * @param {string} clientIp - The client's IP address.
1193
+ * @returns {string} HTML content.
1194
+ */
1195
+ function generateCpuTargetChallengePage(challengeDetails, clientIp) {
1196
+ const { nonce, target, path } = challengeDetails;
1197
+ const solverCode = getPowSolverCode();
1198
+ return `
1199
+ <html><head><title>Security Check</title></head>
1200
+ <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
1201
+ <h1>Please wait... (Level 1)</h1>
1202
+ <p>We are verifying that you are not a bot. This may take a few seconds.</p>
1203
+ <div id="loader" style="margin:20px;">⚙️ Performing CPU security calculation...</div>
1204
+ <script>${solverCode}</script>
1205
+ <script>
1206
+ async function solve() {
1207
+ const clientIp = "${clientIp}";
1208
+ const nonce = "${nonce}";
1209
+ const cpuTarget = BigInt("0x${target}");
1210
+ const solution = await window.solveCpuChallengeInline(clientIp, nonce, cpuTarget, null, (progress) => {
1211
+ // Optional progress callback
1212
+ });
1213
+ window.location.href = "${path}?pow_type=cpu_target&pow_nonce=${nonce}&pow_solution=" + solution;
1214
+ }
1215
+ solve();
1216
+ </script>
1217
+ </body></html>`;
1218
+ }
1219
+
1220
+ /**
1221
+ * Generates the HTML content for a combined CPU + Memory PoW challenge.
1222
+ * @param {object} cpuChallengeDetails - Details from generateCpuTargetChallenge.
1223
+ * @param {number} memoryDifficulty - Memory allocation in MB.
1224
+ * @param {string} clientIp - The client's IP address.
1225
+ * @returns {string} HTML content.
1226
+ */
1227
+ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty, clientIp, clientSecret) {
1228
+ const { nonce, target, path } = cpuChallengeDetails;
1229
+ const solverCode = getPowSolverCode();
1230
+ return `
1231
+ <html><head><title>Advanced Security Check</title></head>
1232
+ <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
1233
+ <h1>Enhanced Verification... (Level 2)</h1>
1234
+ <p>Your activity requires an additional security check. This may take a few moments.</p>
1235
+ <div id="loader" style="margin:20px;">⚙️ Initializing combined verification...</div>
1236
+ <script>${solverCode}</script>
1237
+ <script>
1238
+ async function solve() {
1239
+ const nonce = "${nonce}";
1240
+ const path = "${path}";
1241
+ const clientSecret = "${clientSecret}";
1242
+ const clientIp = "${clientIp}";
1243
+ const cpuTarget = BigInt("0x${target}");
1244
+ const memDifficulty = ${memoryDifficulty};
1245
+
1246
+ // --- CPU Challenge ---
1247
+ document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
1248
+ const cpuSolution = await window.solveCpuChallengeInline(clientIp, nonce, cpuTarget, clientSecret, (progress) => {
1249
+ // Optional progress callback
1250
+ });
1251
+
1252
+ // --- Memory Challenge ---
1253
+ document.getElementById('loader').innerText = '⚙️ Performing memory allocation and calculation... (' + memDifficulty + ' MB)';
1254
+ await new Promise(r => setTimeout(r, 10)); // Yield to update UI
1255
+
1256
+ let memSolution = 0;
1257
+ try {
1258
+ const memSeed = nonce + ":" + clientSecret;
1259
+ memSolution = await window.solveMemoryChallenge(memSeed, memDifficulty);
1260
+ } catch(e) {
1261
+ document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
1262
+ return;
1263
+ }
1264
+ window.location.href = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
1265
+ }
1266
+ solve();
1267
+ </script>
1268
+ </body></html>`;
1269
+ }
1270
+
1271
+ /**
1272
+ * Verifies a PoW solution based on a target and generates a ticket.
1273
+ */
1274
+ export function verifyCpuTargetPoWAndGenerateTicket(
1275
+ clientIp, // This parameter is crucial and must be the actual client IP
1276
+ ticketMaxAge, // NOUVEAU: Durée de validité du ticket configurable
1277
+ nonce,
1278
+ solution,
1279
+ suspicionFactor,
1280
+ clientSecret, // Le secret est maintenant requis
1281
+ ) {
1282
+ const target = calculateTarget(suspicionFactor);
1283
+ const message = clientSecret
1284
+ ? `${clientIp}:${nonce}:${solution}:${clientSecret}`
1285
+ : `${clientIp}:${nonce}:${solution}`;
1286
+ const hash = crypto
1287
+ .createHash("sha256")
1288
+ .update(message)
1289
+ .digest("hex");
1290
+ const hashAsInt = BigInt("0x" + hash);
1291
+
1292
+ if (hashAsInt < target) {
1293
+ // The comparison is direct with native BigInts
1294
+ // The proof is valid, generate the ticket
1295
+ const expiry = Date.now() + (ticketMaxAge || 3600000); // Utilise la durée passée ou un fallback.
1296
+ const signature = crypto
1297
+ .createHmac("sha256", getPowSecret())
1298
+ .update(`${clientIp}:${expiry}`)
1299
+ .digest("hex");
1300
+ return `${expiry}:${signature}`;
1301
+ }
1302
+
1303
+ return null;
1304
+ }
1305
+
1306
+ const staticExtensions = new RegExp(
1307
+ "\\.(js|css|png|jpg|jpeg|gif|svg|mp3|webp|ico|woff|woff2|ttf|otf|map|json|manifest)$",
1308
+ "i",
1309
+ );
1310
+ const isStaticResource = (path) => staticExtensions.test(path);
1311
+
1312
+
1313
+ /**
1314
+ * Détermine le TTL optimal pour un ticket en utilisant un algorithme génétique multi-objectifs.
1315
+ * @param {number} suspicionScore - Le score de suspicion de la requête.
1316
+ * @returns {number} Le TTL optimal calculé en millisecondes.
1317
+ */
1318
+ function determineOptimalTicketTtl(suspicionScore) {
1319
+ // Définir les bornes pour la durée de vie du ticket (5 minutes à 24 heures)
1320
+ const MIN_TTL = 300000;
1321
+ const MAX_TTL = 86400000;
1322
+
1323
+ const solverFunction = () => {
1324
+ const fitnessFunction = Optimization.Operators.createOptimalTtlEvaluator({ suspicionScore });
1325
+
1326
+ // Un "individu" est simplement une valeur de TTL en millisecondes.
1327
+ const createIndividual = () => MIN_TTL + Math.random() * (MAX_TTL - MIN_TTL);
1328
+ const crossover = (ttl1, ttl2) => (ttl1 + ttl2) / 2;
1329
+ const mutate = (ttl) => {
1330
+ const newTtl = ttl + (Math.random() - 0.5) * (MAX_TTL - MIN_TTL) * 0.1; // Mutation de +/- 10% max
1331
+ return Math.max(MIN_TTL, Math.min(MAX_TTL, newTtl));
1332
+ };
1333
+
1334
+ const paretoFront = Optimization.geneticAlgorithmMultiObjective(
1335
+ createIndividual,
1336
+ fitnessFunction,
1337
+ crossover,
1338
+ mutate,
1339
+ {
1340
+ generations: 40,
1341
+ populationSize: 30,
1342
+ }
1343
+ );
1344
+
1345
+ // Pour runMultiple, on doit retourner un objet avec une propriété "fitness" ou "energy".
1346
+ // Pour un front de Pareto, il n'y a pas de score unique. On choisit la meilleure solution
1347
+ // en fonction du score de suspicion et on lui assigne un score de 0 pour que runMultiple la sélectionne.
1348
+ if (!paretoFront || paretoFront.length === 0) {
1349
+ return { solution: null, fitness: Infinity };
1350
+ }
1351
+
1352
+ // Stratégie de sélection :
1353
+ // Pour un score faible (< 50), on privilégie la solution avec le plus grand TTL (minimise la friction).
1354
+ // Pour un score élevé (>= 50), on privilégie la solution avec le plus petit TTL (minimise le risque).
1355
+ let bestSolutionInFront;
1356
+ if (suspicionScore < 50) {
1357
+ bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
1358
+ } else {
1359
+ bestSolutionInFront = paretoFront.reduce((min, p) => Math.min(min, p.solution), Infinity);
1360
+ }
1361
+ return { solution: bestSolutionInFront, fitness: 0 }; // fitness=0 car on a déjà la meilleure solution du cycle.
1362
+ };
1363
+
1364
+ // On exécute le solveur 20 fois pour trouver une solution plus stable et robuste.
1365
+ const { bestResult } = Optimization.runMultiple(solverFunction, 20);
1366
+
1367
+ if (!bestResult || !bestResult.solution || bestResult.solution === Infinity) {
1368
+ // Fallback : si l'algo ne retourne rien, on applique une règle simple et sûre.
1369
+ return Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL);
1370
+ }
1371
+
1372
+ // runMultiple choisit le meilleur résultat sur la base du score (ici, 0).
1373
+ // La "meilleure" solution dépendra du cycle qui a trouvé le meilleur compromis.
1374
+ return bestResult.solution;
1375
+ }
1376
+
1377
+ /**
1378
+ * Vérifie si une chaîne de caractères contient des patterns d'injection connus.
1379
+ * @param {string} str - La chaîne à vérifier.
1380
+ * @returns {boolean} - True si un pattern malveillant est détecté.
1381
+ * @private
1382
+ */
1383
+ function isMalicious(str) {
1384
+ // Regex pour les injections SQL et NoSQL de base
1385
+ // Ajout de la détection des injections basées sur le temps (SLEEP, BENCHMARK, WAITFOR) et d'autres commandes dangereuses.
1386
+ const injectionRegex = /(\$ne|' OR '1'='1|['";]\s*--|; ?(DROP|TRUNCATE|DELETE)|UNION SELECT|SLEEP\(|BENCHMARK\(|WAITFOR DELAY)/i;
1387
+ // Regex pour les injections plus avancées
1388
+ const log4ShellRegex = /\$\{jndi:(ldap|rmi|dns):/i;
1389
+ const sstiRegex = /\{\{.*\}\}|\{%.*%\}/; // Détecte les syntaxes de type Jinja2, Twig, etc.
1390
+ const xxeRegex = /<!ENTITY\s+.*SYSTEM/i;
1391
+ const pathTraversalRegex = /(\.\.\/|\.\.\\)/;
1392
+ // NOUVEAU : Regex pour les injections de commandes basiques.
1393
+ // Cible les séparateurs de commandes et les backticks d'exécution.
1394
+ const commandInjectionRegex = /(&&|\|\||;|\n|`)/;
1395
+
1396
+ return injectionRegex.test(str) || log4ShellRegex.test(str) || sstiRegex.test(str) || xxeRegex.test(str) || pathTraversalRegex.test(str) || commandInjectionRegex.test(str);
1397
+ }
1398
+
1399
+ // --- Middleware Proof-of-Work (Le péage) ---
1400
+ export class FingerprintEngine {
1401
+ constructor(securityConfig) {
1402
+ const isProduction = process.env.NODE_ENV === 'production';
1403
+ this.securityConfig = securityConfig;
1404
+ this.isProduction = isProduction;
1405
+ this._allowlist = this._buildAllowlist();
1406
+ this.verbose = securityConfig.verbose || false;
1407
+ }
1408
+
1409
+ _log(message, data = {}) {
1410
+ if (this.verbose) {
1411
+ console.log(`[FingerprintEngine] ${message}`, data);
1412
+ }
1413
+ }
1414
+ calculateFinalScore = function(suspicionVector) {
1415
+ const { weights } = this.securityConfig;
1416
+ if (!weights) return 0;
1417
+
1418
+ const score =
1419
+ (suspicionVector.historyScore || 0) * (weights.historyScore || 0) +
1420
+ (suspicionVector.rotationScore || 0) * (weights.rotationScore || 0) +
1421
+ (suspicionVector.headerAnomalyScore || 0) * (weights.headerAnomalyScore || 0) +
1422
+ (suspicionVector.requestPatternScore || 0) * (weights.requestPatternScore || 0) +
1423
+ (suspicionVector.inconsistencyScore || 0) * (weights.inconsistencyScore || 0) +
1424
+ (suspicionVector.honeypotScore || 0) * (weights.honeypotScore || 0) +
1425
+ (suspicionVector.behaviorScore || 0) * (weights.behaviorScore || 0);
1426
+
1427
+ return Math.min(100, score);
1428
+ }
1429
+ /**
1430
+ * Checks if an IP address is in the static allowlist (IPs or CIDR ranges).
1431
+ * This is the fastest check and should be performed first.
1432
+ * @private
1433
+ * @param {string} clientIp - The IP address of the client.
1434
+ * @returns {boolean} True if the IP is in the allowlist.
1435
+ */
1436
+ _buildAllowlist() {
1437
+ const blockList = new BlockList();
1438
+ const { whitelist = [] } = this.securityConfig;
1439
+ const allowlistRule = whitelist.find(rule => rule.type === 'allowlist');
1440
+
1441
+ if (!allowlistRule || !allowlistRule.entries || allowlistRule.entries.length === 0) {
1442
+ return blockList; // Retourne une liste vide
1443
+ }
1444
+
1445
+ for (const entry of allowlistRule.entries) {
1446
+ if (entry.includes('/')) { // CIDR range
1447
+ try {
1448
+ const [address, prefix] = entry.split('/');
1449
+ blockList.addSubnet(address, parseInt(prefix, 10));
1450
+ } catch (e) {
1451
+ // Ignore les entrées CIDR invalides
1452
+ }
1453
+ } else { // Direct IP match
1454
+ blockList.addAddress(entry);
1455
+ }
1456
+ }
1457
+ return blockList;
1458
+ }
1459
+ _isIpInAllowlist(clientIp) {
1460
+ return this._allowlist.check(clientIp);
1461
+ }
1462
+ /**
1463
+ * Verifies if a request comes from a legitimate, whitelisted bot (e.g., Googlebot)
1464
+ * using reverse and forward DNS lookups. The result is cached.
1465
+ * @private
1466
+ * @param {object} requestContext - The request context.
1467
+ * @returns {Promise<boolean>} True if the request is from a verified whitelisted bot.
1468
+ */
1469
+ async _verifyWhitelistedBot(requestContext) {
1470
+ const { whitelist = [] } = this.securityConfig;
1471
+ const botRules = whitelist.filter(rule => rule.hostnameSuffix);
1472
+ if (botRules.length === 0) {
1473
+ return false;
1474
+ }
1475
+
1476
+ const { clientIp, headers } = requestContext;
1477
+ const userAgent = headers['user-agent'] || '';
1478
+
1479
+ const matchedRule = botRules.find(rule => {
1480
+ if (!rule.userAgent) return false;
1481
+ try {
1482
+ return new RegExp(rule.userAgent).test(userAgent);
1483
+ } catch (e) {
1484
+ console.error(`[Fingerprint] Invalid regex in whitelist rule: ${rule.userAgent}`);
1485
+ return false;
1486
+ }
1487
+ });
1488
+ if (!matchedRule) {
1489
+ return false;
1490
+ }
1491
+
1492
+ const cacheKey = `ip-whitelist:${clientIp}`;
1493
+ const cachedStatus = await store.get(cacheKey);
1494
+
1495
+ if (cachedStatus === 'verified') {
1496
+ return true;
1497
+ }
1498
+ if (cachedStatus === 'failed') {
1499
+ return false;
1500
+ }
1501
+
1502
+ try {
1503
+ // 1. Reverse DNS lookup
1504
+ const hostnames = await dns.reverse(clientIp);
1505
+ const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
1506
+
1507
+ if (!validHostname) {
1508
+ await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
1509
+ return false;
1510
+ }
1511
+
1512
+ // 2. Forward DNS lookup
1513
+ const addresses = await dns.resolve(validHostname);
1514
+ if (addresses.includes(clientIp)) {
1515
+ await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
1516
+ return true;
1517
+ }
1518
+ } catch (error) {
1519
+ // DNS errors are common (e.g., for IPs with no rDNS record), treat as failure.
1520
+ }
1521
+
1522
+ await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
1523
+ return false;
1524
+ }
1525
+
1526
+ async processRequest(requestContext) {
1527
+
1528
+ const { clientIp = "unknown", path, cookies, query, isStatic } = requestContext;
1529
+ const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
1530
+
1531
+ this._log('Processing request', { clientIp, path, isStatic });
1532
+
1533
+ if (isStatic) {
1534
+ this._log('Static resource - skipping checks');
1535
+ return { action: 'next', score: 0, vector: {} };
1536
+ }
1537
+
1538
+ // 1. Check static IP allowlist first for maximum performance.
1539
+ if (this._isIpInAllowlist(clientIp)) {
1540
+ this._log('IP in allowlist - allowing request', { clientIp });
1541
+ return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'allowlist' } };
1542
+ }
1543
+
1544
+ const { pow_nonce } = query;
1545
+
1546
+ // Honeypot: Direct probing of challenge endpoints is highly suspicious.
1547
+ // A legitimate user only hits these endpoints via the challenge page itself.
1548
+ // If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
1549
+ if (pow_nonce) {
1550
+ const powCookie = cookies?.pow_clearance;
1551
+ if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
1552
+ // This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
1553
+ // The final decision is made later, after calculating the score.
1554
+ }
1555
+ }
1556
+
1557
+ // Check if the request is from a verified, whitelisted bot (e.g., Googlebot)
1558
+ if (await this._verifyWhitelistedBot(requestContext)) {
1559
+ this._log('Whitelisted bot verified - allowing request', { clientIp });
1560
+ return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
1561
+ }
1562
+
1563
+ // --- NOUVELLE LOGIQUE DE PRIORITÉ ---
1564
+ // Si une solution de challenge est soumise, on la traite en priorité absolue,
1565
+ // avant même de recalculer le score de suspicion.
1566
+ const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
1567
+ if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
1568
+ this._log('Challenge solution submitted', { pow_type, pow_nonce });
1569
+
1570
+ // On doit calculer le score de suspicion *avant* de valider le ticket,
1571
+ // car le TTL optimal en dépend.
1572
+ const preliminaryVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
1573
+ const preliminaryScore = this.calculateFinalScore(preliminaryVector);
1574
+
1575
+ this._log('Preliminary suspicion vector calculated', {
1576
+ vector: preliminaryVector,
1577
+ score: preliminaryScore
1578
+ });
1579
+
1580
+ let isValid = false;
1581
+ const challengeContext = await store.get(`secret:${pow_nonce}`);
1582
+ let ticket = null;
1583
+
1584
+ if (challengeContext) {
1585
+ const optimalTtl = determineOptimalTicketTtl(preliminaryScore);
1586
+ this._log('Challenge context found, verifying solution', { optimalTtl });
1587
+
1588
+ if (pow_type === "cpu_target") {
1589
+ // On passe la durée de vie du ticket configurée
1590
+ ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution, null, challengeContext.clientSecret, challengeContext.cpuTarget);
1591
+ isValid = ticket !== null;
1592
+ this._log('CPU target challenge verification', { isValid });
1593
+ } else if (pow_type === "cpu_mem") {
1594
+ const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution_cpu, null, challengeContext.clientSecret, challengeContext.cpuTarget);
1595
+ const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret);
1596
+ isValid = cpuTicket !== null && isMemValid;
1597
+ if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
1598
+ this._log('Combined CPU+Memory challenge verification', {
1599
+ cpuValid: cpuTicket !== null,
1600
+ memValid: isMemValid,
1601
+ isValid
1602
+ });
1603
+ }
1604
+ } else {
1605
+ this._log('Challenge context not found or expired', { pow_nonce });
1606
+ }
1607
+
1608
+ if (isValid) {
1609
+ // La solution est valide. On supprime le secret et on redirige.
1610
+ await store.delete(`secret:${pow_nonce}`);
1611
+ this._log('Challenge solution valid - issuing ticket', { ticketMaxAge: this.securityConfig.ticketMaxAge || 3600000 });
1612
+
1613
+ if (logger) {
1614
+ logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: preliminaryScore, challengeType: pow_type, timestamp: Date.now() });
1615
+ }
1616
+
1617
+ return {
1618
+ action: 'redirect',
1619
+ path: path,
1620
+ score: 0, // Le score n'est pas pertinent ici, on a passé le test.
1621
+ vector: { challenge_solved: 100 },
1622
+ cookie: {
1623
+ name: 'pow_clearance',
1624
+ value: ticket,
1625
+ options: {
1626
+ httpOnly: true,
1627
+ secure: this.isProduction, // Le maxAge est déjà inclus dans le ticket, mais on le met aussi sur le cookie
1628
+ maxAge: this.securityConfig.ticketMaxAge || 3600000,
1629
+ }
1630
+ }
1631
+ };
1632
+ }
1633
+ // Si la solution est INVALIDE, on ne fait rien ici. La requête continuera son cours normal,
1634
+ // sera recalculée comme suspecte, et probablement bloquée ou re-challengée, ce qui est le comportement souhaité.
1635
+ // On pourrait même ajouter une pénalité ici si on le voulait.
1636
+ this._log('Challenge solution invalid', { reason: challengeContext ? 'Invalid solution' : 'Nonce not found or expired' });
1637
+
1638
+ if (logger && challengeContext) {
1639
+ logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Invalid PoW solution', timestamp: Date.now() });
1640
+ } else if (logger && !challengeContext) {
1641
+ logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Nonce not found or expired', timestamp: Date.now() });
1642
+ }
1643
+ }
1644
+ // --- FIN DE LA LOGIQUE DE PRIORITÉ ---
1645
+
1646
+ // Resolve identity and check for persisted "condemned" status early.
1647
+ const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
1648
+ const isNewDevice = !!newCookie;
1649
+
1650
+ this._log('Identity resolved', { deviceId, isNewDevice, hasDeviceData: !!deviceData });
1651
+
1652
+ if (deviceData?.condemned) {
1653
+ this._log('Device condemned - blocking request', { deviceId });
1654
+ if (onDeviceCompromised) {
1655
+ onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Previously condemned', score: 100, vector: { honeypotScore: 100 } });
1656
+ }
1657
+ return { action: 'block', status: 404, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1658
+ }
1659
+
1660
+ // The engine now works with the context directly, no more rawReq dependency here.
1661
+ const suspicionVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
1662
+ // honeypotScore et behaviorScore sont maintenant inclus directement dans le vecteur de suspicion.
1663
+
1664
+ this._log('Suspicion vector calculated', {
1665
+ vector: suspicionVector,
1666
+ weights: this.securityConfig.weights
1667
+ });
1668
+
1669
+ let finalScore = this.calculateFinalScore(suspicionVector);
1670
+
1671
+ this._log('Final score calculated', { finalScore });
1672
+
1673
+ // Si c'est un nouvel appareil, on lui impose un challenge de base, même si son score est bas.
1674
+ // Cela augmente le coût pour les bots qui tentent de simplement supprimer leurs cookies.
1675
+ // NOUVEAU : Cette logique est maintenant configurable.
1676
+ const challengeNewDevices = this.securityConfig.challengeNewDevices === true;
1677
+ if (isNewDevice && finalScore < thresholds.low) {
1678
+ this._log('New device - enforcing minimum challenge score', {
1679
+ originalScore: finalScore,
1680
+ enforcedScore: thresholds.low
1681
+ });
1682
+ finalScore = thresholds.low;
1683
+ }
1684
+
1685
+ const blockThreshold = thresholds.block ?? 95;
1686
+ const isBlocked = finalScore >= blockThreshold;
1687
+
1688
+ const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
1689
+ const isSuspiciousMedium = finalScore >= thresholds.medium;
1690
+ const isSuspicious = finalScore >= thresholds.low;
1691
+
1692
+ // Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
1693
+ const suspicionFactor = isSuspicious
1694
+ ? Math.min(
1695
+ 1.5, // On autorise un dépassement pour rendre les challenges très difficiles si le score est très élevé
1696
+ (finalScore - thresholds.low) / (thresholds.high - thresholds.low),
1697
+ )
1698
+ : 0;
1699
+
1700
+ this._log('Suspicion levels evaluated', {
1701
+ finalScore,
1702
+ isBlocked,
1703
+ isSuspiciousHigh,
1704
+ isSuspiciousMedium,
1705
+ isSuspicious,
1706
+ suspicionFactor,
1707
+ thresholds: { low: thresholds.low, medium: thresholds.medium, high: thresholds.high, block: blockThreshold }
1708
+ });
1709
+
1710
+ const powCookie = cookies?.pow_clearance;
1711
+
1712
+ // If the action is to block, we should still include the score and vector for logging/testing.
1713
+ if (isBlocked) {
1714
+ this._log('Request blocked - score exceeded block threshold', { finalScore, blockThreshold });
1715
+ if (onDeviceCompromised) {
1716
+ onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Score exceeded block threshold', score: finalScore, vector: suspicionVector });
1717
+ }
1718
+ return { action: 'block', status: 404, body: 'Forbidden', score: finalScore, vector: suspicionVector };
1719
+ }
1720
+
1721
+ // Honeypot: Check if the request is for a trap URL generated in a previous challenge.
1722
+ // This requires a nonce from a *previous* challenge, which we can look up via the device ID.
1723
+ const lastNonce = deviceData?.lastChallengeNonce;
1724
+ if (lastNonce && query.sig && verifyTrapUrl(path, query.sig, lastNonce)) {
1725
+ this._log('Honeypot trap URL triggered - condemning device', { path, deviceId });
1726
+ deviceData.condemned = true; // This device is a bot. Condemn it.
1727
+ if (onDeviceCompromised) {
1728
+ onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Triggered signed honeypot trap URL', score: 100, vector: { honeypotScore: 100 } });
1729
+ }
1730
+ if (logger) {
1731
+ logger({ type: 'trap_triggered', deviceId: cookies?.device_id, score: 100, path: path, timestamp: Date.now() });
1732
+ }
1733
+ await store.set(`device:${cookies.device_id}`, deviceData); // No TTL for condemned status
1734
+ return { action: 'block', status: 404, score: 100, vector: { honeypotScore: 100 } };
1735
+ }
1736
+
1737
+ if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
1738
+ this._log('Suspicious request without valid ticket - issuing challenge', { finalScore, hasPowCookie: !!powCookie });
1739
+
1740
+ // Honeypot: Direct probing of challenge endpoints is highly suspicious.
1741
+ // A legitimate user only hits these endpoints via the challenge page itself.
1742
+ // If we see a pow_nonce on a request that IS suspicious but has no valid ticket,
1743
+ // AND it's not a legitimate response to a challenge we issued, it's a probe.
1744
+ const isChallengeResponse = query.pow_solution || (query.pow_solution_cpu && query.pow_solution_mem);
1745
+ if (pow_nonce && !isChallengeResponse) {
1746
+ this._log('Honeypot probe detected - blocking request', { path, pow_nonce });
1747
+ if (logger) {
1748
+ logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now() });
1749
+ }
1750
+ suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
1751
+ // Recalculate the final score with the updated vector.
1752
+ const newFinalScore = this.calculateFinalScore(suspicionVector);
1753
+ return { action: 'block', status: 404, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
1754
+ }
1755
+
1756
+ // --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
1757
+ const nonce = crypto.randomBytes(16).toString("hex");
1758
+ const clientSecret = crypto.randomBytes(16).toString("hex");
1759
+
1760
+ // LEVEL 3: CAPTCHA (the highest)
1761
+ if (isSuspiciousHigh) {
1762
+ // ... logic for TSP/Captcha challenge
1763
+ }
1764
+
1765
+ // UNIFIED CHALLENGE LOGIC for all suspicion levels (low, medium, high)
1766
+ if (isSuspicious) { // Couvre à la fois low et medium
1767
+ // Generate some trap URLs to embed in the challenge page.
1768
+ // These links are visually hidden but present in the DOM to trap bots.
1769
+ const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce));
1770
+ const trapLinksHtml = trapUrls.map(url => `<a href="${url}" tabindex="-1">config</a>`).join(' ');
1771
+
1772
+
1773
+ const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
1774
+
1775
+ // La difficulté mémoire démarre à 0 et augmente seulement après un certain seuil de suspicion.
1776
+ // Par exemple, elle ne commence à augmenter qu'à partir de 25% du chemin entre 'low' et 'high'.
1777
+ const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
1778
+
1779
+ const minMemDifficulty = 0; // Peut être 0 Mo !
1780
+ const maxMemDifficulty = 48; // 48Mo pour les plus suspects
1781
+ const memDifficulty = Math.round(minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty));
1782
+
1783
+ this._log('Challenge parameters calculated', {
1784
+ suspicionFactor,
1785
+ memActivationFactor,
1786
+ memDifficulty,
1787
+ cpuTarget: cpuChallengeDetails.target
1788
+ });
1789
+
1790
+ // Store the entire challenge context with a short TTL (e.g., 5 minutes)
1791
+ await store.set(`secret:${nonce}`, {
1792
+ clientSecret,
1793
+ cpuTarget: cpuChallengeDetails.target,
1794
+ memDifficulty: memDifficulty
1795
+ }, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
1796
+
1797
+ // Associate the current challenge nonce with the device for trap URL verification later.
1798
+ if (deviceData) {
1799
+ deviceData.lastChallengeNonce = nonce;
1800
+ await store.set(`device:${deviceId}`, deviceData); // Utiliser le deviceId résolu, pas celui des cookies
1801
+ }
1802
+
1803
+ this._log('Challenge issued', {
1804
+ nonce,
1805
+ challengeTtl: this.securityConfig.challengeTtl || 300,
1806
+ trapUrlsCount: trapUrls.length
1807
+ });
1808
+
1809
+ if (logger) {
1810
+ logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
1811
+ }
1812
+
1813
+ // Check if the request is an API request to return a JSON challenge
1814
+ const isApi = this.securityConfig.thresholds?.isApiRequest?.(requestContext);
1815
+
1816
+ if (isApi) {
1817
+ // For API clients, send a JSON response with challenge details.
1818
+ const challengePayload = {
1819
+ challenge: {
1820
+ type: 'cpu_mem',
1821
+ nonce: nonce,
1822
+ clientSecret: clientSecret, // The client needs this to solve the challenge
1823
+ cpuTarget: cpuChallengeDetails.target,
1824
+ memDifficulty: memDifficulty,
1825
+ }
1826
+ };
1827
+ this._log('API challenge response generated', { challengePayload });
1828
+ return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 404, body: challengePayload };
1829
+ } else {
1830
+ // For browsers, send the HTML page.
1831
+ const trapContainer = `<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">${trapLinksHtml}</div>`;
1832
+ const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp, clientSecret).replace('</body>', `${trapContainer}</body>`);
1833
+ this._log('Browser challenge page generated', {
1834
+ pageLength: page.length,
1835
+ hasTrapContainer: true
1836
+ });
1837
+ return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 404, body: page };
1838
+ }
1839
+ }
1840
+ }
1841
+
1842
+ // Basic log for each non-static request that passed without a challenge
1843
+ this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie) });
1844
+
1845
+ if (logger) {
1846
+ logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
1847
+ }
1848
+
1849
+ return { action: 'next', score: finalScore, vector: suspicionVector };
1850
+ }
1851
+
1852
+ /**
1853
+ * Identifies a request in a granular way for non-Express environments.
1854
+ * @param {object} requestContext - The request context object.
1855
+ * @returns {Promise<string>} An identification string (e.g., "device:<id>", "suspicious_high:<ip>").
1856
+ */
1857
+ async identifyRequest(requestContext) {
1858
+ const { clientIp, cookies, rawReq, rawRes } = requestContext;
1859
+
1860
+ // --- Update IP reputation ---
1861
+ const ipProfile = (await store.get(`ip:${clientIp}`)) || {
1862
+ type: "residential",
1863
+ deviceIds: new Set(),
1864
+ statelessCount: 0,
1865
+ lastSeen: 0,
1866
+ };
1867
+ ipProfile.lastSeen = Date.now();
1868
+ if (cookies?.device_id) {
1869
+ ipProfile.deviceIds.add(cookies.device_id);
1870
+ } else {
1871
+ ipProfile.statelessCount++;
1872
+ }
1873
+
1874
+ if (ipProfile.deviceIds.size > SHARED_IP_DEVICE_THRESHOLD) {
1875
+ ipProfile.type = "shared";
1876
+ }
1877
+
1878
+ const statelessLimit = ipProfile.type === "shared" ? 50 : 10;
1879
+ if (ipProfile.statelessCount > statelessLimit) {
1880
+ return `suspicious_high:${clientIp}`;
1881
+ }
1882
+ await store.set(`ip:${clientIp}`, ipProfile, 600); // Keep IP profile for 10 minutes
1883
+
1884
+ const vector = await __internal.getSuspicionVector(requestContext, this.securityConfig); // Pass the config
1885
+ const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
1886
+ const { requestPatternScore } = getRequestPatternScore(requestContext, (await store.get(`device:${requestContext.cookies?.device_id}`)), this.securityConfig.patterns);
1887
+ const score =
1888
+ vector.historyScore * (this.securityConfig.weights.historyScore || 0.3) +
1889
+ vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
1890
+ vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
1891
+ vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8) +
1892
+ honeypotScore * (this.securityConfig.weights.honeypotScore || 0) +
1893
+ requestPatternScore * (this.securityConfig.weights.requestPatternScore || 0) +
1894
+ vector.behaviorScore * (this.securityConfig.weights.behaviorScore || 0);
1895
+
1896
+ if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
1897
+ if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
1898
+ if (score >= this.securityConfig.thresholds.medium) return `suspicious_medium:${clientIp}`;
1899
+
1900
+ // If a new device_id was created, it's in the context.
1901
+ const newDeviceId = requestContext._newCookies?.find(c => c.name === 'device_id')?.value;
1902
+ const finalDeviceId = cookies?.device_id || newDeviceId || clientIp;
1903
+
1904
+ return `device:${finalDeviceId}`;
1905
+ }
1906
+ }
1907
+
1908
+ /**
1909
+ * Returns a default list of whitelisting rules for common and legitimate web crawlers.
1910
+ * This list can be used as a base and extended with custom rules.
1911
+ * @returns {Array<{userAgent: string, hostnameSuffix: string}>}
1912
+ */
1913
+ export const default_whitelist = () => [
1914
+ // === Moteurs de recherche majeurs ===
1915
+ { userAgent: 'Googlebot', hostnameSuffix: '.googlebot.com' },
1916
+ { userAgent: 'Google-Extended', hostnameSuffix: '.google.com' },
1917
+ { userAgent: 'AdsBot-Google', hostnameSuffix: '.googlebot.com' },
1918
+ { userAgent: 'Mediapartners-Google', hostnameSuffix: '.google.com' },
1919
+ { userAgent: 'Google-InspectionTool', hostnameSuffix: '.google.com' },
1920
+ { userAgent: '(bingbot|adidxbot)', hostnameSuffix: '.search.msn.com' },
1921
+ { userAgent: 'DuckDuckBot', hostnameSuffix: '.duckduckgo.com' },
1922
+ { userAgent: 'YandexBot', hostnameSuffix: '.yandex.com' },
1923
+ { userAgent: 'YandexImages', hostnameSuffix: '.yandex.com' },
1924
+ { userAgent: 'Baiduspider', hostnameSuffix: '.crawl.baidu.com' },
1925
+ { userAgent: 'Slurp', hostnameSuffix: '.crawl.yahoo.net' },
1926
+ { userAgent: 'Sogou web spider', hostnameSuffix: '.sogou.com' },
1927
+ { userAgent: 'Exabot', hostnameSuffix: '.exabot.com' },
1928
+ { userAgent: 'ia_archiver', hostnameSuffix: '.alexa.com' },
1929
+ { userAgent: 'SeznamBot', hostnameSuffix: '.seznam.cz' },
1930
+ { userAgent: 'Mail.RU_Bot', hostnameSuffix: '.mail.ru' },
1931
+ { userAgent: 'Yeti', hostnameSuffix: '.naver.com' }, // Naver
1932
+
1933
+ // === Outils SEO et d'analyse ===
1934
+ { userAgent: 'AhrefsBot', hostnameSuffix: '.ahrefs.com' },
1935
+ { userAgent: 'SemrushBot', hostnameSuffix: '.semrush.com' },
1936
+ { userAgent: 'MJ12bot', hostnameSuffix: '.mj12bot.com' }, // Majestic
1937
+ { userAgent: 'rogerbot', hostnameSuffix: '.moz.com' }, // Moz
1938
+ { userAgent: 'DotBot', hostnameSuffix: '.moz.com' }, // Moz (anciennement opensiteexplorer.org)
1939
+ { userAgent: 'Screaming Frog SEO Spider', hostnameSuffix: '.screamingfrog.co.uk' },
1940
+ { userAgent: 'cognitiveseo', hostnameSuffix: '.cognitiveseo.com' },
1941
+ { userAgent: 'SEOkicks', hostnameSuffix: '.seokicks.com' },
1942
+ { userAgent: 'serpstatbot', hostnameSuffix: '.serpstatbot.com' },
1943
+ { userAgent: 'MegaIndex', hostnameSuffix: '.megaindex.com' },
1944
+ { userAgent: 'LinkpadBot', hostnameSuffix: '.linkpad.ru' },
1945
+ { userAgent: 'Sistrix', hostnameSuffix: '.sistrix.com' },
1946
+ { userAgent: 'RyteBot', hostnameSuffix: '.ryte.com' },
1947
+ { userAgent: 'linkfluence', hostnameSuffix: '.linkfluence.com' },
1948
+ { userAgent: 'TurnitinBot', hostnameSuffix: '.turnitin.com' },
1949
+ { userAgent: 'GrapeshotCrawler', hostnameSuffix: '.grapeshot.co.uk' },
1950
+
1951
+ // === Robots d'IA et de données ===
1952
+ { userAgent: 'GPTBot', hostnameSuffix: '.openai.com' },
1953
+ { userAgent: 'ChatGPT-User', hostnameSuffix: '.openai.com' },
1954
+ { userAgent: 'Applebot', hostnameSuffix: '.applebot.apple.com' },
1955
+ { userAgent: 'CCBot', hostnameSuffix: '.commoncrawl.org' },
1956
+ { userAgent: 'Bytespider', hostnameSuffix: '.bytespider.com' }, // ByteDance (TikTok)
1957
+ { userAgent: 'Diffbot', hostnameSuffix: '.diffbot.com' },
1958
+ { userAgent: 'PerplexityBot', hostnameSuffix: '.perplexity.ai' },
1959
+ { userAgent: 'ClaudeBot', hostnameSuffix: '.anthropic.com' },
1960
+ { userAgent: 'cohere.io', hostnameSuffix: '.cohere.io' },
1961
+ { userAgent: 'DataForSeoBot', hostnameSuffix: '.dataforseo.com' },
1962
+ { userAgent: 'YouBot', hostnameSuffix: '.you.com' },
1963
+ { userAgent: 'omgili', hostnameSuffix: '.omgili.com' },
1964
+
1965
+ // === Réseaux sociaux et partage ===
1966
+ { userAgent: 'facebookexternalhit', hostnameSuffix: '.facebook.com' },
1967
+ { userAgent: 'facebot', hostnameSuffix: '.facebook.com' },
1968
+ { userAgent: 'Twitterbot', hostnameSuffix: '.twttr.com' },
1969
+ { userAgent: 'Pinterestbot', hostnameSuffix: '.pinterest.com' },
1970
+ { userAgent: 'LinkedInBot', hostnameSuffix: '.linkedin.com' },
1971
+ { userAgent: 'Slackbot', hostnameSuffix: '.slack.com' },
1972
+ { userAgent: 'Discordbot', hostnameSuffix: '.discord.com' },
1973
+ { userAgent: 'TelegramBot', hostnameSuffix: '.telegram.org' },
1974
+ { userAgent: 'WhatsApp', hostnameSuffix: '.wa.me' },
1975
+ { userAgent: 'SkypeUriPreview', hostnameSuffix: '.skype.com' },
1976
+ { userAgent: 'redditbot', hostnameSuffix: '.reddit.com' },
1977
+
1978
+ // === Services de monitoring et d'uptime ===
1979
+ { userAgent: 'UptimeRobot', hostnameSuffix: '.uptimerobot.com' },
1980
+ { userAgent: 'Pingdom', hostnameSuffix: '.pingdom.com' },
1981
+ { userAgent: 'StatusCake', hostnameSuffix: '.statuscake.com' },
1982
+ { userAgent: 'Site24x7', hostnameSuffix: '.site24x7.com' },
1983
+ { userAgent: 'Freshping', hostnameSuffix: '.freshping.io' },
1984
+ { userAgent: 'Better Uptime', hostnameSuffix: '.betteruptime.com' },
1985
+ { userAgent: 'Checkly', hostnameSuffix: '.checkly-infra.com' },
1986
+ { userAgent: 'Datadog', hostnameSuffix: '.datadoghq.com' },
1987
+ { userAgent: 'NewRelicPinger', hostnameSuffix: '.newrelic.com' },
1988
+
1989
+ // === Archives et agrégateurs de contenu ===
1990
+ { userAgent: 'archive.org_bot', hostnameSuffix: '.archive.org' },
1991
+ { userAgent: 'Feedly', hostnameSuffix: '.feedly.com' },
1992
+ { userAgent: 'FeedFetcher-Google', hostnameSuffix: '.google.com' },
1993
+ { userAgent: 'TheOldReader', hostnameSuffix: '.theoldreader.com' },
1994
+ { userAgent: 'Inoreader', hostnameSuffix: '.inoreader.com' },
1995
+ { userAgent: 'FlipboardProxy', hostnameSuffix: '.flipboard.com' },
1996
+ { userAgent: 'PaperLiBot', hostnameSuffix: '.paper.li' },
1997
+
1998
+ // === Services Cloud et Plateformes ===
1999
+ { userAgent: 'Amazon Route 53 Health Check', hostnameSuffix: '.amazonaws.com' },
2000
+ { userAgent: 'Google-Cloud-Scheduler', hostnameSuffix: '.google.com' },
2001
+ { userAgent: 'APIs-Google', hostnameSuffix: '.google.com' },
2002
+
2003
+ // === Divers ===
2004
+ { userAgent: 'W3C_Validator', hostnameSuffix: '.w3.org' },
2005
+ { userAgent: 'GTmetrix', hostnameSuffix: '.gtmetrix.com' },
2006
+ { userAgent: 'WebPageTest', hostnameSuffix: '.webpagetest.org' },
2007
+ { userAgent: 'Google-Site-Verification', hostnameSuffix: '.google.com' },
2008
+ { userAgent: 'KeyCDN', hostnameSuffix: '.keycdn.com' },
2009
+ ];
2010
+
2011
+
2012
+
2013
+ // --- Proof-of-Work Middleware (The Tollbooth) ---
2014
+ export const powMiddleware = (securityConfig) => {
2015
+ const engine = new FingerprintEngine(securityConfig);
2016
+
2017
+ if (securityConfig.autotuning) {
2018
+ startThresholdAutoTuning({
2019
+ securityConfig: securityConfig,
2020
+ ...securityConfig.autotuning,
2021
+ });
2022
+ }
2023
+
2024
+ // Provide a default for isApiRequest if not specified by the user.
2025
+ // This makes API challenge handling work more seamlessly out-of-the-box.
2026
+ if (!securityConfig.thresholds?.isApiRequest) {
2027
+ if (!securityConfig.thresholds) securityConfig.thresholds = {};
2028
+ securityConfig.thresholds.isApiRequest = (req) =>
2029
+ req.headers?.accept?.includes('application/json');
2030
+ }
2031
+
2032
+ return async (req, res, next) => {
2033
+ const requestContext = {
2034
+ clientIp: req.ip || req.socket?.remoteAddress || "unknown",
2035
+ path: req.path,
2036
+ cookies: req.cookies,
2037
+ query: req.query,
2038
+ body: req.body,
2039
+ headers: req.headers,
2040
+ isStatic: isStaticResource(req.path),
2041
+ // Pass the original request object for the isApiRequest function
2042
+ rawReq: req,
2043
+ // Add the newly required properties for full decoupling
2044
+ rawHeaders: req.rawHeaders,
2045
+ // Pass the raw request object for advanced inspection (e.g., JA3)
2046
+ httpVersion: req.httpVersion,
2047
+ };
2048
+
2049
+ const decision = await engine.processRequest(requestContext);
2050
+
2051
+ // Attach the fingerprinting result to the request object for downstream middlewares.
2052
+ req.fingerprint = {
2053
+ score: decision.score,
2054
+ vector: decision.vector,
2055
+ };
2056
+
2057
+ // After getSuspicionVector runs, it might have attached cookies to be set.
2058
+ if (requestContext._newCookies) {
2059
+ requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
2060
+ }
2061
+
2062
+ switch (decision.action) {
2063
+ case 'block':
2064
+ return res.status(decision.status).send(decision.body);
2065
+
2066
+ case 'challenge': // Gère à la fois les réponses HTML et JSON
2067
+ if (typeof decision.body === 'object' && decision.body !== null) {
2068
+ return res.status(decision.status).json(decision.body);
2069
+ }
2070
+ // Par défaut, envoie du HTML
2071
+ return res.status(decision.status).send(decision.body);
2072
+
2073
+ case 'redirect':
2074
+ if (decision.cookie) {
2075
+ res.cookie(decision.cookie.name, decision.cookie.value, decision.cookie.options);
2076
+ }
2077
+ return res.redirect(decision.path);
2078
+
2079
+ case 'next':
2080
+ default:
2081
+ return next();
2082
+ }
2083
+ };
2084
+ };
2085
+
2086
+ /**
2087
+ * @internal
2088
+ * Exporting an object containing the functions to make them mockable in tests.
2089
+ * This is a common pattern to allow mocking of ES module functions.
2090
+ */
2091
+ export const __internal = {
2092
+ getDeviceHash,
2093
+ isMalicious,
2094
+ getSuspicionVector,
2095
+ cyrb53, // Export for testing
2096
+ FingerprintBuilder, // Export for testing
2097
+ calculateTarget,
2098
+ determineOptimalTicketTtl,
2099
+ getRequestPatternScore, // Expose for testing
2100
+ getBehaviorScore, // Expose for testing
2101
+ };
2102
+
2103
+ // --- THRESHOLD AUTO-TUNING SECTION ---
2104
+
2105
+ let autoTuningJobId = null;
2106
+
2107
+ /**
2108
+ * Executes a threshold optimization pass using collected traffic data.
2109
+ * @private
2110
+ * @param {object} securityConfig - The security configuration object to update.
2111
+ * @param {Array<object>} trafficData - The array containing traffic logs.
2112
+ * @param {number} minDataPoints - The minimum number of data points required to start optimization.
2113
+ */
2114
+ function runThresholdOptimization(securityConfig, trafficData, minDataPoints) {
2115
+ if (trafficData.length < minDataPoints) {
2116
+ console.log(`[AutoTuning] Reporté : ${trafficData.length}/${minDataPoints} points de données.`);
2117
+ return;
2118
+ }
2119
+ console.log(`[AutoTuning] Démarrage du cycle d'optimisation avec ${trafficData.length} points de données.`);
2120
+
2121
+ // Classify historical requests with a confidence weight.
2122
+ const solvedDevices = new Set(trafficData.filter(e => e.type === 'challenge_solved').map(e => e.deviceId));
2123
+ const challengedDevices = new Set(trafficData.filter(e => e.type === 'challenge_issued').map(e => e.deviceId));
2124
+
2125
+ const historicalRequests = trafficData.map(log => {
2126
+ // Assign a label ('bot' or 'human') and a confidence weight to each log entry.
2127
+ switch (log.type) {
2128
+ case 'honeypot_probe':
2129
+ case 'trap_triggered':
2130
+ return { score: log.score, label: 'bot', confidence: 10.0 }; // Very high confidence
2131
+
2132
+ case 'challenge_issued':
2133
+ // A challenge issued to a device that never solved it is a strong bot signal.
2134
+ if (!solvedDevices.has(log.deviceId)) {
2135
+ return { score: log.score, label: 'bot', confidence: 3.0 }; // High confidence
2136
+ }
2137
+ // If the challenge was eventually solved, this specific log is neutral.
2138
+ return null;
2139
+
2140
+ case 'challenge_solved':
2141
+ return { score: log.score, label: 'human', confidence: 5.0 }; // High confidence
2142
+
2143
+ case 'request_passed':
2144
+ // A passed request from a device that was never even challenged is likely a human.
2145
+ if (!challengedDevices.has(log.deviceId)) {
2146
+ return { score: log.score, label: 'human', confidence: 0.5 }; // Low confidence
2147
+ }
2148
+ // If the device was challenged at some point, this log is ambiguous.
2149
+ return null;
2150
+
2151
+ default:
2152
+ return null;
2153
+ }
2154
+ }).filter(Boolean); // Remove null entries
2155
+
2156
+ // The "fitness" function evaluates the quality of a set of thresholds.
2157
+ // A lower score is better.
2158
+ const fitnessFunction = (solution) => {
2159
+ const [low, medium, high, velocityThreshold, burstThreshold, scrapeThreshold] = solution;
2160
+ if (low >= medium || medium >= high || low < 10 || high > 90) return Infinity;
2161
+ if (velocityThreshold < 50 || velocityThreshold > burstThreshold || burstThreshold > scrapeThreshold) return Infinity;
2162
+
2163
+ let weightedFalsePositives = 0; // Humans challenged unnecessarily.
2164
+ let weightedFalseNegatives = 0; // Undetected bots.
2165
+
2166
+ for (const req of historicalRequests) {
2167
+ if (req.label === 'bot') {
2168
+ if (req.score < low) weightedFalseNegatives += req.confidence;
2169
+ } else { // 'human'
2170
+ if (req.score >= low) weightedFalsePositives += req.confidence;
2171
+ }
2172
+ }
2173
+ // The penalty for false negatives is implicitly higher due to the higher confidence scores of bot signals.
2174
+ return weightedFalsePositives + weightedFalseNegatives;
2175
+ };
2176
+
2177
+ // Functions for the genetic algorithm.
2178
+ const createIndividual = () => [
2179
+ 10 + Math.random() * 20, // low
2180
+ 30 + Math.random() * 30, // medium
2181
+ 60 + Math.random() * 30, // high
2182
+ 100 + Math.random() * 150, // velocityThreshold (100-250ms)
2183
+ 300 + Math.random() * 400, // burstThreshold (300-700ms)
2184
+ 800 + Math.random() * 700, // scrapeThreshold (800-1500ms)
2185
+ ];
2186
+ const crossover = (p1, p2) => p1.map((val, i) => (val + p2[i]) / 2);
2187
+ const mutate = (s) => {
2188
+ const n = [...s];
2189
+ const i = Math.floor(Math.random() * n.length);
2190
+ // Adjust mutation range based on parameter
2191
+ const mutationRange = i < 3 ? 5 : 50;
2192
+ n[i] += (Math.random() - 0.5) * mutationRange;
2193
+ return n;
2194
+ };
2195
+
2196
+ // Start optimization.
2197
+ const result = Optimization.geneticAlgorithm(createIndividual, fitnessFunction, crossover, mutate, {
2198
+ generations: 50,
2199
+ populationSize: 40
2200
+ });
2201
+
2202
+ const [newLow, newMedium, newHigh, newVelocity, newBurst, newScrape] = result.solution;
2203
+
2204
+ // Update the configuration live.
2205
+ // Ensure thresholds object exists
2206
+ if (!securityConfig.thresholds) securityConfig.thresholds = {};
2207
+ securityConfig.thresholds.low = Math.round(newLow);
2208
+ securityConfig.thresholds.medium = Math.round(newMedium);
2209
+ securityConfig.thresholds.high = Math.round(newHigh);
2210
+
2211
+ // Update pattern detection parameters
2212
+ if (!securityConfig.patterns) securityConfig.patterns = {};
2213
+ securityConfig.patterns.velocityThreshold = Math.round(newVelocity);
2214
+ securityConfig.patterns.burstThreshold = Math.round(newBurst);
2215
+ securityConfig.patterns.scrapeThreshold = Math.round(newScrape);
2216
+ // Weights could also be optimized, but let's keep it to thresholds for now for simplicity.
2217
+
2218
+ console.log("[AutoTuning] Nouveaux seuils optimisés appliqués :", securityConfig.thresholds);
2219
+ if (securityConfig.patterns) {
2220
+ console.log("[AutoTuning] Nouveaux paramètres de pattern appliqués :", securityConfig.patterns);
2221
+ }
2222
+ }
2223
+
2224
+ /**
2225
+ * Starts the background process for auto-tuning security thresholds.
2226
+ * @export
2227
+ * @param {object} options - Configuration options for auto-tuning.
2228
+ * @param {object} options.securityConfig - The live security configuration object that will be mutated.
2229
+ * @param {Array<object>} options.trafficData - The array where the logger pushes traffic data.
2230
+ * @param {number} [options.interval=1800000] - The interval in milliseconds between each optimization cycle (default: 30 minutes).
2231
+ * @param {number} [options.minDataPoints=200] - The minimum number of requests to analyze before starting a cycle (default: 200).
2232
+ */
2233
+ export function startThresholdAutoTuning(options) {
2234
+ if (autoTuningJobId) {
2235
+ console.warn("[AutoTuning] Le job est déjà en cours d'exécution.");
2236
+ return;
2237
+ }
2238
+
2239
+ const {
2240
+ securityConfig,
2241
+ trafficData,
2242
+ interval = 1800000,
2243
+ minDataPoints = 200
2244
+ } = options;
2245
+
2246
+ if (!securityConfig || !trafficData) {
2247
+ throw new Error("[AutoTuning] `securityConfig` et `trafficData` sont requis.");
2248
+ }
2249
+
2250
+ console.log(`[AutoTuning] Job d'optimisation des seuils démarré. Prochain cycle dans ${interval / 60000} minutes.`);
2251
+
2252
+ autoTuningJobId = setInterval(() => {
2253
+ runThresholdOptimization(securityConfig, trafficData, minDataPoints);
2254
+ }, interval);
2255
+ }
2256
+
2257
+ /**
2258
+ * Stops the threshold auto-tuning process.
2259
+ * @export
2260
+ */
2261
+ export function stopThresholdAutoTuning() {
2262
+ if (autoTuningJobId) {
2263
+ clearInterval(autoTuningJobId);
2264
+ autoTuningJobId = null;
2265
+ console.log("[AutoTuning] Job d'optimisation des seuils arrêté.");
2266
+ }
2267
+ }