@anonympins/fingerprint 0.5.2 → 0.5.4

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +1 -1
  3. package/src/js/fingerprint.client.js +1271 -1191
  4. package/src/js/fingerprint.js +202 -108
  5. package/src/js/fingerprint.utils.js +213 -213
  6. package/src/js/library.js +24 -8
  7. package/src/js/mongodb-store.js +165 -79
  8. package/src/js/pow.solver.inline.js +500 -14
  9. package/src/js/pow.solver.js +323 -14
  10. package/src/js/problem-manager.js +1 -1
  11. package/src/js/redis-store.js +114 -42
  12. package/src/js/tests/dns-circuit-breaker.test.js +55 -0
  13. package/src/js/tests/fingerprint.client.init.test.js +143 -143
  14. package/src/js/tests/fingerprint.client.test.js +128 -128
  15. package/src/js/tests/fingerprint.test.js +32 -2
  16. package/src/js/tests/ip-reputation.test.js +1 -1
  17. package/src/js/tests/pow.solver.test.js +5 -2
  18. package/src/js/tests/usefulWorkSolver.test.js +162 -0
  19. package/src/php/Challenge/ChallengeUtils.php +21 -8
  20. package/src/php/FingerprintClient.php +22 -5
  21. package/src/php/FingerprintEngine.php +232 -56
  22. package/src/php/Optimization/Optimization.php +23 -16
  23. package/src/php/Optimization/OptimizationOperators.php +27 -0
  24. package/src/php/ProblemManager.php +1 -1
  25. package/src/php/Store/MongoDbStore.php +184 -104
  26. package/src/php/Store/RedisStore.php +91 -11
  27. package/src/php/Tests/ChallengeUtilsTest.php +285 -285
  28. package/src/php/Tests/DnsCircuitBreakerTest.php +106 -0
  29. package/src/php/Tests/IpReputationTest.php +1 -1
  30. package/src/php/Tests/PowTest.php +1 -1
  31. package/src/php/Tests/RequestUtilsTest.php +29 -0
  32. package/src/php/Tests/TLSClientHelloParserTest.php +177 -117
  33. package/src/php/Utils/BigInt.php +202 -202
  34. package/src/php/Utils/RequestUtils.php +74 -21
  35. package/src/php/Utils/TLSClientHelloParser.php +212 -116
