@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.
- package/README.md +40 -40
- package/fingerprint.js +350 -32
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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
|
|
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
|
|
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 = () => [
|
|
1137
|
-
|
|
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() *
|
|
1141
|
-
|
|
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
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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.
|
|
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"
|