@anonympins/fingerprint 0.3.5 → 0.3.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.
- package/CHANGELOG.md +41 -1
- package/README.md +1200 -1080
- package/package.json +1 -1
- package/src/js/build-client.js +4 -5
- package/src/js/fingerprint.client.js +2 -2
- package/src/js/fingerprint.js +370 -95
- package/src/js/library.js +1 -1
- package/src/js/mongodb-store.js +79 -52
- package/src/js/optimization.worker.js +2 -2
- package/src/js/problem-manager.js +2 -2
- package/src/php/DirectFingerprint.php +145 -80
- package/src/php/FingerprintEngine.php +19 -3
- package/src/php/ProblemManager.php +1 -1
- package/src/php/RequestContext.php +90 -90
- package/src/php/Store/MongoDbStore.php +105 -0
- package/src/php/Store/RedisStore.php +54 -0
- package/src/php/Tests/ChallengeUtilsTest.php +1 -1
- package/src/php/Tests/FingerprintBuilderTest.php +1 -1
- package/src/php/Tests/FingerprintEngineTest.php +4 -4
- package/src/php/Tests/IpReputationTest.php +4 -4
- package/src/php/Tests/Ja3AnomalyDetectorTest.php +1 -1
- package/src/php/Tests/MetricsTest.php +46 -0
- package/src/php/Tests/PowTest.php +2 -2
- package/src/php/Tests/ProblemManagerTest.php +5 -3
- package/src/php/Tests/RequestUtilsTest.php +66 -1
- package/src/php/Utils/MetricsManager.php +167 -0
- package/src/php/Utils/RequestUtils.php +1142 -972
- package/src/php/bin/auto-tune.php +118 -0
package/src/js/fingerprint.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {BlockList, isIPv4, isIPv6} from "node:net";
|
|
3
3
|
import * as dns from "node:dns/promises";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
4
|
+
import {getProblemManager, problemManager} from "./problem-manager.js";
|
|
5
|
+
import {Optimization} from "./library.js";
|
|
6
|
+
import {cyrb53, FingerprintBuilder} from "./fingerprint.builder.js";
|
|
7
|
+
import {readFileSync} from "node:fs";
|
|
8
|
+
import {fileURLToPath} from "node:url";
|
|
9
|
+
import {dirname, join} from "node:path";
|
|
10
|
+
|
|
10
11
|
export { createRedisStore } from "./redis-store.js";
|
|
11
12
|
export { createMongoDbStore } from "./mongodb-store.js";
|
|
12
13
|
|
|
@@ -769,6 +770,8 @@ export const verifyPoWAndGenerateTicket = (
|
|
|
769
770
|
nonce,
|
|
770
771
|
solution,
|
|
771
772
|
difficulty = 4,
|
|
773
|
+
deviceId = '',
|
|
774
|
+
deviceHash = ''
|
|
772
775
|
) => {
|
|
773
776
|
// 1. Verify the solution: hash(ip + nonce + solution) must start with N zeros
|
|
774
777
|
const hash = crypto
|
|
@@ -784,10 +787,10 @@ export const verifyPoWAndGenerateTicket = (
|
|
|
784
787
|
const expiry = Date.now() + 3600000; // 1 heure
|
|
785
788
|
const signature = crypto
|
|
786
789
|
.createHmac("sha256", getPowSecret())
|
|
787
|
-
.update(`${ip}:${
|
|
790
|
+
.update(`${expiry}:${ip}:${deviceId}:${deviceHash}`)
|
|
788
791
|
.digest("hex");
|
|
789
792
|
|
|
790
|
-
return `${expiry}
|
|
793
|
+
return `${expiry}|${ip}|${signature}`;
|
|
791
794
|
};
|
|
792
795
|
|
|
793
796
|
|
|
@@ -822,23 +825,61 @@ export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret =
|
|
|
822
825
|
}
|
|
823
826
|
return finalHash === parseInt(solution, 10);
|
|
824
827
|
};
|
|
825
|
-
export const isTicketValid = (ip, ticket) => {
|
|
828
|
+
export const isTicketValid = (ip, ticket, deviceId = '', deviceHash = '') => {
|
|
826
829
|
// Input validation: ensure the ticket is a non-empty string with the correct format.
|
|
827
|
-
if (typeof ticket !== 'string'
|
|
828
|
-
|
|
830
|
+
if (typeof ticket !== 'string') return false;
|
|
831
|
+
|
|
832
|
+
let expiry, originalIp, sig;
|
|
833
|
+
if (ticket.includes('|')) {
|
|
834
|
+
const parts = ticket.split('|');
|
|
835
|
+
if (parts.length < 3) return false;
|
|
836
|
+
[expiry, originalIp, sig] = parts;
|
|
837
|
+
} else if (ticket.includes(':')) {
|
|
838
|
+
// Legacy fallback format
|
|
839
|
+
const parts = ticket.split(':');
|
|
840
|
+
if (parts.length < 2) return false;
|
|
841
|
+
[expiry, sig] = parts;
|
|
842
|
+
originalIp = ip;
|
|
843
|
+
} else {
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
|
|
829
847
|
if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
848
|
+
|
|
849
|
+
let expectedSig;
|
|
850
|
+
if (ticket.includes('|')) {
|
|
851
|
+
expectedSig = crypto
|
|
852
|
+
.createHmac("sha256", getPowSecret())
|
|
853
|
+
.update(`${expiry}:${originalIp}:${deviceId}:${deviceHash}`)
|
|
854
|
+
.digest("hex");
|
|
855
|
+
} else {
|
|
856
|
+
// Legacy expected signature
|
|
857
|
+
expectedSig = crypto
|
|
858
|
+
.createHmac("sha256", getPowSecret())
|
|
859
|
+
.update(`${ip}:${expiry}`)
|
|
860
|
+
.digest("hex");
|
|
861
|
+
}
|
|
834
862
|
|
|
835
863
|
// Use timingSafeEqual to prevent timing attacks
|
|
836
864
|
try {
|
|
837
|
-
|
|
865
|
+
const isSigValid = crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
|
|
866
|
+
if (!isSigValid) return false;
|
|
838
867
|
} catch (e) {
|
|
839
|
-
// This can happen if the buffers have different lengths, which is a failure case.
|
|
840
868
|
return false;
|
|
841
869
|
}
|
|
870
|
+
|
|
871
|
+
if (!ticket.includes('|')) {
|
|
872
|
+
return ip === originalIp;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Roaming & Terminal Identity checks:
|
|
876
|
+
if (ip === originalIp) return true;
|
|
877
|
+
const currentSubnet = getIpSubnet(ip);
|
|
878
|
+
const originalSubnet = getIpSubnet(originalIp);
|
|
879
|
+
if (currentSubnet && originalSubnet && currentSubnet === originalSubnet) return true;
|
|
880
|
+
|
|
881
|
+
// Perfect terminal identity matched via HMAC signature
|
|
882
|
+
return !!(deviceId && deviceHash);
|
|
842
883
|
};
|
|
843
884
|
|
|
844
885
|
|
|
@@ -1778,6 +1819,22 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
1778
1819
|
}
|
|
1779
1820
|
}
|
|
1780
1821
|
|
|
1822
|
+
// Détection d'énumération de chemins (crawling/scraping de ressources séquentielles)
|
|
1823
|
+
let enumerationScore = 0;
|
|
1824
|
+
if (history.length >= 3) {
|
|
1825
|
+
const templates = history.map(h => h.path.replace(/\d+/g, '{num}'));
|
|
1826
|
+
const uniquePaths = new Set(history.map(h => h.path));
|
|
1827
|
+
|
|
1828
|
+
const templateCounts = {};
|
|
1829
|
+
templates.forEach(t => templateCounts[t] = (templateCounts[t] || 0) + 1);
|
|
1830
|
+
|
|
1831
|
+
const maxTemplateRepetition = Math.max(...Object.values(templateCounts), 0);
|
|
1832
|
+
// Si une même structure de route est répétée mais sur des URLs réelles différentes
|
|
1833
|
+
if (maxTemplateRepetition >= 3 && uniquePaths.size === history.length) {
|
|
1834
|
+
enumerationScore = patternWeight * 0.8; // Appliquer une forte pénalité
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1781
1838
|
// Garder l'historique à une taille raisonnable
|
|
1782
1839
|
if (history.length > historySize) {
|
|
1783
1840
|
history.shift();
|
|
@@ -1796,7 +1853,7 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
1796
1853
|
}
|
|
1797
1854
|
newPatternScore = Math.max(0, newPatternScore);
|
|
1798
1855
|
|
|
1799
|
-
deviceData.lastPatternScore = newPatternScore + instantScore;
|
|
1856
|
+
deviceData.lastPatternScore = newPatternScore + instantScore + enumerationScore;
|
|
1800
1857
|
|
|
1801
1858
|
return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
|
|
1802
1859
|
}
|
|
@@ -2341,6 +2398,8 @@ export function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
2341
2398
|
nonce,
|
|
2342
2399
|
solution,
|
|
2343
2400
|
challengeContext = {}, // Le contexte complet du challenge est maintenant passé
|
|
2401
|
+
deviceId = '',
|
|
2402
|
+
deviceHash = ''
|
|
2344
2403
|
) {
|
|
2345
2404
|
const { cpuTarget, baseBlock } = challengeContext;
|
|
2346
2405
|
if (!cpuTarget || !baseBlock) {
|
|
@@ -2392,9 +2451,9 @@ export function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
2392
2451
|
const expiry = Date.now() + (ticketTtl || 3600000); // Calcule l'expiration à partir du TTL
|
|
2393
2452
|
const signature = crypto
|
|
2394
2453
|
.createHmac("sha256", getPowSecret())
|
|
2395
|
-
.update(`${clientIp}:${
|
|
2454
|
+
.update(`${expiry}:${clientIp}:${deviceId}:${deviceHash}`)
|
|
2396
2455
|
.digest("hex");
|
|
2397
|
-
return `${expiry}
|
|
2456
|
+
return `${expiry}|${clientIp}|${signature}`;
|
|
2398
2457
|
}
|
|
2399
2458
|
|
|
2400
2459
|
return null;
|
|
@@ -2474,7 +2533,7 @@ export class FingerprintEngine {
|
|
|
2474
2533
|
console.log(`[FingerprintEngine] ${message}`, data);
|
|
2475
2534
|
}
|
|
2476
2535
|
}
|
|
2477
|
-
calculateFinalScore
|
|
2536
|
+
calculateFinalScore(suspicionVector) {
|
|
2478
2537
|
const { weights } = this.securityConfig;
|
|
2479
2538
|
if (!weights) return 0;
|
|
2480
2539
|
|
|
@@ -2760,6 +2819,11 @@ export class FingerprintEngine {
|
|
|
2760
2819
|
return { action: 'next', score: 0, vector: {} };
|
|
2761
2820
|
}
|
|
2762
2821
|
|
|
2822
|
+
// Resolve identity and check for persisted "condemned" status early.
|
|
2823
|
+
const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
|
|
2824
|
+
const currentDeviceHash = getCompositeDeviceHash(requestContext);
|
|
2825
|
+
const isNewDevice = !!newCookie;
|
|
2826
|
+
|
|
2763
2827
|
// 1. Check static IP allowlist first for maximum performance.
|
|
2764
2828
|
if (this._isIpInAllowlist(clientIp)) {
|
|
2765
2829
|
this._log('IP in allowlist - allowing request', { clientIp });
|
|
@@ -2798,7 +2862,7 @@ export class FingerprintEngine {
|
|
|
2798
2862
|
// If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
|
|
2799
2863
|
if (pow_nonce) {
|
|
2800
2864
|
const powCookie = cookies?.pow_clearance;
|
|
2801
|
-
if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
|
|
2865
|
+
if (!isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash)) { // Only check if there's no valid ticket
|
|
2802
2866
|
// This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
|
|
2803
2867
|
// The final decision is made later, after calculating the score.
|
|
2804
2868
|
}
|
|
@@ -2810,10 +2874,6 @@ export class FingerprintEngine {
|
|
|
2810
2874
|
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
|
|
2811
2875
|
}
|
|
2812
2876
|
|
|
2813
|
-
// Resolve identity and check for persisted "condemned" status early.
|
|
2814
|
-
const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
|
|
2815
|
-
const isNewDevice = !!newCookie;
|
|
2816
|
-
|
|
2817
2877
|
this._log('Identity resolved', { deviceId, isNewDevice, hasDeviceData: !!deviceData });
|
|
2818
2878
|
|
|
2819
2879
|
if (deviceData?.condemned) {
|
|
@@ -2908,7 +2968,26 @@ export class FingerprintEngine {
|
|
|
2908
2968
|
});
|
|
2909
2969
|
|
|
2910
2970
|
let isValid = false;
|
|
2911
|
-
|
|
2971
|
+
let challengeContext = await store.get(`secret:${pow_nonce}`);
|
|
2972
|
+
|
|
2973
|
+
// SECURITY: Verify that the retrieved context has not been tampered with
|
|
2974
|
+
if (challengeContext && challengeContext.signature) {
|
|
2975
|
+
const payloadToSign = `${challengeContext.clientSecret}:${challengeContext.cpuTarget}:${challengeContext.fingerprint}:${challengeContext.memDifficulty}:${challengeContext.originalPath}:${clientIp}`;
|
|
2976
|
+
const expectedSignature = crypto.createHmac("sha256", getPowSecret()).update(payloadToSign).digest("hex");
|
|
2977
|
+
try {
|
|
2978
|
+
const isSignatureValid = crypto.timingSafeEqual(
|
|
2979
|
+
Buffer.from(challengeContext.signature, 'hex'),
|
|
2980
|
+
Buffer.from(expectedSignature, 'hex')
|
|
2981
|
+
);
|
|
2982
|
+
if (!isSignatureValid) {
|
|
2983
|
+
this._log('Challenge context signature invalid - storage tampering detected!', { nonce: pow_nonce });
|
|
2984
|
+
challengeContext = null; // Invalidate context immediately
|
|
2985
|
+
}
|
|
2986
|
+
} catch (e) {
|
|
2987
|
+
this._log('Error validating challenge context signature:', e);
|
|
2988
|
+
challengeContext = null;
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2912
2991
|
let ticket = null;
|
|
2913
2992
|
// Déclarer optimalTtl ici avec une valeur par défaut
|
|
2914
2993
|
let optimalTtl = this.securityConfig.ticketMaxAge || 3600000;
|
|
@@ -2957,11 +3036,12 @@ export class FingerprintEngine {
|
|
|
2957
3036
|
|
|
2958
3037
|
if ((pow_type === "cpu_target" || !pow_type) && (pow_solution_cpu || pow_solution)) { // !pow_type pour compatibilité
|
|
2959
3038
|
const cpuSolution = pow_solution_cpu || pow_solution;
|
|
2960
|
-
|
|
3039
|
+
ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext, deviceId, currentDeviceHash);
|
|
2961
3040
|
isValid = ticket !== null;
|
|
2962
3041
|
this._log('CPU target challenge verification', { isValid });
|
|
2963
|
-
} else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) {
|
|
2964
|
-
|
|
3042
|
+
} else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) {
|
|
3043
|
+
const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext, deviceId, currentDeviceHash);
|
|
3044
|
+
const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret); // Memory PoW is independent of fingerprint
|
|
2965
3045
|
isValid = cpuTicket !== null && isMemValid;
|
|
2966
3046
|
if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
|
|
2967
3047
|
this._log('Combined CPU+Memory challenge verification', {
|
|
@@ -3087,7 +3167,10 @@ export class FingerprintEngine {
|
|
|
3087
3167
|
if (challengeContext) {
|
|
3088
3168
|
try {
|
|
3089
3169
|
const workResult = JSON.parse(pow_solution_work_result);
|
|
3090
|
-
getProblemManager(
|
|
3170
|
+
getProblemManager({
|
|
3171
|
+
configPath: this.securityConfig.usefulWorkConfigPath,
|
|
3172
|
+
config: this.securityConfig.usefulWorkConfig
|
|
3173
|
+
}, store).integrateSolution(pow_problem_id, workResult);
|
|
3091
3174
|
|
|
3092
3175
|
await store.delete(`secret:${pow_nonce}`);
|
|
3093
3176
|
// Accorder un ticket de passage comme pour un PoW normal
|
|
@@ -3153,7 +3236,7 @@ export class FingerprintEngine {
|
|
|
3153
3236
|
// 1. La requête est suspecte ET il n'y a pas de ticket valide.
|
|
3154
3237
|
// OU
|
|
3155
3238
|
// 2. La requête est *très* suspecte (dépasse le seuil 'high'), ce qui annule la validité du ticket actuel.
|
|
3156
|
-
const hasValidTicket = isTicketValid(clientIp, powCookie);
|
|
3239
|
+
const hasValidTicket = isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash);
|
|
3157
3240
|
const mustReChallenge = isSuspiciousHigh && hasValidTicket;
|
|
3158
3241
|
|
|
3159
3242
|
if (isSuspicious && (!hasValidTicket || mustReChallenge)) {
|
|
@@ -3197,7 +3280,10 @@ export class FingerprintEngine {
|
|
|
3197
3280
|
if (isSuspicious && shouldUseUsefulWork) {
|
|
3198
3281
|
this._log('Issuing a useful work challenge', { finalScore });
|
|
3199
3282
|
|
|
3200
|
-
const { problemId, task } = getProblemManager(
|
|
3283
|
+
const { problemId, task } = getProblemManager({
|
|
3284
|
+
configPath: this.securityConfig.usefulWorkConfigPath,
|
|
3285
|
+
config: this.securityConfig.usefulWorkConfig
|
|
3286
|
+
}, store).dispatchWork(suspicionFactor);
|
|
3201
3287
|
|
|
3202
3288
|
await store.set(`secret:${nonce}`, { clientSecret, originalPath: path }, 300);
|
|
3203
3289
|
|
|
@@ -3245,6 +3331,11 @@ export class FingerprintEngine {
|
|
|
3245
3331
|
|
|
3246
3332
|
// Store the entire challenge context with a short TTL (e.g., 5 minutes)
|
|
3247
3333
|
const baseBlock = createCpuChallengeBaseBlock(nonce, clientSecret, originalFingerprint);
|
|
3334
|
+
|
|
3335
|
+
// SECURITY: Cryptographically sign the payload before storing it to prevent database tampering
|
|
3336
|
+
const payloadToSign = `${clientSecret}:${cpuChallengeDetails.target}:${originalFingerprint}:${memDifficulty}:${path}:${clientIp}`;
|
|
3337
|
+
const signature = crypto.createHmac("sha256", getPowSecret()).update(payloadToSign).digest("hex");
|
|
3338
|
+
|
|
3248
3339
|
await store.set(`secret:${nonce}`, {
|
|
3249
3340
|
clientSecret,
|
|
3250
3341
|
cpuTarget: cpuChallengeDetails.target,
|
|
@@ -3253,6 +3344,7 @@ export class FingerprintEngine {
|
|
|
3253
3344
|
memDifficulty: memDifficulty,
|
|
3254
3345
|
baseBlock: baseBlock, // *** NOUVEAU: Le bloc de base est stocké pour la vérification ***
|
|
3255
3346
|
originalPath: path, // *** FIX: Store the original path ***
|
|
3347
|
+
signature, // *** NOUVEAU: Cryptographic signature to prevent storage tampering ***
|
|
3256
3348
|
}, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
|
|
3257
3349
|
|
|
3258
3350
|
// Associate the current challenge nonce with the device for trap URL verification later.
|
|
@@ -3302,7 +3394,7 @@ export class FingerprintEngine {
|
|
|
3302
3394
|
}
|
|
3303
3395
|
|
|
3304
3396
|
// Basic log for each non-static request that passed without a challenge
|
|
3305
|
-
this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie) });
|
|
3397
|
+
this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash) });
|
|
3306
3398
|
|
|
3307
3399
|
if (logger) {
|
|
3308
3400
|
logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now(), vector: suspicionVector });
|
|
@@ -3365,68 +3457,123 @@ const staticExtensions = new RegExp(
|
|
|
3365
3457
|
const isStaticResource = (path) => staticExtensions.test(path);
|
|
3366
3458
|
|
|
3367
3459
|
|
|
3460
|
+
/** @type {Map<number, number>} Cache des TTL optimisés par score de suspicion (clés de 0 à 100 par pas de 10) */
|
|
3461
|
+
let optimizedTtlCache = new Map();
|
|
3462
|
+
|
|
3368
3463
|
/**
|
|
3369
|
-
*
|
|
3370
|
-
*
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
function determineOptimalTicketTtl(suspicionScore) {
|
|
3374
|
-
// Définir les bornes pour la durée de vie du ticket (5 minutes à 24 heures)
|
|
3464
|
+
* Exécute l'optimisation des TTL en tâche de fond de manière asynchrone et non-bloquante.
|
|
3465
|
+
* Utilise l'algorithme génétique multi-objectifs de Pareto pour trouver des solutions stables.
|
|
3466
|
+
*/
|
|
3467
|
+
export async function runBackgroundTtlOptimization() {
|
|
3375
3468
|
const MIN_TTL = 300000;
|
|
3376
3469
|
const MAX_TTL = 86400000;
|
|
3470
|
+
const tempCache = new Map();
|
|
3471
|
+
const keyScores = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
|
|
3472
|
+
|
|
3473
|
+
for (const suspicionScore of keyScores) {
|
|
3474
|
+
// Rend la main à la boucle d'événements Node.js à chaque itération pour ne pas bloquer les requêtes web actives
|
|
3475
|
+
await new Promise(resolve => {
|
|
3476
|
+
if (typeof setImmediate === 'function') {
|
|
3477
|
+
setImmediate(resolve);
|
|
3478
|
+
} else {
|
|
3479
|
+
setTimeout(resolve, 0);
|
|
3480
|
+
}
|
|
3481
|
+
});
|
|
3377
3482
|
|
|
3378
|
-
|
|
3379
|
-
|
|
3483
|
+
const solverFunction = () => {
|
|
3484
|
+
const fitnessFunction = Optimization.Operators.createOptimalTtlEvaluator({ suspicionScore });
|
|
3485
|
+
const createIndividual = () => MIN_TTL + Math.random() * (MAX_TTL - MIN_TTL);
|
|
3486
|
+
const crossover = (ttl1, ttl2) => (ttl1 + ttl2) / 2;
|
|
3487
|
+
const mutate = (ttl) => {
|
|
3488
|
+
const newTtl = ttl + (Math.random() - 0.5) * (MAX_TTL - MIN_TTL) * 0.1;
|
|
3489
|
+
return Math.max(MIN_TTL, Math.min(MAX_TTL, newTtl));
|
|
3490
|
+
};
|
|
3380
3491
|
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3492
|
+
const paretoFront = Optimization.geneticAlgorithmMultiObjective(
|
|
3493
|
+
createIndividual,
|
|
3494
|
+
fitnessFunction,
|
|
3495
|
+
crossover,
|
|
3496
|
+
mutate,
|
|
3497
|
+
{
|
|
3498
|
+
generations: 40,
|
|
3499
|
+
populationSize: 30,
|
|
3500
|
+
}
|
|
3501
|
+
);
|
|
3388
3502
|
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
fitnessFunction,
|
|
3392
|
-
crossover,
|
|
3393
|
-
mutate,
|
|
3394
|
-
{
|
|
3395
|
-
generations: 40,
|
|
3396
|
-
populationSize: 30,
|
|
3503
|
+
if (!paretoFront || paretoFront.length === 0) {
|
|
3504
|
+
return { solution: null, fitness: Infinity };
|
|
3397
3505
|
}
|
|
3398
|
-
);
|
|
3399
3506
|
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3507
|
+
let bestSolutionInFront;
|
|
3508
|
+
if (suspicionScore < 50) {
|
|
3509
|
+
bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
|
|
3510
|
+
} else {
|
|
3511
|
+
bestSolutionInFront = paretoFront.reduce((min, p) => Math.min(min, p.solution), Infinity);
|
|
3512
|
+
}
|
|
3513
|
+
return { solution: bestSolutionInFront, fitness: 0 };
|
|
3514
|
+
};
|
|
3406
3515
|
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
let bestSolutionInFront;
|
|
3411
|
-
if (suspicionScore < 50) {
|
|
3412
|
-
bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
|
|
3516
|
+
const { bestResult } = Optimization.runMultiple(solverFunction, 20);
|
|
3517
|
+
if (bestResult && bestResult.solution && bestResult.solution !== Infinity) {
|
|
3518
|
+
tempCache.set(suspicionScore, Math.round(bestResult.solution));
|
|
3413
3519
|
} else {
|
|
3414
|
-
|
|
3520
|
+
tempCache.set(suspicionScore, Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL));
|
|
3415
3521
|
}
|
|
3416
|
-
|
|
3417
|
-
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
optimizedTtlCache = tempCache;
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
// Lancement de l'optimisation initiale immédiate en arrière-plan
|
|
3528
|
+
runBackgroundTtlOptimization().catch(err => {
|
|
3529
|
+
console.error('[Fingerprint] Error in background TTL optimization:', err);
|
|
3530
|
+
});
|
|
3418
3531
|
|
|
3419
|
-
|
|
3420
|
-
|
|
3532
|
+
// Planification périodique toutes les 30 minutes sans bloquer la fermeture du processus Node.js (via unref)
|
|
3533
|
+
const ttlInterval = setInterval(() => {
|
|
3534
|
+
runBackgroundTtlOptimization().catch(err => {
|
|
3535
|
+
console.error('[Fingerprint] Error in background TTL optimization:', err);
|
|
3536
|
+
});
|
|
3537
|
+
}, 1800000);
|
|
3538
|
+
if (ttlInterval && typeof ttlInterval.unref === 'function') {
|
|
3539
|
+
ttlInterval.unref();
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
/**
|
|
3543
|
+
* Détermine le TTL optimal pour un ticket.
|
|
3544
|
+
* Utilise les valeurs pré-calculées de la tâche d'optimisation en arrière-plan et effectue
|
|
3545
|
+
* une interpolation linéaire instantanée pour le score requis.
|
|
3546
|
+
*
|
|
3547
|
+
* @param {number} suspicionScore - Le score de suspicion de la requête.
|
|
3548
|
+
* @returns {number} Le TTL optimal calculé en millisecondes.
|
|
3549
|
+
*/
|
|
3550
|
+
function determineOptimalTicketTtl(suspicionScore) {
|
|
3551
|
+
const MIN_TTL = 300000;
|
|
3552
|
+
const MAX_TTL = 86400000;
|
|
3553
|
+
const score = Math.max(0, Math.min(100, suspicionScore));
|
|
3554
|
+
|
|
3555
|
+
if (!optimizedTtlCache || optimizedTtlCache.size === 0) {
|
|
3556
|
+
// Formule mathématique instantanée de secours si le cache de fond n'est pas encore prêt
|
|
3557
|
+
return Math.round(MAX_TTL - (score / 100) * (MAX_TTL - MIN_TTL));
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
const lowerKey = Math.floor(score / 10) * 10;
|
|
3561
|
+
const upperKey = Math.ceil(score / 10) * 10;
|
|
3562
|
+
|
|
3563
|
+
const lowerTtl = optimizedTtlCache.get(lowerKey);
|
|
3564
|
+
const upperTtl = optimizedTtlCache.get(upperKey);
|
|
3421
3565
|
|
|
3422
|
-
if (
|
|
3423
|
-
|
|
3424
|
-
return Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL);
|
|
3566
|
+
if (lowerTtl === undefined || upperTtl === undefined) {
|
|
3567
|
+
return Math.round(MAX_TTL - (score / 100) * (MAX_TTL - MIN_TTL));
|
|
3425
3568
|
}
|
|
3426
3569
|
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3570
|
+
if (lowerKey === upperKey) {
|
|
3571
|
+
return lowerTtl;
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
// Interpolation linéaire entre les deux points clés optimisés du front de Pareto
|
|
3575
|
+
const fraction = (score - lowerKey) / (upperKey - lowerKey);
|
|
3576
|
+
return Math.round(lowerTtl + fraction * (upperTtl - lowerTtl));
|
|
3430
3577
|
}
|
|
3431
3578
|
|
|
3432
3579
|
/**
|
|
@@ -3744,6 +3891,7 @@ export const __internal = {
|
|
|
3744
3891
|
FingerprintBuilder, // Export for testing
|
|
3745
3892
|
calculateTarget,
|
|
3746
3893
|
determineOptimalTicketTtl,
|
|
3894
|
+
runBackgroundTtlOptimization,
|
|
3747
3895
|
getRequestPatternScore, // Expose for testing
|
|
3748
3896
|
getBehaviorScore, // Expose for testing
|
|
3749
3897
|
getCrossLayerInconsistency, // Expose for testing
|
|
@@ -3751,6 +3899,7 @@ export const __internal = {
|
|
|
3751
3899
|
getTimeInconsistencyScore,
|
|
3752
3900
|
getClickVarianceScore, // NOUVEAU: Expose pour les tests
|
|
3753
3901
|
getTlsFingerprint, // NOUVEAU: Expose pour les tests
|
|
3902
|
+
sanitizeTrafficData, // NOUVEAU: Expose pour l'auto-tuner/tests
|
|
3754
3903
|
getTlsSpoofingScore, // NOUVEAU: Expose pour les tests
|
|
3755
3904
|
parseJa3,
|
|
3756
3905
|
generateCpuTargetChallengePage,
|
|
@@ -3762,6 +3911,7 @@ export const __internal = {
|
|
|
3762
3911
|
getSubnetScore, // Expose for testing
|
|
3763
3912
|
getIpReputationScore, // Expose for testing
|
|
3764
3913
|
updateIpReputationScore, // Expose for testing
|
|
3914
|
+
setLastBestSolution: (val) => { lastBestSolution = val; }, // Expose to test auto-tuning metrics
|
|
3765
3915
|
};
|
|
3766
3916
|
|
|
3767
3917
|
// --- THRESHOLD AUTO-TUNING SECTION ---
|
|
@@ -3769,25 +3919,60 @@ export const __internal = {
|
|
|
3769
3919
|
let autoTuningJobId = null;
|
|
3770
3920
|
let lastBestSolution = null; // NOUVEAU: Stocke la meilleure solution trouvée
|
|
3771
3921
|
|
|
3922
|
+
/**
|
|
3923
|
+
* Assainit les données de trafic pour l'auto-tuner afin de prévenir les attaques par empoisonnement.
|
|
3924
|
+
* Limite la contribution de chaque deviceId à un pourcentage maximum (ex: 2%) du jeu de données total.
|
|
3925
|
+
* @export
|
|
3926
|
+
* @param {Array<object>} trafficData
|
|
3927
|
+
* @returns {Array<object>}
|
|
3928
|
+
*/
|
|
3929
|
+
export function sanitizeTrafficData(trafficData) {
|
|
3930
|
+
if (!trafficData || trafficData.length === 0) {
|
|
3931
|
+
return [];
|
|
3932
|
+
}
|
|
3933
|
+
const tempSanitized = [];
|
|
3934
|
+
const deviceCounts = new Map();
|
|
3935
|
+
const maxLogsPerDevice = Math.max(3, Math.floor(trafficData.length * 0.02)); // Max 2% contribution per device
|
|
3936
|
+
|
|
3937
|
+
for (const log of trafficData) {
|
|
3938
|
+
const devId = log.deviceId || 'anonymous';
|
|
3939
|
+
const currentCount = deviceCounts.get(devId) || 0;
|
|
3940
|
+
if (currentCount < maxLogsPerDevice) {
|
|
3941
|
+
deviceCounts.set(devId, currentCount + 1);
|
|
3942
|
+
tempSanitized.push(log);
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
const passedLogs = tempSanitized.filter(log => log.type === 'request_passed');
|
|
3947
|
+
const suspiciousLogs = tempSanitized.filter(log => log.type !== 'request_passed');
|
|
3948
|
+
|
|
3949
|
+
const minDataPoints = 200; // Seuil par défaut
|
|
3950
|
+
const maxPassedAllowed = Math.max(minDataPoints, suspiciousLogs.length * 9);
|
|
3951
|
+
const shuffledPassed = passedLogs.sort(() => 0.5 - Math.random());
|
|
3952
|
+
const selectedPassed = shuffledPassed.slice(0, maxPassedAllowed);
|
|
3953
|
+
|
|
3954
|
+
return [...suspiciousLogs, ...selectedPassed];
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3772
3957
|
/**
|
|
3773
3958
|
* Executes a threshold optimization pass using collected traffic data.
|
|
3774
3959
|
* @private
|
|
3775
|
-
* @param {object} securityConfig - The security configuration object to update.
|
|
3776
|
-
* @param {Array<object>} trafficData - The array containing traffic logs.
|
|
3777
|
-
* @param {number} minDataPoints - The minimum number of data points required to start optimization.
|
|
3778
|
-
* @param {number} maxDataPoints - The maximum number of data points to keep after an optimization cycle.
|
|
3779
|
-
* @param {string} [savePath] - Optional path to save the best configuration to a file.
|
|
3780
3960
|
*/
|
|
3781
3961
|
function runThresholdOptimization(securityConfig, trafficData, minDataPoints, maxDataPoints, savePath) {
|
|
3782
|
-
const
|
|
3783
|
-
|
|
3962
|
+
const sanitizedData = sanitizeTrafficData(trafficData);
|
|
3963
|
+
|
|
3964
|
+
const highConfidenceLogs = sanitizedData.filter(log => log.type === 'challenge_solved' || log.type === 'trap_triggered').length;
|
|
3965
|
+
const highConfidenceRatio = sanitizedData.length > 0 ? highConfidenceLogs / sanitizedData.length : 0;
|
|
3784
3966
|
const MIN_CONFIDENCE_RATIO = 0.05; // Exiger au moins 5% de signaux forts.
|
|
3967
|
+
const MIN_HIGH_CONFIDENCE_COUNT = 10; // Absolu de secours pour éviter le gel lors de floods
|
|
3968
|
+
|
|
3969
|
+
const hasEnoughSignal = highConfidenceRatio >= MIN_CONFIDENCE_RATIO || highConfidenceLogs >= MIN_HIGH_CONFIDENCE_COUNT;
|
|
3785
3970
|
|
|
3786
|
-
if (
|
|
3787
|
-
if (
|
|
3788
|
-
console.log(`[AutoTuning] Reporté : ${
|
|
3971
|
+
if (sanitizedData.length < minDataPoints || !hasEnoughSignal) {
|
|
3972
|
+
if (sanitizedData.length < minDataPoints) {
|
|
3973
|
+
console.log(`[AutoTuning] Reporté : ${sanitizedData.length}/${minDataPoints} points de données.`);
|
|
3789
3974
|
} else {
|
|
3790
|
-
console.log(`[AutoTuning] Reporté :
|
|
3975
|
+
console.log(`[AutoTuning] Reporté : Signaux de confiance insuffisants (Ratio: ${(highConfidenceRatio * 100).toFixed(2)}% < ${(MIN_CONFIDENCE_RATIO * 100).toFixed(2)}% et absolu: ${highConfidenceLogs} < ${MIN_HIGH_CONFIDENCE_COUNT}).`);
|
|
3791
3976
|
}
|
|
3792
3977
|
return;
|
|
3793
3978
|
}
|
|
@@ -3797,9 +3982,9 @@ function runThresholdOptimization(securityConfig, trafficData, minDataPoints, ma
|
|
|
3797
3982
|
trafficData.splice(0, trafficData.length - maxDataPoints);
|
|
3798
3983
|
}
|
|
3799
3984
|
|
|
3800
|
-
console.log(`[AutoTuning] Démarrage du cycle d'optimisation complet avec ${
|
|
3985
|
+
console.log(`[AutoTuning] Démarrage du cycle d'optimisation complet avec ${sanitizedData.length} points de données assainis.`);
|
|
3801
3986
|
|
|
3802
|
-
const paretoFront = Optimization.Operators.solveFullSecurityTuning({ trafficData });
|
|
3987
|
+
const paretoFront = Optimization.Operators.solveFullSecurityTuning({ trafficData: sanitizedData });
|
|
3803
3988
|
|
|
3804
3989
|
if (!paretoFront || paretoFront.length === 0) {
|
|
3805
3990
|
console.warn("[AutoTuning] L'optimisation n'a retourné aucune solution.");
|
|
@@ -3944,3 +4129,93 @@ export function stopThresholdAutoTuning() {
|
|
|
3944
4129
|
export function getBestTuningSolution() {
|
|
3945
4130
|
return lastBestSolution;
|
|
3946
4131
|
}
|
|
4132
|
+
|
|
4133
|
+
class RequestContext {
|
|
4134
|
+
constructor(ip, path, headers, query, body, cookies, httpVersion) {
|
|
4135
|
+
this.clientIp = ip || '127.0.0.1';
|
|
4136
|
+
this.path = path || '/';
|
|
4137
|
+
this.headers = headers || {};
|
|
4138
|
+
this.query = query || {};
|
|
4139
|
+
this.body = body || null;
|
|
4140
|
+
this.cookies = cookies || {};
|
|
4141
|
+
this.httpVersion = httpVersion || '1.1';
|
|
4142
|
+
}
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
const MetricsManager = {
|
|
4146
|
+
getPrometheusMetrics(securityConfig = {}) {
|
|
4147
|
+
let metrics = `# HELP fingerprint_requests_total Total requests processed.\n# TYPE fingerprint_requests_total counter\nfingerprint_requests_total{status="passed"} 1\n`;
|
|
4148
|
+
|
|
4149
|
+
if (securityConfig.weights) {
|
|
4150
|
+
metrics += `\n# HELP fingerprint_security_weight Active weight for each suspicion indicator.\n# TYPE fingerprint_security_weight gauge\n`;
|
|
4151
|
+
for (const [indicator, weight] of Object.entries(securityConfig.weights)) {
|
|
4152
|
+
if (typeof weight === 'number') {
|
|
4153
|
+
metrics += `fingerprint_security_weight{indicator="${indicator}"} ${weight}\n`;
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
if (securityConfig.thresholds) {
|
|
4159
|
+
metrics += `\n# HELP fingerprint_security_threshold Active score threshold for each enforcement action level.\n# TYPE fingerprint_security_threshold gauge\n`;
|
|
4160
|
+
for (const [level, threshold] of Object.entries(securityConfig.thresholds)) {
|
|
4161
|
+
if (typeof threshold === 'number') {
|
|
4162
|
+
metrics += `fingerprint_security_threshold{level="${level}"} ${threshold}\n`;
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
4166
|
+
|
|
4167
|
+
// Include auto-tuning objectives metrics if the auto-tuner has run
|
|
4168
|
+
if (lastBestSolution && lastBestSolution.objectives) {
|
|
4169
|
+
metrics += `\n# HELP fingerprint_autotuning_false_positive_rate Current false positive rate calculated by the auto-tuner.\n# TYPE fingerprint_autotuning_false_positive_rate gauge\nfingerprint_autotuning_false_positive_rate ${lastBestSolution.objectives[0]}\n`;
|
|
4170
|
+
metrics += `\n# HELP fingerprint_autotuning_false_negative_rate Current false negative rate calculated by the auto-tuner.\n# TYPE fingerprint_autotuning_false_negative_rate gauge\nfingerprint_autotuning_false_negative_rate ${lastBestSolution.objectives[1]}\n`;
|
|
4171
|
+
}
|
|
4172
|
+
|
|
4173
|
+
return metrics;
|
|
4174
|
+
}
|
|
4175
|
+
};
|
|
4176
|
+
|
|
4177
|
+
/**
|
|
4178
|
+
* Gère une requête vers le point de terminaison /metrics, en appliquant les règles d'autorisation.
|
|
4179
|
+
* Si les métriques sont activées et autorisées, elle renvoie les métriques au format Prometheus.
|
|
4180
|
+
* Sinon, elle gère l'accès non autorisé ou renvoie un 404 si les métriques ne sont pas activées.
|
|
4181
|
+
*
|
|
4182
|
+
* @param {object} req L'objet requête Express.
|
|
4183
|
+
* @param {object} res L'objet réponse Express.
|
|
4184
|
+
* @param {object} securityConfig La configuration de sécurité.
|
|
4185
|
+
*/
|
|
4186
|
+
export async function handleMetricsRequest(req, res, securityConfig) {
|
|
4187
|
+
// 2. Appliquer le callback d'autorisation personnalisé si défini.
|
|
4188
|
+
const authorizationCallback = securityConfig.metricsAuthorizationCallback;
|
|
4189
|
+
if (typeof authorizationCallback === 'function') {
|
|
4190
|
+
const context = new RequestContext(
|
|
4191
|
+
req.ip,
|
|
4192
|
+
req.path,
|
|
4193
|
+
req.headers,
|
|
4194
|
+
req.query,
|
|
4195
|
+
req.body,
|
|
4196
|
+
req.cookies,
|
|
4197
|
+
req.httpVersion
|
|
4198
|
+
);
|
|
4199
|
+
|
|
4200
|
+
const decision = await authorizationCallback(context); // Supposons que le callback peut être asynchrone
|
|
4201
|
+
|
|
4202
|
+
if (typeof decision === 'boolean') {
|
|
4203
|
+
if (!decision) {
|
|
4204
|
+
res.status(403).send('Access to metrics denied.');
|
|
4205
|
+
return;
|
|
4206
|
+
}
|
|
4207
|
+
} else if (typeof decision === 'object' && decision !== null && decision.action) {
|
|
4208
|
+
if (decision.action === 'block') {
|
|
4209
|
+
res.status(decision.status || 403).send(decision.body || 'Access denied.');
|
|
4210
|
+
return;
|
|
4211
|
+
} else if (decision.action === 'redirect') {
|
|
4212
|
+
res.redirect(decision.status || 302, decision.path);
|
|
4213
|
+
return;
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
4216
|
+
}
|
|
4217
|
+
|
|
4218
|
+
// 3. Si autorisé, servir les métriques.
|
|
4219
|
+
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
4220
|
+
res.send(MetricsManager.getPrometheusMetrics(securityConfig));
|
|
4221
|
+
}
|