@anonympins/fingerprint 0.0.4 → 0.0.6

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 +40 -40
  2. package/fingerprint.js +350 -32
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -19,6 +19,8 @@ The process unfolds in three steps:
19
19
  * **Device Behavior**: Rapid fingerprint changes (User-Agent rotation).
20
20
  * **IP Behavior**: An excessive number of different devices seen from the same IP, or a single device using a large number of IPs (proxy rotation).
21
21
  * **Inconsistency**: A low similarity score between the current fingerprint and the initial one associated with the `device_id` (cookie theft detection).
22
+ * **Request Patterns**: Repetitive, rapid-fire, or sequential requests typical of scraping bots. The parameters for detecting these patterns (e.g., request velocity, burst detection) are dynamically adjusted by the auto-tuner for optimal performance.
23
+ * **Honeypot Trap**: Detection of bots that automatically fill hidden form fields or probe for common but unused URL parameters (e.g., `?debug=true`).
22
24
  3. **Dynamic Challenge**: If the suspicion score exceeds a certain threshold, a challenge is presented to the user. The difficulty and type of challenge depend on the score:
23
25
  * **Low to Medium Suspicion**: A combined CPU and Memory Proof-of-Work (PoW) challenge is issued. The difficulty of both the CPU (hash calculation) and Memory (allocation and computation) components scales progressively with the suspicion score. For low scores, the memory challenge is negligible, making it primarily a CPU task.
24
26
  * **High Suspicion**: For the most suspicious requests, the system issues a high-difficulty combined CPU/Memory challenge. The architecture allows for plugging in more complex challenges like CAPTCHAs if needed.
@@ -32,7 +34,7 @@ Once the challenge is solved, a clearance "ticket" is issued via a secure cookie
32
34
  - **Pluggable Datastore**: Supports external datastores like Redis for state persistence and scalability across multiple server instances.
33
35
  - **Express.js Middleware**: Easy integration into an Express application with `powMiddleware`. The datastore must support setting a Time-To-Live (TTL) for challenge secrets.
34
36
  - **Timing Attack Protection**: Uses `crypto.timingSafeEqual` for secure ticket validation.
35
- - **Automatic Threshold Tuning**: Includes a genetic algorithm-based optimizer (`startThresholdAutoTuning`) that analyzes real traffic to dynamically adjust suspicion thresholds (`low`, `medium`, `high`), improving bot detection accuracy and reducing false positives over time.
37
+ - **Automatic Parameter Tuning**: Includes a genetic algorithm-based optimizer (`startThresholdAutoTuning`) that analyzes real traffic to dynamically adjust not only suspicion thresholds (`low`, `medium`, `high`) but also the parameters for behavioral pattern detection, improving accuracy and reducing false positives over time.
36
38
 
37
39
  ## Installation and Usage
38
40
 
@@ -40,7 +42,7 @@ This module is designed for a Node.js environment.
40
42
 
41
43
  ### Prerequisites
42
44
 
43
- Ensure you have a cookie-parser middleware (like `cookie-parser`) set up in your Express application.
45
+ Ensure you have middleware for parsing cookies (like `cookie-parser`) and request bodies (like `express.json` and `express.urlencoded`) set up in your Express application *before* the `powMiddleware`.
44
46
 
45
47
  ### Configuration
46
48
 
@@ -56,11 +58,18 @@ The `powMiddleware` requires a configuration object defining the weights of susp
56
58
 
