<?php /** * Code related to the integrity.lib.php interface. * * PHP version 5 * * @category Library * @package Sucuri * @subpackage SucuriScanner * @author Daniel Cid <[email protected]> * @copyright 2010-2018 Sucuri Inc. * @license https://www.gnu.org/licenses/gpl-2.0.txt GPL2 * @link https://wordpress.org/plugins/sucuri-scanner */ if (!defined('SUCURISCAN_INIT') || SUCURISCAN_INIT !== true) { if (!headers_sent()) { /* Report invalid access if possible. */ header('HTTP/1.1 403 Forbidden'); } exit(1); } /** * Checks the integrity of the WordPress installation. * * This tool finds changes in the standard WordPress installation. Files located * in the root directory, wp-admin and wp-includes will be compared against the * files distributed with the current WordPress version; all files with * inconsistencies will be listed here. * * @category Library * @package Sucuri * @subpackage SucuriScanner * @author Daniel Cid <[email protected]> * @copyright 2010-2018 Sucuri Inc. * @license https://www.gnu.org/licenses/gpl-2.0.txt GPL2 * @link https://wordpress.org/plugins/sucuri-scanner */ class SucuriScanIntegrity { /** * Compare the md5sum of the core files in the current site with the hashes hosted * remotely in Sucuri servers. These hashes are updated every time a new version * of WordPress is released. If the "Send Email" parameter is set the method will * send a notification to the administrator with a list of files that were added, * modified and/or deleted so far. * * @return string HTML code with a list of files that were affected. */ public static function pageIntegrity() { $params = array(); self::pageIntegritySubmission(); return SucuriScanTemplate::getSection('integrity', $params); } /** * Returns a JSON-encoded object with the WordPress integrity status. * * The plugin gets the checksum of all the files installed in the server that * are also part of a normal WordPress package. Then, it communicates with * a WordPress API service to retrieve the official checksums of the files * distributed with the package with the same version installed in the site. * * For any file found in the site that is not part of a normal installation * the plugin will report it as ADDED, for any file that is missing from the * installation but part of the official WordPress package, the plugin will * report it as DELETED, and for every file found in the site that is also * part of a normal installation, it will report it as MODIFIED if there are * differences in their checksums. * * @codeCoverageIgnore - Notice that there is a test case that covers this * code, but since the WP-Send-JSON method uses die() to stop any further * output it means that XDebug cannot cover the next line, leaving a report * with a missing line in the coverage. Since the test case takes care of * the functionality of this code we will assume that it is fully covered. * * @return void */ public static function ajaxIntegrity() { if (SucuriScanRequest::post('form_action') !== 'check_wordpress_integrity') { return; } wp_send_json(self::getIntegrityStatus(), 200); } /** * Mark as fixed, restore or delete flagged integrity files. * * Process the HTTP requests sent by the form submissions originated in the * integrity page, all forms must have a nonce field that will be checked * against the one generated in the template render function. * * @return void */ private static function pageIntegritySubmission() { /* restore, remove, mark as fixed the core files */ $action = SucuriScanRequest::post(':integrity_action'); if ($action === false || !SucuriScanInterface::checkNonce()) { return; /* skip if the action or nonce is invalid */ } /* skip if the user didn't confirm the operation */ if (SucuriScanRequest::post(':process_form') != 1) { return SucuriScanInterface::error(__('You need to confirm that you understand the risk of this operation.', 'sucuri-scanner')); } /* skip if the requested action is not currently supported */ if ($action !== 'fixed' && $action !== 'delete' && $action !== 'restore') { return SucuriScanInterface::error(__('Requested action is not supported.', 'sucuri-scanner')); } /* process the HTTP request */ $cache = new SucuriScanCache('integrity'); $core_files = SucuriScanRequest::post(':integrity', '_array'); $files_selected = count($core_files); $files_affected = array(); $files_processed = 0; $action_titles = array( 'restore' => __('Core file restored', 'sucuri-scanner'), 'delete' => __('Non-core file deleted', 'sucuri-scanner'), 'fixed' => __('Core file marked as fixed', 'sucuri-scanner'), ); /* skip if no files were selected */ if (!$core_files) { return SucuriScanInterface::error(__('Nothing was selected from the list.', 'sucuri-scanner')); } /* process files until the maximum execution time is reached */ $startTime = microtime(true); $displayTimeoutAlert = false; $maxtime = (int)SucuriScan::iniGet('max_execution_time'); $timeout = ($maxtime > 1) ? ($maxtime - 6) : 30; foreach ((array)$core_files as $file_meta) { if (strpos($file_meta, '@') === false) { continue; } /* avoid gateway timeout; max execution time */ $elapsedTime = (microtime(true) - $startTime); if ($elapsedTime >= $timeout) { $displayTimeoutAlert = true; break; } @list($status_type, $file_path) = explode('@', $file_meta, 2); if (!$file_path || !$status_type) { continue; } $full_path = ABSPATH . '/' . $file_path; if ($action === 'fixed' && ($status_type === 'added' || $status_type === 'removed' || $status_type === 'modified')) { $cache_key = md5($file_path); $cache_value = array( 'file_path' => $file_path, 'file_status' => $status_type, 'ignored_at' => time(), ); if ($cache->add($cache_key, $cache_value)) { $files_affected[] = $full_path; $files_processed++; } continue; } if ($action === 'restore' && ($status_type === 'removed' || $status_type === 'modified')) { $content = SucuriScanAPI::getOriginalCoreFile($file_path); if ($content) { $basedir = dirname($full_path); if (!file_exists($basedir)) { @mkdir($basedir, 0755, true); } if (@file_put_contents($full_path, $content)) { $files_affected[] = $full_path; $files_processed++; } } continue; } if ($action === 'delete' && $status_type === 'added') { if (@unlink($full_path)) { $files_affected[] = $full_path; $files_processed++; } continue; } } /* report files affected as a single event */ if (!empty($files_affected)) { $message = $action_titles[$action] . ':'; $message .= count($files_affected) > 1 ? "\x20(multiple entries):\x20" : ''; $message .= @implode(',', $files_affected); switch ($action) { case 'restore': SucuriScanEvent::reportInfoEvent($message); break; case 'delete': SucuriScanEvent::reportNoticeEvent($message); break; case 'fixed': SucuriScanEvent::reportWarningEvent($message); break; } } if ($displayTimeoutAlert) { SucuriScanInterface::error(__('Server is not fast enough to process this action; maximum execution time reached', 'sucuri-scanner')); } if ($files_processed != $files_selected) { return SucuriScanInterface::error( sprintf( __('Only <b>%d</b> out of <b>%d</b> files were processed.', 'sucuri-scanner'), $files_processed, $files_selected ) ); } return SucuriScanInterface::info( sprintf( __('<b>%d</b> out of <b>%d</b> files were successfully processed.', 'sucuri-scanner'), $files_processed, $files_selected ) ); } public static function getTotalAffectedFiles($latest_hashes, $ignored_files) { $affected_files = 0; if ($latest_hashes) { foreach ($latest_hashes as $list_type => $file_list) { if ($list_type == 'stable' || empty($file_list)) { continue; } foreach ($file_list as $file_info) { $file_path = $file_info['filepath']; if ($ignored_files /* skip files marked as fixed */ && array_key_exists(md5($file_path), $ignored_files) ) { continue; } $affected_files += 1; } } } return $affected_files; } /** * Checks if the WordPress integrity is correct or not. * * For any file found in the site that is not part of a normal installation * the plugin will report it as ADDED, for any file that is missing from the * installation but part of the official WordPress package, the plugin will * report it as DELETED, and for every file found in the site that is also * part of a normal installation, it will report it as MODIFIED if there are * differences in their checksums. * * The website owner will receive an email alert with this information. * * @param bool $send_email Send an email alert to the admins. * @return string|bool HTML with information about the integrity. */ public static function getIntegrityStatus($send_email = false) { $params = array(); /* initialize the values for the pagination */ $maxPerPage = SUCURISCAN_INTEGRITY_FILES_PER_PAGE; $pageNumber = SucuriScanTemplate::pageNumber(); if (isset($_POST['files_per_page'])) { $filesPerPage = intval(SucuriScanRequest::post('files_per_page')); if ($filesPerPage > 0) { $maxPerPage = $filesPerPage; } } $params['Version'] = SucuriScan::siteVersion(); $params['Integrity.List'] = ''; $params['Integrity.ListCount'] = 0; $params['Integrity.RemoteChecksumsURL'] = ''; $params['Integrity.BadVisibility'] = 'hidden'; $params['Integrity.GoodVisibility'] = 'hidden'; $params['Integrity.FailureVisibility'] = 'visible'; $params['Integrity.FalsePositivesVisibility'] = 'hidden'; $params['Integrity.DiffUtility'] = ''; $params['Integrity.Pagination'] = ''; $params['Integrity.PaginationVisibility'] = 'hidden'; $params['Integrity.Items'] = ''; $itemsToLoad = array( 15, 50, 200, 1000 ); foreach ($itemsToLoad as $items) { $params['Integrity.Items'] .= sprintf('<option value="%s">%s</option>', $items, ucfirst($items)); } // Check if there are added, removed, or modified files. $cache = new SucuriScanCache('integrity'); $ignored_files = $cache->getAll(); $latest_hashes = self::checkIntegrityIntegrity(); $affected_files = self::getTotalAffectedFiles($latest_hashes, $ignored_files); $params['Integrity.RemoteChecksumsURL'] = SucuriScanAPI::checksumAPI(); if ($latest_hashes) { $counter = 0; $processed_files = 0; $params['Integrity.BadVisibility'] = 'hidden'; $params['Integrity.GoodVisibility'] = 'visible'; $params['Integrity.FailureVisibility'] = 'hidden'; $iterator_start = ($pageNumber - 1) * $maxPerPage; foreach ($latest_hashes as $list_type => $file_list) { if ($list_type == 'stable' || empty($file_list)) { continue; } foreach ($file_list as $file_info) { $file_path = $file_info['filepath']; $full_filepath = sprintf('%s/%s', rtrim(ABSPATH, '/'), $file_path); if ($ignored_files /* skip files marked as fixed */ && array_key_exists(md5($file_path), $ignored_files) ) { $params['Integrity.FalsePositivesVisibility'] = 'visible'; continue; } // Pagination conditions if ($counter < $iterator_start) { $counter++; continue; } if ($processed_files >= $maxPerPage) { break; } // Add extra information to the file list. $file_size = @filesize($full_filepath); $file_size_human = ''; /* empty */ /* error message if the file cannot be fixed */ $error = ''; $visibility = 'hidden'; if ($file_info['is_fixable'] !== true) { $visibility = 'visible'; if ($list_type === 'added') { $error = __('The plugin has no permission to delete this file because it was created by a different system user who has more privileges than your account. Please use FTP to delete it.', 'sucuri-scanner'); } elseif ($list_type === 'modified') { $error = __('The plugin has no permission to restore this file because it was modified by a different system user who has more privileges than your account. Please use FTP to restore it.', 'sucuri-scanner'); } elseif ($list_type === 'removed') { $error = __('The plugin has no permission to restore this file because its directory is owned by a different system user who has more privileges than your account. Please use FTP to restore it.', 'sucuri-scanner'); } } // Pretty-print the file size in human-readable form. if ($file_size !== false) { $file_size_human = SucuriScan::humanFileSize($file_size); } $modified_at = $file_info['modified_at'] ? SucuriScan::datetime($file_info['modified_at']) : ''; // Generate the HTML code from the snippet template for this file. $params['Integrity.List'] .= SucuriScanTemplate::getSnippet( 'integrity-incorrect', array( 'Integrity.StatusType' => $list_type, 'Integrity.FilePath' => $file_path, 'Integrity.FileSize' => $file_size, 'Integrity.FileSizeHuman' => $file_size_human, 'Integrity.FileSizeNumber' => number_format($file_size), 'Integrity.ModifiedAt' => $modified_at, 'Integrity.ErrorVisibility' => $visibility, 'Integrity.ErrorMessage' => $error, ) ); $processed_files++; $counter++; } } if ($counter > 0) { $params['Integrity.ListCount'] = $affected_files; $params['Integrity.BadVisibility'] = 'visible'; $params['Integrity.GoodVisibility'] = 'hidden'; } } if ($send_email === true) { if ($affected_files > 0) { return SucuriScanEvent::notifyEvent( 'scan_checksums', /* send alert with a list of affected files */ SucuriScanTemplate::getSection('integrity-notification', $params) ); } return false; } ob_start(); $details = SucuriScanSiteCheck::details(); $errors = ob_get_clean(); /* capture possible errors */ $params['SiteCheck.Details'] = empty($errors) ? $details : '<br>' . $errors; $params['Integrity.DiffUtility'] = SucuriScanIntegrity::diffUtility(); if ($affected_files > 1) { $maxpages = ceil($affected_files / $maxPerPage); if ($maxpages > 1) { $params['Integrity.PaginationVisibility'] = 'visible'; $params['Integrity.Pagination'] = SucuriScanTemplate::pagination( SucuriScanTemplate::getUrl(), $affected_files, $maxPerPage ); } } $params['Integrity.Items'] = str_replace('option value="' . $maxPerPage . '"', 'option value="' . $maxPerPage . '" selected', $params['Integrity.Items']); $template = ($affected_files === 0) ? 'correct' : 'incorrect'; return SucuriScanTemplate::getSection('integrity-' . $template, $params); } /** * Setups the page to allow the execution of the diff utility. * * This method will write the modal window and the JavaScript code that will * allow the admin to send an Ajax request to inspect the difference between * a file that is currently installed in the website and the original code * distributed with the official WordPress package. * * @return string HTML and JavaScript code for the diff utility. */ public static function diffUtility() { if (!SucuriScanOption::isEnabled(':diff_utility')) { return ''; /* empty page */ } $params = array(); $params['DiffUtility.Modal'] = SucuriScanTemplate::getModal( 'none', array( 'Title' => __('WordPress Integrity Diff Utility', 'sucuri-scanner'), 'Content' => '' /* empty */, 'Identifier' => 'diff-utility', 'Visibility' => 'hidden', ) ); return SucuriScanTemplate::getSection('integrity-diff-utility', $params); } /** * Returns the output of the diff utility. * * Some errors will be reported if the admin requests to see the differences * in a file that is not part of the official WordPress distribution. Also, * if the file does not exists it will be useless to see the differences * because obviously the content of the file will all be missing. The plugin * will thrown an exception in this case too. * * @codeCoverageIgnore - Notice that there is a test case that covers this * code, but since the WP-Send-JSON method uses die() to stop any further * output it means that XDebug cannot cover the next line, leaving a report * with a missing line in the coverage. Since the test case takes care of * the functionality of this code we will assume that it is fully covered. * * @return void */ public static function ajaxIntegrityDiffUtility() { if (SucuriScanRequest::post('form_action') !== 'integrity_diff_utility') { return; } ob_start(); $filename = SucuriScanRequest::post('filepath'); echo SucuriScanCommand::diffHTML($filename); $response = ob_get_clean(); wp_send_json($response, 200); } /** * Retrieve a list of md5sum and last modification time of all the files in the * folder specified. This is a recursive function. * * @param string $dir The base path where the scanning will start. * @param bool $recursive Either TRUE or FALSE if the scan should be performed recursively. * @return array List of arrays containing the md5sum and last modification time of the files found. */ private static function integrityTree($dir = './', $recursive = false) { $file_info = new SucuriScanFileInfo(); $file_info->ignore_files = false; $file_info->ignore_directories = false; $file_info->run_recursively = $recursive; $tree = $file_info->getDirectoryTreeMd5($dir, true); return !$tree ? array() : $tree; } /** * Check whether the core WordPress files where modified, removed or if any file * was added to the core folders. This method returns an associative array with * these keys: * * <ul> * <li>modified: Files with a different checksum according to the official WordPress archives,</li> * <li>stable: Files with the same checksums as the official files,</li> * <li>removed: Official files which are not present in the local project,</li> * <li>added: Files present in the local project but not in the official WordPress packages.</li> * </ul> * * @return array|bool Associative array with these keys: modified, stable, removed, added. */ private static function checkIntegrityIntegrity() { $base_content_dir = ''; $latest_hashes = SucuriScanAPI::getOfficialChecksums(); if (defined('WP_CONTENT_DIR')) { $base_content_dir = basename(rtrim(WP_CONTENT_DIR, '/')); } // @codeCoverageIgnoreStart if (!$latest_hashes) { return false; } // @codeCoverageIgnoreEnd $output = array( 'added' => array(), 'removed' => array(), 'modified' => array(), 'stable' => array(), ); // Get current filesystem tree. $wp_top_hashes = self::integrityTree(ABSPATH, false); $wp_admin_hashes = self::integrityTree(ABSPATH . 'wp-admin', true); $wp_includes_hashes = self::integrityTree(ABSPATH . 'wp-includes', true); $wp_core_hashes = array_merge($wp_top_hashes, $wp_admin_hashes, $wp_includes_hashes); $checksumAlgorithm = SucuriScanAPI::checksumAlgorithm(); // Compare remote and local checksums and search removed files. foreach ($latest_hashes as $file_path => $remote) { if (self::ignoreIntegrityFilepath($file_path)) { continue; } $full_filepath = sprintf('%s/%s', ABSPATH, $file_path); // @codeCoverageIgnoreStart if (!file_exists($full_filepath) && defined('WP_CONTENT_DIR') && strpos($file_path, 'wp-content') !== false ) { /* patch for custom content directory path */ $file_path = str_replace('wp-content', $base_content_dir, $file_path); $dir_content_dir = dirname(rtrim(WP_CONTENT_DIR, '/')); $full_filepath = sprintf('%s/%s', $dir_content_dir, $file_path); } // @codeCoverageIgnoreEnd // Check whether the official file exists or not. if (file_exists($full_filepath)) { /* skip folders; cannot calculate a hash easily */ if (is_dir($full_filepath)) { $output['stable'][] = array( 'filepath' => $file_path, 'is_fixable' => false, 'modified_at' => 0, ); continue; } $local = SucuriScanAPI::checksum($checksumAlgorithm, $full_filepath); if ($local !== false && $local === $remote) { $output['stable'][] = array( 'filepath' => $file_path, 'is_fixable' => false, 'modified_at' => 0, ); } else { $modified_at = @filemtime($full_filepath); $is_fixable = (bool)is_writable($full_filepath); $output['modified'][] = array( 'filepath' => $file_path, 'is_fixable' => $is_fixable, 'modified_at' => $modified_at, ); } } else { $is_fixable = is_writable(dirname($full_filepath)); $output['removed'][] = array( 'filepath' => $file_path, 'is_fixable' => $is_fixable, 'modified_at' => 0, ); } } // Search added files (files not common in a normal wordpress installation). foreach ($wp_core_hashes as $file_path => $extra_info) { $file_path = str_replace(DIRECTORY_SEPARATOR, '/', $file_path); $file_path = @preg_replace('/^\.\/(.*)/', '$1', $file_path); if (self::ignoreIntegrityFilepath($file_path)) { continue; } if (!array_key_exists($file_path, $latest_hashes)) { $full_filepath = ABSPATH . '/' . $file_path; $modified_at = @filemtime($full_filepath); $is_fixable = (bool)is_writable($full_filepath); $output['added'][] = array( 'filepath' => $file_path, 'is_fixable' => $is_fixable, 'modified_at' => $modified_at, ); } } sort($output['added']); sort($output['removed']); sort($output['modified']); return $output; } /** * Ignore irrelevant files and directories from the integrity checking. * * @param string $path File path that will be compared. * @return bool True if the file should be ignored, false otherwise. */ private static function ignoreIntegrityFilepath($path = '') { $irrelevant = array( 'php.ini', '.htaccess', '.htpasswd', '.ftpquota', 'wp-includes/.htaccess', 'wp-admin/setup-config.php', 'wp-tests-config.php', 'wp-config.php', 'sitemap.xml', 'sitemap.xml.gz', 'readme.html', 'error_log', 'wp-pass.php', 'wp-rss.php', 'wp-feed.php', 'wp-register.php', 'wp-atom.php', 'wp-commentsrss2.php', 'wp-rss2.php', 'wp-rdf.php', '404.php', '503.php', '500.php', '500.shtml', '400.shtml', '401.shtml', '402.shtml', '403.shtml', '404.shtml', '405.shtml', '406.shtml', '407.shtml', '408.shtml', '409.shtml', 'healthcheck.html', ); /** * Ignore i18n files. * * Sites with i18n have differences compared with the official English * version of the project, basically they have files with new variables * specifying the language that will be used in the admin panel, site * options, and emails. */ if (@$GLOBALS['wp_local_package'] != 'en_US') { $irrelevant[] = 'wp-includes/version.php'; $irrelevant[] = 'wp-config-sample.php'; } if (in_array($path, $irrelevant)) { return true; } /* use regular expressions */ $ignore = false; $irrelevant = array( '^sucuri_[0-9a-z\-]+\.php$', '^sucuri-[0-9a-z\-]+\.php$', '^\S+-sucuri-db-dump-gzip-[0-9]{10}-[0-9a-z]{32}\.gz$', '^\.sucuri-sss-resume-[0-9a-z]{32}\.php$', '^([^\/]*)\.(pdf|css|txt|jpg|gif|png|jpeg)$', '^wp-content\/(themes|plugins)\/.+', '^google[0-9a-z]{16}\.html$', '^pinterest-[0-9a-z]{5}\.html$', '^wp-content\/languages\/.+\.mo$', '^wp-content\/languages\/.+\.po$', '^wp-content\/languages\/.+\.json$', '\.ico$', ); foreach ($irrelevant as $pattern) { if (@preg_match('/' . $pattern . '/', $path)) { $ignore = true; break; } } return $ignore; } }