@anonympins/fingerprint 0.7.1 → 0.7.3
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 +28 -3
- package/README.md +1 -1
- package/package.json +1 -1
- package/public/fingerprint-anti-bot.zip +0 -0
- package/src/js/fingerprint.js +106 -29
- package/src/js/fingerprint.utils.js +32 -5
- package/src/js/tests/fingerprint.test.js +91 -4
- package/src/js/tests/threatIntel.test.js +76 -0
- package/src/php/AutoTuner.php +36 -18
- package/src/php/Challenge/ChallengeUtils.php +107 -225
- package/src/php/DirectFingerprint.php +16 -4
- package/src/php/FingerprintClient.php +17 -19
- package/src/php/FingerprintEngine.php +114 -51
- package/src/php/Ja3AnomalyDetector.php +20 -12
- package/src/php/Optimization/Optimization.php +5 -1
- package/src/php/Optimization/OptimizationOperators.php +5 -1
- package/src/php/Optimization/ProblemInitializers.php +16 -4
- package/src/php/ProblemManager.php +20 -14
- package/src/php/Tests/FingerprintEngineTest.php +705 -662
- package/src/php/Tests/ThreatIntelTest.php +64 -0
- package/src/php/Utils/BlockList.php +99 -99
- package/src/php/Utils/Env.php +51 -49
- package/src/php/Utils/MaliciousPatterns.php +74 -74
- package/src/php/Utils/RequestUtils.php +22 -5
- package/src/php/WordPress/WpDbStore.php +4 -0
- package/src/php/WordPress/fingerprint-anti-bot.php +559 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.mo +0 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +262 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +268 -0
- package/src/php/WordPress/package.php +102 -4
- package/src/php/WordPress/readme.txt +36 -0
- package/public/fingerprint-wordpress.zip +0 -0
- package/src/js/fingerprint.client.obfuscated.js +0 -1
- package/src/php/WordPress/fingerprint-wordpress.php +0 -217
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,34 @@
|
|
|
1
|
+
## Version 0.7.3
|
|
2
|
+
|
|
3
|
+
- 🛡️ **Scoring Corrections & Anti-Spoofing Fixes**:
|
|
4
|
+
- **TLS Spoofing Score Fix**: Resolved false positive detections where legitimate Chrome browsers were erroneously flagged as spoofed (`tlsSpoofingScore`), improving GREASE cipher/extension inspection and ALPN protocol validation.
|
|
5
|
+
- **Threat Intelligence Rework (`threatIntelScore`)**: Overhauled the threat intelligence scoring mechanism, refining the correlation between low-level TCP RTT and application-layer transit discrepancies while streamlining federated ZKP blacklist checks.
|
|
6
|
+
- 🌐 **Cross-Language Score Parity**:
|
|
7
|
+
- Resolved calculation discrepancies and harmonized scoring formulas across Node.js, PHP, and Java implementations to guarantee deterministic and identical results across all runtimes.
|
|
8
|
+
- Fixed all known scoring edge cases primarily affecting the Node.js module.
|
|
9
|
+
- 🔌 **WordPress Plugin Hardening & Compliance**:
|
|
10
|
+
- **Full WordPress Plugin Check Compliance**: Resolved all errors, warnings, and notices reported by the official WordPress Plugin Check tool, ensuring strict adherence to WordPress coding and security standards.
|
|
11
|
+
- 👥 **Contributors**:
|
|
12
|
+
- Contributed by @anonympins.
|
|
13
|
+
|
|
14
|
+
## Version 0.7.2
|
|
15
|
+
|
|
16
|
+
- 🔌 **WordPress Plugin Enhancements**:
|
|
17
|
+
- 🌐 **Full Internationalization (i18n)**: The WordPress plugin is now fully translated into French (fr_FR) and German (de_DE), providing a native experience for administrators in those languages.
|
|
18
|
+
- 🐛 **HTTP Fallback Fix**: Resolved a critical issue where challenges would fail on non-HTTPS (HTTP) sites. The system now transparently falls back to a secure server-side validation mechanism when the browser's `crypto.subtle` API is unavailable, ensuring functionality in all environments.
|
|
19
|
+
- 🛡️ **Security & Bot Detection Hardening**:
|
|
20
|
+
- 🔐 **Obfuscated Client Script (`fingerprint.client.obfuscated.js`)**: Introduced a new build step that generates a heavily obfuscated version of the client-side library. This makes reverse-engineering of the fingerprinting and behavioral analysis logic significantly more difficult for attackers.
|
|
21
|
+
- 🤖 **Default Bot Whitelist**: The engine now applies a comprehensive default whitelist for major search engine crawlers (Googlebot, Bingbot, Yandex, etc.) out-of-the-box. This ensures zero-configuration SEO compatibility and prevents legitimate bots from being challenged.
|
|
22
|
+
- 📡 **Updated Googlebot IPs**: The list of official Googlebot IP ranges has been updated to ensure the latest crawlers are correctly identified and allowed.
|
|
23
|
+
- 🔧 **Developer Experience & Debugging**:
|
|
24
|
+
- 📝 **Reverse DNS Logging**: Added detailed logging to the bot verification workflow. This provides clear insights into the reverse and forward DNS lookup process, making it easier to debug whitelisting and identify spoofing attempts.
|
|
25
|
+
|
|
1
26
|
## Version 0.7.1
|
|
2
27
|
|
|
3
|
-
- 🔌 **Native WordPress Plugin Integration (`fingerprint-
|
|
4
|
-
- Full official WordPress plugin integration (`fingerprint-
|
|
28
|
+
- 🔌 **Native WordPress Plugin Integration (`fingerprint-anti-bot`)**:
|
|
29
|
+
- Full official WordPress plugin integration (`fingerprint-anti-bot.php`) without external runtime dependencies, leveraging `$wpdb` through a dedicated `WpDbStore` adapter.
|
|
5
30
|
- Automatic SQL cache table provisioning and periodic cleanup of expired tickets via WP-Cron (`fingerprint_prune_expired_entries`).
|
|
6
|
-
- Automated packaging script (`package.php`) generating a ready-to-deploy ZIP archive (`fingerprint-
|
|
31
|
+
- Automated packaging script (`package.php`) generating a ready-to-deploy ZIP archive (`fingerprint-anti-bot.zip`).
|
|
7
32
|
- Cross-environment honeypot tripwires: immediately detects and drops automated scanners probing for non-WordPress entrypoints (Laravel, Symfony, Django, Spring Boot `/actuator`, phpMyAdmin, `.env`, `.git`).
|
|
8
33
|
- Admin dashboard security notice if running over unencrypted HTTP (warning about modern browsers disabling native `crypto.subtle` APIs in insecure contexts).
|
|
9
34
|
- 🔬 **JavaScript Prototype Tampering Detection (`detectTamperedPrototypes`)**:
|
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anonympins/fingerprint",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Advanced anti-bot library for Node.js using multi-layer fingerprinting (JA3, client-side, headers), behavioral analysis, and adaptive Proof-of-Work (PoW) challenges to mitigate scraping, scalping, and automated threats.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
Binary file
|
package/src/js/fingerprint.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
modPow,
|
|
21
21
|
hashNetwork,
|
|
22
22
|
normalizeReferer,
|
|
23
|
+
isLoopbackIp,
|
|
23
24
|
isPrivateIp,
|
|
24
25
|
parseUserAgent,
|
|
25
26
|
safeJsonStringify
|
|
@@ -2666,9 +2667,6 @@ export function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerp
|
|
|
2666
2667
|
|
|
2667
2668
|
// Check for known spoofed/suspicious JA4 fingerprints
|
|
2668
2669
|
const spoofedJa4s = [
|
|
2669
|
-
't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
|
|
2670
|
-
't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
|
|
2671
|
-
't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
|
|
2672
2670
|
't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
|
|
2673
2671
|
't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
|
|
2674
2672
|
't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
|
|
@@ -3162,6 +3160,9 @@ function decaySubnetData(subnetData, now) {
|
|
|
3162
3160
|
* @param {number} finalScore The final suspicion score.
|
|
3163
3161
|
*/
|
|
3164
3162
|
async function updateSubnetMetrics(context, deviceId, finalScore) {
|
|
3163
|
+
if (!context.clientIp || isLoopbackIp(context.clientIp)) {
|
|
3164
|
+
return;
|
|
3165
|
+
}
|
|
3165
3166
|
const subnet = getIpSubnet(context.clientIp);
|
|
3166
3167
|
if (!subnet) return;
|
|
3167
3168
|
|
|
@@ -3194,7 +3195,8 @@ async function updateSubnetMetrics(context, deviceId, finalScore) {
|
|
|
3194
3195
|
|
|
3195
3196
|
// Utilisation d'un identifiant d'appareil stable (fingerprint matériel) plutôt que l'ID de cookie volatil
|
|
3196
3197
|
const currentDeviceHash = getCompositeDeviceHash(context);
|
|
3197
|
-
const
|
|
3198
|
+
const stablePart = extractStablePart(currentDeviceHash);
|
|
3199
|
+
const stableFpId = stablePart ? cyrb53(stablePart).toString() : (deviceId || cyrb53(currentDeviceHash).toString());
|
|
3198
3200
|
|
|
3199
3201
|
const currentDeviceContributions = subnetData.highScoreDevices[stableFpId] || 0;
|
|
3200
3202
|
if (currentDeviceContributions < 1) {
|
|
@@ -3212,7 +3214,9 @@ async function updateSubnetMetrics(context, deviceId, finalScore) {
|
|
|
3212
3214
|
|
|
3213
3215
|
const userAgent = context.headers?.['user-agent'] || '';
|
|
3214
3216
|
|
|
3215
|
-
|
|
3217
|
+
// Ne pas enregistrer une IP en attaquant si le score élevé provient uniquement d'un seul appareil isolé
|
|
3218
|
+
const isConfirmedClusterAttack = (subnetData.deviceIds.length > 1 && finalScore >= 70) || finalScore >= 95;
|
|
3219
|
+
if (isConfirmedClusterAttack) {
|
|
3216
3220
|
const existingAttacker = subnetData.attackerIps.find(a => a.ip === context.clientIp);
|
|
3217
3221
|
if (existingAttacker) {
|
|
3218
3222
|
existingAttacker.lastSeen = now;
|
|
@@ -3272,6 +3276,9 @@ export function calculateAnalogInconsistencyScore(consistencyScore, inflectionPo
|
|
|
3272
3276
|
* @returns {Promise<{subnetScore: number}>}
|
|
3273
3277
|
*/
|
|
3274
3278
|
async function getSubnetScore(context, deviceId = '', securityConfig = null) {
|
|
3279
|
+
if (!context.clientIp || isLoopbackIp(context.clientIp)) {
|
|
3280
|
+
return { subnetScore: 0.0 };
|
|
3281
|
+
}
|
|
3275
3282
|
if (typeof deviceId === 'object' && deviceId !== null && !securityConfig) {
|
|
3276
3283
|
securityConfig = deviceId;
|
|
3277
3284
|
}
|
|
@@ -3299,7 +3306,7 @@ async function getSubnetScore(context, deviceId = '', securityConfig = null) {
|
|
|
3299
3306
|
}
|
|
3300
3307
|
let uaCount = subnetData.uas ? subnetData.uas.length : 1;
|
|
3301
3308
|
|
|
3302
|
-
if (deviceCount
|
|
3309
|
+
if (deviceCount <= 1 && highScoreCount <= 1) {
|
|
3303
3310
|
return { subnetScore: 0.0 };
|
|
3304
3311
|
}
|
|
3305
3312
|
|
|
@@ -3948,7 +3955,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
3948
3955
|
_ // store.set result
|
|
3949
3956
|
] = await Promise.all([
|
|
3950
3957
|
getBehavioralIndicators(context, deviceData), // This modifies deviceData, so it must be done before saving deviceData
|
|
3951
|
-
getThreatIntelScore(context, zkpY), // NOUVEAU: Score de Threat Intelligence Fédéré
|
|
3958
|
+
getThreatIntelScore(context, zkpY, securityConfig), // NOUVEAU: Score de Threat Intelligence Fédéré
|
|
3952
3959
|
getTlsSpoofingScore(context),
|
|
3953
3960
|
getSubnetScore(context, deviceId, securityConfig),
|
|
3954
3961
|
getIpReputationScore(clientIp),
|
|
@@ -4383,15 +4390,27 @@ function parseGraphQLQuery(body) {
|
|
|
4383
4390
|
* @param {string} zkpY - The 'y' component of the ZKP proof (public key).
|
|
4384
4391
|
* @returns {Promise<{threatIntelScore: number}>}
|
|
4385
4392
|
*/
|
|
4386
|
-
async function getThreatIntelScore(context, zkpY) {
|
|
4387
|
-
let score = 0;
|
|
4393
|
+
async function getThreatIntelScore(context, zkpY, threatIntelConfig = {}) {
|
|
4394
|
+
let score = 0.0;
|
|
4395
|
+
const signals = [];
|
|
4396
|
+
|
|
4397
|
+
// 1. Détection déterministe : Clé publique ZKP bannie par consensus fédéré
|
|
4388
4398
|
if (zkpY) {
|
|
4389
4399
|
const isBanned = await store.has(`banned-zkp-y:${zkpY}`);
|
|
4390
4400
|
if (isBanned) {
|
|
4391
|
-
score = 100;
|
|
4401
|
+
score = 100.0;
|
|
4402
|
+
signals.push({
|
|
4403
|
+
ruleId: 'FEDERATED_ZKP_BANNED',
|
|
4404
|
+
confidence: 1.0,
|
|
4405
|
+
score: 100.0,
|
|
4406
|
+
rationale: 'Cryptographic identity matched banned list consensus'
|
|
4407
|
+
});
|
|
4392
4408
|
}
|
|
4393
4409
|
}
|
|
4394
4410
|
|
|
4411
|
+
// 2. Détection physique : Modélisation continue du différentiel de transport (RTT TCP Edge vs Transit Applicatif)
|
|
4412
|
+
// Rationale : Un RTT TCP de socket très faible (datacenter/edge CDN) couplé à un délai applicatif WAN élevé
|
|
4413
|
+
// prouve l'interposition d'un relais/tunnel/proxy résidentiel entre le client réel et le point d'entrée.
|
|
4395
4414
|
const tcpRttHeader = context.headers?.['x-tcp-rtt'] || context.headers?.['x-real-rtt'];
|
|
4396
4415
|
const tcpRtt = tcpRttHeader ? parseInt(tcpRttHeader, 10) : null;
|
|
4397
4416
|
|
|
@@ -4399,24 +4418,40 @@ async function getThreatIntelScore(context, zkpY) {
|
|
|
4399
4418
|
if (behaviorHeader) {
|
|
4400
4419
|
try {
|
|
4401
4420
|
const metrics = JSON.parse(behaviorHeader);
|
|
4402
|
-
if (metrics && metrics.clientTimestamp && context.requestTimestamp) {
|
|
4421
|
+
if (metrics && typeof metrics.clientTimestamp === 'number' && typeof context.requestTimestamp === 'number') {
|
|
4403
4422
|
const appLatency = context.requestTimestamp - metrics.clientTimestamp;
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4423
|
+
|
|
4424
|
+
// Évaluation uniquement si la latence est mesurable et positive avec une mesure socket RTT valide
|
|
4425
|
+
if (tcpRtt !== null && !isNaN(tcpRtt) && tcpRtt > 0 && appLatency > 0) {
|
|
4426
|
+
// Marge de tolérance contre le jitter réseau et le scheduling JS (Garbage Collector, event-loop)
|
|
4427
|
+
const jitterAllowance = 60.0;
|
|
4428
|
+
const effectiveRtt = Math.max(tcpRtt, 5.0);
|
|
4429
|
+
const tunnelDelta = appLatency - (tcpRtt + jitterAllowance);
|
|
4430
|
+
const divergenceRatio = appLatency / effectiveRtt;
|
|
4431
|
+
|
|
4432
|
+
// Condition de disjonction : liaison edge ultra-proche (< 40ms) + transit applicatif au moins 3x supérieur
|
|
4433
|
+
if (tcpRtt <= 40 && divergenceRatio >= 3.0 && tunnelDelta > 0) {
|
|
4434
|
+
// Progression sigmoïdale continue calibrée sur la propagation optique intercontinentale (120ms)
|
|
4435
|
+
const scaling = 120.0;
|
|
4436
|
+
const ratioWeight = Math.min(1.0, (divergenceRatio - 3.0) / 5.0);
|
|
4437
|
+
const proxyScore = Math.min(95.0, 40.0 + 55.0 * Math.tanh(tunnelDelta / scaling) * ratioWeight);
|
|
4438
|
+
const finalProxyScore = Math.round(proxyScore * 10) / 10;
|
|
4439
|
+
|
|
4440
|
+
score = Math.max(score, finalProxyScore);
|
|
4441
|
+
signals.push({
|
|
4442
|
+
ruleId: 'RESIDENTIAL_PROXY_RTT_DISCREPANCY',
|
|
4443
|
+
confidence: Math.round(ratioWeight * 100) / 100,
|
|
4444
|
+
score: finalProxyScore,
|
|
4445
|
+
rationale: `TCP RTT (${tcpRtt}ms) diverges from application transit (${appLatency}ms) with ratio ${divergenceRatio.toFixed(1)}:1`
|
|
4446
|
+
});
|
|
4412
4447
|
}
|
|
4413
4448
|
}
|
|
4414
4449
|
}
|
|
4415
4450
|
} catch (e) {
|
|
4416
|
-
// Ignorer silencieusement
|
|
4451
|
+
// Ignorer silencieusement les erreurs de parsing des métriques
|
|
4417
4452
|
}
|
|
4418
4453
|
}
|
|
4419
|
-
return { threatIntelScore: score };
|
|
4454
|
+
return { threatIntelScore: score, threatIntelSignals: signals };
|
|
4420
4455
|
}
|
|
4421
4456
|
|
|
4422
4457
|
export class FingerprintEngine {
|
|
@@ -4472,6 +4507,17 @@ export class FingerprintEngine {
|
|
|
4472
4507
|
}
|
|
4473
4508
|
}
|
|
4474
4509
|
}
|
|
4510
|
+
|
|
4511
|
+
// If no security config is provided at all, create a base one.
|
|
4512
|
+
if (!finalConfig) {
|
|
4513
|
+
finalConfig = {};
|
|
4514
|
+
}
|
|
4515
|
+
// If the whitelist is not explicitly provided in the configuration,
|
|
4516
|
+
// apply the default whitelist which includes common search engine bots.
|
|
4517
|
+
if (!finalConfig.whitelist) {
|
|
4518
|
+
finalConfig.whitelist = default_whitelist();
|
|
4519
|
+
}
|
|
4520
|
+
|
|
4475
4521
|
this.securityConfig = finalConfig;
|
|
4476
4522
|
this.isProduction = isProduction;
|
|
4477
4523
|
this._allowlist = this._buildAllowlist();
|
|
@@ -4592,6 +4638,9 @@ export class FingerprintEngine {
|
|
|
4592
4638
|
(suspicionVector.tcpAnomalyScore || 0) * (weights.tcpAnomalyScore || 0) +
|
|
4593
4639
|
(suspicionVector.quicAnomalyScore || 0) * (weights.quicAnomalyScore || 0) + // NOUVEAU: QUIC Anomaly
|
|
4594
4640
|
(suspicionVector.http2AnomalyScore || 0) * (weights.http2AnomalyScore || 0) + // NOUVEAU: HTTP/2 Anomaly
|
|
4641
|
+
(suspicionVector.protocolAnomalyScore || 0) * (weights.protocolAnomalyScore || 0) +
|
|
4642
|
+
(suspicionVector.cookieDroppingScore || 0) * (weights.cookieDroppingScore || 0) +
|
|
4643
|
+
(suspicionVector.virtualizationScore || 0) * (weights.virtualizationScore || 0) +
|
|
4595
4644
|
(suspicionVector.threatIntelScore || 0) * (weights.threatIntelScore || 0) + // NOUVEAU: QUIC Anomaly
|
|
4596
4645
|
(suspicionVector.renderingAnomalyScore || 0) * (weights.renderingAnomalyScore || 0); // NOUVEAU: Rendering Anomaly
|
|
4597
4646
|
|
|
@@ -4670,7 +4719,8 @@ export class FingerprintEngine {
|
|
|
4670
4719
|
return false;
|
|
4671
4720
|
}
|
|
4672
4721
|
_isIpInAllowlist(clientIp) {
|
|
4673
|
-
|
|
4722
|
+
const family = isIPv6(clientIp) ? 'ipv6' : 'ipv4';
|
|
4723
|
+
return this._allowlist.check(clientIp, family);
|
|
4674
4724
|
}
|
|
4675
4725
|
/**
|
|
4676
4726
|
* Checks if the request's host and path match an entry in the host+path allowlist.
|
|
@@ -4790,7 +4840,7 @@ export class FingerprintEngine {
|
|
|
4790
4840
|
const { whitelist = [] } = this.securityConfig;
|
|
4791
4841
|
const botRules = whitelist.filter(rule => rule.hostnameSuffix);
|
|
4792
4842
|
if (botRules.length === 0) {
|
|
4793
|
-
return
|
|
4843
|
+
return null;
|
|
4794
4844
|
}
|
|
4795
4845
|
|
|
4796
4846
|
const { clientIp, headers } = requestContext;
|
|
@@ -4806,10 +4856,13 @@ export class FingerprintEngine {
|
|
|
4806
4856
|
}
|
|
4807
4857
|
});
|
|
4808
4858
|
if (!matchedRule) {
|
|
4809
|
-
return
|
|
4859
|
+
return null;
|
|
4810
4860
|
}
|
|
4811
4861
|
|
|
4862
|
+
const botName = matchedRule.userAgent; // e.g., 'Googlebot'
|
|
4863
|
+
|
|
4812
4864
|
if (!canAttemptDns()) {
|
|
4865
|
+
this._log(`[Bot Verification] DNS circuit breaker is open. Skipping check for ${botName}.`, { clientIp });
|
|
4813
4866
|
return false;
|
|
4814
4867
|
}
|
|
4815
4868
|
|
|
@@ -4817,9 +4870,11 @@ export class FingerprintEngine {
|
|
|
4817
4870
|
const cachedStatus = await store.get(cacheKey);
|
|
4818
4871
|
|
|
4819
4872
|
if (cachedStatus === 'verified') {
|
|
4873
|
+
this._log(`[Bot Verification] PASSED (cached): ${botName}`, { clientIp });
|
|
4820
4874
|
return true;
|
|
4821
4875
|
}
|
|
4822
4876
|
if (cachedStatus === 'failed') {
|
|
4877
|
+
this._log(`[Bot Verification] FAILED (cached): ${botName}`, { clientIp });
|
|
4823
4878
|
return false;
|
|
4824
4879
|
}
|
|
4825
4880
|
|
|
@@ -4829,25 +4884,35 @@ export class FingerprintEngine {
|
|
|
4829
4884
|
const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
|
|
4830
4885
|
|
|
4831
4886
|
if (!validHostname) {
|
|
4887
|
+
this._log(`[Bot Verification] FAILED: Reverse DNS lookup for ${clientIp} did not yield a valid hostname ending in '${matchedRule.hostnameSuffix}'.`, { clientIp, resolvedHostnames: hostnames });
|
|
4832
4888
|
await store.set(cacheKey, 'failed', 300); // Temporary negative caching (5 minutes)
|
|
4833
4889
|
return false;
|
|
4834
4890
|
}
|
|
4835
4891
|
|
|
4892
|
+
this._log(`[Bot Verification] Reverse DNS OK for ${botName}.`, { clientIp, validHostname });
|
|
4893
|
+
|
|
4836
4894
|
// 2. Forward DNS lookup
|
|
4837
4895
|
let addresses = [];
|
|
4838
4896
|
try {
|
|
4839
4897
|
addresses = await withTimeout(dns.resolve(validHostname), 500);
|
|
4840
|
-
} catch (e) {
|
|
4898
|
+
} catch (e) {
|
|
4899
|
+
this._log(`[Bot Verification] Forward DNS (A) lookup FAILED for ${validHostname}.`, { error: e.message });
|
|
4900
|
+
}
|
|
4841
4901
|
try {
|
|
4842
4902
|
const ipv6 = await withTimeout(dns.resolve(validHostname, 'AAAA'), 500);
|
|
4843
4903
|
addresses = addresses.concat(ipv6);
|
|
4844
|
-
} catch (e) {
|
|
4904
|
+
} catch (e) {
|
|
4905
|
+
this._log(`[Bot Verification] Forward DNS (AAAA) lookup FAILED for ${validHostname}.`, { error: e.message });
|
|
4906
|
+
}
|
|
4845
4907
|
if (addresses.includes(clientIp)) {
|
|
4908
|
+
this._log(`[Bot Verification] PASSED: Forward DNS IP matches original IP for ${botName}.`, { clientIp, validHostname, resolvedIps: addresses });
|
|
4846
4909
|
recordDnsSuccess();
|
|
4847
4910
|
await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
|
|
4848
4911
|
return true;
|
|
4849
4912
|
}
|
|
4913
|
+
this._log(`[Bot Verification] FAILED: IP mismatch for ${botName}. Original IP not in resolved addresses.`, { clientIp, validHostname, resolvedIps: addresses });
|
|
4850
4914
|
} catch (error) {
|
|
4915
|
+
this._log(`[Bot Verification] FAILED: DNS lookup error for ${botName}.`, { clientIp, error: error.message });
|
|
4851
4916
|
recordDnsFailure();
|
|
4852
4917
|
await store.set(cacheKey, 'failed', 300); // Temporary negative caching (5 minutes)
|
|
4853
4918
|
return false;
|
|
@@ -4858,6 +4923,13 @@ export class FingerprintEngine {
|
|
|
4858
4923
|
}
|
|
4859
4924
|
|
|
4860
4925
|
async processRequest(requestContext) {
|
|
4926
|
+
if (this.securityConfig?.reset) {
|
|
4927
|
+
const subnet = getIpSubnet(requestContext.clientIp);
|
|
4928
|
+
if (subnet) {
|
|
4929
|
+
await store.delete(`subnet:${subnet}`);
|
|
4930
|
+
}
|
|
4931
|
+
await store.delete(`ip-reputation:${requestContext.clientIp}`);
|
|
4932
|
+
}
|
|
4861
4933
|
sanitizeProxyHeaders(requestContext, this.securityConfig);
|
|
4862
4934
|
|
|
4863
4935
|
const { clientIp = "unknown", path, cookies = {}, query = {}, isStatic, graphqlOperationType, graphqlOperationName } = requestContext;
|
|
@@ -4965,15 +5037,15 @@ export class FingerprintEngine {
|
|
|
4965
5037
|
}
|
|
4966
5038
|
}
|
|
4967
5039
|
|
|
4968
|
-
|
|
4969
|
-
if (
|
|
5040
|
+
const botVerificationResult = await this._verifyWhitelistedBot(requestContext);
|
|
5041
|
+
if (botVerificationResult === true) {
|
|
4970
5042
|
const filterWhitelist = this.securityConfig.filterWhitelist || false;
|
|
4971
5043
|
let bypassWhitelist = false;
|
|
4972
5044
|
if (filterWhitelist === true) {
|
|
4973
5045
|
bypassWhitelist = this._hasCertainAttack(requestContext);
|
|
4974
5046
|
} else if (typeof filterWhitelist === 'number') {
|
|
4975
5047
|
const res = await getScoreAndVector();
|
|
4976
|
-
if (res.score > filterWhitelist) {
|
|
5048
|
+
if (res.score > filterWhitelist) { // NOSONAR
|
|
4977
5049
|
bypassWhitelist = true;
|
|
4978
5050
|
}
|
|
4979
5051
|
}
|
|
@@ -5013,6 +5085,11 @@ export class FingerprintEngine {
|
|
|
5013
5085
|
|
|
5014
5086
|
// The engine now works with the context directly, no more rawReq dependency here.
|
|
5015
5087
|
const suspicionVector = preCalculatedVector || await __internal.getSuspicionVector(requestContext, this.securityConfig);
|
|
5088
|
+
if (botVerificationResult === false) {
|
|
5089
|
+
// This means it claimed to be a bot but failed verification.
|
|
5090
|
+
// This is a strong signal of spoofing.
|
|
5091
|
+
suspicionVector.tlsSpoofingScore = Math.max(suspicionVector.tlsSpoofingScore || 0, 95);
|
|
5092
|
+
}
|
|
5016
5093
|
let finalScore = preCalculatedScore !== null ? preCalculatedScore : this.calculateFinalScore(suspicionVector);
|
|
5017
5094
|
|
|
5018
5095
|
this._log('Suspicion vector calculated', {
|
|
@@ -157,6 +157,16 @@ export function hashNetwork(ip, prefix = 24) {
|
|
|
157
157
|
}
|
|
158
158
|
return hash.toString(16);
|
|
159
159
|
}
|
|
160
|
+
export function isLoopbackIp(ip) {
|
|
161
|
+
if (!ip || typeof ip !== 'string') return true;
|
|
162
|
+
let cleanIp = ip.trim().toLowerCase();
|
|
163
|
+
if (cleanIp.startsWith('::ffff:')) {
|
|
164
|
+
cleanIp = cleanIp.substring(7);
|
|
165
|
+
}
|
|
166
|
+
return cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp === 'localhost' ||
|
|
167
|
+
cleanIp.startsWith('127.') || cleanIp === '0.0.0.0' || cleanIp === '::';
|
|
168
|
+
}
|
|
169
|
+
|
|
160
170
|
export function normalizeReferer(referer) {
|
|
161
171
|
try {
|
|
162
172
|
const url = new URL(referer);
|
|
@@ -167,11 +177,28 @@ export function normalizeReferer(referer) {
|
|
|
167
177
|
}
|
|
168
178
|
|
|
169
179
|
export function isPrivateIp(ip) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
180
|
+
if (!ip || typeof ip !== 'string') return false;
|
|
181
|
+
let cleanIp = ip.trim().toLowerCase();
|
|
182
|
+
if (cleanIp.startsWith('::ffff:')) {
|
|
183
|
+
cleanIp = cleanIp.substring(7);
|
|
184
|
+
}
|
|
185
|
+
if (cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp === 'localhost' || cleanIp.startsWith('127.')) {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
const parts = cleanIp.split('.');
|
|
189
|
+
if (parts.length === 4) {
|
|
190
|
+
const first = parseInt(parts[0], 10);
|
|
191
|
+
const second = parseInt(parts[1], 10);
|
|
192
|
+
if (first === 127 || first === 10 || first === 0) return true;
|
|
193
|
+
if (first === 172 && second >= 16 && second <= 31) return true;
|
|
194
|
+
if (first === 192 && second === 168) return true;
|
|
195
|
+
if (first === 169 && second === 254) return true;
|
|
196
|
+
if (first === 100 && second >= 64 && second <= 127) return true;
|
|
197
|
+
}
|
|
198
|
+
if (cleanIp.startsWith('fc') || cleanIp.startsWith('fd') || cleanIp.startsWith('fe80:')) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
175
202
|
}
|
|
176
203
|
// Utility parsing functions
|
|
177
204
|
export function parseUserAgent(ua) {
|
|
@@ -10,6 +10,8 @@ import * as fingerprint from '../fingerprint.js';
|
|
|
10
10
|
vi.mock('import-meta-env', () => ({
|
|
11
11
|
env: { NODE_ENV: 'test', POW_SECRET: 'fallback-dev-secret-32-chars-minimum' },
|
|
12
12
|
}));
|
|
13
|
+
// Mock the entire dns module
|
|
14
|
+
vi.mock('node:dns/promises');
|
|
13
15
|
// Mock readFileSync for custom challenge page tests
|
|
14
16
|
vi.mock('node:fs', async () => {
|
|
15
17
|
const actualFs = await vi.importActual('node:fs');
|
|
@@ -35,9 +37,8 @@ const {
|
|
|
35
37
|
} = fingerprint;
|
|
36
38
|
const { store, getRequestPatternScore, getDeviceHash } = __internal;
|
|
37
39
|
const { getRenderingAnomalyScore } = __internal;
|
|
38
|
-
let { getBehaviorScore, getClickVarianceScore } = __internal;
|
|
39
|
-
|
|
40
|
-
vi.mock('node:dns/promises');
|
|
40
|
+
let { getBehaviorScore, getClickVarianceScore } = __internal; // eslint-disable-line prefer-const
|
|
41
|
+
|
|
41
42
|
|
|
42
43
|
describe('Fingerprint & PoW Security Suite', () => {
|
|
43
44
|
test('cyrb53 should be deterministic', () => {
|
|
@@ -1758,12 +1759,98 @@ describe('Fingerprint & PoW Security Suite', () => {
|
|
|
1758
1759
|
};
|
|
1759
1760
|
|
|
1760
1761
|
const isVerified = await engine._verifyWhitelistedBot({ ...requestContext, fingerprint: {} });
|
|
1761
|
-
expect(isVerified).
|
|
1762
|
+
expect(isVerified).toBeNull();
|
|
1762
1763
|
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('[Fingerprint] Invalid regex in whitelist rule'));
|
|
1763
1764
|
consoleErrorSpy.mockRestore();
|
|
1764
1765
|
});
|
|
1765
1766
|
});
|
|
1766
1767
|
});
|
|
1768
|
+
describe('Googlebot Whitelisting (Node.js)', () => {
|
|
1769
|
+
const inMemoryStore = {
|
|
1770
|
+
_map: new Map(),
|
|
1771
|
+
async get(key) { return this._map.get(key); },
|
|
1772
|
+
async set(key, value) { this._map.set(key, value); },
|
|
1773
|
+
async has(key) { return this._map.has(key); },
|
|
1774
|
+
async delete(key) { this._map.delete(key); },
|
|
1775
|
+
};
|
|
1776
|
+
|
|
1777
|
+
const securityConfig = {
|
|
1778
|
+
weights: { headerAnomalyScore: 1.0, tlsSpoofingScore: 1.0 }, // Simple config for testing
|
|
1779
|
+
thresholds: { low: 20, medium: 45, high: 75, block: 95 },
|
|
1780
|
+
whitelist: [
|
|
1781
|
+
{ userAgent: 'Googlebot', hostnameSuffix: '.googlebot.com' }
|
|
1782
|
+
]
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1785
|
+
let engine;
|
|
1786
|
+
|
|
1787
|
+
beforeEach(() => {
|
|
1788
|
+
inMemoryStore._map.clear();
|
|
1789
|
+
configureStore(inMemoryStore);
|
|
1790
|
+
vi.clearAllMocks(); // Clear mocks before each test
|
|
1791
|
+
engine = new FingerprintEngine(securityConfig);
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
it('should correctly identify and allow a legitimate Googlebot request', async () => {
|
|
1795
|
+
const googleIp = '::ffff:66.249.66.39'; // An IP from the official list
|
|
1796
|
+
const googleHostname = 'crawl-66-249-66-39.googlebot.com';
|
|
1797
|
+
const googleUa = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
|
|
1798
|
+
|
|
1799
|
+
// Mock the DNS verification process
|
|
1800
|
+
vi.mocked(dns.reverse).mockResolvedValue([googleHostname]);
|
|
1801
|
+
vi.mocked(dns.resolve).mockResolvedValue([googleIp]);
|
|
1802
|
+
|
|
1803
|
+
const requestContext = {
|
|
1804
|
+
clientIp: googleIp,
|
|
1805
|
+
path: '/',
|
|
1806
|
+
headers: { 'user-agent': googleUa },
|
|
1807
|
+
isStatic: false,
|
|
1808
|
+
cookies: {},
|
|
1809
|
+
query: {},
|
|
1810
|
+
body: null,
|
|
1811
|
+
httpVersion: '1.1',
|
|
1812
|
+
rawHeaders: ['user-agent', googleUa]
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
const decision = await engine.processRequest(requestContext);
|
|
1816
|
+
|
|
1817
|
+
expect(decision.action).toBe('next');
|
|
1818
|
+
expect(decision.score).toBe(0);
|
|
1819
|
+
expect(decision.vector.whitelisted).toBe(100);
|
|
1820
|
+
expect(decision.vector.type).toBe('bot');
|
|
1821
|
+
|
|
1822
|
+
expect(dns.reverse).toHaveBeenCalledWith(googleIp);
|
|
1823
|
+
expect(dns.resolve).toHaveBeenCalledWith(googleHostname);
|
|
1824
|
+
});
|
|
1825
|
+
|
|
1826
|
+
it('should challenge or block a request spoofing a Googlebot User-Agent from a non-Google IP', async () => {
|
|
1827
|
+
const fakeGoogleIp = '1.2.3.4';
|
|
1828
|
+
const googleUa = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
|
|
1829
|
+
|
|
1830
|
+
vi.mocked(dns.reverse).mockRejectedValue(new Error('Host not found'));
|
|
1831
|
+
|
|
1832
|
+
const requestContext = {
|
|
1833
|
+
clientIp: fakeGoogleIp,
|
|
1834
|
+
path: '/',
|
|
1835
|
+
headers: { 'user-agent': googleUa, 'accept-language': 'en-US' },
|
|
1836
|
+
isStatic: false,
|
|
1837
|
+
cookies: {},
|
|
1838
|
+
query: {},
|
|
1839
|
+
body: null,
|
|
1840
|
+
httpVersion: '1.1',
|
|
1841
|
+
rawHeaders: ['user-agent', googleUa, 'accept-language', 'en-US']
|
|
1842
|
+
};
|
|
1843
|
+
|
|
1844
|
+
const decision = await engine.processRequest(requestContext);
|
|
1845
|
+
|
|
1846
|
+
expect(decision.action).toBe('block');
|
|
1847
|
+
expect(decision.score).toBeGreaterThan(0);
|
|
1848
|
+
expect(decision.vector.whitelisted).toBeUndefined();
|
|
1849
|
+
|
|
1850
|
+
expect(dns.reverse).toHaveBeenCalledWith(fakeGoogleIp);
|
|
1851
|
+
expect(dns.resolve).not.toHaveBeenCalled();
|
|
1852
|
+
});
|
|
1853
|
+
});
|
|
1767
1854
|
|
|
1768
1855
|
describe('TLS Session Resumption (Cookieless Identity)', () => {
|
|
1769
1856
|
const inMemoryStore = {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { __internal, configureStore } from '../fingerprint.js';
|
|
3
|
+
|
|
4
|
+
describe('Threat Intelligence - Rationale-Driven Defensive Logic', () => {
|
|
5
|
+
const inMemoryStore = {
|
|
6
|
+
_map: new Map(),
|
|
7
|
+
async get(key) { return this._map.get(key); },
|
|
8
|
+
async set(key, value) { this._map.set(key, value); },
|
|
9
|
+
async has(key) { return this._map.has(key); },
|
|
10
|
+
async delete(key) { this._map.delete(key); },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
inMemoryStore._map.clear();
|
|
15
|
+
configureStore(inMemoryStore);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should assign a deterministic score of 100 with audit trace for banned ZKP public keys', async () => {
|
|
19
|
+
await inMemoryStore.set('banned-zkp-y:banned-key-999', true);
|
|
20
|
+
|
|
21
|
+
const context = { headers: {} };
|
|
22
|
+
const result = await __internal.getSuspicionVector(context, {
|
|
23
|
+
weights: { threatIntelScore: 1.0 },
|
|
24
|
+
thresholds: { low: 20 }
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Direct check via internal function with banned key
|
|
28
|
+
const threatResult = await __internal.store.has('banned-zkp-y:banned-key-999');
|
|
29
|
+
expect(threatResult).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should return score 0 for consistent local connections without proxy delay', async () => {
|
|
33
|
+
const reqTime = 1700000000000;
|
|
34
|
+
const context = {
|
|
35
|
+
requestTimestamp: reqTime,
|
|
36
|
+
headers: {
|
|
37
|
+
'x-tcp-rtt': '15',
|
|
38
|
+
'x-behavior-metrics': JSON.stringify({ clientTimestamp: reqTime - 25 })
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const vector = await __internal.getSuspicionVector(context, { weights: { threatIntelScore: 1.0 } });
|
|
43
|
+
expect(vector.threatIntelScore).toBe(0.0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should ignore mild JS event loop jitter and network lag without triggering false positives', async () => {
|
|
47
|
+
const reqTime = 1700000000000;
|
|
48
|
+
// App latency 65ms with a 20ms RTT falls well within the 60ms jitter tolerance window
|
|
49
|
+
const context = {
|
|
50
|
+
requestTimestamp: reqTime,
|
|
51
|
+
headers: {
|
|
52
|
+
'x-tcp-rtt': '20',
|
|
53
|
+
'x-behavior-metrics': JSON.stringify({ clientTimestamp: reqTime - 65 })
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const vector = await __internal.getSuspicionVector(context, { weights: { threatIntelScore: 1.0 } });
|
|
58
|
+
expect(vector.threatIntelScore).toBe(0.0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should smoothly calculate elevated suspicion when residential proxy tunneling is detected', async () => {
|
|
62
|
+
const reqTime = 1700000000000;
|
|
63
|
+
// Edge socket is 10ms (datacenter gateway), but application payload arrives with 280ms latency (WAN proxy hop)
|
|
64
|
+
const context = {
|
|
65
|
+
requestTimestamp: reqTime,
|
|
66
|
+
headers: {
|
|
67
|
+
'x-tcp-rtt': '10',
|
|
68
|
+
'x-behavior-metrics': JSON.stringify({ clientTimestamp: reqTime - 280 })
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const vector = await __internal.getSuspicionVector(context, { weights: { threatIntelScore: 1.0 } });
|
|
73
|
+
expect(vector.threatIntelScore).toBeGreaterThan(70.0);
|
|
74
|
+
expect(vector.threatIntelScore).toBeLessThanOrEqual(95.0);
|
|
75
|
+
});
|
|
76
|
+
});
|