diff --git a/includes/steps/30-core.php b/includes/steps/30-core.php index 6c5755c..2eedd84 100644 --- a/includes/steps/30-core.php +++ b/includes/steps/30-core.php @@ -23,26 +23,70 @@ return new class extends WPH_Step { global $wp_version; $f = []; + // What WordPress itself reports (this can be filtered by WP Downgrade etc.) if (!function_exists('get_core_updates')) require_once ABSPATH . 'wp-admin/includes/update.php'; $updates = function_exists('get_core_updates') ? get_core_updates(['dismissed' => true]) : []; - $latest = (!empty($updates) && !empty($updates[0]->current)) ? $updates[0]->current : $wp_version; - $behind = version_compare($wp_version, $latest, '<'); + $wp_reported_latest = (!empty($updates) && !empty($updates[0]->current)) ? $updates[0]->current : $wp_version; + // Direct query to wp.org — can't be cloaked by site-side filters. + $wp_org_latest = $this->wp_org_core_version(); + + // Detect known plugins that hide/manipulate version reporting. + $cloakers = $this->detect_cloakers(); + + $f[] = $this->finding('wp_current', 'info', 'Currently installed', $wp_version, ''); + + // What WP reports as the latest + $wp_behind = version_compare($wp_version, $wp_reported_latest, '<'); $f[] = $this->finding( - 'wp_current', - 'info', - 'Currently installed', - $wp_version, - '' - ); - $f[] = $this->finding( - 'wp_latest', - $behind ? 'warn' : 'ok', - 'Latest available', - $latest, - $behind ? 'Update available → ' . admin_url('update-core.php') : 'Up to date.' + 'wp_reported_latest', + $wp_behind ? 'warn' : 'ok', + 'Latest per WordPress', + $wp_reported_latest, + 'This is what core/get_core_updates() returns — may be filtered by plugins like WP Downgrade.' ); + // What wp.org actually thinks + if ($wp_org_latest === null) { + $f[] = $this->finding('wp_org_latest', 'info', 'Latest per wp.org', 'could not fetch', 'Direct API call failed — re-run when online.'); + } else { + $actual_behind = version_compare($wp_version, $wp_org_latest, '<'); + $hidden_gap = version_compare($wp_reported_latest, $wp_org_latest, '<'); + $level = $hidden_gap ? 'bad' : ($actual_behind ? 'warn' : 'ok'); + $detail = ''; + if ($hidden_gap) { + $detail = 'CLOAKED — WordPress says latest is ' . $wp_reported_latest . ' but wp.org reports ' . $wp_org_latest . '. Updates likely blocked by a plugin or constant.'; + } elseif ($actual_behind) { + $detail = 'Real update available → ' . admin_url('update-core.php'); + } else { + $detail = 'Up to date against the public WP.org API.'; + } + $f[] = $this->finding('wp_org_latest', $level, 'Latest per wp.org (direct)', $wp_org_latest, $detail); + } + + // Flag cloaker plugins explicitly + if ($cloakers) { + $f[] = $this->finding( + 'cloakers', + 'warn', + 'Update-cloaker plugins active', + implode(', ', $cloakers), + 'These plugins can hide or block core/plugin updates. Confirm with the client whether they\'re needed before disabling them.' + ); + } + + // WP Downgrade specifically pins a target via an option — surface its value + $wpd_target = get_option('wp_downgrade_core_version', ''); + if ($wpd_target) { + $f[] = $this->finding( + 'wp_downgrade_target', + 'warn', + 'WP Downgrade target', + (string) $wpd_target, + 'WordPress core is pinned to this version by WP Downgrade. Set wp_downgrade_core_version to empty to release the pin.' + ); + } + // Database upgrade required? if (function_exists('wp_get_db_schema')) { $required = (int) get_option('db_version'); @@ -66,7 +110,8 @@ return new class extends WPH_Step { ); // Safe-mode update guidance — not an action, just a reminder. - if ($behind) { + $real_behind = $wp_org_latest !== null && version_compare($wp_version, $wp_org_latest, '<'); + if ($real_behind || $wp_behind) { $f[] = $this->finding( 'safe_mode_hint', 'info', @@ -78,4 +123,37 @@ return new class extends WPH_Step { return $f; } + + /** Direct call to wp.org's version-check endpoint. Cached 1h via transient. */ + private function wp_org_core_version(): ?string { + $cached = get_transient('wph_wp_org_core_latest'); + if ($cached !== false) return $cached === '' ? null : (string) $cached; + $resp = wp_remote_get('https://api.wordpress.org/core/version-check/1.7/', ['timeout' => 5]); + if (is_wp_error($resp) || (int) wp_remote_retrieve_response_code($resp) !== 200) { + set_transient('wph_wp_org_core_latest', '', 5 * MINUTE_IN_SECONDS); + return null; + } + $data = json_decode((string) wp_remote_retrieve_body($resp), true); + $latest = isset($data['offers'][0]['version']) ? (string) $data['offers'][0]['version'] : null; + set_transient('wph_wp_org_core_latest', $latest ?: '', HOUR_IN_SECONDS); + return $latest; + } + + /** Detect plugins that are known to hide or manipulate version reporting. */ + private function detect_cloakers(): array { + if (!function_exists('is_plugin_active')) require_once ABSPATH . 'wp-admin/includes/plugin.php'; + $known = [ + 'wp-downgrade/wp-downgrade.php' => 'WP Downgrade', + 'wp-rollback/wp-rollback.php' => 'WP Rollback', + 'easy-updates-manager/wpmudev_install.php' => 'Easy Updates Manager', + 'stops-core-theme-and-plugin-updates/stops-core-theme-and-plugin-updates.php' => 'Stops Core/Theme/Plugin Updates', + 'disable-updates-manager/disable-updates-manager.php' => 'Disable Updates Manager', + 'companion-auto-update/companion-auto-update.php' => 'Companion Auto Update', + ]; + $found = []; + foreach ($known as $file => $label) { + if (is_plugin_active($file)) $found[] = $label; + } + return $found; + } }; diff --git a/includes/steps/40-plugins.php b/includes/steps/40-plugins.php index 831161b..bc9b7a3 100644 --- a/includes/steps/40-plugins.php +++ b/includes/steps/40-plugins.php @@ -29,16 +29,30 @@ return new class extends WPH_Step { $f = []; $abandoned = 0; $removed = 0; - $update_avail = 0; + $update_avail = 0; // WP itself sees an update + $cloaked = 0; // wp.org says there's a newer version, WP doesn't - // Refresh update transient (no-op if recent) + // Refresh update transient (no-op if recent) — this is what WP sees, + // which may be filtered by WP Downgrade / WP Rollback / etc. if (function_exists('wp_update_plugins')) wp_update_plugins(); $updates = get_site_transient('update_plugins'); $update_map = isset($updates->response) && is_array($updates->response) ? $updates->response : []; + // Detect known cloakers + $cloakers = $this->detect_cloakers(); + if ($cloakers) { + $f[] = $this->finding( + 'cloakers', 'warn', + 'Update-cloaker plugins active', + implode(', ', $cloakers), + 'Plugin update reporting may be filtered. The wp.org column below is fetched directly and cannot be cloaked.' + ); + } + foreach ($plugins as $file => $meta) { $slug = dirname($file); if ($slug === '.' || $slug === '') $slug = basename($file, '.php'); + $installed = (string) ($meta['Version'] ?? ''); $info = $this->wp_org_info($slug); $detail_parts = []; $level = 'ok'; @@ -54,50 +68,87 @@ return new class extends WPH_Step { } elseif (is_array($info)) { if (!empty($info['last_updated'])) { $age_days = floor((time() - strtotime($info['last_updated'])) / DAY_IN_SECONDS); - $detail_parts[] = 'Last release ' . $age_days . ' day(s) ago (' . $info['last_updated'] . ')'; + $detail_parts[] = 'Last release ' . $age_days . 'd ago'; if ($age_days > 365) { $level = 'warn'; $abandoned++; - $detail_parts[] = 'no release in >12 months'; + $detail_parts[] = '>12 mo'; } } if (!empty($info['active_installs'])) { $detail_parts[] = number_format($info['active_installs']) . ' active installs'; } - } - if (isset($update_map[$file])) { - $level = 'warn'; - $detail_parts[] = 'Update available → ' . ($update_map[$file]->new_version ?? '?'); - $update_avail++; + // Compare installed vs wp.org reported version — un-cloakable truth. + if (!empty($info['version']) && $installed) { + $real_behind = version_compare($installed, (string) $info['version'], '<'); + $wp_sees_update = isset($update_map[$file]); + if ($real_behind) { + $detail_parts[] = 'wp.org has ' . $info['version']; + $update_avail++; + if (!$wp_sees_update) { + $level = 'bad'; + $detail_parts[] = 'CLOAKED — WP says up-to-date'; + $cloaked++; + } else { + $level = 'warn'; + } + } elseif ($wp_sees_update) { + // Edge case: WP transient says there's an update but we have it or newer. + $detail_parts[] = 'WP transient still flags an update'; + $level = 'warn'; + } + } } $f[] = $this->finding( 'plugin_' . sanitize_key($file), $level, $meta['Name'] ?? $file, - (string) ($meta['Version'] ?? ''), + $installed, implode(' · ', $detail_parts) ); } // Summary at the top array_unshift($f, $this->finding( - 'summary', 'info', + 'summary', + $cloaked ? 'bad' : 'info', 'Summary', count($plugins) . ' plugins', - $update_avail . ' with updates · ' . $abandoned . ' not updated in 12 mo · ' . $removed . ' not in WP.org' + sprintf('%d with updates · %d cloaked · %d not updated in 12 mo · %d not in WP.org', + $update_avail, $cloaked, $abandoned, $removed) )); return $f; } + /** Detect plugins that can hide or manipulate version reporting. */ + private function detect_cloakers(): array { + if (!function_exists('is_plugin_active')) require_once ABSPATH . 'wp-admin/includes/plugin.php'; + $known = [ + 'wp-downgrade/wp-downgrade.php' => 'WP Downgrade', + 'wp-rollback/wp-rollback.php' => 'WP Rollback', + 'easy-updates-manager/wpmudev_install.php' => 'Easy Updates Manager', + 'stops-core-theme-and-plugin-updates/stops-core-theme-and-plugin-updates.php' => 'Stops Core/Theme/Plugin Updates', + 'disable-updates-manager/disable-updates-manager.php' => 'Disable Updates Manager', + 'companion-auto-update/companion-auto-update.php' => 'Companion Auto Update', + ]; + $found = []; + foreach ($known as $file => $label) { + if (is_plugin_active($file)) $found[] = $label; + } + return $found; + } + /** * Returns ['name' => ..., 'last_updated' => ..., 'active_installs' => ...] * or 'not_in_repo' / 'removed' on failure. Cached 24h per slug via transient. */ private function wp_org_info(string $slug) { - $cache_key = 'wph_pi_' . md5($slug); + // v2: cache now includes 'version' field for cloak detection — fresh prefix + // so existing cached entries get re-fetched on the next refresh. + $cache_key = 'wph_pi2_' . md5($slug); $cached = get_transient($cache_key); if ($cached !== false) return $cached; @@ -124,6 +175,7 @@ return new class extends WPH_Step { } $out = [ 'name' => $data['name'] ?? $slug, + 'version' => $data['version'] ?? null, 'last_updated' => $data['last_updated'] ?? null, 'active_installs' => $data['active_installs'] ?? null, ];