57
59
  ```javascript
58
60
  import express from 'express';
61
+ import bodyParser from 'body-parser';
59
62
  import cookieParser from 'cookie-parser';
60
63
  import { powMiddleware /*, configurePow */ } from './fingerprint.js'; // Adjust the path
61
64
 
62
65
  const app = express();
63
66
  app.use(cookieParser());
67
+ app.use(bodyParser.json()); // For parsing application/json
68
+ app.use(bodyParser.urlencoded({ extended: true })); // For parsing application/x-www-form-urlencoded
69
+
70
+ // Array to store traffic analysis data for the auto-tuner.
71
+ // In a real application, this could be a more robust logging system (e.g., writing to a file or a database).
72
+ const trafficData = [];
64
73
 
65
74
  // Configuration of weights and thresholds for calculating the suspicion score.
66
75
  // These values should be adjusted based on traffic and expected user behavior.
@@ -69,7 +78,9 @@ const securityConfig = {
69
78
  historyScore: 0.3, // Penalizes IP rotation (proxy)
70
79
  rotationScore: 0.5, // Penalizes rapid fingerprint changes (user-agent, etc.)
71
80
  headerAnomalyScore: 0.1, // Penalizes abnormal headers (missing UA, etc.)
72
- inconsistencyScore: 0.8 // Strongly penalizes inconsistency between the current and initial fingerprint (stolen cookie)
81
+ requestPatternScore: 0.6,// Penalizes bot-like request sequences (scraping, etc.)
82
+ inconsistencyScore: 0.8, // Strongly penalizes inconsistency between the current and initial fingerprint (stolen cookie)
83
+ honeypotScore: 1.0 // Strongly penalizes bots filling hidden form fields
73
84
  },
74
85
  thresholds: {
75
86
  low: 20, // Score from which a CPU challenge is issued
@@ -77,7 +88,32 @@ const securityConfig = {
77
88
  high: 75, // Score for a very difficult challenge
78
89
  block: 95, // Score above which the request is blocked outright (HTTP 403)
79
90
  isStaticResource: (req) => req.path.startsWith('/static/') // Optional: Custom function to identify static resources
80
- }
91
+ },
92
+ patterns: { // (Optional) Initial values for request pattern detection, optimized by auto-tuner if enabled.
93
+ velocityThreshold: 200, // ms between requests to be considered "fast"
94
+ burstThreshold: 500, // ms for identical requests to be a "burst"
95
+ scrapeThreshold: 1000, // ms for sequential requests to be "scraping"
96
+ historySize: 10, // Number of requests to keep for pattern analysis
97
+ decayFactor: 0.9, // How quickly the pattern score decays over time
98
+ },
99
+ honeypot: {
100
+ // List of field names that are traps for bots.
101
+ // These should be hidden in forms for humans, or be URL parameters your app never uses.
102
+ fields: ['email_confirm', 'user_nickname', 'debug', 'test_mode', 'admin'], // (Optional)
103
+ // List of URL paths that should never be accessed by a legitimate user.
104
+ // A request to one of these paths will immediately flag the device as malicious.
105
+ trapUrls: ['/wp-admin', '/.env', '/admin.php', '/phpmyadmin'], // (Optional)
106
+ // Automatically detect common SQL/NoSQL injection and RCE patterns in request values. (Optional, default: true)
107
+ detectInjections: true
108
+ },
109
+ // The logger is required for auto-tuning. It collects data on requests.
110
+ logger: (log) => trafficData.push(log),
111
+ // (Optional) Configuration for the automatic threshold and pattern tuning.
112
+ autotuning: {
113
+ trafficData: trafficData, // The data source for the genetic algorithm.
114
+ interval: 1800000, // Optimization cycle every 30 minutes (in ms).
115
+ minDataPoints: 200 // Minimum requests before starting an optimization cycle.
116
+ },
81
117
  };
82
118
 
83
119
  // Create an instance of the middleware with your security configuration.
@@ -253,42 +289,6 @@ const server = http.createServer(async (req, res) => {
253
289
  server.listen(3000, () => console.log('Server with manual fingerprint engine started on port 3000'));
254
290
  ```
255
291
 
256
- ### Automatic Threshold Tuning
257
-
258
- Manually setting the `low`, `medium`, and `high` thresholds can be challenging. This library provides a powerful tool to automate this process based on real traffic data. It uses a genetic algorithm to find the optimal thresholds that maximize bot detection while minimizing the impact on legitimate users.
259
-
260
- #### How to use it:
261
-
262
- 1. **Enable Logging**: The auto-tuner needs data. You must provide a `logger` function in your security configuration. This function will be called for significant events (`challenge_issued`, `challenge_solved`, etc.).
263
-
264
- 2. **Enable Auto-tuning**: Add an `autotuning` property to your security configuration. The middleware will automatically start the tuning process.
265
-
266
- ```javascript
267
- import { powMiddleware } from './fingerprint.js';
268
-
269
- // Array to store traffic analysis data. In a real application, this could be
270
- // a more robust logging system.
271
- const trafficData = [];
272
-
273
- const securityConfig = {
274
- weights: { /* ... */ },
275
- thresholds: {
276
- low: 20, // Initial values, will be optimized
277
- medium: 45,
278
- high: 75
279
- },
280
- logger: (log) => trafficData.push(log), // The logger is required for auto-tuning
281
- autotune: {
282
- trafficData: trafficData, // The data source for the algorithm
283
- interval: 1800000, // Optimization cycle every 30 minutes (optional)
284
- minDataPoints: 200 // Minimum requests before starting optimization (optional)
285
- }
286
- };
287
-
288
- const powMiddlewareInstance = powMiddleware(securityConfig);
289
- app.use(powMiddlewareInstance);
290
- ```
291
-
292
292
  ---
293
293
 
294
294
  ## License
