scan_core_files(); if ( ! empty( $changes['modified'] ) || ! empty( $changes['added'] ) || ! empty( $changes['removed'] ) ) { // Store changes. update_option( self::CHANGES_OPTION, $changes ); // Send alert if enabled. $this->send_alert( $changes ); } } /** * Scan WordPress core files. * * @param bool $update_baseline Whether to update the baseline. * @return array */ public function scan_core_files( $update_baseline = false ) { global $wp_version; $changes = array( 'modified' => array(), 'added' => array(), 'removed' => array(), ); // Get stored hashes. $stored_hashes = get_option( self::HASHES_OPTION, array() ); // Get official checksums from WordPress.org. $official_checksums = $this->get_official_checksums( $wp_version ); // Current file hashes. $current_hashes = array(); // Core directories to scan. $core_paths = array( ABSPATH . 'wp-admin/', ABSPATH . 'wp-includes/', ABSPATH . 'index.php', ABSPATH . 'wp-activate.php', ABSPATH . 'wp-blog-header.php', ABSPATH . 'wp-comments-post.php', ABSPATH . 'wp-cron.php', ABSPATH . 'wp-links-opml.php', ABSPATH . 'wp-load.php', ABSPATH . 'wp-login.php', ABSPATH . 'wp-mail.php', ABSPATH . 'wp-settings.php', ABSPATH . 'wp-signup.php', ABSPATH . 'wp-trackback.php', ABSPATH . 'xmlrpc.php', ); // Scan each path. foreach ( $core_paths as $path ) { if ( is_file( $path ) ) { $relative_path = str_replace( ABSPATH, '', $path ); $hash = md5_file( $path ); $current_hashes[ $relative_path ] = $hash; } elseif ( is_dir( $path ) ) { $files = $this->get_directory_files( $path ); foreach ( $files as $file ) { $relative_path = str_replace( ABSPATH, '', $file ); $hash = md5_file( $file ); $current_hashes[ $relative_path ] = $hash; } } } // Compare with official checksums if available. if ( ! empty( $official_checksums ) ) { foreach ( $current_hashes as $file => $hash ) { if ( isset( $official_checksums[ $file ] ) ) { if ( $hash !== $official_checksums[ $file ] ) { $changes['modified'][] = array( 'file' => $file, 'expected' => $official_checksums[ $file ], 'actual' => $hash, 'source' => 'official', ); } } } } elseif ( ! empty( $stored_hashes ) ) { // Compare with stored baseline. foreach ( $current_hashes as $file => $hash ) { if ( isset( $stored_hashes[ $file ] ) ) { if ( $hash !== $stored_hashes[ $file ] ) { $changes['modified'][] = array( 'file' => $file, 'previous' => $stored_hashes[ $file ], 'current' => $hash, 'source' => 'baseline', ); } } else { $changes['added'][] = $file; } } // Check for removed files. foreach ( $stored_hashes as $file => $hash ) { if ( ! isset( $current_hashes[ $file ] ) ) { $changes['removed'][] = $file; } } } // Update baseline if requested or first scan. if ( $update_baseline || empty( $stored_hashes ) ) { update_option( self::HASHES_OPTION, $current_hashes ); } // Update last scan time. update_option( self::LAST_SCAN_OPTION, time() ); return $changes; } /** * Get official checksums from WordPress.org. * * @param string $version WordPress version. * @return array */ private function get_official_checksums( $version ) { $locale = get_locale(); // Try to get from cache. $cache_key = 'arksp_checksums_' . md5( $version . $locale ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } // Fetch from WordPress.org. $url = sprintf( 'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=%s', $version, $locale ); $response = wp_remote_get( $url, array( 'timeout' => 30 ) ); if ( is_wp_error( $response ) ) { return array(); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! isset( $data['checksums'] ) ) { return array(); } $checksums = $data['checksums']; // Cache for 1 day. set_transient( $cache_key, $checksums, DAY_IN_SECONDS ); return $checksums; } /** * Get all PHP files in a directory recursively. * * @param string $dir Directory path. * @return array */ private function get_directory_files( $dir ) { $files = array(); $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ), RecursiveIteratorIterator::SELF_FIRST ); foreach ( $iterator as $file ) { if ( $file->isFile() ) { $ext = strtolower( $file->getExtension() ); // Only track PHP files and critical files. if ( in_array( $ext, array( 'php', 'js', 'css' ), true ) ) { $files[] = $file->getPathname(); } } } return $files; } /** * Send email alert about file changes. * * @param array $changes File changes. */ private function send_alert( $changes ) { if ( ! ARKSP_Plugin::get_setting( 'email_alerts_enabled', false ) ) { return; } $email = ARKSP_Plugin::get_setting( 'email_alerts_address', get_option( 'admin_email' ) ); $site_name = get_bloginfo( 'name' ); $site_url = home_url(); $subject = sprintf( /* translators: %s: Site name */ __( '[%s] File Integrity Alert - Core Files Changed', 'arkhost-security-pack' ), $site_name ); $message = sprintf( /* translators: %s: Site URL */ __( "ArkHost Security Pack has detected changes to WordPress core files on %s.\n\n", 'arkhost-security-pack' ), $site_url ); if ( ! empty( $changes['modified'] ) ) { $message .= __( "Modified files:\n", 'arkhost-security-pack' ); foreach ( $changes['modified'] as $file ) { $message .= '- ' . ( is_array( $file ) ? $file['file'] : $file ) . "\n"; } $message .= "\n"; } if ( ! empty( $changes['added'] ) ) { $message .= __( "New files detected:\n", 'arkhost-security-pack' ); foreach ( $changes['added'] as $file ) { $message .= '- ' . $file . "\n"; } $message .= "\n"; } if ( ! empty( $changes['removed'] ) ) { $message .= __( "Removed files:\n", 'arkhost-security-pack' ); foreach ( $changes['removed'] as $file ) { $message .= '- ' . $file . "\n"; } $message .= "\n"; } $message .= __( "This could indicate:\n", 'arkhost-security-pack' ); $message .= __( "- A recent WordPress update (normal)\n", 'arkhost-security-pack' ); $message .= __( "- Unauthorized modifications (investigate)\n", 'arkhost-security-pack' ); $message .= __( "- Plugin/theme conflicts (rare)\n\n", 'arkhost-security-pack' ); $message .= __( "Review these changes in your WordPress admin panel.", 'arkhost-security-pack' ); wp_mail( $email, $subject, $message ); } /** * Get last scan results. * * @return array */ public function get_last_scan_results() { return array( 'time' => get_option( self::LAST_SCAN_OPTION, 0 ), 'changes' => get_option( self::CHANGES_OPTION, array() ), ); } /** * Clear file changes. */ public function clear_changes() { delete_option( self::CHANGES_OPTION ); } /** * Reset baseline. */ public function reset_baseline() { delete_option( self::HASHES_OPTION ); delete_option( self::CHANGES_OPTION ); } }