bool, 'last_updated_ts'=>int|null]. * @param int $now Reference unix time. * @return array ['risk'=>bool, 'reason'=>string] reason: ''|'removed'|'stale'. */ public static function evaluate( $info, $now ) { $listed = ! empty( $info['listed'] ); $ts = isset( $info['last_updated_ts'] ) ? (int) $info['last_updated_ts'] : 0; if ( ! $listed ) { return array( 'risk' => true, 'reason' => 'removed' ); } if ( $ts > 0 && ( $now - $ts ) > 2 * 365 * DAY_IN_SECONDS ) { return array( 'risk' => true, 'reason' => 'stale' ); } return array( 'risk' => false, 'reason' => '' ); } /** * Cached list of risky (removed/stale) plugins & themes. * Each item: ['type','name','reason']. * * @return array */ public static function get_risky() { $cached = get_transient( self::CACHE_KEY ); if ( is_array( $cached ) ) { return $cached; } $risky = self::scan(); set_transient( self::CACHE_KEY, $risky, DAY_IN_SECONDS ); return $risky; } public static function invalidate() { delete_transient( self::CACHE_KEY ); } /** * Look up every installed plugin & theme on wordpress.org and evaluate risk. * * @return array */ public static function scan() { $now = time(); $risky = array(); if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } foreach ( get_plugins() as $file => $data ) { $slug = dirname( $file ); if ( '.' === $slug || '' === $slug ) { continue; // Single-file plugin; no directory slug to query. } $info = self::lookup( 'plugin', $slug ); if ( null === $info ) { continue; // Not a wordpress.org item (premium/custom) — do not flag. } $r = self::evaluate( $info, $now ); if ( $r['risk'] ) { $risky[] = array( 'type' => 'plugin', 'name' => $data['Name'], 'reason' => $r['reason'] ); } } foreach ( wp_get_themes() as $slug => $theme ) { $info = self::lookup( 'theme', $slug ); if ( null === $info ) { continue; } $r = self::evaluate( $info, $now ); if ( $r['risk'] ) { $risky[] = array( 'type' => 'theme', 'name' => $theme->get( 'Name' ), 'reason' => $r['reason'] ); } } return $risky; } /** * Query wordpress.org for a plugin/theme. Returns null when the item is not * hosted there (so premium/custom code is never flagged). * * @param string $type 'plugin'|'theme'. * @param string $slug Directory slug. * @return array|null ['listed'=>bool,'last_updated_ts'=>int] */ public static function lookup( $type, $slug ) { $url = ( 'plugin' === $type ) ? 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request[slug]=' . rawurlencode( $slug ) : 'https://api.wordpress.org/themes/info/1.2/?action=theme_information&request[slug]=' . rawurlencode( $slug ); $resp = wp_remote_get( $url, array( 'timeout' => 8 ) ); if ( is_wp_error( $resp ) ) { return null; } $code = wp_remote_retrieve_response_code( $resp ); $body = json_decode( wp_remote_retrieve_body( $resp ), true ); // A 404 or an {"error": ...} payload means "not/no longer on wordpress.org". if ( 404 === (int) $code || ( is_array( $body ) && isset( $body['error'] ) ) ) { // Only flag as removed when the API explicitly says the slug is closed; // otherwise treat as "never hosted here" (null) so custom code isn't flagged. if ( is_array( $body ) && isset( $body['error'] ) && false !== stripos( (string) $body['error'], 'closed' ) ) { return array( 'listed' => false, 'last_updated_ts' => 0 ); } return null; } if ( ! is_array( $body ) || empty( $body['last_updated'] ) ) { return null; } return array( 'listed' => true, 'last_updated_ts' => (int) strtotime( $body['last_updated'] ), ); } }