package/fingerprint.js CHANGED
@@ -398,6 +398,219 @@ function getHeaderAnomalies(context) {
398
398
  };
399
399
  }
400
400
 
401
+ /**
402
+ * Checks for submitted honeypot fields to detect bots.
403
+ * @param {object} context - The request context.
404
+ * @param {object} honeypotConfig - The honeypot configuration.
405
+ * @returns {{honeypotScore: number}}
406
+ */
407
+ function getHoneypotScore(context, honeypotConfig = {}) {
408
+ const { fields = [], trapUrls = [], detectInjections = true } = honeypotConfig;
409
+
410
+ // 1. Check for trap URL access
411
+ if (trapUrls.some(trap => context.path.startsWith(trap))) {
412
+ return { honeypotScore: 100 };
413
+ }
414
+
415
+ if (fields.length === 0 && !detectInjections) {
416
+ return { honeypotScore: 0 };
417
+ }
418
+
419
+ // Check both query parameters (for URL probing) and the request body (for hidden form fields).
420
+ const queryData =
421
+ context.query instanceof URLSearchParams
422
+ ? Object.fromEntries(context.query.entries())
423
+ : context.query || {};
424
+ const bodyData = context.body || {};
425
+
426
+ // 2. Check for honeypot field names
427
+ for (const field of fields) {
428
+ // A bot is trapped if the field exists in either the query OR the body.
429
+ if (
430
+ Object.prototype.hasOwnProperty.call(queryData, field) ||
431
+ Object.prototype.hasOwnProperty.call(bodyData, field)
432
+ ) {
433
+ return { honeypotScore: 100 }; // A bot fell into the trap, maximum score.
434
+ }
435
+ }
436
+
437
+ // 3. Check for injection attempts in values
438
+ if (detectInjections) {
439
+ // Regex for common SQL injection patterns
440
+ // WARNING: These are generic and may cause false positives.
441
+ // Consider using a dedicated WAF library or more specific regex for your application.
442
+ const sqlRegex = new RegExp(
443
+ "('|\"|;|--|#|/\\*.*\\*/)|\\b(union|select|insert|update|delete|drop|truncate|from|where|and|or)\\b",
444
+ "i"
445
+ );
446
+ // Regex for common NoSQL (MongoDB) injection patterns (e.g., keys starting with '$')
447
+ // This looks for keys like "$where", "$ne", etc. in a stringified JSON.
448
+ const nosqlKeyRegex = /"\$(where|ne|gt|lt|in|nin)":/;
449
+ // Regex for common Remote Code Execution (RCE) patterns
450
+ const rceRegex = new RegExp(
451
+ // File traversal, command execution functions, and shell commands
452
+ // Added process, child_process to catch Node.js specific RCE.
453
+ "(\\.\\./|\\.\\.\\\\)|\\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
+ "i"
455
+ );
456
+
457
+ const inspect = (obj) => {
458
+ for (const key in obj) {
459
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
460
+ const value = obj[key];
461
+ if (typeof value === 'string') {
462
+ if (rceRegex.test(value) || sqlRegex.test(value)) return true;
463
+ } else if (typeof value === 'object' && value !== null) {
464
+ // For NoSQL, we check the stringified version of the object to find keys like "$gt"
465
+ // This is more accurate when done on the object itself.
466
+ if (nosqlKeyRegex.test(JSON.stringify(value))) return true;
467
+ if (inspect(value)) return true;
468
+ }
469
+ }
470
+ }
471
+ return false;
472
+ };
473
+
474
+ if (inspect(queryData)) {
475
+ return { honeypotScore: 100 };
476
+ }
477
+ if (inspect(bodyData)) {
478
+ return { honeypotScore: 100 };
479
+ }
480
+ }
481
+
482
+ return { honeypotScore: 0 };
483
+ }
484
+
485
+ /**
486
+ * Analyzes server-side request patterns for a given device to detect bot-like behavior.
487
+ * This is a stateful check that looks for repetitive or unnaturally fast requests.
488
+ * @param {object} context - The request context.
489
+ * @param {object} deviceData - The device's activity data from the store.
490
+ * @returns {{requestPatternScore: number}}
491
+ */
492
+ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
493
+ if (!deviceData) return { requestPatternScore: 0 };
494
+
495
+ // Default values for the pattern detection logic, which can be overridden by the auto-tuner.
496
+ const {
497
+ velocityThreshold = 200, velocityWeight = 30,
498
+ burstThreshold = 500, burstWeight = 50,
499
+ scrapeThreshold = 1000, scrapeWeight = 20, scrapeBurstWeight = 40,
500
+ historySize = 10,
501
+ decayFactor = 0.9,
502
+ inactivityReset = 30000
503
+ } = patternConfig;
504
+
505
+ const now = Date.now();
506
+ const currentPath = context.path;
507
+ // Make the function robust to handle both URLSearchParams and plain objects for query.
508
+ // Ensure query parameters are consistently handled, whether they come from a URLSearchParams object or a plain object.
509
+ const params = context.query instanceof URLSearchParams ? context.query : new URLSearchParams(context.query);
510
+ params.sort(); // Sort for deterministic order
511
+ const currentQueryString = params.toString();
512
+
513
+ // Initialize request history if it doesn't exist
514
+ if (!deviceData.requestHistory) {
515
+ deviceData.requestHistory = [];
516
+ }
517
+
518
+ const history = deviceData.requestHistory;
519
+ let score = 0;
520
+
521
+ // --- Analyze patterns based on the last few requests ---
522
+ if (history.length > 0) {
523
+ const lastRequest = history[history.length - 1];
524
+ const timeSinceLast = now - lastRequest.timestamp; // 150
525
+
526
+ // 1. Velocity Check: Penalize requests that are too fast to be human.
527
+ if (timeSinceLast < velocityThreshold) { // 150 < 200 -> true
528
+ score += velocityWeight; // score = 30
529
+ }
530
+
531
+ // 2. Burst Check: Add additional penalty for identical requests in a very short time frame.
532
+ if (currentPath === lastRequest.path && currentQueryString === lastRequest.queryString && timeSinceLast < burstThreshold) { // 150 < 500 -> true
533
+ score += burstWeight; // score = 30 + 50 = 80
534
+ }
535
+
536
+ // 3. Sequential Scraping Check: Add additional penalty for same path with different query params (potential scraping).
537
+ // This is a simplified check, now independent of the burst check.
538
+ if (currentPath === lastRequest.path && currentQueryString !== lastRequest.queryString && timeSinceLast < scrapeThreshold) {
539
+ const previousRequest = history.length > 2 ? history[history.length - 2] : null;
540
+ if (previousRequest && previousRequest.path === currentPath) {
541
+ score += scrapeBurstWeight; // This is at least the 3rd request in a sequence to the same path.
542
+ } else {
543
+ score += scrapeWeight; // First sign of a potential scraping pattern
544
+ }
545
+ }
546
+ }
547
+
548
+ // --- Update history ---
549
+ history.push({
550
+ timestamp: now,
551
+ path: currentPath,
552
+ queryString: currentQueryString,
553
+ });
554
+
555
+ // Keep history to a reasonable size (e.g., last 10 requests)
556
+ if (history.length > historySize) {
557
+ history.shift();
558
+ }
559
+
560
+ // Decay the score over time if behavior becomes normal again.
561
+ // We can store the score in deviceData and decay it.
562
+ deviceData.lastPatternScore = (deviceData.lastPatternScore || 0) * decayFactor + score; // Decay old score and add new
563
+
564
+ // If there hasn't been a request in a while, reset the pattern score.
565
+ if (history.length > 1 && (now - history[history.length - 2].timestamp > inactivityReset)) { // X ms inactivity
566
+ deviceData.lastPatternScore = 0;
567
+ }
568
+
569
+ return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
570
+ }
571
+
572
+ const trapUrlTemplates = [
573
+ '/includes/config-{RANDOM}.php', // Classic PHP config file
574
+ '/.env.{RANDOM}', // Environment file
575
+ '/backups/db_backup_{RANDOM}.sql.gz', // Database backup
576
+ '/api/v1/internal/status?trace={RANDOM}', // Internal API endpoint
577
+ '/_private/deploy_key_{RANDOM}.pem', // Private key file
578
+ '/logs/app_error_{RANDOM}.log', // Log file
579
+ '/.git/config_{RANDOM}' // Exposed git config variant
580
+ ];
581
+
582
+ /**
583
+ * Generates a signed trap URL.
584
+ * @param {string} nonce - The nonce to sign the URL with.
585
+ * @returns {string} The trap URL.
586
+ */
587
+ function generateTrapUrl(nonce) {
588
+ // Pick a random template to diversify the traps
589
+ const template = trapUrlTemplates[Math.floor(Math.random() * trapUrlTemplates.length)];
590
+ const randomPart = crypto.randomBytes(8).toString('hex');
591
+ const path = template.replace('{RANDOM}', randomPart);
592
+
593
+ const signature = crypto.createHmac('sha256', getPowSecret()).update(nonce + path).digest('hex').substring(0, 16);
594
+ return `${path}?sig=${signature}`;
595
+ }
596
+
597
+ /**
598
+ * Verifies if a given path is a valid trap URL for a given nonce.
599
+ * @param {string} path - The request path.
600
+ * @param {string} signature - The signature from the query.
601
+ * @param {string} nonce - The nonce to verify against.
602
+ * @returns {boolean}
603
+ */
604
+ function verifyTrapUrl(path, signature, nonce) {
605
+ const expectedSignature = crypto.createHmac('sha256', getPowSecret()).update(nonce + path).digest('hex').substring(0, 16);
606
+ try {
607
+ // Use timingSafeEqual to prevent timing attacks where an attacker could guess the signature byte by byte.
608
+ return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex'));
609
+ } catch {
610
+ // This will catch errors if buffers have different lengths or contain invalid hex characters, which is a failure case.
611
+ return false;
612
+ }
613
+ }
401
614
  /**
402
615
  * @typedef {object} IStore
403
616
  * @property {(key: string) => Promise<any>} get
@@ -443,7 +656,6 @@ async function resolveRequestIdentity(context) {
443
656
  let consistencyScore = 1.0; // 1.0 = perfectly consistent
444
657
  let deviceData = null;
445
658
  let newCookie = null;
446
-
447
659
  if (deviceId) {
448
660
  deviceData = await store.get(`device:${deviceId}`);
449
661
  }
@@ -474,6 +686,7 @@ async function resolveRequestIdentity(context) {
474
686
  deviceData = {
475
687
  initialDeviceHash: currentDeviceHash, // Anchor the initial fingerprint.
476
688
  ips: new Set(),
689
+ requestHistory: [], // Initialize state for the new pattern score
477
690
  lastUpdate: Date.now(),
478
691
  lastFpHash: currentDeviceHash,
479
692
  lastChangeTimestamp: 0,
@@ -545,10 +758,10 @@ async function getBehavioralIndicators(context, deviceData) {
545
758
  /**
546
759
  * Returns a vector of raw (unweighted) suspicion scores.
547
760
  * @param {object} context - The request context object.
548
- * @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number}>}
761
+ * @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number, honeypotScore: number}>}
549
762
  */
