), which made PHP emit them as literal HTML text on every page load, * leaking the key value to site visitors. * * Detection: the constants exist as text in the file but are not defined as * PHP constants (because PHP never executed those lines). * * Fix: delegates to regeneratePluginSaltRaw(), which removes the misplaced * lines and rewrites them in the correct position via the now-fixed * writePluginSaltToConfig(). * * A WordPress option flag is set on success so this check never runs again. * If wp-config.php is not writable the flag is left unset so the next * admin_init will retry automatically. * * @return void */ public static function maybeHealMisplacedPluginSalt() { if (function_exists('wp_doing_ajax') && wp_doing_ajax()) { return; } if (function_exists('wp_doing_cron') && wp_doing_cron()) { return; } if (!SucuriScanPermissions::canManagePlugin()) { return; } // Already processed — skip immediately (option is cached by WordPress). if (get_option('sucuriscan_plug_salt_position_healed')) { return; } // Constants are loaded from PHP context — the file position is correct. // Mark done so this check never runs again. if (defined('SUCURI_PLUG_KEY') && defined('SUCURI_PLUG_SALT')) { update_option('sucuriscan_plug_salt_position_healed', true, false); return; } // Use token-based detection to find define() lines placed outside PHP context. // This covers full misplacement (both defines outside) and mixed states // (one define inside, one outside). $config_path = self::getConfigPath(); if (!$config_path || !file_exists($config_path)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_exists // No config file accessible — nothing we can fix. update_option('sucuriscan_plug_salt_position_healed', true, false); return; } $content = (string) file_get_contents($config_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if (!self::hasPluginSaltOutsidePhp($content)) { // All define() lines are inside PHP (or absent) — nothing to fix. update_option('sucuriscan_plug_salt_position_healed', true, false); return; } // At least one define() line is outside PHP context. Remove and rewrite. $result = self::regeneratePluginSaltRaw(); if ($result !== false) { update_option('sucuriscan_plug_salt_position_healed', true, false); } // If the fix failed (e.g. wp-config.php not writable), leave the flag // unset so the next admin_init will retry. } /** * Regenerate the SUCURI_PLUG_* salt pair. * * Removes the existing constants from wp-config.php, derives a fresh pair * from the current WordPress AUTH salts, and writes them back. * * Because PHP constants cannot be re-defined within the same request, the * newly derived raw string is returned directly so callers can use it * without relying on the still-stale in-memory constants. * * @return string|bool New combined plug-key + plug-salt string, or false on failure. */ private static function regeneratePluginSaltRaw() { if (!function_exists('wp_salt')) { return false; } if (!self::removePluginSaltFromConfig()) { return false; } $auth_raw = wp_salt('auth'); $plug_key = hash_hmac('sha256', 'sucuri_plug_key_v1', $auth_raw); $plug_salt = hash_hmac('sha256', 'sucuri_plug_salt_v1', $auth_raw); if (!self::writePluginSaltToConfig($plug_key, $plug_salt)) { return false; } return $plug_key . $plug_salt; } /** * Append SUCURI_PLUG_KEY and SUCURI_PLUG_SALT define() lines to wp-config.php. * * Insertion is attempted in this order: * 1. Just before the English stop-editing comment ("That's all, stop editing!"). * 2. Just before the ABSPATH guard (language-independent, present in all WP). * 3. Just before the wp-settings.php require/include line. * 4. End of file, but before any trailing PHP close tag (?>), so the block * is never placed outside PHP context. * * Returns true when the constants are already present (no write needed) or when * the file was updated successfully. * * @param string $plug_key 64-char hex string. * @param string $plug_salt 64-char hex string. * @return bool */ private static function writePluginSaltToConfig($plug_key, $plug_salt) { $config_path = self::getConfigPath(); if (!$config_path || !is_writable($config_path)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable return false; } $content = (string) file_get_contents($config_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ($content === '') { return false; } $has_key = (bool) preg_match('/^\s*define\s*\(\s*[\'"]SUCURI_PLUG_KEY[\'"]/m', $content); $has_salt = (bool) preg_match('/^\s*define\s*\(\s*[\'"]SUCURI_PLUG_SALT[\'"]/m', $content); if ($has_key && $has_salt) { return true; // Already present — nothing to do. } if ($has_key || $has_salt) { // Partial state: strip the orphaned define(s) in-memory so a // complete, correct block can be written atomically below. $content = (string) preg_replace( '/^[^\n]*define\s*\(\s*[\'"]SUCURI_PLUG_(?:KEY|SALT)[\'"][^\n]*\n?/m', '', $content ); } $block = sprintf( "define('SUCURI_PLUG_KEY', '%s');\ndefine('SUCURI_PLUG_SALT', '%s');\n", $plug_key, $plug_salt ); // Insert before the canonical stop-editing marker (English). $stop_marker = "/* That's all, stop editing!"; $stop_pos = strpos($content, $stop_marker); if ($stop_pos !== false) { if (self::isOffsetInsidePhp($content, $stop_pos)) { $new_content = substr($content, 0, $stop_pos) . $block . substr($content, $stop_pos); } else { // Stop marker is outside PHP — insert before the preceding close tag. $close_pos = self::lastPhpCloseTagBefore($content, $stop_pos); if ($close_pos === null) { return false; } $new_content = substr($content, 0, $close_pos) . $block . substr($content, $close_pos); } } else { // Fallback: insert before the ABSPATH guard (language-independent, // always in the user-config section, never after the bootstrap code). $lines = explode("\n", $content); $insert_at = null; $insert_offset = null; $cum_len = 0; foreach ($lines as $i => $line) { if (preg_match('/if\s*\(\s*!?\s*defined\s*\(\s*[\'"]ABSPATH[\'"]/i', $line)) { $insert_at = $i; $insert_offset = $cum_len; break; } $cum_len += strlen($line) + 1; // +1 for the "\n" separator } if ($insert_at === null) { return false; // No safe insertion point found. } if (self::isOffsetInsidePhp($content, $insert_offset)) { array_splice($lines, $insert_at, 0, array(rtrim($block))); $new_content = implode("\n", $lines); } else { // Insertion point is outside PHP — insert before the preceding close tag. $close_pos = self::lastPhpCloseTagBefore($content, $insert_offset); if ($close_pos === null) { return false; } $new_content = substr($content, 0, $close_pos) . $block . substr($content, $close_pos); } } return (bool) file_put_contents($config_path, $new_content, LOCK_EX); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents } /** * Get or initialize the plugin-specific raw salt string. * * Priority: * 1. PHP constants SUCURI_PLUG_KEY and SUCURI_PLUG_SALT are already defined * (written to wp-config.php on a previous run, or user-managed). * 2. First run: derive from the current WordPress AUTH salts, write the * constants to wp-config.php, and return the combined string. * * @return string|bool Combined plug-key and plug-salt string, or false on failure. */ private static function getPluginSaltRaw() { // Constants already available — either loaded from wp-config.php or // defined by the site owner before the plugin loaded. if (defined('SUCURI_PLUG_KEY') && defined('SUCURI_PLUG_SALT')) { return SUCURI_PLUG_KEY . SUCURI_PLUG_SALT; } // First run: derive plugin-specific values from WordPress AUTH salts and // persist them in wp-config.php so they survive future WP salt rotations. if (!function_exists('wp_salt')) { return false; } $auth_raw = wp_salt('auth'); $plug_key = hash_hmac('sha256', 'sucuri_plug_key_v1', $auth_raw); $plug_salt = hash_hmac('sha256', 'sucuri_plug_salt_v1', $auth_raw); if (!self::writePluginSaltToConfig($plug_key, $plug_salt)) { return false; } return $plug_key . $plug_salt; } /** * Build encryption key from WordPress AUTH salts (legacy scheme, payload v:1). * * @return string|bool 32-byte key, or false on failure. */ private static function getAuthEncryptionKey() { if (!function_exists('wp_salt')) { return false; } $context = 'sucuriscan_waf_key_v1'; return substr(hash_hmac('sha256', $context, wp_salt('auth'), true), 0, 32); } /** * Build encryption key from plugin-specific SUCURI_PLUG_* salts (payload v:2). * * @return string|bool 32-byte key, or false on failure. */ private static function getSecretEncryptionKey() { $plug_raw = self::getPluginSaltRaw(); if ($plug_raw === false) { return false; } $context = 'sucuriscan_waf_key_v1'; return substr(hash_hmac('sha256', $context, $plug_raw, true), 0, 32); } /** * Generate random bytes for encryption. * * @param int $length Number of bytes. * @return string|bool */ private static function getSecretRandomBytes($length) { if (function_exists('random_bytes')) { return random_bytes($length); } if (function_exists('openssl_random_pseudo_bytes')) { return openssl_random_pseudo_bytes($length); } return false; } /** * Encrypt a secret value with AES-256-GCM. * * @param string $plaintext Secret value. * @param string|null $raw_salt Optional raw plug salt to use instead of * reading the runtime constants. Pass this * when the constants have just been regenerated * and the new values are not yet available as * PHP constants (constants cannot be redefined * within the same request). * @return array|bool */ private static function encryptSecretValue($plaintext, $raw_salt = null) { if (!self::canEncryptSecrets()) { return false; } if ($raw_salt !== null) { $context = 'sucuriscan_waf_key_v1'; $key = substr(hash_hmac('sha256', $context, $raw_salt, true), 0, 32); } else { $key = self::getSecretEncryptionKey(); } if (!$key) { return false; } $iv = self::getSecretRandomBytes(12); if (!$iv) { return false; } $tag = ''; $ciphertext = openssl_encrypt( $plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag ); if ($ciphertext === false || $tag === '') { return false; } return array( 'v' => 2, 'alg' => 'aes-256-gcm', 'iv' => base64_encode($iv), 'tag' => base64_encode($tag), 'ct' => base64_encode($ciphertext), ); } /** * Decrypt a secret payload. * * @param array $payload Encrypted payload. * @return string|bool */ private static function decryptSecretValue($payload, $raw_salt = null) { if (!self::canEncryptSecrets()) { return false; } if (!is_array($payload) || !isset($payload['v']) || !isset($payload['alg']) || !isset($payload['iv']) || !isset($payload['tag']) || !isset($payload['ct']) ) { return false; } $version = (int) $payload['v']; if ($payload['alg'] !== 'aes-256-gcm') { return false; } // Route decryption key by payload version: // v:1 — legacy, encrypted with WordPress AUTH_SALT via wp_salt('auth'). // v:2 — current, encrypted with plugin-specific SUCURI_PLUG_* salt. // An explicit $raw_salt overrides version-based routing (used for fallback recovery). if ($raw_salt !== null) { $key = substr(hash_hmac('sha256', 'sucuriscan_waf_key_v1', $raw_salt, true), 0, 32); } elseif ($version === 1) { $key = self::getAuthEncryptionKey(); } elseif ($version === 2) { $key = self::getSecretEncryptionKey(); } else { return false; } if (!$key) { return false; } $iv = base64_decode($payload['iv']); $tag = base64_decode($payload['tag']); $ct = base64_decode($payload['ct']); if ($iv === false || $tag === false || $ct === false) { return false; } return openssl_decrypt( $ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag ); } /** * Return the option name for decrypt error flags. * * @return string */ private static function wafKeyDecryptErrorOption() { return 'sucuriscan_waf_key_decrypt_error'; } /** * Return the option name for the no-salt-encryption flag. * * When this flag is set the WAF key is stored as plaintext in the DB * because wp-config.php could not be written on the initial save. * * @return string */ private static function noSaltEncryptionOption() { return 'sucuriscan_no_salt_encryption'; } /** * Set a decryption error flag for the WAF key. * * @param string $message Error detail for logging. * @return void */ private static function setWafKeyDecryptError($message = '') { if (!function_exists('get_option') || !function_exists('update_option')) { return; } if ($message) { $message = sprintf( /* translators: %s: error message */ __('Firewall API key decryption failed: %s', 'sucuri-scanner'), $message ); } $option = self::wafKeyDecryptErrorOption(); $current = get_option($option, array()); $timestamp = isset($current['ts']) ? (int) $current['ts'] : 0; if ($timestamp && (time() - $timestamp) < 3600) { return; } update_option($option, array('ts' => time(), 'message' => (string) $message), false); if ($message) { SucuriScanEvent::reportWarningEvent($message); } } /** * Clear the WAF key decryption error flag. * * @return void */ private static function clearWafKeyDecryptError() { if (function_exists('delete_option')) { delete_option(self::wafKeyDecryptErrorOption()); } } /** * Render a decryption error notice on selected admin pages. * * @return void */ public static function renderWafKeyDecryptNotice() { if (!function_exists('get_option')) { return; } if (!SucuriScanPermissions::canManagePlugin()) { return; } $flag = get_option(self::wafKeyDecryptErrorOption(), array()); if (empty($flag) || !is_array($flag)) { return; } SucuriScanInterface::error( __('The Sucuri WAF API key could not be decrypted. Please re-save the key in the Firewall settings to restore functionality.', 'sucuri-scanner') ); } /** * Retrieve a secret option from the database. * * @param string $option Option name. * @return mixed|null */ private static function getSecretOption($option = '') { if (!function_exists('get_option')) { return null; } $option = self::varPrefix($option); $storage = self::getSecretStorageName($option); $encrypted_storage = self::getSecretEncryptedStorageName($option); if (get_option(self::noSaltEncryptionOption(), false)) { return get_option($storage, null); } $encrypted_payload = get_option($encrypted_storage, null); if ($encrypted_payload !== null) { $payload = is_array($encrypted_payload) ? $encrypted_payload : @json_decode($encrypted_payload, true); $decrypted = self::decryptSecretValue($payload); if ($decrypted !== false) { // Auto-migrate v:1 payloads (AUTH_SALT scheme) to v:2 (SUCURI_PLUG_* scheme). if (is_array($payload) && isset($payload['v']) && (int) $payload['v'] === 1 && self::canEncryptSecrets() ) { $new_payload = self::encryptSecretValue($decrypted); if ($new_payload !== false) { update_option($encrypted_storage, $new_payload, false); } } self::clearWafKeyDecryptError(); return $decrypted; } // Fallback: the payload may have been encrypted with the raw salt derived // directly from wp_salt('auth') — this happens when SUCURI_PLUG_* constants // in wp-config.php were stale or user-defined (e.g. conditional defines that // survived the removal step), so the in-memory constants diverged from what // regeneratePluginSaltRaw() used during the save. Try that key so the // system can self-heal without requiring a manual re-save. if (function_exists('wp_salt') && is_array($payload) && isset($payload['v']) && (int) $payload['v'] === 2 ) { $auth_raw = wp_salt('auth'); $fallback_raw = hash_hmac('sha256', 'sucuri_plug_key_v1', $auth_raw) . hash_hmac('sha256', 'sucuri_plug_salt_v1', $auth_raw); $decrypted = self::decryptSecretValue($payload, $fallback_raw); if ($decrypted !== false) { // Re-encrypt with the constants-based key so subsequent reads succeed. if (self::canEncryptSecrets()) { $new_payload = self::encryptSecretValue($decrypted); if ($new_payload !== false) { update_option($encrypted_storage, $new_payload, false); } } self::clearWafKeyDecryptError(); return $decrypted; } } self::setWafKeyDecryptError('decryption failed; please re-save the key.'); return false; } $value = get_option($storage, null); if ($value !== null) { if (self::canEncryptSecrets()) { $payload = self::encryptSecretValue($value); if ($payload !== false) { $updated = update_option($encrypted_storage, $payload, false); if ($updated) { delete_option($storage); } } } self::clearWafKeyDecryptError(); return $value; } // Backward compatibility: migrate legacy DB value to secret storage. $legacy = get_option($option, null); if ($legacy !== null) { self::updateSecretOption($option, $legacy); delete_option($option); return $legacy; } return null; } /** * Update a secret option in the database (non-autoloaded). * * Encrypts the value with the stable SUCURI_PLUG_* salt (written to * wp-config.php once on first run via getPluginSaltRaw()). The salt is * never rotated on save — doing so rewrote wp-config.php on every key * insert and caused within-request key mismatches. * * @param string $option Option name. * @param mixed $value Option value. * @return bool */ private static function updateSecretOption($option = '', $value = '') { if (!function_exists('update_option')) { return false; } $option = self::varPrefix($option); $storage = self::getSecretStorageName($option); $encrypted_storage = self::getSecretEncryptedStorageName($option); if (self::canEncryptSecrets()) { // Use the stable plugin-specific salt (written to wp-config.php once on // first run, never rotated). Rotating on every save caused wp-config.php // to be rewritten on every key insert and created within-request key // mismatches because PHP constants cannot be redefined. $payload = self::encryptSecretValue($value); if ($payload !== false) { $encrypted_result = update_option($encrypted_storage, $payload, false); if ($encrypted_result) { delete_option($storage); delete_option(self::noSaltEncryptionOption()); self::clearWafKeyDecryptError(); return true; } } } delete_option($encrypted_storage); $result = update_option($storage, $value, false); if ($result) { update_option(self::noSaltEncryptionOption(), true, false); } self::clearWafKeyDecryptError(); return $result; } /** * Delete a secret option from the database. * * @param string $option Option name. * @return bool */ private static function deleteSecretOption($option = '') { if (!function_exists('delete_option')) { return false; } $option = self::varPrefix($option); $storage = self::getSecretStorageName($option); $encrypted_storage = self::getSecretEncryptedStorageName($option); delete_option($encrypted_storage); $deleted = delete_option($storage); // Remove legacy storage if still present. delete_option($option); delete_option(self::noSaltEncryptionOption()); self::clearWafKeyDecryptError(); return $deleted; } /** * Delete an option from the settings file only. * * @param string $option Option name. * @return bool */ private static function deleteOptionFromFile($option = '') { $options = self::getAllOptions(); $option = self::varPrefix($option); if (array_key_exists($option, $options)) { unset($options[$option]); return self::writeNewOptions($options); } return false; } /** * Retrieve a secret option value from the DB or settings file. * * @param string $option Option name. * @param array $options Settings file options. * @return mixed */ private static function getSecretOptionValue($option, $options) { $value = self::getSecretOption($option); if ($value !== null) { return $value; } if (array_key_exists($option, $options)) { $value = $options[$option]; if (self::updateSecretOption($option, $value)) { self::deleteOptionFromFile($option); } return $value; } if (strpos($option, SUCURISCAN . '_') === 0) { $value = self::getDefaultOptions($option); // Only promote to secret storage when there is a real value. // An empty default must not trigger wp-config.php writes. if ($value !== '' && $value !== false && $value !== null) { self::updateSecretOption($option, $value); } return $value; } return false; } /** * Name of all valid plugin's options. * * @return array Name of all valid plugin's options. */ public static function getDefaultOptionNames() { $options = self::getDefaultOptionValues(); $names = array_keys($options); return $names; } /** * Retrieve the default values for some specific options. * * @param string $option List of options, or single option name. * @return mixed The default values for the specified options. */ private static function getDefaultOptions($option = '') { $default = self::getDefaultOptionValues(); // Use framework built-in function. if (function_exists('get_option')) { $admin_email = get_option('admin_email'); $default['sucuriscan_account'] = $admin_email; $default['sucuriscan_notify_to'] = $admin_email; $default['sucuriscan_email_subject'] = sprintf( /* translators: %1$s: domain, %2$s: event, %3$s: remote address */ __('Sucuri Alert, %1$s, %2$s, %3$s', 'sucuri-scanner'), ':domain', ':event', ':remoteaddr' ); } return @$default[$option]; } /** * Returns path of the options storage. * * Returns the absolute path of the file that will store the options * associated to the plugin. This must be a PHP file without public access, * for which reason it will contain a header with an exit point to prevent * malicious people to read the its content. The rest of the file will * content a JSON encoded array. * * @return string File path of the options storage. */ public static function optionsFilePath() { return self::dataStorePath('sucuri-settings.php'); } /** * Returns an array with all the plugin options. * * NOTE: There is a maximum number of lines for this file, one is for the * exit point and the other one is for a single line JSON encoded string. * We will discard any other content that exceeds this limit. * * @return array Array with all the plugin options. */ public static function getAllOptions() { $options = wp_cache_get('alloptions', SUCURISCAN); if ($options && is_array($options)) { return $options; } $options = array(); $fpath = self::optionsFilePath(); /* Use this over SucuriScanCache to prevent nested method calls */ $content = SucuriScanFileInfo::fileContent($fpath); if ($content !== false) { // Refer to self::optionsFilePath to know why the number two. $lines = explode("\n", $content, 2); if (count($lines) >= 2) { $jsonData = json_decode($lines[1], true); if (is_array($jsonData) && !empty($jsonData)) { $options = $jsonData; } } } wp_cache_set('alloptions', $options, SUCURISCAN); return $options; } /** * Write new options into the external options file. * * @param array $options Array with plugins options. * @return bool True if the new options were saved, false otherwise. */ public static function writeNewOptions($options = array()) { wp_cache_delete('alloptions', SUCURISCAN); $fpath = self::optionsFilePath(); $content = "