$this->slug(), 'risk' => 'none', 'summary' => 'Verifies WordPress core files against official WordPress.org checksums and ' . 'reports modified, missing, and unexpected files under wp-admin and wp-includes. Call ' . 'when you suspect tampering, after a malware cleanup, or as a periodic integrity check. ' . 'Does NOT cover plugins, themes, or uploads. If the result reports completed=false, call ' . 'again with the returned resume_from to continue. Read-only.', 'annotations' => array( 'readonly' => true, ), 'input_schema' => array( // 'null' tolerated: a parameterless REST GET run sends input=null (M6). 'type' => array( 'object', 'null' ), 'properties' => array( 'resume_from' => array( 'type' => 'string', 'description' => 'Path cursor from a previous incomplete run; hashing 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 (or a refusal). */ protected function verify( $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( '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; } /** * 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; } } }