550
- export const getSuspicionVector = async (context) => {
551
- const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context);
763
+ export const getSuspicionVector = async (context, securityConfig) => {
764
+ const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context);
552
765
 
553
766
  const clientIp = context.clientIp;
554
767
 
@@ -570,10 +783,11 @@ export const getSuspicionVector = async (context) => {
570
783
  const behavioral = await getBehavioralIndicators(context, deviceData);
571
784
  const { headerAnomalyScore } = getHeaderAnomalies(context);
572
785
  // Calculate the inconsistency score here, separately.
573
- const inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200));
786
+ const inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200)); // Amplified score
574
787
 
575
788
 
576
789
  // Save the updated device state to the store
790
+ // 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.
577
791
  await store.set(`device:${deviceId}`, deviceData);
578
792
 
579
793
  return { ...behavioral, headerAnomalyScore, inconsistencyScore };
@@ -595,17 +809,20 @@ const MAX_RAPID_CHANGES_PER_DEVICE = 3; // Number of rapid fingerprint changes a
595
809
  * Uses FingerprintBuilder to create a fingerprint based on headers
596
810
  * and IP, making spoofing more complex (requires changing the entire stack).
597
811
  */
598
- export const identifyRequest = async (req, res) => {
812
+ export const identifyRequest = (securityConfig) => async (req, res) => {
599
813
  // This function now acts as a lightweight wrapper around the engine's identifyRequest method.
600
814
  // It requires a default configuration to work.
601
- const defaultConfig = {
602
- weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.1, inconsistencyScore: 0.8 },
603
- thresholds: { low: 20, medium: 40, high: 75 }
815
+ const config = securityConfig || {
816
+ weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.1, inconsistencyScore: 0.8, honeypotScore: 1.0 },
817
+ thresholds: { low: 20, medium: 40, high: 75 },
818
+ honeypot: { fields: [] } // Ensure honeypot config exists to prevent errors
604
819
  };
605
- const engine = new FingerprintEngine(defaultConfig);
820
+ const engine = new FingerprintEngine(config);
606
821
 
607
822
  const requestContext = {
608
823
  clientIp: req.ip || req.socket?.remoteAddress || "unknown",
824
+ query: req.query,
825
+ body: req.body,
609
826
  cookies: req.cookies,
610
827
  headers: req.headers,
611
828
  rawHeaders: req.rawHeaders,
@@ -744,9 +961,9 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
744
961
  await new Promise(r => setTimeout(r, 10)); // Yield to update UI
745
962
 
746
963
  let memSolution = 0;
747
- const iterations = size / 16;
748
964
  try {
749
965
  const size = ${memoryDifficulty} * 1024 * 1024;
966
+ const iterations = size / 16;
750
967
  const buffer = new Uint32Array(size / 4);
751
968
  const seed = nonce + ":" + clientSecret;
752
969
  let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
@@ -816,22 +1033,51 @@ class FingerprintEngine {
816
1033
 
817
1034
  async processRequest(requestContext) {
818
1035
  const { clientIp, path, cookies, query, isStatic } = requestContext;
819
- const { weights, thresholds, logger } = this.securityConfig;
1036
+ const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
820
1037
 
821
1038
  if (isStatic) {
822
1039
  return { action: 'next', score: 0, vector: {} };
823
1040
  }
824
1041
 
1042
+ const { pow_nonce } = query;
1043
+
1044
+ // Honeypot: Direct probing of challenge endpoints is highly suspicious.
1045
+ // A legitimate user only hits these endpoints via the challenge page itself, which is only served to suspicious users.
1046
+ // If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot.
1047
+ // We check this early, before the main suspicion calculation.
1048
+ if (pow_nonce) {
1049
+ const powCookie = cookies?.pow_clearance;
1050
+ if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
1051
+ // This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
1052
+ }
1053
+ }
1054
+
1055
+ // Check for persisted "condemned" status early.
1056
+ const { deviceData } = await resolveRequestIdentity(requestContext);
1057
+ if (deviceData?.condemned) {
1058
+ if (onDeviceCompromised) {
1059
+ onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Previously condemned', score: 100, vector: { honeypotScore: 100 } });
1060
+ }
1061
+ return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1062
+ }
1063
+
825
1064
  // The engine now works with the context directly, no more rawReq dependency here.
826
- const suspicionVector = await __internal.getSuspicionVector(requestContext);
1065
+ const suspicionVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
1066
+ const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
1067
+ const { requestPatternScore } = getRequestPatternScore(requestContext, deviceData, this.securityConfig.patterns);
1068
+ suspicionVector.honeypotScore = honeypotScore;
1069
+ suspicionVector.requestPatternScore = requestPatternScore;
827
1070
 
828
1071
  const finalScore =
829
1072
  suspicionVector.historyScore * (weights.historyScore || 0) +
830
1073
  suspicionVector.rotationScore * (weights.rotationScore || 0) +
831
- suspicionVector.headerAnomalyScore * (weights.headerAnomalyScore || 0) +
832
- suspicionVector.inconsistencyScore * (weights.inconsistencyScore || 0);
1074
+ suspicionVector.headerAnomalyScore * (weights.headerAnomalyScore || 0) + suspicionVector.requestPatternScore * (weights.requestPatternScore || 0) +
1075
+ suspicionVector.inconsistencyScore * (weights.inconsistencyScore || 0) +
1076
+ honeypotScore * (weights.honeypotScore || 0);
1077
+
1078
+ const isBlocked = finalScore >= (thresholds.block || 95);
833
1079
 
834
- const isSuspiciousHigh = finalScore >= thresholds.high;
1080
+ const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
835
1081
  const isSuspiciousMedium = finalScore >= thresholds.medium;
836
1082
  const isSuspicious = finalScore >= thresholds.low;
837
1083
 
@@ -843,7 +1089,39 @@ class FingerprintEngine {
843
1089
  )
844
1090
  : 0;
845
1091
  const powCookie = cookies?.pow_clearance;
846
- const { pow_type, pow_nonce, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
1092
+ const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
1093
+
1094
+ // Honeypot: Direct probing of challenge endpoints is highly suspicious.
1095
+ // A legitimate user only hits these endpoints via the challenge page itself.
1096
+ if (pow_nonce && !isSuspicious) {
1097
+ if (logger) {
1098
+ logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now() });
1099
+ }
1100
+ suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
1101
+ // Recalculate score and block immediately.
1102
+ const newFinalScore = finalScore - (honeypotScore * (weights.honeypotScore || 0)) + (100 * (weights.honeypotScore || 0));
1103
+ return { action: 'block', status: 403, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
1104
+ }
1105
+
1106
+ // If the action is to block, we should still include the score and vector for logging/testing.
1107
+ if (isBlocked) {
1108
+ if (onDeviceCompromised) {
1109
+ onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Score exceeded block threshold', score: finalScore, vector: suspicionVector });
1110
+ }
1111
+ return { action: 'block', status: 403, body: 'Forbidden', score: finalScore, vector: suspicionVector };
1112
+ }
1113
+
1114
+ // Honeypot: Check if the request is for a trap URL generated in a previous challenge.
1115
+ // This requires a nonce from a *previous* challenge, which we can look up via the device ID.
1116
+ const lastNonce = deviceData?.lastChallengeNonce;
1117
+ if (lastNonce && query.sig && verifyTrapUrl(path, query.sig, lastNonce)) {
1118
+ deviceData.condemned = true; // This device is a bot. Condemn it.
1119
+ if (onDeviceCompromised) {
1120
+ onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Triggered signed honeypot trap URL', score: 100, vector: { honeypotScore: 100 } });
1121
+ }
1122
+ await store.set(`device:${cookies.device_id}`, deviceData);
1123
+ return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1124
+ }
847
1125
 
848
1126
  if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
849
1127
  // --- CHALLENGE SOLUTION HANDLING ---
@@ -925,6 +1203,12 @@ class FingerprintEngine {
925
1203
  // Store the secret with a short TTL (e.g., 5 minutes)
926
1204
  await store.set(`secret:${nonce}`, clientSecret, 300);
927
1205
 
1206
+ // Associate the current challenge nonce with the device for trap URL verification later.
1207
+ if (deviceData) {
1208
+ deviceData.lastChallengeNonce = nonce;
1209
+ await store.set(`device:${cookies.device_id}`, deviceData);
1210
+ }
1211
+
928
1212
  if (logger) {
929
1213
  logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
930
1214
  }
@@ -936,6 +1220,12 @@ class FingerprintEngine {
936
1220
 
937
1221
  // UNIFIED CHALLENGE LOGIC for all suspicion levels (low, medium, high)
938
1222
  if (isSuspicious) { // Couvre à la fois low et medium
1223
+ // Generate some trap URLs to embed in the challenge page.
1224
+ // These links are visually hidden but present in the DOM to trap bots.
1225
+ const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce));
1226
+ const trapLinksHtml = trapUrls.map(url => `<a href="${url}" tabindex="-1">config</a>`).join(' ');
1227
+
1228
+
939
1229
  const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
940
1230
 
941
1231
  // La difficulté mémoire démarre à 0 et augmente seulement après un certain seuil de suspicion.
@@ -947,7 +1237,8 @@ class FingerprintEngine {
947
1237
  const memDifficulty = Math.round(minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty));
948
1238
 
949
1239
  // Always use the combined page, even if memory difficulty is 0 (it will be almost instant).
950
- const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp, clientSecret);
1240
+ const trapContainer = `<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">${trapLinksHtml}</div>`;
1241
+ const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp, clientSecret).replace('</body>', `${trapContainer}</body>`);
951
1242
  return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 429, body: page };
952
1243
  }
953
1244
  }
