' or * 'theme:' => version. * * @var array */ private static array $versions_before = array(); /** * Packages core reported as installed this request, waiting for * `upgrader_process_complete`. * * @var array */ private static array $installed = array(); /** * Names read at delete_plugin / delete_theme, keyed the same way as * $versions_before. * * @var array */ private static array $names_before_delete = array(); /** * Files the editor is about to write: absolute path => what to log. * * @var array */ private static array $edits = array(); /** * Uploads that replaced an installed package (5.5+ "Replace current * with uploaded"), by kind. * * @var array */ private static array $overwrote = array(); /** * @return array Event type => label for the log's filter. */ public static function event_types(): array { return array( self::PLUGIN_INSTALLED => 'Plugin Installed', self::PLUGIN_UPDATED => 'Plugin Updated', self::PLUGIN_DELETED => 'Plugin Deleted', self::THEME_INSTALLED => 'Theme Installed', self::THEME_UPDATED => 'Theme Updated', self::THEME_DELETED => 'Theme Deleted', self::CORE_UPDATED => 'WordPress Updated', self::FILE_EDITED => 'File Edited', ); } public static function register(): void { add_filter( 'upgrader_pre_install', array( __CLASS__, 'remember_version_before' ), 10, 2 ); add_filter( 'upgrader_install_package_result', array( __CLASS__, 'remember_package_result' ), 10, 2 ); add_action( 'upgrader_overwrote_package', array( __CLASS__, 'remember_overwrite' ), 10, 3 ); add_action( 'upgrader_process_complete', array( __CLASS__, 'log_upgrader_complete' ), 20, 2 ); add_action( 'delete_plugin', array( __CLASS__, 'remember_plugin_name' ) ); add_action( 'deleted_plugin', array( __CLASS__, 'log_plugin_deleted' ), 10, 2 ); add_action( 'delete_theme', array( __CLASS__, 'remember_theme_name' ) ); add_action( 'deleted_theme', array( __CLASS__, 'log_theme_deleted' ), 10, 2 ); // Priority 0: before core's own handler, registered at priority 1. add_action( 'wp_ajax_edit-theme-plugin-file', array( __CLASS__, 'watch_editor_request' ), 0 ); add_action( 'load-plugin-editor.php', array( __CLASS__, 'watch_editor_request' ) ); add_action( 'load-theme-editor.php', array( __CLASS__, 'watch_editor_request' ) ); add_action( 'shutdown', array( __CLASS__, 'log_file_edits' ) ); } /** * `upgrader_pre_install`: the version about to be replaced. A filter, so * the response passes through untouched. * * @param mixed $response Whatever core or another filter decided. * @param mixed $hook_extra Which package this is (an array). * @return mixed */ public static function remember_version_before( $response, $hook_extra ) { $hook_extra = is_array( $hook_extra ) ? $hook_extra : array(); if ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) { $version = self::plugin_header( $hook_extra['plugin'] )['Version']; if ( '' !== $version ) { self::$versions_before[ 'plugin:' . $hook_extra['plugin'] ] = $version; } } elseif ( isset( $hook_extra['theme'] ) && is_string( $hook_extra['theme'] ) ) { $theme = wp_get_theme( $hook_extra['theme'] ); if ( $theme->exists() ) { self::$versions_before[ 'theme:' . $hook_extra['theme'] ] = (string) $theme->get( 'Version' ); } } return $response; } /** * `upgrader_install_package_result`: whether THIS package installed. * * @param mixed $result An array on success, a WP_Error on failure. * @param mixed $hook_extra Which package this is (an array). * @return mixed */ public static function remember_package_result( $result, $hook_extra ) { if ( ! is_array( $result ) || ! is_array( $hook_extra ) ) { return $result; } if ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) { self::$installed[] = array( 'kind' => 'plugin', 'action' => 'update', 'slug' => $hook_extra['plugin'], 'replaced' => false, ); } elseif ( isset( $hook_extra['theme'] ) && is_string( $hook_extra['theme'] ) ) { self::$installed[] = array( 'kind' => 'theme', 'action' => 'update', 'slug' => $hook_extra['theme'], 'replaced' => false, ); } elseif ( 'install' === ( $hook_extra['action'] ?? null ) && in_array( $hook_extra['type'] ?? null, array( 'plugin', 'theme' ), true ) && isset( $result['destination_name'] ) && is_string( $result['destination_name'] ) && '' !== $result['destination_name'] ) { self::$installed[] = array( 'kind' => (string) $hook_extra['type'], 'action' => 'install', 'slug' => $result['destination_name'], 'replaced' => false, ); } return $result; } /** * `upgrader_overwrote_package`: an upload replaced an installed package. * * @param mixed $package The package file. * @param mixed $new_data The new package's headers. * @param mixed $type 'plugin' or 'theme'. */ public static function remember_overwrite( $package, $new_data, $type ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- the action's signature. if ( is_string( $type ) ) { self::$overwrote[ $type ] = true; } } /** * `upgrader_process_complete`: write one row per package that installed. * * @param mixed $upgrader The upgrader. * @param mixed $hook_extra What ran (an array). */ public static function log_upgrader_complete( $upgrader, $hook_extra ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- the action's signature. $hook_extra = is_array( $hook_extra ) ? $hook_extra : array(); if ( 'core' === ( $hook_extra['type'] ?? null ) ) { self::log_core_update(); return; } $installed = self::$installed; self::$installed = array(); foreach ( $installed as $item ) { if ( 'install' === $item['action'] ) { $item['replaced'] = ! empty( self::$overwrote[ $item['kind'] ] ); } if ( 'plugin' === $item['kind'] ) { self::log_plugin_package( $item ); } else { self::log_theme_package( $item ); } } self::$overwrote = array(); } /** * @param array{kind: string, action: string, slug: string, replaced: bool} $item One installed package. */ private static function log_plugin_package( array $item ): void { $file = $item['slug']; if ( 'install' === $item['action'] ) { // An install knows only the directory it unpacked to; the plugin // is its first main file, as Plugin_Upgrader::plugin_info() reads it. if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $found = get_plugins( '/' . $file ); if ( array() === $found ) { return; } $file = $file . '/' . (string) array_key_first( $found ); } $header = self::plugin_header( $file ); $name = '' !== $header['Name'] ? $header['Name'] : $file; if ( 'install' === $item['action'] ) { EventLogger::log( self::PLUGIN_INSTALLED, sprintf( $item['replaced'] ? 'Plugin "%1$s" %2$s installed, replacing the installed copy' : 'Plugin "%1$s" %2$s installed', $name, $header['Version'] ), 'plugin', null, array( 'plugin' => $file, 'version' => $header['Version'], 'replaced' => $item['replaced'], ) ); return; } $before = self::$versions_before[ 'plugin:' . $file ] ?? null; unset( self::$versions_before[ 'plugin:' . $file ] ); EventLogger::log( self::PLUGIN_UPDATED, null !== $before ? sprintf( 'Plugin "%1$s" updated from %2$s to %3$s', $name, $before, $header['Version'] ) : sprintf( 'Plugin "%1$s" updated to %2$s', $name, $header['Version'] ), 'plugin', null, array( 'plugin' => $file, 'old_version' => $before, 'new_version' => $header['Version'], ) ); } /** * @param array{kind: string, action: string, slug: string, replaced: bool} $item One installed package. */ private static function log_theme_package( array $item ): void { $stylesheet = $item['slug']; $theme = wp_get_theme( $stylesheet ); $theme->cache_delete(); $theme = wp_get_theme( $stylesheet ); if ( ! $theme->exists() ) { return; } $name = (string) $theme->get( 'Name' ); $version = (string) $theme->get( 'Version' ); if ( 'install' === $item['action'] ) { EventLogger::log( self::THEME_INSTALLED, sprintf( $item['replaced'] ? 'Theme "%1$s" %2$s installed, replacing the installed copy' : 'Theme "%1$s" %2$s installed', $name, $version ), 'theme', null, array( 'theme' => $stylesheet, 'version' => $version, 'replaced' => $item['replaced'], ) ); return; } $before = self::$versions_before[ 'theme:' . $stylesheet ] ?? null; unset( self::$versions_before[ 'theme:' . $stylesheet ] ); EventLogger::log( self::THEME_UPDATED, null !== $before ? sprintf( 'Theme "%1$s" updated from %2$s to %3$s', $name, $before, $version ) : sprintf( 'Theme "%1$s" updated to %2$s', $name, $version ), 'theme', null, array( 'theme' => $stylesheet, 'old_version' => $before, 'new_version' => $version, ) ); } /** * A core update, when the version on disk differs from the one this * request started with. See the class docblock for why both are read the * way they are. */ private static function log_core_update(): void { $before = isset( $GLOBALS['wp_version'] ) && is_string( $GLOBALS['wp_version'] ) ? $GLOBALS['wp_version'] : ''; $after = self::core_version_on_disk(); if ( null === $after || $after === $before ) { return; } EventLogger::log( self::CORE_UPDATED, '' !== $before ? sprintf( 'WordPress updated from %1$s to %2$s', $before, $after ) : sprintf( 'WordPress updated to %s', $after ), 'core', null, array( 'old_version' => '' !== $before ? $before : null, 'new_version' => $after, ) ); } /** * The `$wp_version` assignment in wp-includes/version.php, read as text: * including the file would overwrite this request's globals. */ public static function core_version_on_disk(): ?string { $path = ABSPATH . 'wp-includes/version.php'; if ( ! is_readable( $path ) ) { return null; } $source = (string) file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local core file. return 1 === preg_match( '/^\$wp_version\s*=\s*\'([^\']{1,40})\'\s*;/m', $source, $m ) ? $m[1] : null; } /** * @param string $plugin Plugin file. */ public static function remember_plugin_name( string $plugin ): void { $header = self::plugin_header( $plugin ); if ( '' !== $header['Name'] ) { self::$names_before_delete[ 'plugin:' . $plugin ] = $header['Name']; } } /** * @param string $plugin Plugin file. * @param bool $deleted Whether core removed it. */ public static function log_plugin_deleted( string $plugin, bool $deleted ): void { $name = self::$names_before_delete[ 'plugin:' . $plugin ] ?? $plugin; unset( self::$names_before_delete[ 'plugin:' . $plugin ] ); if ( ! $deleted ) { return; } EventLogger::log( self::PLUGIN_DELETED, sprintf( 'Plugin "%s" deleted', $name ), 'plugin', null, array( 'plugin' => $plugin ) ); } /** * @param string $stylesheet Theme directory name. */ public static function remember_theme_name( string $stylesheet ): void { $theme = wp_get_theme( $stylesheet ); if ( $theme->exists() ) { self::$names_before_delete[ 'theme:' . $stylesheet ] = (string) $theme->get( 'Name' ); } } /** * @param string $stylesheet Theme directory name. * @param bool $deleted Whether core removed it. */ public static function log_theme_deleted( string $stylesheet, bool $deleted ): void { $name = self::$names_before_delete[ 'theme:' . $stylesheet ] ?? $stylesheet; unset( self::$names_before_delete[ 'theme:' . $stylesheet ] ); if ( ! $deleted ) { return; } EventLogger::log( self::THEME_DELETED, sprintf( 'Theme "%s" deleted', $name ), 'theme', null, array( 'theme' => $stylesheet ) ); } /** * Before core's editor runs: when this request is a genuine editor save * -- core's own nonce for this exact file, and the capability core itself * checks -- note the target file's hash. Anything short of that is * ignored; core will refuse it too. */ public static function watch_editor_request(): void { // Core's editor posts `nonce`; it is verified below, per target, with // the exact action core itself verifies. // phpcs:disable WordPress.Security.NonceVerification.Missing if ( ! isset( $_POST['file'], $_POST['nonce'] ) || ! is_string( $_POST['file'] ) || ! is_string( $_POST['nonce'] ) ) { return; } $request = array( 'file' => sanitize_text_field( wp_unslash( $_POST['file'] ) ), 'nonce' => sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'plugin' => isset( $_POST['plugin'] ) && is_string( $_POST['plugin'] ) ? sanitize_text_field( wp_unslash( $_POST['plugin'] ) ) : '', 'theme' => isset( $_POST['theme'] ) && is_string( $_POST['theme'] ) ? sanitize_text_field( wp_unslash( $_POST['theme'] ) ) : '', ); // phpcs:enable WordPress.Security.NonceVerification.Missing self::watch_edit( $request ); } /** * The testable half of watch_editor_request(): the same checks core's * wp_edit_theme_plugin_file() makes, in the same order, against an * already-unslashed request. * * @param array{file: string, nonce: string, plugin: string, theme: string} $request The editor's fields. */ public static function watch_edit( array $request ): void { $file = $request['file']; if ( '' === $file || 0 !== validate_file( $file ) ) { return; } if ( '' !== $request['plugin'] ) { if ( ! current_user_can( 'edit_plugins' ) || false === wp_verify_nonce( $request['nonce'], 'edit-plugin_' . $file ) ) { return; } $owner = $request['plugin']; $object_type = 'plugin'; $path = WP_PLUGIN_DIR . '/' . $file; // The file must belong to the named plugin, as core requires. $plugin_dir = dirname( $owner ); if ( '.' === $plugin_dir ? $file !== $owner : ! str_starts_with( $file, $plugin_dir . '/' ) ) { return; } } elseif ( '' !== $request['theme'] ) { $stylesheet = $request['theme']; if ( 0 !== validate_file( $stylesheet ) || ! current_user_can( 'edit_themes' ) || false === wp_verify_nonce( $request['nonce'], 'edit-theme_' . $stylesheet . '_' . $file ) ) { return; } $theme = wp_get_theme( $stylesheet ); if ( ! $theme->exists() ) { return; } $owner = $stylesheet; $object_type = 'theme'; $path = $theme->get_stylesheet_directory() . '/' . $file; } else { return; } $exists = is_file( $path ); self::$edits[ $path ] = array( 'object_type' => $object_type, 'owner' => $owner, 'file' => $file, 'hash' => $exists ? (string) md5_file( $path ) : null, 'bytes' => $exists ? (int) filesize( $path ) : null, ); } /** * `shutdown`: every watched file whose content changed was saved. */ public static function log_file_edits(): void { $edits = self::$edits; self::$edits = array(); foreach ( $edits as $path => $edit ) { clearstatcache( true, $path ); $hash = is_file( $path ) ? (string) md5_file( $path ) : null; if ( null === $hash || $hash === $edit['hash'] ) { continue; } $owner = 'plugin' === $edit['object_type'] ? self::plugin_header( $edit['owner'] )['Name'] : (string) wp_get_theme( $edit['owner'] )->get( 'Name' ); $owner = '' !== $owner ? $owner : $edit['owner']; EventLogger::log( self::FILE_EDITED, sprintf( 'File "%1$s" of %2$s "%3$s" edited in the file editor', EventLogger::bounded( $edit['file'], 200 ), $edit['object_type'], $owner ), $edit['object_type'], null, array( $edit['object_type'] => $edit['owner'], 'file' => EventLogger::bounded( $edit['file'], 200 ), 'bytes_before' => $edit['bytes'], 'bytes_after' => (int) filesize( $path ), ) ); } } /** * A plugin's Name and Version, read from its file (never the cached list, * which is stale mid-update). * * @param string $plugin Plugin file, relative to the plugins directory. * @return array{Name: string, Version: string} */ private static function plugin_header( string $plugin ): array { $path = WP_PLUGIN_DIR . '/' . $plugin; if ( 0 !== validate_file( $plugin ) || ! is_file( $path ) ) { return array( 'Name' => '', 'Version' => '', ); } if ( ! function_exists( 'get_plugin_data' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $data = get_plugin_data( $path, false, false ); return array( 'Name' => (string) $data['Name'], 'Version' => (string) $data['Version'], ); } /** * Forget everything held for this request. Tests only; production * requests end. */ public static function reset(): void { self::$versions_before = array(); self::$installed = array(); self::$names_before_delete = array(); self::$edits = array(); self::$overwrote = array(); } }