@anonympins/fingerprint 0.3.4 → 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 +225 -192
- package/README.md +1087 -1080
- package/package.json +1 -1
- package/src/js/fingerprint.js +403 -63
- package/src/php/FingerprintEngine.php +133 -6
- package/src/php/Ja3AnomalyDetector.php +228 -0
- package/src/php/Tests/Ja3AnomalyDetectorTest.php +180 -0
- package/src/php/Tests/RequestUtilsTest.php +65 -0
- package/src/php/Utils/RequestUtils.php +187 -6
- package/src/php/bin/auto-tune.php +118 -0
package/src/js/fingerprint.js
CHANGED
|
@@ -346,6 +346,30 @@ function getTlsFingerprint(context) {
|
|
|
346
346
|
}
|
|
347
347
|
return { ja3, ja4 };
|
|
348
348
|
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Analyses a raw JA3 string.
|
|
352
|
+
* Format: "TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurveFormats"
|
|
353
|
+
* @param {string} ja3String
|
|
354
|
+
* @returns {object|null}
|
|
355
|
+
*/
|
|
356
|
+
export function parseJa3(ja3String) {
|
|
357
|
+
if (!ja3String || typeof ja3String !== 'string') {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
const parts = ja3String.split(',');
|
|
361
|
+
if (parts.length !== 5) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
tlsVersion: parseInt(parts[0], 10),
|
|
366
|
+
ciphers: parts[1] !== '' ? parts[1].split('-').map(Number) : [],
|
|
367
|
+
extensions: parts[2] !== '' ? parts[2].split('-').map(Number) : [],
|
|
368
|
+
curves: parts[3] !== '' ? parts[3].split('-').map(Number) : [],
|
|
369
|
+
points: parts[4] !== '' ? parts[4].split('-').map(Number) : []
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
349
373
|
/**
|
|
350
374
|
* Creates a stable hash based on device characteristics, independent of the IP.
|
|
351
375
|
* This is our "level 2 fingerprint".
|
|
@@ -484,6 +508,17 @@ const tlsFingerprintDb = {
|
|
|
484
508
|
'c72366b9551263d990b7fa574225332c': 'curl', // curl 7.81.0
|
|
485
509
|
};
|
|
486
510
|
|
|
511
|
+
const GREASE_VALUES = [
|
|
512
|
+
2570, 6682, 10794, 14906, 19018, 23130, 27242, 31354,
|
|
513
|
+
35466, 39578, 43690, 47802, 51914, 55926, 60038, 64150
|
|
514
|
+
];
|
|
515
|
+
|
|
516
|
+
/** @private */
|
|
517
|
+
function hasGrease(values) {
|
|
518
|
+
if (!Array.isArray(values)) return false;
|
|
519
|
+
return values.some(val => GREASE_VALUES.includes(val));
|
|
520
|
+
}
|
|
521
|
+
|
|
487
522
|
// Fonctions utilitaires
|
|
488
523
|
function parseUserAgent(ua) {
|
|
489
524
|
// Parser basique du User-Agent
|
|
@@ -734,6 +769,8 @@ export const verifyPoWAndGenerateTicket = (
|
|
|
734
769
|
nonce,
|
|
735
770
|
solution,
|
|
736
771
|
difficulty = 4,
|
|
772
|
+
deviceId = '',
|
|
773
|
+
deviceHash = ''
|
|
737
774
|
) => {
|
|
738
775
|
// 1. Verify the solution: hash(ip + nonce + solution) must start with N zeros
|
|
739
776
|
const hash = crypto
|
|
@@ -749,10 +786,10 @@ export const verifyPoWAndGenerateTicket = (
|
|
|
749
786
|
const expiry = Date.now() + 3600000; // 1 heure
|
|
750
787
|
const signature = crypto
|
|
751
788
|
.createHmac("sha256", getPowSecret())
|
|
752
|
-
.update(`${ip}:${
|
|
789
|
+
.update(`${expiry}:${ip}:${deviceId}:${deviceHash}`)
|
|
753
790
|
.digest("hex");
|
|
754
791
|
|
|
755
|
-
return `${expiry}
|
|
792
|
+
return `${expiry}|${ip}|${signature}`;
|
|
756
793
|
};
|
|
757
794
|
|
|
758
795
|
|
|
@@ -787,23 +824,61 @@ export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret =
|
|
|
787
824
|
}
|
|
788
825
|
return finalHash === parseInt(solution, 10);
|
|
789
826
|
};
|
|
790
|
-
export const isTicketValid = (ip, ticket) => {
|
|
827
|
+
export const isTicketValid = (ip, ticket, deviceId = '', deviceHash = '') => {
|
|
791
828
|
// Input validation: ensure the ticket is a non-empty string with the correct format.
|
|
792
|
-
if (typeof ticket !== 'string'
|
|
793
|
-
|
|
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
|
+
|
|
794
846
|
if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
+
}
|
|
799
861
|
|
|
800
862
|
// Use timingSafeEqual to prevent timing attacks
|
|
801
863
|
try {
|
|
802
|
-
|
|
864
|
+
const isSigValid = crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
|
|
865
|
+
if (!isSigValid) return false;
|
|
803
866
|
} catch (e) {
|
|
804
|
-
// This can happen if the buffers have different lengths, which is a failure case.
|
|
805
867
|
return false;
|
|
806
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);
|
|
807
882
|
};
|
|
808
883
|
|
|
809
884
|
|
|
@@ -814,8 +889,9 @@ export const isTicketValid = (ip, ticket) => {
|
|
|
814
889
|
*/
|
|
815
890
|
function getHeaderAnomalies(context) {
|
|
816
891
|
let anomalyScore = 0;
|
|
892
|
+
const ua = context.headers["user-agent"] || '';
|
|
817
893
|
// Strong penalty if User-Agent is missing or very short (sign of a simple script)
|
|
818
|
-
if (!
|
|
894
|
+
if (!ua || ua.length < 10) {
|
|
819
895
|
anomalyScore += 60;
|
|
820
896
|
}
|
|
821
897
|
// Penalty if Accept-Language header is missing
|
|
@@ -827,6 +903,17 @@ function getHeaderAnomalies(context) {
|
|
|
827
903
|
anomalyScore += 15;
|
|
828
904
|
}
|
|
829
905
|
|
|
906
|
+
// TE: trailers check for Firefox on Desktop
|
|
907
|
+
const uaParts = parseUserAgent(ua);
|
|
908
|
+
const isFirefoxDesktop = uaParts.browser?.startsWith('Firefox') && uaParts.device === 'desktop';
|
|
909
|
+
const teHeader = context.headers['te'];
|
|
910
|
+
|
|
911
|
+
if (isFirefoxDesktop && teHeader !== 'trailers') {
|
|
912
|
+
anomalyScore += 30; // Suspicious: Firefox desktop missing TE: trailers
|
|
913
|
+
} else if (!isFirefoxDesktop && uaParts.device === 'desktop' && teHeader === 'trailers') {
|
|
914
|
+
anomalyScore += 30; // Suspicious: Non-Firefox desktop sending TE: trailers
|
|
915
|
+
}
|
|
916
|
+
|
|
830
917
|
return {
|
|
831
918
|
headerAnomalyScore: Math.min(100, anomalyScore),
|
|
832
919
|
};
|
|
@@ -1142,21 +1229,130 @@ function getCrossLayerInconsistency(context) {
|
|
|
1142
1229
|
return { crossLayerInconsistencyScore: 10 }; // Erreur de parsing = suspect.
|
|
1143
1230
|
}
|
|
1144
1231
|
}
|
|
1232
|
+
function parseJa4(ja4) {
|
|
1233
|
+
if (!ja4 || typeof ja4 !== 'string') return null;
|
|
1234
|
+
const parts = ja4.split('_');
|
|
1235
|
+
const ja4a = parts[0];
|
|
1236
|
+
if (ja4a.length < 10) return null;
|
|
1237
|
+
return {
|
|
1238
|
+
protocol: ja4a[0],
|
|
1239
|
+
version: ja4a.substring(1, 3),
|
|
1240
|
+
sni: ja4a[3],
|
|
1241
|
+
ciphersCount: parseInt(ja4a.substring(4, 6), 10) || 0,
|
|
1242
|
+
extensionsCount: parseInt(ja4a.substring(6, 8), 10) || 0,
|
|
1243
|
+
alpn: ja4a.substring(8, 10),
|
|
1244
|
+
ja4b: parts[1] || null,
|
|
1245
|
+
ja4c: parts[2] || null
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1145
1248
|
|
|
1146
1249
|
/**
|
|
1147
1250
|
* Calcule un score d'incohérence entre les données du fingerprint TLS (JA3/JA4) et les en-têtes serveur (User-Agent).
|
|
1148
1251
|
* Cela permet de détecter le spoofing de fingerprint TLS.
|
|
1149
1252
|
* @param {object} context - Le contexte de la requête.
|
|
1150
|
-
* @returns {{tlsSpoofingScore: number}}
|
|
1253
|
+
* @returns {Promise<{tlsSpoofingScore: number}>}
|
|
1151
1254
|
*/
|
|
1152
|
-
function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint) {
|
|
1153
|
-
|
|
1255
|
+
export function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint, customStore = null) {
|
|
1256
|
+
let actualGetTlsFingerprintFn = getTlsFingerprintFn;
|
|
1257
|
+
let actualStore = customStore || store;
|
|
1258
|
+
|
|
1259
|
+
// Detect if the second argument is actually a store (compatibility with tests)
|
|
1260
|
+
if (getTlsFingerprintFn && typeof getTlsFingerprintFn.get === 'function' && typeof getTlsFingerprintFn.set === 'function') {
|
|
1261
|
+
actualStore = getTlsFingerprintFn;
|
|
1262
|
+
actualGetTlsFingerprintFn = getTlsFingerprint;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const { ja3, ja4 } = actualGetTlsFingerprintFn(context) || { ja3: null, ja4: null }; // Defensive check
|
|
1154
1266
|
const ua = context.headers["user-agent"] || '';
|
|
1267
|
+
const ja3Raw = context.headers['x-ja3-raw'] || null;
|
|
1268
|
+
const httpVersion = context.httpVersion || '';
|
|
1269
|
+
|
|
1270
|
+
let score = 0;
|
|
1155
1271
|
|
|
1156
1272
|
// 1. Penalize if a TLS fingerprint is present but the User-Agent is generic or missing.
|
|
1157
1273
|
// This is a strong indicator of a non-browser client trying to look legitimate.
|
|
1158
1274
|
if ((ja3 || ja4) && (!ua || ua.length < 10 || ua.toLowerCase().includes('python') || ua.toLowerCase().includes('curl'))) {
|
|
1159
|
-
|
|
1275
|
+
score = Math.max(score, 50);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Check for known spoofed/suspicious JA4 fingerprints
|
|
1279
|
+
const spoofedJa4s = [
|
|
1280
|
+
't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
|
|
1281
|
+
't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
|
|
1282
|
+
't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
|
|
1283
|
+
't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
|
|
1284
|
+
't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
|
|
1285
|
+
't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
|
|
1286
|
+
];
|
|
1287
|
+
if (ja4 && spoofedJa4s.includes(ja4)) {
|
|
1288
|
+
score = Math.max(score, 100);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const claimedBrowser = parseUserAgent(ua).browser?.split('/')[0] || null;
|
|
1292
|
+
const isHumanBrowser = ['Chrome', 'Firefox', 'Safari', 'Edge'].includes(claimedBrowser);
|
|
1293
|
+
|
|
1294
|
+
// --- ANALYSE 2 : CONTRÔLE PROFOND SUR L'EMPREINTE BRUTE (RAW JA3) ---
|
|
1295
|
+
if (ja3Raw) {
|
|
1296
|
+
const parsed = parseJa3(ja3Raw);
|
|
1297
|
+
if (parsed) {
|
|
1298
|
+
// Contrôle A : Mécanisme GREASE pour Chrome / Edge (obligatoire)
|
|
1299
|
+
if (claimedBrowser === 'Chrome' || claimedBrowser === 'Edge') {
|
|
1300
|
+
const hasCiphersGrease = hasGrease(parsed.ciphers);
|
|
1301
|
+
const hasExtensionsGrease = hasGrease(parsed.extensions);
|
|
1302
|
+
|
|
1303
|
+
if (!hasCiphersGrease && !hasExtensionsGrease) {
|
|
1304
|
+
// Chrome ou Edge moderne sans GREASE = spoofing de bas niveau (ex: python-requests déguisé)
|
|
1305
|
+
score = Math.max(score, 75);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Contrôle B : HTTP/2 ou HTTP/3 sans négociation ALPN (Extension 16)
|
|
1310
|
+
const isH2OrHigher = (
|
|
1311
|
+
httpVersion.includes('2.0') ||
|
|
1312
|
+
httpVersion.includes('HTTP/2') ||
|
|
1313
|
+
httpVersion.includes('HTTP/3')
|
|
1314
|
+
);
|
|
1315
|
+
const hasAlpnExtension = parsed.extensions.includes(16);
|
|
1316
|
+
|
|
1317
|
+
if (isH2OrHigher && !hasAlpnExtension) {
|
|
1318
|
+
// Négociation HTTP/2 active au niveau serveur mais absente au niveau des extensions TLS du client
|
|
1319
|
+
score = Math.max(score, 70);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// Contrôle C : Version TLS obsolète négociée par un navigateur moderne (ex: TLS < 1.2, id < 771)
|
|
1323
|
+
if (isHumanBrowser && parsed.tlsVersion < 771) {
|
|
1324
|
+
score = Math.max(score, 80);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Parse JA4 if available for advanced checks
|
|
1330
|
+
if (ja4) {
|
|
1331
|
+
const parsedJa4 = parseJa4(ja4);
|
|
1332
|
+
if (parsedJa4) {
|
|
1333
|
+
const uaParts = parseUserAgent(ua);
|
|
1334
|
+
|
|
1335
|
+
// Check 1: Incohérence ALPN / HTTP Version
|
|
1336
|
+
if (parsedJa4.alpn === 'h2' && (context.httpVersion === '1.1' || context.httpVersion === '1.0')) {
|
|
1337
|
+
const hasProxy = context.headers['via'] || context.headers['forwarded'] || context.headers['x-forwarded-proto'] || context.headers['x-forwarded-for'];
|
|
1338
|
+
if (!hasProxy) {
|
|
1339
|
+
score = Math.max(score, 40);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Check 2: Incohérence OS/Plateforme vs Capabilities TLS
|
|
1344
|
+
if (parsedJa4.version === '12' && (uaParts.os === 'iOS' || uaParts.os === 'macOS') && uaParts.browser?.startsWith('Safari')) {
|
|
1345
|
+
score = Math.max(score, 60);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// Check 3: Incohérence User-Agent vs Signature JA4
|
|
1349
|
+
if (uaParts.browser?.startsWith('Chrome') && parsedJa4.alpn === '00') {
|
|
1350
|
+
score = Math.max(score, 50);
|
|
1351
|
+
}
|
|
1352
|
+
if (uaParts.browser?.startsWith('Firefox') && parsedJa4.extensionsCount > 15) {
|
|
1353
|
+
score = Math.max(score, 50);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1160
1356
|
}
|
|
1161
1357
|
|
|
1162
1358
|
// 2. If no JA3 hash is available, we cannot perform the consistency check.
|
|
@@ -1173,23 +1369,72 @@ function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint) {
|
|
|
1173
1369
|
// Parse the User-Agent to get the claimed browser.
|
|
1174
1370
|
const { browser: claimedBrowser } = parseUserAgent(ua);
|
|
1175
1371
|
|
|
1176
|
-
// Check
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1372
|
+
// Check 1: Known library JA3 with a human-claimed browser
|
|
1373
|
+
const isLibrary = expectedBrowsers.some(expected => ['Python', 'Go', 'Java', 'curl'].includes(expected));
|
|
1374
|
+
const claimsToBeHumanBrowser = claimedBrowser && (
|
|
1375
|
+
claimedBrowser.startsWith('Chrome') ||
|
|
1376
|
+
claimedBrowser.startsWith('Firefox') ||
|
|
1377
|
+
claimedBrowser.startsWith('Safari') ||
|
|
1378
|
+
claimedBrowser.startsWith('Edge')
|
|
1379
|
+
);
|
|
1380
|
+
|
|
1381
|
+
if (isLibrary && claimsToBeHumanBrowser) {
|
|
1382
|
+
score = Math.max(score, 90); // High confidence spoofing of library as browser
|
|
1383
|
+
} else {
|
|
1384
|
+
// Check if the claimed browser is one of the legitimate possibilities for this JA3 hash.
|
|
1385
|
+
const isMatch = expectedBrowsers.some(expected => claimedBrowser?.startsWith(expected));
|
|
1386
|
+
if (claimedBrowser && !isMatch) {
|
|
1387
|
+
score = Math.max(score, 80); // High score for a clear mismatch.
|
|
1388
|
+
}
|
|
1183
1389
|
}
|
|
1184
1390
|
}
|
|
1185
1391
|
}
|
|
1186
1392
|
|
|
1187
|
-
//
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1393
|
+
// Create the promise for the async part (Check 4)
|
|
1394
|
+
const promise = (async () => {
|
|
1395
|
+
let asyncScore = score;
|
|
1396
|
+
const uaParts = parseUserAgent(ua);
|
|
1397
|
+
|
|
1398
|
+
if (uaParts.browser) {
|
|
1399
|
+
const browserFamily = uaParts.browser.split('/')[0];
|
|
1400
|
+
|
|
1401
|
+
// Check 4a: Stagnation JA4
|
|
1402
|
+
if (ja4) {
|
|
1403
|
+
const parsedJa4 = parseJa4(ja4);
|
|
1404
|
+
if (parsedJa4) {
|
|
1405
|
+
const ja4Key = `ja4-browsers:${ja4}`;
|
|
1406
|
+
let seenBrowsers = await actualStore.get(ja4Key) || [];
|
|
1407
|
+
if (!Array.isArray(seenBrowsers)) seenBrowsers = [];
|
|
1408
|
+
if (browserFamily && !seenBrowsers.includes(browserFamily)) {
|
|
1409
|
+
seenBrowsers.push(browserFamily);
|
|
1410
|
+
await actualStore.set(ja4Key, seenBrowsers, 86400); // 24h cache
|
|
1411
|
+
}
|
|
1412
|
+
if (seenBrowsers.length > 1) {
|
|
1413
|
+
asyncScore = Math.max(asyncScore, 80);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Check 4b: JA3 MD5 stagnation with rotating browser UAs
|
|
1419
|
+
if (ja3 && browserFamily) {
|
|
1420
|
+
const ja3Key = `ja3-browsers:${ja3}`;
|
|
1421
|
+
let seenBrowsers = await actualStore.get(ja3Key) || [];
|
|
1422
|
+
if (!Array.isArray(seenBrowsers)) seenBrowsers = [];
|
|
1423
|
+
if (!seenBrowsers.includes(browserFamily)) {
|
|
1424
|
+
seenBrowsers.push(browserFamily);
|
|
1425
|
+
await actualStore.set(ja3Key, seenBrowsers, 86400); // 24h cache
|
|
1426
|
+
}
|
|
1427
|
+
if (seenBrowsers.length > 1) {
|
|
1428
|
+
asyncScore = Math.max(asyncScore, 85); // Staging different UAs on same JA3 MD5 signature
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
return { tlsSpoofingScore: asyncScore };
|
|
1433
|
+
})();
|
|
1434
|
+
|
|
1435
|
+
// Decorate the promise so synchronous calls can destructure it!
|
|
1436
|
+
promise.tlsSpoofingScore = score;
|
|
1437
|
+
return promise;
|
|
1193
1438
|
}
|
|
1194
1439
|
|
|
1195
1440
|
/**
|
|
@@ -1501,7 +1746,7 @@ function getBotScore(context) {
|
|
|
1501
1746
|
}
|
|
1502
1747
|
} catch (e) { /* Ignorer les erreurs de parsing */ }
|
|
1503
1748
|
|
|
1504
|
-
|
|
1749
|
+
return { botScore: 0 };
|
|
1505
1750
|
}
|
|
1506
1751
|
|
|
1507
1752
|
/**
|
|
@@ -1573,6 +1818,22 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
1573
1818
|
}
|
|
1574
1819
|
}
|
|
1575
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
|
+
|
|
1576
1837
|
// Garder l'historique à une taille raisonnable
|
|
1577
1838
|
if (history.length > historySize) {
|
|
1578
1839
|
history.shift();
|
|
@@ -1591,7 +1852,7 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
1591
1852
|
}
|
|
1592
1853
|
newPatternScore = Math.max(0, newPatternScore);
|
|
1593
1854
|
|
|
1594
|
-
deviceData.lastPatternScore = newPatternScore + instantScore;
|
|
1855
|
+
deviceData.lastPatternScore = newPatternScore + instantScore + enumerationScore;
|
|
1595
1856
|
|
|
1596
1857
|
return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
|
|
1597
1858
|
}
|
|
@@ -1854,7 +2115,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
1854
2115
|
// On appelle getHoneypotScore ici pour que son résultat soit inclus dans le vecteur.
|
|
1855
2116
|
const { honeypotScore } = getHoneypotScore(context, honeypotConfig);
|
|
1856
2117
|
|
|
1857
|
-
const { tlsSpoofingScore } = getTlsSpoofingScore(context);
|
|
2118
|
+
const { tlsSpoofingScore } = await getTlsSpoofingScore(context);
|
|
1858
2119
|
|
|
1859
2120
|
const { botScore } = getBotScore(context);
|
|
1860
2121
|
|
|
@@ -2136,6 +2397,8 @@ export function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
2136
2397
|
nonce,
|
|
2137
2398
|
solution,
|
|
2138
2399
|
challengeContext = {}, // Le contexte complet du challenge est maintenant passé
|
|
2400
|
+
deviceId = '',
|
|
2401
|
+
deviceHash = ''
|
|
2139
2402
|
) {
|
|
2140
2403
|
const { cpuTarget, baseBlock } = challengeContext;
|
|
2141
2404
|
if (!cpuTarget || !baseBlock) {
|
|
@@ -2187,9 +2450,9 @@ export function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
2187
2450
|
const expiry = Date.now() + (ticketTtl || 3600000); // Calcule l'expiration à partir du TTL
|
|
2188
2451
|
const signature = crypto
|
|
2189
2452
|
.createHmac("sha256", getPowSecret())
|
|
2190
|
-
.update(`${clientIp}:${
|
|
2453
|
+
.update(`${expiry}:${clientIp}:${deviceId}:${deviceHash}`)
|
|
2191
2454
|
.digest("hex");
|
|
2192
|
-
return `${expiry}
|
|
2455
|
+
return `${expiry}|${clientIp}|${signature}`;
|
|
2193
2456
|
}
|
|
2194
2457
|
|
|
2195
2458
|
return null;
|
|
@@ -2269,7 +2532,7 @@ export class FingerprintEngine {
|
|
|
2269
2532
|
console.log(`[FingerprintEngine] ${message}`, data);
|
|
2270
2533
|
}
|
|
2271
2534
|
}
|
|
2272
|
-
calculateFinalScore
|
|
2535
|
+
calculateFinalScore(suspicionVector) {
|
|
2273
2536
|
const { weights } = this.securityConfig;
|
|
2274
2537
|
if (!weights) return 0;
|
|
2275
2538
|
|
|
@@ -2523,8 +2786,15 @@ export class FingerprintEngine {
|
|
|
2523
2786
|
}
|
|
2524
2787
|
|
|
2525
2788
|
// 2. Forward DNS lookup
|
|
2526
|
-
|
|
2527
|
-
|
|
2789
|
+
let addresses = [];
|
|
2790
|
+
try {
|
|
2791
|
+
addresses = await dns.resolve(validHostname);
|
|
2792
|
+
} catch (e) {}
|
|
2793
|
+
try {
|
|
2794
|
+
const ipv6 = await dns.resolve(validHostname, 'AAAA');
|
|
2795
|
+
addresses = addresses.concat(ipv6);
|
|
2796
|
+
} catch (e) {}
|
|
2797
|
+
if (addresses.includes(clientIp)) {
|
|
2528
2798
|
await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
|
|
2529
2799
|
return true;
|
|
2530
2800
|
}
|
|
@@ -2548,6 +2818,11 @@ export class FingerprintEngine {
|
|
|
2548
2818
|
return { action: 'next', score: 0, vector: {} };
|
|
2549
2819
|
}
|
|
2550
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
|
+
|
|
2551
2826
|
// 1. Check static IP allowlist first for maximum performance.
|
|
2552
2827
|
if (this._isIpInAllowlist(clientIp)) {
|
|
2553
2828
|
this._log('IP in allowlist - allowing request', { clientIp });
|
|
@@ -2586,7 +2861,7 @@ export class FingerprintEngine {
|
|
|
2586
2861
|
// If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
|
|
2587
2862
|
if (pow_nonce) {
|
|
2588
2863
|
const powCookie = cookies?.pow_clearance;
|
|
2589
|
-
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
|
|
2590
2865
|
// This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
|
|
2591
2866
|
// The final decision is made later, after calculating the score.
|
|
2592
2867
|
}
|
|
@@ -2598,10 +2873,6 @@ export class FingerprintEngine {
|
|
|
2598
2873
|
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
|
|
2599
2874
|
}
|
|
2600
2875
|
|
|
2601
|
-
// Resolve identity and check for persisted "condemned" status early.
|
|
2602
|
-
const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
|
|
2603
|
-
const isNewDevice = !!newCookie;
|
|
2604
|
-
|
|
2605
2876
|
this._log('Identity resolved', { deviceId, isNewDevice, hasDeviceData: !!deviceData });
|
|
2606
2877
|
|
|
2607
2878
|
if (deviceData?.condemned) {
|
|
@@ -2696,7 +2967,26 @@ export class FingerprintEngine {
|
|
|
2696
2967
|
});
|
|
2697
2968
|
|
|
2698
2969
|
let isValid = false;
|
|
2699
|
-
|
|
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
|
+
}
|
|
2700
2990
|
let ticket = null;
|
|
2701
2991
|
// Déclarer optimalTtl ici avec une valeur par défaut
|
|
2702
2992
|
let optimalTtl = this.securityConfig.ticketMaxAge || 3600000;
|
|
@@ -2745,11 +3035,12 @@ export class FingerprintEngine {
|
|
|
2745
3035
|
|
|
2746
3036
|
if ((pow_type === "cpu_target" || !pow_type) && (pow_solution_cpu || pow_solution)) { // !pow_type pour compatibilité
|
|
2747
3037
|
const cpuSolution = pow_solution_cpu || pow_solution;
|
|
2748
|
-
|
|
3038
|
+
ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext, deviceId, currentDeviceHash);
|
|
2749
3039
|
isValid = ticket !== null;
|
|
2750
3040
|
this._log('CPU target challenge verification', { isValid });
|
|
2751
|
-
} else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) {
|
|
2752
|
-
|
|
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
|
|
2753
3044
|
isValid = cpuTicket !== null && isMemValid;
|
|
2754
3045
|
if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
|
|
2755
3046
|
this._log('Combined CPU+Memory challenge verification', {
|
|
@@ -2875,7 +3166,10 @@ export class FingerprintEngine {
|
|
|
2875
3166
|
if (challengeContext) {
|
|
2876
3167
|
try {
|
|
2877
3168
|
const workResult = JSON.parse(pow_solution_work_result);
|
|
2878
|
-
getProblemManager(
|
|
3169
|
+
getProblemManager({
|
|
3170
|
+
configPath: this.securityConfig.usefulWorkConfigPath,
|
|
3171
|
+
config: this.securityConfig.usefulWorkConfig
|
|
3172
|
+
}, store).integrateSolution(pow_problem_id, workResult);
|
|
2879
3173
|
|
|
2880
3174
|
await store.delete(`secret:${pow_nonce}`);
|
|
2881
3175
|
// Accorder un ticket de passage comme pour un PoW normal
|
|
@@ -2941,7 +3235,7 @@ export class FingerprintEngine {
|
|
|
2941
3235
|
// 1. La requête est suspecte ET il n'y a pas de ticket valide.
|
|
2942
3236
|
// OU
|
|
2943
3237
|
// 2. La requête est *très* suspecte (dépasse le seuil 'high'), ce qui annule la validité du ticket actuel.
|
|
2944
|
-
const hasValidTicket = isTicketValid(clientIp, powCookie);
|
|
3238
|
+
const hasValidTicket = isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash);
|
|
2945
3239
|
const mustReChallenge = isSuspiciousHigh && hasValidTicket;
|
|
2946
3240
|
|
|
2947
3241
|
if (isSuspicious && (!hasValidTicket || mustReChallenge)) {
|
|
@@ -2985,7 +3279,10 @@ export class FingerprintEngine {
|
|
|
2985
3279
|
if (isSuspicious && shouldUseUsefulWork) {
|
|
2986
3280
|
this._log('Issuing a useful work challenge', { finalScore });
|
|
2987
3281
|
|
|
2988
|
-
const { problemId, task } = getProblemManager(
|
|
3282
|
+
const { problemId, task } = getProblemManager({
|
|
3283
|
+
configPath: this.securityConfig.usefulWorkConfigPath,
|
|
3284
|
+
config: this.securityConfig.usefulWorkConfig
|
|
3285
|
+
}, store).dispatchWork(suspicionFactor);
|
|
2989
3286
|
|
|
2990
3287
|
await store.set(`secret:${nonce}`, { clientSecret, originalPath: path }, 300);
|
|
2991
3288
|
|
|
@@ -3033,6 +3330,11 @@ export class FingerprintEngine {
|
|
|
3033
3330
|
|
|
3034
3331
|
// Store the entire challenge context with a short TTL (e.g., 5 minutes)
|
|
3035
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
|
+
|
|
3036
3338
|
await store.set(`secret:${nonce}`, {
|
|
3037
3339
|
clientSecret,
|
|
3038
3340
|
cpuTarget: cpuChallengeDetails.target,
|
|
@@ -3041,6 +3343,7 @@ export class FingerprintEngine {
|
|
|
3041
3343
|
memDifficulty: memDifficulty,
|
|
3042
3344
|
baseBlock: baseBlock, // *** NOUVEAU: Le bloc de base est stocké pour la vérification ***
|
|
3043
3345
|
originalPath: path, // *** FIX: Store the original path ***
|
|
3346
|
+
signature, // *** NOUVEAU: Cryptographic signature to prevent storage tampering ***
|
|
3044
3347
|
}, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
|
|
3045
3348
|
|
|
3046
3349
|
// Associate the current challenge nonce with the device for trap URL verification later.
|
|
@@ -3090,7 +3393,7 @@ export class FingerprintEngine {
|
|
|
3090
3393
|
}
|
|
3091
3394
|
|
|
3092
3395
|
// Basic log for each non-static request that passed without a challenge
|
|
3093
|
-
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) });
|
|
3094
3397
|
|
|
3095
3398
|
if (logger) {
|
|
3096
3399
|
logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now(), vector: suspicionVector });
|
|
@@ -3539,7 +3842,9 @@ export const __internal = {
|
|
|
3539
3842
|
getTimeInconsistencyScore,
|
|
3540
3843
|
getClickVarianceScore, // NOUVEAU: Expose pour les tests
|
|
3541
3844
|
getTlsFingerprint, // NOUVEAU: Expose pour les tests
|
|
3845
|
+
sanitizeTrafficData, // NOUVEAU: Expose pour l'auto-tuner/tests
|
|
3542
3846
|
getTlsSpoofingScore, // NOUVEAU: Expose pour les tests
|
|
3847
|
+
parseJa3,
|
|
3543
3848
|
generateCpuTargetChallengePage,
|
|
3544
3849
|
getClientHintsInconsistencyScore, // Expose for testing
|
|
3545
3850
|
generateCombinedPoWChallengePage,
|
|
@@ -3556,25 +3861,60 @@ export const __internal = {
|
|
|
3556
3861
|
let autoTuningJobId = null;
|
|
3557
3862
|
let lastBestSolution = null; // NOUVEAU: Stocke la meilleure solution trouvée
|
|
3558
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
|
+
|
|
3559
3899
|
/**
|
|
3560
3900
|
* Executes a threshold optimization pass using collected traffic data.
|
|
3561
3901
|
* @private
|
|
3562
|
-
* @param {object} securityConfig - The security configuration object to update.
|
|
3563
|
-
* @param {Array<object>} trafficData - The array containing traffic logs.
|
|
3564
|
-
* @param {number} minDataPoints - The minimum number of data points required to start optimization.
|
|
3565
|
-
* @param {number} maxDataPoints - The maximum number of data points to keep after an optimization cycle.
|
|
3566
|
-
* @param {string} [savePath] - Optional path to save the best configuration to a file.
|
|
3567
3902
|
*/
|
|
3568
3903
|
function runThresholdOptimization(securityConfig, trafficData, minDataPoints, maxDataPoints, savePath) {
|
|
3569
|
-
const
|
|
3570
|
-
|
|
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;
|
|
3571
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;
|
|
3572
3912
|
|
|
3573
|
-
if (
|
|
3574
|
-
if (
|
|
3575
|
-
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.`);
|
|
3576
3916
|
} else {
|
|
3577
|
-
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}).`);
|
|
3578
3918
|
}
|
|
3579
3919
|
return;
|
|
3580
3920
|
}
|
|
@@ -3584,9 +3924,9 @@ function runThresholdOptimization(securityConfig, trafficData, minDataPoints, ma
|
|
|
3584
3924
|
trafficData.splice(0, trafficData.length - maxDataPoints);
|
|
3585
3925
|
}
|
|
3586
3926
|
|
|
3587
|
-
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.`);
|
|
3588
3928
|
|
|
3589
|
-
const paretoFront = Optimization.Operators.solveFullSecurityTuning({ trafficData });
|
|
3929
|
+
const paretoFront = Optimization.Operators.solveFullSecurityTuning({ trafficData: sanitizedData });
|
|
3590
3930
|
|
|
3591
3931
|
if (!paretoFront || paretoFront.length === 0) {
|
|
3592
3932
|
console.warn("[AutoTuning] L'optimisation n'a retourné aucune solution.");
|