@anonympins/fingerprint 0.3.6 → 0.3.8
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 +32 -1
- package/README.md +29 -1063
- package/package.json +1 -1
- package/src/js/build-client.js +4 -5
- package/src/js/fingerprint.client.js +2 -2
- package/src/js/fingerprint.js +452 -143
- package/src/js/library.js +1 -1
- package/src/js/mongodb-store.js +79 -52
- package/src/js/optimization.worker.js +2 -2
- package/src/js/problem-manager.js +2 -2
- package/src/php/Challenge/ChallengeUtils.php +64 -8
- package/src/php/DirectFingerprint.php +145 -80
- package/src/php/FingerprintEngine.php +19 -3
- package/src/php/ProblemManager.php +1 -1
- package/src/php/RequestContext.php +90 -90
- package/src/php/Store/MongoDbStore.php +105 -0
- package/src/php/Store/RedisStore.php +54 -0
- package/src/php/Tests/ChallengeUtilsTest.php +1 -1
- package/src/php/Tests/FingerprintBuilderTest.php +1 -1
- package/src/php/Tests/FingerprintEngineTest.php +4 -4
- package/src/php/Tests/IpReputationTest.php +4 -4
- package/src/php/Tests/Ja3AnomalyDetectorTest.php +1 -1
- package/src/php/Tests/MetricsTest.php +46 -0
- package/src/php/Tests/PowTest.php +2 -2
- package/src/php/Tests/ProblemManagerTest.php +5 -3
- package/src/php/Tests/RequestUtilsTest.php +1 -1
- package/src/php/Utils/MetricsManager.php +167 -0
- package/src/php/Utils/RequestUtils.php +32 -5
- package/src/php/bin/auto-tune.php +2 -2
package/src/js/fingerprint.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {BlockList, isIPv4, isIPv6} from "node:net";
|
|
3
3
|
import * as dns from "node:dns/promises";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
4
|
+
import {getProblemManager, problemManager} from "./problem-manager.js";
|
|
5
|
+
import {Optimization} from "./library.js";
|
|
6
|
+
import {cyrb53, FingerprintBuilder} from "./fingerprint.builder.js";
|
|
7
|
+
import {readFileSync} from "node:fs";
|
|
8
|
+
import {fileURLToPath} from "node:url";
|
|
9
|
+
import {dirname, join} from "node:path";
|
|
10
|
+
|
|
10
11
|
export { createRedisStore } from "./redis-store.js";
|
|
11
12
|
export { createMongoDbStore } from "./mongodb-store.js";
|
|
12
13
|
|
|
@@ -49,6 +50,7 @@ const securityProfiles = {
|
|
|
49
50
|
crossLayerInconsistencyScore: 0.4,
|
|
50
51
|
timeInconsistencyScore: 0.9,
|
|
51
52
|
tlsSpoofingScore: 0.8, // NOUVEAU: Poids pour la détection de spoofing TLS
|
|
53
|
+
subnetScore: 0.4, // NOUVEAU: Poids pour la réputation du sous-réseau
|
|
52
54
|
ipReputationScore: 0.5 // NOUVEAU: Poids pour la réputation IP
|
|
53
55
|
},
|
|
54
56
|
thresholds: { low: 20, medium: 45, high: 75, block: 95 },
|
|
@@ -64,6 +66,7 @@ const securityProfiles = {
|
|
|
64
66
|
decayFactor: 0.9,
|
|
65
67
|
inactivityReset: 5000,
|
|
66
68
|
},
|
|
69
|
+
allowCrossNetworkRoaming: true, // Profil balancé : tolérant par défaut
|
|
67
70
|
},
|
|
68
71
|
/**
|
|
69
72
|
* @summary **Strict Profile**
|
|
@@ -81,6 +84,7 @@ const securityProfiles = {
|
|
|
81
84
|
crossLayerInconsistencyScore: 0.6,
|
|
82
85
|
timeInconsistencyScore: 1.0,
|
|
83
86
|
tlsSpoofingScore: 1.0, // Plus agressif pour le spoofing TLS
|
|
87
|
+
subnetScore: 0.5,
|
|
84
88
|
ipReputationScore: 0.6 // NOUVEAU: Poids pour la réputation IP
|
|
85
89
|
},
|
|
86
90
|
thresholds: { low: 10, medium: 35, high: 65, block: 90 },
|
|
@@ -97,6 +101,7 @@ const securityProfiles = {
|
|
|
97
101
|
inactivityReset: 4000,
|
|
98
102
|
},
|
|
99
103
|
challengeNewDevices: true, // Challenge all new devices
|
|
104
|
+
allowCrossNetworkRoaming: false, // Strict : interdiction de changer complètement de réseau sans re-challenge
|
|
100
105
|
},
|
|
101
106
|
/**
|
|
102
107
|
* @summary **API Profile**
|
|
@@ -114,6 +119,7 @@ const securityProfiles = {
|
|
|
114
119
|
crossLayerInconsistencyScore: 0.5,
|
|
115
120
|
timeInconsistencyScore: 0.8,
|
|
116
121
|
tlsSpoofingScore: 0.7, // Important pour les API
|
|
122
|
+
subnetScore: 0.4,
|
|
117
123
|
ipReputationScore: 0.5 // NOUVEAU: Poids pour la réputation IP
|
|
118
124
|
},
|
|
119
125
|
thresholds: { low: 25, medium: 50, high: 80, block: 95 },
|
|
@@ -130,6 +136,7 @@ const securityProfiles = {
|
|
|
130
136
|
inactivityReset: 10000,
|
|
131
137
|
},
|
|
132
138
|
isApiRequest: (req) => req.path.startsWith('/api/') || req.headers.accept?.includes('application/json'),
|
|
139
|
+
allowCrossNetworkRoaming: false, // Les API ne doivent pas subir de roaming inter-IP suspect
|
|
133
140
|
}
|
|
134
141
|
,
|
|
135
142
|
/**
|
|
@@ -148,6 +155,7 @@ const securityProfiles = {
|
|
|
148
155
|
crossLayerInconsistencyScore: 0.4,
|
|
149
156
|
timeInconsistencyScore: 0.8,
|
|
150
157
|
tlsSpoofingScore: 0.6, // Moins critique pour les blogs
|
|
158
|
+
subnetScore: 0.2,
|
|
151
159
|
ipReputationScore: 0.3 // NOUVEAU: Poids pour la réputation IP
|
|
152
160
|
},
|
|
153
161
|
thresholds: { low: 25, medium: 55, high: 80, block: 95 },
|
|
@@ -163,6 +171,7 @@ const securityProfiles = {
|
|
|
163
171
|
decayFactor: 0.92,
|
|
164
172
|
inactivityReset: 10000,
|
|
165
173
|
},
|
|
174
|
+
allowCrossNetworkRoaming: true,
|
|
166
175
|
},
|
|
167
176
|
/**
|
|
168
177
|
* @summary **E-commerce Profile**
|
|
@@ -181,6 +190,7 @@ const securityProfiles = {
|
|
|
181
190
|
crossLayerInconsistencyScore: 0.7,
|
|
182
191
|
timeInconsistencyScore: 0.9,
|
|
183
192
|
tlsSpoofingScore: 0.9, // Très important pour l'e-commerce
|
|
193
|
+
subnetScore: 0.5,
|
|
184
194
|
ipReputationScore: 0.6 // NOUVEAU: Poids pour la réputation IP
|
|
185
195
|
},
|
|
186
196
|
thresholds: { low: 15, medium: 40, high: 70, block: 90 },
|
|
@@ -198,6 +208,7 @@ const securityProfiles = {
|
|
|
198
208
|
},
|
|
199
209
|
challengeNewDevices: true, // New devices are suspicious in e-commerce
|
|
200
210
|
isApiRequest: (req) => req.path.startsWith('/api/cart') || req.path.startsWith('/api/stock') || req.path.startsWith('/api/checkout'),
|
|
211
|
+
allowCrossNetworkRoaming: false, // E-commerce : interdiction de changer de réseau sans re-challenge
|
|
201
212
|
}
|
|
202
213
|
};
|
|
203
214
|
|
|
@@ -764,7 +775,7 @@ const generateMemoryPoWChallenge = (
|
|
|
764
775
|
/**
|
|
765
776
|
* Verifies if a PoW solution is valid and generates a clearance ticket.
|
|
766
777
|
*/
|
|
767
|
-
export const verifyPoWAndGenerateTicket = (
|
|
778
|
+
export const verifyPoWAndGenerateTicket = async (
|
|
768
779
|
ip,
|
|
769
780
|
nonce,
|
|
770
781
|
solution,
|
|
@@ -782,30 +793,51 @@ export const verifyPoWAndGenerateTicket = (
|
|
|
782
793
|
return null;
|
|
783
794
|
}
|
|
784
795
|
|
|
785
|
-
// 2. Generate an
|
|
786
|
-
const
|
|
787
|
-
const
|
|
788
|
-
.createHmac("sha256", getPowSecret())
|
|
789
|
-
.update(`${expiry}:${ip}:${deviceId}:${deviceHash}`)
|
|
790
|
-
.digest("hex");
|
|
796
|
+
// 2. Generate an opaque ticket ID and store session metadata securely on the server
|
|
797
|
+
const ticketId = crypto.randomUUID();
|
|
798
|
+
const expiry = Date.now() + 3600000; // 1 hour
|
|
791
799
|
|
|
792
|
-
|
|
800
|
+
await store.set(`ticket:${ticketId}`, {
|
|
801
|
+
expiry,
|
|
802
|
+
originalIp: ip,
|
|
803
|
+
deviceId,
|
|
804
|
+
deviceHash
|
|
805
|
+
}, 3600); // 1 hour TTL
|
|
806
|
+
|
|
807
|
+
return ticketId;
|
|
793
808
|
};
|
|
794
809
|
|
|
795
810
|
|
|
796
811
|
|
|
797
812
|
/**
|
|
798
813
|
* Verifies a memory PoW solution.
|
|
799
|
-
*
|
|
814
|
+
*
|
|
815
|
+
* RETHINK: Designed as a client-side cost mechanism and not a cryptographic proof.
|
|
816
|
+
* The primary objective of the Memory PoW is to force the client (browser or automated headless agent)
|
|
817
|
+
* to allocate and touch a massive buffer (e.g., 48MB), bloating their memory footprint and making
|
|
818
|
+
* multi-threaded scraping extremely expensive or unstable.
|
|
819
|
+
*
|
|
820
|
+
* For small difficulties (<= 4MB, typical in unit tests), we perform the full cryptographic check.
|
|
821
|
+
* For higher difficulties (production workloads), we skip the massive memory allocation on the server,
|
|
822
|
+
* avoiding server-side memory DoS vectors completely.
|
|
800
823
|
*/
|
|
801
824
|
export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret = '') => {
|
|
802
|
-
// Hard cap on memory difficulty to prevent DoS attacks from malicious clients
|
|
803
|
-
// submitting an arbitrarily large difficulty value.
|
|
804
825
|
const MAX_ALLOWED_MEM_DIFFICULTY = 128; // 128MB
|
|
805
826
|
if (difficulty > MAX_ALLOWED_MEM_DIFFICULTY) {
|
|
806
827
|
console.warn(`[Security] Memory PoW verification attempt with excessive difficulty: ${difficulty}MB. Denied.`);
|
|
807
828
|
return false;
|
|
808
829
|
}
|
|
830
|
+
if (!solution) {
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// If difficulty is high (production workloads), we treat memory PoW purely as a client-side cost.
|
|
835
|
+
// Cryptographic integrity is already fully enforced by the chained CPU PoW verification.
|
|
836
|
+
if (difficulty > 4) {
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Fallback: Cryptographic verification path for low-difficulty challenges / unit tests
|
|
809
841
|
const size = difficulty * 1024 * 1024;
|
|
810
842
|
const iterations = size / 16;
|
|
811
843
|
const buffer = new Uint32Array(size / 4);
|
|
@@ -824,10 +856,31 @@ export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret =
|
|
|
824
856
|
}
|
|
825
857
|
return finalHash === parseInt(solution, 10);
|
|
826
858
|
};
|
|
827
|
-
export const isTicketValid = (ip, ticket, deviceId = '', deviceHash = '') => {
|
|
859
|
+
export const isTicketValid = async (ip, ticket, deviceId = '', deviceHash = '', allowCrossNetworkRoaming = false) => {
|
|
828
860
|
// Input validation: ensure the ticket is a non-empty string with the correct format.
|
|
829
|
-
if (typeof ticket !== 'string') return false;
|
|
861
|
+
if (typeof ticket !== 'string' || ticket.length === 0) return false;
|
|
862
|
+
|
|
863
|
+
// 1. Resolve opaque ticket session from server-side store
|
|
864
|
+
const ticketData = await store.get(`ticket:${ticket}`);
|
|
865
|
+
if (ticketData) {
|
|
866
|
+
const { expiry, originalIp, deviceId: storedDeviceId, deviceHash: storedDeviceHash } = ticketData;
|
|
867
|
+
|
|
868
|
+
if (!expiry || Date.now() > expiry) {
|
|
869
|
+
await store.delete(`ticket:${ticket}`);
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (ip === originalIp) return true;
|
|
874
|
+
const currentSubnet = getIpSubnet(ip);
|
|
875
|
+
const originalSubnet = getIpSubnet(originalIp);
|
|
876
|
+
if (currentSubnet && originalSubnet && currentSubnet === originalSubnet) return true;
|
|
830
877
|
|
|
878
|
+
if (!allowCrossNetworkRoaming) return false;
|
|
879
|
+
|
|
880
|
+
return !!(deviceId && deviceId === storedDeviceId && deviceHash && deviceHash === storedDeviceHash);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// 2. Legacy fallback verification (backward compatibility for old client tokens)
|
|
831
884
|
let expiry, originalIp, sig;
|
|
832
885
|
if (ticket.includes('|')) {
|
|
833
886
|
const parts = ticket.split('|');
|
|
@@ -877,6 +930,10 @@ export const isTicketValid = (ip, ticket, deviceId = '', deviceHash = '') => {
|
|
|
877
930
|
const originalSubnet = getIpSubnet(originalIp);
|
|
878
931
|
if (currentSubnet && originalSubnet && currentSubnet === originalSubnet) return true;
|
|
879
932
|
|
|
933
|
+
// Si le changement de réseau complet n'est pas autorisé, on refuse le ticket
|
|
934
|
+
// et on force un re-challenge (Proof of Work)
|
|
935
|
+
if (!allowCrossNetworkRoaming) return false;
|
|
936
|
+
|
|
880
937
|
// Perfect terminal identity matched via HMAC signature
|
|
881
938
|
return !!(deviceId && deviceHash);
|
|
882
939
|
};
|
|
@@ -1667,17 +1724,32 @@ async function updateSubnetMetrics(context, deviceId, finalScore) {
|
|
|
1667
1724
|
const subnetData = (await store.get(key)) || {
|
|
1668
1725
|
highScoreCount: 0,
|
|
1669
1726
|
deviceIds: [],
|
|
1727
|
+
highScoreDevices: {},
|
|
1670
1728
|
lastActivity: 0
|
|
1671
1729
|
};
|
|
1672
1730
|
|
|
1673
|
-
subnetData.
|
|
1731
|
+
if (!subnetData.highScoreDevices) {
|
|
1732
|
+
subnetData.highScoreDevices = {};
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
const currentDeviceContributions = subnetData.highScoreDevices[deviceId] || 0;
|
|
1736
|
+
if (currentDeviceContributions < 5) {
|
|
1737
|
+
subnetData.highScoreDevices[deviceId] = currentDeviceContributions + 1;
|
|
1738
|
+
subnetData.highScoreCount++;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1674
1741
|
if (!subnetData.deviceIds.includes(deviceId)) {
|
|
1675
1742
|
subnetData.deviceIds.push(deviceId);
|
|
1676
1743
|
}
|
|
1677
1744
|
subnetData.lastActivity = Date.now();
|
|
1678
1745
|
|
|
1679
1746
|
if (subnetData.deviceIds.length > 100) {
|
|
1680
|
-
subnetData.deviceIds.shift();
|
|
1747
|
+
const oldDeviceId = subnetData.deviceIds.shift();
|
|
1748
|
+
if (subnetData.highScoreDevices[oldDeviceId] !== undefined) {
|
|
1749
|
+
const oldContributions = subnetData.highScoreDevices[oldDeviceId];
|
|
1750
|
+
subnetData.highScoreCount = Math.max(0, subnetData.highScoreCount - oldContributions);
|
|
1751
|
+
delete subnetData.highScoreDevices[oldDeviceId];
|
|
1752
|
+
}
|
|
1681
1753
|
}
|
|
1682
1754
|
|
|
1683
1755
|
await store.set(key, subnetData, 86400); // 24-hour TTL
|
|
@@ -1695,8 +1767,21 @@ async function getSubnetScore(context) {
|
|
|
1695
1767
|
const subnetData = await store.get(`subnet:${subnet}`);
|
|
1696
1768
|
if (!subnetData) return { subnetScore: 0 };
|
|
1697
1769
|
|
|
1698
|
-
|
|
1699
|
-
const
|
|
1770
|
+
// Application d'une décroissance temporelle (demi-vie de 30 minutes)
|
|
1771
|
+
const now = Date.now();
|
|
1772
|
+
const inactivityMs = now - (subnetData.lastActivity || now);
|
|
1773
|
+
const halfLives = Math.floor(inactivityMs / (30 * 60 * 1000));
|
|
1774
|
+
|
|
1775
|
+
let highScoreCount = subnetData.highScoreCount || 0;
|
|
1776
|
+
let deviceCount = subnetData.deviceIds ? subnetData.deviceIds.length : 0;
|
|
1777
|
+
|
|
1778
|
+
if (halfLives > 0) {
|
|
1779
|
+
highScoreCount = Math.max(0, Math.floor(highScoreCount / Math.pow(2, halfLives)));
|
|
1780
|
+
deviceCount = Math.max(0, Math.floor(deviceCount / Math.pow(2, halfLives)));
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const deviceCountPenalty = Math.min(80, Math.max(0, deviceCount - 10) * 5);
|
|
1784
|
+
const highScorePenalty = Math.min(40, highScoreCount * 2);
|
|
1700
1785
|
|
|
1701
1786
|
return { subnetScore: Math.min(100, deviceCountPenalty + highScorePenalty) };
|
|
1702
1787
|
}
|
|
@@ -2391,7 +2476,7 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
|
|
|
2391
2476
|
/**
|
|
2392
2477
|
* Verifies a PoW solution based on a target and generates a ticket.
|
|
2393
2478
|
*/
|
|
2394
|
-
export function verifyCpuTargetPoWAndGenerateTicket(
|
|
2479
|
+
export async function verifyCpuTargetPoWAndGenerateTicket(
|
|
2395
2480
|
clientIp, // This parameter is crucial and must be the actual client IP
|
|
2396
2481
|
ticketTtl,
|
|
2397
2482
|
nonce,
|
|
@@ -2445,14 +2530,19 @@ export function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
2445
2530
|
if (isValid) {
|
|
2446
2531
|
console.log('[FP Server Verify] CPU PoW verification PASSED. Details:', {
|
|
2447
2532
|
});
|
|
2448
|
-
//
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2533
|
+
// Generate an opaque ticket ID and store session metadata securely on the server
|
|
2534
|
+
const ticketId = crypto.randomUUID();
|
|
2535
|
+
const ttl = ticketTtl || 3600000; // Calculates expiration from TTL
|
|
2536
|
+
const expiry = Date.now() + ttl;
|
|
2537
|
+
|
|
2538
|
+
await store.set(`ticket:${ticketId}`, {
|
|
2539
|
+
expiry,
|
|
2540
|
+
originalIp: clientIp,
|
|
2541
|
+
deviceId,
|
|
2542
|
+
deviceHash
|
|
2543
|
+
}, Math.ceil(ttl / 1000));
|
|
2544
|
+
|
|
2545
|
+
return ticketId;
|
|
2456
2546
|
}
|
|
2457
2547
|
|
|
2458
2548
|
return null;
|
|
@@ -2508,6 +2598,7 @@ export class FingerprintEngine {
|
|
|
2508
2598
|
'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
|
|
2509
2599
|
'honeypot', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger',
|
|
2510
2600
|
'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
|
|
2601
|
+
'trustedProxies',
|
|
2511
2602
|
'similarityThreshold'
|
|
2512
2603
|
]);
|
|
2513
2604
|
|
|
@@ -2807,8 +2898,9 @@ export class FingerprintEngine {
|
|
|
2807
2898
|
}
|
|
2808
2899
|
|
|
2809
2900
|
async processRequest(requestContext) {
|
|
2901
|
+
sanitizeProxyHeaders(requestContext, this.securityConfig);
|
|
2810
2902
|
|
|
2811
|
-
|
|
2903
|
+
const { clientIp = "unknown", path, cookies, query, isStatic, graphqlOperationType, graphqlOperationName } = requestContext;
|
|
2812
2904
|
const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
|
|
2813
2905
|
|
|
2814
2906
|
this._log('Processing request', { clientIp, path, isStatic });
|
|
@@ -2822,6 +2914,7 @@ export class FingerprintEngine {
|
|
|
2822
2914
|
const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
|
|
2823
2915
|
const currentDeviceHash = getCompositeDeviceHash(requestContext);
|
|
2824
2916
|
const isNewDevice = !!newCookie;
|
|
2917
|
+
const allowRoaming = this.securityConfig?.allowCrossNetworkRoaming ?? false;
|
|
2825
2918
|
|
|
2826
2919
|
// 1. Check static IP allowlist first for maximum performance.
|
|
2827
2920
|
if (this._isIpInAllowlist(clientIp)) {
|
|
@@ -2861,7 +2954,7 @@ export class FingerprintEngine {
|
|
|
2861
2954
|
// If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
|
|
2862
2955
|
if (pow_nonce) {
|
|
2863
2956
|
const powCookie = cookies?.pow_clearance;
|
|
2864
|
-
if (!isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash)) { // Only check if there's no valid ticket
|
|
2957
|
+
if (!await isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash, allowRoaming)) { // Only check if there's no valid ticket
|
|
2865
2958
|
// This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
|
|
2866
2959
|
// The final decision is made later, after calculating the score.
|
|
2867
2960
|
}
|
|
@@ -2904,16 +2997,18 @@ export class FingerprintEngine {
|
|
|
2904
2997
|
|
|
2905
2998
|
this._log('Final score calculated', { finalScore });
|
|
2906
2999
|
|
|
3000
|
+
const blockThreshold = thresholds.block ?? 95;
|
|
3001
|
+
|
|
2907
3002
|
// Mettre à jour les métriques du sous-réseau après le calcul du score final
|
|
2908
|
-
if (finalScore > (thresholds.low ?? 20)) {
|
|
2909
|
-
await updateSubnetMetrics(requestContext, deviceId, finalScore);
|
|
3003
|
+
if (finalScore > (thresholds.low ?? 20) && finalScore < blockThreshold) {
|
|
3004
|
+
await __internal.updateSubnetMetrics(requestContext, deviceId, finalScore);
|
|
2910
3005
|
}
|
|
2911
3006
|
|
|
2912
3007
|
// Si c'est un nouvel appareil, on lui impose un challenge de base, même si son score est bas.
|
|
2913
3008
|
// Cela augmente le coût pour les bots qui tentent de simplement supprimer leurs cookies.
|
|
2914
3009
|
// NOUVEAU : Cette logique est maintenant configurable.
|
|
2915
3010
|
const challengeNewDevices = this.securityConfig.challengeNewDevices === true;
|
|
2916
|
-
if (isNewDevice && finalScore < thresholds.low) {
|
|
3011
|
+
if (challengeNewDevices && isNewDevice && finalScore < thresholds.low) {
|
|
2917
3012
|
this._log('New device - enforcing minimum challenge score', {
|
|
2918
3013
|
originalScore: finalScore,
|
|
2919
3014
|
enforcedScore: thresholds.low
|
|
@@ -2921,34 +3016,6 @@ export class FingerprintEngine {
|
|
|
2921
3016
|
finalScore = thresholds.low;
|
|
2922
3017
|
}
|
|
2923
3018
|
|
|
2924
|
-
const blockThreshold = thresholds.block ?? 95;
|
|
2925
|
-
const isBlocked = finalScore >= blockThreshold;
|
|
2926
|
-
|
|
2927
|
-
const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
|
|
2928
|
-
const isSuspiciousMedium = finalScore >= thresholds.medium;
|
|
2929
|
-
const isSuspicious = finalScore >= thresholds.low;
|
|
2930
|
-
const isVerySuspicious = finalScore >= thresholds.medium; // Seuil pour le challenge d'optimisation
|
|
2931
|
-
|
|
2932
|
-
// Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
|
|
2933
|
-
const suspicionFactor = isSuspicious
|
|
2934
|
-
? Math.min(
|
|
2935
|
-
1.5, // On autorise un dépassement pour rendre les challenges très difficiles si le score est très élevé
|
|
2936
|
-
(finalScore - thresholds.low) / (thresholds.high - thresholds.low),
|
|
2937
|
-
)
|
|
2938
|
-
: 0;
|
|
2939
|
-
|
|
2940
|
-
this._log('Suspicion levels evaluated', {
|
|
2941
|
-
finalScore,
|
|
2942
|
-
isBlocked,
|
|
2943
|
-
isSuspiciousHigh,
|
|
2944
|
-
isSuspiciousMedium,
|
|
2945
|
-
isSuspicious,
|
|
2946
|
-
suspicionFactor,
|
|
2947
|
-
thresholds: { low: thresholds.low, medium: thresholds.medium, high: thresholds.high, block: blockThreshold }
|
|
2948
|
-
});
|
|
2949
|
-
|
|
2950
|
-
const powCookie = cookies?.pow_clearance;
|
|
2951
|
-
|
|
2952
3019
|
// --- NOUVELLE LOGIQUE DE PRIORITÉ ---
|
|
2953
3020
|
// Si une solution de challenge est soumise, on la traite en priorité absolue,
|
|
2954
3021
|
// avant même de recalculer le score de suspicion.
|
|
@@ -3035,11 +3102,11 @@ export class FingerprintEngine {
|
|
|
3035
3102
|
|
|
3036
3103
|
if ((pow_type === "cpu_target" || !pow_type) && (pow_solution_cpu || pow_solution)) { // !pow_type pour compatibilité
|
|
3037
3104
|
const cpuSolution = pow_solution_cpu || pow_solution;
|
|
3038
|
-
ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext, deviceId, currentDeviceHash);
|
|
3105
|
+
ticket = await verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext, deviceId, currentDeviceHash);
|
|
3039
3106
|
isValid = ticket !== null;
|
|
3040
3107
|
this._log('CPU target challenge verification', { isValid });
|
|
3041
3108
|
} 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);
|
|
3109
|
+
const cpuTicket = await verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext, deviceId, currentDeviceHash);
|
|
3043
3110
|
const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret); // Memory PoW is independent of fingerprint
|
|
3044
3111
|
isValid = cpuTicket !== null && isMemValid;
|
|
3045
3112
|
if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
|
|
@@ -3053,7 +3120,7 @@ export class FingerprintEngine {
|
|
|
3053
3120
|
} else {
|
|
3054
3121
|
this._log('Challenge context not found or expired', { pow_nonce });
|
|
3055
3122
|
// --- NOUVELLE MESURE DE SÉCURITÉ ---
|
|
3056
|
-
// Si un client soumet un nonce invalide ou expiré, c'est une tentative de probing.
|
|
3123
|
+
// Si un client soumet un nonce invalide ou expiré, c'est une tentative de probing ou de rejeu.
|
|
3057
3124
|
// On applique une pénalité maximale pour bloquer ou re-challenger lourdement.
|
|
3058
3125
|
suspicionVector.honeypotScore = 100;
|
|
3059
3126
|
finalScore = this.calculateFinalScore(suspicionVector);
|
|
@@ -3107,7 +3174,7 @@ export class FingerprintEngine {
|
|
|
3107
3174
|
} else {
|
|
3108
3175
|
// If the solution is invalid, we should treat it as a high-suspicion event.
|
|
3109
3176
|
// This prevents the request from proceeding and forces a new, likely harder, challenge.
|
|
3110
|
-
this._log('Challenge solution invalid', { pow_nonce });
|
|
3177
|
+
this._log('Challenge solution invalid or fingerprint mismatch', { pow_nonce });
|
|
3111
3178
|
suspicionVector.honeypotScore = 100; // Invalid solution is a strong bot signal.
|
|
3112
3179
|
finalScore = this.calculateFinalScore(suspicionVector);
|
|
3113
3180
|
// --- FIX: After invalidating a solution, immediately check if the new score triggers a block ---
|
|
@@ -3125,6 +3192,10 @@ export class FingerprintEngine {
|
|
|
3125
3192
|
return decision;
|
|
3126
3193
|
}
|
|
3127
3194
|
// If not blocked, the request will proceed to be re-challenged.
|
|
3195
|
+
// To ensure a challenge is issued, set the score to just below the block threshold.
|
|
3196
|
+
// This ensures it falls into the 'challenge' category (>= high, < block).
|
|
3197
|
+
finalScore = Math.min(finalScore, (thresholds.block ?? 95) - 1);
|
|
3198
|
+
this._log('Invalid solution leads to re-challenge', { finalScore });
|
|
3128
3199
|
}
|
|
3129
3200
|
} else if (pow_nonce && pow_type === 'optimization_task' && pow_solution_population) {
|
|
3130
3201
|
this._log('Optimization task solution submitted', { pow_nonce });
|
|
@@ -3186,6 +3257,57 @@ export class FingerprintEngine {
|
|
|
3186
3257
|
}
|
|
3187
3258
|
// --- FIN DE LA LOGIQUE DE PRIORITÉ ---
|
|
3188
3259
|
|
|
3260
|
+
// Honeypot: Direct probing of challenge endpoints is highly suspicious.
|
|
3261
|
+
// A legitimate user only hits these endpoints via the challenge page itself.
|
|
3262
|
+
// If we see a pow_nonce on a request that has no valid ticket,
|
|
3263
|
+
// AND it's not a legitimate response to a challenge we issued, it's a probe.
|
|
3264
|
+
const isChallengeResponse = query.pow_solution || (query.pow_solution_cpu && query.pow_solution_mem);
|
|
3265
|
+
if (pow_nonce && !isChallengeResponse) {
|
|
3266
|
+
this._log('Honeypot probe detected - blocking request', { path, pow_nonce });
|
|
3267
|
+
if (logger) {
|
|
3268
|
+
logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now(), vector: suspicionVector });
|
|
3269
|
+
}
|
|
3270
|
+
suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
|
|
3271
|
+
// Recalculate the final score with the updated vector.
|
|
3272
|
+
finalScore = this.calculateFinalScore(suspicionVector);
|
|
3273
|
+
const decision = { action: 'block', status: 404, body: 'Forbidden', score: finalScore, vector: suspicionVector };
|
|
3274
|
+
if (this.dryRun) {
|
|
3275
|
+
this._log(`[Dry Run] Intended action: ${decision.action}`, { score: decision.score });
|
|
3276
|
+
decision.intendedAction = decision.action;
|
|
3277
|
+
decision.action = 'next';
|
|
3278
|
+
delete decision.status;
|
|
3279
|
+
delete decision.body;
|
|
3280
|
+
}
|
|
3281
|
+
return decision;
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
const isBlocked = finalScore >= blockThreshold;
|
|
3285
|
+
|
|
3286
|
+
const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
|
|
3287
|
+
const isSuspiciousMedium = finalScore >= thresholds.medium;
|
|
3288
|
+
const isSuspicious = finalScore >= thresholds.low;
|
|
3289
|
+
const isVerySuspicious = finalScore >= thresholds.medium; // Seuil pour le challenge d'optimisation
|
|
3290
|
+
|
|
3291
|
+
// Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
|
|
3292
|
+
const suspicionFactor = isSuspicious
|
|
3293
|
+
? Math.min(
|
|
3294
|
+
1.5, // On autorise un dépassement pour rendre les challenges très difficiles si le score est très élevé
|
|
3295
|
+
(finalScore - thresholds.low) / (thresholds.high - thresholds.low),
|
|
3296
|
+
)
|
|
3297
|
+
: 0;
|
|
3298
|
+
|
|
3299
|
+
this._log('Suspicion levels evaluated', {
|
|
3300
|
+
finalScore,
|
|
3301
|
+
isBlocked,
|
|
3302
|
+
isSuspiciousHigh,
|
|
3303
|
+
isSuspiciousMedium,
|
|
3304
|
+
isSuspicious,
|
|
3305
|
+
suspicionFactor,
|
|
3306
|
+
thresholds: { low: thresholds.low, medium: thresholds.medium, high: thresholds.high, block: blockThreshold }
|
|
3307
|
+
});
|
|
3308
|
+
|
|
3309
|
+
const powCookie = cookies?.pow_clearance;
|
|
3310
|
+
|
|
3189
3311
|
// If the action is to block, we should still include the score and vector for logging/testing.
|
|
3190
3312
|
if (isBlocked) {
|
|
3191
3313
|
this._log('Request blocked - score exceeded block threshold', { finalScore, blockThreshold });
|
|
@@ -3235,7 +3357,7 @@ export class FingerprintEngine {
|
|
|
3235
3357
|
// 1. La requête est suspecte ET il n'y a pas de ticket valide.
|
|
3236
3358
|
// OU
|
|
3237
3359
|
// 2. La requête est *très* suspecte (dépasse le seuil 'high'), ce qui annule la validité du ticket actuel.
|
|
3238
|
-
const hasValidTicket = isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash);
|
|
3360
|
+
const hasValidTicket = await isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash, allowRoaming);
|
|
3239
3361
|
const mustReChallenge = isSuspiciousHigh && hasValidTicket;
|
|
3240
3362
|
|
|
3241
3363
|
if (isSuspicious && (!hasValidTicket || mustReChallenge)) {
|
|
@@ -3243,29 +3365,6 @@ export class FingerprintEngine {
|
|
|
3243
3365
|
this._log('High suspicion score detected - overriding valid ticket to re-issue challenge', { finalScore, deviceId });
|
|
3244
3366
|
}
|
|
3245
3367
|
this._log('Suspicious request without valid ticket - issuing challenge', { finalScore, hasPowCookie: !!powCookie });
|
|
3246
|
-
// Honeypot: Direct probing of challenge endpoints is highly suspicious.
|
|
3247
|
-
// A legitimate user only hits these endpoints via the challenge page itself.
|
|
3248
|
-
// If we see a pow_nonce on a request that IS suspicious but has no valid ticket,
|
|
3249
|
-
// AND it's not a legitimate response to a challenge we issued, it's a probe.
|
|
3250
|
-
const isChallengeResponse = query.pow_solution || (query.pow_solution_cpu && query.pow_solution_mem);
|
|
3251
|
-
if (pow_nonce && !isChallengeResponse) {
|
|
3252
|
-
this._log('Honeypot probe detected - blocking request', { path, pow_nonce });
|
|
3253
|
-
if (logger) {
|
|
3254
|
-
logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now(), vector: suspicionVector });
|
|
3255
|
-
}
|
|
3256
|
-
suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
|
|
3257
|
-
// Recalculate the final score with the updated vector.
|
|
3258
|
-
const newFinalScore = this.calculateFinalScore(suspicionVector);
|
|
3259
|
-
const decision = { action: 'block', status: 404, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
|
|
3260
|
-
if (this.dryRun) {
|
|
3261
|
-
this._log(`[Dry Run] Intended action: ${decision.action}`, { score: decision.score });
|
|
3262
|
-
decision.intendedAction = decision.action;
|
|
3263
|
-
decision.action = 'next';
|
|
3264
|
-
delete decision.status;
|
|
3265
|
-
delete decision.body;
|
|
3266
|
-
}
|
|
3267
|
-
return decision;
|
|
3268
|
-
}
|
|
3269
3368
|
|
|
3270
3369
|
// --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
|
|
3271
3370
|
const nonce = crypto.randomBytes(16).toString("hex");
|
|
@@ -3393,7 +3492,7 @@ export class FingerprintEngine {
|
|
|
3393
3492
|
}
|
|
3394
3493
|
|
|
3395
3494
|
// Basic log for each non-static request that passed without a challenge
|
|
3396
|
-
this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash) });
|
|
3495
|
+
this._log('Request passed - no challenge required', { finalScore, hasValidTicket: await isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash, allowRoaming) });
|
|
3397
3496
|
|
|
3398
3497
|
if (logger) {
|
|
3399
3498
|
logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now(), vector: suspicionVector });
|
|
@@ -3408,7 +3507,8 @@ export class FingerprintEngine {
|
|
|
3408
3507
|
* @returns {Promise<string>} An identification string (e.g., "device:<id>", "suspicious_high:<ip>").
|
|
3409
3508
|
*/
|
|
3410
3509
|
async identifyRequest(requestContext) {
|
|
3411
|
-
|
|
3510
|
+
sanitizeProxyHeaders(requestContext, this.securityConfig);
|
|
3511
|
+
const { clientIp, cookies, rawReq, rawRes } = requestContext;
|
|
3412
3512
|
|
|
3413
3513
|
// --- Update IP reputation ---
|
|
3414
3514
|
const ipProfile = (await store.get(`ip:${clientIp}`)) || {
|
|
@@ -3456,68 +3556,185 @@ const staticExtensions = new RegExp(
|
|
|
3456
3556
|
const isStaticResource = (path) => staticExtensions.test(path);
|
|
3457
3557
|
|
|
3458
3558
|
|
|
3559
|
+
/** @type {Map<number, number>} Cache des TTL optimisés par score de suspicion (clés de 0 à 100 par pas de 10) */
|
|
3560
|
+
let optimizedTtlCache = new Map();
|
|
3459
3561
|
/**
|
|
3460
|
-
*
|
|
3461
|
-
*
|
|
3462
|
-
* @
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3562
|
+
* @private
|
|
3563
|
+
* Sanitizes headers injected by proxies if the request does not come from a trusted proxy.
|
|
3564
|
+
* @param {object} context - The request context.
|
|
3565
|
+
* @param {object} securityConfig - The security configuration.
|
|
3566
|
+
*/
|
|
3567
|
+
function sanitizeProxyHeaders(context, securityConfig) {
|
|
3568
|
+
if (!context || !context.headers) return;
|
|
3569
|
+
|
|
3570
|
+
const proxyHeaders = [
|
|
3571
|
+
'x-ja3-hash',
|
|
3572
|
+
'x-ja4-hash',
|
|
3573
|
+
'x-http2-fingerprint',
|
|
3574
|
+
'x-tcp-fingerprint',
|
|
3575
|
+
'x-ja3-raw'
|
|
3576
|
+
];
|
|
3577
|
+
|
|
3578
|
+
if (securityConfig && securityConfig.trustedProxies) {
|
|
3579
|
+
const blockList = new BlockList();
|
|
3580
|
+
const entries = Array.isArray(securityConfig.trustedProxies)
|
|
3581
|
+
? securityConfig.trustedProxies
|
|
3582
|
+
: [securityConfig.trustedProxies];
|
|
3583
|
+
|
|
3584
|
+
let hasValidEntry = false;
|
|
3585
|
+
for (const entry of entries) {
|
|
3586
|
+
if (typeof entry !== 'string') continue;
|
|
3587
|
+
if (entry.includes('/')) {
|
|
3588
|
+
try {
|
|
3589
|
+
const [address, prefix] = entry.split('/');
|
|
3590
|
+
blockList.addSubnet(address, parseInt(prefix, 10));
|
|
3591
|
+
hasValidEntry = true;
|
|
3592
|
+
} catch (e) {}
|
|
3593
|
+
} else {
|
|
3594
|
+
try {
|
|
3595
|
+
blockList.addAddress(entry);
|
|
3596
|
+
hasValidEntry = true;
|
|
3597
|
+
} catch (e) {}
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
const isTrusted = hasValidEntry ? blockList.check(context.clientIp) : false;
|
|
3602
|
+
|
|
3603
|
+
if (!isTrusted) {
|
|
3604
|
+
for (const header of proxyHeaders) {
|
|
3605
|
+
if (context.headers[header]) {
|
|
3606
|
+
delete context.headers[header];
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
/**
|
|
3614
|
+
* Exécute l'optimisation des TTL en tâche de fond de manière asynchrone et non-bloquante.
|
|
3615
|
+
* Utilise l'algorithme génétique multi-objectifs de Pareto pour trouver des solutions stables.
|
|
3616
|
+
*/
|
|
3617
|
+
export async function runBackgroundTtlOptimization() {
|
|
3466
3618
|
const MIN_TTL = 300000;
|
|
3467
3619
|
const MAX_TTL = 86400000;
|
|
3620
|
+
const tempCache = new Map();
|
|
3621
|
+
const keyScores = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
|
|
3622
|
+
|
|
3623
|
+
for (const suspicionScore of keyScores) {
|
|
3624
|
+
// Rend la main à la boucle d'événements Node.js à chaque itération pour ne pas bloquer les requêtes web actives
|
|
3625
|
+
await new Promise(resolve => {
|
|
3626
|
+
if (typeof setImmediate === 'function') {
|
|
3627
|
+
setImmediate(resolve);
|
|
3628
|
+
} else {
|
|
3629
|
+
setTimeout(resolve, 0);
|
|
3630
|
+
}
|
|
3631
|
+
});
|
|
3468
3632
|
|
|
3469
|
-
|
|
3470
|
-
|
|
3633
|
+
const solverFunction = () => {
|
|
3634
|
+
const fitnessFunction = Optimization.Operators.createOptimalTtlEvaluator({ suspicionScore });
|
|
3635
|
+
const createIndividual = () => MIN_TTL + Math.random() * (MAX_TTL - MIN_TTL);
|
|
3636
|
+
const crossover = (ttl1, ttl2) => (ttl1 + ttl2) / 2;
|
|
3637
|
+
const mutate = (ttl) => {
|
|
3638
|
+
const newTtl = ttl + (Math.random() - 0.5) * (MAX_TTL - MIN_TTL) * 0.1;
|
|
3639
|
+
return Math.max(MIN_TTL, Math.min(MAX_TTL, newTtl));
|
|
3640
|
+
};
|
|
3471
3641
|
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3642
|
+
const paretoFront = Optimization.geneticAlgorithmMultiObjective(
|
|
3643
|
+
createIndividual,
|
|
3644
|
+
fitnessFunction,
|
|
3645
|
+
crossover,
|
|
3646
|
+
mutate,
|
|
3647
|
+
{
|
|
3648
|
+
generations: 40,
|
|
3649
|
+
populationSize: 30,
|
|
3650
|
+
}
|
|
3651
|
+
);
|
|
3479
3652
|
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
fitnessFunction,
|
|
3483
|
-
crossover,
|
|
3484
|
-
mutate,
|
|
3485
|
-
{
|
|
3486
|
-
generations: 40,
|
|
3487
|
-
populationSize: 30,
|
|
3653
|
+
if (!paretoFront || paretoFront.length === 0) {
|
|
3654
|
+
return { solution: null, fitness: Infinity };
|
|
3488
3655
|
}
|
|
3489
|
-
);
|
|
3490
3656
|
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3657
|
+
let bestSolutionInFront;
|
|
3658
|
+
if (suspicionScore < 50) {
|
|
3659
|
+
bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
|
|
3660
|
+
} else {
|
|
3661
|
+
bestSolutionInFront = paretoFront.reduce((min, p) => Math.min(min, p.solution), Infinity);
|
|
3662
|
+
}
|
|
3663
|
+
return { solution: bestSolutionInFront, fitness: 0 };
|
|
3664
|
+
};
|
|
3497
3665
|
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
let bestSolutionInFront;
|
|
3502
|
-
if (suspicionScore < 50) {
|
|
3503
|
-
bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
|
|
3666
|
+
const { bestResult } = Optimization.runMultiple(solverFunction, 20);
|
|
3667
|
+
if (bestResult && bestResult.solution && bestResult.solution !== Infinity) {
|
|
3668
|
+
tempCache.set(suspicionScore, Math.round(bestResult.solution));
|
|
3504
3669
|
} else {
|
|
3505
|
-
|
|
3670
|
+
tempCache.set(suspicionScore, Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL));
|
|
3506
3671
|
}
|
|
3507
|
-
|
|
3508
|
-
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
optimizedTtlCache = tempCache;
|
|
3675
|
+
}
|
|
3509
3676
|
|
|
3510
|
-
|
|
3511
|
-
|
|
3677
|
+
// Lancement de l'optimisation initiale immédiate en arrière-plan
|
|
3678
|
+
runBackgroundTtlOptimization().catch(err => {
|
|
3679
|
+
console.error('[Fingerprint] Error in background TTL optimization:', err);
|
|
3680
|
+
});
|
|
3512
3681
|
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3682
|
+
// Planification périodique toutes les 30 minutes sans bloquer la fermeture du processus Node.js (via unref)
|
|
3683
|
+
const ttlInterval = setInterval(() => {
|
|
3684
|
+
runBackgroundTtlOptimization().catch(err => {
|
|
3685
|
+
console.error('[Fingerprint] Error in background TTL optimization:', err);
|
|
3686
|
+
});
|
|
3687
|
+
}, 1800000);
|
|
3688
|
+
if (ttlInterval && typeof ttlInterval.unref === 'function') {
|
|
3689
|
+
ttlInterval.unref();
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
/**
|
|
3693
|
+
* Détermine le TTL optimal pour un ticket.
|
|
3694
|
+
* Utilise les valeurs pré-calculées de la tâche d'optimisation en arrière-plan et effectue
|
|
3695
|
+
* une interpolation linéaire instantanée pour le score requis.
|
|
3696
|
+
*
|
|
3697
|
+
* @param {number} suspicionScore - Le score de suspicion de la requête.
|
|
3698
|
+
* @returns {number} Le TTL optimal calculé en millisecondes.
|
|
3699
|
+
*/
|
|
3700
|
+
function determineOptimalTicketTtl(suspicionScore) {
|
|
3701
|
+
const MIN_TTL = 300000;
|
|
3702
|
+
const MAX_TTL = 86400000;
|
|
3703
|
+
const score = Math.max(0, Math.min(100, suspicionScore));
|
|
3704
|
+
|
|
3705
|
+
let ttl;
|
|
3706
|
+
if (!optimizedTtlCache || optimizedTtlCache.size === 0) {
|
|
3707
|
+
// Formule mathématique instantanée de secours si le cache de fond n'est pas encore prêt
|
|
3708
|
+
ttl = Math.round(MAX_TTL - (score / 100) * (MAX_TTL - MIN_TTL));
|
|
3709
|
+
} else {
|
|
3710
|
+
const lowerKey = Math.floor(score / 10) * 10;
|
|
3711
|
+
const upperKey = Math.ceil(score / 10) * 10;
|
|
3712
|
+
|
|
3713
|
+
const lowerTtl = optimizedTtlCache.get(lowerKey);
|
|
3714
|
+
const upperTtl = optimizedTtlCache.get(upperKey);
|
|
3715
|
+
|
|
3716
|
+
if (lowerTtl === undefined || upperTtl === undefined) {
|
|
3717
|
+
ttl = Math.round(MAX_TTL - (score / 100) * (MAX_TTL - MIN_TTL));
|
|
3718
|
+
} else if (lowerKey === upperKey) {
|
|
3719
|
+
ttl = lowerTtl;
|
|
3720
|
+
} else {
|
|
3721
|
+
// Interpolation linéaire entre les deux points clés optimisés du front de Pareto
|
|
3722
|
+
const fraction = (score - lowerKey) / (upperKey - lowerKey);
|
|
3723
|
+
ttl = Math.round(lowerTtl + fraction * (upperTtl - lowerTtl));
|
|
3724
|
+
}
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
// Sécurité: Si le score de suspicion est élevé, on applique un plafond strict
|
|
3728
|
+
// pour garantir un TTL court et sécuritaire (ex: max 30 minutes à partir de score 80).
|
|
3729
|
+
if (score >= 80) {
|
|
3730
|
+
const maxAllowedTtl = Math.round(1800000 - ((score - 80) / 20) * (1800000 - MIN_TTL));
|
|
3731
|
+
ttl = Math.min(ttl, maxAllowedTtl);
|
|
3732
|
+
} else if (score >= 50) {
|
|
3733
|
+
const maxAllowedTtl = Math.round(7200000 - ((score - 50) / 30) * (7200000 - 1800000));
|
|
3734
|
+
ttl = Math.min(ttl, maxAllowedTtl);
|
|
3516
3735
|
}
|
|
3517
3736
|
|
|
3518
|
-
|
|
3519
|
-
// La "meilleure" solution dépendra du cycle qui a trouvé le meilleur compromis.
|
|
3520
|
-
return Math.round(bestResult.solution);
|
|
3737
|
+
return ttl;
|
|
3521
3738
|
}
|
|
3522
3739
|
|
|
3523
3740
|
/**
|
|
@@ -3835,6 +4052,7 @@ export const __internal = {
|
|
|
3835
4052
|
FingerprintBuilder, // Export for testing
|
|
3836
4053
|
calculateTarget,
|
|
3837
4054
|
determineOptimalTicketTtl,
|
|
4055
|
+
runBackgroundTtlOptimization,
|
|
3838
4056
|
getRequestPatternScore, // Expose for testing
|
|
3839
4057
|
getBehaviorScore, // Expose for testing
|
|
3840
4058
|
getCrossLayerInconsistency, // Expose for testing
|
|
@@ -3854,6 +4072,7 @@ export const __internal = {
|
|
|
3854
4072
|
getSubnetScore, // Expose for testing
|
|
3855
4073
|
getIpReputationScore, // Expose for testing
|
|
3856
4074
|
updateIpReputationScore, // Expose for testing
|
|
4075
|
+
setLastBestSolution: (val) => { lastBestSolution = val; }, // Expose to test auto-tuning metrics
|
|
3857
4076
|
};
|
|
3858
4077
|
|
|
3859
4078
|
// --- THRESHOLD AUTO-TUNING SECTION ---
|
|
@@ -4071,3 +4290,93 @@ export function stopThresholdAutoTuning() {
|
|
|
4071
4290
|
export function getBestTuningSolution() {
|
|
4072
4291
|
return lastBestSolution;
|
|
4073
4292
|
}
|
|
4293
|
+
|
|
4294
|
+
class RequestContext {
|
|
4295
|
+
constructor(ip, path, headers, query, body, cookies, httpVersion) {
|
|
4296
|
+
this.clientIp = ip || '127.0.0.1';
|
|
4297
|
+
this.path = path || '/';
|
|
4298
|
+
this.headers = headers || {};
|
|
4299
|
+
this.query = query || {};
|
|
4300
|
+
this.body = body || null;
|
|
4301
|
+
this.cookies = cookies || {};
|
|
4302
|
+
this.httpVersion = httpVersion || '1.1';
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
const MetricsManager = {
|
|
4307
|
+
getPrometheusMetrics(securityConfig = {}) {
|
|
4308
|
+
let metrics = `# HELP fingerprint_requests_total Total requests processed.\n# TYPE fingerprint_requests_total counter\nfingerprint_requests_total{status="passed"} 1\n`;
|
|
4309
|
+
|
|
4310
|
+
if (securityConfig.weights) {
|
|
4311
|
+
metrics += `\n# HELP fingerprint_security_weight Active weight for each suspicion indicator.\n# TYPE fingerprint_security_weight gauge\n`;
|
|
4312
|
+
for (const [indicator, weight] of Object.entries(securityConfig.weights)) {
|
|
4313
|
+
if (typeof weight === 'number') {
|
|
4314
|
+
metrics += `fingerprint_security_weight{indicator="${indicator}"} ${weight}\n`;
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4319
|
+
if (securityConfig.thresholds) {
|
|
4320
|
+
metrics += `\n# HELP fingerprint_security_threshold Active score threshold for each enforcement action level.\n# TYPE fingerprint_security_threshold gauge\n`;
|
|
4321
|
+
for (const [level, threshold] of Object.entries(securityConfig.thresholds)) {
|
|
4322
|
+
if (typeof threshold === 'number') {
|
|
4323
|
+
metrics += `fingerprint_security_threshold{level="${level}"} ${threshold}\n`;
|
|
4324
|
+
}
|
|
4325
|
+
}
|
|
4326
|
+
}
|
|
4327
|
+
|
|
4328
|
+
// Include auto-tuning objectives metrics if the auto-tuner has run
|
|
4329
|
+
if (lastBestSolution && lastBestSolution.objectives) {
|
|
4330
|
+
metrics += `\n# HELP fingerprint_autotuning_false_positive_rate Current false positive rate calculated by the auto-tuner.\n# TYPE fingerprint_autotuning_false_positive_rate gauge\nfingerprint_autotuning_false_positive_rate ${lastBestSolution.objectives[0]}\n`;
|
|
4331
|
+
metrics += `\n# HELP fingerprint_autotuning_false_negative_rate Current false negative rate calculated by the auto-tuner.\n# TYPE fingerprint_autotuning_false_negative_rate gauge\nfingerprint_autotuning_false_negative_rate ${lastBestSolution.objectives[1]}\n`;
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
return metrics;
|
|
4335
|
+
}
|
|
4336
|
+
};
|
|
4337
|
+
|
|
4338
|
+
/**
|
|
4339
|
+
* Gère une requête vers le point de terminaison /metrics, en appliquant les règles d'autorisation.
|
|
4340
|
+
* Si les métriques sont activées et autorisées, elle renvoie les métriques au format Prometheus.
|
|
4341
|
+
* Sinon, elle gère l'accès non autorisé ou renvoie un 404 si les métriques ne sont pas activées.
|
|
4342
|
+
*
|
|
4343
|
+
* @param {object} req L'objet requête Express.
|
|
4344
|
+
* @param {object} res L'objet réponse Express.
|
|
4345
|
+
* @param {object} securityConfig La configuration de sécurité.
|
|
4346
|
+
*/
|
|
4347
|
+
export async function handleMetricsRequest(req, res, securityConfig) {
|
|
4348
|
+
// 2. Appliquer le callback d'autorisation personnalisé si défini.
|
|
4349
|
+
const authorizationCallback = securityConfig.metricsAuthorizationCallback;
|
|
4350
|
+
if (typeof authorizationCallback === 'function') {
|
|
4351
|
+
const context = new RequestContext(
|
|
4352
|
+
req.ip,
|
|
4353
|
+
req.path,
|
|
4354
|
+
req.headers,
|
|
4355
|
+
req.query,
|
|
4356
|
+
req.body,
|
|
4357
|
+
req.cookies,
|
|
4358
|
+
req.httpVersion
|
|
4359
|
+
);
|
|
4360
|
+
|
|
4361
|
+
const decision = await authorizationCallback(context); // Supposons que le callback peut être asynchrone
|
|
4362
|
+
|
|
4363
|
+
if (typeof decision === 'boolean') {
|
|
4364
|
+
if (!decision) {
|
|
4365
|
+
res.status(403).send('Access to metrics denied.');
|
|
4366
|
+
return;
|
|
4367
|
+
}
|
|
4368
|
+
} else if (typeof decision === 'object' && decision !== null && decision.action) {
|
|
4369
|
+
if (decision.action === 'block') {
|
|
4370
|
+
res.status(decision.status || 403).send(decision.body || 'Access denied.');
|
|
4371
|
+
return;
|
|
4372
|
+
} else if (decision.action === 'redirect') {
|
|
4373
|
+
res.redirect(decision.status || 302, decision.path);
|
|
4374
|
+
return;
|
|
4375
|
+
}
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
// 3. Si autorisé, servir les métriques.
|
|
4380
|
+
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
4381
|
+
res.send(MetricsManager.getPrometheusMetrics(securityConfig));
|
|
4382
|
+
}
|