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

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