__( 'Modified', 'coreseal' ), self::STATUS_MISSING => __( 'Missing', 'coreseal' ), self::STATUS_UNEXPECTED => __( 'Unexpected file', 'coreseal' ), ); } public function get_columns(): array { return array( 'file' => __( 'File', 'coreseal' ), 'expected' => __( 'Expected MD5', 'coreseal' ), 'actual' => __( 'Actual MD5', 'coreseal' ), ); } public function get_intro(): string { return __( 'Run a first scan to check that your WordPress core files are intact.', 'coreseal' ); } public function summarize( array $scan, array $counts ): array { $version = isset( $scan['meta']['version'] ) ? $scan['meta']['version'] : '?'; $locale = isset( $scan['meta']['locale'] ) ? $scan['meta']['locale'] : '?'; // WordPress updated since the scan: the result no longer describes the site. list( $installed ) = $this->get_installed_package(); if ( $installed !== $version ) { return array( 'state' => 'warning', 'icon' => 'dashicons-update', 'title' => __( 'WordPress has changed since the last scan', 'coreseal' ), 'text' => sprintf( /* translators: 1: version scanned, 2: version installed now. */ __( 'The last scan checked WordPress %1$s, but WordPress %2$s is now installed. Run a new scan.', 'coreseal' ), $version, $installed ), ); } if ( 0 === (int) $scan['issues'] ) { return array( 'state' => 'ok', 'title' => __( 'WordPress core is intact', 'coreseal' ), 'text' => sprintf( /* translators: 1: number of files, 2: WordPress version, 3: package language. */ __( 'All %1$s core files match the official WordPress %2$s release (%3$s package). No unexpected file was found.', 'coreseal' ), number_format_i18n( (int) $scan['files_checked'] ), $version, $locale ), ); } $parts = array(); if ( ! empty( $counts[ self::STATUS_MODIFIED ] ) ) { /* translators: %s: number of files. */ $parts[] = sprintf( _n( '%s modified', '%s modified', $counts[ self::STATUS_MODIFIED ], 'coreseal' ), number_format_i18n( $counts[ self::STATUS_MODIFIED ] ) ); } if ( ! empty( $counts[ self::STATUS_MISSING ] ) ) { /* translators: %s: number of files. */ $parts[] = sprintf( _n( '%s missing', '%s missing', $counts[ self::STATUS_MISSING ], 'coreseal' ), number_format_i18n( $counts[ self::STATUS_MISSING ] ) ); } if ( ! empty( $counts[ self::STATUS_UNEXPECTED ] ) ) { /* translators: %s: number of files. */ $parts[] = sprintf( _n( '%s unexpected', '%s unexpected', $counts[ self::STATUS_UNEXPECTED ], 'coreseal' ), number_format_i18n( $counts[ self::STATUS_UNEXPECTED ] ) ); } return array( 'state' => 'alert', 'title' => sprintf( /* translators: %s: number of files. */ _n( '%s file needs attention', '%s files need attention', (int) $scan['issues'], 'coreseal' ), number_format_i18n( (int) $scan['issues'] ) ), 'text' => sprintf( /* translators: 1: details such as "2 modified, 1 unexpected", 2: number of files, 3: WordPress version. */ __( '%1$s — out of %2$s core files checked against WordPress %3$s.', 'coreseal' ), implode( ', ', $parts ), number_format_i18n( (int) $scan['files_checked'] ), $version ), ); } public function run() { list( $version, $locale ) = $this->get_installed_package(); $checksums = $this->fetch_checksums( $version, $locale ); if ( is_wp_error( $checksums ) ) { return $checksums; } $locale = $checksums['locale']; $checksums = $checksums['files']; if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- a full scan reads thousands of files and can exceed the default 30 seconds. } $items = array(); $checked = 0; foreach ( $checksums as $file => $expected ) { // wp-content holds default themes and plugins the owner may delete // or update independently: they are not part of the core check. if ( 0 === strpos( $file, 'wp-content/' ) ) { continue; } $path = Coreseal_Paths::core_root() . $file; ++$checked; if ( ! file_exists( $path ) ) { $items[] = array( 'file' => $file, 'status' => self::STATUS_MISSING, 'expected' => $expected, 'actual' => '', ); continue; } $actual = md5_file( $path ); if ( $actual !== $expected ) { $items[] = array( 'file' => $file, 'status' => self::STATUS_MODIFIED, 'expected' => $expected, 'actual' => (string) $actual, ); } } foreach ( $this->find_unexpected_files( $checksums ) as $file ) { $items[] = array( 'file' => $file, 'status' => self::STATUS_UNEXPECTED, 'expected' => '', 'actual' => (string) md5_file( Coreseal_Paths::core_root() . $file ), ); } return array( 'files_checked' => $checked, 'meta' => array( 'version' => $version, 'locale' => $locale, ), 'items' => $items, ); } /** * Reads the version and the language of the installed package from the * text of wp-includes/version.php (the file is not executed), so a * tampered global $wp_version cannot fool the check. The globals are only * a fallback when the file cannot be read. * * The package language ($wp_local_package) is what decides the content * of translated files such as readme.html, wp-config-sample.php and * version.php itself. It can differ from the site language chosen in * Settings, so get_locale() must not be used here. * * @return array{0:string,1:string} Version, package locale. */ public function get_installed_package(): array { global $wp_version, $wp_local_package; $version = (string) $wp_version; $locale = isset( $wp_local_package ) ? (string) $wp_local_package : ''; /* * Read the values from version.php as text (the file is not executed): * right after a core update, the values loaded in memory at the start * of the request can be older than the files on disk. */ $source = file_get_contents( Coreseal_Paths::core_root() . WPINC . '/version.php' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local core file. if ( is_string( $source ) ) { if ( preg_match( '/^\$wp_version\s*=\s*\'([^\']+)\'/m', $source, $match ) ) { $version = $match[1]; } if ( preg_match( '/^\$wp_local_package\s*=\s*\'([A-Za-z_]+)\'/m', $source, $match ) ) { $locale = $match[1]; } } return array( $version, '' !== $locale ? $locale : 'en_US', ); } /** * Fetches the official checksums, falling back to en_US * when the locale has no published checksums. * * @return array|WP_Error array( 'locale' => string, 'files' => array file => md5 ) */ private function fetch_checksums( string $version, string $locale ) { if ( ! function_exists( 'get_core_checksums' ) ) { require_once ABSPATH . 'wp-admin/includes/update.php'; } $checksums = get_core_checksums( $version, $locale ); if ( ! is_array( $checksums ) && 'en_US' !== $locale ) { $locale = 'en_US'; $checksums = get_core_checksums( $version, $locale ); } if ( is_array( $checksums ) && ! empty( $checksums ) ) { return array( 'locale' => $locale, 'files' => $checksums, ); } return new WP_Error( 'coreseal_no_checksums', sprintf( /* translators: %s: WordPress version. */ __( 'Could not retrieve the official checksums for WordPress %s. Check that your server can reach api.wordpress.org.', 'coreseal' ), $version ) ); } /** * Files present in wp-admin/ and wp-includes/ that are not part of * the official release: a classic hiding place for backdoors. * * @return string[] Relative paths. */ private function find_unexpected_files( array $checksums ): array { $found = $this->find_unexpected_root_files( $checksums ); foreach ( array( 'wp-admin', 'wp-includes' ) as $dir ) { $root = Coreseal_Paths::core_root() . $dir; if ( ! is_dir( $root ) ) { continue; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ( $iterator as $file ) { if ( ! $file->isFile() ) { continue; } $relative = str_replace( '\\', '/', substr( wp_normalize_path( $file->getPathname() ), strlen( Coreseal_Paths::core_root() ) ) ); if ( ! isset( $checksums[ $relative ] ) ) { $found[] = $relative; } } } sort( $found ); /** * Lets site owners or add-ons whitelist known extra files. * * @param string[] $found Relative paths of unexpected files. */ return (array) apply_filters( 'coreseal_core_unexpected_files', $found ); } /** * Files at the root of the site (not recursive) that are not part of * the official release, except the few files a normal site needs. * * @return string[] File names. */ private function find_unexpected_root_files( array $checksums ): array { /** * Root files that are expected on a normal site and never reported. * Comparison is case-insensitive. * * @param string[] $allowed File names. */ $allowed = array_map( 'strtolower', (array) apply_filters( 'coreseal_core_allowed_root_files', array( 'wp-config.php', '.htaccess', '.user.ini', 'php.ini', 'robots.txt', 'favicon.ico', 'web.config', ) ) ); $found = array(); foreach ( new DirectoryIterator( Coreseal_Paths::core_root() ) as $file ) { if ( $file->isDot() || ! $file->isFile() ) { continue; } $name = $file->getFilename(); if ( isset( $checksums[ $name ] ) || in_array( strtolower( $name ), $allowed, true ) ) { continue; } $found[] = $name; } return $found; } }