@anonympins/fingerprint 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +35 -6
  2. package/fingerprint.js +150 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -13,7 +13,7 @@ This system identifies and slows down bots and automated scripts by evaluating t
13
13
 
14
14
  The process unfolds in three steps:
15
15
 
16
- 1. **Identification & Fingerprinting**: A unique fingerprint is generated for each device based on browser characteristics (client-side) and request headers (server-side). A `device_id` cookie is used to track the device over time.
16
+ 1. **Identification & Fingerprinting**: A unique fingerprint is generated for each device. This combines a client-side browser fingerprint, server-side request headers, and the **JA3 fingerprint** from the TLS handshake, which reliably identifies the underlying HTTP client library (e.g., Chrome vs. a Python script). A `device_id` cookie is used to track the device over time.
17
17
  2. **Suspicion Score Calculation**: Several indicators are analyzed to calculate a suspicion score:
18
18
  * **Header Anomalies**: Missing `User-Agent`, `Accept-Language`, etc.
19
19
  * **Device Behavior**: Rapid fingerprint changes (User-Agent rotation).
@@ -104,7 +104,33 @@ const securityConfig = {
104
104
  // A request to one of these paths will immediately flag the device as malicious.
105
105
  trapUrls: ['/wp-admin', '/.env', '/admin.php', '/phpmyadmin'], // (Optional)
106
106
  // Automatically detect common SQL/NoSQL injection and RCE patterns in request values. (Optional, default: true)
107
- detectInjections: true
107
+ detectInjections: true,
108
+ // (Optional) Plug in external, more robust analyzers. This allows you to extend the default detection with specialized libraries (e.g., WAFs, anti-spam) or your own custom logic.
109
+ // Each function receives an object with all query and body data and should return `true` if a threat is detected.
110
+ analyzers: [
111
+ // Example 1: Using a general-purpose WAF library.
112
+ // (npm install generic-waf)
113
+ (data) => {
114
+ const WAF = require('generic-waf');
115
+ const waf = new WAF();
116
+ // This WAF expects a string, so we stringify the data to check all values at once.
117
+ return waf.isMalicious(JSON.stringify(data));
118
+ },
119
+ // Example 2: Using a specialized library for XSS detection.
120
+ // (npm install xss)
121
+ (data) => {
122
+ const xss = require('xss');
123
+ const originalData = JSON.stringify(data);
124
+ // If the sanitized string is different from the original, it means malicious HTML/JS was found and removed.
125
+ return xss(originalData) !== originalData;
126
+ },
127
+ // Example 3: A custom function to detect specific keywords (e.g., for anti-spam).
128
+ (data) => {
129
+ const spamKeywords = ['viagra', 'free money', 'crypto pump'];
130
+ const dataString = JSON.stringify(data).toLowerCase();
131
+ return spamKeywords.some(keyword => dataString.includes(keyword));
132
+ }
133
+ ]
108
134
  },
109
135
  // The logger is required for auto-tuning. It collects data on requests.
110
136
  logger: (log) => trafficData.push(log),
@@ -152,6 +178,8 @@ The main Express middleware. It orchestrates identification, suspicion calculati
152
178
  #### `configureStore(store)`
153
179
  Allows replacing the in-memory store with an external datastore (like Redis) for persistence and scaling.
154
180
 
181
+ See the Datastore Integration Guide for a complete example of creating a Redis store.
182
+
155
183
  ```javascript
156
184
  import { configureStore } from './fingerprint.js';
157
185
  import { createRedisStore } from './redis-store.js'; // Assuming a redis store implementation exists
@@ -234,22 +262,23 @@ Although not exported for direct public use, understanding its role can be usefu
234
262
 
235
263
  While `powMiddleware` is convenient for Express, you can use the `FingerprintEngine` directly in any Node.js server environment (e.g., native `http`, Fastify, Koa). This gives you full control over the request/response cycle.
236
264
 
237
- The engine is available via the internal exports: `import { __internal } from './fingerprint.js'`.
265
+ **For concrete examples with Koa and Fastify, see our Framework Integration Guide.**
266
+
267
+ The engine is a named export from the main module.
238
268
 
239
269
  **Workflow:**
240
270
 
241
271
  1. **Instantiate the Engine**: Create an instance with your `securityConfig`.
242
272
  2. **Build the `requestContext`**: On each request, manually create a context object. It must include `clientIp`, `path`, `cookies`, `query`, `headers`, and mock `rawReq`/`rawRes` objects for cookie handling.
243
- 3. **Process the Request**: Call `engine.processRequest(requestContext)`.
273
+ 3. **Process the Request**: Call `engine.processRequest(requestContext)`. This method is asynchronous.
244
274
  4. **Handle the Decision**: The engine returns a decision object (`{ action: 'challenge' | 'redirect' | 'next', ... }`). You are responsible for implementing the corresponding HTTP response.
245
275
 
246
276
  **Example with native Node.js `http` server:**
247
277
 
248
278
  ```javascript