@@ -27,6 +27,57 @@ import {
27
27
  const __filename = fileURLToPath(import.meta.url);
28
28
  const __dirname = dirname(__filename);
29
29
 
30
+ let dnsCircuitBreaker = {
31
+ state: 'CLOSED', // 'CLOSED', 'OPEN', 'HALF-OPEN'
32
+ failureCount: 0,
33
+ lastStateChange: 0,
34
+ threshold: 5,
35
+ cooldownMs: 30000, // 30 seconds
36
+ };
37
+
38
+ function recordDnsSuccess() {
39
+ dnsCircuitBreaker.failureCount = 0;
40
+ dnsCircuitBreaker.state = 'CLOSED';
41
+ }
42
+
43
+ function recordDnsFailure() {
44
+ dnsCircuitBreaker.failureCount++;
45
+ if (dnsCircuitBreaker.failureCount >= dnsCircuitBreaker.threshold) {
46
+ dnsCircuitBreaker.state = 'OPEN';
47
+ dnsCircuitBreaker.lastStateChange = Date.now();
48
+ }
49
+ }
50
+
51
+ function canAttemptDns() {
52
+ if (dnsCircuitBreaker.state === 'CLOSED') {
53
+ return true;
54
+ }
55
+ if (dnsCircuitBreaker.state === 'OPEN') {
56
+ if (Date.now() - dnsCircuitBreaker.lastStateChange > dnsCircuitBreaker.cooldownMs) {
57
+ dnsCircuitBreaker.state = 'HALF-OPEN';
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+ return true; // HALF-OPEN
63
+ }
64
+
65
+ const withTimeout = (promise, ms) => {
66
+ let timeoutId;
67
+ const timeoutPromise = new Promise((_, reject) => {
68
+ timeoutId = setTimeout(() => {
69
+ reject(new Error('DNS_TIMEOUT'));
70
+ }, ms);
71
+ });
72
+ return Promise.race([
73
+ promise.then((res) => {
74
+ clearTimeout(timeoutId);
75
+ return res;
76
+ }),
77
+ timeoutPromise
78
+ ]);
79
+ };
80
+
30
81
  export { createRedisStore } from "./redis-store.js";
31
82
  export { createMongoDbStore } from "./mongodb-store.js";
32
83
 
@@ -1455,9 +1506,10 @@ export async function generateSpaceChallenge(clientIp, nonce, suspicionFactor, o
1455
1506
  }
1456
1507
 
1457
1508
  function generateSpaceChallengePage(challengeDetails, clientSecret, securityConfig) {
1458
- const { nonce, sizeMb, queries, path } = challengeDetails;
1509
+ const { nonce, sizeMb, queries, path, peerId, peerBlockIdx } = challengeDetails;
1459
1510
  const solverCode = getPowSolverCode();
1460
1511
  const safePath = sanitizeRedirectPath(path);
1512
+ const coopTimeout = securityConfig?.pospace?.coopTimeout ?? 15;
1461
1513
 
1462
1514
  const challengeScript = `
1463
1515
  async function solve() {
@@ -1466,16 +1518,49 @@ function generateSpaceChallengePage(challengeDetails, clientSecret, securityConf
1466
1518
  const clientSecret = ${safeJsonStringify(clientSecret)};
1467
1519
  const queries = ${JSON.stringify(queries)};
1468
1520
  const sizeMb = ${sizeMb};
1521
+ const nodeId = nonce;
1522
+ const peerId = ${safeJsonStringify(peerId || '')};
1523
+ const peerBlockIdx = ${peerBlockIdx ?? -1};
1524
+ const coopTimeout = ${coopTimeout};
1469
1525
 
1470
1526
  document.getElementById('loader').innerText = '⚙️ Checking persistent local storage...';
1471
1527
  await new Promise(r => setTimeout(r, 10));
1472
1528
 
1473
1529
  try {
1474
1530
  await window.initializeSpace(nonce + ":" + clientSecret, sizeMb);
1531
+
1532
+ if (peerId && peerBlockIdx !== -1) {
1533
+ // Enregistrement coopératif
1534
+ await fetch(window.location.pathname + "?coop_op=register&node_id=" + nodeId + "&seed=" + encodeURIComponent(nonce + ":" + clientSecret));
1535
+ }
1536
+
1475
1537
  document.getElementById('loader').innerText = '⚙️ Generating Proof of Space...';
1476
- const hash = await window.solveSpaceChallenge(nonce + ":" + clientSecret, queries, nonce, clientSecret);
1538
+
1539
+ let peerBlock = "";
1540
+ if (peerId && peerBlockIdx !== -1) {
1541
+ document.getElementById('loader').innerText = '📥 Téléchargement du bloc de validation du pair (' + peerId + ')...';
1542
+ const reqId = Math.random().toString(36).substring(2);
1543
+ await fetch(window.location.pathname + "?coop_op=request_peer_block&node_id=" + nodeId + "&peer_id=" + peerId + "&block_idx=" + peerBlockIdx + "&req_id=" + reqId);
1544
+
1545
+ let attempts = 0;
1546
+ while (attempts < coopTimeout) {
1547
+ const res = await fetch(window.location.pathname + "?coop_op=poll_response&node_id=" + nodeId + "&req_id=" + reqId);
1548
+ const data = await res.json();
1549
+ if (data.status === 'ready') {
1550
+ peerBlock = data.block_data;
1551
+ break;
1552
+ }
1553
+ await new Promise(r => setTimeout(r, 1000));
1554
+ attempts++;
1555
+ }
1556
+ if (!peerBlock) {
1557
+ document.getElementById('loader').innerText = '⚠️ Peer de sous-réseau injoignable. Validation solo...';
1558
+ }
1559
+ }
1560
+
1561
+ const hash = await window.solveSpaceChallenge(nonce + ":" + clientSecret, queries, nonce, clientSecret, peerBlock);
1477
1562
 
1478
- window.location.href = path + "?pow_type=pospace&pow_nonce=" + nonce + "&pow_solution_space=" + hash;
1563
+ window.location.href = path + "?pow_type=pospace&pow_nonce=" + nonce + "&pow_solution_space=" + hash + (peerBlock ? "&pow_coop=1" : "");
1479
1564
  } catch(e) {
1480
1565
  document.getElementById('loader').innerText = "Error initializing local storage: " + e.message;
1481
1566
  }
@@ -2351,80 +2436,6 @@ function getClickVarianceScore(context) {
2351
2436
  * @returns {string|null} The subnet CIDR or null if the IP is invalid.
2352
2437
  */
2353
2438
  function getIpSubnet(ip, ipv4Prefix = 24, ipv6Prefix = 48) { // eslint-disable-line no-unused-vars
2354
- /**
2355
- * @private
2356
- * Parses an IPv6 string, expanding '::' into a 16-byte Buffer.
2357
- * @param {string} ipStr The IPv6 address string.
2358
- * @returns {Buffer|null}
2359
- */
2360
- const parseIPv6 = (ipStr) => {
2361
- const parts = ipStr.split('::');
2362
- if (parts.length > 2) return null;
2363
-
2364
- let hextets = [];
2365
- if (parts[0]) hextets.push(...parts[0].split(':'));
2366
-
2367
- if (parts.length === 2) {
2368
- const hextetsInPart2 = parts[1] ? parts[1].split(':').length : 0;
2369
- const zerosToInsert = 8 - hextets.length - hextetsInPart2;
2370
- for (let i = 0; i < zerosToInsert; i++) {
2371
- hextets.push('0');
2372
- }
2373
- if (parts[1]) hextets.push(...parts[1].split(':'));
2374
- }
2375
-
2376
- if (hextets.length !== 8) return null;
2377
-
2378
- const buffer = Buffer.alloc(16);
2379
- for (let i = 0; i < 8; i++) {
2380
- const val = parseInt(hextets[i] || '0', 16);
2381
- if (isNaN(val)) return null;
2382
- buffer.writeUInt16BE(val, i * 2);
2383
- }
2384
- return buffer;
2385
- };
2386
-
2387
- /**
2388
- * @private
2389
- * Formats a 16-byte IPv6 buffer into a compressed string representation.
2390
- * @param {Buffer} buffer The 16-byte buffer.
2391
- * @returns {string}
2392
- */
2393
- const formatIPv6 = (buffer) => {
2394
- const hextets = [];
2395
- for (let i = 0; i < 16; i += 2) {
2396
- hextets.push(buffer.readUInt16BE(i).toString(16));
2397
- }
2398
-
2399
- let bestStart = -1, bestLength = 0, currentStart = -1, currentLength = 0;
2400
- for (let i = 0; i < hextets.length; i++) {
2401
- if (hextets[i] === '0') {
2402
- if (currentStart === -1) currentStart = i;
2403
- currentLength++;
2404
- } else {
2405
- if (currentLength > bestLength) {
2406
- bestStart = currentStart;
2407
- bestLength = currentLength;
2408
- }
2409
- currentStart = -1;
2410
- currentLength = 0;
2411
- }
2412
- }
2413
- if (currentLength > bestLength) {
2414
- bestStart = currentStart;
2415
- bestLength = currentLength;
2416
- }
2417
-
2418
- // For subnet calculations, an uncompressed view is often clearer.
2419
- // We will avoid compression to match test expectations.
2420
- // if (bestLength > 1) {
2421
- // const part1 = hextets.slice(0, bestStart).join(':');
2422
- // const part2 = hextets.slice(bestStart + bestLength).join(':');
2423
- // return `${part1}::${part2}`;
2424
- // }
2425
- return hextets.join(':');
2426
- };
2427
-
2428
2439
  try {
2429
2440
  if (isIPv4(ip)) {
2430
2441
  const ipBuffer = Buffer.from(ip.split('.').map(Number));
@@ -2433,14 +2444,37 @@ function getIpSubnet(ip, ipv4Prefix = 24, ipv6Prefix = 48) { // eslint-disable-l
2433
2444
  for (let i = 0; i < 4; i++) ipBuffer[i] &= mask[i];
2434
2445
  return `${Array.from(ipBuffer).join('.')}/${ipv4Prefix}`;
2435
2446
  } else if (isIPv6(ip)) {
2436
- const ipBuffer = parseIPv6(ip);
2437
- if (!ipBuffer) return null;
2438
-
2439
- const mask = Buffer.alloc(16, 0);
2440
- for (let i = 0; i < ipv6Prefix; i++) mask[Math.floor(i / 8)] |= 1 << (7 - (i % 8));
2441
- for (let i = 0; i < 16; i++) ipBuffer[i] &= mask[i];
2442
-
2443
- return `${formatIPv6(ipBuffer)}/${ipv6Prefix}`;
2447
+ let normalized = ip.trim().toLowerCase();
2448
+ if (normalized.includes("::")) {
2449
+ const parts = normalized.split("::");
2450
+ if (parts.length > 2) return null;
2451
+ const left = parts[0] ? parts[0].split(":") : [];
2452
+ const right = parts[1] ? parts[1].split(":") : [];
2453
+ const missing = 8 - (left.length + right.length);
2454
+ const middle = Array(missing).fill("0000");
2455
+ normalized = [...left, ...middle, ...right].join(":");
2456
+ } else {
2457
+ const parts = normalized.split(":");
2458
+ if (parts.length !== 8) return null;
2459
+ }
2460
+ const groups = normalized.split(":").map(g => {
2461
+ const val = parseInt(g, 16);
2462
+ return isNaN(val) ? "0000" : val.toString(16).padStart(4, "0");
2463
+ });
2464
+ for (let i = 0; i < 8; i++) {
2465
+ const startBit = i * 16;
2466
+ if (ipv6Prefix >= (i + 1) * 16) {
2467
+ continue;
2468
+ } else if (ipv6Prefix <= startBit) {
2469
+ groups[i] = "0000";
2470
+ } else {
2471
+ const bitsToKeep = ipv6Prefix - startBit;
2472
+ const val = parseInt(groups[i], 16);
2473
+ const mask = (0xffff << (16 - bitsToKeep)) & 0xffff;
2474
+ groups[i] = (val & mask).toString(16).padStart(4, "0");
2475
+ }
2476
+ }
2477
+ return `${groups.join(":")}/${ipv6Prefix}`;
2444
2478
  }
2445
2479
  } catch (e) {
2446
2480
  // Catch any unexpected errors during parsing or manipulation
@@ -2853,12 +2887,19 @@ async function resolveRequestIdentity(context, securityConfig = {}) {
2853
2887
  // Case 2: New user or lost/invalid cookie.
2854
2888
  deviceId = crypto.randomUUID(); // Generate a new "passport".
2855
2889
 
2890
+ const isHttps = context.headers?.['x-forwarded-proto'] === 'https' ||
2891
+ context.rawReq?.secure ||
2892
+ context.rawReq?.protocol === 'https' ||
2893
+ context.rawReq?.connection?.encrypted;
2894
+ const secureOption = isHttps || process.env.NODE_ENV === "production";
2895
+
2856
2896
  // Return the intention to set a cookie.
2857
2897
  newCookie = {
2858
2898
  name: "device_id",
2859
2899
  value: deviceId,
2860
2900
  options: {
2861
- httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict",
2901
+ httpOnly: true, secure: secureOption, sameSite: "strict",
2902
+ ...(secureOption && { partitioned: true }),
2862
2903
  // Le maxAge est maintenant configurable. Par défaut, c'est un cookie de session.
2863
2904
  ...(securityConfig.deviceIdCookieMaxAge && { maxAge: securityConfig.deviceIdCookieMaxAge }),
2864
2905
  }
@@ -3250,7 +3291,7 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
3250
3291
  }
3251
3292
 
3252
3293
  // Redirect with both solutions and the fingerprint used to solve.
3253
- const finalUrl = path + "?pow_type=cpu_mem&pow_nonce=" + ${JSON.stringify(nonce)} + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
3294
+ const finalUrl = path + "?pow_type=cpu_mem&pow_nonce=" + ${JSON.stringify(nonce)} + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + encodeURIComponent(JSON.stringify(memSolution));
3254
3295
  window.location.href = finalUrl;
3255
3296
  }
3256
3297
 
@@ -3705,6 +3746,10 @@ export class FingerprintEngine {
3705
3746
  return false;
3706
3747
  }
3707
3748
 
3749
+ if (!canAttemptDns()) {
3750
+ return false;
3751
+ }
3752
+
3708
3753
  const cacheKey = `ip-whitelist:${clientIp}`;
3709
3754
  const cachedStatus = await store.get(cacheKey);
3710
3755
 
@@ -3717,32 +3762,35 @@ export class FingerprintEngine {
3717
3762
 
3718
3763
  try {
3719
3764
  // 1. Reverse DNS lookup
3720
- const hostnames = await dns.reverse(clientIp);
3765
+ const hostnames = await withTimeout(dns.reverse(clientIp), 500);
3721
3766
  const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
3722
3767
 
3723
3768
  if (!validHostname) {
3724
- await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
3769
+ await store.set(cacheKey, 'failed', 300); // Temporary negative caching (5 minutes)
3725
3770
  return false;
3726
3771
  }
3727
3772
 
3728
3773
  // 2. Forward DNS lookup
3729
3774
  let addresses = [];
3730
3775
  try {
3731
- addresses = await dns.resolve(validHostname);
3776
+ addresses = await withTimeout(dns.resolve(validHostname), 500);
3732
3777
  } catch (e) {}
3733
3778
  try {
3734
- const ipv6 = await dns.resolve(validHostname, 'AAAA');
3779
+ const ipv6 = await withTimeout(dns.resolve(validHostname, 'AAAA'), 500);
3735
3780
  addresses = addresses.concat(ipv6);
3736
3781
  } catch (e) {}
3737
3782
  if (addresses.includes(clientIp)) {
3783
+ recordDnsSuccess();
3738
3784
  await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
3739
3785
  return true;
3740
3786
  }
3741
3787
  } catch (error) {
3742
- // DNS errors are common (e.g., for IPs with no rDNS record), treat as failure.
3788
+ recordDnsFailure();
3789
+ await store.set(cacheKey, 'failed', 300); // Temporary negative caching (5 minutes)
3790
+ return false;
3743
3791
  }
3744
3792
 
3745
- await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
3793
+ await store.set(cacheKey, 'failed', 300); // Temporary negative caching (5 minutes)
3746
3794
  return false;
3747
3795
  }
3748
3796
 
@@ -4040,6 +4088,11 @@ export class FingerprintEngine {
4040
4088
  const finalQueryString = finalSearchParams.toString();
4041
4089
  const finalRedirectPath = finalQueryString ? `${originalUrl.pathname}?${finalQueryString}` : originalUrl.pathname;
4042
4090
  this._log('Redirecting to clean path', { finalRedirectPath, cookieMaxAge: finalTtl });
4091
+ const isHttps = requestContext.headers?.['x-forwarded-proto'] === 'https' ||
4092
+ requestContext.rawReq?.secure ||
4093
+ requestContext.rawReq?.protocol === 'https' ||
4094
+ requestContext.rawReq?.connection?.encrypted;
4095
+ const secureOption = isHttps || this.isProduction;
4043
4096
  return {
4044
4097
  action: 'redirect',
4045
4098
  path: finalRedirectPath,
@@ -4048,7 +4101,13 @@ export class FingerprintEngine {
4048
4101
  cookie: {
4049
4102
  name: 'pow_clearance',
4050
4103
  value: ticket, // The ticket itself
4051
- options: { httpOnly: true, secure: this.isProduction, maxAge: finalTtl } // Options for setting the cookie
4104
+ options: {
4105
+ httpOnly: true,
4106
+ secure: secureOption,
4107
+ sameSite: 'strict',
4108
+ ...(secureOption && { partitioned: true }),
4109
+ maxAge: finalTtl
4110
+ } // Options for setting the cookie
4052
4111
  }
4053
4112
  };
4054
4113
  } else {
@@ -4123,7 +4182,7 @@ export class FingerprintEngine {
4123
4182
  if (challengeContext) {
4124
4183
  try {
4125
4184
  const workResult = JSON.parse(pow_solution_work_result);
4126
- const defaultPath = resolve(__dirname, '..', '..', 'problems.config.json');
4185
+ const defaultPath = resolve(__dirname, '..', '..', 'config', 'problems.config.json');
4127
4186
  const configPath = this.securityConfig.usefulWorkConfigPath || (existsSync(defaultPath) ? defaultPath : undefined);
4128
4187
  const manager = await getProblemManager({
4129
4188
  configPath,
@@ -4319,7 +4378,7 @@ export class FingerprintEngine {
4319
4378
  this._log('Issuing a useful work challenge', { finalScore });
4320
4379
 
4321
4380
  try {
4322
- const defaultPath = resolve(process.cwd(), 'problems.config.json');
4381
+ const defaultPath = resolve(process.cwd(), 'config', 'problems.config.json');
4323
4382
  const configPath = this.securityConfig.usefulWorkConfigPath || (existsSync(defaultPath) ? defaultPath : undefined);
4324
4383
  const manager = await getProblemManager({
4325
4384
  configPath,
@@ -5285,7 +5344,7 @@ export const powMiddleware = (securityConfig) => {
5285
5344
 
5286
5345
  // Initialize the problem manager with the configured path, if provided.
5287
5346
  if (securityConfig.enableUsefulWork) {
5288
- const defaultPath = resolve(__dirname, '..', '..', 'problems.config.json');
5347
+ const defaultPath = resolve(__dirname, '..', '..', 'config', 'problems.config.json');
5289
5348
  const configPath = securityConfig.usefulWorkConfigPath || (existsSync(defaultPath) ? defaultPath : undefined);
5290
5349
  getProblemManager({
5291
5350
  configPath,
@@ -5505,6 +5564,10 @@ export const __internal = {
5505
5564
  getTcpAnomalyScore, // Expose for testing,
5506
5565
  getQuicAnomalyScore, // NOUVEAU: Expose pour les tests
5507
5566
  getRenderingAnomalyScore, // NOUVEAU: Expose pour les tests
5567
+ dnsCircuitBreaker,
5568
+ recordDnsSuccess,
5569
+ recordDnsFailure,
5570
+ canAttemptDns,
5508
5571
  registerCooperativeNode,
5509
5572
  findPeerInSubnet,
5510
5573
  handleCooperativeRequest
@@ -5526,48 +5589,79 @@ export function sanitizeTrafficData(trafficData) {
5526
5589
  if (!trafficData || trafficData.length === 0) {
5527
5590
  return [];
5528
5591
  }
5529
- const tempSanitized = [];
5592
+ const suspiciousLogs = [];
5593
+ const passedLogs = [];
5530
5594
  const deviceCounts = new Map();
5531
5595
  const ipCounts = new Map();
5532
5596
  const subnetCounts = new Map();
5597
+ const hardwareClusterCounts = new Map();
5598
+ const hwClusterCache = new Map();
5533
5599
 
5534
5600
  // Calcul des quotas maximums pour éviter l'influence démesurée d'une entité
5535
5601
  const maxLogsPerDevice = Math.max(3, Math.floor(trafficData.length * 0.02)); // Max 2% contribution per device
5536
5602
  const maxLogsPerIp = Math.max(3, Math.floor(trafficData.length * 0.02)); // Max 2% par adresse IP individuelle
5537
5603
  const maxLogsPerSubnet = Math.max(5, Math.floor(trafficData.length * 0.05)); // Max 5% par bloc réseau (anti-proxy-rotation)
5604
+ const maxLogsPerHardwareCluster = Math.max(3, Math.floor(trafficData.length * 0.02)); // Max 2% par cluster matériel stable
5605
+
5606
+ const getHardwareCluster = (log) => {
5607
+ const fp = log.deviceHash || log.fingerprint || log.deviceFingerprint || '';
5608
+ if (!fp || typeof fp !== 'string') {
5609
+ return log.deviceId || 'anonymous-cluster';
5610
+ }
5611
+ if (hwClusterCache.has(fp)) {
5612
+ return hwClusterCache.get(fp);
5613
+ }
5614
+ const parts = fp.split('|');
5615
+ const hwComponents = [];
5616
+ for (const part of parts) {
5617
+ const pair = part.split(':');
5618
+ if (pair.length === 2 && (pair[0] === 'gpu' || pair[0] === 'cvs' || pair[0] === 'hw')) {
5619
+ hwComponents.push(part);
5620
+ }
5621
+ }
5622
+ const result = hwComponents.length > 0 ? hwComponents.sort().join('|') : (log.deviceId || 'anonymous-cluster');
5623
+ hwClusterCache.set(fp, result);
5624
+ return result;
5625
+ };
5538
5626
 
5539
5627
  for (const log of trafficData) {
5540
5628
  const devId = log.deviceId || 'anonymous';
5541
5629
  const ip = log.clientIp || log.ip || 'unknown';
5542
5630
  const subnet = getIpSubnet(ip) || 'unknown-subnet';
5631
+ const hwCluster = getHardwareCluster(log);
5543
5632
 
5544
5633
  const currentDeviceCount = deviceCounts.get(devId) || 0;
5545
5634
  const currentIpCount = ipCounts.get(ip) || 0;
5546
5635
  const currentSubnetCount = subnetCounts.get(subnet) || 0;
5636
+ const currentHwClusterCount = hardwareClusterCounts.get(hwCluster) || 0;
5547
5637
 
5548
- // Filtrage anti-poisoning strict sur 3 axes cumulatifs
5638
+ // Filtrage anti-poisoning strict sur 4 axes cumulatifs (incluant le clustering matériel stable)
5549
5639
  if (
5550
5640
  currentDeviceCount < maxLogsPerDevice &&
5551
5641
  (ip === 'unknown' || currentIpCount < maxLogsPerIp) &&
5552
- (subnet === 'unknown-subnet' || currentSubnetCount < maxLogsPerSubnet)
5642
+ (subnet === 'unknown-subnet' || currentSubnetCount < maxLogsPerSubnet) &&
5643
+ currentHwClusterCount < maxLogsPerHardwareCluster
5553
5644
  ) {
5554
5645
  deviceCounts.set(devId, currentDeviceCount + 1);
5555
5646
  if (ip !== 'unknown') ipCounts.set(ip, currentIpCount + 1);
5556
5647
  if (subnet !== 'unknown-subnet') subnetCounts.set(subnet, currentSubnetCount + 1);
5648
+ hardwareClusterCounts.set(hwCluster, currentHwClusterCount + 1);
5557
5649
 
5558
- tempSanitized.push(log);
5650
+ if (log.type === 'request_passed') {
5651
+ passedLogs.push(log);
5652
+ } else {
5653
+ suspiciousLogs.push(log);
5654
+ }
5559
5655
  }
5560
5656
  }
5561
5657
 
5562
- const passedLogs = tempSanitized.filter(log => log.type === 'request_passed');
5563
- const suspiciousLogs = tempSanitized.filter(log => log.type !== 'request_passed');
5564
-
5565
5658
  const minDataPoints = 200; // Seuil par défaut
5566
5659
  const maxPassedAllowed = Math.max(minDataPoints, suspiciousLogs.length * 9);
5567
- const shuffledPassed = passedLogs.sort(() => 0.5 - Math.random());
5568
- const selectedPassed = shuffledPassed.slice(0, maxPassedAllowed);
5569
-
5570
- return [...suspiciousLogs, ...selectedPassed];
5660
+ if (passedLogs.length > maxPassedAllowed) {
5661
+ const shuffledPassed = passedLogs.sort(() => 0.5 - Math.random());
5662
+ return [...suspiciousLogs, ...shuffledPassed.slice(0, maxPassedAllowed)];
5663
+ }
5664
+ return [...suspiciousLogs, ...passedLogs];
5571
5665
  }
5572
5666
  /**
5573
5667
  * Assainit et limite la taille/ancienneté des données de trafic pour éviter les fuites de mémoire.