@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.
@@ -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 ?: json_decode(file_get_contents('php://input'), true);
34
- $headers = function_exists('getallheaders') ? getallheaders() : [];
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
- // phpcs:ignore WordPress.Security.NonceVerification.Recommended
47
- $_GET,
67
+ $query,
48
68
  $body,
49
- $_COOKIE,
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
- $pemResult = ChallengeUtils::generateIssuerPemKeys();
177
- $privateKeyPem = $pemResult['privateKey'];
178
- $publicKeyPem = $pemResult['publicKey'];
179
- Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
180
- Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
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
- $configDir = dirname(__DIR__, 1) . '/config';
231
- $persistentKeyPath = $configDir . '/ed25519_key.json';
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
- $keys = json_decode((string)file_get_contents($persistentKeyPath), true);
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
- // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Non-WordPress standalone environment fallback
257
- mkdir($configDir, 0777, true);
270
+ @mkdir($configDir, 0700, true);
258
271
  }
259
272
  }
260
- // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.PluginDirectoryWrite -- Non-WordPress standalone environment fallback
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
- $configDir = dirname(__DIR__, 2) . '/config';
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'] ?? 0.72);
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 9505 & Privacy Pass) ZERO-FRICTION BYPASS ---
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 - granting zero-friction bypass', [
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
- $decision = [
1293
- 'action' => 'next',
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 = ($memSolution !== null && $memSolution !== '')
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
- // @phpstan-ignore-next-line - The instance is managed by the singleton
1414
- $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
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 = dirname(__DIR__, 1) . '/config/problems.config.json';
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
- $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
40
- $configPath = file_exists($defaultPath) ? $defaultPath : null;
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 = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
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 = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
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-----',
@@ -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
- return $_ENV[$key];
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
- return $_SERVER[$key];
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 0.0;
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(0.0, $data['score'] - $decay);
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, float $inflectionPoint = 0.72, float $steepness = 12.0): float
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
  }