249
279
  import http from 'http';
250
- import { __internal } from './fingerprint.js'; // Adjust path
280
+ import { FingerprintEngine } from './fingerprint.js'; // Adjust path
251
281
 
252
- const { FingerprintEngine } = __internal;
253
282
  const securityConfig = { /* ... your config ... */ };
254
283
  const engine = new FingerprintEngine(securityConfig);
255
284
 
package/fingerprint.js CHANGED
@@ -15,6 +15,50 @@ const getPowSecret = () => {
15
15
  return secret || "fallback-dev-secret-32-chars-minimum";
16
16
  };
17
17
 
18
+ /**
19
+ * Calculates the JA3 fingerprint hash from the TLS Client Hello message.
20
+ * JA3 is a more reliable way to identify client applications (e.g., a specific browser or a script)
21
+ * based on the specifics of its TLS handshake.
22
+ * @param {object} context - The request context, containing the raw request object.
23
+ * @returns {string|null} The MD5 hash of the JA3 string, or null if it cannot be computed.
24
+ */
25
+ function getJa3Hash(context) {
26
+ // 1. Prefer the JA3 hash from a trusted reverse proxy (e.g., Nginx, Cloudflare).
27
+ const ja3FromHeader = context.headers['x-ja3-hash'];
28
+ if (ja3FromHeader) {
29
+ return ja3FromHeader;
30
+ }
31
+
32
+ // 2. Fallback to calculating from the raw socket if available (requires Node.js to handle TLS).
33
+ const clientHello = context.rawReq?.socket?.clientHello;
34
+ if (!clientHello) {
35
+ return null;
36
+ }
37
+
38
+ try {
39
+ const { version, ciphers, extensions, ellipticCurves, ellipticCurvePointFormats } = clientHello;
40
+
41
+ // The official JA3 spec includes the TLS version.
42
+ // Node.js provides it as a string like 'TLSv1.3', we need the corresponding decimal value.
43
+ const tlsVersionMap = {
44
+ 'TLSv1': 769, 'TLSv1.1': 770, 'TLSv1.2': 771, 'TLSv1.3': 772
45
+ };
46
+ const tlsVersionId = tlsVersionMap[version] || 0;
47
+
48
+ const ja3String = [
49
+ tlsVersionId,
50
+ // The ciphers array from clientHello is an array of objects, not just IDs.
51
+ Array.isArray(ciphers) ? ciphers.join('-') : '',
52
+ extensions?.join('-') || '',
53
+ ellipticCurves?.join('-') || '',
54
+ ellipticCurvePointFormats?.join('-') || ''
55
+ ].join(',');
56
+
57
+ return crypto.createHash('md5').update(ja3String).digest('hex');
58
+ } catch (e) {
59
+ return null; // Could fail if clientHello structure is unexpected.
60
+ }
61
+ }
18
62
  /**
19
63
  * Creates a stable hash based on device characteristics, independent of the IP.
20
64
  * This is our "level 2 fingerprint".
@@ -43,6 +87,10 @@ export function getDeviceHash(context) {
43
87
  if (context.headers["sec-ch-ua-platform"])
44
88
  srv.add("os", context.headers["sec-ch-ua-platform"]);
45
89
  if (context.headers["sec-ch-ua"]) srv.add("ch", context.headers["sec-ch-ua"]);
90
+ // Add JA3 hash if available. This is a very strong signal.
91
+ const ja3 = getJa3Hash(context);
92
+ if (ja3) srv.add("ja3", ja3);
93
+
46
94
  srv.add("h_ord", getHeaderSignature(context));
47
95
  return srv.toString();
48
96
  }
@@ -406,6 +454,14 @@ function getHeaderAnomalies(context) {
406
454
  */
