@anonympins/fingerprint 0.8.0 → 0.8.2
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 +54 -0
- package/README.md +1 -1
- package/package.json +4 -2
- package/public/anonympins-bot-mitigation-pow.zip +0 -0
- package/src/js/asn-lookup.js +223 -0
- package/src/js/fingerprint.client.obfuscated.js +1 -1
- package/src/js/fingerprint.js +214 -29
- package/src/js/tests/asn-lookup.test.js +57 -0
- package/src/php/AsnLookupEngine.php +173 -0
- package/src/php/Challenge/ChallengeUtils.php +162 -64
- package/src/php/DirectFingerprint.php +25 -5
- package/src/php/FingerprintEngine.php +126 -30
- package/src/php/NetworkProfile.php +68 -0
- package/src/php/ProblemManager.php +12 -2
- package/src/php/Tests/AsnLookupTest.php +61 -0
- package/src/php/Tests/FingerprintEngineTest.php +4 -2
- package/src/php/Utils/Env.php +7 -2
- package/src/php/Utils/RequestUtils.php +24 -6
- package/src/php/WordPress/WpDbStore.php +27 -0
- package/src/php/WordPress/anonympins-bot-mitigation-pow.php +303 -64
- package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.po +1 -1
- package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +1 -1
- package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +1 -1
- package/src/php/WordPress/package.php +10 -0
- package/src/php/WordPress/readme.txt +7 -1
|
@@ -173,11 +173,12 @@ class FingerprintEngine
|
|
|
173
173
|
Env::set('ED25519_PUBLIC_KEY', $storedKeys['publicKey']);
|
|
174
174
|
} elseif (defined('OPENSSL_KEYTYPE_ED25519')) {
|
|
175
175
|
$pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
|
|
176
|
-
$
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
|
|
177
|
+
$details = openssl_pkey_get_details($pkey);
|
|
178
|
+
$publicKeyPem = $details['key'] ?? '';
|
|
179
|
+
Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
|
|
180
|
+
Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
|
|
181
|
+
}
|
|
181
182
|
update_option('fingerprint_ed25519_keys', [
|
|
182
183
|
'privateKey' => $privateKeyPem,
|
|
183
184
|
'publicKey' => $publicKeyPem,
|
|
@@ -227,12 +228,25 @@ class FingerprintEngine
|
|
|
227
228
|
*/
|
|
228
229
|
private static function initStandaloneEd25519Keys(): void
|
|
229
230
|
{
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
if (function_exists('get_option')) {
|
|
232
|
+
return; // Managed exclusively via WordPress options in WP runtime
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (function_exists('wp_upload_dir') && function_exists('wp_mkdir_p')) {
|
|
236
|
+
$uploadDir = wp_upload_dir();
|
|
237
|
+
$configDir = (!empty($uploadDir['basedir']) && is_dir($uploadDir['basedir']))
|
|
238
|
+
? $uploadDir['basedir'] . '/anonympins-bot-mitigation-pow'
|
|
239
|
+
: sys_get_temp_dir();
|
|
240
|
+
} else {
|
|
241
|
+
$envKeyDir = Env::get('FINGERPRINT_KEY_DIR');
|
|
242
|
+
$configDir = !empty($envKeyDir) ? (string)$envKeyDir : sys_get_temp_dir();
|
|
243
|
+
}
|
|
244
|
+
$persistentKeyPath = rtrim($configDir, '/\\') . '/ed25519_key.json';
|
|
232
245
|
|
|
233
246
|
if (file_exists($persistentKeyPath)) {
|
|
234
247
|
try {
|
|
235
|
-
$
|
|
248
|
+
$raw = (string)file_get_contents($persistentKeyPath);
|
|
249
|
+
$keys = json_decode($raw, true);
|
|
236
250
|
if (isset($keys['privateKey'], $keys['publicKey'])) {
|
|
237
251
|
Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
|
|
238
252
|
Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
|
|
@@ -253,11 +267,11 @@ class FingerprintEngine
|
|
|
253
267
|
if (function_exists('wp_mkdir_p')) {
|
|
254
268
|
wp_mkdir_p($configDir);
|
|
255
269
|
} else {
|
|
256
|
-
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir --
|
|
257
|
-
mkdir($configDir,
|
|
270
|
+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Standalone filesystem fallback
|
|
271
|
+
@mkdir($configDir, 0700, true);
|
|
258
272
|
}
|
|
259
273
|
}
|
|
260
|
-
// phpcs:ignore
|
|
274
|
+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
|
|
261
275
|
file_put_contents($persistentKeyPath, json_encode([
|
|
262
276
|
'privateKey' => $privateKeyPem,
|
|
263
277
|
'publicKey' => $publicKeyPem
|
|
@@ -346,7 +360,13 @@ class FingerprintEngine
|
|
|
346
360
|
*/
|
|
347
361
|
private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
|
|
348
362
|
{
|
|
349
|
-
|
|
363
|
+
if (defined('ANONYMPINS_BOT_MITIGATION_DIR')) {
|
|
364
|
+
$configDir = rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config';
|
|
365
|
+
} elseif (is_dir(__DIR__ . '/config')) {
|
|
366
|
+
$configDir = __DIR__ . '/config';
|
|
367
|
+
} else {
|
|
368
|
+
$configDir = __DIR__;
|
|
369
|
+
}
|
|
350
370
|
$filePath = $configDir . '/' . $filename;
|
|
351
371
|
if (file_exists($filePath)) {
|
|
352
372
|
try {
|
|
@@ -670,6 +690,21 @@ class FingerprintEngine
|
|
|
670
690
|
*/
|
|
671
691
|
private function checkAllowlists(RequestContext $context): bool
|
|
672
692
|
{
|
|
693
|
+
// Auto-whitelisting des pairs fédérés
|
|
694
|
+
$peers = $this->securityConfig['federatedPeers'] ?? [];
|
|
695
|
+
if (!empty($peers)) {
|
|
696
|
+
$allowedHosts = array_map(function ($url) {
|
|
697
|
+
// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Covered by wp_parse_url, Fallback for standalone PHP environments
|
|
698
|
+
$host = function_exists('wp_parse_url') ? wp_parse_url($url, PHP_URL_HOST) : parse_url($url, PHP_URL_HOST);
|
|
699
|
+
return !empty($host) ? $host : $url;
|
|
700
|
+
}, $peers);
|
|
701
|
+
|
|
702
|
+
if (in_array($context->clientIp, $allowedHosts, true)) {
|
|
703
|
+
$this->log("Federated peer auto-whitelisted", ['clientIp' => $context->clientIp]);
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
673
708
|
$whitelisted = false;
|
|
674
709
|
$type = '';
|
|
675
710
|
|
|
@@ -682,6 +717,15 @@ class FingerprintEngine
|
|
|
682
717
|
} elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
|
|
683
718
|
$whitelisted = true;
|
|
684
719
|
$type = 'host_path_allowlist';
|
|
720
|
+
} elseif (StoreManager::getStore()->has("federated-whitelist:ip:{$context->clientIp}")) {
|
|
721
|
+
$whitelisted = true;
|
|
722
|
+
$type = 'federated_whitelist_ip';
|
|
723
|
+
} elseif (($subnet = RequestUtils::getIpSubnet($context->clientIp)) && StoreManager::getStore()->has("federated-whitelist:subnet:{$subnet}")) {
|
|
724
|
+
$whitelisted = true;
|
|
725
|
+
$type = 'federated_whitelist_subnet';
|
|
726
|
+
} elseif (($ua = $context->getHeader('user-agent')) && StoreManager::getStore()->has("federated-whitelist:user_agent:{$ua}")) {
|
|
727
|
+
$whitelisted = true;
|
|
728
|
+
$type = 'federated_whitelist_user_agent';
|
|
685
729
|
} elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
|
|
686
730
|
$whitelisted = true;
|
|
687
731
|
$type = 'graphql_operation_allowlist';
|
|
@@ -876,12 +920,13 @@ class FingerprintEngine
|
|
|
876
920
|
// --- Computation of the various suspicion scores ---
|
|
877
921
|
|
|
878
922
|
// Fingerprint inconsistency score (moved here to be with the others)
|
|
923
|
+
$netProfile = AsnLookupEngine::getInstance()->lookup($context->clientIp);
|
|
879
924
|
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
880
925
|
$consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
|
|
881
|
-
$similarityThreshold = (float)($this->securityConfig['similarityThreshold'] ??
|
|
926
|
+
$similarityThreshold = (float)($this->securityConfig['similarityThreshold'] ?? $netProfile->getInflectionPoint());
|
|
882
927
|
$inconsistencyScore = RequestUtils::calculateAnalogInconsistencyScore($consistencyScore, $similarityThreshold);
|
|
883
928
|
|
|
884
|
-
$behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
|
|
929
|
+
$behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData, $netProfile);
|
|
885
930
|
|
|
886
931
|
// Header anomaly score
|
|
887
932
|
$headerAnomalies = RequestUtils::getHeaderAnomalies($context);
|
|
@@ -1272,8 +1317,9 @@ class FingerprintEngine
|
|
|
1272
1317
|
return $decision;
|
|
1273
1318
|
}
|
|
1274
1319
|
|
|
1275
|
-
// --- PRIVATE ACCESS TOKENS (PAT / RFC
|
|
1320
|
+
// --- PRIVATE ACCESS TOKENS (PAT / RFC 9578 & Privacy Pass) ZERO-FRICTION CHALLENGE EXEMPTION ---
|
|
1276
1321
|
$rawPatTokens = RequestUtils::extractPrivateAccessTokens($context);
|
|
1322
|
+
$hasValidPat = false;
|
|
1277
1323
|
if (!empty($rawPatTokens)) {
|
|
1278
1324
|
$patConfig = $this->securityConfig['pat'] ?? [];
|
|
1279
1325
|
foreach ($rawPatTokens as $rawToken) {
|
|
@@ -1285,16 +1331,12 @@ class FingerprintEngine
|
|
|
1285
1331
|
if ($isValid) {
|
|
1286
1332
|
$nonceTtl = $patConfig['nonceTtl'] ?? 86400;
|
|
1287
1333
|
$store->set($nonceKey, true, $nonceTtl);
|
|
1288
|
-
$this->log('Private Access Token (PAT) cryptographically verified -
|
|
1334
|
+
$this->log('Private Access Token (PAT) cryptographically verified - challenge exemption enabled', [
|
|
1289
1335
|
'tokenType' => $parsedToken['tokenType'],
|
|
1290
1336
|
'tokenKeyId' => $parsedToken['tokenKeyId']
|
|
1291
1337
|
]);
|
|
1292
|
-
$
|
|
1293
|
-
|
|
1294
|
-
'score' => 0.0,
|
|
1295
|
-
'vector' => ['pat_verified' => 100.0, 'privacy_pass' => 100.0]
|
|
1296
|
-
];
|
|
1297
|
-
return $decision;
|
|
1338
|
+
$hasValidPat = true;
|
|
1339
|
+
break;
|
|
1298
1340
|
}
|
|
1299
1341
|
}
|
|
1300
1342
|
}
|
|
@@ -1365,14 +1407,12 @@ class FingerprintEngine
|
|
|
1365
1407
|
$isMemValid = true;
|
|
1366
1408
|
} else {
|
|
1367
1409
|
$memSolution = $context->query['pow_solution_mem'] ?? null;
|
|
1368
|
-
$isMemValid =
|
|
1369
|
-
? ChallengeUtils::verifyMemoryPoW(
|
|
1410
|
+
$isMemValid = $memSolution !== null && $memSolution !== '' && ChallengeUtils::verifyMemoryPoW(
|
|
1370
1411
|
$powNonce,
|
|
1371
1412
|
(string)$memSolution,
|
|
1372
1413
|
$challengeContext['memDifficulty'] ?? 0,
|
|
1373
1414
|
$challengeContext['clientSecret'] ?? ''
|
|
1374
|
-
)
|
|
1375
|
-
: false;
|
|
1415
|
+
);
|
|
1376
1416
|
}
|
|
1377
1417
|
$isValid = $isValid && $isMemValid;
|
|
1378
1418
|
}
|
|
@@ -1410,8 +1450,9 @@ class FingerprintEngine
|
|
|
1410
1450
|
'jsonLastError' => json_last_error_msg()
|
|
1411
1451
|
]);
|
|
1412
1452
|
if (json_last_error() === JSON_ERROR_NONE) {
|
|
1413
|
-
|
|
1414
|
-
|
|
1453
|
+
$defaultPath = defined('ANONYMPINS_BOT_MITIGATION_DIR')
|
|
1454
|
+
? rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config/problems.config.json'
|
|
1455
|
+
: (file_exists(__DIR__ . '/config/problems.config.json') ? __DIR__ . '/config/problems.config.json' : null);
|
|
1415
1456
|
$configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
|
|
1416
1457
|
$problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
|
|
1417
1458
|
// FIX: The solution is directly $workResult, not a sub-property.
|
|
@@ -1490,7 +1531,7 @@ class FingerprintEngine
|
|
|
1490
1531
|
$deviceId = $context->cookies['device_id'] ?? '';
|
|
1491
1532
|
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
1492
1533
|
$allowRoaming = $this->securityConfig['allowCrossNetworkRoaming'] ?? false;
|
|
1493
|
-
if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof)) {
|
|
1534
|
+
if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof) || $hasValidPat) {
|
|
1494
1535
|
$hasValidTicket = true;
|
|
1495
1536
|
MetricsManager::incrementCounter('tickets_valid_total');
|
|
1496
1537
|
// We do not return immediately to allow re-challenging.
|
|
@@ -1517,6 +1558,9 @@ class FingerprintEngine
|
|
|
1517
1558
|
}
|
|
1518
1559
|
|
|
1519
1560
|
$suspicionVector = $context->preCalculatedVector ?? $this->getSuspicionVector($context, $suspicionVector);
|
|
1561
|
+
if ($hasValidPat) {
|
|
1562
|
+
$suspicionVector['pat_verified'] = 100.0;
|
|
1563
|
+
}
|
|
1520
1564
|
$finalScore = $context->preCalculatedScore ?? $this->calculateFinalScore($suspicionVector);
|
|
1521
1565
|
$this->log('Suspicion vector and final score calculated', [
|
|
1522
1566
|
'finalScore' => round($finalScore, 2),
|
|
@@ -1692,7 +1736,9 @@ class FingerprintEngine
|
|
|
1692
1736
|
|
|
1693
1737
|
if ($shouldUseUsefulWork) {
|
|
1694
1738
|
$this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
|
|
1695
|
-
$defaultPath =
|
|
1739
|
+
$defaultPath = defined('ANONYMPINS_BOT_MITIGATION_DIR')
|
|
1740
|
+
? rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config/problems.config.json'
|
|
1741
|
+
: (file_exists(__DIR__ . '/config/problems.config.json') ? __DIR__ . '/config/problems.config.json' : null);
|
|
1696
1742
|
$configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
|
|
1697
1743
|
$problemManager = ProblemManager::getInstance($configPath, $store);
|
|
1698
1744
|
$work = $problemManager->dispatchWork($finalScore);
|
|
@@ -1984,6 +2030,56 @@ class FingerprintEngine
|
|
|
1984
2030
|
}
|
|
1985
2031
|
}
|
|
1986
2032
|
|
|
2033
|
+
public function broadcastWhitelist(string $entry, string $entryType = 'ip', int $ttl = 86400): void
|
|
2034
|
+
{
|
|
2035
|
+
$peers = $this->securityConfig['federatedPeers'] ?? [];
|
|
2036
|
+
if (empty($peers)) return;
|
|
2037
|
+
|
|
2038
|
+
$cleanEntry = trim($entry);
|
|
2039
|
+
if ($cleanEntry === '*' || $cleanEntry === '0.0.0.0/0' || $cleanEntry === '::/0') {
|
|
2040
|
+
throw new \InvalidArgumentException('Permissive wildcard entries are prohibited');
|
|
2041
|
+
}
|
|
2042
|
+
if ($cleanEntry === '') {
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
$boundedTtl = min(604800, max(60, $ttl));
|
|
2047
|
+
$ts = (int)(microtime(true) * 1000);
|
|
2048
|
+
$msg = "{$ts}:whitelist:{$entryType}:{$cleanEntry}";
|
|
2049
|
+
|
|
2050
|
+
$privateKey = Env::get('ED25519_PRIVATE_KEY');
|
|
2051
|
+
if (!$privateKey) {
|
|
2052
|
+
throw new \RuntimeException('ED25519_PRIVATE_KEY is required for federated whitelist broadcast');
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
$signature = '';
|
|
2056
|
+
try {
|
|
2057
|
+
$cleanKey = str_replace('\n', "\n", $privateKey);
|
|
2058
|
+
$pkeyObj = openssl_pkey_get_private($cleanKey);
|
|
2059
|
+
if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
|
|
2060
|
+
$signature = bin2hex($sigBytes);
|
|
2061
|
+
}
|
|
2062
|
+
} catch (\Throwable $e) {
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
if (empty($signature)) return;
|
|
2066
|
+
|
|
2067
|
+
foreach ($peers as $peerUrl) {
|
|
2068
|
+
$this->asyncPost($peerUrl . '?coop_op=share_whitelist', [
|
|
2069
|
+
'entry' => $cleanEntry,
|
|
2070
|
+
'entry_type' => $entryType,
|
|
2071
|
+
'signature_ed25519' => $signature,
|
|
2072
|
+
'timestamp' => $ts,
|
|
2073
|
+
'ttl' => $boundedTtl,
|
|
2074
|
+
]);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
public function broadcastWhitelistedEntry(string $entry, string $entryType = 'ip', int $ttl = 86400): void
|
|
2079
|
+
{
|
|
2080
|
+
$this->broadcastWhitelist($entry, $entryType, $ttl);
|
|
2081
|
+
}
|
|
2082
|
+
|
|
1987
2083
|
/**
|
|
1988
2084
|
* Sends an asynchronous HTTP POST request to a given URL with the given
|
|
1989
2085
|
* parameters (JSON-encoded). Uses non-blocking stream sockets with a short
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Anonympins\Fingerprint;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Profils de connectivité réseau et paramètres de modulation pour le scoring analogique.
|
|
9
|
+
*/
|
|
10
|
+
class NetworkProfile
|
|
11
|
+
{
|
|
12
|
+
public const RESIDENTIAL = 'RESIDENTIAL';
|
|
13
|
+
public const CELLULAR = 'CELLULAR';
|
|
14
|
+
public const SATELLITE = 'SATELLITE';
|
|
15
|
+
public const HOSTING = 'HOSTING';
|
|
16
|
+
public const ANONYMIZER = 'ANONYMIZER';
|
|
17
|
+
|
|
18
|
+
private string $type;
|
|
19
|
+
private float $baseScore;
|
|
20
|
+
private float $inflectionPoint;
|
|
21
|
+
private bool $toleranceRotation;
|
|
22
|
+
private float $jitterTolerance;
|
|
23
|
+
|
|
24
|
+
public function __construct(
|
|
25
|
+
string $type,
|
|
26
|
+
float $baseScore,
|
|
27
|
+
float $inflectionPoint,
|
|
28
|
+
bool $toleranceRotation,
|
|
29
|
+
float $jitterTolerance
|
|
30
|
+
) {
|
|
31
|
+
$this->type = $type;
|
|
32
|
+
$this->baseScore = $baseScore;
|
|
33
|
+
$this->inflectionPoint = $inflectionPoint;
|
|
34
|
+
$this->toleranceRotation = $toleranceRotation;
|
|
35
|
+
$this->jitterTolerance = $jitterTolerance;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public function getType(): string { return $this->type; }
|
|
39
|
+
public function getBaseScore(): float { return $this->baseScore; }
|
|
40
|
+
public function getInflectionPoint(): float { return $this->inflectionPoint; }
|
|
41
|
+
public function isToleranceRotation(): bool { return $this->toleranceRotation; }
|
|
42
|
+
public function getJitterTolerance(): float { return $this->jitterTolerance; }
|
|
43
|
+
|
|
44
|
+
public static function residential(): self
|
|
45
|
+
{
|
|
46
|
+
return new self(self::RESIDENTIAL, 0.0, 0.72, false, 60.0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public static function cellular(): self
|
|
50
|
+
{
|
|
51
|
+
return new self(self::CELLULAR, 10.0, 0.60, true, 80.0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public static function satellite(): self
|
|
55
|
+
{
|
|
56
|
+
return new self(self::SATELLITE, 15.0, 0.68, false, 150.0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public static function hosting(): self
|
|
60
|
+
{
|
|
61
|
+
return new self(self::HOSTING, 55.0, 0.85, false, 30.0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public static function anonymizer(): self
|
|
65
|
+
{
|
|
66
|
+
return new self(self::ANONYMIZER, 85.0, 0.90, false, 40.0);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -36,8 +36,18 @@ class ProblemManager
|
|
|
36
36
|
{
|
|
37
37
|
if (self::$instance === null) {
|
|
38
38
|
if ($configPath === null) {
|
|
39
|
-
$
|
|
40
|
-
|
|
39
|
+
$candidateDirs = [];
|
|
40
|
+
if (defined('ANONYMPINS_BOT_MITIGATION_DIR')) {
|
|
41
|
+
$candidateDirs[] = rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config';
|
|
42
|
+
}
|
|
43
|
+
$candidateDirs[] = __DIR__ . '/config';
|
|
44
|
+
foreach ($candidateDirs as $dir) {
|
|
45
|
+
$p = $dir . '/problems.config.json';
|
|
46
|
+
if (file_exists($p)) {
|
|
47
|
+
$configPath = $p;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
41
51
|
}
|
|
42
52
|
// Error if accessed before initialization
|
|
43
53
|
if ($configPath === null || $store === null) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Anonympins\Fingerprint\Tests;
|
|
6
|
+
|
|
7
|
+
use Anonympins\Fingerprint\AsnLookupEngine;
|
|
8
|
+
use Anonympins\Fingerprint\NetworkProfile;
|
|
9
|
+
use Anonympins\Fingerprint\Utils\RequestUtils;
|
|
10
|
+
use PHPUnit\Framework\TestCase;
|
|
11
|
+
|
|
12
|
+
class AsnLookupTest extends TestCase
|
|
13
|
+
{
|
|
14
|
+
private AsnLookupEngine $engine;
|
|
15
|
+
|
|
16
|
+
protected function setUp(): void
|
|
17
|
+
{
|
|
18
|
+
parent::setUp();
|
|
19
|
+
$this->engine = AsnLookupEngine::getInstance();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public function testDatacenterResolutionAndPriorScore(): void
|
|
23
|
+
{
|
|
24
|
+
$hetzner = $this->engine->lookup('95.216.12.34');
|
|
25
|
+
$this->assertSame('HOSTING', $hetzner->getType());
|
|
26
|
+
$this->assertSame(55.0, $hetzner->getBaseScore());
|
|
27
|
+
$this->assertSame(0.85, $hetzner->getInflectionPoint());
|
|
28
|
+
$this->assertFalse($hetzner->isToleranceRotation());
|
|
29
|
+
|
|
30
|
+
$aws = $this->engine->lookup('54.210.1.20');
|
|
31
|
+
$this->assertSame('HOSTING', $aws->getType());
|
|
32
|
+
$this->assertSame(55.0, $aws->getBaseScore());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public function testMobileCgnatResolution(): void
|
|
36
|
+
{
|
|
37
|
+
$cgnat = $this->engine->lookup('100.70.1.25');
|
|
38
|
+
$this->assertSame('CELLULAR', $cgnat->getType());
|
|
39
|
+
$this->assertSame(10.0, $cgnat->getBaseScore());
|
|
40
|
+
$this->assertSame(0.60, $cgnat->getInflectionPoint());
|
|
41
|
+
$this->assertTrue($cgnat->isToleranceRotation());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public function testStarlinkSatelliteResolution(): void
|
|
45
|
+
{
|
|
46
|
+
$starlink = $this->engine->lookup('98.97.10.5');
|
|
47
|
+
$this->assertSame('SATELLITE', $starlink->getType());
|
|
48
|
+
$this->assertSame(15.0, $starlink->getBaseScore());
|
|
49
|
+
$this->assertSame(150.0, $starlink->getJitterTolerance());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public function testAnalogInconsistencyModulation(): void
|
|
53
|
+
{
|
|
54
|
+
$consistency = 0.80; // Incohérence légère de rendu
|
|
55
|
+
$scoreDatacenter = RequestUtils::calculateAnalogInconsistencyScore($consistency, NetworkProfile::hosting());
|
|
56
|
+
$scoreMobile = RequestUtils::calculateAnalogInconsistencyScore($consistency, NetworkProfile::cellular());
|
|
57
|
+
|
|
58
|
+
$this->assertGreaterThan(55.0, $scoreDatacenter, 'Sur Datacenter (0.80 < 0.85), la suspicion décolle au-dessus de 55.0');
|
|
59
|
+
$this->assertLessThan(10.0, $scoreMobile, 'Sur Mobile (0.80 > 0.60), la variation est entièrement absorbée (< 10.0)');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -303,11 +303,12 @@ class FingerprintEngineTest extends TestCase
|
|
|
303
303
|
$this->markTestSkipped('Ed25519 is not supported in this PHP/OpenSSL environment.');
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
-
$configDir =
|
|
306
|
+
$configDir = sys_get_temp_dir() . '/anonympins-test-keys-' . time();
|
|
307
307
|
if (!is_dir($configDir)) {
|
|
308
308
|
mkdir($configDir, 0777, true);
|
|
309
309
|
}
|
|
310
310
|
$keyPath = $configDir . '/ed25519_key.json';
|
|
311
|
+
Env::set('FINGERPRINT_KEY_DIR', $configDir);
|
|
311
312
|
|
|
312
313
|
Env::clear('ED25519_PRIVATE_KEY');
|
|
313
314
|
Env::clear('ED25519_PUBLIC_KEY');
|
|
@@ -338,11 +339,12 @@ class FingerprintEngineTest extends TestCase
|
|
|
338
339
|
|
|
339
340
|
public function testEd25519KeyLoadingFromDisk(): void
|
|
340
341
|
{
|
|
341
|
-
$configDir =
|
|
342
|
+
$configDir = sys_get_temp_dir() . '/anonympins-test-keys-' . time();
|
|
342
343
|
if (!is_dir($configDir)) {
|
|
343
344
|
mkdir($configDir, 0777, true);
|
|
344
345
|
}
|
|
345
346
|
$keyPath = $configDir . '/ed25519_key.json';
|
|
347
|
+
Env::set('FINGERPRINT_KEY_DIR', $configDir);
|
|
346
348
|
|
|
347
349
|
$dummyKeys = [
|
|
348
350
|
'privateKey' => '-----BEGIN PRIVATE KEY-----\ndummy-php-private\n-----END PRIVATE KEY-----',
|
package/src/php/Utils/Env.php
CHANGED
|
@@ -21,10 +21,15 @@ class Env
|
|
|
21
21
|
}
|
|
22
22
|
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Environment and server variables may contain raw cryptographic keys.
|
|
23
23
|
if (isset($_ENV[$key]) && $_ENV[$key] !== '') {
|
|
24
|
-
|
|
24
|
+
$envVal = function_exists('wp_unslash') ? wp_unslash($_ENV[$key]) : $_ENV[$key];
|
|
25
|
+
return is_string($envVal) ? (function_exists('sanitize_textarea_field') ? sanitize_textarea_field($envVal) : $envVal) : $envVal;
|
|
25
26
|
}
|
|
26
27
|
if (isset($_SERVER[$key]) && $_SERVER[$key] !== '') {
|
|
27
|
-
|
|
28
|
+
$serverVal = function_exists('wp_unslash') ? wp_unslash($_SERVER[$key]) : $_SERVER[$key];
|
|
29
|
+
if (is_string($serverVal)) {
|
|
30
|
+
return function_exists('sanitize_textarea_field') ? sanitize_textarea_field($serverVal) : $serverVal;
|
|
31
|
+
}
|
|
32
|
+
return $serverVal;
|
|
28
33
|
}
|
|
29
34
|
// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
|
|
30
35
|
$val = getenv($key);
|
|
@@ -9,6 +9,8 @@ use Anonympins\Fingerprint\FingerprintBuilder;
|
|
|
9
9
|
use Anonympins\Fingerprint\Optimization\Optimization;
|
|
10
10
|
use Anonympins\Fingerprint\RequestContext;
|
|
11
11
|
use Anonympins\Fingerprint\Store\StoreManager;
|
|
12
|
+
use Anonympins\Fingerprint\AsnLookupEngine;
|
|
13
|
+
use Anonympins\Fingerprint\NetworkProfile;
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Classe utilitaire pour l'analyse des requêtes et le calcul des scores de suspicion.
|
|
@@ -314,6 +316,7 @@ class RequestUtils
|
|
|
314
316
|
|
|
315
317
|
$claimedBrowserInfo = self::parseUserAgent($ua);
|
|
316
318
|
$claimedBrowser = $claimedBrowserInfo['browser'] ?? null;
|
|
319
|
+
$tlsSpoofingScore = 0.0;
|
|
317
320
|
|
|
318
321
|
if (empty($claimedBrowser) || empty($ua)) {
|
|
319
322
|
return ['tlsSpoofingScore' => 0.0];
|
|
@@ -365,7 +368,7 @@ class RequestUtils
|
|
|
365
368
|
$echExpected = $context->getHeader('x-ech-expected') === 'true' || !empty($outerSni);
|
|
366
369
|
|
|
367
370
|
if (!empty($outerSni) && !empty($host) && strcasecmp($outerSni, $host) !== 0 && !$hasEch) {
|
|
368
|
-
return ['tlsSpoofingScore' => max($tlsSpoofingScore
|
|
371
|
+
return ['tlsSpoofingScore' => max($tlsSpoofingScore, 60.0)];
|
|
369
372
|
}
|
|
370
373
|
|
|
371
374
|
$browserVer = (int)($claimedBrowserInfo['version'] ?? 0);
|
|
@@ -374,7 +377,7 @@ class RequestUtils
|
|
|
374
377
|
($claimedBrowser === 'Firefox' && $browserVer >= 118)
|
|
375
378
|
);
|
|
376
379
|
if ($context->isHttps && $isModernEchBrowser && $echExpected && !$hasEch) {
|
|
377
|
-
return ['tlsSpoofingScore' => max($tlsSpoofingScore
|
|
380
|
+
return ['tlsSpoofingScore' => max($tlsSpoofingScore, 55.0)];
|
|
378
381
|
}
|
|
379
382
|
|
|
380
383
|
return ['tlsSpoofingScore' => 0.0];
|
|
@@ -1021,7 +1024,7 @@ class RequestUtils
|
|
|
1021
1024
|
* @param array<string, mixed> $deviceData
|
|
1022
1025
|
* @return array{'historyScore': float, 'rotationScore': float}
|
|
1023
1026
|
*/
|
|
1024
|
-
public static function getBehavioralIndicators(RequestContext $context, array &$deviceData): array
|
|
1027
|
+
public static function getBehavioralIndicators(RequestContext $context, array &$deviceData, ?NetworkProfile $netProfile = null): array
|
|
1025
1028
|
{
|
|
1026
1029
|
$now = time() * 1000;
|
|
1027
1030
|
$clientIp = $context->clientIp;
|
|
@@ -1089,6 +1092,11 @@ class RequestUtils
|
|
|
1089
1092
|
$maxIpsPerDevice = 15;
|
|
1090
1093
|
$freeIpChanges = 3;
|
|
1091
1094
|
$historyScore = min(100.0, (max(0, count($deviceData['ips']) - $freeIpChanges) / $maxIpsPerDevice) * 100);
|
|
1095
|
+
|
|
1096
|
+
// Mobile CGNAT : désactivation/amortissement de la pénalité de rotation rapide d'IP
|
|
1097
|
+
if ($netProfile !== null && $netProfile->isToleranceRotation()) {
|
|
1098
|
+
$historyScore = min($historyScore, 10.0);
|
|
1099
|
+
}
|
|
1092
1100
|
// Score de rotation basé sur les changements rapides de fingerprint
|
|
1093
1101
|
$rotationScore = min(100.0, (($deviceData['rapidChangeCount'] ?? 0) / $maxRapidChanges) * 100);
|
|
1094
1102
|
|
|
@@ -1858,18 +1866,20 @@ class RequestUtils
|
|
|
1858
1866
|
*/
|
|
1859
1867
|
public static function getIpReputationScore(string $ip): float
|
|
1860
1868
|
{
|
|
1869
|
+
$profile = AsnLookupEngine::getInstance()->lookup($ip);
|
|
1870
|
+
$baseScore = $profile->getBaseScore();
|
|
1861
1871
|
$store = StoreManager::getStore();
|
|
1862
1872
|
$key = "ip-reputation:{$ip}";
|
|
1863
1873
|
$data = $store->get($key);
|
|
1864
1874
|
if ($data === null) {
|
|
1865
|
-
return
|
|
1875
|
+
return $baseScore;
|
|
1866
1876
|
}
|
|
1867
1877
|
|
|
1868
1878
|
$now = time();
|
|
1869
1879
|
$hoursPassed = ($now - $data['lastUpdate']) / 3600;
|
|
1870
1880
|
$decay = (int)floor($hoursPassed * 2); // Décroissance de 2 points par heure
|
|
1871
1881
|
|
|
1872
|
-
return (float)max(
|
|
1882
|
+
return (float)max($baseScore, $data['score'] - $decay);
|
|
1873
1883
|
}
|
|
1874
1884
|
|
|
1875
1885
|
/**
|
|
@@ -2693,13 +2703,21 @@ class RequestUtils
|
|
|
2693
2703
|
/**
|
|
2694
2704
|
* Calcule un score d'incohérence analogique et lisse plafonnant à 99.9 max.
|
|
2695
2705
|
*/
|
|
2696
|
-
public static function calculateAnalogInconsistencyScore(float $consistencyScore,
|
|
2706
|
+
public static function calculateAnalogInconsistencyScore(float $consistencyScore, $inflectionOrProfile = 0.72, float $steepness = 12.0): float
|
|
2697
2707
|
{
|
|
2698
2708
|
$s = max(0.0, min(1.0, $consistencyScore));
|
|
2699
2709
|
if ($s >= 0.98) {
|
|
2700
2710
|
return 0.0;
|
|
2701
2711
|
}
|
|
2702
2712
|
|
|
2713
|
+
if ($inflectionOrProfile instanceof NetworkProfile) {
|
|
2714
|
+
$inflectionPoint = $inflectionOrProfile->getInflectionPoint();
|
|
2715
|
+
} elseif (is_array($inflectionOrProfile)) {
|
|
2716
|
+
$inflectionPoint = (float)($inflectionOrProfile['inflectionPoint'] ?? $inflectionOrProfile['inflection_point'] ?? 0.72);
|
|
2717
|
+
} else {
|
|
2718
|
+
$inflectionPoint = (float)$inflectionOrProfile;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2703
2721
|
$asymptote = 99.9;
|
|
2704
2722
|
$raw = 1.0 / (1.0 + exp($steepness * ($s - $inflectionPoint)));
|
|
2705
2723
|
$minVal = 1.0 / (1.0 + exp($steepness * (1.0 - $inflectionPoint)));
|
|
@@ -156,4 +156,31 @@ class WpDbStore implements IStore
|
|
|
156
156
|
);
|
|
157
157
|
return is_numeric($deleted) ? (int)$deleted : 0;
|
|
158
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Returns the total number of records currently stored.
|
|
162
|
+
*/
|
|
163
|
+
public function getTotalCount(): int
|
|
164
|
+
{
|
|
165
|
+
if (!$this->db || !method_exists($this->db, 'get_var')) {
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
$count = $this->db->get_var("SELECT COUNT(*) FROM `{$this->table}`");
|
|
169
|
+
return is_numeric($count) ? (int)$count : 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Returns the number of expired records awaiting cron deletion.
|
|
174
|
+
*/
|
|
175
|
+
public function getExpiredCount(): int
|
|
176
|
+
{
|
|
177
|
+
if (!$this->db || !method_exists($this->db, 'get_var') || !method_exists($this->db, 'prepare')) {
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
$now = time();
|
|
181
|
+
$count = $this->db->get_var(
|
|
182
|
+
$this->db->prepare("SELECT COUNT(*) FROM `{$this->table}` WHERE expires_at IS NOT NULL AND expires_at < %d", $now)
|
|
183
|
+
);
|
|
184
|
+
return is_numeric($count) ? (int)$count : 0;
|
|
185
|
+
}
|
|
159
186
|
}
|