@anonympins/fingerprint 0.3.5 → 0.3.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/CHANGELOG.md +21 -0
- package/README.md +1087 -1080
- package/package.json +1 -1
- package/src/js/fingerprint.js +168 -41
- package/src/php/Tests/RequestUtilsTest.php +65 -0
- package/src/php/Utils/RequestUtils.php +171 -1
- package/src/php/bin/auto-tune.php +118 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anonympins/fingerprint",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Advanced anti-bot library for Node.js using multi-layer fingerprinting (JA3, client-side, headers), behavioral analysis, and adaptive Proof-of-Work (PoW) challenges to mitigate scraping, scalping, and automated threats.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
package/src/js/fingerprint.js
CHANGED
|
@@ -769,6 +769,8 @@ export const verifyPoWAndGenerateTicket = (
|
|
|
769
769
|
nonce,
|
|
770
770
|
solution,
|
|
771
771
|
difficulty = 4,
|
|
772
|
+
deviceId = '',
|
|
773
|
+
deviceHash = ''
|
|
772
774
|
) => {
|
|
773
775
|
// 1. Verify the solution: hash(ip + nonce + solution) must start with N zeros
|
|
774
776
|
const hash = crypto
|
|
@@ -784,10 +786,10 @@ export const verifyPoWAndGenerateTicket = (
|
|
|
784
786
|
const expiry = Date.now() + 3600000; // 1 heure
|
|
785
787
|
const signature = crypto
|
|
786
788
|
.createHmac("sha256", getPowSecret())
|
|
787
|
-
.update(`${ip}:${
|
|
789
|
+
.update(`${expiry}:${ip}:${deviceId}:${deviceHash}`)
|
|
788
790
|
.digest("hex");
|
|
789
791
|
|
|
790
|
-
return `${expiry}
|
|
792
|
+
return `${expiry}|${ip}|${signature}`;
|
|
791
793
|
};
|
|
792
794
|
|
|
793
795
|
|
|
@@ -822,23 +824,61 @@ export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret =
|
|
|
822
824
|
}
|
|
823
825
|
return finalHash === parseInt(solution, 10);
|
|
824
826
|
};
|
|
825
|
-
export const isTicketValid = (ip, ticket) => {
|
|
827
|
+
export const isTicketValid = (ip, ticket, deviceId = '', deviceHash = '') => {
|
|
826
828
|
// Input validation: ensure the ticket is a non-empty string with the correct format.
|
|
827
|
-
if (typeof ticket !== 'string'
|
|
828
|
-
|
|
829
|
+
if (typeof ticket !== 'string') return false;
|
|
830
|
+
|
|
831
|
+
let expiry, originalIp, sig;
|
|
832
|
+
if (ticket.includes('|')) {
|
|
833
|
+
const parts = ticket.split('|');
|
|
834
|
+
if (parts.length < 3) return false;
|
|
835
|
+
[expiry, originalIp, sig] = parts;
|
|
836
|
+
} else if (ticket.includes(':')) {
|
|
837
|
+
// Legacy fallback format
|
|
838
|
+
const parts = ticket.split(':');
|
|
839
|
+
if (parts.length < 2) return false;
|
|
840
|
+
[expiry, sig] = parts;
|
|
841
|
+
originalIp = ip;
|
|
842
|
+
} else {
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
845
|
+
|
|
829
846
|
if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
847
|
+
|
|
848
|
+
let expectedSig;
|
|
849
|
+
if (ticket.includes('|')) {
|
|
850
|
+
expectedSig = crypto
|
|
851
|
+
.createHmac("sha256", getPowSecret())
|
|
852
|
+
.update(`${expiry}:${originalIp}:${deviceId}:${deviceHash}`)
|
|
853
|
+
.digest("hex");
|
|
854
|
+
} else {
|
|
855
|
+
// Legacy expected signature
|
|
856
|
+
expectedSig = crypto
|
|
857
|
+
.createHmac("sha256", getPowSecret())
|
|
858
|
+
.update(`${ip}:${expiry}`)
|
|
859
|
+
.digest("hex");
|
|
860
|
+
}
|
|
834
861
|
|
|
835
862
|
// Use timingSafeEqual to prevent timing attacks
|
|
836
863
|
try {
|
|
837
|
-
|
|
864
|
+
const isSigValid = crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
|
|
865
|
+
if (!isSigValid) return false;
|
|
838
866
|
} catch (e) {
|
|
839
|
-
// This can happen if the buffers have different lengths, which is a failure case.
|
|
840
867
|
return false;
|
|
841
868
|
}
|
|
869
|
+
|
|
870
|
+
if (!ticket.includes('|')) {
|
|
871
|
+
return ip === originalIp;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Roaming & Terminal Identity checks:
|
|
875
|
+
if (ip === originalIp) return true;
|
|
876
|
+
const currentSubnet = getIpSubnet(ip);
|
|
877
|
+
const originalSubnet = getIpSubnet(originalIp);
|
|
878
|
+
if (currentSubnet && originalSubnet && currentSubnet === originalSubnet) return true;
|
|
879
|
+
|
|
880
|
+
// Perfect terminal identity matched via HMAC signature
|
|
881
|
+
return !!(deviceId && deviceHash);
|
|
842
882
|
};
|
|
843
883
|
|
|
844
884
|
|
|
@@ -1778,6 +1818,22 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
1778
1818
|
}
|
|
1779
1819
|
}
|
|
1780
1820
|
|
|
1821
|
+
// Détection d'énumération de chemins (crawling/scraping de ressources séquentielles)
|
|
1822
|
+
let enumerationScore = 0;
|
|
1823
|
+
if (history.length >= 3) {
|
|
1824
|
+
const templates = history.map(h => h.path.replace(/\d+/g, '{num}'));
|
|
1825
|
+
const uniquePaths = new Set(history.map(h => h.path));
|
|
1826
|
+
|
|
1827
|
+
const templateCounts = {};
|
|
1828
|
+
templates.forEach(t => templateCounts[t] = (templateCounts[t] || 0) + 1);
|
|
1829
|
+
|
|
1830
|
+
const maxTemplateRepetition = Math.max(...Object.values(templateCounts), 0);
|
|
1831
|
+
// Si une même structure de route est répétée mais sur des URLs réelles différentes
|
|
1832
|
+
if (maxTemplateRepetition >= 3 && uniquePaths.size === history.length) {
|
|
1833
|
+
enumerationScore = patternWeight * 0.8; // Appliquer une forte pénalité
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1781
1837
|
// Garder l'historique à une taille raisonnable
|
|
1782
1838
|
if (history.length > historySize) {
|
|
1783
1839
|
history.shift();
|
|
@@ -1796,7 +1852,7 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
1796
1852
|
}
|
|
1797
1853
|
newPatternScore = Math.max(0, newPatternScore);
|
|
1798
1854
|
|
|
1799
|
-
deviceData.lastPatternScore = newPatternScore + instantScore;
|
|
1855
|
+
deviceData.lastPatternScore = newPatternScore + instantScore + enumerationScore;
|
|
1800
1856
|
|
|
1801
1857
|
return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
|
|
1802
1858
|
}
|
|
@@ -2341,6 +2397,8 @@ export function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
2341
2397
|
nonce,
|
|
2342
2398
|
solution,
|
|
2343
2399
|
challengeContext = {}, // Le contexte complet du challenge est maintenant passé
|
|
2400
|
+
deviceId = '',
|
|
2401
|
+
deviceHash = ''
|
|
2344
2402
|
) {
|
|
2345
2403
|
const { cpuTarget, baseBlock } = challengeContext;
|
|
2346
2404
|
if (!cpuTarget || !baseBlock) {
|
|
@@ -2392,9 +2450,9 @@ export function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
2392
2450
|
const expiry = Date.now() + (ticketTtl || 3600000); // Calcule l'expiration à partir du TTL
|
|
2393
2451
|
const signature = crypto
|
|
2394
2452
|
.createHmac("sha256", getPowSecret())
|
|
2395
|
-
.update(`${clientIp}:${
|
|
2453
|
+
.update(`${expiry}:${clientIp}:${deviceId}:${deviceHash}`)
|
|
2396
2454
|
.digest("hex");
|
|
2397
|
-
return `${expiry}
|
|
2455
|
+
return `${expiry}|${clientIp}|${signature}`;
|
|
2398
2456
|
}
|
|
2399
2457
|
|
|
2400
2458
|
return null;
|
|
@@ -2474,7 +2532,7 @@ export class FingerprintEngine {
|
|
|
2474
2532
|
console.log(`[FingerprintEngine] ${message}`, data);
|
|
2475
2533
|
}
|
|
2476
2534
|
}
|
|
2477
|
-
calculateFinalScore
|
|
2535
|
+
calculateFinalScore(suspicionVector) {
|
|
2478
2536
|
const { weights } = this.securityConfig;
|
|
2479
2537
|
if (!weights) return 0;
|
|
2480
2538
|
|
|
@@ -2760,6 +2818,11 @@ export class FingerprintEngine {
|
|
|
2760
2818
|
return { action: 'next', score: 0, vector: {} };
|
|
2761
2819
|
}
|
|
2762
2820
|
|
|
2821
|
+
// Resolve identity and check for persisted "condemned" status early.
|
|
2822
|
+
const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
|
|
2823
|
+
const currentDeviceHash = getCompositeDeviceHash(requestContext);
|
|
2824
|
+
const isNewDevice = !!newCookie;
|
|
2825
|
+
|
|
2763
2826
|
// 1. Check static IP allowlist first for maximum performance.
|
|
2764
2827
|
if (this._isIpInAllowlist(clientIp)) {
|
|
2765
2828
|
this._log('IP in allowlist - allowing request', { clientIp });
|
|
@@ -2798,7 +2861,7 @@ export class FingerprintEngine {
|
|
|
2798
2861
|
// If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
|
|
2799
2862
|
if (pow_nonce) {
|
|
2800
2863
|
const powCookie = cookies?.pow_clearance;
|
|
2801
|
-
if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
|
|
2864
|
+
if (!isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash)) { // Only check if there's no valid ticket
|
|
2802
2865
|
// This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
|
|
2803
2866
|
// The final decision is made later, after calculating the score.
|
|
2804
2867
|
}
|
|
@@ -2810,10 +2873,6 @@ export class FingerprintEngine {
|
|
|
2810
2873
|
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
|
|
2811
2874
|
}
|
|
2812
2875
|
|
|
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
2876
|
this._log('Identity resolved', { deviceId, isNewDevice, hasDeviceData: !!deviceData });
|
|
2818
2877
|
|
|
2819
2878
|
if (deviceData?.condemned) {
|
|
@@ -2908,7 +2967,26 @@ export class FingerprintEngine {
|
|
|
2908
2967
|
});
|
|
2909
2968
|
|
|
2910
2969
|
let isValid = false;
|
|
2911
|
-
|
|
2970
|
+
let challengeContext = await store.get(`secret:${pow_nonce}`);
|
|
2971
|
+
|
|
2972
|
+
// SECURITY: Verify that the retrieved context has not been tampered with
|
|
2973
|
+
if (challengeContext && challengeContext.signature) {
|
|
2974
|
+
const payloadToSign = `${challengeContext.clientSecret}:${challengeContext.cpuTarget}:${challengeContext.fingerprint}:${challengeContext.memDifficulty}:${challengeContext.originalPath}:${clientIp}`;
|
|
2975
|
+
const expectedSignature = crypto.createHmac("sha256", getPowSecret()).update(payloadToSign).digest("hex");
|
|
2976
|
+
try {
|
|
2977
|
+
const isSignatureValid = crypto.timingSafeEqual(
|
|
2978
|
+
Buffer.from(challengeContext.signature, 'hex'),
|
|
2979
|
+
Buffer.from(expectedSignature, 'hex')
|
|
2980
|
+
);
|
|
2981
|
+
if (!isSignatureValid) {
|
|
2982
|
+
this._log('Challenge context signature invalid - storage tampering detected!', { nonce: pow_nonce });
|
|
2983
|
+
challengeContext = null; // Invalidate context immediately
|
|
2984
|
+
}
|
|
2985
|
+
} catch (e) {
|
|
2986
|
+
this._log('Error validating challenge context signature:', e);
|
|
2987
|
+
challengeContext = null;
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2912
2990
|
let ticket = null;
|
|
2913
2991
|
// Déclarer optimalTtl ici avec une valeur par défaut
|
|
2914
2992
|
let optimalTtl = this.securityConfig.ticketMaxAge || 3600000;
|
|
@@ -2957,11 +3035,12 @@ export class FingerprintEngine {
|
|
|
2957
3035
|
|
|
2958
3036
|
if ((pow_type === "cpu_target" || !pow_type) && (pow_solution_cpu || pow_solution)) { // !pow_type pour compatibilité
|
|
2959
3037
|
const cpuSolution = pow_solution_cpu || pow_solution;
|
|
2960
|
-
|
|
3038
|
+
ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext, deviceId, currentDeviceHash);
|
|
2961
3039
|
isValid = ticket !== null;
|
|
2962
3040
|
this._log('CPU target challenge verification', { isValid });
|
|
2963
|
-
} else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) {
|
|
2964
|
-
|
|
3041
|
+
} else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) {
|
|
3042
|
+
const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext, deviceId, currentDeviceHash);
|
|
3043
|
+
const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret); // Memory PoW is independent of fingerprint
|
|
2965
3044
|
isValid = cpuTicket !== null && isMemValid;
|
|
2966
3045
|
if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
|
|
2967
3046
|
this._log('Combined CPU+Memory challenge verification', {
|
|
@@ -3087,7 +3166,10 @@ export class FingerprintEngine {
|
|
|
3087
3166
|
if (challengeContext) {
|
|
3088
3167
|
try {
|
|
3089
3168
|
const workResult = JSON.parse(pow_solution_work_result);
|
|
3090
|
-
getProblemManager(
|
|
3169
|
+
getProblemManager({
|
|
3170
|
+
configPath: this.securityConfig.usefulWorkConfigPath,
|
|
3171
|
+
config: this.securityConfig.usefulWorkConfig
|
|
3172
|
+
}, store).integrateSolution(pow_problem_id, workResult);
|
|
3091
3173
|
|
|
3092
3174
|
await store.delete(`secret:${pow_nonce}`);
|
|
3093
3175
|
// Accorder un ticket de passage comme pour un PoW normal
|
|
@@ -3153,7 +3235,7 @@ export class FingerprintEngine {
|
|
|
3153
3235
|
// 1. La requête est suspecte ET il n'y a pas de ticket valide.
|
|
3154
3236
|
// OU
|
|
3155
3237
|
// 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);
|
|
3238
|
+
const hasValidTicket = isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash);
|
|
3157
3239
|
const mustReChallenge = isSuspiciousHigh && hasValidTicket;
|
|
3158
3240
|
|
|
3159
3241
|
if (isSuspicious && (!hasValidTicket || mustReChallenge)) {
|
|
@@ -3197,7 +3279,10 @@ export class FingerprintEngine {
|
|
|
3197
3279
|
if (isSuspicious && shouldUseUsefulWork) {
|
|
3198
3280
|
this._log('Issuing a useful work challenge', { finalScore });
|
|
3199
3281
|
|
|
3200
|
-
const { problemId, task } = getProblemManager(
|
|
3282
|
+
const { problemId, task } = getProblemManager({
|
|
3283
|
+
configPath: this.securityConfig.usefulWorkConfigPath,
|
|
3284
|
+
config: this.securityConfig.usefulWorkConfig
|
|
3285
|
+
}, store).dispatchWork(suspicionFactor);
|
|
3201
3286
|
|
|
3202
3287
|
await store.set(`secret:${nonce}`, { clientSecret, originalPath: path }, 300);
|
|
3203
3288
|
|
|
@@ -3245,6 +3330,11 @@ export class FingerprintEngine {
|
|
|
3245
3330
|
|
|
3246
3331
|
// Store the entire challenge context with a short TTL (e.g., 5 minutes)
|
|
3247
3332
|
const baseBlock = createCpuChallengeBaseBlock(nonce, clientSecret, originalFingerprint);
|
|
3333
|
+
|
|
3334
|
+
// SECURITY: Cryptographically sign the payload before storing it to prevent database tampering
|
|
3335
|
+
const payloadToSign = `${clientSecret}:${cpuChallengeDetails.target}:${originalFingerprint}:${memDifficulty}:${path}:${clientIp}`;
|
|
3336
|
+
const signature = crypto.createHmac("sha256", getPowSecret()).update(payloadToSign).digest("hex");
|
|
3337
|
+
|
|
3248
3338
|
await store.set(`secret:${nonce}`, {
|
|
3249
3339
|
clientSecret,
|
|
3250
3340
|
cpuTarget: cpuChallengeDetails.target,
|
|
@@ -3253,6 +3343,7 @@ export class FingerprintEngine {
|
|
|
3253
3343
|
memDifficulty: memDifficulty,
|
|
3254
3344
|
baseBlock: baseBlock, // *** NOUVEAU: Le bloc de base est stocké pour la vérification ***
|
|
3255
3345
|
originalPath: path, // *** FIX: Store the original path ***
|
|
3346
|
+
signature, // *** NOUVEAU: Cryptographic signature to prevent storage tampering ***
|
|
3256
3347
|
}, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
|
|
3257
3348
|
|
|
3258
3349
|
// Associate the current challenge nonce with the device for trap URL verification later.
|
|
@@ -3302,7 +3393,7 @@ export class FingerprintEngine {
|
|
|
3302
3393
|
}
|
|
3303
3394
|
|
|
3304
3395
|
// 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) });
|
|
3396
|
+
this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash) });
|
|
3306
3397
|
|
|
3307
3398
|
if (logger) {
|
|
3308
3399
|
logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now(), vector: suspicionVector });
|
|
@@ -3751,6 +3842,7 @@ export const __internal = {
|
|
|
3751
3842
|
getTimeInconsistencyScore,
|
|
3752
3843
|
getClickVarianceScore, // NOUVEAU: Expose pour les tests
|
|
3753
3844
|
getTlsFingerprint, // NOUVEAU: Expose pour les tests
|
|
3845
|
+
sanitizeTrafficData, // NOUVEAU: Expose pour l'auto-tuner/tests
|
|
3754
3846
|
getTlsSpoofingScore, // NOUVEAU: Expose pour les tests
|
|
3755
3847
|
parseJa3,
|
|
3756
3848
|
generateCpuTargetChallengePage,
|
|
@@ -3769,25 +3861,60 @@ export const __internal = {
|
|
|
3769
3861
|
let autoTuningJobId = null;
|
|
3770
3862
|
let lastBestSolution = null; // NOUVEAU: Stocke la meilleure solution trouvée
|
|
3771
3863
|
|
|
3864
|
+
/**
|
|
3865
|
+
* Assainit les données de trafic pour l'auto-tuner afin de prévenir les attaques par empoisonnement.
|
|
3866
|
+
* Limite la contribution de chaque deviceId à un pourcentage maximum (ex: 2%) du jeu de données total.
|
|
3867
|
+
* @export
|
|
3868
|
+
* @param {Array<object>} trafficData
|
|
3869
|
+
* @returns {Array<object>}
|
|
3870
|
+
*/
|
|
3871
|
+
export function sanitizeTrafficData(trafficData) {
|
|
3872
|
+
if (!trafficData || trafficData.length === 0) {
|
|
3873
|
+
return [];
|
|
3874
|
+
}
|
|
3875
|
+
const tempSanitized = [];
|
|
3876
|
+
const deviceCounts = new Map();
|
|
3877
|
+
const maxLogsPerDevice = Math.max(3, Math.floor(trafficData.length * 0.02)); // Max 2% contribution per device
|
|
3878
|
+
|
|
3879
|
+
for (const log of trafficData) {
|
|
3880
|
+
const devId = log.deviceId || 'anonymous';
|
|
3881
|
+
const currentCount = deviceCounts.get(devId) || 0;
|
|
3882
|
+
if (currentCount < maxLogsPerDevice) {
|
|
3883
|
+
deviceCounts.set(devId, currentCount + 1);
|
|
3884
|
+
tempSanitized.push(log);
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
const passedLogs = tempSanitized.filter(log => log.type === 'request_passed');
|
|
3889
|
+
const suspiciousLogs = tempSanitized.filter(log => log.type !== 'request_passed');
|
|
3890
|
+
|
|
3891
|
+
const minDataPoints = 200; // Seuil par défaut
|
|
3892
|
+
const maxPassedAllowed = Math.max(minDataPoints, suspiciousLogs.length * 9);
|
|
3893
|
+
const shuffledPassed = passedLogs.sort(() => 0.5 - Math.random());
|
|
3894
|
+
const selectedPassed = shuffledPassed.slice(0, maxPassedAllowed);
|
|
3895
|
+
|
|
3896
|
+
return [...suspiciousLogs, ...selectedPassed];
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3772
3899
|
/**
|
|
3773
3900
|
* Executes a threshold optimization pass using collected traffic data.
|
|
3774
3901
|
* @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
3902
|
*/
|
|
3781
3903
|
function runThresholdOptimization(securityConfig, trafficData, minDataPoints, maxDataPoints, savePath) {
|
|
3782
|
-
const
|
|
3783
|
-
|
|
3904
|
+
const sanitizedData = sanitizeTrafficData(trafficData);
|
|
3905
|
+
|
|
3906
|
+
const highConfidenceLogs = sanitizedData.filter(log => log.type === 'challenge_solved' || log.type === 'trap_triggered').length;
|
|
3907
|
+
const highConfidenceRatio = sanitizedData.length > 0 ? highConfidenceLogs / sanitizedData.length : 0;
|
|
3784
3908
|
const MIN_CONFIDENCE_RATIO = 0.05; // Exiger au moins 5% de signaux forts.
|
|
3909
|
+
const MIN_HIGH_CONFIDENCE_COUNT = 10; // Absolu de secours pour éviter le gel lors de floods
|
|
3910
|
+
|
|
3911
|
+
const hasEnoughSignal = highConfidenceRatio >= MIN_CONFIDENCE_RATIO || highConfidenceLogs >= MIN_HIGH_CONFIDENCE_COUNT;
|
|
3785
3912
|
|
|
3786
|
-
if (
|
|
3787
|
-
if (
|
|
3788
|
-
console.log(`[AutoTuning] Reporté : ${
|
|
3913
|
+
if (sanitizedData.length < minDataPoints || !hasEnoughSignal) {
|
|
3914
|
+
if (sanitizedData.length < minDataPoints) {
|
|
3915
|
+
console.log(`[AutoTuning] Reporté : ${sanitizedData.length}/${minDataPoints} points de données.`);
|
|
3789
3916
|
} else {
|
|
3790
|
-
console.log(`[AutoTuning] Reporté :
|
|
3917
|
+
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
3918
|
}
|
|
3792
3919
|
return;
|
|
3793
3920
|
}
|
|
@@ -3797,9 +3924,9 @@ function runThresholdOptimization(securityConfig, trafficData, minDataPoints, ma
|
|
|
3797
3924
|
trafficData.splice(0, trafficData.length - maxDataPoints);
|
|
3798
3925
|
}
|
|
3799
3926
|
|
|
3800
|
-
console.log(`[AutoTuning] Démarrage du cycle d'optimisation complet avec ${
|
|
3927
|
+
console.log(`[AutoTuning] Démarrage du cycle d'optimisation complet avec ${sanitizedData.length} points de données assainis.`);
|
|
3801
3928
|
|
|
3802
|
-
const paretoFront = Optimization.Operators.solveFullSecurityTuning({ trafficData });
|
|
3929
|
+
const paretoFront = Optimization.Operators.solveFullSecurityTuning({ trafficData: sanitizedData });
|
|
3803
3930
|
|
|
3804
3931
|
if (!paretoFront || paretoFront.length === 0) {
|
|
3805
3932
|
console.warn("[AutoTuning] L'optimisation n'a retourné aucune solution.");
|
|
@@ -78,4 +78,69 @@ class RequestUtilsTest extends TestCase
|
|
|
78
78
|
$result = RequestUtils::getClickVarianceScore($context);
|
|
79
79
|
$this->assertEquals(0.0, $result['clickVarianceScore']);
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
public function testSanitizeTrafficDataFiltersSybilAttacks(): void
|
|
83
|
+
{
|
|
84
|
+
$trafficData = [];
|
|
85
|
+
// Ajout de 100 logs provenant d'un attaquant (Sybil)
|
|
86
|
+
for ($i = 0; $i < 100; $i++) {
|
|
87
|
+
$trafficData[] = ['deviceId' => 'attacker_device', 'type' => 'trap_triggered'];
|
|
88
|
+
}
|
|
89
|
+
// Ajout de 10 logs d'utilisateurs légitimes distincts
|
|
90
|
+
for ($i = 0; $i < 10; $i++) {
|
|
91
|
+
$trafficData[] = ['deviceId' => "legit_device_{$i}", 'type' => 'challenge_solved'];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
$sanitized = RequestUtils::sanitizeTrafficData($trafficData);
|
|
95
|
+
|
|
96
|
+
$attackerLogs = array_filter($sanitized, fn($log) => $log['deviceId'] === 'attacker_device');
|
|
97
|
+
|
|
98
|
+
// Total de 110 logs. 2% de 110 est 2.2 -> max(3, 2) = 3 logs maximum autorisés pour l'attaquant.
|
|
99
|
+
$this->assertLessThanOrEqual(3, count($attackerLogs));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public function testChallengePayloadSigningAndVerification(): void
|
|
103
|
+
{
|
|
104
|
+
$secret = 'test-fallback-dev-secret-32-chars-minimum';
|
|
105
|
+
$clientIp = '203.0.113.42';
|
|
106
|
+
$payload = [
|
|
107
|
+
'clientSecret' => 'some_client_secret',
|
|
108
|
+
'cpuTarget' => '00000000ffffffff',
|
|
109
|
+
'fingerprint' => 'os:hash|gpu:hash2',
|
|
110
|
+
'memDifficulty' => '16',
|
|
111
|
+
'originalPath' => '/submit',
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
// 1. Cas nominal : Signature et vérification réussies
|
|
115
|
+
$signature = RequestUtils::signChallengePayload($secret, $payload, $clientIp);
|
|
116
|
+
$this->assertNotEmpty($signature);
|
|
117
|
+
|
|
118
|
+
$payloadWithSig = $payload;
|
|
119
|
+
$payloadWithSig['signature'] = $signature;
|
|
120
|
+
|
|
121
|
+
$isValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, $clientIp);
|
|
122
|
+
$this->assertTrue($isValid, "La signature valide doit être acceptée.");
|
|
123
|
+
|
|
124
|
+
// 2. Détection de modification (tampering) sur cpuTarget
|
|
125
|
+
$tamperedPayload = $payloadWithSig;
|
|
126
|
+
$tamperedPayload['cpuTarget'] = 'ffffffffffffffff'; // Tentative de baisse de la difficulté
|
|
127
|
+
|
|
128
|
+
$isTamperedValid = RequestUtils::verifyChallengePayload($secret, $tamperedPayload, $clientIp);
|
|
129
|
+
$this->assertFalse($isTamperedValid, "Un payload modifié doit être rejeté.");
|
|
130
|
+
|
|
131
|
+
// 3. Détection de modification sur le fingerprint
|
|
132
|
+
$tamperedFpPayload = $payloadWithSig;
|
|
133
|
+
$tamperedFpPayload['fingerprint'] = 'os:another_hash|gpu:hash2';
|
|
134
|
+
|
|
135
|
+
$isTamperedFpValid = RequestUtils::verifyChallengePayload($secret, $tamperedFpPayload, $clientIp);
|
|
136
|
+
$this->assertFalse($isTamperedFpValid, "Un fingerprint modifié doit être rejeté.");
|
|
137
|
+
|
|
138
|
+
// 4. Détection d'usurpation d'adresse IP (IP mismatch)
|
|
139
|
+
$isIpMismatchValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, '198.51.100.1');
|
|
140
|
+
$this->assertFalse($isIpMismatchValid, "Le payload ne doit pas être valide pour une autre adresse IP.");
|
|
141
|
+
|
|
142
|
+
// 5. Absence de signature
|
|
143
|
+
$isMissingSigValid = RequestUtils::verifyChallengePayload($secret, $payload, $clientIp);
|
|
144
|
+
$this->assertFalse($isMissingSigValid, "Un payload sans signature doit être rejeté.");
|
|
145
|
+
}
|
|
81
146
|
}
|
|
@@ -631,6 +631,25 @@ class RequestUtils
|
|
|
631
631
|
}
|
|
632
632
|
}
|
|
633
633
|
|
|
634
|
+
// Détection d'énumération de chemins (crawling/scraping de ressources séquentielles)
|
|
635
|
+
$enumerationScore = 0;
|
|
636
|
+
if (count($history) >= 3) {
|
|
637
|
+
$templates = array_map(function($h) {
|
|
638
|
+
return preg_replace('/\d+/', '{num}', $h['path']);
|
|
639
|
+
}, $history);
|
|
640
|
+
|
|
641
|
+
$uniquePaths = array_unique(array_map(function($h) {
|
|
642
|
+
return $h['path'];
|
|
643
|
+
}, $history));
|
|
644
|
+
|
|
645
|
+
$templateCounts = array_count_values($templates);
|
|
646
|
+
$maxTemplateRepetition = !empty($templateCounts) ? max($templateCounts) : 0;
|
|
647
|
+
|
|
648
|
+
if ($maxTemplateRepetition >= 3 && count($uniquePaths) === count($history)) {
|
|
649
|
+
$enumerationScore = $patternWeight * 0.8;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
634
653
|
// Logique de décroissance et de score final
|
|
635
654
|
$newPatternScore = $deviceData['lastPatternScore'] ?? 0;
|
|
636
655
|
|
|
@@ -641,7 +660,7 @@ class RequestUtils
|
|
|
641
660
|
}
|
|
642
661
|
$newPatternScore = max(0, $newPatternScore);
|
|
643
662
|
|
|
644
|
-
$deviceData['lastPatternScore'] = $newPatternScore + $instantScore;
|
|
663
|
+
$deviceData['lastPatternScore'] = $newPatternScore + $instantScore + $enumerationScore;
|
|
645
664
|
|
|
646
665
|
return ['requestPatternScore' => min(100.0, $deviceData['lastPatternScore'])];
|
|
647
666
|
}
|
|
@@ -970,4 +989,155 @@ class RequestUtils
|
|
|
970
989
|
$newScore = min(100.0, max(0.0, $current + $change));
|
|
971
990
|
$store->set($key, ['score' => $newScore, 'lastUpdate' => time()], 86400 * 7); // TTL de 7 jours
|
|
972
991
|
}
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Assainit les données de trafic pour l'auto-tuner afin de prévenir les attaques par empoisonnement.
|
|
996
|
+
* Limite la contribution de chaque deviceId à un pourcentage maximum (ex: 2%) du jeu de données total.
|
|
997
|
+
*
|
|
998
|
+
* @param array<int, array<string, mixed>> $trafficData
|
|
999
|
+
* @return array<int, array<string, mixed>>
|
|
1000
|
+
*/
|
|
1001
|
+
public static function sanitizeTrafficData(array $trafficData): array
|
|
1002
|
+
{
|
|
1003
|
+
if (empty($trafficData)) {
|
|
1004
|
+
return [];
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
$tempSanitized = [];
|
|
1008
|
+
$deviceCounts = [];
|
|
1009
|
+
$maxLogsPerDevice = max(3, (int)floor(count($trafficData) * 0.02));
|
|
1010
|
+
|
|
1011
|
+
foreach ($trafficData as $log) {
|
|
1012
|
+
$deviceId = $log['deviceId'] ?? 'anonymous';
|
|
1013
|
+
if (!isset($deviceCounts[$deviceId])) {
|
|
1014
|
+
$deviceCounts[$deviceId] = 0;
|
|
1015
|
+
}
|
|
1016
|
+
if ($deviceCounts[$deviceId] < $maxLogsPerDevice) {
|
|
1017
|
+
$deviceCounts[$deviceId]++;
|
|
1018
|
+
$tempSanitized[] = $log;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
$passedLogs = [];
|
|
1023
|
+
$suspiciousLogs = [];
|
|
1024
|
+
foreach ($tempSanitized as $log) {
|
|
1025
|
+
if (($log['type'] ?? '') === 'request_passed') {
|
|
1026
|
+
$passedLogs[] = $log;
|
|
1027
|
+
} else {
|
|
1028
|
+
$suspiciousLogs[] = $log;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
$minDataPoints = 200; // Seuil par défaut
|
|
1033
|
+
$maxPassedAllowed = max($minDataPoints, count($suspiciousLogs) * 9);
|
|
1034
|
+
|
|
1035
|
+
if (count($passedLogs) > $maxPassedAllowed) {
|
|
1036
|
+
shuffle($passedLogs);
|
|
1037
|
+
$passedLogs = array_slice($passedLogs, 0, $maxPassedAllowed);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return array_merge($suspiciousLogs, $passedLogs);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Génère une signature HMAC-SHA256 pour sécuriser les données du challenge stockées.
|
|
1045
|
+
* @param string $secret Le secret global (POW_SECRET).
|
|
1046
|
+
* @param array<string, mixed> $payload Les données du challenge.
|
|
1047
|
+
* @param string $clientIp L'IP du client pour lier la signature.
|
|
1048
|
+
* @return string
|
|
1049
|
+
*/
|
|
1050
|
+
public static function signChallengePayload(string $secret, array $payload, string $clientIp): string
|
|
1051
|
+
{
|
|
1052
|
+
$dataToSign = implode(':', [
|
|
1053
|
+
$payload['clientSecret'] ?? '',
|
|
1054
|
+
$payload['cpuTarget'] ?? '',
|
|
1055
|
+
$payload['fingerprint'] ?? '',
|
|
1056
|
+
$payload['memDifficulty'] ?? '',
|
|
1057
|
+
$payload['originalPath'] ?? '',
|
|
1058
|
+
$clientIp
|
|
1059
|
+
]);
|
|
1060
|
+
|
|
1061
|
+
return hash_hmac('sha256', $dataToSign, $secret);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Vérifie la signature HMAC-SHA256 des données de challenge récupérées du store.
|
|
1066
|
+
* @param string $secret Le secret global (POW_SECRET).
|
|
1067
|
+
* @param array<string, mixed> $payload Les données du challenge contenant la signature.
|
|
1068
|
+
* @param string $clientIp L'IP du client.
|
|
1069
|
+
* @return bool True si la signature est valide, false sinon.
|
|
1070
|
+
*/
|
|
1071
|
+
public static function verifyChallengePayload(string $secret, array $payload, string $clientIp): bool
|
|
1072
|
+
{
|
|
1073
|
+
if (empty($payload['signature'])) {
|
|
1074
|
+
return false;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
$storedSignature = $payload['signature'];
|
|
1078
|
+
$payloadWithoutSig = $payload;
|
|
1079
|
+
unset($payloadWithoutSig['signature']);
|
|
1080
|
+
|
|
1081
|
+
$expectedSignature = self::signChallengePayload($secret, $payloadWithoutSig, $clientIp);
|
|
1082
|
+
|
|
1083
|
+
return hash_equals($expectedSignature, $storedSignature);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Vérifie si un ticket de clearance (PoW) est valide, en supportant la tolérance au roaming.
|
|
1088
|
+
*
|
|
1089
|
+
* @param string $ip L'adresse IP de la requête courante.
|
|
1090
|
+
* @param string|null $ticket Le ticket de clearance extrait du cookie.
|
|
1091
|
+
* @param string $deviceId L'identifiant du cookie de l'appareil.
|
|
1092
|
+
* @param string $deviceHash L'empreinte matérielle calculée côté serveur.
|
|
1093
|
+
* @param string $secret La clé secrète (POW_SECRET).
|
|
1094
|
+
* @return bool True si le ticket est valide et correspond aux contraintes de sécurité.
|
|
1095
|
+
*/
|
|
1096
|
+
public static function isTicketValid(string $ip, ?string $ticket, string $deviceId = '', string $deviceHash = '', string $secret = ''): bool
|
|
1097
|
+
{
|
|
1098
|
+
if (empty($ticket)) {
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (str_contains($ticket, '|')) {
|
|
1103
|
+
$parts = explode('|', $ticket);
|
|
1104
|
+
if (count($parts) < 3) return false;
|
|
1105
|
+
[$expiry, $originalIp, $sig] = $parts;
|
|
1106
|
+
} elseif (str_contains($ticket, ':')) {
|
|
1107
|
+
// Fallback rétrocompatible pour les anciens tickets
|
|
1108
|
+
$parts = explode(':', $ticket);
|
|
1109
|
+
if (count($parts) < 2) return false;
|
|
1110
|
+
[$expiry, $sig] = $parts;
|
|
1111
|
+
$originalIp = $ip;
|
|
1112
|
+
} else {
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (empty($expiry) || empty($sig) || (time() * 1000) > (int)$expiry) {
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (str_contains($ticket, '|')) {
|
|
1121
|
+
$expectedSig = hash_hmac('sha256', "{$expiry}:{$originalIp}:{$deviceId}:{$deviceHash}", $secret);
|
|
1122
|
+
} else {
|
|
1123
|
+
$expectedSig = hash_hmac('sha256', "{$ip}:{$expiry}", $secret);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (!hash_equals($expectedSig, $sig)) {
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (!str_contains($ticket, '|')) {
|
|
1131
|
+
return $ip === $originalIp;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if ($ip === $originalIp) return true;
|
|
1135
|
+
$currentSubnet = self::getIpSubnet($ip);
|
|
1136
|
+
$originalSubnet = self::getIpSubnet($originalIp);
|
|
1137
|
+
if ($currentSubnet !== null && $originalSubnet !== null && $currentSubnet === $originalSubnet) {
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
return !empty($deviceId) && !empty($deviceHash); // Match d'identité matérielle stricte (deviceId + deviceHash validés par HMAC)
|
|
1142
|
+
}
|
|
973
1143
|
}
|