@@ -992,12 +1283,16 @@ class FingerprintEngine {
992
1283
  }
993
1284
  await store.set(`ip:${clientIp}`, ipProfile);
994
1285
 
995
- const vector = await __internal.getSuspicionVector(requestContext);
1286
+ const vector = await __internal.getSuspicionVector(requestContext, this.securityConfig); // Pass the config
1287
+ const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
1288
+ const { requestPatternScore } = getRequestPatternScore(requestContext, (await store.get(`device:${requestContext.cookies?.device_id}`)), this.securityConfig.patterns);
996
1289
  const score =
997
1290
  vector.historyScore * (this.securityConfig.weights.historyScore || 0.3) +
998
1291
  vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
999
1292
  vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
1000
- vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8);
1293
+ vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8) +
1294
+ honeypotScore * (this.securityConfig.weights.honeypotScore || 0) +
1295
+ requestPatternScore * (this.securityConfig.weights.requestPatternScore || 0);
1001
1296
 
1002
1297
  if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
1003
1298
  if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
@@ -1028,6 +1323,7 @@ export const powMiddleware = (securityConfig) => {
1028
1323
  path: req.path,
1029
1324
  cookies: req.cookies,
1030
1325
  query: req.query,
1326
+ body: req.body,
1031
1327
  headers: req.headers,
1032
1328
  isStatic: isStaticResource(req.path),
1033
1329
  // Add the newly required properties for full decoupling
@@ -1080,6 +1376,7 @@ export const __internal = {
1080
1376
  FingerprintBuilder, // Export for testing
1081
1377
  calculateTarget,
1082
1378
  FingerprintEngine, // Expose for advanced testing
1379
+ getRequestPatternScore, // Expose for testing
1083
1380
  };
1084
1381
 
1085
1382
  // --- THRESHOLD AUTO-TUNING SECTION ---
@@ -1114,9 +1411,11 @@ function runThresholdOptimization(securityConfig, trafficData, minDataPoints) {
1114
1411
  // The "fitness" function evaluates the quality of a set of thresholds.
1115
1412
  // A lower score is better.
1116
1413
  const fitnessFunction = (solution) => {
1117
- const [low, medium, high] = solution;
1414
+ const [low, medium, high, velocityThreshold, burstThreshold, scrapeThreshold] = solution;
1118
1415
  // Constraints: thresholds must be ordered and within a reasonable range.
1119
- if (low >= medium || medium >= high || low <= 10 || high >= 90) return Infinity;
1416
+ if (low >= medium || medium >= high || low < 10 || high > 90) return Infinity;
1417
+ // Constraints for pattern thresholds
1418
+ if (velocityThreshold < 50 || velocityThreshold > burstThreshold || burstThreshold > scrapeThreshold) return Infinity;
1120
1419
 
1121
1420
  let falsePositives = 0; // Humans challenged unnecessarily.
1122
1421
  let falseNegatives = 0; // Undetected bots.
@@ -1133,12 +1432,21 @@ function runThresholdOptimization(securityConfig, trafficData, minDataPoints) {
1133
1432
  };
1134
1433
 
1135
1434
  // Functions for the genetic algorithm.
1136
- const createIndividual = () => [10 + Math.random() * 20, 30 + Math.random() * 30, 60 + Math.random() * 30];
1137
- const crossover = (p1, p2) => [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2, (p1[2] + p2[2]) / 2];
1435
+ const createIndividual = () => [
1436
+ 10 + Math.random() * 20, // low
1437
+ 30 + Math.random() * 30, // medium
1438
+ 60 + Math.random() * 30, // high
1439
+ 100 + Math.random() * 150, // velocityThreshold (100-250ms)
1440
+ 300 + Math.random() * 400, // burstThreshold (300-700ms)
1441
+ 800 + Math.random() * 700, // scrapeThreshold (800-1500ms)
1442
+ ];
1443
+ const crossover = (p1, p2) => p1.map((val, i) => (val + p2[i]) / 2);
1138
1444
  const mutate = (s) => {
1139
1445
  const n = [...s];
1140
- const i = Math.floor(Math.random() * 3);
1141
- n[i] += (Math.random() - 0.5) * 5;
1446
+ const i = Math.floor(Math.random() * n.length);
1447
+ // Adjust mutation range based on parameter
1448
+ const mutationRange = i < 3 ? 5 : 50;
1449
+ n[i] += (Math.random() - 0.5) * mutationRange;
1142
1450
  return n;
1143
1451
  };
1144
1452
 
@@ -1148,16 +1456,26 @@ function runThresholdOptimization(securityConfig, trafficData, minDataPoints) {
1148
1456
  populationSize: 40
1149
1457
  });
1150
1458
 
1151
- const [newLow, newMedium, newHigh] = result.solution;
1459
+ const [newLow, newMedium, newHigh, newVelocity, newBurst, newScrape] = result.solution;
1152
1460
 
1153
1461
  // Update the configuration live.
1154
- securityConfig.thresholds = {
1155
- low: Math.round(newLow),
1156
- medium: Math.round(newMedium),
1157
- high: Math.round(newHigh)
1158
- };
1462
+ // Ensure thresholds object exists
1463
+ if (!securityConfig.thresholds) securityConfig.thresholds = {};
1464
+ securityConfig.thresholds.low = Math.round(newLow);
1465
+ securityConfig.thresholds.medium = Math.round(newMedium);
1466
+ securityConfig.thresholds.high = Math.round(newHigh);
1467
+
1468
+ // Update pattern detection parameters
1469
+ if (!securityConfig.patterns) securityConfig.patterns = {};
1470
+ securityConfig.patterns.velocityThreshold = Math.round(newVelocity);
1471
+ securityConfig.patterns.burstThreshold = Math.round(newBurst);
1472
+ securityConfig.patterns.scrapeThreshold = Math.round(newScrape);
1473
+ // Weights could also be optimized, but let's keep it to thresholds for now for simplicity.
1159
1474
 
1160
1475
  console.log("[AutoTuning] Nouveaux seuils optimisés appliqués :", securityConfig.thresholds);
1476
+ if (securityConfig.patterns) {
1477
+ console.log("[AutoTuning] Nouveaux paramètres de pattern appliqués :", securityConfig.patterns);
1478
+ }
1161
1479
  }
1162
1480
 
1163
1481
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anonympins/fingerprint",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
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",
@@ -40,6 +40,7 @@
40
40
  },
41
41
  "homepage": "https://github.com/anonympins/fingerprint#readme",
42
42
  "devDependencies": {
43
+ "body-parser": "^1.20.2",
43
44
  "cookie-parser": "^1.4.6",
44
45
  "express": "^4.18.2",
45
46
  "vitest": "^4.1.11"