PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.7
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.7
2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 2.9.4 2.9.3 All 86 releases
vigilante / includes / class-plugin-status.php

class-plugin-status.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.11.7, at includes/class-plugin-status.php

862 lines 32.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Status Checker
4 *
5 * Detects installed plugins whose slug has been closed in the WordPress.org
6 * repository. Closures usually mean security issues, guideline violations,
7 * malware, or supply chain compromises.
8 *
9 * Runs daily via WP-Cron. Logs to Security Audit with severity "critical"
10 * and sends an email alert on the first detection (per slug) when File
11 * Integrity's instant_alert toggle is on.
12 *
13 * @package Vigilante
14 * @since 2.6.0
15 */
16
17 // Prevent direct access
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 /**
23 * Class Vigilante_Plugin_Status
24 *
25 * Periodic check of installed plugin slugs against the WordPress.org
26 * plugin information API. Tracks per-slug state across runs so 404s
27 * after a previously-alive observation are treated as removals
28 * (typical of closures hidden by Security Issue takedowns).
29 */
30 class Vigilante_Plugin_Status {
31
32 /**
33 * Settings instance
34 *
35 * @var Vigilante_Settings
36 */
37 private $settings;
38
39 /**
40 * Activity log instance
41 *
42 * @var Vigilante_Activity_Log|null
43 */
44 private $activity_log;
45
46 /**
47 * Option name holding the per-slug state map.
48 */
49 const STATE_OPTION = 'vigilante_plugin_status_state';
50
51 /**
52 * Option name holding the timestamp of the last completed sweep.
53 */
54 const LAST_CHECK_OPTION = 'vigilante_plugin_status_last_check';
55
56 /**
57 * Option name holding the list of slugs the admin has chosen to ignore
58 * (still tracked, still shown in a "Ignored" subsection, but excluded
59 * from the main results list and from the email digest).
60 */
61 const IGNORED_OPTION = 'vigilante_ignored_closed_plugins';
62
63 /**
64 * Cron hook fired daily.
65 */
66 const CRON_HOOK = 'vigilante_plugin_status_check';
67
68 /**
69 * Per-request HTTP timeout in seconds.
70 */
71 const HTTP_TIMEOUT = 5;
72
73 /**
74 * Maximum total seconds the sweep is allowed to spend on HTTP calls.
75 * If exceeded, the remaining slugs are deferred to the next run.
76 */
77 const SCAN_BUDGET = 60;
78
79 /**
80 * Per-slug transient TTL in seconds. Manual "Check Now" presses within
81 * this window reuse the cached API response; the scheduled daily cron
82 * is longer than this, so it always fetches fresh data.
83 */
84 const TRANSIENT_TTL = 3600;
85
86 /**
87 * Sweep start time for budget control.
88 *
89 * @var float
90 */
91 private $sweep_started_at = 0.0;
92
93 /**
94 * Constructor
95 *
96 * @param Vigilante_Settings $settings Settings instance.
97 * @param Vigilante_Activity_Log|null $activity_log Activity log instance.
98 */
99 public function __construct( $settings, $activity_log = null ) {
100 $this->settings = $settings;
101 $this->activity_log = $activity_log;
102
103 if ( $this->is_enabled() ) {
104 $this->schedule_cron();
105 }
106 }
107
108 /**
109 * Whether the daily check is enabled.
110 *
111 * @return bool
112 */
113 private function is_enabled() {
114 if ( ! $this->settings ) {
115 return false;
116 }
117 $options = $this->settings->get_section( 'file_integrity' );
118 return ! empty( $options['check_closed_plugins'] );
119 }
120
121 /**
122 * Ensure the daily cron is registered.
123 */
124 private function schedule_cron() {
125 if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
126 wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK );
127 }
128 }
129
130 /**
131 * Cron callback. Always runs (the hook is registered globally so the
132 * event still fires even if the toggle was flipped after scheduling);
133 * the early-exit here is the actual gate.
134 */
135 public function run_scheduled_check() {
136 if ( ! $this->is_enabled() ) {
137 return;
138 }
139 $this->check_all_plugins();
140 }
141
142 /**
143 * Sweep all installed plugins.
144 *
145 * @param bool $force_fresh When true, the per-slug transient cache is
146 * bypassed so every plugin is re-queried from
147 * wp.org. Used by entry points that must
148 * re-evaluate immediately (manual scan, etc.).
149 * @param bool $suppress_email When true, no per-transition alert email is
150 * sent. Used by the file-integrity scan which
151 * folds closed plugins into its own digest
152 * email so the user doesn't get two emails for
153 * the same finding.
154 * @return array Current state map after the sweep (slug => entry).
155 */
156 public function check_all_plugins( $force_fresh = false, $suppress_email = false ) {
157 $this->sweep_started_at = microtime( true );
158
159 if ( ! function_exists( 'get_plugins' ) ) {
160 require_once ABSPATH . 'wp-admin/includes/plugin.php';
161 }
162
163 $plugins = get_plugins();
164 $state = $this->get_state();
165
166 // Build a set of currently-installed slugs so we can prune state for
167 // plugins that were uninstalled since the last sweep.
168 $installed_slugs = array();
169
170 foreach ( $plugins as $plugin_file => $plugin_data ) {
171 $slug = dirname( $plugin_file );
172
173 // Skip single-file plugins (no folder, no wp.org slug to query).
174 if ( '.' === $slug || '' === $slug ) {
175 continue;
176 }
177
178 $installed_slugs[ $slug ] = true;
179
180 // Honour the time budget. Plugins not reached in this run keep
181 // their previous state and will be evaluated in the next cron.
182 if ( $this->is_budget_exceeded() ) {
183 break;
184 }
185
186 $previous = isset( $state[ $slug ] ) ? $state[ $slug ] : null;
187 $signal = $this->check_plugin_status( $slug, $force_fresh );
188
189 if ( null === $signal ) {
190 // Transient error; preserve previous entry, just bump last_checked.
191 if ( null !== $previous ) {
192 $previous['last_checked'] = time();
193 $state[ $slug ] = $previous;
194 }
195 continue;
196 }
197
198 $new_entry = $this->build_entry( $slug, $plugin_data, $previous, $signal );
199 $this->handle_transition( $slug, $previous, $new_entry, $plugin_data, $suppress_email );
200 $state[ $slug ] = $new_entry;
201 }
202
203 // Prune state entries whose plugins are no longer installed locally.
204 foreach ( array_keys( $state ) as $stored_slug ) {
205 if ( ! isset( $installed_slugs[ $stored_slug ] ) ) {
206 unset( $state[ $stored_slug ] );
207 }
208 }
209
210 update_option( self::STATE_OPTION, $state, false );
211 update_option( self::LAST_CHECK_OPTION, time(), false );
212
213 // Auto-purge ignored slugs whose current state is no longer in an
214 // alert tier (closed/removed). The "Ignore" decision applies to a
215 // specific closure finding; once the plugin is back to open in
216 // wp.org (or has been pruned from the state for any reason) the
217 // ignore is no longer relevant and would otherwise silence a future
218 // re-closure. Cleaning the list here keeps the security guarantee
219 // that a fresh closure always alerts.
220 $ignored = $this->get_ignored_slugs();
221 if ( ! empty( $ignored ) ) {
222 $still_alert = array();
223 foreach ( $ignored as $slug ) {
224 if ( isset( $state[ $slug ]['state'] ) && in_array( $state[ $slug ]['state'], array( 'closed', 'removed' ), true ) ) {
225 $still_alert[] = $slug;
226 }
227 }
228 if ( count( $still_alert ) !== count( $ignored ) ) {
229 update_option( self::IGNORED_OPTION, array_values( $still_alert ), false );
230 }
231 }
232
233 return $state;
234 }
235
236 /**
237 * Query the wp.org plugin information API for a single slug.
238 *
239 * @param string $slug Plugin folder slug.
240 * @param bool $force_fresh When true, skip the transient cache and re-query
241 * even if we have a recent answer for this slug.
242 * @return array|null Signal array with keys:
243 * - status: 'open' | 'closed' | 'not_found' | null
244 * - closed_date: string (only when status=closed)
245 * - closed_reason: string (only when status=closed)
246 * - closed_reason_text: string (only when status=closed)
247 * Returns null when the call failed transiently
248 * (timeout / 5xx). The caller preserves prior state.
249 */
250 public function check_plugin_status( $slug, $force_fresh = false ) {
251 $cache_key = 'vigilante_plugin_status_' . md5( $slug );
252 if ( ! $force_fresh ) {
253 $cached = get_transient( $cache_key );
254 if ( false !== $cached && is_array( $cached ) ) {
255 return $cached;
256 }
257 } else {
258 delete_transient( $cache_key );
259 }
260
261 $url = sprintf(
262 'https://api.wordpress.org/plugins/info/1.0/%s.json',
263 rawurlencode( $slug )
264 );
265
266 $response = wp_remote_get(
267 $url,
268 array(
269 'timeout' => self::HTTP_TIMEOUT,
270 'user-agent' => 'Vigilant/' . VIGILANTE_VERSION . '; ' . home_url(),
271 )
272 );
273
274 if ( is_wp_error( $response ) ) {
275 return null;
276 }
277
278 $code = wp_remote_retrieve_response_code( $response );
279 $body = wp_remote_retrieve_body( $response );
280
281 // Only real server errors are transient. Note: the 1.0 endpoint serves
282 // closed plugins with HTTP 404 but a valid JSON body that still carries
283 // closed_date and closed:true — so we must parse the body first instead
284 // of short-circuiting on the status code.
285 if ( $code >= 500 ) {
286 return null;
287 }
288
289 $signal = null;
290 $data = ( '' !== $body ) ? json_decode( $body, true ) : null;
291
292 if ( ! is_array( $data ) || empty( $data ) ) {
293 $signal = array( 'status' => 'not_found' );
294 } elseif ( ! empty( $data['closed'] ) || ! empty( $data['closed_date'] ) ) {
295 // Confirmed closed: wp.org explicitly flagged it. The wp.org 1.0
296 // payload uses `reason` / `reason_text` (often `false` when the
297 // takedown reason is not public) and `description` for the human
298 // readable explanation. We surface all three so the UI can pick
299 // the most informative non-empty value.
300 $reason = isset( $data['reason'] ) && is_string( $data['reason'] ) ? sanitize_text_field( $data['reason'] ) : '';
301 $reason_text = isset( $data['reason_text'] ) && is_string( $data['reason_text'] ) ? sanitize_text_field( $data['reason_text'] ) : '';
302 $description = isset( $data['description'] ) && is_string( $data['description'] ) ? wp_strip_all_tags( $data['description'] ) : '';
303
304 // If no reason_text was provided, fall back to the description
305 // (typical pattern when wp.org keeps the reason private but ships
306 // a generic "This plugin has been closed as of …" message).
307 if ( '' === $reason_text && '' !== $description ) {
308 $reason_text = $description;
309 }
310
311 $signal = array(
312 'status' => 'closed',
313 'closed_date' => isset( $data['closed_date'] ) ? sanitize_text_field( (string) $data['closed_date'] ) : '',
314 'closed_reason' => $reason,
315 'closed_reason_text' => $reason_text,
316 );
317 } elseif ( isset( $data['error'] ) ) {
318 // Error payload without a closed flag (e.g. "Plugin not found.").
319 // The state tracker decides if this is a removal of a known slug
320 // or just a slug that was never in wp.org.
321 $signal = array( 'status' => 'not_found' );
322 } elseif ( ! empty( $data['name'] ) || ! empty( $data['version'] ) || ! empty( $data['slug'] ) ) {
323 $signal = array( 'status' => 'open' );
324 } else {
325 $signal = array( 'status' => 'not_found' );
326 }
327
328 set_transient( $cache_key, $signal, self::TRANSIENT_TTL );
329 return $signal;
330 }
331
332 /**
333 * Combine the API signal with the prior state to produce the new state entry.
334 *
335 * Logic:
336 * - signal=open → state=open
337 * - signal=closed → state=closed
338 * - signal=closed AND reused slug → state=not_in_repo (premium reusing a dead wp.org slug)
339 * - signal=not_found AND prior=open → state=removed (high confidence)
340 * - signal=not_found AND prior=null → state=not_in_repo (premium/custom)
341 * - signal=not_found AND prior=closed → keep state=closed (still closed)
342 * - signal=not_found AND prior=removed→ keep state=removed
343 * - signal=not_found AND prior=not_in_repo → keep state=not_in_repo
344 *
345 * @param string $slug Plugin slug.
346 * @param array $plugin_data WP plugin header data.
347 * @param array|null $previous Prior state entry or null.
348 * @param array $signal API signal returned by check_plugin_status().
349 * @return array New state entry.
350 */
351 private function build_entry( $slug, $plugin_data, $previous, $signal ) {
352 $now = time();
353 $name = isset( $plugin_data['Name'] ) ? (string) $plugin_data['Name'] : $slug;
354 $version = isset( $plugin_data['Version'] ) ? (string) $plugin_data['Version'] : '';
355 $previous = is_array( $previous ) ? $previous : array();
356
357 $new_state = '';
358
359 switch ( $signal['status'] ) {
360 case 'open':
361 $new_state = 'open';
362 break;
363 case 'closed':
364 // A closed verdict only matters if the wp.org listing refers
365 // to the plugin actually installed; commercial plugins that
366 // reuse a slug closed years ago are handled as not_in_repo.
367 $new_state = $this->is_reused_premium_slug( $slug, $plugin_data ) ? 'not_in_repo' : 'closed';
368 break;
369 case 'not_found':
370 default:
371 $prior_state = isset( $previous['state'] ) ? $previous['state'] : '';
372 if ( 'open' === $prior_state ) {
373 $new_state = 'removed';
374 } elseif ( in_array( $prior_state, array( 'closed', 'removed', 'not_in_repo' ), true ) ) {
375 $new_state = $prior_state;
376 } else {
377 $new_state = 'not_in_repo';
378 }
379 break;
380 }
381
382 $entry = array(
383 'state' => $new_state,
384 'name' => $name,
385 'version' => $version,
386 'last_checked' => $now,
387 );
388
389 // Preserve fields that should survive across runs.
390 if ( ! empty( $previous['first_detected'] ) ) {
391 $entry['first_detected'] = (int) $previous['first_detected'];
392 }
393 if ( ! empty( $previous['last_alive'] ) ) {
394 $entry['last_alive'] = (int) $previous['last_alive'];
395 }
396
397 if ( 'open' === $new_state ) {
398 $entry['last_alive'] = $now;
399 }
400
401 if ( 'closed' === $new_state ) {
402 $entry['closed_date'] = isset( $signal['closed_date'] ) ? $signal['closed_date'] : '';
403 $entry['closed_reason'] = isset( $signal['closed_reason'] ) ? $signal['closed_reason'] : '';
404 $entry['closed_reason_text'] = isset( $signal['closed_reason_text'] ) ? $signal['closed_reason_text'] : '';
405 } elseif ( 'closed' === ( $previous['state'] ?? '' ) && 'closed' === $new_state ) {
406 // Carry forward closure metadata when staying in 'closed'.
407 $entry['closed_date'] = $previous['closed_date'] ?? '';
408 $entry['closed_reason'] = $previous['closed_reason'] ?? '';
409 $entry['closed_reason_text'] = $previous['closed_reason_text'] ?? '';
410 } elseif ( 'removed' === $new_state ) {
411 // No metadata for removals; record the reason in a stable shape so
412 // the UI can show something meaningful.
413 $entry['closed_date'] = '';
414 $entry['closed_reason'] = 'removed';
415 $entry['closed_reason_text'] = '';
416 }
417
418 // First-detection marker on the first transition into closed/removed.
419 $is_alert_state = in_array( $new_state, array( 'closed', 'removed' ), true );
420 $was_alert_state = in_array( ( $previous['state'] ?? '' ), array( 'closed', 'removed' ), true );
421 if ( $is_alert_state && ! $was_alert_state ) {
422 $entry['first_detected'] = $now;
423 }
424
425 return $entry;
426 }
427
428 /**
429 * Whether a "closed" verdict from wp.org refers to a DIFFERENT plugin
430 * than the one installed.
431 *
432 * Premium plugins sometimes ship in a folder whose slug once lived on
433 * wp.org and was closed when the product went commercial. The archived
434 * listing then describes the abandoned free version, not the installed
435 * one, so alerting "closed" (critical + email) on it is a false
436 * positive. Real case: WPML installs as sitepress-multilingual-cms, a
437 * slug closed on wp.org in its 2.x days, while the commercial 4.x
438 * updates from wpml.org.
439 *
440 * Two signals, either one is enough:
441 * - The Update URI header (WP 5.8+) points outside wordpress.org (or is
442 * "false"): updates are not served by wp.org, so the wp.org slug
443 * status is not about this plugin. Note this only exempts a plugin
444 * whose own header opts out of wp.org; a plugin genuinely installed
445 * from wp.org carries no such header and still alerts.
446 * - The slug is in the built-in allowlist of known reused slugs, which
447 * covers commercial plugins that predate the Update URI header.
448 *
449 * @since 2.9.3
450 * @param string $slug Plugin folder slug.
451 * @param array $plugin_data WP plugin header data from get_plugins().
452 * @return bool
453 */
454 private function is_reused_premium_slug( $slug, $plugin_data ) {
455 $known_reused = array(
456 'sitepress-multilingual-cms', // WPML (OnTheGoSystems).
457 );
458
459 if ( in_array( $slug, $known_reused, true ) ) {
460 return true;
461 }
462
463 $update_uri = isset( $plugin_data['UpdateURI'] ) ? trim( (string) $plugin_data['UpdateURI'] ) : '';
464
465 if ( '' === $update_uri ) {
466 return false;
467 }
468
469 if ( 'false' === strtolower( $update_uri ) ) {
470 return true;
471 }
472
473 $host = wp_parse_url( $update_uri, PHP_URL_HOST );
474
475 if ( is_string( $host ) && '' !== $host && ! preg_match( '/(^|\.)(wordpress\.org|w\.org)$/i', $host ) ) {
476 return true;
477 }
478
479 return false;
480 }
481
482 /**
483 * Act on the transition between previous and new state: log to Security
484 * Audit and (optionally) send a per-transition alert email.
485 *
486 * @param string $slug Plugin slug.
487 * @param array|null $previous Prior state entry or null.
488 * @param array $new_entry New state entry just built.
489 * @param array $plugin_data WP plugin header data.
490 * @param bool $suppress_email When true, no email is sent regardless
491 * of the transition. Used by the file
492 * integrity scan path so the closed
493 * plugins are folded into its digest
494 * email instead of triggering a second
495 * one.
496 */
497 private function handle_transition( $slug, $previous, $new_entry, $plugin_data, $suppress_email = false ) {
498 $previous_state = is_array( $previous ) && isset( $previous['state'] ) ? $previous['state'] : '';
499 $new_state = $new_entry['state'];
500
501 if ( $previous_state === $new_state ) {
502 // Stable state, dedupe.
503 return;
504 }
505
506 $alert_states = array( 'closed', 'removed' );
507
508 // Transition INTO an alert state — log critical and (optionally) email.
509 if ( in_array( $new_state, $alert_states, true ) && ! in_array( $previous_state, $alert_states, true ) ) {
510 $this->log_closure( $slug, $new_entry );
511 if ( ! $suppress_email && ! in_array( $slug, $this->get_ignored_slugs(), true ) ) {
512 $this->maybe_send_alert_email( $slug, $new_entry );
513 }
514 return;
515 }
516
517 // Reopen: closed/removed → open.
518 if ( 'open' === $new_state && in_array( $previous_state, $alert_states, true ) ) {
519 $this->log_reopen( $slug, $new_entry );
520 return;
521 }
522
523 // closed → removed, or removed → closed: still an alert situation, but
524 // the slug was already known as compromised. Log a softer entry so
525 // the audit trail captures the change, no email.
526 if ( in_array( $new_state, $alert_states, true ) && in_array( $previous_state, $alert_states, true ) ) {
527 $this->log_state_change( $slug, $previous_state, $new_entry );
528 }
529 }
530
531 /**
532 * Log a new closure to Security Audit (critical).
533 *
534 * @param string $slug Plugin slug.
535 * @param array $new_entry New state entry.
536 */
537 private function log_closure( $slug, $new_entry ) {
538 if ( ! $this->activity_log ) {
539 return;
540 }
541
542 $name = $new_entry['name'];
543 $state = $new_entry['state'];
544
545 if ( 'closed' === $state ) {
546 $message = sprintf(
547 /* translators: %s: plugin name */
548 __( 'Plugin "%s" appears as closed in the WordPress.org repository', 'vigilante' ),
549 $name
550 );
551 } else {
552 $message = sprintf(
553 /* translators: %s: plugin name */
554 __( 'Plugin "%s" has been removed from the WordPress.org repository (likely closed for security reasons)', 'vigilante' ),
555 $name
556 );
557 }
558
559 $this->activity_log->log(
560 'plugin',
561 'closed_detected',
562 $message,
563 array(
564 'object_type' => 'plugin',
565 'object_name' => $slug,
566 'slug' => $slug,
567 'plugin_name' => $name,
568 'version' => $new_entry['version'],
569 'detected_state' => $state,
570 'closed_date' => $new_entry['closed_date'] ?? '',
571 'closed_reason' => $new_entry['closed_reason'] ?? '',
572 'closed_reason_text' => $new_entry['closed_reason_text'] ?? '',
573 ),
574 'critical'
575 );
576 }
577
578 /**
579 * Log a reopen (closed/removed → open) to Security Audit (info).
580 *
581 * @param string $slug Plugin slug.
582 * @param array $new_entry New state entry.
583 */
584 private function log_reopen( $slug, $new_entry ) {
585 if ( ! $this->activity_log ) {
586 return;
587 }
588
589 $this->activity_log->log(
590 'plugin',
591 'closed_reopened',
592 sprintf(
593 /* translators: %s: plugin name */
594 __( 'Plugin "%s" is listed again as active in the WordPress.org repository', 'vigilante' ),
595 $new_entry['name']
596 ),
597 array(
598 'object_type' => 'plugin',
599 'object_name' => $slug,
600 'slug' => $slug,
601 'plugin_name' => $new_entry['name'],
602 'version' => $new_entry['version'],
603 ),
604 'info'
605 );
606 }
607
608 /**
609 * Log a closed→removed or removed→closed transition (still an alert
610 * state, no email since it was already known compromised).
611 *
612 * @param string $slug Plugin slug.
613 * @param string $previous_state Previous state value.
614 * @param array $new_entry New state entry.
615 */
616 private function log_state_change( $slug, $previous_state, $new_entry ) {
617 if ( ! $this->activity_log ) {
618 return;
619 }
620
621 $this->activity_log->log(
622 'plugin',
623 'closed_state_changed',
624 sprintf(
625 /* translators: 1: plugin name, 2: previous state, 3: new state */
626 __( 'Plugin "%1$s" closure state changed from %2$s to %3$s', 'vigilante' ),
627 $new_entry['name'],
628 $previous_state,
629 $new_entry['state']
630 ),
631 array(
632 'object_type' => 'plugin',
633 'object_name' => $slug,
634 'slug' => $slug,
635 'previous_state' => $previous_state,
636 'new_state' => $new_entry['state'],
637 ),
638 'warning'
639 );
640 }
641
642 /**
643 * Send an alert email on the first detection of a closed/removed plugin.
644 * Gated by File Integrity's instant_alert toggle, consistent with how the
645 * file integrity scan handles suspicious-file alerts.
646 *
647 * @param string $slug Plugin slug.
648 * @param array $new_entry New state entry.
649 */
650 private function maybe_send_alert_email( $slug, $new_entry ) {
651 $fi_options = $this->settings->get_section( 'file_integrity' );
652 if ( empty( $fi_options['instant_alert'] ) ) {
653 return;
654 }
655
656 if ( ! class_exists( 'Vigilante_Email_Template' ) ) {
657 require_once VIGILANTE_INCLUDES_DIR . 'class-email-template.php';
658 }
659
660 $recipients = Vigilante_Email_Template::get_admin_recipients();
661 if ( empty( $recipients ) ) {
662 return;
663 }
664
665 $site_name = get_bloginfo( 'name' );
666 $state = $new_entry['state'];
667
668 $subject = sprintf(
669 /* translators: 1: Site name, 2: plugin slug */
670 __( '[%1$s] Vigilant: Closed plugin detected — %2$s', 'vigilante' ),
671 $site_name,
672 $slug
673 );
674
675 if ( 'closed' === $state ) {
676 $intro = sprintf(
677 /* translators: %s: plugin name */
678 __( 'Vigilant detected that the plugin "%s" has been closed in the WordPress.org repository.', 'vigilante' ),
679 $new_entry['name']
680 );
681 } else {
682 $intro = sprintf(
683 /* translators: %s: plugin name */
684 __( 'Vigilant detected that the plugin "%s" has been removed from the WordPress.org repository. This usually indicates a closure for security reasons where the public metadata has been hidden.', 'vigilante' ),
685 $new_entry['name']
686 );
687 }
688
689 $body = Vigilante_Email_Template::p( $intro );
690 $body .= Vigilante_Email_Template::alert_box( __( 'Closed or removed plugins should be deactivated and replaced as soon as possible. They no longer receive security updates and the repository team has flagged them as a risk.', 'vigilante' ) );
691
692 $rows = array(
693 __( 'Plugin', 'vigilante' ) => $new_entry['name'],
694 __( 'Slug', 'vigilante' ) => $slug,
695 __( 'Version', 'vigilante' ) => $new_entry['version'] !== '' ? $new_entry['version'] : __( 'unknown', 'vigilante' ),
696 __( 'State', 'vigilante' ) => 'closed' === $state ? __( 'Closed', 'vigilante' ) : __( 'Removed', 'vigilante' ),
697 );
698 if ( ! empty( $new_entry['closed_date'] ) ) {
699 $rows[ __( 'Closure date', 'vigilante' ) ] = $new_entry['closed_date'];
700 }
701 if ( ! empty( $new_entry['closed_reason_text'] ) ) {
702 $rows[ __( 'Reason', 'vigilante' ) ] = $new_entry['closed_reason_text'];
703 }
704 $body .= Vigilante_Email_Template::data_table( $rows );
705
706 $body .= Vigilante_Email_Template::button(
707 admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-closed-plugins' ),
708 __( 'Review in Vigilant', 'vigilante' )
709 );
710
711 Vigilante_Email_Template::send( $recipients, $subject, __( 'Closed plugin detected', 'vigilante' ), $body, true );
712 }
713
714 /**
715 * Get the stored state map (slug => entry).
716 *
717 * @return array
718 */
719 public function get_state() {
720 $state = get_option( self::STATE_OPTION, array() );
721 return is_array( $state ) ? $state : array();
722 }
723
724 /**
725 * Get slugs currently in an alert state (closed or removed).
726 *
727 * @param bool $include_ignored When true, slugs the admin has chosen to
728 * ignore are returned alongside the active
729 * ones. Default false (matches what the
730 * main UI list and the email digest show).
731 * @return array Subset of the state map keyed by slug.
732 */
733 public function get_closed_plugins( $include_ignored = false ) {
734 $state = $this->get_state();
735 $ignored = $include_ignored ? array() : $this->get_ignored_slugs();
736 $out = array();
737 foreach ( $state as $slug => $entry ) {
738 if ( ! isset( $entry['state'] ) ) {
739 continue;
740 }
741 if ( ! in_array( $entry['state'], array( 'closed', 'removed' ), true ) ) {
742 continue;
743 }
744 if ( in_array( $slug, $ignored, true ) ) {
745 continue;
746 }
747 $out[ $slug ] = $entry;
748 }
749 return $out;
750 }
751
752 /**
753 * Get the closed/removed slugs the admin has ignored. Used by the UI to
754 * render the "Ignored Closed Plugins" subsection separately from the
755 * active list, so the user does not lose track of what is intentionally
756 * being silenced.
757 *
758 * @return array Subset of the state map keyed by slug.
759 */
760 public function get_ignored_closed_plugins() {
761 $state = $this->get_state();
762 $ignored = $this->get_ignored_slugs();
763 $out = array();
764 foreach ( $ignored as $slug ) {
765 if ( isset( $state[ $slug ]['state'] ) && in_array( $state[ $slug ]['state'], array( 'closed', 'removed' ), true ) ) {
766 $out[ $slug ] = $state[ $slug ];
767 }
768 }
769 return $out;
770 }
771
772 /**
773 * Get the raw list of ignored slugs.
774 *
775 * @return array
776 */
777 public function get_ignored_slugs() {
778 $ignored = get_option( self::IGNORED_OPTION, array() );
779 return is_array( $ignored ) ? $ignored : array();
780 }
781
782 /**
783 * Mark a slug as ignored. Idempotent.
784 *
785 * @param string $slug Plugin slug.
786 * @return bool True on success, false on invalid input.
787 */
788 public function ignore_slug( $slug ) {
789 $slug = sanitize_key( $slug );
790 if ( '' === $slug ) {
791 return false;
792 }
793 $ignored = $this->get_ignored_slugs();
794 if ( ! in_array( $slug, $ignored, true ) ) {
795 $ignored[] = $slug;
796 update_option( self::IGNORED_OPTION, $ignored, false );
797 }
798 return true;
799 }
800
801 /**
802 * Stop ignoring a slug. Idempotent.
803 *
804 * @param string $slug Plugin slug.
805 * @return bool True on success, false on invalid input.
806 */
807 public function unignore_slug( $slug ) {
808 $slug = sanitize_key( $slug );
809 if ( '' === $slug ) {
810 return false;
811 }
812 $ignored = $this->get_ignored_slugs();
813 $key = array_search( $slug, $ignored, true );
814 if ( false !== $key ) {
815 unset( $ignored[ $key ] );
816 update_option( self::IGNORED_OPTION, array_values( $ignored ), false );
817 }
818 return true;
819 }
820
821 /**
822 * Drop the entire ignore list.
823 */
824 public function clear_ignored() {
825 delete_option( self::IGNORED_OPTION );
826 }
827
828 /**
829 * Get timestamp of the last completed sweep.
830 *
831 * @return int Unix timestamp (0 if never run).
832 */
833 public function get_last_check_time() {
834 return (int) get_option( self::LAST_CHECK_OPTION, 0 );
835 }
836
837 /**
838 * Clear stored scan state (state map + last_check timestamp). Not called
839 * from "Clear Previous Results" any more — that button leaves the plugin
840 * status untouched. This method is left available for explicit resets
841 * (debug mu-plugin, future Reset to Defaults paths). The ignore list is
842 * preserved here; the dedicated "Clear All Ignored Closed + Removed
843 * Plugins" button is the explicit way to drop ignores.
844 */
845 public function clear_results() {
846 delete_option( self::STATE_OPTION );
847 delete_option( self::LAST_CHECK_OPTION );
848 }
849
850 /**
851 * Whether the sweep has consumed its time budget.
852 *
853 * @return bool
854 */
855 private function is_budget_exceeded() {
856 if ( 0.0 === $this->sweep_started_at ) {
857 return false;
858 }
859 return ( microtime( true ) - $this->sweep_started_at ) > self::SCAN_BUDGET;
860 }
861 }
862