PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.5
3.0.0 2.11.12 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 All 88 releases
vigilante / includes / class-file-integrity.php

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

2,801 lines 108.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * File Integrity Class
4 *
5 * Handles file integrity monitoring and scanning
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_File_Integrity
17 *
18 * Manages file integrity checks against WordPress.org checksums
19 */
20 class Vigilante_File_Integrity {
21
22 /**
23 * Settings instance
24 *
25 * @var Vigilante_Settings
26 */
27 private $settings;
28
29 /**
30 * Database instance
31 *
32 * @var Vigilante_Database
33 */
34 private $database;
35
36 /**
37 * Activity log instance
38 *
39 * @var Vigilante_Activity_Log
40 */
41 private $activity_log;
42
43 /**
44 * File integrity options
45 *
46 * @var array
47 */
48 private $options;
49
50 /**
51 * WordPress version
52 *
53 * @var string
54 */
55 private $wp_version;
56
57 /**
58 * Ignored files list
59 *
60 * @var array
61 */
62 private $ignored_files;
63
64 /**
65 * Scan start time for timeout control
66 *
67 * @var float
68 */
69 private $scan_start_time = 0;
70
71 /**
72 * Maximum scan time in seconds (default 60s for thorough scanning)
73 *
74 * @var int
75 */
76 private $max_scan_time = 60;
77
78 /**
79 * Option name for critical files baseline hashes.
80 *
81 * @var string
82 */
83 const BASELINE_OPTION = 'vigilante_critical_files_baseline';
84
85 /**
86 * Critical root files to monitor against a stored baseline.
87 * These files have no official WordPress.org checksum because their
88 * content is unique per installation.
89 *
90 * @var array
91 */
92 private $critical_root_files = array(
93 'wp-config.php',
94 '.htaccess',
95 );
96
97 /**
98 * Vigilante markers used in wp-config.php (constants block).
99 *
100 * @var array
101 */
102 private $wpconfig_markers = array(
103 array( '/* BEGIN Vigilante Security Constants */', '/* END Vigilante Security Constants */' ),
104 array( '/* BEGIN AyudaWP Security Constants */', '/* END AyudaWP Security Constants */' ),
105 );
106
107 /**
108 * Vigilante marker for commented-out original constants in wp-config.php.
109 *
110 * @var string
111 */
112 private $wpconfig_original_marker = '// [VIGILANTE_ORIGINAL] ';
113
114 /**
115 * Vigilante markers used in .htaccess (firewall + security headers).
116 *
117 * @var array
118 */
119 private $htaccess_markers = array(
120 array( '# BEGIN Vigilante Protection', '# END Vigilante Protection' ),
121 array( '# BEGIN Vigilante Security Headers', '# END Vigilante Security Headers' ),
122 );
123
124 /**
125 * Core files known to produce false positives in checksum comparison.
126 * These are skipped during core scanning (e.g. version.php is rewritten
127 * during auto-updates and localized installs, readme files vary by locale).
128 *
129 * @var array
130 */
131 private $core_known_false_positives = array(
132 'wp-includes/version.php',
133 'readme.html',
134 'license.txt',
135 'licencia.txt',
136 );
137
138 /**
139 * Plugin files known to produce false positives in checksum comparison.
140 * Readme files frequently differ between WordPress.org API checksums and
141 * the actual installed version due to encoding, line endings, or locale.
142 *
143 * @var array
144 */
145 private $plugin_known_false_positives = array(
146 'readme.txt',
147 'readme.md',
148 );
149
150 /**
151 * Legitimate non-PHP files commonly found in WordPress root.
152 * These are reported as 'additional' (informational), not suspicious.
153 * Dotfiles (e.g. .htaccess) are skipped entirely by the root scanner.
154 *
155 * @var array
156 */
157 private $known_safe_root_files = array(
158 'robots.txt',
159 'security.txt',
160 'humans.txt',
161 'llms.txt',
162 'llms-full.txt',
163 'ads.txt',
164 'app-ads.txt',
165 'favicon.ico',
166 'favicon.png',
167 'favicon.svg',
168 'apple-touch-icon.png',
169 'apple-touch-icon-precomposed.png',
170 'sitemap.xml',
171 'sitemap_index.xml',
172 'bingsiteauth.xml',
173 'livesearchsiteauth.xml',
174 'google-site-verification.html',
175 'php.ini',
176 // PHP error logs commonly created by managed hosting (SiteGround, Hostinger, cPanel).
177 // Not executable; reported as "additional" instead of "suspicious".
178 'php_errorlog',
179 'error_log',
180 );
181
182 /**
183 * Legacy WordPress core files removed from newer versions but kept on
184 * existing installs to prevent breakage. These are dead code, not malware.
185 * Marked as 'extra' (additional) instead of 'suspicious' with advice to delete.
186 *
187 * @see https://core.trac.wordpress.org/ticket/48540
188 * @see https://core.trac.wordpress.org/ticket/18384
189 * @var array
190 */
191 private $legacy_core_root_files = array(
192 'wp-feed.php',
193 'wp-rss.php',
194 'wp-rss2.php',
195 'wp-rdf.php',
196 'wp-atom.php',
197 'wp-commentsrss2.php',
198 'wp-pass.php',
199 'wp-register.php',
200 );
201
202 /**
203 * Constructor
204 *
205 * @param Vigilante_Settings $settings Settings instance.
206 * @param Vigilante_Database|null $database Database instance.
207 * @param Vigilante_Activity_Log|null $activity_log Activity log instance.
208 */
209 public function __construct( $settings, $database = null, $activity_log = null ) {
210 $this->settings = $settings;
211 $this->database = $database;
212 $this->activity_log = $activity_log;
213 $this->options = $settings ? $settings->get_section( 'file_integrity' ) : array();
214 $this->wp_version = get_bloginfo( 'version' );
215 $this->ignored_files = get_option( 'vigilante_ignored_files', array() );
216
217 // Schedule automated scans only if options available
218 if ( ! empty( $this->options['auto_scan'] ) ) {
219 add_action( 'vigilante_file_integrity_scan', array( $this, 'run_scheduled_scan' ) );
220 $this->schedule_scan();
221 }
222
223 // Post-update verification: verify any just-updated plugin/theme against
224 // WordPress.org immediately, and open a short grace window so the
225 // scheduled scan does not raise false positives while wp.org is still
226 // publishing the new version's checksums. Registered regardless of
227 // auto_scan because it reacts to update events, not to the schedule.
228 add_action( 'upgrader_process_complete', array( $this, 'on_upgrade_complete' ), 20, 2 );
229 add_action( 'vigilante_fi_postupdate_verify', array( $this, 'run_postupdate_verify' ) );
230 }
231
232 /**
233 * Check if scan time limit has been exceeded
234 *
235 * @return bool True if time exceeded.
236 */
237 private function is_time_exceeded() {
238 if ( 0 === $this->scan_start_time ) {
239 return false;
240 }
241 return ( microtime( true ) - $this->scan_start_time ) > $this->max_scan_time;
242 }
243
244 /**
245 * Schedule automated scans
246 */
247 private function schedule_scan() {
248 $frequency = $this->options['scan_frequency'] ?? 'daily';
249
250 if ( ! wp_next_scheduled( 'vigilante_file_integrity_scan' ) ) {
251 wp_schedule_event( time(), $frequency, 'vigilante_file_integrity_scan' );
252 }
253 }
254
255 /**
256 * Run a scheduled scan
257 */
258 public function run_scheduled_scan() {
259 $results = $this->run_scan();
260
261 // Store last scan time
262 update_option( 'vigilante_last_integrity_scan', time() );
263 update_option( 'vigilante_last_integrity_results', $results );
264 }
265
266 /**
267 * React to a completed plugin/theme update (upgrader_process_complete).
268 *
269 * Opens a short grace window for each updated slug (so the scheduled scan
270 * skips it and the checksum cache is bypassed) and schedules an immediate
271 * verification against WordPress.org. This stops the post-update "files
272 * don't match WordPress.org" false positives and, when WP-Cron is healthy,
273 * verifies the update against WordPress.org right away instead of waiting
274 * for the next scheduled scan. The grace window is intentionally short (30
275 * minutes) so that if WP-Cron never fires the verification, the normal scan
276 * resumes for the slug rather than leaving it unscanned.
277 *
278 * Runs as the old plugin code with the new files already on disk, so slugs
279 * are read from $hook_extra, not from in-memory version constants.
280 *
281 * @param WP_Upgrader|mixed $upgrader Upgrader instance (unused).
282 * @param array $hook_extra Update context.
283 */
284 public function on_upgrade_complete( $upgrader, $hook_extra ) {
285 unset( $upgrader );
286 if ( ! is_array( $hook_extra ) || 'update' !== ( $hook_extra['action'] ?? '' ) ) {
287 return;
288 }
289
290 $type = $hook_extra['type'] ?? '';
291 $targets = array(); // type => list of slugs.
292
293 if ( 'plugin' === $type ) {
294 $files = array();
295 if ( ! empty( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
296 $files = $hook_extra['plugins'];
297 } elseif ( ! empty( $hook_extra['plugin'] ) ) {
298 $files = array( $hook_extra['plugin'] );
299 }
300 foreach ( $files as $file ) {
301 $slug = dirname( (string) $file );
302 if ( '.' !== $slug && '' !== $slug ) {
303 $targets['plugin'][] = $slug;
304 }
305 }
306 } elseif ( 'theme' === $type && ! empty( $hook_extra['themes'] ) && is_array( $hook_extra['themes'] ) ) {
307 foreach ( $hook_extra['themes'] as $slug ) {
308 $slug = (string) $slug;
309 if ( '' !== $slug ) {
310 $targets['theme'][] = $slug;
311 }
312 }
313 }
314
315 if ( empty( $targets ) ) {
316 return;
317 }
318
319 // Open a short grace window per slug (30 minutes; closed earlier once the
320 // verifier runs). Kept short on purpose: with WP-Cron disabled the
321 // verifier never fires, so a longer window would leave a just-updated
322 // slug unscanned. After it expires the normal scan resumes.
323 foreach ( $targets as $t => $slugs ) {
324 foreach ( array_unique( $slugs ) as $slug ) {
325 set_transient( 'vigilante_fi_grace_' . $t . '_' . md5( $slug ), 1, 30 * MINUTE_IN_SECONDS );
326 }
327 }
328
329 // Verify shortly after the update settles. A single delayed event keeps
330 // the heavy work out of the update request itself.
331 if ( ! wp_next_scheduled( 'vigilante_fi_postupdate_verify', array( $targets ) ) ) {
332 wp_schedule_single_event( time() + 90, 'vigilante_fi_postupdate_verify', array( $targets ) );
333 }
334 }
335
336 /**
337 * Immediate post-update verification callback (vigilante_fi_postupdate_verify).
338 *
339 * @param array $targets type => list of slugs.
340 */
341 public function run_postupdate_verify( $targets ) {
342 if ( ! is_array( $targets ) ) {
343 return;
344 }
345 foreach ( $targets as $type => $slugs ) {
346 if ( 'plugin' !== $type && 'theme' !== $type ) {
347 continue;
348 }
349 foreach ( array_unique( (array) $slugs ) as $slug ) {
350 $this->verify_updated_slug( $type, (string) $slug );
351 }
352 }
353 }
354
355 /**
356 * Verify a single just-updated plugin/theme against fresh wp.org checksums.
357 *
358 * The grace window forces get_*_checksums() to fetch a live manifest, so this
359 * never compares against a manifest cached during wp.org's propagation lag.
360 * Outcomes: checksums not published yet => leave the window to expire and let
361 * the next scan re-check; all files match => close the window early; a file
362 * matches no published hash => a genuine mismatch (right after a legit update
363 * that points at a tampered package), logged as a warning, and the window is
364 * closed so the finding also surfaces in the normal scan.
365 *
366 * @param string $type 'plugin' or 'theme'.
367 * @param string $slug Slug.
368 */
369 private function verify_updated_slug( $type, $slug ) {
370 $grace_key = 'vigilante_fi_grace_' . $type . '_' . md5( $slug );
371
372 if ( 'plugin' === $type ) {
373 if ( ! function_exists( 'get_plugins' ) ) {
374 require_once ABSPATH . 'wp-admin/includes/plugin.php';
375 }
376 $version = '';
377 $name = $slug;
378 foreach ( get_plugins() as $file => $data ) {
379 if ( dirname( $file ) === $slug ) {
380 $version = $data['Version'] ?? '';
381 $name = $data['Name'] ?? $slug;
382 break;
383 }
384 }
385 $checksums = $this->get_plugin_checksums( $slug, $version );
386 $base_dir = WP_PLUGIN_DIR . '/' . $slug;
387 } else {
388 $theme = wp_get_theme( $slug );
389 if ( ! $theme->exists() ) {
390 delete_transient( $grace_key );
391 return;
392 }
393 $version = $theme->get( 'Version' );
394 $name = $theme->get( 'Name' );
395 $checksums = $this->get_theme_checksums( $slug, $version );
396 $base_dir = $theme->get_stylesheet_directory();
397 }
398
399 // Checksums not available yet (propagation lag): leave the grace window
400 // to expire; the next scheduled scan re-verifies once wp.org publishes.
401 if ( is_wp_error( $checksums ) || 'not_found' === $checksums || ! is_array( $checksums ) ) {
402 if ( $this->activity_log ) {
403 $this->activity_log->log(
404 'file',
405 'postupdate_pending',
406 sprintf(
407 /* translators: 1: Plugin or theme name. */
408 __( 'Post-update verification pending for %1$s: WordPress.org has not published the new version checksums yet. It will be re-verified automatically.', 'vigilante' ),
409 $name
410 ),
411 array(
412 'type' => $type,
413 'slug' => $slug,
414 'version' => $version,
415 ),
416 'info'
417 );
418 }
419 return;
420 }
421
422 // Compare every shipped file against the fresh manifest.
423 $mismatched = array();
424 foreach ( $checksums as $file => $expected ) {
425 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
426 continue;
427 }
428 $path = $base_dir . '/' . $file;
429 if ( $this->is_path_excluded( $path ) || $this->is_extension_excluded( $path ) ) {
430 continue;
431 }
432 // Honor the user ignore list, exactly as run_scan()'s filter_ignored()
433 // does, so the verifier never warns about a file the user silenced.
434 $rel = $type . 's/' . $slug . '/' . $file;
435 if ( in_array( $rel, (array) $this->ignored_files, true ) ) {
436 continue;
437 }
438 if ( ! file_exists( $path ) ) {
439 continue;
440 }
441 if ( ! $this->hash_matches_published( $path, $expected ) ) {
442 $mismatched[] = $rel;
443 }
444 }
445
446 // Verified clean: close the grace window so normal scanning resumes.
447 if ( empty( $mismatched ) ) {
448 delete_transient( $grace_key );
449 if ( $this->activity_log ) {
450 $this->activity_log->log(
451 'file',
452 'postupdate_verified',
453 sprintf(
454 /* translators: 1: Plugin or theme name. */
455 __( 'Post-update verification passed: %1$s matches the WordPress.org distribution.', 'vigilante' ),
456 $name
457 ),
458 array(
459 'type' => $type,
460 'slug' => $slug,
461 'version' => $version,
462 ),
463 'info'
464 );
465 }
466 return;
467 }
468
469 // Genuine mismatch right after a legitimate update: likely a tampered
470 // package. Close the window so the normal scan also surfaces it, and log
471 // a warning (Audit Alerts escalates warnings if configured).
472 delete_transient( $grace_key );
473 if ( $this->activity_log ) {
474 $this->activity_log->log(
475 'file',
476 'postupdate_mismatch',
477 sprintf(
478 /* translators: 1: Number of files, 2: Plugin or theme name. */
479 _n(
480 'Post-update integrity check failed: %1$d file in %2$s does not match the WordPress.org distribution.',
481 'Post-update integrity check failed: %1$d files in %2$s do not match the WordPress.org distribution.',
482 count( $mismatched ),
483 'vigilante'
484 ),
485 count( $mismatched ),
486 $name
487 ),
488 array(
489 'type' => $type,
490 'slug' => $slug,
491 'version' => $version,
492 'files' => array_slice( $mismatched, 0, 50 ),
493 ),
494 'warning'
495 );
496 }
497 }
498
499 /**
500 * Run a full integrity scan
501 *
502 * @return array Scan results.
503 */
504 public function run_scan() {
505 // Initialize scan timer
506 $this->scan_start_time = microtime( true );
507
508 $results = array(
509 'scanned' => 0,
510 'ok' => 0,
511 'modified' => array(),
512 'missing' => array(),
513 'suspicious' => array(),
514 'extra' => array(),
515 'new' => array(),
516 'errors' => array(),
517 'scan_time' => 0,
518 'incomplete' => false,
519 );
520
521 // Use settings from options page
522 $options = is_array( $this->options ) ? $this->options : array();
523
524 // Scan uploads for suspicious files FIRST (highest security priority)
525 // PHP files in uploads are almost always malware
526 if ( ! empty( $options['scan_uploads'] ) && ! $this->is_time_exceeded() ) {
527 $upload_results = $this->scan_uploads();
528 $results['suspicious'] = array_merge( $results['suspicious'], $upload_results['suspicious'] );
529 $results['extra'] = array_merge( $results['extra'], $upload_results['extra'] );
530 }
531
532 // Scan core files
533 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
534 $core_results = $this->scan_core_files();
535 $results = $this->merge_results( $results, $core_results );
536 }
537
538 // Scan root directory for non-core files (PHP = suspicious, others = additional)
539 // Runs after core scan so checksums are already cached
540 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
541 $root_results = $this->scan_root_files();
542 $results['suspicious'] = array_merge( $results['suspicious'], $root_results['suspicious'] );
543 $results['extra'] = array_merge( $results['extra'], $root_results['extra'] );
544 }
545
546 // Scan critical config files (wp-config.php, .htaccess) against stored baseline
547 if ( ! empty( $options['scan_critical_config'] ) && ! $this->is_time_exceeded() ) {
548 $critical_results = $this->scan_critical_root_files();
549 $results['modified'] = array_merge( $results['modified'], $critical_results );
550 }
551
552 // Scan plugins
553 if ( ! empty( $options['scan_plugins'] ) && ! $this->is_time_exceeded() ) {
554 $plugin_results = $this->scan_plugins();
555 $results = $this->merge_results( $results, $plugin_results );
556 }
557
558 // Scan themes
559 if ( ! empty( $options['scan_themes'] ) && ! $this->is_time_exceeded() ) {
560 $theme_results = $this->scan_themes();
561 $results = $this->merge_results( $results, $theme_results );
562 }
563
564 // Mark as incomplete if time was exceeded
565 if ( $this->is_time_exceeded() ) {
566 $results['incomplete'] = true;
567 $results['errors'][] = __( 'Scan was incomplete due to time limit. Results may be partial.', 'vigilante' );
568 }
569
570 // Filter out ignored files from all result categories
571 $results['modified'] = $this->filter_ignored( $results['modified'] );
572 $results['suspicious'] = $this->filter_ignored( $results['suspicious'] );
573 $results['extra'] = $this->filter_ignored( $results['extra'] );
574
575 $results['scan_time'] = round( microtime( true ) - $this->scan_start_time, 2 );
576
577 // Log the scan only if activity_log is available
578 if ( $this->activity_log ) {
579 $has_issues = ! empty( $results['modified'] ) || ! empty( $results['suspicious'] ) || ! empty( $results['extra'] );
580 $severity = $has_issues ? 'warning' : 'info';
581
582 $this->activity_log->log(
583 'file',
584 'integrity_scan',
585 sprintf(
586 /* translators: 1: Scanned count, 2: Modified count, 3: Suspicious count, 4: Extra files count */
587 __( 'File integrity scan completed: %1$d files scanned, %2$d modified, %3$d suspicious, %4$d extra', 'vigilante' ),
588 $results['scanned'],
589 count( $results['modified'] ),
590 count( $results['suspicious'] ),
591 count( $results['extra'] )
592 ),
593 array(
594 'scanned' => $results['scanned'],
595 'modified' => count( $results['modified'] ),
596 'suspicious' => count( $results['suspicious'] ),
597 'extra' => count( $results['extra'] ),
598 'scan_time' => $results['scan_time'],
599 'incomplete' => $results['incomplete'],
600 ),
601 $severity
602 );
603 }
604
605 // Closed plugins check: queries the wp.org repository for the closure status
606 // of every installed plugin slug. Independent of the file-level scan_* toggles
607 // (gated by its own `check_closed_plugins` toggle in Scan Scope). Runs BEFORE
608 // the notification call so closed plugins are folded into the scan email
609 // (instead of triggering a separate one-shot). Quick (~10 s for 50 plugins).
610 //
611 // suppress_email=true: this entry point is the file integrity scan; the
612 // daily plugin-status cron passes suppress_email=false so urgent closures
613 // still produce an immediate alert when the file scan is on a weekly schedule.
614 if ( ! empty( $options['check_closed_plugins'] ) ) {
615 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
616 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
617 }
618 $closed_checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
619 $closed_checker->check_all_plugins( true, true );
620 }
621
622 // Send email notification based on notify_level (now also includes closed
623 // plugins picked up just above).
624 $this->maybe_send_notification( $results );
625
626 return $results;
627 }
628
629 /**
630 * Scan WordPress core files
631 *
632 * @return array Scan results.
633 */
634 private function scan_core_files() {
635 $results = array(
636 'scanned' => 0,
637 'ok' => 0,
638 'modified' => array(),
639 'missing' => array(),
640 'errors' => array(),
641 );
642
643 // Get official checksums from WordPress.org
644 $checksums = $this->get_core_checksums();
645
646 if ( is_wp_error( $checksums ) ) {
647 $results['errors'][] = $checksums->get_error_message();
648 return $results;
649 }
650
651 foreach ( $checksums as $file => $expected_hash ) {
652 // Check time limit
653 if ( $this->is_time_exceeded() ) {
654 break;
655 }
656
657 $file_path = ABSPATH . $file;
658
659 // Skip excluded paths
660 if ( $this->is_path_excluded( $file_path ) ) {
661 continue;
662 }
663
664 // Skip excluded extensions
665 if ( $this->is_extension_excluded( $file_path ) ) {
666 continue;
667 }
668
669 // Skip known false positives (e.g. version.php, readme.html)
670 if ( in_array( $file, $this->core_known_false_positives, true ) ) {
671 continue;
672 }
673
674 $results['scanned']++;
675
676 if ( ! file_exists( $file_path ) ) {
677 $results['missing'][] = array(
678 'file' => $file,
679 'type' => 'core',
680 );
681 continue;
682 }
683
684 $actual_hash = md5_file( $file_path );
685
686 if ( $actual_hash !== $expected_hash ) {
687 $results['modified'][] = array(
688 'file' => $file,
689 'type' => 'core',
690 'expected_hash' => $expected_hash,
691 'actual_hash' => $actual_hash,
692 );
693 } else {
694 $results['ok']++;
695 }
696 }
697
698 return $results;
699 }
700
701 /**
702 * Scan WordPress root directory for non-core files
703 *
704 * Compares files in ABSPATH (non-recursive) against the official core
705 * checksums list. PHP files not in the core distribution are flagged as
706 * suspicious (common attack vector: info.php, shell.php, backdoors).
707 * Non-PHP files not in the known safe list are flagged as extra/additional.
708 * Dotfiles and known safe files (robots.txt, etc.) are skipped.
709 *
710 * @return array Array with 'suspicious' and 'extra' sub-arrays.
711 */
712 private function scan_root_files() {
713 $found = array(
714 'suspicious' => array(),
715 'extra' => array(),
716 );
717
718 // Get core checksums to know which root files are legitimate
719 $checksums = $this->get_core_checksums();
720 if ( is_wp_error( $checksums ) ) {
721 return $found;
722 }
723
724 // Build list of known core root files from checksums (only root-level, no directory prefix)
725 $core_root_files = array();
726 foreach ( array_keys( $checksums ) as $file ) {
727 // Only root-level files (no directory separator)
728 if ( false === strpos( $file, '/' ) ) {
729 $core_root_files[] = $file;
730 }
731 }
732
733 // Also add wp-config.php which is not in checksums but is core
734 $core_root_files[] = 'wp-config.php';
735
736 $php_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'phps' );
737
738 // Scan only direct children of ABSPATH (not recursive)
739 $root_path = untrailingslashit( ABSPATH );
740 $handle = opendir( $root_path );
741
742 if ( ! $handle ) {
743 return $found;
744 }
745
746 while ( false !== ( $entry = readdir( $handle ) ) ) {
747 if ( $this->is_time_exceeded() ) {
748 break;
749 }
750
751 // Skip . and ..
752 if ( '.' === $entry || '..' === $entry ) {
753 continue;
754 }
755
756 // Skip dotfiles (.htaccess, .user.ini, .env, etc.) — handled by firewall protection
757 if ( 0 === strpos( $entry, '.' ) ) {
758 continue;
759 }
760
761 $full_path = $root_path . '/' . $entry;
762
763 // Skip directories — we only care about files in root
764 if ( is_dir( $full_path ) ) {
765 continue;
766 }
767
768 // Skip if this is a known core file
769 if ( in_array( $entry, $core_root_files, true ) ) {
770 continue;
771 }
772
773 // Skip excluded paths
774 if ( $this->is_path_excluded( $full_path ) ) {
775 continue;
776 }
777
778 // Skip known safe non-PHP root files
779 if ( in_array( strtolower( $entry ), $this->known_safe_root_files, true ) ) {
780 continue;
781 }
782
783 $extension = strtolower( pathinfo( $entry, PATHINFO_EXTENSION ) );
784
785 if ( in_array( $extension, $php_extensions, true ) ) {
786 // Check if this is a legacy WordPress core file (removed from newer versions)
787 if ( in_array( $entry, $this->legacy_core_root_files, true ) ) {
788 $found['extra'][] = array(
789 'file' => $entry,
790 'type' => 'legacy_core',
791 'reason' => __( 'Legacy WordPress core file, removed in newer versions. Safe to delete.', 'vigilante' ),
792 );
793 continue;
794 }
795
796 // Silence-is-golden placeholders dropped here by some setups
797 // (e.g. WordPress installed in a subdirectory, or third-party tooling).
798 if ( $this->is_silence_golden_file( $full_path ) ) {
799 continue;
800 }
801
802 // PHP file not in core = suspicious
803 $reason = __( 'Non-core PHP file in WordPress root directory', 'vigilante' );
804
805 // Scan content for specific patterns
806 if ( filesize( $full_path ) < 512000 ) {
807 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
808 $pattern = $this->detect_suspicious_pattern( $content );
809 if ( $pattern ) {
810 /* translators: %s: Suspicious pattern found */
811 $reason = sprintf( __( 'Non-core PHP in root with suspicious code: %s', 'vigilante' ), $pattern );
812 }
813 }
814
815 $found['suspicious'][] = array(
816 'file' => $entry,
817 'type' => 'php_in_root',
818 'reason' => $reason,
819 );
820 } else {
821 // Non-PHP, non-known-safe file = additional (informational)
822 $found['extra'][] = array(
823 'file' => $entry,
824 'type' => 'extra_root',
825 'reason' => __( 'Non-core file in WordPress root directory', 'vigilante' ),
826 );
827 }
828 }
829
830 closedir( $handle );
831
832 return $found;
833 }
834
835 // =========================================================================
836 // Critical config file baseline monitoring (wp-config.php, .htaccess)
837 // =========================================================================
838
839 /**
840 * Scan critical root files against stored baseline hashes
841 *
842 * Files like wp-config.php and .htaccess have no official WordPress.org
843 * checksum because their content is unique per installation. We maintain
844 * our own baseline hash and alert when the file changes outside of
845 * Vigilante's own modifications.
846 *
847 * On the first scan (no baseline stored yet) the baseline is created
848 * silently — there is nothing to compare against.
849 *
850 * @return array Array of modified file entries (same format as core modified).
851 */
852 private function scan_critical_root_files() {
853 $modified = array();
854 $baseline = $this->get_critical_files_baseline();
855 $baseline_changed = false;
856 $root_path = untrailingslashit( ABSPATH );
857
858 foreach ( $this->critical_root_files as $filename ) {
859 $full_path = $root_path . '/' . $filename;
860
861 if ( ! file_exists( $full_path ) ) {
862 continue;
863 }
864
865 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
866 if ( false === $content ) {
867 continue;
868 }
869
870 $normalized = $this->normalize_critical_file( $filename, $content );
871 $current_hash = md5( $normalized );
872
873 if ( ! isset( $baseline[ $filename ] ) ) {
874 // First time seeing this file — store baseline silently
875 $baseline[ $filename ] = array(
876 'hash' => $current_hash,
877 'size' => strlen( $content ),
878 'content' => $normalized,
879 'updated' => time(),
880 );
881 $baseline_changed = true;
882 continue;
883 }
884
885 // Upgrade legacy baseline entries that lack content (pre-diff format)
886 if ( ! isset( $baseline[ $filename ]['content'] ) && $baseline[ $filename ]['hash'] === $current_hash ) {
887 $baseline[ $filename ]['content'] = $normalized;
888 $baseline_changed = true;
889 continue;
890 }
891
892 // Compare against stored baseline
893 if ( $baseline[ $filename ]['hash'] !== $current_hash ) {
894 $baseline_content = $baseline[ $filename ]['content'] ?? '';
895 $diff = '' !== $baseline_content
896 ? $this->compute_simple_diff( $baseline_content, $normalized )
897 : array( 'added' => array(), 'removed' => array(), 'unavailable' => true );
898
899 $modified[] = array(
900 'file' => $filename,
901 'type' => 'critical_config',
902 'expected_hash' => $baseline[ $filename ]['hash'],
903 'actual_hash' => $current_hash,
904 'baseline_size' => $baseline[ $filename ]['size'],
905 'current_size' => strlen( $content ),
906 'diff' => $diff,
907 );
908 }
909 }
910
911 if ( $baseline_changed ) {
912 update_option( self::BASELINE_OPTION, $baseline, false );
913 }
914
915 return $modified;
916 }
917
918 /**
919 * Compute a simple line-based diff between two strings
920 *
921 * Returns added and removed lines with their original line numbers.
922 * Order is preserved. Uses a simple "line present in set" approach
923 * which works well for config files where most lines are unique.
924 *
925 * @param string $old Baseline content.
926 * @param string $new Current content.
927 * @return array Array with 'added' and 'removed' line entries.
928 */
929 private function compute_simple_diff( $old, $new ) {
930 $old_lines = explode( "\n", $old );
931 $new_lines = explode( "\n", $new );
932
933 // Use hash sets for O(1) lookup. Use array_flip for cheap existence check.
934 $old_set = array_count_values( $old_lines );
935 $new_set = array_count_values( $new_lines );
936
937 $removed = array();
938 foreach ( $old_lines as $i => $line ) {
939 // Line only considered removed if baseline has more occurrences than current
940 if ( ! isset( $new_set[ $line ] ) || $new_set[ $line ] < ( $old_set[ $line ] ?? 0 ) ) {
941 $removed[] = array(
942 'line' => $i + 1,
943 'content' => $line,
944 );
945 // Decrement to handle duplicates correctly
946 if ( isset( $old_set[ $line ] ) ) {
947 $old_set[ $line ]--;
948 }
949 }
950 }
951
952 // Reset for added detection
953 $old_set = array_count_values( $old_lines );
954 $added = array();
955 foreach ( $new_lines as $i => $line ) {
956 if ( ! isset( $old_set[ $line ] ) || $old_set[ $line ] < ( $new_set[ $line ] ?? 0 ) ) {
957 $added[] = array(
958 'line' => $i + 1,
959 'content' => $line,
960 );
961 if ( isset( $new_set[ $line ] ) ) {
962 $new_set[ $line ]--;
963 }
964 }
965 }
966
967 return array(
968 'added' => $added,
969 'removed' => $removed,
970 'unavailable' => false,
971 );
972 }
973
974 /**
975 * Normalize critical file content by removing Vigilante-managed blocks
976 *
977 * This ensures that changes made by Vigilante itself (security constants,
978 * htaccess rules) do not trigger false-positive modification alerts.
979 * Line endings are normalized to LF to prevent false positives from
980 * editors that change CRLF/LF.
981 *
982 * @param string $filename File name (e.g. 'wp-config.php').
983 * @param string $content Raw file content.
984 * @return string Normalized content for hashing.
985 */
986 private function normalize_critical_file( $filename, $content ) {
987 // Normalize line endings first (CRLF and CR to LF)
988 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
989
990 if ( 'wp-config.php' === $filename ) {
991 // Remove Vigilante constants blocks (current and legacy)
992 foreach ( $this->wpconfig_markers as $markers ) {
993 $pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '\s*/s';
994 $content = preg_replace( $pattern, '', $content );
995 }
996
997 // Remove lines commented out by Vigilante (original constants)
998 $content = preg_replace(
999 '/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m',
1000 '',
1001 $content
1002 );
1003 } elseif ( '.htaccess' === $filename ) {
1004 // Remove Vigilante htaccess blocks (firewall + security headers)
1005 foreach ( $this->htaccess_markers as $markers ) {
1006 $pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '\s*/s';
1007 $content = preg_replace( $pattern, '', $content );
1008 }
1009 }
1010
1011 // Collapse multiple blank lines into one (blocks removal leaves gaps)
1012 $content = preg_replace( '/\n{3,}/', "\n\n", $content );
1013
1014 return trim( $content );
1015 }
1016
1017 /**
1018 * Get stored baseline hashes for critical files
1019 *
1020 * @return array Associative array keyed by filename.
1021 */
1022 public function get_critical_files_baseline() {
1023 $baseline = get_option( self::BASELINE_OPTION, array() );
1024 return is_array( $baseline ) ? $baseline : array();
1025 }
1026
1027 /**
1028 * Update baseline hash for a single critical file
1029 *
1030 * Called by wp-config and htaccess writers after Vigilante modifies
1031 * the file, so the next scan does not flag the change as suspicious.
1032 *
1033 * @param string $filename File name relative to ABSPATH (e.g. 'wp-config.php').
1034 * @return bool True on success.
1035 */
1036 public function update_critical_file_baseline( $filename ) {
1037 $full_path = untrailingslashit( ABSPATH ) . '/' . $filename;
1038
1039 if ( ! file_exists( $full_path ) ) {
1040 return false;
1041 }
1042
1043 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
1044 if ( false === $content ) {
1045 return false;
1046 }
1047
1048 $normalized = $this->normalize_critical_file( $filename, $content );
1049
1050 $baseline = $this->get_critical_files_baseline();
1051 $baseline[ $filename ] = array(
1052 'hash' => md5( $normalized ),
1053 'size' => strlen( $content ),
1054 'content' => $normalized,
1055 'updated' => time(),
1056 );
1057
1058 return update_option( self::BASELINE_OPTION, $baseline, false );
1059 }
1060
1061 /**
1062 * Regenerate baseline for all critical files
1063 *
1064 * Used by the admin UI button and the 1.14.0 migration.
1065 *
1066 * @return array Updated baseline data.
1067 */
1068 public function regenerate_all_baselines() {
1069 $baseline = array();
1070 $root_path = untrailingslashit( ABSPATH );
1071
1072 foreach ( $this->critical_root_files as $filename ) {
1073 $full_path = $root_path . '/' . $filename;
1074
1075 if ( ! file_exists( $full_path ) ) {
1076 continue;
1077 }
1078
1079 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
1080 if ( false === $content ) {
1081 continue;
1082 }
1083
1084 $normalized = $this->normalize_critical_file( $filename, $content );
1085 $baseline[ $filename ] = array(
1086 'hash' => md5( $normalized ),
1087 'size' => strlen( $content ),
1088 'content' => $normalized,
1089 'updated' => time(),
1090 );
1091 }
1092
1093 update_option( self::BASELINE_OPTION, $baseline, false );
1094
1095 return $baseline;
1096 }
1097
1098 /**
1099 * Get core checksums from WordPress.org API
1100 *
1101 * @return array|WP_Error Checksums or error.
1102 */
1103 private function get_core_checksums() {
1104 $locale = get_locale();
1105 $version = $this->wp_version;
1106
1107 // Check cache first
1108 $cache_key = 'vigilante_core_checksums_' . md5( $version . $locale );
1109 $cached = get_transient( $cache_key );
1110 if ( false !== $cached ) {
1111 return $cached;
1112 }
1113
1114 // Fetch from WordPress.org
1115 $url = sprintf(
1116 'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=%s',
1117 $version,
1118 $locale
1119 );
1120
1121 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
1122
1123 if ( is_wp_error( $response ) ) {
1124 return $response;
1125 }
1126
1127 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1128
1129 if ( empty( $body['checksums'] ) ) {
1130 return new WP_Error( 'no_checksums', __( 'Could not retrieve WordPress core checksums', 'vigilante' ) );
1131 }
1132
1133 $checksums = $body['checksums'];
1134
1135 // Handle nested format: checksums keyed under version string (WP 6.9+)
1136 if ( isset( $checksums[ $version ] ) && is_array( $checksums[ $version ] ) ) {
1137 $checksums = $checksums[ $version ];
1138 }
1139
1140 // Cache for 24 hours
1141 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
1142
1143 return $checksums;
1144 }
1145
1146 /**
1147 * Scan plugins for modifications
1148 *
1149 * @return array Scan results.
1150 */
1151 private function scan_plugins() {
1152 $results = array(
1153 'scanned' => 0,
1154 'ok' => 0,
1155 'modified' => array(),
1156 'suspicious' => array(),
1157 'extra' => array(),
1158 'errors' => array(),
1159 );
1160
1161 // Get all installed plugins
1162 if ( ! function_exists( 'get_plugins' ) ) {
1163 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1164 }
1165
1166 $plugins = get_plugins();
1167
1168 foreach ( $plugins as $plugin_file => $plugin_data ) {
1169 // Check time limit
1170 if ( $this->is_time_exceeded() ) {
1171 break;
1172 }
1173
1174 $plugin_slug = dirname( $plugin_file );
1175
1176 // Skip single-file plugins
1177 if ( '.' === $plugin_slug ) {
1178 continue;
1179 }
1180
1181 // Skip slugs in their post-update grace window: wp.org may still be
1182 // publishing the new version's checksums, so a scheduled scan here
1183 // would raise benign "modified/extra" noise. The dedicated post-update
1184 // verifier (vigilante_fi_postupdate_verify) handles these instead.
1185 if ( $this->in_post_update_grace( 'plugin', $plugin_slug ) ) {
1186 continue;
1187 }
1188
1189 // Get checksums from WordPress.org
1190 $version = $plugin_data['Version'] ?? '';
1191 $checksums = $this->get_plugin_checksums( $plugin_slug, $version );
1192
1193 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
1194
1195 $plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug;
1196
1197 // Check known files against checksums (only if available)
1198 if ( $has_checksums ) {
1199 foreach ( $checksums as $file => $expected_hash ) {
1200 // Check time limit inside inner loop too
1201 if ( $this->is_time_exceeded() ) {
1202 break 2; // Break both loops
1203 }
1204
1205 $file_path = $plugin_dir . '/' . $file;
1206
1207 // Skip excluded paths
1208 if ( $this->is_path_excluded( $file_path ) ) {
1209 continue;
1210 }
1211
1212 // Skip excluded extensions
1213 if ( $this->is_extension_excluded( $file_path ) ) {
1214 continue;
1215 }
1216
1217 // Skip known false positives (e.g. readme.txt, readme.md)
1218 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
1219 continue;
1220 }
1221
1222 $results['scanned']++;
1223
1224 if ( ! file_exists( $file_path ) ) {
1225 continue; // Some files might not be installed
1226 }
1227
1228 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
1229 $results['modified'][] = array(
1230 'file' => 'plugins/' . $plugin_slug . '/' . $file,
1231 'type' => 'plugin',
1232 'plugin' => $plugin_data['Name'],
1233 'expected_hash' => $this->expected_hash_label( $expected_hash ),
1234 'actual_hash' => md5_file( $file_path ),
1235 );
1236 } else {
1237 $results['ok']++;
1238 }
1239 }
1240 } // end if $has_checksums
1241
1242 // Detect extra/suspicious files
1243 // With checksums: finds files not in the original distribution
1244 // Without checksums: scans ALL plugin files but only flags suspicious patterns
1245 if ( ! $this->is_time_exceeded() ) {
1246 $known_files = $has_checksums ? $checksums : array();
1247 $suspicious_only = ! $has_checksums; // Without checksums, only report files with suspicious code
1248 $extra_results = $this->detect_extra_files( $plugin_dir, $known_files, 'plugin', $plugin_data['Name'], $suspicious_only );
1249 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
1250 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
1251 }
1252 }
1253
1254 return $results;
1255 }
1256
1257 /**
1258 * Whether a file on disk matches a hash WordPress.org publishes for it.
1259 *
1260 * The wp.org checksums JSON gives, per file, an md5 (and usually a sha256)
1261 * that may be a single string OR an array of strings: a file whose content
1262 * differs across the re-tagged zips that one checksums file covers (e.g. a
1263 * release that shares its checksums file with a beta) gets every valid hash
1264 * listed as an array. The old `$actual !== $expected` comparison evaluated a
1265 * 32-char string against an array as unequal unconditionally, so those files
1266 * were always reported as "modified" even when the on-disk hash was one of
1267 * the published ones. Match against membership, and accept either md5 or
1268 * sha256, so the comparison is correct and strictly stronger than md5-only.
1269 *
1270 * Robust to both shapes: the new record array( 'md5' => ..., 'sha256' => ... )
1271 * and a legacy cached value (a bare md5 string or array), so a transient
1272 * cached by an older version still compares correctly until it expires.
1273 *
1274 * @param string $file_path Absolute path to the file on disk.
1275 * @param array|string $expected Record array, or a legacy md5 string|array.
1276 * @return bool True when the file matches a published hash.
1277 */
1278 private function hash_matches_published( $file_path, $expected ) {
1279 $md5 = $expected;
1280 $sha = null;
1281 if ( is_array( $expected ) && ( array_key_exists( 'md5', $expected ) || array_key_exists( 'sha256', $expected ) ) ) {
1282 $md5 = isset( $expected['md5'] ) ? $expected['md5'] : null;
1283 $sha = isset( $expected['sha256'] ) ? $expected['sha256'] : null;
1284 }
1285
1286 if ( null !== $md5 && in_array( md5_file( $file_path ), (array) $md5, true ) ) {
1287 return true;
1288 }
1289 if ( ! empty( $sha ) && in_array( hash_file( 'sha256', $file_path ), (array) $sha, true ) ) {
1290 return true;
1291 }
1292
1293 // Fallback: some hosts and deploy pipelines rewrite text files on disk
1294 // (prepend a UTF-8 BOM, or convert LF line endings to CRLF) without
1295 // changing a single line of code. That alters the raw bytes, so the
1296 // md5/sha256 stops matching WordPress.org even though the file is
1297 // intact, which surfaced as false "modified file" alerts. Retry the
1298 // comparison against a normalized copy (BOM stripped, CRLF/CR collapsed
1299 // to LF) for text files only, so a genuine code change is still caught.
1300 if ( is_string( $file_path ) && '' !== $file_path && $this->is_text_file( $file_path ) && is_readable( $file_path ) ) {
1301 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read for hashing, not remote.
1302 $content = file_get_contents( $file_path );
1303 if ( false !== $content ) {
1304 if ( "\xEF\xBB\xBF" === substr( $content, 0, 3 ) ) {
1305 $content = substr( $content, 3 );
1306 }
1307 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
1308 if ( null !== $md5 && in_array( md5( $content ), (array) $md5, true ) ) {
1309 return true;
1310 }
1311 if ( ! empty( $sha ) && in_array( hash( 'sha256', $content ), (array) $sha, true ) ) {
1312 return true;
1313 }
1314 }
1315 }
1316
1317 return false;
1318 }
1319
1320 /**
1321 * Human-readable expected-hash value for a modified-file result record.
1322 *
1323 * The published md5 may be a string or an array; flatten it for storage.
1324 *
1325 * @param array|string $expected Record array or legacy md5 string|array.
1326 * @return string
1327 */
1328 private function expected_hash_label( $expected ) {
1329 $md5 = ( is_array( $expected ) && array_key_exists( 'md5', $expected ) ) ? $expected['md5'] : $expected;
1330 if ( is_array( $md5 ) ) {
1331 return implode( ', ', array_map( 'strval', $md5 ) );
1332 }
1333 return (string) $md5;
1334 }
1335
1336 /**
1337 * Whether a plugin/theme slug is inside its post-update grace window.
1338 *
1339 * Set by on_upgrade_complete() right after WordPress finishes updating a
1340 * plugin or theme. During the window the checksum cache is bypassed (so a
1341 * stale manifest cached during wp.org's propagation lag is never reused) and
1342 * the scheduled scan skips the slug (the dedicated post-update verifier
1343 * handles it instead), which is what stops the "files don't match
1344 * WordPress.org" false positives right after an update.
1345 *
1346 * @param string $type 'plugin' or 'theme'.
1347 * @param string $slug Slug.
1348 * @return bool
1349 */
1350 private function in_post_update_grace( $type, $slug ) {
1351 return (bool) get_transient( 'vigilante_fi_grace_' . $type . '_' . md5( $slug ) );
1352 }
1353
1354 /**
1355 * Get plugin checksums from WordPress.org
1356 *
1357 * @param string $slug Plugin slug.
1358 * @param string $version Plugin version.
1359 * @return array|WP_Error|string
1360 */
1361 private function get_plugin_checksums( $slug, $version ) {
1362 $cache_key = 'vigilante_plugin_checksums_' . md5( $slug . $version );
1363
1364 // During the post-update grace window, bypass the cache entirely so a
1365 // manifest cached while wp.org was still propagating the new version's
1366 // checksums can never be reused. Fetch fresh and do not write it back.
1367 $grace = $this->in_post_update_grace( 'plugin', $slug );
1368
1369 if ( ! $grace ) {
1370 $cached = get_transient( $cache_key );
1371 if ( false !== $cached ) {
1372 return $cached;
1373 }
1374 }
1375
1376 $url = sprintf(
1377 'https://downloads.wordpress.org/plugin-checksums/%s/%s.json',
1378 $slug,
1379 $version
1380 );
1381
1382 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
1383
1384 if ( is_wp_error( $response ) ) {
1385 return $response;
1386 }
1387
1388 $status = wp_remote_retrieve_response_code( $response );
1389 if ( 200 !== $status ) {
1390 // Cache "not found" to avoid repeated requests (never during grace).
1391 if ( ! $grace ) {
1392 set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
1393 }
1394 return 'not_found';
1395 }
1396
1397 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1398
1399 if ( empty( $body['files'] ) ) {
1400 return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
1401 }
1402
1403 // Store the full per-file record (md5 + sha256). Either value can be a
1404 // string or an array of strings; hash_matches_published() handles both.
1405 $checksums = array();
1406 foreach ( $body['files'] as $file => $data ) {
1407 $checksums[ $file ] = array(
1408 'md5' => isset( $data['md5'] ) ? $data['md5'] : null,
1409 'sha256' => isset( $data['sha256'] ) ? $data['sha256'] : null,
1410 );
1411 }
1412
1413 // Cache for 24 hours (never during grace, to avoid persisting a manifest
1414 // wp.org may still be regenerating).
1415 if ( ! $grace ) {
1416 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
1417 }
1418
1419 return $checksums;
1420 }
1421
1422 /**
1423 * Scan themes for modifications
1424 *
1425 * @return array Scan results.
1426 */
1427 private function scan_themes() {
1428 $results = array(
1429 'scanned' => 0,
1430 'ok' => 0,
1431 'modified' => array(),
1432 'suspicious' => array(),
1433 'extra' => array(),
1434 'errors' => array(),
1435 );
1436
1437 $themes = wp_get_themes();
1438
1439 foreach ( $themes as $theme_slug => $theme ) {
1440 // Check time limit
1441 if ( $this->is_time_exceeded() ) {
1442 break;
1443 }
1444
1445 // Skip slugs in their post-update grace window (see scan_plugins()).
1446 if ( $this->in_post_update_grace( 'theme', $theme_slug ) ) {
1447 continue;
1448 }
1449
1450 $version = $theme->get( 'Version' );
1451 $checksums = $this->get_theme_checksums( $theme_slug, $version );
1452
1453 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
1454
1455 $theme_dir = $theme->get_stylesheet_directory();
1456
1457 // Check known files against checksums (only if available)
1458 if ( $has_checksums ) {
1459 foreach ( $checksums as $file => $expected_hash ) {
1460 // Check time limit inside inner loop too
1461 if ( $this->is_time_exceeded() ) {
1462 break 2; // Break both loops
1463 }
1464
1465 $file_path = $theme_dir . '/' . $file;
1466
1467 // Skip excluded paths
1468 if ( $this->is_path_excluded( $file_path ) ) {
1469 continue;
1470 }
1471
1472 // Skip excluded extensions
1473 if ( $this->is_extension_excluded( $file_path ) ) {
1474 continue;
1475 }
1476
1477 // Skip known false positives (e.g. readme.txt, readme.md)
1478 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
1479 continue;
1480 }
1481
1482 $results['scanned']++;
1483
1484 if ( ! file_exists( $file_path ) ) {
1485 continue;
1486 }
1487
1488 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
1489 $results['modified'][] = array(
1490 'file' => 'themes/' . $theme_slug . '/' . $file,
1491 'type' => 'theme',
1492 'theme' => $theme->get( 'Name' ),
1493 'expected_hash' => $this->expected_hash_label( $expected_hash ),
1494 'actual_hash' => md5_file( $file_path ),
1495 );
1496 } else {
1497 $results['ok']++;
1498 }
1499 }
1500 } // end if $has_checksums
1501
1502 // Detect extra/suspicious files
1503 if ( ! $this->is_time_exceeded() ) {
1504 $known_files = $has_checksums ? $checksums : array();
1505 $suspicious_only = ! $has_checksums;
1506 $extra_results = $this->detect_extra_files( $theme_dir, $known_files, 'theme', $theme->get( 'Name' ), $suspicious_only );
1507 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
1508 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
1509 }
1510 }
1511
1512 return $results;
1513 }
1514
1515 /**
1516 * Get theme checksums from WordPress.org
1517 *
1518 * @param string $slug Theme slug.
1519 * @param string $version Theme version.
1520 * @return array|WP_Error
1521 */
1522 private function get_theme_checksums( $slug, $version ) {
1523 $cache_key = 'vigilante_theme_checksums_' . md5( $slug . $version );
1524
1525 // Bypass the cache during the post-update grace window (see plugin path).
1526 $grace = $this->in_post_update_grace( 'theme', $slug );
1527
1528 if ( ! $grace ) {
1529 $cached = get_transient( $cache_key );
1530 if ( false !== $cached ) {
1531 return $cached;
1532 }
1533 }
1534
1535 $url = sprintf(
1536 'https://downloads.wordpress.org/theme-checksums/%s/%s.json',
1537 $slug,
1538 $version
1539 );
1540
1541 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
1542
1543 if ( is_wp_error( $response ) ) {
1544 return $response;
1545 }
1546
1547 $status = wp_remote_retrieve_response_code( $response );
1548 if ( 200 !== $status ) {
1549 // Cache "not found" to avoid repeated requests (never during grace).
1550 if ( ! $grace ) {
1551 set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
1552 }
1553 return new WP_Error( 'not_found', __( 'Checksums not available', 'vigilante' ) );
1554 }
1555
1556 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1557
1558 if ( empty( $body['files'] ) ) {
1559 return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
1560 }
1561
1562 // Store the full per-file record (md5 + sha256), either of which may be a
1563 // string or an array; hash_matches_published() handles both shapes.
1564 $checksums = array();
1565 foreach ( $body['files'] as $file => $data ) {
1566 $checksums[ $file ] = array(
1567 'md5' => isset( $data['md5'] ) ? $data['md5'] : null,
1568 'sha256' => isset( $data['sha256'] ) ? $data['sha256'] : null,
1569 );
1570 }
1571
1572 // Cache for 24 hours (never during grace).
1573 if ( ! $grace ) {
1574 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
1575 }
1576
1577 return $checksums;
1578 }
1579
1580 /**
1581 * Scan uploads directory for suspicious files
1582 *
1583 * @return array Array with 'suspicious' and 'extra' sub-arrays.
1584 */
1585 private function scan_uploads() {
1586 $found = array(
1587 'suspicious' => array(),
1588 'extra' => array(),
1589 );
1590 $upload_dir = wp_upload_dir();
1591 $base_dir = $upload_dir['basedir'];
1592 $max_files = 10000; // Increased limit for thorough scanning
1593 $files_checked = 0;
1594
1595 // Executable extensions that should never be in uploads
1596 $dangerous_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'phps' );
1597
1598 if ( ! is_dir( $base_dir ) ) {
1599 return $found;
1600 }
1601
1602 try {
1603 $iterator = new RecursiveIteratorIterator(
1604 new RecursiveDirectoryIterator( $base_dir, RecursiveDirectoryIterator::SKIP_DOTS ),
1605 RecursiveIteratorIterator::LEAVES_ONLY
1606 );
1607
1608 foreach ( $iterator as $file ) {
1609 // Check global time limit
1610 if ( $this->is_time_exceeded() ) {
1611 break;
1612 }
1613
1614 // Check file limit
1615 $files_checked++;
1616 if ( $files_checked > $max_files ) {
1617 break;
1618 }
1619
1620 $file_path = $file->getPathname();
1621 $basename = basename( $file_path );
1622 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
1623 $relative = str_replace( ABSPATH, '', $file_path );
1624
1625 // Skip excluded paths
1626 if ( $this->is_path_excluded( $file_path ) ) {
1627 continue;
1628 }
1629
1630 // 1. Check for PHP files in uploads (most important security check)
1631 if ( in_array( $extension, $dangerous_extensions, true ) ) {
1632 // Silence-is-golden placeholders are dropped by WordPress and many
1633 // plugins into upload subfolders to block directory listings.
1634 // Whitelist by content so an attacker can't bypass the rule with
1635 // a payload named index.php.
1636 if ( $this->is_silence_golden_file( $file_path ) ) {
1637 continue;
1638 }
1639
1640 $reason = __( 'PHP file found in uploads directory', 'vigilante' );
1641
1642 // Scan content for specific suspicious patterns
1643 if ( $file->getSize() < 512000 ) { // Only scan files < 500KB
1644 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
1645 $pattern = $this->detect_suspicious_pattern( $content );
1646 if ( $pattern ) {
1647 /* translators: %s: Suspicious pattern found */
1648 $reason = sprintf( __( 'PHP in uploads with suspicious code: %s', 'vigilante' ), $pattern );
1649 }
1650 }
1651
1652 $found['suspicious'][] = array(
1653 'file' => $relative,
1654 'type' => 'php_in_uploads',
1655 'reason' => $reason,
1656 );
1657 continue;
1658 }
1659
1660 // 2. Check for double extensions (image.php.jpg, file.phtml.png)
1661 if ( preg_match( '/\.(' . implode( '|', $dangerous_extensions ) . ')\.[a-z]{2,4}$/i', $basename ) ) {
1662 $found['suspicious'][] = array(
1663 'file' => $relative,
1664 'type' => 'double_extension',
1665 'reason' => __( 'Double extension detected (possible disguised executable)', 'vigilante' ),
1666 );
1667 continue;
1668 }
1669
1670 // 3. Check for .htaccess files in uploads
1671 // Read content to classify: dangerous rules = suspicious, protective rules = extra
1672 if ( '.htaccess' === $basename ) {
1673 $htaccess_result = $this->classify_htaccess_in_uploads( $file_path, $relative );
1674 $found[ $htaccess_result['category'] ][] = $htaccess_result['item'];
1675 }
1676 }
1677 } catch ( Exception $e ) {
1678 // Ignore iterator errors
1679 }
1680
1681 return $found;
1682 }
1683
1684 /**
1685 * Classify a .htaccess file found in uploads directory
1686 *
1687 * Reads the file content to determine if it contains dangerous rules
1688 * (enabling PHP execution, rewriting to executables) or protective rules
1689 * (deny access, disable indexes). Dangerous = suspicious, protective = extra.
1690 *
1691 * @param string $file_path Absolute file path.
1692 * @param string $relative Relative file path for display.
1693 * @return array Array with 'category' ('suspicious' or 'extra') and 'item' data.
1694 */
1695 private function classify_htaccess_in_uploads( $file_path, $relative ) {
1696 $content = '';
1697
1698 if ( filesize( $file_path ) < 65536 ) { // Only read files < 64KB
1699 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
1700 }
1701
1702 // If we can't read it or it's empty, treat as suspicious (unknown)
1703 if ( empty( trim( $content ) ) ) {
1704 return array(
1705 'category' => 'suspicious',
1706 'item' => array(
1707 'file' => $relative,
1708 'type' => 'htaccess_in_uploads',
1709 'reason' => __( '.htaccess file in uploads directory (empty or unreadable)', 'vigilante' ),
1710 ),
1711 );
1712 }
1713
1714 // Dangerous patterns: rules that enable code execution or rewrite to executables
1715 $dangerous_patterns = array(
1716 '/AddHandler\s+.*(php|cgi|pl|py)/i' => 'AddHandler enabling script execution',
1717 '/AddType\s+application\/x-httpd-php/i' => 'AddType enabling PHP execution',
1718 '/SetHandler\s+.*(php|cgi)/i' => 'SetHandler enabling script execution',
1719 '/php_flag\s+engine\s+on/i' => 'PHP engine enabled',
1720 '/php_admin_flag\s+engine\s+on/i' => 'PHP admin engine enabled',
1721 '/RewriteRule\s+.*\.(php|phtml|phar)/i' => 'Rewrite rule targeting PHP files',
1722 '/auto_prepend_file/i' => 'auto_prepend_file directive',
1723 '/auto_append_file/i' => 'auto_append_file directive',
1724 );
1725
1726 foreach ( $dangerous_patterns as $pattern => $label ) {
1727 if ( preg_match( $pattern, $content ) ) {
1728 return array(
1729 'category' => 'suspicious',
1730 'item' => array(
1731 'file' => $relative,
1732 'type' => 'htaccess_in_uploads',
1733 /* translators: %s: Dangerous rule description */
1734 'reason' => sprintf( __( '.htaccess with dangerous rule: %s', 'vigilante' ), $label ),
1735 ),
1736 );
1737 }
1738 }
1739
1740 // Identify what protective/benign rules it contains for informational display
1741 $found_rules = array();
1742
1743 $benign_patterns = array(
1744 '/Deny\s+from\s+all/i' => 'Deny from all',
1745 '/Require\s+all\s+denied/i' => 'Require all denied',
1746 '/Options\s+.*-Indexes/i' => 'Options -Indexes',
1747 '/Header\s+set/i' => 'Header rules',
1748 '/ExpiresActive/i' => 'Expires/cache rules',
1749 '/RewriteEngine/i' => 'Rewrite rules',
1750 '/FilesMatch/i' => 'FilesMatch rules',
1751 '/ForceType\s+application\/octet/i' => 'ForceType (force download)',
1752 );
1753
1754 foreach ( $benign_patterns as $pattern => $label ) {
1755 if ( preg_match( $pattern, $content ) ) {
1756 $found_rules[] = $label;
1757 }
1758 }
1759
1760 $rules_summary = ! empty( $found_rules )
1761 ? implode( ', ', $found_rules )
1762 : __( 'Custom rules', 'vigilante' );
1763
1764 return array(
1765 'category' => 'extra',
1766 'item' => array(
1767 'file' => $relative,
1768 'type' => 'htaccess_in_uploads',
1769 /* translators: %s: Summary of rules found in the .htaccess file */
1770 'reason' => sprintf( __( '.htaccess in uploads (likely from plugin). Contains: %s', 'vigilante' ), $rules_summary ),
1771 ),
1772 );
1773 }
1774
1775 /**
1776 * Check content for suspicious patterns
1777 *
1778 * @param string $content File content.
1779 * @return bool
1780 */
1781 private function has_suspicious_content( $content ) {
1782 return (bool) $this->detect_suspicious_pattern( $content );
1783 }
1784
1785 /**
1786 * Detect specific suspicious pattern in file content
1787 *
1788 * Two detection levels:
1789 * - Standard (strict=false): for uploads where ANY PHP is already suspicious.
1790 * Single-function matches like dangerous functions, superglobals are enough.
1791 * - Strict (strict=true): for plugins/themes without checksums where PHP is expected.
1792 * Only flags clear obfuscation combos to avoid false positives on legitimate code.
1793 *
1794 * Patterns are loaded from an external JSON file (scan-patterns.json)
1795 * with base64-encoded needles to prevent WAF/antimalware false positives
1796 * on the scanner file itself.
1797 *
1798 * @param string $content File content.
1799 * @param bool $strict Use strict mode (fewer, higher-confidence patterns).
1800 * @return string|false The pattern found, or false.
1801 */
1802 private function detect_suspicious_pattern( $content, $strict = false ) {
1803
1804 if ( $strict ) {
1805 return $this->detect_strict_suspicious_pattern( $content );
1806 }
1807
1808 $patterns_data = $this->load_scan_patterns();
1809 if ( empty( $patterns_data['standard_patterns'] ) ) {
1810 return false;
1811 }
1812
1813 // Standard mode: broad detection for uploads and known-extra files
1814 foreach ( $patterns_data['standard_patterns'] as $encoded_needle => $label ) {
1815 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding pattern definitions, not user input.
1816 $needle = base64_decode( $encoded_needle );
1817 if ( $this->needle_present( $content, $needle ) ) {
1818 return $label;
1819 }
1820 }
1821
1822 // Check for preg_replace with /e modifier (code execution)
1823 if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/e[\'"]/i', $content ) ) {
1824 return 'preg_replace /e modifier';
1825 }
1826
1827 // Check for long hex-encoded strings (obfuscated payloads)
1828 if ( preg_match( '/\\\\x[0-9a-f]{2}(\\\\x[0-9a-f]{2}){10,}/i', $content ) ) {
1829 return 'hex-encoded string';
1830 }
1831
1832 // Check for heavily concatenated chr() calls (char-by-char obfuscation)
1833 if ( preg_match( '/chr\s*\(\s*\d+\s*\)\s*\.\s*chr\s*\(\s*\d+\s*\)\s*\.\s*chr/i', $content ) ) {
1834 return 'chr() concatenation obfuscation';
1835 }
1836
1837 return false;
1838 }
1839
1840 /**
1841 * Strict suspicious pattern detection for plugins/themes without checksums
1842 *
1843 * Only flags high-confidence obfuscation combos that are almost certainly malware.
1844 * Individual functions are normal in plugins and are not flagged.
1845 *
1846 * @param string $content File content.
1847 * @return string|false The pattern found, or false.
1848 */
1849 private function detect_strict_suspicious_pattern( $content ) {
1850
1851 $patterns_data = $this->load_scan_patterns();
1852 if ( empty( $patterns_data['strict_fragments'] ) ) {
1853 return false;
1854 }
1855
1856 // Decode fragment names from JSON
1857 $fragments = array();
1858 foreach ( $patterns_data['strict_fragments'] as $key => $encoded ) {
1859 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1860 $fragments[ $key ] = base64_decode( $encoded );
1861 }
1862
1863 $ev = $fragments['ev'] ?? '';
1864 $b6 = $fragments['b6'] ?? '';
1865 $gz = $fragments['gz'] ?? '';
1866 $gu = $fragments['gu'] ?? '';
1867 $sr = $fragments['sr'] ?? '';
1868 $hb = $fragments['hb'] ?? '';
1869 $as = $fragments['as'] ?? '';
1870 $ss = $fragments['ss'] ?? '';
1871 $cf = $fragments['cf'] ?? '';
1872
1873 // Obfuscation combos: dangerous function wrapping decoded content
1874 $obfuscation_combos = array(
1875 '/' . $ev . '\s*\(\s*' . $b6 . '\s*\(/i' => $ev . '(' . $b6 . '())',
1876 '/' . $ev . '\s*\(\s*' . $gz . '\s*\(/i' => $ev . '(' . $gz . '())',
1877 '/' . $ev . '\s*\(\s*' . $gu . '\s*\(/i' => $ev . '(' . $gu . '())',
1878 '/' . $ev . '\s*\(\s*' . $sr . '\s*\(/i' => $ev . '(' . $sr . '())',
1879 '/' . $ev . '\s*\(\s*' . $hb . '\s*\(/i' => $ev . '(' . $hb . '())',
1880 '/' . $as . '\s*\(\s*' . $b6 . '\s*\(/i' => $as . '(' . $b6 . '())',
1881 '/' . $ev . '\s*\(\s*\$[a-z_]+\s*\(/i' => $ev . '($variable())',
1882 '/' . $ev . '\s*\(\s*' . $ss . '\s*\(/i' => $ev . '(' . $ss . '())',
1883 );
1884
1885 foreach ( $obfuscation_combos as $regex => $label ) {
1886 if ( preg_match( $regex, $content ) ) {
1887 return $label;
1888 }
1889 }
1890
1891 // Deprecated dynamic function constructor, nearly always malicious in modern code
1892 if ( ! empty( $cf ) && $this->needle_present( $content, $cf ) ) {
1893 return $cf . ')';
1894 }
1895
1896 // Remote fetch piped into unserialize: a PHP object-injection / supply-chain
1897 // vector seen in trojanized or nulled plugins (download a payload from a
1898 // remote URL and unserialize it). Requires a REAL unserialize() call
1899 // (not maybe_unserialize() / igbinary_unserialize(), which are common and
1900 // safe) sitting CLOSE TO a remote-fetch call. Earlier releases only
1901 // checked that both strings appeared somewhere in the file, which
1902 // false-positived on legitimate code using both in unrelated methods
1903 // (e.g. a theme reading a transient with maybe_unserialize() while
1904 // fetching its public IP with wp_remote_get()).
1905 $us = $fragments['us'] ?? '';
1906 if ( '' !== $us ) {
1907 $us_regex = '/(?<![a-z0-9_])' . preg_quote( $us, '/' ) . '\s*\(/i';
1908 $remote_fetchers = array(
1909 $fragments['wr'] ?? '', // wp_remote_get
1910 $fragments['rb'] ?? '', // wp_remote_retrieve_body
1911 $fragments['ce'] ?? '', // curl_exec
1912 );
1913 foreach ( $remote_fetchers as $rf ) {
1914 if ( '' === $rf ) {
1915 continue;
1916 }
1917 $rf_regex = '/(?<![a-z0-9_])' . preg_quote( $rf, '/' ) . '\s*\(/i';
1918 if ( $this->pattern_near( $content, $us_regex, $rf_regex, 600 ) ) {
1919 return $rf . '() + ' . $us . '() remote deserialization';
1920 }
1921 }
1922
1923 // file_get_contents() is treated separately from the fetchers
1924 // above: those are unambiguously remote, while file_get_contents
1925 // is PHP's most common LOCAL file reader, and reading a local
1926 // path right next to unserialize() is a legitimate pattern
1927 // (settings import/export, PSR-6 file caches shipped in premium
1928 // plugins, which have no wp.org checksums so this heuristic is
1929 // their only filter). It only acts as a remote fetcher when its
1930 // argument is a URL, so the combo additionally requires a
1931 // remote-scheme literal near the call before it fires.
1932 $fg = $fragments['fg'] ?? '';
1933 if ( '' !== $fg ) {
1934 $fg_regex = '/(?<![a-z0-9_])' . preg_quote( $fg, '/' ) . '\s*\(/i';
1935 $scheme_regex = '/(?:https?|ftps?):\/\/|php:\/\/input/i';
1936
1937 if ( $this->pattern_near( $content, $us_regex, $fg_regex, 600 )
1938 && $this->pattern_near( $content, $fg_regex, $scheme_regex, 600 ) ) {
1939 return $fg . '() + ' . $us . '() remote deserialization';
1940 }
1941 }
1942 }
1943
1944 // preg_replace with /e modifier (arbitrary code execution, deprecated)
1945 if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/e[\'"]/i', $content ) ) {
1946 return 'preg_replace /e modifier';
1947 }
1948
1949 // Long hex-encoded strings (obfuscated payloads)
1950 if ( preg_match( '/\\\\x[0-9a-f]{2}(\\\\x[0-9a-f]{2}){10,}/i', $content ) ) {
1951 return 'hex-encoded string';
1952 }
1953
1954 // Heavily concatenated chr() calls (char-by-char obfuscation)
1955 if ( preg_match( '/chr\s*\(\s*\d+\s*\)\s*\.\s*chr\s*\(\s*\d+\s*\)\s*\.\s*chr/i', $content ) ) {
1956 return 'chr() concatenation obfuscation';
1957 }
1958
1959 // Detect dangerous function names built from string concatenation
1960 if ( preg_match_all( '/\$([a-z_]\w*)\s*=\s*((?:["\'][a-z0-9_]*["\']\s*\.\s*)+["\'][a-z0-9_]*["\'])\s*;/i', $content, $matches, PREG_SET_ORDER ) ) {
1961 $dangerous_names = array();
1962 if ( ! empty( $patterns_data['dangerous_names'] ) ) {
1963 foreach ( $patterns_data['dangerous_names'] as $encoded_name ) {
1964 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1965 $dangerous_names[] = base64_decode( $encoded_name );
1966 }
1967 }
1968
1969 foreach ( $matches as $match ) {
1970 $combined = strtolower( preg_replace( '/["\'\s\.]/', '', $match[2] ) );
1971 if ( in_array( $combined, $dangerous_names, true ) ) {
1972 $var_pattern = '/\$' . preg_quote( $match[1], '/' ) . '\s*\(/';
1973 if ( preg_match( $var_pattern, $content ) ) {
1974 return 'obfuscated ' . $combined . '() call';
1975 }
1976 }
1977 }
1978 }
1979
1980 return false;
1981 }
1982
1983 /**
1984 * Whether a scan needle is present as a real token rather than glued inside
1985 * a longer identifier.
1986 *
1987 * Function-call needles (an identifier followed by "(") are matched with a
1988 * left word boundary, so "file_get_contents(" no longer matches
1989 * "wpcom_vip_file_get_contents(", "unserialize(" no longer matches
1990 * "maybe_unserialize(", and "eval(" no longer matches "retrieval(". Needles
1991 * that are not plain identifiers (such as the "$_GET[" superglobal probes)
1992 * keep a plain case-insensitive substring search.
1993 *
1994 * @param string $content File content.
1995 * @param string $needle Decoded needle (e.g. "eval(", "$_GET[").
1996 * @return bool
1997 */
1998 private function needle_present( $content, $needle ) {
1999 if ( '' === $needle ) {
2000 return false;
2001 }
2002 if ( preg_match( '/^[a-z_][a-z0-9_]*\($/i', $needle ) ) {
2003 $fn = rtrim( $needle, '(' );
2004 return (bool) preg_match( '/(?<![a-z0-9_])' . preg_quote( $fn, '/' ) . '\s*\(/i', $content );
2005 }
2006 return stripos( $content, $needle ) !== false;
2007 }
2008
2009 /**
2010 * Whether two patterns both occur within $window bytes of each other.
2011 *
2012 * Used to require that correlated malware signals (for example a remote
2013 * fetch and an unserialize call) sit in the same code path instead of
2014 * merely coexisting somewhere in the file, which was a false-positive
2015 * source when only their presence was checked.
2016 *
2017 * @param string $content File content.
2018 * @param string $regex_a First anchored pattern (with delimiters and flags).
2019 * @param string $regex_b Second anchored pattern (with delimiters and flags).
2020 * @param int $window Maximum byte distance between a match of each.
2021 * @return bool
2022 */
2023 private function pattern_near( $content, $regex_a, $regex_b, $window ) {
2024 if ( ! preg_match_all( $regex_a, $content, $m_a, PREG_OFFSET_CAPTURE ) ) {
2025 return false;
2026 }
2027 if ( ! preg_match_all( $regex_b, $content, $m_b, PREG_OFFSET_CAPTURE ) ) {
2028 return false;
2029 }
2030 foreach ( $m_a[0] as $a ) {
2031 foreach ( $m_b[0] as $b ) {
2032 if ( abs( $a[1] - $b[1] ) <= $window ) {
2033 return true;
2034 }
2035 }
2036 }
2037 return false;
2038 }
2039
2040 /**
2041 * Whether a file is a text file worth normalizing before the fallback hash
2042 * comparison in hash_matches_published().
2043 *
2044 * @param string $file_path Absolute path.
2045 * @return bool
2046 */
2047 private function is_text_file( $file_path ) {
2048 $text_ext = array(
2049 'php', 'php3', 'php4', 'php5', 'php7', 'phtml',
2050 'js', 'css', 'html', 'htm', 'xml', 'svg',
2051 'txt', 'md', 'json', 'po', 'pot', 'yml', 'yaml', 'ini', 'csv',
2052 );
2053 return in_array( strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ), $text_ext, true );
2054 }
2055
2056 /**
2057 * Normalize a relative path for checksum-key comparison: forward slashes,
2058 * no doubled slashes, no leading "./" or "/". Case is preserved because
2059 * plugin and theme file systems are case-sensitive on most hosts.
2060 *
2061 * @param string $path Relative path.
2062 * @return string
2063 */
2064 private function normalize_rel_path( $path ) {
2065 $path = str_replace( '\\', '/', $path );
2066 $path = preg_replace( '#/+#', '/', $path );
2067 if ( 0 === strpos( $path, './' ) ) {
2068 $path = substr( $path, 2 );
2069 }
2070 return ltrim( $path, '/' );
2071 }
2072
2073 /**
2074 * Load scan patterns from external JSON file
2075 *
2076 * Patterns are stored in a JSON file with base64-encoded values
2077 * to prevent hosting WAF/antimalware from flagging the scanner
2078 * PHP file as suspicious.
2079 *
2080 * @return array Patterns data.
2081 */
2082 private function load_scan_patterns() {
2083 static $cached = null;
2084
2085 if ( null !== $cached ) {
2086 return $cached;
2087 }
2088
2089 $file = VIGILANTE_INCLUDES_DIR . 'scan-patterns.json';
2090
2091 if ( ! file_exists( $file ) ) {
2092 $cached = array();
2093 return $cached;
2094 }
2095
2096 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read, not remote.
2097 $json = file_get_contents( $file );
2098 $cached = json_decode( $json, true );
2099
2100 if ( ! is_array( $cached ) ) {
2101 $cached = array();
2102 }
2103
2104 return $cached;
2105 }
2106
2107 /**
2108 * Detect extra files in a directory that are not in checksums
2109 * (potential backdoors injected into plugins/themes)
2110 *
2111 * When $suspicious_only is true (no checksums available), only files
2112 * with suspicious code patterns are reported. This avoids flooding
2113 * results with every PHP file from plugins/themes not on WordPress.org.
2114 *
2115 * @param string $directory Directory to scan.
2116 * @param array $checksums Known checksums from WordPress.org (empty if unavailable).
2117 * @param string $type 'plugin' or 'theme'.
2118 * @param string $name Plugin or theme name.
2119 * @param bool $suspicious_only Only report files with suspicious patterns.
2120 * @return array Array with 'suspicious' and 'extra' sub-arrays.
2121 */
2122 private function detect_extra_files( $directory, $checksums, $type, $name, $suspicious_only = false ) {
2123 $found = array(
2124 'suspicious' => array(),
2125 'extra' => array(),
2126 );
2127 $max_extra = 50; // Limit to prevent timeout on large plugins
2128 $count = 0;
2129
2130 // Only check PHP files for performance
2131 $php_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar' );
2132
2133 // Pre-normalize the checksum keys once so path-shape differences
2134 // (Windows backslashes, a leading "./", doubled slashes) don't make a
2135 // known file look "extra" and get scanned or flagged. Case is preserved.
2136 $known_normalized = array();
2137 foreach ( array_keys( $checksums ) as $known_file ) {
2138 $known_normalized[ $this->normalize_rel_path( $known_file ) ] = true;
2139 }
2140
2141 try {
2142 $iterator = new RecursiveIteratorIterator(
2143 new RecursiveDirectoryIterator( $directory, RecursiveDirectoryIterator::SKIP_DOTS ),
2144 RecursiveIteratorIterator::LEAVES_ONLY
2145 );
2146
2147 foreach ( $iterator as $file ) {
2148 if ( $this->is_time_exceeded() || $count >= $max_extra ) {
2149 break;
2150 }
2151
2152 $file_path = $file->getPathname();
2153 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
2154
2155 // Only check PHP files
2156 if ( ! in_array( $extension, $php_extensions, true ) ) {
2157 continue;
2158 }
2159
2160 // Get relative path within plugin/theme directory, normalized so
2161 // path-shape quirks don't misclassify a known file as "extra".
2162 $norm_path = str_replace( '\\', '/', $file_path );
2163 $norm_dir = str_replace( '\\', '/', $directory );
2164 $relative_to_dir = $this->normalize_rel_path( str_replace( $norm_dir . '/', '', $norm_path ) );
2165
2166 // Skip if file is in the checksums (it's known)
2167 if ( isset( $known_normalized[ $relative_to_dir ] ) ) {
2168 continue;
2169 }
2170
2171 // Skip excluded paths
2172 if ( $this->is_path_excluded( $file_path ) ) {
2173 continue;
2174 }
2175
2176 // Skip "Silence is golden" placeholder index.php files used by
2177 // WordPress core and many plugins to prevent directory listings.
2178 // The check is content-based — an attacker cannot bypass it by
2179 // simply naming a payload file index.php.
2180 if ( $this->is_silence_golden_file( $file_path ) ) {
2181 continue;
2182 }
2183
2184 $count++;
2185 $relative = str_replace( ABSPATH, '', $file_path );
2186 $pattern = false;
2187
2188 // Check for suspicious content in extra files
2189 // Use strict mode for plugins without checksums to avoid false positives
2190 if ( $file->getSize() < 512000 ) { // Only scan files < 500KB
2191 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2192 $pattern = $this->detect_suspicious_pattern( $content, $suspicious_only );
2193 }
2194
2195 $item = array(
2196 'file' => $relative,
2197 $type => $name,
2198 );
2199
2200 if ( $pattern ) {
2201 // Suspicious content: promote to suspicious category
2202 $item['type'] = 'suspicious_' . $type;
2203 /* translators: 1: Plugin or theme name, 2: Suspicious pattern found */
2204 $item['reason'] = sprintf( __( 'Injected file in %1$s with suspicious code: %2$s', 'vigilante' ), $name, $pattern );
2205 $found['suspicious'][] = $item;
2206 } elseif ( ! $suspicious_only ) {
2207 // No suspicious patterns and checksums available: report as extra
2208 // Skipped in suspicious_only mode (no checksums) to avoid noise
2209 $item['type'] = 'extra_' . $type;
2210 $item['reason'] = __( 'PHP file not present in original distribution', 'vigilante' );
2211 $found['extra'][] = $item;
2212 }
2213 }
2214 } catch ( Exception $e ) {
2215 // Ignore iterator errors
2216 }
2217
2218 return $found;
2219 }
2220
2221 /**
2222 * Whether the given file is a trivial "Silence is golden" placeholder.
2223 *
2224 * WordPress core and most plugins drop an empty or near-empty index.php
2225 * inside their directories to block directory listings on misconfigured
2226 * servers. Those files trip the extra/suspicious detector even though
2227 * they're harmless. We whitelist them by content (not by name) so an
2228 * attacker cannot bypass the rule simply by calling a payload index.php.
2229 *
2230 * @param string $file_path Absolute path to the file being scanned.
2231 * @return bool True when the file is a known harmless placeholder.
2232 */
2233 private function is_silence_golden_file( $file_path ) {
2234 if ( 'index.php' !== basename( $file_path ) ) {
2235 return false;
2236 }
2237
2238 // Cap to avoid reading large files just to check this. Real placeholders
2239 // are always tiny (< 100 bytes); anything bigger isn't one.
2240 $size = @filesize( $file_path );
2241 if ( false === $size || $size > 256 ) {
2242 return false;
2243 }
2244
2245 $content = @file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2246 if ( false === $content ) {
2247 return false;
2248 }
2249
2250 $normalized = strtolower( trim( str_replace( array( "\r\n", "\r" ), "\n", $content ) ) );
2251
2252 $known = array(
2253 '',
2254 '<?php',
2255 '<?php //silence is golden.',
2256 '<?php // silence is golden.',
2257 '<?php //silence is golden',
2258 '<?php // silence is golden',
2259 );
2260
2261 return in_array( $normalized, $known, true );
2262 }
2263
2264 /**
2265 * Check if a path is excluded from scanning
2266 *
2267 * @param string $path File path.
2268 * @return bool
2269 */
2270 private function is_path_excluded( $path ) {
2271 $excluded = $this->options['excluded_paths'] ?? array();
2272 $relative = str_replace( ABSPATH, '', $path );
2273
2274 foreach ( $excluded as $exclude ) {
2275 if ( strpos( $relative, $exclude ) !== false ) {
2276 return true;
2277 }
2278 }
2279
2280 return false;
2281 }
2282
2283 /**
2284 * Check if a file extension is excluded from scanning
2285 *
2286 * @param string $path File path.
2287 * @return bool
2288 */
2289 private function is_extension_excluded( $path ) {
2290 $excluded = $this->options['excluded_extensions'] ?? array();
2291
2292 if ( empty( $excluded ) ) {
2293 return false;
2294 }
2295
2296 $extension = '.' . strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
2297
2298 foreach ( $excluded as $exclude ) {
2299 $exclude = strtolower( trim( $exclude ) );
2300 // Support both ".log" and "log" formats
2301 if ( 0 !== strpos( $exclude, '.' ) ) {
2302 $exclude = '.' . $exclude;
2303 }
2304 if ( $exclude === $extension ) {
2305 return true;
2306 }
2307 }
2308
2309 return false;
2310 }
2311
2312 /**
2313 * Filter out ignored files from results
2314 *
2315 * @param array $items Array of scan result items.
2316 * @return array Filtered items.
2317 */
2318 private function filter_ignored( $items ) {
2319 if ( empty( $this->ignored_files ) || empty( $items ) ) {
2320 return $items;
2321 }
2322
2323 return array_values(
2324 array_filter(
2325 $items,
2326 function ( $item ) {
2327 $file = is_array( $item ) && isset( $item['file'] ) ? $item['file'] : '';
2328 return ! in_array( $file, $this->ignored_files, true );
2329 }
2330 )
2331 );
2332 }
2333
2334 /**
2335 * Send email notification based on notify_level setting
2336 *
2337 * Supports three levels:
2338 * - 'all': notify on any issues (modified + suspicious + extra)
2339 * - 'suspicious_only': notify only when suspicious or extra files found
2340 * - 'disabled': never send
2341 *
2342 * Backward compatible with old notify_on_changes boolean.
2343 *
2344 * @param array $results Scan results.
2345 */
2346 private function maybe_send_notification( $results ) {
2347 $options = is_array( $this->options ) ? $this->options : array();
2348
2349 // Count critical_config separately from regular modified so we can treat it
2350 // as "serious" for notification level purposes (same tier as suspicious/extra).
2351 $has_critical_config = false;
2352 foreach ( $results['modified'] ?? array() as $item ) {
2353 if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) {
2354 $has_critical_config = true;
2355 break;
2356 }
2357 }
2358
2359 // Collect closed/removed plugins (excluding ignored slugs). These count
2360 // as "serious" for notification purposes: a closed plugin in wp.org is
2361 // a security-critical finding, same tier as a suspicious file.
2362 $closed_plugins = $this->collect_closed_plugins_for_email();
2363 $has_closed = ! empty( $closed_plugins );
2364
2365 $has_suspicious = ! empty( $results['suspicious'] ) || ! empty( $results['extra'] ) || $has_critical_config || $has_closed;
2366 $has_modified = ! empty( $results['modified'] );
2367
2368 // Instant alert: send for suspicious, extra, critical_config, modified
2369 // files, or closed plugins.
2370 $instant_alert = ! empty( $options['instant_alert'] );
2371 if ( $instant_alert && ( $has_suspicious || $has_modified ) ) {
2372 $this->send_notification( $results, 'all', $closed_plugins );
2373 return;
2374 }
2375
2376 // Determine notify level with backward compatibility
2377 $notify_level = $options['notify_level'] ?? '';
2378
2379 // Backward compat: if notify_level not set, check old boolean
2380 if ( empty( $notify_level ) ) {
2381 if ( ! empty( $options['notify_on_changes'] ) ) {
2382 $notify_level = 'all';
2383 } else {
2384 $notify_level = 'disabled';
2385 }
2386 }
2387
2388 if ( 'disabled' === $notify_level ) {
2389 return;
2390 }
2391
2392 // 'suspicious_only' treats suspicious/extra, critical_config AND closed
2393 // plugins as serious.
2394 if ( 'suspicious_only' === $notify_level && ! $has_suspicious ) {
2395 return;
2396 }
2397
2398 if ( ! $has_suspicious && ! $has_modified ) {
2399 return;
2400 }
2401
2402 $this->send_notification( $results, $notify_level, $closed_plugins );
2403 }
2404
2405 /**
2406 * Collect the closed/removed plugins (excluding ignored slugs) so they can
2407 * be folded into the scan email digest. Returns an array keyed by slug.
2408 *
2409 * @return array
2410 */
2411 private function collect_closed_plugins_for_email() {
2412 if ( empty( $this->options['check_closed_plugins'] ) ) {
2413 return array();
2414 }
2415 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
2416 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
2417 }
2418 $checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
2419 return $checker->get_closed_plugins();
2420 }
2421
2422 /**
2423 * Merge scan results
2424 *
2425 * @param array $results1 First results.
2426 * @param array $results2 Second results.
2427 * @return array Merged results.
2428 */
2429 private function merge_results( $results1, $results2 ) {
2430 return array(
2431 'scanned' => $results1['scanned'] + ( $results2['scanned'] ?? 0 ),
2432 'ok' => $results1['ok'] + ( $results2['ok'] ?? 0 ),
2433 'modified' => array_merge( $results1['modified'], $results2['modified'] ?? array() ),
2434 'missing' => array_merge( $results1['missing'] ?? array(), $results2['missing'] ?? array() ),
2435 'suspicious' => array_merge( $results1['suspicious'] ?? array(), $results2['suspicious'] ?? array() ),
2436 'extra' => array_merge( $results1['extra'] ?? array(), $results2['extra'] ?? array() ),
2437 'new' => $results1['new'] ?? array(),
2438 'errors' => array_merge( $results1['errors'] ?? array(), $results2['errors'] ?? array() ),
2439 'scan_time' => $results1['scan_time'] ?? 0,
2440 'incomplete' => $results1['incomplete'] ?? false,
2441 );
2442 }
2443
2444 /**
2445 * Send notification email about scan results
2446 *
2447 * @param array $results Scan results.
2448 * @param string $notify_level Notification level ('all' or 'suspicious_only').
2449 * @param array $closed_plugins Optional map of slug=>state-entry for closed/removed
2450 * plugins to include as a dedicated section.
2451 */
2452 private function send_notification( $results, $notify_level = 'all', $closed_plugins = array() ) {
2453 $to = Vigilante_Email_Template::get_admin_recipients();
2454 $site_name = get_bloginfo( 'name' );
2455
2456 // Split critical_config files from regular modified so they get their own
2457 // prominent section in the email, next to suspicious/extra.
2458 $critical_config = array();
2459 $regular_modified = array();
2460 foreach ( $results['modified'] ?? array() as $item ) {
2461 if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) {
2462 // Synthesize a reason string with size + line-diff info so get_section_html shows it
2463 $baseline_size = $item['baseline_size'] ?? 0;
2464 $current_size = $item['current_size'] ?? 0;
2465 $added_count = is_array( $item['diff'] ?? null ) ? count( $item['diff']['added'] ?? array() ) : 0;
2466 $removed_count = is_array( $item['diff'] ?? null ) ? count( $item['diff']['removed'] ?? array() ) : 0;
2467 $diff_unavail = is_array( $item['diff'] ?? null ) && ! empty( $item['diff']['unavailable'] );
2468
2469 $reason = sprintf(
2470 /* translators: 1: baseline size, 2: current size */
2471 __( '%1$s → %2$s bytes', 'vigilante' ),
2472 number_format_i18n( $baseline_size ),
2473 number_format_i18n( $current_size )
2474 );
2475 if ( ! $diff_unavail ) {
2476 $reason .= sprintf( ' (+%d / -%d %s)', $added_count, $removed_count, __( 'lines', 'vigilante' ) );
2477 }
2478
2479 $item['reason'] = $reason;
2480 $critical_config[] = $item;
2481 } else {
2482 $regular_modified[] = $item;
2483 }
2484 }
2485
2486 $suspicious_count = count( $results['suspicious'] ?? array() );
2487 $extra_count = count( $results['extra'] ?? array() );
2488 $critical_config_count = count( $critical_config );
2489 $modified_count = count( $regular_modified );
2490 $closed_count = count( $closed_plugins );
2491
2492 // Use more urgent subject when suspicious files, critical config changes
2493 // or closed plugins are found (all three are security-critical).
2494 if ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 ) {
2495 $subject = sprintf(
2496 /* translators: %s: Site name */
2497 __( '[%s] SECURITY ALERT: File integrity issues detected', 'vigilante' ),
2498 $site_name
2499 );
2500 } else {
2501 $subject = sprintf(
2502 /* translators: %s: Site name */
2503 __( '[%s] File integrity issues detected', 'vigilante' ),
2504 $site_name
2505 );
2506 }
2507
2508 // Build HTML email using template wrapper
2509 $inner = '';
2510
2511 // Summary counts
2512 $inner .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom:20px;">';
2513 $inner .= '<tr>';
2514 if ( $suspicious_count > 0 ) {
2515 $inner .= $this->get_stat_cell( $suspicious_count, __( 'Suspicious', 'vigilante' ), '#d63638' );
2516 }
2517 if ( $extra_count > 0 ) {
2518 $inner .= $this->get_stat_cell( $extra_count, __( 'Extra', 'vigilante' ), '#b32d2e' );
2519 }
2520 if ( $critical_config_count > 0 ) {
2521 $inner .= $this->get_stat_cell( $critical_config_count, __( 'Critical', 'vigilante' ), '#e36210' );
2522 }
2523 if ( $closed_count > 0 ) {
2524 $inner .= $this->get_stat_cell( $closed_count, __( 'Closed', 'vigilante' ), '#d63638' );
2525 }
2526 if ( 'all' === $notify_level && $modified_count > 0 ) {
2527 $inner .= $this->get_stat_cell( $modified_count, __( 'Modified', 'vigilante' ), '#dba617' );
2528 }
2529 $inner .= $this->get_stat_cell( $results['scanned'] ?? 0, __( 'Scanned', 'vigilante' ), '#50575e' );
2530 $inner .= '</tr></table>';
2531
2532 // Suspicious files section
2533 if ( ! empty( $results['suspicious'] ) ) {
2534 $inner .= $this->get_section_html(
2535 __( 'Suspicious files', 'vigilante' ),
2536 __( 'These files may contain malicious code. Review immediately.', 'vigilante' ),
2537 $results['suspicious'],
2538 '#d63638',
2539 '#fef1f1',
2540 20,
2541 true
2542 );
2543 }
2544
2545 // Extra files section
2546 if ( ! empty( $results['extra'] ) ) {
2547 $inner .= $this->get_section_html(
2548 __( 'Extra files', 'vigilante' ),
2549 __( 'PHP files not in the original WordPress.org distribution.', 'vigilante' ),
2550 $results['extra'],
2551 '#b32d2e',
2552 '#fdf6f4',
2553 20,
2554 true
2555 );
2556 }
2557
2558 // Critical config files section (wp-config.php, .htaccess modified outside Vigilante)
2559 if ( ! empty( $critical_config ) ) {
2560 $inner .= $this->get_section_html(
2561 __( 'Critical config files modified', 'vigilante' ),
2562 __( 'These files are common targets for code injection. Review the changes and approve if they are legitimate.', 'vigilante' ),
2563 $critical_config,
2564 '#e36210',
2565 '#fdf2e6',
2566 10,
2567 true
2568 );
2569 }
2570
2571 // Modified files section (only if notify_level is 'all')
2572 if ( 'all' === $notify_level && ! empty( $regular_modified ) ) {
2573 $inner .= $this->get_section_html(
2574 __( 'Modified files', 'vigilante' ),
2575 __( 'Checksum mismatch with WordPress.org originals.', 'vigilante' ),
2576 $regular_modified,
2577 '#dba617',
2578 '#fdf8e8',
2579 15,
2580 false
2581 );
2582 }
2583
2584 // Closed + Removed plugins section.
2585 // Same tier as suspicious files: WordPress.org has flagged the plugin as
2586 // closed or removed, the site keeps running its code, and ignoring the
2587 // finding is an explicit per-slug action by the admin.
2588 if ( $closed_count > 0 ) {
2589 $inner .= $this->build_closed_plugins_email_section( $closed_plugins );
2590 }
2591
2592 // CTA button
2593 $inner .= Vigilante_Email_Template::button(
2594 admin_url( 'admin.php?page=vigilante&tab=file-integrity' ),
2595 __( 'Review in Vigilant', 'vigilante' )
2596 );
2597
2598 $is_alert = ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 );
2599 $title = $is_alert
2600 ? __( 'Security alert', 'vigilante' )
2601 : __( 'File integrity report', 'vigilante' );
2602
2603 Vigilante_Email_Template::send( $to, $subject, $title, $inner, $is_alert );
2604 }
2605
2606 /**
2607 * Build the closed + removed plugins block for the scan email.
2608 *
2609 * Reuses the same visual treatment as the suspicious files section
2610 * (red accent, danger description) because the security tier is the
2611 * same: WordPress.org has marked the plugin as compromised or removed.
2612 *
2613 * @param array $closed_plugins Map of slug=>state entry.
2614 * @return string HTML block.
2615 */
2616 private function build_closed_plugins_email_section( $closed_plugins ) {
2617 $color = '#d63638';
2618 $bg_color = '#fef1f1';
2619 $title = __( 'Closed + Removed plugins', 'vigilante' );
2620 $desc = __( 'These plugins have been closed in the WordPress.org repository. Closures usually indicate malware, security issues, guideline violations, or supply chain attacks. Uninstall and replace as soon as possible.', 'vigilante' );
2621
2622 $html = '<div style="background:' . $bg_color . ';border-left:4px solid ' . $color . ';border-radius:4px;padding:14px 16px;margin-bottom:16px;">';
2623 $html .= '<h2 style="margin:0 0 4px;font-size:14px;color:' . $color . ';">' . esc_html( $title ) . '</h2>';
2624 $html .= '<p style="margin:0 0 12px;font-size:12px;color:#50575e;">' . esc_html( $desc ) . '</p>';
2625
2626 $html .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:12px;">';
2627 foreach ( $closed_plugins as $slug => $entry ) {
2628 $name = isset( $entry['name'] ) ? $entry['name'] : $slug;
2629 $version = isset( $entry['version'] ) ? $entry['version'] : '';
2630 $state = isset( $entry['state'] ) ? $entry['state'] : '';
2631 $state_label = 'closed' === $state ? __( 'Closed', 'vigilante' ) : __( 'Removed', 'vigilante' );
2632 $closed_date = isset( $entry['closed_date'] ) ? $entry['closed_date'] : '';
2633 $reason = isset( $entry['closed_reason_text'] ) && '' !== $entry['closed_reason_text']
2634 ? $entry['closed_reason_text']
2635 : '';
2636
2637 $detail_bits = array();
2638 $detail_bits[] = $state_label;
2639 if ( '' !== $closed_date ) {
2640 $detail_bits[] = esc_html( $closed_date );
2641 }
2642 if ( '' !== $version ) {
2643 $detail_bits[] = 'v' . esc_html( $version );
2644 }
2645
2646 $html .= '<tr>';
2647 $html .= '<td style="padding:4px 0;color:#1d2327;font-family:Consolas,Monaco,monospace;font-size:11px;word-break:break-all;">';
2648 $html .= '<strong>' . esc_html( $name ) . '</strong> &middot; <a href="' . esc_url( 'https://wordpress.org/plugins/' . $slug . '/' ) . '" style="color:#2271b1;text-decoration:none;"><code>' . esc_html( $slug ) . '</code></a>';
2649 $html .= '</td></tr>';
2650 $html .= '<tr><td style="padding:0 0 4px 12px;color:#787c82;font-size:11px;">' . esc_html( implode( ' &middot; ', array_map( 'wp_strip_all_tags', $detail_bits ) ) ) . '</td></tr>';
2651 if ( '' !== $reason ) {
2652 $html .= '<tr><td style="padding:0 0 8px 12px;color:#787c82;font-size:11px;font-style:italic;">' . esc_html( $reason ) . '</td></tr>';
2653 }
2654 }
2655 $html .= '</table>';
2656 $html .= '</div>';
2657
2658 return $html;
2659 }
2660
2661 /**
2662 * Get a summary stat cell for email
2663 *
2664 * @param int $count Stat count.
2665 * @param string $label Stat label.
2666 * @param string $color Color hex.
2667 * @return string HTML table cell.
2668 */
2669 private function get_stat_cell( $count, $label, $color ) {
2670 $html = '<td style="text-align:center;padding:12px 8px;">';
2671 $html .= '<div style="font-size:24px;font-weight:700;color:' . $color . ';line-height:1.2;">' . (int) $count . '</div>';
2672 $html .= '<div style="font-size:11px;color:#50575e;text-transform:uppercase;letter-spacing:0.5px;">' . esc_html( $label ) . '</div>';
2673 $html .= '</td>';
2674
2675 return $html;
2676 }
2677
2678 /**
2679 * Get an HTML section for file list in email
2680 *
2681 * @param string $title Section title.
2682 * @param string $description Section description.
2683 * @param array $files Array of file items.
2684 * @param string $color Accent color.
2685 * @param string $bg_color Background color.
2686 * @param int $max Max files to show.
2687 * @param bool $show_reason Whether to show reason column.
2688 * @return string HTML.
2689 */
2690 private function get_section_html( $title, $description, $files, $color, $bg_color, $max, $show_reason ) {
2691 $total = count( $files );
2692 $shown = array_slice( $files, 0, $max );
2693
2694 $html = '<div style="background:' . $bg_color . ';border-left:4px solid ' . $color . ';border-radius:4px;padding:14px 16px;margin-bottom:16px;">';
2695 $html .= '<h2 style="margin:0 0 4px;font-size:14px;color:' . $color . ';">' . esc_html( $title ) . '</h2>';
2696 $html .= '<p style="margin:0 0 12px;font-size:12px;color:#50575e;">' . esc_html( $description ) . '</p>';
2697
2698 $html .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:12px;">';
2699 foreach ( $shown as $file ) {
2700 $file_path = is_array( $file ) ? ( $file['file'] ?? '' ) : (string) $file;
2701 $reason = is_array( $file ) ? ( $file['reason'] ?? '' ) : '';
2702
2703 $html .= '<tr>';
2704 $html .= '<td style="padding:4px 0;color:#1d2327;font-family:Consolas,Monaco,monospace;font-size:11px;word-break:break-all;">' . esc_html( $file_path ) . '</td>';
2705 $html .= '</tr>';
2706
2707 if ( $show_reason && ! empty( $reason ) ) {
2708 $html .= '<tr>';
2709 $html .= '<td style="padding:0 0 8px 12px;color:#787c82;font-size:11px;font-style:italic;">' . esc_html( $reason ) . '</td>';
2710 $html .= '</tr>';
2711 }
2712 }
2713 $html .= '</table>';
2714
2715 if ( $total > $max ) {
2716 $html .= '<p style="margin:8px 0 0;font-size:12px;color:#787c82;">';
2717 /* translators: %d: Number of additional files */
2718 $html .= sprintf( esc_html__( '... and %d more', 'vigilante' ), $total - $max );
2719 $html .= '</p>';
2720 }
2721
2722 $html .= '</div>';
2723
2724 return $html;
2725 }
2726
2727 /**
2728 * Add a file to the ignored list
2729 *
2730 * @param string $file_path Relative file path to ignore.
2731 * @return bool
2732 */
2733 public function ignore_file( $file_path ) {
2734 $ignored = get_option( 'vigilante_ignored_files', array() );
2735
2736 if ( ! in_array( $file_path, $ignored, true ) ) {
2737 $ignored[] = sanitize_text_field( $file_path );
2738 return update_option( 'vigilante_ignored_files', $ignored );
2739 }
2740
2741 return true;
2742 }
2743
2744 /**
2745 * Remove a file from the ignored list
2746 *
2747 * @param string $file_path Relative file path to stop ignoring.
2748 * @return bool
2749 */
2750 public function unignore_file( $file_path ) {
2751 $ignored = get_option( 'vigilante_ignored_files', array() );
2752 $ignored = array_values( array_diff( $ignored, array( $file_path ) ) );
2753
2754 return update_option( 'vigilante_ignored_files', $ignored );
2755 }
2756
2757 /**
2758 * Get the list of ignored files
2759 *
2760 * @return array
2761 */
2762 public function get_ignored_files() {
2763 return get_option( 'vigilante_ignored_files', array() );
2764 }
2765
2766 /**
2767 * Clear all ignored files
2768 *
2769 * @return bool
2770 */
2771 public function clear_ignored_files() {
2772 return delete_option( 'vigilante_ignored_files' );
2773 }
2774
2775 /**
2776 * Get last scan results
2777 *
2778 * @return array|false
2779 */
2780 public function get_last_scan_results() {
2781 return get_option( 'vigilante_last_integrity_results', false );
2782 }
2783
2784 /**
2785 * Get last scan time
2786 *
2787 * @return int|false
2788 */
2789 public function get_last_scan_time() {
2790 return get_option( 'vigilante_last_integrity_scan', false );
2791 }
2792
2793 /**
2794 * Clear stored hashes
2795 *
2796 * @return bool
2797 */
2798 public function clear_hashes() {
2799 return $this->database->clear_file_hashes();
2800 }
2801 }