@anonympins/fingerprint 0.2.2 → 0.2.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/README.md +726 -650
- package/fingerprint.builder.js +171 -160
- package/fingerprint.client.js +482 -459
- package/fingerprint.js +446 -87
- package/package.json +88 -79
- package/pow.solver.js +12 -0
- package/problem-manager.js +198 -22
package/fingerprint.js
CHANGED
|
@@ -10,6 +10,206 @@ import { dirname, join } from "node:path";
|
|
|
10
10
|
export { createRedisStore } from "./redis-store.js";
|
|
11
11
|
export { createMongoDbStore } from "./mongodb-store.js";
|
|
12
12
|
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @private
|
|
16
|
+
* Deep merges two objects. The `source` object's properties overwrite the `target`'s.
|
|
17
|
+
* @param {object} target - The target object.
|
|
18
|
+
* @param {object} source - The source object.
|
|
19
|
+
* @returns {object} The merged object.
|
|
20
|
+
*/
|
|
21
|
+
function deepMerge(target, source) {
|
|
22
|
+
const output = { ...target };
|
|
23
|
+
if (target && typeof target === 'object' && source && typeof source === 'object') {
|
|
24
|
+
Object.keys(source).forEach(key => {
|
|
25
|
+
if (source[key] && typeof source[key] === 'object' && key in target) {
|
|
26
|
+
output[key] = deepMerge(target[key], source[key]);
|
|
27
|
+
} else {
|
|
28
|
+
output[key] = source[key];
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return output;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const securityProfiles = {
|
|
36
|
+
/**
|
|
37
|
+
* @summary **Balanced Profile (Default)**
|
|
38
|
+
* @description A general-purpose configuration suitable for most websites, offering a good mix of security and user experience. It's sensitive enough to catch common bots without being overly aggressive towards legitimate users.
|
|
39
|
+
*/
|
|
40
|
+
balanced: {
|
|
41
|
+
weights: {
|
|
42
|
+
historyScore: 0.3,
|
|
43
|
+
rotationScore: 0.5,
|
|
44
|
+
headerAnomalyScore: 0.1,
|
|
45
|
+
requestPatternScore: 0.6,
|
|
46
|
+
inconsistencyScore: 0.8,
|
|
47
|
+
behaviorScore: 0.7,
|
|
48
|
+
honeypotScore: 1.0,
|
|
49
|
+
crossLayerInconsistencyScore: 0.4,
|
|
50
|
+
timeInconsistencyScore: 0.9,
|
|
51
|
+
tlsSpoofingScore: 0.8 // NOUVEAU: Poids pour la détection de spoofing TLS
|
|
52
|
+
},
|
|
53
|
+
thresholds: { low: 20, medium: 45, high: 75, block: 95 },
|
|
54
|
+
patterns: {
|
|
55
|
+
velocityThreshold: 800,
|
|
56
|
+
burstThreshold: 1500,
|
|
57
|
+
scrapeThreshold: 1000,
|
|
58
|
+
historySize: 10,
|
|
59
|
+
minSamples: 5,
|
|
60
|
+
regularityThreshold: 50,
|
|
61
|
+
benfordThreshold: 0.15,
|
|
62
|
+
patternWeight: 80,
|
|
63
|
+
decayFactor: 0.9,
|
|
64
|
+
inactivityReset: 5000,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
/**
|
|
68
|
+
* @summary **Strict Profile**
|
|
69
|
+
* @description An aggressive configuration for sensitive applications (e.g., financial services, admin panels). It uses lower suspicion thresholds and higher penalties for anomalies, prioritizing security over user convenience. All new devices are challenged by default.
|
|
70
|
+
*/
|
|
71
|
+
strict: {
|
|
72
|
+
weights: {
|
|
73
|
+
historyScore: 0.4,
|
|
74
|
+
rotationScore: 0.6,
|
|
75
|
+
headerAnomalyScore: 0.2,
|
|
76
|
+
requestPatternScore: 0.8,
|
|
77
|
+
inconsistencyScore: 1.0,
|
|
78
|
+
behaviorScore: 0.8,
|
|
79
|
+
honeypotScore: 1.0,
|
|
80
|
+
crossLayerInconsistencyScore: 0.6,
|
|
81
|
+
timeInconsistencyScore: 1.0,
|
|
82
|
+
tlsSpoofingScore: 1.0 // NOUVEAU: Plus agressif pour le spoofing TLS
|
|
83
|
+
},
|
|
84
|
+
thresholds: { low: 10, medium: 35, high: 65, block: 90 },
|
|
85
|
+
patterns: {
|
|
86
|
+
velocityThreshold: 1000,
|
|
87
|
+
burstThreshold: 1800,
|
|
88
|
+
scrapeThreshold: 1200,
|
|
89
|
+
historySize: 15,
|
|
90
|
+
minSamples: 4,
|
|
91
|
+
regularityThreshold: 40,
|
|
92
|
+
benfordThreshold: 0.12,
|
|
93
|
+
patternWeight: 90,
|
|
94
|
+
decayFactor: 0.85,
|
|
95
|
+
inactivityReset: 4000,
|
|
96
|
+
},
|
|
97
|
+
challengeNewDevices: true, // Challenge all new devices
|
|
98
|
+
},
|
|
99
|
+
/**
|
|
100
|
+
* @summary **API Profile**
|
|
101
|
+
* @description Optimized for protecting API endpoints. This profile is highly sensitive to request patterns (velocity, bursts) and less reliant on browser-specific behavioral metrics. It's designed to quickly identify and throttle scrapers and automated clients.
|
|
102
|
+
*/
|
|
103
|
+
api: {
|
|
104
|
+
weights: {
|
|
105
|
+
historyScore: 0.5,
|
|
106
|
+
rotationScore: 0.5,
|
|
107
|
+
headerAnomalyScore: 0.3,
|
|
108
|
+
requestPatternScore: 1.0, // Very high weight for API patterns
|
|
109
|
+
inconsistencyScore: 0.7,
|
|
110
|
+
behaviorScore: 0.2, // Lower weight, as browser behavior is not applicable
|
|
111
|
+
honeypotScore: 1.0,
|
|
112
|
+
crossLayerInconsistencyScore: 0.5,
|
|
113
|
+
timeInconsistencyScore: 0.8,
|
|
114
|
+
tlsSpoofingScore: 0.7 // NOUVEAU: Important pour les API
|
|
115
|
+
},
|
|
116
|
+
thresholds: { low: 25, medium: 50, high: 80, block: 95 },
|
|
117
|
+
patterns: {
|
|
118
|
+
velocityThreshold: 200, // APIs are expected to be fast
|
|
119
|
+
burstThreshold: 500,
|
|
120
|
+
scrapeThreshold: 400,
|
|
121
|
+
historySize: 20,
|
|
122
|
+
minSamples: 8,
|
|
123
|
+
regularityThreshold: 20,
|
|
124
|
+
benfordThreshold: 0.18,
|
|
125
|
+
patternWeight: 85,
|
|
126
|
+
decayFactor: 0.9,
|
|
127
|
+
inactivityReset: 10000,
|
|
128
|
+
},
|
|
129
|
+
isApiRequest: (req) => req.path.startsWith('/api/') || req.headers.accept?.includes('application/json'),
|
|
130
|
+
}
|
|
131
|
+
,
|
|
132
|
+
/**
|
|
133
|
+
* @summary **Blog Profile**
|
|
134
|
+
* @description Tuned for blogs and content-heavy websites. This profile focuses on detecting content scraping and comment spam by placing a high weight on request patterns and honeypot traps, while being more lenient on behavioral metrics typical of readers.
|
|
135
|
+
*/
|
|
136
|
+
blog: {
|
|
137
|
+
weights: {
|
|
138
|
+
historyScore: 0.2,
|
|
139
|
+
rotationScore: 0.3,
|
|
140
|
+
headerAnomalyScore: 0.1,
|
|
141
|
+
requestPatternScore: 0.8, // High weight to detect content scraping
|
|
142
|
+
inconsistencyScore: 0.7,
|
|
143
|
+
behaviorScore: 0.5, // Less emphasis on complex interactions
|
|
144
|
+
honeypotScore: 1.0, // Crucial for comment spam
|
|
145
|
+
crossLayerInconsistencyScore: 0.4,
|
|
146
|
+
timeInconsistencyScore: 0.8,
|
|
147
|
+
tlsSpoofingScore: 0.6 // NOUVEAU: Moins critique pour les blogs
|
|
148
|
+
},
|
|
149
|
+
thresholds: { low: 25, medium: 55, high: 80, block: 95 },
|
|
150
|
+
patterns: {
|
|
151
|
+
velocityThreshold: 1000, // Readers can be fast
|
|
152
|
+
burstThreshold: 2000,
|
|
153
|
+
scrapeThreshold: 800, // Very sensitive to scraping patterns
|
|
154
|
+
historySize: 12,
|
|
155
|
+
minSamples: 5,
|
|
156
|
+
regularityThreshold: 60,
|
|
157
|
+
benfordThreshold: 0.16,
|
|
158
|
+
patternWeight: 85,
|
|
159
|
+
decayFactor: 0.92,
|
|
160
|
+
inactivityReset: 10000,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
/**
|
|
164
|
+
* @summary **E-commerce Profile**
|
|
165
|
+
* @description A strict profile tailored for e-commerce sites. It's designed to combat inventory scalping, price scraping, and account takeover attempts by using high weights for request patterns and fingerprint inconsistency. It also challenges all new devices to increase the cost for bots.
|
|
166
|
+
*/
|
|
167
|
+
ecommerce: {
|
|
168
|
+
weights: {
|
|
169
|
+
historyScore: 0.4,
|
|
170
|
+
rotationScore: 0.6,
|
|
171
|
+
headerAnomalyScore: 0.2,
|
|
172
|
+
// Scission du requestPatternScore pour un contrôle plus fin
|
|
173
|
+
velocityScore: 0.8, // Pénalise la vitesse globale
|
|
174
|
+
burstScore: 1.0, // Pénalise fortement les rafales sur la même ressource (scalping)
|
|
175
|
+
scrapeScore: 0.9, // Pénalise le parcours de pages/produits
|
|
176
|
+
regularityScore: 0.7, // Détecte les bots de type "cron"
|
|
177
|
+
inconsistencyScore: 1.0, // Crucial for preventing account takeover
|
|
178
|
+
behaviorScore: 0.8, // Important for checkout/login forms
|
|
179
|
+
honeypotScore: 1.0,
|
|
180
|
+
crossLayerInconsistencyScore: 0.7,
|
|
181
|
+
timeInconsistencyScore: 0.9,
|
|
182
|
+
tlsSpoofingScore: 0.9 // NOUVEAU: Très important pour l'e-commerce
|
|
183
|
+
},
|
|
184
|
+
thresholds: { low: 15, medium: 40, high: 70, block: 90 },
|
|
185
|
+
patterns: {
|
|
186
|
+
velocityThreshold: 500, // Bots are very fast
|
|
187
|
+
burstThreshold: 1000, // Detects rapid retries on the same product/action
|
|
188
|
+
scrapeThreshold: 600,
|
|
189
|
+
historySize: 15,
|
|
190
|
+
minSamples: 6,
|
|
191
|
+
regularityThreshold: 30,
|
|
192
|
+
benfordThreshold: 0.14,
|
|
193
|
+
patternWeight: 95,
|
|
194
|
+
decayFactor: 0.88,
|
|
195
|
+
inactivityReset: 3000,
|
|
196
|
+
},
|
|
197
|
+
challengeNewDevices: true, // New devices are suspicious in e-commerce
|
|
198
|
+
isApiRequest: (req) => req.path.startsWith('/api/cart') || req.path.startsWith('/api/stock') || req.path.startsWith('/api/checkout'),
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Creates a security configuration based on a named profile, with optional overrides.
|
|
204
|
+
* @param {'balanced' | 'strict' | 'api'} [profileName='balanced'] - The name of the profile to use.
|
|
205
|
+
* @param {object} [overrides={}] - An object to deeply merge with the profile, allowing for customization.
|
|
206
|
+
* @returns {object} The final security configuration object.
|
|
207
|
+
*/
|
|
208
|
+
export function createSecurityProfile(profileName = 'balanced', overrides = {}) { // eslint-disable-line no-unused-vars
|
|
209
|
+
const baseProfile = securityProfiles[profileName] || securityProfiles.balanced;
|
|
210
|
+
return deepMerge(baseProfile, overrides);
|
|
211
|
+
}
|
|
212
|
+
|
|
13
213
|
/**
|
|
14
214
|
* Retrieves the POW_SECRET from environment variables with appropriate checks.
|
|
15
215
|
* @returns {string} The secret key.
|
|
@@ -36,48 +236,56 @@ const getPowSolverCode = () => {
|
|
|
36
236
|
};
|
|
37
237
|
|
|
38
238
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
239
|
+
* Extracts TLS fingerprints (JA3 and JA4) from request context.
|
|
240
|
+
* Prioritizes headers from reverse proxies (x-ja4-hash) and falls back to JA3 calculation
|
|
241
|
+
* from raw socket data if available.
|
|
42
242
|
* @param {object} context - The request context, containing the raw request object.
|
|
43
|
-
* @returns {string|null
|
|
243
|
+
* @returns {{ja3: string|null, ja4: string|null}} An object containing JA3 and JA4 hashes.
|
|
44
244
|
*/
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
245
|
+
function getTlsFingerprint(context) {
|
|
246
|
+
let ja3 = null;
|
|
247
|
+
let ja4 = null;
|
|
248
|
+
|
|
249
|
+
// 1. Prefer JA4 hash from a trusted reverse proxy header.
|
|
250
|
+
const ja4FromHeader = context.headers['x-ja4-hash'];
|
|
251
|
+
if (ja4FromHeader) {
|
|
252
|
+
ja4 = ja4FromHeader;
|
|
253
|
+
}
|
|
254
|
+
// 2. Prefer JA3 hash from a trusted reverse proxy header.
|
|
255
|
+
const ja3FromHeader = context.headers['x-ja3-hash']; // Assuming a proxy might provide JA3 too
|
|
48
256
|
if (ja3FromHeader) {
|
|
49
|
-
|
|
257
|
+
ja3 = ja3FromHeader;
|
|
50
258
|
}
|
|
51
259
|
|
|
52
|
-
//
|
|
260
|
+
// 3. Fallback to calculating from the raw socket if available and if headers were not present.
|
|
53
261
|
const clientHello = context.rawReq?.socket?.clientHello;
|
|
54
|
-
if (!
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
const { version, ciphers, extensions, ellipticCurves, ellipticCurvePointFormats } = clientHello;
|
|
262
|
+
if (clientHello && !ja3) { // Only calculate if ja3 is not already set
|
|
263
|
+
try {
|
|
264
|
+
const { version, ciphers, extensions, ellipticCurves, ellipticCurvePointFormats } = clientHello;
|
|
60
265
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
266
|
+
// The official JA3 spec includes the TLS version.
|
|
267
|
+
// Node.js provides it as a string like 'TLSv1.3', we need the corresponding decimal value.
|
|
268
|
+
const tlsVersionMap = {
|
|
269
|
+
'TLSv1': 769, 'TLSv1.1': 770, 'TLSv1.2': 771, 'TLSv1.3': 772
|
|
270
|
+
};
|
|
271
|
+
const tlsVersionId = tlsVersionMap[version] || 0;
|
|
272
|
+
|
|
273
|
+
const ja3String = [
|
|
274
|
+
tlsVersionId,
|
|
275
|
+
// The ciphers array from clientHello is an array of objects, not just IDs.
|
|
276
|
+
Array.isArray(ciphers) ? ciphers.join('-') : '',
|
|
277
|
+
extensions?.join('-') || '',
|
|
278
|
+
ellipticCurves?.join('-') || '',
|
|
279
|
+
ellipticCurvePointFormats?.join('-') || ''
|
|
280
|
+
].join(',');
|
|
281
|
+
|
|
282
|
+
ja3 = crypto.createHash('md5').update(ja3String).digest('hex');
|
|
283
|
+
} catch (e) {
|
|
284
|
+
// Could fail if clientHello structure is unexpected.
|
|
285
|
+
ja3 = null;
|
|
286
|
+
}
|
|
80
287
|
}
|
|
288
|
+
return { ja3, ja4 };
|
|
81
289
|
}
|
|
82
290
|
/**
|
|
83
291
|
* Creates a stable hash based on device characteristics, independent of the IP.
|
|
@@ -109,7 +317,7 @@ export function getDeviceHash(context) {
|
|
|
109
317
|
return getCompositeDeviceHash(context);
|
|
110
318
|
}
|
|
111
319
|
|
|
112
|
-
|
|
320
|
+
function getCompositeDeviceHash(context) {
|
|
113
321
|
const srv = new FingerprintBuilder();
|
|
114
322
|
|
|
115
323
|
// Si un fingerprint client est fourni, on l'intègre comme un signal fort,
|
|
@@ -127,70 +335,55 @@ export function getCompositeDeviceHash(context) {
|
|
|
127
335
|
// 1. SIGNAL FORT: User Agent (poids élevé)
|
|
128
336
|
const ua = context.headers["user-agent"];
|
|
129
337
|
if (ua) {
|
|
130
|
-
srv.add("ua", ua);
|
|
338
|
+
srv.add("ua", ua); // User-Agent
|
|
131
339
|
}
|
|
132
340
|
|
|
133
|
-
// 2.
|
|
134
|
-
const ja3 =
|
|
341
|
+
// 2. SIGNAUX DE BAS NIVEAU (Transport & Réseau) - Très fiables si fournis par un proxy
|
|
342
|
+
const { ja3, ja4 } = getTlsFingerprint(context);
|
|
135
343
|
if (ja3) srv.add("ja3", ja3);
|
|
344
|
+
if (ja4) srv.add("ja4", ja4);
|
|
345
|
+
|
|
346
|
+
const h2Fingerprint = context.headers['x-http2-fingerprint'];
|
|
347
|
+
if (h2Fingerprint) srv.add("h2", h2Fingerprint);
|
|
348
|
+
|
|
349
|
+
const tcpFingerprint = context.headers['x-tcp-fingerprint'];
|
|
350
|
+
if (tcpFingerprint) srv.add("tcp", tcpFingerprint);
|
|
351
|
+
|
|
352
|
+
// 3. SIGNAUX DE HAUT NIVEAU (Applicatif) - Moins fiables, mais utiles pour la corroboration
|
|
353
|
+
const headersToCapture = {
|
|
354
|
+
"ch_ua": "sec-ch-ua",
|
|
355
|
+
"ch_platform": "sec-ch-ua-platform",
|
|
356
|
+
"ch_mobile": "sec-ch-ua-mobile",
|
|
357
|
+
"ch_model": "sec-ch-ua-model",
|
|
358
|
+
"ch_arch": "sec-ch-ua-arch",
|
|
359
|
+
"ch_bitness": "sec-ch-ua-bitness",
|
|
360
|
+
"upgrade_req": "upgrade-insecure-requests",
|
|
361
|
+
"accept_lang": "accept-language",
|
|
362
|
+
"accept_enc": "accept-encoding",
|
|
363
|
+
"accept": "accept"
|
|
364
|
+
};
|
|
136
365
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
srv.add("ch_platform", context.headers["sec-ch-ua-platform"]);
|
|
143
|
-
}
|
|
144
|
-
if (context.headers["sec-ch-ua-mobile"]) {
|
|
145
|
-
srv.add("ch_mobile", context.headers["sec-ch-ua-mobile"]);
|
|
146
|
-
}
|
|
147
|
-
if (context.headers["sec-ch-ua-model"]) {
|
|
148
|
-
srv.add("ch_model", context.headers["sec-ch-ua-model"]);
|
|
149
|
-
}
|
|
150
|
-
if (context.headers["sec-ch-ua-arch"]) {
|
|
151
|
-
srv.add("ch_arch", context.headers["sec-ch-ua-arch"]);
|
|
152
|
-
}
|
|
153
|
-
if (context.headers["sec-ch-ua-bitness"]) {
|
|
154
|
-
srv.add("ch_bitness", context.headers["sec-ch-ua-bitness"]);
|
|
366
|
+
for (const [key, headerName] of Object.entries(headersToCapture)) {
|
|
367
|
+
const headerValue = context.headers[headerName];
|
|
368
|
+
if (headerValue) {
|
|
369
|
+
srv.add(key, headerValue);
|
|
370
|
+
}
|
|
155
371
|
}
|
|
156
372
|
|
|
157
|
-
// 4.
|
|
373
|
+
// 4. SIGNAUX DE CONTEXTE (HTTP Version, Cookies)
|
|
158
374
|
if (context.httpVersion) {
|
|
159
375
|
srv.add("http_ver", context.httpVersion);
|
|
160
376
|
}
|
|
161
|
-
if (context.headers["upgrade-insecure-requests"]) {
|
|
162
|
-
srv.add("upgrade", context.headers["upgrade-insecure-requests"]);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// 11. SIGNAL AVANCÉ: Cookies (si disponible)
|
|
166
377
|
if (context.cookies) {
|
|
167
378
|
const cookieKeys = Object.keys(context.cookies).sort().join(',');
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// 12. SIGNAL AVANCÉ: Format de la requête
|
|
172
|
-
if (context.rawHeaders) {
|
|
173
|
-
// Vérifier des headers spécifiques qui indiquent le client
|
|
174
|
-
const clientHeaders = ['x-requested-with', 'x-forwarded-for', 'x-real-ip', 'cf-connecting-ip'];
|
|
175
|
-
clientHeaders.forEach(h => {
|
|
176
|
-
if (context.headers[h]) {
|
|
177
|
-
srv.add(h.replace(/-/g, '_'), context.headers[h]);
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// 13. OPTIONNEL: IP (version simplifiée pour les réseaux partagés)
|
|
183
|
-
// Ne pas inclure l'IP complète, mais un hash du réseau /24 ou /16
|
|
184
|
-
// pour détecter les changements de réseau tout en protégeant la vie privée
|
|
185
|
-
const ip = context.clientIp || context.headers['x-forwarded-for']?.split(',')[0]?.trim();
|
|
186
|
-
if (ip && isPrivateIp(ip)) {
|
|
187
|
-
// Pour les IP privées, on peut prendre le /24
|
|
188
|
-
const networkHash = hashNetwork(ip, 24);
|
|
189
|
-
srv.add("network", networkHash);
|
|
379
|
+
if (cookieKeys) {
|
|
380
|
+
srv.add("cookie_keys", cookieKeys);
|
|
381
|
+
}
|
|
190
382
|
}
|
|
191
383
|
|
|
192
384
|
return srv.toString();
|
|
193
385
|
}
|
|
386
|
+
export { getCompositeDeviceHash };
|
|
194
387
|
|
|
195
388
|
// Fonctions utilitaires
|
|
196
389
|
function parseUserAgent(ua) {
|
|
@@ -700,6 +893,17 @@ function getBehaviorScore(context) {
|
|
|
700
893
|
score += 40;
|
|
701
894
|
}
|
|
702
895
|
|
|
896
|
+
// 3. (NOUVEAU) Analyse de la longueur de l'historique de navigation.
|
|
897
|
+
// Un historique court est suspect (nouvel onglet, bot), un historique long est un bon signe.
|
|
898
|
+
if (typeof metrics.historyLength === 'number') {
|
|
899
|
+
if (metrics.historyLength === 1) {
|
|
900
|
+
score += 15; // Légère pénalité pour un historique de session vierge.
|
|
901
|
+
} else if (metrics.historyLength >= 5) {
|
|
902
|
+
score -= 20; // Bonus : un historique long est un fort indicateur humain.
|
|
903
|
+
} else if (metrics.historyLength >= 2) {
|
|
904
|
+
score -= 10; // Petit bonus pour une navigation de base.
|
|
905
|
+
}
|
|
906
|
+
}
|
|
703
907
|
// 3. Vérification de la plausibilité et de la distribution des métriques.
|
|
704
908
|
// Un bot pourrait envoyer des valeurs aléatoires, mais elles ne suivront probablement pas
|
|
705
909
|
// des distributions naturelles (comme la loi de Benford pour les premiers chiffres).
|
|
@@ -728,7 +932,7 @@ function getBehaviorScore(context) {
|
|
|
728
932
|
if (keystrokeDeviation > 0.15) score += 40;
|
|
729
933
|
}
|
|
730
934
|
|
|
731
|
-
return { behaviorScore: Math.min(100, score) };
|
|
935
|
+
return { behaviorScore: Math.max(0, Math.min(100, score)) }; // Assure que le score reste entre 0 et 100
|
|
732
936
|
} catch (e) {
|
|
733
937
|
return { behaviorScore: 10 }; // En-tête malformé = légèrement suspect.
|
|
734
938
|
}
|
|
@@ -812,6 +1016,66 @@ function getCrossLayerInconsistency(context) {
|
|
|
812
1016
|
}
|
|
813
1017
|
}
|
|
814
1018
|
|
|
1019
|
+
/**
|
|
1020
|
+
* Calcule un score d'incohérence entre les données du fingerprint TLS (JA3/JA4) et les en-têtes serveur (User-Agent).
|
|
1021
|
+
* Cela permet de détecter le spoofing de fingerprint TLS.
|
|
1022
|
+
* @param {object} context - Le contexte de la requête.
|
|
1023
|
+
* @returns {{tlsSpoofingScore: number}}
|
|
1024
|
+
*/
|
|
1025
|
+
function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint) {
|
|
1026
|
+
let score = 0;
|
|
1027
|
+
const { ja3, ja4 } = getTlsFingerprintFn(context) || { ja3: null, ja4: null }; // Defensive check
|
|
1028
|
+
const ua = context.headers["user-agent"] || '';
|
|
1029
|
+
|
|
1030
|
+
// Si un fingerprint TLS est présent, mais le User-Agent est générique ou manquant.
|
|
1031
|
+
if ((ja3 || ja4) && (!ua || ua.length < 10 || ua.toLowerCase().includes('python') || ua.toLowerCase().includes('curl'))) {
|
|
1032
|
+
score += 50; // Forte suspicion
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Plus complexe: Comparer le navigateur/OS déduit du JA3/JA4 avec le User-Agent.
|
|
1036
|
+
// Ceci nécessiterait une base de données de JA3/JA4 connus ou une logique de parsing avancée.
|
|
1037
|
+
// Pour l'instant, une implémentation simplifiée:
|
|
1038
|
+
// Si JA3/JA4 est présent et le UA est un navigateur connu, mais ils ne correspondent pas.
|
|
1039
|
+
if (ja3 && ua) {
|
|
1040
|
+
// Exemple très simplifié: si JA3 est typique de Chrome, mais UA est Firefox.
|
|
1041
|
+
// Ceci est une heuristique et peut générer des faux positifs sans une base de données robuste.
|
|
1042
|
+
// JA3 de Chrome commence souvent par 'e' (TLS 1.3) ou 'd' (TLS 1.2)
|
|
1043
|
+
const isJa3Chrome = ja3.startsWith('e') || ja3.startsWith('d');
|
|
1044
|
+
const isUaChrome = ua.includes('Chrome') && !ua.includes('Edg');
|
|
1045
|
+
// JA3 de Firefox commence souvent par 'c' (TLS 1.3) ou 'b' (TLS 1.2)
|
|
1046
|
+
const isJa3Firefox = ja3.startsWith('c') || ja3.startsWith('b');
|
|
1047
|
+
const isUaFirefox = ua.includes('Firefox');
|
|
1048
|
+
|
|
1049
|
+
if ((isJa3Chrome && isUaFirefox) || (isJa3Firefox && isUaChrome)) {
|
|
1050
|
+
score += 80; // Très forte incohérence
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
// On pourrait ajouter des vérifications similaires pour JA4 si on avait une base de données de JA4.
|
|
1054
|
+
|
|
1055
|
+
return { tlsSpoofingScore: Math.min(100, score) };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Calcule un score basé sur la détection explicite de frameworks d'automatisation.
|
|
1061
|
+
* @param {object} context - Le contexte de la requête.
|
|
1062
|
+
* @returns {{botScore: number}}
|
|
1063
|
+
*/
|
|
1064
|
+
function getBotScore(context) {
|
|
1065
|
+
const clientFpString = context.headers['x-device-fingerprint'];
|
|
1066
|
+
if (!clientFpString) return { botScore: 0 };
|
|
1067
|
+
|
|
1068
|
+
try {
|
|
1069
|
+
const clientFpMap = new Map(clientFpString.split("|").map(part => part.split(":")));
|
|
1070
|
+
// Pénalité maximale si l'un des marqueurs d'automatisation est présent.
|
|
1071
|
+
if (clientFpMap.has('bot') || clientFpMap.has('cdp')) {
|
|
1072
|
+
return { botScore: 100 };
|
|
1073
|
+
}
|
|
1074
|
+
} catch (e) { /* Ignorer les erreurs de parsing */ }
|
|
1075
|
+
|
|
1076
|
+
return { botScore: 0 };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
815
1079
|
/**
|
|
816
1080
|
* Analyzes server-side request patterns for a given device to detect bot-like behavior.
|
|
817
1081
|
* This is a stateful check that looks for repetitive or unnaturally fast requests.
|
|
@@ -1150,6 +1414,12 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
1150
1414
|
// On appelle getHoneypotScore ici pour que son résultat soit inclus dans le vecteur.
|
|
1151
1415
|
const { honeypotScore } = getHoneypotScore(context, honeypotConfig);
|
|
1152
1416
|
|
|
1417
|
+
// NOUVEAU: On calcule le score de spoofing TLS.
|
|
1418
|
+
const { tlsSpoofingScore } = getTlsSpoofingScore(context);
|
|
1419
|
+
|
|
1420
|
+
// NOUVEAU: On appelle getBotScore pour détecter les marqueurs d'automatisation.
|
|
1421
|
+
const { botScore } = getBotScore(context);
|
|
1422
|
+
|
|
1153
1423
|
// NOUVEAU: On calcule le score d'incohérence temporelle.
|
|
1154
1424
|
const { timeInconsistencyScore } = getTimeInconsistencyScore(context, JSON.parse(context.headers['x-behavior-metrics'] || '{}'));
|
|
1155
1425
|
|
|
@@ -1168,7 +1438,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
1168
1438
|
deviceData.ips = new Set(deviceData.ips);
|
|
1169
1439
|
}
|
|
1170
1440
|
// Le vecteur de suspicion est maintenant complet.
|
|
1171
|
-
return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, requestPatternScore, crossLayerInconsistencyScore, timeInconsistencyScore };
|
|
1441
|
+
return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, botScore, requestPatternScore, crossLayerInconsistencyScore, timeInconsistencyScore, tlsSpoofingScore };
|
|
1172
1442
|
};
|
|
1173
1443
|
|
|
1174
1444
|
// A residential user can change networks (home, 4G, public wifi).
|
|
@@ -1489,7 +1759,9 @@ export class FingerprintEngine {
|
|
|
1489
1759
|
(suspicionVector.inconsistencyScore || 0) * (weights.inconsistencyScore || 0) +
|
|
1490
1760
|
(suspicionVector.honeypotScore || 0) * (weights.honeypotScore || 0) +
|
|
1491
1761
|
(suspicionVector.behaviorScore || 0) * (weights.behaviorScore || 0) +
|
|
1762
|
+
(suspicionVector.botScore || 0) * (weights.botScore || 0) + // Ajout du nouveau score
|
|
1492
1763
|
(suspicionVector.crossLayerInconsistencyScore || 0) * (weights.crossLayerInconsistencyScore || 0) +
|
|
1764
|
+
(suspicionVector.tlsSpoofingScore || 0) * (weights.tlsSpoofingScore || 0) + // NOUVEAU: TLS Spoofing
|
|
1493
1765
|
(suspicionVector.timeInconsistencyScore || 0) * (weights.timeInconsistencyScore || 0);
|
|
1494
1766
|
|
|
1495
1767
|
return Math.min(100, score);
|
|
@@ -1567,6 +1839,78 @@ export class FingerprintEngine {
|
|
|
1567
1839
|
_isIpInAllowlist(clientIp) {
|
|
1568
1840
|
return this._allowlist.check(clientIp);
|
|
1569
1841
|
}
|
|
1842
|
+
/**
|
|
1843
|
+
* Checks if the request's host and path match an entry in the host+path allowlist.
|
|
1844
|
+
* @private
|
|
1845
|
+
* @param {string} requestHost - The host from the request headers.
|
|
1846
|
+
* @param {string} requestPath - The path of the incoming request.
|
|
1847
|
+
* @returns {boolean} True if the combination is in the allowlist.
|
|
1848
|
+
*/
|
|
1849
|
+
_isHostPathInAllowlist(requestHost, requestPath) {
|
|
1850
|
+
const { whitelist = [] } = this.securityConfig;
|
|
1851
|
+
const hostPathRule = whitelist.find(rule => rule.type === 'host_path_allowlist');
|
|
1852
|
+
|
|
1853
|
+
if (!hostPathRule || !hostPathRule.entries || hostPathRule.entries.length === 0) {
|
|
1854
|
+
return false;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
for (const entry of hostPathRule.entries) {
|
|
1858
|
+
// Find the first slash to separate host and path
|
|
1859
|
+
const firstSlashIndex = entry.indexOf('/');
|
|
1860
|
+
if (firstSlashIndex === -1) continue; // Invalid entry
|
|
1861
|
+
|
|
1862
|
+
const hostPattern = entry.substring(0, firstSlashIndex);
|
|
1863
|
+
const pathPattern = entry.substring(firstSlashIndex);
|
|
1864
|
+
|
|
1865
|
+
// Check if the request host matches the host pattern
|
|
1866
|
+
if (requestHost !== hostPattern) {
|
|
1867
|
+
continue;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// Check if the request path matches the path pattern (with wildcard support)
|
|
1871
|
+
if (pathPattern.endsWith('*')) {
|
|
1872
|
+
const basePath = pathPattern.slice(0, -1);
|
|
1873
|
+
if (requestPath.startsWith(basePath)) {
|
|
1874
|
+
return true; // Wildcard match
|
|
1875
|
+
}
|
|
1876
|
+
} else if (requestPath === pathPattern) {
|
|
1877
|
+
return true; // Exact match
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
return false;
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* Checks if the request path matches any entry in the path allowlist.
|
|
1884
|
+
* Supports simple wildcards (*) at the end of a path.
|
|
1885
|
+
* @private
|
|
1886
|
+
* @param {string} requestPath - The path of the incoming request.
|
|
1887
|
+
* @returns {boolean} True if the path is in the allowlist.
|
|
1888
|
+
*/
|
|
1889
|
+
_isPathInAllowlist(requestPath) {
|
|
1890
|
+
const { whitelist = [] } = this.securityConfig;
|
|
1891
|
+
const pathAllowlistRule = whitelist.find(rule => rule.type === 'path_allowlist');
|
|
1892
|
+
|
|
1893
|
+
if (!pathAllowlistRule || !pathAllowlistRule.entries || pathAllowlistRule.entries.length === 0) {
|
|
1894
|
+
return false;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
for (const entry of pathAllowlistRule.entries) {
|
|
1898
|
+
if (entry.endsWith('*')) {
|
|
1899
|
+
// Handle wildcard matching
|
|
1900
|
+
const base = entry.slice(0, -1);
|
|
1901
|
+
if (requestPath.startsWith(base)) {
|
|
1902
|
+
return true;
|
|
1903
|
+
}
|
|
1904
|
+
} else {
|
|
1905
|
+
// Handle exact path matching
|
|
1906
|
+
if (requestPath === entry) {
|
|
1907
|
+
return true;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
return false;
|
|
1913
|
+
}
|
|
1570
1914
|
/**
|
|
1571
1915
|
* Verifies if a request comes from a legitimate, whitelisted bot (e.g., Googlebot)
|
|
1572
1916
|
* using reverse and forward DNS lookups. The result is cached.
|
|
@@ -1655,6 +1999,19 @@ export class FingerprintEngine {
|
|
|
1655
1999
|
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'hostname_allowlist' } };
|
|
1656
2000
|
}
|
|
1657
2001
|
|
|
2002
|
+
// 3. Check host+path based allowlist.
|
|
2003
|
+
const requestHost = requestContext.headers?.host;
|
|
2004
|
+
if (requestHost && this._isHostPathInAllowlist(requestHost, path)) {
|
|
2005
|
+
this._log('Host and path in allowlist - allowing request', { host: requestHost, path });
|
|
2006
|
+
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'host_path_allowlist' } };
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// 3. Check path-based allowlist.
|
|
2010
|
+
if (this._isPathInAllowlist(path)) {
|
|
2011
|
+
this._log('Path in allowlist - allowing request', { path });
|
|
2012
|
+
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'path_allowlist' } };
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1658
2015
|
const { pow_nonce } = query;
|
|
1659
2016
|
|
|
1660
2017
|
// Honeypot: Direct probing of challenge endpoints is highly suspicious.
|
|
@@ -1790,7 +2147,7 @@ export class FingerprintEngine {
|
|
|
1790
2147
|
// We compare the fingerprint of the request that triggered the challenge
|
|
1791
2148
|
// with the fingerprint of the request that is submitting the solution.
|
|
1792
2149
|
// They should be very similar.
|
|
1793
|
-
similarity = FingerprintBuilder.compare(originalFingerprint,
|
|
2150
|
+
similarity = FingerprintBuilder.compare(originalFingerprint, solverFingerprint);
|
|
1794
2151
|
}
|
|
1795
2152
|
|
|
1796
2153
|
if (similarity < similarityThreshold) {
|
|
@@ -2533,6 +2890,8 @@ export const __internal = {
|
|
|
2533
2890
|
getCrossLayerInconsistency, // Expose for testing
|
|
2534
2891
|
// Expose page generators for security testing
|
|
2535
2892
|
getTimeInconsistencyScore,
|
|
2893
|
+
getTlsFingerprint, // NOUVEAU: Expose pour les tests
|
|
2894
|
+
getTlsSpoofingScore, // NOUVEAU: Expose pour les tests
|
|
2536
2895
|
generateCpuTargetChallengePage,
|
|
2537
2896
|
generateCombinedPoWChallengePage,
|
|
2538
2897
|
};
|