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'; if ($info === 'not_in_repo') { $level = 'warn'; $detail_parts[] = 'Not in WP.org repo (could be premium, custom, or removed)'; $removed++; } elseif ($info === 'removed') { $level = 'bad'; $detail_parts[] = '⚠ Removed from WP.org repo — possible abandonment or compromise'; $removed++; } 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 . 'd ago'; if ($age_days > 365) { $level = 'warn'; $abandoned++; $detail_parts[] = '>12 mo'; } } if (!empty($info['active_installs'])) { $detail_parts[] = number_format($info['active_installs']) . ' active installs'; } // 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, $installed, implode(' · ', $detail_parts) ); } // Summary at the top array_unshift($f, $this->finding( 'summary', $cloaked ? 'bad' : 'info', 'Summary', count($plugins) . ' plugins', 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) { // 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; $url = 'https://api.wordpress.org/plugins/info/1.0/' . rawurlencode($slug) . '.json'; $resp = wp_remote_get($url, ['timeout' => 4]); if (is_wp_error($resp)) { set_transient($cache_key, 'not_in_repo', HOUR_IN_SECONDS); return 'not_in_repo'; } $code = wp_remote_retrieve_response_code($resp); $body = wp_remote_retrieve_body($resp); if ($code === 404) { set_transient($cache_key, 'not_in_repo', DAY_IN_SECONDS); return 'not_in_repo'; } $data = json_decode((string) $body, true); if (!is_array($data)) { set_transient($cache_key, 'not_in_repo', HOUR_IN_SECONDS); return 'not_in_repo'; } if (isset($data['error'])) { set_transient($cache_key, 'removed', DAY_IN_SECONDS); return 'removed'; } $out = [ 'name' => $data['name'] ?? $slug, 'version' => $data['version'] ?? null, 'last_updated' => $data['last_updated'] ?? null, 'active_installs' => $data['active_installs'] ?? null, ]; set_transient($cache_key, $out, DAY_IN_SECONDS); return $out; } };