/.json. Plugins the * API has no map for (premium/custom/single-file) are reported per-plugin as * skipped with a reason — absence of a checksum map is not a failure and must * not read as "clean". Each plugin dir is also walked for unexpected files. * * Hashing is wall-time budgeted (20 s) with a resume_from cursor (core: file * path; plugin: plugin basename, per-plugin atomic). Checksum maps are * refetched per call rather than cached — small requests, same class of * traffic as core's own update checks. */ // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } if ( ! class_exists( 'ATB_Operation_Verify_Checksums' ) ) { class ATB_Operation_Verify_Checksums extends ATB_Operation { const TIME_BUDGET = 20; // seconds, wall time const LIST_CAP = 100; // max paths per finding list const UNEXPECTED_CAP = 50; public function slug() { return 'verify-checksums'; } public function describe() { return array( 'slug' => $this->slug(), 'risk' => 'none', 'summary' => 'Verifies files against official WordPress.org checksums and reports modified, ' . 'missing, and unexpected files. scope=core (default) covers WordPress core; scope=plugin ' . 'covers one named plugin or every installed plugin — plugins without an official ' . 'wordpress.org checksum map (premium, custom, single-file) are listed as skipped, which ' . 'does NOT mean clean. Call when you suspect tampering, after a malware cleanup, or as a ' . 'periodic integrity check. Themes and uploads are not covered. If the result reports ' . 'completed=false, call again with the returned resume_from to continue. Read-only.', 'annotations' => array( 'readonly' => true, ), // Prefill for the admin Run panel (v1.4); agents may copy it too. 'example_input' => array( 'scope' => 'plugin', 'plugin' => 'classic-editor' ), 'input_schema' => array( // 'null' tolerated: a parameterless REST GET run sends input=null (M6). 'type' => array( 'object', 'null' ), 'properties' => array( 'scope' => array( 'type' => 'string', 'enum' => array( 'core', 'plugin' ), 'default' => 'core', 'description' => 'What to verify: WordPress core files, or installed plugins.', ), 'plugin' => array( 'type' => 'string', 'description' => 'scope=plugin only: verify just this plugin (plugin file like dir/plugin.php, or its slug). Omit to verify every installed plugin.', ), 'resume_from' => array( 'type' => 'string', 'description' => 'Cursor from a previous incomplete run (core: file path; plugin: plugin basename); verification continues after it.', ), ), ), ); } public function dry_run( $atb_input ) { $atb_details = $this->verify( $atb_input ); return $this->output( ! isset( $atb_details['refused'] ), true, $atb_details ); } public function run( $atb_input ) { $atb_details = $this->verify( $atb_input ); return $this->output( ! isset( $atb_details['refused'] ), false, $atb_details ); } /** * @param array|null $atb_input * @return array details shape per v1.2 spec §3.5 / v1.3 plan §3.3 (or a refusal). */ protected function verify( $atb_input ) { if ( is_array( $atb_input ) && isset( $atb_input['scope'] ) && 'plugin' === $atb_input['scope'] ) { return $this->verify_plugins( $atb_input ); } $atb_version = get_bloginfo( 'version' ); $atb_map = $this->fetch_checksums( $atb_version, get_locale(), $atb_locale_used ); if ( ! is_array( $atb_map ) || empty( $atb_map ) ) { return array( 'refused' => true, 'reason' => 'checksums_unavailable', 'message' => sprintf( 'WordPress.org returned no checksums for version %s (locale %s). Try again later; if it persists, the version may be too new or too old for the API.', $atb_version, get_locale() ), ); } $atb_resume_from = ( is_array( $atb_input ) && isset( $atb_input['resume_from'] ) ) ? (string) $atb_input['resume_from'] : ''; // Deterministic order makes the resume cursor meaningful. ksort( $atb_map, SORT_STRING ); $atb_started = microtime( true ); $atb_skipped_wp_content = 0; $atb_checked = 0; $atb_verified = 0; $atb_mismatched = array(); $atb_missing = array(); $atb_mismatched_count = 0; $atb_missing_count = 0; $atb_completed = true; $atb_next_cursor = ''; $atb_skipping = ( '' !== $atb_resume_from ); foreach ( $atb_map as $atb_path => $atb_md5 ) { $atb_path = (string) $atb_path; if ( 0 === strpos( $atb_path, 'wp-content/' ) ) { $atb_skipped_wp_content++; continue; } if ( $atb_skipping ) { if ( strcmp( $atb_path, $atb_resume_from ) <= 0 ) { continue; } $atb_skipping = false; } // Guaranteed progress: at least one file is hashed per call, // so a resume loop can never stall on an exhausted budget. if ( $atb_checked > 0 && ( microtime( true ) - $atb_started ) > $this->time_budget() ) { $atb_completed = false; break; } $atb_checked++; $atb_next_cursor = $atb_path; $atb_file = ABSPATH . $atb_path; if ( ! file_exists( $atb_file ) ) { $atb_missing_count++; if ( count( $atb_missing ) < self::LIST_CAP ) { $atb_missing[] = $atb_path; } continue; } if ( md5_file( $atb_file ) === (string) $atb_md5 ) { $atb_verified++; } else { $atb_mismatched_count++; if ( count( $atb_mismatched ) < self::LIST_CAP ) { $atb_mismatched[] = $atb_path; } } } $atb_details = array( 'scope' => 'core', 'wp_version' => (string) $atb_version, 'locale_used' => (string) $atb_locale_used, 'total_files' => count( $atb_map ) - $atb_skipped_wp_content, 'checked' => $atb_checked, 'verified' => $atb_verified, 'mismatched' => $atb_mismatched, 'mismatched_count' => $atb_mismatched_count, 'missing' => $atb_missing, 'missing_count' => $atb_missing_count, 'skipped_wp_content' => $atb_skipped_wp_content, 'completed' => $atb_completed, ); if ( ! $atb_completed ) { $atb_details['resume_from'] = $atb_next_cursor; $atb_details['unexpected_scan_done'] = false; return $atb_details; } // Unexpected files: present under wp-admin/ or wp-includes/ but not // in the map. A directory walk without hashing — cheap, runs only // once the full map has been verified. $atb_unexpected = array(); $atb_unexpected_capped = false; foreach ( array( 'wp-admin', 'wp-includes' ) as $atb_dir ) { if ( $this->collect_unexpected( $atb_dir, $atb_map, $atb_unexpected ) ) { $atb_unexpected_capped = true; break; } } $atb_details['unexpected'] = $atb_unexpected; $atb_details['unexpected_capped'] = $atb_unexpected_capped; $atb_details['unexpected_scan_done'] = true; return $atb_details; } /** * scope=plugin: verifies installed plugins against the wp.org * plugin-checksums API (v1.3 plan §3.3). * * The budget is checked between plugins only, so one plugin's result is * always atomic (a partial file list would be misleading); the first * plugin of every call runs regardless of budget, so a resume loop can * never stall. resume_from is the basename of the last completed * plugin file. * * @param array $atb_input * @return array details shape (or a refusal). */ protected function verify_plugins( $atb_input ) { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $atb_installed = get_plugins(); $atb_target = isset( $atb_input['plugin'] ) ? (string) $atb_input['plugin'] : ''; if ( '' !== $atb_target ) { $atb_resolved = $this->resolve_plugin( $atb_target, $atb_installed ); if ( null === $atb_resolved ) { return array( 'refused' => true, 'reason' => 'plugin_not_installed', 'message' => 'plugin not installed: ' . $atb_target, ); } $atb_installed = array( $atb_resolved => $atb_installed[ $atb_resolved ] ); } $atb_resume_from = isset( $atb_input['resume_from'] ) ? (string) $atb_input['resume_from'] : ''; // Deterministic order makes the resume cursor meaningful. ksort( $atb_installed, SORT_STRING ); $atb_started = microtime( true ); $atb_rows = array(); $atb_checked = 0; $atb_skipped = 0; $atb_with_issues = 0; $atb_completed = true; $atb_cursor = ''; $atb_skipping = ( '' !== $atb_resume_from ); foreach ( $atb_installed as $atb_file => $atb_data ) { $atb_file = (string) $atb_file; if ( $atb_skipping ) { if ( strcmp( $atb_file, $atb_resume_from ) <= 0 ) { continue; } $atb_skipping = false; } if ( $atb_checked + $atb_skipped > 0 && ( microtime( true ) - $atb_started ) > $this->time_budget() ) { $atb_completed = false; break; } $atb_rows[] = $this->verify_one_plugin( $atb_file, is_array( $atb_data ) ? $atb_data : array() ); $atb_row = $atb_rows[ count( $atb_rows ) - 1 ]; $atb_cursor = $atb_file; if ( 'skipped' === $atb_row['status'] ) { $atb_skipped++; } else { $atb_checked++; if ( 'issues' === $atb_row['status'] ) { $atb_with_issues++; } } } $atb_details = array( 'scope' => 'plugin', 'plugins' => $atb_rows, 'plugins_total' => count( $atb_installed ), 'plugins_checked' => $atb_checked, 'plugins_skipped' => $atb_skipped, 'plugins_with_issues' => $atb_with_issues, 'completed' => $atb_completed, ); if ( ! $atb_completed ) { $atb_details['resume_from'] = $atb_cursor; } return $atb_details; } /** * Resolves a caller-supplied plugin identifier (file or slug) against * the installed-plugins map. * * @param string $atb_target * @param array $atb_installed get_plugins() map. * @return string|null Plugin file key, or null when not installed. */ protected function resolve_plugin( $atb_target, $atb_installed ) { if ( isset( $atb_installed[ $atb_target ] ) ) { return $atb_target; } foreach ( array_keys( $atb_installed ) as $atb_file ) { if ( dirname( (string) $atb_file ) === $atb_target ) { return (string) $atb_file; } } return null; } /** * Verifies one plugin's files against its wp.org checksum map. * * @param string $atb_file Plugin file (dir/plugin.php or single.php). * @param array $atb_data get_plugins() row (Name, Version, ...). * @return array Per-plugin result row. */ protected function verify_one_plugin( $atb_file, $atb_data ) { $atb_slug = dirname( $atb_file ); $atb_version = isset( $atb_data['Version'] ) ? (string) $atb_data['Version'] : ''; $atb_row = array( 'plugin' => $atb_file, 'slug' => '.' === $atb_slug ? null : $atb_slug, 'version' => $atb_version, ); // Single-file plugins install without their wp.org directory name, // so the slug (and therefore the checksum URL) cannot be derived. if ( '.' === $atb_slug ) { $atb_row['status'] = 'skipped'; $atb_row['reason'] = 'single_file_plugin'; return $atb_row; } if ( '' === $atb_version ) { $atb_row['status'] = 'skipped'; $atb_row['reason'] = 'version_unknown'; return $atb_row; } $atb_map = $this->fetch_plugin_checksums( $atb_slug, $atb_version, $atb_reason ); if ( ! is_array( $atb_map ) || empty( $atb_map ) ) { $atb_row['status'] = 'skipped'; $atb_row['reason'] = $atb_reason; return $atb_row; } ksort( $atb_map, SORT_STRING ); $atb_base = trailingslashit( WP_PLUGIN_DIR ) . $atb_slug . '/'; $atb_verified = 0; $atb_mismatched = array(); $atb_missing = array(); $atb_mismatched_count = 0; $atb_missing_count = 0; foreach ( $atb_map as $atb_rel => $atb_hashes ) { $atb_rel = (string) $atb_rel; // files[path] = { md5: string|array, sha256: ... } — md5 is an // array when wp.org rebuilt the same version more than once. $atb_md5 = is_array( $atb_hashes ) && isset( $atb_hashes['md5'] ) ? $atb_hashes['md5'] : null; if ( null === $atb_md5 ) { continue; } $atb_path = $atb_base . $atb_rel; if ( ! file_exists( $atb_path ) ) { $atb_missing_count++; if ( count( $atb_missing ) < self::LIST_CAP ) { $atb_missing[] = $atb_rel; } continue; } $atb_actual = md5_file( $atb_path ); $atb_ok = is_array( $atb_md5 ) ? in_array( $atb_actual, array_map( 'strval', $atb_md5 ), true ) : ( $atb_actual === (string) $atb_md5 ); if ( $atb_ok ) { $atb_verified++; } else { $atb_mismatched_count++; if ( count( $atb_mismatched ) < self::LIST_CAP ) { $atb_mismatched[] = $atb_rel; } } } // Unexpected files inside the plugin dir — same drop-zone logic as // the core scan, relative to the plugin root. $atb_unexpected = array(); $atb_unexpected_capped = $this->collect_unexpected_in( trailingslashit( WP_PLUGIN_DIR ) . $atb_slug, $atb_map, $atb_unexpected ); $atb_row['total_files'] = count( $atb_map ); $atb_row['verified'] = $atb_verified; $atb_row['mismatched'] = $atb_mismatched; $atb_row['mismatched_count'] = $atb_mismatched_count; $atb_row['missing'] = $atb_missing; $atb_row['missing_count'] = $atb_missing_count; $atb_row['unexpected'] = $atb_unexpected; $atb_row['unexpected_capped'] = $atb_unexpected_capped; $atb_row['status'] = ( $atb_mismatched_count || $atb_missing_count || ! empty( $atb_unexpected ) ) ? 'issues' : 'clean'; return $atb_row; } /** * Fetches one plugin's checksum map from downloads.wordpress.org. * The request carries only the public slug and version. * * @param string $atb_slug * @param string $atb_version * @param string $atb_reason Set on failure: 'no_official_checksums' * (wp.org has no map for it) or * 'checksums_unavailable' (network/API error). * @return array|false files map, or false. */ protected function fetch_plugin_checksums( $atb_slug, $atb_version, &$atb_reason ) { $atb_reason = ''; $atb_url = 'https://downloads.wordpress.org/plugin-checksums/' . rawurlencode( $atb_slug ) . '/' . rawurlencode( $atb_version ) . '.json'; $atb_response = wp_remote_get( $atb_url, array( 'timeout' => 10 ) ); if ( is_wp_error( $atb_response ) ) { $atb_reason = 'checksums_unavailable'; return false; } $atb_code = (int) wp_remote_retrieve_response_code( $atb_response ); if ( 404 === $atb_code ) { $atb_reason = 'no_official_checksums'; return false; } if ( 200 !== $atb_code ) { $atb_reason = 'checksums_unavailable'; return false; } $atb_json = json_decode( (string) wp_remote_retrieve_body( $atb_response ), true ); if ( ! is_array( $atb_json ) || empty( $atb_json['files'] ) || ! is_array( $atb_json['files'] ) ) { $atb_reason = 'no_official_checksums'; return false; } return $atb_json['files']; } /** * Walks one absolute directory; appends paths (relative to it) absent * from the checksum map. Returns true when the cap was hit. * * @param string $atb_root Absolute directory. * @param array $atb_map Checksum map keyed by relative path. * @param array $atb_out Collected unexpected paths (by reference). * @return bool Cap reached. */ protected function collect_unexpected_in( $atb_root, $atb_map, &$atb_out ) { if ( ! is_dir( $atb_root ) ) { return false; } $atb_iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $atb_root, FilesystemIterator::SKIP_DOTS ) ); foreach ( $atb_iterator as $atb_file ) { if ( ! $atb_file->isFile() ) { continue; } $atb_rel = ltrim( str_replace( '\\', '/', substr( $atb_file->getPathname(), strlen( $atb_root ) ) ), '/' ); if ( ! isset( $atb_map[ $atb_rel ] ) ) { $atb_out[] = $atb_rel; if ( count( $atb_out ) >= self::UNEXPECTED_CAP ) { return true; } } } return false; } /** * Fetches the checksum map for the site locale, falling back to en_US. * * @param string $atb_version * @param string $atb_locale * @param string $atb_locale_used Set to the locale whose map was returned. * @return array|false */ protected function fetch_checksums( $atb_version, $atb_locale, &$atb_locale_used ) { if ( ! function_exists( 'get_core_checksums' ) ) { require_once ABSPATH . 'wp-admin/includes/update.php'; } $atb_locale_used = (string) $atb_locale; $atb_map = get_core_checksums( $atb_version, $atb_locale ); if ( ( ! is_array( $atb_map ) || empty( $atb_map ) ) && 'en_US' !== $atb_locale ) { $atb_locale_used = 'en_US'; $atb_map = get_core_checksums( $atb_version, 'en_US' ); } return $atb_map; } /** * Walks one core directory recursively; appends relative paths absent * from the checksum map. Returns true when the cap was hit. * * @param string $atb_dir Relative dir name ('wp-admin'|'wp-includes'). * @param array $atb_map Checksum map (path => md5). * @param array $atb_out Collected unexpected paths (by reference). * @return bool Cap reached. */ protected function collect_unexpected( $atb_dir, $atb_map, &$atb_out ) { $atb_root = ABSPATH . $atb_dir; if ( ! is_dir( $atb_root ) ) { return false; } $atb_iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $atb_root, FilesystemIterator::SKIP_DOTS ) ); foreach ( $atb_iterator as $atb_file ) { if ( ! $atb_file->isFile() ) { continue; } $atb_rel = ltrim( str_replace( '\\', '/', substr( $atb_file->getPathname(), strlen( ABSPATH ) ) ), '/' ); if ( ! isset( $atb_map[ $atb_rel ] ) ) { $atb_out[] = $atb_rel; if ( count( $atb_out ) >= self::UNEXPECTED_CAP ) { return true; } } } return false; } /** * Overridable for tests (same pattern as regenerate-thumbnails). * * @return int seconds */ protected function time_budget() { return self::TIME_BUDGET; } } }