@anonympins/fingerprint 0.8.0 → 0.8.1
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 +25 -0
- package/README.md +1 -1
- package/package.json +3 -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 +48 -22
- package/src/js/tests/asn-lookup.test.js +57 -0
- package/src/php/AsnLookupEngine.php +173 -0
- package/src/php/Challenge/ChallengeUtils.php +66 -49
- package/src/php/DirectFingerprint.php +25 -5
- package/src/php/FingerprintEngine.php +51 -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 +21 -4
- package/src/php/WordPress/WpDbStore.php +27 -0
- package/src/php/WordPress/anonympins-bot-mitigation-pow.php +109 -8
- 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 +8 -2
|
@@ -29,24 +29,44 @@ class DirectFingerprint
|
|
|
29
29
|
*/
|
|
30
30
|
public function buildRequestContext(): RequestContext
|
|
31
31
|
{
|
|
32
|
+
$sanitizeDeep = function ($data) use (&$sanitizeDeep) {
|
|
33
|
+
if (is_array($data)) {
|
|
34
|
+
$clean = [];
|
|
35
|
+
foreach ($data as $k => $v) {
|
|
36
|
+
$cleanKey = is_string($k) ? (function_exists('sanitize_text_field') && function_exists('wp_unslash') ? sanitize_text_field(wp_unslash($k)) : trim((string)$k)) : $k;
|
|
37
|
+
$clean[$cleanKey] = $sanitizeDeep($v);
|
|
38
|
+
}
|
|
39
|
+
return $clean;
|
|
40
|
+
}
|
|
41
|
+
if (is_string($data)) {
|
|
42
|
+
return function_exists('sanitize_text_field') && function_exists('wp_unslash') ? sanitize_text_field(wp_unslash($data)) : trim($data);
|
|
43
|
+
}
|
|
44
|
+
return $data;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
$rawInput = (string)file_get_contents('php://input');
|
|
48
|
+
$decodedJson = !empty($rawInput) ? json_decode($rawInput, true) : null;
|
|
32
49
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- Direct WAF inspection firewall before WP core logic
|
|
33
|
-
$body = $_POST
|
|
34
|
-
$
|
|
50
|
+
$body = !empty($_POST) ? $sanitizeDeep($_POST) : (is_array($decodedJson) ? $sanitizeDeep($decodedJson) : null);
|
|
51
|
+
$rawHeaders = function_exists('getallheaders') ? getallheaders() : [];
|
|
52
|
+
$headers = is_array($rawHeaders) ? $sanitizeDeep($rawHeaders) : [];
|
|
35
53
|
|
|
36
54
|
$remoteAddr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '127.0.0.1';
|
|
37
55
|
$rawUri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '/';
|
|
38
56
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Uses wp_parse_url when available
|
|
39
57
|
$path = function_exists('wp_parse_url') ? (string)wp_parse_url($rawUri, PHP_URL_PATH) : (string)parse_url($rawUri, PHP_URL_PATH);
|
|
40
58
|
$serverProtocol = isset($_SERVER['SERVER_PROTOCOL']) ? sanitize_text_field(wp_unslash($_SERVER['SERVER_PROTOCOL'])) : '1.1';
|
|
59
|
+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
|
60
|
+
$query = isset($_GET) && is_array($_GET) ? $sanitizeDeep($_GET) : [];
|
|
61
|
+
$cookies = isset($_COOKIE) && is_array($_COOKIE) ? $sanitizeDeep($_COOKIE) : [];
|
|
41
62
|
|
|
42
63
|
return new RequestContext(
|
|
43
64
|
$remoteAddr,
|
|
44
65
|
!empty($path) ? $path : '/',
|
|
45
66
|
$headers,
|
|
46
|
-
|
|
47
|
-
$_GET,
|
|
67
|
+
$query,
|
|
48
68
|
$body,
|
|
49
|
-
$
|
|
69
|
+
$cookies,
|
|
50
70
|
$serverProtocol
|
|
51
71
|
);
|
|
52
72
|
}
|
|
@@ -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,10 @@ class FingerprintEngine
|
|
|
253
267
|
if (function_exists('wp_mkdir_p')) {
|
|
254
268
|
wp_mkdir_p($configDir);
|
|
255
269
|
} else {
|
|
256
|
-
|
|
257
|
-
mkdir($configDir, 0777, true);
|
|
270
|
+
@mkdir($configDir, 0700, true);
|
|
258
271
|
}
|
|
259
272
|
}
|
|
260
|
-
// phpcs:ignore
|
|
273
|
+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
|
|
261
274
|
file_put_contents($persistentKeyPath, json_encode([
|
|
262
275
|
'privateKey' => $privateKeyPem,
|
|
263
276
|
'publicKey' => $publicKeyPem
|
|
@@ -346,7 +359,13 @@ class FingerprintEngine
|
|
|
346
359
|
*/
|
|
347
360
|
private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
|
|
348
361
|
{
|
|
349
|
-
|
|
362
|
+
if (defined('ANONYMPINS_BOT_MITIGATION_DIR')) {
|
|
363
|
+
$configDir = rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config';
|
|
364
|
+
} elseif (is_dir(__DIR__ . '/config')) {
|
|
365
|
+
$configDir = __DIR__ . '/config';
|
|
366
|
+
} else {
|
|
367
|
+
$configDir = __DIR__;
|
|
368
|
+
}
|
|
350
369
|
$filePath = $configDir . '/' . $filename;
|
|
351
370
|
if (file_exists($filePath)) {
|
|
352
371
|
try {
|
|
@@ -876,12 +895,13 @@ class FingerprintEngine
|
|
|
876
895
|
// --- Computation of the various suspicion scores ---
|
|
877
896
|
|
|
878
897
|
// Fingerprint inconsistency score (moved here to be with the others)
|
|
898
|
+
$netProfile = AsnLookupEngine::getInstance()->lookup($context->clientIp);
|
|
879
899
|
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
880
900
|
$consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
|
|
881
|
-
$similarityThreshold = (float)($this->securityConfig['similarityThreshold'] ??
|
|
901
|
+
$similarityThreshold = (float)($this->securityConfig['similarityThreshold'] ?? $netProfile->getInflectionPoint());
|
|
882
902
|
$inconsistencyScore = RequestUtils::calculateAnalogInconsistencyScore($consistencyScore, $similarityThreshold);
|
|
883
903
|
|
|
884
|
-
$behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
|
|
904
|
+
$behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData, $netProfile);
|
|
885
905
|
|
|
886
906
|
// Header anomaly score
|
|
887
907
|
$headerAnomalies = RequestUtils::getHeaderAnomalies($context);
|
|
@@ -1272,8 +1292,9 @@ class FingerprintEngine
|
|
|
1272
1292
|
return $decision;
|
|
1273
1293
|
}
|
|
1274
1294
|
|
|
1275
|
-
// --- PRIVATE ACCESS TOKENS (PAT / RFC
|
|
1295
|
+
// --- PRIVATE ACCESS TOKENS (PAT / RFC 9578 & Privacy Pass) ZERO-FRICTION CHALLENGE EXEMPTION ---
|
|
1276
1296
|
$rawPatTokens = RequestUtils::extractPrivateAccessTokens($context);
|
|
1297
|
+
$hasValidPat = false;
|
|
1277
1298
|
if (!empty($rawPatTokens)) {
|
|
1278
1299
|
$patConfig = $this->securityConfig['pat'] ?? [];
|
|
1279
1300
|
foreach ($rawPatTokens as $rawToken) {
|
|
@@ -1285,16 +1306,12 @@ class FingerprintEngine
|
|
|
1285
1306
|
if ($isValid) {
|
|
1286
1307
|
$nonceTtl = $patConfig['nonceTtl'] ?? 86400;
|
|
1287
1308
|
$store->set($nonceKey, true, $nonceTtl);
|
|
1288
|
-
$this->log('Private Access Token (PAT) cryptographically verified -
|
|
1309
|
+
$this->log('Private Access Token (PAT) cryptographically verified - challenge exemption enabled', [
|
|
1289
1310
|
'tokenType' => $parsedToken['tokenType'],
|
|
1290
1311
|
'tokenKeyId' => $parsedToken['tokenKeyId']
|
|
1291
1312
|
]);
|
|
1292
|
-
$
|
|
1293
|
-
|
|
1294
|
-
'score' => 0.0,
|
|
1295
|
-
'vector' => ['pat_verified' => 100.0, 'privacy_pass' => 100.0]
|
|
1296
|
-
];
|
|
1297
|
-
return $decision;
|
|
1313
|
+
$hasValidPat = true;
|
|
1314
|
+
break;
|
|
1298
1315
|
}
|
|
1299
1316
|
}
|
|
1300
1317
|
}
|
|
@@ -1365,14 +1382,12 @@ class FingerprintEngine
|
|
|
1365
1382
|
$isMemValid = true;
|
|
1366
1383
|
} else {
|
|
1367
1384
|
$memSolution = $context->query['pow_solution_mem'] ?? null;
|
|
1368
|
-
$isMemValid =
|
|
1369
|
-
? ChallengeUtils::verifyMemoryPoW(
|
|
1385
|
+
$isMemValid = $memSolution !== null && $memSolution !== '' && ChallengeUtils::verifyMemoryPoW(
|
|
1370
1386
|
$powNonce,
|
|
1371
1387
|
(string)$memSolution,
|
|
1372
1388
|
$challengeContext['memDifficulty'] ?? 0,
|
|
1373
1389
|
$challengeContext['clientSecret'] ?? ''
|
|
1374
|
-
)
|
|
1375
|
-
: false;
|
|
1390
|
+
);
|
|
1376
1391
|
}
|
|
1377
1392
|
$isValid = $isValid && $isMemValid;
|
|
1378
1393
|
}
|
|
@@ -1410,8 +1425,9 @@ class FingerprintEngine
|
|
|
1410
1425
|
'jsonLastError' => json_last_error_msg()
|
|
1411
1426
|
]);
|
|
1412
1427
|
if (json_last_error() === JSON_ERROR_NONE) {
|
|
1413
|
-
|
|
1414
|
-
|
|
1428
|
+
$defaultPath = defined('ANONYMPINS_BOT_MITIGATION_DIR')
|
|
1429
|
+
? rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config/problems.config.json'
|
|
1430
|
+
: (file_exists(__DIR__ . '/config/problems.config.json') ? __DIR__ . '/config/problems.config.json' : null);
|
|
1415
1431
|
$configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
|
|
1416
1432
|
$problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
|
|
1417
1433
|
// FIX: The solution is directly $workResult, not a sub-property.
|
|
@@ -1490,7 +1506,7 @@ class FingerprintEngine
|
|
|
1490
1506
|
$deviceId = $context->cookies['device_id'] ?? '';
|
|
1491
1507
|
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
1492
1508
|
$allowRoaming = $this->securityConfig['allowCrossNetworkRoaming'] ?? false;
|
|
1493
|
-
if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof)) {
|
|
1509
|
+
if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof) || $hasValidPat) {
|
|
1494
1510
|
$hasValidTicket = true;
|
|
1495
1511
|
MetricsManager::incrementCounter('tickets_valid_total');
|
|
1496
1512
|
// We do not return immediately to allow re-challenging.
|
|
@@ -1517,6 +1533,9 @@ class FingerprintEngine
|
|
|
1517
1533
|
}
|
|
1518
1534
|
|
|
1519
1535
|
$suspicionVector = $context->preCalculatedVector ?? $this->getSuspicionVector($context, $suspicionVector);
|
|
1536
|
+
if ($hasValidPat) {
|
|
1537
|
+
$suspicionVector['pat_verified'] = 100.0;
|
|
1538
|
+
}
|
|
1520
1539
|
$finalScore = $context->preCalculatedScore ?? $this->calculateFinalScore($suspicionVector);
|
|
1521
1540
|
$this->log('Suspicion vector and final score calculated', [
|
|
1522
1541
|
'finalScore' => round($finalScore, 2),
|
|
@@ -1692,7 +1711,9 @@ class FingerprintEngine
|
|
|
1692
1711
|
|
|
1693
1712
|
if ($shouldUseUsefulWork) {
|
|
1694
1713
|
$this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
|
|
1695
|
-
$defaultPath =
|
|
1714
|
+
$defaultPath = defined('ANONYMPINS_BOT_MITIGATION_DIR')
|
|
1715
|
+
? rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config/problems.config.json'
|
|
1716
|
+
: (file_exists(__DIR__ . '/config/problems.config.json') ? __DIR__ . '/config/problems.config.json' : null);
|
|
1696
1717
|
$configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
|
|
1697
1718
|
$problemManager = ProblemManager::getInstance($configPath, $store);
|
|
1698
1719
|
$work = $problemManager->dispatchWork($finalScore);
|
|
@@ -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.
|
|
@@ -1021,7 +1023,7 @@ class RequestUtils
|
|
|
1021
1023
|
* @param array<string, mixed> $deviceData
|
|
1022
1024
|
* @return array{'historyScore': float, 'rotationScore': float}
|
|
1023
1025
|
*/
|
|
1024
|
-
public static function getBehavioralIndicators(RequestContext $context, array &$deviceData): array
|
|
1026
|
+
public static function getBehavioralIndicators(RequestContext $context, array &$deviceData, ?NetworkProfile $netProfile = null): array
|
|
1025
1027
|
{
|
|
1026
1028
|
$now = time() * 1000;
|
|
1027
1029
|
$clientIp = $context->clientIp;
|
|
@@ -1089,6 +1091,11 @@ class RequestUtils
|
|
|
1089
1091
|
$maxIpsPerDevice = 15;
|
|
1090
1092
|
$freeIpChanges = 3;
|
|
1091
1093
|
$historyScore = min(100.0, (max(0, count($deviceData['ips']) - $freeIpChanges) / $maxIpsPerDevice) * 100);
|
|
1094
|
+
|
|
1095
|
+
// Mobile CGNAT : désactivation/amortissement de la pénalité de rotation rapide d'IP
|
|
1096
|
+
if ($netProfile !== null && $netProfile->isToleranceRotation()) {
|
|
1097
|
+
$historyScore = min($historyScore, 10.0);
|
|
1098
|
+
}
|
|
1092
1099
|
// Score de rotation basé sur les changements rapides de fingerprint
|
|
1093
1100
|
$rotationScore = min(100.0, (($deviceData['rapidChangeCount'] ?? 0) / $maxRapidChanges) * 100);
|
|
1094
1101
|
|
|
@@ -1858,18 +1865,20 @@ class RequestUtils
|
|
|
1858
1865
|
*/
|
|
1859
1866
|
public static function getIpReputationScore(string $ip): float
|
|
1860
1867
|
{
|
|
1868
|
+
$profile = AsnLookupEngine::getInstance()->lookup($ip);
|
|
1869
|
+
$baseScore = $profile->getBaseScore();
|
|
1861
1870
|
$store = StoreManager::getStore();
|
|
1862
1871
|
$key = "ip-reputation:{$ip}";
|
|
1863
1872
|
$data = $store->get($key);
|
|
1864
1873
|
if ($data === null) {
|
|
1865
|
-
return
|
|
1874
|
+
return $baseScore;
|
|
1866
1875
|
}
|
|
1867
1876
|
|
|
1868
1877
|
$now = time();
|
|
1869
1878
|
$hoursPassed = ($now - $data['lastUpdate']) / 3600;
|
|
1870
1879
|
$decay = (int)floor($hoursPassed * 2); // Décroissance de 2 points par heure
|
|
1871
1880
|
|
|
1872
|
-
return (float)max(
|
|
1881
|
+
return (float)max($baseScore, $data['score'] - $decay);
|
|
1873
1882
|
}
|
|
1874
1883
|
|
|
1875
1884
|
/**
|
|
@@ -2693,13 +2702,21 @@ class RequestUtils
|
|
|
2693
2702
|
/**
|
|
2694
2703
|
* Calcule un score d'incohérence analogique et lisse plafonnant à 99.9 max.
|
|
2695
2704
|
*/
|
|
2696
|
-
public static function calculateAnalogInconsistencyScore(float $consistencyScore,
|
|
2705
|
+
public static function calculateAnalogInconsistencyScore(float $consistencyScore, $inflectionOrProfile = 0.72, float $steepness = 12.0): float
|
|
2697
2706
|
{
|
|
2698
2707
|
$s = max(0.0, min(1.0, $consistencyScore));
|
|
2699
2708
|
if ($s >= 0.98) {
|
|
2700
2709
|
return 0.0;
|
|
2701
2710
|
}
|
|
2702
2711
|
|
|
2712
|
+
if ($inflectionOrProfile instanceof NetworkProfile) {
|
|
2713
|
+
$inflectionPoint = $inflectionOrProfile->getInflectionPoint();
|
|
2714
|
+
} elseif (is_array($inflectionOrProfile)) {
|
|
2715
|
+
$inflectionPoint = (float)($inflectionOrProfile['inflectionPoint'] ?? $inflectionOrProfile['inflection_point'] ?? 0.72);
|
|
2716
|
+
} else {
|
|
2717
|
+
$inflectionPoint = (float)$inflectionOrProfile;
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2703
2720
|
$asymptote = 99.9;
|
|
2704
2721
|
$raw = 1.0 / (1.0 + exp($steepness * ($s - $inflectionPoint)));
|
|
2705
2722
|
$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
|
}
|