@anonympins/fingerprint 0.3.4 → 0.3.5
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 +204 -192
- package/package.json +1 -1
- package/src/js/fingerprint.js +235 -22
- 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/Utils/RequestUtils.php +16 -5
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
|
|
@@ -814,8 +849,9 @@ export const isTicketValid = (ip, ticket) => {
|
|
|
814
849
|
*/
|
|
815
850
|
function getHeaderAnomalies(context) {
|
|
816
851
|
let anomalyScore = 0;
|
|
852
|
+
const ua = context.headers["user-agent"] || '';
|
|
817
853
|
// Strong penalty if User-Agent is missing or very short (sign of a simple script)
|
|
818
|
-
if (!
|
|
854
|
+
if (!ua || ua.length < 10) {
|
|
819
855
|
anomalyScore += 60;
|
|
820
856
|
}
|
|
821
857
|
// Penalty if Accept-Language header is missing
|
|
@@ -827,6 +863,17 @@ function getHeaderAnomalies(context) {
|
|
|
827
863
|
anomalyScore += 15;
|
|
828
864
|
}
|
|
829
865
|
|
|
866
|
+
// TE: trailers check for Firefox on Desktop
|
|
867
|
+
const uaParts = parseUserAgent(ua);
|
|
868
|
+
const isFirefoxDesktop = uaParts.browser?.startsWith('Firefox') && uaParts.device === 'desktop';
|
|
869
|
+
const teHeader = context.headers['te'];
|
|
870
|
+
|
|
871
|
+
if (isFirefoxDesktop && teHeader !== 'trailers') {
|
|
872
|
+
anomalyScore += 30; // Suspicious: Firefox desktop missing TE: trailers
|
|
873
|
+
} else if (!isFirefoxDesktop && uaParts.device === 'desktop' && teHeader === 'trailers') {
|
|
874
|
+
anomalyScore += 30; // Suspicious: Non-Firefox desktop sending TE: trailers
|
|
875
|
+
}
|
|
876
|
+
|
|
830
877
|
return {
|
|
831
878
|
headerAnomalyScore: Math.min(100, anomalyScore),
|
|
832
879
|
};
|
|
@@ -1142,21 +1189,130 @@ function getCrossLayerInconsistency(context) {
|
|
|
1142
1189
|
return { crossLayerInconsistencyScore: 10 }; // Erreur de parsing = suspect.
|
|
1143
1190
|
}
|
|
1144
1191
|
}
|
|
1192
|
+
function parseJa4(ja4) {
|
|
1193
|
+
if (!ja4 || typeof ja4 !== 'string') return null;
|
|
1194
|
+
const parts = ja4.split('_');
|
|
1195
|
+
const ja4a = parts[0];
|
|
1196
|
+
if (ja4a.length < 10) return null;
|
|
1197
|
+
return {
|
|
1198
|
+
protocol: ja4a[0],
|
|
1199
|
+
version: ja4a.substring(1, 3),
|
|
1200
|
+
sni: ja4a[3],
|
|
1201
|
+
ciphersCount: parseInt(ja4a.substring(4, 6), 10) || 0,
|
|
1202
|
+
extensionsCount: parseInt(ja4a.substring(6, 8), 10) || 0,
|
|
1203
|
+
alpn: ja4a.substring(8, 10),
|
|
1204
|
+
ja4b: parts[1] || null,
|
|
1205
|
+
ja4c: parts[2] || null
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1145
1208
|
|
|
1146
1209
|
/**
|
|
1147
1210
|
* Calcule un score d'incohérence entre les données du fingerprint TLS (JA3/JA4) et les en-têtes serveur (User-Agent).
|
|
1148
1211
|
* Cela permet de détecter le spoofing de fingerprint TLS.
|
|
1149
1212
|
* @param {object} context - Le contexte de la requête.
|
|
1150
|
-
* @returns {{tlsSpoofingScore: number}}
|
|
1213
|
+
* @returns {Promise<{tlsSpoofingScore: number}>}
|
|
1151
1214
|
*/
|
|
1152
|
-
function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint) {
|
|
1153
|
-
|
|
1215
|
+
export function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint, customStore = null) {
|
|
1216
|
+
let actualGetTlsFingerprintFn = getTlsFingerprintFn;
|
|
1217
|
+
let actualStore = customStore || store;
|
|
1218
|
+
|
|
1219
|
+
// Detect if the second argument is actually a store (compatibility with tests)
|
|
1220
|
+
if (getTlsFingerprintFn && typeof getTlsFingerprintFn.get === 'function' && typeof getTlsFingerprintFn.set === 'function') {
|
|
1221
|
+
actualStore = getTlsFingerprintFn;
|
|
1222
|
+
actualGetTlsFingerprintFn = getTlsFingerprint;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const { ja3, ja4 } = actualGetTlsFingerprintFn(context) || { ja3: null, ja4: null }; // Defensive check
|
|
1154
1226
|
const ua = context.headers["user-agent"] || '';
|
|
1227
|
+
const ja3Raw = context.headers['x-ja3-raw'] || null;
|
|
1228
|
+
const httpVersion = context.httpVersion || '';
|
|
1229
|
+
|
|
1230
|
+
let score = 0;
|
|
1155
1231
|
|
|
1156
1232
|
// 1. Penalize if a TLS fingerprint is present but the User-Agent is generic or missing.
|
|
1157
1233
|
// This is a strong indicator of a non-browser client trying to look legitimate.
|
|
1158
1234
|
if ((ja3 || ja4) && (!ua || ua.length < 10 || ua.toLowerCase().includes('python') || ua.toLowerCase().includes('curl'))) {
|
|
1159
|
-
|
|
1235
|
+
score = Math.max(score, 50);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Check for known spoofed/suspicious JA4 fingerprints
|
|
1239
|
+
const spoofedJa4s = [
|
|
1240
|
+
't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
|
|
1241
|
+
't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
|
|
1242
|
+
't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
|
|
1243
|
+
't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
|
|
1244
|
+
't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
|
|
1245
|
+
't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
|
|
1246
|
+
];
|
|
1247
|
+
if (ja4 && spoofedJa4s.includes(ja4)) {
|
|
1248
|
+
score = Math.max(score, 100);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const claimedBrowser = parseUserAgent(ua).browser?.split('/')[0] || null;
|
|
1252
|
+
const isHumanBrowser = ['Chrome', 'Firefox', 'Safari', 'Edge'].includes(claimedBrowser);
|
|
1253
|
+
|
|
1254
|
+
// --- ANALYSE 2 : CONTRÔLE PROFOND SUR L'EMPREINTE BRUTE (RAW JA3) ---
|
|
1255
|
+
if (ja3Raw) {
|
|
1256
|
+
const parsed = parseJa3(ja3Raw);
|
|
1257
|
+
if (parsed) {
|
|
1258
|
+
// Contrôle A : Mécanisme GREASE pour Chrome / Edge (obligatoire)
|
|
1259
|
+
if (claimedBrowser === 'Chrome' || claimedBrowser === 'Edge') {
|
|
1260
|
+
const hasCiphersGrease = hasGrease(parsed.ciphers);
|
|
1261
|
+
const hasExtensionsGrease = hasGrease(parsed.extensions);
|
|
1262
|
+
|
|
1263
|
+
if (!hasCiphersGrease && !hasExtensionsGrease) {
|
|
1264
|
+
// Chrome ou Edge moderne sans GREASE = spoofing de bas niveau (ex: python-requests déguisé)
|
|
1265
|
+
score = Math.max(score, 75);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Contrôle B : HTTP/2 ou HTTP/3 sans négociation ALPN (Extension 16)
|
|
1270
|
+
const isH2OrHigher = (
|
|
1271
|
+
httpVersion.includes('2.0') ||
|
|
1272
|
+
httpVersion.includes('HTTP/2') ||
|
|
1273
|
+
httpVersion.includes('HTTP/3')
|
|
1274
|
+
);
|
|
1275
|
+
const hasAlpnExtension = parsed.extensions.includes(16);
|
|
1276
|
+
|
|
1277
|
+
if (isH2OrHigher && !hasAlpnExtension) {
|
|
1278
|
+
// Négociation HTTP/2 active au niveau serveur mais absente au niveau des extensions TLS du client
|
|
1279
|
+
score = Math.max(score, 70);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Contrôle C : Version TLS obsolète négociée par un navigateur moderne (ex: TLS < 1.2, id < 771)
|
|
1283
|
+
if (isHumanBrowser && parsed.tlsVersion < 771) {
|
|
1284
|
+
score = Math.max(score, 80);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Parse JA4 if available for advanced checks
|
|
1290
|
+
if (ja4) {
|
|
1291
|
+
const parsedJa4 = parseJa4(ja4);
|
|
1292
|
+
if (parsedJa4) {
|
|
1293
|
+
const uaParts = parseUserAgent(ua);
|
|
1294
|
+
|
|
1295
|
+
// Check 1: Incohérence ALPN / HTTP Version
|
|
1296
|
+
if (parsedJa4.alpn === 'h2' && (context.httpVersion === '1.1' || context.httpVersion === '1.0')) {
|
|
1297
|
+
const hasProxy = context.headers['via'] || context.headers['forwarded'] || context.headers['x-forwarded-proto'] || context.headers['x-forwarded-for'];
|
|
1298
|
+
if (!hasProxy) {
|
|
1299
|
+
score = Math.max(score, 40);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// Check 2: Incohérence OS/Plateforme vs Capabilities TLS
|
|
1304
|
+
if (parsedJa4.version === '12' && (uaParts.os === 'iOS' || uaParts.os === 'macOS') && uaParts.browser?.startsWith('Safari')) {
|
|
1305
|
+
score = Math.max(score, 60);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// Check 3: Incohérence User-Agent vs Signature JA4
|
|
1309
|
+
if (uaParts.browser?.startsWith('Chrome') && parsedJa4.alpn === '00') {
|
|
1310
|
+
score = Math.max(score, 50);
|
|
1311
|
+
}
|
|
1312
|
+
if (uaParts.browser?.startsWith('Firefox') && parsedJa4.extensionsCount > 15) {
|
|
1313
|
+
score = Math.max(score, 50);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1160
1316
|
}
|
|
1161
1317
|
|
|
1162
1318
|
// 2. If no JA3 hash is available, we cannot perform the consistency check.
|
|
@@ -1173,23 +1329,72 @@ function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint) {
|
|
|
1173
1329
|
// Parse the User-Agent to get the claimed browser.
|
|
1174
1330
|
const { browser: claimedBrowser } = parseUserAgent(ua);
|
|
1175
1331
|
|
|
1176
|
-
// Check
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1332
|
+
// Check 1: Known library JA3 with a human-claimed browser
|
|
1333
|
+
const isLibrary = expectedBrowsers.some(expected => ['Python', 'Go', 'Java', 'curl'].includes(expected));
|
|
1334
|
+
const claimsToBeHumanBrowser = claimedBrowser && (
|
|
1335
|
+
claimedBrowser.startsWith('Chrome') ||
|
|
1336
|
+
claimedBrowser.startsWith('Firefox') ||
|
|
1337
|
+
claimedBrowser.startsWith('Safari') ||
|
|
1338
|
+
claimedBrowser.startsWith('Edge')
|
|
1339
|
+
);
|
|
1340
|
+
|
|
1341
|
+
if (isLibrary && claimsToBeHumanBrowser) {
|
|
1342
|
+
score = Math.max(score, 90); // High confidence spoofing of library as browser
|
|
1343
|
+
} else {
|
|
1344
|
+
// Check if the claimed browser is one of the legitimate possibilities for this JA3 hash.
|
|
1345
|
+
const isMatch = expectedBrowsers.some(expected => claimedBrowser?.startsWith(expected));
|
|
1346
|
+
if (claimedBrowser && !isMatch) {
|
|
1347
|
+
score = Math.max(score, 80); // High score for a clear mismatch.
|
|
1348
|
+
}
|
|
1183
1349
|
}
|
|
1184
1350
|
}
|
|
1185
1351
|
}
|
|
1186
1352
|
|
|
1187
|
-
//
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1353
|
+
// Create the promise for the async part (Check 4)
|
|
1354
|
+
const promise = (async () => {
|
|
1355
|
+
let asyncScore = score;
|
|
1356
|
+
const uaParts = parseUserAgent(ua);
|
|
1357
|
+
|
|
1358
|
+
if (uaParts.browser) {
|
|
1359
|
+
const browserFamily = uaParts.browser.split('/')[0];
|
|
1360
|
+
|
|
1361
|
+
// Check 4a: Stagnation JA4
|
|
1362
|
+
if (ja4) {
|
|
1363
|
+
const parsedJa4 = parseJa4(ja4);
|
|
1364
|
+
if (parsedJa4) {
|
|
1365
|
+
const ja4Key = `ja4-browsers:${ja4}`;
|
|
1366
|
+
let seenBrowsers = await actualStore.get(ja4Key) || [];
|
|
1367
|
+
if (!Array.isArray(seenBrowsers)) seenBrowsers = [];
|
|
1368
|
+
if (browserFamily && !seenBrowsers.includes(browserFamily)) {
|
|
1369
|
+
seenBrowsers.push(browserFamily);
|
|
1370
|
+
await actualStore.set(ja4Key, seenBrowsers, 86400); // 24h cache
|
|
1371
|
+
}
|
|
1372
|
+
if (seenBrowsers.length > 1) {
|
|
1373
|
+
asyncScore = Math.max(asyncScore, 80);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// Check 4b: JA3 MD5 stagnation with rotating browser UAs
|
|
1379
|
+
if (ja3 && browserFamily) {
|
|
1380
|
+
const ja3Key = `ja3-browsers:${ja3}`;
|
|
1381
|
+
let seenBrowsers = await actualStore.get(ja3Key) || [];
|
|
1382
|
+
if (!Array.isArray(seenBrowsers)) seenBrowsers = [];
|
|
1383
|
+
if (!seenBrowsers.includes(browserFamily)) {
|
|
1384
|
+
seenBrowsers.push(browserFamily);
|
|
1385
|
+
await actualStore.set(ja3Key, seenBrowsers, 86400); // 24h cache
|
|
1386
|
+
}
|
|
1387
|
+
if (seenBrowsers.length > 1) {
|
|
1388
|
+
asyncScore = Math.max(asyncScore, 85); // Staging different UAs on same JA3 MD5 signature
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
return { tlsSpoofingScore: asyncScore };
|
|
1393
|
+
})();
|
|
1394
|
+
|
|
1395
|
+
// Decorate the promise so synchronous calls can destructure it!
|
|
1396
|
+
promise.tlsSpoofingScore = score;
|
|
1397
|
+
return promise;
|
|
1193
1398
|
}
|
|
1194
1399
|
|
|
1195
1400
|
/**
|
|
@@ -1501,7 +1706,7 @@ function getBotScore(context) {
|
|
|
1501
1706
|
}
|
|
1502
1707
|
} catch (e) { /* Ignorer les erreurs de parsing */ }
|
|
1503
1708
|
|
|
1504
|
-
|
|
1709
|
+
return { botScore: 0 };
|
|
1505
1710
|
}
|
|
1506
1711
|
|
|
1507
1712
|
/**
|
|
@@ -1854,7 +2059,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
1854
2059
|
// On appelle getHoneypotScore ici pour que son résultat soit inclus dans le vecteur.
|
|
1855
2060
|
const { honeypotScore } = getHoneypotScore(context, honeypotConfig);
|
|
1856
2061
|
|
|
1857
|
-
const { tlsSpoofingScore } = getTlsSpoofingScore(context);
|
|
2062
|
+
const { tlsSpoofingScore } = await getTlsSpoofingScore(context);
|
|
1858
2063
|
|
|
1859
2064
|
const { botScore } = getBotScore(context);
|
|
1860
2065
|
|
|
@@ -2523,8 +2728,15 @@ export class FingerprintEngine {
|
|
|
2523
2728
|
}
|
|
2524
2729
|
|
|
2525
2730
|
// 2. Forward DNS lookup
|
|
2526
|
-
|
|
2527
|
-
|
|
2731
|
+
let addresses = [];
|
|
2732
|
+
try {
|
|
2733
|
+
addresses = await dns.resolve(validHostname);
|
|
2734
|
+
} catch (e) {}
|
|
2735
|
+
try {
|
|
2736
|
+
const ipv6 = await dns.resolve(validHostname, 'AAAA');
|
|
2737
|
+
addresses = addresses.concat(ipv6);
|
|
2738
|
+
} catch (e) {}
|
|
2739
|
+
if (addresses.includes(clientIp)) {
|
|
2528
2740
|
await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
|
|
2529
2741
|
return true;
|
|
2530
2742
|
}
|
|
@@ -3540,6 +3752,7 @@ export const __internal = {
|
|
|
3540
3752
|
getClickVarianceScore, // NOUVEAU: Expose pour les tests
|
|
3541
3753
|
getTlsFingerprint, // NOUVEAU: Expose pour les tests
|
|
3542
3754
|
getTlsSpoofingScore, // NOUVEAU: Expose pour les tests
|
|
3755
|
+
parseJa3,
|
|
3543
3756
|
generateCpuTargetChallengePage,
|
|
3544
3757
|
getClientHintsInconsistencyScore, // Expose for testing
|
|
3545
3758
|
generateCombinedPoWChallengePage,
|
|
@@ -324,6 +324,68 @@
|
|
|
324
324
|
|
|
325
325
|
// Score de spoofing TLS
|
|
326
326
|
$tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
|
|
327
|
+
$tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
|
|
328
|
+
|
|
329
|
+
// Advanced JA4 TLS Inconsistency checks
|
|
330
|
+
$ja4 = $context->getHeader('x-ja4-hash');
|
|
331
|
+
if ($ja4) {
|
|
332
|
+
$spoofedJa4s = [
|
|
333
|
+
't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
|
|
334
|
+
't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
|
|
335
|
+
't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
|
|
336
|
+
't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
|
|
337
|
+
't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
|
|
338
|
+
't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
|
|
339
|
+
];
|
|
340
|
+
if (in_array($ja4, $spoofedJa4s, true)) {
|
|
341
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
$parsedJa4 = $this->parseJa4($ja4);
|
|
345
|
+
if ($parsedJa4) {
|
|
346
|
+
$ua = $context->getHeader('user-agent') ?? '';
|
|
347
|
+
$uaParts = $this->parseUserAgent($ua);
|
|
348
|
+
|
|
349
|
+
// Check 1: Incohérence ALPN / HTTP Version
|
|
350
|
+
$httpVersion = $context->httpVersion ?? '1.1';
|
|
351
|
+
if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
|
|
352
|
+
$hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
|
|
353
|
+
if (!$hasProxy) {
|
|
354
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Check 2: Incohérence OS/Plateforme vs Capabilities TLS
|
|
359
|
+
if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
|
|
360
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Check 3: Incohérence User-Agent vs Signature JA4
|
|
364
|
+
if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
|
|
365
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
|
|
366
|
+
}
|
|
367
|
+
if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
|
|
368
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Check 4: La stagnation (Lack of Entropy / Genericity)
|
|
372
|
+
if ($uaParts['browser']) {
|
|
373
|
+
$ja4Key = "ja4-browsers:{$ja4}";
|
|
374
|
+
$seenBrowsers = $store->get($ja4Key) ?: [];
|
|
375
|
+
if (!is_array($seenBrowsers)) {
|
|
376
|
+
$seenBrowsers = [];
|
|
377
|
+
}
|
|
378
|
+
$browserFamily = explode('/', $uaParts['browser'])[0] ?? null;
|
|
379
|
+
if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
|
|
380
|
+
$seenBrowsers[] = $browserFamily;
|
|
381
|
+
$store->set($ja4Key, $seenBrowsers, 86400);
|
|
382
|
+
}
|
|
383
|
+
if (count($seenBrowsers) > 1) {
|
|
384
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
327
389
|
|
|
328
390
|
// Score d'incohérence temporelle (attaque par rejeu)
|
|
329
391
|
$timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
|
|
@@ -346,9 +408,6 @@
|
|
|
346
408
|
// Score de variance des clics
|
|
347
409
|
$clickVariance = RequestUtils::getClickVarianceScore($context);
|
|
348
410
|
|
|
349
|
-
// Score de variance des clics
|
|
350
|
-
$clickVariance = RequestUtils::getClickVarianceScore($context);
|
|
351
|
-
|
|
352
411
|
// Score basé sur les listes de menaces (Threat Intelligence)
|
|
353
412
|
$threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
|
|
354
413
|
|
|
@@ -364,7 +423,7 @@
|
|
|
364
423
|
'historyScore' => $behavioral['historyScore'],
|
|
365
424
|
'rotationScore' => $behavioral['rotationScore'],
|
|
366
425
|
'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
|
|
367
|
-
'tlsSpoofingScore' => $
|
|
426
|
+
'tlsSpoofingScore' => $tlsSpoofingScore,
|
|
368
427
|
'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
|
|
369
428
|
'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
|
|
370
429
|
'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
|
|
@@ -383,6 +442,66 @@
|
|
|
383
442
|
return $suspicionVector;
|
|
384
443
|
}
|
|
385
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Parse a basic User-Agent string.
|
|
447
|
+
*/
|
|
448
|
+
private function parseUserAgent(string $ua): array
|
|
449
|
+
{
|
|
450
|
+
$result = ['browser' => null, 'os' => null];
|
|
451
|
+
|
|
452
|
+
if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
|
|
453
|
+
$result['browser'] = 'Chrome';
|
|
454
|
+
if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
|
|
455
|
+
$result['browser'] .= '/' . $matches[1];
|
|
456
|
+
}
|
|
457
|
+
} elseif (str_contains($ua, 'Firefox')) {
|
|
458
|
+
$result['browser'] = 'Firefox';
|
|
459
|
+
if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
|
|
460
|
+
$result['browser'] .= '/' . $matches[1];
|
|
461
|
+
}
|
|
462
|
+
} elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
|
|
463
|
+
$result['browser'] = 'Safari';
|
|
464
|
+
if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
|
|
465
|
+
$result['browser'] .= '/' . $matches[1];
|
|
466
|
+
}
|
|
467
|
+
} elseif (str_contains($ua, 'Edg')) {
|
|
468
|
+
$result['browser'] = 'Edge';
|
|
469
|
+
if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
|
|
470
|
+
$result['browser'] .= '/' . $matches[1];
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
|
|
475
|
+
elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
|
|
476
|
+
elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
|
|
477
|
+
elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
|
|
478
|
+
elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
|
|
479
|
+
elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
|
|
480
|
+
|
|
481
|
+
return $result;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Parse JA4 string into protocol, version, ALPN etc.
|
|
486
|
+
*/
|
|
487
|
+
private function parseJa4(?string $ja4): ?array
|
|
488
|
+
{
|
|
489
|
+
if (empty($ja4)) return null;
|
|
490
|
+
$parts = explode('_', $ja4);
|
|
491
|
+
$ja4a = $parts[0];
|
|
492
|
+
if (strlen($ja4a) < 10) return null;
|
|
493
|
+
return [
|
|
494
|
+
'protocol' => $ja4a[0],
|
|
495
|
+
'version' => substr($ja4a, 1, 2),
|
|
496
|
+
'sni' => $ja4a[3],
|
|
497
|
+
'ciphersCount' => (int)substr($ja4a, 4, 2),
|
|
498
|
+
'extensionsCount' => (int)substr($ja4a, 6, 2),
|
|
499
|
+
'alpn' => substr($ja4a, 8, 2),
|
|
500
|
+
'ja4b' => $parts[1] ?? null,
|
|
501
|
+
'ja4c' => $parts[2] ?? null
|
|
502
|
+
];
|
|
503
|
+
}
|
|
504
|
+
|
|
386
505
|
/**
|
|
387
506
|
* Traite une requête entrante et retourne une décision.
|
|
388
507
|
* @param RequestContext $context Le contexte de la requête.
|
|
@@ -840,9 +959,17 @@
|
|
|
840
959
|
|
|
841
960
|
// 2. Forward DNS lookup
|
|
842
961
|
$addresses = array_merge(dns_get_record($validHostname, DNS_A) ?: [], dns_get_record($validHostname, DNS_AAAA) ?: []);
|
|
843
|
-
$ips =
|
|
962
|
+
$ips = [];
|
|
963
|
+
foreach ($addresses as $address) {
|
|
964
|
+
if (isset($address['ip'])) {
|
|
965
|
+
$ips[] = $address['ip'];
|
|
966
|
+
}
|
|
967
|
+
if (isset($address['ipv6'])) {
|
|
968
|
+
$ips[] = $address['ipv6'];
|
|
969
|
+
}
|
|
970
|
+
}
|
|
844
971
|
|
|
845
|
-
if (in_array($context->clientIp, $
|
|
972
|
+
if (in_array($context->clientIp, $ips, true)) {
|
|
846
973
|
$store->set($cacheKey, 'verified', 86400);
|
|
847
974
|
return true;
|
|
848
975
|
}
|