Current File : /home/digitaw/www/wp-content/plugins/simple-history/loggers/class-core-files-logger.php
<?php

namespace Simple_History\Loggers;

use Simple_History\Event_Details\Event_Details_Group;
use Simple_History\Event_Details\Event_Details_Item;

/**
 * Logger to detect modifications to WordPress core files
 *
 * Checks core file integrity by comparing MD5 hashes against WordPress checksums
 * and logs any detected modifications for security monitoring.
 */
class Core_Files_Logger extends Logger {
	/** @var string Logger slug */
	public $slug = 'CoreFilesLogger';

	/** @var string Option name to store previous check results */
	const OPTION_NAME_FILE_CHECK_RESULTS = 'simple_history_core_files_integrity_results';

	/** @var string Cron hook name */
	const CRON_HOOK = 'simple_history/core_files_integrity_check';

	/**
	 * Get array with information about this logger
	 *
	 * @return array
	 */
	public function get_info() {
		return [
			'name'        => __( 'Core Files Logger', 'simple-history' ),
			'description' => __( 'Detects modifications to WordPress core files by checking file integrity against official checksums', 'simple-history' ),
			'capability'  => 'manage_options',
			'messages'    => [
				'core_files_modified'     => __( 'Detected modifications to {file_count} WordPress core files', 'simple-history' ),
				'core_files_restored'     => __( 'Verified integrity restored for {file_count} WordPress core files', 'simple-history' ),
				'core_files_check_failed' => __( 'Could not check WordPress core files integrity: {error_message}', 'simple-history' ),
			],
			'labels'      => [
				'search' => [
					'label'   => _x( 'Core Files Modifications', 'Core Files Logger: search', 'simple-history' ),
					'options' => [
						_x( 'Core file modifications', 'Core Files Logger: search', 'simple-history' ) => [
							'core_files_modified',
							'core_files_restored',
							'core_files_check_failed',
						],
					],
				],
			],
		];
	}

	/**
	 * Called when logger is loaded.
	 */
	public function loaded() {
		// Set up cron job for daily integrity checks.
		add_action( 'init', [ $this, 'setup_cron' ] );

		// Handle the actual cron job.
		add_action( self::CRON_HOOK, [ $this, 'perform_integrity_check' ] );
	}

