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

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