array( 'name' => 'Hello Dolly', 'path' => 'wp-content/plugins/hello.php', ), ); /** * Plugins folder. * * @var string|null */ private $plugins_dir; /** * Constructor. * * @param string|null $plugins_dir Plugins folder; defaults to WP_PLUGIN_DIR. */ public function __construct( $plugins_dir = null ) { $this->plugins_dir = $plugins_dir; } /** * {@inheritDoc} */ public function name(): string { return 'verify_plugin_checksums'; } /** * Protected: the result tells an attacker which plugin files differ, and each call makes the site contact * WordPress.org once per plugin. */ public function access(): string { return Tool::ACCESS_PROTECTED; } /** * {@inheritDoc} */ public function definition(): array { $plugin = CoreChecksums::output_schema( array( 'slug' => array( 'type' => 'string' ), 'version' => array( 'type' => 'string' ), 'status' => array( 'type' => 'string', 'enum' => array( 'verified', 'modified', 'unavailable' ), 'description' => 'unavailable: WordPress.org has no checksums for this plugin version (not hosted there, e.g. premium or custom, or could not be reached).', ), ), array( 'slug', 'version', 'status', 'counts', 'files', 'truncated' ) ); unset( $plugin['properties']['verified'], $plugin['properties']['files_checked'] ); return array( 'name' => $this->name(), 'title' => 'Verify plugin files with FlitAgent', 'description' => 'From the FlitAgent WordPress plugin: checks installed plugin files against the checksums WordPress.org publishes for each plugin version (like wp plugin verify-checksums). Returns per plugin whether it verifies, and the relative paths of modified, missing or extra files. readme files are skipped. The Hello Dolly that WordPress bundles (hello.php) is checked against the checksums of WordPress core, as WP-CLI does. Plugins not on WordPress.org are reported as unavailable. Never returns file contents. Contacts WordPress.org once per plugin. Requires an administrator login.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'slug' => array( 'type' => 'string', 'description' => 'Plugin slug (its folder name, e.g. "akismet"). Omit to check every installed plugin.', ), ), 'additionalProperties' => false, ), 'outputSchema' => array( 'type' => 'object', 'properties' => array( 'plugins' => array( 'type' => 'array', 'items' => $plugin, ), ), 'required' => array( 'plugins' ), ), 'annotations' => Annotations::read_only( 'Verify plugin files with FlitAgent', true ), ); } /** * Verifies one plugin or all of them. * * @param array $arguments Optional `slug`. * @return array * @throws ToolError When the arguments are invalid or the plugin is not installed. */ public function call( array $arguments ): array { foreach ( array_keys( $arguments ) as $key ) { if ( 'slug' !== $key ) { throw new ToolError( 'Unknown argument. verify_plugin_checksums accepts only slug.' ); } } $slug = null; if ( isset( $arguments['slug'] ) ) { $slug = $arguments['slug']; if ( ! is_string( $slug ) || 1 !== preg_match( '/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $slug ) ) { throw new ToolError( 'slug must be a plugin folder name such as "akismet".' ); } } if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $plugins = array(); foreach ( get_plugins() as $file => $data ) { $file = (string) $file; $folder = dirname( $file ); $single = '.' === $folder; $key = $single ? basename( $file, '.php' ) : $folder; if ( null === $slug || $slug === $key ) { $plugins[ $key ] = array( 'file' => $file, 'single' => $single, 'version' => isset( $data['Version'] ) ? (string) $data['Version'] : '', 'name' => isset( $data['Name'] ) ? (string) $data['Name'] : '', ); } } if ( null !== $slug && array() === $plugins ) { throw new ToolError( sprintf( 'Plugin "%s" is not installed.', esc_textarea( $slug ) ) ); } $results = array(); foreach ( $plugins as $key => $plugin ) { $key = (string) $key; if ( $plugin['single'] && isset( self::CORE_BUNDLED[ $key ] ) && self::CORE_BUNDLED[ $key ]['name'] === $plugin['name'] ) { $manifest = self::core_manifest( basename( $plugin['file'] ), self::CORE_BUNDLED[ $key ]['path'] ); } else { $manifest = '' === $plugin['version'] ? null : self::manifest( $key, $plugin['version'] ); } $results[] = $this->verify( $key, $plugin['file'], $plugin['single'], $plugin['version'], $manifest ); } return array( 'plugins' => $results ); } /** * Verifies one plugin. * * @param string $slug Slug. * @param string $file Plugin file relative to the plugins folder. * @param bool $single Whether it is a single-file plugin in the plugins folder root. * @param string $version Installed version. * @param array|null $manifest Relative path => MD5 hashes, or null if WordPress.org has none. * @return array */ private function verify( string $slug, string $file, bool $single, string $version, $manifest ): array { if ( null === $manifest ) { return array( 'slug' => $slug, 'version' => $version, 'status' => 'unavailable', 'counts' => array( 'modified' => 0, 'missing' => 0, 'extra' => 0, ), 'files' => array(), 'truncated' => false, ); } $plugins_dir = rtrim( null === $this->plugins_dir ? WP_PLUGIN_DIR : $this->plugins_dir, '/\\' ) . '/'; $own_file = basename( $file ); $result = FileChecksums::compare( $single ? $plugins_dir : $plugins_dir . dirname( $file ), $manifest, static function ( string $path ) use ( $single, $own_file ): bool { if ( $single ) { // The plugins folder root holds other plugins too: only this plugin's own file is in scope. return $own_file === $path; } return ! in_array( strtolower( $path ), self::SOFT_CHANGE_FILES, true ); }, $single ? array() : array( '' => true ), static function (): bool { return true; } ); return array( 'slug' => $slug, 'version' => $version, 'status' => $result['verified'] ? 'verified' : 'modified', 'counts' => $result['counts'], 'files' => $result['files'], 'truncated' => $result['truncated'], ); } /** * The manifest of a single-file plugin WordPress bundles, from core's checksums for this version and locale. * * @param string $file The plugin's file name in the plugins folder root. * @param string $core_path Its path in core's checksums. * @return array|null File name => MD5 hashes, or null if core's checksums do not list it. */ private static function core_manifest( string $file, string $core_path ) { global $wp_version; $found = CoreChecksums::for_site( (string) $wp_version, (string) get_locale() ); if ( null === $found || ! isset( $found['checksums'][ $core_path ] ) ) { return null; } $hashes = FileChecksums::md5_list( $found['checksums'][ $core_path ] ); return array() === $hashes ? null : array( $file => $hashes ); } /** * Fetches a plugin's checksums. * * @param string $slug Slug. * @param string $version Version. * @return array|null Relative path => MD5 hashes, or null if WordPress.org has none. */ private static function manifest( string $slug, string $version ) { $body = FileChecksums::fetch_json( self::API . rawurlencode( $slug ) . '/' . rawurlencode( $version ) . '.json' ); if ( ! isset( $body['files'] ) || ! is_array( $body['files'] ) || array() === $body['files'] ) { return null; } $manifest = array(); foreach ( $body['files'] as $path => $hashes ) { $manifest[ (string) $path ] = FileChecksums::md5_list( $hashes ); } return $manifest; } }