PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.7
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.7
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.7, at includes/class-file-integrity.php

2,813 lines 109.6 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 // Skip translations that travel inside the localized core ZIP but do
675 // not belong to core. WordPress.org's localized checksum manifest
676 // lists Akismet and the default themes' language files (8 entries on
677 // every non-en_US locale, none on en_US), yet they are updated on the
678 // plugin and theme cycle and are absent from the core language pack.
679 // Deleting an unused plugin or theme, which this plugin's own audit
680 // recommends, otherwise left permanent "missing core file" findings.
681 if ( 0 === strpos( $file, 'wp-content/languages/plugins/' )
682 || 0 === strpos( $file, 'wp-content/languages/themes/' ) ) {
683 continue;
684 }
685
686 $results['scanned']++;
687
688 if ( ! file_exists( $file_path ) ) {
689 $results['missing'][] = array(
690 'file' => $file,
691 'type' => 'core',
692 );
693 continue;
694 }
695
696 $actual_hash = md5_file( $file_path );
697
698 if ( $actual_hash !== $expected_hash ) {
699 $results['modified'][] = array(
700 'file' => $file,
701 'type' => 'core',
702 'expected_hash' => $expected_hash,
703 'actual_hash' => $actual_hash,
704 );
705 } else {
706 $results['ok']++;
707 }
708 }
709
710 return $results;
711 }
712
713 /**
714 * Scan WordPress root directory for non-core files
715 *
716 * Compares files in ABSPATH (non-recursive) against the official core
717 * checksums list. PHP files not in the core distribution are flagged as
718 * suspicious (common attack vector: info.php, shell.php, backdoors).
719 * Non-PHP files not in the known safe list are flagged as extra/additional.
720 * Dotfiles and known safe files (robots.txt, etc.) are skipped.
721 *
722 * @return array Array with 'suspicious' and 'extra' sub-arrays.
723 */
724 private function scan_root_files() {
725 $found = array(
726 'suspicious' => array(),
727 'extra' => array(),
728 );
729
730 // Get core checksums to know which root files are legitimate
731 $checksums = $this->get_core_checksums();
732 if ( is_wp_error( $checksums ) ) {
733 return $found;
734 }
735
736 // Build list of known core root files from checksums (only root-level, no directory prefix)
737 $core_root_files = array();
738 foreach ( array_keys( $checksums ) as $file ) {
739 // Only root-level files (no directory separator)
740 if ( false === strpos( $file, '/' ) ) {
741 $core_root_files[] = $file;
742 }
743 }
744
745 // Also add wp-config.php which is not in checksums but is core
746 $core_root_files[] = 'wp-config.php';
747
748 $php_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'phps' );
749
750 // Scan only direct children of ABSPATH (not recursive)
751 $root_path = untrailingslashit( ABSPATH );
752 $handle = opendir( $root_path );
753
754 if ( ! $handle ) {
755 return $found;
756 }
757
758 while ( false !== ( $entry = readdir( $handle ) ) ) {
759 if ( $this->is_time_exceeded() ) {
760 break;
761 }
762
763 // Skip . and ..
764 if ( '.' === $entry || '..' === $entry ) {
765 continue;
766 }
767
768 // Skip dotfiles (.htaccess, .user.ini, .env, etc.) — handled by firewall protection
769 if ( 0 === strpos( $entry, '.' ) ) {
770 continue;
771 }
772
773 $full_path = $root_path . '/' . $entry;
774
775 // Skip directories — we only care about files in root
776 if ( is_dir( $full_path ) ) {
777 continue;
778 }
779
780 // Skip if this is a known core file
781 if ( in_array( $entry, $core_root_files, true ) ) {
782 continue;
783 }
784
785 // Skip excluded paths
786 if ( $this->is_path_excluded( $full_path ) ) {
787 continue;
788 }
789
790 // Skip known safe non-PHP root files
791 if ( in_array( strtolower( $entry ), $this->known_safe_root_files, true ) ) {
792 continue;
793 }
794
795 $extension = strtolower( pathinfo( $entry, PATHINFO_EXTENSION ) );
796
797 if ( in_array( $extension, $php_extensions, true ) ) {
798 // Check if this is a legacy WordPress core file (removed from newer versions)
799 if ( in_array( $entry, $this->legacy_core_root_files, true ) ) {
800 $found['extra'][] = array(
801 'file' => $entry,
802 'type' => 'legacy_core',
803 'reason' => __( 'Legacy WordPress core file, removed in newer versions. Safe to delete.', 'vigilante' ),
804 );
805 continue;
806 }
807
808 // Silence-is-golden placeholders dropped here by some setups
809 // (e.g. WordPress installed in a subdirectory, or third-party tooling).
810 if ( $this->is_silence_golden_file( $full_path ) ) {
811 continue;
812 }
813
814 // PHP file not in core = suspicious
815 $reason = __( 'Non-core PHP file in WordPress root directory', 'vigilante' );
816
817 // Scan content for specific patterns
818 if ( filesize( $full_path ) < 512000 ) {
819 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
820 $pattern = $this->detect_suspicious_pattern( $content );
821 if ( $pattern ) {
822 /* translators: %s: Suspicious pattern found */
823 $reason = sprintf( __( 'Non-core PHP in root with suspicious code: %s', 'vigilante' ), $pattern );
824 }
825 }
826
827 $found['suspicious'][] = array(
828 'file' => $entry,
829 'type' => 'php_in_root',
830 'reason' => $reason,
831 );
832 } else {
833 // Non-PHP, non-known-safe file = additional (informational)
834 $found['extra'][] = array(
835 'file' => $entry,
836 'type' => 'extra_root',
837 'reason' => __( 'Non-core file in WordPress root directory', 'vigilante' ),
838 );
839 }
840 }
841
842 closedir( $handle );
843
844 return $found;
845 }
846
847 // =========================================================================
848 // Critical config file baseline monitoring (wp-config.php, .htaccess)
849 // =========================================================================
850
851 /**
852 * Scan critical root files against stored baseline hashes
853 *
854 * Files like wp-config.php and .htaccess have no official WordPress.org
855 * checksum because their content is unique per installation. We maintain
856 * our own baseline hash and alert when the file changes outside of
857 * Vigilante's own modifications.
858 *
859 * On the first scan (no baseline stored yet) the baseline is created
860 * silently — there is nothing to compare against.
861 *
862 * @return array Array of modified file entries (same format as core modified).
863 */
864 private function scan_critical_root_files() {
865 $modified = array();
866 $baseline = $this->get_critical_files_baseline();
867 $baseline_changed = false;
868 $root_path = untrailingslashit( ABSPATH );
869
870 foreach ( $this->critical_root_files as $filename ) {
871 $full_path = $root_path . '/' . $filename;
872
873 if ( ! file_exists( $full_path ) ) {
874 continue;
875 }
876
877 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
878 if ( false === $content ) {
879 continue;
880 }
881
882 $normalized = $this->normalize_critical_file( $filename, $content );
883 $current_hash = md5( $normalized );
884
885 if ( ! isset( $baseline[ $filename ] ) ) {
886 // First time seeing this file — store baseline silently
887 $baseline[ $filename ] = array(
888 'hash' => $current_hash,
889 'size' => strlen( $content ),
890 'content' => $normalized,
891 'updated' => time(),
892 );
893 $baseline_changed = true;
894 continue;
895 }
896
897 // Upgrade legacy baseline entries that lack content (pre-diff format)
898 if ( ! isset( $baseline[ $filename ]['content'] ) && $baseline[ $filename ]['hash'] === $current_hash ) {
899 $baseline[ $filename ]['content'] = $normalized;
900 $baseline_changed = true;
901 continue;
902 }
903
904 // Compare against stored baseline
905 if ( $baseline[ $filename ]['hash'] !== $current_hash ) {
906 $baseline_content = $baseline[ $filename ]['content'] ?? '';
907 $diff = '' !== $baseline_content
908 ? $this->compute_simple_diff( $baseline_content, $normalized )
909 : array( 'added' => array(), 'removed' => array(), 'unavailable' => true );
910
911 $modified[] = array(
912 'file' => $filename,
913 'type' => 'critical_config',
914 'expected_hash' => $baseline[ $filename ]['hash'],
915 'actual_hash' => $current_hash,
916 'baseline_size' => $baseline[ $filename ]['size'],
917 'current_size' => strlen( $content ),
918 'diff' => $diff,
919 );
920 }
921 }
922
923 if ( $baseline_changed ) {
924 update_option( self::BASELINE_OPTION, $baseline, false );
925 }
926
927 return $modified;
928 }
929
930 /**
931 * Compute a simple line-based diff between two strings
932 *
933 * Returns added and removed lines with their original line numbers.
934 * Order is preserved. Uses a simple "line present in set" approach
935 * which works well for config files where most lines are unique.
936 *
937 * @param string $old Baseline content.
938 * @param string $new Current content.
939 * @return array Array with 'added' and 'removed' line entries.
940 */
941 private function compute_simple_diff( $old, $new ) {
942 $old_lines = explode( "\n", $old );
943 $new_lines = explode( "\n", $new );
944
945 // Use hash sets for O(1) lookup. Use array_flip for cheap existence check.
946 $old_set = array_count_values( $old_lines );
947 $new_set = array_count_values( $new_lines );
948
949 $removed = array();
950 foreach ( $old_lines as $i => $line ) {
951 // Line only considered removed if baseline has more occurrences than current
952 if ( ! isset( $new_set[ $line ] ) || $new_set[ $line ] < ( $old_set[ $line ] ?? 0 ) ) {
953 $removed[] = array(
954 'line' => $i + 1,
955 'content' => $line,
956 );
957 // Decrement to handle duplicates correctly
958 if ( isset( $old_set[ $line ] ) ) {
959 $old_set[ $line ]--;
960 }
961 }
962 }
963
964 // Reset for added detection
965 $old_set = array_count_values( $old_lines );
966 $added = array();
967 foreach ( $new_lines as $i => $line ) {
968 if ( ! isset( $old_set[ $line ] ) || $old_set[ $line ] < ( $new_set[ $line ] ?? 0 ) ) {
969 $added[] = array(
970 'line' => $i + 1,
971 'content' => $line,
972 );
973 if ( isset( $new_set[ $line ] ) ) {
974 $new_set[ $line ]--;
975 }
976 }
977 }
978
979 return array(
980 'added' => $added,
981 'removed' => $removed,
982 'unavailable' => false,
983 );
984 }
985
986 /**
987 * Normalize critical file content by removing Vigilante-managed blocks
988 *
989 * This ensures that changes made by Vigilante itself (security constants,
990 * htaccess rules) do not trigger false-positive modification alerts.
991 * Line endings are normalized to LF to prevent false positives from
992 * editors that change CRLF/LF.
993 *
994 * @param string $filename File name (e.g. 'wp-config.php').
995 * @param string $content Raw file content.
996 * @return string Normalized content for hashing.
997 */
998 private function normalize_critical_file( $filename, $content ) {
999 // Normalize line endings first (CRLF and CR to LF)
1000 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
1001
1002 if ( 'wp-config.php' === $filename ) {
1003 // Remove Vigilante constants blocks (current and legacy)
1004 foreach ( $this->wpconfig_markers as $markers ) {
1005 $pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '\s*/s';
1006 $content = preg_replace( $pattern, '', $content );
1007 }
1008
1009 // Remove lines commented out by Vigilante (original constants)
1010 $content = preg_replace(
1011 '/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m',
1012 '',
1013 $content
1014 );
1015 } elseif ( '.htaccess' === $filename ) {
1016 // Remove Vigilante htaccess blocks (firewall + security headers)
1017 foreach ( $this->htaccess_markers as $markers ) {
1018 $pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '\s*/s';
1019 $content = preg_replace( $pattern, '', $content );
1020 }
1021 }
1022
1023 // Collapse multiple blank lines into one (blocks removal leaves gaps)
1024 $content = preg_replace( '/\n{3,}/', "\n\n", $content );
1025
1026 return trim( $content );
1027 }
1028
1029 /**
1030 * Get stored baseline hashes for critical files
1031 *
1032 * @return array Associative array keyed by filename.
1033 */
1034 public function get_critical_files_baseline() {
1035 $baseline = get_option( self::BASELINE_OPTION, array() );
1036 return is_array( $baseline ) ? $baseline : array();
1037 }
1038
1039 /**
1040 * Update baseline hash for a single critical file
1041 *
1042 * Called by wp-config and htaccess writers after Vigilante modifies
1043 * the file, so the next scan does not flag the change as suspicious.
1044 *
1045 * @param string $filename File name relative to ABSPATH (e.g. 'wp-config.php').
1046 * @return bool True on success.
1047 */
1048 public function update_critical_file_baseline( $filename ) {
1049 $full_path = untrailingslashit( ABSPATH ) . '/' . $filename;
1050
1051 if ( ! file_exists( $full_path ) ) {
1052 return false;
1053 }
1054
1055 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
1056 if ( false === $content ) {
1057 return false;
1058 }
1059
1060 $normalized = $this->normalize_critical_file( $filename, $content );
1061
1062 $baseline = $this->get_critical_files_baseline();
1063 $baseline[ $filename ] = array(
1064 'hash' => md5( $normalized ),
1065 'size' => strlen( $content ),
1066 'content' => $normalized,
1067 'updated' => time(),
1068 );
1069
1070 return update_option( self::BASELINE_OPTION, $baseline, false );
1071 }
1072
1073 /**
1074 * Regenerate baseline for all critical files
1075 *
1076 * Used by the admin UI button and the 1.14.0 migration.
1077 *
1078 * @return array Updated baseline data.
1079 */
1080 public function regenerate_all_baselines() {
1081 $baseline = array();
1082 $root_path = untrailingslashit( ABSPATH );
1083
1084 foreach ( $this->critical_root_files as $filename ) {
1085 $full_path = $root_path . '/' . $filename;
1086
1087 if ( ! file_exists( $full_path ) ) {
1088 continue;
1089 }
1090
1091 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
1092 if ( false === $content ) {
1093 continue;
1094 }
1095
1096 $normalized = $this->normalize_critical_file( $filename, $content );
1097 $baseline[ $filename ] = array(
1098 'hash' => md5( $normalized ),
1099 'size' => strlen( $content ),
1100 'content' => $normalized,
1101 'updated' => time(),
1102 );
1103 }
1104
1105 update_option( self::BASELINE_OPTION, $baseline, false );
1106
1107 return $baseline;
1108 }
1109
1110 /**
1111 * Get core checksums from WordPress.org API
1112 *
1113 * @return array|WP_Error Checksums or error.
1114 */
1115 private function get_core_checksums() {
1116 $locale = get_locale();
1117 $version = $this->wp_version;
1118
1119 // Check cache first
1120 $cache_key = 'vigilante_core_checksums_' . md5( $version . $locale );
1121 $cached = get_transient( $cache_key );
1122 if ( false !== $cached ) {
1123 return $cached;
1124 }
1125
1126 // Fetch from WordPress.org
1127 $url = sprintf(
1128 'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=%s',
1129 $version,
1130 $locale
1131 );
1132
1133 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
1134
1135 if ( is_wp_error( $response ) ) {
1136 return $response;
1137 }
1138
1139 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1140
1141 if ( empty( $body['checksums'] ) ) {
1142 return new WP_Error( 'no_checksums', __( 'Could not retrieve WordPress core checksums', 'vigilante' ) );
1143 }
1144
1145 $checksums = $body['checksums'];
1146
1147 // Handle nested format: checksums keyed under version string (WP 6.9+)
1148 if ( isset( $checksums[ $version ] ) && is_array( $checksums[ $version ] ) ) {
1149 $checksums = $checksums[ $version ];
1150 }
1151
1152 // Cache for 24 hours
1153 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
1154
1155 return $checksums;
1156 }
1157
1158 /**
1159 * Scan plugins for modifications
1160 *
1161 * @return array Scan results.
1162 */
1163 private function scan_plugins() {
1164 $results = array(
1165 'scanned' => 0,
1166 'ok' => 0,
1167 'modified' => array(),
1168 'suspicious' => array(),
1169 'extra' => array(),
1170 'errors' => array(),
1171 );
1172
1173 // Get all installed plugins
1174 if ( ! function_exists( 'get_plugins' ) ) {
1175 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1176 }
1177
1178 $plugins = get_plugins();
1179
1180 foreach ( $plugins as $plugin_file => $plugin_data ) {
1181 // Check time limit
1182 if ( $this->is_time_exceeded() ) {
1183 break;
1184 }
1185
1186 $plugin_slug = dirname( $plugin_file );
1187
1188 // Skip single-file plugins
1189 if ( '.' === $plugin_slug ) {
1190 continue;
1191 }
1192
1193 // Skip slugs in their post-update grace window: wp.org may still be
1194 // publishing the new version's checksums, so a scheduled scan here
1195 // would raise benign "modified/extra" noise. The dedicated post-update
1196 // verifier (vigilante_fi_postupdate_verify) handles these instead.
1197 if ( $this->in_post_update_grace( 'plugin', $plugin_slug ) ) {
1198 continue;
1199 }
1200
1201 // Get checksums from WordPress.org
1202 $version = $plugin_data['Version'] ?? '';
1203 $checksums = $this->get_plugin_checksums( $plugin_slug, $version );
1204
1205 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
1206
1207 $plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug;
1208
1209 // Check known files against checksums (only if available)
1210 if ( $has_checksums ) {
1211 foreach ( $checksums as $file => $expected_hash ) {
1212 // Check time limit inside inner loop too
1213 if ( $this->is_time_exceeded() ) {
1214 break 2; // Break both loops
1215 }
1216
1217 $file_path = $plugin_dir . '/' . $file;
1218
1219 // Skip excluded paths
1220 if ( $this->is_path_excluded( $file_path ) ) {
1221 continue;
1222 }
1223
1224 // Skip excluded extensions
1225 if ( $this->is_extension_excluded( $file_path ) ) {
1226 continue;
1227 }
1228
1229 // Skip known false positives (e.g. readme.txt, readme.md)
1230 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
1231 continue;
1232 }
1233
1234 $results['scanned']++;
1235
1236 if ( ! file_exists( $file_path ) ) {
1237 continue; // Some files might not be installed
1238 }
1239
1240 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
1241 $results['modified'][] = array(
1242 'file' => 'plugins/' . $plugin_slug . '/' . $file,
1243 'type' => 'plugin',
1244 'plugin' => $plugin_data['Name'],
1245 'expected_hash' => $this->expected_hash_label( $expected_hash ),
1246 'actual_hash' => md5_file( $file_path ),
1247 );
1248 } else {
1249 $results['ok']++;
1250 }
1251 }
1252 } // end if $has_checksums
1253
1254 // Detect extra/suspicious files
1255 // With checksums: finds files not in the original distribution
1256 // Without checksums: scans ALL plugin files but only flags suspicious patterns
1257 if ( ! $this->is_time_exceeded() ) {
1258 $known_files = $has_checksums ? $checksums : array();
1259 $suspicious_only = ! $has_checksums; // Without checksums, only report files with suspicious code
1260 $extra_results = $this->detect_extra_files( $plugin_dir, $known_files, 'plugin', $plugin_data['Name'], $suspicious_only );
1261 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
1262 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
1263 }
1264 }
1265
1266 return $results;
1267 }
1268
1269 /**
1270 * Whether a file on disk matches a hash WordPress.org publishes for it.
1271 *
1272 * The wp.org checksums JSON gives, per file, an md5 (and usually a sha256)
1273 * that may be a single string OR an array of strings: a file whose content
1274 * differs across the re-tagged zips that one checksums file covers (e.g. a
1275 * release that shares its checksums file with a beta) gets every valid hash
1276 * listed as an array. The old `$actual !== $expected` comparison evaluated a
1277 * 32-char string against an array as unequal unconditionally, so those files
1278 * were always reported as "modified" even when the on-disk hash was one of
1279 * the published ones. Match against membership, and accept either md5 or
1280 * sha256, so the comparison is correct and strictly stronger than md5-only.
1281 *
1282 * Robust to both shapes: the new record array( 'md5' => ..., 'sha256' => ... )
1283 * and a legacy cached value (a bare md5 string or array), so a transient
1284 * cached by an older version still compares correctly until it expires.
1285 *
1286 * @param string $file_path Absolute path to the file on disk.
1287 * @param array|string $expected Record array, or a legacy md5 string|array.
1288 * @return bool True when the file matches a published hash.
1289 */
1290 private function hash_matches_published( $file_path, $expected ) {
1291 $md5 = $expected;
1292 $sha = null;
1293 if ( is_array( $expected ) && ( array_key_exists( 'md5', $expected ) || array_key_exists( 'sha256', $expected ) ) ) {
1294 $md5 = isset( $expected['md5'] ) ? $expected['md5'] : null;
1295 $sha = isset( $expected['sha256'] ) ? $expected['sha256'] : null;
1296 }
1297
1298 if ( null !== $md5 && in_array( md5_file( $file_path ), (array) $md5, true ) ) {
1299 return true;
1300 }
1301 if ( ! empty( $sha ) && in_array( hash_file( 'sha256', $file_path ), (array) $sha, true ) ) {
1302 return true;
1303 }
1304
1305 // Fallback: some hosts and deploy pipelines rewrite text files on disk
1306 // (prepend a UTF-8 BOM, or convert LF line endings to CRLF) without
1307 // changing a single line of code. That alters the raw bytes, so the
1308 // md5/sha256 stops matching WordPress.org even though the file is
1309 // intact, which surfaced as false "modified file" alerts. Retry the
1310 // comparison against a normalized copy (BOM stripped, CRLF/CR collapsed
1311 // to LF) for text files only, so a genuine code change is still caught.
1312 if ( is_string( $file_path ) && '' !== $file_path && $this->is_text_file( $file_path ) && is_readable( $file_path ) ) {
1313 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read for hashing, not remote.
1314 $content = file_get_contents( $file_path );
1315 if ( false !== $content ) {
1316 if ( "\xEF\xBB\xBF" === substr( $content, 0, 3 ) ) {
1317 $content = substr( $content, 3 );
1318 }
1319 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
1320 if ( null !== $md5 && in_array( md5( $content ), (array) $md5, true ) ) {
1321 return true;
1322 }
1323 if ( ! empty( $sha ) && in_array( hash( 'sha256', $content ), (array) $sha, true ) ) {
1324 return true;
1325 }
1326 }
1327 }
1328
1329 return false;
1330 }
1331
1332 /**
1333 * Human-readable expected-hash value for a modified-file result record.
1334 *
1335 * The published md5 may be a string or an array; flatten it for storage.
1336 *
1337 * @param array|string $expected Record array or legacy md5 string|array.
1338 * @return string
1339 */
1340 private function expected_hash_label( $expected ) {
1341 $md5 = ( is_array( $expected ) && array_key_exists( 'md5', $expected ) ) ? $expected['md5'] : $expected;
1342 if ( is_array( $md5 ) ) {
1343 return implode( ', ', array_map( 'strval', $md5 ) );
1344 }
1345 return (string) $md5;
1346 }
1347
1348 /**
1349 * Whether a plugin/theme slug is inside its post-update grace window.
1350 *
1351 * Set by on_upgrade_complete() right after WordPress finishes updating a
1352 * plugin or theme. During the window the checksum cache is bypassed (so a
1353 * stale manifest cached during wp.org's propagation lag is never reused) and
1354 * the scheduled scan skips the slug (the dedicated post-update verifier
1355 * handles it instead), which is what stops the "files don't match
1356 * WordPress.org" false positives right after an update.
1357 *
1358 * @param string $type 'plugin' or 'theme'.
1359 * @param string $slug Slug.
1360 * @return bool
1361 */
1362 private function in_post_update_grace( $type, $slug ) {
1363 return (bool) get_transient( 'vigilante_fi_grace_' . $type . '_' . md5( $slug ) );
1364 }
1365
1366 /**
1367 * Get plugin checksums from WordPress.org
1368 *
1369 * @param string $slug Plugin slug.
1370 * @param string $version Plugin version.
1371 * @return array|WP_Error|string
1372 */
1373 private function get_plugin_checksums( $slug, $version ) {
1374 $cache_key = 'vigilante_plugin_checksums_' . md5( $slug . $version );
1375
1376 // During the post-update grace window, bypass the cache entirely so a
1377 // manifest cached while wp.org was still propagating the new version's
1378 // checksums can never be reused. Fetch fresh and do not write it back.
1379 $grace = $this->in_post_update_grace( 'plugin', $slug );
1380
1381 if ( ! $grace ) {
1382 $cached = get_transient( $cache_key );
1383 if ( false !== $cached ) {
1384 return $cached;
1385 }
1386 }
1387
1388 $url = sprintf(
1389 'https://downloads.wordpress.org/plugin-checksums/%s/%s.json',
1390 $slug,
1391 $version
1392 );
1393
1394 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
1395
1396 if ( is_wp_error( $response ) ) {
1397 return $response;
1398 }
1399
1400 $status = wp_remote_retrieve_response_code( $response );
1401 if ( 200 !== $status ) {
1402 // Cache "not found" to avoid repeated requests (never during grace).
1403 if ( ! $grace ) {
1404 set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
1405 }
1406 return 'not_found';
1407 }
1408
1409 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1410
1411 if ( empty( $body['files'] ) ) {
1412 return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
1413 }
1414
1415 // Store the full per-file record (md5 + sha256). Either value can be a
1416 // string or an array of strings; hash_matches_published() handles both.
1417 $checksums = array();
1418 foreach ( $body['files'] as $file => $data ) {
1419 $checksums[ $file ] = array(
1420 'md5' => isset( $data['md5'] ) ? $data['md5'] : null,
1421 'sha256' => isset( $data['sha256'] ) ? $data['sha256'] : null,
1422 );
1423 }
1424
1425 // Cache for 24 hours (never during grace, to avoid persisting a manifest
1426 // wp.org may still be regenerating).
1427 if ( ! $grace ) {
1428 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
1429 }
1430
1431 return $checksums;
1432 }
1433
1434 /**
1435 * Scan themes for modifications
1436 *
1437 * @return array Scan results.
1438 */
1439 private function scan_themes() {
1440 $results = array(
1441 'scanned' => 0,
1442 'ok' => 0,
1443 'modified' => array(),
1444 'suspicious' => array(),
1445 'extra' => array(),
1446 'errors' => array(),
1447 );
1448
1449 $themes = wp_get_themes();
1450
1451 foreach ( $themes as $theme_slug => $theme ) {
1452 // Check time limit
1453 if ( $this->is_time_exceeded() ) {
1454 break;
1455 }
1456
1457 // Skip slugs in their post-update grace window (see scan_plugins()).
1458 if ( $this->in_post_update_grace( 'theme', $theme_slug ) ) {
1459 continue;
1460 }
1461
1462 $version = $theme->get( 'Version' );
1463 $checksums = $this->get_theme_checksums( $theme_slug, $version );
1464
1465 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
1466
1467 $theme_dir = $theme->get_stylesheet_directory();
1468
1469 // Check known files against checksums (only if available)
1470 if ( $has_checksums ) {
1471 foreach ( $checksums as $file => $expected_hash ) {
1472 // Check time limit inside inner loop too
1473 if ( $this->is_time_exceeded() ) {
1474 break 2; // Break both loops
1475 }
1476
1477 $file_path = $theme_dir . '/' . $file;
1478
1479 // Skip excluded paths
1480 if ( $this->is_path_excluded( $file_path ) ) {
1481 continue;
1482 }
1483
1484 // Skip excluded extensions
1485 if ( $this->is_extension_excluded( $file_path ) ) {
1486 continue;
1487 }
1488
1489 // Skip known false positives (e.g. readme.txt, readme.md)
1490 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
1491 continue;
1492 }
1493
1494 $results['scanned']++;
1495
1496 if ( ! file_exists( $file_path ) ) {
1497 continue;
1498 }
1499
1500 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
1501 $results['modified'][] = array(
1502 'file' => 'themes/' . $theme_slug . '/' . $file,
1503 'type' => 'theme',
1504 'theme' => $theme->get( 'Name' ),
1505 'expected_hash' => $this->expected_hash_label( $expected_hash ),
1506 'actual_hash' => md5_file( $file_path ),
1507 );
1508 } else {
1509 $results['ok']++;
1510 }
1511 }
1512 } // end if $has_checksums
1513
1514 // Detect extra/suspicious files
1515 if ( ! $this->is_time_exceeded() ) {
1516 $known_files = $has_checksums ? $checksums : array();
1517 $suspicious_only = ! $has_checksums;
1518 $extra_results = $this->detect_extra_files( $theme_dir, $known_files, 'theme', $theme->get( 'Name' ), $suspicious_only );
1519 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
1520 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
1521 }
1522 }
1523
1524 return $results;
1525 }
1526
1527 /**
1528 * Get theme checksums from WordPress.org
1529 *
1530 * @param string $slug Theme slug.
1531 * @param string $version Theme version.
1532 * @return array|WP_Error
1533 */
1534 private function get_theme_checksums( $slug, $version ) {
1535 $cache_key = 'vigilante_theme_checksums_' . md5( $slug . $version );
1536
1537 // Bypass the cache during the post-update grace window (see plugin path).
1538 $grace = $this->in_post_update_grace( 'theme', $slug );
1539
1540 if ( ! $grace ) {
1541 $cached = get_transient( $cache_key );
1542 if ( false !== $cached ) {
1543 return $cached;
1544 }
1545 }
1546
1547 $url = sprintf(
1548 'https://downloads.wordpress.org/theme-checksums/%s/%s.json',
1549 $slug,
1550 $version
1551 );
1552
1553 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
1554
1555 if ( is_wp_error( $response ) ) {
1556 return $response;
1557 }
1558
1559 $status = wp_remote_retrieve_response_code( $response );
1560 if ( 200 !== $status ) {
1561 // Cache "not found" to avoid repeated requests (never during grace).
1562 if ( ! $grace ) {
1563 set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
1564 }
1565 return new WP_Error( 'not_found', __( 'Checksums not available', 'vigilante' ) );
1566 }
1567
1568 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1569
1570 if ( empty( $body['files'] ) ) {
1571 return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
1572 }
1573
1574 // Store the full per-file record (md5 + sha256), either of which may be a
1575 // string or an array; hash_matches_published() handles both shapes.
1576 $checksums = array();
1577 foreach ( $body['files'] as $file => $data ) {
1578 $checksums[ $file ] = array(
1579 'md5' => isset( $data['md5'] ) ? $data['md5'] : null,
1580 'sha256' => isset( $data['sha256'] ) ? $data['sha256'] : null,
1581 );
1582 }
1583
1584 // Cache for 24 hours (never during grace).
1585 if ( ! $grace ) {
1586 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
1587 }
1588
1589 return $checksums;
1590 }
1591
1592 /**
1593 * Scan uploads directory for suspicious files
1594 *
1595 * @return array Array with 'suspicious' and 'extra' sub-arrays.
1596 */
1597 private function scan_uploads() {
1598 $found = array(
1599 'suspicious' => array(),
1600 'extra' => array(),
1601 );
1602 $upload_dir = wp_upload_dir();
1603 $base_dir = $upload_dir['basedir'];
1604 $max_files = 10000; // Increased limit for thorough scanning
1605 $files_checked = 0;
1606
1607 // Executable extensions that should never be in uploads
1608 $dangerous_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'phps' );
1609
1610 if ( ! is_dir( $base_dir ) ) {
1611 return $found;
1612 }
1613
1614 try {
1615 $iterator = new RecursiveIteratorIterator(
1616 new RecursiveDirectoryIterator( $base_dir, RecursiveDirectoryIterator::SKIP_DOTS ),
1617 RecursiveIteratorIterator::LEAVES_ONLY
1618 );
1619
1620 foreach ( $iterator as $file ) {
1621 // Check global time limit
1622 if ( $this->is_time_exceeded() ) {
1623 break;
1624 }
1625
1626 // Check file limit
1627 $files_checked++;
1628 if ( $files_checked > $max_files ) {
1629 break;
1630 }
1631
1632 $file_path = $file->getPathname();
1633 $basename = basename( $file_path );
1634 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
1635 $relative = str_replace( ABSPATH, '', $file_path );
1636
1637 // Skip excluded paths
1638 if ( $this->is_path_excluded( $file_path ) ) {
1639 continue;
1640 }
1641
1642 // 1. Check for PHP files in uploads (most important security check)
1643 if ( in_array( $extension, $dangerous_extensions, true ) ) {
1644 // Silence-is-golden placeholders are dropped by WordPress and many
1645 // plugins into upload subfolders to block directory listings.
1646 // Whitelist by content so an attacker can't bypass the rule with
1647 // a payload named index.php.
1648 if ( $this->is_silence_golden_file( $file_path ) ) {
1649 continue;
1650 }
1651
1652 $reason = __( 'PHP file found in uploads directory', 'vigilante' );
1653
1654 // Scan content for specific suspicious patterns
1655 if ( $file->getSize() < 512000 ) { // Only scan files < 500KB
1656 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
1657 $pattern = $this->detect_suspicious_pattern( $content );
1658 if ( $pattern ) {
1659 /* translators: %s: Suspicious pattern found */
1660 $reason = sprintf( __( 'PHP in uploads with suspicious code: %s', 'vigilante' ), $pattern );
1661 }
1662 }
1663
1664 $found['suspicious'][] = array(
1665 'file' => $relative,
1666 'type' => 'php_in_uploads',
1667 'reason' => $reason,
1668 );
1669 continue;
1670 }
1671
1672 // 2. Check for double extensions (image.php.jpg, file.phtml.png)
1673 if ( preg_match( '/\.(' . implode( '|', $dangerous_extensions ) . ')\.[a-z]{2,4}$/i', $basename ) ) {
1674 $found['suspicious'][] = array(
1675 'file' => $relative,
1676 'type' => 'double_extension',
1677 'reason' => __( 'Double extension detected (possible disguised executable)', 'vigilante' ),
1678 );
1679 continue;
1680 }
1681
1682 // 3. Check for .htaccess files in uploads
1683 // Read content to classify: dangerous rules = suspicious, protective rules = extra
1684 if ( '.htaccess' === $basename ) {
1685 $htaccess_result = $this->classify_htaccess_in_uploads( $file_path, $relative );
1686 $found[ $htaccess_result['category'] ][] = $htaccess_result['item'];
1687 }
1688 }
1689 } catch ( Exception $e ) {
1690 // Ignore iterator errors
1691 }
1692
1693 return $found;
1694 }
1695
1696 /**
1697 * Classify a .htaccess file found in uploads directory
1698 *
1699 * Reads the file content to determine if it contains dangerous rules
1700 * (enabling PHP execution, rewriting to executables) or protective rules
1701 * (deny access, disable indexes). Dangerous = suspicious, protective = extra.
1702 *
1703 * @param string $file_path Absolute file path.
1704 * @param string $relative Relative file path for display.
1705 * @return array Array with 'category' ('suspicious' or 'extra') and 'item' data.
1706 */
1707 private function classify_htaccess_in_uploads( $file_path, $relative ) {
1708 $content = '';
1709
1710 if ( filesize( $file_path ) < 65536 ) { // Only read files < 64KB
1711 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
1712 }
1713
1714 // If we can't read it or it's empty, treat as suspicious (unknown)
1715 if ( empty( trim( $content ) ) ) {
1716 return array(
1717 'category' => 'suspicious',
1718 'item' => array(
1719 'file' => $relative,
1720 'type' => 'htaccess_in_uploads',
1721 'reason' => __( '.htaccess file in uploads directory (empty or unreadable)', 'vigilante' ),
1722 ),
1723 );
1724 }
1725
1726 // Dangerous patterns: rules that enable code execution or rewrite to executables
1727 $dangerous_patterns = array(
1728 '/AddHandler\s+.*(php|cgi|pl|py)/i' => 'AddHandler enabling script execution',
1729 '/AddType\s+application\/x-httpd-php/i' => 'AddType enabling PHP execution',
1730 '/SetHandler\s+.*(php|cgi)/i' => 'SetHandler enabling script execution',
1731 '/php_flag\s+engine\s+on/i' => 'PHP engine enabled',
1732 '/php_admin_flag\s+engine\s+on/i' => 'PHP admin engine enabled',
1733 '/RewriteRule\s+.*\.(php|phtml|phar)/i' => 'Rewrite rule targeting PHP files',
1734 '/auto_prepend_file/i' => 'auto_prepend_file directive',
1735 '/auto_append_file/i' => 'auto_append_file directive',
1736 );
1737
1738 foreach ( $dangerous_patterns as $pattern => $label ) {
1739 if ( preg_match( $pattern, $content ) ) {
1740 return array(
1741 'category' => 'suspicious',
1742 'item' => array(
1743 'file' => $relative,
1744 'type' => 'htaccess_in_uploads',
1745 /* translators: %s: Dangerous rule description */
1746 'reason' => sprintf( __( '.htaccess with dangerous rule: %s', 'vigilante' ), $label ),
1747 ),
1748 );
1749 }
1750 }
1751
1752 // Identify what protective/benign rules it contains for informational display
1753 $found_rules = array();
1754
1755 $benign_patterns = array(
1756 '/Deny\s+from\s+all/i' => 'Deny from all',
1757 '/Require\s+all\s+denied/i' => 'Require all denied',
1758 '/Options\s+.*-Indexes/i' => 'Options -Indexes',
1759 '/Header\s+set/i' => 'Header rules',
1760 '/ExpiresActive/i' => 'Expires/cache rules',
1761 '/RewriteEngine/i' => 'Rewrite rules',
1762 '/FilesMatch/i' => 'FilesMatch rules',
1763 '/ForceType\s+application\/octet/i' => 'ForceType (force download)',
1764 );
1765
1766 foreach ( $benign_patterns as $pattern => $label ) {
1767 if ( preg_match( $pattern, $content ) ) {
1768 $found_rules[] = $label;
1769 }
1770 }
1771
1772 $rules_summary = ! empty( $found_rules )
1773 ? implode( ', ', $found_rules )
1774 : __( 'Custom rules', 'vigilante' );
1775
1776 return array(
1777 'category' => 'extra',
1778 'item' => array(
1779 'file' => $relative,
1780 'type' => 'htaccess_in_uploads',
1781 /* translators: %s: Summary of rules found in the .htaccess file */
1782 'reason' => sprintf( __( '.htaccess in uploads (likely from plugin). Contains: %s', 'vigilante' ), $rules_summary ),
1783 ),
1784 );
1785 }
1786
1787 /**
1788 * Check content for suspicious patterns
1789 *
1790 * @param string $content File content.
1791 * @return bool
1792 */
1793 private function has_suspicious_content( $content ) {
1794 return (bool) $this->detect_suspicious_pattern( $content );
1795 }
1796
1797 /**
1798 * Detect specific suspicious pattern in file content
1799 *
1800 * Two detection levels:
1801 * - Standard (strict=false): for uploads where ANY PHP is already suspicious.
1802 * Single-function matches like dangerous functions, superglobals are enough.
1803 * - Strict (strict=true): for plugins/themes without checksums where PHP is expected.
1804 * Only flags clear obfuscation combos to avoid false positives on legitimate code.
1805 *
1806 * Patterns are loaded from an external JSON file (scan-patterns.json)
1807 * with base64-encoded needles to prevent WAF/antimalware false positives
1808 * on the scanner file itself.
1809 *
1810 * @param string $content File content.
1811 * @param bool $strict Use strict mode (fewer, higher-confidence patterns).
1812 * @return string|false The pattern found, or false.
1813 */
1814 private function detect_suspicious_pattern( $content, $strict = false ) {
1815
1816 if ( $strict ) {
1817 return $this->detect_strict_suspicious_pattern( $content );
1818 }
1819
1820 $patterns_data = $this->load_scan_patterns();
1821 if ( empty( $patterns_data['standard_patterns'] ) ) {
1822 return false;
1823 }
1824
1825 // Standard mode: broad detection for uploads and known-extra files
1826 foreach ( $patterns_data['standard_patterns'] as $encoded_needle => $label ) {
1827 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding pattern definitions, not user input.
1828 $needle = base64_decode( $encoded_needle );
1829 if ( $this->needle_present( $content, $needle ) ) {
1830 return $label;
1831 }
1832 }
1833
1834 // Check for preg_replace with /e modifier (code execution)
1835 if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/e[\'"]/i', $content ) ) {
1836 return 'preg_replace /e modifier';
1837 }
1838
1839 // Check for long hex-encoded strings (obfuscated payloads)
1840 if ( preg_match( '/\\\\x[0-9a-f]{2}(\\\\x[0-9a-f]{2}){10,}/i', $content ) ) {
1841 return 'hex-encoded string';
1842 }
1843
1844 // Check for heavily concatenated chr() calls (char-by-char obfuscation)
1845 if ( preg_match( '/chr\s*\(\s*\d+\s*\)\s*\.\s*chr\s*\(\s*\d+\s*\)\s*\.\s*chr/i', $content ) ) {
1846 return 'chr() concatenation obfuscation';
1847 }
1848
1849 return false;
1850 }
1851
1852 /**
1853 * Strict suspicious pattern detection for plugins/themes without checksums
1854 *
1855 * Only flags high-confidence obfuscation combos that are almost certainly malware.
1856 * Individual functions are normal in plugins and are not flagged.
1857 *
1858 * @param string $content File content.
1859 * @return string|false The pattern found, or false.
1860 */
1861 private function detect_strict_suspicious_pattern( $content ) {
1862
1863 $patterns_data = $this->load_scan_patterns();
1864 if ( empty( $patterns_data['strict_fragments'] ) ) {
1865 return false;
1866 }
1867
1868 // Decode fragment names from JSON
1869 $fragments = array();
1870 foreach ( $patterns_data['strict_fragments'] as $key => $encoded ) {
1871 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1872 $fragments[ $key ] = base64_decode( $encoded );
1873 }
1874
1875 $ev = $fragments['ev'] ?? '';
1876 $b6 = $fragments['b6'] ?? '';
1877 $gz = $fragments['gz'] ?? '';
1878 $gu = $fragments['gu'] ?? '';
1879 $sr = $fragments['sr'] ?? '';
1880 $hb = $fragments['hb'] ?? '';
1881 $as = $fragments['as'] ?? '';
1882 $ss = $fragments['ss'] ?? '';
1883 $cf = $fragments['cf'] ?? '';
1884
1885 // Obfuscation combos: dangerous function wrapping decoded content
1886 $obfuscation_combos = array(
1887 '/' . $ev . '\s*\(\s*' . $b6 . '\s*\(/i' => $ev . '(' . $b6 . '())',
1888 '/' . $ev . '\s*\(\s*' . $gz . '\s*\(/i' => $ev . '(' . $gz . '())',
1889 '/' . $ev . '\s*\(\s*' . $gu . '\s*\(/i' => $ev . '(' . $gu . '())',
1890 '/' . $ev . '\s*\(\s*' . $sr . '\s*\(/i' => $ev . '(' . $sr . '())',
1891 '/' . $ev . '\s*\(\s*' . $hb . '\s*\(/i' => $ev . '(' . $hb . '())',
1892 '/' . $as . '\s*\(\s*' . $b6 . '\s*\(/i' => $as . '(' . $b6 . '())',
1893 '/' . $ev . '\s*\(\s*\$[a-z_]+\s*\(/i' => $ev . '($variable())',
1894 '/' . $ev . '\s*\(\s*' . $ss . '\s*\(/i' => $ev . '(' . $ss . '())',
1895 );
1896
1897 foreach ( $obfuscation_combos as $regex => $label ) {
1898 if ( preg_match( $regex, $content ) ) {
1899 return $label;
1900 }
1901 }
1902
1903 // Deprecated dynamic function constructor, nearly always malicious in modern code
1904 if ( ! empty( $cf ) && $this->needle_present( $content, $cf ) ) {
1905 return $cf . ')';
1906 }
1907
1908 // Remote fetch piped into unserialize: a PHP object-injection / supply-chain
1909 // vector seen in trojanized or nulled plugins (download a payload from a
1910 // remote URL and unserialize it). Requires a REAL unserialize() call
1911 // (not maybe_unserialize() / igbinary_unserialize(), which are common and
1912 // safe) sitting CLOSE TO a remote-fetch call. Earlier releases only
1913 // checked that both strings appeared somewhere in the file, which
1914 // false-positived on legitimate code using both in unrelated methods
1915 // (e.g. a theme reading a transient with maybe_unserialize() while
1916 // fetching its public IP with wp_remote_get()).
1917 $us = $fragments['us'] ?? '';
1918 if ( '' !== $us ) {
1919 $us_regex = '/(?<![a-z0-9_])' . preg_quote( $us, '/' ) . '\s*\(/i';
1920 $remote_fetchers = array(
1921 $fragments['wr'] ?? '', // wp_remote_get
1922 $fragments['rb'] ?? '', // wp_remote_retrieve_body
1923 $fragments['ce'] ?? '', // curl_exec
1924 );
1925 foreach ( $remote_fetchers as $rf ) {
1926 if ( '' === $rf ) {
1927 continue;
1928 }
1929 $rf_regex = '/(?<![a-z0-9_])' . preg_quote( $rf, '/' ) . '\s*\(/i';
1930 if ( $this->pattern_near( $content, $us_regex, $rf_regex, 600 ) ) {
1931 return $rf . '() + ' . $us . '() remote deserialization';
1932 }
1933 }
1934
1935 // file_get_contents() is treated separately from the fetchers
1936 // above: those are unambiguously remote, while file_get_contents
1937 // is PHP's most common LOCAL file reader, and reading a local
1938 // path right next to unserialize() is a legitimate pattern
1939 // (settings import/export, PSR-6 file caches shipped in premium
1940 // plugins, which have no wp.org checksums so this heuristic is
1941 // their only filter). It only acts as a remote fetcher when its
1942 // argument is a URL, so the combo additionally requires a
1943 // remote-scheme literal near the call before it fires.
1944 $fg = $fragments['fg'] ?? '';
1945 if ( '' !== $fg ) {
1946 $fg_regex = '/(?<![a-z0-9_])' . preg_quote( $fg, '/' ) . '\s*\(/i';
1947 $scheme_regex = '/(?:https?|ftps?):\/\/|php:\/\/input/i';
1948
1949 if ( $this->pattern_near( $content, $us_regex, $fg_regex, 600 )
1950 && $this->pattern_near( $content, $fg_regex, $scheme_regex, 600 ) ) {
1951 return $fg . '() + ' . $us . '() remote deserialization';
1952 }
1953 }
1954 }
1955
1956 // preg_replace with /e modifier (arbitrary code execution, deprecated)
1957 if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/e[\'"]/i', $content ) ) {
1958 return 'preg_replace /e modifier';
1959 }
1960
1961 // Long hex-encoded strings (obfuscated payloads)
1962 if ( preg_match( '/\\\\x[0-9a-f]{2}(\\\\x[0-9a-f]{2}){10,}/i', $content ) ) {
1963 return 'hex-encoded string';
1964 }
1965
1966 // Heavily concatenated chr() calls (char-by-char obfuscation)
1967 if ( preg_match( '/chr\s*\(\s*\d+\s*\)\s*\.\s*chr\s*\(\s*\d+\s*\)\s*\.\s*chr/i', $content ) ) {
1968 return 'chr() concatenation obfuscation';
1969 }
1970
1971 // Detect dangerous function names built from string concatenation
1972 if ( preg_match_all( '/\$([a-z_]\w*)\s*=\s*((?:["\'][a-z0-9_]*["\']\s*\.\s*)+["\'][a-z0-9_]*["\'])\s*;/i', $content, $matches, PREG_SET_ORDER ) ) {
1973 $dangerous_names = array();
1974 if ( ! empty( $patterns_data['dangerous_names'] ) ) {
1975 foreach ( $patterns_data['dangerous_names'] as $encoded_name ) {
1976 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1977 $dangerous_names[] = base64_decode( $encoded_name );
1978 }
1979 }
1980
1981 foreach ( $matches as $match ) {
1982 $combined = strtolower( preg_replace( '/["\'\s\.]/', '', $match[2] ) );
1983 if ( in_array( $combined, $dangerous_names, true ) ) {
1984 $var_pattern = '/\$' . preg_quote( $match[1], '/' ) . '\s*\(/';
1985 if ( preg_match( $var_pattern, $content ) ) {
1986 return 'obfuscated ' . $combined . '() call';
1987 }
1988 }
1989 }
1990 }
1991
1992 return false;
1993 }
1994
1995 /**
1996 * Whether a scan needle is present as a real token rather than glued inside
1997 * a longer identifier.
1998 *
1999 * Function-call needles (an identifier followed by "(") are matched with a
2000 * left word boundary, so "file_get_contents(" no longer matches
2001 * "wpcom_vip_file_get_contents(", "unserialize(" no longer matches
2002 * "maybe_unserialize(", and "eval(" no longer matches "retrieval(". Needles
2003 * that are not plain identifiers (such as the "$_GET[" superglobal probes)
2004 * keep a plain case-insensitive substring search.
2005 *
2006 * @param string $content File content.
2007 * @param string $needle Decoded needle (e.g. "eval(", "$_GET[").
2008 * @return bool
2009 */
2010 private function needle_present( $content, $needle ) {
2011 if ( '' === $needle ) {
2012 return false;
2013 }
2014 if ( preg_match( '/^[a-z_][a-z0-9_]*\($/i', $needle ) ) {
2015 $fn = rtrim( $needle, '(' );
2016 return (bool) preg_match( '/(?<![a-z0-9_])' . preg_quote( $fn, '/' ) . '\s*\(/i', $content );
2017 }
2018 return stripos( $content, $needle ) !== false;
2019 }
2020
2021 /**
2022 * Whether two patterns both occur within $window bytes of each other.
2023 *
2024 * Used to require that correlated malware signals (for example a remote
2025 * fetch and an unserialize call) sit in the same code path instead of
2026 * merely coexisting somewhere in the file, which was a false-positive
2027 * source when only their presence was checked.
2028 *
2029 * @param string $content File content.
2030 * @param string $regex_a First anchored pattern (with delimiters and flags).
2031 * @param string $regex_b Second anchored pattern (with delimiters and flags).
2032 * @param int $window Maximum byte distance between a match of each.
2033 * @return bool
2034 */
2035 private function pattern_near( $content, $regex_a, $regex_b, $window ) {
2036 if ( ! preg_match_all( $regex_a, $content, $m_a, PREG_OFFSET_CAPTURE ) ) {
2037 return false;
2038 }
2039 if ( ! preg_match_all( $regex_b, $content, $m_b, PREG_OFFSET_CAPTURE ) ) {
2040 return false;
2041 }
2042 foreach ( $m_a[0] as $a ) {
2043 foreach ( $m_b[0] as $b ) {
2044 if ( abs( $a[1] - $b[1] ) <= $window ) {
2045 return true;
2046 }
2047 }
2048 }
2049 return false;
2050 }
2051
2052 /**
2053 * Whether a file is a text file worth normalizing before the fallback hash
2054 * comparison in hash_matches_published().
2055 *
2056 * @param string $file_path Absolute path.
2057 * @return bool
2058 */
2059 private function is_text_file( $file_path ) {
2060 $text_ext = array(
2061 'php', 'php3', 'php4', 'php5', 'php7', 'phtml',
2062 'js', 'css', 'html', 'htm', 'xml', 'svg',
2063 'txt', 'md', 'json', 'po', 'pot', 'yml', 'yaml', 'ini', 'csv',
2064 );
2065 return in_array( strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ), $text_ext, true );
2066 }
2067
2068 /**
2069 * Normalize a relative path for checksum-key comparison: forward slashes,
2070 * no doubled slashes, no leading "./" or "/". Case is preserved because
2071 * plugin and theme file systems are case-sensitive on most hosts.
2072 *
2073 * @param string $path Relative path.
2074 * @return string
2075 */
2076 private function normalize_rel_path( $path ) {
2077 $path = str_replace( '\\', '/', $path );
2078 $path = preg_replace( '#/+#', '/', $path );
2079 if ( 0 === strpos( $path, './' ) ) {
2080 $path = substr( $path, 2 );
2081 }
2082 return ltrim( $path, '/' );
2083 }
2084
2085 /**
2086 * Load scan patterns from external JSON file
2087 *
2088 * Patterns are stored in a JSON file with base64-encoded values
2089 * to prevent hosting WAF/antimalware from flagging the scanner
2090 * PHP file as suspicious.
2091 *
2092 * @return array Patterns data.
2093 */
2094 private function load_scan_patterns() {
2095 static $cached = null;
2096
2097 if ( null !== $cached ) {
2098 return $cached;
2099 }
2100
2101 $file = VIGILANTE_INCLUDES_DIR . 'scan-patterns.json';
2102
2103 if ( ! file_exists( $file ) ) {
2104 $cached = array();
2105 return $cached;
2106 }
2107
2108 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read, not remote.
2109 $json = file_get_contents( $file );
2110 $cached = json_decode( $json, true );
2111
2112 if ( ! is_array( $cached ) ) {
2113 $cached = array();
2114 }
2115
2116 return $cached;
2117 }
2118
2119 /**
2120 * Detect extra files in a directory that are not in checksums
2121 * (potential backdoors injected into plugins/themes)
2122 *
2123 * When $suspicious_only is true (no checksums available), only files
2124 * with suspicious code patterns are reported. This avoids flooding
2125 * results with every PHP file from plugins/themes not on WordPress.org.
2126 *
2127 * @param string $directory Directory to scan.
2128 * @param array $checksums Known checksums from WordPress.org (empty if unavailable).
2129 * @param string $type 'plugin' or 'theme'.
2130 * @param string $name Plugin or theme name.
2131 * @param bool $suspicious_only Only report files with suspicious patterns.
2132 * @return array Array with 'suspicious' and 'extra' sub-arrays.
2133 */
2134 private function detect_extra_files( $directory, $checksums, $type, $name, $suspicious_only = false ) {
2135 $found = array(
2136 'suspicious' => array(),
2137 'extra' => array(),
2138 );
2139 $max_extra = 50; // Limit to prevent timeout on large plugins
2140 $count = 0;
2141
2142 // Only check PHP files for performance
2143 $php_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar' );
2144
2145 // Pre-normalize the checksum keys once so path-shape differences
2146 // (Windows backslashes, a leading "./", doubled slashes) don't make a
2147 // known file look "extra" and get scanned or flagged. Case is preserved.
2148 $known_normalized = array();
2149 foreach ( array_keys( $checksums ) as $known_file ) {
2150 $known_normalized[ $this->normalize_rel_path( $known_file ) ] = true;
2151 }
2152
2153 try {
2154 $iterator = new RecursiveIteratorIterator(
2155 new RecursiveDirectoryIterator( $directory, RecursiveDirectoryIterator::SKIP_DOTS ),
2156 RecursiveIteratorIterator::LEAVES_ONLY
2157 );
2158
2159 foreach ( $iterator as $file ) {
2160 if ( $this->is_time_exceeded() || $count >= $max_extra ) {
2161 break;
2162 }
2163
2164 $file_path = $file->getPathname();
2165 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
2166
2167 // Only check PHP files
2168 if ( ! in_array( $extension, $php_extensions, true ) ) {
2169 continue;
2170 }
2171
2172 // Get relative path within plugin/theme directory, normalized so
2173 // path-shape quirks don't misclassify a known file as "extra".
2174 $norm_path = str_replace( '\\', '/', $file_path );
2175 $norm_dir = str_replace( '\\', '/', $directory );
2176 $relative_to_dir = $this->normalize_rel_path( str_replace( $norm_dir . '/', '', $norm_path ) );
2177
2178 // Skip if file is in the checksums (it's known)
2179 if ( isset( $known_normalized[ $relative_to_dir ] ) ) {
2180 continue;
2181 }
2182
2183 // Skip excluded paths
2184 if ( $this->is_path_excluded( $file_path ) ) {
2185 continue;
2186 }
2187
2188 // Skip "Silence is golden" placeholder index.php files used by
2189 // WordPress core and many plugins to prevent directory listings.
2190 // The check is content-based — an attacker cannot bypass it by
2191 // simply naming a payload file index.php.
2192 if ( $this->is_silence_golden_file( $file_path ) ) {
2193 continue;
2194 }
2195
2196 $count++;
2197 $relative = str_replace( ABSPATH, '', $file_path );
2198 $pattern = false;
2199
2200 // Check for suspicious content in extra files
2201 // Use strict mode for plugins without checksums to avoid false positives
2202 if ( $file->getSize() < 512000 ) { // Only scan files < 500KB
2203 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2204 $pattern = $this->detect_suspicious_pattern( $content, $suspicious_only );
2205 }
2206
2207 $item = array(
2208 'file' => $relative,
2209 $type => $name,
2210 );
2211
2212 if ( $pattern ) {
2213 // Suspicious content: promote to suspicious category
2214 $item['type'] = 'suspicious_' . $type;
2215 /* translators: 1: Plugin or theme name, 2: Suspicious pattern found */
2216 $item['reason'] = sprintf( __( 'Injected file in %1$s with suspicious code: %2$s', 'vigilante' ), $name, $pattern );
2217 $found['suspicious'][] = $item;
2218 } elseif ( ! $suspicious_only ) {
2219 // No suspicious patterns and checksums available: report as extra
2220 // Skipped in suspicious_only mode (no checksums) to avoid noise
2221 $item['type'] = 'extra_' . $type;
2222 $item['reason'] = __( 'PHP file not present in original distribution', 'vigilante' );
2223 $found['extra'][] = $item;
2224 }
2225 }
2226 } catch ( Exception $e ) {
2227 // Ignore iterator errors
2228 }
2229
2230 return $found;
2231 }
2232
2233 /**
2234 * Whether the given file is a trivial "Silence is golden" placeholder.
2235 *
2236 * WordPress core and most plugins drop an empty or near-empty index.php
2237 * inside their directories to block directory listings on misconfigured
2238 * servers. Those files trip the extra/suspicious detector even though
2239 * they're harmless. We whitelist them by content (not by name) so an
2240 * attacker cannot bypass the rule simply by calling a payload index.php.
2241 *
2242 * @param string $file_path Absolute path to the file being scanned.
2243 * @return bool True when the file is a known harmless placeholder.
2244 */
2245 private function is_silence_golden_file( $file_path ) {
2246 if ( 'index.php' !== basename( $file_path ) ) {
2247 return false;
2248 }
2249
2250 // Cap to avoid reading large files just to check this. Real placeholders
2251 // are always tiny (< 100 bytes); anything bigger isn't one.
2252 $size = @filesize( $file_path );
2253 if ( false === $size || $size > 256 ) {
2254 return false;
2255 }
2256
2257 $content = @file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2258 if ( false === $content ) {
2259 return false;
2260 }
2261
2262 $normalized = strtolower( trim( str_replace( array( "\r\n", "\r" ), "\n", $content ) ) );
2263
2264 $known = array(
2265 '',
2266 '<?php',
2267 '<?php //silence is golden.',
2268 '<?php // silence is golden.',
2269 '<?php //silence is golden',
2270 '<?php // silence is golden',
2271 );
2272
2273 return in_array( $normalized, $known, true );
2274 }
2275
2276 /**
2277 * Check if a path is excluded from scanning
2278 *
2279 * @param string $path File path.
2280 * @return bool
2281 */
2282 private function is_path_excluded( $path ) {
2283 $excluded = $this->options['excluded_paths'] ?? array();
2284 $relative = str_replace( ABSPATH, '', $path );
2285
2286 foreach ( $excluded as $exclude ) {
2287 if ( strpos( $relative, $exclude ) !== false ) {
2288 return true;
2289 }
2290 }
2291
2292 return false;
2293 }
2294
2295 /**
2296 * Check if a file extension is excluded from scanning
2297 *
2298 * @param string $path File path.
2299 * @return bool
2300 */
2301 private function is_extension_excluded( $path ) {
2302 $excluded = $this->options['excluded_extensions'] ?? array();
2303
2304 if ( empty( $excluded ) ) {
2305 return false;
2306 }
2307
2308 $extension = '.' . strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
2309
2310 foreach ( $excluded as $exclude ) {
2311 $exclude = strtolower( trim( $exclude ) );
2312 // Support both ".log" and "log" formats
2313 if ( 0 !== strpos( $exclude, '.' ) ) {
2314 $exclude = '.' . $exclude;
2315 }
2316 if ( $exclude === $extension ) {
2317 return true;
2318 }
2319 }
2320
2321 return false;
2322 }
2323
2324 /**
2325 * Filter out ignored files from results
2326 *
2327 * @param array $items Array of scan result items.
2328 * @return array Filtered items.
2329 */
2330 private function filter_ignored( $items ) {
2331 if ( empty( $this->ignored_files ) || empty( $items ) ) {
2332 return $items;
2333 }
2334
2335 return array_values(
2336 array_filter(
2337 $items,
2338 function ( $item ) {
2339 $file = is_array( $item ) && isset( $item['file'] ) ? $item['file'] : '';
2340 return ! in_array( $file, $this->ignored_files, true );
2341 }
2342 )
2343 );
2344 }
2345
2346 /**
2347 * Send email notification based on notify_level setting
2348 *
2349 * Supports three levels:
2350 * - 'all': notify on any issues (modified + suspicious + extra)
2351 * - 'suspicious_only': notify only when suspicious or extra files found
2352 * - 'disabled': never send
2353 *
2354 * Backward compatible with old notify_on_changes boolean.
2355 *
2356 * @param array $results Scan results.
2357 */
2358 private function maybe_send_notification( $results ) {
2359 $options = is_array( $this->options ) ? $this->options : array();
2360
2361 // Count critical_config separately from regular modified so we can treat it
2362 // as "serious" for notification level purposes (same tier as suspicious/extra).
2363 $has_critical_config = false;
2364 foreach ( $results['modified'] ?? array() as $item ) {
2365 if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) {
2366 $has_critical_config = true;
2367 break;
2368 }
2369 }
2370
2371 // Collect closed/removed plugins (excluding ignored slugs). These count
2372 // as "serious" for notification purposes: a closed plugin in wp.org is
2373 // a security-critical finding, same tier as a suspicious file.
2374 $closed_plugins = $this->collect_closed_plugins_for_email();
2375 $has_closed = ! empty( $closed_plugins );
2376
2377 $has_suspicious = ! empty( $results['suspicious'] ) || ! empty( $results['extra'] ) || $has_critical_config || $has_closed;
2378 $has_modified = ! empty( $results['modified'] );
2379
2380 // Instant alert: send for suspicious, extra, critical_config, modified
2381 // files, or closed plugins.
2382 $instant_alert = ! empty( $options['instant_alert'] );
2383 if ( $instant_alert && ( $has_suspicious || $has_modified ) ) {
2384 $this->send_notification( $results, 'all', $closed_plugins );
2385 return;
2386 }
2387
2388 // Determine notify level with backward compatibility
2389 $notify_level = $options['notify_level'] ?? '';
2390
2391 // Backward compat: if notify_level not set, check old boolean
2392 if ( empty( $notify_level ) ) {
2393 if ( ! empty( $options['notify_on_changes'] ) ) {
2394 $notify_level = 'all';
2395 } else {
2396 $notify_level = 'disabled';
2397 }
2398 }
2399
2400 if ( 'disabled' === $notify_level ) {
2401 return;
2402 }
2403
2404 // 'suspicious_only' treats suspicious/extra, critical_config AND closed
2405 // plugins as serious.
2406 if ( 'suspicious_only' === $notify_level && ! $has_suspicious ) {
2407 return;
2408 }
2409
2410 if ( ! $has_suspicious && ! $has_modified ) {
2411 return;
2412 }
2413
2414 $this->send_notification( $results, $notify_level, $closed_plugins );
2415 }
2416
2417 /**
2418 * Collect the closed/removed plugins (excluding ignored slugs) so they can
2419 * be folded into the scan email digest. Returns an array keyed by slug.
2420 *
2421 * @return array
2422 */
2423 private function collect_closed_plugins_for_email() {
2424 if ( empty( $this->options['check_closed_plugins'] ) ) {
2425 return array();
2426 }
2427 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
2428 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
2429 }
2430 $checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
2431 return $checker->get_closed_plugins();
2432 }
2433
2434 /**
2435 * Merge scan results
2436 *
2437 * @param array $results1 First results.
2438 * @param array $results2 Second results.
2439 * @return array Merged results.
2440 */
2441 private function merge_results( $results1, $results2 ) {
2442 return array(
2443 'scanned' => $results1['scanned'] + ( $results2['scanned'] ?? 0 ),
2444 'ok' => $results1['ok'] + ( $results2['ok'] ?? 0 ),
2445 'modified' => array_merge( $results1['modified'], $results2['modified'] ?? array() ),
2446 'missing' => array_merge( $results1['missing'] ?? array(), $results2['missing'] ?? array() ),
2447 'suspicious' => array_merge( $results1['suspicious'] ?? array(), $results2['suspicious'] ?? array() ),
2448 'extra' => array_merge( $results1['extra'] ?? array(), $results2['extra'] ?? array() ),
2449 'new' => $results1['new'] ?? array(),
2450 'errors' => array_merge( $results1['errors'] ?? array(), $results2['errors'] ?? array() ),
2451 'scan_time' => $results1['scan_time'] ?? 0,
2452 'incomplete' => $results1['incomplete'] ?? false,
2453 );
2454 }
2455
2456 /**
2457 * Send notification email about scan results
2458 *
2459 * @param array $results Scan results.
2460 * @param string $notify_level Notification level ('all' or 'suspicious_only').
2461 * @param array $closed_plugins Optional map of slug=>state-entry for closed/removed
2462 * plugins to include as a dedicated section.
2463 */
2464 private function send_notification( $results, $notify_level = 'all', $closed_plugins = array() ) {
2465 $to = Vigilante_Email_Template::get_admin_recipients();
2466 $site_name = get_bloginfo( 'name' );
2467
2468 // Split critical_config files from regular modified so they get their own
2469 // prominent section in the email, next to suspicious/extra.
2470 $critical_config = array();
2471 $regular_modified = array();
2472 foreach ( $results['modified'] ?? array() as $item ) {
2473 if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) {
2474 // Synthesize a reason string with size + line-diff info so get_section_html shows it
2475 $baseline_size = $item['baseline_size'] ?? 0;
2476 $current_size = $item['current_size'] ?? 0;
2477 $added_count = is_array( $item['diff'] ?? null ) ? count( $item['diff']['added'] ?? array() ) : 0;
2478 $removed_count = is_array( $item['diff'] ?? null ) ? count( $item['diff']['removed'] ?? array() ) : 0;
2479 $diff_unavail = is_array( $item['diff'] ?? null ) && ! empty( $item['diff']['unavailable'] );
2480
2481 $reason = sprintf(
2482 /* translators: 1: baseline size, 2: current size */
2483 __( '%1$s → %2$s bytes', 'vigilante' ),
2484 number_format_i18n( $baseline_size ),
2485 number_format_i18n( $current_size )
2486 );
2487 if ( ! $diff_unavail ) {
2488 $reason .= sprintf( ' (+%d / -%d %s)', $added_count, $removed_count, __( 'lines', 'vigilante' ) );
2489 }
2490
2491 $item['reason'] = $reason;
2492 $critical_config[] = $item;
2493 } else {
2494 $regular_modified[] = $item;
2495 }
2496 }
2497
2498 $suspicious_count = count( $results['suspicious'] ?? array() );
2499 $extra_count = count( $results['extra'] ?? array() );
2500 $critical_config_count = count( $critical_config );
2501 $modified_count = count( $regular_modified );
2502 $closed_count = count( $closed_plugins );
2503
2504 // Use more urgent subject when suspicious files, critical config changes
2505 // or closed plugins are found (all three are security-critical).
2506 if ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 ) {
2507 $subject = sprintf(
2508 /* translators: %s: Site name */
2509 __( '[%s] SECURITY ALERT: File integrity issues detected', 'vigilante' ),
2510 $site_name
2511 );
2512 } else {
2513 $subject = sprintf(
2514 /* translators: %s: Site name */
2515 __( '[%s] File integrity issues detected', 'vigilante' ),
2516 $site_name
2517 );
2518 }
2519
2520 // Build HTML email using template wrapper
2521 $inner = '';
2522
2523 // Summary counts
2524 $inner .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom:20px;">';
2525 $inner .= '<tr>';
2526 if ( $suspicious_count > 0 ) {
2527 $inner .= $this->get_stat_cell( $suspicious_count, __( 'Suspicious', 'vigilante' ), '#d63638' );
2528 }
2529 if ( $extra_count > 0 ) {
2530 $inner .= $this->get_stat_cell( $extra_count, __( 'Extra', 'vigilante' ), '#b32d2e' );
2531 }
2532 if ( $critical_config_count > 0 ) {
2533 $inner .= $this->get_stat_cell( $critical_config_count, __( 'Critical', 'vigilante' ), '#e36210' );
2534 }
2535 if ( $closed_count > 0 ) {
2536 $inner .= $this->get_stat_cell( $closed_count, __( 'Closed', 'vigilante' ), '#d63638' );
2537 }
2538 if ( 'all' === $notify_level && $modified_count > 0 ) {
2539 $inner .= $this->get_stat_cell( $modified_count, __( 'Modified', 'vigilante' ), '#dba617' );
2540 }
2541 $inner .= $this->get_stat_cell( $results['scanned'] ?? 0, __( 'Scanned', 'vigilante' ), '#50575e' );
2542 $inner .= '</tr></table>';
2543
2544 // Suspicious files section
2545 if ( ! empty( $results['suspicious'] ) ) {
2546 $inner .= $this->get_section_html(
2547 __( 'Suspicious files', 'vigilante' ),
2548 __( 'These files may contain malicious code. Review immediately.', 'vigilante' ),
2549 $results['suspicious'],
2550 '#d63638',
2551 '#fef1f1',
2552 20,
2553 true
2554 );
2555 }
2556
2557 // Extra files section
2558 if ( ! empty( $results['extra'] ) ) {
2559 $inner .= $this->get_section_html(
2560 __( 'Extra files', 'vigilante' ),
2561 __( 'PHP files not in the original WordPress.org distribution.', 'vigilante' ),
2562 $results['extra'],
2563 '#b32d2e',
2564 '#fdf6f4',
2565 20,
2566 true
2567 );
2568 }
2569
2570 // Critical config files section (wp-config.php, .htaccess modified outside Vigilante)
2571 if ( ! empty( $critical_config ) ) {
2572 $inner .= $this->get_section_html(
2573 __( 'Critical config files modified', 'vigilante' ),
2574 __( 'These files are common targets for code injection. Review the changes and approve if they are legitimate.', 'vigilante' ),
2575 $critical_config,
2576 '#e36210',
2577 '#fdf2e6',
2578 10,
2579 true
2580 );
2581 }
2582
2583 // Modified files section (only if notify_level is 'all')
2584 if ( 'all' === $notify_level && ! empty( $regular_modified ) ) {
2585 $inner .= $this->get_section_html(
2586 __( 'Modified files', 'vigilante' ),
2587 __( 'Checksum mismatch with WordPress.org originals.', 'vigilante' ),
2588 $regular_modified,
2589 '#dba617',
2590 '#fdf8e8',
2591 15,
2592 false
2593 );
2594 }
2595
2596 // Closed + Removed plugins section.
2597 // Same tier as suspicious files: WordPress.org has flagged the plugin as
2598 // closed or removed, the site keeps running its code, and ignoring the
2599 // finding is an explicit per-slug action by the admin.
2600 if ( $closed_count > 0 ) {
2601 $inner .= $this->build_closed_plugins_email_section( $closed_plugins );
2602 }
2603
2604 // CTA button
2605 $inner .= Vigilante_Email_Template::button(
2606 admin_url( 'admin.php?page=vigilante&tab=file-integrity' ),
2607 __( 'Review in Vigilant', 'vigilante' )
2608 );
2609
2610 $is_alert = ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 );
2611 $title = $is_alert
2612 ? __( 'Security alert', 'vigilante' )
2613 : __( 'File integrity report', 'vigilante' );
2614
2615 Vigilante_Email_Template::send( $to, $subject, $title, $inner, $is_alert );
2616 }
2617
2618 /**
2619 * Build the closed + removed plugins block for the scan email.
2620 *
2621 * Reuses the same visual treatment as the suspicious files section
2622 * (red accent, danger description) because the security tier is the
2623 * same: WordPress.org has marked the plugin as compromised or removed.
2624 *
2625 * @param array $closed_plugins Map of slug=>state entry.
2626 * @return string HTML block.
2627 */
2628 private function build_closed_plugins_email_section( $closed_plugins ) {
2629 $color = '#d63638';
2630 $bg_color = '#fef1f1';
2631 $title = __( 'Closed + Removed plugins', 'vigilante' );
2632 $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' );
2633
2634 $html = '<div style="background:' . $bg_color . ';border-left:4px solid ' . $color . ';border-radius:4px;padding:14px 16px;margin-bottom:16px;">';
2635 $html .= '<h2 style="margin:0 0 4px;font-size:14px;color:' . $color . ';">' . esc_html( $title ) . '</h2>';
2636 $html .= '<p style="margin:0 0 12px;font-size:12px;color:#50575e;">' . esc_html( $desc ) . '</p>';
2637
2638 $html .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:12px;">';
2639 foreach ( $closed_plugins as $slug => $entry ) {
2640 $name = isset( $entry['name'] ) ? $entry['name'] : $slug;
2641 $version = isset( $entry['version'] ) ? $entry['version'] : '';
2642 $state = isset( $entry['state'] ) ? $entry['state'] : '';
2643 $state_label = 'closed' === $state ? __( 'Closed', 'vigilante' ) : __( 'Removed', 'vigilante' );
2644 $closed_date = isset( $entry['closed_date'] ) ? $entry['closed_date'] : '';
2645 $reason = isset( $entry['closed_reason_text'] ) && '' !== $entry['closed_reason_text']
2646 ? $entry['closed_reason_text']
2647 : '';
2648
2649 $detail_bits = array();
2650 $detail_bits[] = $state_label;
2651 if ( '' !== $closed_date ) {
2652 $detail_bits[] = esc_html( $closed_date );
2653 }
2654 if ( '' !== $version ) {
2655 $detail_bits[] = 'v' . esc_html( $version );
2656 }
2657
2658 $html .= '<tr>';
2659 $html .= '<td style="padding:4px 0;color:#1d2327;font-family:Consolas,Monaco,monospace;font-size:11px;word-break:break-all;">';
2660 $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>';
2661 $html .= '</td></tr>';
2662 $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>';
2663 if ( '' !== $reason ) {
2664 $html .= '<tr><td style="padding:0 0 8px 12px;color:#787c82;font-size:11px;font-style:italic;">' . esc_html( $reason ) . '</td></tr>';
2665 }
2666 }
2667 $html .= '</table>';
2668 $html .= '</div>';
2669
2670 return $html;
2671 }
2672
2673 /**
2674 * Get a summary stat cell for email
2675 *
2676 * @param int $count Stat count.
2677 * @param string $label Stat label.
2678 * @param string $color Color hex.
2679 * @return string HTML table cell.
2680 */
2681 private function get_stat_cell( $count, $label, $color ) {
2682 $html = '<td style="text-align:center;padding:12px 8px;">';
2683 $html .= '<div style="font-size:24px;font-weight:700;color:' . $color . ';line-height:1.2;">' . (int) $count . '</div>';
2684 $html .= '<div style="font-size:11px;color:#50575e;text-transform:uppercase;letter-spacing:0.5px;">' . esc_html( $label ) . '</div>';
2685 $html .= '</td>';
2686
2687 return $html;
2688 }
2689
2690 /**
2691 * Get an HTML section for file list in email
2692 *
2693 * @param string $title Section title.
2694 * @param string $description Section description.
2695 * @param array $files Array of file items.
2696 * @param string $color Accent color.
2697 * @param string $bg_color Background color.
2698 * @param int $max Max files to show.
2699 * @param bool $show_reason Whether to show reason column.
2700 * @return string HTML.
2701 */
2702 private function get_section_html( $title, $description, $files, $color, $bg_color, $max, $show_reason ) {
2703 $total = count( $files );
2704 $shown = array_slice( $files, 0, $max );
2705
2706 $html = '<div style="background:' . $bg_color . ';border-left:4px solid ' . $color . ';border-radius:4px;padding:14px 16px;margin-bottom:16px;">';
2707 $html .= '<h2 style="margin:0 0 4px;font-size:14px;color:' . $color . ';">' . esc_html( $title ) . '</h2>';
2708 $html .= '<p style="margin:0 0 12px;font-size:12px;color:#50575e;">' . esc_html( $description ) . '</p>';
2709
2710 $html .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:12px;">';
2711 foreach ( $shown as $file ) {
2712 $file_path = is_array( $file ) ? ( $file['file'] ?? '' ) : (string) $file;
2713 $reason = is_array( $file ) ? ( $file['reason'] ?? '' ) : '';
2714
2715 $html .= '<tr>';
2716 $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>';
2717 $html .= '</tr>';
2718
2719 if ( $show_reason && ! empty( $reason ) ) {
2720 $html .= '<tr>';
2721 $html .= '<td style="padding:0 0 8px 12px;color:#787c82;font-size:11px;font-style:italic;">' . esc_html( $reason ) . '</td>';
2722 $html .= '</tr>';
2723 }
2724 }
2725 $html .= '</table>';
2726
2727 if ( $total > $max ) {
2728 $html .= '<p style="margin:8px 0 0;font-size:12px;color:#787c82;">';
2729 /* translators: %d: Number of additional files */
2730 $html .= sprintf( esc_html__( '... and %d more', 'vigilante' ), $total - $max );
2731 $html .= '</p>';
2732 }
2733
2734 $html .= '</div>';
2735
2736 return $html;
2737 }
2738
2739 /**
2740 * Add a file to the ignored list
2741 *
2742 * @param string $file_path Relative file path to ignore.
2743 * @return bool
2744 */
2745 public function ignore_file( $file_path ) {
2746 $ignored = get_option( 'vigilante_ignored_files', array() );
2747
2748 if ( ! in_array( $file_path, $ignored, true ) ) {
2749 $ignored[] = sanitize_text_field( $file_path );
2750 return update_option( 'vigilante_ignored_files', $ignored );
2751 }
2752
2753 return true;
2754 }
2755
2756 /**
2757 * Remove a file from the ignored list
2758 *
2759 * @param string $file_path Relative file path to stop ignoring.
2760 * @return bool
2761 */
2762 public function unignore_file( $file_path ) {
2763 $ignored = get_option( 'vigilante_ignored_files', array() );
2764 $ignored = array_values( array_diff( $ignored, array( $file_path ) ) );
2765
2766 return update_option( 'vigilante_ignored_files', $ignored );
2767 }
2768
2769 /**
2770 * Get the list of ignored files
2771 *
2772 * @return array
2773 */
2774 public function get_ignored_files() {
2775 return get_option( 'vigilante_ignored_files', array() );
2776 }
2777
2778 /**
2779 * Clear all ignored files
2780 *
2781 * @return bool
2782 */
2783 public function clear_ignored_files() {
2784 return delete_option( 'vigilante_ignored_files' );
2785 }
2786
2787 /**
2788 * Get last scan results
2789 *
2790 * @return array|false
2791 */
2792 public function get_last_scan_results() {
2793 return get_option( 'vigilante_last_integrity_results', false );
2794 }
2795
2796 /**
2797 * Get last scan time
2798 *
2799 * @return int|false
2800 */
2801 public function get_last_scan_time() {
2802 return get_option( 'vigilante_last_integrity_scan', false );
2803 }
2804
2805 /**
2806 * Clear stored hashes
2807 *
2808 * @return bool
2809 */
2810 public function clear_hashes() {
2811 return $this->database->clear_file_hashes();
2812 }
2813 }