407
455
  function getHoneypotScore(context, honeypotConfig = {}) {
408
456
  const { fields = [], trapUrls = [], detectInjections = true } = honeypotConfig;
457
+ // (NOUVEAU) Permettre de brancher des analyseurs externes plus robustes.
458
+ // L'utilisateur pourrait passer une fonction qui prend les données de la requête
459
+ // et retourne `true` si une menace est détectée.
460
+ // Exemple: `(data) => myWafLibrary.isMalicious(data)`
461
+ const externalAnalyzers = honeypotConfig.analyzers || [];
462
+ if (typeof detectInjections === 'object' && detectInjections.analyzers) {
463
+ externalAnalyzers.push(...detectInjections.analyzers);
464
+ }
409
465
 
410
466
  // 1. Check for trap URL access
411
467
  if (trapUrls.some(trap => context.path.startsWith(trap))) {
@@ -434,6 +490,17 @@ function getHoneypotScore(context, honeypotConfig = {}) {
434
490
  }
435
491
  }
436
492
 
493
+ // 3. (NOUVEAU) Utiliser les analyseurs externes
494
+ const allData = { ...queryData, ...bodyData };
495
+ if (externalAnalyzers.length > 0) {
496
+ for (const analyzer of externalAnalyzers) {
497
+ // On passe à l'analyseur l'ensemble des données de la requête.
498
+ if (analyzer(allData)) {
499
+ return { honeypotScore: 100 };
500
+ }
501
+ }
502
+ }
503
+
437
504
  // 3. Check for injection attempts in values
438
505
  if (detectInjections) {
439
506
  // Regex for common SQL injection patterns
@@ -453,13 +520,19 @@ function getHoneypotScore(context, honeypotConfig = {}) {
453
520
  "(\\.\\./|\\.\\.\\\\)|\\b(exec|system|shell_exec|passthru|popen|proc_open|eval|assert|require|include|process|child_process)(_once)?\\s*\\(|\\b(wget|curl|bash|sh|powershell|php)\\b",
454
521
  "i"
455
522
  );
523
+ // Regex for Log4Shell (JNDI injection)
524
+ const log4shellRegex = new RegExp("\\$\\{jndi:", "i");
525
+ // Regex for Server-Side Template Injection (SSTI)
526
+ const sstiRegex = new RegExp(
527
+ "(\\{\\{|\\{%|#\\{)[^}]+(config|settings|self|class|application|request|session|process|env)", "i"
528
+ );
456
529
 
457
530
  const inspect = (obj) => {
458
531
  for (const key in obj) {
459
532
  if (Object.prototype.hasOwnProperty.call(obj, key)) {
460
533
  const value = obj[key];
461
534
  if (typeof value === 'string') {
462
- if (rceRegex.test(value) || sqlRegex.test(value)) return true;
535
+ if (rceRegex.test(value) || sqlRegex.test(value) || log4shellRegex.test(value) || sstiRegex.test(value)) return true;
463
536
  } else if (typeof value === 'object' && value !== null) {
464
537
  // For NoSQL, we check the stringified version of the object to find keys like "$gt"
465
538
  // This is more accurate when done on the object itself.
@@ -499,7 +572,9 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
499
572
  scrapeThreshold = 1000, scrapeWeight = 20, scrapeBurstWeight = 40,
500
573
  historySize = 10,
501
574
  decayFactor = 0.9,
502
- inactivityReset = 30000
575
+ inactivityReset = 30000,
576
+ // Nouveau paramètre pour la détection de séquences
577
+ sequenceLength = 3, sequenceWeight = 60
503
578
  } = patternConfig;
504
579
 
505
580
  const now = Date.now();
@@ -543,6 +618,17 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
543
618
  score += scrapeWeight; // First sign of a potential scraping pattern
544
619
  }
545
620
  }
621
+
622
+ // 4. (NOUVEAU) Détection de séquences répétitives (ex: A -> B -> C -> A -> B -> C)
623
+ if (history.length >= sequenceLength * 2) {
624
+ const lastSequence = history.slice(-sequenceLength);
625
+ const previousSequence = history.slice(-sequenceLength * 2, -sequenceLength);
626
+
627
+ const isRepeating = lastSequence.every((req, i) =>
628
+ req.path === previousSequence[i].path && req.queryString === previousSequence[i].queryString
629
+ );
630
+ if (isRepeating) score += sequenceWeight;
631
+ }
546
632
  }
547
633
 
548
634
  // --- Update history ---
@@ -691,6 +777,8 @@ async function resolveRequestIdentity(context) {
691
777
  lastFpHash: currentDeviceHash,
692
778
  lastChangeTimestamp: 0,
693
779
  rapidChangeCount: 0,
780
+ highScoreCount: 0,
781
+ lastHighScoreTimestamp: 0,
694
782
  };
695
783
  // The write will happen in getSuspicionVector after all modifications.
696
784
  }
@@ -790,6 +878,11 @@ export const getSuspicionVector = async (context, securityConfig) => {
790
878
  // Note: deviceData.ips is a Set, which may not serialize correctly in all stores (e.g., JSON). A Redis store should handle this via custom serialization or by converting to an array.
791
879
  await store.set(`device:${deviceId}`, deviceData);
792
880
 
881
+ // Ensure deviceData.ips is a Set for subsequent operations within the same request,
882
+ // even if the store returns an array.
883
+ if (Array.isArray(deviceData.ips)) {
884
+ deviceData.ips = new Set(deviceData.ips);
885
+ }
793
886
  return { ...behavioral, headerAnomalyScore, inconsistencyScore };
794
887
  };
795
888
 
@@ -1024,7 +1117,7 @@ const staticExtensions = new RegExp(
1024
1117
  const isStaticResource = (path) => staticExtensions.test(path);
1025
1118
 
1026
1119
  // --- Middleware Proof-of-Work (Le péage) ---
1027
- class FingerprintEngine {
1120
+ export class FingerprintEngine {
1028
1121
  constructor(securityConfig) {
1029
1122
  const isProduction = process.env.NODE_ENV === 'production';
1030
1123
  this.securityConfig = securityConfig;
@@ -1061,6 +1154,9 @@ class FingerprintEngine {
1061
1154
  return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1062
1155
  }
1063
1156
 
1157
+ // (NOUVEAU) Vérifier si un nouveau device_id a été créé lors de cette requête
1158
+ const isNewDevice = requestContext._newCookies?.some(c => c.name === 'device_id');
1159
+
1064
1160
  // The engine now works with the context directly, no more rawReq dependency here.
1065
1161
  const suspicionVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
1066
1162
  const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
@@ -1075,6 +1171,11 @@ class FingerprintEngine {
1075
1171
  suspicionVector.inconsistencyScore * (weights.inconsistencyScore || 0) +
1076
1172
  honeypotScore * (weights.honeypotScore || 0);
1077
1173
 
1174
+ // Si c'est un nouvel appareil, on lui impose un challenge de base, même si son score est bas.
1175
+ // Cela augmente le coût pour les bots qui tentent de simplement supprimer leurs cookies.
1176
+ const requiresChallengeForNewDevice = isNewDevice && finalScore < thresholds.low;
1177
+
1178
+
1078
1179
  const isBlocked = finalScore >= (thresholds.block || 95);
1079
1180
 
1080
1181
  const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
@@ -1119,11 +1220,14 @@ class FingerprintEngine {
1119
1220
  if (onDeviceCompromised) {
1120
1221
  onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Triggered signed honeypot trap URL', score: 100, vector: { honeypotScore: 100 } });
1121
1222
  }
1223
+ if (logger) {
1224
+ logger({ type: 'trap_triggered', deviceId: cookies?.device_id, score: 100, path: path, timestamp: Date.now() });
1225
+ }
1122
1226
  await store.set(`device:${cookies.device_id}`, deviceData);
1123
1227
  return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1124
1228
  }
1125
1229
 
1126
- if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
1230
+ if ((isSuspicious || requiresChallengeForNewDevice) && !isTicketValid(clientIp, powCookie)) {
1127
1231
  // --- CHALLENGE SOLUTION HANDLING ---
1128
1232
  if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
1129
1233
  let isValid = false,
@@ -1219,7 +1323,7 @@ class FingerprintEngine {
1219
1323
  }
1220
1324
 
1221
1325
  // UNIFIED CHALLENGE LOGIC for all suspicion levels (low, medium, high)
1222
- if (isSuspicious) { // Couvre à la fois low et medium
1326
+ if (isSuspicious || requiresChallengeForNewDevice) { // Couvre à la fois low et medium
1223
1327
  // Generate some trap URLs to embed in the challenge page.
1224
1328
  // These links are visually hidden but present in the DOM to trap bots.
1225
1329
  const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce));
@@ -1328,6 +1432,8 @@ export const powMiddleware = (securityConfig) => {
1328
1432
  isStatic: isStaticResource(req.path),
1329
1433
  // Add the newly required properties for full decoupling
1330
1434
  rawHeaders: req.rawHeaders,
1435
+ // Pass the raw request object for advanced inspection (e.g., JA3)
1436
+ rawReq: req,
1331
1437
  httpVersion: req.httpVersion,
1332
1438
  };
1333
1439
 
@@ -1375,7 +1481,6 @@ export const __internal = {
1375
1481
  cyrb53, // Export for testing
1376
1482
  FingerprintBuilder, // Export for testing
1377
1483
  calculateTarget,
1378
- FingerprintEngine, // Expose for advanced testing
1379
1484
  getRequestPatternScore, // Expose for testing
1380
1485
  };
1381
1486
 
@@ -1397,38 +1502,60 @@ function runThresholdOptimization(securityConfig, trafficData, minDataPoints) {
1397
1502
  }
1398
1503
  console.log(`[AutoTuning] Démarrage du cycle d'optimisation avec ${trafficData.length} points de données.`);
1399
1504
 
1400
- // Identify "bots" (those who received a challenge but never solved it)
1401
- // and "humans" (those who passed the challenge or never received one).
1505
+ // Classify historical requests with a confidence weight.
1402
1506
  const solvedDevices = new Set(trafficData.filter(e => e.type === 'challenge_solved').map(e => e.deviceId));
1507
+ const challengedDevices = new Set(trafficData.filter(e => e.type === 'challenge_issued').map(e => e.deviceId));
1508
+
1403
1509
  const historicalRequests = trafficData.map(log => {
1404
- let isBot = false;
1405
- if (log.type === 'challenge_issued' && !solvedDevices.has(log.deviceId)) {
1406
- isBot = true; // Assumption: a challenge issued and not solved is a bot.
1510
+ // Assign a label ('bot' or 'human') and a confidence weight to each log entry.
1511
+ switch (log.type) {
1512
+ case 'honeypot_probe':
1513
+ case 'trap_triggered':
1514
+ return { score: log.score, label: 'bot', confidence: 10.0 }; // Very high confidence
1515
+
1516
+ case 'challenge_issued':
1517
+ // A challenge issued to a device that never solved it is a strong bot signal.
1518
+ if (!solvedDevices.has(log.deviceId)) {
1519
+ return { score: log.score, label: 'bot', confidence: 3.0 }; // High confidence
1520
+ }
1521
+ // If the challenge was eventually solved, this specific log is neutral.
1522
+ return null;
1523
+
1524
+ case 'challenge_solved':
1525
+ return { score: log.score, label: 'human', confidence: 5.0 }; // High confidence
1526
+
1527
+ case 'request_passed':
1528
+ // A passed request from a device that was never even challenged is likely a human.
1529
+ if (!challengedDevices.has(log.deviceId)) {
1530
+ return { score: log.score, label: 'human', confidence: 0.5 }; // Low confidence
1531
+ }
1532
+ // If the device was challenged at some point, this log is ambiguous.
1533
+ return null;
1534
+
1535
+ default:
1536
+ return null;
1407
1537
  }
1408
- return { score: log.score, isBot };
1409
- });
1538
+ }).filter(Boolean); // Remove null entries
1410
1539
 
1411
1540
  // The "fitness" function evaluates the quality of a set of thresholds.
1412
1541
  // A lower score is better.
1413
1542
  const fitnessFunction = (solution) => {
1414
1543
  const [low, medium, high, velocityThreshold, burstThreshold, scrapeThreshold] = solution;
1415
- // Constraints: thresholds must be ordered and within a reasonable range.
1416
1544
  if (low >= medium || medium >= high || low < 10 || high > 90) return Infinity;
1417
- // Constraints for pattern thresholds
1418
1545
  if (velocityThreshold < 50 || velocityThreshold > burstThreshold || burstThreshold > scrapeThreshold) return Infinity;
1419
1546
 
1420
- let falsePositives = 0; // Humans challenged unnecessarily.
1421
- let falseNegatives = 0; // Undetected bots.
1547
+ let weightedFalsePositives = 0; // Humans challenged unnecessarily.
1548
+ let weightedFalseNegatives = 0; // Undetected bots.
1422
1549
 
1423
1550
  for (const req of historicalRequests) {
1424
- if (req.isBot) {
1425
- if (req.score < low) falseNegatives++;
1426
- } else { // Human
1427
- if (req.score >= low) falsePositives++;
1551
+ if (req.label === 'bot') {
1552
+ if (req.score < low) weightedFalseNegatives += req.confidence;
1553
+ } else { // 'human'
1554
+ if (req.score >= low) weightedFalsePositives += req.confidence;
1428
1555
  }
1429
1556
  }
1430
- // Penalize passing bots 2x more than inconvenienced humans.
1431
- return (falsePositives * 1.0) + (falseNegatives * 2.0);
1557
+ // The penalty for false negatives is implicitly higher due to the higher confidence scores of bot signals.
1558
+ return weightedFalsePositives + weightedFalseNegatives;
1432
1559
  };
1433
1560
 
1434
1561
  // Functions for the genetic algorithm.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anonympins/fingerprint",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "An HTTP(S) client mitigation and anti-bot protection library for Node.js/Express, based on digital fingerprinting and dynamic Proof-of-Work (PoW) challenges.",
5
5
  "main": "fingerprint.js",
6
6
  "type": "module",