	/**
	 * Setup WordPress cron job for daily core files integrity checks.
	 */
	public function setup_cron() {
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			// Schedule daily check at 3 AM site time to minimize server impact.
			$timezone  = wp_timezone();
			$datetime  = new \DateTime( 'tomorrow 3:00 AM', $timezone );
			$timestamp = $datetime->getTimestamp();
			wp_schedule_event( $timestamp, 'daily', self::CRON_HOOK );
		}
	}

	/**
	 * Perform core files integrity check.
	 *
	 * This is the main method that gets called by the cron job
	 */
	public function perform_integrity_check() {
		$modified_files = $this->check_core_files_integrity();

		// Bail if error.
		if ( is_wp_error( $modified_files ) ) {
			return;
		}

		$this->process_check_results( $modified_files );
	}

	/**
	 * Check WordPress core files integrity using official checksums.
	 *
	 * If modified files are found, they are returned like this:
	 *
	 * Array
	 * (
	 *     [0] => Array
	 *         (
	 *             [file] => xmlrpc.php
	 *             [issue] => modified
	 *             [expected_hash] => fb407463c202f1a8ab8783fa5b24ec13
	 *             [actual_hash] => 57cb4f86b855614dd3e7d565b2f6f888
	 *         )
	 *
	 *     [1] => Array
	 *         (
	 *             [file] => wp-settings.php
	 *             [issue] => modified
	 *             [expected_hash] => 0f52e2e688de1d2d776a12e55e5ca9c3
	 *             [actual_hash] => 2e60a9b2c1daef9089a8fea7e0a691dd
	 *         )
	 * )
	 *
	 * @return array|\WP_Error Array of modified files with their details or WP_Error if there is an error.
	 */
	private function check_core_files_integrity() {
		global $wp_version;

		// Make sure the `get_core_checksums()` function is available.
		if ( ! function_exists( 'get_core_checksums' ) ) {
			require_once ABSPATH . 'wp-admin/includes/update.php';
		}

		// Get official WordPress checksums for current version.
		$checksums = get_core_checksums( $wp_version, 'en_US' );

		if ( ! is_array( $checksums ) || empty( $checksums ) ) {
			return new \WP_Error( 'core_files_check_failed', 'Unable to retrieve WordPress core checksums for version ' . esc_html( $wp_version ) );
		}

		$modified_files = [];
		$wp_root        = ABSPATH;

		// Check each file in the checksums array.
		foreach ( $checksums as $file => $expected_hash ) {
			// Skip files which get updated.
			if ( str_starts_with( $file, 'wp-content' ) ) {
				continue;
			}

			$file_path = $wp_root . $file;

			// Check if file doesn't exist (missing core files should be logged).
			if ( ! file_exists( $file_path ) ) {
				$modified_files[] = [
					'file'          => $file,
					'issue'         => 'missing',
					'expected_hash' => $expected_hash,
					'actual_hash'   => null,
				];
				continue;
			}

			// Calculate actual file hash.
			$actual_hash = md5_file( $file_path );

			if ( $actual_hash === false ) {
				// File exists but can't be read.
				$modified_files[] = [
					'file'          => $file,
					'issue'         => 'unreadable',
					'expected_hash' => $expected_hash,
					'actual_hash'   => null,
				];
				continue;
			}

			// Compare hashes.
			if ( $actual_hash !== $expected_hash ) {
				$modified_files[] = [
					'file'          => $file,
					'issue'         => 'modified',
					'expected_hash' => $expected_hash,
					'actual_hash'   => $actual_hash,
				];
			}
		}

		return $modified_files;
	}

	/**
	 * Process check results and log changes appropriately
	 *
	 * @param array $modified_files Array of modified files from integrity check.
	 */
	private function process_check_results( $modified_files ) {
		$previous_results = get_option( self::OPTION_NAME_FILE_CHECK_RESULTS, [] );
		$current_results  = [];

		// Convert modified files to simple array for comparison.
		foreach ( $modified_files as $file_data ) {
			$current_results[ $file_data['file'] ] = $file_data;
		}

		// Check if this is a new issue or resolved issue.
		$new_issues      = array_diff_key( $current_results, $previous_results );
		$resolved_issues = array_diff_key( $previous_results, $current_results );

		// Log new issues.
		if ( ! empty( $new_issues ) ) {
			$context = [
				'file_count'     => count( $new_issues ),
				'modified_files' => array_keys( $new_issues ),
				'file_details'   => array_values( $new_issues ),
			];

			$this->warning_message( 'core_files_modified', $context );
		}

		// Log resolved issues.
		if ( ! empty( $resolved_issues ) && ! empty( $previous_results ) ) {
			$context = [
				'file_count'     => count( $resolved_issues ),
				'restored_files' => array_keys( $resolved_issues ),
				'file_details'   => array_values( $resolved_issues ),
			];

			$this->info_message( 'core_files_restored', $context );
		}

		// Update stored results.
		update_option( self::OPTION_NAME_FILE_CHECK_RESULTS, $current_results );
	}

	/**
	 * Get output for log row details
	 *
	 * @param object $row Log row.
	 * @return Event_Details_Group|null
	 */
	public function get_log_row_details_output( $row ) {
		$context     = $row->context;
		$message_key = $context['_message_key'] ?? null;

		if ( ! $message_key ) {
			return null;
		}

		// Handle detected and restored events.
		if ( ! in_array( $message_key, [ 'core_files_modified', 'core_files_restored' ], true ) ) {
			return null;
		}

		if ( empty( $context['file_details'] ) ) {
			return null;
		}

		// Decode the JSON stored file_details.
		$file_details = json_decode( $context['file_details'] );
		if ( ! is_array( $file_details ) ) {
			return null;
		}

		$event_details_group = new Event_Details_Group();

		// Set appropriate title based on the event type.
		if ( 'core_files_restored' === $message_key ) {
			$event_details_group->set_title( __( 'Restored Core Files', 'simple-history' ) );
		} else {
			$event_details_group->set_title( __( 'Modified Core Files', 'simple-history' ) );
		}

		// Limit to first 5 files to keep log events manageable.
		$limited_file_details = array_slice( $file_details, 0, 5 );
		$total_files          = count( $file_details );

		foreach ( $limited_file_details as $file_data ) {
			// Handle stdClass objects.
			$file  = $file_data->file ?? '';
			$issue = $file_data->issue ?? '';

			if ( empty( $file ) || empty( $issue ) ) {
				continue;
			}

			// Determine the status text.
			if ( 'core_files_restored' === $message_key ) {
				// For restored files, show what was fixed.
				if ( 'modified' === $issue ) {
					$status_text = __( 'Hash mismatch fixed', 'simple-history' );
				} elseif ( 'unreadable' === $issue ) {
					$status_text = __( 'File readability restored', 'simple-history' );
				} elseif ( 'missing' === $issue ) {
					$status_text = __( 'Missing file restored', 'simple-history' );
				} else {
					/* translators: %s: issue type */
					$status_text = sprintf( __( '%s fixed', 'simple-history' ), esc_html( $issue ) );
				}
			} elseif ( 'core_files_modified' === $message_key ) {
				// For detected issues, show the current problem.
				if ( 'modified' === $issue ) {
					$status_text = __( 'Hash mismatch', 'simple-history' );
				} elseif ( 'unreadable' === $issue ) {
					$status_text = __( 'File unreadable', 'simple-history' );
				} elseif ( 'missing' === $issue ) {
					$status_text = __( 'File missing', 'simple-history' );
				} else {
					$status_text = esc_html( $issue );
				}
			}

			// Create an Event_Details_Item for each file without context key.
			$event_details_group->add_item(
				( new Event_Details_Item(
					null, // No context key needed.
					$file // Label (file name).
				) )->set_new_value( $status_text ) // Manually set the value.
			);
		}

		// Add summary if there are more files than displayed.
		if ( $total_files > 5 ) {
			$remaining_count = $total_files - 5;
			$event_details_group->add_item(
				( new Event_Details_Item(
					null,
					__( 'Additional files', 'simple-history' )
				) )->set_new_value(
					sprintf(
						/* translators: %d: number of additional files not shown */
						_n(
							'%d more file affected',
							'%d more files affected',
							$remaining_count,
							'simple-history'
						),
						$remaining_count
					)
				)
			);
		}

		return $event_details_group;
	}
}