@anonympins/fingerprint 0.7.4 → 0.8.0
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 +59 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/public/anonympins-bot-mitigation-pow.zip +0 -0
- package/src/js/dynamic-wasm.js +218 -218
- package/src/js/fingerprint.client.js +1784 -1738
- package/src/js/fingerprint.client.obfuscated.js +1 -1
- package/src/js/fingerprint.js +489 -99
- package/src/js/fingerprint.utils.js +252 -2
- package/src/js/pow.worker.js +2 -2
- package/src/js/tests/cross_parity.test.js +3 -3
- package/src/js/tests/dns-circuit-breaker.test.js +6 -6
- package/src/js/tests/dynamic-wasm.test.js +77 -77
- package/src/js/tests/fingerprint.builder.test.js +76 -78
- package/src/js/tests/fingerprint.client.init.test.js +78 -0
- package/src/js/tests/fingerprint.client.test.js +16 -17
- package/src/js/tests/fingerprint.test.js +37 -12
- package/src/js/tests/ja3AnomalyDetector.test.js +135 -136
- package/src/js/tests/library.test.js +94 -95
- package/src/js/tests/obfuscation.test.js +8 -8
- package/src/js/tests/quicFingerprint.test.js +47 -0
- package/src/js/tests/tcpFingerprint.test.js +4 -4
- package/src/js/tests/threatIntel.test.js +40 -1
- package/src/js/upow-model-task.js +155 -112
- package/src/php/Challenge/ChallengeUtils.php +392 -86
- package/src/php/Config/SecurityProfiles.php +79 -61
- package/src/php/DirectFingerprint.php +55 -8
- package/src/php/FingerprintBuilder.php +13 -13
- package/src/php/FingerprintClient.php +6 -6
- package/src/php/FingerprintEngine.php +2122 -1690
- package/src/php/Ja3AnomalyDetector.php +22 -22
- package/src/php/Optimization/FunctionRegistry.php +4 -4
- package/src/php/Optimization/Optimization.php +6 -6
- package/src/php/RequestContext.php +11 -11
- package/src/php/Store/InMemoryStore.php +96 -66
- package/src/php/Store/MongoDbStore.php +2 -2
- package/src/php/Store/RedisStore.php +1 -1
- package/src/php/Store/StoreManager.php +48 -35
- package/src/php/Tests/ChallengeUtilsTest.php +31 -0
- package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
- package/src/php/Tests/FingerprintClientTest.php +70 -70
- package/src/php/Tests/PowTest.php +37 -39
- package/src/php/Tests/QuicFingerprintTest.php +115 -53
- package/src/php/Tests/ThreatIntelTest.php +34 -0
- package/src/php/Utils/BigInt.php +202 -202
- package/src/php/Utils/BlockList.php +13 -13
- package/src/php/Utils/Logger.php +19 -1
- package/src/php/Utils/MaliciousPatterns.php +6 -6
- package/src/php/Utils/MetricsManager.php +209 -209
- package/src/php/Utils/RequestUtils.php +362 -1
- package/src/php/Utils/TLSClientHelloParser.php +385 -369
- package/src/php/WordPress/WpDbStore.php +7 -7
- package/src/php/WordPress/anonympins-bot-mitigation-pow.php +1121 -0
- package/src/php/WordPress/fingerprint-anti-bot.php +0 -559
- package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.mo +0 -0
- package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.po +232 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.mo +0 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +297 -261
- package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +348 -267
- package/src/php/WordPress/package.php +80 -101
- package/src/php/WordPress/readme.txt +21 -5
- package/src/php/bin/auto-tune.php +1 -1
- package/public/fingerprint-anti-bot.zip +0 -0
|
@@ -1,972 +1,1211 @@
|
|
|
1
1
|
<?php
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
private static function recordDnsSuccess(): void
|
|
44
|
-
{
|
|
45
|
-
self::$dnsCircuitBreaker['failureCount'] = 0;
|
|
46
|
-
self::$dnsCircuitBreaker['state'] = 'CLOSED';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
private static function recordDnsFailure(): void
|
|
50
|
-
{
|
|
51
|
-
self::$dnsCircuitBreaker['failureCount']++;
|
|
52
|
-
if (self::$dnsCircuitBreaker['failureCount'] >= self::$dnsCircuitBreaker['threshold']) {
|
|
53
|
-
self::$dnsCircuitBreaker['state'] = 'OPEN';
|
|
54
|
-
self::$dnsCircuitBreaker['lastStateChange'] = microtime(true);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
private static function canAttemptDns(): bool
|
|
59
|
-
{
|
|
60
|
-
if (self::$dnsCircuitBreaker['state'] === 'CLOSED') {
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
63
|
-
if (self::$dnsCircuitBreaker['state'] === 'OPEN') {
|
|
64
|
-
if (microtime(true) - self::$dnsCircuitBreaker['lastStateChange'] >= self::$dnsCircuitBreaker['cooldown']) {
|
|
65
|
-
self::$dnsCircuitBreaker['state'] = 'HALF-OPEN';
|
|
66
|
-
return true;
|
|
67
|
-
}
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
return true; // HALF-OPEN
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
private static function getReverseDnsName(string $ip): ?string
|
|
74
|
-
{
|
|
75
|
-
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
76
|
-
return implode('.', array_reverse(explode('.', $ip))) . '.in-addr.arpa';
|
|
77
|
-
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
|
78
|
-
$hex = bin2hex((string)inet_pton($ip));
|
|
79
|
-
return implode('.', array_reverse(str_split($hex))) . '.ip6.arpa';
|
|
80
|
-
}
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
private static function resolveDnsNative(string $query, string $type): ?array
|
|
85
|
-
{
|
|
86
|
-
$typeMap = [
|
|
87
|
-
'PTR' => DNS_PTR,
|
|
88
|
-
'A' => DNS_A,
|
|
89
|
-
'AAAA' => DNS_AAAA
|
|
90
|
-
];
|
|
91
|
-
$dnsType = $typeMap[$type] ?? null;
|
|
92
|
-
if ($dnsType === null) {
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
$records = @dns_get_record($query, $dnsType);
|
|
97
|
-
if ($records === false) {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
$results = [];
|
|
102
|
-
foreach ($records as $record) {
|
|
103
|
-
if ($type === 'PTR' && isset($record['target'])) {
|
|
104
|
-
$results[] = $record['target'];
|
|
105
|
-
} elseif ($type === 'A' && isset($record['ip'])) {
|
|
106
|
-
$results[] = $record['ip'];
|
|
107
|
-
} elseif ($type === 'AAAA' && isset($record['ipv6'])) {
|
|
108
|
-
$results[] = $record['ipv6'];
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return $results;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
public function __construct(array $securityConfig)
|
|
115
|
-
{
|
|
116
|
-
$this->isProduction = Env::get('APP_ENV') === 'production';
|
|
117
|
-
|
|
118
|
-
// Dynamically bind Ed25519 keys if passed via config
|
|
119
|
-
if (isset($securityConfig['ed25519_private_key'])) {
|
|
120
|
-
Env::set('ED25519_PRIVATE_KEY', $securityConfig['ed25519_private_key']);
|
|
121
|
-
}
|
|
122
|
-
if (isset($securityConfig['ed25519_public_key'])) {
|
|
123
|
-
Env::set('ED25519_PUBLIC_KEY', $securityConfig['ed25519_public_key']);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Génération et persistance automatique de la paire de clés Ed25519
|
|
127
|
-
$useAsymmetric = ($securityConfig['useAsymmetricTickets'] ?? false) === true || ($securityConfig['ed25519'] ?? '') === 'auto';
|
|
128
|
-
$envPrivate = Env::get('ED25519_PRIVATE_KEY');
|
|
129
|
-
if ($useAsymmetric && ($envPrivate === null || $envPrivate === '')) {
|
|
130
|
-
if (function_exists('get_option') && function_exists('update_option')) {
|
|
131
|
-
// In WordPress, persist cryptographic keys in the database options table
|
|
132
|
-
$storedKeys = get_option('fingerprint_ed25519_keys');
|
|
133
|
-
if (is_array($storedKeys) && isset($storedKeys['privateKey'], $storedKeys['publicKey'])) {
|
|
134
|
-
Env::set('ED25519_PRIVATE_KEY', $storedKeys['privateKey']);
|
|
135
|
-
Env::set('ED25519_PUBLIC_KEY', $storedKeys['publicKey']);
|
|
136
|
-
} elseif (defined('OPENSSL_KEYTYPE_ED25519')) {
|
|
137
|
-
$pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
|
|
138
|
-
if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
|
|
139
|
-
$details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
|
|
140
|
-
$publicKeyPem = $details['key'] ?? ''; // @phpstan-ignore-line
|
|
141
|
-
Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
|
|
142
|
-
Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
|
|
143
|
-
update_option('fingerprint_ed25519_keys', [
|
|
144
|
-
'privateKey' => $privateKeyPem,
|
|
145
|
-
'publicKey' => $publicKeyPem,
|
|
146
|
-
], false);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
} else {
|
|
150
|
-
self::initStandaloneEd25519Keys();
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Auto-load optimized config if autotuning savePath is specified
|
|
155
|
-
if (isset($securityConfig['autotuning']['savePath'])) {
|
|
156
|
-
$savePath = $securityConfig['autotuning']['savePath'];
|
|
157
|
-
if (file_exists($savePath)) {
|
|
158
|
-
try {
|
|
159
|
-
$savedConfig = json_decode(file_get_contents($savePath), true);
|
|
160
|
-
if (json_last_error() === JSON_ERROR_NONE && is_array($savedConfig)) {
|
|
161
|
-
$securityConfig = SecurityProfiles::deepMerge($securityConfig, $savedConfig);
|
|
162
|
-
}
|
|
163
|
-
} catch (\Throwable $e) {
|
|
164
|
-
self::logError("[FingerprintEngine] Failed to auto-load optimized config from {$savePath}: " . $e->getMessage());
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Si aucune liste blanche n'est explicitement fournie, appliquer la liste par défaut.
|
|
170
|
-
if (!isset($securityConfig['whitelist'])) {
|
|
171
|
-
$securityConfig['whitelist'] = self::default_whitelist();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
$this->securityConfig = $securityConfig;
|
|
175
|
-
$this->verbose = $securityConfig['verbose'] ?? false;
|
|
176
|
-
$this->allowlist = $this->buildAllowlist();
|
|
177
|
-
$this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
|
|
178
|
-
$this->dryRun = $securityConfig['dryRun'] ?? false;
|
|
179
|
-
$this->validateConfig($securityConfig);
|
|
180
|
-
if ($this->securityConfig['reset'] ?? false) {
|
|
181
|
-
$this->resetStore();
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Initialise et persiste les cles Ed25519 pour les environnements PHP hors WordPress.
|
|
187
|
-
*/
|
|
188
|
-
private static function initStandaloneEd25519Keys(): void
|
|
189
|
-
{
|
|
190
|
-
$configDir = dirname(__DIR__, 1) . '/config';
|
|
191
|
-
$persistentKeyPath = $configDir . '/ed25519_key.json';
|
|
192
|
-
|
|
193
|
-
if (file_exists($persistentKeyPath)) {
|
|
194
|
-
try {
|
|
195
|
-
$keys = json_decode((string)file_get_contents($persistentKeyPath), true);
|
|
196
|
-
if (isset($keys['privateKey'], $keys['publicKey'])) {
|
|
197
|
-
Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
|
|
198
|
-
Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
|
|
199
|
-
}
|
|
200
|
-
} catch (\Throwable $e) {
|
|
201
|
-
self::logError('[Fingerprint] Failed to load persistent Ed25519 keys: ' . $e->getMessage());
|
|
202
|
-
}
|
|
203
|
-
} else {
|
|
204
|
-
try {
|
|
205
|
-
if (defined('OPENSSL_KEYTYPE_ED25519')) {
|
|
206
|
-
$pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
|
|
207
|
-
if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
|
|
208
|
-
$details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
|
|
209
|
-
$publicKeyPem = $details['key'] ?? ''; // @phpstan-ignore-line
|
|
210
|
-
Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
|
|
211
|
-
Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
|
|
212
|
-
if (!is_dir($configDir)) {
|
|
213
|
-
mkdir($configDir, 0777, true);
|
|
214
|
-
}
|
|
215
|
-
// phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.PluginDirectoryWrite -- Non-WordPress standalone environment fallback
|
|
216
|
-
file_put_contents($persistentKeyPath, json_encode([
|
|
217
|
-
'privateKey' => $privateKeyPem,
|
|
218
|
-
'publicKey' => $publicKeyPem
|
|
219
|
-
], JSON_PRETTY_PRINT));
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
} catch (\Throwable $e) {
|
|
223
|
-
self::logError('[Fingerprint] Native Ed25519 key generation failed: ' . $e->getMessage());
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Anonympins\Fingerprint;
|
|
6
|
+
|
|
7
|
+
use Anonympins\Fingerprint\Challenge\ChallengeUtils;
|
|
8
|
+
use Anonympins\Fingerprint\Config\SecurityProfiles;
|
|
9
|
+
use Anonympins\Fingerprint\Store\StoreManager;
|
|
10
|
+
use Anonympins\Fingerprint\Utils\BlockList;
|
|
11
|
+
use Anonympins\Fingerprint\Utils\Logger;
|
|
12
|
+
use Anonympins\Fingerprint\Utils\MetricsManager;
|
|
13
|
+
use Anonympins\Fingerprint\Utils\RequestUtils;
|
|
14
|
+
use Anonympins\Fingerprint\Utils\Env;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The main engine of the fingerprinting library.
|
|
18
|
+
* Orchestrates identification, suspicion calculation, and challenge management.
|
|
19
|
+
*/
|
|
20
|
+
class FingerprintEngine
|
|
21
|
+
{
|
|
22
|
+
private array $securityConfig;
|
|
23
|
+
private bool $isProduction;
|
|
24
|
+
private BlockList $allowlist;
|
|
25
|
+
private bool $verbose;
|
|
26
|
+
private ?Logger $logger = null;
|
|
27
|
+
private bool $dryRun;
|
|
28
|
+
|
|
29
|
+
private static ?array $googlebotEntries = null;
|
|
30
|
+
private static ?array $yandexEntries = null;
|
|
31
|
+
private static ?array $bingbotEntries = null;
|
|
32
|
+
private static ?array $facebookEntries = null;
|
|
33
|
+
|
|
34
|
+
private static array $dnsCircuitBreaker = [
|
|
35
|
+
'state' => 'CLOSED',
|
|
36
|
+
'failureCount' => 0,
|
|
37
|
+
'lastStateChange' => 0,
|
|
38
|
+
'threshold' => 5,
|
|
39
|
+
'cooldown' => 30, // seconds
|
|
40
|
+
];
|
|
227
41
|
|
|
228
42
|
/**
|
|
229
|
-
*
|
|
230
|
-
* sans nécessiter de redémarrage.
|
|
43
|
+
* Records a successful DNS lookup, resetting the circuit breaker to CLOSED.
|
|
231
44
|
*
|
|
232
|
-
* @
|
|
45
|
+
* @return void
|
|
46
|
+
*/
|
|
47
|
+
private static function recordDnsSuccess(): void
|
|
48
|
+
{
|
|
49
|
+
self::$dnsCircuitBreaker['failureCount'] = 0;
|
|
50
|
+
self::$dnsCircuitBreaker['state'] = 'CLOSED';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Records a DNS lookup failure. If the failure count reaches the threshold,
|
|
55
|
+
* the circuit breaker transitions to the OPEN state.
|
|
56
|
+
*
|
|
57
|
+
* @return void
|
|
58
|
+
*/
|
|
59
|
+
private static function recordDnsFailure(): void
|
|
60
|
+
{
|
|
61
|
+
self::$dnsCircuitBreaker['failureCount']++;
|
|
62
|
+
if (self::$dnsCircuitBreaker['failureCount'] >= self::$dnsCircuitBreaker['threshold']) {
|
|
63
|
+
self::$dnsCircuitBreaker['state'] = 'OPEN';
|
|
64
|
+
self::$dnsCircuitBreaker['lastStateChange'] = microtime(true);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Determines whether a DNS attempt is allowed based on the circuit breaker state.
|
|
70
|
+
* In OPEN state, allows a retry only after the cooldown has elapsed (HALF-OPEN).
|
|
71
|
+
*
|
|
72
|
+
* @return bool True if a DNS attempt may be made, false otherwise.
|
|
73
|
+
*/
|
|
74
|
+
private static function canAttemptDns(): bool
|
|
75
|
+
{
|
|
76
|
+
if (self::$dnsCircuitBreaker['state'] === 'CLOSED') {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (self::$dnsCircuitBreaker['state'] === 'OPEN') {
|
|
80
|
+
if (microtime(true) - self::$dnsCircuitBreaker['lastStateChange'] >= self::$dnsCircuitBreaker['cooldown']) {
|
|
81
|
+
self::$dnsCircuitBreaker['state'] = 'HALF-OPEN';
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return true; // HALF-OPEN
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Builds the reverse DNS query name for a given IP address
|
|
91
|
+
* (in-addr.arpa for IPv4, ip6.arpa for IPv6).
|
|
92
|
+
*
|
|
93
|
+
* @param string $ip The IP address.
|
|
94
|
+
* @return string|null The reverse DNS name, or null if the IP is invalid.
|
|
95
|
+
*/
|
|
96
|
+
private static function getReverseDnsName(string $ip): ?string
|
|
97
|
+
{
|
|
98
|
+
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
99
|
+
return implode('.', array_reverse(explode('.', $ip))) . '.in-addr.arpa';
|
|
100
|
+
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
|
101
|
+
$hex = bin2hex((string)inet_pton($ip));
|
|
102
|
+
return implode('.', array_reverse(str_split($hex))) . '.ip6.arpa';
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolves a DNS query using PHP's native `dns_get_record` function.
|
|
109
|
+
* Supports PTR, A, and AAAA record types.
|
|
110
|
+
*
|
|
111
|
+
* @param string $query The DNS query name.
|
|
112
|
+
* @param string $type The record type ('PTR', 'A', or 'AAAA').
|
|
113
|
+
* @return array|null The list of resolved records, or null on failure.
|
|
114
|
+
*/
|
|
115
|
+
private static function resolveDnsNative(string $query, string $type): ?array
|
|
116
|
+
{
|
|
117
|
+
$typeMap = [
|
|
118
|
+
'PTR' => DNS_PTR,
|
|
119
|
+
'A' => DNS_A,
|
|
120
|
+
'AAAA' => DNS_AAAA
|
|
121
|
+
];
|
|
122
|
+
$dnsType = $typeMap[$type] ?? null;
|
|
123
|
+
if ($dnsType === null) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
$records = @dns_get_record($query, $dnsType);
|
|
128
|
+
if ($records === false) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
$results = [];
|
|
133
|
+
foreach ($records as $record) {
|
|
134
|
+
if ($type === 'PTR' && isset($record['target'])) {
|
|
135
|
+
$results[] = $record['target'];
|
|
136
|
+
} elseif ($type === 'A' && isset($record['ip'])) {
|
|
137
|
+
$results[] = $record['ip'];
|
|
138
|
+
} elseif ($type === 'AAAA' && isset($record['ipv6'])) {
|
|
139
|
+
$results[] = $record['ipv6'];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return $results;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Creates a new FingerprintEngine with the given security configuration.
|
|
147
|
+
* Handles Ed25519 key binding/generation, autotuning config loading,
|
|
148
|
+
* default whitelist application, config validation, and optional store reset.
|
|
149
|
+
*
|
|
150
|
+
* @param array $securityConfig The security configuration.
|
|
151
|
+
*/
|
|
152
|
+
public function __construct(array $securityConfig)
|
|
153
|
+
{
|
|
154
|
+
$this->isProduction = Env::get('APP_ENV') === 'production';
|
|
155
|
+
|
|
156
|
+
// Dynamically bind Ed25519 keys if passed via config
|
|
157
|
+
if (isset($securityConfig['ed25519_private_key'])) {
|
|
158
|
+
Env::set('ED25519_PRIVATE_KEY', $securityConfig['ed25519_private_key']);
|
|
159
|
+
}
|
|
160
|
+
if (isset($securityConfig['ed25519_public_key'])) {
|
|
161
|
+
Env::set('ED25519_PUBLIC_KEY', $securityConfig['ed25519_public_key']);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Automatic generation and persistence of the Ed25519 key pair
|
|
165
|
+
$useAsymmetric = ($securityConfig['useAsymmetricTickets'] ?? false) === true || ($securityConfig['ed25519'] ?? '') === 'auto';
|
|
166
|
+
$envPrivate = Env::get('ED25519_PRIVATE_KEY');
|
|
167
|
+
if ($useAsymmetric && ($envPrivate === null || $envPrivate === '')) {
|
|
168
|
+
if (function_exists('get_option') && function_exists('update_option')) {
|
|
169
|
+
// In WordPress, persist cryptographic keys in the database options table
|
|
170
|
+
$storedKeys = get_option('fingerprint_ed25519_keys');
|
|
171
|
+
if (is_array($storedKeys) && isset($storedKeys['privateKey'], $storedKeys['publicKey'])) {
|
|
172
|
+
Env::set('ED25519_PRIVATE_KEY', $storedKeys['privateKey']);
|
|
173
|
+
Env::set('ED25519_PUBLIC_KEY', $storedKeys['publicKey']);
|
|
174
|
+
} elseif (defined('OPENSSL_KEYTYPE_ED25519')) {
|
|
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);
|
|
181
|
+
update_option('fingerprint_ed25519_keys', [
|
|
182
|
+
'privateKey' => $privateKeyPem,
|
|
183
|
+
'publicKey' => $publicKeyPem,
|
|
184
|
+
], false);
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
self::initStandaloneEd25519Keys();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Auto-load optimized config if autotuning savePath is specified
|
|
192
|
+
if (isset($securityConfig['autotuning']['savePath'])) {
|
|
193
|
+
$savePath = $securityConfig['autotuning']['savePath'];
|
|
194
|
+
if (file_exists($savePath)) {
|
|
195
|
+
try {
|
|
196
|
+
$savedConfig = json_decode(file_get_contents($savePath), true);
|
|
197
|
+
if (json_last_error() === JSON_ERROR_NONE && is_array($savedConfig)) {
|
|
198
|
+
$securityConfig = SecurityProfiles::deepMerge($securityConfig, $savedConfig);
|
|
199
|
+
}
|
|
200
|
+
} catch (\Throwable $e) {
|
|
201
|
+
self::logError("[FingerprintEngine] Failed to auto-load optimized config from {$savePath}: " . $e->getMessage());
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// If no allowlist is explicitly provided, apply the default list.
|
|
207
|
+
if (!isset($securityConfig['whitelist'])) {
|
|
208
|
+
$securityConfig['whitelist'] = self::default_whitelist();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
$this->securityConfig = $securityConfig;
|
|
212
|
+
$this->verbose = $securityConfig['verbose'] ?? false;
|
|
213
|
+
$this->allowlist = $this->buildAllowlist();
|
|
214
|
+
$this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
|
|
215
|
+
$this->dryRun = $securityConfig['dryRun'] ?? false;
|
|
216
|
+
$this->validateConfig($securityConfig);
|
|
217
|
+
if ($this->securityConfig['reset'] ?? false) {
|
|
218
|
+
$this->resetStore();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Initializes and persists Ed25519 keys for standalone PHP environments outside WordPress.
|
|
224
|
+
* Loads from a persistent JSON file if it exists, otherwise generates a new key pair.
|
|
225
|
+
*
|
|
226
|
+
* @return void
|
|
227
|
+
*/
|
|
228
|
+
private static function initStandaloneEd25519Keys(): void
|
|
229
|
+
{
|
|
230
|
+
$configDir = dirname(__DIR__, 1) . '/config';
|
|
231
|
+
$persistentKeyPath = $configDir . '/ed25519_key.json';
|
|
232
|
+
|
|
233
|
+
if (file_exists($persistentKeyPath)) {
|
|
234
|
+
try {
|
|
235
|
+
$keys = json_decode((string)file_get_contents($persistentKeyPath), true);
|
|
236
|
+
if (isset($keys['privateKey'], $keys['publicKey'])) {
|
|
237
|
+
Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
|
|
238
|
+
Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
|
|
239
|
+
}
|
|
240
|
+
} catch (\Throwable $e) {
|
|
241
|
+
self::logError('[Fingerprint] Failed to load persistent Ed25519 keys: ' . $e->getMessage());
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
try {
|
|
245
|
+
if (defined('OPENSSL_KEYTYPE_ED25519')) {
|
|
246
|
+
$pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
|
|
247
|
+
if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
|
|
248
|
+
$details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
|
|
249
|
+
$publicKeyPem = $details['key'] ?? ''; // @phpstan-ignore-line
|
|
250
|
+
Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
|
|
251
|
+
Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
|
|
252
|
+
if (!is_dir($configDir)) {
|
|
253
|
+
if (function_exists('wp_mkdir_p')) {
|
|
254
|
+
wp_mkdir_p($configDir);
|
|
255
|
+
} else {
|
|
256
|
+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Non-WordPress standalone environment fallback
|
|
257
|
+
mkdir($configDir, 0777, true);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.PluginDirectoryWrite -- Non-WordPress standalone environment fallback
|
|
261
|
+
file_put_contents($persistentKeyPath, json_encode([
|
|
262
|
+
'privateKey' => $privateKeyPem,
|
|
263
|
+
'publicKey' => $publicKeyPem
|
|
264
|
+
], JSON_PRETTY_PRINT));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} catch (\Throwable $e) {
|
|
268
|
+
self::logError('[Fingerprint] Native Ed25519 key generation failed: ' . $e->getMessage());
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Hot-reloads a new security configuration (weights, thresholds, etc.)
|
|
275
|
+
* without requiring a restart.
|
|
276
|
+
*
|
|
277
|
+
* @param array $newConfig The new configuration (partial or complete).
|
|
233
278
|
*/
|
|
234
279
|
public function updateConfig(array $newConfig): void
|
|
235
280
|
{
|
|
236
|
-
$this->validateConfig($newConfig);
|
|
237
|
-
$this->securityConfig = SecurityProfiles::deepMerge($this->securityConfig, $newConfig);
|
|
238
|
-
$this->log('Configuration mise à jour à chaud (Hot-Reloaded)', $this->securityConfig);
|
|
281
|
+
$this->validateConfig($newConfig);
|
|
282
|
+
$this->securityConfig = SecurityProfiles::deepMerge($this->securityConfig, $newConfig);
|
|
283
|
+
$this->log('Configuration mise à jour à chaud (Hot-Reloaded)', $this->securityConfig);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Resets the active persistence store.
|
|
288
|
+
*/
|
|
289
|
+
public function resetStore(): void
|
|
290
|
+
{
|
|
291
|
+
$store = StoreManager::getStore();
|
|
292
|
+
if (method_exists($store, 'clear')) {
|
|
293
|
+
$store->clear();
|
|
294
|
+
}
|
|
295
|
+
$this->log('Store has been reset/cleared.');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Validates a security configuration. Warns about missing or unknown keys
|
|
300
|
+
* (e.g. weights, thresholds, typos).
|
|
301
|
+
*
|
|
302
|
+
* @param array $config The configuration to validate.
|
|
303
|
+
* @return void
|
|
304
|
+
*/
|
|
305
|
+
private function validateConfig(array $config): void
|
|
306
|
+
{
|
|
307
|
+
if (empty($config)) {
|
|
308
|
+
$this->log('Warning: No securityConfig provided. Using default behaviors.', [], 'warn');
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
$knownKeys = [
|
|
313
|
+
'weights', 'thresholds', 'cpu', 'ticketMaxAge', 'challengeTtl',
|
|
314
|
+
'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
|
|
315
|
+
'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
|
|
316
|
+
'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
|
|
317
|
+
'similarityThreshold', 'summary', 'description',
|
|
318
|
+
'ed25519_private_key', 'ed25519_public_key',
|
|
319
|
+
'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret', 'reset',
|
|
320
|
+
'filterWhitelist',
|
|
321
|
+
'differentialPrivacy', 'dpEpsilon'
|
|
322
|
+
,'pat'
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
if (empty($config['weights'])) {
|
|
326
|
+
$this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
|
|
327
|
+
}
|
|
328
|
+
if (empty($config['thresholds'])) {
|
|
329
|
+
$this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
foreach (array_keys($config) as $key) {
|
|
333
|
+
if (!in_array($key, $knownKeys, true)) {
|
|
334
|
+
$this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Loads a bot whitelist from a JSON file in the config directory.
|
|
341
|
+
* Falls back to the provided default entries if the file is missing or invalid.
|
|
342
|
+
*
|
|
343
|
+
* @param string $filename The whitelist JSON filename.
|
|
344
|
+
* @param array $fallbackEntries The fallback entries if loading fails.
|
|
345
|
+
* @return array The loaded or fallback whitelist entries.
|
|
346
|
+
*/
|
|
347
|
+
private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
|
|
348
|
+
{
|
|
349
|
+
$configDir = dirname(__DIR__, 2) . '/config';
|
|
350
|
+
$filePath = $configDir . '/' . $filename;
|
|
351
|
+
if (file_exists($filePath)) {
|
|
352
|
+
try {
|
|
353
|
+
$content = file_get_contents($filePath);
|
|
354
|
+
if ($content !== false) {
|
|
355
|
+
$decoded = json_decode($content, true);
|
|
356
|
+
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
|
357
|
+
return $decoded;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} catch (\Throwable $e) {
|
|
361
|
+
self::logError("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return $fallbackEntries;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Returns the Googlebot allowlist definition (type + entries).
|
|
369
|
+
* The entries are lazily loaded and cached in a static property.
|
|
370
|
+
*
|
|
371
|
+
* @return array The Googlebot allowlist rule.
|
|
372
|
+
*/
|
|
373
|
+
public static function googlebot_whitelist(): array
|
|
374
|
+
{
|
|
375
|
+
if (self::$googlebotEntries === null) {
|
|
376
|
+
self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
|
|
377
|
+
"2001:4860:4801:10::/64",
|
|
378
|
+
"2001:4860:4801:11::/64",
|
|
379
|
+
"2001:4860:4801:12::/64",
|
|
380
|
+
"66.249.79.64"
|
|
381
|
+
]);
|
|
382
|
+
}
|
|
383
|
+
return [
|
|
384
|
+
'type' => 'allowlist',
|
|
385
|
+
'entries' => self::$googlebotEntries
|
|
386
|
+
];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Returns the Yandex allowlist definition (type + entries).
|
|
391
|
+
* The entries are lazily loaded and cached in a static property.
|
|
392
|
+
*
|
|
393
|
+
* @return array The Yandex allowlist rule.
|
|
394
|
+
*/
|
|
395
|
+
public static function yandex_whitelist(): array
|
|
396
|
+
{
|
|
397
|
+
if (self::$yandexEntries === null) {
|
|
398
|
+
self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
|
|
399
|
+
"2a02:6b8::/29",
|
|
400
|
+
"5.45.192.0/18",
|
|
401
|
+
"213.180.192.0/19"
|
|
402
|
+
]);
|
|
403
|
+
}
|
|
404
|
+
return [
|
|
405
|
+
'type' => 'allowlist',
|
|
406
|
+
'entries' => self::$yandexEntries
|
|
407
|
+
];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Returns the Bingbot allowlist definition (type + entries).
|
|
412
|
+
* The entries are lazily loaded and cached in a static property.
|
|
413
|
+
*
|
|
414
|
+
* @return array The Bingbot allowlist rule.
|
|
415
|
+
*/
|
|
416
|
+
public static function bingbot_whitelist(): array
|
|
417
|
+
{
|
|
418
|
+
if (self::$bingbotEntries === null) {
|
|
419
|
+
self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
|
|
420
|
+
"157.55.39.0/24",
|
|
421
|
+
"207.46.13.0/24",
|
|
422
|
+
"40.77.178.0/23"
|
|
423
|
+
]);
|
|
424
|
+
}
|
|
425
|
+
return [
|
|
426
|
+
'type' => 'allowlist',
|
|
427
|
+
'entries' => self::$bingbotEntries
|
|
428
|
+
];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Returns the Facebook allowlist definition (type + entries).
|
|
433
|
+
* The entries are lazily loaded and cached in a static property.
|
|
434
|
+
*
|
|
435
|
+
* @return array The Facebook allowlist rule.
|
|
436
|
+
*/
|
|
437
|
+
public static function facebook_whitelist(): array
|
|
438
|
+
{
|
|
439
|
+
if (self::$facebookEntries === null) {
|
|
440
|
+
self::$facebookEntries = self::loadBotWhitelist('facebook.json', [
|
|
441
|
+
"31.13.64.0/18", "66.220.144.0/20", "69.63.176.0/20", "157.240.0.0/16"
|
|
442
|
+
]);
|
|
443
|
+
}
|
|
444
|
+
return [
|
|
445
|
+
'type' => 'allowlist',
|
|
446
|
+
'entries' => self::$facebookEntries
|
|
447
|
+
];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Returns the complete default whitelist, combining IP allowlists for major
|
|
452
|
+
* bots (Googlebot, Bingbot, Yandex, Facebook) with hostname-suffix rules
|
|
453
|
+
* for many known crawlers.
|
|
454
|
+
*
|
|
455
|
+
* @return array The default whitelist rules.
|
|
456
|
+
*/
|
|
457
|
+
public static function default_whitelist(): array
|
|
458
|
+
{
|
|
459
|
+
return [
|
|
460
|
+
self::googlebot_whitelist(),
|
|
461
|
+
self::bingbot_whitelist(),
|
|
462
|
+
self::yandex_whitelist(),
|
|
463
|
+
self::facebook_whitelist(),
|
|
464
|
+
['userAgent' => 'Googlebot', 'hostnameSuffix' => '.googlebot.com'],
|
|
465
|
+
['userAgent' => 'Google-Extended', 'hostnameSuffix' => '.google.com'],
|
|
466
|
+
['userAgent' => 'AdsBot-Google', 'hostnameSuffix' => '.googlebot.com'],
|
|
467
|
+
['userAgent' => 'Mediapartners-Google', 'hostnameSuffix' => '.google.com'],
|
|
468
|
+
['userAgent' => 'Google-InspectionTool', 'hostnameSuffix' => '.google.com'],
|
|
469
|
+
['userAgent' => '(bingbot|adidxbot)', 'hostnameSuffix' => '.search.msn.com'],
|
|
470
|
+
['userAgent' => 'DuckDuckBot', 'hostnameSuffix' => '.duckduckgo.com'],
|
|
471
|
+
['userAgent' => 'YandexBot', 'hostnameSuffix' => '.yandex.com'],
|
|
472
|
+
['userAgent' => 'YandexImages', 'hostnameSuffix' => '.yandex.com'],
|
|
473
|
+
['userAgent' => 'Baiduspider', 'hostnameSuffix' => '.crawl.baidu.com'],
|
|
474
|
+
['userAgent' => 'Slurp', 'hostnameSuffix' => '.crawl.yahoo.net'],
|
|
475
|
+
['userAgent' => 'Sogou web spider', 'hostnameSuffix' => '.sogou.com'],
|
|
476
|
+
['userAgent' => 'Exabot', 'hostnameSuffix' => '.exabot.com'],
|
|
477
|
+
['userAgent' => 'ia_archiver', 'hostnameSuffix' => '.alexa.com'],
|
|
478
|
+
['userAgent' => 'SeznamBot', 'hostnameSuffix' => '.seznam.cz'],
|
|
479
|
+
['userAgent' => 'Mail.RU_Bot', 'hostnameSuffix' => '.mail.ru'],
|
|
480
|
+
['userAgent' => 'Yeti', 'hostnameSuffix' => '.naver.com'],
|
|
481
|
+
['userAgent' => 'AhrefsBot', 'hostnameSuffix' => '.ahrefs.com'],
|
|
482
|
+
['userAgent' => 'SemrushBot', 'hostnameSuffix' => '.semrush.com'],
|
|
483
|
+
['userAgent' => 'MJ12bot', 'hostnameSuffix' => '.mj12bot.com'],
|
|
484
|
+
['userAgent' => 'rogerbot', 'hostnameSuffix' => '.moz.com'],
|
|
485
|
+
['userAgent' => 'DotBot', 'hostnameSuffix' => '.moz.com'],
|
|
486
|
+
['userAgent' => 'Screaming Frog SEO Spider', 'hostnameSuffix' => '.screamingfrog.co.uk'],
|
|
487
|
+
];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Emits a log message if verbose mode is enabled. Uses the configured
|
|
492
|
+
* logger callback if available, otherwise falls back to error_log.
|
|
493
|
+
*
|
|
494
|
+
* @param string $message The message to log.
|
|
495
|
+
* @param array $data Additional data to include.
|
|
496
|
+
* @param string $level The log level (default 'info').
|
|
497
|
+
* @return void
|
|
498
|
+
*/
|
|
499
|
+
private function log(string $message, array $data = [], string $level = 'info'): void
|
|
500
|
+
{
|
|
501
|
+
if ($this->verbose) {
|
|
502
|
+
// Uses the logger if configured, otherwise error_log
|
|
503
|
+
if ($this->logger) {
|
|
504
|
+
$this->logger->log($level, "[FingerprintEngine] " . $message, $data);
|
|
505
|
+
} else {
|
|
506
|
+
$logMessage = "[FingerprintEngine] {$message}";
|
|
507
|
+
if (!empty($data)) $logMessage .= ' ' . json_encode($data);
|
|
508
|
+
self::logError($logMessage);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Computes the final suspicion score from a suspicion vector by applying
|
|
515
|
+
* the configured weights. When an MTU anomaly (tunnel) is detected, several
|
|
516
|
+
* weights are dynamically amplified to increase the score.
|
|
517
|
+
*
|
|
518
|
+
* @param array $suspicionVector The suspicion vector (metric name => score).
|
|
519
|
+
* @return float The final score, capped at 100.0.
|
|
520
|
+
*/
|
|
521
|
+
public function calculateFinalScore(array $suspicionVector): float
|
|
522
|
+
{
|
|
523
|
+
$baseWeights = $this->securityConfig['weights'] ?? [];
|
|
524
|
+
|
|
525
|
+
$score = 0.0;
|
|
526
|
+
// NEW: Weight amplification logic
|
|
527
|
+
// If a tunnel is detected, increase the weight of other suspicious signals.
|
|
528
|
+
$dynamicWeights = $baseWeights;
|
|
529
|
+
if (($suspicionVector['mtuAnomalyScore'] ?? 0.0) > 50.0) {
|
|
530
|
+
$this->log('Tunnel detected, amplifying suspicion weights.', ['mtuScore' => $suspicionVector['mtuAnomalyScore']]);
|
|
531
|
+
|
|
532
|
+
// Increases the weight of low-level inconsistencies (hard to falsify)
|
|
533
|
+
$dynamicWeights['tlsSpoofingScore'] = ($baseWeights['tlsSpoofingScore'] ?? 0.8) * 1.25;
|
|
534
|
+
$dynamicWeights['crossLayerInconsistencyScore'] = ($baseWeights['crossLayerInconsistencyScore'] ?? 0.4) * 1.4;
|
|
535
|
+
$dynamicWeights['clientHintsInconsistencyScore'] = ($baseWeights['clientHintsInconsistencyScore'] ?? 0.7) * 1.2;
|
|
536
|
+
|
|
537
|
+
// Increases the weight of behavioral anomalies (a bot behind a VPN is more suspect)
|
|
538
|
+
$dynamicWeights['behaviorScore'] = ($baseWeights['behaviorScore'] ?? 0.7) * 1.15;
|
|
539
|
+
$dynamicWeights['requestPatternScore'] = ($baseWeights['requestPatternScore'] ?? 0.6) * 1.2;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
foreach ($dynamicWeights as $key => $weight) {
|
|
543
|
+
$metricScore = $suspicionVector[$key] ?? 0.0;
|
|
544
|
+
if ($metricScore > 0) {
|
|
545
|
+
$score += $metricScore * $weight;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return min(100.0, $score);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Builds the internal BlockList used for IP allowlist checks, from the
|
|
554
|
+
* 'allowlist' rule found in the whitelist configuration.
|
|
555
|
+
*
|
|
556
|
+
* @return BlockList The populated allowlist.
|
|
557
|
+
*/
|
|
558
|
+
private function buildAllowlist(): BlockList
|
|
559
|
+
{
|
|
560
|
+
$blockList = new BlockList();
|
|
561
|
+
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
562
|
+
|
|
563
|
+
$allowlistRule = null;
|
|
564
|
+
foreach ($whitelistRules as $rule) {
|
|
565
|
+
if (($rule['type'] ?? '') === 'allowlist') {
|
|
566
|
+
$allowlistRule = $rule;
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (empty($allowlistRule['entries'])) {
|
|
572
|
+
return $blockList;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
foreach ($allowlistRule['entries'] as $entry) {
|
|
576
|
+
$blockList->add($entry);
|
|
577
|
+
}
|
|
578
|
+
return $blockList;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Checks whether the given client IP is in the IP allowlist.
|
|
583
|
+
*
|
|
584
|
+
* @param string $clientIp The client IP address.
|
|
585
|
+
* @return bool True if the IP is allowlisted, false otherwise.
|
|
586
|
+
*/
|
|
587
|
+
private function isIpInAllowlist(string $clientIp): bool
|
|
588
|
+
{
|
|
589
|
+
return $this->allowlist->check($clientIp);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Checks whether the given request path is in the path allowlist.
|
|
594
|
+
* Supports trailing-wildcard entries (e.g. "/admin/*").
|
|
595
|
+
*
|
|
596
|
+
* @param string $requestPath The request path.
|
|
597
|
+
* @return bool True if the path is allowlisted, false otherwise.
|
|
598
|
+
*/
|
|
599
|
+
private function isPathInAllowlist(string $requestPath): bool
|
|
600
|
+
{
|
|
601
|
+
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
602
|
+
$pathAllowlistRule = null;
|
|
603
|
+
foreach ($whitelistRules as $rule) {
|
|
604
|
+
if (($rule['type'] ?? '') === 'path_allowlist') {
|
|
605
|
+
$pathAllowlistRule = $rule;
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (empty($pathAllowlistRule['entries'])) {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
foreach ($pathAllowlistRule['entries'] as $entry) {
|
|
615
|
+
if (str_ends_with($entry, '*')) {
|
|
616
|
+
$base = substr($entry, 0, -1);
|
|
617
|
+
if (str_starts_with($requestPath, $base)) {
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
} elseif ($requestPath === $entry) {
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Checks whether the (host, path) pair matches a host_path_allowlist entry.
|
|
630
|
+
*
|
|
631
|
+
* @param string|null $requestHost The request host.
|
|
632
|
+
* @param string $requestPath The request path.
|
|
633
|
+
* @return bool True if the host+path is allowlisted, false otherwise.
|
|
634
|
+
*/
|
|
635
|
+
private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
|
|
636
|
+
{
|
|
637
|
+
if (empty($requestHost)) {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
642
|
+
$hostPathRule = null;
|
|
643
|
+
foreach ($whitelistRules as $rule) {
|
|
644
|
+
if (($rule['type'] ?? '') === 'host_path_allowlist') {
|
|
645
|
+
$hostPathRule = $rule;
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (empty($hostPathRule['entries'])) {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
foreach ($hostPathRule['entries'] as $entry) {
|
|
655
|
+
if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Checks if a request should be exempted due to an allowlist rule.
|
|
664
|
+
* Evaluates IP, path, host+path, GraphQL operation, and verified bot rules.
|
|
665
|
+
* Applies the `filterWhitelist` security option to optionally override
|
|
666
|
+
* allowlisting for clearly malicious requests.
|
|
667
|
+
*
|
|
668
|
+
* @param RequestContext $context The request context.
|
|
669
|
+
* @return bool True if the request should be allowed, false otherwise.
|
|
670
|
+
*/
|
|
671
|
+
private function checkAllowlists(RequestContext $context): bool
|
|
672
|
+
{
|
|
673
|
+
$whitelisted = false;
|
|
674
|
+
$type = '';
|
|
675
|
+
|
|
676
|
+
if ($this->isIpInAllowlist($context->clientIp)) {
|
|
677
|
+
$whitelisted = true;
|
|
678
|
+
$type = 'allowlist';
|
|
679
|
+
} elseif ($this->isPathInAllowlist($context->path)) {
|
|
680
|
+
$whitelisted = true;
|
|
681
|
+
$type = 'path_allowlist';
|
|
682
|
+
} elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
|
|
683
|
+
$whitelisted = true;
|
|
684
|
+
$type = 'host_path_allowlist';
|
|
685
|
+
} elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
|
|
686
|
+
$whitelisted = true;
|
|
687
|
+
$type = 'graphql_operation_allowlist';
|
|
688
|
+
} elseif ($this->verifyWhitelistedBot($context)) {
|
|
689
|
+
$whitelisted = true;
|
|
690
|
+
$type = 'bot';
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if ($whitelisted) {
|
|
694
|
+
$filterWhitelist = $this->securityConfig['filterWhitelist'] ?? false;
|
|
695
|
+
$bypassWhitelist = false;
|
|
696
|
+
if ($filterWhitelist === true || (is_numeric($filterWhitelist) && $type === 'bot')) {
|
|
697
|
+
$bypassWhitelist = $this->hasCertainAttack($context);
|
|
698
|
+
} elseif (is_numeric($filterWhitelist)) {
|
|
699
|
+
if ($context->preCalculatedScore === null) {
|
|
700
|
+
$suspicionVector = [];
|
|
701
|
+
$context->preCalculatedVector = $this->getSuspicionVector($context, $suspicionVector);
|
|
702
|
+
$context->preCalculatedScore = $this->calculateFinalScore($context->preCalculatedVector);
|
|
703
|
+
}
|
|
704
|
+
if ($context->preCalculatedScore > $filterWhitelist) {
|
|
705
|
+
$bypassWhitelist = true;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if ($bypassWhitelist) {
|
|
710
|
+
$this->log('Whitelisted request exceeds filter threshold - bypassing whitelist bypass', ['clientIp' => $context->clientIp, 'path' => $context->path]);
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
$this->log("IP/Path in allowlist ({$type}) - allowing request", ['clientIp' => $context->clientIp, 'path' => $context->path]);
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Resolves the request identity (device ID, device data, and possibly a
|
|
722
|
+
* new cookie to set). Attempts cookieless identity recovery via TLS session
|
|
723
|
+
* ID, detects cookie dropping, and validates WebAuthn hardware anchors.
|
|
724
|
+
*
|
|
725
|
+
* @param RequestContext $context The request context.
|
|
726
|
+
* @param array &$suspicionVector The suspicion vector (updated in place).
|
|
727
|
+
* @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
|
|
728
|
+
*/
|
|
729
|
+
private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
|
|
730
|
+
{
|
|
731
|
+
if ($context->resolvedIdentity !== null) {
|
|
732
|
+
return $context->resolvedIdentity;
|
|
733
|
+
}
|
|
734
|
+
$this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
|
|
735
|
+
$store = StoreManager::getStore();
|
|
736
|
+
$existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
|
|
737
|
+
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
738
|
+
$pendingCookieTtl = 120; // 2 minutes to detect removal
|
|
739
|
+
|
|
740
|
+
$deviceId = $existingDeviceId;
|
|
741
|
+
$deviceData = null;
|
|
742
|
+
$newCookie = null;
|
|
743
|
+
|
|
744
|
+
// Cookieless Identity Tracking: Attempt to restore device ID using TLS session resume ID
|
|
745
|
+
$tlsSessionId = $context->tlsSessionId;
|
|
746
|
+
if (!$deviceId && $tlsSessionId) {
|
|
747
|
+
$resumedDeviceId = $store->get("tls-session:{$tlsSessionId}");
|
|
748
|
+
if ($resumedDeviceId) {
|
|
749
|
+
$deviceId = $resumedDeviceId;
|
|
750
|
+
$this->log('Identity resumed via TLS Session ID', ['deviceId' => $deviceId, 'tlsSessionId' => $tlsSessionId]);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if ($deviceId) {
|
|
755
|
+
$deviceData = $store->get("device:{$deviceId}");
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
$ua = $context->getHeader('user-agent') ?? '';
|
|
759
|
+
$pendingKey = "pending_cookie:" . md5($context->clientIp . '|' . $ua);
|
|
760
|
+
|
|
761
|
+
if ($deviceData === null) {
|
|
762
|
+
// New user or lost/invalid cookie
|
|
763
|
+
$pendingDeviceId = $store->get($pendingKey);
|
|
764
|
+
if ($pendingDeviceId && !$existingDeviceId) {
|
|
765
|
+
// The penalty is now added directly to the suspicion vector.
|
|
766
|
+
$this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
|
|
767
|
+
$suspicionVector['cookieDroppingScore'] = 100.0;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
$deviceId = bin2hex(random_bytes(16)); // UUID-like
|
|
771
|
+
|
|
772
|
+
$isHttps = !empty($context->isHttps);
|
|
773
|
+
$secureOption = $isHttps;
|
|
774
|
+
|
|
775
|
+
// Prepare the cookie to be sent
|
|
776
|
+
$newCookie = [
|
|
777
|
+
'name' => 'device_id',
|
|
778
|
+
'value' => $deviceId,
|
|
779
|
+
'options' => [
|
|
780
|
+
'httponly' => true,
|
|
781
|
+
'secure' => $secureOption,
|
|
782
|
+
'samesite' => 'Lax',
|
|
783
|
+
'path' => '/',
|
|
784
|
+
]
|
|
785
|
+
];
|
|
786
|
+
if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
|
|
787
|
+
$newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
$store->set($pendingKey, $deviceId, $pendingCookieTtl);
|
|
791
|
+
|
|
792
|
+
$deviceData = [
|
|
793
|
+
'initialDeviceHash' => $currentDeviceHash,
|
|
794
|
+
'ips' => [$context->clientIp],
|
|
795
|
+
'requestHistory' => [],
|
|
796
|
+
'lastUpdate' => time() * 1000,
|
|
797
|
+
'lastFpHash' => $currentDeviceHash,
|
|
798
|
+
'lastChangeTimestamp' => 0,
|
|
799
|
+
'rapidChangeCount' => 0,
|
|
800
|
+
'highScoreCount' => 0,
|
|
801
|
+
'lastHighScoreTimestamp' => 0,
|
|
802
|
+
];
|
|
803
|
+
} else {
|
|
804
|
+
// Ensure 'ips' is an array for the following operations.
|
|
805
|
+
if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
|
|
806
|
+
$deviceData['ips'] = [];
|
|
807
|
+
}
|
|
808
|
+
// The client returned a valid cookie: purge the pending entry immediately
|
|
809
|
+
$store->delete($pendingKey);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// WebAuthn hardware anchor (Secure Enclave / TPM) validation
|
|
813
|
+
$behaviorHeader = $context->getHeader('x-behavior-metrics');
|
|
814
|
+
if (!empty($behaviorHeader) && is_string($behaviorHeader) && str_starts_with($behaviorHeader, '{')) {
|
|
815
|
+
try {
|
|
816
|
+
$parsedMetrics = json_decode($behaviorHeader, true);
|
|
817
|
+
if (is_array($parsedMetrics) && !empty($parsedMetrics['webauthnAnchor']) && is_array($deviceData)) {
|
|
818
|
+
if ($this->verifyWebAuthnHardwareAnchor($parsedMetrics['webauthnAnchor'], $deviceData)) {
|
|
819
|
+
$deviceData['webauthnVerified'] = true;
|
|
820
|
+
$store->set("device:{$deviceId}", $deviceData);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
} catch (\Throwable $e) {
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Bind the current TLS session ID to the device ID
|
|
828
|
+
if ($deviceId && $tlsSessionId) {
|
|
829
|
+
$store->set("tls-session:{$tlsSessionId}", $deviceId, 3600); // 1h cache duration
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
$context->resolvedIdentity = [
|
|
833
|
+
'deviceId' => $deviceId,
|
|
834
|
+
'deviceData' => $deviceData,
|
|
835
|
+
'newCookie' => $newCookie,
|
|
836
|
+
];
|
|
837
|
+
return $context->resolvedIdentity;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Computes the full suspicion vector for a request by aggregating dozens
|
|
842
|
+
* of indicators (fingerprint consistency, TLS spoofing/JA4, behavioral
|
|
843
|
+
* metrics, threat intel, subnet reputation, protocol/TCP anomalies, etc.).
|
|
844
|
+
* Also updates and persists the device's state in the store.
|
|
845
|
+
*
|
|
846
|
+
* @param RequestContext $context The request context.
|
|
847
|
+
* @param array &$suspicionVector The suspicion vector (updated in place).
|
|
848
|
+
* @return array<string, float> The completed suspicion vector.
|
|
849
|
+
*/
|
|
850
|
+
public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
|
|
851
|
+
{
|
|
852
|
+
$store = StoreManager::getStore();
|
|
853
|
+
$identity = $this->resolveRequestIdentity($context, $suspicionVector);
|
|
854
|
+
$deviceData = $identity['deviceData'];
|
|
855
|
+
$deviceId = $identity['deviceId'];
|
|
856
|
+
if ($deviceData && ($deviceData['condemned'] ?? false)) {
|
|
857
|
+
$suspicionVector['honeypotScore'] = 100;
|
|
858
|
+
return $suspicionVector;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// If a new cookie must be set, temporarily store it in the context
|
|
862
|
+
// so that the calling code can handle it.
|
|
863
|
+
if ($identity['newCookie']) {
|
|
864
|
+
// This property is non-standard, prefix it to avoid conflicts.
|
|
865
|
+
$context->newCookieForResponse = $identity['newCookie'];
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Periodic device data cleanup
|
|
869
|
+
if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
|
|
870
|
+
$deviceData['ips'] = [];
|
|
871
|
+
$deviceData['rapidChangeCount'] = 0;
|
|
872
|
+
$deviceData['ipTimes'] = [];
|
|
873
|
+
}
|
|
874
|
+
$deviceData['lastUpdate'] = time() * 1000;
|
|
875
|
+
|
|
876
|
+
// --- Computation of the various suspicion scores ---
|
|
877
|
+
|
|
878
|
+
// Fingerprint inconsistency score (moved here to be with the others)
|
|
879
|
+
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
880
|
+
$consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
|
|
881
|
+
$similarityThreshold = (float)($this->securityConfig['similarityThreshold'] ?? 0.72);
|
|
882
|
+
$inconsistencyScore = RequestUtils::calculateAnalogInconsistencyScore($consistencyScore, $similarityThreshold);
|
|
883
|
+
|
|
884
|
+
$behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
|
|
885
|
+
|
|
886
|
+
// Header anomaly score
|
|
887
|
+
$headerAnomalies = RequestUtils::getHeaderAnomalies($context);
|
|
888
|
+
|
|
889
|
+
// TLS spoofing score
|
|
890
|
+
$tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
|
|
891
|
+
$tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
|
|
892
|
+
$virtualizationScore = RequestUtils::getVirtualizationAnomalyScore($context);
|
|
893
|
+
|
|
894
|
+
// Advanced JA4 TLS Inconsistency checks
|
|
895
|
+
$ja4 = $context->getHeader('x-ja4-hash');
|
|
896
|
+
if ($ja4) {
|
|
897
|
+
$spoofedJa4s = [
|
|
898
|
+
't13d1516h2_8daaf6152771_390237aa04be', // Classic Chrome (curl-impersonate / tls-client)
|
|
899
|
+
't13d1413h2_bc66258908f0_bc2531da1615', // Static Firefox (curl-impersonate-ff / curl_cffi)
|
|
900
|
+
't13d1515h2_8daaf6152771_a729e2f67de4', // Static Safari (curl-impersonate-safari / tls-client)
|
|
901
|
+
't13d1516h2_8daaf6152771_4be0df930c2c', // Chrome alternative (tls-client Go)
|
|
902
|
+
't12d1516h2_8daaf6152771_390237aa04be', // Spoofed Chrome downgraded to TLS 1.2
|
|
903
|
+
't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Custom OpenSSL scraping bot
|
|
904
|
+
];
|
|
905
|
+
if (in_array($ja4, $spoofedJa4s, true)) {
|
|
906
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
$parsedJa4 = $this->parseJa4($ja4);
|
|
910
|
+
if ($parsedJa4) {
|
|
911
|
+
$ua = $context->getHeader('user-agent') ?? '';
|
|
912
|
+
$uaParts = $this->parseUserAgent($ua);
|
|
913
|
+
|
|
914
|
+
// Check 1: ALPN / HTTP Version inconsistency
|
|
915
|
+
$httpVersion = $context->httpVersion ?? '1.1';
|
|
916
|
+
if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
|
|
917
|
+
$hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
|
|
918
|
+
if (!$hasProxy) {
|
|
919
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Check 2: OS/Platform vs TLS Capabilities inconsistency
|
|
924
|
+
if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
|
|
925
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Check 3: User-Agent vs JA4 Signature inconsistency
|
|
929
|
+
if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
|
|
930
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
|
|
931
|
+
}
|
|
932
|
+
if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
|
|
933
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Check 4: Stagnation (Lack of Entropy / Genericity)
|
|
937
|
+
if (is_string($uaParts['browser'])) {
|
|
938
|
+
$ja4Key = "ja4-browsers:{$ja4}";
|
|
939
|
+
$seenBrowsers = $store->get($ja4Key) ?: [];
|
|
940
|
+
if (!is_array($seenBrowsers)) {
|
|
941
|
+
$seenBrowsers = [];
|
|
942
|
+
}
|
|
943
|
+
$browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
|
|
944
|
+
if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
|
|
945
|
+
$seenBrowsers[] = $browserFamily;
|
|
946
|
+
$store->set($ja4Key, $seenBrowsers, 86400);
|
|
947
|
+
}
|
|
948
|
+
if (count($seenBrowsers) > 1) {
|
|
949
|
+
$tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Time inconsistency score (replay attack)
|
|
956
|
+
$timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
|
|
957
|
+
|
|
958
|
+
// Cross-layer inconsistency score (client vs server)
|
|
959
|
+
$crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
|
|
960
|
+
|
|
961
|
+
// Request pattern score (scraping, velocity)
|
|
962
|
+
$requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
|
|
963
|
+
|
|
964
|
+
// Honeypot score
|
|
965
|
+
$honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
|
|
966
|
+
|
|
967
|
+
// Client behavioral metrics score (mouse, keyboard)
|
|
968
|
+
$behavior = RequestUtils::getBehaviorScore($context);
|
|
969
|
+
|
|
970
|
+
// Explicit bot detection score (automation markers)
|
|
971
|
+
$bot = RequestUtils::getBotScore($context);
|
|
972
|
+
|
|
973
|
+
// Click variance score
|
|
974
|
+
$clickVariance = RequestUtils::getClickVarianceScore($context);
|
|
975
|
+
|
|
976
|
+
// Threat Intelligence score
|
|
977
|
+
$threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
|
|
978
|
+
|
|
979
|
+
// Client-Hints inconsistency score
|
|
980
|
+
$clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
|
|
981
|
+
|
|
982
|
+
// Global fingerprint similarity score (Botnet Clustering)
|
|
983
|
+
$stableFp = RequestUtils::extractStablePart($currentDeviceHash);
|
|
984
|
+
$stableFpHash = FingerprintBuilder::cyrb53($stableFp);
|
|
985
|
+
$botnetCluster = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
|
|
986
|
+
|
|
987
|
+
// NEW: IP subnet reputation score
|
|
988
|
+
$subnetScore = RequestUtils::getSubnetScore($context, $deviceId, $this->securityConfig);
|
|
989
|
+
|
|
990
|
+
// TCP/IP stack anomaly score
|
|
991
|
+
$tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
|
|
992
|
+
|
|
993
|
+
// Protocol anomaly score (HTTP/2 and QUIC)
|
|
994
|
+
$protocolAnomaly = RequestUtils::getProtocolAnomalyScore($context);
|
|
995
|
+
|
|
996
|
+
// Display rendering anomaly score (V-Sync)
|
|
997
|
+
$renderingAnomaly = RequestUtils::getRenderingAnomalyScore($context);
|
|
998
|
+
|
|
999
|
+
// Assembly of the final suspicion vector
|
|
1000
|
+
$suspicionVector = array_merge($suspicionVector, [
|
|
1001
|
+
'inconsistencyScore' => $inconsistencyScore,
|
|
1002
|
+
'historyScore' => $behavioral['historyScore'],
|
|
1003
|
+
'rotationScore' => $behavioral['rotationScore'],
|
|
1004
|
+
'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
|
|
1005
|
+
'tlsSpoofingScore' => $tlsSpoofingScore,
|
|
1006
|
+
'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
|
|
1007
|
+
'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
|
|
1008
|
+
'requestPatternScore' => $requestPattern['requestPatternScore'], // This score is now computed
|
|
1009
|
+
'honeypotScore' => $honeypot['honeypotScore'],
|
|
1010
|
+
'behaviorScore' => $behavior['behaviorScore'],
|
|
1011
|
+
'botScore' => $bot['botScore'],
|
|
1012
|
+
'clickVarianceScore' => $clickVariance['clickVarianceScore'],
|
|
1013
|
+
'threatIntelScore' => $threatIntel['threatIntelScore'],
|
|
1014
|
+
'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
|
|
1015
|
+
'subnetScore' => $subnetScore['subnetScore'],
|
|
1016
|
+
'botnetClusterScore' => $botnetCluster['botnetClusterScore'],
|
|
1017
|
+
'tcpAnomalyScore' => $tcpAnomaly['tcpAnomalyScore'],
|
|
1018
|
+
'protocolAnomalyScore' => $protocolAnomaly['protocolAnomalyScore'],
|
|
1019
|
+
'renderingAnomalyScore' => $renderingAnomaly['renderingAnomalyScore'],
|
|
1020
|
+
'virtualizationScore' => $virtualizationScore
|
|
1021
|
+
]);
|
|
1022
|
+
|
|
1023
|
+
// Save the updated device state in the store
|
|
1024
|
+
$store->set("device:{$deviceId}", $deviceData);
|
|
1025
|
+
|
|
1026
|
+
return $suspicionVector;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Parse a basic User-Agent string.
|
|
1031
|
+
*
|
|
1032
|
+
* @param string $ua The User-Agent string.
|
|
1033
|
+
* @return array An array with 'browser' and 'os' keys.
|
|
1034
|
+
*/
|
|
1035
|
+
private function parseUserAgent(string $ua): array
|
|
1036
|
+
{
|
|
1037
|
+
$result = ['browser' => null, 'os' => null];
|
|
1038
|
+
|
|
1039
|
+
if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
|
|
1040
|
+
$result['browser'] = 'Chrome';
|
|
1041
|
+
if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
|
|
1042
|
+
$result['browser'] .= '/' . $matches[1];
|
|
1043
|
+
}
|
|
1044
|
+
} elseif (str_contains($ua, 'Firefox')) {
|
|
1045
|
+
$result['browser'] = 'Firefox';
|
|
1046
|
+
if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
|
|
1047
|
+
$result['browser'] .= '/' . $matches[1];
|
|
1048
|
+
}
|
|
1049
|
+
} elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
|
|
1050
|
+
$result['browser'] = 'Safari';
|
|
1051
|
+
if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
|
|
1052
|
+
$result['browser'] .= '/' . $matches[1];
|
|
1053
|
+
}
|
|
1054
|
+
} elseif (str_contains($ua, 'Edg')) {
|
|
1055
|
+
$result['browser'] = 'Edge';
|
|
1056
|
+
if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
|
|
1057
|
+
$result['browser'] .= '/' . $matches[1];
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
|
|
1062
|
+
elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
|
|
1063
|
+
elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
|
|
1064
|
+
elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
|
|
1065
|
+
elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
|
|
1066
|
+
elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
|
|
1067
|
+
|
|
1068
|
+
return $result;
|
|
239
1069
|
}
|
|
240
1070
|
|
|
241
1071
|
/**
|
|
242
|
-
*
|
|
1072
|
+
* Parse JA4 string into protocol, version, ALPN etc.
|
|
1073
|
+
*
|
|
1074
|
+
* @param string|null $ja4 The JA4 string.
|
|
1075
|
+
* @return array|null The parsed components, or null if invalid.
|
|
243
1076
|
*/
|
|
244
|
-
|
|
1077
|
+
private function parseJa4(?string $ja4): ?array
|
|
1078
|
+
{
|
|
1079
|
+
if (empty($ja4)) return null;
|
|
1080
|
+
$parts = explode('_', $ja4);
|
|
1081
|
+
$ja4a = $parts[0];
|
|
1082
|
+
if (strlen($ja4a) < 10) return null;
|
|
1083
|
+
return [
|
|
1084
|
+
'protocol' => $ja4a[0],
|
|
1085
|
+
'version' => substr($ja4a, 1, 2),
|
|
1086
|
+
'sni' => $ja4a[3],
|
|
1087
|
+
'ciphersCount' => (int)substr($ja4a, 4, 2),
|
|
1088
|
+
'extensionsCount' => (int)substr($ja4a, 6, 2),
|
|
1089
|
+
'alpn' => substr($ja4a, 8, 2),
|
|
1090
|
+
'ja4b' => $parts[1] ?? null,
|
|
1091
|
+
'ja4c' => $parts[2] ?? null
|
|
1092
|
+
];
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Decodes a polymorphic fingerprint by restoring the original keys from
|
|
1098
|
+
* a mapping of randomized keys.
|
|
1099
|
+
*
|
|
1100
|
+
* @param string $fpString The encoded fingerprint string.
|
|
1101
|
+
* @param array $mapping The mapping containing a `keys` (orig => rand) entry.
|
|
1102
|
+
* @return string The decoded fingerprint with original keys.
|
|
1103
|
+
*/
|
|
1104
|
+
private function decodePolymorphicFingerprint(string $fpString, array $mapping): string
|
|
1105
|
+
{
|
|
1106
|
+
if (empty($mapping['keys'])) {
|
|
1107
|
+
return $fpString;
|
|
1108
|
+
}
|
|
1109
|
+
$reverseKeys = array_flip($mapping['keys']);
|
|
1110
|
+
$parts = explode('|', $fpString);
|
|
1111
|
+
$mappedParts = [];
|
|
1112
|
+
foreach ($parts as $part) {
|
|
1113
|
+
$pair = explode(':', $part, 2);
|
|
1114
|
+
if (count($pair) === 2) {
|
|
1115
|
+
$origKey = $reverseKeys[$pair[0]] ?? $pair[0];
|
|
1116
|
+
$mappedParts[] = "{$origKey}:{$pair[1]}";
|
|
1117
|
+
} else {
|
|
1118
|
+
$mappedParts[] = $part;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return implode('|', $mappedParts);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Translates polymorphic (randomized) header names back to their canonical
|
|
1126
|
+
* names (x-device-fingerprint, x-behavior-metrics) and decodes the
|
|
1127
|
+
* fingerprint values using the active mapping.
|
|
1128
|
+
*
|
|
1129
|
+
* @param RequestContext $context The request context (mutated in place).
|
|
1130
|
+
* @return void
|
|
1131
|
+
*/
|
|
1132
|
+
private function translatePolymorphicHeaders(RequestContext $context): void
|
|
245
1133
|
{
|
|
246
1134
|
$store = StoreManager::getStore();
|
|
247
|
-
|
|
248
|
-
|
|
1135
|
+
$activeMappings = $store->get('active-polymorphic-mappings') ?: [];
|
|
1136
|
+
if (!is_array($activeMappings)) {
|
|
1137
|
+
return;
|
|
249
1138
|
}
|
|
250
|
-
|
|
1139
|
+
|
|
1140
|
+
foreach ($activeMappings as $mapping) {
|
|
1141
|
+
$devFpHeader = strtolower($mapping['headers']['x-device-fingerprint'] ?? '');
|
|
1142
|
+
$behaviorHeader = strtolower($mapping['headers']['x-behavior-metrics'] ?? '');
|
|
1143
|
+
|
|
1144
|
+
if (!empty($devFpHeader) && isset($context->headers[$devFpHeader])) {
|
|
1145
|
+
$context->headers['x-device-fingerprint'] = $context->headers[$devFpHeader];
|
|
1146
|
+
if (!empty($behaviorHeader) && isset($context->headers[$behaviorHeader])) {
|
|
1147
|
+
$context->headers['x-behavior-metrics'] = $context->headers[$behaviorHeader];
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
$clientFp = $context->headers['x-device-fingerprint'];
|
|
1151
|
+
if ($clientFp && is_string($clientFp)) {
|
|
1152
|
+
$context->headers['x-device-fingerprint'] = $this->decodePolymorphicFingerprint($clientFp, $mapping);
|
|
1153
|
+
}
|
|
1154
|
+
break;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Evaluates whether the request exhibits clear attack characteristics or
|
|
1161
|
+
* has triggered a honeypot (i.e. a certain attack).
|
|
1162
|
+
*
|
|
1163
|
+
* @param RequestContext $context The request context.
|
|
1164
|
+
* @return bool True if a certain attack is detected, false otherwise.
|
|
1165
|
+
*/
|
|
1166
|
+
private function hasCertainAttack(RequestContext $context): bool
|
|
1167
|
+
{
|
|
1168
|
+
$honeypotConfig = $this->securityConfig['honeypot'] ?? [];
|
|
1169
|
+
$honeypot = RequestUtils::getHoneypotScore($context, $honeypotConfig);
|
|
1170
|
+
if (($honeypot['honeypotScore'] ?? 0.0) >= 100.0) {
|
|
1171
|
+
return true;
|
|
1172
|
+
}
|
|
1173
|
+
$bot = RequestUtils::getBotScore($context);
|
|
1174
|
+
if (($bot['botScore'] ?? 0.0) >= 100.0) {
|
|
1175
|
+
return true;
|
|
1176
|
+
}
|
|
1177
|
+
return false;
|
|
251
1178
|
}
|
|
252
1179
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
'similarityThreshold', 'summary', 'description',
|
|
266
|
-
'ed25519_private_key', 'ed25519_public_key',
|
|
267
|
-
'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret', 'reset',
|
|
268
|
-
'filterWhitelist'
|
|
269
|
-
];
|
|
270
|
-
|
|
271
|
-
if (empty($config['weights'])) {
|
|
272
|
-
$this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
|
|
273
|
-
}
|
|
274
|
-
if (empty($config['thresholds'])) {
|
|
275
|
-
$this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
foreach (array_keys($config) as $key) {
|
|
279
|
-
if (!in_array($key, $knownKeys, true)) {
|
|
280
|
-
$this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
|
|
285
|
-
{
|
|
286
|
-
$configDir = dirname(__DIR__, 2) . '/config';
|
|
287
|
-
$filePath = $configDir . '/' . $filename;
|
|
288
|
-
if (file_exists($filePath)) {
|
|
289
|
-
try {
|
|
290
|
-
$content = file_get_contents($filePath);
|
|
291
|
-
if ($content !== false) {
|
|
292
|
-
$decoded = json_decode($content, true);
|
|
293
|
-
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
|
294
|
-
return $decoded;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
} catch (\Throwable $e) {
|
|
298
|
-
self::logError("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
return $fallbackEntries;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
public static function googlebot_whitelist(): array
|
|
305
|
-
{
|
|
306
|
-
if (self::$googlebotEntries === null) {
|
|
307
|
-
self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
|
|
308
|
-
"2001:4860:4801:10::/64",
|
|
309
|
-
"2001:4860:4801:11::/64",
|
|
310
|
-
"2001:4860:4801:12::/64",
|
|
311
|
-
"66.249.79.64"
|
|
312
|
-
]);
|
|
313
|
-
}
|
|
314
|
-
return [
|
|
315
|
-
'type' => 'allowlist',
|
|
316
|
-
'entries' => self::$googlebotEntries
|
|
317
|
-
];
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
public static function yandex_whitelist(): array
|
|
321
|
-
{
|
|
322
|
-
if (self::$yandexEntries === null) {
|
|
323
|
-
self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
|
|
324
|
-
"2a02:6b8::/29",
|
|
325
|
-
"5.45.192.0/18",
|
|
326
|
-
"213.180.192.0/19"
|
|
327
|
-
]);
|
|
328
|
-
}
|
|
329
|
-
return [
|
|
330
|
-
'type' => 'allowlist',
|
|
331
|
-
'entries' => self::$yandexEntries
|
|
332
|
-
];
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
public static function bingbot_whitelist(): array
|
|
336
|
-
{
|
|
337
|
-
if (self::$bingbotEntries === null) {
|
|
338
|
-
self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
|
|
339
|
-
"157.55.39.0/24",
|
|
340
|
-
"207.46.13.0/24",
|
|
341
|
-
"40.77.178.0/23"
|
|
342
|
-
]);
|
|
343
|
-
}
|
|
344
|
-
return [
|
|
345
|
-
'type' => 'allowlist',
|
|
346
|
-
'entries' => self::$bingbotEntries
|
|
347
|
-
];
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
public static function default_whitelist(): array
|
|
351
|
-
{
|
|
352
|
-
return [
|
|
353
|
-
self::googlebot_whitelist(),
|
|
354
|
-
self::bingbot_whitelist(),
|
|
355
|
-
self::yandex_whitelist(),
|
|
356
|
-
['userAgent' => 'Googlebot', 'hostnameSuffix' => '.googlebot.com'],
|
|
357
|
-
['userAgent' => 'Google-Extended', 'hostnameSuffix' => '.google.com'],
|
|
358
|
-
['userAgent' => 'AdsBot-Google', 'hostnameSuffix' => '.googlebot.com'],
|
|
359
|
-
['userAgent' => 'Mediapartners-Google', 'hostnameSuffix' => '.google.com'],
|
|
360
|
-
['userAgent' => 'Google-InspectionTool', 'hostnameSuffix' => '.google.com'],
|
|
361
|
-
['userAgent' => '(bingbot|adidxbot)', 'hostnameSuffix' => '.search.msn.com'],
|
|
362
|
-
['userAgent' => 'DuckDuckBot', 'hostnameSuffix' => '.duckduckgo.com'],
|
|
363
|
-
['userAgent' => 'YandexBot', 'hostnameSuffix' => '.yandex.com'],
|
|
364
|
-
['userAgent' => 'YandexImages', 'hostnameSuffix' => '.yandex.com'],
|
|
365
|
-
['userAgent' => 'Baiduspider', 'hostnameSuffix' => '.crawl.baidu.com'],
|
|
366
|
-
['userAgent' => 'Slurp', 'hostnameSuffix' => '.crawl.yahoo.net'],
|
|
367
|
-
['userAgent' => 'Sogou web spider', 'hostnameSuffix' => '.sogou.com'],
|
|
368
|
-
['userAgent' => 'Exabot', 'hostnameSuffix' => '.exabot.com'],
|
|
369
|
-
['userAgent' => 'ia_archiver', 'hostnameSuffix' => '.alexa.com'],
|
|
370
|
-
['userAgent' => 'SeznamBot', 'hostnameSuffix' => '.seznam.cz'],
|
|
371
|
-
['userAgent' => 'Mail.RU_Bot', 'hostnameSuffix' => '.mail.ru'],
|
|
372
|
-
['userAgent' => 'Yeti', 'hostnameSuffix' => '.naver.com'],
|
|
373
|
-
['userAgent' => 'AhrefsBot', 'hostnameSuffix' => '.ahrefs.com'],
|
|
374
|
-
['userAgent' => 'SemrushBot', 'hostnameSuffix' => '.semrush.com'],
|
|
375
|
-
['userAgent' => 'MJ12bot', 'hostnameSuffix' => '.mj12bot.com'],
|
|
376
|
-
['userAgent' => 'rogerbot', 'hostnameSuffix' => '.moz.com'],
|
|
377
|
-
['userAgent' => 'DotBot', 'hostnameSuffix' => '.moz.com'],
|
|
378
|
-
['userAgent' => 'Screaming Frog SEO Spider', 'hostnameSuffix' => '.screamingfrog.co.uk'],
|
|
379
|
-
];
|
|
380
|
-
}
|
|
381
|
-
private function log(string $message, array $data = [], string $level = 'info'): void
|
|
382
|
-
{
|
|
383
|
-
if ($this->verbose) {
|
|
384
|
-
// Utilise le logger s'il est configuré, sinon error_log
|
|
385
|
-
if ($this->logger) {
|
|
386
|
-
$this->logger->log($level, "[FingerprintEngine] " . $message, $data);
|
|
387
|
-
} else {
|
|
388
|
-
$logMessage = "[FingerprintEngine] {$message}";
|
|
389
|
-
if (!empty($data)) $logMessage .= ' ' . json_encode($data);
|
|
390
|
-
self::logError($logMessage);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
public function calculateFinalScore(array $suspicionVector): float
|
|
396
|
-
{
|
|
397
|
-
$weights = $this->securityConfig['weights'] ?? [];
|
|
398
|
-
if (empty($weights)) {
|
|
399
|
-
return 0.0;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
$score = 0.0;
|
|
403
|
-
foreach ($weights as $key => $weight) {
|
|
404
|
-
$score += ($suspicionVector[$key] ?? 0) * $weight;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
return min(100.0, $score);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
private function buildAllowlist(): BlockList
|
|
411
|
-
{
|
|
412
|
-
$blockList = new BlockList();
|
|
413
|
-
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
414
|
-
|
|
415
|
-
$allowlistRule = null;
|
|
416
|
-
foreach ($whitelistRules as $rule) {
|
|
417
|
-
if (($rule['type'] ?? '') === 'allowlist') {
|
|
418
|
-
$allowlistRule = $rule;
|
|
419
|
-
break;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
if (empty($allowlistRule['entries'])) {
|
|
424
|
-
return $blockList;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
foreach ($allowlistRule['entries'] as $entry) {
|
|
428
|
-
$blockList->add($entry);
|
|
429
|
-
}
|
|
430
|
-
return $blockList;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
private function isIpInAllowlist(string $clientIp): bool
|
|
434
|
-
{
|
|
435
|
-
return $this->allowlist->check($clientIp);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
private function isPathInAllowlist(string $requestPath): bool
|
|
439
|
-
{
|
|
440
|
-
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
441
|
-
$pathAllowlistRule = null;
|
|
442
|
-
foreach ($whitelistRules as $rule) {
|
|
443
|
-
if (($rule['type'] ?? '') === 'path_allowlist') {
|
|
444
|
-
$pathAllowlistRule = $rule;
|
|
445
|
-
break;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (empty($pathAllowlistRule['entries'])) {
|
|
450
|
-
return false;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
foreach ($pathAllowlistRule['entries'] as $entry) {
|
|
454
|
-
if (str_ends_with($entry, '*')) {
|
|
455
|
-
$base = substr($entry, 0, -1);
|
|
456
|
-
if (str_starts_with($requestPath, $base)) {
|
|
457
|
-
return true;
|
|
458
|
-
}
|
|
459
|
-
} elseif ($requestPath === $entry) {
|
|
460
|
-
return true;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
return false;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
|
|
468
|
-
{
|
|
469
|
-
if (empty($requestHost)) {
|
|
470
|
-
return false;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
474
|
-
$hostPathRule = null;
|
|
475
|
-
foreach ($whitelistRules as $rule) {
|
|
476
|
-
if (($rule['type'] ?? '') === 'host_path_allowlist') {
|
|
477
|
-
$hostPathRule = $rule;
|
|
478
|
-
break;
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
if (empty($hostPathRule['entries'])) {
|
|
483
|
-
return false;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
foreach ($hostPathRule['entries'] as $entry) {
|
|
487
|
-
if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
|
|
488
|
-
return true;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
return false;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Vérifie si une requête doit être exemptée en raison d'une règle de liste blanche.
|
|
496
|
-
*/
|
|
497
|
-
private function checkAllowlists(RequestContext $context): bool
|
|
498
|
-
{
|
|
499
|
-
$whitelisted = false;
|
|
500
|
-
$type = '';
|
|
501
|
-
|
|
502
|
-
if ($this->isIpInAllowlist($context->clientIp)) {
|
|
503
|
-
$whitelisted = true;
|
|
504
|
-
$type = 'allowlist';
|
|
505
|
-
} elseif ($this->isPathInAllowlist($context->path)) {
|
|
506
|
-
$whitelisted = true;
|
|
507
|
-
$type = 'path_allowlist';
|
|
508
|
-
} elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
|
|
509
|
-
$whitelisted = true;
|
|
510
|
-
$type = 'host_path_allowlist';
|
|
511
|
-
} elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
|
|
512
|
-
$whitelisted = true;
|
|
513
|
-
$type = 'graphql_operation_allowlist';
|
|
514
|
-
} elseif ($this->verifyWhitelistedBot($context)) {
|
|
515
|
-
$whitelisted = true;
|
|
516
|
-
$type = 'bot';
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
if ($whitelisted) {
|
|
520
|
-
$filterWhitelist = $this->securityConfig['filterWhitelist'] ?? false;
|
|
521
|
-
$bypassWhitelist = false;
|
|
522
|
-
if ($filterWhitelist === true) {
|
|
523
|
-
$bypassWhitelist = $this->hasCertainAttack($context);
|
|
524
|
-
} elseif (is_numeric($filterWhitelist)) {
|
|
525
|
-
if ($context->preCalculatedScore === null) {
|
|
526
|
-
$suspicionVector = [];
|
|
527
|
-
$context->preCalculatedVector = $this->getSuspicionVector($context, $suspicionVector);
|
|
528
|
-
$context->preCalculatedScore = $this->calculateFinalScore($context->preCalculatedVector);
|
|
529
|
-
}
|
|
530
|
-
if ($context->preCalculatedScore > $filterWhitelist) {
|
|
531
|
-
$bypassWhitelist = true;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
if ($bypassWhitelist) {
|
|
536
|
-
$this->log('Whitelisted request exceeds filter threshold - bypassing whitelist bypass', ['clientIp' => $context->clientIp, 'path' => $context->path]);
|
|
537
|
-
return false;
|
|
538
|
-
}
|
|
539
|
-
$this->log("IP/Path in allowlist ({$type}) - allowing request", ['clientIp' => $context->clientIp, 'path' => $context->path]);
|
|
540
|
-
return true;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
return false;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/**
|
|
547
|
-
* @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
|
|
548
|
-
*/
|
|
549
|
-
private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
|
|
550
|
-
{
|
|
551
|
-
if ($context->resolvedIdentity !== null) {
|
|
552
|
-
return $context->resolvedIdentity;
|
|
553
|
-
}
|
|
554
|
-
$this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
|
|
555
|
-
$store = StoreManager::getStore();
|
|
556
|
-
$existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
|
|
557
|
-
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
558
|
-
$pendingCookieTtl = 120; // 2 minutes pour détecter la suppression
|
|
559
|
-
|
|
560
|
-
$deviceId = $existingDeviceId;
|
|
561
|
-
$deviceData = null;
|
|
562
|
-
$newCookie = null;
|
|
563
|
-
|
|
564
|
-
// Cookieless Identity Tracking: Attempt to restore device ID using TLS session resume ID
|
|
565
|
-
$tlsSessionId = $context->tlsSessionId;
|
|
566
|
-
if (!$deviceId && $tlsSessionId) {
|
|
567
|
-
$resumedDeviceId = $store->get("tls-session:{$tlsSessionId}");
|
|
568
|
-
if ($resumedDeviceId) {
|
|
569
|
-
$deviceId = $resumedDeviceId;
|
|
570
|
-
$this->log('Identity resumed via TLS Session ID', ['deviceId' => $deviceId, 'tlsSessionId' => $tlsSessionId]);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
if ($deviceId) {
|
|
575
|
-
$deviceData = $store->get("device:{$deviceId}");
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
if ($deviceData === null) {
|
|
579
|
-
// Nouvel utilisateur ou cookie perdu/invalide
|
|
580
|
-
$pendingDeviceId = $store->get("pending_cookie:{$context->clientIp}");
|
|
581
|
-
if ($pendingDeviceId && !$existingDeviceId) {
|
|
582
|
-
// La pénalité est maintenant ajoutée directement au vecteur de suspicion.
|
|
583
|
-
$this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
|
|
584
|
-
$suspicionVector['cookieDroppingScore'] = 100.0;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
$deviceId = bin2hex(random_bytes(16)); // UUID-like
|
|
588
|
-
|
|
589
|
-
$isHttps = !empty($context->isHttps);
|
|
590
|
-
$secureOption = $isHttps;
|
|
591
|
-
|
|
592
|
-
// Préparer le cookie à envoyer
|
|
593
|
-
$newCookie = [
|
|
594
|
-
'name' => 'device_id',
|
|
595
|
-
'value' => $deviceId,
|
|
596
|
-
'options' => [
|
|
597
|
-
'httponly' => true,
|
|
598
|
-
'secure' => $secureOption,
|
|
599
|
-
'samesite' => 'Strict',
|
|
600
|
-
'path' => '/',
|
|
601
|
-
]
|
|
602
|
-
];
|
|
603
|
-
if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
|
|
604
|
-
$newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
$store->set("pending_cookie:{$context->clientIp}", $deviceId, $pendingCookieTtl);
|
|
608
|
-
|
|
609
|
-
$deviceData = [
|
|
610
|
-
'initialDeviceHash' => $currentDeviceHash,
|
|
611
|
-
'ips' => [$context->clientIp],
|
|
612
|
-
'requestHistory' => [],
|
|
613
|
-
'lastUpdate' => time() * 1000,
|
|
614
|
-
'lastFpHash' => $currentDeviceHash,
|
|
615
|
-
'lastChangeTimestamp' => 0,
|
|
616
|
-
'rapidChangeCount' => 0,
|
|
617
|
-
'highScoreCount' => 0,
|
|
618
|
-
'lastHighScoreTimestamp' => 0,
|
|
619
|
-
];
|
|
620
|
-
} else {
|
|
621
|
-
// S'assurer que 'ips' est un tableau pour les opérations suivantes.
|
|
622
|
-
if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
|
|
623
|
-
$deviceData['ips'] = [];
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// Bind the current TLS session ID to the device ID
|
|
628
|
-
if ($deviceId && $tlsSessionId) {
|
|
629
|
-
$store->set("tls-session:{$tlsSessionId}", $deviceId, 3600); // 1h cache duration
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
$context->resolvedIdentity = [
|
|
633
|
-
'deviceId' => $deviceId,
|
|
634
|
-
'deviceData' => $deviceData,
|
|
635
|
-
'newCookie' => $newCookie,
|
|
636
|
-
];
|
|
637
|
-
return $context->resolvedIdentity;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* @return array<string, float>
|
|
642
|
-
*/
|
|
643
|
-
public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
|
|
644
|
-
{
|
|
645
|
-
$store = StoreManager::getStore();
|
|
646
|
-
$identity = $this->resolveRequestIdentity($context, $suspicionVector);
|
|
647
|
-
$deviceData = $identity['deviceData'];
|
|
648
|
-
$deviceId = $identity['deviceId'];
|
|
649
|
-
if ($deviceData && ($deviceData['condemned'] ?? false)) {
|
|
650
|
-
$suspicionVector['honeypotScore'] = 100;
|
|
651
|
-
return $suspicionVector;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// Si un nouveau cookie doit être défini, on le stocke temporairement dans le contexte
|
|
655
|
-
// pour que le code appelant puisse le gérer.
|
|
656
|
-
if ($identity['newCookie']) {
|
|
657
|
-
// Cette propriété n'est pas standard, on la préfixe pour éviter les conflits.
|
|
658
|
-
$context->newCookieForResponse = $identity['newCookie'];
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// Nettoyage périodique des données de l'appareil
|
|
662
|
-
if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
|
|
663
|
-
$deviceData['ips'] = [];
|
|
664
|
-
$deviceData['rapidChangeCount'] = 0;
|
|
665
|
-
$deviceData['ipTimes'] = [];
|
|
666
|
-
}
|
|
667
|
-
$deviceData['lastUpdate'] = time() * 1000;
|
|
668
|
-
|
|
669
|
-
// --- Calcul des différents scores de suspicion ---
|
|
670
|
-
|
|
671
|
-
// Score d'incohérence du fingerprint (déplacé ici pour être avec les autres)
|
|
672
|
-
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
673
|
-
$consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
|
|
674
|
-
$similarityThreshold = (float)($this->securityConfig['similarityThreshold'] ?? 0.72);
|
|
675
|
-
$inconsistencyScore = RequestUtils::calculateAnalogInconsistencyScore($consistencyScore, $similarityThreshold);
|
|
676
|
-
|
|
677
|
-
$behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
|
|
678
|
-
|
|
679
|
-
// Score des anomalies d'en-têtes
|
|
680
|
-
$headerAnomalies = RequestUtils::getHeaderAnomalies($context);
|
|
681
|
-
|
|
682
|
-
// Score de spoofing TLS
|
|
683
|
-
$tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
|
|
684
|
-
$tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
|
|
685
|
-
$virtualizationScore = RequestUtils::getVirtualizationAnomalyScore($context);
|
|
686
|
-
|
|
687
|
-
// Advanced JA4 TLS Inconsistency checks
|
|
688
|
-
$ja4 = $context->getHeader('x-ja4-hash');
|
|
689
|
-
if ($ja4) {
|
|
690
|
-
$spoofedJa4s = [
|
|
691
|
-
't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
|
|
692
|
-
't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
|
|
693
|
-
't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
|
|
694
|
-
't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
|
|
695
|
-
't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
|
|
696
|
-
't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
|
|
697
|
-
];
|
|
698
|
-
if (in_array($ja4, $spoofedJa4s, true)) {
|
|
699
|
-
$tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
$parsedJa4 = $this->parseJa4($ja4);
|
|
703
|
-
if ($parsedJa4) {
|
|
704
|
-
$ua = $context->getHeader('user-agent') ?? '';
|
|
705
|
-
$uaParts = $this->parseUserAgent($ua);
|
|
706
|
-
|
|
707
|
-
// Check 1: Incohérence ALPN / HTTP Version
|
|
708
|
-
$httpVersion = $context->httpVersion ?? '1.1';
|
|
709
|
-
if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
|
|
710
|
-
$hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
|
|
711
|
-
if (!$hasProxy) {
|
|
712
|
-
$tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Check 2: Incohérence OS/Plateforme vs Capabilities TLS
|
|
717
|
-
if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
|
|
718
|
-
$tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
// Check 3: Incohérence User-Agent vs Signature JA4
|
|
722
|
-
if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
|
|
723
|
-
$tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
|
|
724
|
-
}
|
|
725
|
-
if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
|
|
726
|
-
$tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
// Check 4: La stagnation (Lack of Entropy / Genericity)
|
|
730
|
-
if (is_string($uaParts['browser'])) {
|
|
731
|
-
$ja4Key = "ja4-browsers:{$ja4}";
|
|
732
|
-
$seenBrowsers = $store->get($ja4Key) ?: [];
|
|
733
|
-
if (!is_array($seenBrowsers)) {
|
|
734
|
-
$seenBrowsers = [];
|
|
735
|
-
}
|
|
736
|
-
$browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
|
|
737
|
-
if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
|
|
738
|
-
$seenBrowsers[] = $browserFamily;
|
|
739
|
-
$store->set($ja4Key, $seenBrowsers, 86400);
|
|
740
|
-
}
|
|
741
|
-
if (count($seenBrowsers) > 1) {
|
|
742
|
-
$tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
// Score d'incohérence temporelle (attaque par rejeu)
|
|
749
|
-
$timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
|
|
750
|
-
|
|
751
|
-
// Score des incohérences entre couches (client vs serveur)
|
|
752
|
-
$crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
|
|
753
|
-
|
|
754
|
-
// Score des patterns de requêtes (scraping, vélocité)
|
|
755
|
-
$requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
|
|
756
|
-
|
|
757
|
-
// Score des honeypots
|
|
758
|
-
$honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
|
|
759
|
-
|
|
760
|
-
// Score des métriques comportementales client (souris, clavier)
|
|
761
|
-
$behavior = RequestUtils::getBehaviorScore($context);
|
|
762
|
-
|
|
763
|
-
// Score de détection de bot explicite (marqueurs d'automatisation)
|
|
764
|
-
$bot = RequestUtils::getBotScore($context);
|
|
765
|
-
|
|
766
|
-
// Score de variance des clics
|
|
767
|
-
$clickVariance = RequestUtils::getClickVarianceScore($context);
|
|
768
|
-
|
|
769
|
-
// Score basé sur les listes de menaces (Threat Intelligence)
|
|
770
|
-
$threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
|
|
771
|
-
|
|
772
|
-
// Score d'incohérence des Client-Hints
|
|
773
|
-
$clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
|
|
774
|
-
|
|
775
|
-
// Score de similarité globale de l'empreinte (Clustering Botnet)
|
|
776
|
-
$stableFp = RequestUtils::extractStablePart($currentDeviceHash);
|
|
777
|
-
$stableFpHash = FingerprintBuilder::cyrb53($stableFp);
|
|
778
|
-
$botnetCluster = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
|
|
779
|
-
|
|
780
|
-
// NOUVEAU: Score de réputation du sous-réseau IP
|
|
781
|
-
$subnetScore = RequestUtils::getSubnetScore($context, $deviceId, $this->securityConfig);
|
|
782
|
-
|
|
783
|
-
// Score d'anomalie de pile TCP/IP
|
|
784
|
-
$tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
|
|
785
|
-
|
|
786
|
-
// Score d'anomalie de protocole (HTTP/2 et QUIC)
|
|
787
|
-
$protocolAnomaly = RequestUtils::getProtocolAnomalyScore($context);
|
|
788
|
-
|
|
789
|
-
// Score d'anomalie de rendu d'affichage (V-Sync)
|
|
790
|
-
$renderingAnomaly = RequestUtils::getRenderingAnomalyScore($context);
|
|
791
|
-
|
|
792
|
-
// Assemblage du vecteur de suspicion final
|
|
793
|
-
$suspicionVector = array_merge($suspicionVector, [
|
|
794
|
-
'inconsistencyScore' => $inconsistencyScore,
|
|
795
|
-
'historyScore' => $behavioral['historyScore'],
|
|
796
|
-
'rotationScore' => $behavioral['rotationScore'],
|
|
797
|
-
'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
|
|
798
|
-
'tlsSpoofingScore' => $tlsSpoofingScore,
|
|
799
|
-
'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
|
|
800
|
-
'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
|
|
801
|
-
'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
|
|
802
|
-
'honeypotScore' => $honeypot['honeypotScore'],
|
|
803
|
-
'behaviorScore' => $behavior['behaviorScore'],
|
|
804
|
-
'botScore' => $bot['botScore'],
|
|
805
|
-
'clickVarianceScore' => $clickVariance['clickVarianceScore'],
|
|
806
|
-
'threatIntelScore' => $threatIntel['threatIntelScore'],
|
|
807
|
-
'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
|
|
808
|
-
'subnetScore' => $subnetScore['subnetScore'],
|
|
809
|
-
'botnetClusterScore' => $botnetCluster['botnetClusterScore'],
|
|
810
|
-
'tcpAnomalyScore' => $tcpAnomaly['tcpAnomalyScore'],
|
|
811
|
-
'protocolAnomalyScore' => $protocolAnomaly['protocolAnomalyScore'],
|
|
812
|
-
'renderingAnomalyScore' => $renderingAnomaly['renderingAnomalyScore'],
|
|
813
|
-
'virtualizationScore' => $virtualizationScore
|
|
814
|
-
]);
|
|
815
|
-
|
|
816
|
-
// Sauvegarder l'état mis à jour de l'appareil dans le store
|
|
817
|
-
$store->set("device:{$deviceId}", $deviceData);
|
|
818
|
-
|
|
819
|
-
return $suspicionVector;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
/**
|
|
823
|
-
* Parse a basic User-Agent string.
|
|
824
|
-
*/
|
|
825
|
-
private function parseUserAgent(string $ua): array
|
|
826
|
-
{
|
|
827
|
-
$result = ['browser' => null, 'os' => null];
|
|
828
|
-
|
|
829
|
-
if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
|
|
830
|
-
$result['browser'] = 'Chrome';
|
|
831
|
-
if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
|
|
832
|
-
$result['browser'] .= '/' . $matches[1];
|
|
833
|
-
}
|
|
834
|
-
} elseif (str_contains($ua, 'Firefox')) {
|
|
835
|
-
$result['browser'] = 'Firefox';
|
|
836
|
-
if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
|
|
837
|
-
$result['browser'] .= '/' . $matches[1];
|
|
838
|
-
}
|
|
839
|
-
} elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
|
|
840
|
-
$result['browser'] = 'Safari';
|
|
841
|
-
if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
|
|
842
|
-
$result['browser'] .= '/' . $matches[1];
|
|
843
|
-
}
|
|
844
|
-
} elseif (str_contains($ua, 'Edg')) {
|
|
845
|
-
$result['browser'] = 'Edge';
|
|
846
|
-
if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
|
|
847
|
-
$result['browser'] .= '/' . $matches[1];
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
|
|
852
|
-
elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
|
|
853
|
-
elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
|
|
854
|
-
elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
|
|
855
|
-
elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
|
|
856
|
-
elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
|
|
857
|
-
|
|
858
|
-
return $result;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
/**
|
|
862
|
-
* Parse JA4 string into protocol, version, ALPN etc.
|
|
863
|
-
*/
|
|
864
|
-
private function parseJa4(?string $ja4): ?array
|
|
865
|
-
{
|
|
866
|
-
if (empty($ja4)) return null;
|
|
867
|
-
$parts = explode('_', $ja4);
|
|
868
|
-
$ja4a = $parts[0];
|
|
869
|
-
if (strlen($ja4a) < 10) return null;
|
|
870
|
-
return [
|
|
871
|
-
'protocol' => $ja4a[0],
|
|
872
|
-
'version' => substr($ja4a, 1, 2),
|
|
873
|
-
'sni' => $ja4a[3],
|
|
874
|
-
'ciphersCount' => (int)substr($ja4a, 4, 2),
|
|
875
|
-
'extensionsCount' => (int)substr($ja4a, 6, 2),
|
|
876
|
-
'alpn' => substr($ja4a, 8, 2),
|
|
877
|
-
'ja4b' => $parts[1] ?? null,
|
|
878
|
-
'ja4c' => $parts[2] ?? null
|
|
879
|
-
];
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
private function decodePolymorphicFingerprint(string $fpString, array $mapping): string
|
|
884
|
-
{
|
|
885
|
-
if (empty($mapping['keys'])) {
|
|
886
|
-
return $fpString;
|
|
887
|
-
}
|
|
888
|
-
$reverseKeys = array_flip($mapping['keys']);
|
|
889
|
-
$parts = explode('|', $fpString);
|
|
890
|
-
$mappedParts = [];
|
|
891
|
-
foreach ($parts as $part) {
|
|
892
|
-
$pair = explode(':', $part, 2);
|
|
893
|
-
if (count($pair) === 2) {
|
|
894
|
-
$origKey = $reverseKeys[$pair[0]] ?? $pair[0];
|
|
895
|
-
$mappedParts[] = "{$origKey}:{$pair[1]}";
|
|
896
|
-
} else {
|
|
897
|
-
$mappedParts[] = $part;
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
return implode('|', $mappedParts);
|
|
901
|
-
}
|
|
902
|
-
private function translatePolymorphicHeaders(RequestContext $context): void
|
|
903
|
-
{
|
|
904
|
-
$store = StoreManager::getStore();
|
|
905
|
-
$activeMappings = $store->get('active-polymorphic-mappings') ?: [];
|
|
906
|
-
if (!is_array($activeMappings)) {
|
|
907
|
-
return;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
foreach ($activeMappings as $mapping) {
|
|
911
|
-
$devFpHeader = strtolower($mapping['headers']['x-device-fingerprint'] ?? '');
|
|
912
|
-
$behaviorHeader = strtolower($mapping['headers']['x-behavior-metrics'] ?? '');
|
|
913
|
-
|
|
914
|
-
if (!empty($devFpHeader) && isset($context->headers[$devFpHeader])) {
|
|
915
|
-
$context->headers['x-device-fingerprint'] = $context->headers[$devFpHeader];
|
|
916
|
-
if (!empty($behaviorHeader) && isset($context->headers[$behaviorHeader])) {
|
|
917
|
-
$context->headers['x-behavior-metrics'] = $context->headers[$behaviorHeader];
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
$clientFp = $context->headers['x-device-fingerprint'];
|
|
921
|
-
if ($clientFp && is_string($clientFp)) {
|
|
922
|
-
$context->headers['x-device-fingerprint'] = $this->decodePolymorphicFingerprint($clientFp, $mapping);
|
|
923
|
-
}
|
|
924
|
-
break;
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
/**
|
|
930
|
-
* Évalue si la requête présente des caractéristiques d'attaque flagrantes ou a déclenché un honeypot.
|
|
931
|
-
*/
|
|
932
|
-
private function hasCertainAttack(RequestContext $context): bool
|
|
933
|
-
{
|
|
934
|
-
$honeypotConfig = $this->securityConfig['honeypot'] ?? [];
|
|
935
|
-
$honeypot = RequestUtils::getHoneypotScore($context, $honeypotConfig);
|
|
936
|
-
if (($honeypot['honeypotScore'] ?? 0.0) >= 100.0) {
|
|
937
|
-
return true;
|
|
938
|
-
}
|
|
939
|
-
$bot = RequestUtils::getBotScore($context);
|
|
940
|
-
if (($bot['botScore'] ?? 0.0) >= 100.0) {
|
|
941
|
-
return true;
|
|
942
|
-
}
|
|
943
|
-
return false;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
/**
|
|
947
|
-
* Traite une requête entrante et retourne une décision.
|
|
948
|
-
* @param RequestContext $context Le contexte de la requête.
|
|
949
|
-
* @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
|
|
950
|
-
*/
|
|
951
|
-
public function processRequest(RequestContext $context): array
|
|
952
|
-
{
|
|
1180
|
+
/**
|
|
1181
|
+
* Processes an incoming request and returns a decision (action to take).
|
|
1182
|
+
* This is the main entry point of the engine: it resolves identity,
|
|
1183
|
+
* checks allowlists/tickets, computes the suspicion score, verifies PoW
|
|
1184
|
+
* submissions, selects and issues challenges, and applies block/allow logic.
|
|
1185
|
+
*
|
|
1186
|
+
* @param RequestContext $context The request context.
|
|
1187
|
+
* @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
|
|
1188
|
+
*/
|
|
1189
|
+
public function processRequest(RequestContext $context): array
|
|
1190
|
+
{
|
|
1191
|
+
$store = StoreManager::getStore();
|
|
953
1192
|
$this->translatePolymorphicHeaders($context);
|
|
954
1193
|
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1194
|
+
// Initialize the suspicion vector to avoid type errors.
|
|
1195
|
+
$suspicionVector = [];
|
|
1196
|
+
|
|
1197
|
+
$coopOp = $context->query['coop_op'] ?? null;
|
|
1198
|
+
if ($coopOp) {
|
|
1199
|
+
$result = ChallengeUtils::handleCooperativeRequest($context->query, $context->clientIp, $this->securityConfig);
|
|
1200
|
+
return [
|
|
1201
|
+
'action' => 'challenge',
|
|
1202
|
+
'status' => 200,
|
|
1203
|
+
'body' => $result
|
|
1204
|
+
];
|
|
1205
|
+
}
|
|
967
1206
|
|
|
968
1207
|
$this->log('Processing request', ['clientIp' => $context->clientIp, 'path' => $context->path]);
|
|
969
|
-
|
|
1208
|
+
|
|
970
1209
|
// Parse GraphQL query if applicable
|
|
971
1210
|
if ($context->path === '/graphql' && !empty($context->body)) {
|
|
972
1211
|
$gqlInfo = RequestUtils::parseGraphQLQuery(is_array($context->body) ? $context->body : []);
|
|
@@ -974,777 +1213,949 @@
|
|
|
974
1213
|
$context->graphqlOperation = $gqlInfo;
|
|
975
1214
|
}
|
|
976
1215
|
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1216
|
+
|
|
1217
|
+
// 0. Resolve device identity and validate WebAuthn hardware attestation
|
|
1218
|
+
$identity = $this->resolveRequestIdentity($context, $suspicionVector);
|
|
1219
|
+
$deviceId = $identity['deviceId'];
|
|
1220
|
+
$deviceData = $identity['deviceData'];
|
|
1221
|
+
|
|
1222
|
+
// Anti-Farm Rule: A condemned device or one conducting a certain attack gets no bypass
|
|
1223
|
+
if (($deviceData['condemned'] ?? false) || $this->hasCertainAttack($context)) {
|
|
1224
|
+
if (is_array($deviceData)) {
|
|
1225
|
+
$deviceData['condemned'] = true;
|
|
1226
|
+
$deviceData['webauthnVerified'] = false;
|
|
1227
|
+
$store->set("device:{$deviceId}", $deviceData);
|
|
1228
|
+
}
|
|
1229
|
+
$this->log('Condemned device or attack detected - revoking hardware trust and blocking', ['deviceId' => $deviceId]);
|
|
1230
|
+
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100.0, 'vector' => ['honeypotScore' => 100.0]];
|
|
1231
|
+
if ($this->dryRun) {
|
|
1232
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1233
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1234
|
+
$decision['action'] = 'next';
|
|
1235
|
+
unset($decision['status'], $decision['body']);
|
|
1236
|
+
}
|
|
1237
|
+
return $decision;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Instant bypass if the device has cryptographically proven its hardware identity (Secure Enclave / TPM)
|
|
1241
|
+
if ($deviceData && !empty($deviceData['webauthnVerified'])) {
|
|
1242
|
+
$this->log('Hardware-anchored device verified (WebAuthn) - full bypass granted', ['deviceId' => $deviceId]);
|
|
1243
|
+
$greenlistTtl = 365 * 86400 * 1000; // 1 year (permanent)
|
|
1244
|
+
$greenlistTicket = ChallengeUtils::generateStatelessTicket([
|
|
1245
|
+
'expiry' => (int)floor(microtime(true) * 1000) + $greenlistTtl,
|
|
1246
|
+
'originalIp' => $context->clientIp,
|
|
1247
|
+
'deviceId' => $deviceId,
|
|
1248
|
+
'deviceHash' => 'webauthn:greenlist:' . ($deviceData['webauthnCredentialId'] ?? $deviceId),
|
|
1249
|
+
'greenlist' => true
|
|
1250
|
+
]);
|
|
1251
|
+
|
|
1252
|
+
$isHttps = !empty($context->isHttps);
|
|
1253
|
+
$decision = [
|
|
1254
|
+
'action' => 'next',
|
|
1255
|
+
'score' => 0.0,
|
|
1256
|
+
'vector' => ['webauthn_verified' => 100.0],
|
|
1257
|
+
'cookie' => [
|
|
1258
|
+
'name' => 'pow_clearance',
|
|
1259
|
+
'value' => $greenlistTicket,
|
|
1260
|
+
'options' => [
|
|
1261
|
+
'httponly' => true,
|
|
1262
|
+
'secure' => $isHttps,
|
|
1263
|
+
'samesite' => 'Strict',
|
|
1264
|
+
'expires' => time() + ($greenlistTtl / 1000),
|
|
1265
|
+
'path' => '/',
|
|
1266
|
+
]
|
|
1267
|
+
]
|
|
1268
|
+
];
|
|
1269
|
+
if (isset($context->newCookieForResponse)) {
|
|
1270
|
+
$decision['newCookieForResponse'] = $context->newCookieForResponse;
|
|
1271
|
+
}
|
|
1272
|
+
return $decision;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// --- PRIVATE ACCESS TOKENS (PAT / RFC 9505 & Privacy Pass) ZERO-FRICTION BYPASS ---
|
|
1276
|
+
$rawPatTokens = RequestUtils::extractPrivateAccessTokens($context);
|
|
1277
|
+
if (!empty($rawPatTokens)) {
|
|
1278
|
+
$patConfig = $this->securityConfig['pat'] ?? [];
|
|
1279
|
+
foreach ($rawPatTokens as $rawToken) {
|
|
1280
|
+
$parsedToken = RequestUtils::parsePrivateAccessToken($rawToken);
|
|
1281
|
+
if ($parsedToken !== null) {
|
|
1282
|
+
$nonceKey = "pat-nonce:{$parsedToken['nonce']}";
|
|
1283
|
+
if (!$store->has($nonceKey)) {
|
|
1284
|
+
$isValid = RequestUtils::verifyPatSignature($parsedToken, $patConfig);
|
|
1285
|
+
if ($isValid) {
|
|
1286
|
+
$nonceTtl = $patConfig['nonceTtl'] ?? 86400;
|
|
1287
|
+
$store->set($nonceKey, true, $nonceTtl);
|
|
1288
|
+
$this->log('Private Access Token (PAT) cryptographically verified - granting zero-friction bypass', [
|
|
1289
|
+
'tokenType' => $parsedToken['tokenType'],
|
|
1290
|
+
'tokenKeyId' => $parsedToken['tokenKeyId']
|
|
1291
|
+
]);
|
|
1292
|
+
$decision = [
|
|
1293
|
+
'action' => 'next',
|
|
1294
|
+
'score' => 0.0,
|
|
1295
|
+
'vector' => ['pat_verified' => 100.0, 'privacy_pass' => 100.0]
|
|
1296
|
+
];
|
|
1297
|
+
return $decision;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// 1. Check allowlists
|
|
1305
|
+
if ($this->checkAllowlists($context)) {
|
|
1306
|
+
MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
|
|
1307
|
+
return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Initialize the suspicion vector
|
|
1311
|
+
$thresholds = $this->securityConfig['thresholds'];
|
|
1312
|
+
|
|
1313
|
+
// 2. Handle challenge solution submission (HIGH PRIORITY)
|
|
1314
|
+
$powNonce = $context->query['pow_nonce'] ?? null;
|
|
1315
|
+
$isChallengeSubmission = $powNonce && (
|
|
1316
|
+
isset($context->query['pow_solution']) ||
|
|
1317
|
+
isset($context->query['pow_solution_cpu']) ||
|
|
1318
|
+
isset($context->query['pow_solution_space']) ||
|
|
1319
|
+
(isset($context->query['pow_type']) && $context->query['pow_type'] === 'useful_work_task')
|
|
1320
|
+
);
|
|
1321
|
+
if ($isChallengeSubmission) {
|
|
1322
|
+
$this->log('Challenge solution submitted', ['pow_type' => $context->query['pow_type'] ?? 'unknown', 'nonce' => $powNonce]);
|
|
1323
|
+
$store = StoreManager::getStore();
|
|
1324
|
+
$challengeContext = $store->get("secret:{$powNonce}");
|
|
1325
|
+
$powType = $context->query['pow_type'] ?? null;
|
|
1326
|
+
|
|
1327
|
+
if ($challengeContext) {
|
|
1328
|
+
$isValid = false;
|
|
1329
|
+
$ticket = null;
|
|
1330
|
+
|
|
1331
|
+
// Fingerprint consistency check
|
|
1332
|
+
$solverFingerprint = $context->query['pow_fp'] ?? RequestUtils::getCompositeDeviceHash($context);
|
|
1333
|
+
$originalFingerprint = $challengeContext['fingerprint'] ?? '';
|
|
1334
|
+
$similarity = FingerprintBuilder::compare($originalFingerprint, $solverFingerprint);
|
|
1335
|
+
$similarityThreshold = $this->securityConfig['similarityThreshold'] ?? 0.95;
|
|
1336
|
+
|
|
1337
|
+
if ($similarity < $similarityThreshold) {
|
|
1338
|
+
$this->log('Fingerprint mismatch - challenge solved on a different machine!', [
|
|
1339
|
+
'similarity' => round($similarity, 4),
|
|
1340
|
+
'threshold' => $similarityThreshold
|
|
1341
|
+
], 'warn');
|
|
1342
|
+
$isValid = false;
|
|
1343
|
+
} else {
|
|
1344
|
+
// The fingerprint is consistent, the solution can be verified
|
|
1345
|
+
if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
|
|
1346
|
+
$cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
|
|
1347
|
+
if ($cpuSolution !== null && $cpuSolution !== '') {
|
|
1348
|
+
$identity = $this->resolveRequestIdentity($context, $suspicionVector);
|
|
1349
|
+
$deviceId = (string)($identity['deviceId'] ?? '');
|
|
1350
|
+
$currentDeviceHash = (string)($identity['currentDeviceHash'] ?? RequestUtils::getCompositeDeviceHash($context));
|
|
1351
|
+
|
|
1352
|
+
$ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket(
|
|
1353
|
+
$context->clientIp,
|
|
1354
|
+
3600000,
|
|
1355
|
+
$powNonce,
|
|
1356
|
+
(string)$cpuSolution,
|
|
1357
|
+
$challengeContext,
|
|
1358
|
+
$deviceId,
|
|
1359
|
+
$currentDeviceHash
|
|
1360
|
+
);
|
|
1361
|
+
$isValid = $ticket !== null;
|
|
1362
|
+
|
|
1363
|
+
if ($powType === 'cpu_mem') {
|
|
1364
|
+
if (!empty($challengeContext['isHttp'])) {
|
|
1365
|
+
$isMemValid = true;
|
|
1366
|
+
} else {
|
|
1367
|
+
$memSolution = $context->query['pow_solution_mem'] ?? null;
|
|
1368
|
+
$isMemValid = ($memSolution !== null && $memSolution !== '')
|
|
1369
|
+
? ChallengeUtils::verifyMemoryPoW(
|
|
1370
|
+
$powNonce,
|
|
1371
|
+
(string)$memSolution,
|
|
1372
|
+
$challengeContext['memDifficulty'] ?? 0,
|
|
1373
|
+
$challengeContext['clientSecret'] ?? ''
|
|
1374
|
+
)
|
|
1375
|
+
: false;
|
|
1376
|
+
}
|
|
1377
|
+
$isValid = $isValid && $isMemValid;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
} elseif ($powType === 'pospace') {
|
|
1381
|
+
$powSolutionSpace = $context->query['pow_solution_space'] ?? null;
|
|
1382
|
+
if ($powSolutionSpace && isset($challengeContext['queries'])) {
|
|
1383
|
+
$isSpaceValid = ChallengeUtils::verifySpacePoW(
|
|
1384
|
+
$powNonce,
|
|
1385
|
+
$powSolutionSpace,
|
|
1386
|
+
$challengeContext['queries'],
|
|
1387
|
+
$powNonce . ":" . $challengeContext['clientSecret'],
|
|
1388
|
+
$challengeContext['clientSecret']
|
|
1389
|
+
);
|
|
1390
|
+
$isValid = $isSpaceValid;
|
|
1391
|
+
if ($isValid) {
|
|
1392
|
+
$ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
|
|
1393
|
+
$expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
|
|
1394
|
+
$ticket = ChallengeUtils::generateStatelessTicket([
|
|
1395
|
+
'expiry' => $expiry,
|
|
1396
|
+
'originalIp' => $context->clientIp,
|
|
1397
|
+
'deviceId' => '',
|
|
1398
|
+
'deviceHash' => ''
|
|
1399
|
+
]);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
} elseif ($powType === 'useful_work_task') {
|
|
1403
|
+
$problemId = $context->query['pow_problem_id'] ?? null;
|
|
1404
|
+
$workResultJson = $context->query['pow_solution_work_result'] ?? null;
|
|
1405
|
+
if ($problemId && $workResultJson) {
|
|
1406
|
+
$workResult = json_decode($workResultJson, true);
|
|
1407
|
+
$this->log('Verifying useful work solution.', [
|
|
1408
|
+
'problemId' => $problemId,
|
|
1409
|
+
'receivedData' => $workResult,
|
|
1410
|
+
'jsonLastError' => json_last_error_msg()
|
|
1411
|
+
]);
|
|
1412
|
+
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';
|
|
1089
1415
|
$configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
|
|
1090
1416
|
$problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1417
|
+
// FIX: The solution is directly $workResult, not a sub-property.
|
|
1418
|
+
$problemManager->integrateSolution($problemId, $workResult);
|
|
1419
|
+
|
|
1420
|
+
// If the solved problem is security auto-tuning and auto-tuning is enabled,
|
|
1421
|
+
// we directly apply the best computed solution to the live engine.
|
|
1422
|
+
if ($problemId === 'security_auto_tuning' && ($this->securityConfig['autotuning']['enabled'] ?? false)) {
|
|
1423
|
+
$paretoFront = $workResult['paretoFront'] ?? null;
|
|
1424
|
+
if (is_array($paretoFront) && !empty($paretoFront)) {
|
|
1425
|
+
$bestSolution = $paretoFront[0];
|
|
1426
|
+
$minDistance = sqrt(pow((float)$bestSolution['objectives'][0], 2) + pow((float)$bestSolution['objectives'][1], 2));
|
|
1427
|
+
for ($i = 1; $i < count($paretoFront); $i++) {
|
|
1428
|
+
$distance = sqrt(pow((float)$paretoFront[$i]['objectives'][0], 2) + pow((float)$paretoFront[$i]['objectives'][1], 2));
|
|
1429
|
+
if ($distance < $minDistance) {
|
|
1430
|
+
$minDistance = $distance;
|
|
1431
|
+
$bestSolution = $paretoFront[$i];
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
if (isset($bestSolution['solution'])) {
|
|
1435
|
+
$this->updateConfig($bestSolution['solution']);
|
|
1436
|
+
$this->log('Useful Work auto-tuning applied successfully to live config.');
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
$isValid = true;
|
|
1442
|
+
// FIX: Generate a real ticket for uPoW, like for a normal PoW.
|
|
1443
|
+
$ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000; // 1 hour by default
|
|
1444
|
+
$expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
|
|
1445
|
+
$signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", ChallengeUtils::getPowSecret());
|
|
1446
|
+
$ticket = "{$expiry}:{$signature}";
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
if ($isValid) {
|
|
1453
|
+
$store->delete("secret:{$powNonce}");
|
|
1454
|
+
$ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
|
|
1455
|
+
MetricsManager::incrementCounter('challenges_solved_total');
|
|
1456
|
+
$this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
|
|
1457
|
+
|
|
1458
|
+
$isHttps = !empty($context->isHttps);
|
|
1459
|
+
$secureOption = $isHttps;
|
|
1460
|
+
|
|
1461
|
+
return [
|
|
1462
|
+
'action' => 'redirect',
|
|
1463
|
+
'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
|
|
1464
|
+
'score' => 0.0,
|
|
1465
|
+
'vector' => ['challenge_solved' => 100],
|
|
1466
|
+
'cookie' => [
|
|
1467
|
+
'name' => 'pow_clearance',
|
|
1468
|
+
'value' => $ticket,
|
|
1469
|
+
'options' => [
|
|
1470
|
+
'httponly' => true,
|
|
1471
|
+
'secure' => $secureOption,
|
|
1472
|
+
'samesite' => 'Strict',
|
|
1473
|
+
'expires' => time() + ($ticketTtl / 1000),
|
|
1474
|
+
'path' => '/',
|
|
1475
|
+
]
|
|
1476
|
+
]
|
|
1477
|
+
];
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
// If the solution is invalid or the nonce is expired, we strongly penalize it for the following steps.
|
|
1155
1481
|
MetricsManager::incrementCounter('challenges_failed_total');
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1482
|
+
$this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
|
|
1483
|
+
$suspicionVector['honeypotScore'] = 100.0;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// 3. Check an existing ticket
|
|
1487
|
+
$hasValidTicket = false;
|
|
1488
|
+
$powCookie = $context->cookies['pow_clearance'] ?? null;
|
|
1489
|
+
$zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
|
|
1490
|
+
$deviceId = $context->cookies['device_id'] ?? '';
|
|
1491
|
+
$currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
|
|
1492
|
+
$allowRoaming = $this->securityConfig['allowCrossNetworkRoaming'] ?? false;
|
|
1493
|
+
if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof)) {
|
|
1494
|
+
$hasValidTicket = true;
|
|
1169
1495
|
MetricsManager::incrementCounter('tickets_valid_total');
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1496
|
+
// We do not return immediately to allow re-challenging.
|
|
1497
|
+
// $this->log('Valid clearance ticket found');
|
|
1498
|
+
// return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// 4. Compute the suspicion vector and score
|
|
1502
|
+
// Resolve identity and check "condemned" status
|
|
1503
|
+
$store = StoreManager::getStore();
|
|
1504
|
+
$identity = $this->resolveRequestIdentity($context, $suspicionVector);
|
|
1505
|
+
$deviceId = $identity['deviceId'];
|
|
1506
|
+
$deviceData = $identity['deviceData'];
|
|
1507
|
+
if ($deviceData && ($deviceData['condemned'] ?? false)) {
|
|
1508
|
+
$this->log('Device condemned - blocking request', ['deviceId' => $deviceId], 'warn');
|
|
1509
|
+
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
|
|
1510
|
+
if ($this->dryRun) {
|
|
1511
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1512
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1513
|
+
$decision['action'] = 'next';
|
|
1514
|
+
unset($decision['status'], $decision['body']);
|
|
1515
|
+
}
|
|
1516
|
+
return $decision;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
$suspicionVector = $context->preCalculatedVector ?? $this->getSuspicionVector($context, $suspicionVector);
|
|
1520
|
+
$finalScore = $context->preCalculatedScore ?? $this->calculateFinalScore($suspicionVector);
|
|
1521
|
+
$this->log('Suspicion vector and final score calculated', [
|
|
1522
|
+
'finalScore' => round($finalScore, 2),
|
|
1523
|
+
'vector' => $suspicionVector
|
|
1524
|
+
]);
|
|
1525
|
+
|
|
1526
|
+
// Update subnet metrics after computing the final score
|
|
1527
|
+
if ($finalScore >= ($thresholds['medium'] ?? 45)) {
|
|
1202
1528
|
RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
|
|
1203
1529
|
MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// Logic to challenge new devices (moved here to have the final score)
|
|
1533
|
+
$isNewDevice = $identity['newCookie'] !== null;
|
|
1534
|
+
if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
|
|
1535
|
+
$this->log('New device - enforcing minimum challenge score', [
|
|
1536
|
+
'originalScore' => round($finalScore, 2),
|
|
1537
|
+
'enforcedScore' => (float)$thresholds['low']
|
|
1538
|
+
]);
|
|
1539
|
+
$finalScore = (float)$thresholds['low'];
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// Check trap URLs (after score computation)
|
|
1543
|
+
$lastNonce = $deviceData['lastChallengeNonce'] ?? null;
|
|
1544
|
+
if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
|
|
1545
|
+
if ($this->logger) {
|
|
1546
|
+
$this->logger->log('info', 'trap_triggered', ['deviceId' => $deviceId, 'score' => 100, 'path' => $context->path, 'vector' => ['honeypotScore' => 100]]);
|
|
1547
|
+
}
|
|
1548
|
+
$this->log('Honeypot trap URL triggered - condemning device', ['path' => $context->path, 'deviceId' => $deviceId]);
|
|
1549
|
+
$deviceData['condemned'] = true; // @phpstan-ignore-line
|
|
1550
|
+
$store->set("device:{$deviceId}", $deviceData);
|
|
1551
|
+
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
|
|
1552
|
+
if ($this->dryRun) {
|
|
1553
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1554
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1555
|
+
$decision['action'] = 'next';
|
|
1556
|
+
unset($decision['status'], $decision['body']);
|
|
1557
|
+
}
|
|
1558
|
+
return $decision;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// 5. Decide based on the score - Check blocking first.
|
|
1562
|
+
$blockThreshold = $thresholds['block'] ?? 95;
|
|
1563
|
+
$isBlocked = $finalScore >= $blockThreshold;
|
|
1564
|
+
if ($isBlocked) {
|
|
1565
|
+
if ($this->logger) {
|
|
1240
1566
|
MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1567
|
+
$this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Federated Threat Intelligence & ZKP Synchronization
|
|
1571
|
+
$zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
|
|
1572
|
+
$parts = explode(':', $zkpProof);
|
|
1573
|
+
if (count($parts) === 3) {
|
|
1574
|
+
$zkpY = $parts[0];
|
|
1575
|
+
$zkpT = $parts[1];
|
|
1576
|
+
$zkpS = $parts[2];
|
|
1577
|
+
// 1. Cryptographically validate the proof before banning/broadcasting
|
|
1578
|
+
if (ChallengeUtils::verifyZkpProof($zkpY, $zkpT, $zkpS)) {
|
|
1579
|
+
// 2. De-duplicate: only broadcast if the key is not already banned
|
|
1580
|
+
$peersKey = "fed-peers:{$zkpY}";
|
|
1581
|
+
$reportedPeers = $store->get($peersKey) ?: [];
|
|
1582
|
+
if (!is_array($reportedPeers)) {
|
|
1583
|
+
$reportedPeers = [];
|
|
1584
|
+
}
|
|
1585
|
+
if (!in_array('local', $reportedPeers, true)) {
|
|
1586
|
+
$reportedPeers[] = 'local';
|
|
1587
|
+
$store->set($peersKey, $reportedPeers, 86400 * 30);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
$threshold = $this->securityConfig['federationConsensusThreshold'] ?? 3;
|
|
1591
|
+
if (count($reportedPeers) >= $threshold) {
|
|
1592
|
+
$store->set("banned-zkp-y:{$zkpY}", true, 86400 * 30);
|
|
1593
|
+
}
|
|
1594
|
+
$this->broadcastBannedZkp($zkpY);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
|
|
1599
|
+
if ($this->dryRun) {
|
|
1600
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1601
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1602
|
+
$decision['action'] = 'next';
|
|
1603
|
+
unset($decision['status'], $decision['body']);
|
|
1278
1604
|
MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1605
|
+
}
|
|
1606
|
+
$response = $decision;
|
|
1607
|
+
} else {
|
|
1608
|
+
// Re-challenge logic
|
|
1609
|
+
// If a nonce is present but it is not a valid solution submission, it is a probe.
|
|
1610
|
+
if ($powNonce && !$isChallengeSubmission) {
|
|
1611
|
+
$this->log('Honeypot probe detected - blocking request', ['path' => $context->path, 'pow_nonce' => $powNonce]);
|
|
1612
|
+
$suspicionVector['honeypotScore'] = 100.0;
|
|
1613
|
+
$finalScore = $this->calculateFinalScore($suspicionVector); // Recalculate score
|
|
1614
|
+
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
|
|
1615
|
+
// Apply dry run logic here as well
|
|
1616
|
+
if ($this->dryRun) {
|
|
1291
1617
|
MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1618
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1619
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1620
|
+
$decision['action'] = 'next';
|
|
1621
|
+
unset($decision['status'], $decision['body']);
|
|
1622
|
+
}
|
|
1623
|
+
return $decision;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
$mediumThreshold = $thresholds['medium'] ?? 45;
|
|
1627
|
+
$maxIndicatorsCount = 0;
|
|
1628
|
+
foreach ($suspicionVector as $val) {
|
|
1629
|
+
if (is_numeric($val) && (float)$val >= 100.0) {
|
|
1630
|
+
$maxIndicatorsCount++;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
$mustReChallenge = (($suspicionVector['honeypotScore'] ?? 0.0) >= $mediumThreshold) || ($maxIndicatorsCount >= 1);
|
|
1634
|
+
|
|
1635
|
+
$lowThreshold = $thresholds['low'] ?? 20;
|
|
1636
|
+
if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
|
|
1637
|
+
if ($mustReChallenge) {
|
|
1638
|
+
$this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// --- ADDED: Rate limiter (Token Bucket) ---
|
|
1642
|
+
$rateLimitPassed = ChallengeUtils::checkChallengeRateLimit($context->clientIp);
|
|
1643
|
+
if (!$rateLimitPassed) {
|
|
1644
|
+
$this->log('Challenge rate limit exceeded - blocking with 429', ['clientIp' => $context->clientIp]);
|
|
1645
|
+
$decision = [
|
|
1646
|
+
'action' => 'block',
|
|
1647
|
+
'status' => 429,
|
|
1648
|
+
'body' => 'Too Many Requests',
|
|
1649
|
+
'score' => $finalScore,
|
|
1650
|
+
'vector' => $suspicionVector
|
|
1651
|
+
];
|
|
1652
|
+
if ($this->dryRun) {
|
|
1653
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1654
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1655
|
+
$decision['action'] = 'next';
|
|
1656
|
+
unset($decision['status'], $decision['body']);
|
|
1657
|
+
}
|
|
1658
|
+
return $decision;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
$decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
|
|
1662
|
+
|
|
1663
|
+
if ($this->dryRun) {
|
|
1338
1664
|
MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_challenge']);
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1665
|
+
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
1666
|
+
$decision['intendedAction'] = $decision['action'];
|
|
1667
|
+
$decision['action'] = 'next';
|
|
1668
|
+
unset($decision['status']);
|
|
1669
|
+
return $decision;
|
|
1670
|
+
}
|
|
1345
1671
|
|
|
1346
1672
|
MetricsManager::incrementCounter('requests_total', ['status' => 'challenged']);
|
|
1347
|
-
|
|
1673
|
+
$this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
|
|
1348
1674
|
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1675
|
+
$nonce = bin2hex(random_bytes(16));
|
|
1676
|
+
$clientSecret = bin2hex(random_bytes(16));
|
|
1677
|
+
$highThreshold = $thresholds['high'] ?? 75;
|
|
1352
1678
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1679
|
+
$suspicionFactor = ($finalScore - $lowThreshold) / (($thresholds['high'] ?? 75) - $lowThreshold);
|
|
1680
|
+
$suspicionFactor = max(0, min(1.5, $suspicionFactor));
|
|
1355
1681
|
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1682
|
+
// --- NEW uPoW LOGIC ---
|
|
1683
|
+
$shouldUseUsefulWork = ($this->securityConfig['enableUsefulWork'] ?? false) && (
|
|
1684
|
+
($this->securityConfig['forceUsefulWork'] ?? false) || (random_int(0, 255) / 255) > 0.5
|
|
1685
|
+
);
|
|
1360
1686
|
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1687
|
+
// Determine if it is an API request before choosing the challenge type
|
|
1688
|
+
$isApiRequest = false;
|
|
1689
|
+
if (isset($this->securityConfig['isApiRequest']) && is_callable($this->securityConfig['isApiRequest'])) {
|
|
1690
|
+
$isApiRequest = ($this->securityConfig['isApiRequest'])($context);
|
|
1691
|
+
}
|
|
1366
1692
|
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1693
|
+
if ($shouldUseUsefulWork) {
|
|
1694
|
+
$this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
|
|
1695
|
+
$defaultPath = dirname(__DIR__, 1) . '/config/problems.config.json';
|
|
1370
1696
|
$configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
|
|
1371
1697
|
$problemManager = ProblemManager::getInstance($configPath, $store);
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1698
|
+
$work = $problemManager->dispatchWork($finalScore);
|
|
1699
|
+
|
|
1700
|
+
if ($work !== null) {
|
|
1701
|
+
$store->set("secret:{$nonce}", ['clientSecret' => $clientSecret, 'originalPath' => $context->path], 300);
|
|
1702
|
+
$challengePayload = [
|
|
1703
|
+
'challenge' => [
|
|
1704
|
+
'type' => 'useful_work_task',
|
|
1705
|
+
'nonce' => $nonce,
|
|
1706
|
+
'clientSecret' => $clientSecret,
|
|
1707
|
+
'usefulWorkTask' => [
|
|
1708
|
+
'problemId' => $work['problemId'],
|
|
1709
|
+
'task' => $work['task']
|
|
1710
|
+
]
|
|
1711
|
+
]
|
|
1712
|
+
];
|
|
1713
|
+
if ($isApiRequest) {
|
|
1714
|
+
$decision['body'] = $challengePayload;
|
|
1715
|
+
return $decision;
|
|
1716
|
+
} else {
|
|
1717
|
+
$html = '<html><body><script>';
|
|
1718
|
+
$html .= 'window.location.href = "' . $context->path . '?pow_type=useful_work_task&pow_nonce=' . $nonce . '&pow_problem_id=' . $work['problemId'] . '&pow_solution_work_result=" + encodeURIComponent(JSON.stringify({"solution": [], "energy": 0}));';
|
|
1719
|
+
$html .= '</script></body></html>';
|
|
1720
|
+
$decision['body'] = $html;
|
|
1721
|
+
return $decision;
|
|
1722
|
+
}
|
|
1723
|
+
} else {
|
|
1724
|
+
// This case handles when uPoW is enabled but dispatching a task fails (e.g., config not found).
|
|
1725
|
+
// We log it and fall through to the standard PoW challenge.
|
|
1726
|
+
$this->log('Useful work dispatch failed, falling back to standard PoW.', [], 'warn');
|
|
1727
|
+
$shouldUseUsefulWork = false; // Explicitly disable for this request
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// --- END OF uPoW LOGIC (the rest is the fallback) ---
|
|
1732
|
+
|
|
1733
|
+
if ($this->securityConfig['enableProofOfSpace'] ?? false) {
|
|
1734
|
+
$spaceChallenge = ChallengeUtils::generateSpaceChallenge($context->clientIp, $nonce, $suspicionFactor, $context->path, $this->securityConfig);
|
|
1735
|
+
$store->set("secret:{$nonce}", [
|
|
1736
|
+
'clientSecret' => $clientSecret,
|
|
1737
|
+
'suspicionScore' => $finalScore,
|
|
1738
|
+
'queries' => $spaceChallenge['queries'],
|
|
1739
|
+
'sizeMb' => $spaceChallenge['sizeMb'],
|
|
1740
|
+
'fingerprint' => RequestUtils::getCompositeDeviceHash($context),
|
|
1741
|
+
'originalPath' => $context->path,
|
|
1742
|
+
], $this->securityConfig['challengeTtl'] ?? 300);
|
|
1743
|
+
|
|
1744
|
+
if ($deviceData) {
|
|
1745
|
+
$deviceData['lastChallengeNonce'] = $nonce;
|
|
1746
|
+
$store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
if ($isApiRequest) {
|
|
1750
|
+
$decision['body'] = [
|
|
1751
|
+
'challenge' => [
|
|
1752
|
+
'type' => 'pospace',
|
|
1753
|
+
'nonce' => $nonce,
|
|
1754
|
+
'clientSecret' => $clientSecret,
|
|
1755
|
+
'queries' => $spaceChallenge['queries'],
|
|
1756
|
+
'sizeMb' => $spaceChallenge['sizeMb'],
|
|
1757
|
+
]
|
|
1758
|
+
];
|
|
1759
|
+
} else {
|
|
1760
|
+
$page = ChallengeUtils::generateSpaceChallengePage($spaceChallenge, $clientSecret, $this->securityConfig);
|
|
1761
|
+
$decision['body'] = $page;
|
|
1762
|
+
}
|
|
1763
|
+
return $decision;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
$cpuChallengeDetails = [
|
|
1767
|
+
'type' => 'cpu_target',
|
|
1768
|
+
'nonce' => $nonce,
|
|
1769
|
+
'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
|
|
1770
|
+
'path' => $context->path,
|
|
1771
|
+
];
|
|
1772
|
+
|
|
1773
|
+
// Perfect linear alignment of the CPU/Memory effort ratio
|
|
1774
|
+
$memActivationFactor = $suspicionFactor;
|
|
1775
|
+
$memDifficulty = (int)round($memActivationFactor * 48); // 0 to 48MB
|
|
1776
|
+
|
|
1777
|
+
$originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
|
|
1778
|
+
$tlsSessionId = $context->tlsSessionId ?? '';
|
|
1779
|
+
$baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint, $context->clientIp, $tlsSessionId);
|
|
1780
|
+
|
|
1781
|
+
$isHttps = !empty($context->isHttps);
|
|
1782
|
+
$challengeContext = [
|
|
1783
|
+
'clientSecret' => $clientSecret,
|
|
1784
|
+
'cpuTarget' => $cpuChallengeDetails['target'],
|
|
1785
|
+
'suspicionScore' => $finalScore,
|
|
1786
|
+
'fingerprint' => $originalFingerprint,
|
|
1787
|
+
'memDifficulty' => $memDifficulty,
|
|
1788
|
+
'baseBlock' => $baseBlock,
|
|
1789
|
+
'originalPath' => $context->path,
|
|
1790
|
+
'isHttp' => !$isHttps,
|
|
1791
|
+
];
|
|
1792
|
+
|
|
1793
|
+
$store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
|
|
1794
|
+
|
|
1795
|
+
// Associate the nonce with the device for trap URL verification
|
|
1796
|
+
if ($deviceData) {
|
|
1797
|
+
$deviceData['lastChallengeNonce'] = $nonce;
|
|
1798
|
+
$store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
$trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
|
|
1802
|
+
$this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
|
|
1803
|
+
|
|
1804
|
+
if ($this->logger) {
|
|
1805
|
+
$this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// For API requests, return a JSON challenge
|
|
1809
|
+
if ($isApiRequest) {
|
|
1810
|
+
$challengePayload = [
|
|
1811
|
+
'challenge' => [
|
|
1812
|
+
'type' => 'cpu_mem',
|
|
1813
|
+
'nonce' => $nonce,
|
|
1814
|
+
'clientSecret' => $clientSecret,
|
|
1815
|
+
'cpuTarget' => $cpuChallengeDetails['target'],
|
|
1816
|
+
'memDifficulty' => $memDifficulty,
|
|
1817
|
+
'baseBlock' => array_values(unpack('C*', $baseBlock)), // Send as a byte array
|
|
1818
|
+
]
|
|
1819
|
+
];
|
|
1820
|
+
$decision['body'] = $challengePayload;
|
|
1821
|
+
} else {
|
|
1822
|
+
// For browsers, return an HTML page
|
|
1823
|
+
$pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
|
|
1824
|
+
$cpuChallengeDetails, $memDifficulty, $clientSecret,
|
|
1825
|
+
$this->securityConfig, $trapUrls, $originalFingerprint,
|
|
1826
|
+
$context->clientIp, $tlsSessionId, $baseBlock, $isHttps
|
|
1827
|
+
);
|
|
1828
|
+
$decision['body'] = $pageBody;
|
|
1829
|
+
}
|
|
1830
|
+
$response = $decision;
|
|
1831
|
+
} elseif ($hasValidTicket) {
|
|
1832
|
+
// If we get here with a valid ticket and a low score, allow the request
|
|
1507
1833
|
MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1834
|
+
$this->log('Valid clearance ticket found and score is low - allowing request');
|
|
1835
|
+
$response = ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100], 'intendedAction' => 'next'];
|
|
1836
|
+
} else {
|
|
1837
|
+
// 6. If the score is low and there is no ticket, allow the request
|
|
1512
1838
|
MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
|
|
1513
|
-
|
|
1514
|
-
|
|
1839
|
+
$this->log('Request passed - no challenge required', ['finalScore' => $finalScore]);
|
|
1840
|
+
if ($this->logger) {
|
|
1515
1841
|
MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'passed']);
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1842
|
+
$this->logger->log('info', 'request_passed', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
|
|
1843
|
+
}
|
|
1844
|
+
$response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// If a new identification cookie has been generated, add it to the response.
|
|
1849
|
+
if (isset($context->newCookieForResponse)) {
|
|
1850
|
+
$response['newCookieForResponse'] = $context->newCookieForResponse;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
return $response;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* Checks whether the GraphQL operation matches a whitelist entry.
|
|
1858
|
+
* Supports exact matches, wildcard '*' for operation name, and
|
|
1859
|
+
* trailing-wildcard prefixes on operation names.
|
|
1860
|
+
*
|
|
1861
|
+
* @param string|null $operationType The GraphQL operation type (query/mutation).
|
|
1862
|
+
* @param string|null $operationName The GraphQL operation name.
|
|
1863
|
+
* @return bool True if the operation is allowlisted, false otherwise.
|
|
1864
|
+
*/
|
|
1865
|
+
private function isGraphqlOperationInAllowlist(?string $operationType, ?string $operationName): bool
|
|
1866
|
+
{
|
|
1867
|
+
if (empty($operationType) || empty($operationName)) {
|
|
1868
|
+
return false;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
1872
|
+
$graphqlRule = null;
|
|
1873
|
+
foreach ($whitelistRules as $rule) {
|
|
1874
|
+
if (($rule['type'] ?? '') === 'graphql_operation_allowlist') {
|
|
1875
|
+
$graphqlRule = $rule;
|
|
1876
|
+
break;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
if (empty($graphqlRule['entries'])) {
|
|
1881
|
+
return false;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
foreach ($graphqlRule['entries'] as $entry) {
|
|
1885
|
+
[$entryType, $entryName] = explode(':', $entry, 2);
|
|
1886
|
+
if ($entryType !== $operationType) continue;
|
|
1887
|
+
|
|
1888
|
+
if ($entryName === $operationName || $entryName === '*') return true;
|
|
1889
|
+
|
|
1890
|
+
if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
return false;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* Broadcasts a banned ZKP public key to all federated peers, with optional
|
|
1899
|
+
* differential privacy (Laplacian timestamp noise and decoy keys).
|
|
1900
|
+
*
|
|
1901
|
+
* @param string $zkpY The ZKP public key to broadcast.
|
|
1902
|
+
* @return void
|
|
1903
|
+
*/
|
|
1904
|
+
private function broadcastBannedZkp(string $zkpY): void
|
|
1905
|
+
{
|
|
1906
|
+
$peers = $this->securityConfig['federatedPeers'] ?? [];
|
|
1907
|
+
if (empty($peers)) return;
|
|
1908
|
+
|
|
1909
|
+
$dpConfig = $this->securityConfig['differentialPrivacy'] ?? [];
|
|
1910
|
+
$dpEnabled = ($dpConfig['enabled'] ?? true) !== false;
|
|
1911
|
+
$epsilon = isset($dpConfig['epsilon']) && is_numeric($dpConfig['epsilon'])
|
|
1912
|
+
? (float)$dpConfig['epsilon']
|
|
1913
|
+
: (float)($this->securityConfig['dpEpsilon'] ?? 1.0);
|
|
1914
|
+
|
|
1915
|
+
$sendThreatReport = function (string $targetZkpY, int $ts) use ($peers) {
|
|
1916
|
+
$msg = "{$ts}:{$targetZkpY}";
|
|
1917
|
+
$signature = '';
|
|
1918
|
+
$isAsymmetric = false;
|
|
1919
|
+
|
|
1920
|
+
$privateKey = Env::get('ED25519_PRIVATE_KEY');
|
|
1921
|
+
if ($privateKey) {
|
|
1922
|
+
try {
|
|
1923
|
+
$cleanKey = str_replace('\n', "\n", $privateKey);
|
|
1924
|
+
$pkeyObj = openssl_pkey_get_private($cleanKey);
|
|
1925
|
+
if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
|
|
1926
|
+
$signature = bin2hex($sigBytes);
|
|
1927
|
+
$isAsymmetric = true;
|
|
1928
|
+
}
|
|
1929
|
+
} catch (\Throwable $e) {
|
|
1930
|
+
self::logError('[Fingerprint] Asymmetric broadcast signing failed: ' . $e->getMessage());
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
if (!$isAsymmetric) {
|
|
1935
|
+
$secret = $this->securityConfig['federationSecret'] ?? ChallengeUtils::getPowSecret();
|
|
1936
|
+
$signature = hash_hmac('sha256', $msg, $secret);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
foreach ($peers as $peerUrl) {
|
|
1940
|
+
$this->asyncPost($peerUrl . '?coop_op=share_threat_intel', [
|
|
1941
|
+
'zkpY' => $targetZkpY,
|
|
1942
|
+
'signature' => $isAsymmetric ? '' : $signature,
|
|
1943
|
+
'signature_ed25519' => $isAsymmetric ? $signature : '',
|
|
1944
|
+
'timestamp' => $ts
|
|
1945
|
+
]);
|
|
1946
|
+
}
|
|
1947
|
+
};
|
|
1948
|
+
|
|
1949
|
+
$now = (int)(microtime(true) * 1000);
|
|
1950
|
+
$reportTimestamp = $now;
|
|
1951
|
+
|
|
1952
|
+
if ($dpEnabled) {
|
|
1953
|
+
// 1. Differential Privacy: Laplacian noise on the timestamp
|
|
1954
|
+
$deltaT = 5000;
|
|
1955
|
+
$b = $deltaT / max(0.1, $epsilon);
|
|
1956
|
+
$u = (random_int(1, 999999) / 1000000.0) - 0.5;
|
|
1957
|
+
$safeU = abs($u) < 1e-7 ? ($u >= 0 ? 1e-7 : -1e-7) : $u;
|
|
1958
|
+
$laplaceNoise = -$b * ($safeU <=> 0) * log(1.0 - 2.0 * abs($safeU));
|
|
1959
|
+
$clampedNoise = (int)max(-60000, min(60000, round($laplaceNoise)));
|
|
1960
|
+
$reportTimestamp = $now + $clampedNoise;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
$sendThreatReport($zkpY, $reportTimestamp);
|
|
1964
|
+
|
|
1965
|
+
if ($dpEnabled) {
|
|
1966
|
+
// 2. Differential Privacy: Decoy ZKP key injection
|
|
1967
|
+
$decoyProb = isset($dpConfig['dummyRate']) && is_numeric($dpConfig['dummyRate'])
|
|
1968
|
+
? (float)$dpConfig['dummyRate']
|
|
1969
|
+
: (1.0 / (1.0 + exp($epsilon)));
|
|
1970
|
+
|
|
1971
|
+
$randFloat = random_int(0, 999999) / 1000000.0;
|
|
1972
|
+
if ($randFloat < $decoyProb) {
|
|
1973
|
+
$decoyBytes = random_bytes(32);
|
|
1974
|
+
$decoyZkpY = ltrim(bin2hex($decoyBytes), '0') ?: '1';
|
|
1975
|
+
|
|
1976
|
+
$uDecoy = (random_int(1, 999999) / 1000000.0) - 0.5;
|
|
1977
|
+
$safeUDecoy = abs($uDecoy) < 1e-7 ? ($uDecoy >= 0 ? 1e-7 : -1e-7) : $uDecoy;
|
|
1978
|
+
$b = 5000 / max(0.1, $epsilon);
|
|
1979
|
+
$decoyNoise = (int)max(-60000, min(60000, round(-$b * ($safeUDecoy <=> 0) * log(1.0 - 2.0 * abs($safeUDecoy)))));
|
|
1980
|
+
$decoyTimestamp = $now + $decoyNoise;
|
|
1981
|
+
|
|
1982
|
+
$sendThreatReport($decoyZkpY, $decoyTimestamp);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
/**
|
|
1988
|
+
* Sends an asynchronous HTTP POST request to a given URL with the given
|
|
1989
|
+
* parameters (JSON-encoded). Uses non-blocking stream sockets with a short
|
|
1990
|
+
* connection timeout, so the caller is not delayed.
|
|
1991
|
+
*
|
|
1992
|
+
* @param string $url The target URL.
|
|
1993
|
+
* @param array $params The parameters to JSON-encode and POST.
|
|
1994
|
+
* @return void
|
|
1995
|
+
*/
|
|
1996
|
+
protected function asyncPost(string $url, array $params): void
|
|
1997
|
+
{
|
|
1998
|
+
// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- wp_parse_url used if available
|
|
1999
|
+
$parts = function_exists('wp_parse_url') ? wp_parse_url($url) : parse_url($url);
|
|
2000
|
+
if ($parts === false) return;
|
|
2001
|
+
|
|
2002
|
+
$host = $parts['host'];
|
|
2003
|
+
$port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
|
|
2004
|
+
$path = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : '');
|
|
2005
|
+
$scheme = $parts['scheme'] === 'https' ? 'ssl://' : '';
|
|
2006
|
+
|
|
2007
|
+
$postData = json_encode($params);
|
|
2008
|
+
|
|
2009
|
+
$fp = @stream_socket_client(
|
|
2010
|
+
"{$scheme}{$host}:{$port}",
|
|
2011
|
+
$errno,
|
|
2012
|
+
$errstr,
|
|
2013
|
+
0.5,
|
|
2014
|
+
STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
|
|
2015
|
+
);
|
|
2016
|
+
|
|
2017
|
+
if ($fp) {
|
|
2018
|
+
stream_set_blocking($fp, false);
|
|
2019
|
+
$out = "POST {$path} HTTP/1.1\r\n";
|
|
2020
|
+
$out .= "Host: {$host}\r\n";
|
|
2021
|
+
$out .= "Content-Type: application/json\r\n";
|
|
2022
|
+
$out .= "Content-Length: " . strlen($postData) . "\r\n";
|
|
2023
|
+
$out .= "X-Federation-Signature: " . ($params['signature'] ?? '') . "\r\n";
|
|
2024
|
+
$out .= "X-Federation-Signature-Ed25519: " . ($params['signature_ed25519'] ?? '') . "\r\n";
|
|
2025
|
+
$out .= "X-Federation-Timestamp: " . ($params['timestamp'] ?? '') . "\r\n";
|
|
2026
|
+
$out .= "Connection: Close\r\n\r\n";
|
|
2027
|
+
$out .= $postData;
|
|
2028
|
+
|
|
2029
|
+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Socket stream write
|
|
2030
|
+
@fwrite($fp, $out);
|
|
2031
|
+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Socket stream close
|
|
2032
|
+
@fclose($fp);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/**
|
|
2037
|
+
* Checks whether a request comes from a legitimate, whitelisted bot (e.g. Googlebot)
|
|
2038
|
+
* using reverse DNS lookup followed by forward DNS lookup (with a circuit breaker
|
|
2039
|
+
* on DNS failures). Results are cached for 24 hours on success and 5 minutes on
|
|
2040
|
+
* failure.
|
|
2041
|
+
*
|
|
2042
|
+
* @param RequestContext $context The request context.
|
|
2043
|
+
* @return bool True if the request is from a verified whitelisted bot, false otherwise.
|
|
2044
|
+
*/
|
|
2045
|
+
private function verifyWhitelistedBot(RequestContext $context): bool
|
|
2046
|
+
{
|
|
2047
|
+
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
2048
|
+
$botRules = array_filter($whitelistRules, fn($rule) => isset($rule['hostnameSuffix']));
|
|
2049
|
+
if (empty($botRules)) {
|
|
2050
|
+
return false;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
$userAgent = $context->getHeader('user-agent') ?? '';
|
|
2054
|
+
$matchedRule = null;
|
|
2055
|
+
foreach ($botRules as $rule) {
|
|
2056
|
+
if (isset($rule['userAgent']) && preg_match('/' . $rule['userAgent'] . '/', $userAgent)) {
|
|
2057
|
+
$matchedRule = $rule;
|
|
2058
|
+
break;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
if ($matchedRule === null) {
|
|
2062
|
+
return false;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
$store = StoreManager::getStore();
|
|
2066
|
+
$cacheKey = "ip-whitelist:{$context->clientIp}";
|
|
2067
|
+
$cachedStatus = $store->get($cacheKey);
|
|
2068
|
+
|
|
2069
|
+
if ($cachedStatus === 'verified') return true;
|
|
2070
|
+
if ($cachedStatus === 'failed') return false;
|
|
2071
|
+
|
|
2072
|
+
if (!self::canAttemptDns()) {
|
|
2073
|
+
return false;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
try {
|
|
2077
|
+
// 1. Reverse DNS lookup with strict 500ms timeout using our custom UDP DNS Client
|
|
2078
|
+
$revName = self::getReverseDnsName($context->clientIp);
|
|
2079
|
+
if (!$revName) {
|
|
2080
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
2081
|
+
return false;
|
|
2082
|
+
}
|
|
2083
|
+
$hostnames = self::resolveDnsNative($revName, 'PTR');
|
|
2084
|
+
if ($hostnames === null || empty($hostnames)) {
|
|
2085
|
+
self::recordDnsFailure();
|
|
2086
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
2087
|
+
return false;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
$validHostname = null;
|
|
2091
|
+
foreach ($hostnames as $hostname) {
|
|
2092
|
+
if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
|
|
2093
|
+
$validHostname = $hostname;
|
|
2094
|
+
break;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
if ($validHostname === null) {
|
|
2099
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
2100
|
+
return false;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// 2. Forward DNS lookup with strict 500ms timeout
|
|
2104
|
+
$ips = [];
|
|
2105
|
+
$resolvedA = self::resolveDnsNative($validHostname, 'A');
|
|
2106
|
+
if ($resolvedA === null) {
|
|
2107
|
+
self::recordDnsFailure();
|
|
2108
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
2109
|
+
return false;
|
|
2110
|
+
}
|
|
2111
|
+
$ips = array_merge($ips, $resolvedA);
|
|
2112
|
+
|
|
2113
|
+
$resolvedAaaa = self::resolveDnsNative($validHostname, 'AAAA');
|
|
2114
|
+
if ($resolvedAaaa === null) {
|
|
2115
|
+
self::recordDnsFailure();
|
|
2116
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
2117
|
+
return false;
|
|
2118
|
+
}
|
|
2119
|
+
$ips = array_merge($ips, $resolvedAaaa);
|
|
2120
|
+
|
|
2121
|
+
if (in_array($context->clientIp, $ips, true)) {
|
|
2122
|
+
self::recordDnsSuccess();
|
|
2123
|
+
$store->set($cacheKey, 'verified', 86400);
|
|
2124
|
+
return true;
|
|
2125
|
+
}
|
|
2126
|
+
} catch (\Exception $e) {
|
|
2127
|
+
self::recordDnsFailure();
|
|
2128
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
2129
|
+
return false;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
2133
|
+
return false;
|
|
2134
|
+
}
|
|
1739
2135
|
|
|
1740
2136
|
/**
|
|
1741
2137
|
* @internal For testing purposes only.
|
|
2138
|
+
*
|
|
2139
|
+
* Returns the list of registered problems from the ProblemManager singleton.
|
|
2140
|
+
*
|
|
2141
|
+
* @return array The list of problems.
|
|
1742
2142
|
*/
|
|
1743
2143
|
public function getProblems(): array
|
|
1744
2144
|
{
|
|
1745
2145
|
$problemManager = ProblemManager::getInstance();
|
|
1746
2146
|
return $problemManager->getProblems();
|
|
1747
2147
|
}
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* Decodes a CBOR-encoded string (used for WebAuthn attestation objects).
|
|
2151
|
+
* Supports major types 0-5 (unsigned int, negative int, byte string,
|
|
2152
|
+
* text string, array, map).
|
|
2153
|
+
*
|
|
2154
|
+
* @param string $str The CBOR-encoded input.
|
|
2155
|
+
* @param int &$offset The current byte offset (updated in place).
|
|
2156
|
+
* @return mixed The decoded value.
|
|
2157
|
+
* @throws \Exception On malformed CBOR or unsupported integer sizes.
|
|
2158
|
+
*/
|
|
1748
2159
|
private static function decodeCBOR(string $str, int &$offset = 0)
|
|
1749
2160
|
{
|
|
1750
2161
|
if ($offset >= strlen($str)) throw new \Exception("End of CBOR");
|
|
@@ -1796,6 +2207,12 @@
|
|
|
1796
2207
|
return null;
|
|
1797
2208
|
}
|
|
1798
2209
|
|
|
2210
|
+
/**
|
|
2211
|
+
* Returns the list of trusted hardware attestation root certificates
|
|
2212
|
+
* (e.g. Yubico, FIDO Alliance) used to validate WebAuthn attestation chains.
|
|
2213
|
+
*
|
|
2214
|
+
* @return array The list of PEM-encoded trusted root certificates.
|
|
2215
|
+
*/
|
|
1799
2216
|
private static function getTrustedHardwareRoots(): array
|
|
1800
2217
|
{
|
|
1801
2218
|
return [
|
|
@@ -1817,6 +2234,15 @@
|
|
|
1817
2234
|
];
|
|
1818
2235
|
}
|
|
1819
2236
|
|
|
2237
|
+
/**
|
|
2238
|
+
* Verifies a WebAuthn hardware anchor, handling both registration
|
|
2239
|
+
* (attestation chain validation against trusted roots) and assertion
|
|
2240
|
+
* (signature verification against the stored public key).
|
|
2241
|
+
*
|
|
2242
|
+
* @param array $anchor The WebAuthn anchor data (type, clientDataJSON, etc.).
|
|
2243
|
+
* @param array &$deviceData The device data (updated in place on registration).
|
|
2244
|
+
* @return bool True if the anchor is valid, false otherwise.
|
|
2245
|
+
*/
|
|
1820
2246
|
private function verifyWebAuthnHardwareAnchor(array $anchor, array &$deviceData): bool
|
|
1821
2247
|
{
|
|
1822
2248
|
if (empty($anchor['type'])) return false;
|
|
@@ -1832,7 +2258,7 @@
|
|
|
1832
2258
|
$attestationBytes = base64_decode($anchor['attestationObject']);
|
|
1833
2259
|
$offset = 0;
|
|
1834
2260
|
$decoded = self::decodeCBOR($attestationBytes, $offset);
|
|
1835
|
-
|
|
2261
|
+
|
|
1836
2262
|
if (!$decoded || empty($decoded['fmt']) || empty($decoded['attStmt'])) {
|
|
1837
2263
|
return false;
|
|
1838
2264
|
}
|
|
@@ -1891,9 +2317,15 @@
|
|
|
1891
2317
|
return false;
|
|
1892
2318
|
}
|
|
1893
2319
|
|
|
2320
|
+
/**
|
|
2321
|
+
* Logs an error message using the PHP error log.
|
|
2322
|
+
*
|
|
2323
|
+
* @param string $message The message to log.
|
|
2324
|
+
* @return void
|
|
2325
|
+
*/
|
|
1894
2326
|
private static function logError(string $message): void
|
|
1895
2327
|
{
|
|
1896
2328
|
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Engine diagnostic error log
|
|
1897
2329
|
error_log($message);
|
|
1898
2330
|
}
|
|
1899
|
-
|
|
2331
|
+
}
|