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

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