@anonympins/fingerprint 0.6.7 → 0.7.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 +33 -0
- package/README.md +3 -7
- package/composer.json +8 -1
- package/package.json +1 -1
- package/public/fingerprint-wordpress.zip +0 -0
- package/public/fp.wasm +0 -0
- package/src/js/build-client.js +9 -11
- package/src/js/dynamic-wasm.js +7 -7
- package/src/js/fingerprint.builder.js +37 -38
- package/src/js/fingerprint.client.js +236 -152
- package/src/js/fingerprint.client.obfuscated.js +1 -1
- package/src/js/fingerprint.js +517 -101
- package/src/js/fingerprint.utils.js +213 -213
- package/src/js/library.js +355 -384
- package/src/js/mongodb-store.js +1 -1
- package/src/js/optimization.worker.js +27 -27
- package/src/js/pow.solver.inline.js +206 -62
- package/src/js/pow.solver.js +235 -101
- package/src/js/pow.worker.js +48 -26
- package/src/js/redis-store.js +1 -1
- package/src/js/tests/fingerprint.client.init.test.js +146 -143
- package/src/js/tests/fingerprint.test.js +101 -40
- package/src/js/tests/ip-reputation.test.js +1 -0
- package/src/js/upow-model-task.js +14 -15
- package/src/php/AutoTuner.php +34 -34
- package/src/php/Challenge/ChallengeUtils.php +204 -32
- package/src/php/Config/SecurityProfiles.php +21 -0
- package/src/php/DirectFingerprint.php +190 -145
- package/src/php/FingerprintBuilder.php +29 -30
- package/src/php/FingerprintClient.php +24 -25
- package/src/php/FingerprintEngine.php +1836 -1880
- package/src/php/Ja3AnomalyDetector.php +258 -227
- package/src/php/Optimization/FunctionRegistry.php +7 -7
- package/src/php/Optimization/Optimization.php +24 -24
- package/src/php/Optimization/OptimizationOperators.php +28 -27
- package/src/php/Optimization/ProblemInitializers.php +52 -52
- package/src/php/ProblemManager.php +31 -34
- package/src/php/RequestContext.php +8 -1
- package/src/php/Store/IStore.php +7 -8
- package/src/php/Store/InMemoryStore.php +66 -66
- package/src/php/Store/MongoDbStore.php +5 -5
- package/src/php/Store/RedisStore.php +5 -5
- package/src/php/Store/StoreManager.php +35 -35
- package/src/php/Tests/ChallengeUtilsTest.php +453 -453
- package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
- package/src/php/Tests/FingerprintEngineTest.php +662 -693
- package/src/php/Tests/IpReputationTest.php +10 -0
- package/src/php/Tests/MaliciousPatternsTest.php +103 -103
- package/src/php/Tests/MetricsTest.php +97 -97
- package/src/php/Tests/ProblemManagerTest.php +376 -376
- package/src/php/Tests/QuicFingerprintTest.php +53 -53
- package/src/php/Tests/RequestUtilsTest.php +471 -471
- package/src/php/Tests/TLSClientHelloParserTest.php +177 -177
- package/src/php/Tests/config/ed25519_key.json +3 -3
- package/src/php/Utils/Env.php +49 -49
- package/src/php/Utils/MaliciousPatterns.php +74 -74
- package/src/php/Utils/MetricsManager.php +209 -209
- package/src/php/Utils/RequestUtils.php +236 -19
- package/src/php/Utils/TLSClientHelloParser.php +369 -212
- package/src/php/WordPress/WpDbStore.php +155 -0
- package/src/php/WordPress/fingerprint-wordpress.php +217 -0
- package/src/php/WordPress/package.php +123 -0
- package/src/php/bin/auto-tune.php +118 -118
|
@@ -411,6 +411,9 @@ class RequestUtils
|
|
|
411
411
|
}
|
|
412
412
|
|
|
413
413
|
$score = 0.0;
|
|
414
|
+
if (!empty($metrics['prototypeTampered'])) {
|
|
415
|
+
$score += 80.0;
|
|
416
|
+
}
|
|
414
417
|
|
|
415
418
|
$mouseAnalysis = self::analyzeMouseMovements($metrics['mouseMovementsHistory'] ?? null);
|
|
416
419
|
$touch = self::analyzeTouchMovements($metrics['touchMovementsHistory'] ?? null);
|
|
@@ -501,6 +504,15 @@ class RequestUtils
|
|
|
501
504
|
$score += 35;
|
|
502
505
|
}
|
|
503
506
|
}
|
|
507
|
+
|
|
508
|
+
// Détection de ferme mobile : Touch actif sur mobile sans aucune vibration physique (châssis/rack ADB)
|
|
509
|
+
$ua = $context->getHeader('user-agent') ?? '';
|
|
510
|
+
$isMobileDevice = str_contains($ua, 'Mobile');
|
|
511
|
+
if ($isMobileDevice && count($touchHistory) >= 5 && isset($metrics['motionVariance']) && is_numeric($metrics['motionVariance'])) {
|
|
512
|
+
if ((float)$metrics['motionVariance'] === 0.0) {
|
|
513
|
+
$score += 50.0;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
504
516
|
}
|
|
505
517
|
|
|
506
518
|
return ['behaviorScore' => min(100.0, $score)];
|
|
@@ -1025,13 +1037,20 @@ class RequestUtils
|
|
|
1025
1037
|
|
|
1026
1038
|
public static function getThreatIntelScore(RequestContext $context, array $threatIntelConfig): array
|
|
1027
1039
|
{
|
|
1040
|
+
$score = 0.0;
|
|
1028
1041
|
$zkpY = $context->zkpY;
|
|
1029
|
-
if (empty($zkpY)) {
|
|
1030
|
-
|
|
1042
|
+
if (!empty($zkpY)) {
|
|
1043
|
+
$store = StoreManager::getStore();
|
|
1044
|
+
$isBanned = $store->has("banned-zkp-y:{$zkpY}");
|
|
1045
|
+
if ($isBanned) {
|
|
1046
|
+
$score = 100.0;
|
|
1047
|
+
}
|
|
1031
1048
|
}
|
|
1032
|
-
|
|
1033
|
-
$
|
|
1034
|
-
|
|
1049
|
+
|
|
1050
|
+
$rttProxyScore = \Anonympins\Fingerprint\Ja3AnomalyDetector::getRttProxyScore($context);
|
|
1051
|
+
$score = max($score, (float)$rttProxyScore);
|
|
1052
|
+
|
|
1053
|
+
return ['threatIntelScore' => $score];
|
|
1035
1054
|
}
|
|
1036
1055
|
|
|
1037
1056
|
/**
|
|
@@ -1237,6 +1256,36 @@ class RequestUtils
|
|
|
1237
1256
|
return null;
|
|
1238
1257
|
}
|
|
1239
1258
|
|
|
1259
|
+
/**
|
|
1260
|
+
* Calcule la longueur du préfixe commun (en bits) entre deux adresses IP (IPv4 ou IPv6).
|
|
1261
|
+
*/
|
|
1262
|
+
public static function getIpCommonPrefixLength(string $ip1, string $ip2): int
|
|
1263
|
+
{
|
|
1264
|
+
$bin1 = @inet_pton($ip1);
|
|
1265
|
+
$bin2 = @inet_pton($ip2);
|
|
1266
|
+
if ($bin1 === false || $bin2 === false || strlen($bin1) !== strlen($bin2)) {
|
|
1267
|
+
return 0;
|
|
1268
|
+
}
|
|
1269
|
+
$len = strlen($bin1);
|
|
1270
|
+
$bits = 0;
|
|
1271
|
+
for ($i = 0; $i < $len; $i++) {
|
|
1272
|
+
$xor = ord($bin1[$i]) ^ ord($bin2[$i]);
|
|
1273
|
+
if ($xor === 0) {
|
|
1274
|
+
$bits += 8;
|
|
1275
|
+
} else {
|
|
1276
|
+
for ($b = 7; $b >= 0; $b--) {
|
|
1277
|
+
if (($xor & (1 << $b)) === 0) {
|
|
1278
|
+
$bits++;
|
|
1279
|
+
} else {
|
|
1280
|
+
break;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
break;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return $bits;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1240
1289
|
/**
|
|
1241
1290
|
* Met à jour les métriques agrégées pour un sous-réseau IP.
|
|
1242
1291
|
* @param RequestContext $context
|
|
@@ -1256,7 +1305,8 @@ class RequestUtils
|
|
|
1256
1305
|
'highScoreDevices' => [],
|
|
1257
1306
|
'lastActivity' => 0,
|
|
1258
1307
|
'ips' => [],
|
|
1259
|
-
'uas' => []
|
|
1308
|
+
'uas' => [],
|
|
1309
|
+
'attackerIps' => []
|
|
1260
1310
|
];
|
|
1261
1311
|
|
|
1262
1312
|
if (!isset($subnetData['highScoreDevices'])) {
|
|
@@ -1268,6 +1318,9 @@ class RequestUtils
|
|
|
1268
1318
|
if (!isset($subnetData['uas'])) {
|
|
1269
1319
|
$subnetData['uas'] = [];
|
|
1270
1320
|
}
|
|
1321
|
+
if (!isset($subnetData['attackerIps']) || !is_array($subnetData['attackerIps'])) {
|
|
1322
|
+
$subnetData['attackerIps'] = [];
|
|
1323
|
+
}
|
|
1271
1324
|
$now = time();
|
|
1272
1325
|
self::decaySubnetData($subnetData, $now);
|
|
1273
1326
|
|
|
@@ -1290,6 +1343,28 @@ class RequestUtils
|
|
|
1290
1343
|
}
|
|
1291
1344
|
|
|
1292
1345
|
$userAgent = $context->getHeader('user-agent') ?? '';
|
|
1346
|
+
if ($finalScore >= 70.0) {
|
|
1347
|
+
$foundAttacker = false;
|
|
1348
|
+
foreach ($subnetData['attackerIps'] as &$att) {
|
|
1349
|
+
if (isset($att['ip']) && $att['ip'] === $context->clientIp) {
|
|
1350
|
+
$att['lastSeen'] = $now;
|
|
1351
|
+
$att['score'] = max((float)($att['score'] ?? 0.0), $finalScore);
|
|
1352
|
+
$foundAttacker = true;
|
|
1353
|
+
break;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
unset($att);
|
|
1357
|
+
if (!$foundAttacker) {
|
|
1358
|
+
$subnetData['attackerIps'][] = [
|
|
1359
|
+
'ip' => $context->clientIp,
|
|
1360
|
+
'lastSeen' => $now,
|
|
1361
|
+
'score' => $finalScore
|
|
1362
|
+
];
|
|
1363
|
+
if (count($subnetData['attackerIps']) > 30) {
|
|
1364
|
+
array_shift($subnetData['attackerIps']);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1293
1368
|
if (!empty($userAgent) && !in_array($userAgent, $subnetData['uas'], true)) {
|
|
1294
1369
|
$subnetData['uas'][] = $userAgent;
|
|
1295
1370
|
}
|
|
@@ -1319,9 +1394,10 @@ class RequestUtils
|
|
|
1319
1394
|
* Calcule un score de suspicion basé sur l'activité historique du sous-réseau IP.
|
|
1320
1395
|
* @param RequestContext $context
|
|
1321
1396
|
* @param string $currentDeviceId
|
|
1397
|
+
* @param array<string, mixed> $securityConfig
|
|
1322
1398
|
* @return array{'subnetScore': float}
|
|
1323
1399
|
*/
|
|
1324
|
-
public static function getSubnetScore(RequestContext $context, string $currentDeviceId): array
|
|
1400
|
+
public static function getSubnetScore(RequestContext $context, string $currentDeviceId = '', array $securityConfig = []): array
|
|
1325
1401
|
{
|
|
1326
1402
|
$subnet = self::getIpSubnet($context->clientIp);
|
|
1327
1403
|
if ($subnet === null) {
|
|
@@ -1355,24 +1431,72 @@ class RequestUtils
|
|
|
1355
1431
|
return ['subnetScore' => 0.0];
|
|
1356
1432
|
}
|
|
1357
1433
|
|
|
1358
|
-
|
|
1359
|
-
$
|
|
1434
|
+
// 1. Régularisation bayésienne continue de la densité
|
|
1435
|
+
$bayesianDensity = ((float)$highScoreCount + 0.5) / ((float)$deviceCount + 2.5);
|
|
1436
|
+
|
|
1437
|
+
// 2. Dispersion IP / Terminal (CGNAT vs Proxy Pool)
|
|
1438
|
+
$ipDispersion = min(2.0, (float)$ipCount / (float)$deviceCount);
|
|
1439
|
+
$ipMultiplier = 0.6 + 0.4 * tanh($ipDispersion);
|
|
1360
1440
|
|
|
1361
|
-
//
|
|
1362
|
-
$
|
|
1363
|
-
$uaMultiplier = 0.
|
|
1441
|
+
// 3. Dispersion User-Agent / Terminal
|
|
1442
|
+
$uaDispersion = min(3.0, (float)max(1, $uaCount) / (float)$deviceCount);
|
|
1443
|
+
$uaMultiplier = 0.7 + 0.3 * tanh($uaDispersion - 1.0);
|
|
1364
1444
|
|
|
1365
|
-
|
|
1445
|
+
// 4. Intensité brute globale de la menace
|
|
1446
|
+
$rawIntensity = (float)$highScoreCount * $bayesianDensity * $ipMultiplier * $uaMultiplier;
|
|
1366
1447
|
|
|
1367
|
-
|
|
1368
|
-
$
|
|
1448
|
+
// 5. Composante 1 : Score ambiant plafonné en zone Medium (asymptote au seuil medium)
|
|
1449
|
+
$mediumThreshold = (float)($securityConfig['thresholds']['medium'] ?? 45.0);
|
|
1450
|
+
$ambientAsymptote = $mediumThreshold;
|
|
1451
|
+
$maxProximityBoost = max(0.0, 100.0 - $ambientAsymptote);
|
|
1452
|
+
$scale = 10.0;
|
|
1453
|
+
$ambientScore = $ambientAsymptote * tanh($rawIntensity / $scale);
|
|
1369
1454
|
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1455
|
+
// 6. Composante 2 : Boost de proximité micro-réseau avec des attaquants récents (le reste va jusqu'à 100)
|
|
1456
|
+
$proximityBoost = 0.0;
|
|
1457
|
+
$attackerIps = $subnetData['attackerIps'] ?? [];
|
|
1458
|
+
$clientIp = $context->clientIp;
|
|
1459
|
+
|
|
1460
|
+
if (!empty($attackerIps) && !empty($clientIp)) {
|
|
1461
|
+
$maxCommonPrefix = 0;
|
|
1462
|
+
$isV4 = filter_var($clientIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
|
|
1463
|
+
$isV6 = filter_var($clientIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
|
1464
|
+
|
|
1465
|
+
foreach ($attackerIps as $att) {
|
|
1466
|
+
$attIp = $att['ip'] ?? '';
|
|
1467
|
+
$lastSeen = $att['lastSeen'] ?? $now;
|
|
1468
|
+
if ($attIp !== '' && ($now - $lastSeen) <= 1800) {
|
|
1469
|
+
$prefixLen = self::getIpCommonPrefixLength($clientIp, $attIp);
|
|
1470
|
+
if ($prefixLen > $maxCommonPrefix) {
|
|
1471
|
+
$maxCommonPrefix = $prefixLen;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
if ($isV4) {
|
|
1477
|
+
if ($maxCommonPrefix >= 32) {
|
|
1478
|
+
$proximityBoost = $maxProximityBoost; // Même adresse IP exacte
|
|
1479
|
+
} elseif ($maxCommonPrefix >= 30) {
|
|
1480
|
+
$proximityBoost = $maxProximityBoost * (45.0 / 55.0); // Même /30 (écart <= 3 adresses)
|
|
1481
|
+
} elseif ($maxCommonPrefix >= 28) {
|
|
1482
|
+
$proximityBoost = $maxProximityBoost * (30.0 / 55.0); // Même /28 (bloc de 16 adresses)
|
|
1483
|
+
} elseif ($maxCommonPrefix >= 26) {
|
|
1484
|
+
$proximityBoost = $maxProximityBoost * (15.0 / 55.0); // Même /26 (bloc de 64 adresses)
|
|
1485
|
+
}
|
|
1486
|
+
} elseif ($isV6) {
|
|
1487
|
+
if ($maxCommonPrefix >= 128) {
|
|
1488
|
+
$proximityBoost = $maxProximityBoost; // Même IPv6 exacte
|
|
1489
|
+
} elseif ($maxCommonPrefix >= 120) {
|
|
1490
|
+
$proximityBoost = $maxProximityBoost * (45.0 / 55.0); // Même /120
|
|
1491
|
+
} elseif ($maxCommonPrefix >= 112) {
|
|
1492
|
+
$proximityBoost = $maxProximityBoost * (30.0 / 55.0); // Même /112
|
|
1493
|
+
} elseif ($maxCommonPrefix >= 96) {
|
|
1494
|
+
$proximityBoost = $maxProximityBoost * (15.0 / 55.0); // Même /96
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1373
1497
|
}
|
|
1374
|
-
$finalScore = min(100.0, round($baseScore * $densityMultiplier * $distributionMultiplier * $uaMultiplier * $dampening * 10.0) / 10.0);
|
|
1375
1498
|
|
|
1499
|
+
$finalScore = min(100.0, round(($ambientScore + $proximityBoost) * 10.0) / 10.0);
|
|
1376
1500
|
return ['subnetScore' => $finalScore];
|
|
1377
1501
|
}
|
|
1378
1502
|
/**
|
|
@@ -1408,6 +1532,12 @@ class RequestUtils
|
|
|
1408
1532
|
}
|
|
1409
1533
|
}
|
|
1410
1534
|
|
|
1535
|
+
if (isset($subnetData['attackerIps']) && is_array($subnetData['attackerIps'])) {
|
|
1536
|
+
$subnetData['attackerIps'] = array_values(array_filter($subnetData['attackerIps'], function ($att) use ($now) {
|
|
1537
|
+
return ($now - ($att['lastSeen'] ?? $now)) < 3600;
|
|
1538
|
+
}));
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1411
1541
|
$subnetData['lastActivity'] = $now - ($inactivitySec % 1800);
|
|
1412
1542
|
}
|
|
1413
1543
|
}
|
|
@@ -2031,4 +2161,91 @@ class RequestUtils
|
|
|
2031
2161
|
|
|
2032
2162
|
return !empty($deviceId) && !empty($deviceHash); // Match d'identité matérielle stricte (deviceId + deviceHash validés par HMAC)
|
|
2033
2163
|
}
|
|
2164
|
+
|
|
2165
|
+
private static function imul(int $a, int $b): int
|
|
2166
|
+
{
|
|
2167
|
+
return ($a * $b) & 0xffffffff;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
public static function cyrb53(string $str, int $seed = 0): int
|
|
2171
|
+
{
|
|
2172
|
+
$h1 = (0xdeadbeef ^ $seed) & 0xffffffff;
|
|
2173
|
+
$h2 = (0x41c6ce57 ^ $seed) & 0xffffffff;
|
|
2174
|
+
for ($i = 0; $i < strlen($str); $i++) {
|
|
2175
|
+
$ch = ord($str[$i]);
|
|
2176
|
+
$h1 = self::imul($h1 ^ $ch, 2654435761);
|
|
2177
|
+
$h2 = self::imul($h2 ^ $ch, 1597334677);
|
|
2178
|
+
}
|
|
2179
|
+
$h1 = self::imul($h1 ^ ($h1 >> 16), 2246822507) ^ self::imul($h2 ^ ($h2 >> 13), 3266489909);
|
|
2180
|
+
$h2 = self::imul($h2 ^ ($h2 >> 16), 2246822507) ^ self::imul($h1 ^ ($h1 >> 13), 3266489909);
|
|
2181
|
+
$unsigned_h1 = $h1 & 0xffffffff;
|
|
2182
|
+
return 4294967296 * (2097151 & $h2) + $unsigned_h1;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
public static function getVirtualizationAnomalyScore(RequestContext $context): float
|
|
2186
|
+
{
|
|
2187
|
+
$clientFp = $context->headers['x-device-fingerprint'] ?? null;
|
|
2188
|
+
if (!$clientFp) {
|
|
2189
|
+
return 0.0;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
$fpMap = [];
|
|
2193
|
+
foreach (explode('|', $clientFp) as $part) {
|
|
2194
|
+
$pair = explode(':', $part, 2);
|
|
2195
|
+
if (count($pair) === 2) {
|
|
2196
|
+
$fpMap[$pair[0]] = $pair[1];
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
$score = 0.0;
|
|
2201
|
+
$clientGpuHash = $fpMap['gpu'] ?? null;
|
|
2202
|
+
if ($clientGpuHash) {
|
|
2203
|
+
$virtualGpus = [
|
|
2204
|
+
"Google SwiftShader", "SwiftShader",
|
|
2205
|
+
"Mesa llvmpipe", "llvmpipe", "Mesa Gallium",
|
|
2206
|
+
"Microsoft Basic Render Driver", "HeadlessChrome",
|
|
2207
|
+
"Intel(R) HD Graphics"
|
|
2208
|
+
];
|
|
2209
|
+
$virtualGpuHashes = [];
|
|
2210
|
+
foreach ($virtualGpus as $gpu) {
|
|
2211
|
+
$virtualGpuHashes[] = (string)self::cyrb53($gpu);
|
|
2212
|
+
}
|
|
2213
|
+
if (in_array($clientGpuHash, $virtualGpuHashes, true)) {
|
|
2214
|
+
$score += 75.0;
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
$clientScreenHash = $fpMap['scr'] ?? null;
|
|
2219
|
+
if ($clientScreenHash) {
|
|
2220
|
+
$headlessResolutions = ["800x600_24", "1024x768_24"];
|
|
2221
|
+
$headlessHashes = [];
|
|
2222
|
+
foreach ($headlessResolutions as $res) {
|
|
2223
|
+
$headlessHashes[] = (string)self::cyrb53($res);
|
|
2224
|
+
}
|
|
2225
|
+
if (in_array($clientScreenHash, $headlessHashes, true)) {
|
|
2226
|
+
$score += 25.0;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
return min(100.0, $score);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
/**
|
|
2234
|
+
* Calcule un score d'incohérence analogique et lisse plafonnant à 99.9 max.
|
|
2235
|
+
*/
|
|
2236
|
+
public static function calculateAnalogInconsistencyScore(float $consistencyScore, float $inflectionPoint = 0.72, float $steepness = 12.0): float
|
|
2237
|
+
{
|
|
2238
|
+
$s = max(0.0, min(1.0, $consistencyScore));
|
|
2239
|
+
if ($s >= 0.98) {
|
|
2240
|
+
return 0.0;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
$asymptote = 99.9;
|
|
2244
|
+
$raw = 1.0 / (1.0 + exp($steepness * ($s - $inflectionPoint)));
|
|
2245
|
+
$minVal = 1.0 / (1.0 + exp($steepness * (1.0 - $inflectionPoint)));
|
|
2246
|
+
$maxVal = 1.0 / (1.0 + exp($steepness * (0.0 - $inflectionPoint)));
|
|
2247
|
+
$normalized = (($raw - $minVal) / ($maxVal - $minVal)) * $asymptote;
|
|
2248
|
+
|
|
2249
|
+
return min($asymptote, round($normalized, 1));
|
|
2250
|
+
}
|
|
2034
2251
|
}
|